feat: 新增 WidgetAiDailyBrief.vue:快照统计 pill(口径取自生成时的 data_snapshot,保证数字与 AI 文本一致)+ 分段正文(按【标题】切段,不规范输出兜底整段)+ 重新生成按钮;AI 生成失败时芯片仍展示、正文位置给重试。

每日首次弹窗:生成成功且 localStorage 当天未记录时自动弹全文,之后点「查看全文」随时再看;样式全部走主题变量,暗色自适配,卡片强调用规则要求的「1px 同色边框 + 微光」。
      布局上简报作为全宽内容卡固定渲染在日程行与 KPI 区之间,不进 KPI 芯片集合。
This commit is contained in:
李琦
2026-08-17 09:51:07 +08:00
parent 4ca7479550
commit c6d2b0f917
28 changed files with 3597 additions and 563 deletions

View File

@@ -100,6 +100,22 @@ export type AppointmentUpcomingListResult = {
total: number;
};
/** AI 今日简报(管理员=昨日经营+今日待办,医生=今日值班+挂号+预约) */
export type AiDailyBriefResult = {
brief_date: string;
/** 1=生成成功 0=生成失败(失败时 error_msg 有值data 快照仍可展示) */
status: number;
content: string;
error_msg: string;
/** 生成时收集的结构化业务数据快照(统计芯片直接用,保证与 AI 文本口径一致) */
data: Record<string, any>;
provider: string;
model: string;
generated_at: string;
/** true=命中当日缓存(非今天首次生成,前端据此决定是否弹当日简报弹窗) */
from_cache: boolean;
};
/**
* 获取当前登录角色的工作台布局
*/
@@ -177,3 +193,13 @@ export async function getAppointmentUpcomingListApi(data: {
{ params: data },
);
}
/**
* AI 今日简报每天首次调用后端生成并缓存refresh=true 强制重新生成)
* 生成可能耗时十几秒(走 AI调用方需自行处理 loading 展示
*/
export async function getAiDailyBriefApi(refresh: boolean = false) {
return requestClient.get<AiDailyBriefResult>(`${prefix}ai-daily-brief`, {
params: refresh ? { refresh: 1 } : {},
});
}

View File

