feat: 新增 WidgetAiDailyBrief.vue:快照统计 pill(口径取自生成时的 data_snapshot,保证数字与 AI 文本一致)+ 分段正文(按【标题】切段,不规范输出兜底整段)+ 重新生成按钮;AI 生成失败时芯片仍展示、正文位置给重试。
每日首次弹窗:生成成功且 localStorage 当天未记录时自动弹全文,之后点「查看全文」随时再看;样式全部走主题变量,暗色自适配,卡片强调用规则要求的「1px 同色边框 + 微光」。
布局上简报作为全宽内容卡固定渲染在日程行与 KPI 区之间,不进 KPI 芯片集合。
This commit is contained in:
@@ -20,3 +20,27 @@ export async function getAiGenerationDetail(id: number) {
|
||||
export async function getAiGenerationUsageStats(data?: Record<string, any>) {
|
||||
return requestClient.get<any>(`${prefix}usage-stats`, { params: data || {} });
|
||||
}
|
||||
|
||||
/** 超管人工打分 */
|
||||
export async function reviewAiGeneration(data: {
|
||||
id: number;
|
||||
is_correct: number;
|
||||
quality_score: number;
|
||||
review_remark?: string;
|
||||
}) {
|
||||
return requestClient.post<any>(`${prefix}review`, data);
|
||||
}
|
||||
|
||||
/** 归档为金标准样本 */
|
||||
export async function archiveAiGeneration(data: {
|
||||
id: number;
|
||||
expect_json?: Record<string, any>;
|
||||
remark?: string;
|
||||
}) {
|
||||
return requestClient.post<any>(`${prefix}archive`, data);
|
||||
}
|
||||
|
||||
/** 导出已归档正确样本 JSONL */
|
||||
export async function exportAiTrainJsonl(data?: Record<string, any>) {
|
||||
return requestClient.post<any>(`${prefix}export-train-jsonl`, data || {});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* AI 生成记录「归档为金标准」独立弹窗
|
||||
* 列表/卡片/详情底部栏均可打开;需已打分才可提交
|
||||
*/
|
||||
import { reactive, ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { Button, Input, Spin, Tag, message } from 'ant-design-vue';
|
||||
|
||||
import { archiveAiGeneration, getAiGenerationDetail } from '../api';
|
||||
|
||||
const emit = defineEmits<{ success: [] }>();
|
||||
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const detail = ref<Record<string, any>>({});
|
||||
|
||||
const archiveForm = reactive({
|
||||
expect_syndrome: '',
|
||||
must_have_drugs: '',
|
||||
must_not_drugs: '',
|
||||
remark: '',
|
||||
});
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
title: '归档为金标准',
|
||||
class: 'w-[560px]',
|
||||
showConfirmButton: false,
|
||||
cancelText: '关闭',
|
||||
// 可叠在详情弹窗之上
|
||||
zIndex: 2000,
|
||||
async onOpenChange(isOpen) {
|
||||
if (!isOpen) {
|
||||
detail.value = {};
|
||||
return;
|
||||
}
|
||||
const data = modalApi.getData<{ id: number }>();
|
||||
const id = Number(data?.id || 0);
|
||||
if (!id) return;
|
||||
await loadDetail(id);
|
||||
},
|
||||
});
|
||||
|
||||
/** 拉取详情并清空归档表单 */
|
||||
async function loadDetail(id: number) {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getAiGenerationDetail(id);
|
||||
detail.value = res?.data || res || {};
|
||||
archiveForm.expect_syndrome = '';
|
||||
archiveForm.must_have_drugs = '';
|
||||
archiveForm.must_not_drugs = '';
|
||||
archiveForm.remark = '';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 顿号/逗号分隔 → 字符串数组 */
|
||||
function splitList(text: string): string[] {
|
||||
return String(text || '')
|
||||
.split(/[、,,;;\s]+/u)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
async function submitArchive() {
|
||||
const id = Number(detail.value.id || 0);
|
||||
if (!id) return;
|
||||
if (Number(detail.value.status) !== 1) {
|
||||
message.warning('仅成功的生成记录可归档');
|
||||
return;
|
||||
}
|
||||
if (Number(detail.value.is_correct) === 0 || Number(detail.value.quality_score) < 1) {
|
||||
message.warning('请先完成打分再归档');
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
await archiveAiGeneration({
|
||||
id,
|
||||
expect_json: {
|
||||
expect_syndrome: splitList(archiveForm.expect_syndrome),
|
||||
must_have_drugs: splitList(archiveForm.must_have_drugs),
|
||||
must_not_drugs: splitList(archiveForm.must_not_drugs),
|
||||
},
|
||||
remark: archiveForm.remark,
|
||||
});
|
||||
message.success('归档成功');
|
||||
emit('success');
|
||||
modalApi.close();
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal>
|
||||
<Spin :spinning="loading">
|
||||
<div class="mb-3 space-y-1 text-sm">
|
||||
<div>
|
||||
<span class="text-[hsl(var(--muted-foreground))]">记录 </span>
|
||||
#{{ detail.id || '—' }}
|
||||
<Tag
|
||||
v-if="detail.scene_txt || detail.scene"
|
||||
class="ml-2"
|
||||
color="processing"
|
||||
>
|
||||
{{ detail.scene_txt || detail.scene }}
|
||||
</Tag>
|
||||
<Tag
|
||||
v-if="Number(detail.is_archived) === 1"
|
||||
class="ml-1"
|
||||
color="success"
|
||||
>
|
||||
已归档
|
||||
</Tag>
|
||||
</div>
|
||||
<div class="text-[hsl(var(--muted-foreground))]">
|
||||
{{ detail.name || '—' }} ·
|
||||
{{ detail.correct_txt || '未评' }}
|
||||
<template v-if="Number(detail.quality_score) > 0">
|
||||
· {{ detail.quality_score }} 分
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="space-y-3 rounded border border-[hsl(var(--primary)/30%)] p-3 shadow-[0_0_6px_hsl(var(--primary)/18%)]"
|
||||
>
|
||||
<Input
|
||||
v-model:value="archiveForm.expect_syndrome"
|
||||
placeholder="期望证型(顿号/逗号分隔,可选)"
|
||||
allow-clear
|
||||
/>
|
||||
<Input
|
||||
v-model:value="archiveForm.must_have_drugs"
|
||||
placeholder="必含药材(顿号/逗号分隔,可选)"
|
||||
allow-clear
|
||||
/>
|
||||
<Input
|
||||
v-model:value="archiveForm.must_not_drugs"
|
||||
placeholder="禁含药材(顿号/逗号分隔,可选)"
|
||||
allow-clear
|
||||
/>
|
||||
<Input
|
||||
v-model:value="archiveForm.remark"
|
||||
placeholder="归档备注(可选)"
|
||||
allow-clear
|
||||
/>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<Button type="primary" :loading="saving" @click="submitArchive">
|
||||
{{ Number(detail.is_archived) === 1 ? '更新归档' : '归档' }}
|
||||
</Button>
|
||||
<span class="text-xs text-[hsl(var(--muted-foreground))]">
|
||||
归档后写入评测集,可供 ai:eval 与训练 JSONL 导出
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Spin>
|
||||
</Modal>
|
||||
</template>
|
||||
@@ -1,22 +1,31 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* AI 生成记录详情弹窗
|
||||
* 展示 input_snapshot / result_json / 错误与耗时,便于排障
|
||||
* 只负责查看;打分/归档通过 emit 交给页面级弹窗打开(Vben 规范:二级弹窗与一级同级挂载,避免嵌套层级错乱)
|
||||
*/
|
||||
import { ref } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { Descriptions, Spin, Tag } from 'ant-design-vue';
|
||||
import { Button, Descriptions, Spin, Tag, message } from 'ant-design-vue';
|
||||
|
||||
import { getAiGenerationDetail } from '../api';
|
||||
import GenerationFlowTimeline from './generation-flow-timeline.vue';
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** 打开页面级打分弹窗 */
|
||||
review: [row: Record<string, any>];
|
||||
/** 打开页面级归档弹窗 */
|
||||
archive: [row: Record<string, any>];
|
||||
}>();
|
||||
|
||||
const loading = ref(false);
|
||||
const detail = ref<Record<string, any>>({});
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
title: '生成记录详情',
|
||||
class: 'w-[820px]',
|
||||
class: 'w-[min(1280px,96vw)]',
|
||||
contentClass: 'detail-modal-body',
|
||||
showConfirmButton: false,
|
||||
cancelText: '关闭',
|
||||
async onOpenChange(isOpen) {
|
||||
@@ -27,13 +36,25 @@ const [Modal, modalApi] = useVbenModal({
|
||||
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;
|
||||
}
|
||||
await loadDetail(id);
|
||||
},
|
||||
});
|
||||
|
||||
async function loadDetail(id: number) {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getAiGenerationDetail(id);
|
||||
detail.value = res?.data || res || {};
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 供父页在打分/归档成功后刷新详情内容 */
|
||||
defineExpose({
|
||||
reload: () => {
|
||||
const id = Number(detail.value.id || modalApi.getData()?.id || 0);
|
||||
if (id) return loadDetail(id);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -57,47 +78,171 @@ function prettyJson(v: any) {
|
||||
return String(v ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
const canReview = computed(() => Number(detail.value.status) === 1);
|
||||
const canArchive = computed(
|
||||
() =>
|
||||
Number(detail.value.status) === 1 &&
|
||||
Number(detail.value.is_correct) !== 0 &&
|
||||
Number(detail.value.quality_score) > 0,
|
||||
);
|
||||
|
||||
function onReviewClick() {
|
||||
if (!canReview.value) {
|
||||
message.warning('仅成功的生成记录可打分');
|
||||
return;
|
||||
}
|
||||
emit('review', { ...detail.value });
|
||||
}
|
||||
|
||||
function onArchiveClick() {
|
||||
if (!canReview.value) {
|
||||
message.warning('仅成功的生成记录可归档');
|
||||
return;
|
||||
}
|
||||
if (!canArchive.value) {
|
||||
message.warning('请先完成打分再归档');
|
||||
return;
|
||||
}
|
||||
emit('archive', { ...detail.value });
|
||||
}
|
||||
</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>
|
||||
<div class="detail-scroll">
|
||||
<Descriptions bordered size="small" :column="3" 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="名称" :span="2">
|
||||
{{ detail.name || '—' }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="路径">
|
||||
<Tag :color="Number(detail.via_agent) === 1 ? 'gold' : 'default'">
|
||||
{{
|
||||
detail.via_agent_txt ||
|
||||
(Number(detail.via_agent) === 1 ? 'Agent' : '直连')
|
||||
}}
|
||||
</Tag>
|
||||
</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="采纳">{{ detail.adopted_txt || '—' }}</Descriptions.Item>
|
||||
<Descriptions.Item label="正确性">
|
||||
<Tag
|
||||
:color="
|
||||
Number(detail.is_correct) === 1
|
||||
? 'success'
|
||||
: Number(detail.is_correct) === 2
|
||||
? 'error'
|
||||
: 'default'
|
||||
"
|
||||
>
|
||||
{{ detail.correct_txt || '未评' }}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="得分">
|
||||
{{ Number(detail.quality_score) > 0 ? `${detail.quality_score} 分` : '—' }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="归档">
|
||||
<Tag :color="Number(detail.is_archived) === 1 ? 'success' : 'default'">
|
||||
{{ detail.archived_txt || '未归档' }}
|
||||
</Tag>
|
||||
<span
|
||||
v-if="Number(detail.eval_case_id) > 0"
|
||||
class="ml-2 text-xs text-[hsl(var(--muted-foreground))]"
|
||||
>
|
||||
case #{{ detail.eval_case_id }}
|
||||
</span>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="错误" :span="3">
|
||||
{{ detail.error_msg || '—' }}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
<div
|
||||
v-if="Array.isArray(detail.steps) && detail.steps.length"
|
||||
class="mt-3 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--muted)/18%)] px-2 py-3"
|
||||
>
|
||||
<div class="mb-2 text-xs text-[hsl(var(--muted-foreground))]">生成工作流</div>
|
||||
<GenerationFlowTimeline :row="detail" />
|
||||
</div>
|
||||
|
||||
<div class="mt-3 grid grid-cols-1 gap-3 lg:grid-cols-2">
|
||||
<div class="json-panel">
|
||||
<div class="json-title">输入快照</div>
|
||||
<pre class="json-pre">{{ prettyJson(detail.input_snapshot) }}</pre>
|
||||
</div>
|
||||
<div class="json-panel">
|
||||
<div class="json-title">结果 JSON</div>
|
||||
<pre class="json-pre">{{ prettyJson(detail.result_json) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Spin>
|
||||
|
||||
<template #center-footer>
|
||||
<Button type="primary" :disabled="!canReview" @click="onReviewClick">
|
||||
{{ Number(detail.quality_score) > 0 ? '修改打分' : '打分' }}
|
||||
</Button>
|
||||
<Button class="ml-2" :disabled="!canArchive" @click="onArchiveClick">
|
||||
{{ Number(detail.is_archived) === 1 ? '更新归档' : '归档为金标准' }}
|
||||
</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.detail-scroll {
|
||||
max-height: min(72vh, 780px);
|
||||
overflow: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.json-panel {
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 8px;
|
||||
background: hsl(var(--card, var(--background)));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.json-title {
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
background: hsl(var(--muted) / 20%);
|
||||
}
|
||||
|
||||
.json-pre {
|
||||
margin: 0;
|
||||
max-height: 220px;
|
||||
overflow: auto;
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* AI 生成记录卡片视图(一行两个)
|
||||
* 展示与列表对齐的完整信息:步骤轮次、药名未匹配、Tokens、耗时、错误等
|
||||
*/
|
||||
import { ref, watch } from 'vue';
|
||||
|
||||
import { Button, Pagination, Spin, Tag } from 'ant-design-vue';
|
||||
|
||||
import { getAiGenerationList } from '../api';
|
||||
import { formatDurationMs } from '../utils/format';
|
||||
import GenerationFlowTimeline from './generation-flow-timeline.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
/** 已 normalize 的筛选参数(不含 page) */
|
||||
filters: Record<string, any>;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
detail: [row: any];
|
||||
review: [row: any];
|
||||
archive: [row: any];
|
||||
}>();
|
||||
|
||||
const loading = ref(false);
|
||||
const items = ref<any[]>([]);
|
||||
const total = ref(0);
|
||||
const page = ref(1);
|
||||
const pageSize = ref(12);
|
||||
|
||||
function statusColor(status: number) {
|
||||
if (status === 1) return 'success';
|
||||
if (status === 2) return 'error';
|
||||
return 'processing';
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getAiGenerationList({
|
||||
page: page.value,
|
||||
pageSize: pageSize.value,
|
||||
...props.filters,
|
||||
});
|
||||
const data = res?.data || res || {};
|
||||
items.value = Array.isArray(data.items)
|
||||
? data.items
|
||||
: Array.isArray(data)
|
||||
? data
|
||||
: [];
|
||||
total.value = Number(data.total ?? items.value.length);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 筛选变化时回到第一页,避免空页 */
|
||||
watch(
|
||||
() => props.filters,
|
||||
() => {
|
||||
page.value = 1;
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => [props.filters, page.value, pageSize.value],
|
||||
() => {
|
||||
void load();
|
||||
},
|
||||
{ deep: true, immediate: true },
|
||||
);
|
||||
|
||||
function onPageChange(p: number, ps: number) {
|
||||
page.value = p;
|
||||
pageSize.value = ps;
|
||||
}
|
||||
|
||||
defineExpose({ reload: load });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Spin :spinning="loading">
|
||||
<div
|
||||
v-if="!items.length && !loading"
|
||||
class="py-16 text-center text-sm text-[hsl(var(--muted-foreground))]"
|
||||
>
|
||||
暂无生成记录
|
||||
</div>
|
||||
<div v-else class="grid grid-cols-1 gap-3 xl:grid-cols-2">
|
||||
<div v-for="row in items" :key="row.id" class="gen-card flex flex-col rounded-lg p-4">
|
||||
<div class="mb-3 flex items-start justify-between gap-2">
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-base font-medium text-[hsl(var(--foreground))]">
|
||||
{{ row.name || `记录 #${row.id}` }}
|
||||
</div>
|
||||
<div class="mt-0.5 text-xs text-[hsl(var(--muted-foreground))]">
|
||||
#{{ row.id }} · {{ row.scene_txt || row.scene || '—' }} ·
|
||||
{{ row.created_at || '—' }}
|
||||
</div>
|
||||
</div>
|
||||
<Tag :color="statusColor(Number(row.status))">
|
||||
{{ row.status_txt || row.status }}
|
||||
</Tag>
|
||||
</div>
|
||||
|
||||
<div class="mb-3 flex flex-wrap gap-1.5">
|
||||
<Tag :color="Number(row.via_agent) === 1 ? 'gold' : 'default'">
|
||||
{{ row.via_agent_txt || (Number(row.via_agent) === 1 ? 'Agent' : '直连') }}
|
||||
</Tag>
|
||||
<Tag
|
||||
v-if="Number(row.is_adopted) === 1"
|
||||
:color="Number(row.is_modified_final) === 1 ? 'processing' : 'success'"
|
||||
>
|
||||
{{ row.adopted_txt || '已采纳' }}
|
||||
</Tag>
|
||||
<Tag v-else>未采纳</Tag>
|
||||
<Tag v-if="Number(row.is_correct) === 1" color="success">正确</Tag>
|
||||
<Tag v-else-if="Number(row.is_correct) === 2" color="error">不正确</Tag>
|
||||
<Tag v-else>未评</Tag>
|
||||
<Tag v-if="Number(row.quality_score) > 0">{{ row.quality_score }} 分</Tag>
|
||||
<Tag v-if="Number(row.is_archived) === 1" color="success">已归档</Tag>
|
||||
</div>
|
||||
|
||||
<!-- 工作流时间轴:开始→生成→中间步骤(分节点)→返回→完成/是否导入 -->
|
||||
<div
|
||||
class="mb-3 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--muted)/18%)] px-2 py-3"
|
||||
>
|
||||
<GenerationFlowTimeline :row="row" />
|
||||
</div>
|
||||
|
||||
<!-- 关键指标:步骤轮次 / 药名未匹配 / Tokens / 耗时 -->
|
||||
<div class="mb-3 grid grid-cols-2 gap-2 text-sm sm:grid-cols-4">
|
||||
<div class="metric-cell">
|
||||
<div class="metric-label">Agent 步骤</div>
|
||||
<div class="metric-value">
|
||||
<template v-if="Number(row.step_count) > 0">
|
||||
{{ row.step_count }} 步
|
||||
</template>
|
||||
<template v-else>—</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric-cell">
|
||||
<div class="metric-label">药名未匹配</div>
|
||||
<div
|
||||
class="metric-value"
|
||||
:class="{
|
||||
'text-[hsl(var(--destructive))]': Number(row.match_drug_unmatched) > 0,
|
||||
}"
|
||||
>
|
||||
<template v-if="Number(row.match_drug_total) > 0">
|
||||
{{ row.match_drug_unmatched || 0 }}/{{ row.match_drug_total }}
|
||||
</template>
|
||||
<template v-else>—</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric-cell">
|
||||
<div class="metric-label">Tokens</div>
|
||||
<div class="metric-value" :title="`P ${row.prompt_tokens || 0} / C ${row.completion_tokens || 0}`">
|
||||
{{ row.total_tokens || 0 }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric-cell">
|
||||
<div class="metric-label">耗时</div>
|
||||
<div class="metric-value">
|
||||
{{ formatDurationMs(Number(row.duration_ms || 0)) || '—' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-2 space-y-1 text-xs text-[hsl(var(--muted-foreground))]">
|
||||
<div>
|
||||
模型:{{ row.provider || '—' }} / {{ row.model || '—' }}
|
||||
<template v-if="row.api_key_name"> · 密钥 {{ row.api_key_name }}</template>
|
||||
</div>
|
||||
<div v-if="row.platform_name || row.platform_code">
|
||||
平台:{{ row.platform_name || row.platform_code }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="row.error_msg"
|
||||
class="mb-3 line-clamp-2 rounded border border-[hsl(var(--destructive)/30%)] bg-[hsl(var(--destructive)/8%)] px-2 py-1 text-xs text-[hsl(var(--destructive))]"
|
||||
:title="row.error_msg"
|
||||
>
|
||||
错误:{{ row.error_msg }}
|
||||
</div>
|
||||
|
||||
<div class="mt-auto flex gap-3 border-t border-[hsl(var(--border))] pt-2">
|
||||
<Button size="small" type="link" class="!px-0" @click="emit('detail', row)">
|
||||
详情
|
||||
</Button>
|
||||
<Button
|
||||
v-if="Number(row.status) === 1"
|
||||
size="small"
|
||||
type="link"
|
||||
class="!px-0"
|
||||
@click="emit('review', row)"
|
||||
>
|
||||
打分
|
||||
</Button>
|
||||
<Button
|
||||
v-if="
|
||||
Number(row.status) === 1 &&
|
||||
Number(row.is_correct) !== 0 &&
|
||||
Number(row.quality_score) > 0
|
||||
"
|
||||
size="small"
|
||||
type="link"
|
||||
class="!px-0"
|
||||
@click="emit('archive', row)"
|
||||
>
|
||||
归档
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="total > 0" class="mt-4 flex justify-end">
|
||||
<Pagination
|
||||
:current="page"
|
||||
:page-size="pageSize"
|
||||
:total="total"
|
||||
:page-size-options="['12', '24', '48']"
|
||||
show-size-changer
|
||||
show-quick-jumper
|
||||
@change="onPageChange"
|
||||
/>
|
||||
</div>
|
||||
</Spin>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.gen-card {
|
||||
border: 1px solid hsl(var(--primary) / 28%);
|
||||
background: hsl(var(--card, var(--background)));
|
||||
box-shadow: 0 0 6px hsl(var(--primary) / 16%);
|
||||
}
|
||||
|
||||
.metric-cell {
|
||||
border-radius: 8px;
|
||||
border: 1px solid hsl(var(--border));
|
||||
background: hsl(var(--muted) / 25%);
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
font-size: 11px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
margin-top: 2px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,499 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* AI 生成工作流时间轴(卡片内)
|
||||
* 骨架:开始 → 生成 →(Agent 步骤)→ 返回 → 完成/失败
|
||||
* 之后按时间插入:打分、放入金方(有才展示,谁先发生谁先排)
|
||||
* 完成/失败节点下方展示是否导入(采纳)
|
||||
*/
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { formatDurationMs } from '../utils/format';
|
||||
|
||||
type FlowState = 'done' | 'fail' | 'pending' | 'running';
|
||||
|
||||
interface FlowNode {
|
||||
key: string;
|
||||
title: string;
|
||||
sub?: string;
|
||||
state: FlowState;
|
||||
/** 排序用 unix 秒;固定骨架节点可不填 */
|
||||
at?: number;
|
||||
/** 完成/失败节点下方展示导入结果 */
|
||||
adoptTip?: string;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
row: Record<string, any>;
|
||||
}>();
|
||||
|
||||
/** 任意时间字段 → unix 秒,便于打分/归档按先后排序 */
|
||||
function toUnix(v: unknown): number {
|
||||
if (v === null || v === undefined || v === '' || v === 0 || v === '0') return 0;
|
||||
if (typeof v === 'string' && v.includes('-')) {
|
||||
const t = Date.parse(v.replace(/-/g, '/'));
|
||||
return Number.isNaN(t) ? 0 : Math.floor(t / 1000);
|
||||
}
|
||||
const n = Number(v);
|
||||
return !n || Number.isNaN(n) ? 0 : n;
|
||||
}
|
||||
|
||||
/** unix 秒或已格式化字符串 → 短时间文案 */
|
||||
function formatAt(v: unknown): string {
|
||||
if (v === null || v === undefined || v === '' || v === 0 || v === '0') return '';
|
||||
if (typeof v === 'string' && v.includes('-')) {
|
||||
return v.length > 16 ? v.slice(5, 19) : v;
|
||||
}
|
||||
const n = Number(v);
|
||||
if (!n || Number.isNaN(n)) return '';
|
||||
const d = new Date(n * 1000);
|
||||
if (Number.isNaN(d.getTime())) return '';
|
||||
const pad = (x: number) => String(x).padStart(2, '0');
|
||||
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||
}
|
||||
|
||||
function stepState(status: number): FlowState {
|
||||
if (status === 1) return 'done';
|
||||
if (status === 2) return 'fail';
|
||||
if (status === 0) return 'running';
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
/** 根据主记录 + steps 拼工作流节点(中间步骤各自独立节点) */
|
||||
const nodes = computed<FlowNode[]>(() => {
|
||||
const row = props.row || {};
|
||||
const status = Number(row.status || 0);
|
||||
const started = formatAt(row.started_at || row.created_at);
|
||||
const finished = formatAt(row.finished_at);
|
||||
const duration = formatDurationMs(Number(row.duration_ms || 0));
|
||||
const list: FlowNode[] = [];
|
||||
|
||||
list.push({
|
||||
key: 'start',
|
||||
title: '开始',
|
||||
sub: started || '—',
|
||||
state: status === 0 && !row.finished_at ? 'running' : 'done',
|
||||
});
|
||||
|
||||
list.push({
|
||||
key: 'generate',
|
||||
title: '生成',
|
||||
sub:
|
||||
Number(row.via_agent) === 1
|
||||
? 'Agent 中转'
|
||||
: row.provider
|
||||
? `${row.provider}`
|
||||
: '直连模型',
|
||||
state: status === 0 ? 'running' : 'done',
|
||||
});
|
||||
|
||||
const steps = Array.isArray(row.steps) ? row.steps : [];
|
||||
steps.forEach((s: any, idx: number) => {
|
||||
const st = Number(s.status || 0);
|
||||
const dur = formatDurationMs(Number(s.duration_ms || 0));
|
||||
const detail = String(s.detail || '').trim();
|
||||
const subParts: string[] = [];
|
||||
if (dur) subParts.push(dur);
|
||||
if (Number(s.total_tokens) > 0) subParts.push(`${s.total_tokens} tok`);
|
||||
if (detail) subParts.push(detail.slice(0, 40));
|
||||
list.push({
|
||||
key: `step-${s.id || idx}`,
|
||||
title: s.step_type_txt || s.step_type || `步骤${idx + 1}`,
|
||||
sub: subParts.join(' · ') || formatAt(s.started_at) || undefined,
|
||||
state: stepState(st),
|
||||
});
|
||||
});
|
||||
|
||||
// 有结果回写或已结束才画「返回」
|
||||
const hasReturn = status === 1 || status === 2 || Number(row.finished_at) > 0;
|
||||
if (hasReturn || steps.length > 0) {
|
||||
list.push({
|
||||
key: 'return',
|
||||
title: '返回',
|
||||
sub: finished || (status === 0 ? '等待中' : duration || undefined),
|
||||
state: status === 0 ? 'running' : status === 2 ? 'fail' : 'done',
|
||||
});
|
||||
}
|
||||
|
||||
const adoptTip =
|
||||
Number(row.is_adopted) === 1
|
||||
? row.adopted_txt || '已导入'
|
||||
: '未导入';
|
||||
// 失败结尾写「失败」,成功写「完成」
|
||||
if (status === 2) {
|
||||
list.push({
|
||||
key: 'finish',
|
||||
title: '失败',
|
||||
sub: row.error_msg
|
||||
? String(row.error_msg).slice(0, 28)
|
||||
: duration || finished || undefined,
|
||||
state: 'fail',
|
||||
adoptTip,
|
||||
});
|
||||
} else if (status === 1) {
|
||||
list.push({
|
||||
key: 'finish',
|
||||
title: '完成',
|
||||
sub: `成功${duration ? ` · ${duration}` : ''}`,
|
||||
state: 'done',
|
||||
adoptTip,
|
||||
});
|
||||
} else {
|
||||
list.push({
|
||||
key: 'finish',
|
||||
title: '进行中',
|
||||
sub: '等待结果',
|
||||
state: 'running',
|
||||
adoptTip,
|
||||
});
|
||||
}
|
||||
|
||||
// 打分 / 放入金方:有才展示,按 reviewed_at / archived_at 先后插入
|
||||
const extras: FlowNode[] = [];
|
||||
const reviewedAt = toUnix(row.reviewed_at);
|
||||
const hasReview =
|
||||
reviewedAt > 0 || Number(row.quality_score) > 0 || Number(row.is_correct) > 0;
|
||||
if (hasReview) {
|
||||
const scoreParts: string[] = [];
|
||||
if (row.correct_txt) scoreParts.push(String(row.correct_txt));
|
||||
if (Number(row.quality_score) > 0) scoreParts.push(`${row.quality_score} 分`);
|
||||
const t = formatAt(row.reviewed_at);
|
||||
if (t) scoreParts.push(t);
|
||||
extras.push({
|
||||
key: 'review',
|
||||
title: '打分',
|
||||
sub: scoreParts.join(' · ') || undefined,
|
||||
state: Number(row.is_correct) === 2 ? 'fail' : 'done',
|
||||
at: reviewedAt || Number.MAX_SAFE_INTEGER - 1,
|
||||
});
|
||||
}
|
||||
const archivedAt = toUnix(row.archived_at);
|
||||
const hasArchive = Number(row.is_archived) === 1 || archivedAt > 0;
|
||||
if (hasArchive) {
|
||||
const archParts: string[] = [];
|
||||
if (Number(row.eval_case_id) > 0) archParts.push(`case #${row.eval_case_id}`);
|
||||
const t = formatAt(row.archived_at);
|
||||
if (t) archParts.push(t);
|
||||
extras.push({
|
||||
key: 'archive',
|
||||
title: '放入金方',
|
||||
sub: archParts.join(' · ') || '已归档',
|
||||
state: 'done',
|
||||
at: archivedAt || Number.MAX_SAFE_INTEGER,
|
||||
});
|
||||
}
|
||||
extras.sort((a, b) => (a.at || 0) - (b.at || 0));
|
||||
list.push(...extras);
|
||||
|
||||
return list;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flow-wrap" aria-label="生成工作流">
|
||||
<div class="flow-track">
|
||||
<div
|
||||
v-for="(n, i) in nodes"
|
||||
:key="n.key"
|
||||
class="flow-item"
|
||||
:class="[`is-${n.state}`, { 'is-last': i === nodes.length - 1 }]"
|
||||
>
|
||||
<div class="flow-rail">
|
||||
<span class="flow-dot" />
|
||||
<!-- 末节点粒子微光:与未读卡片同色系语言 -->
|
||||
<template v-if="i === nodes.length - 1">
|
||||
<span class="flow-spark s1" aria-hidden="true" />
|
||||
<span class="flow-spark s2" aria-hidden="true" />
|
||||
<span class="flow-spark s3" aria-hidden="true" />
|
||||
<span class="flow-spark s4" aria-hidden="true" />
|
||||
</template>
|
||||
<span v-if="i < nodes.length - 1" class="flow-line" />
|
||||
</div>
|
||||
<div class="flow-body">
|
||||
<div class="flow-title">{{ n.title }}</div>
|
||||
<div v-if="n.sub" class="flow-sub" :title="n.sub">{{ n.sub }}</div>
|
||||
<div v-if="n.adoptTip" class="flow-adopt" :class="{ ok: n.adoptTip !== '未导入' }">
|
||||
{{ n.adoptTip }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 沾满容器宽度;节点过多时自动换行,而不是横向撑出滚动条 */
|
||||
.flow-wrap {
|
||||
width: 100%;
|
||||
padding: 4px 2px 2px;
|
||||
}
|
||||
|
||||
.flow-track {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
row-gap: 12px;
|
||||
}
|
||||
|
||||
.flow-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
/* 均分一行剩余宽度;窄于 min 时换行 */
|
||||
flex: 1 1 96px;
|
||||
min-width: 88px;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.flow-rail {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 18px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.flow-dot {
|
||||
z-index: 1;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
border: 2px solid hsl(var(--primary));
|
||||
background: hsl(var(--card, var(--background)));
|
||||
box-shadow: 0 0 0 3px hsl(var(--primary) / 12%);
|
||||
}
|
||||
|
||||
.flow-line {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
right: -50%;
|
||||
top: 50%;
|
||||
height: 2px;
|
||||
transform: translateY(-50%);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
hsl(var(--primary) / 55%),
|
||||
hsl(var(--border))
|
||||
);
|
||||
}
|
||||
|
||||
.flow-item.is-last .flow-line {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 最后一个节点:外圈微光脉冲 + 飘散粒子 */
|
||||
.flow-item.is-last .flow-rail {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.flow-item.is-last .flow-dot {
|
||||
border-color: hsl(var(--primary));
|
||||
background: hsl(var(--primary) / 35%);
|
||||
box-shadow:
|
||||
0 0 0 1px hsl(var(--primary) / 18%),
|
||||
0 0 10px hsl(var(--primary) / 42%),
|
||||
0 0 18px hsl(var(--primary) / 22%);
|
||||
animation: flow-last-glow 2.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.flow-item.is-last.is-fail .flow-dot {
|
||||
border-color: hsl(var(--destructive));
|
||||
background: hsl(var(--destructive) / 30%);
|
||||
box-shadow:
|
||||
0 0 0 1px hsl(var(--destructive) / 18%),
|
||||
0 0 10px hsl(var(--destructive) / 40%),
|
||||
0 0 18px hsl(var(--destructive) / 20%);
|
||||
animation-name: flow-last-glow-fail;
|
||||
}
|
||||
|
||||
.flow-item.is-last .flow-title {
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.flow-item.is-last.is-fail .flow-title {
|
||||
color: hsl(var(--destructive));
|
||||
}
|
||||
|
||||
.flow-item.is-last .flow-adopt {
|
||||
animation: flow-last-glow 2.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.flow-spark {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: hsl(var(--primary) / 90%);
|
||||
box-shadow: 0 0 8px hsl(var(--primary) / 75%);
|
||||
pointer-events: none;
|
||||
animation: flow-spark 2.4s linear infinite;
|
||||
}
|
||||
|
||||
.flow-item.is-last.is-fail .flow-spark {
|
||||
background: hsl(var(--destructive) / 90%);
|
||||
box-shadow: 0 0 8px hsl(var(--destructive) / 70%);
|
||||
}
|
||||
|
||||
.flow-spark.s1 {
|
||||
top: 0;
|
||||
left: calc(50% - 14px);
|
||||
}
|
||||
|
||||
.flow-spark.s2 {
|
||||
top: 2px;
|
||||
left: calc(50% + 10px);
|
||||
animation-delay: 0.6s;
|
||||
}
|
||||
|
||||
.flow-spark.s3 {
|
||||
bottom: -2px;
|
||||
left: calc(50% - 8px);
|
||||
animation-delay: 1.2s;
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
}
|
||||
|
||||
.flow-spark.s4 {
|
||||
top: -2px;
|
||||
left: calc(50% + 2px);
|
||||
animation-delay: 1.8s;
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
}
|
||||
|
||||
.flow-body {
|
||||
text-align: center;
|
||||
padding: 0 6px;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.flow-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
color: hsl(var(--foreground));
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.flow-sub {
|
||||
margin-top: 2px;
|
||||
font-size: 10px;
|
||||
line-height: 1.35;
|
||||
color: hsl(var(--muted-foreground));
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.flow-adopt {
|
||||
margin-top: 4px;
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
font-size: 10px;
|
||||
line-height: 16px;
|
||||
border: 1px solid hsl(var(--border));
|
||||
color: hsl(var(--muted-foreground));
|
||||
background: hsl(var(--muted) / 30%);
|
||||
}
|
||||
|
||||
.flow-adopt.ok {
|
||||
border-color: hsl(var(--primary) / 35%);
|
||||
color: hsl(var(--primary));
|
||||
background: hsl(var(--primary) / 10%);
|
||||
box-shadow: 0 0 6px hsl(var(--primary) / 14%);
|
||||
}
|
||||
|
||||
.flow-item.is-fail .flow-dot {
|
||||
border-color: hsl(var(--destructive));
|
||||
box-shadow: 0 0 0 3px hsl(var(--destructive) / 14%);
|
||||
}
|
||||
|
||||
.flow-item.is-fail .flow-title {
|
||||
color: hsl(var(--destructive));
|
||||
}
|
||||
|
||||
.flow-item.is-fail .flow-line {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
hsl(var(--destructive) / 45%),
|
||||
hsl(var(--border))
|
||||
);
|
||||
}
|
||||
|
||||
.flow-item.is-running:not(.is-last) .flow-dot {
|
||||
border-color: hsl(var(--warning, var(--primary)));
|
||||
background: hsl(var(--warning, var(--primary)) / 35%);
|
||||
animation: flow-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.flow-item.is-pending .flow-dot {
|
||||
border-color: hsl(var(--muted-foreground) / 45%);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
@keyframes flow-pulse {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 3px hsl(var(--primary) / 10%);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 5px hsl(var(--primary) / 22%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes flow-last-glow {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow:
|
||||
0 0 0 1px hsl(var(--primary) / 12%),
|
||||
0 0 8px hsl(var(--primary) / 28%),
|
||||
0 0 14px hsl(var(--primary) / 12%);
|
||||
}
|
||||
50% {
|
||||
box-shadow:
|
||||
0 0 0 1px hsl(var(--primary) / 28%),
|
||||
0 0 14px hsl(var(--primary) / 48%),
|
||||
0 0 24px hsl(var(--primary) / 22%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes flow-last-glow-fail {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow:
|
||||
0 0 0 1px hsl(var(--destructive) / 12%),
|
||||
0 0 8px hsl(var(--destructive) / 26%),
|
||||
0 0 14px hsl(var(--destructive) / 12%);
|
||||
}
|
||||
50% {
|
||||
box-shadow:
|
||||
0 0 0 1px hsl(var(--destructive) / 26%),
|
||||
0 0 14px hsl(var(--destructive) / 44%),
|
||||
0 0 24px hsl(var(--destructive) / 20%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes flow-spark {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translate(0, 0) scale(0.5);
|
||||
}
|
||||
18% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate(12px, -14px) scale(0.15);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,141 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* AI 生成记录独立打分弹窗
|
||||
* 列表/卡片/详情均可打开,提交后 emit success 供父页刷新
|
||||
*/
|
||||
import { reactive, ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
InputNumber,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
Spin,
|
||||
Tag,
|
||||
message,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import { getAiGenerationDetail, reviewAiGeneration } from '../api';
|
||||
import { AI_REVIEW_CORRECT_OPTIONS } from '../config/constants';
|
||||
|
||||
const emit = defineEmits<{ success: [] }>();
|
||||
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const detail = ref<Record<string, any>>({});
|
||||
|
||||
const reviewForm = reactive({
|
||||
is_correct: 1 as number,
|
||||
quality_score: 5 as number,
|
||||
review_remark: '',
|
||||
});
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
title: '人工打分',
|
||||
class: 'w-[520px]',
|
||||
showConfirmButton: false,
|
||||
cancelText: '关闭',
|
||||
// 可叠在详情弹窗之上(与 ProxyPayQrcodeModal 同思路:二级高于一级默认 1000)
|
||||
zIndex: 2000,
|
||||
async onOpenChange(isOpen) {
|
||||
if (!isOpen) {
|
||||
detail.value = {};
|
||||
return;
|
||||
}
|
||||
const data = modalApi.getData<{ id: number }>();
|
||||
const id = Number(data?.id || 0);
|
||||
if (!id) return;
|
||||
await loadDetail(id);
|
||||
},
|
||||
});
|
||||
|
||||
/** 拉取详情并回填打分表单 */
|
||||
async function loadDetail(id: number) {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getAiGenerationDetail(id);
|
||||
const row = res?.data || res || {};
|
||||
detail.value = row;
|
||||
reviewForm.is_correct = Number(row.is_correct) === 2 ? 2 : 1;
|
||||
reviewForm.quality_score =
|
||||
Number(row.quality_score) > 0 ? Number(row.quality_score) : 5;
|
||||
reviewForm.review_remark = String(row.review_remark || '');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitReview() {
|
||||
const id = Number(detail.value.id || 0);
|
||||
if (!id) return;
|
||||
if (Number(detail.value.status) !== 1) {
|
||||
message.warning('仅成功的生成记录可打分');
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
const res = await reviewAiGeneration({
|
||||
id,
|
||||
is_correct: Number(reviewForm.is_correct),
|
||||
quality_score: Number(reviewForm.quality_score),
|
||||
review_remark: reviewForm.review_remark,
|
||||
});
|
||||
detail.value = res?.data || res || detail.value;
|
||||
message.success('打分成功');
|
||||
emit('success');
|
||||
modalApi.close();
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal>
|
||||
<Spin :spinning="loading">
|
||||
<div class="mb-3 space-y-1 text-sm">
|
||||
<div>
|
||||
<span class="text-[hsl(var(--muted-foreground))]">记录 </span>
|
||||
#{{ detail.id || '—' }}
|
||||
<Tag
|
||||
v-if="detail.scene_txt || detail.scene"
|
||||
class="ml-2"
|
||||
color="processing"
|
||||
>
|
||||
{{ detail.scene_txt || detail.scene }}
|
||||
</Tag>
|
||||
</div>
|
||||
<div class="text-[hsl(var(--muted-foreground))]">
|
||||
{{ detail.name || '—' }} · 采纳:{{ detail.adopted_txt || '未采纳' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-3 rounded border border-[hsl(var(--border))] p-3">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<span class="text-sm text-[hsl(var(--muted-foreground))]">是否正确</span>
|
||||
<RadioGroup v-model:value="reviewForm.is_correct">
|
||||
<Radio
|
||||
v-for="opt in AI_REVIEW_CORRECT_OPTIONS"
|
||||
:key="opt.value"
|
||||
:value="opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</Radio>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<span class="text-sm text-[hsl(var(--muted-foreground))]">得分(1-5)</span>
|
||||
<InputNumber v-model:value="reviewForm.quality_score" :min="1" :max="5" />
|
||||
</div>
|
||||
<Input
|
||||
v-model:value="reviewForm.review_remark"
|
||||
placeholder="审阅备注(可选)"
|
||||
allow-clear
|
||||
/>
|
||||
<Button type="primary" :loading="saving" @click="submitReview">保存打分</Button>
|
||||
</div>
|
||||
</Spin>
|
||||
</Modal>
|
||||
</template>
|
||||
@@ -11,3 +11,43 @@ export const AI_GENERATION_SCENE_OPTIONS = [
|
||||
{ label: '写病历', value: 'medical_record' },
|
||||
{ label: '出方', value: 'prescription' },
|
||||
];
|
||||
|
||||
/** 生成路径:Go Agent 中转 vs PHP 直连 */
|
||||
export const AI_GENERATION_VIA_AGENT_OPTIONS = [
|
||||
{ label: 'Go Agent', value: 1 },
|
||||
{ label: 'PHP 直连', value: 0 },
|
||||
];
|
||||
|
||||
/** 人工正确性 */
|
||||
export const AI_GENERATION_CORRECT_OPTIONS = [
|
||||
{ label: '未评', value: 0 },
|
||||
{ label: '正确', value: 1 },
|
||||
{ label: '不正确', value: 2 },
|
||||
];
|
||||
|
||||
/** 归档状态 */
|
||||
export const AI_GENERATION_ARCHIVED_OPTIONS = [
|
||||
{ label: '未归档', value: 0 },
|
||||
{ label: '已归档', value: 1 },
|
||||
];
|
||||
|
||||
/** 是否采纳(医生导入后)——用于快速找到可打分数据 */
|
||||
export const AI_GENERATION_ADOPTED_OPTIONS = [
|
||||
{ label: '已采纳', value: 1 },
|
||||
{ label: '未采纳', value: 0 },
|
||||
];
|
||||
|
||||
/** 打分表单用:是否正确(不含未评) */
|
||||
export const AI_REVIEW_CORRECT_OPTIONS = [
|
||||
{ label: '正确', value: 1 },
|
||||
{ label: '不正确', value: 2 },
|
||||
];
|
||||
|
||||
/** 1-5 分 */
|
||||
export const AI_QUALITY_SCORE_OPTIONS = [
|
||||
{ label: '1 分', value: 1 },
|
||||
{ label: '2 分', value: 2 },
|
||||
{ label: '3 分', value: 3 },
|
||||
{ label: '4 分', value: 4 },
|
||||
{ label: '5 分', value: 5 },
|
||||
];
|
||||
|
||||
@@ -2,17 +2,44 @@ import type { VbenFormProps } from '#/adapter/form';
|
||||
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { AI_GENERATION_SCENE_OPTIONS, AI_GENERATION_STATUS_OPTIONS } from './constants';
|
||||
import {
|
||||
AI_GENERATION_ADOPTED_OPTIONS,
|
||||
AI_GENERATION_ARCHIVED_OPTIONS,
|
||||
AI_GENERATION_CORRECT_OPTIONS,
|
||||
AI_GENERATION_SCENE_OPTIONS,
|
||||
AI_GENERATION_STATUS_OPTIONS,
|
||||
AI_GENERATION_VIA_AGENT_OPTIONS,
|
||||
} from './constants';
|
||||
|
||||
/**
|
||||
* AI 生成记录搜索表单
|
||||
* 平台/模型用下拉(options 在页面 onMounted 注入),时间范围映射为 start_time/end_time
|
||||
* 抽出 Grid 后必须显式多列栅格,否则默认一行一个字段占满屏
|
||||
* 平台/模型 options 在页面 onMounted 注入;时间范围映射为 start_time/end_time
|
||||
*/
|
||||
export const formOptions: VbenFormProps = {
|
||||
collapsed: false,
|
||||
// 默认收起只显示第一行,减少占位
|
||||
collapsed: true,
|
||||
commonConfig: {
|
||||
labelWidth: 70,
|
||||
componentProps: {
|
||||
class: 'w-full',
|
||||
},
|
||||
},
|
||||
wrapperClass:
|
||||
'grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5',
|
||||
schema: [
|
||||
{
|
||||
component: 'VbenInput',
|
||||
component: 'RangePicker',
|
||||
componentProps: {
|
||||
format: 'YYYY-MM-DD',
|
||||
valueFormat: 'YYYY-MM-DD',
|
||||
},
|
||||
defaultValue: undefined,
|
||||
fieldName: 'search_time',
|
||||
label: '时间范围',
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '名称 / 错误摘要',
|
||||
allowClear: true,
|
||||
@@ -22,27 +49,77 @@ export const formOptions: VbenFormProps = {
|
||||
label: '关键词',
|
||||
},
|
||||
{
|
||||
component: 'VbenSelect',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
placeholder: '状态',
|
||||
allowClear: true,
|
||||
options: AI_GENERATION_STATUS_OPTIONS,
|
||||
class: 'w-full',
|
||||
},
|
||||
defaultValue: undefined,
|
||||
fieldName: 'status',
|
||||
label: '状态',
|
||||
},
|
||||
{
|
||||
component: 'VbenSelect',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
placeholder: '场景',
|
||||
allowClear: true,
|
||||
options: AI_GENERATION_SCENE_OPTIONS,
|
||||
class: 'w-full',
|
||||
},
|
||||
defaultValue: undefined,
|
||||
fieldName: 'scene',
|
||||
label: '场景',
|
||||
},
|
||||
{
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
placeholder: '全部路径',
|
||||
allowClear: true,
|
||||
options: AI_GENERATION_VIA_AGENT_OPTIONS,
|
||||
class: 'w-full',
|
||||
},
|
||||
defaultValue: undefined,
|
||||
fieldName: 'via_agent',
|
||||
label: '路径',
|
||||
},
|
||||
{
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
placeholder: '是否采纳',
|
||||
allowClear: true,
|
||||
options: AI_GENERATION_ADOPTED_OPTIONS,
|
||||
class: 'w-full',
|
||||
},
|
||||
defaultValue: undefined,
|
||||
fieldName: 'is_adopted',
|
||||
label: '采纳',
|
||||
},
|
||||
{
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
placeholder: '正确性',
|
||||
allowClear: true,
|
||||
options: AI_GENERATION_CORRECT_OPTIONS,
|
||||
class: 'w-full',
|
||||
},
|
||||
defaultValue: undefined,
|
||||
fieldName: 'is_correct',
|
||||
label: '正确性',
|
||||
},
|
||||
{
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
placeholder: '归档',
|
||||
allowClear: true,
|
||||
options: AI_GENERATION_ARCHIVED_OPTIONS,
|
||||
class: 'w-full',
|
||||
},
|
||||
defaultValue: undefined,
|
||||
fieldName: 'is_archived',
|
||||
label: '归档',
|
||||
},
|
||||
{
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
@@ -51,6 +128,7 @@ export const formOptions: VbenFormProps = {
|
||||
showSearch: true,
|
||||
optionFilterProp: 'label',
|
||||
options: [],
|
||||
class: 'w-full',
|
||||
},
|
||||
defaultValue: undefined,
|
||||
fieldName: 'platform_id',
|
||||
@@ -64,21 +142,12 @@ export const formOptions: VbenFormProps = {
|
||||
showSearch: true,
|
||||
optionFilterProp: 'label',
|
||||
options: [],
|
||||
class: 'w-full',
|
||||
},
|
||||
defaultValue: undefined,
|
||||
fieldName: 'model',
|
||||
label: '模型',
|
||||
},
|
||||
{
|
||||
component: 'RangePicker',
|
||||
componentProps: {
|
||||
format: 'YYYY-MM-DD',
|
||||
valueFormat: 'YYYY-MM-DD',
|
||||
},
|
||||
defaultValue: undefined,
|
||||
fieldName: 'search_time',
|
||||
label: '时间范围',
|
||||
},
|
||||
],
|
||||
showCollapseButton: true,
|
||||
submitButtonOptions: {
|
||||
|
||||
@@ -11,6 +11,8 @@ interface RowType {
|
||||
provider: string;
|
||||
model: string;
|
||||
api_key_name: string;
|
||||
via_agent: number;
|
||||
via_agent_txt: string;
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
@@ -21,6 +23,11 @@ interface RowType {
|
||||
is_adopted: number;
|
||||
is_modified_final: number;
|
||||
adopted_txt: string;
|
||||
is_correct: number;
|
||||
correct_txt: string;
|
||||
quality_score: number;
|
||||
is_archived: number;
|
||||
archived_txt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -47,6 +54,13 @@ export const gridOptions: VxeGridProps<RowType> = {
|
||||
width: 90,
|
||||
slots: { default: 'status' },
|
||||
},
|
||||
{
|
||||
field: 'via_agent_txt',
|
||||
align: 'left',
|
||||
title: '路径',
|
||||
width: 90,
|
||||
slots: { default: 'via_agent' },
|
||||
},
|
||||
{ field: 'provider', align: 'left', title: '供应商', width: 100 },
|
||||
{ field: 'model', align: 'left', title: '模型', minWidth: 120 },
|
||||
{ field: 'api_key_name', align: 'left', title: '密钥', minWidth: 110 },
|
||||
@@ -64,6 +78,22 @@ export const gridOptions: VxeGridProps<RowType> = {
|
||||
width: 90,
|
||||
slots: { default: 'duration' },
|
||||
},
|
||||
{
|
||||
// Agent 路径:Go 回传 steps 条数,直观看「跑了几轮/几步」
|
||||
field: 'step_count',
|
||||
align: 'right',
|
||||
title: '步骤',
|
||||
width: 72,
|
||||
slots: { default: 'steps' },
|
||||
},
|
||||
{
|
||||
// 处方场景药名对照:未匹配数/总数,未匹配即「错了几次」
|
||||
field: 'match_drug_unmatched',
|
||||
align: 'left',
|
||||
title: '药名匹配',
|
||||
width: 110,
|
||||
slots: { default: 'match' },
|
||||
},
|
||||
{
|
||||
// P4:医生采纳情况(未采纳/直接采纳/改后采纳),直接采纳是质量最好的信号
|
||||
field: 'adopted_txt',
|
||||
@@ -72,6 +102,27 @@ export const gridOptions: VxeGridProps<RowType> = {
|
||||
width: 96,
|
||||
slots: { default: 'adopted' },
|
||||
},
|
||||
{
|
||||
field: 'correct_txt',
|
||||
align: 'left',
|
||||
title: '正确性',
|
||||
width: 88,
|
||||
slots: { default: 'correct' },
|
||||
},
|
||||
{
|
||||
field: 'quality_score',
|
||||
align: 'left',
|
||||
title: '得分',
|
||||
width: 70,
|
||||
slots: { default: 'score' },
|
||||
},
|
||||
{
|
||||
field: 'archived_txt',
|
||||
align: 'left',
|
||||
title: '归档',
|
||||
width: 88,
|
||||
slots: { default: 'archived' },
|
||||
},
|
||||
{
|
||||
field: 'error_msg',
|
||||
align: 'left',
|
||||
@@ -84,7 +135,7 @@ export const gridOptions: VxeGridProps<RowType> = {
|
||||
type: 'html',
|
||||
title: '操作',
|
||||
slots: { default: 'action' },
|
||||
width: 100,
|
||||
width: 168,
|
||||
fixed: 'right',
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,28 +1,42 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* AI 生成记录列表页
|
||||
* 顶部用量摘要(随当前筛选刷新)+ VxeGrid 明细
|
||||
* 平台/模型筛选下拉:从 option 接口注入,选平台后联动刷新模型列表
|
||||
* 顶部用量摘要 + 抽出搜索表单 + 列表/卡片双视图
|
||||
* 打分可从列表、卡片、详情打开同一 ReviewModal
|
||||
*/
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue';
|
||||
|
||||
import { Page, useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { Card, Col, Row, Tag } from 'ant-design-vue';
|
||||
import { Button, Card, Col, Row, Tag, message } from 'ant-design-vue';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { TableAction } from '#/components/table-action';
|
||||
import { useViewMode, ViewModeSwitch } from '#/components/view-mode-switch';
|
||||
|
||||
import { getAiModelOption } from '../platform/api/model';
|
||||
import { getAiPlatformOption } from '../platform/api';
|
||||
import { getAiGenerationList, getAiGenerationUsageStats } from './api';
|
||||
import {
|
||||
exportAiTrainJsonl,
|
||||
getAiGenerationList,
|
||||
getAiGenerationUsageStats,
|
||||
} from './api';
|
||||
import DetailModal from './components/detail-modal.vue';
|
||||
import GenerationCardList from './components/generation-card-list.vue';
|
||||
import ArchiveModal from './components/archive-modal.vue';
|
||||
import ReviewModal from './components/review-modal.vue';
|
||||
import { formOptions, normalizeAiGenerationFilters } from './config/search';
|
||||
import { gridOptions } from './config/table';
|
||||
import { gridOptions as baseGridOptions } from './config/table';
|
||||
import { formatDurationMs } from './utils/format';
|
||||
|
||||
defineOptions({ name: 'AiGeneration' });
|
||||
|
||||
const exporting = ref(false);
|
||||
/** 列表/卡片双视图,localStorage 记忆偏好 */
|
||||
const viewMode = useViewMode('ai-generation-view-mode');
|
||||
const cardListRef = ref<InstanceType<typeof GenerationCardList> | null>(null);
|
||||
|
||||
const stats = reactive({
|
||||
total_tokens: 0,
|
||||
prompt_tokens: 0,
|
||||
@@ -32,7 +46,6 @@ const stats = reactive({
|
||||
total_count: 0,
|
||||
success_count: 0,
|
||||
fail_count: 0,
|
||||
// P4 质量指标:采纳率/直接采纳率/修改率/药名未匹配率/人均重生成
|
||||
adopted_count: 0,
|
||||
adoption_rate: 0,
|
||||
direct_rate: 0,
|
||||
@@ -42,14 +55,30 @@ const stats = reactive({
|
||||
});
|
||||
|
||||
/** 全量模型 option,选平台时按 platform_id 过滤 */
|
||||
const allModelOptions = ref<Array<{ label: string; value: string; platform_id?: number }>>([]);
|
||||
const allModelOptions = ref<
|
||||
Array<{ label: string; value: string; platform_id?: number }>
|
||||
>([]);
|
||||
|
||||
/** 从 schema 取 defaultValue,保证首屏列表/卡片筛选口径一致 */
|
||||
function collectDefaultSearchValues(): Record<string, any> {
|
||||
const values: Record<string, any> = {};
|
||||
for (const item of formOptions.schema ?? []) {
|
||||
if (item.fieldName && item.defaultValue !== undefined) {
|
||||
values[item.fieldName] = item.defaultValue;
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
/** 双视图共用的原始表单值(未 normalize) */
|
||||
const searchValues = ref<Record<string, any>>(collectDefaultSearchValues());
|
||||
/** 卡片列表用已 normalize 的筛选,computed 避免模板每次新建对象导致死循环重拉 */
|
||||
const cardFilters = computed(() => normalizeAiGenerationFilters(searchValues.value));
|
||||
|
||||
/** 把毫秒格式化为秒文案 */
|
||||
function formatDuration(ms: number) {
|
||||
return formatDurationMs(ms);
|
||||
}
|
||||
|
||||
/** 按当前搜索表单刷新摘要卡片 */
|
||||
async function refreshStats(formValues?: Record<string, any>) {
|
||||
const params = normalizeAiGenerationFilters(formValues || {});
|
||||
try {
|
||||
@@ -80,7 +109,7 @@ function refreshModelSchema(platformId?: number) {
|
||||
platformId && platformId > 0
|
||||
? allModelOptions.value.filter((m) => Number(m.platform_id) === platformId)
|
||||
: allModelOptions.value;
|
||||
gridApi.formApi?.updateSchema?.([
|
||||
searchFormApi.updateSchema?.([
|
||||
{
|
||||
fieldName: 'model',
|
||||
componentProps: {
|
||||
@@ -94,36 +123,60 @@ function refreshModelSchema(platformId?: number) {
|
||||
]);
|
||||
}
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
formOptions: {
|
||||
...formOptions,
|
||||
schema: formOptions.schema?.map((item) =>
|
||||
item.fieldName === 'platform_id'
|
||||
? {
|
||||
...item,
|
||||
componentProps: {
|
||||
...item.componentProps,
|
||||
onChange: (val: number | undefined) => {
|
||||
// 切换平台时清掉已选模型,避免跨平台残留
|
||||
gridApi.formApi?.setFieldValue?.('model', undefined);
|
||||
refreshModelSchema(val ? Number(val) : undefined);
|
||||
},
|
||||
/**
|
||||
* 顶部统一搜索:列表/卡片共用
|
||||
* 抽出后 Grid 可直接挂在 Page 下,height:auto 才能撑满剩余高度
|
||||
*/
|
||||
const [SearchForm, searchFormApi] = useVbenForm({
|
||||
...formOptions,
|
||||
schema: formOptions.schema?.map((item) =>
|
||||
item.fieldName === 'platform_id'
|
||||
? {
|
||||
...item,
|
||||
componentProps: {
|
||||
...item.componentProps,
|
||||
onChange: (val: number | undefined) => {
|
||||
searchFormApi.setFieldValue?.('model', undefined);
|
||||
refreshModelSchema(val ? Number(val) : undefined);
|
||||
},
|
||||
}
|
||||
: item,
|
||||
),
|
||||
},
|
||||
}
|
||||
: item,
|
||||
),
|
||||
handleSubmit: async (values) => {
|
||||
searchValues.value = { ...(values || {}) };
|
||||
void refreshStats(searchValues.value);
|
||||
if (viewMode.value === 'list') {
|
||||
gridApi.reload();
|
||||
}
|
||||
},
|
||||
handleReset: async () => {
|
||||
await searchFormApi.reset();
|
||||
searchValues.value = collectDefaultSearchValues();
|
||||
void refreshStats(searchValues.value);
|
||||
if (viewMode.value === 'list') {
|
||||
gridApi.reload();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
gridOptions: {
|
||||
...gridOptions,
|
||||
...baseGridOptions,
|
||||
toolbarConfig: {
|
||||
...baseGridOptions.toolbarConfig,
|
||||
search: false,
|
||||
},
|
||||
proxyConfig: {
|
||||
...gridOptions.proxyConfig,
|
||||
...baseGridOptions.proxyConfig,
|
||||
ajax: {
|
||||
query: async ({ page }, formValues) => {
|
||||
void refreshStats(formValues);
|
||||
query: async ({ page }) => {
|
||||
const filters = normalizeAiGenerationFilters(searchValues.value);
|
||||
void refreshStats(searchValues.value);
|
||||
return await getAiGenerationList({
|
||||
page: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
...normalizeAiGenerationFilters(formValues || {}),
|
||||
...filters,
|
||||
});
|
||||
},
|
||||
},
|
||||
@@ -131,23 +184,119 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
||||
},
|
||||
});
|
||||
|
||||
/** 详情是否打开:打分/归档成功后用于刷新详情内容 */
|
||||
const openedDetailId = ref(0);
|
||||
/** 主动关开详情刷新时,跳过 onOpenChange 清空 id */
|
||||
let suppressDetailClear = false;
|
||||
|
||||
const [DetailModalComp, detailModalApi] = useVbenModal({
|
||||
connectedComponent: DetailModal,
|
||||
onOpenChange(isOpen) {
|
||||
if (!isOpen && !suppressDetailClear) openedDetailId.value = 0;
|
||||
},
|
||||
});
|
||||
|
||||
const [ReviewModalComp, reviewModalApi] = useVbenModal({
|
||||
connectedComponent: ReviewModal,
|
||||
});
|
||||
|
||||
const [ArchiveModalComp, archiveModalApi] = useVbenModal({
|
||||
connectedComponent: ArchiveModal,
|
||||
});
|
||||
|
||||
function openDetail(row: any) {
|
||||
detailModalApi.setData({ id: Number(row?.id || 0) });
|
||||
const id = Number(row?.id || 0);
|
||||
openedDetailId.value = id;
|
||||
detailModalApi.setData({ id });
|
||||
detailModalApi.open();
|
||||
}
|
||||
|
||||
function openReview(row: any) {
|
||||
if (Number(row?.status) !== 1) {
|
||||
message.warning('仅成功的生成记录可打分');
|
||||
return;
|
||||
}
|
||||
reviewModalApi.setData({ id: Number(row?.id || 0) });
|
||||
reviewModalApi.open();
|
||||
}
|
||||
|
||||
function openArchive(row: any) {
|
||||
if (Number(row?.status) !== 1) {
|
||||
message.warning('仅成功的生成记录可归档');
|
||||
return;
|
||||
}
|
||||
if (Number(row?.is_correct) === 0 || Number(row?.quality_score) < 1) {
|
||||
message.warning('请先完成打分再归档');
|
||||
return;
|
||||
}
|
||||
archiveModalApi.setData({ id: Number(row?.id || 0) });
|
||||
archiveModalApi.open();
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表/卡片/详情打分或归档成功后刷新
|
||||
* 二级弹窗由页面级挂载(与挂号详情打开处方同模式),保证层级正确
|
||||
*/
|
||||
async function onActionSuccess() {
|
||||
if (viewMode.value === 'card') {
|
||||
cardListRef.value?.reload?.();
|
||||
} else {
|
||||
gridApi.query();
|
||||
}
|
||||
const id = openedDetailId.value;
|
||||
if (id > 0) {
|
||||
suppressDetailClear = true;
|
||||
detailModalApi.close();
|
||||
await nextTick();
|
||||
openedDetailId.value = id;
|
||||
detailModalApi.setData({ id });
|
||||
detailModalApi.open();
|
||||
await nextTick();
|
||||
suppressDetailClear = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 切回列表时 Grid 被 v-if 重建,需主动拉数 */
|
||||
watch(viewMode, (mode, prev) => {
|
||||
if (mode === 'list' && prev === 'card') {
|
||||
gridApi.query();
|
||||
}
|
||||
});
|
||||
|
||||
function statusColor(status: number) {
|
||||
if (status === 1) return 'success';
|
||||
if (status === 2) return 'error';
|
||||
return 'processing';
|
||||
}
|
||||
|
||||
async function handleExportJsonl() {
|
||||
exporting.value = true;
|
||||
try {
|
||||
const formValues =
|
||||
(await searchFormApi.getValues?.()) || searchValues.value || {};
|
||||
const res = await exportAiTrainJsonl(normalizeAiGenerationFilters(formValues));
|
||||
const data = res?.data || res || {};
|
||||
const content = String(data.content || '');
|
||||
const filename = String(data.filename || `ai_train_${Date.now()}.jsonl`);
|
||||
const blob = new Blob([content], {
|
||||
type: 'application/x-ndjson;charset=utf-8',
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
message.success(`已导出 ${Number(data.count || 0)} 条`);
|
||||
} catch {
|
||||
// 错误由拦截器提示
|
||||
} finally {
|
||||
exporting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
void refreshStats();
|
||||
void refreshStats(searchValues.value);
|
||||
try {
|
||||
const [platformRes, modelRes] = await Promise.all([
|
||||
getAiPlatformOption(),
|
||||
@@ -162,7 +311,7 @@ onMounted(async () => {
|
||||
value: String(m.value ?? m.code ?? ''),
|
||||
platform_id: Number(m.platform_id || 0),
|
||||
}));
|
||||
gridApi.formApi?.updateSchema?.([
|
||||
searchFormApi.updateSchema?.([
|
||||
{
|
||||
fieldName: 'platform_id',
|
||||
componentProps: {
|
||||
@@ -172,7 +321,7 @@ onMounted(async () => {
|
||||
options: platforms,
|
||||
placeholder: '全部平台',
|
||||
onChange: (val: number | undefined) => {
|
||||
gridApi.formApi?.setFieldValue?.('model', undefined);
|
||||
searchFormApi.setFieldValue?.('model', undefined);
|
||||
refreshModelSchema(val ? Number(val) : undefined);
|
||||
},
|
||||
},
|
||||
@@ -187,7 +336,9 @@ onMounted(async () => {
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<DetailModalComp />
|
||||
<DetailModalComp @review="openReview" @archive="openArchive" />
|
||||
<ReviewModalComp @success="onActionSuccess" />
|
||||
<ArchiveModalComp @success="onActionSuccess" />
|
||||
<div class="mb-3">
|
||||
<Row :gutter="12">
|
||||
<Col :xs="12" :sm="8" :md="4">
|
||||
@@ -234,7 +385,6 @@ onMounted(async () => {
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
<!-- P4 质量指标行:度量 AI 生成质量,随筛选联动(如按场景/时间对比) -->
|
||||
<Row :gutter="12" class="mt-3">
|
||||
<Col :xs="12" :sm="8" :md="5">
|
||||
<Card size="small">
|
||||
@@ -290,12 +440,31 @@ onMounted(async () => {
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
<Grid>
|
||||
|
||||
<!-- 双视图共用搜索,避免包一层 div 破坏 Grid height:auto -->
|
||||
<div
|
||||
class="mb-3 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--card,var(--background)))] p-3"
|
||||
>
|
||||
<SearchForm />
|
||||
</div>
|
||||
<div class="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<ViewModeSwitch v-model="viewMode" />
|
||||
<Button type="primary" ghost :loading="exporting" @click="handleExportJsonl">
|
||||
导出训练 JSONL
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Grid v-if="viewMode === 'list'">
|
||||
<template #status="{ row }">
|
||||
<Tag :color="statusColor(Number(row.status))">
|
||||
{{ row.status_txt || row.status }}
|
||||
</Tag>
|
||||
</template>
|
||||
<template #via_agent="{ row }">
|
||||
<Tag :color="Number(row.via_agent) === 1 ? 'gold' : 'default'">
|
||||
{{ row.via_agent_txt || (Number(row.via_agent) === 1 ? 'Agent' : '直连') }}
|
||||
</Tag>
|
||||
</template>
|
||||
<template #tokens="{ row }">
|
||||
<span :title="`P ${row.prompt_tokens} / C ${row.completion_tokens}`">
|
||||
{{ row.total_tokens || 0 }}
|
||||
@@ -304,8 +473,24 @@ onMounted(async () => {
|
||||
<template #duration="{ row }">
|
||||
{{ formatDuration(Number(row.duration_ms || 0)) }}
|
||||
</template>
|
||||
<template #steps="{ row }">
|
||||
<span v-if="Number(row.step_count) > 0">{{ row.step_count }} 步</span>
|
||||
<span v-else class="text-xs text-[hsl(var(--muted-foreground))]">—</span>
|
||||
</template>
|
||||
<template #match="{ row }">
|
||||
<span
|
||||
v-if="Number(row.match_drug_total) > 0"
|
||||
:class="
|
||||
Number(row.match_drug_unmatched) > 0
|
||||
? 'text-[hsl(var(--destructive,var(--warning)))]'
|
||||
: ''
|
||||
"
|
||||
>
|
||||
未匹配 {{ row.match_drug_unmatched || 0 }}/{{ row.match_drug_total }}
|
||||
</span>
|
||||
<span v-else class="text-xs text-[hsl(var(--muted-foreground))]">—</span>
|
||||
</template>
|
||||
<template #adopted="{ row }">
|
||||
<!-- P4 采纳状态:直接采纳绿色(质量最好)、改后采纳蓝色、未采纳灰字 -->
|
||||
<Tag
|
||||
v-if="Number(row.is_adopted) === 1"
|
||||
:color="Number(row.is_modified_final) === 1 ? 'processing' : 'success'"
|
||||
@@ -314,17 +499,64 @@ onMounted(async () => {
|
||||
</Tag>
|
||||
<span v-else class="text-xs text-[hsl(var(--muted-foreground))]">未采纳</span>
|
||||
</template>
|
||||
<template #action="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '详情',
|
||||
type: 'link',
|
||||
onClick: () => openDetail(row),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
<template #correct="{ row }">
|
||||
<Tag v-if="Number(row.is_correct) === 1" color="success">正确</Tag>
|
||||
<Tag v-else-if="Number(row.is_correct) === 2" color="error">不正确</Tag>
|
||||
<span v-else class="text-xs text-[hsl(var(--muted-foreground))]">未评</span>
|
||||
</template>
|
||||
</Grid>
|
||||
<template #score="{ row }">
|
||||
<span v-if="Number(row.quality_score) > 0">{{ row.quality_score }}</span>
|
||||
<span v-else class="text-xs text-[hsl(var(--muted-foreground))]">—</span>
|
||||
</template>
|
||||
<template #archived="{ row }">
|
||||
<Tag v-if="Number(row.is_archived) === 1" color="success">已归档</Tag>
|
||||
<span v-else class="text-xs text-[hsl(var(--muted-foreground))]">未归档</span>
|
||||
</template>
|
||||
<template #action="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '详情',
|
||||
type: 'link',
|
||||
onClick: () => openDetail(row),
|
||||
},
|
||||
{
|
||||
label: '打分',
|
||||
type: 'link',
|
||||
ifShow: Number(row.status) === 1,
|
||||
onClick: () => openReview(row),
|
||||
},
|
||||
{
|
||||
label: '归档',
|
||||
type: 'link',
|
||||
ifShow:
|
||||
Number(row.status) === 1 &&
|
||||
Number(row.is_correct) !== 0 &&
|
||||
Number(row.quality_score) > 0,
|
||||
onClick: () => openArchive(row),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
|
||||
<div v-else class="gen-card-wrap">
|
||||
<GenerationCardList
|
||||
ref="cardListRef"
|
||||
:filters="cardFilters"
|
||||
@detail="openDetail"
|
||||
@review="openReview"
|
||||
@archive="openArchive"
|
||||
/>
|
||||
</div>
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 卡片区撑满摘要+搜索下方剩余高度,内部自行滚动 */
|
||||
.gen-card-wrap {
|
||||
height: calc(100% - 12px);
|
||||
min-height: 420px;
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -81,6 +81,8 @@ const aiAgentViaAgent = ref(false);
|
||||
const aiAgentBaseUrl = ref('http://127.0.0.1:18123');
|
||||
const aiAgentSecret = ref('');
|
||||
const aiAgentTesting = ref(false);
|
||||
/** 一次 AI 出方生成几份方案(1–5,默认 1;>1 需开启 Agent 中转并由 Go 并行) */
|
||||
const aiPrescriptionGenerateCount = ref(1);
|
||||
// 分组 1:ReAct 多轮循环
|
||||
const aiReactEnabled = ref(false);
|
||||
const aiReactMaxIterations = ref(3);
|
||||
@@ -242,6 +244,13 @@ async function loadAdvancedCfg() {
|
||||
aiAgentSecret.value = String(row.config_value || '');
|
||||
break;
|
||||
}
|
||||
case 'ai_prescription_generate_count': {
|
||||
// 钳制 1–5,非法回落默认 1
|
||||
const n = Number(row.config_value);
|
||||
aiPrescriptionGenerateCount.value =
|
||||
Number.isFinite(n) && n >= 1 ? Math.min(5, Math.floor(n)) : 1;
|
||||
break;
|
||||
}
|
||||
case 'ai_react_enabled': {
|
||||
aiReactEnabled.value = parseBoolAdv(row.config_value);
|
||||
break;
|
||||
@@ -316,6 +325,12 @@ async function handleSaveAdvanced() {
|
||||
{ config_key: 'ai_agent_via_agent', config_value: boolStr(aiAgentViaAgent.value) },
|
||||
{ config_key: 'ai_agent_base_url', config_value: aiAgentBaseUrl.value },
|
||||
{ config_key: 'ai_agent_secret', config_value: aiAgentSecret.value },
|
||||
{
|
||||
config_key: 'ai_prescription_generate_count',
|
||||
config_value: numStr(
|
||||
Math.min(5, Math.max(1, Math.floor(Number(aiPrescriptionGenerateCount.value) || 1))),
|
||||
),
|
||||
},
|
||||
// 分组 1:ReAct
|
||||
{ config_key: 'ai_react_enabled', config_value: boolStr(aiReactEnabled.value) },
|
||||
{ config_key: 'ai_react_max_iterations', config_value: numStr(aiReactMaxIterations.value) },
|
||||
@@ -488,15 +503,17 @@ onMounted(() => {
|
||||
</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="cfg-panel">
|
||||
<div class="cfg-panel-body !pt-2">
|
||||
<Spin :spinning="loading">
|
||||
<div class="ai-config-panel">
|
||||
<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"
|
||||
@@ -623,12 +640,6 @@ onMounted(() => {
|
||||
>
|
||||
当前平台暂无启用模型,请先到「AI平台管理」添加
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
<Button type="primary" :loading="saving" @click="handleSave">
|
||||
保存 AI 配置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.TabPane>
|
||||
|
||||
@@ -656,11 +667,6 @@ onMounted(() => {
|
||||
{{ opt.label }}
|
||||
</Checkbox>
|
||||
</Checkbox.Group>
|
||||
<div class="mt-6">
|
||||
<Button type="primary" :loading="mrSaving" @click="handleSaveMrFields">
|
||||
保存病历配置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.TabPane>
|
||||
|
||||
@@ -714,11 +720,6 @@ onMounted(() => {
|
||||
placeholder="我已知悉:…"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="featureCopies.length" class="mt-2">
|
||||
<Button type="primary" :loading="copySaving" @click="handleSaveCopies">
|
||||
保存 AI 功能文案
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.TabPane>
|
||||
|
||||
@@ -788,6 +789,18 @@ onMounted(() => {
|
||||
仅校验服务可达(GET /health),不验证密钥;密钥错误会在实际业务调用时报 401
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-4 max-w-xs">
|
||||
<div class="mb-1 text-sm text-[hsl(var(--foreground))]">一次生成处方份数</div>
|
||||
<InputNumber
|
||||
v-model:value="aiPrescriptionGenerateCount"
|
||||
:min="1"
|
||||
:max="5"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<div class="mt-1 text-xs text-[hsl(var(--muted-foreground))]">
|
||||
默认 1;设为 2–5 时由 Go Agent 并行生成多套方案供医生对比(需开启上方中转;单路失败不影响其它成功方案)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分组 1:ReAct 多轮推理循环 -->
|
||||
@@ -892,17 +905,47 @@ onMounted(() => {
|
||||
<div class="mt-1 text-xs text-[hsl(var(--muted-foreground))]">10 表示 10% 用户走 Agent 实验组</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button type="primary" :loading="advSaving" @click="handleSaveAdvanced">
|
||||
保存 Agent 高级配置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
</Tabs>
|
||||
</div>
|
||||
</Spin>
|
||||
</div>
|
||||
</Spin>
|
||||
<div class="cfg-panel-footer">
|
||||
<Button
|
||||
v-if="subTab === 'model'"
|
||||
type="primary"
|
||||
:loading="saving"
|
||||
@click="handleSave"
|
||||
>
|
||||
保存 AI 配置
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="subTab === 'mr'"
|
||||
type="primary"
|
||||
:loading="mrSaving"
|
||||
@click="handleSaveMrFields"
|
||||
>
|
||||
保存病历配置
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="subTab === 'copy'"
|
||||
type="primary"
|
||||
:loading="copySaving"
|
||||
@click="handleSaveCopies"
|
||||
>
|
||||
保存 AI 功能文案
|
||||
</Button>
|
||||
<Button
|
||||
v-else
|
||||
type="primary"
|
||||
:loading="advSaving"
|
||||
@click="handleSaveAdvanced"
|
||||
>
|
||||
保存 Agent 高级配置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 系统配置 - 字典搜索:匹配度处理端
|
||||
*/
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import { Button, Radio, message } from 'ant-design-vue';
|
||||
|
||||
import { getSystemConfigList, saveSystemConfig } from '../api';
|
||||
|
||||
defineOptions({ name: 'DictSearchConfigPanel' });
|
||||
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const dictSearchRankMode = ref<'backend' | 'frontend'>('backend');
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const list = await getSystemConfigList();
|
||||
const rows = Array.isArray(list) ? list : list?.data || [];
|
||||
for (const row of rows) {
|
||||
if (row.config_key === 'dict_search_rank_mode') {
|
||||
dictSearchRankMode.value =
|
||||
String(row.config_value || '') === 'frontend' ? 'frontend' : 'backend';
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
saving.value = true;
|
||||
try {
|
||||
await saveSystemConfig([
|
||||
{
|
||||
config_key: 'dict_search_rank_mode',
|
||||
config_value: dictSearchRankMode.value,
|
||||
value_type: 'string',
|
||||
config_group: 'search',
|
||||
description: '字典搜索匹配度:backend服务端 / frontend前端',
|
||||
sort: 320,
|
||||
},
|
||||
]);
|
||||
message.success('保存成功');
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="cfg-panel" :class="{ 'opacity-60': loading }">
|
||||
<div class="cfg-panel-body">
|
||||
<div class="mb-2 font-medium text-[hsl(var(--foreground))]">
|
||||
诊断 / 医嘱 / 病历词条匹配度处理端
|
||||
</div>
|
||||
<div class="mb-2 text-sm text-[hsl(var(--muted-foreground))]">
|
||||
后端:服务端计算匹配度并排序(适合疾病库等大数据量);前端:接口只返回候选,由端上打分排序(适合调试或减轻服务端
|
||||
CPU)。关键字高亮始终在端上渲染。
|
||||
</div>
|
||||
<Radio.Group v-model:value="dictSearchRankMode">
|
||||
<Radio value="backend">后端处理(推荐)</Radio>
|
||||
<Radio value="frontend">前端处理</Radio>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
<div class="cfg-panel-footer">
|
||||
<Button type="primary" :loading="saving" @click="handleSave">保存本模块配置</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,77 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 系统配置 - 登录安全:业务端单机登录
|
||||
*/
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import { Button, Switch, message } from 'ant-design-vue';
|
||||
|
||||
import { getSystemConfigList, saveSystemConfig } from '../api';
|
||||
|
||||
defineOptions({ name: 'DoctorLoginConfigPanel' });
|
||||
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const doctorSingleDeviceLogin = ref(false);
|
||||
|
||||
function parseBoolConfig(val: unknown, defaultVal: boolean): boolean {
|
||||
if (val === undefined || val === null || val === '') return defaultVal;
|
||||
return val === '1' || val === 1 || val === true || val === 'true';
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const list = await getSystemConfigList();
|
||||
const rows = Array.isArray(list) ? list : list?.data || [];
|
||||
for (const row of rows) {
|
||||
if (row.config_key === 'doctor_single_device_login') {
|
||||
doctorSingleDeviceLogin.value = parseBoolConfig(row.config_value, false);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
saving.value = true;
|
||||
try {
|
||||
await saveSystemConfig([
|
||||
{
|
||||
config_key: 'doctor_single_device_login',
|
||||
config_value: doctorSingleDeviceLogin.value ? '1' : '0',
|
||||
value_type: 'bool',
|
||||
config_group: 'auth',
|
||||
description: '业务端单机登录(医生/药师/诊所管理员等:1手机+1网页)',
|
||||
sort: 310,
|
||||
},
|
||||
]);
|
||||
message.success('保存成功');
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="cfg-panel" :class="{ 'opacity-60': loading }">
|
||||
<div class="cfg-panel-body">
|
||||
<div class="mb-2 font-medium text-[hsl(var(--foreground))]">单机登录</div>
|
||||
<div class="mb-2 text-sm text-[hsl(var(--muted-foreground))]">
|
||||
适用于医生/药师/诊所管理员/平台管理员/业务员及 PC 管理端。关闭时不限制登录设备;开启后同一账号仅允许
|
||||
1 个手机端会话 + 1 个 PC 网页会话,同端后登录会使先登录失效;同手机号下不同身份互不影响
|
||||
</div>
|
||||
<Switch
|
||||
v-model:checked="doctorSingleDeviceLogin"
|
||||
checked-children="开启"
|
||||
un-checked-children="关闭"
|
||||
/>
|
||||
</div>
|
||||
<div class="cfg-panel-footer">
|
||||
<Button type="primary" :loading="saving" @click="handleSave">保存本模块配置</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,69 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 系统配置 - 录入审核:诊所/医生预填录入是否自动过审
|
||||
*/
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import { Button, Switch, message } from 'ant-design-vue';
|
||||
|
||||
import { getSystemConfigList, saveSystemConfig } from '../api';
|
||||
|
||||
defineOptions({ name: 'InputAuditConfigPanel' });
|
||||
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const inputAutoAuditPass = ref(true);
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const list = await getSystemConfigList();
|
||||
const rows = Array.isArray(list) ? list : list?.data || [];
|
||||
for (const row of rows) {
|
||||
if (row.config_key === 'input_auto_audit_pass') {
|
||||
inputAutoAuditPass.value =
|
||||
row.config_value === '1' ||
|
||||
row.config_value === true ||
|
||||
row.config_value === 'true';
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
saving.value = true;
|
||||
try {
|
||||
await saveSystemConfig([
|
||||
{
|
||||
config_key: 'input_auto_audit_pass',
|
||||
config_value: inputAutoAuditPass.value ? '1' : '0',
|
||||
},
|
||||
]);
|
||||
message.success('保存成功');
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="cfg-panel" :class="{ 'opacity-60': loading }">
|
||||
<div class="cfg-panel-body">
|
||||
<div class="mb-2 font-medium text-[hsl(var(--foreground))]">
|
||||
诊所/医生信息预填录入后是否自动通过审核
|
||||
</div>
|
||||
<Switch
|
||||
v-model:checked="inputAutoAuditPass"
|
||||
checked-children="是"
|
||||
un-checked-children="否"
|
||||
/>
|
||||
</div>
|
||||
<div class="cfg-panel-footer">
|
||||
<Button type="primary" :loading="saving" @click="handleSave">保存本模块配置</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,89 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 系统配置 - 开票通知:患者开票渠道多选
|
||||
*/
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import { Button, Checkbox, message } from 'ant-design-vue';
|
||||
|
||||
import { getSystemConfigList, saveSystemConfig } from '../api';
|
||||
|
||||
defineOptions({ name: 'InvoiceNoticeConfigPanel' });
|
||||
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const invoiceNoticeChannels = ref<string[]>(['subscribe']);
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const list = await getSystemConfigList();
|
||||
const rows = Array.isArray(list) ? list : list?.data || [];
|
||||
for (const row of rows) {
|
||||
if (row.config_key === 'invoice_notice_channels') {
|
||||
let channels: unknown = row.config_value;
|
||||
if (typeof channels === 'string') {
|
||||
try {
|
||||
channels = JSON.parse(channels);
|
||||
} catch {
|
||||
channels = ['subscribe'];
|
||||
}
|
||||
}
|
||||
if (Array.isArray(channels) && channels.length) {
|
||||
invoiceNoticeChannels.value = channels
|
||||
.map(String)
|
||||
.filter((c) => c === 'subscribe' || c === 'sms');
|
||||
}
|
||||
if (!invoiceNoticeChannels.value.length) {
|
||||
invoiceNoticeChannels.value = ['subscribe'];
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
saving.value = true;
|
||||
try {
|
||||
await saveSystemConfig([
|
||||
{
|
||||
config_key: 'invoice_notice_channels',
|
||||
config_value: JSON.stringify(
|
||||
invoiceNoticeChannels.value.length
|
||||
? invoiceNoticeChannels.value
|
||||
: ['subscribe'],
|
||||
),
|
||||
value_type: 'json',
|
||||
config_group: 'invoice',
|
||||
description: '开票通知渠道:subscribe订阅消息 / sms短信',
|
||||
sort: 200,
|
||||
},
|
||||
]);
|
||||
message.success('保存成功');
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="cfg-panel" :class="{ 'opacity-60': loading }">
|
||||
<div class="cfg-panel-body">
|
||||
<div class="mb-2 font-medium text-[hsl(var(--foreground))]">患者开票通知渠道</div>
|
||||
<div class="mb-2 text-sm text-[hsl(var(--muted-foreground))]">
|
||||
受理与开票完成时按所选渠道发送;可多选(全选)。默认仅小程序订阅消息。
|
||||
</div>
|
||||
<Checkbox.Group v-model:value="invoiceNoticeChannels">
|
||||
<Checkbox value="subscribe">小程序订阅消息</Checkbox>
|
||||
<Checkbox value="sms">短信</Checkbox>
|
||||
</Checkbox.Group>
|
||||
</div>
|
||||
<div class="cfg-panel-footer">
|
||||
<Button type="primary" :loading="saving" @click="handleSave">保存本模块配置</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,97 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 系统配置 - 物流展示:患者端/诊所端是否显示配送仓名
|
||||
*/
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import { Button, Switch, message } from 'ant-design-vue';
|
||||
|
||||
import { getSystemConfigList, saveSystemConfig } from '../api';
|
||||
|
||||
defineOptions({ name: 'LogisticsConfigPanel' });
|
||||
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const logisticsShowWhNameUser = ref(false);
|
||||
const logisticsShowWhNameClinic = ref(true);
|
||||
|
||||
function parseBoolConfig(val: unknown, defaultVal: boolean): boolean {
|
||||
if (val === undefined || val === null || val === '') return defaultVal;
|
||||
return val === '1' || val === 1 || val === true || val === 'true';
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const list = await getSystemConfigList();
|
||||
const rows = Array.isArray(list) ? list : list?.data || [];
|
||||
for (const row of rows) {
|
||||
if (row.config_key === 'logistics_show_warehouse_name_user') {
|
||||
logisticsShowWhNameUser.value = parseBoolConfig(row.config_value, false);
|
||||
}
|
||||
if (row.config_key === 'logistics_show_warehouse_name_clinic') {
|
||||
logisticsShowWhNameClinic.value = parseBoolConfig(row.config_value, true);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
saving.value = true;
|
||||
try {
|
||||
await saveSystemConfig([
|
||||
{
|
||||
config_key: 'logistics_show_warehouse_name_user',
|
||||
config_value: logisticsShowWhNameUser.value ? '1' : '0',
|
||||
},
|
||||
{
|
||||
config_key: 'logistics_show_warehouse_name_clinic',
|
||||
config_value: logisticsShowWhNameClinic.value ? '1' : '0',
|
||||
},
|
||||
]);
|
||||
message.success('保存成功');
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="cfg-panel" :class="{ 'opacity-60': loading }">
|
||||
<div class="cfg-panel-body space-y-6">
|
||||
<div>
|
||||
<div class="mb-2 font-medium text-[hsl(var(--foreground))]">
|
||||
患者端:物流多包裹是否显示配送仓名称
|
||||
</div>
|
||||
<div class="mb-2 text-sm text-[hsl(var(--muted-foreground))]">
|
||||
关闭后患者端仅显示「包裹1」「包裹2」,不展示仓名
|
||||
</div>
|
||||
<Switch
|
||||
v-model:checked="logisticsShowWhNameUser"
|
||||
checked-children="显示"
|
||||
un-checked-children="隐藏"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-2 font-medium text-[hsl(var(--foreground))]">
|
||||
诊所端:物流多包裹是否显示配送仓名称
|
||||
</div>
|
||||
<div class="mb-2 text-sm text-[hsl(var(--muted-foreground))]">
|
||||
关闭后诊所/商家小程序订单物流仅显示包裹序号;平台后台产品订单不受影响
|
||||
</div>
|
||||
<Switch
|
||||
v-model:checked="logisticsShowWhNameClinic"
|
||||
checked-children="显示"
|
||||
un-checked-children="隐藏"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cfg-panel-footer">
|
||||
<Button type="primary" :loading="saving" @click="handleSave">保存本模块配置</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,75 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 系统配置 - 词条:病历诊断/医嘱常用展示位置
|
||||
*/
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import { Button, Radio, message } from 'ant-design-vue';
|
||||
|
||||
import { getSystemConfigList, saveSystemConfig } from '../api';
|
||||
|
||||
defineOptions({ name: 'MedicalRecordConfigPanel' });
|
||||
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const mrCommonDisplayMode = ref<'tags' | 'bubble' | 'both'>('tags');
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const list = await getSystemConfigList();
|
||||
const rows = Array.isArray(list) ? list : list?.data || [];
|
||||
for (const row of rows) {
|
||||
if (row.config_key === 'mr_common_display_mode') {
|
||||
const mode = String(row.config_value || 'tags');
|
||||
mrCommonDisplayMode.value =
|
||||
mode === 'bubble' || mode === 'both' ? mode : 'tags';
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
saving.value = true;
|
||||
try {
|
||||
await saveSystemConfig([
|
||||
{
|
||||
config_key: 'mr_common_display_mode',
|
||||
config_value: mrCommonDisplayMode.value,
|
||||
value_type: 'string',
|
||||
config_group: 'medical_record',
|
||||
description: '病历诊断医嘱常用展示:tags标签 / bubble气泡 / both两边',
|
||||
sort: 330,
|
||||
},
|
||||
]);
|
||||
message.success('保存成功');
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="cfg-panel" :class="{ 'opacity-60': loading }">
|
||||
<div class="cfg-panel-body">
|
||||
<div class="mb-2 font-medium text-[hsl(var(--foreground))]">
|
||||
诊断 / 医嘱「常用|我的」展示位置
|
||||
</div>
|
||||
<div class="mb-2 text-sm text-[hsl(var(--muted-foreground))]">
|
||||
控制接诊病历里诊断、医嘱的常用快捷出现在哪里。默认「标签」即文本域下方的快捷标签;「气泡卡片」则在关键字搜索气泡内默认展示常用;「两边」同时展示。
|
||||
</div>
|
||||
<Radio.Group v-model:value="mrCommonDisplayMode">
|
||||
<Radio value="tags">标签(默认)</Radio>
|
||||
<Radio value="bubble">气泡卡片默认展示</Radio>
|
||||
<Radio value="both">两边都展示</Radio>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
<div class="cfg-panel-footer">
|
||||
<Button type="primary" :loading="saving" @click="handleSave">保存本模块配置</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,65 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 系统配置 - 小程序:空状态占位图
|
||||
*/
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import { Button, message } from 'ant-design-vue';
|
||||
|
||||
import FormAvatar from '#/components/form/components/avatar.vue';
|
||||
|
||||
import { getSystemConfigList, saveSystemConfig } from '../api';
|
||||
|
||||
defineOptions({ name: 'MiniprogramConfigPanel' });
|
||||
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const miniprogramEmptyImage = ref('');
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const list = await getSystemConfigList();
|
||||
const rows = Array.isArray(list) ? list : list?.data || [];
|
||||
for (const row of rows) {
|
||||
if (row.config_key === 'miniprogram_empty_image') {
|
||||
miniprogramEmptyImage.value = String(row.config_value || '');
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
saving.value = true;
|
||||
try {
|
||||
await saveSystemConfig([
|
||||
{
|
||||
config_key: 'miniprogram_empty_image',
|
||||
config_value: miniprogramEmptyImage.value,
|
||||
},
|
||||
]);
|
||||
message.success('保存成功');
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="cfg-panel" :class="{ 'opacity-60': loading }">
|
||||
<div class="cfg-panel-body">
|
||||
<div class="mb-2 font-medium text-[hsl(var(--foreground))]">空状态图片</div>
|
||||
<div class="mb-2 text-sm text-[hsl(var(--muted-foreground))]">
|
||||
用于小程序列表等无数据时的占位图
|
||||
</div>
|
||||
<FormAvatar v-model:value="miniprogramEmptyImage" />
|
||||
</div>
|
||||
<div class="cfg-panel-footer">
|
||||
<Button type="primary" :loading="saving" @click="handleSave">保存本模块配置</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -152,7 +152,8 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="py-4">
|
||||
<div class="cfg-panel">
|
||||
<div class="cfg-panel-body">
|
||||
<Card class="mb-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
@@ -287,6 +288,7 @@ onMounted(() => {
|
||||
</div>
|
||||
</Spin>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 系统配置 - 订单调价:打折浮动范围 + 快捷比例选项
|
||||
* 自管 load/save,底部保存条固定在面板底部
|
||||
*/
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import { Button, Input, InputNumber, Radio, Space, Tag, message } from 'ant-design-vue';
|
||||
|
||||
import type { QuickDiscountOption } from '#/utils/pricePercentAdjust';
|
||||
|
||||
import { getSystemConfigList, saveSystemConfig } from '../api';
|
||||
|
||||
defineOptions({ name: 'PriceAdjustConfigPanel' });
|
||||
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const scope = ref<'both' | 'sale_only'>('sale_only');
|
||||
const quickOptions = ref<QuickDiscountOption[]>([
|
||||
{ name: '九五折', value: 95 },
|
||||
{ name: '九折', value: 90 },
|
||||
{ name: '八五折', value: 85 },
|
||||
{ name: '八折', value: 80 },
|
||||
{ name: '涨10%', value: 110 },
|
||||
{ name: '涨20%', value: 120 },
|
||||
]);
|
||||
const newQuickName = ref('');
|
||||
const newQuickValue = ref<number | null>(null);
|
||||
|
||||
function parseQuickOptions(raw: unknown): QuickDiscountOption[] {
|
||||
if (typeof raw === 'string') {
|
||||
try {
|
||||
return parseQuickOptions(JSON.parse(raw));
|
||||
} catch {
|
||||
return quickOptions.value;
|
||||
}
|
||||
}
|
||||
if (!Array.isArray(raw)) return quickOptions.value;
|
||||
const list: QuickDiscountOption[] = [];
|
||||
for (const item of raw) {
|
||||
if (item && typeof item === 'object' && 'value' in item) {
|
||||
const val = Number((item as QuickDiscountOption).value);
|
||||
if (val > 0) {
|
||||
list.push({
|
||||
name: String((item as QuickDiscountOption).name || `${val}%`),
|
||||
value: val,
|
||||
});
|
||||
}
|
||||
} else if (typeof item === 'number' && item > 0) {
|
||||
list.push({ name: `${item}%`, value: item });
|
||||
}
|
||||
}
|
||||
return list.length ? list : quickOptions.value;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const list = await getSystemConfigList();
|
||||
const rows = Array.isArray(list) ? list : list?.data || [];
|
||||
for (const row of rows) {
|
||||
if (row.config_key === 'order_price_adjust_scope') {
|
||||
scope.value = row.config_value === 'both' ? 'both' : 'sale_only';
|
||||
}
|
||||
if (row.config_key === 'order_discount_quick_options') {
|
||||
quickOptions.value = parseQuickOptions(row.config_value);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function addQuick() {
|
||||
const v = Number(newQuickValue.value);
|
||||
const name = String(newQuickName.value || '').trim();
|
||||
if (!name || !v || v <= 0) {
|
||||
message.warning('请填写名称和有效比例值');
|
||||
return;
|
||||
}
|
||||
if (!quickOptions.value.some((o) => o.value === v && o.name === name)) {
|
||||
quickOptions.value = [...quickOptions.value, { name, value: v }].sort(
|
||||
(a, b) => b.value - a.value,
|
||||
);
|
||||
}
|
||||
newQuickName.value = '';
|
||||
newQuickValue.value = null;
|
||||
}
|
||||
|
||||
function removeQuick(opt: QuickDiscountOption) {
|
||||
quickOptions.value = quickOptions.value.filter(
|
||||
(o) => !(o.name === opt.name && o.value === opt.value),
|
||||
);
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
saving.value = true;
|
||||
try {
|
||||
await saveSystemConfig([
|
||||
{ config_key: 'order_price_adjust_scope', config_value: scope.value },
|
||||
{
|
||||
config_key: 'order_discount_quick_options',
|
||||
config_value: JSON.stringify(quickOptions.value),
|
||||
},
|
||||
]);
|
||||
message.success('保存成功');
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="cfg-panel" :class="{ 'opacity-60': loading }">
|
||||
<div class="cfg-panel-body">
|
||||
<div class="mb-6">
|
||||
<div class="mb-2 font-medium text-[hsl(var(--foreground))]">订单打折时价格浮动范围</div>
|
||||
<Radio.Group v-model:value="scope">
|
||||
<Radio value="sale_only">仅售价浮动</Radio>
|
||||
<Radio value="both">供货价与售价一起浮动</Radio>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<div class="mb-2 font-medium text-[hsl(var(--foreground))]">
|
||||
调价快捷选项(name 展示名,value 比例:100=原价,90=九折,120=涨20%)
|
||||
</div>
|
||||
<Space wrap class="mb-3">
|
||||
<Tag
|
||||
v-for="item in quickOptions"
|
||||
:key="item.name + item.value"
|
||||
closable
|
||||
:color="item.value < 100 ? 'processing' : 'warning'"
|
||||
@close="removeQuick(item)"
|
||||
>
|
||||
{{ item.name }}({{ item.value }}%)
|
||||
</Tag>
|
||||
</Space>
|
||||
<Space wrap>
|
||||
<Input v-model:value="newQuickName" placeholder="名称,如九折" style="width: 140px" />
|
||||
<InputNumber v-model:value="newQuickValue" :min="1" :max="500" placeholder="比例值" />
|
||||
<Button @click="addQuick">添加快捷项</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cfg-panel-footer">
|
||||
<Button type="primary" :loading="saving" @click="handleSave">保存本模块配置</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,78 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 系统配置 - 业务员权限:是否允许编辑/改价/配置所属诊所
|
||||
*/
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import { Button, Switch, message } from 'ant-design-vue';
|
||||
|
||||
import { getSystemConfigList, saveSystemConfig } from '../api';
|
||||
|
||||
defineOptions({ name: 'SalespersonConfigPanel' });
|
||||
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const salespersonStoreEditEnabled = ref(false);
|
||||
|
||||
function parseBoolConfig(val: unknown, defaultVal: boolean): boolean {
|
||||
if (val === undefined || val === null || val === '') return defaultVal;
|
||||
return val === '1' || val === 1 || val === true || val === 'true';
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const list = await getSystemConfigList();
|
||||
const rows = Array.isArray(list) ? list : list?.data || [];
|
||||
for (const row of rows) {
|
||||
if (row.config_key === 'salesperson_store_edit_enabled') {
|
||||
salespersonStoreEditEnabled.value = parseBoolConfig(row.config_value, false);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
saving.value = true;
|
||||
try {
|
||||
await saveSystemConfig([
|
||||
{
|
||||
config_key: 'salesperson_store_edit_enabled',
|
||||
config_value: salespersonStoreEditEnabled.value ? '1' : '0',
|
||||
value_type: 'bool',
|
||||
config_group: 'salesperson',
|
||||
description: '允许业务员编辑/改价/配置所属诊所',
|
||||
sort: 300,
|
||||
},
|
||||
]);
|
||||
message.success('保存成功');
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="cfg-panel" :class="{ 'opacity-60': loading }">
|
||||
<div class="cfg-panel-body">
|
||||
<div class="mb-2 font-medium text-[hsl(var(--foreground))]">
|
||||
允许业务员编辑/改价/配置所属诊所
|
||||
</div>
|
||||
<div class="mb-2 text-sm text-[hsl(var(--muted-foreground))]">
|
||||
关闭时业务员小程序「我的诊所」仅可查看;开启后显示编辑资料、改价、配置开关等操作
|
||||
</div>
|
||||
<Switch
|
||||
v-model:checked="salespersonStoreEditEnabled"
|
||||
checked-children="开启"
|
||||
un-checked-children="关闭"
|
||||
/>
|
||||
</div>
|
||||
<div class="cfg-panel-footer">
|
||||
<Button type="primary" :loading="saving" @click="handleSave">保存本模块配置</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,424 +1,168 @@
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
/**
|
||||
* 系统配置页壳:左侧一级菜单 + 右侧动态面板
|
||||
* 各模块自管 load/save;AI 在右侧保留二级 Tab
|
||||
*/
|
||||
import { computed, onMounted, ref, type Component } from 'vue';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
|
||||
import { Button, Card, Checkbox, Input, InputNumber, message, Radio, Space, Switch, Tabs, Tag } from 'ant-design-vue';
|
||||
|
||||
import type { QuickDiscountOption } from '#/utils/pricePercentAdjust';
|
||||
|
||||
import FormAvatar from '#/components/form/components/avatar.vue';
|
||||
import { Card } from 'ant-design-vue';
|
||||
|
||||
import AiModelConfigPanel from './components/ai-model-config-panel.vue';
|
||||
import DictSearchConfigPanel from './components/dict-search-config-panel.vue';
|
||||
import DoctorLoginConfigPanel from './components/doctor-login-config-panel.vue';
|
||||
import InputAuditConfigPanel from './components/input-audit-config-panel.vue';
|
||||
import InvoiceNoticeConfigPanel from './components/invoice-notice-config-panel.vue';
|
||||
import LogisticsConfigPanel from './components/logistics-config-panel.vue';
|
||||
import MedicalRecordConfigPanel from './components/medical-record-config-panel.vue';
|
||||
import MiniprogramConfigPanel from './components/miniprogram-config-panel.vue';
|
||||
import OaNotifyConfigPanel from './components/oa-notify-config-panel.vue';
|
||||
import { getSystemConfigList, saveSystemConfig } from './api';
|
||||
import PriceAdjustConfigPanel from './components/price-adjust-config-panel.vue';
|
||||
import SalespersonConfigPanel from './components/salesperson-config-panel.vue';
|
||||
|
||||
defineOptions({ name: 'SystemConfig' });
|
||||
|
||||
const TAB_STORAGE_KEY = 'system_config_active_tab';
|
||||
const MENU_STORAGE_KEY = 'system_config_active_tab';
|
||||
|
||||
interface MenuItem {
|
||||
key: string;
|
||||
title: string;
|
||||
component: Component;
|
||||
}
|
||||
|
||||
const menuItems: MenuItem[] = [
|
||||
{ key: 'price_adjust', title: '订单调价', component: PriceAdjustConfigPanel },
|
||||
{ key: 'input_audit', title: '录入审核', component: InputAuditConfigPanel },
|
||||
{ key: 'miniprogram', title: '小程序', component: MiniprogramConfigPanel },
|
||||
{ key: 'logistics', title: '物流展示', component: LogisticsConfigPanel },
|
||||
{ key: 'salesperson', title: '业务员权限', component: SalespersonConfigPanel },
|
||||
{ key: 'doctor_login', title: '登录安全', component: DoctorLoginConfigPanel },
|
||||
{ key: 'dict_search', title: '字典搜索', component: DictSearchConfigPanel },
|
||||
{ key: 'medical_record', title: '词条', component: MedicalRecordConfigPanel },
|
||||
{ key: 'invoice_notice', title: '开票通知', component: InvoiceNoticeConfigPanel },
|
||||
{ key: 'oa_notify', title: 'OA通知', component: OaNotifyConfigPanel },
|
||||
{ key: 'ai_model', title: 'AI模型', component: AiModelConfigPanel },
|
||||
];
|
||||
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const activeKey = ref('price_adjust');
|
||||
const scope = ref<'both' | 'sale_only'>('sale_only');
|
||||
const quickOptions = ref<QuickDiscountOption[]>([
|
||||
{ name: '九五折', value: 95 },
|
||||
{ name: '九折', value: 90 },
|
||||
{ name: '八五折', value: 85 },
|
||||
{ name: '八折', value: 80 },
|
||||
{ name: '涨10%', value: 110 },
|
||||
{ name: '涨20%', value: 120 },
|
||||
]);
|
||||
const newQuickName = ref('');
|
||||
const newQuickValue = ref<number | null>(null);
|
||||
const inputAutoAuditPass = ref(true);
|
||||
const miniprogramEmptyImage = ref('');
|
||||
/** 患者端物流是否显示配送仓名(默认否) */
|
||||
const logisticsShowWhNameUser = ref(false);
|
||||
/** 诊所端物流是否显示配送仓名(默认是) */
|
||||
const logisticsShowWhNameClinic = ref(true);
|
||||
/** 业务员是否允许编辑/改价/配置所属诊所(默认否) */
|
||||
const salespersonStoreEditEnabled = ref(false);
|
||||
/** 业务端单机登录:医生/药师/诊所管理员等同一账号仅 1 手机 + 1 网页(默认否) */
|
||||
const doctorSingleDeviceLogin = ref(false);
|
||||
/** 字典搜索匹配度:backend 服务端 / frontend 前端 */
|
||||
const dictSearchRankMode = ref<'backend' | 'frontend'>('backend');
|
||||
/**
|
||||
* 病历诊断/医嘱「常用|我的」展示位置
|
||||
* tags=文本域下标签(默认) bubble=气泡内展示 both=两边
|
||||
*/
|
||||
const mrCommonDisplayMode = ref<'tags' | 'bubble' | 'both'>('tags');
|
||||
/** 开票通知渠道:subscribe / sms,默认仅订阅消息 */
|
||||
const invoiceNoticeChannels = ref<string[]>(['subscribe']);
|
||||
|
||||
function readStoredTab() {
|
||||
const stored = localStorage.getItem(TAB_STORAGE_KEY);
|
||||
if (
|
||||
stored === 'price_adjust' ||
|
||||
stored === 'input_audit' ||
|
||||
stored === 'miniprogram' ||
|
||||
stored === 'logistics' ||
|
||||
stored === 'salesperson' ||
|
||||
stored === 'invoice_notice' ||
|
||||
stored === 'doctor_login' ||
|
||||
stored === 'dict_search' ||
|
||||
stored === 'medical_record' ||
|
||||
stored === 'oa_notify' ||
|
||||
stored === 'ai_model'
|
||||
) {
|
||||
const currentPanel = computed(() => {
|
||||
const hit = menuItems.find((m) => m.key === activeKey.value);
|
||||
return hit?.component || PriceAdjustConfigPanel;
|
||||
});
|
||||
|
||||
function readStoredMenu() {
|
||||
const stored = localStorage.getItem(MENU_STORAGE_KEY);
|
||||
if (stored && menuItems.some((m) => m.key === stored)) {
|
||||
activeKey.value = stored;
|
||||
}
|
||||
}
|
||||
|
||||
function parseBoolConfig(val: unknown, defaultVal: boolean): boolean {
|
||||
if (val === undefined || val === null || val === '') return defaultVal;
|
||||
return val === '1' || val === 1 || val === true || val === 'true';
|
||||
function selectMenu(key: string) {
|
||||
activeKey.value = key;
|
||||
localStorage.setItem(MENU_STORAGE_KEY, key);
|
||||
}
|
||||
|
||||
function handleTabChange(key: string | number) {
|
||||
activeKey.value = String(key);
|
||||
localStorage.setItem(TAB_STORAGE_KEY, String(key));
|
||||
}
|
||||
|
||||
function parseQuickOptions(raw: unknown): QuickDiscountOption[] {
|
||||
if (typeof raw === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return parseQuickOptions(parsed);
|
||||
} catch {
|
||||
return quickOptions.value;
|
||||
}
|
||||
}
|
||||
if (!Array.isArray(raw)) return quickOptions.value;
|
||||
const list: QuickDiscountOption[] = [];
|
||||
for (const item of raw) {
|
||||
if (item && typeof item === 'object' && 'value' in item) {
|
||||
const val = Number((item as QuickDiscountOption).value);
|
||||
if (val > 0) list.push({ name: String((item as QuickDiscountOption).name || `${val}%`), value: val });
|
||||
} else if (typeof item === 'number' && item > 0) {
|
||||
list.push({ name: `${item}%`, value: item });
|
||||
}
|
||||
}
|
||||
return list.length ? list : quickOptions.value;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const list = await getSystemConfigList();
|
||||
const rows = Array.isArray(list) ? list : list?.data || [];
|
||||
for (const row of rows) {
|
||||
if (row.config_key === 'order_price_adjust_scope') {
|
||||
scope.value = row.config_value === 'both' ? 'both' : 'sale_only';
|
||||
}
|
||||
if (row.config_key === 'order_discount_quick_options') {
|
||||
quickOptions.value = parseQuickOptions(row.config_value);
|
||||
}
|
||||
if (row.config_key === 'input_auto_audit_pass') {
|
||||
inputAutoAuditPass.value = row.config_value === '1' || row.config_value === true || row.config_value === 'true';
|
||||
}
|
||||
if (row.config_key === 'miniprogram_empty_image') {
|
||||
miniprogramEmptyImage.value = String(row.config_value || '');
|
||||
}
|
||||
if (row.config_key === 'logistics_show_warehouse_name_user') {
|
||||
logisticsShowWhNameUser.value = parseBoolConfig(row.config_value, false);
|
||||
}
|
||||
if (row.config_key === 'logistics_show_warehouse_name_clinic') {
|
||||
logisticsShowWhNameClinic.value = parseBoolConfig(row.config_value, true);
|
||||
}
|
||||
if (row.config_key === 'salesperson_store_edit_enabled') {
|
||||
salespersonStoreEditEnabled.value = parseBoolConfig(row.config_value, false);
|
||||
}
|
||||
if (row.config_key === 'doctor_single_device_login') {
|
||||
doctorSingleDeviceLogin.value = parseBoolConfig(row.config_value, false);
|
||||
}
|
||||
if (row.config_key === 'dict_search_rank_mode') {
|
||||
dictSearchRankMode.value =
|
||||
String(row.config_value || '') === 'frontend' ? 'frontend' : 'backend';
|
||||
}
|
||||
if (row.config_key === 'mr_common_display_mode') {
|
||||
const mode = String(row.config_value || 'tags');
|
||||
mrCommonDisplayMode.value =
|
||||
mode === 'bubble' || mode === 'both' ? mode : 'tags';
|
||||
}
|
||||
if (row.config_key === 'invoice_notice_channels') {
|
||||
let channels: unknown = row.config_value;
|
||||
if (typeof channels === 'string') {
|
||||
try {
|
||||
channels = JSON.parse(channels);
|
||||
} catch {
|
||||
channels = ['subscribe'];
|
||||
}
|
||||
}
|
||||
if (Array.isArray(channels) && channels.length) {
|
||||
invoiceNoticeChannels.value = channels.map(String).filter((c) => c === 'subscribe' || c === 'sms');
|
||||
}
|
||||
if (!invoiceNoticeChannels.value.length) {
|
||||
invoiceNoticeChannels.value = ['subscribe'];
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function addQuick() {
|
||||
const v = Number(newQuickValue.value);
|
||||
const name = String(newQuickName.value || '').trim();
|
||||
if (!name || !v || v <= 0) {
|
||||
message.warning('请填写名称和有效比例值');
|
||||
return;
|
||||
}
|
||||
if (!quickOptions.value.some((o) => o.value === v && o.name === name)) {
|
||||
quickOptions.value = [...quickOptions.value, { name, value: v }].sort((a, b) => b.value - a.value);
|
||||
}
|
||||
newQuickName.value = '';
|
||||
newQuickValue.value = null;
|
||||
}
|
||||
|
||||
function removeQuick(opt: QuickDiscountOption) {
|
||||
quickOptions.value = quickOptions.value.filter((o) => !(o.name === opt.name && o.value === opt.value));
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
saving.value = true;
|
||||
try {
|
||||
await saveSystemConfig([
|
||||
{ config_key: 'order_price_adjust_scope', config_value: scope.value },
|
||||
{
|
||||
config_key: 'order_discount_quick_options',
|
||||
config_value: JSON.stringify(quickOptions.value),
|
||||
},
|
||||
{
|
||||
config_key: 'input_auto_audit_pass',
|
||||
config_value: inputAutoAuditPass.value ? '1' : '0',
|
||||
},
|
||||
{
|
||||
config_key: 'miniprogram_empty_image',
|
||||
config_value: miniprogramEmptyImage.value,
|
||||
},
|
||||
{
|
||||
config_key: 'logistics_show_warehouse_name_user',
|
||||
config_value: logisticsShowWhNameUser.value ? '1' : '0',
|
||||
},
|
||||
{
|
||||
config_key: 'logistics_show_warehouse_name_clinic',
|
||||
config_value: logisticsShowWhNameClinic.value ? '1' : '0',
|
||||
},
|
||||
{
|
||||
config_key: 'salesperson_store_edit_enabled',
|
||||
config_value: salespersonStoreEditEnabled.value ? '1' : '0',
|
||||
value_type: 'bool',
|
||||
config_group: 'salesperson',
|
||||
description: '允许业务员编辑/改价/配置所属诊所',
|
||||
sort: 300,
|
||||
},
|
||||
{
|
||||
config_key: 'doctor_single_device_login',
|
||||
config_value: doctorSingleDeviceLogin.value ? '1' : '0',
|
||||
value_type: 'bool',
|
||||
config_group: 'auth',
|
||||
description: '业务端单机登录(医生/药师/诊所管理员等:1手机+1网页)',
|
||||
sort: 310,
|
||||
},
|
||||
{
|
||||
config_key: 'dict_search_rank_mode',
|
||||
config_value: dictSearchRankMode.value,
|
||||
value_type: 'string',
|
||||
config_group: 'search',
|
||||
description: '字典搜索匹配度:backend服务端 / frontend前端',
|
||||
sort: 320,
|
||||
},
|
||||
{
|
||||
config_key: 'mr_common_display_mode',
|
||||
config_value: mrCommonDisplayMode.value,
|
||||
value_type: 'string',
|
||||
config_group: 'medical_record',
|
||||
description: '病历诊断医嘱常用展示:tags标签 / bubble气泡 / both两边',
|
||||
sort: 330,
|
||||
},
|
||||
{
|
||||
config_key: 'invoice_notice_channels',
|
||||
config_value: JSON.stringify(
|
||||
invoiceNoticeChannels.value.length ? invoiceNoticeChannels.value : ['subscribe'],
|
||||
),
|
||||
value_type: 'json',
|
||||
config_group: 'invoice',
|
||||
description: '开票通知渠道:subscribe订阅消息 / sms短信',
|
||||
sort: 200,
|
||||
},
|
||||
]);
|
||||
message.success('保存成功');
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
readStoredTab();
|
||||
load();
|
||||
});
|
||||
onMounted(readStoredMenu);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height title="系统配置">
|
||||
<Card :loading="loading">
|
||||
<Tabs :active-key="activeKey" @change="handleTabChange">
|
||||
<Tabs.TabPane key="price_adjust" tab="订单调价">
|
||||
<div class="py-4">
|
||||
<div class="mb-6">
|
||||
<div class="mb-2 font-medium">订单打折时价格浮动范围</div>
|
||||
<Radio.Group v-model:value="scope">
|
||||
<Radio value="sale_only">仅售价浮动</Radio>
|
||||
<Radio value="both">供货价与售价一起浮动</Radio>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<div class="mb-2 font-medium">调价快捷选项(name 展示名,value 比例:100=原价,90=九折,120=涨20%)</div>
|
||||
<Space wrap class="mb-3">
|
||||
<Tag
|
||||
v-for="item in quickOptions"
|
||||
:key="item.name + item.value"
|
||||
closable
|
||||
:color="item.value < 100 ? 'processing' : 'warning'"
|
||||
@close="removeQuick(item)"
|
||||
>
|
||||
{{ item.name }}({{ item.value }}%)
|
||||
</Tag>
|
||||
</Space>
|
||||
<Space wrap>
|
||||
<Input v-model:value="newQuickName" placeholder="名称,如九折" style="width: 140px" />
|
||||
<InputNumber v-model:value="newQuickValue" :min="1" :max="500" placeholder="比例值" />
|
||||
<Button @click="addQuick">添加快捷项</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.TabPane>
|
||||
|
||||
<Tabs.TabPane key="input_audit" tab="录入审核">
|
||||
<div class="py-4">
|
||||
<div class="mb-2 font-medium">诊所/医生信息预填录入后是否自动通过审核</div>
|
||||
<Switch v-model:checked="inputAutoAuditPass" checked-children="是" un-checked-children="否" />
|
||||
</div>
|
||||
</Tabs.TabPane>
|
||||
|
||||
<Tabs.TabPane key="miniprogram" tab="小程序">
|
||||
<div class="py-4">
|
||||
<div class="mb-2 font-medium">空状态图片</div>
|
||||
<div class="mb-2 text-sm text-gray-500">用于小程序列表等无数据时的占位图</div>
|
||||
<FormAvatar v-model:value="miniprogramEmptyImage" />
|
||||
</div>
|
||||
</Tabs.TabPane>
|
||||
|
||||
<Tabs.TabPane key="logistics" tab="物流展示">
|
||||
<div class="py-4 space-y-6">
|
||||
<div>
|
||||
<div class="mb-2 font-medium">患者端:物流多包裹是否显示配送仓名称</div>
|
||||
<div class="mb-2 text-sm text-gray-500">
|
||||
关闭后患者端仅显示「包裹1」「包裹2」,不展示仓名
|
||||
</div>
|
||||
<Switch
|
||||
v-model:checked="logisticsShowWhNameUser"
|
||||
checked-children="显示"
|
||||
un-checked-children="隐藏"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-2 font-medium">诊所端:物流多包裹是否显示配送仓名称</div>
|
||||
<div class="mb-2 text-sm text-gray-500">
|
||||
关闭后诊所/商家小程序订单物流仅显示包裹序号;平台后台产品订单不受影响
|
||||
</div>
|
||||
<Switch
|
||||
v-model:checked="logisticsShowWhNameClinic"
|
||||
checked-children="显示"
|
||||
un-checked-children="隐藏"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.TabPane>
|
||||
|
||||
<Tabs.TabPane key="salesperson" tab="业务员权限">
|
||||
<div class="py-4">
|
||||
<div class="mb-2 font-medium">允许业务员编辑/改价/配置所属诊所</div>
|
||||
<div class="mb-2 text-sm text-gray-500">
|
||||
关闭时业务员小程序「我的诊所」仅可查看;开启后显示编辑资料、改价、配置开关等操作
|
||||
</div>
|
||||
<Switch
|
||||
v-model:checked="salespersonStoreEditEnabled"
|
||||
checked-children="开启"
|
||||
un-checked-children="关闭"
|
||||
/>
|
||||
</div>
|
||||
</Tabs.TabPane>
|
||||
|
||||
<Tabs.TabPane key="doctor_login" tab="登录安全">
|
||||
<div class="py-4">
|
||||
<div class="mb-2 font-medium">单机登录</div>
|
||||
<div class="mb-2 text-sm text-gray-500">
|
||||
适用于医生/药师/诊所管理员/平台管理员/业务员及 PC 管理端。关闭时不限制登录设备;开启后同一账号仅允许 1 个手机端会话 + 1 个 PC 网页会话,同端后登录会使先登录失效;同手机号下不同身份互不影响
|
||||
</div>
|
||||
<Switch
|
||||
v-model:checked="doctorSingleDeviceLogin"
|
||||
checked-children="开启"
|
||||
un-checked-children="关闭"
|
||||
/>
|
||||
</div>
|
||||
</Tabs.TabPane>
|
||||
|
||||
<Tabs.TabPane key="dict_search" tab="字典搜索">
|
||||
<div class="py-4">
|
||||
<div class="mb-2 font-medium">诊断 / 医嘱 / 病历词条匹配度处理端</div>
|
||||
<div class="mb-2 text-sm text-gray-500">
|
||||
后端:服务端计算匹配度并排序(适合疾病库等大数据量);前端:接口只返回候选,由端上打分排序(适合调试或减轻服务端 CPU)。关键字高亮始终在端上渲染。
|
||||
</div>
|
||||
<Radio.Group v-model:value="dictSearchRankMode">
|
||||
<Radio value="backend">后端处理(推荐)</Radio>
|
||||
<Radio value="frontend">前端处理</Radio>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
</Tabs.TabPane>
|
||||
|
||||
<Tabs.TabPane key="medical_record" tab="词条">
|
||||
<div class="py-4">
|
||||
<div class="mb-2 font-medium">诊断 / 医嘱「常用|我的」展示位置</div>
|
||||
<div class="mb-2 text-sm text-gray-500">
|
||||
控制接诊病历里诊断、医嘱的常用快捷出现在哪里。默认「标签」即文本域下方的快捷标签;「气泡卡片」则在关键字搜索气泡内默认展示常用;「两边」同时展示。
|
||||
</div>
|
||||
<Radio.Group v-model:value="mrCommonDisplayMode">
|
||||
<Radio value="tags">标签(默认)</Radio>
|
||||
<Radio value="bubble">气泡卡片默认展示</Radio>
|
||||
<Radio value="both">两边都展示</Radio>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
</Tabs.TabPane>
|
||||
|
||||
<Tabs.TabPane key="invoice_notice" tab="开票通知">
|
||||
<div class="py-4">
|
||||
<div class="mb-2 font-medium">患者开票通知渠道</div>
|
||||
<div class="mb-2 text-sm text-gray-500">
|
||||
受理与开票完成时按所选渠道发送;可多选(全选)。默认仅小程序订阅消息。
|
||||
</div>
|
||||
<Checkbox.Group v-model:value="invoiceNoticeChannels">
|
||||
<Checkbox value="subscribe">小程序订阅消息</Checkbox>
|
||||
<Checkbox value="sms">短信</Checkbox>
|
||||
</Checkbox.Group>
|
||||
</div>
|
||||
</Tabs.TabPane>
|
||||
|
||||
<Tabs.TabPane key="oa_notify" tab="OA通知">
|
||||
<OaNotifyConfigPanel />
|
||||
</Tabs.TabPane>
|
||||
|
||||
<Tabs.TabPane key="ai_model" tab="AI模型">
|
||||
<AiModelConfigPanel />
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
|
||||
<div v-if="activeKey !== 'oa_notify' && activeKey !== 'ai_model'" class="mt-4">
|
||||
<Button type="primary" :loading="saving" @click="handleSave">保存配置</Button>
|
||||
<Card class="sys-cfg-card" :body-style="{ padding: 0, height: '100%' }">
|
||||
<div class="sys-cfg-layout">
|
||||
<aside class="sys-cfg-nav">
|
||||
<button
|
||||
v-for="item in menuItems"
|
||||
:key="item.key"
|
||||
type="button"
|
||||
class="sys-cfg-nav-item"
|
||||
:class="{ active: activeKey === item.key }"
|
||||
@click="selectMenu(item.key)"
|
||||
>
|
||||
{{ item.title }}
|
||||
</button>
|
||||
</aside>
|
||||
<section class="sys-cfg-main">
|
||||
<component :is="currentPanel" :key="activeKey" />
|
||||
</section>
|
||||
</div>
|
||||
</Card>
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sys-cfg-card {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.sys-cfg-layout {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
min-height: 480px;
|
||||
max-height: calc(100vh - 160px);
|
||||
}
|
||||
.sys-cfg-nav {
|
||||
width: 188px;
|
||||
flex-shrink: 0;
|
||||
overflow-y: auto;
|
||||
border-right: 1px solid hsl(var(--border));
|
||||
background: hsl(var(--muted) / 0.25);
|
||||
padding: 8px;
|
||||
}
|
||||
.sys-cfg-nav-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: hsl(var(--foreground));
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition:
|
||||
background 0.15s ease,
|
||||
border-color 0.15s ease,
|
||||
color 0.15s ease;
|
||||
}
|
||||
.sys-cfg-nav-item:hover {
|
||||
background: hsl(var(--accent));
|
||||
}
|
||||
.sys-cfg-nav-item.active {
|
||||
border-color: hsl(var(--primary) / 35%);
|
||||
background: hsl(var(--primary) / 0.1);
|
||||
color: hsl(var(--primary));
|
||||
box-shadow: 0 0 6px hsl(var(--primary) / 18%);
|
||||
}
|
||||
.sys-cfg-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
/* 子面板统一:内容滚动 + 底栏不滚走 */
|
||||
.sys-cfg-main :deep(.cfg-panel) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
.sys-cfg-main :deep(.cfg-panel-body) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
.sys-cfg-main :deep(.cfg-panel-footer) {
|
||||
flex-shrink: 0;
|
||||
border-top: 1px solid hsl(var(--border));
|
||||
background: hsl(var(--card, var(--background)));
|
||||
padding: 12px 20px;
|
||||
z-index: 2;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user