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

@@ -0,0 +1,249 @@
<script lang="ts" setup>
/**
* 系统配置 - 处方签名:中药/西药/服务包/配送仓分别上传调配人、发药人
* 一次保存整份 JSONdispense_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>