feat: 补齐素材文件夹、挂号改期、系统配置分组与工作台简报开关

素材库按 object_key 目录同步文件夹侧栏,选图和筛选可按目录收窄。
挂号列表支持管理端代改期,号源日历与患者端同源。
系统配置按端拆模块(患者首页展示、医生默认常用入口、工作台简报总开关、处方签名),旧菜单 key 做迁移以免刷新丢选中。
AI 生成记录补每日简报场景和缓存命中筛选;个人中心改走后端菜单 Profile。
This commit is contained in:
李琦
2026-08-18 09:12:07 +08:00
parent c6d2b0f917
commit d159e514b2
33 changed files with 4342 additions and 1133 deletions

View File

@@ -7,16 +7,16 @@ import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Button, Descriptions, Spin, Tag, message } from 'ant-design-vue';
import { Button, Descriptions, message, Spin, Tag } 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>];
/** 打开页面级打分弹窗 */
review: [row: Record<string, any>];
}>();
const loading = ref(false);
@@ -79,6 +79,96 @@ function prettyJson(v: any) {
}
}
/**
* 每日简报详情:result_json 里可能是包了一层的 JSON,先拆成【标题】正文再展示
*/
const briefPreview = computed(() => {
if (String(detail.value.scene || '') !== 'daily_brief') return '';
const raw =
detail.value?.result_json?.content ?? detail.value?.raw_response ?? '';
return unwrapBriefContent(String(raw || ''));
});
function unwrapBriefContent(raw: string, depth = 0): string {
let text = String(raw || '').trim();
if (!text || depth > 4) return text;
const fence = text.match(/```(?:json)?\s*([\s\S]*?)```/);
if (fence?.[1]) text = fence[1].trim();
try {
const decoded = JSON.parse(text);
if (typeof decoded === 'string')
return unwrapBriefContent(decoded, depth + 1);
if (!decoded || typeof decoded !== 'object') return text;
if (
typeof (decoded as any).content === 'string' &&
(decoded as any).content.trim()
) {
return unwrapBriefContent((decoded as any).content, depth + 1);
}
if (
typeof (decoded as any).text === 'string' &&
(decoded as any).text.trim()
) {
return unwrapBriefContent((decoded as any).text, depth + 1);
}
const parts: string[] = [];
Object.entries(decoded).forEach(([key, value]) => {
if (typeof value !== 'string' && typeof value !== 'number') return;
const title = String(key).trim();
const body = String(value).trim();
if (
!title ||
!body ||
['brief_status', 'content', 'status', 'text'].includes(title)
)
return;
parts.push(`【${title}】${body}`);
});
return parts.length > 0 ? parts.join('') : text;
} catch {
return text;
}
}
/** 详情接口顶层 kb_hits:本次注入 prompt 的知识库文档 / 金方 */
const kbHits = computed<Record<string, any>[]>(() => {
const list = detail.value?.kb_hits;
return Array.isArray(list) ? list : [];
});
/** Agent 路径或已有检索步骤时才展示命中区,直连生成没有知识库检索 */
const showKbHitsPanel = computed(() => {
if (kbHits.value.length > 0) return true;
if (Number(detail.value.via_agent) === 1) return true;
const steps = Array.isArray(detail.value.steps) ? detail.value.steps : [];
return steps.some(
(s: Record<string, any>) => String(s?.step_type || '') === 'kb_retrieval',
);
});
const kbHitsEmptyText = computed(() => {
const reason = String(detail.value.kb_retrieval_detail || '').trim();
if (reason.includes('未检索')) return reason;
if (reason.includes('命中 0')) return reason || '检索完成,未命中文档或方剂';
if (reason) {
return `${reason}。本次未落库命中正文,请确认已开启知识库检索并重新生成`;
}
if (Number(detail.value.via_agent) === 1) {
return '本次无知识库检索步骤';
}
return '直连路径未检索知识库';
});
function kbHitKindLabel(kind: string) {
return kind === 'formula' ? '金方' : '文档';
}
function formatKbScore(v: unknown) {
const n = Number(v);
if (!n || Number.isNaN(n)) return '';
return n >= 10 ? n.toFixed(1) : n.toFixed(3);
}
const canReview = computed(() => Number(detail.value.status) === 1);
const canArchive = computed(
() =>
@@ -113,7 +203,9 @@ function onArchiveClick() {
<Spin :spinning="loading">
<div class="detail-scroll">
<Descriptions bordered size="small" :column="3" class="text-sm">
<Descriptions.Item label="ID">{{ detail.id || '—' }}</Descriptions.Item>
<Descriptions.Item label="ID">
{{ detail.id || '—' }}
</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag :color="statusColor(Number(detail.status))">
{{ detail.status_txt || detail.status }}
@@ -133,8 +225,24 @@ function onArchiveClick() {
}}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="供应商">{{ detail.provider || '—' }}</Descriptions.Item>
<Descriptions.Item label="模型">{{ detail.model || '—' }}</Descriptions.Item>
<Descriptions.Item label="缓存">
<Tag
:color="
Number(detail.is_cache_hit) === 1 ? 'processing' : 'default'
"
>
{{
detail.cache_txt ||
(Number(detail.is_cache_hit) === 1 ? '命中缓存' : '未命中')
}}
</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>
@@ -142,13 +250,16 @@ function onArchiveClick() {
{{ 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 }})
总 {{ 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="采纳">
{{ detail.adopted_txt || '—' }}
</Descriptions.Item>
<Descriptions.Item label="正确性">
<Tag
:color="
@@ -163,10 +274,16 @@ function onArchiveClick() {
</Tag>
</Descriptions.Item>
<Descriptions.Item label="得分">
{{ Number(detail.quality_score) > 0 ? `${detail.quality_score} 分` : '—' }}
{{
Number(detail.quality_score) > 0
? `${detail.quality_score} 分`
: '—'
}}
</Descriptions.Item>
<Descriptions.Item label="归档">
<Tag :color="Number(detail.is_archived) === 1 ? 'success' : 'default'">
<Tag
:color="Number(detail.is_archived) === 1 ? 'success' : 'default'"
>
{{ detail.archived_txt || '未归档' }}
</Tag>
<span
@@ -185,10 +302,53 @@ function onArchiveClick() {
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>
<div class="mb-2 text-xs text-[hsl(var(--muted-foreground))]">
生成工作流
</div>
<GenerationFlowTimeline :row="detail" />
</div>
<div v-if="showKbHitsPanel" class="kb-hits-panel mt-3">
<div class="kb-hits-title">
知识库命中
<span class="kb-hits-count">{{ kbHits.length }} 条</span>
</div>
<div v-if="!kbHits.length" class="kb-hits-empty">
{{ kbHitsEmptyText }}
</div>
<div v-else class="kb-hits-list">
<article
v-for="(hit, idx) in kbHits"
:key="`${hit.kind}-${hit.doc_id}-${idx}`"
class="kb-hit-card"
:class="hit.kind === 'formula' ? 'is-formula' : 'is-doc'"
>
<div class="kb-hit-head">
<Tag
:color="hit.kind === 'formula' ? 'gold' : 'blue'"
class="!mr-0"
>
{{ kbHitKindLabel(String(hit.kind || 'doc')) }}
</Tag>
<span class="kb-hit-title">{{ hit.title || '未命名' }}</span>
<span v-if="hit.library" class="kb-hit-lib">{{
hit.library
}}</span>
<span v-if="formatKbScore(hit.score)" class="kb-hit-score">
{{ formatKbScore(hit.score) }}
</span>
</div>
<pre v-if="hit.content" class="kb-hit-body">{{
hit.content
}}</pre>
</article>
</div>
</div>
<div v-if="briefPreview" class="json-panel mt-3">
<div class="json-title">简报正文</div>
<pre class="json-pre">{{ briefPreview }}</pre>
</div>
<div class="mt-3 grid grid-cols-1 gap-3 lg:grid-cols-2">
<div class="json-panel">
<div class="json-title">输入快照</div>
@@ -216,33 +376,118 @@ function onArchiveClick() {
<style scoped>
.detail-scroll {
max-height: min(72vh, 780px);
overflow: auto;
padding-right: 4px;
overflow: auto;
}
.json-panel {
overflow: hidden;
background: hsl(var(--card, var(--background)));
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%);
border-bottom: 1px solid hsl(var(--border));
}
.json-pre {
margin: 0;
max-height: 220px;
overflow: auto;
padding: 8px 10px;
margin: 0;
overflow: auto;
font-size: 12px;
line-height: 1.45;
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.kb-hits-panel {
overflow: hidden;
background: hsl(var(--card, var(--background)));
border: 1px solid hsl(var(--border));
border-radius: 8px;
}
.kb-hits-title {
display: flex;
gap: 8px;
align-items: center;
padding: 6px 10px;
font-size: 12px;
color: hsl(var(--muted-foreground));
background: hsl(var(--muted) / 20%);
border-bottom: 1px solid hsl(var(--border));
}
.kb-hits-count {
color: hsl(var(--foreground));
}
.kb-hits-empty {
padding: 16px 12px;
font-size: 13px;
line-height: 1.5;
color: hsl(var(--muted-foreground));
}
.kb-hits-list {
display: grid;
gap: 10px;
padding: 10px;
}
.kb-hit-card {
padding: 10px 12px;
background: hsl(var(--background));
border-radius: 8px;
}
.kb-hit-card.is-doc {
border: 1px solid hsl(var(--primary) / 30%);
box-shadow: 0 0 6px hsl(var(--primary) / 18%);
}
.kb-hit-card.is-formula {
border: 1px solid hsl(var(--warning) / 30%);
box-shadow: 0 0 6px hsl(var(--warning) / 18%);
}
.kb-hit-head {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.kb-hit-title {
font-size: 13px;
font-weight: 600;
color: hsl(var(--foreground));
}
.kb-hit-lib,
.kb-hit-score {
font-size: 12px;
color: hsl(var(--muted-foreground));
}
.kb-hit-score::before {
content: '分 ';
}
.kb-hit-body {
max-height: 140px;
margin: 8px 0 0;
overflow: auto;
font-size: 12px;
line-height: 1.55;
color: hsl(var(--foreground));
overflow-wrap: anywhere;
white-space: pre-wrap;
word-break: break-word;
}
</style>

View File

@@ -17,9 +17,9 @@ const props = defineProps<{
}>();
const emit = defineEmits<{
archive: [row: any];
detail: [row: any];
review: [row: any];
archive: [row: any];
}>();
const loading = ref(false);
@@ -43,11 +43,13 @@ async function load() {
...props.filters,
});
const data = res?.data || res || {};
items.value = Array.isArray(data.items)
? data.items
: Array.isArray(data)
? data
: [];
if (Array.isArray(data.items)) {
items.value = data.items;
} else if (Array.isArray(data)) {
items.value = data;
} else {
items.value = [];
}
total.value = Number(data.total ?? items.value.length);
} finally {
loading.value = false;
@@ -88,10 +90,16 @@ defineExpose({ reload: load });
暂无生成记录
</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
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))]">
<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))]">
@@ -106,19 +114,36 @@ defineExpose({ reload: load });
<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' : '直连') }}
{{
row.via_agent_txt ||
(Number(row.via_agent) === 1 ? 'Agent' : '直连')
}}
</Tag>
<Tag
:color="Number(row.is_cache_hit) === 1 ? 'processing' : 'default'"
>
{{
row.cache_txt ||
(Number(row.is_cache_hit) === 1 ? '命中缓存' : '未命中')
}}
</Tag>
<Tag
v-if="Number(row.is_adopted) === 1"
:color="Number(row.is_modified_final) === 1 ? 'processing' : 'success'"
: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-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.quality_score) > 0">
{{ row.quality_score }} 分
</Tag>
<Tag v-if="Number(row.is_archived) === 1" color="success">已归档</Tag>
</div>
@@ -145,7 +170,8 @@ defineExpose({ reload: load });
<div
class="metric-value"
:class="{
'text-[hsl(var(--destructive))]': Number(row.match_drug_unmatched) > 0,
'text-[hsl(var(--destructive))]':
Number(row.match_drug_unmatched) > 0,
}"
>
<template v-if="Number(row.match_drug_total) > 0">
@@ -156,7 +182,10 @@ defineExpose({ reload: load });
</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}`">
<div
class="metric-value"
:title="`P ${row.prompt_tokens || 0} / C ${row.completion_tokens || 0}`"
>
{{ row.total_tokens || 0 }}
</div>
</div>
@@ -171,7 +200,9 @@ defineExpose({ reload: load });
<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>
<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 }}
@@ -186,8 +217,15 @@ defineExpose({ reload: load });
错误:{{ 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)">
<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
@@ -231,16 +269,16 @@ defineExpose({ reload: load });
<style scoped>
.gen-card {
border: 1px solid hsl(var(--primary) / 28%);
background: hsl(var(--card, var(--background)));
border: 1px solid hsl(var(--primary) / 28%);
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;
background: hsl(var(--muted) / 25%);
border: 1px solid hsl(var(--border));
border-radius: 8px;
}
.metric-label {

View File

@@ -10,6 +10,13 @@ export const AI_GENERATION_STATUS_OPTIONS = [
export const AI_GENERATION_SCENE_OPTIONS = [
{ label: '写病历', value: 'medical_record' },
{ label: '出方', value: 'prescription' },
{ label: '每日简报', value: 'daily_brief' },
];
/** 是否命中业务缓存(简报按天缓存等) */
export const AI_GENERATION_CACHE_OPTIONS = [
{ label: '命中缓存', value: 1 },
{ label: '未命中', value: 0 },
];
/** 生成路径:Go Agent 中转 vs PHP 直连 */

View File

@@ -5,6 +5,7 @@ import dayjs from 'dayjs';
import {
AI_GENERATION_ADOPTED_OPTIONS,
AI_GENERATION_ARCHIVED_OPTIONS,
AI_GENERATION_CACHE_OPTIONS,
AI_GENERATION_CORRECT_OPTIONS,
AI_GENERATION_SCENE_OPTIONS,
AI_GENERATION_STATUS_OPTIONS,
@@ -72,6 +73,18 @@ export const formOptions: VbenFormProps = {
fieldName: 'scene',
label: '场景',
},
{
component: 'Select',
componentProps: {
placeholder: '是否命中缓存',
allowClear: true,
options: AI_GENERATION_CACHE_OPTIONS,
class: 'w-full',
},
defaultValue: undefined,
fieldName: 'is_cache_hit',
label: '缓存',
},
{
component: 'Select',
componentProps: {
@@ -160,7 +173,9 @@ export const formOptions: VbenFormProps = {
/**
* 将表单值转为接口参数:search_time → start_time/end_time(当天起止秒级时间戳)
*/
export function normalizeAiGenerationFilters(formValues: Record<string, any> = {}) {
export function normalizeAiGenerationFilters(
formValues: Record<string, any> = {},
) {
const params: Record<string, any> = { ...formValues };
const range = params.search_time;
delete params.search_time;
@@ -168,10 +183,11 @@ export function normalizeAiGenerationFilters(formValues: Record<string, any> = {
params.start_time = dayjs(range[0]).startOf('day').unix();
params.end_time = dayjs(range[1]).endOf('day').unix();
}
Object.keys(params).forEach((k) => {
if (params[k] === '' || params[k] === undefined || params[k] === null) {
delete params[k];
const cleaned: Record<string, any> = {};
Object.entries(params).forEach(([k, v]) => {
if (v !== '' && v !== undefined && v !== null) {
cleaned[k] = v;
}
});
return params;
return cleaned;
}

View File

@@ -46,6 +46,13 @@ export const gridOptions: VxeGridProps<RowType> = {
columns: [
{ field: 'id', align: 'left', title: 'ID', width: 80 },
{ field: 'scene_txt', align: 'left', title: '场景', width: 90 },
{
field: 'cache_txt',
align: 'left',
title: '缓存',
width: 160,
slots: { default: 'cache' },
},
{ field: 'name', align: 'left', title: '名称', minWidth: 140 },
{
field: 'status',

View File

@@ -8,23 +8,23 @@ import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue';
import { Page, useVbenModal } from '@vben/common-ui';
import { Button, Card, Col, Row, Tag, message } from 'ant-design-vue';
import { Button, Card, Col, message, Row, Tag } 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 { getAiModelOption } from '../platform/api/model';
import {
exportAiTrainJsonl,
getAiGenerationList,
getAiGenerationUsageStats,
} from './api';
import ArchiveModal from './components/archive-modal.vue';
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 as baseGridOptions } from './config/table';
@@ -56,7 +56,7 @@ const stats = reactive({
/** 全量模型 option,选平台时按 platform_id 过滤 */
const allModelOptions = ref<
Array<{ label: string; value: string; platform_id?: number }>
Array<{ label: string; platform_id?: number; value: string }>
>([]);
/** 从 schema 取 defaultValue,保证首屏列表/卡片筛选口径一致 */
@@ -73,7 +73,9 @@ function collectDefaultSearchValues(): Record<string, any> {
/** 双视图共用的原始表单值(未 normalize) */
const searchValues = ref<Record<string, any>>(collectDefaultSearchValues());
/** 卡片列表用已 normalize 的筛选,computed 避免模板每次新建对象导致死循环重拉 */
const cardFilters = computed(() => normalizeAiGenerationFilters(searchValues.value));
const cardFilters = computed(() =>
normalizeAiGenerationFilters(searchValues.value),
);
function formatDuration(ms: number) {
return formatDurationMs(ms);
@@ -107,7 +109,9 @@ async function refreshStats(formValues?: Record<string, any>) {
function refreshModelSchema(platformId?: number) {
const filtered =
platformId && platformId > 0
? allModelOptions.value.filter((m) => Number(m.platform_id) === platformId)
? allModelOptions.value.filter(
(m) => Number(m.platform_id) === platformId,
)
: allModelOptions.value;
searchFormApi.updateSchema?.([
{
@@ -144,7 +148,7 @@ const [SearchForm, searchFormApi] = useVbenForm({
: item,
),
handleSubmit: async (values) => {
searchValues.value = { ...(values || {}) };
searchValues.value = { ...values };
void refreshStats(searchValues.value);
if (viewMode.value === 'list') {
gridApi.reload();
@@ -274,7 +278,9 @@ async function handleExportJsonl() {
try {
const formValues =
(await searchFormApi.getValues?.()) || searchValues.value || {};
const res = await exportAiTrainJsonl(normalizeAiGenerationFilters(formValues));
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`);
@@ -343,25 +349,39 @@ onMounted(async () => {
<Row :gutter="12">
<Col :xs="12" :sm="8" :md="4">
<Card size="small">
<div class="text-xs text-[hsl(var(--muted-foreground))]">总 Tokens</div>
<div class="mt-1 text-lg font-semibold">{{ stats.total_tokens }}</div>
<div class="text-xs text-[hsl(var(--muted-foreground))]">
总 Tokens
</div>
<div class="mt-1 text-lg font-semibold">
{{ stats.total_tokens }}
</div>
</Card>
</Col>
<Col :xs="12" :sm="8" :md="4">
<Card size="small">
<div class="text-xs text-[hsl(var(--muted-foreground))]">Prompt</div>
<div class="mt-1 text-lg font-semibold">{{ stats.prompt_tokens }}</div>
<div class="text-xs text-[hsl(var(--muted-foreground))]">
Prompt
</div>
<div class="mt-1 text-lg font-semibold">
{{ stats.prompt_tokens }}
</div>
</Card>
</Col>
<Col :xs="12" :sm="8" :md="4">
<Card size="small">
<div class="text-xs text-[hsl(var(--muted-foreground))]">Completion</div>
<div class="mt-1 text-lg font-semibold">{{ stats.completion_tokens }}</div>
<div class="text-xs text-[hsl(var(--muted-foreground))]">
Completion
</div>
<div class="mt-1 text-lg font-semibold">
{{ stats.completion_tokens }}
</div>
</Card>
</Col>
<Col :xs="12" :sm="8" :md="4">
<Card size="small">
<div class="text-xs text-[hsl(var(--muted-foreground))]">平均耗时</div>
<div class="text-xs text-[hsl(var(--muted-foreground))]">
平均耗时
</div>
<div class="mt-1 text-lg font-semibold">
{{ formatDuration(stats.avg_duration_ms) }}
</div>
@@ -369,16 +389,24 @@ onMounted(async () => {
</Col>
<Col :xs="12" :sm="8" :md="4">
<Card size="small">
<div class="text-xs text-[hsl(var(--muted-foreground))]">成功率</div>
<div class="mt-1 text-lg font-semibold">{{ stats.success_rate }}%</div>
<div class="text-xs text-[hsl(var(--muted-foreground))]">
成功率
</div>
<div class="mt-1 text-lg font-semibold">
{{ stats.success_rate }}%
</div>
</Card>
</Col>
<Col :xs="12" :sm="8" :md="4">
<Card size="small">
<div class="text-xs text-[hsl(var(--muted-foreground))]">请求数</div>
<div class="text-xs text-[hsl(var(--muted-foreground))]">
请求数
</div>
<div class="mt-1 text-lg font-semibold">
{{ stats.success_count }}/{{ stats.total_count }}
<span class="ml-1 text-xs font-normal text-[hsl(var(--muted-foreground))]">
<span
class="ml-1 text-xs font-normal text-[hsl(var(--muted-foreground))]"
>
失败 {{ stats.fail_count }}
</span>
</div>
@@ -394,7 +422,9 @@ onMounted(async () => {
</div>
<div class="mt-1 text-lg font-semibold">
{{ stats.adoption_rate }}%
<span class="ml-1 text-xs font-normal text-[hsl(var(--muted-foreground))]">
<span
class="ml-1 text-xs font-normal text-[hsl(var(--muted-foreground))]"
>
{{ stats.adopted_count }}/{{ stats.success_count }}
</span>
</div>
@@ -417,7 +447,9 @@ onMounted(async () => {
修改率
<span class="ml-1 opacity-70">采纳但改过原稿</span>
</div>
<div class="mt-1 text-lg font-semibold">{{ stats.modified_rate }}%</div>
<div class="mt-1 text-lg font-semibold">
{{ stats.modified_rate }}%
</div>
</Card>
</Col>
<Col :xs="12" :sm="8" :md="5">
@@ -426,7 +458,9 @@ onMounted(async () => {
药名未匹配率
<span class="ml-1 opacity-70">越低越好</span>
</div>
<div class="mt-1 text-lg font-semibold">{{ stats.unmatched_rate }}%</div>
<div class="mt-1 text-lg font-semibold">
{{ stats.unmatched_rate }}%
</div>
</Card>
</Col>
<Col :xs="12" :sm="8" :md="4">
@@ -449,7 +483,12 @@ onMounted(async () => {
</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">
<Button
type="primary"
ghost
:loading="exporting"
@click="handleExportJsonl"
>
导出训练 JSONL
</Button>
</div>
@@ -462,7 +501,18 @@ onMounted(async () => {
</template>
<template #via_agent="{ row }">
<Tag :color="Number(row.via_agent) === 1 ? 'gold' : 'default'">
{{ row.via_agent_txt || (Number(row.via_agent) === 1 ? 'Agent' : '直连') }}
{{
row.via_agent_txt ||
(Number(row.via_agent) === 1 ? 'Agent' : '直连')
}}
</Tag>
</template>
<template #cache="{ row }">
<Tag :color="Number(row.is_cache_hit) === 1 ? 'processing' : 'default'">
{{
row.cache_txt ||
(Number(row.is_cache_hit) === 1 ? '命中缓存' : '未命中')
}}
</Tag>
</template>
<template #tokens="{ row }">
@@ -493,7 +543,9 @@ onMounted(async () => {
<template #adopted="{ row }">
<Tag
v-if="Number(row.is_adopted) === 1"
:color="Number(row.is_modified_final) === 1 ? 'processing' : 'success'"
:color="
Number(row.is_modified_final) === 1 ? 'processing' : 'success'
"
>
{{ row.adopted_txt }}
</Tag>
@@ -505,50 +557,52 @@ onMounted(async () => {
<span v-else class="text-xs text-[hsl(var(--muted-foreground))]">未评</span>
</template>
<template #score="{ row }">
<span v-if="Number(row.quality_score) > 0">{{ row.quality_score }}</span>
<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"
<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),
},
]"
/>
</div>
</template>
</Grid>
<div v-else class="gen-card-wrap">
<GenerationCardList
ref="cardListRef"
:filters="cardFilters"
@detail="openDetail"
@review="openReview"
@archive="openArchive"
/>
</div>
</Page>
</template>

View File

@@ -0,0 +1,417 @@
<script lang="ts" setup>
import type { FileFolderItem } from '#/api/core/file-folder';
import type {
FileGallerySidebarTab,
FileGalleryTab,
} from '#/composables/use-file-gallery-filter';
/**
* 素材库左侧栏:分类(xk_file_group)与文件夹(由 object_key 按 / 拆目录)互斥筛选
* 文件夹按 pid 组成树,父级可折叠;列表区有最大高度,超出滚动
*/
import { computed, onMounted, ref, watch } from 'vue';
import {
CaretDownOutlined,
CaretRightOutlined,
FolderAddOutlined,
FolderOpenOutlined,
FolderOutlined,
InboxOutlined,
} from '@ant-design/icons-vue';
import { Button, Input, message, Modal, Tabs } from 'ant-design-vue';
import { getFileFolderList } from '#/api/core/file-folder';
import { createFileGroup } from '#/api/core/file-group';
import {
ALL_FOLDER_VALUE,
UNFILED_FOLDER_VALUE,
} from '#/composables/use-file-gallery-filter';
import { useFileGroupCache } from '#/composables/use-file-group-cache';
defineOptions({ name: 'FileGalleryFolderSidebar' });
const props = defineProps<{
activeFolderId: number;
activeGroupId: number;
groupTabs: FileGalleryTab[];
sidebarTab: FileGallerySidebarTab;
}>();
const emit = defineEmits<{
selectFolder: [id: number];
selectGroup: [id: number];
'update:sidebarTab': [tab: FileGallerySidebarTab];
}>();
const { invalidateGroupOptions } = useFileGroupCache();
const folders = ref<FileFolderItem[]>([]);
const expandedIds = ref<number[]>([]);
const createOpen = ref(false);
const createName = ref('');
const creating = ref(false);
function unwrapList(res: any): FileFolderItem[] {
const data = res?.result ?? res?.data ?? res;
if (Array.isArray(data)) {
return data;
}
return Array.isArray(data?.items) ? data.items : [];
}
/** 按 pid 分组,根目录 pid=0 */
const childrenByPid = computed(() => {
const map = new Map<number, FileFolderItem[]>();
for (const item of folders.value) {
const pid = item.pid > 0 ? item.pid : 0;
const list = map.get(pid) ?? [];
list.push(item);
map.set(pid, list);
}
return map;
});
/** 当前展开状态下可见的文件夹行(含子节点标记) */
const visibleFolderRows = computed(() => {
const rows: { hasChildren: boolean; item: FileFolderItem }[] = [];
const walk = (pid: number) => {
for (const item of childrenByPid.value.get(pid) ?? []) {
const hasChildren = (childrenByPid.value.get(item.id) ?? []).length > 0;
rows.push({ item, hasChildren });
if (hasChildren && expandedIds.value.includes(item.id)) {
walk(item.id);
}
}
};
walk(0);
return rows;
});
function isExpanded(id: number) {
return expandedIds.value.includes(id);
}
/** 点箭头只折叠/展开,不切换当前筛选 */
function toggleExpand(id: number) {
if (isExpanded(id)) {
expandedIds.value = expandedIds.value.filter((item) => item !== id);
return;
}
expandedIds.value = [...expandedIds.value, id];
}
/** 选中深层文件夹时把祖先展开,避免当前项被藏起来 */
function expandAncestors(folderId: number) {
const byId = new Map(folders.value.map((item) => [item.id, item]));
const next = new Set(expandedIds.value);
let current = byId.get(folderId);
while (current && current.pid > 0) {
next.add(current.pid);
current = byId.get(current.pid);
}
expandedIds.value = [...next];
}
/** 拉文件夹树:后端会先按 object_key 同步目录 */
async function loadFolders() {
const res = await getFileFolderList();
folders.value = unwrapList(res);
if (props.activeFolderId > 0) {
expandAncestors(props.activeFolderId);
}
}
function onTabChange(key: number | string) {
const tab: FileGallerySidebarTab = key === 'folder' ? 'folder' : 'group';
emit('update:sidebarTab', tab);
if (tab === 'folder') {
void loadFolders();
}
}
function openCreate() {
createName.value = '';
createOpen.value = true;
}
/** 仅分类 Tab 支持新建;文件夹由 OSS 路径自动生成 */
async function submitCreate() {
const name = createName.value.trim();
if (!name) {
message.warning('请填写分类名称');
return;
}
creating.value = true;
try {
await createFileGroup({ name, sort: 0 });
message.success('已新建分类');
createOpen.value = false;
await invalidateGroupOptions();
} finally {
creating.value = false;
}
}
watch(
() => props.activeFolderId,
(id) => {
if (id > 0) {
expandAncestors(id);
}
},
);
onMounted(() => {
void loadFolders();
});
</script>
<template>
<aside class="folder-panel">
<Tabs
class="sidebar-tabs"
size="small"
:active-key="sidebarTab"
@change="onTabChange"
>
<Tabs.TabPane key="group" tab="分类" />
<Tabs.TabPane key="folder" tab="文件夹" />
</Tabs>
<div class="folder-head">
<span class="folder-title">{{
sidebarTab === 'folder' ? '文件夹' : '分类'
}}</span>
<Button
v-if="sidebarTab === 'group'"
size="small"
type="text"
@click="openCreate"
>
<FolderAddOutlined />
新建
</Button>
</div>
<div v-if="sidebarTab === 'folder'" class="folder-hint">
按上传路径 / 自动生成
</div>
<div v-if="sidebarTab === 'group'" class="folder-list">
<button
v-for="tab in groupTabs"
:key="tab.value"
type="button"
class="folder-item"
:class="{ active: activeGroupId === tab.value }"
@click="emit('selectGroup', tab.value)"
>
<FolderOpenOutlined
v-if="activeGroupId === tab.value"
class="folder-icon"
/>
<FolderOutlined v-else class="folder-icon" />
<span class="folder-name">{{ tab.label }}</span>
</button>
</div>
<div v-else class="folder-list">
<button
type="button"
class="folder-item"
:class="{ active: activeFolderId === ALL_FOLDER_VALUE }"
@click="emit('selectFolder', ALL_FOLDER_VALUE)"
>
<span class="folder-caret is-placeholder"></span>
<FolderOpenOutlined
v-if="activeFolderId === ALL_FOLDER_VALUE"
class="folder-icon"
/>
<FolderOutlined v-else class="folder-icon" />
<span class="folder-name">全部文件夹</span>
</button>
<button
type="button"
class="folder-item"
:class="{ active: activeFolderId === UNFILED_FOLDER_VALUE }"
@click="emit('selectFolder', UNFILED_FOLDER_VALUE)"
>
<span class="folder-caret is-placeholder"></span>
<InboxOutlined class="folder-icon" />
<span class="folder-name">未入文件夹</span>
</button>
<button
v-for="row in visibleFolderRows"
:key="row.item.id"
type="button"
class="folder-item folder-tree-item"
:class="{ active: activeFolderId === row.item.id }"
:style="{ paddingLeft: `${10 + row.item.depth * 14}px` }"
:title="row.item.path"
@click="emit('selectFolder', row.item.id)"
>
<span
class="folder-caret"
:class="{ 'is-placeholder': !row.hasChildren }"
@click.stop="row.hasChildren && toggleExpand(row.item.id)"
>
<CaretDownOutlined
v-if="row.hasChildren && isExpanded(row.item.id)"
/>
<CaretRightOutlined v-else-if="row.hasChildren" />
</span>
<FolderOpenOutlined
v-if="activeFolderId === row.item.id"
class="folder-icon"
/>
<FolderOutlined v-else class="folder-icon" />
<span class="folder-name">{{ row.item.name }}</span>
</button>
</div>
<Modal
v-model:open="createOpen"
title="新建分类"
ok-text="创建"
cancel-text="取消"
:confirm-loading="creating"
@ok="submitCreate"
>
<Input
v-model:value="createName"
placeholder="例如:轮播图、证件照"
@press-enter="submitCreate"
/>
</Modal>
</aside>
</template>
<style scoped lang="scss">
.folder-panel {
display: flex;
flex-shrink: 0;
flex-direction: column;
width: 228px;
max-height: calc(100vh - 240px);
padding: 12px;
overflow: hidden;
background: hsl(var(--card, var(--background)));
border: 1px solid hsl(var(--border));
border-radius: 10px;
}
.sidebar-tabs {
flex-shrink: 0;
:deep(.ant-tabs-nav) {
margin-bottom: 8px;
}
:deep(.ant-tabs-tab) {
padding: 6px 8px;
font-size: 13px;
}
}
.folder-head {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: space-between;
padding: 0 4px;
margin-bottom: 8px;
}
.folder-title {
font-size: 13px;
font-weight: 600;
color: hsl(var(--foreground));
}
.folder-hint {
flex-shrink: 0;
margin: -4px 4px 8px;
font-size: 12px;
color: hsl(var(--muted-foreground));
}
.folder-list {
display: flex;
flex: 1;
flex-direction: column;
gap: 4px;
min-height: 0;
overflow-y: auto;
}
.folder-item {
display: flex;
gap: 6px;
align-items: center;
width: 100%;
padding: 8px 10px;
color: hsl(var(--muted-foreground));
text-align: left;
cursor: pointer;
background: transparent;
border: 1px solid transparent;
border-radius: 8px;
transition:
background-color 0.2s ease,
border-color 0.2s ease,
color 0.2s ease,
box-shadow 0.2s ease;
&:hover {
color: hsl(var(--foreground));
background: hsl(var(--muted) / 35%);
}
&.active {
color: hsl(var(--primary));
background: hsl(var(--primary) / 10%);
border-color: hsl(var(--primary) / 30%);
box-shadow: 0 0 6px hsl(var(--primary) / 18%);
}
}
.folder-caret {
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
font-size: 10px;
color: hsl(var(--muted-foreground));
&:not(.is-placeholder) {
cursor: pointer;
border-radius: 4px;
&:hover {
color: hsl(var(--foreground));
background: hsl(var(--muted) / 50%);
}
}
}
.folder-icon {
flex-shrink: 0;
font-size: 15px;
}
.folder-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
font-size: 13px;
white-space: nowrap;
}
@media (max-width: 900px) {
.folder-panel {
width: 100%;
max-height: 280px;
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -10,9 +10,7 @@ import { Button, Image, message } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { TableAction } from '#/components/table-action';
import {
deleteHomeZones,
} from './api';
import { deleteHomeZones } from './api';
import HomeZoneModal from './components/modal.vue';
import SortModal from './components/sort-modal.vue';
import { formOptions as searchFormOptions } from './config/search';
@@ -22,12 +20,10 @@ const hasTopTableDropDownActions = ref(false);
const gridEvents: VxeGridListeners<any> = {
checkboxChange() {
// eslint-disable-next-line no-use-before-define
const records = gridApi.grid.getCheckboxRecords();
hasTopTableDropDownActions.value = records.length > 0;
},
checkboxAll() {
// eslint-disable-next-line no-use-before-define
const records = gridApi.grid.getCheckboxRecords();
hasTopTableDropDownActions.value = records.length > 0;
},
@@ -64,7 +60,7 @@ const showSortModal = () => {
};
const deleteZonesApi = (row: any) => {
let ids: (string | number)[] = [];
let ids: (number | string)[] = [];
if (row) {
ids.push(row);
} else {
@@ -108,7 +104,7 @@ const deleteZonesApi = (row: any) => {
:width="60"
:height="60"
:src="row.icon"
:fallback="'/static/mine/avatar_1.png'"
fallback="/static/mine/avatar_1.png"
/>
</template>
@@ -145,4 +141,3 @@ const deleteZonesApi = (row: any) => {
</div>
</Page>
</template>

View File

@@ -6,7 +6,16 @@ export async function getSystemConfigList() {
return requestClient.get<any>(`${prefix}list`);
}
export async function saveSystemConfig(items: Array<{ config_key: string; config_value: string }>) {
export async function saveSystemConfig(
items: Array<{
config_group?: string;
config_key: string;
config_value: any;
description?: string;
sort?: number;
value_type?: string;
}>,
) {
return requestClient.post<any>(`${prefix}save`, { items });
}
@@ -22,3 +31,20 @@ export async function getSystemConfigByKeys(keys: string[]) {
params: { keys: keys.join(',') },
});
}
const wxEntryPrefix = 'wx-workbench-entry/';
/** 某角色已分配的医生端功能入口(含 is_default_fav) */
export async function getWxWorkbenchEntryByRoleId(roleId: number) {
return requestClient.get<any>(`${wxEntryPrefix}get-by-role-id`, {
params: { role_id: roleId },
});
}
/** 保存某角色的默认常用入口(只改 is_default_fav) */
export async function saveWxWorkbenchDefaultFav(data: {
entry_ids: number[];
role_id: number;
}) {
return requestClient.post<any>(`${wxEntryPrefix}save-default-fav`, data);
}

View File

@@ -0,0 +1,249 @@
<script lang="ts" setup>
/**
* 系统配置 - 处方签名:中药/西药/服务包/配送仓分别上传调配人、发药人
* 一次保存整份 JSON(dispense_sign),配送仓按所选仓库 id 写入 warehouses
*/
import { computed, onMounted, ref, watch } from 'vue';
import { Button, message, Select, Tabs } from 'ant-design-vue';
import FormAvatar from '#/components/form/components/avatar.vue';
import { getDeliveryWarehouseOption } from '#/views/system/delivery-warehouse/api';
import { getSystemConfigByKeys, saveSystemConfig } from '../api';
defineOptions({ name: 'DispenseSignConfigPanel' });
type SignPair = { dispenser: string; sender: string };
type DispenseSignConfig = {
pkg: SignPair;
tcm: SignPair;
warehouses: Record<string, SignPair>;
wm: SignPair;
};
const SCOPE_TABS = [
{ key: 'tcm', title: '中药' },
{ key: 'wm', title: '西药' },
{ key: 'pkg', title: '产品服务包' },
{ key: 'warehouse', title: '配送仓库' },
];
const loading = ref(false);
const saving = ref(false);
const activeScope = ref('tcm');
const warehouseId = ref<number | undefined>(undefined);
const warehouseOptions = ref<{ label: string; value: number }[]>([]);
const form = ref<DispenseSignConfig>(emptyConfig());
function emptyPair(): SignPair {
return { dispenser: '', sender: '' };
}
function emptyConfig(): DispenseSignConfig {
return {
tcm: emptyPair(),
wm: emptyPair(),
pkg: emptyPair(),
warehouses: {},
};
}
function normalizePair(raw: any): SignPair {
return {
dispenser: String(raw?.dispenser || ''),
sender: String(raw?.sender || ''),
};
}
/** 当前 Tab 对应的一对签名(配送仓按所选 id) */
const currentPair = computed<SignPair>(() => {
if (activeScope.value === 'warehouse') {
const id = Number(warehouseId.value || 0);
if (id <= 0) return emptyPair();
return form.value.warehouses[String(id)] || emptyPair();
}
const key = activeScope.value as 'pkg' | 'tcm' | 'wm';
return form.value[key] || emptyPair();
});
function setCurrentPair(field: keyof SignPair, url: string) {
if (activeScope.value === 'warehouse') {
const id = Number(warehouseId.value || 0);
if (id <= 0) return;
const next = { ...(form.value.warehouses[String(id)] || emptyPair()) };
next[field] = url;
form.value.warehouses[String(id)] = next;
return;
}
const key = activeScope.value as 'pkg' | 'tcm' | 'wm';
form.value[key] = { ...form.value[key], [field]: url };
}
/** 拉取配置 + 配送仓下拉 */
async function load() {
loading.value = true;
try {
const [cfgRes, opts] = await Promise.all([
getSystemConfigByKeys(['dispense_sign']),
getDeliveryWarehouseOption(),
]);
const raw = cfgRes?.dispense_sign;
const parsed = typeof raw === 'string' ? safeParse(raw) : raw;
form.value = {
tcm: normalizePair(parsed?.tcm),
wm: normalizePair(parsed?.wm),
pkg: normalizePair(parsed?.pkg),
warehouses: normalizeWarehouses(parsed?.warehouses),
};
const list = Array.isArray(opts) ? opts : opts?.data || [];
warehouseOptions.value = list.map((item: any) => ({
label: item.name || item.label || `#${item.id}`,
value: Number(item.id || item.value),
}));
if (!warehouseId.value && warehouseOptions.value[0]) {
warehouseId.value = warehouseOptions.value[0].value;
}
} finally {
loading.value = false;
}
}
function safeParse(text: string): any {
try {
return JSON.parse(text);
} catch {
return {};
}
}
function normalizeWarehouses(raw: any): Record<string, SignPair> {
if (!raw || typeof raw !== 'object') return {};
const out: Record<string, SignPair> = {};
Object.keys(raw).forEach((id) => {
if (Number(id) > 0) out[id] = normalizePair(raw[id]);
});
return out;
}
/** 整份 JSON 一次入库,配送仓未选不影响其它品类 */
async function handleSave() {
saving.value = true;
try {
await saveSystemConfig([
{
config_key: 'dispense_sign',
config_value: form.value as any,
value_type: 'json',
config_group: 'prescription',
description:
'处方笺调配人/发药人签名:tcm中药 wm西药 pkg服务包 warehouses按仓',
sort: 90,
} as any,
]);
message.success('保存成功');
} finally {
saving.value = false;
}
}
watch(warehouseId, (id) => {
if (!id) return;
const key = String(id);
if (!form.value.warehouses[key]) {
form.value.warehouses[key] = emptyPair();
}
});
onMounted(load);
</script>
<template>
<div class="cfg-panel" :class="{ 'opacity-60': loading }">
<div class="cfg-panel-body">
<Tabs v-model:active-key="activeScope">
<Tabs.TabPane
v-for="item in SCOPE_TABS"
:key="item.key"
:tab="item.title"
/>
</Tabs>
<p class="sign-hint">
上传后处方详情「调配人 /
发药人」按品类展示。配送仓库商品使用该仓自己的签名。
</p>
<div v-if="activeScope === 'warehouse'" class="sign-warehouse">
<div class="sign-label">配送仓库</div>
<Select
v-model:value="warehouseId"
class="sign-select"
:options="warehouseOptions"
placeholder="请选择配送仓库"
show-search
option-filter-prop="label"
/>
</div>
<div class="sign-grid">
<div class="sign-card">
<div class="sign-label">调配人签名</div>
<FormAvatar
:value="currentPair.dispenser"
@update:value="(v: string) => setCurrentPair('dispenser', v)"
/>
</div>
<div class="sign-card">
<div class="sign-label">发药人签名</div>
<FormAvatar
:value="currentPair.sender"
@update:value="(v: string) => setCurrentPair('sender', v)"
/>
</div>
</div>
</div>
<div class="cfg-panel-footer">
<Button type="primary" :loading="saving" @click="handleSave">
保存本模块配置
</Button>
</div>
</div>
</template>
<style scoped>
.sign-hint {
margin: 0 0 16px;
font-size: 13px;
line-height: 1.6;
color: hsl(var(--muted-foreground));
}
.sign-warehouse {
margin-bottom: 16px;
}
.sign-select {
width: 280px;
}
.sign-label {
margin-bottom: 8px;
font-size: 13px;
font-weight: 600;
color: hsl(var(--foreground));
}
.sign-grid {
display: flex;
flex-wrap: wrap;
gap: 16px;
}
.sign-card {
min-width: 200px;
padding: 14px 16px;
background: hsl(var(--muted) / 25%);
border: 1px solid hsl(var(--primary) / 30%);
border-radius: 8px;
box-shadow: 0 0 6px hsl(var(--primary) / 18%);
}
</style>

View File

@@ -0,0 +1,100 @@
<script lang="ts" setup>
/**
* 系统配置 - 患者端小程序 / 首页展示:金刚区布局 + 订单提醒样式
*/
import { onMounted, ref } from 'vue';
import { Button, message, Radio } from 'ant-design-vue';
import { getSystemConfigByKeys, saveSystemConfig } from '../api';
defineOptions({ name: 'HomeDisplayConfigPanel' });
const loading = ref(false);
const saving = ref(false);
const zoneLayout = ref<'scroll' | 'wrap2' | 'wrap'>('scroll');
const orderRemindStyle = ref<'grid' | 'zone'>('grid');
/** 读取首页展示样式,缺省回落默认值 */
async function load() {
loading.value = true;
try {
const res = await getSystemConfigByKeys([
'home_zone_layout',
'home_order_remind_style',
]);
const data = res && typeof res === 'object' ? res : {};
const layout = String(data.home_zone_layout || 'scroll');
zoneLayout.value =
layout === 'wrap' || layout === 'wrap2' ? layout : 'scroll';
orderRemindStyle.value =
String(data.home_order_remind_style || '') === 'zone' ? 'zone' : 'grid';
} catch {
zoneLayout.value = 'scroll';
orderRemindStyle.value = 'grid';
} finally {
loading.value = false;
}
}
/** 写入系统配置,患者端首页即时读取 */
async function handleSave() {
saving.value = true;
try {
await saveSystemConfig([
{
config_key: 'home_zone_layout',
config_value: zoneLayout.value,
value_type: 'string',
config_group: 'home',
description: '首页金刚区布局:scroll横向滑 / wrap换行 / wrap2一行两个',
sort: 80,
},
{
config_key: 'home_order_remind_style',
config_value: orderRemindStyle.value,
value_type: 'string',
config_group: 'home',
description: '首页订单提醒样式:grid一行两列卡片 / zone金刚区图标',
sort: 81,
},
]);
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="zoneLayout" class="mb-6">
<Radio value="scroll">横向滑动</Radio>
<Radio value="wrap">换行(一行五个)</Radio>
<Radio value="wrap2">一行两个</Radio>
</Radio.Group>
<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="orderRemindStyle">
<Radio value="grid">一行两列卡片</Radio>
<Radio value="zone">金刚区图标</Radio>
</Radio.Group>
</div>
<div class="cfg-panel-footer">
<Button type="primary" :loading="saving" @click="handleSave">
保存本模块配置
</Button>
</div>
</div>
</template>

View File

@@ -1,6 +1,6 @@
<script lang="ts" setup>
/**
* 系统配置 - 小程序:空状态占位图
* 系统配置 - 患者端小程序:空状态占位图
*/
import { onMounted, ref } from 'vue';
@@ -52,14 +52,18 @@ onMounted(load);
<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 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>
<Button type="primary" :loading="saving" @click="handleSave">
保存本模块配置
</Button>
</div>
</div>
</template>

View File

@@ -0,0 +1,90 @@
<script lang="ts" setup>
/**
* 系统配置通用壳:右侧二级 Tab,和 AI 模块同一套交互
* 各 Tab 仍是独立面板,自己管 load/save,底部保存条不互相覆盖
*/
import type { Component } from 'vue';
import { onMounted, ref } from 'vue';
import { Tabs } from 'ant-design-vue';
export interface ModuleTabItem {
key: string;
title: string;
component: Component;
}
const props = defineProps<{
moduleKey: string;
tabs: ModuleTabItem[];
}>();
const subTab = ref(props.tabs[0]?.key || '');
function storageKey() {
return `system_config_${props.moduleKey}_sub_tab`;
}
function onSubTabChange(key: number | string) {
subTab.value = String(key);
localStorage.setItem(storageKey(), String(key));
}
onMounted(() => {
const stored = localStorage.getItem(storageKey());
if (stored && props.tabs.some((tab) => tab.key === stored)) {
subTab.value = stored;
}
});
</script>
<template>
<div class="cfg-panel">
<div class="cfg-panel-body module-tabs-body">
<Tabs :active-key="subTab" @change="onSubTabChange">
<Tabs.TabPane v-for="tab in tabs" :key="tab.key" :tab="tab.title">
<component :is="tab.component" />
</Tabs.TabPane>
</Tabs>
</div>
</div>
</template>
<style scoped>
.module-tabs-body {
display: flex;
flex-direction: column;
padding: 8px 0 0;
overflow: hidden;
}
.module-tabs-body :deep(.ant-tabs) {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
}
.module-tabs-body :deep(.ant-tabs-nav) {
flex-shrink: 0;
margin: 0 20px;
}
.module-tabs-body :deep(.ant-tabs-content-holder) {
flex: 1;
min-height: 0;
overflow: hidden;
}
.module-tabs-body :deep(.ant-tabs-content),
.module-tabs-body :deep(.ant-tabs-tabpane-active) {
height: 100%;
}
/* 子面板铺满 Tab,自己滚内容、底栏固定 */
.module-tabs-body :deep(.cfg-panel) {
height: 100%;
min-height: 0;
}
</style>

View File

@@ -0,0 +1,86 @@
<script lang="ts" setup>
/**
* 系统配置 - 工作台:AI 今日简报总开关
* 关闭后 PC / 医生小程序工作台都隐藏该模块,后端也不生成
*/
import { onMounted, ref } from 'vue';
import { Button, message, Switch } from 'ant-design-vue';
import { getSystemConfigList, saveSystemConfig } from '../api';
defineOptions({ name: 'WorkbenchConfigPanel' });
const loading = ref(false);
const saving = ref(false);
const aiDailyBriefEnabled = 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';
}
/** 读取工作台简报开关,未落库时按开启回落,与后端 getValue 默认一致 */
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 === 'ai_daily_brief_enabled') {
aiDailyBriefEnabled.value = parseBoolConfig(row.config_value, true);
}
}
} finally {
loading.value = false;
}
}
/** 写入系统配置;关闭后工作台立刻不再生成简报 */
async function handleSave() {
saving.value = true;
try {
await saveSystemConfig([
{
config_key: 'ai_daily_brief_enabled',
config_value: aiDailyBriefEnabled.value ? '1' : '0',
value_type: 'bool',
config_group: 'workbench',
description:
'工作台AI今日简报:1开启(生成并展示)0关闭(隐藏且不生成)',
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))]">
AI 今日简报
</div>
<div class="mb-2 text-sm text-[hsl(var(--muted-foreground))]">
控制 PC
管理端与医生小程序工作台的「今日简报」。开启后进入工作台才生成(按天缓存);关闭后两端都隐藏该模块,且不再调用
AI、不写生成记录。
</div>
<Switch
v-model:checked="aiDailyBriefEnabled"
checked-children="开启"
un-checked-children="关闭"
/>
</div>
<div class="cfg-panel-footer">
<Button type="primary" :loading="saving" @click="handleSave">
保存本模块配置
</Button>
</div>
</div>
</template>

View File

@@ -0,0 +1,218 @@
<script lang="ts" setup>
/**
* 系统配置 - 医生端小程序 / 默认常用入口
* 按角色勾选首次访问自动写入「我的常用」的入口,只能勾选该角色已分配项
*/
import { computed, onMounted, ref, watch } from 'vue';
import { Button, Checkbox, Empty, message, Tabs } from 'ant-design-vue';
import { getWxWorkbenchEntryByRoleId, saveWxWorkbenchDefaultFav } from '../api';
defineOptions({ name: 'WxWorkbenchDefaultFavPanel' });
const TAB_STORAGE_KEY = 'wx_workbench_default_fav_role';
/** 有工作台入口的角色,value 与 xk_role.id 一致 */
const ROLE_TABS = [
{ label: '诊所管理员', value: 8 },
{ label: '超级管理员', value: 1 },
{ label: '系统管理员', value: 2 },
{ label: '省级管理员', value: 3 },
{ label: '市级管理员', value: 4 },
{ label: '业务员', value: 6 },
{ label: '诊所推广员', value: 14 },
];
type AssignedEntry = {
code: string;
entry_id: number;
group_name: string;
is_default_fav: number;
name: string;
};
const activeRole = ref(String(ROLE_TABS[0]?.value || 8));
const assigned = ref<AssignedEntry[]>([]);
const selectedIds = ref<number[]>([]);
const loading = ref(false);
const saving = ref(false);
/** 已分配入口按分组展示,方便对照小程序功能页 */
const assignedGroups = computed(() => {
const groups: { items: AssignedEntry[]; name: string }[] = [];
assigned.value.forEach((item) => {
const name = item.group_name || '未分组';
let group = groups.find((g) => g.name === name);
if (!group) {
group = { name, items: [] };
groups.push(group);
}
group.items.push(item);
});
return groups;
});
/** 拉取当前角色已分配入口,并用 is_default_fav 回显勾选 */
async function loadAssigned() {
const roleId = Number(activeRole.value || 0);
if (!roleId) return;
loading.value = true;
try {
const rows = await getWxWorkbenchEntryByRoleId(roleId);
const list = (Array.isArray(rows) ? rows : []) as AssignedEntry[];
assigned.value = list;
selectedIds.value = list
.filter((item) => Number(item.is_default_fav) === 1)
.map((item) => Number(item.entry_id));
} catch {
message.error('获取已分配入口失败');
assigned.value = [];
selectedIds.value = [];
} finally {
loading.value = false;
}
}
/** 保存当前角色的默认常用(空数组表示该角色不自动写入) */
async function handleSave() {
const roleId = Number(activeRole.value || 0);
if (!roleId) {
message.error('请先选择角色');
return;
}
saving.value = true;
try {
await saveWxWorkbenchDefaultFav({
role_id: roleId,
entry_ids: selectedIds.value,
});
message.success('保存成功');
await loadAssigned();
} catch {
message.error('保存失败');
} finally {
saving.value = false;
}
}
/** 勾选/取消某入口为默认常用(只改本地,点保存才入库) */
function toggleDefault(id: number, checked: boolean) {
if (checked) {
if (!selectedIds.value.includes(id)) {
selectedIds.value.push(id);
}
return;
}
selectedIds.value = selectedIds.value.filter((item) => item !== id);
}
onMounted(() => {
const cached = localStorage.getItem(TAB_STORAGE_KEY);
const valid = ROLE_TABS.some((item) => String(item.value) === cached);
if (valid && cached) {
activeRole.value = cached;
}
});
watch(
activeRole,
(key) => {
localStorage.setItem(TAB_STORAGE_KEY, key);
loadAssigned();
},
{ immediate: true },
);
</script>
<template>
<div class="cfg-panel" :class="{ 'opacity-60': loading }">
<div class="cfg-panel-body">
<Tabs v-model:active-key="activeRole">
<Tabs.TabPane
v-for="item in ROLE_TABS"
:key="String(item.value)"
:tab="item.label"
/>
</Tabs>
<p class="fav-hint">
勾选后,该角色用户第一次进入医生端小程序时,会自动写入「我的常用」。已加过再清空的用户不会被覆盖。仅能勾选该角色已分配的入口。
</p>
<template v-if="assignedGroups.length">
<div
v-for="group in assignedGroups"
:key="group.name"
class="fav-group"
>
<div class="fav-group__title">{{ group.name }}</div>
<div class="fav-group__list">
<label
v-for="entry in group.items"
:key="entry.entry_id"
class="fav-item"
>
<Checkbox
:checked="selectedIds.includes(Number(entry.entry_id))"
@update:checked="
(val: boolean) => toggleDefault(Number(entry.entry_id), val)
"
>
{{ entry.name }}
</Checkbox>
</label>
</div>
</div>
</template>
<Empty
v-else
class="fav-empty"
description="该角色尚未分配功能入口,请先到「小程序功能入口」或角色管理里勾选"
/>
</div>
<div class="cfg-panel-footer">
<Button type="primary" :loading="saving" @click="handleSave">
保存本模块配置
</Button>
</div>
</div>
</template>
<style scoped>
.fav-hint {
margin: 0 0 16px;
font-size: 13px;
line-height: 1.6;
color: hsl(var(--muted-foreground));
}
.fav-group {
padding: 12px 14px;
margin-bottom: 16px;
background: hsl(var(--muted) / 25%);
border: 1px solid hsl(var(--primary) / 30%);
border-radius: 8px;
box-shadow: 0 0 6px hsl(var(--primary) / 18%);
}
.fav-group__title {
margin-bottom: 10px;
font-size: 13px;
font-weight: 600;
color: hsl(var(--foreground));
}
.fav-group__list {
display: flex;
flex-wrap: wrap;
gap: 8px 16px;
}
.fav-item {
min-width: 140px;
color: hsl(var(--foreground));
}
.fav-empty {
padding: 24px 0;
}
</style>

View File

@@ -1,9 +1,13 @@
<script lang="ts" setup>
import type { Component } from 'vue';
import type { ModuleTabItem } from './components/module-tabs-panel.vue';
/**
* 系统配置页壳:左侧一级菜单 + 右侧动态面板
* 各模块自管 load/save;AI 在右侧保留二级 Tab
* 系统配置页壳:左侧一级模块 + 右侧二级 Tab(与 AI 模块同一交互)
* 各子面板自管 load/save;旧菜单 key 会映射到新模块/Tab
*/
import { computed, onMounted, ref, type Component } from 'vue';
import { computed, onMounted, ref } from 'vue';
import { Page } from '@vben/common-ui';
@@ -11,15 +15,20 @@ 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 DispenseSignConfigPanel from './components/dispense-sign-config-panel.vue';
import DoctorLoginConfigPanel from './components/doctor-login-config-panel.vue';
import HomeDisplayConfigPanel from './components/home-display-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 ModuleTabsPanel from './components/module-tabs-panel.vue';
import OaNotifyConfigPanel from './components/oa-notify-config-panel.vue';
import PriceAdjustConfigPanel from './components/price-adjust-config-panel.vue';
import SalespersonConfigPanel from './components/salesperson-config-panel.vue';
import WorkbenchConfigPanel from './components/workbench-config-panel.vue';
import WxWorkbenchDefaultFavPanel from './components/wx-workbench-default-fav-panel.vue';
defineOptions({ name: 'SystemConfig' });
@@ -28,35 +37,146 @@ const MENU_STORAGE_KEY = 'system_config_active_tab';
interface MenuItem {
key: string;
title: string;
component: Component;
/** 已自带二级 Tab 的整块面板(AI) */
component?: Component;
tabs?: ModuleTabItem[];
}
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 },
{
key: 'mp_patient',
title: '患者端小程序',
tabs: [
{ key: 'empty', title: '空状态图', component: MiniprogramConfigPanel },
{ key: 'home', title: '首页展示', component: HomeDisplayConfigPanel },
],
},
{
key: 'mp_doctor',
title: '医生端小程序',
tabs: [
{
key: 'default_fav',
title: '默认常用入口',
component: WxWorkbenchDefaultFavPanel,
},
],
},
{
key: 'workbench',
title: '工作台',
component: WorkbenchConfigPanel,
},
{
key: 'dispense_sign',
title: '处方签名',
component: DispenseSignConfigPanel,
},
{
key: 'order',
title: '订单',
tabs: [
{
key: 'price_adjust',
title: '订单调价',
component: PriceAdjustConfigPanel,
},
{ key: 'logistics', title: '物流展示', component: LogisticsConfigPanel },
],
},
{
key: 'security',
title: '权限安全',
tabs: [
{ key: 'login', title: '登录安全', component: DoctorLoginConfigPanel },
{
key: 'salesperson',
title: '业务员权限',
component: SalespersonConfigPanel,
},
],
},
{
key: 'medical',
title: '病历词条',
tabs: [
{ key: 'entry', title: '词条', component: MedicalRecordConfigPanel },
{
key: 'dict_search',
title: '字典搜索',
component: DictSearchConfigPanel,
},
],
},
{
key: 'notice',
title: '通知',
tabs: [
{
key: 'invoice',
title: '开票通知',
component: InvoiceNoticeConfigPanel,
},
{ key: 'oa', title: 'OA通知', component: OaNotifyConfigPanel },
],
},
{
key: 'audit',
title: '审核',
component: InputAuditConfigPanel,
},
{
key: 'ai_model',
title: 'AI模型',
component: AiModelConfigPanel,
},
];
const activeKey = ref('price_adjust');
/** 旧左侧菜单 key → 新模块 + 二级 Tab,避免刷新后丢选中 */
const LEGACY_MENU_MAP: Record<string, { menu: string; tab?: string }> = {
price_adjust: { menu: 'order', tab: 'price_adjust' },
input_audit: { menu: 'audit', tab: 'input_audit' },
miniprogram: { menu: 'mp_patient' },
logistics: { menu: 'order', tab: 'logistics' },
salesperson: { menu: 'security', tab: 'salesperson' },
doctor_login: { menu: 'security', tab: 'login' },
dict_search: { menu: 'medical', tab: 'dict_search' },
medical_record: { menu: 'medical', tab: 'entry' },
invoice_notice: { menu: 'notice', tab: 'invoice' },
oa_notify: { menu: 'notice', tab: 'oa' },
ai_model: { menu: 'ai_model' },
};
const currentPanel = computed(() => {
const hit = menuItems.find((m) => m.key === activeKey.value);
return hit?.component || PriceAdjustConfigPanel;
const activeKey = ref('mp_patient');
const currentMenu = computed(() => {
return menuItems.find((m) => m.key === activeKey.value) || menuItems[0];
});
function readStoredMenu() {
const stored = localStorage.getItem(MENU_STORAGE_KEY);
if (stored && menuItems.some((m) => m.key === stored)) {
activeKey.value = stored;
if (!stored) {
return;
}
if (menuItems.some((m) => m.key === stored)) {
activeKey.value = stored;
return;
}
const mapped = LEGACY_MENU_MAP[stored];
if (!mapped) {
return;
}
activeKey.value = mapped.menu;
if (mapped.tab) {
localStorage.setItem(`system_config_${mapped.menu}_sub_tab`, mapped.tab);
} else if (stored === 'miniprogram') {
// 旧「小程序」二级 Tab 迁到患者端,避免刷新后丢「首页展示」
const oldSub = localStorage.getItem('system_config_miniprogram_sub_tab');
if (oldSub) {
localStorage.setItem('system_config_mp_patient_sub_tab', oldSub);
}
}
localStorage.setItem(MENU_STORAGE_KEY, mapped.menu);
}
function selectMenu(key: string) {
@@ -84,7 +204,17 @@ onMounted(readStoredMenu);
</button>
</aside>
<section class="sys-cfg-main">
<component :is="currentPanel" :key="activeKey" />
<component
v-if="currentMenu.component"
:is="currentMenu.component"
:key="currentMenu.key"
/>
<ModuleTabsPanel
v-else-if="currentMenu.tabs"
:key="currentMenu.key"
:module-key="currentMenu.key"
:tabs="currentMenu.tabs"
/>
</section>
</div>
</Card>
@@ -93,58 +223,65 @@ onMounted(readStoredMenu);
<style scoped>
.sys-cfg-card {
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.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);
width: 188px;
padding: 8px;
overflow-y: auto;
background: hsl(var(--muted) / 25%);
border-right: 1px solid hsl(var(--border));
}
.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;
color: hsl(var(--foreground));
text-align: left;
cursor: pointer;
background: transparent;
border: 1px solid transparent;
border-radius: 8px;
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));
background: hsl(var(--primary) / 10%);
border-color: hsl(var(--primary) / 35%);
box-shadow: 0 0 6px hsl(var(--primary) / 18%);
}
.sys-cfg-main {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
}
/* 子面板统一:内容滚动 + 底栏不滚走 */
.sys-cfg-main :deep(.cfg-panel) {
display: flex;
@@ -152,17 +289,24 @@ onMounted(readStoredMenu);
height: 100%;
min-height: 0;
}
.sys-cfg-main :deep(.cfg-panel-body) {
flex: 1;
min-height: 0;
overflow: auto;
padding: 16px 20px;
overflow: auto;
}
.sys-cfg-main :deep(.module-tabs-body) {
padding: 8px 0 0;
overflow: hidden;
}
.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;
flex-shrink: 0;
padding: 12px 20px;
background: hsl(var(--card, var(--background)));
border-top: 1px solid hsl(var(--border));
}
</style>