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

@@ -169,7 +169,7 @@ watch(durationPreset, (v) => {
});
</script>
<template>
<Modal :title="title" class="w-[760px]">
<Modal :title="title" class="w-[50%]">
<!-- 预览徽标卡片 + 丝带随时长自动适配 -->
<div class="vip-upgrade-preview mb-5">
<VipBadgeCombo

View File

@@ -17,6 +17,8 @@ export const ENCRYPT_FIELDS = new Set([
'password',
'patient_mobile',
'phone',
'open_api_key',
'api_key',
]);
/** 需要生成 _tm_text 并默认脱敏展示的字段encrypt_only */
@@ -96,7 +98,7 @@ export function maskSensitiveField(field: string, plainText: unknown): string {
return '';
}
const str = String(plainText);
if (field === 'password') {
if (field === 'password' || field === 'api_key' || field === 'open_api_key') {
return '*'.repeat(Math.max(str.length, 6));
}
if (PHONE_FIELDS.has(field)) {

View File

@@ -63,6 +63,7 @@ import WesternModal from '#/views/doctor/doctor-reception/components/WesternModa
import SimpleProductModal from '#/views/doctor/doctor-reception/components/SimpleProductModal.vue';
// 常用方选择弹窗组件
import CommonPrescriptionModal from '#/views/doctor/doctor-reception/components/CommonPrescriptionModal.vue';
import GoldenFormulaModal from '#/views/doctor/doctor-reception/components/GoldenFormulaModal.vue';
// 信息提示模态框
import InfoModal from '#/components/modal/InfoModal.vue';
// 药品搜索选择组件
@@ -175,6 +176,8 @@ const allowInsuranceCategory = computed(
const canUseCommonPrescription = computed(() =>
[1, 2].includes(prescriptionStore.activeCategory),
);
/** 金方导入 VIP */
const canUseGoldenFormula = computed(() => hasVipPermission('golden_formula'));
watch(allowInsuranceCategory, (allow) => {
if (!allow) {
@@ -310,6 +313,11 @@ const [CommonPrescriptionModals, CommonPrescriptionModalApi] = useVbenModal({
connectedComponent: CommonPrescriptionModal,
});
/** 金方导入弹窗 */
const [GoldenFormulaModals, GoldenFormulaModalApi] = useVbenModal({
connectedComponent: GoldenFormulaModal,
});
const setVisible = (value: boolean, instruction = ''): void => {
previewImage.value = [];
if (instruction === '') {
@@ -855,6 +863,89 @@ function openCommonPrescriptionModal() {
CommonPrescriptionModalApi.open();
}
/**
* 打开金方导入VIPgolden_formula
*/
function openGoldenFormulaModal() {
if (!canUseGoldenFormula.value) {
message.warning('当前门店未开通金方 VIP 功能');
return;
}
GoldenFormulaModalApi.setData({
storeId: medicalRecordStoreId.value,
registerId: medicalRecordRegisterId.value,
category: prescriptionStore.activeCategory,
});
GoldenFormulaModalApi.open();
}
/**
* 金方确认导入:整表覆盖当前处方药品(与 PC 接诊 AI/金方一致)
*/
function handleImportGoldenFormula(payload: { rows?: any[]; formulaName?: string }) {
const list = Array.isArray(payload?.rows) ? payload.rows : [];
if (!list.length) {
message.warning('没有可导入的药品');
return;
}
const resolveWayId = (usage: string) => {
const u = String(usage || '').trim();
if (!u) return 0;
const found = (prescriptionStore.drugUseWay || []).find(
(w: any) =>
String(w.name || '') === u || String(w.name || '').includes(u),
);
return found ? Number(found.id || 0) : 0;
};
const next: any[] = [];
for (const row of list) {
const c = row?.candidate || {};
const drugId = Number(c.drug_id || 0);
if (!drugId) continue;
if (next.find((item: any) => Number(item.id) === drugId)) continue;
const doseNum = parseFloat(row.dose);
const qty = !Number.isNaN(doseNum) && doseNum > 0 ? doseNum : 1;
const drug = c.drug || {};
if (prescriptionStore.activeCategory === 1) {
const wayId = resolveWayId(row.usage || '');
next.push({
index_id: c.id || drugId,
id: drugId,
drug_name: c.drug_name || row.ai_name,
number: qty,
price: parseFloat(c.price) || 0,
way_id: wayId,
use_ways: (prescriptionStore.drugUseWay || []).find(
(item: any) => item.id === wayId,
),
select_number: 1,
unit: c.unit || { id: 0, name: row.unit || 'g' },
});
continue;
}
next.push({
index_id: c.id || drugId,
id: drugId,
drug_name: c.drug_name || drug.drug_name || row.ai_name,
number: qty,
select_number: qty,
price: parseFloat(c.price) || 0,
image: c.image || drug.image || '',
instruction: drug.instruction || '',
time_id: c.time_id || drug.time_id || 0,
type_id: c.type_id || drug.type_id || 0,
frequency_id: c.frequency_id || drug.frequency_id || 0,
unit_id: c.unit_id || drug.unit_id || 0,
type: 2,
});
}
if (!next.length) {
message.warning('没有可写入的已匹配药品');
return;
}
prescriptionStore.currentDrugs = next;
}
/**
* 处理选择常用方
* @param data 常用方数据包含prescription和recipes
@@ -1194,6 +1285,15 @@ const cancelSaveCommonPrescription = () => {
<BookOutlined />
选择常用方
</Button>
<Button
v-if="canUseGoldenFormula"
type="link"
size="small"
@click="openGoldenFormulaModal"
>
<MedicineBoxOutlined />
导入金方
</Button>
<Button
v-if="canUseCommonPrescription"
type="link"
@@ -1822,7 +1922,8 @@ const cancelSaveCommonPrescription = () => {
</div>
<div
v-show="canUseMedicalRecord && rxMrTab === 'mr'"
v-if="canUseMedicalRecord"
v-show="rxMrTab === 'mr'"
class="medical-record-wrap py-2"
>
<MedicalRecordPanel
@@ -1916,6 +2017,7 @@ const cancelSaveCommonPrescription = () => {
<PrescriptionDetailModal />
<!-- 常用方选择弹窗 -->
<CommonPrescriptionModals />
<GoldenFormulaModals @import="handleImportGoldenFormula" />
<!-- 药店开方选配送仓库 -->
<WarehouseSelectModals @confirm="onWarehouseSelected" />
<PatientInfoDrawerComp />

View File

@@ -0,0 +1,39 @@
<script lang="ts" setup>
/**
* AI 醒目提示条:病历/处方生成记录与模态框顶部展示
* 样式仅用主题变量,禁止 :global(.dark) 以免污染全局配色
*/
defineProps<{
/** 完整提示文案(可含换行) */
text?: string;
}>();
</script>
<template>
<div v-if="text" class="ai-disclaimer" role="note">
<div class="ai-disclaimer__title">重要提示</div>
<div class="ai-disclaimer__body">{{ text }}</div>
</div>
</template>
<style scoped>
.ai-disclaimer {
border: 1px solid hsl(var(--warning) / 0.45);
border-radius: 10px;
background: hsl(var(--warning) / 0.12);
padding: 10px 12px;
color: hsl(var(--foreground));
}
.ai-disclaimer__title {
margin-bottom: 4px;
font-size: 13px;
font-weight: 600;
color: hsl(var(--warning));
}
.ai-disclaimer__body {
white-space: pre-wrap;
font-size: 12px;
line-height: 1.55;
color: hsl(var(--muted-foreground));
}
</style>

View File

@@ -438,10 +438,27 @@ export async function aiGeneratePrescriptionApi(data: {
chief_complaint?: string;
/** 当前病历表单快照(可未保存),供 AI 综合参考 */
medical_record?: Record<string, any>;
/** 中药:是否委托调剂 */
use_entrusted_process?: 0 | 1 | boolean;
/** 中药委托调剂:制剂要求 idyii_process_rule pid=0 */
process_rule_id?: number;
}) {
return requestClient.post<any>(`${prefix}ai-generate-prescription`, data);
}
/** AI 功能门闸(醒目提示 + 是否已签署知情同意) */
export async function aiFeatureGateApi(data: { feature_code: string }) {
return requestClient.get<any>(`${prefix}ai-feature-gate`, { params: data });
}
/** AI 知情同意签署(按 VIP 功能码,签署一次即可) */
export async function aiAgreeConsentApi(data: {
feature_code: string;
store_id?: number;
}) {
return requestClient.post<any>(`${prefix}ai-agree-consent`, data);
}
/** 本挂号 AI 生成历史(轻量列表) */
export async function aiListGenerationsApi(data: {
register_id: number;
@@ -466,3 +483,31 @@ export async function aiMatchPrescriptionDrugsApi(data: {
}) {
return requestClient.post<any>(`${prefix}ai-match-prescription-drugs`, data);
}
/** 金方列表VIPgolden_formula */
export async function goldenFormulaListApi(data: {
keyword?: string;
page?: number;
pageSize?: number;
store_id?: number;
}) {
return requestClient.get<any>(`${prefix}golden-formula-list`, { params: data });
}
/** 金方详情 */
export async function goldenFormulaDetailApi(data: {
id: number;
store_id?: number;
}) {
return requestClient.get<any>(`${prefix}golden-formula-detail`, { params: data });
}
/** 金方导入药名对照 */
export async function goldenFormulaApplyApi(data: {
id: number;
store_id?: number;
register_id?: number;
prescription_type?: number;
}) {
return requestClient.post<any>(`${prefix}golden-formula-apply`, data);
}

View File

@@ -7,13 +7,16 @@
import { computed, ref } from 'vue';
import { useVbenDrawer, useVbenModal } from '@vben/common-ui';
import { Button, Descriptions, Input, Switch, Tag, message } from 'ant-design-vue';
import { Button, Checkbox, Descriptions, Input, Select, Spin, Switch, Tag, message } from 'ant-design-vue';
import {
aiGeneratePrescriptionApi,
aiListGenerationsApi,
aiMatchPrescriptionDrugsApi,
getProcessRuleList,
} from '../api';
import AiDisclaimerBanner from '#/views/doctor/components/AiDisclaimerBanner.vue';
import { ensureAiFeatureConsent } from '#/views/doctor/utils/aiFeatureGate';
import PrescriptionLoading from '#/components/loading/PrescriptionLoading.vue';
/** 二级弹窗通栏字段Descriptions span=2 */
@@ -57,6 +60,10 @@ const emit = defineEmits<{
rows: Array<Record<string, any>>;
dosage?: number;
day_dosage?: number;
process_rule_id?: number;
child_process_rule_id?: number;
process_rule_note_id?: number;
rule_type?: number;
},
): void;
(e: 'search-drug', keyword: string): void;
@@ -85,8 +92,24 @@ const rxDayDosage = ref(0);
const rxReason = ref('');
const rxBasis = ref('');
const rxPrescriptionName = ref('');
/** 最近一次生成耗时(毫秒) */
const lastDurationMs = ref(0);
/** AI 返回的委托调剂制剂信息(导入时传给开方区) */
const rxProcessRuleId = ref(0);
const rxChildProcessRuleId = ref(0);
const rxProcessRuleNoteId = ref(0);
const rxProcessRuleName = ref('');
const rxChildProcessRuleName = ref('');
const rxProcessRuleNote = ref('');
/** 生成弹窗:中药委托调剂选项 */
const useEntrustedProcess = ref(false);
const genProcessRuleId = ref<number | undefined>(undefined);
const processRuleOptions = ref<Array<{ label: string; value: number }>>([]);
const processRuleLoading = ref(false);
/** 二级弹窗:当前正在编辑的字段 key空表示浏览态 */
const editingKey = ref('');
/** 醒目提示文案(系统配置 / 门闸下发) */
const disclaimerText = ref('');
const sexLabel = computed(() => {
const s = Number(patientSex.value);
@@ -130,7 +153,7 @@ const [Drawer, drawerApi] = useVbenDrawer({
showConfirmButton: false,
// 内容区改 flex 铺满,由内部滚动,避免双重裁切
contentClass: '!flex !flex-col !overflow-hidden',
onOpenChange(isOpen) {
async onOpenChange(isOpen) {
if (!isOpen) {
resetState();
return;
@@ -157,7 +180,22 @@ const [Drawer, drawerApi] = useVbenDrawer({
? { ...data.medicalRecord }
: {};
resetPreview();
// 打开即进历史,并尽量选中第一条
// 首次使用对应 VIP 功能需签署知情同意;拒绝则关闭抽屉
try {
const gate = await ensureAiFeatureConsent({
featureCode: 'ai_prescription',
storeId: storeId.value || undefined,
});
if (!gate) {
drawerApi.close();
return;
}
disclaimerText.value = gate.disclaimer_text || '';
} catch (e: any) {
message.error(e?.message || e?.msg || '无法校验 AI 使用规范');
drawerApi.close();
return;
}
void bootstrapHistory();
},
});
@@ -180,6 +218,13 @@ function resetPreview() {
rxReason.value = '';
rxBasis.value = '';
rxPrescriptionName.value = '';
lastDurationMs.value = 0;
rxProcessRuleId.value = 0;
rxChildProcessRuleId.value = 0;
rxProcessRuleNoteId.value = 0;
rxProcessRuleName.value = '';
rxChildProcessRuleName.value = '';
rxProcessRuleNote.value = '';
activeId.value = 0;
editingKey.value = '';
}
@@ -191,6 +236,10 @@ function resetState() {
historyList.value = [];
medicalRecord.value = {};
chiefComplaint.value = '';
disclaimerText.value = '';
useEntrustedProcess.value = false;
genProcessRuleId.value = undefined;
processRuleOptions.value = [];
resetPreview();
genModalApi.close();
}
@@ -203,6 +252,15 @@ function formatTime(ts: number) {
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
/** 毫秒转可读耗时850ms / 1.2s */
function formatDurationMs(ms: number | undefined | null) {
const n = Number(ms || 0);
if (!n) return '';
if (n < 1000) return `${Math.round(n)}ms`;
const sec = n / 1000;
return sec >= 10 ? `${Math.round(sec)}s` : `${sec.toFixed(1)}s`;
}
function typeLabelOf(t: number | string) {
const n = Number(t || 0);
if (n === 1) return '中药';
@@ -216,6 +274,42 @@ function applyRxMeta(data: any) {
rxReason.value = String(data?.reason || '').trim();
rxBasis.value = String(data?.basis || '').trim();
rxPrescriptionName.value = String(data?.prescription_name || '').trim();
if (data?.duration_ms != null) {
lastDurationMs.value = Number(data.duration_ms || 0);
}
rxProcessRuleId.value = Number(data?.process_rule_id || 0);
rxChildProcessRuleId.value = Number(data?.child_process_rule_id || 0);
rxProcessRuleNoteId.value = Number(data?.process_rule_note_id || 0);
rxProcessRuleName.value = String(data?.process_rule_name || '').trim();
rxChildProcessRuleName.value = String(data?.child_process_rule_name || '').trim();
rxProcessRuleNote.value = String(data?.process_rule_note || '').trim();
}
/** 加载制剂要求列表pid=0供生成前委托调剂选择 */
async function loadTopProcessRules() {
if (!isTcmRx.value) return;
processRuleLoading.value = true;
try {
const list = await getProcessRuleList({ pid: 0 });
const arr = Array.isArray(list) ? list : [];
processRuleOptions.value = arr.map((item: any) => ({
label: String(item.name || item.title || `#${item.id}`),
value: Number(item.id),
}));
} catch {
processRuleOptions.value = [];
} finally {
processRuleLoading.value = false;
}
}
function onEntrustedToggle(checked: boolean) {
if (checked && !processRuleOptions.value.length) {
void loadTopProcessRules();
}
if (!checked) {
genProcessRuleId.value = undefined;
}
}
function applyConflict(conflict: any) {
@@ -268,6 +362,9 @@ function openGenerateModal() {
return;
}
editingKey.value = '';
if (isTcmRx.value && useEntrustedProcess.value && !processRuleOptions.value.length) {
void loadTopProcessRules();
}
genModalApi.open();
}
@@ -321,6 +418,10 @@ async function confirmGenerateAndClose(): Promise<boolean> {
message.warning('挂号无效');
return false;
}
if (isTcmRx.value && useEntrustedProcess.value && !genProcessRuleId.value) {
message.warning('请选择制剂要求');
return false;
}
// 先关模态在抽屉内展示「AI 处理中」
genModalApi.close();
void runGenerateInDrawer(chief);
@@ -330,7 +431,7 @@ async function confirmGenerateAndClose(): Promise<boolean> {
async function runGenerateInDrawer(chief: string) {
generating.value = true;
try {
const data = await aiGeneratePrescriptionApi({
const req: Record<string, any> = {
register_id: registerId.value,
store_id: storeId.value || undefined,
prescription_type: prescriptionType.value,
@@ -339,13 +440,37 @@ async function runGenerateInDrawer(chief: string) {
...(medicalRecord.value || {}),
chief_complaint: chief,
},
});
};
// 中药委托调剂:传制剂要求,后端会补全煎法/规格
if (isTcmRx.value && useEntrustedProcess.value && genProcessRuleId.value) {
req.use_entrusted_process = 1;
req.process_rule_id = genProcessRuleId.value;
}
const data = await aiGeneratePrescriptionApi(req);
const durationText = formatDurationMs(data?.duration_ms);
// 业务软失败HTTP 成功但 ok=false警告提示而非 error
if (data?.ok === false) {
matchedList.value = [];
unmatchedList.value = [];
conflictMessages.value = [];
applyRxMeta({
duration_ms: data.duration_ms,
});
if (data?.generation_id) {
activeId.value = Number(data.generation_id);
await loadHistory();
}
message.warning(String(data?.message || '生成未成功'));
return;
}
await loadHistory();
activeId.value = Number(data?.generation_id || 0);
applyMatch(data?.match || null);
applyConflict(data?.conflict || null);
applyRxMeta(data);
message.success('已生成,请确认导入');
message.success(
durationText ? `已生成(耗时 ${durationText}` : '已生成,请确认导入',
);
} catch (e: any) {
message.error(e?.message || e?.msg || '生成失败');
} finally {
@@ -370,6 +495,7 @@ async function onSelectHistory(row: any) {
applyConflict(data?.conflict || null);
applyRxMeta({
...data,
duration_ms: row.duration_ms,
prescription_name:
data?.prescription_name || row?.name || row?.prescription_name || '',
});
@@ -405,11 +531,30 @@ function onConfirmImport() {
message.warning('请先勾选已对照药品');
return;
}
emit('import', {
const importPayload: {
rows: typeof drugs;
dosage?: number;
day_dosage?: number;
rule_type?: number;
process_rule_id?: number;
child_process_rule_id?: number;
process_rule_note_id?: number;
} = {
rows: drugs,
dosage: rxDosage.value || undefined,
day_dosage: rxDayDosage.value || undefined,
});
};
if (rxProcessRuleId.value > 0) {
importPayload.rule_type = 2;
importPayload.process_rule_id = rxProcessRuleId.value;
if (rxChildProcessRuleId.value > 0) {
importPayload.child_process_rule_id = rxChildProcessRuleId.value;
}
if (rxProcessRuleNoteId.value > 0) {
importPayload.process_rule_note_id = rxProcessRuleNoteId.value;
}
}
emit('import', importPayload);
drawerApi.close();
}
@@ -438,6 +583,7 @@ defineExpose({
{{ patientName || '—' }} · {{ sexLabel }} · {{ patientAge || '—' }} ·
{{ typeLabel }}
</div>
<AiDisclaimerBanner class="shrink-0" :text="disclaimerText" />
<div
v-if="conflictMessages.length"
class="shrink-0 rounded-lg border border-orange-500/35 bg-orange-500/10 px-3 py-2 text-xs"
@@ -469,29 +615,45 @@ defineExpose({
<div class="mt-0.5 text-muted-foreground">
{{ row.prescription_type_label || typeLabelOf(row.prescription_type) }}
· {{ row.item_count || 0 }} · {{ formatTime(row.created_at) }}
<span v-if="row.duration_ms"> · {{ formatDurationMs(row.duration_ms) }}</span>
</div>
</div>
</div>
<div class="min-h-0 min-w-0 flex-1 overflow-y-auto pl-1 pb-2">
<!-- AI 专属动画仅真正调用生成接口时展示历史/对照加载用普通 Spin -->
<div
v-if="generating || matchLoading"
v-if="generating"
class="flex h-full min-h-[260px] items-center justify-center"
>
<PrescriptionLoading
type="prescription"
:text="
generating ? 'AI正在生成处方' : 'AI正在对照药品'
"
text="AI正在生成处方"
class="h-[280px] w-full max-w-[420px]"
/>
</div>
<div
v-else-if="matchLoading"
class="flex h-full min-h-[200px] flex-col items-center justify-center gap-2 text-muted-foreground"
>
<Spin />
<span class="text-sm">加载历史处方</span>
</div>
<template v-else>
<div
v-if="!matchedList.length && !unmatchedList.length"
v-if="!matchedList.length && !unmatchedList.length && !lastDurationMs"
class="py-8 text-center text-muted-foreground"
>
请选择左侧历史或点击生成处方
</div>
<div
v-else-if="!matchedList.length && !unmatchedList.length && lastDurationMs"
class="py-8 text-center text-muted-foreground"
>
本次生成未产出对照结果
<span v-if="formatDurationMs(lastDurationMs)">
耗时 {{ formatDurationMs(lastDurationMs) }}
</span>
</div>
<template v-else>
<div class="mb-1.5 text-xs font-medium text-muted-foreground">
已对照{{ matchedList.length }}
@@ -593,7 +755,9 @@ defineExpose({
rxReason ||
rxBasis ||
rxDosage ||
rxDayDosage
rxDayDosage ||
lastDurationMs ||
rxProcessRuleName
"
:column="2"
bordered
@@ -610,6 +774,18 @@ defineExpose({
<Descriptions.Item label="每天几次">
{{ rxDayDosage || '—' }}次
</Descriptions.Item>
<Descriptions.Item v-if="lastDurationMs" label="生成耗时">
{{ formatDurationMs(lastDurationMs) }}
</Descriptions.Item>
<Descriptions.Item
v-if="rxProcessRuleName"
label="制剂要求"
:span="2"
>
{{ rxProcessRuleName }}
<span v-if="rxChildProcessRuleName"> · {{ rxChildProcessRuleName }}</span>
<span v-if="rxProcessRuleNote"> · {{ rxProcessRuleNote }}</span>
</Descriptions.Item>
<Descriptions.Item
v-if="rxReason"
label="为什么出这个方"
@@ -650,6 +826,7 @@ defineExpose({
<!-- 二级:病历预览(可点改)+ 生成Descriptions 双列 -->
<GenModal>
<div class="space-y-2 text-sm text-foreground">
<AiDisclaimerBanner :text="disclaimerText" />
<div class="text-xs text-muted-foreground">
{{ patientName || '—' }} · {{ sexLabel }} · {{ patientAge || '—' }}岁 ·
{{ typeLabel }} · 点击文字可编辑,将同步到病历
@@ -693,6 +870,29 @@ defineExpose({
仅有主诉。建议先在病历区补充现病史、舌脉等,再生成。
</div>
</div>
<!-- 中药:生成前可选委托调剂 + 制剂要求 -->
<div
v-if="isTcmRx"
class="rounded-lg border border-border bg-muted/30 p-3"
>
<Checkbox
v-model:checked="useEntrustedProcess"
@update:checked="onEntrustedToggle"
>
委托调剂
</Checkbox>
<div v-if="useEntrustedProcess" class="mt-2">
<div class="mb-1 text-xs text-muted-foreground">制剂要求</div>
<Select
v-model:value="genProcessRuleId"
:loading="processRuleLoading"
placeholder="请选择制剂要求"
class="w-full"
:options="processRuleOptions"
allow-clear
/>
</div>
</div>
</div>
</GenModal>
</template>

View File

@@ -0,0 +1,290 @@
<script lang="ts" setup>
/**
* 金方导入弹窗PC 接诊 / 在线复诊共用)
* - VIPgolden_formula
* - 选方后调用药名对照,确认导入时抛出与 AI 出方相同结构的 rows
*/
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import {
Button,
Empty,
Input,
Spin,
Tag,
message,
} from 'ant-design-vue';
import {
goldenFormulaApplyApi,
goldenFormulaListApi,
} from '../api';
const emit = defineEmits<{
(
e: 'import',
payload: {
rows: Array<Record<string, any>>;
formulaName?: string;
},
): void;
}>();
const loading = ref(false);
const applying = ref(false);
const keyword = ref('');
const list = ref<any[]>([]);
const total = ref(0);
const page = ref(1);
const pageSize = 20;
const storeId = ref(0);
const registerId = ref(0);
/** 处方类型1中药 2西药与 ProductTypeEnum 对齐;接诊 activeCategory 1=中 2=西) */
const prescriptionType = ref(1);
const selectedId = ref(0);
const matchResult = ref<any | null>(null);
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
draggable: true,
footer: false,
class: 'w-[880px]',
onCancel() {
modalApi.close();
},
onOpenChange(isOpen: boolean) {
if (!isOpen) {
keyword.value = '';
list.value = [];
matchResult.value = null;
selectedId.value = 0;
return;
}
const data = modalApi.getData<{
storeId?: number;
registerId?: number;
/** 开方页 activeCategory1中药 2西药 → ProductType 同值 */
category?: number;
}>() || {};
storeId.value = Number(data.storeId || 0);
registerId.value = Number(data.registerId || 0);
// activeCategory 1=中药 2=西药,与 ProductTypeEnum 一致
prescriptionType.value = Number(data.category || 1) === 2 ? 2 : 1;
page.value = 1;
loadList();
},
});
/** 加载金方列表 */
async function loadList() {
loading.value = true;
matchResult.value = null;
selectedId.value = 0;
try {
const res = await goldenFormulaListApi({
keyword: keyword.value,
page: page.value,
pageSize,
store_id: storeId.value || undefined,
});
list.value = res?.items || [];
total.value = Number(res?.total || 0);
} catch (e: any) {
message.error(e?.message || '加载金方失败');
} finally {
loading.value = false;
}
}
function onSearch() {
page.value = 1;
loadList();
}
/**
* 选中金方并做药名对照
*/
async function handleSelect(row: any) {
selectedId.value = Number(row.id);
applying.value = true;
matchResult.value = null;
try {
const res = await goldenFormulaApplyApi({
id: selectedId.value,
store_id: storeId.value || undefined,
register_id: registerId.value || undefined,
prescription_type: prescriptionType.value,
});
matchResult.value = res;
} catch (e: any) {
message.error(e?.message || '对照失败');
} finally {
applying.value = false;
}
}
/**
* 确认导入:把 matched 转成 AI 导入同构 rows候选默认取第一项
*/
function handleConfirmImport() {
const matched = matchResult.value?.matched || [];
if (!matched.length) {
message.warning('没有可导入的已匹配药品');
return;
}
const rows = matched.map((m: any) => {
const candidates = m.candidates || [];
const selectedId = Number(m.selected_drug_id || 0);
const candidate =
candidates.find((c: any) => Number(c.drug_id) === selectedId) ||
candidates[0] ||
{};
return {
ai_name: m.ai_name,
dose: m.dose,
unit: m.unit,
usage: m.usage,
frequency: m.frequency,
candidate,
};
});
emit('import', {
rows,
formulaName: matchResult.value?.formula?.name || '',
});
const unmatched = matchResult.value?.unmatched || [];
if (unmatched.length) {
message.warning(
`已导入 ${rows.length} 味;未匹配 ${unmatched.length} 味请手动补录`,
);
} else {
message.success(`已导入金方:${matchResult.value?.formula?.name || ''}`);
}
modalApi.close();
}
defineExpose({
open: (data: Record<string, any>) => {
modalApi.setData(data);
modalApi.open();
},
});
</script>
<template>
<Modal title="导入金方">
<div class="flex gap-3" style="min-height: 420px">
<div class="w-[340px] shrink-0 border-r pr-3">
<div class="mb-2 flex gap-2">
<Input
v-model:value="keyword"
allow-clear
placeholder="名称/首拼/来源/速览"
@press-enter="onSearch"
/>
<Button type="primary" @click="onSearch">搜索</Button>
</div>
<Spin :spinning="loading">
<div v-if="!list.length" class="py-8">
<Empty description="暂无金方" />
</div>
<div v-else class="max-h-[360px] space-y-2 overflow-y-auto">
<div
v-for="item in list"
:key="item.id"
class="cursor-pointer rounded border p-2 transition hover:border-primary"
:class="selectedId === item.id ? 'border-primary bg-primary/5' : ''"
@click="handleSelect(item)"
>
<div class="font-medium">{{ item.name }}</div>
<div class="mt-1 text-xs text-gray-500 line-clamp-2">
{{ item.herb_overview || '—' }}
</div>
<div class="mt-1 flex flex-wrap gap-1">
<Tag v-if="item.source" color="gold">{{ item.source }}</Tag>
</div>
</div>
</div>
<div v-if="total > pageSize" class="mt-2 text-center text-xs text-gray-400">
{{ total }} 当前第 {{ page }}
<Button
v-if="page * pageSize < total"
type="link"
size="small"
@click="page += 1; loadList()"
>
下一页
</Button>
</div>
</Spin>
</div>
<div class="min-w-0 flex-1">
<Spin :spinning="applying">
<div v-if="!matchResult" class="py-16">
<Empty description="请选择左侧金方进行药名对照" />
</div>
<div v-else>
<div class="mb-2 text-base font-medium">
{{ matchResult.formula?.name }}
<Tag v-if="matchResult.formula?.source" class="ml-2" color="gold">
{{ matchResult.formula.source }}
</Tag>
</div>
<div
v-if="matchResult.formula?.indication_translation || matchResult.formula?.indication_original"
class="mb-2 text-xs text-gray-500"
>
主治
{{
matchResult.formula.indication_translation ||
matchResult.formula.indication_original
}}
</div>
<div
v-if="matchResult.formula?.original_formula"
class="mb-2 max-h-24 overflow-y-auto rounded bg-amber-50 px-2 py-1 text-xs text-gray-700 whitespace-pre-wrap"
>
<span class="font-medium text-amber-700">原方</span>
{{ matchResult.formula.original_formula }}
</div>
<div class="mb-2 text-sm">
已匹配
<Tag color="success">{{ (matchResult.matched || []).length }}</Tag>
未匹配
<Tag color="warning">{{ (matchResult.unmatched || []).length }}</Tag>
</div>
<div class="max-h-[280px] space-y-1 overflow-y-auto text-sm">
<div
v-for="(m, idx) in matchResult.matched || []"
:key="'m' + idx"
class="rounded bg-green-50 px-2 py-1"
>
{{ m.show_dose || m.display_text || `${m.ai_name} ${m.dose || ''}${m.unit || ''}${m.usage ? `${m.usage}` : ''}` }}
{{ (m.candidates && m.candidates[0] && m.candidates[0].drug_name) || '—' }}
</div>
<div
v-for="(u, idx) in matchResult.unmatched || []"
:key="'u' + idx"
class="rounded bg-orange-50 px-2 py-1"
>
{{ u.show_dose || u.display_text || `${u.ai_name} ${u.dose || ''}${u.unit || ''}` }}
未匹配
</div>
</div>
<div class="mt-4 text-right">
<Button
type="primary"
:disabled="!(matchResult.matched || []).length"
@click="handleConfirmImport"
>
确认导入到处方
</Button>
</div>
</div>
</Spin>
</div>
</div>
</Modal>
</template>

View File

@@ -31,6 +31,8 @@ type DrawerData = {
patientName?: string;
/** 复用回调:传入处方 id */
onReuse?: (prescriptionId: number) => void | Promise<void>;
/** 点击处方号打开详情 */
onDetail?: (prescriptionId: number) => void;
/** 是否允许撤回(线下接诊) */
allowWithdraw?: boolean;
/** 撤回成功后刷新 */
@@ -140,6 +142,17 @@ async function handleReuse(row: Record<string, any>) {
}
}
/** 点击处方号打开处方详情弹窗 */
function handleOpenDetail(row: Record<string, any>) {
const id = Number(row.id || 0);
if (!id) return;
if (typeof meta.value.onDetail === 'function') {
meta.value.onDetail(id);
return;
}
message.warning('暂无法打开处方详情');
}
async function handleWithdraw(row: Record<string, any>) {
const prescriptionId = Number(row.id || 0);
const registerId = Number(row.register_id || meta.value.registerId || 0);
@@ -217,7 +230,13 @@ defineExpose({ drawerApi });
>
<div class="rx-history-main">
<div class="rx-history-title">
<span class="font-medium">{{ row.prescription_no || `#${row.id}` }}</span>
<a
class="cursor-pointer font-medium text-primary hover:underline"
title="查看处方详情"
@click.prevent="handleOpenDetail(row)"
>
{{ row.prescription_no || `#${row.id}` }}
</a>
<Tag class="ml-2">{{ typeLabel(row.prescription_type) }}</Tag>
<Tag>{{ categoryLabel(row.category) }}</Tag>
<Tag :color="payColor(row)">{{ row.order_pay_text || '—' }}</Tag>

View File

@@ -53,6 +53,7 @@ import CommonDxOrderChips from '#/views/doctor/medical-record/components/CommonD
import { saveMedicalRecord } from '#/views/doctor/medical-record/api';
import {
clearMedicalRecordDraft,
loadMedicalRecordDraft,
loadRxMrTab,
saveRxMrTab,
} from '#/views/doctor/medical-record/utils/localDraft';
@@ -86,6 +87,7 @@ import RefusalOfTreatmentModal
from "#/views/doctor/doctor-reception/components/RefusalOfTreatmentModal.vue";
// 常用方选择弹窗组件
import CommonPrescriptionModal from './components/CommonPrescriptionModal.vue';
import GoldenFormulaModal from './components/GoldenFormulaModal.vue';
import AiPrescriptionDrawer from './components/AiPrescriptionDrawer.vue';
// 确认模态框组件
import ConfirmModal from './components/ConfirmModal.vue';
@@ -181,6 +183,8 @@ const canUseMedicalRecord = computed(() => hasVipPermission('medical_record'));
const canUseAiPrescription = computed(() => hasVipPermission('ai_prescription'));
/** AI 写病历 VIP */
const canUseAiMedicalRecord = computed(() => hasVipPermission('ai_medical_record'));
/** 金方导入 VIP */
const canUseGoldenFormula = computed(() => hasVipPermission('golden_formula'));
watch(rxMrTab, (tab) => {
const rid = getRegisterId();
@@ -1287,11 +1291,15 @@ const [CommonPrescriptionModals, CommonPrescriptionModalApi] = useVbenModal({
connectedComponent: CommonPrescriptionModal,
});
/** 金方导入弹窗 */
const [GoldenFormulaModals, GoldenFormulaModalApi] = useVbenModal({
connectedComponent: GoldenFormulaModal,
});
// ==================== 确认模态框 ====================
const [ConfirmModalComponent, confirmModalApi] = useVbenModal({
connectedComponent: ConfirmModal,
});
// ==================== 信息提示模态框 ====================
const [InfoModalComponent, infoModalApi] = useVbenModal({
connectedComponent: InfoModal,
@@ -1309,6 +1317,33 @@ function openCommonPrescriptionModal() {
CommonPrescriptionModalApi.open();
}
/**
* 打开金方导入弹窗VIPgolden_formula
*/
function openGoldenFormulaModal() {
if (!canUseGoldenFormula.value) {
message.warning('当前门店未开通金方 VIP 功能');
return;
}
if (guardSpecialPrescriptionCartEdit()) return;
const registerId = Number.parseInt(
localStorage.getItem(`doctorReception-id`) || '0',
);
GoldenFormulaModalApi.setData({
storeId: Number(myStoreId.value || 0),
registerId,
category: activeCategory.value,
});
GoldenFormulaModalApi.open();
}
/**
* 金方确认导入:复用 AI 出方写入购物车逻辑
*/
function handleImportGoldenFormula(payload: { rows?: any[]; formulaName?: string }) {
handleImportAiPrescription({ rows: payload?.rows || [] });
}
const aiPrescriptionDrawerRef = ref<InstanceType<typeof AiPrescriptionDrawer> | null>(null);
/** 打开 AI 给处方抽屉:先确认患者与主诉,再生成预览 */
@@ -1329,7 +1364,16 @@ function openAiPrescriptionDrawer() {
message.warning('请先选择处方类型');
return;
}
const payload = medicalRecordPanelRef.value?.getPayload?.() || {};
// 病历面板用 v-show 保活;仍兜底合并本地草稿,避免偶发空表单
let payload: Record<string, any> =
medicalRecordPanelRef.value?.getPayload?.() || {};
const draft = loadMedicalRecordDraft(registerId);
if (draft && typeof draft === 'object') {
payload = { ...draft, ...payload };
}
// 诊断/医嘱以处方侧当前值为准(与病历双向绑定)
if (diagnosis.value) payload.diagnosis = diagnosis.value;
if (medicalAdvice.value) payload.doctor_order = medicalAdvice.value;
aiPrescriptionDrawerRef.value?.open({
registerId,
storeId: Number(myStoreId.value || 0) || undefined,
@@ -1338,7 +1382,6 @@ function openAiPrescriptionDrawer() {
patientSex: Number(activePatient.value?.sex || 0),
patientAge: Number(activePatient.value?.age || 0),
chiefComplaint: String(payload.chief_complaint || ''),
// 带入当前病历表单(含未保存内容),出方时综合参考而非只看主诉
medicalRecord: { ...payload },
});
}
@@ -1354,10 +1397,14 @@ function handleSyncAiMedicalRecord(partial: Record<string, any>) {
* 将 AI 对照结果覆盖导入当前处方(非追加)
* 中药会同步剂数/每日次数,并按 usage 匹配先煎后下 way_id
*/
function handleImportAiPrescription(payload: {
async function handleImportAiPrescription(payload: {
rows?: any[];
dosage?: number;
day_dosage?: number;
rule_type?: number;
process_rule_id?: number;
child_process_rule_id?: number;
process_rule_note_id?: number;
}) {
if (guardSpecialPrescriptionCartEdit()) return;
const list = Array.isArray(payload?.rows) ? payload.rows : [];
@@ -1374,7 +1421,27 @@ function handleImportAiPrescription(payload: {
);
return found ? Number(found.id || 0) : 0;
};
const doOverwrite = () => {
/** AI 返回委托调剂时,同步 ruleType 并级联加载制剂/煎法/备注选项 */
const applyAiProcessRules = async () => {
if (activeCategory.value !== 1) return;
const prId = Number(payload.process_rule_id || 0);
if (prId <= 0 && payload.rule_type !== 2) return;
ruleType.value = 2;
if (prId > 0) {
processRuleId.value = prId;
await loadProcessRuleData(prId, 0);
}
const childId = Number(payload.child_process_rule_id || 0);
if (childId > 0) {
childProcessRuleId.value = childId;
await loadProcessRuleData(0, childId);
}
const noteId = Number(payload.process_rule_note_id || 0);
if (noteId > 0) {
processRuleNoteId.value = noteId;
}
};
const doOverwrite = async () => {
clearAppliedSpecialPrescription();
const next: any[] = [];
for (const row of list) {
@@ -1455,6 +1522,7 @@ function handleImportAiPrescription(payload: {
if (payload.day_dosage != null && Number(payload.day_dosage) > 0) {
dayDosage.value = Number(payload.day_dosage);
}
await applyAiProcessRules();
}
updateLocalStorage();
message.success(`已覆盖导入 ${next.length} 个药品`);
@@ -1469,7 +1537,7 @@ function handleImportAiPrescription(payload: {
});
return;
}
doOverwrite();
await doOverwrite();
}
/** 未对照药名:提示手动搜索 */
@@ -2278,6 +2346,8 @@ function openPatientRxHistoryDrawer() {
patientName: activePatient.value?.name,
allowWithdraw: true,
onReuse: reusePrescriptionFromList,
// 点击处方号打开详情(与医生名片/患者管理一致)
onDetail: (prescriptionId: number) => openPrescriptionDetail(prescriptionId),
onWithdrawn: () => {
if (selectPatientId.value) {
getPatientItem(selectPatientId.value).then((value) => {
@@ -3165,6 +3235,15 @@ function onStoreSelectOpenChange(open: boolean) {
<BookOutlined />
选择常用方
</Button>
<Button
v-if="!isSpecialPrescriptionCartLocked && canUseGoldenFormula"
type="link"
size="small"
@click="openGoldenFormulaModal"
>
<MedicineBoxOutlined />
导入金方
</Button>
<Button
v-if="!isSpecialPrescriptionCartLocked && canUseAiPrescription"
type="link"
@@ -3854,7 +3933,7 @@ function onStoreSelectOpenChange(open: boolean) {
:child-process-rule-list="childProcessRuleList"
:process-rule-note-list="processRuleNoteList"
:disabled="isSpecialPrescriptionCartLocked"
class="mb-5"
class="mb-5 p-3"
@change="handleChineseConfigChange"
/>
<div class="mb-2">
@@ -3968,8 +4047,10 @@ function onStoreSelectOpenChange(open: boolean) {
</div>
</div>
<!-- VIP 开通才挂载Tab v-show 保活避免处方态 AI 出方 getPayload 拿不到病历 -->
<div
v-if="canUseMedicalRecord && rxMrTab === 'mr'"
v-if="canUseMedicalRecord"
v-show="rxMrTab === 'mr'"
class="medical-record-wrap"
>
<MedicalRecordPanel
@@ -4004,6 +4085,7 @@ function onStoreSelectOpenChange(open: boolean) {
<PrescriptionDetailModal/>
<!-- 常用方选择弹窗 -->
<CommonPrescriptionModals/>
<GoldenFormulaModals @import="handleImportGoldenFormula" />
<AiPrescriptionDrawer
ref="aiPrescriptionDrawerRef"
@import="handleImportAiPrescription"

View File

@@ -610,6 +610,7 @@ function handleAiGenerate() {
patientSex: Number(props.patientSex || 0),
patientAge: Number(props.patientAge || 0),
chiefComplaint: String(form.chief_complaint || ''),
medicalRecord: { ...form },
});
}

View File

@@ -84,11 +84,12 @@ export async function getMedicalRecordListByPatient(data: {
return requestClient.get<any>(`${prefix}list-by-patient`, { params: data });
}
/** AI 写病历(需先填主诉;返回草稿字段) */
/** AI 写病历(需先填主诉;可选传 medical_record 上下文字段) */
export async function aiGenerateMedicalRecord(data: {
register_id: number;
store_id?: number;
chief_complaint?: string;
medical_record?: Record<string, any>;
}) {
return requestClient.post<any>(`${prefix}ai-generate-medical-record`, data);
}

View File

@@ -8,7 +8,7 @@
import { computed, ref } from 'vue';
import { useVbenDrawer, useVbenModal } from '@vben/common-ui';
import { Button, Descriptions, Input, message } from 'ant-design-vue';
import { Button, Descriptions, Input, Spin, message } from 'ant-design-vue';
import {
aiGenerateMedicalRecord,
@@ -16,6 +16,8 @@ import {
aiListGenerations,
} from '../api';
import { MEDICAL_RECORD_FIELD_OPTIONS } from '../config/constants';
import AiDisclaimerBanner from '#/views/doctor/components/AiDisclaimerBanner.vue';
import { ensureAiFeatureConsent } from '#/views/doctor/utils/aiFeatureGate';
import PrescriptionLoading from '#/components/loading/PrescriptionLoading.vue';
/** Descriptions 通栏字段 */
@@ -27,7 +29,7 @@ const FULL_SPAN_KEYS = new Set([
'physical_exam',
'chief_complaint',
]);
/** 预览字段顺序(不含主诉) */
/** 预览字段顺序(不含主诉;与后端 MR_OUTPUT_FIELDS 对齐,含医嘱 */
const PREVIEW_FIELDS = [
{ label: '现病史', value: 'present_illness' },
{ label: '舌象', value: 'tongue' },
@@ -38,6 +40,7 @@ const PREVIEW_FIELDS = [
{ label: '中医治法', value: 'tcm_method' },
{ label: '临床诊断', value: 'diagnosis' },
{ label: '治疗意见', value: 'treatment_advice' },
{ label: '医嘱', value: 'doctor_order' },
...MEDICAL_RECORD_FIELD_OPTIONS.filter((f) =>
[
'family_history',
@@ -53,6 +56,27 @@ const PREVIEW_FIELDS = [
),
];
/** 生成前可选填(非必填),传给 AI 作上下文;有值则优先保留 */
const GEN_OPTIONAL_FIELDS = [
{ label: '现病史', value: 'present_illness' },
{ label: '既往史', value: 'past_history' },
{ label: '过敏史', value: 'allergy_history' },
{ label: '月经史', value: 'menstrual_history' },
{ label: '家族史', value: 'family_history' },
{ label: '婚育史', value: 'marital_history' },
{ label: '个人史', value: 'personal_history' },
{ label: '体征检查', value: 'physical_exam' },
{ label: '辅助检查', value: 'auxiliary_exam' },
{ label: '舌象', value: 'tongue' },
{ label: '脉象', value: 'pulse' },
];
function emptyGenContext(): Record<string, string> {
const o: Record<string, string> = {};
for (const f of GEN_OPTIONAL_FIELDS) o[f.value] = '';
return o;
}
const emit = defineEmits<{
(
e: 'import',
@@ -66,6 +90,8 @@ const patientName = ref('');
const patientSex = ref(0);
const patientAge = ref(0);
const chiefComplaint = ref('');
/** 生成弹窗可选上下文字段 */
const genContext = ref<Record<string, string>>(emptyGenContext());
const histLoading = ref(false);
const generating = ref(false);
const detailLoading = ref(false);
@@ -73,6 +99,10 @@ const historyList = ref<any[]>([]);
const activeId = ref(0);
const previewFields = ref<Record<string, string>>({});
const previewChief = ref('');
/** 最近一次生成耗时(毫秒) */
const lastDurationMs = ref(0);
/** 醒目提示文案(系统配置 / 门闸下发) */
const disclaimerText = ref('');
const sexLabel = computed(() => {
const s = Number(patientSex.value);
@@ -96,7 +126,7 @@ const [Drawer, drawerApi] = useVbenDrawer({
showCancelButton: false,
showConfirmButton: false,
contentClass: '!flex !flex-col !overflow-hidden',
onOpenChange(isOpen) {
async onOpenChange(isOpen) {
if (!isOpen) {
resetState();
return;
@@ -108,6 +138,7 @@ const [Drawer, drawerApi] = useVbenDrawer({
patientSex?: number;
patientAge?: number;
chiefComplaint?: string;
medicalRecord?: Record<string, any>;
}>();
registerId.value = Number(data?.registerId || 0);
storeId.value = Number(data?.storeId || 0);
@@ -115,16 +146,39 @@ const [Drawer, drawerApi] = useVbenDrawer({
patientSex.value = Number(data?.patientSex || 0);
patientAge.value = Number(data?.patientAge || 0);
chiefComplaint.value = String(data?.chiefComplaint || '');
// 从当前病历表单预填可选字段
const nextCtx = emptyGenContext();
const snap = data?.medicalRecord || {};
for (const f of GEN_OPTIONAL_FIELDS) {
nextCtx[f.value] = String(snap[f.value] ?? '').trim();
}
genContext.value = nextCtx;
previewFields.value = {};
previewChief.value = '';
activeId.value = 0;
try {
const gate = await ensureAiFeatureConsent({
featureCode: 'ai_medical_record',
storeId: storeId.value || undefined,
});
if (!gate) {
drawerApi.close();
return;
}
disclaimerText.value = gate.disclaimer_text || '';
} catch (e: any) {
message.error(e?.message || e?.msg || '无法校验 AI 使用规范');
drawerApi.close();
return;
}
void bootstrapHistory();
},
});
const [GenModal, genModalApi] = useVbenModal({
title: '生成病历',
class: 'w-[560px]',
// 与 AI 出方生成弹窗同宽,便于选填字段双列展示
class: 'w-[920px]',
confirmText: '开始生成',
cancelText: '取消',
onConfirm: async () => confirmGenerateAndClose(),
@@ -138,7 +192,10 @@ function resetState() {
activeId.value = 0;
previewFields.value = {};
previewChief.value = '';
lastDurationMs.value = 0;
chiefComplaint.value = '';
genContext.value = emptyGenContext();
disclaimerText.value = '';
genModalApi.close();
}
@@ -150,6 +207,15 @@ function formatTime(ts: number) {
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
/** 毫秒转可读耗时850ms / 1.2s */
function formatDurationMs(ms: number | undefined | null) {
const n = Number(ms || 0);
if (!n) return '';
if (n < 1000) return `${Math.round(n)}ms`;
const sec = n / 1000;
return sec >= 10 ? `${Math.round(sec)}s` : `${sec.toFixed(1)}s`;
}
async function loadHistory() {
histLoading.value = true;
try {
@@ -194,6 +260,7 @@ async function onSelectHistory(row: any) {
previewChief.value = String(
snap.chief_complaint || chiefComplaint.value || '',
).trim();
lastDurationMs.value = Number(row.duration_ms || 0);
} catch (e: any) {
previewFields.value = {};
previewChief.value = '';
@@ -224,16 +291,31 @@ async function runGenerateInDrawer(chief: string) {
generating.value = true;
previewFields.value = {};
previewChief.value = chief;
lastDurationMs.value = 0;
try {
const data = await aiGenerateMedicalRecord({
register_id: registerId.value,
store_id: storeId.value || undefined,
chief_complaint: chief,
medical_record: { ...genContext.value },
});
const durationText = formatDurationMs(data?.duration_ms);
lastDurationMs.value = Number(data?.duration_ms || 0);
// 业务软失败HTTP 成功但 ok=false警告提示而非 error
if (data?.ok === false) {
if (data?.generation_id) {
activeId.value = Number(data.generation_id);
await loadHistory();
}
message.warning(String(data?.message || '生成未成功'));
return;
}
previewFields.value = (data?.fields || {}) as Record<string, string>;
await loadHistory();
activeId.value = Number(data?.generation_id || 0);
message.success('已生成,请预览后确认导入');
message.success(
durationText ? `已生成(耗时 ${durationText}` : '已生成,请预览后确认导入',
);
} catch (e: any) {
message.error(e?.message || e?.msg || 'AI写病历失败');
} finally {
@@ -261,6 +343,7 @@ defineExpose({
patientSex?: number;
patientAge?: number;
chiefComplaint?: string;
medicalRecord?: Record<string, any>;
}) {
drawerApi.setData(payload);
drawerApi.open();
@@ -274,6 +357,7 @@ defineExpose({
<div class="shrink-0 text-sm text-muted-foreground">
{{ patientName || '—' }} · {{ sexLabel }} · {{ patientAge || '—' }}
</div>
<AiDisclaimerBanner class="shrink-0" :text="disclaimerText" />
<div class="flex min-h-0 flex-1 gap-3 overflow-hidden">
<div class="w-44 shrink-0 overflow-y-auto border-r border-border pr-2">
<div class="mb-2 text-sm font-medium">历史记录</div>
@@ -295,29 +379,45 @@ defineExpose({
</div>
<div class="mt-0.5 text-muted-foreground">
{{ formatTime(row.created_at) }}
<span v-if="row.duration_ms"> · {{ formatDurationMs(row.duration_ms) }}</span>
</div>
</div>
</div>
<div class="min-h-0 min-w-0 flex-1 overflow-y-auto pl-1 pb-2">
<!-- AI 专属动画仅真正调用生成接口时展示历史详情加载用普通 Spin -->
<div
v-if="generating || detailLoading"
v-if="generating"
class="flex h-full min-h-[260px] items-center justify-center"
>
<PrescriptionLoading
type="medical_record"
:text="
generating ? 'AI正在生成病历' : 'AI正在加载病历'
"
text="AI正在生成病历"
class="h-[280px] w-full max-w-[420px]"
/>
</div>
<div
v-else-if="detailLoading"
class="flex h-full min-h-[200px] flex-col items-center justify-center gap-2 text-muted-foreground"
>
<Spin />
<span class="text-sm">加载历史病历</span>
</div>
<template v-else>
<div
v-if="!previewRows.length && !previewChief"
v-if="!previewRows.length && !previewChief && !lastDurationMs"
class="py-8 text-center text-muted-foreground"
>
请选择左侧历史或点击生成病历
</div>
<div
v-else-if="!previewRows.length && !previewChief && lastDurationMs"
class="py-8 text-center text-muted-foreground"
>
本次生成未产出病历内容
<span v-if="formatDurationMs(lastDurationMs)">
耗时 {{ formatDurationMs(lastDurationMs) }}
</span>
</div>
<template v-else>
<Descriptions
v-if="previewChief || previewRows.length"
@@ -335,6 +435,12 @@ defineExpose({
{{ previewChief }}
</div>
</Descriptions.Item>
<Descriptions.Item
v-if="lastDurationMs"
label="生成耗时"
>
{{ formatDurationMs(lastDurationMs) }}
</Descriptions.Item>
<Descriptions.Item
v-for="row in previewRows"
:key="row.code"
@@ -370,23 +476,41 @@ defineExpose({
</div>
</Drawer>
<!-- 二级 AI 出方一致 Descriptions 双列紧凑表单 -->
<GenModal>
<div class="space-y-3 text-sm text-foreground">
<div class="rounded-lg border border-border bg-muted/40 p-3">
<div class="mb-1 font-medium">患者信息确认</div>
<div class="text-muted-foreground">
姓名{{ patientName || '—' }} 性别{{ sexLabel }} 年龄{{
patientAge || '—'
}}
</div>
<div class="space-y-2 text-sm text-foreground">
<AiDisclaimerBanner :text="disclaimerText" />
<div class="text-xs text-muted-foreground">
{{ patientName || '—' }} · {{ sexLabel }} · {{ patientAge || '—' }} ·
主诉必填下方选填有内容会作为 AI 参考若该字段在病历配置中需生成则保留原文
</div>
<div>
<div class="mb-2 font-medium">主诉可编辑</div>
<Input.TextArea
v-model:value="chiefComplaint"
:rows="4"
placeholder="请确认或补充主诉后再生成"
/>
<div class="max-h-[58vh] overflow-y-auto">
<Descriptions
:column="2"
bordered
size="small"
class="ai-mr-gen-desc"
>
<Descriptions.Item label="主诉" :span="2">
<Input.TextArea
v-model:value="chiefComplaint"
:rows="3"
placeholder="请确认或补充主诉后再生成"
/>
</Descriptions.Item>
<Descriptions.Item
v-for="f in GEN_OPTIONAL_FIELDS"
:key="f.value"
:label="f.label"
:span="1"
>
<Input.TextArea
v-model:value="genContext[f.value]"
:rows="2"
:placeholder="`选填${f.label}`"
/>
</Descriptions.Item>
</Descriptions>
</div>
<div class="text-xs text-muted-foreground">
点击开始生成后将关闭本弹窗请在抽屉中等待结果
@@ -396,11 +520,13 @@ defineExpose({
</template>
<style scoped>
.ai-mr-preview-desc :deep(.ant-descriptions-item-label) {
.ai-mr-preview-desc :deep(.ant-descriptions-item-label),
.ai-mr-gen-desc :deep(.ant-descriptions-item-label) {
width: 88px;
white-space: nowrap;
}
.ai-mr-preview-desc :deep(.ant-descriptions-item-content) {
.ai-mr-preview-desc :deep(.ant-descriptions-item-content),
.ai-mr-gen-desc :deep(.ant-descriptions-item-content) {
font-size: 12px;
}
</style>

View File

@@ -0,0 +1,101 @@
/**
* AI 功能门闸:拉取醒目提示 + 首次知情同意
* 同意按 VIP 功能码隔离,签署后该功能不再弹
*/
import { h } from 'vue';
import { Modal, message } from 'ant-design-vue';
import { requestClient } from '#/api/request';
export type AiFeatureCode = 'ai_medical_record' | 'ai_prescription';
export interface AiFeatureGate {
id: number;
feature_code: string;
record_label: string;
disclaimer_text: string;
consent_text: string;
consented: boolean;
}
/** 拉取门闸信息 */
export async function fetchAiFeatureGate(
featureCode: AiFeatureCode,
): Promise<AiFeatureGate> {
const res = await requestClient.get<any>('doctor-reception/ai-feature-gate', {
params: { feature_code: featureCode },
});
const data = res?.data || res || {};
return {
id: Number(data.id || 0),
feature_code: String(data.feature_code || featureCode),
record_label: String(data.record_label || ''),
disclaimer_text: String(data.disclaimer_text || ''),
consent_text: String(data.consent_text || ''),
consented: !!data.consented,
};
}
/** 签署知情同意 */
export async function agreeAiFeatureConsent(
featureCode: AiFeatureCode,
storeId?: number,
): Promise<void> {
await requestClient.post<any>('doctor-reception/ai-agree-consent', {
feature_code: featureCode,
store_id: storeId || 0,
});
}
/**
* 打开 AI 功能前:未签署则弹子模态框;拒绝则返回 null
*/
export async function ensureAiFeatureConsent(options: {
featureCode: AiFeatureCode;
storeId?: number;
}): Promise<AiFeatureGate | null> {
const gate = await fetchAiFeatureGate(options.featureCode);
if (gate.consented) {
return gate;
}
const ok = await new Promise<boolean>((resolve) => {
Modal.confirm({
title: 'AI 使用知情同意',
width: 560,
centered: true,
okText: '我已知悉并同意',
cancelText: '取消',
content: () =>
h(
'div',
{
style: {
whiteSpace: 'pre-wrap',
maxHeight: '50vh',
overflow: 'auto',
lineHeight: '1.6',
fontSize: '13px',
},
},
gate.consent_text || '请先阅读并同意 AI 使用规范',
),
onOk: async () => {
try {
await agreeAiFeatureConsent(options.featureCode, options.storeId);
message.success('已记录知情同意');
resolve(true);
} catch (e: any) {
message.error(e?.message || e?.msg || '签署失败');
resolve(false);
return Promise.reject(e);
}
},
onCancel: () => resolve(false),
});
});
if (!ok) {
return null;
}
return { ...gate, consented: true };
}

View File

@@ -0,0 +1,33 @@
import { requestClient } from '#/api/request';
/** AI API Key 管理 API */
const prefix = 'ai-api-key/';
export async function getAiApiKeyList(data: any) {
return requestClient.get<any>(`${prefix}list`, { params: data });
}
export async function getAiApiKeyDetail(id: number) {
return requestClient.get<any>(`${prefix}detail`, { params: { id } });
}
export async function getAiApiKeyOption(data?: { platform_id?: number }) {
return requestClient.get<any>(`${prefix}option`, { params: data || {} });
}
export async function createAiApiKey(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}create`, data);
}
export async function updateAiApiKey(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}update`, data);
}
export async function deleteAiApiKey(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}delete`, data);
}
/** 设为平台默认密钥 */
export async function setAiApiKeyDefault(data: { id: number }) {
return requestClient.post<any>(`${prefix}set-default`, data);
}

View File

@@ -0,0 +1,238 @@
<script lang="ts" setup>
/**
* AI API Key 新增/编辑
* 所属平台用带 Logo 的卡片单选(与系统配置 AI 卡片一致),敏感字段 api_key 走传输加密
*/
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Radio, message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { getAiPlatformConfigOptions } from '../../platform/api';
import {
createAiApiKey,
getAiApiKeyDetail,
updateAiApiKey,
} from '../api';
import { modalFormProps } from '../config/form';
interface PlatformCard {
id: number;
code: string;
name: string;
logo: string;
description: string;
default_model: string;
}
const isUpdate = ref(false);
const gridApi = ref();
const onSuccess = ref<null | (() => void)>(null);
const lockPlatformId = ref(false);
const platforms = ref<PlatformCard[]>([]);
const selectedPlatformId = ref<number | undefined>(undefined);
const [Form, formApi] = useVbenForm(modalFormProps);
/** 点选平台卡片:同步到隐藏字段 platform_id */
function onSelectPlatform(id: number) {
if (lockPlatformId.value) return;
selectedPlatformId.value = id;
formApi.setValues({ platform_id: id });
}
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
draggable: true,
onCancel() {
modalApi.close();
},
onConfirm: async () => {
if (!selectedPlatformId.value) {
message.warning('请选择所属 AI 平台');
return;
}
formApi.setValues({ platform_id: selectedPlatformId.value });
const e = await formApi.validate();
if (!e.valid) return;
const values = await formApi.getValues();
values.platform_id = selectedPlatformId.value;
// 编辑时允许不改密钥:空串不传
if (isUpdate.value && !String(values.api_key || '').trim()) {
delete values.api_key;
} else if (!isUpdate.value && !String(values.api_key || '').trim()) {
message.warning('请填写 API Key');
return;
}
modalApi.setState({ confirmLoading: true });
try {
const api = isUpdate.value ? updateAiApiKey : createAiApiKey;
await api(values);
message.success('保存成功');
gridApi.value?.query?.();
onSuccess.value?.();
modalApi.close();
} finally {
modalApi.setState({ confirmLoading: false });
}
},
async onOpenChange(isOpen: boolean) {
const openData = isOpen ? modalApi.getData<Record<string, any>>() || {} : {};
gridApi.value = isOpen ? openData.gridApi : null;
onSuccess.value = isOpen
? (typeof openData.onSuccess === 'function' ? openData.onSuccess : null)
: null;
lockPlatformId.value = isOpen ? !!openData.lockPlatformId : false;
if (!isOpen) {
formApi.resetFields();
selectedPlatformId.value = undefined;
platforms.value = [];
return;
}
// 拉取启用中的平台卡片(含 logo
try {
const res = await getAiPlatformConfigOptions();
const data = res?.data || res || {};
platforms.value = Array.isArray(data.platforms) ? data.platforms : [];
} catch {
platforms.value = [];
}
const { values, update } = openData;
isUpdate.value = !!update;
if (values && update && values.id) {
try {
const detail = await getAiApiKeyDetail(values.id);
const row = detail?.data || detail || {};
const pid = Number(row.platform_id || 0) || undefined;
selectedPlatformId.value = pid;
formApi.setValues({
...row,
platform_id: pid,
api_key: row.api_key || '',
});
} catch {
const pid = Number(values.platform_id || 0) || undefined;
selectedPlatformId.value = pid;
formApi.setValues({ ...values, platform_id: pid, api_key: '' });
}
} else {
formApi.resetFields();
const presetPid = Number(openData.platform_id || values?.platform_id || 0) || undefined;
const firstId = presetPid || platforms.value[0]?.id;
selectedPlatformId.value = firstId;
formApi.setValues({
sort: 0,
status: 1,
api_key: '',
platform_id: firstId,
is_default: 0,
});
}
},
});
</script>
<template>
<Modal :title="`${isUpdate ? '编辑' : '新增'}AI密钥`" class="w-[720px]">
<div v-if="!lockPlatformId" class="ai-key-modal mb-4">
<div class="mb-2 text-sm font-medium text-[hsl(var(--foreground))]">
所属平台
</div>
<Radio.Group
:value="selectedPlatformId"
class="w-full"
@update:value="(v: number) => onSelectPlatform(v)"
>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<label
v-for="p in platforms"
:key="p.id"
class="ai-plat-card"
:class="{ 'ai-plat-card--active': selectedPlatformId === p.id }"
@click.prevent="onSelectPlatform(p.id)"
>
<Radio :value="p.id" class="!hidden" />
<div class="flex items-start gap-3">
<div class="ai-plat-logo">
<img
v-if="p.logo"
:src="p.logo"
:alt="p.name"
class="h-full w-full object-contain"
/>
<span v-else class="ai-plat-logo-fallback">
{{ (p.name || p.code || '?').slice(0, 1) }}
</span>
</div>
<div class="min-w-0 flex-1">
<div class="font-medium text-[hsl(var(--foreground))]">
{{ p.name }}
</div>
<div class="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 || '' }}
</div>
</div>
</div>
</label>
</div>
</Radio.Group>
<div
v-if="!platforms.length"
class="mt-2 text-xs text-amber-600 dark:text-amber-400"
>
暂无启用中的 AI 平台请先到AI平台管理添加
</div>
</div>
<Form />
</Modal>
</template>
<style scoped>
.ai-plat-card {
display: block;
cursor: pointer;
border: 1px solid hsl(var(--border));
border-radius: 10px;
padding: 12px 14px;
background: hsl(var(--card));
color: hsl(var(--foreground));
transition:
border-color 0.15s ease,
box-shadow 0.15s ease,
background-color 0.15s ease;
}
.ai-plat-card:hover {
border-color: hsl(var(--primary) / 0.55);
}
.ai-plat-card--active {
border-color: hsl(var(--primary));
box-shadow: 0 0 0 2px hsl(var(--primary) / 0.22);
background: hsl(var(--primary) / 0.1);
}
.ai-plat-logo {
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.55);
padding: 5px;
}
.ai-plat-logo-fallback {
font-size: 15px;
font-weight: 600;
color: hsl(var(--muted-foreground));
}
</style>

View File

@@ -0,0 +1,2 @@
/** 复用平台状态选项 */
export { STATUS_OPTIONS } from '../../platform/config/constants';

View File

@@ -0,0 +1,84 @@
import type { VbenFormProps } from '#/adapter/form';
import { STATUS_OPTIONS } from './constants';
/**
* AI 密钥新增/编辑表单
* 所属平台在弹窗外用卡片单选,不走下拉
*/
export const modalFormProps: VbenFormProps = {
wrapperClass: 'grid-cols-12',
commonConfig: {
formItemClass: 'col-span-12',
componentProps: { class: 'w-full' },
},
layout: 'horizontal',
schema: [
{
component: 'VbenInput',
fieldName: 'id',
label: 'ID',
dependencies: { show: false, triggerFields: ['id'] },
},
// platform_id 由上方卡片单选写入,隐藏字段仅用于提交
{
component: 'VbenInput',
fieldName: 'platform_id',
label: '所属平台',
dependencies: { show: false, triggerFields: ['platform_id'] },
},
{
component: 'VbenInput',
fieldName: 'name',
label: '密钥别名',
rules: 'required',
formItemClass: 'col-span-6',
componentProps: { placeholder: '如:生产环境主密钥' },
},
{
component: 'VbenInputPassword',
fieldName: 'api_key',
label: 'API Key',
help: '新增必填;编辑留空表示不修改。库内 AES 加密,传输亦加密',
componentProps: { placeholder: 'Bearer 用的密钥明文' },
defaultValue: '',
},
{
component: 'VbenSelect',
fieldName: 'status',
label: '状态',
formItemClass: 'col-span-3',
componentProps: { options: STATUS_OPTIONS },
defaultValue: 1,
},
{
component: 'InputNumber',
fieldName: 'sort',
label: '排序',
formItemClass: 'col-span-3',
componentProps: { min: 0 },
defaultValue: 0,
},
{
component: 'VbenSelect',
fieldName: 'is_default',
label: '设为默认',
formItemClass: 'col-span-6',
componentProps: {
options: [
{ label: '否', value: 0 },
{ label: '是', value: 1 },
],
},
defaultValue: 0,
},
{
component: 'VbenInput',
fieldName: 'remark',
label: '备注',
componentProps: { type: 'textarea', rows: 2 },
defaultValue: '',
},
],
showDefaultActions: false,
};

View File

@@ -0,0 +1,193 @@
<script lang="ts" setup>
/**
* AI API Key 管理:列表脱敏,详情/编辑走传输加密
*/
import type { VxeGridListeners } from '#/adapter/vxe-table';
import { onMounted, ref } from 'vue';
import { Page, useVbenModal } from '@vben/common-ui';
import { Tag, message } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { TableAction } from '#/components/table-action';
import { getAiPlatformOption } from '../platform/api';
import { deleteAiApiKey, getAiApiKeyList } from './api';
import FormModal from './components/modal.vue';
import { STATUS_OPTIONS } from './config/constants';
defineOptions({ name: 'AiApiKey' });
const hasTopTableDropDownActions = ref(false);
const platformOptions = ref<{ label: string; value: number }[]>([]);
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: [
{
component: 'VbenSelect',
fieldName: 'platform_id',
label: '平台',
componentProps: {
allowClear: true,
options: platformOptions,
placeholder: '全部平台',
},
},
{
component: 'VbenInput',
fieldName: 'name',
label: '别名',
componentProps: { placeholder: '密钥别名' },
},
{
component: 'VbenSelect',
fieldName: 'status',
label: '状态',
componentProps: { allowClear: true, options: STATUS_OPTIONS },
},
],
},
gridOptions: {
checkboxConfig: { highlight: true, labelField: '' },
columns: [
{ type: 'checkbox', width: 60 },
{ field: 'id', title: 'ID', width: 80 },
{ field: 'platform_name', title: '平台', minWidth: 120 },
{ field: 'platform_code', title: '编码', width: 100 },
{ field: 'name', title: '别名', minWidth: 140 },
{ field: 'api_key_mask', title: '密钥', minWidth: 160 },
{ field: 'status', title: '状态', width: 90, slots: { default: 'status' } },
{ field: 'sort', title: '排序', width: 80 },
{ field: 'created_at', title: '创建时间', width: 170 },
{ title: '操作', slots: { default: 'action' }, width: 160, fixed: 'right' },
],
keepSource: true,
pagerConfig: {},
proxyConfig: {
ajax: {
query: async ({ page }: any, formValues: any) => {
return await getAiApiKeyList({
page: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
height: 'auto',
toolbarConfig: {
search: true,
refresh: true,
slots: { buttons: 'toolbar-buttons' },
},
},
gridEvents: {
checkboxChange() {
hasTopTableDropDownActions.value =
gridApi.grid.getCheckboxRecords().length > 0;
},
checkboxAll() {
hasTopTableDropDownActions.value =
gridApi.grid.getCheckboxRecords().length > 0;
},
} as VxeGridListeners<any>,
});
const [FormModalComp, formModalApi] = useVbenModal({
connectedComponent: FormModal,
});
const showModal = (data: any = {}, isUpdate = false) => {
formModalApi.setData({
values: data,
update: isUpdate,
gridApi,
});
formModalApi.open();
};
const handleDelete = async (ids: number[]) => {
await deleteAiApiKey({ ids });
message.success('删除成功');
gridApi.query();
};
onMounted(async () => {
try {
const opts = await getAiPlatformOption();
platformOptions.value = Array.isArray(opts) ? opts : opts?.data || [];
gridApi.formApi?.updateSchema?.([
{
fieldName: 'platform_id',
componentProps: {
allowClear: true,
options: platformOptions.value,
placeholder: '全部平台',
},
},
]);
} catch {
/* ignore */
}
});
</script>
<template>
<Page auto-content-height>
<FormModalComp />
<Grid>
<template #toolbar-buttons>
<TableAction
:actions="[
{
label: '新增',
type: 'primary',
onClick: () => showModal({}, false),
},
]"
:drop-down-actions="
hasTopTableDropDownActions
? [
{
label: '批量删除',
popConfirm: {
title: '确认删除选中项',
confirm: () => {
const rows = gridApi.grid.getCheckboxRecords();
handleDelete(rows.map((r: any) => r.id));
},
},
},
]
: []
"
/>
</template>
<template #status="{ row }">
<Tag :color="row.status === 1 ? 'success' : 'default'">
{{ row.status_txt || (row.status === 1 ? '启用' : '禁用') }}
</Tag>
</template>
<template #action="{ row }">
<TableAction
:actions="[
{
label: '编辑',
onClick: () => showModal(row, true),
},
{
label: '删除',
popConfirm: {
title: '确认删除?',
confirm: () => handleDelete([row.id]),
},
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@@ -0,0 +1,22 @@
import { requestClient } from '#/api/request';
/**
* AI 生成记录管理 API
* 路由前缀 ai-generation/
*/
const prefix = 'ai-generation/';
/** 分页列表 */
export async function getAiGenerationList(data: any) {
return requestClient.get<any>(`${prefix}list`, { params: data });
}
/** 详情 */
export async function getAiGenerationDetail(id: number) {
return requestClient.get<any>(`${prefix}detail`, { params: { id } });
}
/** 用量统计(与 list 相同筛选) */
export async function getAiGenerationUsageStats(data?: Record<string, any>) {
return requestClient.get<any>(`${prefix}usage-stats`, { params: data || {} });
}

View File

@@ -0,0 +1,103 @@
<script lang="ts" setup>
/**
* AI 生成记录详情弹窗
* 展示 input_snapshot / result_json / 错误与耗时,便于排障
*/
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Descriptions, Spin, Tag } from 'ant-design-vue';
import { getAiGenerationDetail } from '../api';
const loading = ref(false);
const detail = ref<Record<string, any>>({});
const [Modal, modalApi] = useVbenModal({
title: '生成记录详情',
class: 'w-[820px]',
showConfirmButton: false,
cancelText: '关闭',
async onOpenChange(isOpen) {
if (!isOpen) {
detail.value = {};
return;
}
const data = modalApi.getData<{ id: number }>();
const id = Number(data?.id || 0);
if (!id) return;
loading.value = true;
try {
const res = await getAiGenerationDetail(id);
detail.value = res?.data || res || {};
} finally {
loading.value = false;
}
},
});
function formatDuration(ms: number) {
const n = Number(ms || 0);
if (n <= 0) return '—';
if (n < 1000) return `${n} ms`;
return `${(n / 1000).toFixed(1)} s`;
}
function statusColor(status: number) {
if (status === 1) return 'success';
if (status === 2) return 'error';
return 'processing';
}
function prettyJson(v: any) {
try {
return JSON.stringify(v ?? {}, null, 2);
} catch {
return String(v ?? '');
}
}
</script>
<template>
<Modal>
<Spin :spinning="loading">
<Descriptions bordered size="small" :column="2" class="text-sm">
<Descriptions.Item label="ID">{{ detail.id || '—' }}</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag :color="statusColor(Number(detail.status))">
{{ detail.status_txt || detail.status }}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="场景">{{ detail.scene_txt || detail.scene }}</Descriptions.Item>
<Descriptions.Item label="名称">{{ detail.name || '—' }}</Descriptions.Item>
<Descriptions.Item label="供应商">{{ detail.provider || '—' }}</Descriptions.Item>
<Descriptions.Item label="模型">{{ detail.model || '—' }}</Descriptions.Item>
<Descriptions.Item label="平台">
{{ detail.platform_name || detail.platform_code || '—' }}
</Descriptions.Item>
<Descriptions.Item label="密钥">{{ detail.api_key_name || detail.api_key_id || '—' }}</Descriptions.Item>
<Descriptions.Item label="Tokens">
{{ detail.total_tokens || 0 }}
P {{ detail.prompt_tokens || 0 }} / C {{ detail.completion_tokens || 0 }}
</Descriptions.Item>
<Descriptions.Item label="耗时">
{{ formatDuration(Number(detail.duration_ms || 0)) }}
</Descriptions.Item>
<Descriptions.Item label="错误" :span="2">
{{ detail.error_msg || '—' }}
</Descriptions.Item>
<Descriptions.Item label="输入快照" :span="2">
<pre class="max-h-40 overflow-auto whitespace-pre-wrap text-xs">{{
prettyJson(detail.input_snapshot)
}}</pre>
</Descriptions.Item>
<Descriptions.Item label="结果 JSON" :span="2">
<pre class="max-h-56 overflow-auto whitespace-pre-wrap text-xs">{{
prettyJson(detail.result_json)
}}</pre>
</Descriptions.Item>
</Descriptions>
</Spin>
</Modal>
</template>

View File

@@ -0,0 +1,13 @@
/**
* AI 生成记录常量
*/
export const AI_GENERATION_STATUS_OPTIONS = [
{ label: '进行中', value: 0 },
{ label: '成功', value: 1 },
{ label: '失败', value: 2 },
];
export const AI_GENERATION_SCENE_OPTIONS = [
{ label: '写病历', value: 'medical_record' },
{ label: '出方', value: 'prescription' },
];

View File

@@ -0,0 +1,108 @@
import type { VbenFormProps } from '#/adapter/form';
import dayjs from 'dayjs';
import { AI_GENERATION_SCENE_OPTIONS, AI_GENERATION_STATUS_OPTIONS } from './constants';
/**
* AI 生成记录搜索表单
* 时间范围字段 search_time查询前映射为 start_time/end_time 时间戳
*/
export const formOptions: VbenFormProps = {
collapsed: false,
schema: [
{
component: 'VbenInput',
componentProps: {
placeholder: '名称 / 模型 / 错误摘要',
},
defaultValue: '',
fieldName: 'keyword',
label: '关键词',
},
{
component: 'VbenSelect',
componentProps: {
placeholder: '状态',
allowClear: true,
options: AI_GENERATION_STATUS_OPTIONS,
},
defaultValue: undefined,
fieldName: 'status',
label: '状态',
},
{
component: 'VbenSelect',
componentProps: {
placeholder: '场景',
allowClear: true,
options: AI_GENERATION_SCENE_OPTIONS,
},
defaultValue: undefined,
fieldName: 'scene',
label: '场景',
},
{
component: 'VbenInput',
componentProps: {
placeholder: '如 spark / deepseek',
},
defaultValue: '',
fieldName: 'provider',
label: '供应商',
},
{
component: 'VbenInput',
componentProps: {
placeholder: '模型编码',
},
defaultValue: '',
fieldName: 'model',
label: '模型',
},
{
component: 'VbenInput',
componentProps: {
placeholder: '密钥 ID',
},
defaultValue: '',
fieldName: 'api_key_id',
label: '密钥ID',
},
{
component: 'RangePicker',
componentProps: {
format: 'YYYY-MM-DD',
valueFormat: 'YYYY-MM-DD',
},
defaultValue: undefined,
fieldName: 'search_time',
label: '时间范围',
},
],
showCollapseButton: true,
submitButtonOptions: {
content: '查询',
},
submitOnChange: true,
submitOnEnter: false,
};
/**
* 将表单值转为接口参数search_time → start_time/end_time当天起止秒级时间戳
*/
export function normalizeAiGenerationFilters(formValues: Record<string, any> = {}) {
const params: Record<string, any> = { ...formValues };
const range = params.search_time;
delete params.search_time;
if (Array.isArray(range) && range.length === 2 && range[0] && range[1]) {
params.start_time = dayjs(range[0]).startOf('day').unix();
params.end_time = dayjs(range[1]).endOf('day').unix();
}
Object.keys(params).forEach((k) => {
if (params[k] === '' || params[k] === undefined || params[k] === null) {
delete params[k];
}
});
return params;
}

View File

@@ -0,0 +1,106 @@
import type { VxeGridProps } from '#/adapter/vxe-table';
import { getAiGenerationList } from '../api';
import { normalizeAiGenerationFilters } from './search';
interface RowType {
id: number;
scene: string;
name: string;
status: number;
provider: string;
model: string;
api_key_name: string;
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
duration_ms: number;
error_msg: string;
created_at: string;
}
/**
* AI 生成记录表格
*/
export const gridOptions: VxeGridProps<RowType> = {
checkboxConfig: {
highlight: false,
},
columnConfig: {
useKey: true,
},
rowConfig: {
useKey: true,
},
columns: [
{ field: 'id', align: 'left', title: 'ID', width: 80 },
{ field: 'scene_txt', align: 'left', title: '场景', width: 90 },
{ field: 'name', align: 'left', title: '名称', minWidth: 140 },
{
field: 'status',
align: 'left',
title: '状态',
width: 90,
slots: { default: 'status' },
},
{ field: 'provider', align: 'left', title: '供应商', width: 100 },
{ field: 'model', align: 'left', title: '模型', minWidth: 120 },
{ field: 'api_key_name', align: 'left', title: '密钥', minWidth: 110 },
{
field: 'total_tokens',
align: 'right',
title: 'Tokens',
width: 110,
slots: { default: 'tokens' },
},
{
field: 'duration_ms',
align: 'right',
title: '耗时',
width: 90,
slots: { default: 'duration' },
},
{
field: 'error_msg',
align: 'left',
title: '错误',
minWidth: 160,
showOverflow: true,
},
{ field: 'created_at', align: 'left', title: '创建时间', width: 170 },
{
type: 'html',
title: '操作',
slots: { default: 'action' },
width: 100,
fixed: 'right',
},
],
keepSource: true,
pagerConfig: {},
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
const params = {
page: page.currentPage,
pageSize: page.pageSize,
...normalizeAiGenerationFilters(formValues || {}),
};
return await getAiGenerationList(params);
},
},
},
height: 'auto',
border: false,
toolbarConfig: {
search: true,
refresh: true,
print: false,
export: false,
zoom: true,
custom: {
icon: 'vxe-icon-menu',
},
},
showOverflow: true,
};

View File

@@ -0,0 +1,175 @@
<script lang="ts" setup>
/**
* AI 生成记录列表页
* 顶部用量摘要(随当前筛选刷新)+ VxeGrid 明细
*/
import { onMounted, reactive } from 'vue';
import { Page, useVbenModal } from '@vben/common-ui';
import { Card, Col, Row, Tag } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { TableAction } from '#/components/table-action';
import { getAiGenerationList, getAiGenerationUsageStats } from './api';
import DetailModal from './components/detail-modal.vue';
import { formOptions, normalizeAiGenerationFilters } from './config/search';
import { gridOptions } from './config/table';
import { formatDurationMs } from './utils/format';
defineOptions({ name: 'AiGeneration' });
const stats = reactive({
total_tokens: 0,
prompt_tokens: 0,
completion_tokens: 0,
avg_duration_ms: 0,
success_rate: 0,
total_count: 0,
success_count: 0,
fail_count: 0,
});
/** 把毫秒格式化为秒文案 */
function formatDuration(ms: number) {
return formatDurationMs(ms);
}
/** 按当前搜索表单刷新摘要卡片 */
async function refreshStats(formValues?: Record<string, any>) {
const params = normalizeAiGenerationFilters(formValues || {});
try {
const res = await getAiGenerationUsageStats(params);
const data = res?.data || res || {};
stats.total_tokens = Number(data.total_tokens || 0);
stats.prompt_tokens = Number(data.prompt_tokens || 0);
stats.completion_tokens = Number(data.completion_tokens || 0);
stats.avg_duration_ms = Number(data.avg_duration_ms || 0);
stats.success_rate = Number(data.success_rate || 0);
stats.total_count = Number(data.total_count || 0);
stats.success_count = Number(data.success_count || 0);
stats.fail_count = Number(data.fail_count || 0);
} catch {
// 摘要失败不阻断列表
}
}
const [Grid] = useVbenVxeGrid({
formOptions,
gridOptions: {
...gridOptions,
proxyConfig: {
...gridOptions.proxyConfig,
ajax: {
query: async ({ page }, formValues) => {
void refreshStats(formValues);
return await getAiGenerationList({
page: page.currentPage,
pageSize: page.pageSize,
...normalizeAiGenerationFilters(formValues || {}),
});
},
},
},
},
});
const [DetailModalComp, detailModalApi] = useVbenModal({
connectedComponent: DetailModal,
});
function openDetail(row: any) {
detailModalApi.setData({ id: Number(row?.id || 0) });
detailModalApi.open();
}
function statusColor(status: number) {
if (status === 1) return 'success';
if (status === 2) return 'error';
return 'processing';
}
onMounted(() => {
void refreshStats();
});
</script>
<template>
<Page auto-content-height>
<DetailModalComp />
<div class="mb-3">
<Row :gutter="12">
<Col :xs="12" :sm="8" :md="4">
<Card size="small">
<div class="text-xs text-[hsl(var(--muted-foreground))]"> Tokens</div>
<div class="mt-1 text-lg font-semibold">{{ stats.total_tokens }}</div>
</Card>
</Col>
<Col :xs="12" :sm="8" :md="4">
<Card size="small">
<div class="text-xs text-[hsl(var(--muted-foreground))]">Prompt</div>
<div class="mt-1 text-lg font-semibold">{{ stats.prompt_tokens }}</div>
</Card>
</Col>
<Col :xs="12" :sm="8" :md="4">
<Card size="small">
<div class="text-xs text-[hsl(var(--muted-foreground))]">Completion</div>
<div class="mt-1 text-lg font-semibold">{{ stats.completion_tokens }}</div>
</Card>
</Col>
<Col :xs="12" :sm="8" :md="4">
<Card size="small">
<div class="text-xs text-[hsl(var(--muted-foreground))]">平均耗时</div>
<div class="mt-1 text-lg font-semibold">
{{ formatDuration(stats.avg_duration_ms) }}
</div>
</Card>
</Col>
<Col :xs="12" :sm="8" :md="4">
<Card size="small">
<div class="text-xs text-[hsl(var(--muted-foreground))]">成功率</div>
<div class="mt-1 text-lg font-semibold">{{ stats.success_rate }}%</div>
</Card>
</Col>
<Col :xs="12" :sm="8" :md="4">
<Card size="small">
<div class="text-xs text-[hsl(var(--muted-foreground))]">请求数</div>
<div class="mt-1 text-lg font-semibold">
{{ stats.success_count }}/{{ stats.total_count }}
<span class="ml-1 text-xs font-normal text-[hsl(var(--muted-foreground))]">
失败 {{ stats.fail_count }}
</span>
</div>
</Card>
</Col>
</Row>
</div>
<Grid>
<template #status="{ row }">
<Tag :color="statusColor(Number(row.status))">
{{ row.status_txt || row.status }}
</Tag>
</template>
<template #tokens="{ row }">
<span :title="`P ${row.prompt_tokens} / C ${row.completion_tokens}`">
{{ row.total_tokens || 0 }}
</span>
</template>
<template #duration="{ row }">
{{ formatDuration(Number(row.duration_ms || 0)) }}
</template>
<template #action="{ row }">
<TableAction
:actions="[
{
label: '详情',
type: 'link',
onClick: () => openDetail(row),
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@@ -0,0 +1,19 @@
/**
* 毫秒耗时友好展示≥1s 保留一位小数秒,否则展示 ms
*/
export function formatDurationMs(ms: number | string | null | undefined): string {
const n = Number(ms);
if (!n || n <= 0) return '—';
if (n >= 1000) {
const sec = n / 1000;
return `${sec >= 10 ? Math.round(sec) : sec.toFixed(1)}s`;
}
return `${Math.round(n)}ms`;
}
/** 数字千分位,空值显示 — */
export function formatTokenCount(val: number | string | null | undefined): string {
const n = Number(val);
if (!n) return '0';
return n.toLocaleString('zh-CN');
}

View File

@@ -0,0 +1,58 @@
import { requestClient } from '#/api/request';
/** AI 平台管理 API */
const prefix = 'ai-platform/';
export async function getAiPlatformList(data: any) {
return requestClient.get<any>(`${prefix}list`, { params: data });
}
/** 平台页卡片看板(含模型/密钥,支持 keyword 模糊搜索) */
export async function getAiPlatformManageBoard(data?: { keyword?: string }) {
return requestClient.get<any>(`${prefix}manage-board`, { params: data || {} });
}
/** 平台详情看板(概览 / Key·模型消耗 / 近期请求) */
export async function getAiPlatformDetailBoard(id: number) {
return requestClient.get<any>(`${prefix}detail-board`, { params: { id } });
}
/** 设为系统当前选用平台 */
export async function setAiPlatformActive(data: { id: number }) {
return requestClient.post<any>(`${prefix}set-active`, data);
}
export async function getAiPlatformDetail(id: number) {
return requestClient.get<any>(`${prefix}detail`, { params: { id } });
}
export async function getAiPlatformOption() {
return requestClient.get<any>(`${prefix}option`);
}
/** 系统配置用:平台卡片 + 模型 + 密钥 + 当前选用 */
export async function getAiPlatformConfigOptions() {
return requestClient.get<any>(`${prefix}config-options`);
}
/** 系统配置AI 功能文案列表 */
export async function getAiFeatureCopyList() {
return requestClient.get<any>(`${prefix}copy-list`);
}
/** 系统配置:保存 AI 功能文案 */
export async function saveAiFeatureCopy(data: { items: Record<string, any>[] }) {
return requestClient.post<any>(`${prefix}copy-save`, data);
}
export async function createAiPlatform(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}create`, data);
}
export async function updateAiPlatform(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}update`, data);
}
export async function deleteAiPlatform(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}delete`, data);
}

View File

@@ -0,0 +1,29 @@
import { requestClient } from '#/api/request';
/** AI 厂商模型 API */
const prefix = 'ai-model/';
export async function getAiModelList(data: any) {
return requestClient.get<any>(`${prefix}list`, { params: data });
}
export async function getAiModelOption(data?: { platform_id?: number }) {
return requestClient.get<any>(`${prefix}option`, { params: data || {} });
}
export async function createAiModel(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}create`, data);
}
export async function updateAiModel(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}update`, data);
}
export async function deleteAiModel(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}delete`, data);
}
/** 设为平台默认模型 */
export async function setAiModelDefault(data: { id: number }) {
return requestClient.post<any>(`${prefix}set-default`, data);
}

View File

@@ -0,0 +1,66 @@
<script lang="ts" setup>
/**
* AI 平台新增/编辑弹窗
*/
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { createAiPlatform, updateAiPlatform } from '../api';
import { modalFormProps } from '../config/form';
const isUpdate = ref(false);
const gridApi = ref();
const onSuccess = ref<null | (() => void)>(null);
const [Form, formApi] = useVbenForm(modalFormProps);
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
draggable: true,
onCancel() {
modalApi.close();
},
onConfirm: async () => {
const e = await formApi.validate();
if (!e.valid) return;
const values = await formApi.getValues();
modalApi.setState({ confirmLoading: true });
try {
const api = isUpdate.value ? updateAiPlatform : createAiPlatform;
await api(values);
message.success('保存成功');
gridApi.value?.query?.();
onSuccess.value?.();
modalApi.close();
} finally {
modalApi.setState({ confirmLoading: false });
}
},
onOpenChange(isOpen: boolean) {
gridApi.value = isOpen ? modalApi.getData()?.gridApi : null;
onSuccess.value = isOpen
? modalApi.getData()?.onSuccess || null
: null;
if (!isOpen) {
formApi.resetFields();
return;
}
const { values, update } = modalApi.getData<Record<string, any>>() || {};
isUpdate.value = !!update;
if (values && update) {
formApi.setValues({ ...values });
} else {
formApi.resetFields();
formApi.setValues({ sort: 0, status: 1 });
}
},
});
</script>
<template>
<Modal :title="`${isUpdate ? '编辑' : '新增'}AI平台`" class="w-[640px]">
<Form />
</Modal>
</template>

View File

@@ -0,0 +1,144 @@
<script lang="ts" setup>
/**
* AI 厂商模型新增/编辑弹窗
*/
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { createAiModel, updateAiModel } from '../api/model';
import { STATUS_OPTIONS } from '../config/constants';
const isUpdate = ref(false);
const onSuccess = ref<null | (() => void)>(null);
const [Form, formApi] = useVbenForm({
wrapperClass: 'grid-cols-12',
commonConfig: {
formItemClass: 'col-span-12',
componentProps: { class: 'w-full' },
},
layout: 'horizontal',
schema: [
{
component: 'VbenInput',
fieldName: 'id',
label: 'ID',
dependencies: { show: false, triggerFields: ['id'] },
},
{
component: 'VbenInput',
fieldName: 'platform_id',
label: '平台ID',
dependencies: { show: false, triggerFields: ['platform_id'] },
},
{
component: 'VbenInput',
fieldName: 'code',
label: '模型编码',
rules: 'required',
formItemClass: 'col-span-6',
help: '调用 API 的 model 参数,如 lite / deepseek-chat',
componentProps: { placeholder: 'deepseek-chat' },
},
{
component: 'VbenInput',
fieldName: 'name',
label: '展示名称',
rules: 'required',
formItemClass: 'col-span-6',
componentProps: { placeholder: 'DeepSeek Chat' },
},
{
component: 'VbenSelect',
fieldName: 'status',
label: '状态',
formItemClass: 'col-span-4',
componentProps: { options: STATUS_OPTIONS },
defaultValue: 1,
},
{
component: 'InputNumber',
fieldName: 'sort',
label: '排序',
formItemClass: 'col-span-4',
componentProps: { min: 0 },
defaultValue: 0,
},
{
component: 'VbenSelect',
fieldName: 'is_default',
label: '设为默认',
formItemClass: 'col-span-4',
componentProps: {
options: [
{ label: '否', value: 0 },
{ label: '是', value: 1 },
],
},
defaultValue: 0,
},
{
component: 'VbenInput',
fieldName: 'description',
label: '说明',
componentProps: { type: 'textarea', rows: 2, placeholder: '模型简介' },
defaultValue: '',
},
],
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
draggable: true,
onCancel() {
modalApi.close();
},
onConfirm: async () => {
const e = await formApi.validate();
if (!e.valid) return;
const values = await formApi.getValues();
modalApi.setState({ confirmLoading: true });
try {
const api = isUpdate.value ? updateAiModel : createAiModel;
await api(values);
message.success('保存成功');
onSuccess.value?.();
modalApi.close();
} finally {
modalApi.setState({ confirmLoading: false });
}
},
onOpenChange(isOpen: boolean) {
if (!isOpen) {
formApi.resetFields();
onSuccess.value = null;
return;
}
const data = modalApi.getData<Record<string, any>>() || {};
isUpdate.value = !!data.update;
onSuccess.value = typeof data.onSuccess === 'function' ? data.onSuccess : null;
if (data.values && data.update) {
formApi.setValues({ ...data.values });
} else {
formApi.resetFields();
formApi.setValues({
platform_id: data.platform_id || data.values?.platform_id || 0,
sort: 0,
status: 1,
is_default: 0,
});
}
},
});
</script>
<template>
<Modal :title="`${isUpdate ? '编辑' : '新增'}模型`" class="w-[560px]">
<Form />
</Modal>
</template>

View File

@@ -0,0 +1,498 @@
<script lang="ts" setup>
/**
* AI 平台详情抽屉(二级页)
* Tabs概览 | Key消耗 | 模型消耗 | 请求记录 | Key管理 | 模型管理
*/
import { computed, ref } from 'vue';
import { useVbenDrawer, useVbenModal } from '@vben/common-ui';
import {
Button,
Card,
Col,
Popconfirm,
Row,
Spin,
Table,
Tabs,
Tag,
message,
} from 'ant-design-vue';
import KeyModal from '../../api-key/components/modal.vue';
import {
deleteAiApiKey,
setAiApiKeyDefault,
} from '../../api-key/api';
import { getAiGenerationList } from '../../generation/api';
import { getAiPlatformDetailBoard, setAiPlatformActive } from '../api';
import { deleteAiModel, setAiModelDefault } from '../api/model';
import ModelModal from './model-modal.vue';
defineOptions({ name: 'AiPlatformDetail' });
interface ModelCard {
id: number;
platform_id: number;
code: string;
name: string;
description: string;
is_default: number;
status: number;
}
interface KeyCard {
id: number;
platform_id: number;
name: string;
remark: string;
is_default: number;
status: number;
api_key_mask: string;
}
const activeTab = ref('overview');
const loading = ref(false);
const platformId = ref(0);
const onChanged = ref<null | (() => void)>(null);
const platform = ref<Record<string, any>>({});
const overview = ref<Record<string, any>>({});
const usageByKey = ref<any[]>([]);
const usageByModel = ref<any[]>([]);
const recentGenerations = ref<any[]>([]);
const keys = ref<KeyCard[]>([]);
const models = ref<ModelCard[]>([]);
const requestRows = ref<any[]>([]);
const requestLoading = ref(false);
const requestTotal = ref(0);
const requestPage = ref(1);
const [Drawer, drawerApi] = useVbenDrawer({
title: '平台详情',
class: 'w-[960px]',
footer: false,
contentClass: '!flex !flex-col !overflow-hidden',
async onOpenChange(isOpen) {
if (!isOpen) {
reset();
return;
}
const data = drawerApi.getData<{
platformId: number;
onChanged?: () => void;
}>();
platformId.value = Number(data?.platformId || 0);
onChanged.value = data?.onChanged || null;
activeTab.value = 'overview';
await loadDetail();
},
});
const [ModelModalComp, modelModalApi] = useVbenModal({
connectedComponent: ModelModal,
});
const [KeyModalComp, keyModalApi] = useVbenModal({
connectedComponent: KeyModal,
});
const drawerTitle = computed(
() => `平台详情 · ${platform.value?.name || platform.value?.code || ''}`,
);
function reset() {
platformId.value = 0;
platform.value = {};
overview.value = {};
usageByKey.value = [];
usageByModel.value = [];
recentGenerations.value = [];
keys.value = [];
models.value = [];
requestRows.value = [];
requestTotal.value = 0;
requestPage.value = 1;
}
async function loadDetail() {
if (!platformId.value) return;
loading.value = true;
try {
const res = await getAiPlatformDetailBoard(platformId.value);
const data = res?.data || res || {};
platform.value = data.platform || {};
overview.value = data.overview || {};
usageByKey.value = Array.isArray(data.usage_by_key) ? data.usage_by_key : [];
usageByModel.value = Array.isArray(data.usage_by_model)
? data.usage_by_model
: [];
recentGenerations.value = Array.isArray(data.recent_generations)
? data.recent_generations
: [];
keys.value = Array.isArray(data.keys) ? data.keys : [];
models.value = Array.isArray(data.models) ? data.models : [];
drawerApi.setState({ title: drawerTitle.value });
} finally {
loading.value = false;
}
}
function notifyParent() {
onChanged.value?.();
}
async function reloadAll() {
await loadDetail();
notifyParent();
if (activeTab.value === 'requests') {
await loadRequests();
}
}
async function loadRequests(page = requestPage.value) {
if (!platformId.value) return;
requestLoading.value = true;
requestPage.value = page;
try {
const res = await getAiGenerationList({
platform_id: platformId.value,
page,
pageSize: 10,
});
const data = res?.data || res || {};
requestRows.value = Array.isArray(data.items) ? data.items : [];
requestTotal.value = Number(data.total || 0);
} finally {
requestLoading.value = false;
}
}
function onTabChange(key: string | number) {
activeTab.value = String(key);
if (String(key) === 'requests' && !requestRows.value.length) {
void loadRequests(1);
}
}
function formatDuration(ms: number) {
const n = Number(ms || 0);
if (n <= 0) return '—';
if (n < 1000) return `${n} ms`;
return `${(n / 1000).toFixed(1)} s`;
}
function openModelModal(row?: ModelCard) {
modelModalApi.setData({
values: row || {},
update: !!row?.id,
platform_id: platformId.value,
onSuccess: reloadAll,
});
modelModalApi.open();
}
function openKeyModal(row?: KeyCard) {
keyModalApi.setData({
values: row || { platform_id: platformId.value },
update: !!row?.id,
platform_id: platformId.value,
lockPlatformId: true,
onSuccess: reloadAll,
});
keyModalApi.open();
}
async function handleSetActive() {
await setAiPlatformActive({ id: platformId.value });
message.success('已设为当前选用平台');
await reloadAll();
}
async function handleDeleteModel(id: number) {
await deleteAiModel({ ids: [id] });
message.success('已删除模型');
await reloadAll();
}
async function handleDeleteKey(id: number) {
await deleteAiApiKey({ ids: [id] });
message.success('已删除密钥');
await reloadAll();
}
async function handleSetDefaultModel(id: number) {
await setAiModelDefault({ id });
message.success('已设为默认模型');
await reloadAll();
}
async function handleSetDefaultKey(id: number) {
await setAiApiKeyDefault({ id });
message.success('已设为默认密钥');
await reloadAll();
}
const usageKeyColumns = [
{ title: '密钥', dataIndex: 'api_key_name', key: 'api_key_name' },
{ title: '请求数', dataIndex: 'request_count', key: 'request_count', width: 90 },
{ title: 'Tokens', dataIndex: 'total_tokens', key: 'total_tokens', width: 110 },
{
title: '平均耗时',
dataIndex: 'avg_duration_ms',
key: 'avg_duration_ms',
width: 100,
customRender: ({ text }: any) => formatDuration(Number(text || 0)),
},
];
const usageModelColumns = [
{ title: '模型', dataIndex: 'model', key: 'model' },
{ title: '请求数', dataIndex: 'request_count', key: 'request_count', width: 90 },
{ title: 'Tokens', dataIndex: 'total_tokens', key: 'total_tokens', width: 110 },
{
title: '平均耗时',
dataIndex: 'avg_duration_ms',
key: 'avg_duration_ms',
width: 100,
customRender: ({ text }: any) => formatDuration(Number(text || 0)),
},
];
const requestColumns = [
{ title: 'ID', dataIndex: 'id', key: 'id', width: 70 },
{ title: '场景', dataIndex: 'scene_txt', key: 'scene_txt', width: 80 },
{ title: '名称', dataIndex: 'name', key: 'name', ellipsis: true },
{ title: '状态', dataIndex: 'status_txt', key: 'status_txt', width: 80 },
{ title: '模型', dataIndex: 'model', key: 'model', width: 120, ellipsis: true },
{ title: 'Tokens', dataIndex: 'total_tokens', key: 'total_tokens', width: 90 },
{
title: '耗时',
dataIndex: 'duration_ms',
key: 'duration_ms',
width: 90,
customRender: ({ text }: any) => formatDuration(Number(text || 0)),
},
{ title: '时间', dataIndex: 'created_at', key: 'created_at', width: 160 },
];
</script>
<template>
<Drawer>
<ModelModalComp />
<KeyModalComp />
<Spin :spinning="loading">
<div class="flex min-h-0 flex-1 flex-col overflow-hidden p-1">
<div class="mb-3 flex flex-wrap items-center gap-2">
<Tag v-if="platform.is_active === 1" color="processing">当前选用</Tag>
<Tag :color="platform.status === 1 ? 'success' : 'default'">
{{ platform.status === 1 ? '启用' : '禁用' }}
</Tag>
<span class="text-xs text-[hsl(var(--muted-foreground))]">
{{ platform.code }} · {{ platform.api_url || '—' }}
</span>
<div class="ml-auto flex gap-2">
<Button
v-if="platform.is_active !== 1"
size="small"
type="primary"
ghost
@click="handleSetActive"
>
设为当前
</Button>
<Button size="small" @click="reloadAll">刷新</Button>
</div>
</div>
<Tabs :active-key="activeTab" class="ai-plat-tabs min-h-0 flex-1" @change="onTabChange">
<Tabs.TabPane key="overview" tab="概览">
<Row :gutter="12">
<Col :span="8">
<Card size="small">
<div class="text-xs text-[hsl(var(--muted-foreground))]">密钥数</div>
<div class="mt-1 text-lg font-semibold">{{ overview.key_count || 0 }}</div>
</Card>
</Col>
<Col :span="8">
<Card size="small">
<div class="text-xs text-[hsl(var(--muted-foreground))]">模型数</div>
<div class="mt-1 text-lg font-semibold">{{ overview.model_count || 0 }}</div>
</Card>
</Col>
<Col :span="8">
<Card size="small">
<div class="text-xs text-[hsl(var(--muted-foreground))]">默认模型</div>
<div class="mt-1 truncate text-lg font-semibold">
{{ platform.default_model || '—' }}
</div>
</Card>
</Col>
<Col :span="8" class="mt-3">
<Card size="small">
<div class="text-xs text-[hsl(var(--muted-foreground))]"> Tokens</div>
<div class="mt-1 text-lg font-semibold">{{ overview.total_tokens || 0 }}</div>
</Card>
</Col>
<Col :span="8" class="mt-3">
<Card size="small">
<div class="text-xs text-[hsl(var(--muted-foreground))]">成功/失败</div>
<div class="mt-1 text-lg font-semibold">
{{ overview.success_count || 0 }}/{{ overview.fail_count || 0 }}
</div>
</Card>
</Col>
<Col :span="8" class="mt-3">
<Card size="small">
<div class="text-xs text-[hsl(var(--muted-foreground))]">平均耗时</div>
<div class="mt-1 text-lg font-semibold">
{{ formatDuration(Number(overview.avg_duration_ms || 0)) }}
</div>
</Card>
</Col>
</Row>
<div class="mt-4 text-sm font-medium">近期请求</div>
<Table
class="mt-2"
size="small"
:pagination="false"
:columns="requestColumns"
:data-source="recentGenerations"
:row-key="(r: any) => r.id"
/>
</Tabs.TabPane>
<Tabs.TabPane key="key-usage" tab="Key消耗">
<Table
size="small"
:pagination="false"
:columns="usageKeyColumns"
:data-source="usageByKey"
:row-key="(r: any) => r.api_key_id"
/>
</Tabs.TabPane>
<Tabs.TabPane key="model-usage" tab="模型消耗">
<Table
size="small"
:pagination="false"
:columns="usageModelColumns"
:data-source="usageByModel"
:row-key="(r: any) => r.model"
/>
</Tabs.TabPane>
<Tabs.TabPane key="requests" tab="请求记录">
<Table
size="small"
:loading="requestLoading"
:columns="requestColumns"
:data-source="requestRows"
:row-key="(r: any) => r.id"
:pagination="{
current: requestPage,
pageSize: 10,
total: requestTotal,
onChange: (p: number) => loadRequests(p),
}"
/>
</Tabs.TabPane>
<Tabs.TabPane key="keys" tab="Key管理">
<div class="mb-2 flex justify-end">
<Button type="primary" size="small" @click="openKeyModal()">新增密钥</Button>
</div>
<div
v-if="!keys.length"
class="rounded-lg border border-dashed border-[hsl(var(--border))] p-4 text-sm text-[hsl(var(--muted-foreground))]"
>
暂无密钥
</div>
<div v-else class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div
v-for="k in keys"
:key="k.id"
class="rounded-lg border border-[hsl(var(--border))] p-3"
>
<div class="flex flex-wrap items-center gap-2">
<span class="font-medium">{{ k.name }}</span>
<Tag v-if="k.is_default === 1" color="gold">默认</Tag>
<Tag :color="k.status === 1 ? 'success' : 'default'">
{{ k.status === 1 ? '启用' : '禁用' }}
</Tag>
</div>
<div class="mt-1 font-mono text-xs text-[hsl(var(--muted-foreground))]">
{{ k.api_key_mask || '—' }}
</div>
<div class="mt-3 flex flex-wrap gap-2">
<Button
v-if="k.is_default !== 1"
size="small"
type="primary"
ghost
@click="handleSetDefaultKey(k.id)"
>
设为默认
</Button>
<Button size="small" @click="openKeyModal(k)">编辑</Button>
<Popconfirm title="确认删除该密钥" @confirm="handleDeleteKey(k.id)">
<Button size="small" danger>删除</Button>
</Popconfirm>
</div>
</div>
</div>
</Tabs.TabPane>
<Tabs.TabPane key="models" tab="模型管理">
<div class="mb-2 flex justify-end">
<Button type="primary" size="small" @click="openModelModal()">新增模型</Button>
</div>
<div
v-if="!models.length"
class="rounded-lg border border-dashed border-[hsl(var(--border))] p-4 text-sm text-[hsl(var(--muted-foreground))]"
>
暂无模型
</div>
<div v-else class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div
v-for="m in models"
:key="m.id"
class="rounded-lg border border-[hsl(var(--border))] p-3"
>
<div class="flex flex-wrap items-center gap-2">
<span class="font-medium">{{ m.name }}</span>
<Tag v-if="m.is_default === 1" color="gold">默认</Tag>
<Tag :color="m.status === 1 ? 'success' : 'default'">
{{ m.status === 1 ? '启用' : '禁用' }}
</Tag>
</div>
<div class="mt-1 text-xs text-[hsl(var(--muted-foreground))]">{{ m.code }}</div>
<div class="mt-3 flex flex-wrap gap-2">
<Button
v-if="m.is_default !== 1"
size="small"
type="primary"
ghost
@click="handleSetDefaultModel(m.id)"
>
设为默认
</Button>
<Button size="small" @click="openModelModal(m)">编辑</Button>
<Popconfirm title="确认删除该模型" @confirm="handleDeleteModel(m.id)">
<Button size="small" danger>删除</Button>
</Popconfirm>
</div>
</div>
</div>
</Tabs.TabPane>
</Tabs>
</div>
</Spin>
</Drawer>
</template>
<style scoped>
.ai-plat-tabs :deep(.ant-tabs-content) {
height: 100%;
overflow: auto;
}
</style>

View File

@@ -0,0 +1,5 @@
/** AI 平台 / 密钥共用状态选项 */
export const STATUS_OPTIONS = [
{ label: '启用', value: 1 },
{ label: '禁用', value: 0 },
];

View File

@@ -0,0 +1,78 @@
import type { VbenFormProps } from '#/adapter/form';
import { STATUS_OPTIONS } from './constants';
/** AI 平台新增/编辑表单 */
export const modalFormProps: VbenFormProps = {
wrapperClass: 'grid-cols-12',
commonConfig: {
formItemClass: 'col-span-12',
componentProps: { class: 'w-full' },
},
layout: 'horizontal',
schema: [
{
component: 'VbenInput',
fieldName: 'id',
label: 'ID',
dependencies: { show: false, triggerFields: ['id'] },
},
{
component: 'VbenInput',
fieldName: 'code',
label: '平台编码',
rules: 'required',
formItemClass: 'col-span-6',
help: '小写蛇形,如 spark / deepseek须与 Agent 工厂一致',
componentProps: { placeholder: 'spark' },
},
{
component: 'VbenInput',
fieldName: 'name',
label: '平台名称',
rules: 'required',
formItemClass: 'col-span-6',
componentProps: { placeholder: '讯飞星火' },
},
{
component: 'VbenInput',
fieldName: 'api_url',
label: '接口地址',
componentProps: {
placeholder: 'https://xxx/v1/chat/completions',
},
defaultValue: '',
},
{
component: 'VbenSelect',
fieldName: 'status',
label: '状态',
formItemClass: 'col-span-6',
componentProps: { options: STATUS_OPTIONS },
defaultValue: 1,
},
{
component: 'InputNumber',
fieldName: 'sort',
label: '排序',
formItemClass: 'col-span-6',
componentProps: { min: 0 },
defaultValue: 0,
},
{
component: 'Avatar',
fieldName: 'logo',
label: 'Logo',
help: '卡片展示用;默认模型请在下方「模型」卡片中设置',
defaultValue: '',
},
{
component: 'VbenInput',
fieldName: 'description',
label: '说明',
componentProps: { type: 'textarea', rows: 2, placeholder: '平台简介' },
defaultValue: '',
},
],
showDefaultActions: false,
};

View File

@@ -0,0 +1,241 @@
<script lang="ts" setup>
/**
* AI 平台管理(一级仅平台卡片)
* 点击平台打开详情抽屉(二级页):概览 / 消耗 / 请求 / Key·模型管理
*/
import { onMounted, ref, watch } from 'vue';
import { Page, useVbenDrawer, useVbenModal } from '@vben/common-ui';
import { Button, Input, Popconfirm, Spin, Tag, message } from 'ant-design-vue';
import {
deleteAiPlatform,
getAiPlatformManageBoard,
setAiPlatformActive,
} from './api';
import FormModal from './components/modal.vue';
import PlatformDetail from './components/platform-detail.vue';
defineOptions({ name: 'AiPlatform' });
interface PlatformBoard {
id: number;
code: string;
name: string;
api_url: string;
default_model: string;
logo: string;
description: string;
status: number;
is_active: number;
models?: any[];
keys?: any[];
}
const loading = ref(false);
const keyword = ref('');
const platforms = ref<PlatformBoard[]>([]);
const activeProvider = ref('');
let searchTimer: ReturnType<typeof setTimeout> | null = null;
async function loadBoard() {
loading.value = true;
try {
const res = await getAiPlatformManageBoard({
keyword: keyword.value.trim() || undefined,
});
const data = res?.data || res || {};
platforms.value = Array.isArray(data.platforms) ? data.platforms : [];
activeProvider.value = String(data.active?.provider || '');
} finally {
loading.value = false;
}
}
/** 输入防抖后模糊搜索 */
function onKeywordChange() {
if (searchTimer) clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
void loadBoard();
}, 280);
}
watch(keyword, () => onKeywordChange());
const [PlatformModalComp, platformModalApi] = useVbenModal({
connectedComponent: FormModal,
});
const [DetailDrawer, detailDrawerApi] = useVbenDrawer({
connectedComponent: PlatformDetail,
});
function openPlatformModal(row?: PlatformBoard) {
platformModalApi.setData({
values: row || {},
update: !!row?.id,
onSuccess: loadBoard,
});
platformModalApi.open();
}
/** 打开平台详情抽屉(模拟二级页) */
function openPlatformDetail(p: PlatformBoard) {
detailDrawerApi.setData({
platformId: p.id,
onChanged: loadBoard,
});
detailDrawerApi.open();
}
async function handleDeletePlatform(id: number) {
await deleteAiPlatform({ ids: [id] });
message.success('已删除平台');
await loadBoard();
}
async function handleSetActivePlatform(id: number) {
await setAiPlatformActive({ id });
message.success('已设为当前选用平台');
await loadBoard();
}
onMounted(() => {
void loadBoard();
});
</script>
<template>
<Page auto-content-height>
<PlatformModalComp />
<DetailDrawer />
<Spin :spinning="loading">
<div class="ai-board space-y-4 p-1">
<div class="flex flex-wrap items-center gap-3">
<Input
v-model:value="keyword"
allow-clear
class="max-w-md"
placeholder="模糊搜索平台 / 模型 / 密钥"
/>
<Button type="primary" @click="openPlatformModal()">新增平台</Button>
<Button @click="loadBoard">刷新</Button>
<span class="text-xs text-[hsl(var(--muted-foreground))]">
当前选用{{ activeProvider || '—' }}
</span>
</div>
<div>
<div class="mb-2 font-medium text-[hsl(var(--foreground))]">平台</div>
<div
v-if="!platforms.length"
class="rounded-lg border border-dashed border-[hsl(var(--border))] p-6 text-sm text-[hsl(var(--muted-foreground))]"
>
暂无平台请先新增
</div>
<div v-else class="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
<div
v-for="p in platforms"
:key="p.id"
class="ai-board-card"
:class="{ 'ai-board-card--current': p.is_active === 1 }"
@click="openPlatformDetail(p)"
>
<div class="flex items-start gap-3">
<div class="ai-board-logo">
<img
v-if="p.logo"
:src="p.logo"
:alt="p.name"
class="h-full w-full object-contain"
/>
<span v-else class="ai-board-logo-fallback">
{{ (p.name || p.code || '?').slice(0, 1) }}
</span>
</div>
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<span class="font-medium text-[hsl(var(--foreground))]">
{{ p.name }}
</span>
<Tag v-if="p.is_active === 1" color="processing">当前选用</Tag>
<Tag :color="p.status === 1 ? 'success' : 'default'">
{{ p.status === 1 ? '启用' : '禁用' }}
</Tag>
</div>
<div class="mt-1 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 class="mt-1 text-xs text-[hsl(var(--muted-foreground))]">
模型 {{ p.models?.length || 0 }} · 密钥 {{ p.keys?.length || 0 }}
</div>
</div>
</div>
<div class="mt-3 flex flex-wrap gap-2" @click.stop>
<Button
v-if="p.is_active !== 1"
size="small"
type="primary"
ghost
@click="handleSetActivePlatform(p.id)"
>
设为当前
</Button>
<Button size="small" @click="openPlatformModal(p)">编辑</Button>
<Button size="small" type="link" @click="openPlatformDetail(p)">
详情
</Button>
<Popconfirm title="确认删除该平台?" @confirm="handleDeletePlatform(p.id)">
<Button size="small" danger>删除</Button>
</Popconfirm>
</div>
</div>
</div>
</div>
</div>
</Spin>
</Page>
</template>
<style scoped>
.ai-board-card {
cursor: pointer;
border: 1px solid hsl(var(--border));
border-radius: 10px;
padding: 14px 16px;
background: hsl(var(--card));
color: hsl(var(--foreground));
transition:
border-color 0.15s ease,
box-shadow 0.15s ease,
background-color 0.15s ease;
}
.ai-board-card:hover {
border-color: hsl(var(--primary) / 0.55);
}
.ai-board-card--current {
border-color: hsl(var(--primary) / 0.75);
}
.ai-board-logo {
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.55);
padding: 5px;
}
.ai-board-logo-fallback {
font-size: 15px;
font-weight: 600;
color: hsl(var(--muted-foreground));
}
</style>

View File

@@ -0,0 +1,29 @@
import { requestClient } from '#/api/request';
/** 金方库管理 API */
const prefix = 'golden-formula/';
export async function getGoldenFormulaList(data: any) {
return requestClient.get<any>(`${prefix}list`, { params: data });
}
export async function getGoldenFormulaDetail(id: number) {
return requestClient.get<any>(`${prefix}detail`, { params: { id } });
}
export async function createGoldenFormula(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}create`, data);
}
export async function updateGoldenFormula(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}update`, data);
}
export async function deleteGoldenFormula(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}delete`, data);
}
/** Excel 解析后的行批量导入 */
export async function importGoldenFormula(data: { rows: Record<string, any>[] }) {
return requestClient.post<any>(`${prefix}import`, data);
}

View File

@@ -0,0 +1,88 @@
<script lang="ts" setup>
/**
* 金方新增/编辑弹窗
*/
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { createGoldenFormula, updateGoldenFormula } from '../api';
import { modalFormProps } from '../config/form';
const isUpdate = ref(false);
const gridApi = ref();
const [Form, formApi] = useVbenForm(modalFormProps);
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
draggable: true,
onCancel() {
modalApi.close();
},
onConfirm: async () => {
const e = await formApi.validate();
if (!e.valid) return;
const values = await formApi.getValues();
// 表单里 drugs_json 是字符串;空串转空数组交给后端
const drugsRaw = values.drugs_json;
let drugs_json: any = drugsRaw;
if (typeof drugsRaw === 'string') {
const t = drugsRaw.trim();
if (t === '') {
drugs_json = [];
} else {
try {
drugs_json = JSON.parse(t);
} catch {
message.error('药品JSON 不是合法 JSON');
return;
}
}
}
modalApi.setState({ confirmLoading: true });
try {
const payload = { ...values, drugs_json };
const api = isUpdate.value ? updateGoldenFormula : createGoldenFormula;
await api(payload);
message.success('保存成功');
gridApi.value?.query?.();
gridApi.value?.reload?.();
modalApi.close();
} finally {
modalApi.setState({ confirmLoading: false });
}
},
onOpenChange(isOpen: boolean) {
gridApi.value = isOpen ? modalApi.getData()?.gridApi : null;
if (!isOpen) {
formApi.resetFields();
return;
}
const { values, update } = modalApi.getData<Record<string, any>>() || {};
isUpdate.value = !!update;
if (values && update) {
const drugs = values.drugs_json;
formApi.setValues({
...values,
drugs_json:
typeof drugs === 'string'
? drugs
: drugs
? JSON.stringify(drugs, null, 2)
: '',
});
} else {
formApi.resetFields();
formApi.setValues({ sort: 0, status: 1, drugs_json: '' });
}
},
});
</script>
<template>
<Modal :title="`${isUpdate ? '编辑' : '新增'}金方`" class="w-[720px]">
<Form />
</Modal>
</template>

View File

@@ -0,0 +1,6 @@
/** 金方模块常量 */
export const STATUS_OPTIONS = [
{ label: '启用', value: 1 },
{ label: '禁用', value: 0 },
];

View File

@@ -0,0 +1,130 @@
import type { VbenFormProps } from '#/adapter/form';
import { STATUS_OPTIONS } from './constants';
/** 金方新增/编辑表单 */
export const modalFormProps: VbenFormProps = {
wrapperClass: 'grid-cols-12',
commonConfig: {
formItemClass: 'col-span-12',
componentProps: { class: 'w-full' },
},
layout: 'horizontal',
schema: [
{
component: 'VbenInput',
fieldName: 'id',
label: 'ID',
dependencies: { show: false, triggerFields: ['id'] },
},
{
component: 'VbenInput',
fieldName: 'name',
label: '药方名字',
rules: 'required',
componentProps: { placeholder: '如:桂枝汤' },
},
{
component: 'VbenInput',
fieldName: 'source',
label: '金方来源',
formItemClass: 'col-span-6',
componentProps: { placeholder: '如:伤寒论' },
defaultValue: '',
},
{
component: 'VbenSelect',
fieldName: 'status',
label: '状态',
formItemClass: 'col-span-3',
componentProps: { options: STATUS_OPTIONS },
defaultValue: 1,
},
{
component: 'InputNumber',
fieldName: 'sort',
label: '排序',
formItemClass: 'col-span-3',
componentProps: { min: 0 },
defaultValue: 0,
},
{
component: 'VbenInput',
fieldName: 'herb_overview',
label: '药材速览',
componentProps: { placeholder: '如:桂枝、芍药、甘草、生姜、大枣' },
defaultValue: '',
},
{
component: 'VbenInput',
fieldName: 'drugs_json',
label: '药品JSON',
help: '系统字段name/dose(克数)/unit(g)/usage(先煎后下)。展示扩展ancient_dose(三两)、prep(去皮/炙/切/擘),仅展示不参与导入',
componentProps: {
type: 'textarea',
rows: 5,
placeholder:
'[{"name":"桂枝","dose":"9","unit":"g","usage":"","ancient_dose":"三两","prep":"去皮"},{"name":"芍药","dose":"9","unit":"g","ancient_dose":"三两"},{"name":"甘草","dose":"6","unit":"g","ancient_dose":"二两","prep":"炙"},{"name":"生姜","dose":"9","unit":"g","ancient_dose":"三两","prep":"切"},{"name":"大枣","dose":"12","unit":"g","ancient_dose":"十二枚","prep":"擘"}]',
},
defaultValue: '',
},
{
component: 'VbenInput',
fieldName: 'original_formula',
label: '原方',
help: '典籍药味与服法全文(含煮法、服法、禁忌)',
componentProps: {
type: 'textarea',
rows: 5,
placeholder:
'桂枝三两,去皮 芍药三两 甘草二两,炙 生姜三两,切 大枣十二枚,擘。右五味,㕮咀三味,以水七升…',
},
defaultValue: '',
},
{
component: 'VbenInput',
fieldName: 'indication_original',
label: '主治(原文)',
componentProps: { type: 'textarea', rows: 2 },
defaultValue: '',
},
{
component: 'VbenInput',
fieldName: 'indication_translation',
label: '主治(译文)',
componentProps: { type: 'textarea', rows: 2 },
defaultValue: '',
},
{
component: 'VbenInput',
fieldName: 'intro',
label: '简介',
componentProps: { type: 'textarea', rows: 2 },
defaultValue: '',
},
{
component: 'VbenInput',
fieldName: 'cover',
label: '封面',
componentProps: { placeholder: '封面图 URL' },
defaultValue: '',
},
{
component: 'VbenInput',
fieldName: 'pinyin_initials',
label: '拼音首拼',
formItemClass: 'col-span-6',
componentProps: { placeholder: '可空,保存时按药方名自动生成' },
defaultValue: '',
},
{
component: 'VbenInput',
fieldName: 'pinyin',
label: '拼音全拼',
formItemClass: 'col-span-6',
componentProps: { placeholder: '可空,保存时按药方名自动生成' },
defaultValue: '',
},
],
showDefaultActions: false,
};

View File

@@ -0,0 +1,231 @@
<script lang="ts" setup>
/**
* 金方库管理CRUD + Excel 导入
* VIP 功能码 golden_formula 由「VIP等级配置」勾选分配本页只管平台金方数据
*/
import type { VxeGridListeners } from '#/adapter/vxe-table';
import { ref } from 'vue';
import { Page, useVbenModal } from '@vben/common-ui';
import { Button, Tag, Upload, message, Modal as AntModal } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { TableAction } from '#/components/table-action';
import { downloadByData } from '#/util/tool';
import {
deleteGoldenFormula,
getGoldenFormulaList,
importGoldenFormula,
} from './api';
import FormModal from './components/modal.vue';
import { STATUS_OPTIONS } from './config/constants';
import { exportGoldenFormulaTemplate } from './utils/exportGoldenFormulaExcel';
import { parseGoldenFormulaExcelBuffer } from './utils/parseGoldenFormulaExcel';
defineOptions({ name: 'GoldenFormula' });
const hasTopTableDropDownActions = ref(false);
const importing = ref(false);
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: [
{
component: 'VbenInput',
fieldName: 'name',
label: '药方名字',
componentProps: { placeholder: '名称/首拼/速览' },
},
{
component: 'VbenInput',
fieldName: 'source',
label: '来源',
componentProps: { placeholder: '金方来源' },
},
{
component: 'VbenSelect',
fieldName: 'status',
label: '状态',
componentProps: { allowClear: true, options: STATUS_OPTIONS },
},
],
},
gridOptions: {
checkboxConfig: { highlight: true, labelField: '' },
columns: [
{ type: 'checkbox', width: 60 },
{ field: 'id', title: 'ID', width: 80 },
{ field: 'name', title: '药方名字', minWidth: 140 },
{ field: 'source', title: '来源', width: 120 },
{ field: 'herb_overview', title: '药材速览', minWidth: 180 },
{ field: 'drug_count', title: '药味数', width: 80 },
{ field: 'pinyin_initials', title: '首拼', width: 100 },
{ field: 'status', title: '状态', width: 90, slots: { default: 'status' } },
{ field: 'sort', title: '排序', width: 80 },
{ field: 'created_at', title: '创建时间', width: 170 },
{ title: '操作', slots: { default: 'action' }, width: 160, fixed: 'right' },
],
keepSource: true,
pagerConfig: {},
proxyConfig: {
ajax: {
query: async ({ page }: any, formValues: any) => {
return await getGoldenFormulaList({
page: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
height: 'auto',
toolbarConfig: {
search: true,
refresh: true,
slots: { buttons: 'toolbar-buttons' },
},
},
gridEvents: {
checkboxChange() {
hasTopTableDropDownActions.value =
gridApi.grid.getCheckboxRecords().length > 0;
},
checkboxAll() {
hasTopTableDropDownActions.value =
gridApi.grid.getCheckboxRecords().length > 0;
},
} as VxeGridListeners<any>,
});
const [FormModalComp, formModalApi] = useVbenModal({
connectedComponent: FormModal,
});
const showModal = (data: any = {}, isUpdate = false) => {
formModalApi.setData({
values: data,
update: isUpdate,
gridApi,
});
formModalApi.open();
};
const handleDelete = async (ids: number[]) => {
await deleteGoldenFormula({ ids });
message.success('删除成功');
gridApi.query();
};
async function handleDownloadTemplate() {
const buffer = await exportGoldenFormulaTemplate();
downloadByData(
buffer,
'金方导入模板.xlsx',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
);
}
async function handleImportFile(file: File) {
importing.value = true;
try {
const buffer = await file.arrayBuffer();
const parsed = await parseGoldenFormulaExcelBuffer(buffer);
if (!parsed.items.length) {
const detail = parsed.firstInvalidReason
? `${parsed.firstInvalidReason}`
: '';
message.warning(
parsed.invalidCount
? `没有可导入的有效行(无效 ${parsed.invalidCount} 行)${detail}`
: '文件无有效数据,请确认填写的是「金方导入」工作表',
);
return false;
}
const res = await importGoldenFormula({ rows: parsed.items });
const ok = Number(res?.success || 0);
const fail = Number(res?.fail || 0);
const errors = Array.isArray(res?.errors) ? res.errors : [];
AntModal.info({
title: '导入结果',
content: `成功 ${ok} 条,失败 ${fail}${
errors.length ? `\n${errors.slice(0, 10).join('\n')}` : ''
}`,
width: 520,
});
gridApi.reload();
} catch (e: any) {
message.error(e?.message || '导入失败');
} finally {
importing.value = false;
}
return false;
}
</script>
<template>
<Page auto-content-height title="金方管理">
<FormModalComp />
<Grid>
<template #toolbar-buttons>
<TableAction
:actions="[
{
label: '新增',
type: 'primary',
onClick: () => showModal({}, false),
},
{
label: '下载模板',
onClick: handleDownloadTemplate,
},
{
label: '批量删除',
danger: true,
disabled: !hasTopTableDropDownActions,
popConfirm: {
title: '确认删除选中金方?',
confirm: () => {
const rows = gridApi.grid.getCheckboxRecords();
handleDelete(rows.map((r: any) => r.id));
},
},
},
]"
/>
<Upload
:show-upload-list="false"
:before-upload="handleImportFile"
accept=".xlsx,.xls"
class="ml-2 inline-block"
>
<Button :loading="importing" type="default">导入 Excel</Button>
</Upload>
</template>
<template #status="{ row }">
<Tag :color="row.status === 1 ? 'success' : 'default'">
{{ row.status === 1 ? '启用' : '禁用' }}
</Tag>
</template>
<template #action="{ row }">
<TableAction
:actions="[
{
label: '编辑',
onClick: () => showModal(row, true),
},
{
label: '删除',
danger: true,
popConfirm: {
title: '确认删除该金方?',
confirm: () => handleDelete([row.id]),
},
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@@ -0,0 +1,120 @@
import ExcelJS from 'exceljs';
const THIN_BORDER: Partial<ExcelJS.Borders> = {
top: { style: 'thin', color: { argb: 'FF000000' } },
left: { style: 'thin', color: { argb: 'FF000000' } },
bottom: { style: 'thin', color: { argb: 'FF000000' } },
right: { style: 'thin', color: { argb: 'FF000000' } },
};
const HEADERS = [
'药方名字',
'药品JSON',
'药材速览',
'原方',
'药方主治(原文)',
'药方主治(译文)',
'简介',
'封面',
'药方拼音首拼',
'药方拼音全拼',
'金方来源',
] as const;
/**
* 下载金方导入模板(含字段说明 sheet
*/
export async function exportGoldenFormulaTemplate(): Promise<ArrayBuffer> {
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('金方导入');
sheet.columns = [
{ width: 14 },
{ width: 52 },
{ width: 24 },
{ width: 48 },
{ width: 24 },
{ width: 24 },
{ width: 20 },
{ width: 20 },
{ width: 12 },
{ width: 16 },
{ width: 12 },
];
const headerRow = sheet.addRow([...HEADERS]);
headerRow.height = 28;
headerRow.eachCell((cell) => {
cell.font = { bold: true };
cell.fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FFFFFF00' },
};
cell.alignment = { horizontal: 'center', vertical: 'middle', wrapText: true };
cell.border = THIN_BORDER;
});
// 示例:系统克数 + 展示扩展(古代剂量/炮制)
const sampleDrugs =
'[{"name":"桂枝","dose":"9","unit":"g","usage":"","ancient_dose":"三两","prep":"去皮"},{"name":"芍药","dose":"9","unit":"g","ancient_dose":"三两"},{"name":"甘草","dose":"6","unit":"g","ancient_dose":"二两","prep":"炙"},{"name":"生姜","dose":"9","unit":"g","ancient_dose":"三两","prep":"切"},{"name":"大枣","dose":"12","unit":"g","ancient_dose":"十二枚","prep":"擘"}]';
const sampleOriginal =
'桂枝三两,去皮 芍药三两 甘草二两,炙 生姜三两,切 大枣十二枚,擘。右五味,㕮咀三味,以水七升,微火煮取三升,去滓,适寒温,服一升。服已须臾,啜热稀粥一升余,以助药力。禁生冷、粘滑、肉面、五辛、酒酪、臭恶等物。';
const sample = sheet.addRow([
'桂枝汤',
sampleDrugs,
'桂枝、芍药、甘草、生姜、大枣',
sampleOriginal,
'太阳中风,阳浮而阴弱',
'外感风寒表虚证',
'解肌发表,调和营卫',
'',
'gzt',
'guizhitang',
'伤寒论',
]);
sample.height = 80;
sample.eachCell((cell) => {
cell.border = THIN_BORDER;
cell.alignment = { vertical: 'middle', wrapText: true };
});
sheet.views = [{ state: 'frozen', ySplit: 1 }];
const tip = workbook.addWorksheet('填写说明');
tip.columns = [{ width: 18 }, { width: 80 }];
const tipHead = tip.addRow(['字段', '说明']);
tipHead.eachCell((cell) => {
cell.font = { bold: true };
cell.fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FFD9EAD3' },
};
cell.border = THIN_BORDER;
});
const tips: [string, string][] = [
['药方名字', '必填'],
[
'药品JSON',
'系统字段导入用name/dose(克数数字)/unit(固定g)/usage(先煎、后下等,可空)。展示扩展仅展示ancient_dose(如三两、十二枚)、prep(去皮、炙、切、擘)。勿把古代单位或炮制写进 dose/unit/usage。',
],
['药材速览', '展示用简短药味列表'],
[
'原方',
'典籍药味+煮法+服法+禁忌全文,如「桂枝三两,去皮…服一升…禁生冷…」',
],
['药方主治(原文)', '主治典籍原文'],
['药方主治(译文)', '主治白话译文'],
['简介', '方义/说明'],
['封面', '封面图完整 URL可空'],
['药方拼音首拼', '可空,系统按药方名自动生成'],
['药方拼音全拼', '可空,系统按药方名自动生成'],
['金方来源', '如伤寒论、金匮要略'],
];
tips.forEach(([k, v]) => {
const row = tip.addRow([k, v]);
row.eachCell((cell) => {
cell.border = THIN_BORDER;
cell.alignment = { wrapText: true, vertical: 'top' };
});
});
return workbook.xlsx.writeBuffer() as Promise<ArrayBuffer>;
}

View File

@@ -0,0 +1,178 @@
import ExcelJS from 'exceljs';
const MAX_ROWS = 2000;
export type ParsedGoldenFormulaRow = {
name: string;
drugs_json: string;
herb_overview: string;
original_formula: string;
indication_original: string;
indication_translation: string;
intro: string;
cover: string;
pinyin_initials: string;
pinyin: string;
source: string;
status: number;
sort: number;
};
export type ParseGoldenFormulaResult = {
items: ParsedGoldenFormulaRow[];
invalidCount: number;
rowCount: number;
firstInvalidReason?: string;
};
function normalizeCell(value: ExcelJS.CellValue): string {
if (value === null || value === undefined) return '';
if (typeof value === 'object') {
if ('richText' in value && Array.isArray((value as any).richText)) {
return (value as any).richText
.map((p: { text?: string }) => p?.text ?? '')
.join('')
.trim();
}
if ('text' in value) return String((value as any).text ?? '').trim();
if ('result' in value) {
return normalizeCell((value as any).result as ExcelJS.CellValue);
}
}
if (typeof value === 'number') {
return Number.isInteger(value) ? String(value) : String(value).trim();
}
if (typeof value === 'boolean') return value ? '1' : '0';
return String(value).trim();
}
function normalizeHeader(value: string): string {
return value.replace(/\s+/g, '').replace(/\u3000/g, '');
}
const NAME_ALIASES = ['药方名字', '药方名称', '方名', 'name'];
const DRUGS_ALIASES = ['药品JSON', '药品json', '药品', 'drugs_json', 'drugs'];
const HERB_ALIASES = ['药材速览', '速览', 'herb_overview'];
const ORIGINAL_ALIASES = ['原方', '原方全文', 'original_formula', '方文'];
const IND_ORI_ALIASES = ['药方主治(原文)', '药方主治原文', '主治原文', 'indication_original'];
const IND_TR_ALIASES = ['药方主治(译文)', '药方主治译文', '主治译文', 'indication_translation'];
const INTRO_ALIASES = ['简介', 'intro'];
const COVER_ALIASES = ['封面', '封面图', 'cover'];
const PY_ABBR_ALIASES = ['药方拼音首拼', '拼音首拼', '首拼', 'pinyin_initials'];
const PY_FULL_ALIASES = ['药方拼音全拼', '拼音全拼', '全拼', 'pinyin'];
const SOURCE_ALIASES = ['金方来源', '来源', 'source'];
const STATUS_ALIASES = ['状态', 'status'];
const SORT_ALIASES = ['排序', 'sort'];
function buildHeaderIndexMap(headerRow: ExcelJS.Row): Record<string, number> {
const map: Record<string, number> = {};
headerRow.eachCell({ includeEmpty: true }, (cell, colNumber) => {
const key = normalizeHeader(normalizeCell(cell.value));
if (key) map[key] = colNumber;
});
return map;
}
function pickColumn(
headerMap: Record<string, number>,
aliases: string[],
): number | undefined {
for (const alias of aliases) {
const col = headerMap[normalizeHeader(alias)];
if (col) return col;
}
return undefined;
}
function getCellText(row: ExcelJS.Row, col?: number): string {
if (!col) return '';
return normalizeCell(row.getCell(col).value);
}
function getCellInt(row: ExcelJS.Row, col?: number, fallback = 0): number {
if (!col) return fallback;
const raw = normalizeCell(row.getCell(col).value);
if (raw === '') return fallback;
const n = Number(raw);
return Number.isFinite(n) ? Math.trunc(n) : fallback;
}
function rowIsEmpty(row: ExcelJS.Row): boolean {
let hasValue = false;
row.eachCell({ includeEmpty: false }, (cell) => {
if (normalizeCell(cell.value) !== '') hasValue = true;
});
return !hasValue;
}
function pickImportSheet(workbook: ExcelJS.Workbook): ExcelJS.Worksheet | undefined {
const byName = workbook.worksheets.find((s) => s.name === '金方导入');
return byName || workbook.worksheets[0];
}
/**
* 解析金方导入 Excel首行为表头
* 药品JSON 列原样保留字符串,由后端再校验
*/
export async function parseGoldenFormulaExcelBuffer(
buffer: ArrayBuffer,
): Promise<ParseGoldenFormulaResult> {
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(buffer);
const sheet = pickImportSheet(workbook);
if (!sheet) {
return { items: [], invalidCount: 0, rowCount: 0 };
}
const headerRow = sheet.getRow(1);
const headerMap = buildHeaderIndexMap(headerRow);
const nameCol = pickColumn(headerMap, NAME_ALIASES);
if (!nameCol) {
throw new Error('模板表头缺少「药方名字」列,请重新下载模板');
}
const drugsCol = pickColumn(headerMap, DRUGS_ALIASES);
const herbCol = pickColumn(headerMap, HERB_ALIASES);
const originalCol = pickColumn(headerMap, ORIGINAL_ALIASES);
const indOriCol = pickColumn(headerMap, IND_ORI_ALIASES);
const indTrCol = pickColumn(headerMap, IND_TR_ALIASES);
const introCol = pickColumn(headerMap, INTRO_ALIASES);
const coverCol = pickColumn(headerMap, COVER_ALIASES);
const pyAbbrCol = pickColumn(headerMap, PY_ABBR_ALIASES);
const pyFullCol = pickColumn(headerMap, PY_FULL_ALIASES);
const sourceCol = pickColumn(headerMap, SOURCE_ALIASES);
const statusCol = pickColumn(headerMap, STATUS_ALIASES);
const sortCol = pickColumn(headerMap, SORT_ALIASES);
const items: ParsedGoldenFormulaRow[] = [];
let invalidCount = 0;
let rowCount = 0;
let firstInvalidReason: string | undefined;
const maxRow = Math.min(sheet.rowCount || 0, MAX_ROWS + 1);
for (let rowNumber = 2; rowNumber <= maxRow; rowNumber++) {
const row = sheet.getRow(rowNumber);
if (rowIsEmpty(row)) continue;
rowCount++;
if (items.length >= MAX_ROWS) break;
const name = getCellText(row, nameCol);
if (!name) {
invalidCount++;
if (!firstInvalidReason) firstInvalidReason = `${rowNumber}行:药方名字为空`;
continue;
}
const statusRaw = getCellInt(row, statusCol, 1);
items.push({
name,
drugs_json: getCellText(row, drugsCol),
herb_overview: getCellText(row, herbCol),
original_formula: getCellText(row, originalCol),
indication_original: getCellText(row, indOriCol),
indication_translation: getCellText(row, indTrCol),
intro: getCellText(row, introCol),
cover: getCellText(row, coverCol),
pinyin_initials: getCellText(row, pyAbbrCol),
pinyin: getCellText(row, pyFullCol),
source: getCellText(row, sourceCol),
status: statusRaw === 0 ? 0 : 1,
sort: getCellInt(row, sortCol, 0),
});
}
return { items, invalidCount, rowCount, firstInvalidReason };
}

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>

View File

@@ -9,6 +9,7 @@ import type { QuickDiscountOption } from '#/utils/pricePercentAdjust';
import FormAvatar from '#/components/form/components/avatar.vue';
import AiModelConfigPanel from './components/ai-model-config-panel.vue';
import OaNotifyConfigPanel from './components/oa-notify-config-panel.vue';
import { getSystemConfigList, saveSystemConfig } from './api';
@@ -62,7 +63,8 @@ function readStoredTab() {
stored === 'doctor_login' ||
stored === 'dict_search' ||
stored === 'medical_record' ||
stored === 'oa_notify'
stored === 'oa_notify' ||
stored === 'ai_model'
) {
activeKey.value = stored;
}
@@ -408,9 +410,13 @@ onMounted(() => {
<Tabs.TabPane key="oa_notify" tab="OA通知">
<OaNotifyConfigPanel />
</Tabs.TabPane>
<Tabs.TabPane key="ai_model" tab="AI模型">
<AiModelConfigPanel />
</Tabs.TabPane>
</Tabs>
<div class="mt-4">
<div v-if="activeKey !== 'oa_notify' && activeKey !== 'ai_model'" class="mt-4">
<Button type="primary" :loading="saving" @click="handleSave">保存配置</Button>
</div>
</Card>