feat: 联调
This commit is contained in:
@@ -0,0 +1,572 @@
|
||||
export type DraftCaseType = 'traditional' | 'teaching'
|
||||
export type CasePublishStatus = 0 | 1 | 2
|
||||
|
||||
export interface CaseListParams {
|
||||
token: string
|
||||
search?: string
|
||||
case_type?: string
|
||||
publish_status?: CasePublishStatus | ''
|
||||
institution?: string
|
||||
department?: string
|
||||
status?: string
|
||||
osce_enabled?: boolean | string | ''
|
||||
ordering?: string
|
||||
page?: number
|
||||
}
|
||||
|
||||
export interface CaseListItem {
|
||||
id: string
|
||||
title: string
|
||||
caseType: string
|
||||
publishStatus: CasePublishStatus | null
|
||||
institutionId: string
|
||||
institutionName: string
|
||||
departmentId: string
|
||||
departmentName: string
|
||||
difficulty: string
|
||||
chiefComplaint: string
|
||||
tags: string[]
|
||||
icdCodes: string[]
|
||||
estimatedMinutes: number | null
|
||||
updatedAt: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface CaseListResult {
|
||||
cases: CaseListItem[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface CaseScoringRulePayload {
|
||||
dimension: string
|
||||
score_weight: number
|
||||
description?: string
|
||||
ai_auto_score?: boolean
|
||||
scoring_standard?: string
|
||||
}
|
||||
|
||||
export interface CaseExamItemPayload {
|
||||
item_code: string
|
||||
item_name?: string
|
||||
item_type?: string
|
||||
}
|
||||
|
||||
export interface CreateCaseDraftPayload {
|
||||
title: string
|
||||
case_type: DraftCaseType
|
||||
institution_id?: number
|
||||
department_name?: string
|
||||
traditional?: Record<string, unknown>
|
||||
teaching?: Record<string, unknown>
|
||||
scoring_rules: CaseScoringRulePayload[]
|
||||
exam_items?: CaseExamItemPayload[]
|
||||
difficulty?: string
|
||||
chief_complaint?: string
|
||||
description?: string
|
||||
patient_age?: number
|
||||
patient_gender?: string
|
||||
tags?: string | string[]
|
||||
icd_codes?: string[]
|
||||
estimated_minutes?: number
|
||||
osce_enabled?: boolean
|
||||
}
|
||||
|
||||
export interface CreateCaseDraftParams {
|
||||
token: string
|
||||
payload: CreateCaseDraftPayload
|
||||
}
|
||||
|
||||
export interface AiGenerateCasePayload {
|
||||
prompt: string
|
||||
case_type: DraftCaseType
|
||||
}
|
||||
|
||||
export interface AiGenerateCaseParams {
|
||||
token: string
|
||||
payload: AiGenerateCasePayload
|
||||
}
|
||||
|
||||
export interface AiCaseUsage {
|
||||
promptTokens: number | null
|
||||
completionTokens: number | null
|
||||
}
|
||||
|
||||
export interface AiGeneratedCaseData extends Record<string, unknown> {
|
||||
title?: string
|
||||
case_type?: string
|
||||
chief_complaint?: string
|
||||
department_name?: string
|
||||
patient_age?: number
|
||||
patient_gender?: string
|
||||
}
|
||||
|
||||
export interface AiGenerateCaseResult {
|
||||
parseId: string
|
||||
caseType: string
|
||||
aiUsage: AiCaseUsage
|
||||
promptVersion: string
|
||||
parsingSeconds: number | null
|
||||
generatingSeconds: number | null
|
||||
data: AiGeneratedCaseData
|
||||
raw: unknown
|
||||
}
|
||||
|
||||
export interface ImportCasePdfParams {
|
||||
token: string
|
||||
file: File
|
||||
}
|
||||
|
||||
export interface UpdateCaseRelationsPayload {
|
||||
institution_id?: number | null
|
||||
department_id?: number | null
|
||||
department_name?: string
|
||||
}
|
||||
|
||||
export interface UpdateCaseRelationsParams {
|
||||
token: string
|
||||
id: string | number
|
||||
payload: UpdateCaseRelationsPayload
|
||||
}
|
||||
|
||||
export interface CaseActionParams {
|
||||
token: string
|
||||
id: string | number
|
||||
}
|
||||
|
||||
const listKeys = ['results', 'list', 'rows', 'items', 'records', 'cases']
|
||||
const totalKeys = ['count', 'total', 'total_count', 'totalCount']
|
||||
|
||||
function createAuthorization(token: string) {
|
||||
return /^Bearer\s+/i.test(token) ? token : `Bearer ${token}`
|
||||
}
|
||||
|
||||
function parseResponseText(text: string): unknown {
|
||||
if (!text) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(text)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function getMessageFromResponse(data: unknown): string {
|
||||
if (!data || typeof data !== 'object') {
|
||||
return ''
|
||||
}
|
||||
|
||||
const record = data as Record<string, unknown>
|
||||
const message = record.message || record.msg || record.detail
|
||||
if (typeof message === 'string') {
|
||||
return message
|
||||
}
|
||||
|
||||
return getMessageFromResponse(record.data)
|
||||
}
|
||||
|
||||
function getRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {}
|
||||
}
|
||||
|
||||
function getFirst(record: Record<string, unknown>, keys: string[]): unknown {
|
||||
for (const key of keys) {
|
||||
if (record[key] !== undefined && record[key] !== null) {
|
||||
return record[key]
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function getString(record: Record<string, unknown>, keys: string[], fallback = ''): string {
|
||||
for (const key of keys) {
|
||||
const value = record[key]
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
return value
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
function getNumber(record: Record<string, unknown>, keys: string[]): number | null {
|
||||
const value = getFirst(record, keys)
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value
|
||||
}
|
||||
if (typeof value === 'string' && value.trim() && Number.isFinite(Number(value))) {
|
||||
return Number(value)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function getStringList(record: Record<string, unknown>, keys: string[]): string[] {
|
||||
const value = getFirst(record, keys)
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map(item => {
|
||||
if (typeof item === 'string' || typeof item === 'number') {
|
||||
return String(item)
|
||||
}
|
||||
const itemRecord = getRecord(item)
|
||||
return getString(itemRecord, ['name', 'title', 'code', 'value'])
|
||||
})
|
||||
.map(item => item.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
.split(/[,\n,;;]/)
|
||||
.map(item => item.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
function normalizePublishStatus(value: unknown): CasePublishStatus | null {
|
||||
if (value === 0 || value === 1 || value === 2) {
|
||||
return value
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const normalized = value.trim()
|
||||
const numeric = Number(normalized)
|
||||
if (numeric === 0 || numeric === 1 || numeric === 2) {
|
||||
return numeric
|
||||
}
|
||||
if (normalized.includes('草稿') || normalized.toLowerCase() === 'draft') {
|
||||
return 0
|
||||
}
|
||||
if (normalized.includes('发布') || normalized.toLowerCase() === 'published') {
|
||||
return 2
|
||||
}
|
||||
if (normalized.includes('正常') || normalized.includes('启用') || normalized.toLowerCase() === 'active') {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function getTotal(record: Record<string, unknown>, fallback: number): number {
|
||||
for (const key of totalKeys) {
|
||||
const value = record[key]
|
||||
if (typeof value === 'number') {
|
||||
return value
|
||||
}
|
||||
if (typeof value === 'string' && Number.isFinite(Number(value))) {
|
||||
return Number(value)
|
||||
}
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
function findCasePayload(data: unknown): { items: unknown[]; total: number } {
|
||||
if (Array.isArray(data)) {
|
||||
return { items: data, total: data.length }
|
||||
}
|
||||
|
||||
const record = getRecord(data)
|
||||
if (!Object.keys(record).length) {
|
||||
return { items: [], total: 0 }
|
||||
}
|
||||
|
||||
for (const key of listKeys) {
|
||||
const value = record[key]
|
||||
if (Array.isArray(value)) {
|
||||
return { items: value, total: getTotal(record, value.length) }
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(record.data)) {
|
||||
return { items: record.data, total: getTotal(record, record.data.length) }
|
||||
}
|
||||
|
||||
if (record.data && typeof record.data === 'object') {
|
||||
const nested = findCasePayload(record.data)
|
||||
return {
|
||||
items: nested.items,
|
||||
total: nested.total || getTotal(record, nested.items.length)
|
||||
}
|
||||
}
|
||||
|
||||
return { items: [], total: getTotal(record, 0) }
|
||||
}
|
||||
|
||||
function getRelatedId(record: Record<string, unknown>, directKeys: string[], objectKeys: string[]): string {
|
||||
const direct = getString(record, directKeys)
|
||||
if (direct) {
|
||||
return direct
|
||||
}
|
||||
|
||||
for (const key of objectKeys) {
|
||||
const nested = getRecord(record[key])
|
||||
const value = getString(nested, ['id', 'pk', 'value'])
|
||||
if (value) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function getRelatedName(record: Record<string, unknown>, directKeys: string[], objectKeys: string[]): string {
|
||||
const direct = getString(record, directKeys)
|
||||
if (direct) {
|
||||
return direct
|
||||
}
|
||||
|
||||
for (const key of objectKeys) {
|
||||
const nested = getRecord(record[key])
|
||||
const value = getString(nested, ['name', 'title', 'label'])
|
||||
if (value) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function normalizeCase(item: unknown, index: number): CaseListItem {
|
||||
const record = getRecord(item)
|
||||
const publishStatus = normalizePublishStatus(getFirst(record, ['publish_status', 'publishStatus', 'status']))
|
||||
const title = getString(record, ['title', 'name', 'case_title', 'caseTitle'], `病例${index + 1}`)
|
||||
|
||||
return {
|
||||
id: getString(record, ['id', 'uuid', 'case_id', 'caseId'], `${index}`),
|
||||
title,
|
||||
caseType: getString(record, ['case_type', 'caseType', 'type']),
|
||||
publishStatus,
|
||||
institutionId: getRelatedId(record, ['institution_id', 'institutionId'], ['institution', 'institution_info', 'institutionInfo']),
|
||||
institutionName: getRelatedName(record, ['institution_name', 'institutionName', 'organization_name', 'organizationName'], ['institution', 'institution_info', 'institutionInfo']),
|
||||
departmentId: getRelatedId(record, ['department_id', 'departmentId'], ['department', 'department_info', 'departmentInfo']),
|
||||
departmentName: getRelatedName(record, ['department_name', 'departmentName'], ['department', 'department_info', 'departmentInfo']),
|
||||
difficulty: getString(record, ['difficulty']),
|
||||
chiefComplaint: getString(record, ['chief_complaint', 'chiefComplaint']),
|
||||
tags: getStringList(record, ['tags', 'tag_list', 'tagList']),
|
||||
icdCodes: getStringList(record, ['icd_codes', 'icdCodes', 'icd_list', 'icdList']),
|
||||
estimatedMinutes: getNumber(record, ['estimated_minutes', 'estimatedMinutes']),
|
||||
updatedAt: getString(record, ['updated_at', 'updatedAt', 'modified_at', 'modifiedAt']),
|
||||
createdAt: getString(record, ['created_at', 'createdAt'])
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeAiGenerateResult(data: unknown): AiGenerateCaseResult {
|
||||
const root = getRecord(data)
|
||||
const payload = root.parse_id || root.parseId ? root : getRecord(root.data)
|
||||
const usage = getRecord(getFirst(payload, ['ai_usage', 'aiUsage']))
|
||||
const generatedData = getRecord(payload.data)
|
||||
|
||||
return {
|
||||
parseId: getString(payload, ['parse_id', 'parseId']),
|
||||
caseType: getString(payload, ['case_type', 'caseType']),
|
||||
aiUsage: {
|
||||
promptTokens: getNumber(usage, ['prompt_tokens', 'promptTokens']),
|
||||
completionTokens: getNumber(usage, ['completion_tokens', 'completionTokens'])
|
||||
},
|
||||
promptVersion: getString(payload, ['prompt_version', 'promptVersion']),
|
||||
parsingSeconds: getNumber(payload, ['parsing_seconds', 'parsingSeconds']),
|
||||
generatingSeconds: getNumber(payload, ['generating_seconds', 'generatingSeconds']),
|
||||
data: generatedData as AiGeneratedCaseData,
|
||||
raw: data
|
||||
}
|
||||
}
|
||||
|
||||
function createCaseQuery(params: Partial<CaseListParams>) {
|
||||
const query = new URLSearchParams()
|
||||
if (params.search?.trim()) {
|
||||
query.set('search', params.search.trim())
|
||||
}
|
||||
if (params.case_type) {
|
||||
query.set('case_type', params.case_type)
|
||||
}
|
||||
if (params.publish_status !== undefined && params.publish_status !== '') {
|
||||
query.set('publish_status', String(params.publish_status))
|
||||
}
|
||||
if (params.institution?.trim()) {
|
||||
query.set('institution', params.institution.trim())
|
||||
}
|
||||
if (params.department?.trim()) {
|
||||
query.set('department', params.department.trim())
|
||||
}
|
||||
if (params.status?.trim()) {
|
||||
query.set('status', params.status.trim())
|
||||
}
|
||||
if (params.osce_enabled !== undefined && params.osce_enabled !== '') {
|
||||
query.set('osce_enabled', String(params.osce_enabled))
|
||||
}
|
||||
if (params.ordering?.trim()) {
|
||||
query.set('ordering', params.ordering.trim())
|
||||
}
|
||||
if (params.page) {
|
||||
query.set('page', String(params.page))
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
async function parseMutationResponse(response: Response, fallbackMessage: string): Promise<unknown> {
|
||||
const text = await response.text()
|
||||
const data = parseResponseText(text)
|
||||
|
||||
if (!response.ok) {
|
||||
const message = getMessageFromResponse(data) || fallbackMessage
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function fetchCases(params: CaseListParams): Promise<CaseListResult> {
|
||||
const query = createCaseQuery(params)
|
||||
const url = `/server/api/cms/cases/${query.toString() ? `?${query.toString()}` : ''}`
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: createAuthorization(params.token)
|
||||
}
|
||||
})
|
||||
const text = await response.text()
|
||||
const data = parseResponseText(text)
|
||||
|
||||
if (!response.ok) {
|
||||
const message = getMessageFromResponse(data) || '获取病例列表失败'
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
const payload = findCasePayload(data)
|
||||
|
||||
return {
|
||||
cases: payload.items.map(normalizeCase),
|
||||
total: payload.total
|
||||
}
|
||||
}
|
||||
|
||||
export async function createCaseDraft(params: CreateCaseDraftParams): Promise<unknown> {
|
||||
const response = await fetch('/server/api/cms/cases/', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: createAuthorization(params.token),
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(params.payload)
|
||||
})
|
||||
|
||||
return parseMutationResponse(response, '新增病例草稿失败')
|
||||
}
|
||||
|
||||
export async function generateCaseWithAi(params: AiGenerateCaseParams): Promise<AiGenerateCaseResult> {
|
||||
const response = await fetch('/server/api/cms/cases/ai-generate/', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: createAuthorization(params.token),
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(params.payload)
|
||||
})
|
||||
const text = await response.text()
|
||||
const data = parseResponseText(text)
|
||||
|
||||
if (!response.ok) {
|
||||
const message = getMessageFromResponse(data) || 'AI 生成病例失败'
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
return normalizeAiGenerateResult(data)
|
||||
}
|
||||
|
||||
export async function importCasePdf(params: ImportCasePdfParams): Promise<unknown> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', params.file)
|
||||
|
||||
const response = await fetch('/server/api/cms/cases/import-pdf/', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: createAuthorization(params.token)
|
||||
},
|
||||
body: formData
|
||||
})
|
||||
|
||||
return parseMutationResponse(response, '导入 PDF 病例失败')
|
||||
}
|
||||
|
||||
export async function updateCaseRelations(params: UpdateCaseRelationsParams): Promise<unknown> {
|
||||
const response = await fetch(`/server/api/cms/cases/${params.id}/relations/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: createAuthorization(params.token),
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(params.payload)
|
||||
})
|
||||
|
||||
return parseMutationResponse(response, '编辑病例关联失败')
|
||||
}
|
||||
|
||||
export async function submitCase(params: CaseActionParams): Promise<unknown> {
|
||||
const response = await fetch(`/server/api/cms/cases/${params.id}/submit/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: createAuthorization(params.token)
|
||||
}
|
||||
})
|
||||
|
||||
return parseMutationResponse(response, '提交病例失败')
|
||||
}
|
||||
|
||||
export async function disableCase(params: CaseActionParams): Promise<unknown> {
|
||||
const response = await fetch(`/server/api/cms/cases/${params.id}/disable/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: createAuthorization(params.token)
|
||||
}
|
||||
})
|
||||
|
||||
return parseMutationResponse(response, '停用病例失败')
|
||||
}
|
||||
|
||||
export async function publishCase(params: CaseActionParams): Promise<unknown> {
|
||||
const response = await fetch(`/server/api/cms/cases/${params.id}/publish/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: createAuthorization(params.token)
|
||||
}
|
||||
})
|
||||
|
||||
return parseMutationResponse(response, '发布病例失败')
|
||||
}
|
||||
|
||||
export async function fetchCaseFull(params: CaseActionParams): Promise<unknown> {
|
||||
const response = await fetch(`/server/api/cms/cases/${params.id}/full/`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: createAuthorization(params.token)
|
||||
}
|
||||
})
|
||||
const text = await response.text()
|
||||
const data = parseResponseText(text)
|
||||
|
||||
if (!response.ok) {
|
||||
const message = getMessageFromResponse(data) || '获取病例详情失败'
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
@@ -316,6 +316,16 @@ p {
|
||||
color: #fff;
|
||||
background: rgb(37 99 235 / 42%);
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
color: rgb(189 208 231 / 42%);
|
||||
cursor: not-allowed;
|
||||
|
||||
&:hover {
|
||||
color: rgb(189 208 231 / 42%);
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
@@ -757,6 +767,18 @@ p {
|
||||
grid-template-columns: minmax(240px, 1fr) 140px 140px 130px 96px 96px;
|
||||
}
|
||||
|
||||
.case-filter {
|
||||
grid-template-columns: minmax(260px, 1fr) 160px 140px 150px 96px 96px;
|
||||
}
|
||||
|
||||
.content-case-filter {
|
||||
grid-template-columns: minmax(220px, 1fr) 140px 130px 120px 120px 120px 140px 96px 96px;
|
||||
}
|
||||
|
||||
.case-review-filter {
|
||||
grid-template-columns: minmax(260px, 1fr) 150px 96px 96px;
|
||||
}
|
||||
|
||||
.table-subtext {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
@@ -764,6 +786,77 @@ p {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.case-tag-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.case-create-form,
|
||||
.case-relations-form {
|
||||
.el-select,
|
||||
.el-input-number {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.case-form-section {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 14px 0;
|
||||
border-top: 1px solid var(--border);
|
||||
|
||||
&:first-child {
|
||||
padding-top: 0;
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.case-section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.scoring-rule-row,
|
||||
.exam-item-row {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.scoring-rule-row {
|
||||
grid-template-columns: minmax(120px, 0.7fr) 100px 120px minmax(180px, 1fr) 36px;
|
||||
}
|
||||
|
||||
.exam-item-row {
|
||||
grid-template-columns: minmax(150px, 0.8fr) minmax(160px, 1fr) minmax(120px, 0.8fr) 36px;
|
||||
}
|
||||
|
||||
.case-empty-line {
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
|
||||
.relation-input {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.case-detail-drawer {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.kanban-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
@@ -871,6 +964,50 @@ p {
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.ai-case-workbench {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(420px, 0.82fr) minmax(0, 1.18fr);
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.ai-case-form {
|
||||
.el-radio-group,
|
||||
.el-textarea {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.ai-result-section {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.ai-result-kpis {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
|
||||
div {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
min-height: 76px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
|
||||
span {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
strong {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.profile-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 320px minmax(0, 1fr);
|
||||
@@ -923,6 +1060,7 @@ p {
|
||||
.dashboard-grid,
|
||||
.content-grid,
|
||||
.ai-workbench,
|
||||
.ai-case-workbench,
|
||||
.profile-layout,
|
||||
.settings-grid {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
@@ -13,10 +13,16 @@
|
||||
<nav class="nav-list">
|
||||
<section v-for="section in visibleSections" :key="section.title" class="nav-section">
|
||||
<div class="nav-section-title">{{ section.title }}</div>
|
||||
<router-link v-for="item in section.items" :key="item.page" :to="getPagePath(item.page)" class="nav-item">
|
||||
<el-icon><component :is="item.icon" /></el-icon>
|
||||
<span>{{ item.title }}</span>
|
||||
</router-link>
|
||||
<template v-for="item in section.items" :key="item.page">
|
||||
<span v-if="item.disabled" class="nav-item disabled" aria-disabled="true">
|
||||
<el-icon><component :is="item.icon" /></el-icon>
|
||||
<span>{{ item.title }}</span>
|
||||
</span>
|
||||
<router-link v-else :to="getPagePath(item.page)" class="nav-item">
|
||||
<el-icon><component :is="item.icon" /></el-icon>
|
||||
<span>{{ item.title }}</span>
|
||||
</router-link>
|
||||
</template>
|
||||
</section>
|
||||
</nav>
|
||||
</el-scrollbar>
|
||||
|
||||
+11
-5
@@ -50,7 +50,7 @@ export const roleMenus: Record<RoleKey, MenuSection[]> = {
|
||||
title: '内容管理',
|
||||
items: [
|
||||
{ page: 'case-list', icon: Collection, title: '病例库' },
|
||||
{ page: 'tag-category', icon: PriceTag, title: '标签分类' },
|
||||
{ page: 'tag-category', icon: PriceTag, title: '标签分类', disabled: true },
|
||||
{ page: 'ai-case', icon: Cpu, title: 'AI病例生成' }
|
||||
]
|
||||
},
|
||||
@@ -70,10 +70,10 @@ export const roleMenus: Record<RoleKey, MenuSection[]> = {
|
||||
{
|
||||
title: '数据分析',
|
||||
items: [
|
||||
{ page: 'data-user', icon: UserFilled, title: '用户分析' },
|
||||
{ page: 'data-case', icon: Files, title: '病例分析' },
|
||||
{ page: 'data-hospital', icon: OfficeBuilding, title: '医院分析' },
|
||||
{ page: 'data-retention', icon: Refresh, title: '留存分析' }
|
||||
{ page: 'data-user', icon: UserFilled, title: '用户分析', disabled: true },
|
||||
{ page: 'data-case', icon: Files, title: '病例分析', disabled: true },
|
||||
{ page: 'data-hospital', icon: OfficeBuilding, title: '医院分析', disabled: true },
|
||||
{ page: 'data-retention', icon: Refresh, title: '留存分析', disabled: true }
|
||||
]
|
||||
},
|
||||
],
|
||||
@@ -90,6 +90,7 @@ export const roleMenus: Record<RoleKey, MenuSection[]> = {
|
||||
{
|
||||
title: '运营数据',
|
||||
items: [
|
||||
{ page: 'case-review', icon: DocumentChecked, title: '病例审核' },
|
||||
{ page: 'hospital-data', icon: DataAnalysis, title: '运营数据' },
|
||||
{ page: 'case-usage', icon: Collection, title: '病例使用' },
|
||||
{ page: 'training-stats', icon: Memo, title: '训练统计' },
|
||||
@@ -152,6 +153,11 @@ export const pageTitles = Object.values(roleMenus).reduce<Record<string, string>
|
||||
}, {})
|
||||
|
||||
const directPagePaths: Record<string, string> = {
|
||||
'ai-case': '/cases/ai-generate',
|
||||
'ai-generate': '/cases/ai-generate',
|
||||
'case-library': '/cases',
|
||||
'case-list': '/cases',
|
||||
'case-review': '/cases/review',
|
||||
'content-admin-list': '/users/content-admins',
|
||||
'department-list': '/departments',
|
||||
'doctor-list': '/users/doctors',
|
||||
|
||||
@@ -19,6 +19,8 @@ const routes: RouteRecordRaw[] = [
|
||||
{ path: 'institutions', name: 'Institutions', component: () => import('@/views/InstitutionsView.vue'), meta: { title: '医院管理' } },
|
||||
{ path: 'departments', name: 'Departments', component: () => import('@/views/DepartmentsView.vue'), meta: { title: '科室管理' } },
|
||||
{ path: 'users', name: 'Users', component: () => import('@/views/UsersView.vue'), meta: { title: '用户列表' } },
|
||||
{ path: 'cases/ai-generate', name: 'AiCaseGenerate', component: () => import('@/views/AiCaseGenerateView.vue'), meta: { title: 'AI病例生成' } },
|
||||
{ path: 'cases/review', name: 'CaseReview', component: () => import('@/views/CaseReviewView.vue'), meta: { title: '病例审核' } },
|
||||
{ path: 'users/doctors', name: 'DoctorUsers', component: () => import('@/views/RoleUsersView.vue'), meta: { title: '医生管理', roleType: 'doctor' } },
|
||||
{ path: 'users/students', name: 'StudentUsers', component: () => import('@/views/RoleUsersView.vue'), meta: { title: '医学生管理', roleType: 'student' } },
|
||||
{ path: 'users/content-admins', name: 'ContentAdminUsers', component: () => import('@/views/RoleUsersView.vue'), meta: { title: '内容管理员', roleType: 'content_admin' } },
|
||||
@@ -28,6 +30,11 @@ const routes: RouteRecordRaw[] = [
|
||||
{ path: 'module/hospital-dashboard', name: 'HospitalDashboard', component: () => import('@/views/HospitalDashboardView.vue'), meta: { title: '医院驾驶舱' } },
|
||||
{ path: 'module/content-dashboard', name: 'ContentDashboard', component: () => import('@/views/ContentDashboardView.vue'), meta: { title: '内容概览' } },
|
||||
{ path: 'module/teacher-dashboard', name: 'TeacherDashboard', component: () => import('@/views/TeacherDashboardView.vue'), meta: { title: '教学概览' } },
|
||||
{ path: 'module/case-list', redirect: '/cases' },
|
||||
{ path: 'module/case-library', redirect: '/cases' },
|
||||
{ path: 'module/case-review', redirect: '/cases/review' },
|
||||
{ path: 'module/ai-case', redirect: '/cases/ai-generate' },
|
||||
{ path: 'module/ai-generate', redirect: '/cases/ai-generate' },
|
||||
{ path: 'module/content-admin-list', redirect: '/users/content-admins' },
|
||||
{ path: 'module/department-list', redirect: '/departments' },
|
||||
{ path: 'module/doctor-list', redirect: '/users/doctors' },
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface MenuItem {
|
||||
page: string
|
||||
title: string
|
||||
icon: Component
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export interface MenuSection {
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
<template>
|
||||
<div class="page-stack">
|
||||
<section class="page-toolbar">
|
||||
<div>
|
||||
<h1>AI病例生成</h1>
|
||||
<p>输入病例长描述并选择病例类型,生成结构化传统病例或教学病例。</p>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<el-button :icon="Refresh" @click="resetForm">重置</el-button>
|
||||
<el-button :icon="Cpu" :loading="generating" type="primary" @click="submitGenerate">开始生成</el-button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="ai-case-workbench">
|
||||
<div class="data-section">
|
||||
<div class="section-header">
|
||||
<div>
|
||||
<h2>生成配置</h2>
|
||||
<p>填写病例描述后生成结构化内容,可直接用于后续草稿录入。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-form ref="formRef" class="ai-case-form" :model="form" :rules="rules" label-position="top">
|
||||
<el-form-item label="病例类型" prop="case_type">
|
||||
<el-radio-group v-model="form.case_type">
|
||||
<el-radio-button label="traditional">传统病例</el-radio-button>
|
||||
<el-radio-button label="teaching">教学病例</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="病例长描述" prop="prompt">
|
||||
<el-input
|
||||
v-model="form.prompt"
|
||||
type="textarea"
|
||||
:rows="10"
|
||||
maxlength="2000"
|
||||
show-word-limit
|
||||
placeholder="请生成一个儿科急性上呼吸道感染传统病例,患儿4岁男孩,发热咳嗽3天。"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<div class="data-section ai-result-section">
|
||||
<div class="section-header">
|
||||
<div>
|
||||
<h2>生成结果</h2>
|
||||
<p>{{ result ? `解析ID:${result.parseId || '-'}` : '等待生成结果返回。' }}</p>
|
||||
</div>
|
||||
<el-tag v-if="result" type="success">{{ caseTypeLabel(result.caseType) }}</el-tag>
|
||||
</div>
|
||||
|
||||
<el-empty v-if="!result" description="暂无生成结果" />
|
||||
|
||||
<template v-else>
|
||||
<div class="ai-result-kpis">
|
||||
<div>
|
||||
<span>输入消耗</span>
|
||||
<strong>{{ formatNumber(result.aiUsage.promptTokens) }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>输出消耗</span>
|
||||
<strong>{{ formatNumber(result.aiUsage.completionTokens) }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>生成耗时</span>
|
||||
<strong>{{ formatSeconds(result.generatingSeconds) }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>解析耗时</span>
|
||||
<strong>{{ formatSeconds(result.parsingSeconds) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="标题">{{ result.data.title || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="科室">{{ result.data.department_name || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="主诉" :span="2">{{ result.data.chief_complaint || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="患者年龄">{{ formatValue(result.data.patient_age) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="患者性别">{{ patientGenderLabel(result.data.patient_gender) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="Prompt版本" :span="2">{{ result.promptVersion || '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { Cpu, Refresh } from '@element-plus/icons-vue'
|
||||
import { generateCaseWithAi, type AiGenerateCaseResult, type DraftCaseType } from '@/api/cases'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
|
||||
const appStore = useAppStore()
|
||||
const formRef = ref<FormInstance>()
|
||||
const generating = ref(false)
|
||||
const result = ref<AiGenerateCaseResult | null>(null)
|
||||
|
||||
const form = reactive({
|
||||
case_type: 'traditional' as DraftCaseType,
|
||||
prompt: '请生成一个儿科急性上呼吸道感染传统病例,患儿4岁男孩,发热咳嗽3天。'
|
||||
})
|
||||
|
||||
const rules: FormRules = {
|
||||
case_type: [{ required: true, message: '请选择病例类型', trigger: 'change' }],
|
||||
prompt: [{ required: true, message: '请输入病例长描述', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
async function submitGenerate() {
|
||||
if (!appStore.token) {
|
||||
ElMessage.warning('缺少登录信息,请重新登录')
|
||||
return
|
||||
}
|
||||
|
||||
const isValid = await formRef.value?.validate().catch(() => false)
|
||||
if (!isValid) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
generating.value = true
|
||||
result.value = await generateCaseWithAi({
|
||||
token: appStore.token,
|
||||
payload: {
|
||||
case_type: form.case_type,
|
||||
prompt: form.prompt.trim()
|
||||
}
|
||||
})
|
||||
ElMessage.success('AI病例生成完成')
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : 'AI病例生成失败')
|
||||
} finally {
|
||||
generating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
form.case_type = 'traditional'
|
||||
form.prompt = ''
|
||||
result.value = null
|
||||
formRef.value?.clearValidate()
|
||||
}
|
||||
|
||||
function caseTypeLabel(type: string) {
|
||||
const labels: Record<string, string> = {
|
||||
traditional: '传统病例',
|
||||
teaching: '教学病例'
|
||||
}
|
||||
|
||||
return labels[type] || type || '-'
|
||||
}
|
||||
|
||||
function patientGenderLabel(value: unknown) {
|
||||
if (value === 'male' || value === '男') return '男'
|
||||
if (value === 'female' || value === '女') return '女'
|
||||
if (value === 'unknown') return '未知'
|
||||
return formatValue(value)
|
||||
}
|
||||
|
||||
function formatNumber(value: number | null) {
|
||||
return value === null ? '-' : value.toLocaleString()
|
||||
}
|
||||
|
||||
function formatSeconds(value: number | null) {
|
||||
return value === null ? '-' : `${value}s`
|
||||
}
|
||||
|
||||
function formatValue(value: unknown) {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return '-'
|
||||
}
|
||||
|
||||
return String(value)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,308 @@
|
||||
<template>
|
||||
<div class="page-stack">
|
||||
<section class="page-toolbar">
|
||||
<div>
|
||||
<h1>病例审核</h1>
|
||||
<p>审核内容管理员提交的病例,确认无误后发布到医院病例库。</p>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<el-button :icon="Refresh" @click="resetFilters">刷新</el-button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="filter-bar case-review-filter">
|
||||
<el-input v-model="filters.search" :prefix-icon="Search" clearable placeholder="搜索病例" @keyup.enter="handleSearch" />
|
||||
<el-input v-model="filters.department" clearable placeholder="科室ID" @keyup.enter="handleSearch" />
|
||||
<el-button :icon="Search" type="primary" @click="handleSearch">查询</el-button>
|
||||
<el-button :icon="Refresh" @click="resetFilters">重置</el-button>
|
||||
</section>
|
||||
|
||||
<section class="data-section">
|
||||
<el-table v-loading="loading" :data="cases" empty-text="暂无待审核病例" row-key="id">
|
||||
<el-table-column prop="id" label="ID" width="90" />
|
||||
<el-table-column prop="title" label="病例标题" min-width="240">
|
||||
<template #default="{ row }">
|
||||
<strong>{{ row.title }}</strong>
|
||||
<span v-if="row.chiefComplaint" class="table-subtext">{{ row.chiefComplaint }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="类型" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag>{{ caseTypeLabel(row.caseType) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="科室" min-width="150">
|
||||
<template #default="{ row }">
|
||||
{{ row.departmentName || row.departmentId || '未关联科室' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="难度" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="difficultyTagType(row.difficulty)">{{ row.difficulty || '-' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="标签/ICD" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<div class="case-tag-list">
|
||||
<el-tag v-for="tag in row.tags.slice(0, 2)" :key="tag" size="small" type="info">{{ tag }}</el-tag>
|
||||
<el-tag v-for="code in row.icdCodes.slice(0, 2)" :key="code" size="small">{{ code }}</el-tag>
|
||||
<span v-if="row.tags.length + row.icdCodes.length > 4" class="table-subtext">+{{ row.tags.length + row.icdCodes.length - 4 }}</span>
|
||||
<span v-if="!row.tags.length && !row.icdCodes.length">-</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="更新时间" width="170">
|
||||
<template #default="{ row }">
|
||||
{{ formatDateTime(row.updatedAt || row.createdAt) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag type="warning">{{ publishStatusLabel(row.publishStatus) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="openDetailDrawer(row)">查看</el-button>
|
||||
<el-button link type="success" @click="confirmPublishCase(row)">发布</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="table-pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.page"
|
||||
:total="pagination.total"
|
||||
background
|
||||
layout="total, prev, pager, next, jumper"
|
||||
@current-change="loadReviewCases"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<el-drawer v-model="detailDrawerVisible" :title="detailDrawerTitle" size="50%" destroy-on-close>
|
||||
<div v-loading="detailLoading" class="case-detail-drawer">
|
||||
<el-descriptions v-if="detailCase" :column="2" border>
|
||||
<el-descriptions-item label="ID">{{ detailDisplay.id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="类型">{{ caseTypeLabel(detailDisplay.caseType) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">{{ publishStatusLabel(detailCase.publishStatus) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="难度">{{ detailDisplay.difficulty }}</el-descriptions-item>
|
||||
<el-descriptions-item label="科室">{{ detailDisplay.department }}</el-descriptions-item>
|
||||
<el-descriptions-item label="患者">{{ detailDisplay.patient }}</el-descriptions-item>
|
||||
<el-descriptions-item label="预计时长">{{ detailDisplay.estimatedMinutes }}</el-descriptions-item>
|
||||
<el-descriptions-item label="主诉" :span="2">{{ detailDisplay.chiefComplaint }}</el-descriptions-item>
|
||||
<el-descriptions-item label="描述" :span="2">{{ detailDisplay.description }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Refresh, Search } from '@element-plus/icons-vue'
|
||||
import {
|
||||
fetchCaseFull,
|
||||
fetchCases,
|
||||
publishCase,
|
||||
type CaseListItem,
|
||||
type CasePublishStatus
|
||||
} from '@/api/cases'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
|
||||
const appStore = useAppStore()
|
||||
const loading = ref(false)
|
||||
const publishing = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const detailDrawerVisible = ref(false)
|
||||
const cases = ref<CaseListItem[]>([])
|
||||
const detailCase = ref<CaseListItem | null>(null)
|
||||
const caseDetail = ref<unknown>(null)
|
||||
|
||||
const filters = reactive({
|
||||
search: '',
|
||||
department: ''
|
||||
})
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
total: 0
|
||||
})
|
||||
|
||||
const detailDrawerTitle = computed(() => (detailCase.value ? `病例详情:${detailCase.value.title}` : '病例详情'))
|
||||
const detailDisplay = computed(() => createDetailDisplay(detailCase.value, caseDetail.value))
|
||||
|
||||
async function loadReviewCases() {
|
||||
if (!appStore.token) {
|
||||
ElMessage.warning('缺少登录信息,请重新登录')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
const result = await fetchCases({
|
||||
token: appStore.token,
|
||||
publish_status: 1,
|
||||
search: filters.search,
|
||||
department: filters.department,
|
||||
page: pagination.page
|
||||
})
|
||||
cases.value = result.cases
|
||||
pagination.total = result.total
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '获取待审核病例失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
pagination.page = 1
|
||||
loadReviewCases()
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
filters.search = ''
|
||||
filters.department = ''
|
||||
pagination.page = 1
|
||||
loadReviewCases()
|
||||
}
|
||||
|
||||
async function openDetailDrawer(row: CaseListItem) {
|
||||
if (!appStore.token) {
|
||||
ElMessage.warning('缺少登录信息,请重新登录')
|
||||
return
|
||||
}
|
||||
|
||||
detailCase.value = row
|
||||
detailDrawerVisible.value = true
|
||||
caseDetail.value = null
|
||||
|
||||
try {
|
||||
detailLoading.value = true
|
||||
caseDetail.value = await fetchCaseFull({
|
||||
token: appStore.token,
|
||||
id: row.id
|
||||
})
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '获取病例详情失败')
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmPublishCase(row: CaseListItem) {
|
||||
if (!appStore.token || publishing.value) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认发布「${row.title}」吗?`, '发布病例', {
|
||||
confirmButtonText: '发布',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
publishing.value = true
|
||||
await publishCase({
|
||||
token: appStore.token,
|
||||
id: row.id
|
||||
})
|
||||
ElMessage.success('病例已发布')
|
||||
await loadReviewCases()
|
||||
} catch (error) {
|
||||
if (error !== 'cancel' && error !== 'close') {
|
||||
ElMessage.error(error instanceof Error ? error.message : '发布病例失败')
|
||||
}
|
||||
} finally {
|
||||
publishing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function createDetailDisplay(row: CaseListItem | null, fullData: unknown) {
|
||||
const record = getDetailRecord(fullData)
|
||||
const title = getDetailString(record, ['title', 'name']) || row?.title || '-'
|
||||
const caseType = getDetailString(record, ['case_type', 'caseType']) || row?.caseType || ''
|
||||
const difficulty = getDetailString(record, ['difficulty']) || row?.difficulty || '-'
|
||||
const department = getDetailString(record, ['department_name', 'departmentName']) || row?.departmentName || row?.departmentId || '-'
|
||||
const chiefComplaint = getDetailString(record, ['chief_complaint', 'chiefComplaint']) || row?.chiefComplaint || '-'
|
||||
const description = getDetailString(record, ['description', 'summary', 'content']) || '-'
|
||||
const patientAge = getDetailString(record, ['patient_age', 'patientAge'])
|
||||
const patientGender = patientGenderLabel(getDetailString(record, ['patient_gender', 'patientGender']))
|
||||
const estimatedMinutes = getDetailString(record, ['estimated_minutes', 'estimatedMinutes']) || (row?.estimatedMinutes ? String(row.estimatedMinutes) : '')
|
||||
|
||||
return {
|
||||
id: getDetailString(record, ['id']) || row?.id || '-',
|
||||
title,
|
||||
caseType,
|
||||
difficulty,
|
||||
department,
|
||||
chiefComplaint,
|
||||
description,
|
||||
patient: [patientAge ? `${patientAge}岁` : '', patientGender].filter(Boolean).join(' / ') || '-',
|
||||
estimatedMinutes: estimatedMinutes ? `${estimatedMinutes} 分钟` : '-'
|
||||
}
|
||||
}
|
||||
|
||||
function getDetailRecord(value: unknown): Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const record = value as Record<string, unknown>
|
||||
if (record.data && typeof record.data === 'object' && !Array.isArray(record.data)) {
|
||||
return record.data as Record<string, unknown>
|
||||
}
|
||||
|
||||
return record
|
||||
}
|
||||
|
||||
function getDetailString(record: Record<string, unknown>, keys: string[]) {
|
||||
for (const key of keys) {
|
||||
const value = record[key]
|
||||
if (typeof value === 'string' && value.trim()) return value
|
||||
if (typeof value === 'number') return String(value)
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function caseTypeLabel(type: string) {
|
||||
const labels: Record<string, string> = {
|
||||
traditional: '传统病例',
|
||||
teaching: '教学病例'
|
||||
}
|
||||
|
||||
return labels[type] || type || '-'
|
||||
}
|
||||
|
||||
function patientGenderLabel(value: string) {
|
||||
if (value === 'male' || value === '男') return '男'
|
||||
if (value === 'female' || value === '女') return '女'
|
||||
if (value === 'unknown') return '未知'
|
||||
return value || ''
|
||||
}
|
||||
|
||||
function publishStatusLabel(status: CasePublishStatus | null) {
|
||||
if (status === 0) return '草稿'
|
||||
if (status === 1) return '待审核'
|
||||
if (status === 2) return '已发布'
|
||||
return '-'
|
||||
}
|
||||
|
||||
function difficultyTagType(value: string) {
|
||||
if (['高', 'high', 'hard'].includes(value.toLowerCase())) return 'danger'
|
||||
if (['中', 'medium', 'middle'].includes(value.toLowerCase())) return 'warning'
|
||||
if (['低', 'low', 'easy'].includes(value.toLowerCase())) return 'success'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
function formatDateTime(value: string) {
|
||||
if (!value) {
|
||||
return '-'
|
||||
}
|
||||
|
||||
return value.replace('T', ' ').slice(0, 19)
|
||||
}
|
||||
|
||||
onMounted(loadReviewCases)
|
||||
</script>
|
||||
+1082
-44
File diff suppressed because it is too large
Load Diff
@@ -57,7 +57,7 @@
|
||||
<div class="section-header">
|
||||
<div>
|
||||
<h2>内容质量处理队列</h2>
|
||||
<p>可直接用于后续接入病例列表接口</p>
|
||||
<p>集中跟进低通过率和待优化内容</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="quality-list">
|
||||
|
||||
@@ -93,7 +93,7 @@ async function handleLogin() {
|
||||
role: form.role
|
||||
})
|
||||
if (!result.token) {
|
||||
throw new Error('登录接口未返回访问令牌')
|
||||
throw new Error('登录失败,请稍后重试')
|
||||
}
|
||||
appStore.login(form.account, form.role, result.token, result.roleType || form.role)
|
||||
loading.value = false
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
<div class="section-header">
|
||||
<div>
|
||||
<h2>任务列表</h2>
|
||||
<p>展示已下发任务情况,后续可接老师创建任务后的统计接口</p>
|
||||
<p>展示已下发任务的完成进度与训练情况</p>
|
||||
</div>
|
||||
<el-button :icon="Plus" type="primary">新建任务</el-button>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user