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