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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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