feat: 补齐素材文件夹、挂号改期、系统配置分组与工作台简报开关
素材库按 object_key 目录同步文件夹侧栏,选图和筛选可按目录收窄。 挂号列表支持管理端代改期,号源日历与患者端同源。 系统配置按端拆模块(患者首页展示、医生默认常用入口、工作台简报总开关、处方签名),旧菜单 key 做迁移以免刷新丢选中。 AI 生成记录补每日简报场景和缓存命中筛选;个人中心改走后端菜单 Profile。
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 直连 */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user