feat: 联调

This commit is contained in:
王天骄
2026-06-12 18:10:15 +08:00
parent 9fddb42ebe
commit 1d093c9589
12 changed files with 2309 additions and 56 deletions
+572
View File
@@ -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
}
+138
View File
@@ -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;
+10 -4
View File
@@ -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
View File
@@ -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',
+7
View File
@@ -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' },
+1
View File
@@ -17,6 +17,7 @@ export interface MenuItem {
page: string
title: string
icon: Component
disabled?: boolean
}
export interface MenuSection {
+177
View File
@@ -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>
+308
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -57,7 +57,7 @@
<div class="section-header">
<div>
<h2>内容质量处理队列</h2>
<p>可直接用于后续接入病例列表接口</p>
<p>集中跟进低通过率和待优化内容</p>
</div>
</div>
<div class="quality-list">
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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>