feat: 病历模块优化、AI辅助问诊、接入deepseek
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开金方导入(VIP:golden_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 />
|
||||
|
||||
@@ -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>
|
||||
@@ -438,10 +438,27 @@ export async function aiGeneratePrescriptionApi(data: {
|
||||
chief_complaint?: string;
|
||||
/** 当前病历表单快照(可未保存),供 AI 综合参考 */
|
||||
medical_record?: Record<string, any>;
|
||||
/** 中药:是否委托调剂 */
|
||||
use_entrusted_process?: 0 | 1 | boolean;
|
||||
/** 中药委托调剂:制剂要求 id(yii_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);
|
||||
}
|
||||
|
||||
/** 金方列表(VIP:golden_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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 金方导入弹窗(PC 接诊 / 在线复诊共用)
|
||||
* - VIP:golden_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;
|
||||
/** 开方页 activeCategory:1中药 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开金方导入弹窗(VIP:golden_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"
|
||||
|
||||
@@ -610,6 +610,7 @@ function handleAiGenerate() {
|
||||
patientSex: Number(props.patientSex || 0),
|
||||
patientAge: Number(props.patientAge || 0),
|
||||
chiefComplaint: String(form.chief_complaint || ''),
|
||||
medicalRecord: { ...form },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
101
apps/web-antd/src/views/doctor/utils/aiFeatureGate.ts
Normal file
101
apps/web-antd/src/views/doctor/utils/aiFeatureGate.ts
Normal 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 };
|
||||
}
|
||||
33
apps/web-antd/src/views/system/ai/api-key/api/index.ts
Normal file
33
apps/web-antd/src/views/system/ai/api-key/api/index.ts
Normal 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);
|
||||
}
|
||||
238
apps/web-antd/src/views/system/ai/api-key/components/modal.vue
Normal file
238
apps/web-antd/src/views/system/ai/api-key/components/modal.vue
Normal 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>
|
||||
@@ -0,0 +1,2 @@
|
||||
/** 复用平台状态选项 */
|
||||
export { STATUS_OPTIONS } from '../../platform/config/constants';
|
||||
84
apps/web-antd/src/views/system/ai/api-key/config/form.ts
Normal file
84
apps/web-antd/src/views/system/ai/api-key/config/form.ts
Normal 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,
|
||||
};
|
||||
193
apps/web-antd/src/views/system/ai/api-key/index.vue
Normal file
193
apps/web-antd/src/views/system/ai/api-key/index.vue
Normal 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>
|
||||
22
apps/web-antd/src/views/system/ai/generation/api/index.ts
Normal file
22
apps/web-antd/src/views/system/ai/generation/api/index.ts
Normal 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 || {} });
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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' },
|
||||
];
|
||||
108
apps/web-antd/src/views/system/ai/generation/config/search.ts
Normal file
108
apps/web-antd/src/views/system/ai/generation/config/search.ts
Normal 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;
|
||||
}
|
||||
106
apps/web-antd/src/views/system/ai/generation/config/table.ts
Normal file
106
apps/web-antd/src/views/system/ai/generation/config/table.ts
Normal 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,
|
||||
};
|
||||
175
apps/web-antd/src/views/system/ai/generation/index.vue
Normal file
175
apps/web-antd/src/views/system/ai/generation/index.vue
Normal 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>
|
||||
19
apps/web-antd/src/views/system/ai/generation/utils/format.ts
Normal file
19
apps/web-antd/src/views/system/ai/generation/utils/format.ts
Normal 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');
|
||||
}
|
||||
58
apps/web-antd/src/views/system/ai/platform/api/index.ts
Normal file
58
apps/web-antd/src/views/system/ai/platform/api/index.ts
Normal 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);
|
||||
}
|
||||
29
apps/web-antd/src/views/system/ai/platform/api/model.ts
Normal file
29
apps/web-antd/src/views/system/ai/platform/api/model.ts
Normal 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);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,5 @@
|
||||
/** AI 平台 / 密钥共用状态选项 */
|
||||
export const STATUS_OPTIONS = [
|
||||
{ label: '启用', value: 1 },
|
||||
{ label: '禁用', value: 0 },
|
||||
];
|
||||
78
apps/web-antd/src/views/system/ai/platform/config/form.ts
Normal file
78
apps/web-antd/src/views/system/ai/platform/config/form.ts
Normal 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,
|
||||
};
|
||||
241
apps/web-antd/src/views/system/ai/platform/index.vue
Normal file
241
apps/web-antd/src/views/system/ai/platform/index.vue
Normal 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>
|
||||
29
apps/web-antd/src/views/system/golden-formula/api/index.ts
Normal file
29
apps/web-antd/src/views/system/golden-formula/api/index.ts
Normal 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);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,6 @@
|
||||
/** 金方模块常量 */
|
||||
|
||||
export const STATUS_OPTIONS = [
|
||||
{ label: '启用', value: 1 },
|
||||
{ label: '禁用', value: 0 },
|
||||
];
|
||||
130
apps/web-antd/src/views/system/golden-formula/config/form.ts
Normal file
130
apps/web-antd/src/views/system/golden-formula/config/form.ts
Normal 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,
|
||||
};
|
||||
231
apps/web-antd/src/views/system/golden-formula/index.vue
Normal file
231
apps/web-antd/src/views/system/golden-formula/index.vue
Normal 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>
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user