feat: 病历模块优化、AI辅助问诊、接入deepseek
This commit is contained in:
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