@@ -0,0 +1,568 @@
<script lang="ts" setup>
/**
* AI 今日简报卡片 + 每日首次弹窗
* 数据流:进入工作台拉取 workbench/ai-daily-brief后端每天首次生成并缓存当天再进直接读缓存
* 弹窗策略每天首次进入工作台自动弹一次全文localStorage 按天记录,同一浏览器当天不重复弹),
* 之后简报常驻卡片可随时查看;「重新生成」走 refresh=1 强制重生成(后端原行 UPDATE
* 视角由后端按登录角色判定:管理员=昨日经营+今日待办,医生=今日值班+挂号+预约,前端不传身份参数
*/
import type { AiDailyBriefResult } from '../api';
import { computed, onMounted, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { IconifyIcon as VbenIcon } from '@vben/icons';
import { getAiDailyBriefApi } from '../api';
interface Props {
title: string;
}
const props = defineProps<Props>();
const brief = ref<AiDailyBriefResult | null>(null);
const loading = ref(true);
/** 接口本身失败(网络/鉴权),区别于 brief.status=0 的「AI 生成失败」 */
const fetchError = ref(false);
/** 每日首弹的 localStorage key按天记录避免当天反复弹打扰 */
const POPUP_STORAGE_KEY = 'workbench_ai_brief_popup_date';
/** 每日简报弹窗:纯查看无 footer宽度适中保证正文可读 */
const [BriefModal, briefModalApi] = useVbenModal({
footer: false,
title: 'AI 今日简报',
class: 'w-[560px]',
});
/**
* 正文分段解析:后端约定 AI 输出「【昨日概览】...【重点提醒】...【今日建议】...」格式,
* 按【标题】切段渲染更易读AI 偶发不按格式输出时兜底整段展示,保证内容不丢
*/
const sections = computed(() => {
const content = brief.value?.content || '';
if (!content) return [];
const matches = [...content.matchAll(/【([^】]+)】([^【]*)/g)];
if (matches.length === 0) return [{ title: '', text: content }];
return matches.map((m) => ({ title: m[1] || '', text: (m[2] || '').trim() }));
});
/**
* 快照统计 pill直接读生成时落库的 data_snapshot保证数字与 AI 文本口径一致
* (若改为实时接口,刷新页面后统计可能与简报文字对不上)
*/
const chips = computed(() => {
const data = brief.value?.data;
if (!data) return [];
if (data.scope === 'admin') {
const y = data.yesterday || {};
const todoTotal = Array.isArray(data.todo)
? data.todo.reduce((sum: number, item: any) => sum + (Number(item.count) || 0), 0)
: 0;
return [
{ icon: 'lucide:coins', label: '昨日营收', value: `¥${y.revenue ?? 0}` },
{ icon: 'lucide:stethoscope', label: '昨日挂号', value: y.register_count ?? 0 },
{ icon: 'lucide:store', label: '昨日新录入门店', value: y.store_input_count ?? 0 },
{ icon: 'lucide:list-checks', label: '今日待办', value: todoTotal, highlight: todoTotal > 0 },
];
}
const today = data.today || {};
const schedule = data.schedule || {};
const upcoming = data.upcoming || {};
return [
{
icon: 'lucide:calendar-check',
label: '今日值班',
value: schedule.is_work ? `${schedule.left ?? 0}/${schedule.total ?? 0}余号` : '休诊',
},
{ icon: 'lucide:users', label: '待接诊', value: today.wait_accept ?? 0, highlight: (today.wait_accept ?? 0) > 0 },
{
icon: 'lucide:bell-ring',
label: '今日到期预约',
value: today.appointment_due_waiting ?? 0,
highlight: (today.appointment_due_waiting ?? 0) > 0,
},
{ icon: 'lucide:calendar-clock', label: '未来7天预约', value: upcoming.total ?? 0 },
];
});
/**
* 拉取简报refresh=true 时强制后端重新生成(用户点「重新生成」)
* 首次生成可能耗时十几秒(走 AI期间维持骨架加载态
*/
async function fetchBrief(refresh: boolean = false) {
loading.value = true;
fetchError.value = false;
try {
brief.value = await getAiDailyBriefApi(refresh);
maybeAutoPopup();
} catch {
fetchError.value = true;
} finally {
loading.value = false;
}
}
/**
* 每日首次自动弹窗:生成成功且本浏览器今天没弹过才弹
* 失败态不自动弹(打扰用户),卡片内保留重试入口
*/
function maybeAutoPopup() {
if (!brief.value || brief.value.status !== 1) return;
const today = brief.value.brief_date;
if (localStorage.getItem(POPUP_STORAGE_KEY) === today) return;
localStorage.setItem(POPUP_STORAGE_KEY, today);
briefModalApi.open();
}
/**
* 手动展开弹窗查看全文(卡片正文做了行数收纳,长简报点开看全文)
*/
function openPopup() {
briefModalApi.open();
}
onMounted(() => fetchBrief());
</script>
<template>
<div class="ai-brief">
<div class="ai-brief__card">
<div class="ai-brief__head">
<span class="ai-brief__icon-box">
<VbenIcon icon="lucide:sparkles" class="ai-brief__icon" />
</span>
<span class="ai-brief__title">{{ props.title || 'AI 今日简报' }}</span>
<span v-if="brief && brief.status === 1" class="ai-brief__time">
{{ brief.generated_at }}
</span>
<button
type="button"
class="ai-brief__refresh"
:class="{ 'is-loading': loading }"
:disabled="loading"
aria-label="重新生成"
@click="fetchBrief(true)"
>
<VbenIcon icon="lucide:refresh-cw" />
<span>重新生成</span>
</button>
</div>
<!-- 加载态AI 生成可能耗时十几秒给出明确等待文案而非静默骨架 -->
<div v-if="loading" class="ai-brief__loading">
<span class="ai-brief__loading-dot"></span>
<span class="ai-brief__loading-dot"></span>
<span class="ai-brief__loading-dot"></span>
<span class="ai-brief__loading-text">AI 正在分析今日数据请稍候</span>
</div>
<!-- 接口失败态网络层 -->
<div v-else-if="fetchError" class="ai-brief__error">
<VbenIcon icon="lucide:cloud-off" />
<span>简报加载失败</span>
<button type="button" class="ai-brief__retry" @click="fetchBrief()">重试</button>
</div>
<!-- AI 生成失败态快照芯片仍可看正文位置给重试引导 -->
<template v-else-if="brief && brief.status !== 1">
<div v-if="chips.length" class="ai-brief__chips">
<span
v-for="chip in chips"
:key="chip.label"
class="ai-chip"
:class="{ 'ai-chip--hl': chip.highlight }"
>
<VbenIcon :icon="chip.icon" class="ai-chip__icon" />
<span class="ai-chip__value">{{ chip.value }}</span>
<span class="ai-chip__label">{{ chip.label }}</span>
</span>
</div>
<div class="ai-brief__error">
<VbenIcon icon="lucide:bot-off" />
<span>AI 生成失败{{ brief.error_msg ? `${brief.error_msg}` : '' }}</span>
<button type="button" class="ai-brief__retry" @click="fetchBrief(true)">重新生成</button>
</div>
</template>
<!-- 成功态快照芯片 + 分段正文收纳高度查看全文弹窗读全篇 -->
<template v-else-if="brief">
<div v-if="chips.length" class="ai-brief__chips">
<span
v-for="chip in chips"
:key="chip.label"
class="ai-chip"
:class="{ 'ai-chip--hl': chip.highlight }"
>
<VbenIcon :icon="chip.icon" class="ai-chip__icon" />
<span class="ai-chip__value">{{ chip.value }}</span>
<span class="ai-chip__label">{{ chip.label }}</span>
</span>
</div>
<div class="ai-brief__body" role="button" tabindex="0" @click="openPopup" @keydown.enter="openPopup">
<div v-for="(section, idx) in sections" :key="idx" class="ai-brief__section">
<span v-if="section.title" class="ai-brief__section-tag">{{ section.title }}</span>
<p class="ai-brief__section-text">{{ section.text }}</p>
</div>
</div>
<button type="button" class="ai-brief__more" @click="openPopup">
查看全文
<VbenIcon icon="lucide:chevron-right" />
</button>
</template>
</div>
<!-- 每日首次自动弹出 / 查看全文手动弹出放卡片加载态外层避免随重渲染卸载 -->
<BriefModal>
<div class="ai-popup">
<div class="ai-popup__date">
<VbenIcon icon="lucide:sparkles" class="ai-popup__date-icon" />
<span>{{ brief?.brief_date }} 今日简报</span>
</div>
<div v-if="chips.length" class="ai-brief__chips">
<span
v-for="chip in chips"
:key="chip.label"
class="ai-chip"
:class="{ 'ai-chip--hl': chip.highlight }"
>
<VbenIcon :icon="chip.icon" class="ai-chip__icon" />
<span class="ai-chip__value">{{ chip.value }}</span>
<span class="ai-chip__label">{{ chip.label }}</span>
</span>
</div>
<div class="ai-popup__body">
<div v-for="(section, idx) in sections" :key="idx" class="ai-popup__section">
<span v-if="section.title" class="ai-brief__section-tag">{{ section.title }}</span>
<p class="ai-popup__section-text">{{ section.text }}</p>
</div>
</div>
<div v-if="brief" class="ai-popup__meta">
AI 生成仅供参考 · {{ brief.generated_at }}
</div>
</div>
</BriefModal>
</div>
</template>
<style scoped>
/* 卡片外壳:与 KpiCard 同基调 + AI 主题微光规则1px 同色系边框 + box-shadow 微光,不用左侧竖条) */
.ai-brief__card {
padding: 18px 20px;
background: hsl(var(--card));
border: 1px solid hsl(var(--primary) / 30%);
border-radius: 12px;
box-shadow: 0 0 6px hsl(var(--primary) / 18%);
}
.ai-brief__head {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 12px;
}
.ai-brief__icon-box {
display: grid;
place-items: center;
width: 26px;
height: 26px;
background: hsl(var(--primary) / 0.1);
border-radius: 8px;
}
.ai-brief__icon {
width: 15px;
height: 15px;
color: hsl(var(--primary));
}
.ai-brief__title {
font-size: 15px;
font-weight: 650;
color: hsl(var(--foreground));
letter-spacing: -0.01em;
}
.ai-brief__time {
font-size: 12px;
color: hsl(var(--muted-foreground));
}
/* 重新生成按钮:靠右 pill加载中图标旋转 */
.ai-brief__refresh {
display: flex;
gap: 5px;
align-items: center;
height: 26px;
padding: 0 10px;
margin-left: auto;
border: 1px solid hsl(var(--border));
border-radius: 999px;
background: transparent;
color: hsl(var(--muted-foreground));
font-size: 12px;
cursor: pointer;
transition:
color 0.15s ease,
border-color 0.15s ease,
background 0.15s ease;
}
.ai-brief__refresh:hover:not(:disabled) {
color: hsl(var(--primary));
border-color: hsl(var(--primary) / 0.4);
background: hsl(var(--primary) / 0.06);
}
.ai-brief__refresh:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.ai-brief__refresh :deep(svg) {
width: 12px;
height: 12px;
}
.ai-brief__refresh.is-loading :deep(svg) {
animation: ai-spin 0.8s linear infinite;
}
/* ===== 加载态:三点呼吸 + 文案 ===== */
.ai-brief__loading {
display: flex;
gap: 6px;
align-items: center;
padding: 18px 4px;
}
.ai-brief__loading-dot {
width: 6px;
height: 6px;
background: hsl(var(--primary) / 0.7);
border-radius: 50%;
animation: ai-bounce 1.2s ease-in-out infinite;
}
.ai-brief__loading-dot:nth-child(2) {
animation-delay: 0.15s;
}
.ai-brief__loading-dot:nth-child(3) {
animation-delay: 0.3s;
}
.ai-brief__loading-text {
margin-left: 6px;
font-size: 13px;
color: hsl(var(--muted-foreground));
}
/* ===== 失败态 ===== */
.ai-brief__error {
display: flex;
gap: 8px;
align-items: center;
padding: 12px 4px;
font-size: 13px;
color: hsl(var(--muted-foreground));
}
.ai-brief__error :deep(svg) {
width: 16px;
height: 16px;
flex-shrink: 0;
}
.ai-brief__retry {
height: 26px;
padding: 0 12px;
border: 1px solid hsl(var(--primary) / 0.35);
border-radius: 999px;
background: hsl(var(--primary) / 0.06);
color: hsl(var(--primary));
font-size: 12px;
cursor: pointer;
flex-shrink: 0;
transition: background 0.15s ease;
}
.ai-brief__retry:hover {
background: hsl(var(--primary) / 0.12);
}
/* ===== 快照统计 pill ===== */
.ai-brief__chips {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.ai-chip {
display: inline-flex;
gap: 6px;
align-items: center;
padding: 5px 10px;
border: 1px solid hsl(var(--border) / 0.7);
border-radius: 999px;
font-size: 12px;
}
.ai-chip--hl {
border-color: hsl(var(--warning) / 0.45);
background: hsl(var(--warning) / 0.1);
}
.ai-chip__icon {
width: 13px;
height: 13px;
color: hsl(var(--primary));
}
.ai-chip--hl .ai-chip__icon {
color: hsl(var(--warning));
}
.ai-chip__value {
font-weight: 650;
color: hsl(var(--foreground));
}
.ai-chip__label {
color: hsl(var(--muted-foreground));
}
/* ===== 卡片内正文:分段渲染,限制高度内滚动避免撑爆工作台 ===== */
.ai-brief__body {
display: flex;
flex-direction: column;
gap: 10px;
max-height: 180px;
padding: 12px 14px;
overflow-y: auto;
background: hsl(var(--muted) / 0.25);
border-radius: 10px;
cursor: pointer;
}
.ai-brief__section {
display: flex;
flex-direction: column;
gap: 4px;
}
.ai-brief__section-tag {
align-self: flex-start;
padding: 1px 8px;
background: hsl(var(--primary) / 0.1);
border-radius: 999px;
font-size: 11px;
font-weight: 600;
color: hsl(var(--primary));
}
.ai-brief__section-text {
margin: 0;
font-size: 13px;
line-height: 1.7;
color: hsl(var(--foreground) / 0.9);
white-space: pre-wrap;
}
.ai-brief__more {
display: inline-flex;
gap: 2px;
align-items: center;
margin-top: 8px;
padding: 0;
border: none;
background: transparent;
font-size: 12px;
color: hsl(var(--primary));
cursor: pointer;
}
.ai-brief__more :deep(svg) {
width: 13px;
height: 13px;
}
/* ===== 每日首弹弹窗 ===== */
.ai-popup {
padding: 4px 2px 8px;
}
.ai-popup__date {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 12px;
font-size: 14px;
font-weight: 650;
color: hsl(var(--foreground));
}
.ai-popup__date-icon {
width: 16px;
height: 16px;
color: hsl(var(--primary));
}
.ai-popup__body {
display: flex;
flex-direction: column;
gap: 14px;
max-height: 52vh;
overflow-y: auto;
}
.ai-popup__section {
display: flex;
flex-direction: column;
gap: 6px;
}
.ai-popup__section-text {
margin: 0;
font-size: 14px;
line-height: 1.8;
color: hsl(var(--foreground) / 0.92);
white-space: pre-wrap;
}
.ai-popup__meta {
margin-top: 14px;
padding-top: 10px;
border-top: 1px solid hsl(var(--border) / 0.5);
font-size: 12px;
color: hsl(var(--muted-foreground));
}
@keyframes ai-spin {
to {
transform: rotate(360deg);
}
}
@keyframes ai-bounce {
0%,
80%,
100% {
opacity: 0.35;
transform: translateY(0);
}
40% {
opacity: 1;
transform: translateY(-3px);
}
}
@media (prefers-reduced-motion: reduce) {
.ai-brief__loading-dot,
.ai-brief__refresh.is-loading :deep(svg) {
animation: none;
}
}
</style>

View File

@@ -1,6 +1,7 @@
import type { Component } from 'vue';
import NoticeTicker from '../components/NoticeTicker.vue';
import WidgetAiDailyBrief from '../components/WidgetAiDailyBrief.vue';
import WidgetAppointmentUpcoming from '../components/WidgetAppointmentUpcoming.vue';
import WidgetCalendarTodo from '../components/WidgetCalendarTodo.vue';
import WidgetClinicToday from '../components/WidgetClinicToday.vue';
@@ -33,6 +34,8 @@ export const WIDGET_COMPONENT_MAP: Record<string, Component> = {
// 日程待办(快捷入口行右侧)与日程日历(快捷入口行下方全宽),布局位置固定不进 KPI 区
calendar_todo: WidgetCalendarTodo,
work_calendar: WidgetWorkCalendar,
// AI 今日简报全宽内容卡文本主体固定渲染在日程行之后、KPI 区之前,不进 KPI 芯片区
ai_daily_brief: WidgetAiDailyBrief,
};
/** KPI 类模块(渲染在中部 KPI 区,其余按固定位置渲染) */

View File

@@ -58,7 +58,12 @@ const workCalendarItem = computed(() =>
validItems.value.find((item) => item.widget_code === 'work_calendar'),
);
/** KPI 摘要区(中部,按 sort 依次渲染;总览/待办已合并进顶部驾驶舱,需排除 */
/** AI 今日简报:日程行之后全宽一行(文本主体卡,含每日首弹弹窗 */
const aiBriefItem = computed(() =>
validItems.value.find((item) => item.widget_code === 'ai_daily_brief'),
);
/** KPI 摘要区(中部,按 sort 依次渲染;总览/待办已合并进顶部驾驶舱需排除AI 简报不在 KPI 集合天然不进来) */
const kpiItems = computed(() =>
validItems.value.filter(
(item) =>
@@ -136,6 +141,28 @@ onMounted(fetchLayout);
</div>
</template>
<!-- AI 今日简报全宽一行每天首次进入自动弹窗展示全文 -->
<div v-if="aiBriefItem" class="workspace-page__section" style="--delay: 3">
<component
:is="WIDGET_COMPONENT_MAP[aiBriefItem.widget_code]"
:title="aiBriefItem.title"
/>
</div>
<!-- 角色 KPI 摘要 -->
<div
v-for="(item, index) in kpiItems"
:key="item.widget_code"
:style="{ '--delay': index + 4 }"
class="workspace-page__section"
>
<component
:is="WIDGET_COMPONENT_MAP[item.widget_code]"
:title="item.title"
/>
</div>
<!-- 第二行日程日历 6+ 日程待办 4等高缺一方时另一方全宽 -->
<div
v-if="workCalendarItem || calendarTodoItem"
@@ -160,19 +187,6 @@ onMounted(fetchLayout);
</div>
</div>
</div>
<!-- 角色 KPI 摘要 -->
<div
v-for="(item, index) in kpiItems"
:key="item.widget_code"
:style="{ '--delay': index + 3 }"
class="workspace-page__section"
>
<component
:is="WIDGET_COMPONENT_MAP[item.widget_code]"
:title="item.title"
/>
</div>
</template>
</div>
</div>
@@ -188,7 +202,6 @@ onMounted(fetchLayout);
display: flex;
flex-direction: column;
gap: 16px;
max-width: 1400px;
margin: 0 auto;
}

View File

@@ -473,6 +473,13 @@ export async function aiGenerationDetailApi(data: { id: number }) {
return requestClient.get<any>(`${prefix}ai-generation-detail`, { params: data });
}
/** 标记 AI 生成记录为已读(熄灭历史未读微光) */
export async function aiMarkGenerationReadApi(data: {
generation_id: number;
}) {
return requestClient.post<any>(`${prefix}ai-mark-generation-read`, data);
}
/** AI 处方药名模糊对照 */
export async function aiMatchPrescriptionDrugsApi(data: {
generation_id?: number;

View File

@@ -7,11 +7,12 @@
import { computed, ref } from 'vue';
import { useVbenDrawer, useVbenModal } from '@vben/common-ui';
import { Button, Checkbox, Descriptions, Input, Radio, Spin, Switch, Tag, message } from 'ant-design-vue';
import { Button, Checkbox, Descriptions, Input, Radio, Spin, Switch, Tabs, Tag, message } from 'ant-design-vue';
import {
aiGeneratePrescriptionApi,
aiListGenerationsApi,
aiMarkGenerationReadApi,
aiMatchPrescriptionDrugsApi,
aiSavePrescriptionDrugSelectionApi,
getProcessRuleList,
@@ -114,6 +115,12 @@ const rxProcessRuleNoteId = ref(0);
const rxProcessRuleName = ref('');
const rxChildProcessRuleName = ref('');
const rxProcessRuleNote = ref('');
/**
* 一次多份生成结果(方案对比):
* 后端 prescriptions[];选中成功方案后右侧对照/导入跟选中份走
*/
const multiPrescriptions = ref<any[]>([]);
const multiActiveIndex = ref(0);
/** 生成弹窗:中药委托调剂选项(本地记忆) */
const AI_RX_ENTRUSTED_STORAGE_KEY = 'xk_ai_rx_use_entrusted_process';
const useEntrustedProcess = ref(false);
@@ -247,6 +254,8 @@ function resetPreview() {
rxProcessRuleNote.value = '';
activeId.value = 0;
editingKey.value = '';
multiPrescriptions.value = [];
multiActiveIndex.value = 0;
}
function resetState() {
@@ -608,9 +617,76 @@ async function confirmGenerateAndClose(): Promise<boolean> {
return true;
}
/**
* 本地把某条历史标为已读(同步 multi / historyList
*/
function patchLocalRead(generationId: number) {
const gid = Number(generationId || 0);
if (gid <= 0) return;
historyList.value = (historyList.value || []).map((r: any) =>
Number(r.id) === gid ? { ...r, is_read: 1 } : r,
);
multiPrescriptions.value = (multiPrescriptions.value || []).map((p: any) =>
Number(p?.generation_id) === gid ? { ...p, is_read: 1 } : p,
);
}
/**
* 医生查看方案:调后端标记已读(失败不阻断预览)
*/
async function markReadIfNeeded(generationId: number, alreadyRead?: number | boolean) {
const gid = Number(generationId || 0);
if (gid <= 0 || Number(alreadyRead) === 1) return;
try {
await aiMarkGenerationReadApi({ generation_id: gid });
patchLocalRead(gid);
} catch {
// 已读标记失败不影响对照预览
}
}
/**
* 应用某一份多方案结果到右侧预览(成功份可导入,失败份只展示错误)
*/
function applyMultiSlot(slot: any, index: number) {
multiActiveIndex.value = index;
if (!slot || slot.ok === false) {
matchedList.value = [];
unmatchedList.value = [];
conflictMessages.value = [];
safetyFlags.value = [];
applyRxMeta({
dosage: 0,
day_dosage: 0,
reason: '',
basis: '',
prescription_name: '',
duration_ms: slot?.duration_ms,
});
activeId.value = Number(slot?.generation_id || 0);
return;
}
activeId.value = Number(slot.generation_id || 0);
applyMatch(slot.match || null);
applyConflict(slot.conflict || null);
applySafetyFlags(slot.safety_flags || null);
applyRxMeta(slot);
void markReadIfNeeded(activeId.value, slot.is_read);
}
/** 切换多方案 Tab */
function onMultiTabChange(key: string | number) {
const idx = Number(key);
const slot = multiPrescriptions.value[idx];
if (!slot) return;
applyMultiSlot(slot, idx);
}
async function runGenerateInDrawer(chief: string) {
beginGeneratingPlaceholder();
generating.value = true;
multiPrescriptions.value = [];
multiActiveIndex.value = 0;
try {
const req: Record<string, any> = {
register_id: registerId.value,
@@ -630,10 +706,14 @@ async function runGenerateInDrawer(chief: string) {
}
const data = await aiGeneratePrescriptionApi(req);
const durationText = formatDurationMs(data?.duration_ms);
const list = Array.isArray(data?.prescriptions) ? data.prescriptions : [];
// 业务软失败HTTP 成功但 ok=false警告提示而非 error
if (data?.ok === false) {
endGeneratingPlaceholder();
resetPreview();
if (list.length > 1) {
multiPrescriptions.value = list;
}
if (data?.generation_id) {
await loadHistory();
activeId.value = Number(data.generation_id);
@@ -642,11 +722,26 @@ async function runGenerateInDrawer(chief: string) {
return;
}
await loadHistory();
if (list.length > 1) {
multiPrescriptions.value = list;
const firstOkIdx = list.findIndex((p: any) => p && p.ok !== false);
const useIdx = firstOkIdx >= 0 ? firstOkIdx : 0;
applyMultiSlot(list[useIdx], useIdx);
const okN = list.filter((p: any) => p && p.ok !== false).length;
const failN = list.length - okN;
const baseMsg =
failN > 0
? `已生成 ${okN} 套可用方案(${failN} 套失败),可切换对比`
: `已生成 ${okN} 套方案,可切换对比`;
message.success(durationText ? `${baseMsg}(耗时 ${durationText}` : baseMsg);
return;
}
activeId.value = Number(data?.generation_id || 0);
applyMatch(data?.match || null);
applyConflict(data?.conflict || null);
applySafetyFlags(data?.safety_flags || null);
applyRxMeta(data);
void markReadIfNeeded(activeId.value, 0);
message.success(
durationText ? `已生成(耗时 ${durationText}` : '已生成,请确认导入',
);
@@ -661,6 +756,9 @@ async function runGenerateInDrawer(chief: string) {
async function onSelectHistory(row: any) {
if (!row?.id || Number(row.id) === PENDING_GEN_ID || row._pending) return;
// 点历史时退出「本次多方案」对比态,避免 Tab 与历史选中互相干扰
multiPrescriptions.value = [];
multiActiveIndex.value = 0;
activeId.value = Number(row.id);
matchLoading.value = true;
try {
@@ -672,6 +770,8 @@ async function onSelectHistory(row: any) {
prescriptionType.value || row.prescription_type || 0,
),
});
// match 接口后端已标已读,本地同步熄灭微光
patchLocalRead(Number(row.id));
applyMatch(data);
applyConflict(data?.conflict || null);
applySafetyFlags(data?.safety_flags || null);
@@ -733,6 +833,15 @@ async function onPickCandidate(mi: number, candidate: any) {
}
function onConfirmImport() {
// 多方案时:失败方案不可导入
const multi = multiPrescriptions.value;
if (multi.length > 1) {
const cur = multi[multiActiveIndex.value];
if (!cur || cur.ok === false) {
message.warning('当前方案生成失败,请切换到成功方案后再导入');
return;
}
}
const drugs: any[] = [];
for (const m of matchedList.value) {
if (!m || m._import === false) continue;
@@ -860,6 +969,10 @@ defineExpose({
'bg-primary/10 ring-1 ring-primary/20': activeId === Number(row.id),
'cursor-default border border-dashed border-primary/40 bg-primary/5':
Number(row.id) === PENDING_GEN_ID || row._pending,
'ai-hist-unread':
Number(row.id) !== PENDING_GEN_ID &&
!row._pending &&
Number(row.is_read) !== 1,
}"
@click="onSelectHistory(row)"
>
@@ -869,6 +982,14 @@ defineExpose({
? '正在生成中…'
: row.name || row.prescription_name || `方案 #${row.id}`
}}
<span
v-if="
Number(row.id) !== PENDING_GEN_ID &&
!row._pending &&
Number(row.is_read) !== 1
"
class="ml-1 text-[10px] text-primary"
>未读</span>
</div>
<div class="mt-0.5 text-muted-foreground">
<template v-if="Number(row.id) === PENDING_GEN_ID || row._pending">
@@ -902,6 +1023,50 @@ defineExpose({
<span class="text-sm">加载历史处方…</span>
</div>
<template v-else>
<!-- 多方案对比Tab 切换;失败方案标红不可导入 -->
<div v-if="multiPrescriptions.length > 1" class="mb-2 shrink-0">
<Tabs
:active-key="String(multiActiveIndex)"
size="small"
@change="onMultiTabChange"
>
<Tabs.TabPane
v-for="(p, pi) in multiPrescriptions"
:key="String(pi)"
:disabled="false"
>
<template #tab>
<span
class="inline-flex items-center gap-1"
:class="{
'text-red-500': p && p.ok === false,
'ai-multi-unread-tab':
p && p.ok !== false && Number(p.is_read) !== 1,
}"
>
方案 {{ Number(pi) + 1
}}{{ p && p.ok === false ? '(失败)' : '' }}
<span
v-if="p && p.ok !== false && Number(p.is_read) !== 1"
class="text-[10px] text-primary"
>新</span>
</span>
</template>
</Tabs.TabPane>
</Tabs>
<div
v-if="
multiPrescriptions[multiActiveIndex] &&
multiPrescriptions[multiActiveIndex].ok === false
"
class="rounded-lg border border-red-500/35 bg-red-500/5 px-3 py-2 text-xs text-red-500"
>
{{
multiPrescriptions[multiActiveIndex].error_msg ||
'该方案生成失败'
}}
</div>
</div>
<div
v-if="!matchedList.length && !unmatchedList.length && !lastDurationMs"
class="py-8 text-center text-muted-foreground"
@@ -1246,4 +1411,71 @@ defineExpose({
color: #3dd6b5;
border-color: #00a88a;
}
/* 未读历史:同色系边框 + 粒子微光脉冲(禁止左侧色条) */
.ai-hist-unread {
position: relative;
border: 1px solid hsl(var(--primary) / 45%);
box-shadow:
0 0 0 1px hsl(var(--primary) / 12%),
0 0 10px hsl(var(--primary) / 28%),
0 0 18px hsl(var(--primary) / 14%);
animation: ai-unread-glow 2.2s ease-in-out infinite;
overflow: hidden;
}
.ai-hist-unread::before,
.ai-hist-unread::after {
content: '';
position: absolute;
width: 5px;
height: 5px;
border-radius: 50%;
background: hsl(var(--primary) / 85%);
box-shadow: 0 0 8px hsl(var(--primary) / 70%);
pointer-events: none;
animation: ai-unread-spark 2.4s linear infinite;
}
.ai-hist-unread::before {
top: 6px;
left: 10%;
}
.ai-hist-unread::after {
bottom: 8px;
right: 12%;
animation-delay: 1.1s;
}
.ai-multi-unread-tab {
padding: 0 4px;
border: 1px solid hsl(var(--primary) / 40%);
border-radius: 6px;
box-shadow: 0 0 8px hsl(var(--primary) / 22%);
animation: ai-unread-glow 2.2s ease-in-out infinite;
}
@keyframes ai-unread-glow {
0%,
100% {
box-shadow:
0 0 0 1px hsl(var(--primary) / 10%),
0 0 8px hsl(var(--primary) / 22%),
0 0 14px hsl(var(--primary) / 10%);
}
50% {
box-shadow:
0 0 0 1px hsl(var(--primary) / 22%),
0 0 14px hsl(var(--primary) / 38%),
0 0 22px hsl(var(--primary) / 18%);
}
}
@keyframes ai-unread-spark {
0% {
opacity: 0;
transform: translate(0, 0) scale(0.6);
}
20% {
opacity: 1;
}
100% {
opacity: 0;
transform: translate(18px, -10px) scale(0.2);
}
}
</style>

View File

@@ -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 || {});
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 },
];

View File

@@ -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: {

View File

@@ -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',
},
],

View File

@@ -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>

View File

@@ -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 出方生成几份方案15默认 1>1 需开启 Agent 中转并由 Go 并行) */
const aiPrescriptionGenerateCount = ref(1);
// 分组 1ReAct 多轮循环
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': {
// 钳制 15非法回落默认 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))),
),
},
// 分组 1ReAct
{ 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设为 25 时由 Go Agent 并行生成多套方案供医生对比需开启上方中转单路失败不影响其它成功方案
</div>
</div>
</div>
<!-- 分组 1ReAct 多轮推理循环 -->
@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -1,424 +1,168 @@
<script lang="ts" setup>
import { onMounted, ref } from 'vue';
/**
* 系统配置页壳:左侧一级菜单 + 右侧动态面板
* 各模块自管 load/saveAI 在右侧保留二级 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>