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