feat: 病历模块优化、AI辅助问诊、接入deepseek

This commit is contained in:
李琦
2026-08-06 17:56:28 +08:00
parent c1bc234509
commit 174693f6db
42 changed files with 4668 additions and 56 deletions

View File

@@ -0,0 +1,601 @@
<script lang="ts" setup>
/**
* 系统配置 - AI:二级 Tab
* 1) 模型配置:选用平台 / 密钥 / 模型
* 2) 病历配置:勾选 AI 写病历需要生成的字段
* 3) 提醒文案:VIP 关联的醒目提示与知情同意
*/
import { computed, onMounted, ref, watch } from 'vue';
import { Button, Checkbox, Input, Spin, Tabs, Tag, message } from 'ant-design-vue';
import { CheckCircleFilled, KeyOutlined } from '@ant-design/icons-vue';
import {
getAiFeatureCopyList,
getAiPlatformConfigOptions,
saveAiFeatureCopy,
} from '#/views/system/ai/platform/api';
import { saveSystemConfig } from '#/views/system/system-config/api';
/** 二级 Tab 持久化 */
const SUB_TAB_STORAGE_KEY = 'system_config_ai_sub_tab';
interface PlatformCard {
id: number;
code: string;
name: string;
logo: string;
description: string;
default_model: string;
api_url: string;
models: {
id: number;
label: string;
value: string;
code: string;
name: string;
description: string;
is_default: number;
}[];
keys: {
id?: number;
key_name?: string;
name?: string;
label?: string;
value: number;
is_default?: number;
}[];
}
interface FeatureCopyRow {
id: number;
feature_code: string;
feature_name: string;
record_label: string;
disclaimer_text: string;
consent_text: string;
}
const loading = ref(false);
const saving = ref(false);
const copySaving = ref(false);
const platforms = ref<PlatformCard[]>([]);
const activeProvider = ref('');
const activeApiKeyId = ref<number | undefined>(undefined);
const activeModel = ref('');
const featureCopies = ref<FeatureCopyRow[]>([]);
const subTab = ref('model');
/** 病历配置:可选字段与已勾选编码 */
const mrFieldOptions = ref<{ label: string; value: string }[]>([]);
const mrSelectedFields = ref<string[]>([]);
const mrSaving = ref(false);
const currentPlatform = computed(() =>
platforms.value.find((p) => p.code === activeProvider.value),
);
const keyOptions = computed(() => currentPlatform.value?.keys || []);
const modelOptions = computed(() => currentPlatform.value?.models || []);
/** 密钥展示名:key_name → name → label → 兜底 */
function keyDisplayName(k: {
key_name?: string;
name?: string;
label?: string;
value: number;
id?: number;
}): string {
const candidates = [k.key_name, k.name, k.label];
for (const c of candidates) {
const s = String(c || '').trim();
if (s) return s;
}
return `密钥#${k.id || k.value}`;
}
async function load() {
loading.value = true;
try {
const [optRes, copyRes] = await Promise.all([
getAiPlatformConfigOptions(),
getAiFeatureCopyList(),
]);
const data = optRes?.data || optRes || {};
platforms.value = Array.isArray(data.platforms) ? data.platforms : [];
const active = data.active || {};
activeProvider.value = String(active.provider || platforms.value[0]?.code || '');
activeApiKeyId.value = active.api_key_id ? Number(active.api_key_id) : undefined;
activeModel.value = String(active.model || '');
syncSelectionForPlatform();
const mr = data.medical_record_fields || {};
mrFieldOptions.value = Array.isArray(mr.options)
? mr.options.map((o: any) => ({
label: String(o.label || o.value || ''),
value: String(o.value || ''),
}))
: [];
mrSelectedFields.value = Array.isArray(mr.selected)
? mr.selected.map((c: any) => String(c))
: mrFieldOptions.value.map((o) => o.value);
const copies = copyRes?.data || copyRes || [];
featureCopies.value = (Array.isArray(copies) ? copies : []).map((r: any) => ({
id: Number(r.id || 0),
feature_code: String(r.feature_code || ''),
feature_name: String(r.feature_name || r.feature_code || ''),
record_label: String(r.record_label || ''),
disclaimer_text: String(r.disclaimer_text || ''),
consent_text: String(r.consent_text || ''),
}));
} finally {
loading.value = false;
}
}
/** 切换平台后:密钥/模型回落到该平台默认或首个 */
function syncSelectionForPlatform() {
const keys = keyOptions.value;
if (activeApiKeyId.value && !keys.some((k) => k.value === activeApiKeyId.value)) {
const def = keys.find((k) => Number(k.is_default) === 1);
activeApiKeyId.value = def?.value ?? keys[0]?.value;
} else if (!activeApiKeyId.value && keys[0]) {
const def = keys.find((k) => Number(k.is_default) === 1);
activeApiKeyId.value = def?.value ?? keys[0].value;
}
const models = modelOptions.value;
if (activeModel.value && !models.some((m) => m.value === activeModel.value)) {
const def = models.find((m) => Number(m.is_default) === 1);
activeModel.value =
def?.value || currentPlatform.value?.default_model || models[0]?.value || '';
} else if (!activeModel.value) {
const def = models.find((m) => Number(m.is_default) === 1);
activeModel.value =
def?.value || currentPlatform.value?.default_model || models[0]?.value || '';
}
}
watch(activeProvider, () => {
syncSelectionForPlatform();
});
function onSubTabChange(key: string | number) {
subTab.value = String(key);
localStorage.setItem(SUB_TAB_STORAGE_KEY, String(key));
}
/** 病历字段全选 / 清空 */
function selectAllMrFields() {
mrSelectedFields.value = mrFieldOptions.value.map((o) => o.value);
}
function clearMrFields() {
mrSelectedFields.value = [];
}
/** 保存 AI 写病历输出字段勾选 */
async function handleSaveMrFields() {
if (!mrSelectedFields.value.length) {
message.warning('请至少勾选一个病历字段');
return;
}
// 按 options 顺序保存,保证提示词字段顺序稳定
const selectedSet = new Set(mrSelectedFields.value);
const ordered = mrFieldOptions.value
.map((o) => o.value)
.filter((v) => selectedSet.has(v));
mrSaving.value = true;
try {
await saveSystemConfig([
{
config_key: 'ai_medical_record_fields',
config_value: JSON.stringify(ordered),
value_type: 'json',
config_group: 'ai',
description: 'AI写病历需生成的字段编码列表(JSON数组,不含主诉)',
sort: 410,
} as any,
]);
mrSelectedFields.value = ordered;
message.success('病历字段配置已保存');
} finally {
mrSaving.value = false;
}
}
function selectProvider(code: string) {
activeProvider.value = code;
}
function selectKey(id: number) {
activeApiKeyId.value = id;
}
function selectModel(code: string) {
activeModel.value = code;
}
async function handleSave() {
if (!activeProvider.value) {
message.warning('请选择 AI 平台');
return;
}
saving.value = true;
try {
await saveSystemConfig([
{
config_key: 'ai_active_provider',
config_value: activeProvider.value,
value_type: 'string',
config_group: 'ai',
description: '当前 AI 平台编码(spark/deepseek)',
sort: 400,
},
{
config_key: 'ai_active_api_key_id',
config_value: String(activeApiKeyId.value || 0),
value_type: 'string',
config_group: 'ai',
description: '当前选用的 AI API Key ID',
sort: 401,
},
{
config_key: 'ai_active_model',
config_value: String(activeModel.value || ''),
value_type: 'string',
config_group: 'ai',
description: '当前 AI 模型名(空则用平台默认)',
sort: 402,
},
]);
message.success('AI 配置已保存');
} finally {
saving.value = false;
}
}
/** 保存 VIP 关联的 AI 醒目提示 / 知情同意文案 */
async function handleSaveCopies() {
if (!featureCopies.value.length) {
message.warning('暂无文案可保存,请先执行 SQL 初始化');
return;
}
copySaving.value = true;
try {
await saveAiFeatureCopy({
items: featureCopies.value.map((r) => ({
id: r.id,
feature_code: r.feature_code,
record_label: r.record_label,
disclaimer_text: r.disclaimer_text,
consent_text: r.consent_text,
})),
});
message.success('AI 功能文案已保存');
} finally {
copySaving.value = false;
}
}
onMounted(() => {
const stored = localStorage.getItem(SUB_TAB_STORAGE_KEY);
if (stored === 'model' || stored === 'copy' || stored === 'mr') {
subTab.value = stored;
}
load();
});
</script>
<template>
<Spin :spinning="loading">
<div class="ai-config-panel py-2">
<Tabs :active-key="subTab" @change="onSubTabChange">
<Tabs.TabPane key="model" tab="模型配置">
<div class="py-2">
<div class="mb-2 font-medium text-[hsl(var(--foreground))]">选用 AI 平台</div>
<div class="mb-3 text-sm text-[hsl(var(--muted-foreground))]">
平台 / 模型 / 密钥请在「AI平台管理」维护;此处点选当前运行时组合。
</div>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
<div
v-for="p in platforms"
:key="p.code"
class="ai-pick-card"
:class="{ 'ai-pick-card--on': activeProvider === p.code }"
role="button"
tabindex="0"
@click="selectProvider(p.code)"
@keydown.enter="selectProvider(p.code)"
>
<CheckCircleFilled
v-if="activeProvider === p.code"
class="ai-pick-check"
/>
<div class="flex items-start gap-3">
<div class="ai-pick-logo">
<img
v-if="p.logo"
:src="p.logo"
:alt="p.name"
class="h-full w-full object-contain"
/>
<span v-else class="ai-pick-logo-fallback">
{{ (p.name || p.code || '?').slice(0, 1) }}
</span>
</div>
<div class="min-w-0 flex-1 pr-5">
<div class="font-medium text-[hsl(var(--foreground))]">
{{ p.name }}
</div>
<div class="mt-0.5 text-xs text-[hsl(var(--muted-foreground))]">
{{ p.code }} · {{ p.default_model || '—' }}
</div>
<div
class="mt-1 line-clamp-2 text-xs text-[hsl(var(--muted-foreground))]"
>
{{ p.description || p.api_url }}
</div>
</div>
</div>
</div>
</div>
<div class="mt-6 mb-2 font-medium text-[hsl(var(--foreground))]">API Key</div>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
<div
v-for="k in keyOptions"
:key="k.value"
class="ai-pick-card"
:class="{ 'ai-pick-card--on': activeApiKeyId === k.value }"
role="button"
tabindex="0"
@click="selectKey(k.value)"
@keydown.enter="selectKey(k.value)"
>
<CheckCircleFilled
v-if="activeApiKeyId === k.value"
class="ai-pick-check"
/>
<div class="flex items-start gap-3">
<div class="ai-pick-icon">
<KeyOutlined />
</div>
<div class="min-w-0 flex-1 pr-5">
<div class="flex flex-wrap items-center gap-2">
<span class="font-medium text-[hsl(var(--foreground))]">
{{ keyDisplayName(k) }}
</span>
<Tag v-if="k.is_default === 1" color="gold">默认</Tag>
</div>
<div class="mt-1 text-xs text-[hsl(var(--muted-foreground))]">
ID {{ k.value }}
</div>
</div>
</div>
</div>
</div>
<div
v-if="!keyOptions.length"
class="mt-1 text-xs text-amber-600 dark:text-amber-400"
>
当前平台暂无启用密钥,请先到「AI平台管理」添加
</div>
<div class="mt-6 mb-2 font-medium text-[hsl(var(--foreground))]">模型</div>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
<div
v-for="m in modelOptions"
:key="m.value"
class="ai-pick-card"
:class="{ 'ai-pick-card--on': activeModel === m.value }"
role="button"
tabindex="0"
@click="selectModel(m.value)"
@keydown.enter="selectModel(m.value)"
>
<CheckCircleFilled
v-if="activeModel === m.value"
class="ai-pick-check"
/>
<div class="min-w-0 pr-5">
<div class="flex flex-wrap items-center gap-2">
<span class="font-medium text-[hsl(var(--foreground))]">
{{ m.name || m.label || m.code }}
</span>
<Tag v-if="m.is_default === 1" color="gold">默认</Tag>
</div>
<div class="mt-1 text-xs text-[hsl(var(--muted-foreground))]">
{{ m.code }}
</div>
<div
v-if="m.description"
class="mt-1 line-clamp-2 text-xs text-[hsl(var(--muted-foreground))]"
>
{{ m.description }}
</div>
</div>
</div>
</div>
<div
v-if="!modelOptions.length"
class="mt-1 text-xs text-amber-600 dark:text-amber-400"
>
当前平台暂无启用模型,请先到「AI平台管理」添加
</div>
<div class="mt-6">
<Button type="primary" :loading="saving" @click="handleSave">
保存 AI 配置
</Button>
</div>
</div>
</Tabs.TabPane>
<Tabs.TabPane key="mr" tab="病历配置">
<div class="py-2">
<div class="mb-3 text-sm text-[hsl(var(--muted-foreground))]">
勾选后,AI 写病历只会返回这些字段(主诉仍由医生填写,不在此配置)。未勾选的字段不会生成。
</div>
<div class="mb-3 flex flex-wrap gap-2">
<Button size="small" @click="selectAllMrFields">全选</Button>
<Button size="small" @click="clearMrFields">清空</Button>
<span class="text-xs text-[hsl(var(--muted-foreground))] self-center">
已选 {{ mrSelectedFields.length }} / {{ mrFieldOptions.length }}
</span>
</div>
<Checkbox.Group
v-model:value="mrSelectedFields"
class="ai-mr-fields grid grid-cols-2 gap-x-4 gap-y-2 md:grid-cols-3 xl:grid-cols-4"
>
<Checkbox
v-for="opt in mrFieldOptions"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</Checkbox>
</Checkbox.Group>
<div class="mt-6">
<Button type="primary" :loading="mrSaving" @click="handleSaveMrFields">
保存病历配置
</Button>
</div>
</div>
</Tabs.TabPane>
<Tabs.TabPane key="copy" tab="提醒文案">
<div class="py-2">
<div class="mb-3 text-sm text-[hsl(var(--muted-foreground))]">
按 VIP 功能码维护醒目提示与首次知情同意全文;医生签署后仅该功能无需再次确认。
</div>
<div
v-if="!featureCopies.length"
class="rounded-lg border border-dashed border-[hsl(var(--border))] p-4 text-sm text-[hsl(var(--muted-foreground))]"
>
暂无文案数据,请执行 sql/20260806/xk_ai_disclaimer_consent.sql
</div>
<div
v-for="row in featureCopies"
:key="row.feature_code"
class="ai-copy-card mb-4"
>
<div class="mb-2 flex flex-wrap items-center gap-2">
<span class="font-medium text-[hsl(var(--foreground))]">
{{ row.feature_name || row.feature_code }}
</span>
<span class="text-xs text-[hsl(var(--muted-foreground))]">
{{ row.feature_code }}
</span>
</div>
<div class="mb-1 text-xs text-[hsl(var(--muted-foreground))]">
记录称谓(用于「本{称谓}由 AI…」)
</div>
<Input
v-model:value="row.record_label"
class="mb-3 max-w-xs"
placeholder="病历 / 处方"
/>
<div class="mb-1 text-xs text-[hsl(var(--muted-foreground))]">
醒目提示(生成记录 / 模态框顶部)
</div>
<Input.TextArea
v-model:value="row.disclaimer_text"
:rows="4"
class="mb-3"
placeholder="本病历/处方由 AI…"
/>
<div class="mb-1 text-xs text-[hsl(var(--muted-foreground))]">
首次知情同意全文
</div>
<Input.TextArea
v-model:value="row.consent_text"
:rows="6"
placeholder="我已知悉:…"
/>
</div>
<div v-if="featureCopies.length" class="mt-2">
<Button type="primary" :loading="copySaving" @click="handleSaveCopies">
保存 AI 功能文案
</Button>
</div>
</div>
</Tabs.TabPane>
</Tabs>
</div>
</Spin>
</template>
<style scoped>
.ai-pick-card {
position: relative;
cursor: pointer;
border: 1px solid hsl(var(--border));
border-radius: 12px;
padding: 14px 16px;
background: hsl(var(--card));
color: hsl(var(--foreground));
outline: none;
transition:
border-color 0.15s ease,
box-shadow 0.15s ease,
background-color 0.15s ease,
transform 0.15s ease;
}
.ai-pick-card:hover {
border-color: hsl(var(--primary) / 0.45);
box-shadow: 0 4px 14px hsl(var(--foreground) / 0.06);
}
.ai-pick-card--on {
border-color: hsl(var(--primary));
background: linear-gradient(
135deg,
hsl(var(--primary) / 0.14),
hsl(var(--primary) / 0.04)
);
box-shadow:
0 0 0 1px hsl(var(--primary) / 0.35),
0 8px 20px hsl(var(--primary) / 0.12);
}
.ai-pick-check {
position: absolute;
top: 10px;
right: 10px;
font-size: 18px;
color: hsl(var(--primary));
}
.ai-pick-logo,
.ai-pick-icon {
display: flex;
height: 44px;
width: 44px;
flex-shrink: 0;
align-items: center;
justify-content: center;
overflow: hidden;
border-radius: 10px;
border: 1px solid hsl(var(--border));
background: hsl(var(--muted) / 0.45);
}
.ai-pick-logo {
padding: 5px;
}
.ai-pick-icon {
font-size: 18px;
color: hsl(var(--primary));
background: hsl(var(--primary) / 0.1);
border-color: hsl(var(--primary) / 0.25);
}
.ai-pick-logo-fallback {
font-size: 15px;
font-weight: 600;
color: hsl(var(--muted-foreground));
}
.ai-copy-card {
border: 1px solid hsl(var(--border));
border-radius: 10px;
padding: 14px 16px;
background: hsl(var(--card));
}
.ai-mr-fields :deep(.ant-checkbox-wrapper) {
margin-inline-start: 0;
color: hsl(var(--foreground));
}
</style>