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,27 @@
import { requestClient } from '#/api/request';
const prefix = 'file-folder/';
/** 素材库文件夹path 来自 object_key 按 / 拆出的目录 */
export interface FileFolderItem {
id: number;
pid: number;
name: string;
path: string;
depth: number;
sort: number;
}
/** 文件夹列表(会先按 object_key 同步xk-api GET /file-folder/list */
export async function getFileFolderList() {
return requestClient.get<FileFolderItem[]>(`${prefix}list`);
}
/** 手动从 object_key 同步文件夹xk-api POST /file-folder/sync */
export async function syncFileFolder() {
return requestClient.post<{
bound: number;
created: number;
folder_count: number;
}>(`${prefix}sync`);
}

View File

@@ -16,6 +16,9 @@ export interface FileGalleryItem {
original_name?: string; original_name?: string;
object_key?: string; object_key?: string;
source?: number; source?: number;
is_qrcode?: number;
ref_count?: number;
folder_id?: number;
} }
export interface FileTypeItem { export interface FileTypeItem {
@@ -32,28 +35,47 @@ export interface FileGalleryDetail extends FileGalleryItem {
updated_at?: string; updated_at?: string;
} }
export async function getFileGalleryList(params?: { export interface FileGalleryStats {
total_count: number;
total_size: number;
total_size_text: string;
unref_count: number;
recyclable_size: number;
recyclable_size_text: string;
has_scanned: boolean;
}
export interface FileGalleryListParams {
page?: number; page?: number;
page_size?: number; page_size?: number;
pid?: number; pid?: number;
type?: number; type?: number;
group_id?: number; group_id?: number;
keyword?: string; keyword?: string;
}) { exclude_qrcode?: number;
unref_only?: number;
folder_id?: number;
unfiled?: number;
}
export async function getFileGalleryList(params?: FileGalleryListParams) {
return requestClient.get<{ return requestClient.get<{
items: FileGalleryItem[]; items: FileGalleryItem[];
total: number;
page: number; page: number;
page_size: number; page_size: number;
total: number;
}>(`${prefix}list`, { params }); }>(`${prefix}list`, { params });
} }
export async function getFileGalleryDetail(id: number) { export async function getFileGalleryDetail(id: number) {
return requestClient.get<FileGalleryDetail>(`${prefix}detail`, { params: { id } }); return requestClient.get<FileGalleryDetail>(`${prefix}detail`, {
params: { id },
});
} }
export async function deleteFileGalleryItem(id: number) { export async function deleteFileGalleryItem(id: number | number[]) {
return requestClient.post<any>(`${prefix}delete`, { id }); const data = Array.isArray(id) ? { ids: id } : { id };
return requestClient.post<any>(`${prefix}delete`, data);
} }
export async function renameFileGalleryItem(id: number, file_name: string) { export async function renameFileGalleryItem(id: number, file_name: string) {
@@ -66,8 +88,8 @@ export async function moveFileGalleryType(id: number, type: number) {
export async function batchArchiveFiles() { export async function batchArchiveFiles() {
return requestClient.post<{ return requestClient.post<{
reclassified: number;
file_name_filled: number; file_name_filled: number;
reclassified: number;
}>(`${prefix}batch-archive`); }>(`${prefix}batch-archive`);
} }
@@ -76,15 +98,77 @@ export async function getFileTypes() {
} }
export async function syncFileGalleryFromOss() { export async function syncFileGalleryFromOss() {
return requestClient.post<{ synced: number; skipped: number; scanned: number }>(`${prefix}sync-oss`); return requestClient.post<{
scanned: number;
skipped: number;
synced: number;
}>(`${prefix}sync-oss`);
} }
/** 文件加入自定义分组 */ /** 文件加入自定义分组 */
export async function addFileToGroup(file_id: number, group_id: number) { export async function addFileToGroup(file_id: number, group_id: number) {
return requestClient.post<any>(`${prefix}add-to-group`, { file_id, group_id }); return requestClient.post<any>(`${prefix}add-to-group`, {
file_id,
group_id,
});
} }
/** 文件移出自定义分组 */ /** 文件移出自定义分组 */
export async function removeFileFromGroup(file_id: number, group_id: number) { export async function removeFileFromGroup(file_id: number, group_id: number) {
return requestClient.post<any>(`${prefix}remove-from-group`, { file_id, group_id }); return requestClient.post<any>(`${prefix}remove-from-group`, {
file_id,
group_id,
});
}
/** 素材库统计:总数 / 容量 / 未引用 / 可回收 */
export async function getFileGalleryStats() {
return requestClient.get<FileGalleryStats>(`${prefix}stats`);
}
/** 右键标记或取消二维码 */
export async function markFileQrcode(id: number, is_qrcode: number) {
return requestClient.post<{ id: number; is_qrcode: number }>(
`${prefix}mark-qrcode`,
{
id,
is_qrcode,
},
);
}
/** 扫描业务表 URL 引用(两库全表文本列) */
export async function scanFileGalleryRefs() {
return requestClient.post<{
elapsed_ms: number;
mysql_columns: number;
mysql_failed: number;
old_mysql_columns: number;
old_mysql_failed: number;
referenced: number;
scanned_files: number;
unreferenced: number;
}>(`${prefix}scan-refs`);
}
/** 批量加入分组(移动到文件夹) */
export async function batchAddFilesToGroup(ids: number[], group_id: number) {
return requestClient.post<{ count: number; group_id: number }>(
`${prefix}batch-add-to-group`,
{
ids,
group_id,
},
);
}
/** 批量移入真实文件夹 */
export async function batchMoveFilesToFolder(ids: number[], folder_id: number) {
return requestClient.post<{ count: number; folder_id: number }>(
`${prefix}batch-move-to-folder`,
{
ids,
folder_id,
},
);
} }

View File

@@ -1,19 +1,17 @@
<script setup lang="ts"> <script setup lang="ts">
import type { FileGalleryItem } from '#/api/core/file-gallery';
import { computed, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import { Empty, Input, Modal, Pagination, Spin, Tabs } from 'ant-design-vue'; import { Empty, Input, Modal, Pagination, Spin, Tabs } from 'ant-design-vue';
import { getFileGalleryList } from '#/api/core/file-gallery';
import MIcon from '#/components/icon/icon.vue'; import MIcon from '#/components/icon/icon.vue';
import { useFileGalleryFilter } from '#/composables/use-file-gallery-filter'; import { useFileGalleryFilter } from '#/composables/use-file-gallery-filter';
import { resolveTypeIcon } from '#/composables/use-file-type-icon'; import { resolveTypeIcon } from '#/composables/use-file-type-icon';
import type { FileGalleryItem } from '#/api/core/file-gallery';
import { getFileGalleryList } from '#/api/core/file-gallery';
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
open: boolean;
multiple?: boolean;
maxCount?: number;
/** /**
* 限制可选择的文件类型(按 xk_file_type.value 过滤) * 限制可选择的文件类型(按 xk_file_type.value 过滤)
* - 不传:显示全部类型(默认行为,保持向后兼容) * - 不传:显示全部类型(默认行为,保持向后兼容)
@@ -21,6 +19,9 @@ const props = withDefaults(
* 例如图片:[0];图片+视频:[0, 1];文件:[6] * 例如图片:[0];图片+视频:[0, 1];文件:[6]
*/ */
acceptTypes?: number[]; acceptTypes?: number[];
maxCount?: number;
multiple?: boolean;
open: boolean;
}>(), }>(),
{ {
multiple: false, multiple: false,
@@ -30,8 +31,8 @@ const props = withDefaults(
); );
const emit = defineEmits<{ const emit = defineEmits<{
'update:open': [value: boolean];
select: [urls: string[]]; select: [urls: string[]];
'update:open': [value: boolean];
}>(); }>();
const loading = ref(false); const loading = ref(false);
@@ -80,8 +81,9 @@ function lockAcceptType() {
if (!arr || arr.length === 0) { if (!arr || arr.length === 0) {
return; return;
} }
if (activeType.value !== arr[0]) { const firstType = arr[0];
setActiveType(arr[0]!); if (firstType !== undefined && activeType.value !== firstType) {
setActiveType(firstType);
} }
} }
@@ -104,9 +106,11 @@ async function load() {
} }
loading.value = true; loading.value = true;
try { try {
const res = await getFileGalleryList( const res = await getFileGalleryList({
buildListParams(page.value, pageSize.value), ...buildListParams(page.value, pageSize.value),
); // 选择器隐藏二维码:标记字段 + 路径启发式由后端 exclude_qrcode 处理
exclude_qrcode: 1,
});
const data = (res as any)?.data ?? res; const data = (res as any)?.data ?? res;
items.value = data?.items ?? []; items.value = data?.items ?? [];
total.value = data?.total ?? 0; total.value = data?.total ?? 0;
@@ -128,7 +132,7 @@ watch(
{ immediate: true }, { immediate: true },
); );
let searchTimer: ReturnType<typeof setTimeout> | null = null; let searchTimer: null | ReturnType<typeof setTimeout> = null;
watch( watch(
() => props.open, () => props.open,
@@ -153,7 +157,7 @@ watch(typesLoading, (val) => {
function toggleSelect(url: string) { function toggleSelect(url: string) {
if (props.multiple) { if (props.multiple) {
const idx = selected.value.indexOf(url); const idx = selected.value.indexOf(url);
if (idx >= 0) { if (idx !== -1) {
selected.value = selected.value.filter((u) => u !== url); selected.value = selected.value.filter((u) => u !== url);
return; return;
} }
@@ -171,7 +175,7 @@ function isSelected(url: string) {
} }
function handleOk() { function handleOk() {
if (!selected.value.length) { if (selected.value.length === 0) {
return; return;
} }
emit('select', [...selected.value]); emit('select', [...selected.value]);
@@ -196,7 +200,7 @@ function onShowSizeChange(_current: number, size: number) {
void load(); void load();
} }
function onTabChange(key: string | number) { function onTabChange(key: number | string) {
setActiveType(Number(key)); setActiveType(Number(key));
page.value = 1; page.value = 1;
void load(); void load();
@@ -308,8 +312,8 @@ function isImageItem(item: FileGalleryItem) {
.tab-label { .tab-label {
display: inline-flex; display: inline-flex;
align-items: center;
gap: 4px; gap: 4px;
align-items: center;
} }
.picker-toolbar { .picker-toolbar {
@@ -321,18 +325,19 @@ function isImageItem(item: FileGalleryItem) {
grid-template-columns: repeat(auto-fill, minmax(118px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(118px, 1fr));
gap: 12px; gap: 12px;
max-height: 420px; max-height: 420px;
overflow-y: auto;
padding: 4px; padding: 4px;
overflow-y: auto;
} }
.gallery-card { .gallery-card {
@include picker.picker-card-base;
padding: 6px; padding: 6px;
@include picker.picker-card-base;
&.active { &.active {
border-color: #165dff;
background: #f2f7ff; background: #f2f7ff;
box-shadow: 0 0 0 2px rgba(22, 93, 255, 0.15); border-color: #165dff;
box-shadow: 0 0 0 2px rgb(22 93 255 / 15%);
} }
} }
@@ -340,29 +345,29 @@ function isImageItem(item: FileGalleryItem) {
width: 100%; width: 100%;
height: 88px; height: 88px;
object-fit: cover; object-fit: cover;
border-radius: 6px;
background: #f5f5f5; background: #f5f5f5;
border-radius: 6px;
} }
.gallery-file-icon { .gallery-file-icon {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 6px;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 6px;
height: 88px; height: 88px;
border-radius: 6px;
background: #f5f5f5;
color: #4e5969;
padding: 6px; padding: 6px;
color: #4e5969;
background: #f5f5f5;
border-radius: 6px;
.file-name { .file-name {
font-size: 11px;
text-align: center;
word-break: break-all;
line-height: 1.2;
max-height: 28px; max-height: 28px;
overflow: hidden; overflow: hidden;
font-size: 11px;
line-height: 1.2;
text-align: center;
word-break: break-all;
} }
} }
@@ -372,20 +377,20 @@ function isImageItem(item: FileGalleryItem) {
.file-title { .file-title {
display: inline-flex; display: inline-flex;
align-items: center;
gap: 4px; gap: 4px;
color: #1d2129; align-items: center;
max-width: 100%; max-width: 100%;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
color: #1d2129;
white-space: nowrap; white-space: nowrap;
} }
} }
.gallery-pagination { .gallery-pagination {
margin-top: 16px;
display: flex; display: flex;
justify-content: center; justify-content: center;
margin-top: 16px;
} }
.dark { .dark {
@@ -393,9 +398,9 @@ function isImageItem(item: FileGalleryItem) {
@include picker.picker-card-dark-props; @include picker.picker-card-dark-props;
&.active { &.active {
border-color: #60a5fa;
background: #374151; background: #374151;
box-shadow: 0 0 0 2px rgba(96, 165, 250, 0.2); border-color: #60a5fa;
box-shadow: 0 0 0 2px rgb(96 165 250 / 20%);
} }
} }

View File

@@ -1,8 +1,11 @@
import { computed, onMounted, ref, toValue, type MaybeRefOrGetter } from 'vue'; import type { MaybeRefOrGetter } from 'vue';
import type { FileTypeItem } from '#/api/core/file-gallery'; import type { FileTypeItem } from '#/api/core/file-gallery';
import { getFileTypes } from '#/api/core/file-gallery';
import type { FileGroupOptionItem } from '#/api/core/file-group'; import type { FileGroupOptionItem } from '#/api/core/file-group';
import { computed, onMounted, ref, toValue } from 'vue';
import { getFileTypes } from '#/api/core/file-gallery';
import { useFileGroupCache } from '#/composables/use-file-group-cache'; import { useFileGroupCache } from '#/composables/use-file-group-cache';
import { ALL_TYPE_ICON } from '#/composables/use-file-type-icon'; import { ALL_TYPE_ICON } from '#/composables/use-file-type-icon';
@@ -10,6 +13,12 @@ export const ALL_TYPE_VALUE = -1;
export const ALL_GROUP_VALUE = 0; export const ALL_GROUP_VALUE = 0;
/** 文件夹侧栏0=全部,-1=未入文件夹 */
export const ALL_FOLDER_VALUE = 0;
export const UNFILED_FOLDER_VALUE = -1;
export type FileGallerySidebarTab = 'folder' | 'group';
export interface FileGalleryTab { export interface FileGalleryTab {
key: string; key: string;
value: number; value: number;
@@ -18,7 +27,9 @@ export interface FileGalleryTab {
} }
/** /**
* 文件库筛选(类型 tab + 分 + 关键字) * 文件库筛选(类型 tab + 分类/文件夹侧栏 + 关键字)
* 分类与文件夹互斥buildListParams 只按当前 sidebarTab 带 group_id 或 folder_id
* 文件夹筛选用 object_key 目录前缀,不是手工建夹
* @param storageKeyInput localStorage 键;支持动态 getter便于按 acceptTypes 隔离记忆 * @param storageKeyInput localStorage 键;支持动态 getter便于按 acceptTypes 隔离记忆
*/ */
export function useFileGalleryFilter( export function useFileGalleryFilter(
@@ -27,7 +38,11 @@ export function useFileGalleryFilter(
const fileTypes = ref<FileTypeItem[]>([]); const fileTypes = ref<FileTypeItem[]>([]);
const activeType = ref<number>(ALL_TYPE_VALUE); const activeType = ref<number>(ALL_TYPE_VALUE);
const activeGroupId = ref<number>(ALL_GROUP_VALUE); const activeGroupId = ref<number>(ALL_GROUP_VALUE);
const activeFolderId = ref<number>(ALL_FOLDER_VALUE);
const sidebarTab = ref<FileGallerySidebarTab>('group');
const keyword = ref(''); const keyword = ref('');
/** 只看无引用(管理页用,选择器不传) */
const unrefOnly = ref(false);
const typesLoading = ref(false); const typesLoading = ref(false);
const { groupOptions, loadGroupOptions } = useFileGroupCache(); const { groupOptions, loadGroupOptions } = useFileGroupCache();
@@ -48,7 +63,7 @@ export function useFileGalleryFilter(
/** 分组 Tab独立于文件类型 */ /** 分组 Tab独立于文件类型 */
const groupTabs = computed<FileGalleryTab[]>(() => [ const groupTabs = computed<FileGalleryTab[]>(() => [
{ key: 'group-all', value: ALL_GROUP_VALUE, label: '全部分' }, { key: 'group-all', value: ALL_GROUP_VALUE, label: '全部分' },
...groupOptions.value.map((item: FileGroupOptionItem) => ({ ...groupOptions.value.map((item: FileGroupOptionItem) => ({
key: `group-${item.id}`, key: `group-${item.id}`,
value: item.id, value: item.id,
@@ -80,14 +95,38 @@ export function useFileGalleryFilter(
activeGroupId.value = value; activeGroupId.value = value;
} }
function setActiveFolderId(value: number) {
activeFolderId.value = value;
}
function setSidebarTab(value: FileGallerySidebarTab) {
sidebarTab.value = value;
localStorage.setItem('file-gallery-sidebar-tab', value);
}
function restoreSidebarTab() {
const saved = localStorage.getItem('file-gallery-sidebar-tab');
if (saved === 'folder' || saved === 'group') {
sidebarTab.value = saved;
}
}
/** 管理页「只看无引用」,选择器不要开 */
function setUnrefOnly(value: boolean) {
unrefOnly.value = value;
}
function buildListParams(page: number, pageSize: number) { function buildListParams(page: number, pageSize: number) {
const params: { const params: {
folder_id?: number;
group_id?: number;
keyword?: string;
page: number; page: number;
page_size: number; page_size: number;
pid: number; pid: number;
type?: number; type?: number;
group_id?: number; unfiled?: number;
keyword?: string; unref_only?: number;
} = { } = {
page, page,
page_size: pageSize, page_size: pageSize,
@@ -98,15 +137,27 @@ export function useFileGalleryFilter(
params.type = activeType.value; params.type = activeType.value;
} }
if (activeGroupId.value > 0) { // 分类 Tab 用分组;文件夹 Tab 用 path 前缀,互不混用
if (sidebarTab.value === 'group' && activeGroupId.value > 0) {
params.group_id = activeGroupId.value; params.group_id = activeGroupId.value;
} }
if (sidebarTab.value === 'folder') {
if (activeFolderId.value === UNFILED_FOLDER_VALUE) {
params.unfiled = 1;
} else if (activeFolderId.value > 0) {
params.folder_id = activeFolderId.value;
}
}
const kw = keyword.value.trim(); const kw = keyword.value.trim();
if (kw) { if (kw) {
params.keyword = kw; params.keyword = kw;
} }
if (unrefOnly.value) {
params.unref_only = 1;
}
return params; return params;
} }
@@ -122,6 +173,8 @@ export function useFileGalleryFilter(
} }
} }
restoreSidebarTab();
onMounted(() => { onMounted(() => {
void loadFileTypes(); void loadFileTypes();
void loadGroupOptions(); void loadGroupOptions();
@@ -131,13 +184,19 @@ export function useFileGalleryFilter(
fileTypes, fileTypes,
activeType, activeType,
activeGroupId, activeGroupId,
activeFolderId,
sidebarTab,
keyword, keyword,
unrefOnly,
typesLoading, typesLoading,
tabs, tabs,
groupTabs, groupTabs,
groupOptions, groupOptions,
setActiveType, setActiveType,
setActiveGroupId, setActiveGroupId,
setActiveFolderId,
setSidebarTab,
setUnrefOnly,
buildListParams, buildListParams,
loadFileTypes, loadFileTypes,
loadGroupOptions, loadGroupOptions,

View File

@@ -101,16 +101,7 @@ const routes: RouteRecordRaw[] = [
order: 9999, order: 9999,
}, },
}, },
{ // 个人中心路由已改由后端 xk_menu 隐藏菜单下发backend 访问控制下静态路由不会注册,写在这里等于死代码)
name: 'Profile',
path: '/profile',
component: () => import('#/views/_core/profile/index.vue'),
meta: {
icon: 'lucide:user',
hideInMenu: true,
title: $t('page.auth.profile'),
},
},
]; ];
export default routes; export default routes;

View File

@@ -1,7 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* 个人中心:左侧账号卡(头像/昵称/角色)+ 右侧 Tab基本资料 / 修改密码) * 个人中心:左侧账号卡(头像/昵称/角色)+ 右侧 Tab基本资料 / 修改密码)
* 入口:顶栏头像下拉「个人中心」、工作台问候头;静态路由 /profilehideInMenu不走 xk_menu * 入口:顶栏头像下拉「个人中心」、工作台问候头;路由由后端 xk_menu 隐藏菜单下发
* backend 访问控制模式name=Profile 必须与菜单表一致SQL 见 20260817/03_xk_menu_profile.sql
* 资料数据统一从 auth/my-profile 实时接口拉取,子组件保存成功后回调刷新 * 资料数据统一从 auth/my-profile 实时接口拉取,子组件保存成功后回调刷新
*/ */
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
@@ -12,6 +13,9 @@ import { getMyProfile } from './api';
import ProfileBase from './base-setting.vue'; import ProfileBase from './base-setting.vue';
import ProfilePasswordSetting from './password-setting.vue'; import ProfilePasswordSetting from './password-setting.vue';
// 路由 name 必须与 xk_menu.nameProfile一致backend 模式动态路由靠它命中本组件并支持 push({ name })
defineOptions({ name: 'Profile' });
const tabsValue = ref<string>('basic'); const tabsValue = ref<string>('basic');
const tabs = ref([ const tabs = ref([

View File

@@ -1,4 +1,8 @@
<script lang="ts" setup> <script lang="ts" setup>
/**
* 商品订单退款弹窗
* 确认时自行校验并调 refund 接口;不再走已弃用的 validateAndSubmitForm
*/
import { ref } from 'vue'; import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui'; import { useVbenModal } from '@vben/common-ui';
@@ -22,22 +26,18 @@ const [Modal, modalApi] = useVbenModal({
modalApi.close(); modalApi.close();
}, },
onConfirm: async () => { onConfirm: async () => {
formApi.validate().then(async (e: any) => { const e = await formApi.validate();
if (e.valid) { if (!e.valid) return;
const values = await formApi.getValues(); const values = await formApi.getValues();
modalApi.setState({ loading: true, confirmLoading: true }); modalApi.setState({ loading: true, confirmLoading: true });
refundApi(values) try {
.then(() => { await refundApi(values);
message.success('保存成功'); message.success('保存成功');
gridApi.value?.reload(); gridApi.value?.reload();
modalApi.close(); modalApi.close();
}) } finally {
.finally(() => { modalApi.setState({ loading: false, confirmLoading: false });
modalApi.setState({ loading: false, confirmLoading: false }); }
});
}
});
await formApi.validateAndSubmitForm();
}, },
onOpenChange(isOpen: boolean) { onOpenChange(isOpen: boolean) {
gridApi.value = isOpen ? modalApi.getData()?.gridApi : null; gridApi.value = isOpen ? modalApi.getData()?.gridApi : null;
@@ -52,10 +52,7 @@ const [Modal, modalApi] = useVbenModal({
}); });
</script> </script>
<template> <template>
<Modal <Modal title="退款" class="w-[80%] md:w-[50%] lg:w-[30%] h-[60%]">
title="退款"
class="w-[80%] md:w-[50%] lg:w-[30%] h-[60%]"
>
<Form /> <Form />
</Modal> </Modal>
</template> </template>

View File

@@ -38,3 +38,51 @@ export async function getRegisterListApi(data: Record<string, unknown>) {
export async function refundRegisterApi(data: Record<string, unknown>) { export async function refundRegisterApi(data: Record<string, unknown>) {
return requestClient.post<any>(`${prefix}refund`, data); return requestClient.post<any>(`${prefix}refund`, data);
} }
/** 号源日历时段(改期弹窗展示余号/约满/时段已过) */
export interface RegisterCalendarSlot {
slot_id: number;
slot_name: string;
start_time: string;
end_time: string;
capacity: number;
used: number;
left: number;
is_full: boolean;
is_expired: boolean;
}
/** 号源日历单日(与患者端挂号日历同源结构) */
export interface RegisterCalendarDay {
date: string;
date_ts: number;
week: string;
is_today: boolean;
is_work: boolean;
total: number;
left: number;
is_full: boolean;
slots: RegisterCalendarSlot[];
}
/** 医生号源日历(管理端代改期选择新时段;与患者/医生端同源) */
export async function getRegisterScheduleCalendarApi(data: {
doctor_id: number;
store_id: number;
}) {
return requestClient.get<{
book_days: number;
days: RegisterCalendarDay[];
is_default: boolean;
}>(`${prefix}schedule-calendar`, { params: data });
}
/** 管理端代患者改期(仅已支付待接诊单,管理员不受就诊当天限制) */
export async function rescheduleRegisterApi(data: {
register_id: number;
remark?: string;
slot_id: number;
visit_date: string;
}) {
return requestClient.post<any>(`${prefix}reschedule`, data);
}

View File

@@ -0,0 +1,293 @@
<script lang="ts" setup>
/**
* 挂号改期弹窗(管理端代患者改期)
*
* 流程:打开时按单据医生+门店拉号源日历(与患者端同源接口)→ 选新就诊日 → 选时段(展示余号,
* 约满/已过不可选)→ 可填备注 → 提交 POST register/reschedule。
* 管理员操作不受「就诊当天不可改」限制(后端 operator=OPERATOR_ADMIN超卖/撞单由后端事务内兜底。
*/
import type { RegisterCalendarDay, RegisterCalendarSlot } from '../api';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Input, message, Spin } from 'ant-design-vue';
import { getRegisterScheduleCalendarApi, rescheduleRegisterApi } from '../api';
/** 当前操作的挂号单行数据(列表行透传,含就诊人/医生/门店关联) */
const row = ref<Record<string, any>>({});
/** 列表刷新代理(父级传入,成功后 query 保留当前页) */
const gridApi = ref<any>(null);
const loading = ref(false);
/** 号源日历(未来 N 天出诊/时段/余号) */
const days = ref<RegisterCalendarDay[]>([]);
const selectedDate = ref('');
/** 选中的时段IDnull=未选择0 是合法值:全天兜底时段) */
const selectedSlotId = ref<null | number>(null);
const remark = ref('');
/** 当前选中日期对应的时段列表 */
const currentSlots = computed<RegisterCalendarSlot[]>(() => {
const day = days.value.find((d) => d.date === selectedDate.value);
return day?.slots ?? [];
});
/** 日期是否可选:出诊且未约满(今天时段是否可选由时段级 is_expired 再判) */
function isDaySelectable(day: RegisterCalendarDay) {
return day.is_work && !day.is_full;
}
function selectDay(day: RegisterCalendarDay) {
if (!isDaySelectable(day)) return;
selectedDate.value = day.date;
// 换日期后时段必须重选,避免带着上个日期的 slot_id 提交
selectedSlotId.value = null;
}
function selectSlot(slot: RegisterCalendarSlot) {
if (slot.is_full || slot.is_expired) return;
selectedSlotId.value = slot.slot_id;
}
/** 拉号源日历:医生取单据 service_user_id门店取履约门店 store_id */
async function loadCalendar() {
const doctorId = Number(row.value.service_user_id || 0);
const storeId = Number(row.value.store?.id || row.value.store_id || 0);
if (!doctorId || !storeId) {
message.error('挂号单缺少医生或门店信息,无法改期');
return;
}
loading.value = true;
try {
const res = await getRegisterScheduleCalendarApi({
doctor_id: doctorId,
store_id: storeId,
});
days.value = res?.days ?? [];
} finally {
loading.value = false;
}
}
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
draggable: true,
onConfirm: async () => {
if (!selectedDate.value) {
message.warning('请选择新的就诊日期');
return;
}
if (selectedSlotId.value === null) {
message.warning('请选择新的就诊时段');
return;
}
// lock 防重复提交(>5.5.3 统一用 lock/unlock不手写按钮 loading
modalApi.lock();
try {
await rescheduleRegisterApi({
register_id: Number(row.value.id),
visit_date: selectedDate.value,
slot_id: selectedSlotId.value,
remark: remark.value,
});
message.success('改期成功');
gridApi.value?.query?.();
modalApi.close();
} finally {
modalApi.unlock();
}
},
onOpenChange(isOpen: boolean) {
if (!isOpen) {
return;
}
const data = modalApi.getData<Record<string, any>>() || {};
row.value = data.row || {};
gridApi.value = data.gridApi || null;
// 每次打开重置选择状态,避免残留上一单的日期/时段
days.value = [];
selectedDate.value = '';
selectedSlotId.value = null;
remark.value = '';
loadCalendar();
},
});
</script>
<template>
<Modal title="挂号改期" class="w-[92%] md:w-[70%] lg:w-[620px]">
<div class="rs-wrap">
<!-- 单据摘要确认改的是哪一单 -->
<div class="rs-summary">
<span>订单号{{ row.order_no || '—' }}</span>
<span>就诊人{{ row.user_patient?.name || '—' }}</span>
<span>医生{{ row.doctor_info?.name || '—' }}</span>
</div>
<Spin :spinning="loading">
<!-- 新就诊日期 -->
<div class="rs-label">选择新就诊日期</div>
<div class="rs-days">
<div
v-for="day in days"
:key="day.date"
class="rs-day"
:class="{
'is-active': day.date === selectedDate,
'is-disabled': !isDaySelectable(day),
}"
@click="selectDay(day)"
>
<div class="rs-day-week">{{ day.week }}</div>
<div class="rs-day-date">{{ day.date.slice(5) }}</div>
<div class="rs-day-left">
{{
day.is_work
? day.is_full
? '已约满'
: `${day.left}`
: '休诊'
}}
</div>
</div>
<div v-if="!loading && days.length === 0" class="rs-empty">
未获取到该医生的号源日历
</div>
</div>
<!-- 新时段 -->
<template v-if="selectedDate">
<div class="rs-label">选择新时段</div>
<div class="rs-slots">
<div
v-for="slot in currentSlots"
:key="slot.slot_id"
class="rs-slot"
:class="{
'is-active': slot.slot_id === selectedSlotId,
'is-disabled': slot.is_full || slot.is_expired,
}"
@click="selectSlot(slot)"
>
<div class="rs-slot-name">{{ slot.slot_name }}</div>
<div class="rs-slot-time">
{{ slot.start_time }} - {{ slot.end_time }}
</div>
<div class="rs-slot-left">
{{
slot.is_expired
? '已过时段'
: slot.is_full
? '已约满'
: `${slot.left}`
}}
</div>
</div>
</div>
</template>
<!-- 备注 -->
<div class="rs-label">备注选填落改期日志便于溯源</div>
<Input.TextArea
v-model:value="remark"
:rows="2"
:maxlength="255"
placeholder="例如:患者电话联系诊所要求调整"
/>
</Spin>
</div>
</Modal>
</template>
<style scoped lang="scss">
/* 全部用主题变量,亮/暗色自动跟随选中态遵守「1px 同色系边框 + 微光」规范 */
.rs-wrap {
display: flex;
flex-direction: column;
gap: 10px;
}
.rs-summary {
display: flex;
flex-wrap: wrap;
gap: 8px 20px;
padding: 10px 12px;
font-size: 13px;
color: hsl(var(--muted-foreground));
background: hsl(var(--muted) / 25%);
border: 1px solid hsl(var(--border));
border-radius: 8px;
}
.rs-label {
font-size: 13px;
font-weight: 600;
color: hsl(var(--foreground));
}
.rs-days {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.rs-day,
.rs-slot {
min-width: 88px;
padding: 8px 10px;
text-align: center;
cursor: pointer;
background: hsl(var(--card, var(--background)));
border: 1px solid hsl(var(--border));
border-radius: 8px;
transition:
border-color 0.2s,
box-shadow 0.2s;
&.is-active {
border: 1px solid hsl(var(--primary) / 30%);
box-shadow: 0 0 6px hsl(var(--primary) / 18%);
.rs-day-date,
.rs-slot-name {
color: hsl(var(--primary));
}
}
&.is-disabled {
cursor: not-allowed;
opacity: 0.45;
}
}
.rs-day-week,
.rs-slot-time {
font-size: 12px;
color: hsl(var(--muted-foreground));
}
.rs-day-date,
.rs-slot-name {
margin: 2px 0;
font-size: 14px;
font-weight: 600;
color: hsl(var(--foreground));
}
.rs-day-left,
.rs-slot-left {
font-size: 12px;
color: hsl(var(--muted-foreground));
}
.rs-slots {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.rs-empty {
padding: 12px;
font-size: 13px;
color: hsl(var(--muted-foreground));
}
</style>

View File

@@ -19,15 +19,16 @@ import { Button, Image, Tag } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form'; import { useVbenForm } from '#/adapter/form';
import { useVbenVxeGrid } from '#/adapter/vxe-table'; import { useVbenVxeGrid } from '#/adapter/vxe-table';
import StoreCardModal from '#/components/store-card/StoreCardModal.vue';
import { TableAction } from '#/components/table-action'; import { TableAction } from '#/components/table-action';
import { useViewMode, ViewModeSwitch } from '#/components/view-mode-switch'; import { useViewMode, ViewModeSwitch } from '#/components/view-mode-switch';
import StoreCardModal from '#/components/store-card/StoreCardModal.vue';
import PrescriptionDetail from '#/views/doctor/doctor-reception/components/PrescriptionDetail.vue'; import PrescriptionDetail from '#/views/doctor/doctor-reception/components/PrescriptionDetail.vue';
import { getRegisterListApi } from './api'; import { getRegisterListApi } from './api';
import Refund from './components/refund.vue'; import Refund from './components/refund.vue';
import RegisterDetailModal from './components/RegisterDetailModal.vue'; import RegisterDetailModal from './components/RegisterDetailModal.vue';
import RegisterOrderCardList from './components/RegisterOrderCardList.vue'; import RegisterOrderCardList from './components/RegisterOrderCardList.vue';
import RescheduleModal from './components/reschedule-modal.vue';
import { import {
REGISTER_REFUND_STATUS_MAP, REGISTER_REFUND_STATUS_MAP,
REGISTER_STATUS_MAP, REGISTER_STATUS_MAP,
@@ -41,7 +42,9 @@ const hasTopTableDropDownActions = ref(false);
/** 列表/卡片双视图:通用 useViewMode 自带 localStorage 记忆 */ /** 列表/卡片双视图:通用 useViewMode 自带 localStorage 记忆 */
const viewMode = useViewMode('register-order-view-mode'); const viewMode = useViewMode('register-order-view-mode');
const cardListRef = ref<InstanceType<typeof RegisterOrderCardList> | null>(null); const cardListRef = ref<InstanceType<typeof RegisterOrderCardList> | null>(
null,
);
const gridEvents: VxeGridListeners<any> = { const gridEvents: VxeGridListeners<any> = {
checkboxChange() { checkboxChange() {
@@ -104,7 +107,7 @@ const searchValues = ref<Record<string, any>>(collectDefaultSearchValues());
const [SearchForm, searchFormApi] = useVbenForm({ const [SearchForm, searchFormApi] = useVbenForm({
...effectiveFormOptions, ...effectiveFormOptions,
handleSubmit: async (values) => { handleSubmit: async (values) => {
searchValues.value = { ...(values || {}) }; searchValues.value = { ...values };
if (viewMode.value === 'list') { if (viewMode.value === 'list') {
gridApi.reload(); gridApi.reload();
} }
@@ -127,7 +130,11 @@ const [Grid, gridApi] = useVbenVxeGrid({
proxyConfig: { proxyConfig: {
ajax: { ajax: {
/** 始终合并顶部统一搜索条件 */ /** 始终合并顶部统一搜索条件 */
query: async ({ page }: { page: { currentPage: number; pageSize: number } }) => { query: async ({
page,
}: {
page: { currentPage: number; pageSize: number };
}) => {
return await getRegisterListApi({ return await getRegisterListApi({
page: page.currentPage, page: page.currentPage,
pageSize: page.pageSize, pageSize: page.pageSize,
@@ -190,6 +197,11 @@ const [RefundModal, RefundModalApi] = useVbenModal({
connectedComponent: Refund, connectedComponent: Refund,
}); });
/** 改期弹窗:管理端代患者改期(拉号源日历选新日期/时段) */
const [RescheduleModalComp, rescheduleModalApi] = useVbenModal({
connectedComponent: RescheduleModal,
});
/** 挂号详情弹窗:列表行「详情」与卡片点击共用 */ /** 挂号详情弹窗:列表行「详情」与卡片点击共用 */
const [RegisterDetailModalComp, registerDetailModalApi] = useVbenModal({ const [RegisterDetailModalComp, registerDetailModalApi] = useVbenModal({
connectedComponent: RegisterDetailModal, connectedComponent: RegisterDetailModal,
@@ -221,6 +233,16 @@ function canRefund(row: Record<string, any>) {
return row.is_pay === 1 && row.refund_status !== 1 && row.refund_status !== 3; return row.is_pay === 1 && row.refund_status !== 1 && row.refund_status !== 3;
} }
/** 仅「已支付 + 待接诊 + 未取消」可改期(与后端 RegisterRescheduleService 校验口径一致) */
function canReschedule(row: Record<string, any>) {
return row.is_pay === 1 && row.status === 1 && row.is_cancel !== 1;
}
function openRescheduleModal(row: Record<string, any>) {
rescheduleModalApi.setData({ row, gridApi: gridApiProxy });
rescheduleModalApi.open();
}
function formatRegisterPrice(price: unknown) { function formatRegisterPrice(price: unknown) {
const n = Number(price); const n = Number(price);
if (!Number.isFinite(n)) { if (!Number.isFinite(n)) {
@@ -250,6 +272,7 @@ function rowRefundMeta(row: Record<string, any>) {
<Page auto-content-height title="订单管理"> <Page auto-content-height title="订单管理">
<PrescriptionDetailModal /> <PrescriptionDetailModal />
<RefundModal /> <RefundModal />
<RescheduleModalComp />
<StoreCardModalComp /> <StoreCardModalComp />
<!-- 挂号详情弹窗处方号/诊所名点击透传到已有弹窗 --> <!-- 挂号详情弹窗处方号/诊所名点击透传到已有弹窗 -->
<RegisterDetailModalComp <RegisterDetailModalComp
@@ -257,7 +280,9 @@ function rowRefundMeta(row: Record<string, any>) {
@open-store="openStoreCard" @open-store="openStoreCard"
/> />
<!-- 顶部统一搜索双视图共用替代原 grid 内嵌表单 --> <!-- 顶部统一搜索双视图共用替代原 grid 内嵌表单 -->
<div class="mb-3 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--card,var(--background)))] p-3"> <div
class="mb-3 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--card,var(--background)))] p-3"
>
<SearchForm /> <SearchForm />
</div> </div>
<!-- 视图切换列表 / 卡片看板 --> <!-- 视图切换列表 / 卡片看板 -->
@@ -268,9 +293,7 @@ function rowRefundMeta(row: Record<string, any>) {
<template #toolbar-actions> <template #toolbar-actions>
<TableAction :actions="[]" :drop-down-actions="[]"> <TableAction :actions="[]" :drop-down-actions="[]">
<template #more> <template #more>
<Button style="margin-left: 16px"> <Button style="margin-left: 16px"> 批量操作 </Button>
批量操作
</Button>
</template> </template>
</TableAction> </TableAction>
</template> </template>
@@ -340,6 +363,13 @@ function rowRefundMeta(row: Record<string, any>) {
icon: 'marketeq:eye', icon: 'marketeq:eye',
onClick: openRegisterDetail.bind(null, row), onClick: openRegisterDetail.bind(null, row),
}, },
{
label: '改期',
type: 'link',
icon: 'ant-design:calendar-outlined',
ifShow: canReschedule(row),
onClick: openRescheduleModal.bind(null, row),
},
{ {
label: '退款', label: '退款',
type: 'link', type: 'link',

View File

@@ -4,21 +4,21 @@ const prefix = 'workbench/';
/** 工作台布局项(后端按角色下发) */ /** 工作台布局项(后端按角色下发) */
export type WorkbenchLayoutItem = { export type WorkbenchLayoutItem = {
id: number;
widget_code: string;
title: string;
sort: number;
config: null | Record<string, any>; config: null | Record<string, any>;
id: number;
sort: number;
title: string;
widget_code: string;
}; };
/** 医生今日摘要(与医生小程序工作台同口径) */ /** 医生今日摘要(与医生小程序工作台同口径) */
export type DoctorSummaryResult = { export type DoctorSummaryResult = {
range: string;
prescription_count: number;
wait_accept: number;
accepting: number; accepting: number;
completed: number; completed: number;
prescription_amount: string; prescription_amount: string;
prescription_count: number;
range: string;
wait_accept: number;
}; };
/** 药师待审摘要 */ /** 药师待审摘要 */
@@ -28,16 +28,16 @@ export type PharmacistSummaryResult = {
/** 诊所今日摘要 */ /** 诊所今日摘要 */
export type ClinicSummaryResult = { export type ClinicSummaryResult = {
wait_accept: number;
accepting: number; accepting: number;
completed: number; completed: number;
wait_accept: number;
wait_delivery: number; wait_delivery: number;
}; };
/** 订单管理员摘要 */ /** 订单管理员摘要 */
export type OrderSummaryResult = { export type OrderSummaryResult = {
wait_delivery: number;
refunding: number; refunding: number;
wait_delivery: number;
}; };
/** 平台今日总览(超管/系统管理员) */ /** 平台今日总览(超管/系统管理员) */
@@ -51,8 +51,8 @@ export type PlatformOverviewResult = {
/** 待办中心单项count>0 需要处理path 为跳转路由) */ /** 待办中心单项count>0 需要处理path 为跳转路由) */
export type TodoCenterItem = { export type TodoCenterItem = {
code: string; code: string;
label: string;
count: number; count: number;
label: string;
path: string; path: string;
}; };
@@ -63,35 +63,35 @@ export type TodoCenterResult = {
/** 未来7天预约单日分布 */ /** 未来7天预约单日分布 */
export type AppointmentUpcomingDay = { export type AppointmentUpcomingDay = {
count: number;
date: string; date: string;
label: string; label: string;
week: string; week: string;
count: number;
}; };
/** 未来7天预约统计卡片 */ /** 未来7天预约统计卡片 */
export type AppointmentUpcomingResult = { export type AppointmentUpcomingResult = {
total: number;
days: AppointmentUpcomingDay[]; days: AppointmentUpcomingDay[];
total: number;
}; };
/** 未来7天预约明细行 */ /** 未来7天预约明细行 */
export type AppointmentUpcomingItem = { export type AppointmentUpcomingItem = {
register_id: number; doctor_name: string;
end_time: string;
is_pay: number;
order_no: string; order_no: string;
visit_date: string; patient_name: string;
week: string; price: string;
register_id: number;
slot_label: string;
slot_name: string; slot_name: string;
start_time: string; start_time: string;
end_time: string;
slot_label: string;
patient_name: string;
doctor_name: string;
store_name: string;
is_pay: number;
price: string;
status: number; status: number;
status_text: string; status_text: string;
store_name: string;
visit_date: string;
week: string;
}; };
/** 未来7天预约明细分页结果 */ /** 未来7天预约明细分页结果 */
@@ -103,17 +103,19 @@ export type AppointmentUpcomingListResult = {
/** AI 今日简报(管理员=昨日经营+今日待办,医生=今日值班+挂号+预约) */ /** AI 今日简报(管理员=昨日经营+今日待办,医生=今日值班+挂号+预约) */
export type AiDailyBriefResult = { export type AiDailyBriefResult = {
brief_date: string; brief_date: string;
/** 1=生成成功 0=生成失败(失败时 error_msg 有值data 快照仍可展示) */
status: number;
content: string; content: string;
error_msg: string;
/** 生成时收集的结构化业务数据快照(统计芯片直接用,保证与 AI 文本口径一致) */ /** 生成时收集的结构化业务数据快照(统计芯片直接用,保证与 AI 文本口径一致) */
data: Record<string, any>; data: Record<string, any>;
provider: string; /** 系统配置总开关false 时前端应隐藏且后端不会生成 */
model: string; enabled?: boolean;
generated_at: string; error_msg: string;
/** true=命中当日缓存(非今天首次生成,前端据此决定是否弹当日简报弹窗) */ /** true=命中当日缓存(非今天首次生成,前端据此决定是否弹当日简报弹窗) */
from_cache: boolean; from_cache: boolean;
generated_at: string;
model: string;
provider: string;
/** 1=生成成功 0=生成失败(失败时 error_msg 有值data 快照仍可展示) */
status: number;
}; };
/** /**

View File

@@ -39,13 +39,83 @@ const [BriefModal, briefModalApi] = useVbenModal({
* 正文分段解析:后端约定 AI 输出「【昨日概览】...【重点提醒】...【今日建议】...」格式, * 正文分段解析:后端约定 AI 输出「【昨日概览】...【重点提醒】...【今日建议】...」格式,
* 按【标题】切段渲染更易读AI 偶发不按格式输出时兜底整段展示,保证内容不丢 * 按【标题】切段渲染更易读AI 偶发不按格式输出时兜底整段展示,保证内容不丢
*/ */
const sections = computed(() => { const sections = computed(() => parseBriefSections(brief.value?.content || ''));
const content = brief.value?.content || '';
/**
* 简报分段:优先按【标题】切;整段是 JSON 时再拆一层Agent 偶发包 JSON
*/
function parseBriefSections(
raw: string,
): Array<{ text: string; title: string }> {
const content = unwrapBriefContent(raw);
if (!content) return []; if (!content) return [];
const matches = [...content.matchAll(/【([^】]+)】([^【]*)/g)]; const matches = [...content.matchAll(/【([^】]+)】([^【]*)/g)];
if (matches.length === 0) return [{ title: '', text: content }]; if (matches.length === 0) return [{ title: '', text: content }];
return matches.map((m) => ({ title: m[1] || '', text: (m[2] || '').trim() })); return matches.map((m) => ({ title: m[1] || '', text: (m[2] || '').trim() }));
}); }
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 (Array.isArray(decoded)) {
return flattenBriefSections(decoded) || text;
}
for (const field of ['content', 'text', 'brief', 'summary']) {
const val = (decoded as Record<string, unknown>)[field];
if (typeof val === 'string' && val.trim())
return unwrapBriefContent(val, depth + 1);
if (val && typeof val === 'object') {
return unwrapBriefContent(JSON.stringify(val), depth + 1);
}
}
if (Array.isArray((decoded as any).sections)) {
return flattenBriefSections((decoded as any).sections) || text;
}
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',
'brief_status',
'content',
'status',
'summary',
'text',
].includes(title)
) {
return;
}
parts.push(`${title}${body}`);
});
return parts.length > 0 ? parts.join('') : text;
} catch {
return text;
}
}
function flattenBriefSections(list: any[]): string {
return list
.map((sec) => {
if (typeof sec === 'string') return sec.trim();
const title = String(sec?.title || sec?.name || sec?.label || '').trim();
const body = String(sec?.text || sec?.content || sec?.body || '').trim();
return title && body ? `${title}${body}` : body;
})
.filter(Boolean)
.join('');
}
/** /**
* 快照统计 pill直接读生成时落库的 data_snapshot保证数字与 AI 文本口径一致 * 快照统计 pill直接读生成时落库的 data_snapshot保证数字与 AI 文本口径一致
@@ -57,32 +127,53 @@ const chips = computed(() => {
if (data.scope === 'admin') { if (data.scope === 'admin') {
const y = data.yesterday || {}; const y = data.yesterday || {};
const todoTotal = Array.isArray(data.todo) const todoTotal = Array.isArray(data.todo)
? data.todo.reduce((sum: number, item: any) => sum + (Number(item.count) || 0), 0) ? data.todo.reduce(
(sum: number, item: any) => sum + (Number(item.count) || 0),
0,
)
: 0; : 0;
return [ return [
{ icon: 'lucide:coins', label: '昨日营收', value: `¥${y.revenue ?? 0}` }, { icon: 'lucide:coins', label: '昨日营收', value: `¥${y.revenue ?? 0}` },
{ icon: 'lucide:stethoscope', label: '昨日挂号', value: y.register_count ?? 0 }, {
{ icon: 'lucide:store', label: '昨日新录入门店', value: y.store_input_count ?? 0 }, icon: 'lucide:stethoscope',
{ icon: 'lucide:list-checks', label: '今日待办', value: todoTotal, highlight: todoTotal > 0 }, label: '昨日挂号',
value: y.register_count ?? 0,
},
{
icon: 'lucide:store',
label: '昨日新录入门店',
value: y.store_input_count ?? 0,
},
{
icon: 'lucide:list-checks',
label: '今日待办',
value: todoTotal,
highlight: todoTotal > 0,
},
]; ];
} }
const today = data.today || {}; const today = data.today || {};
const schedule = data.schedule || {}; const schedule = data.schedule || {};
const upcoming = data.upcoming || {};
return [ return [
{ {
icon: 'lucide:calendar-check', icon: 'lucide:calendar-check',
label: '今日值班', label: '今日值班',
value: schedule.is_work ? `${schedule.left ?? 0}/${schedule.total ?? 0}余号` : '休诊', value: schedule.is_work
? `${schedule.left ?? 0}/${schedule.total ?? 0}余号`
: '休诊',
},
{
icon: 'lucide:users',
label: '待接诊',
value: today.wait_accept ?? 0,
highlight: (today.wait_accept ?? 0) > 0,
}, },
{ icon: 'lucide:users', label: '待接诊', value: today.wait_accept ?? 0, highlight: (today.wait_accept ?? 0) > 0 },
{ {
icon: 'lucide:bell-ring', icon: 'lucide:bell-ring',
label: '今日到期预约', label: '今日到期预约',
value: today.appointment_due_waiting ?? 0, value: today.appointment_due_waiting ?? 0,
highlight: (today.appointment_due_waiting ?? 0) > 0, highlight: (today.appointment_due_waiting ?? 0) > 0,
}, },
{ icon: 'lucide:calendar-clock', label: '未来7天预约', value: upcoming.total ?? 0 },
]; ];
}); });
@@ -94,7 +185,13 @@ async function fetchBrief(refresh: boolean = false) {
loading.value = true; loading.value = true;
fetchError.value = false; fetchError.value = false;
try { try {
brief.value = await getAiDailyBriefApi(refresh); const data = await getAiDailyBriefApi(refresh);
// 总开关关闭:不展示、不弹窗(正常走 layout 过滤,这里是接口兜底)
if (data && data.enabled === false) {
brief.value = null;
return;
}
brief.value = data;
maybeAutoPopup(); maybeAutoPopup();
} catch { } catch {
fetchError.value = true; fetchError.value = true;
@@ -161,7 +258,9 @@ onMounted(() => fetchBrief());
<div v-else-if="fetchError" class="ai-brief__error"> <div v-else-if="fetchError" class="ai-brief__error">
<VbenIcon icon="lucide:cloud-off" /> <VbenIcon icon="lucide:cloud-off" />
<span>简报加载失败</span> <span>简报加载失败</span>
<button type="button" class="ai-brief__retry" @click="fetchBrief()">重试</button> <button type="button" class="ai-brief__retry" @click="fetchBrief()">
重试
</button>
</div> </div>
<!-- AI 生成失败态快照芯片仍可看正文位置给重试引导 --> <!-- AI 生成失败态快照芯片仍可看正文位置给重试引导 -->
@@ -180,8 +279,16 @@ onMounted(() => fetchBrief());
</div> </div>
<div class="ai-brief__error"> <div class="ai-brief__error">
<VbenIcon icon="lucide:bot-off" /> <VbenIcon icon="lucide:bot-off" />
<span>AI 生成失败{{ brief.error_msg ? `${brief.error_msg}` : '' }}</span> <span>AI 生成失败{{
<button type="button" class="ai-brief__retry" @click="fetchBrief(true)">重新生成</button> brief.error_msg ? `${brief.error_msg}` : ''
}}</span>
<button
type="button"
class="ai-brief__retry"
@click="fetchBrief(true)"
>
重新生成
</button>
</div> </div>
</template> </template>
@@ -199,9 +306,21 @@ onMounted(() => fetchBrief());
<span class="ai-chip__label">{{ chip.label }}</span> <span class="ai-chip__label">{{ chip.label }}</span>
</span> </span>
</div> </div>
<div class="ai-brief__body" role="button" tabindex="0" @click="openPopup" @keydown.enter="openPopup"> <div
<div v-for="(section, idx) in sections" :key="idx" class="ai-brief__section"> class="ai-brief__body"
<span v-if="section.title" class="ai-brief__section-tag">{{ section.title }}</span> role="button"
tabindex="0"
@click="openPopup"
@keydown.enter="openPopup"
>
<div
v-for="(section, idx) in sections"
:key="idx"
class="ai-brief__section"
>
<span v-if="section.title" class="ai-brief__section-tag">{{
section.title
}}</span>
<p class="ai-brief__section-text">{{ section.text }}</p> <p class="ai-brief__section-text">{{ section.text }}</p>
</div> </div>
</div> </div>
@@ -232,8 +351,14 @@ onMounted(() => fetchBrief());
</span> </span>
</div> </div>
<div class="ai-popup__body"> <div class="ai-popup__body">
<div v-for="(section, idx) in sections" :key="idx" class="ai-popup__section"> <div
<span v-if="section.title" class="ai-brief__section-tag">{{ section.title }}</span> v-for="(section, idx) in sections"
:key="idx"
class="ai-popup__section"
>
<span v-if="section.title" class="ai-brief__section-tag">{{
section.title
}}</span>
<p class="ai-popup__section-text">{{ section.text }}</p> <p class="ai-popup__section-text">{{ section.text }}</p>
</div> </div>
</div> </div>
@@ -267,7 +392,7 @@ onMounted(() => fetchBrief());
place-items: center; place-items: center;
width: 26px; width: 26px;
height: 26px; height: 26px;
background: hsl(var(--primary) / 0.1); background: hsl(var(--primary) / 10%);
border-radius: 8px; border-radius: 8px;
} }
@@ -297,12 +422,12 @@ onMounted(() => fetchBrief());
height: 26px; height: 26px;
padding: 0 10px; padding: 0 10px;
margin-left: auto; margin-left: auto;
font-size: 12px;
color: hsl(var(--muted-foreground));
cursor: pointer;
background: transparent;
border: 1px solid hsl(var(--border)); border: 1px solid hsl(var(--border));
border-radius: 999px; border-radius: 999px;
background: transparent;
color: hsl(var(--muted-foreground));
font-size: 12px;
cursor: pointer;
transition: transition:
color 0.15s ease, color 0.15s ease,
border-color 0.15s ease, border-color 0.15s ease,
@@ -311,8 +436,8 @@ onMounted(() => fetchBrief());
.ai-brief__refresh:hover:not(:disabled) { .ai-brief__refresh:hover:not(:disabled) {
color: hsl(var(--primary)); color: hsl(var(--primary));
border-color: hsl(var(--primary) / 0.4); background: hsl(var(--primary) / 6%);
background: hsl(var(--primary) / 0.06); border-color: hsl(var(--primary) / 40%);
} }
.ai-brief__refresh:disabled { .ai-brief__refresh:disabled {
@@ -340,7 +465,7 @@ onMounted(() => fetchBrief());
.ai-brief__loading-dot { .ai-brief__loading-dot {
width: 6px; width: 6px;
height: 6px; height: 6px;
background: hsl(var(--primary) / 0.7); background: hsl(var(--primary) / 70%);
border-radius: 50%; border-radius: 50%;
animation: ai-bounce 1.2s ease-in-out infinite; animation: ai-bounce 1.2s ease-in-out infinite;
} }
@@ -370,33 +495,33 @@ onMounted(() => fetchBrief());
} }
.ai-brief__error :deep(svg) { .ai-brief__error :deep(svg) {
flex-shrink: 0;
width: 16px; width: 16px;
height: 16px; height: 16px;
flex-shrink: 0;
} }
.ai-brief__retry { .ai-brief__retry {
flex-shrink: 0;
height: 26px; height: 26px;
padding: 0 12px; padding: 0 12px;
border: 1px solid hsl(var(--primary) / 0.35);
border-radius: 999px;
background: hsl(var(--primary) / 0.06);
color: hsl(var(--primary));
font-size: 12px; font-size: 12px;
color: hsl(var(--primary));
cursor: pointer; cursor: pointer;
flex-shrink: 0; background: hsl(var(--primary) / 6%);
border: 1px solid hsl(var(--primary) / 35%);
border-radius: 999px;
transition: background 0.15s ease; transition: background 0.15s ease;
} }
.ai-brief__retry:hover { .ai-brief__retry:hover {
background: hsl(var(--primary) / 0.12); background: hsl(var(--primary) / 12%);
} }
/* ===== 快照统计 pill ===== */ /* ===== 快照统计 pill ===== */
.ai-brief__chips { .ai-brief__chips {
display: flex; display: flex;
gap: 8px;
flex-wrap: wrap; flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px; margin-bottom: 12px;
} }
@@ -405,14 +530,14 @@ onMounted(() => fetchBrief());
gap: 6px; gap: 6px;
align-items: center; align-items: center;
padding: 5px 10px; padding: 5px 10px;
border: 1px solid hsl(var(--border) / 0.7);
border-radius: 999px;
font-size: 12px; font-size: 12px;
border: 1px solid hsl(var(--border) / 70%);
border-radius: 999px;
} }
.ai-chip--hl { .ai-chip--hl {
border-color: hsl(var(--warning) / 0.45); background: hsl(var(--warning) / 10%);
background: hsl(var(--warning) / 0.1); border-color: hsl(var(--warning) / 45%);
} }
.ai-chip__icon { .ai-chip__icon {
@@ -442,9 +567,9 @@ onMounted(() => fetchBrief());
max-height: 180px; max-height: 180px;
padding: 12px 14px; padding: 12px 14px;
overflow-y: auto; overflow-y: auto;
background: hsl(var(--muted) / 0.25);
border-radius: 10px;
cursor: pointer; cursor: pointer;
background: hsl(var(--muted) / 25%);
border-radius: 10px;
} }
.ai-brief__section { .ai-brief__section {
@@ -456,18 +581,18 @@ onMounted(() => fetchBrief());
.ai-brief__section-tag { .ai-brief__section-tag {
align-self: flex-start; align-self: flex-start;
padding: 1px 8px; padding: 1px 8px;
background: hsl(var(--primary) / 0.1);
border-radius: 999px;
font-size: 11px; font-size: 11px;
font-weight: 600; font-weight: 600;
color: hsl(var(--primary)); color: hsl(var(--primary));
background: hsl(var(--primary) / 10%);
border-radius: 999px;
} }
.ai-brief__section-text { .ai-brief__section-text {
margin: 0; margin: 0;
font-size: 13px; font-size: 13px;
line-height: 1.7; line-height: 1.7;
color: hsl(var(--foreground) / 0.9); color: hsl(var(--foreground) / 90%);
white-space: pre-wrap; white-space: pre-wrap;
} }
@@ -475,13 +600,13 @@ onMounted(() => fetchBrief());
display: inline-flex; display: inline-flex;
gap: 2px; gap: 2px;
align-items: center; align-items: center;
margin-top: 8px;
padding: 0; padding: 0;
border: none; margin-top: 8px;
background: transparent;
font-size: 12px; font-size: 12px;
color: hsl(var(--primary)); color: hsl(var(--primary));
cursor: pointer; cursor: pointer;
background: transparent;
border: none;
} }
.ai-brief__more :deep(svg) { .ai-brief__more :deep(svg) {
@@ -528,16 +653,16 @@ onMounted(() => fetchBrief());
margin: 0; margin: 0;
font-size: 14px; font-size: 14px;
line-height: 1.8; line-height: 1.8;
color: hsl(var(--foreground) / 0.92); color: hsl(var(--foreground) / 92%);
white-space: pre-wrap; white-space: pre-wrap;
} }
.ai-popup__meta { .ai-popup__meta {
margin-top: 14px;
padding-top: 10px; padding-top: 10px;
border-top: 1px solid hsl(var(--border) / 0.5); margin-top: 14px;
font-size: 12px; font-size: 12px;
color: hsl(var(--muted-foreground)); color: hsl(var(--muted-foreground));
border-top: 1px solid hsl(var(--border) / 50%);
} }
@keyframes ai-spin { @keyframes ai-spin {
@@ -553,6 +678,7 @@ onMounted(() => fetchBrief());
opacity: 0.35; opacity: 0.35;
transform: translateY(0); transform: translateY(0);
} }
40% { 40% {
opacity: 1; opacity: 1;
transform: translateY(-3px); transform: translateY(-3px);

View File

@@ -51,7 +51,7 @@ function goNoticeCenter() {
} }
/** /**
* 进入个人中心(静态路由 /profile,与顶栏头像下拉同一入口) * 进入个人中心(路由由后端 xk_menu 隐藏菜单下发,与顶栏头像下拉同一入口)
* 头像/姓名区域与右侧按钮都走这里 * 头像/姓名区域与右侧按钮都走这里
*/ */
function goProfile() { function goProfile() {
@@ -142,21 +142,21 @@ onMounted(fetchUnreadCount);
background: background:
linear-gradient( linear-gradient(
120deg, 120deg,
hsl(var(--primary) / 0.1), hsl(var(--primary) / 10%),
hsl(var(--primary) / 0.03) 45%, hsl(var(--primary) / 3%) 45%,
transparent 70% transparent 70%
), ),
hsl(var(--card)); hsl(var(--card));
border: 1px solid hsl(var(--border) / 0.7); border: 1px solid hsl(var(--border) / 70%);
border-radius: 12px; border-radius: 12px;
} }
/* 装饰光斑:低透明度 + 大模糊,暗色主题下自然减弱 */ /* 装饰光斑:低透明度 + 大模糊,暗色主题下自然减弱 */
.ws-header__orb { .ws-header__orb {
position: absolute; position: absolute;
pointer-events: none;
border-radius: 50%; border-radius: 50%;
filter: blur(64px); filter: blur(64px);
pointer-events: none;
} }
.ws-header__orb--1 { .ws-header__orb--1 {
@@ -164,7 +164,7 @@ onMounted(fetchUnreadCount);
right: 10%; right: 10%;
width: 180px; width: 180px;
height: 180px; height: 180px;
background: hsl(var(--primary) / 0.18); background: hsl(var(--primary) / 18%);
} }
.ws-header__orb--2 { .ws-header__orb--2 {
@@ -172,7 +172,7 @@ onMounted(fetchUnreadCount);
bottom: -80px; bottom: -80px;
width: 160px; width: 160px;
height: 160px; height: 160px;
background: hsl(var(--primary) / 0.1); background: hsl(var(--primary) / 10%);
} }
.ws-header__left { .ws-header__left {
@@ -193,7 +193,7 @@ onMounted(fetchUnreadCount);
} }
.ws-header__left--clickable:hover { .ws-header__left--clickable:hover {
background: hsl(var(--primary) / 0.08); background: hsl(var(--primary) / 8%);
} }
.ws-header__left--clickable:hover .ws-header__title { .ws-header__left--clickable:hover .ws-header__title {
@@ -203,13 +203,13 @@ onMounted(fetchUnreadCount);
/* 头像光环primary 低透明双环 */ /* 头像光环primary 低透明双环 */
.ws-header__avatar-ring { .ws-header__avatar-ring {
display: flex; display: flex;
flex-shrink: 0;
padding: 3px; padding: 3px;
background: hsl(var(--card)); background: hsl(var(--card));
border-radius: 50%; border-radius: 50%;
box-shadow: box-shadow:
0 0 0 2px hsl(var(--primary) / 0.25), 0 0 0 2px hsl(var(--primary) / 25%),
0 4px 12px hsl(var(--primary) / 0.12); 0 4px 12px hsl(var(--primary) / 12%);
flex-shrink: 0;
} }
.ws-header__text { .ws-header__text {
@@ -218,24 +218,24 @@ onMounted(fetchUnreadCount);
.ws-header__title { .ws-header__title {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis;
font-size: 16px; font-size: 16px;
font-weight: 650; font-weight: 650;
color: hsl(var(--foreground)); color: hsl(var(--foreground));
letter-spacing: -0.01em; letter-spacing: -0.01em;
text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.ws-header__role { .ws-header__role {
display: inline-block; display: inline-block;
margin-left: 8px;
padding: 1px 8px; padding: 1px 8px;
margin-left: 8px;
font-size: 11px; font-size: 11px;
font-weight: 500; font-weight: 500;
color: hsl(var(--primary));
vertical-align: 2px; vertical-align: 2px;
background: hsl(var(--primary) / 0.1); color: hsl(var(--primary));
border: 1px solid hsl(var(--primary) / 0.2); background: hsl(var(--primary) / 10%);
border: 1px solid hsl(var(--primary) / 20%);
border-radius: 999px; border-radius: 999px;
} }
@@ -260,8 +260,8 @@ onMounted(fetchUnreadCount);
width: 38px; width: 38px;
height: 38px; height: 38px;
cursor: pointer; cursor: pointer;
background: hsl(var(--muted) / 0.4); background: hsl(var(--muted) / 40%);
border: 1px solid hsl(var(--border) / 0.7); border: 1px solid hsl(var(--border) / 70%);
border-radius: 50%; border-radius: 50%;
transition: transition:
background-color 0.2s ease, background-color 0.2s ease,
@@ -270,15 +270,15 @@ onMounted(fetchUnreadCount);
} }
.ws-header__bell:hover { .ws-header__bell:hover {
background: hsl(var(--primary) / 0.1); background: hsl(var(--primary) / 10%);
border-color: hsl(var(--primary) / 0.3); border-color: hsl(var(--primary) / 30%);
transform: translateY(-1px); transform: translateY(-1px);
} }
.ws-header__bell-icon { .ws-header__bell-icon {
width: 17px; width: 17px;
height: 17px; height: 17px;
color: hsl(var(--foreground) / 0.75); color: hsl(var(--foreground) / 75%);
} }
.ws-header__bell:hover .ws-header__bell-icon { .ws-header__bell:hover .ws-header__bell-icon {

View File

@@ -8,12 +8,12 @@ import { computed, ref } from 'vue';
import { Page, useVbenModal } from '@vben/common-ui'; import { Page, useVbenModal } from '@vben/common-ui';
import { Button, Popover, Select, Spin, Switch, message } from 'ant-design-vue'; import { Button, message, Popover, Select, Spin, Switch } from 'ant-design-vue';
import { desktop, isElectron } from '#/util/desktop'; import { desktop, isElectron } from '#/util/desktop';
import { getPrescriptionInfoApi } from '#/views/doctor/doctor-reception/api';
import { formatDoctorOrderText } from '#/utils/prescriptionDoctorOrder';
import { formatStoreNameWithHu } from '#/utils/formatStoreNameWithHu'; import { formatStoreNameWithHu } from '#/utils/formatStoreNameWithHu';
import { formatDoctorOrderText } from '#/utils/prescriptionDoctorOrder';
import { getPrescriptionInfoApi } from '#/views/doctor/doctor-reception/api';
import { passApi } from '#/views/pharmacist/audit-prescription/api'; import { passApi } from '#/views/pharmacist/audit-prescription/api';
import RejectionReason from '#/views/pharmacist/audit-prescription/components/modal.vue'; import RejectionReason from '#/views/pharmacist/audit-prescription/components/modal.vue';
@@ -98,8 +98,8 @@ async function handlePass() {
const refresh = onAudited.value; const refresh = onAudited.value;
refresh?.(); refresh?.();
modalApi.close(); modalApi.close();
} catch (e) { } catch (error) {
console.error(e); console.error(error);
} finally { } finally {
passing.value = false; passing.value = false;
} }
@@ -163,14 +163,16 @@ function buildPrintHtml(fileName: string): null | string {
const printBox = document.querySelector('.print-box'); const printBox = document.querySelector('.print-box');
if (!printBox) return null; if (!printBox) return null;
const styleTags: string[] = []; const styleTags: string[] = [];
document.querySelectorAll('style, link[rel="stylesheet"]').forEach((style) => { document
// link 取绝对地址(.href 属性自动补全),隐藏窗口加载临时文件时才能命中样式 .querySelectorAll('style, link[rel="stylesheet"]')
styleTags.push( .forEach((style) => {
style.tagName === 'LINK' // link 取绝对地址(.href 属性自动补全),隐藏窗口加载临时文件时才能命中样式
? `<link rel="stylesheet" href="${(style as HTMLLinkElement).href}" />` styleTags.push(
: style.outerHTML, style.tagName === 'LINK'
); ? `<link rel="stylesheet" href="${(style as HTMLLinkElement).href}" />`
}); : style.outerHTML,
);
});
return `<!DOCTYPE html><html><head><meta charset="utf-8" /><title>${fileName || '文档打印'}</title>${styleTags.join('')}</head><body>${printBox.outerHTML}</body></html>`; return `<!DOCTYPE html><html><head><meta charset="utf-8" /><title>${fileName || '文档打印'}</title>${styleTags.join('')}</head><body>${printBox.outerHTML}</body></html>`;
} }
@@ -249,7 +251,7 @@ const getImageSource = (imageString) => {
return `data:image/jpeg;base64,${imageString}`; return `data:image/jpeg;base64,${imageString}`;
}; };
function parseRecipeContent(content: string | Record<string, unknown>) { function parseRecipeContent(content: Record<string, unknown> | string) {
if (!content) return {}; if (!content) return {};
if (typeof content === 'string') { if (typeof content === 'string') {
try { try {
@@ -299,7 +301,9 @@ function parseRecipeContent(content: string | Record<string, unknown>) {
allow-clear allow-clear
@change=" @change="
(value: any) => (value: any) =>
updatePrintSettings({ printerName: (value as string) || '' }) updatePrintSettings({
printerName: (value as string) || '',
})
" "
/> />
</div> </div>
@@ -362,7 +366,9 @@ function parseRecipeContent(content: string | Record<string, unknown>) {
<span class="drug-name">{{ <span class="drug-name">{{
drug?.name || drug?.drug_name drug?.name || drug?.drug_name
}}</span> }}</span>
<span class="drug-quantity mr-5">{{ drug?.number }} /{{ drug?.unit?.name || drug?.use_unit?.name || 'g' }}</span> <span class="drug-quantity mr-5">{{ drug?.number }} /{{
drug?.unit?.name || drug?.use_unit?.name || 'g'
}}</span>
</div> </div>
<div> <div>
方法: 方法:
@@ -393,20 +399,14 @@ function parseRecipeContent(content: string | Record<string, unknown>) {
> >
<template <template
v-for="drug in [parseRecipeContent(recipe.content)]" v-for="drug in [parseRecipeContent(recipe.content)]"
:key="`west-${index}`" :key="drug?.id || drug?.name || `west-${index}`"
> >
<div class="medicine-item"> <div class="medicine-item">
<span class="drug-name" <span class="drug-name">{{ drug?.name || drug?.drug_name
>{{ drug?.name || drug?.drug_name }}<span v-if="drug?.specification" class="drug-spec">{{
}}<span drug.specification
v-if="drug?.specification" }}</span></span>
class="drug-spec" <span class="drug-quantity">{{ drug?.number }}{{ drug?.unit?.name }}</span>
>{{ drug.specification }}</span
></span
>
<span class="drug-quantity"
>{{ drug?.number }}{{ drug?.unit?.name }}</span
>
<div v-if="drug?.useWay" class="usage-info"> <div v-if="drug?.useWay" class="usage-info">
{{ drug.useWay }} {{ drug.useWay }}
</div> </div>
@@ -432,23 +432,23 @@ function parseRecipeContent(content: string | Record<string, unknown>) {
getImageSource(item.doctor_info.identity_info.sign_image) getImageSource(item.doctor_info.identity_info.sign_image)
" "
alt="签名" alt="签名"
style="width: 120px; height: 50px; display: inline-block" style="display: inline-block; width: 120px; height: 50px"
/> />
<!-- 二次签名:相冲继续开方时展示第二张医生签名 --> <!-- 二次签名:相冲继续开方时展示第二张医生签名 -->
<img <img
v-if=" v-if="
item.doctor_second_sign == 1 && item.doctor_second_sign === 1 &&
item.doctor_info?.identity_info?.sign_image item.doctor_info?.identity_info?.sign_image
" "
:src=" :src="
getImageSource(item.doctor_info.identity_info.sign_image) getImageSource(item.doctor_info.identity_info.sign_image)
" "
alt="二次签名" alt="二次签名"
style="width: 120px; height: 50px; display: inline-block" style="display: inline-block; width: 120px; height: 50px"
/> />
<span v-else-if="!item.doctor_info?.identity_info?.sign_image">{{ <span
item.content?.doctor?.name v-else-if="!item.doctor_info?.identity_info?.sign_image"
}}</span> >{{ item.content?.doctor?.name }}</span>
</div> </div>
<div class="signature"> <div class="signature">
<label>审核药师:</label> <label>审核药师:</label>
@@ -458,21 +458,18 @@ function parseRecipeContent(content: string | Record<string, unknown>) {
getImageSource(item.pharmacist_info.identity.sign_image) getImageSource(item.pharmacist_info.identity.sign_image)
" "
alt="签名" alt="签名"
style="width: 120px; height: 50px; display: inline-block" style="display: inline-block; width: 120px; height: 50px"
/> />
<span v-else>{{ item.pharmacist_info?.name }}</span> <span v-else>{{ item.pharmacist_info?.name }}</span>
</div> </div>
<div class="signature"> <div class="signature">
<label>调配人:</label> <label>调配人:</label>
<img <img
v-if="item.doctor_info?.identity_info?.sign_image" v-if="item.dispenser_sign_image"
:src=" :src="getImageSource(item.dispenser_sign_image)"
getImageSource(item.doctor_info.identity_info.sign_image)
"
alt="签名" alt="签名"
style="width: 120px; height: 50px; display: inline-block" style="display: inline-block; width: 120px; height: 50px"
/> />
<span v-else>{{ item.content?.doctor?.name }}</span>
</div> </div>
<div class="signature"> <div class="signature">
<label>核对人:</label> <label>核对人:</label>
@@ -482,21 +479,18 @@ function parseRecipeContent(content: string | Record<string, unknown>) {
getImageSource(item.pharmacist_info.identity.sign_image) getImageSource(item.pharmacist_info.identity.sign_image)
" "
alt="签名" alt="签名"
style="width: 120px; height: 50px; display: inline-block" style="display: inline-block; width: 120px; height: 50px"
/> />
<span v-else>{{ item.pharmacist_info?.name }}</span> <span v-else>{{ item.pharmacist_info?.name }}</span>
</div> </div>
<div class="signature"> <div class="signature">
<label>发药人:</label> <label>发药人:</label>
<img <img
v-if="item.doctor_info?.identity_info?.sign_image" v-if="item.sender_sign_image"
:src=" :src="getImageSource(item.sender_sign_image)"
getImageSource(item.doctor_info.identity_info.sign_image)
"
alt="签名" alt="签名"
style="width: 120px; height: 50px; display: inline-block" style="display: inline-block; width: 120px; height: 50px"
/> />
<span v-else>{{ item.content?.doctor?.name }}</span>
</div> </div>
</div> </div>
<div class="price-info"></div> <div class="price-info"></div>
@@ -504,7 +498,7 @@ function parseRecipeContent(content: string | Record<string, unknown>) {
<div <div
v-if="item.status === 2" v-if="item.status === 2"
class="validity" class="validity"
style="color: red; font-weight: bold" style="font-weight: bold; color: red"
> >
该处方未通过审核 该处方未通过审核
</div> </div>
@@ -534,101 +528,123 @@ function parseRecipeContent(content: string | Record<string, unknown>) {
.dark .signature img { .dark .signature img {
filter: invert(100%); filter: invert(100%);
} }
.prescription-container { .prescription-container {
padding: 20px; padding: 20px;
font-family: 'SimSun', serif; font-family: SimSun, serif;
} }
.prescription-header { .prescription-header {
border-bottom: 2px solid #000;
padding-bottom: 15px; padding-bottom: 15px;
margin-bottom: 20px; margin-bottom: 20px;
border-bottom: 2px solid #000;
} }
.header-top { .header-top {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
margin-bottom: 15px; margin-bottom: 15px;
} }
.prescription-type { .prescription-type {
border: 1px solid #666;
padding: 2px 8px; padding: 2px 8px;
border: 1px solid #666;
border-radius: 4px; border-radius: 4px;
} }
.clinic-name { .clinic-name {
text-align: center;
font-size: 24px;
margin: 15px 0; margin: 15px 0;
font-size: 24px;
text-align: center;
} }
.prescription-date { .prescription-date {
text-align: right; text-align: right;
} }
.patient-info { .patient-info {
margin-bottom: 20px; margin-bottom: 20px;
} }
.info-row { .info-row {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
margin: 8px 0; margin: 8px 0;
} }
.medicine-list { .medicine-list {
min-height: 200px; min-height: 200px;
padding: 15px 0;
border-top: 1px dashed #666; border-top: 1px dashed #666;
border-bottom: 1px dashed #666; border-bottom: 1px dashed #666;
padding: 15px 0;
} }
.rp-title { .rp-title {
font-weight: bold;
font-size: 18px;
margin-bottom: 10px; margin-bottom: 10px;
font-size: 18px;
font-weight: bold;
} }
.recipe-item { .recipe-item {
margin-bottom: 15px; margin-bottom: 15px;
} }
.medicine-item { .medicine-item {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
margin: 5px 0; margin: 5px 0;
} }
.drug-name { .drug-name {
font-weight: bold; font-weight: bold;
} }
.drug-spec { .drug-spec {
margin-left: 6px; margin-left: 6px;
font-weight: normal; font-weight: normal;
color: #666; color: #666;
} }
.usage-info { .usage-info {
width: 100%; width: 100%;
font-size: 12px; font-size: 12px;
color: #666; color: #666;
} }
.preparation-info { .preparation-info {
margin-top: 8px; margin-top: 8px;
color: #333; color: #333;
} }
.footer-section { .footer-section {
margin-top: 20px; margin-top: 20px;
} }
.medical-advice { .medical-advice {
margin-bottom: 15px; margin-bottom: 15px;
} }
.signature-area { .signature-area {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 12px 24px; gap: 12px 24px;
margin: 15px 0; margin: 15px 0;
} }
.signature { .signature {
display: flex; display: flex;
align-items: center;
gap: 8px; gap: 8px;
align-items: center;
} }
.price-info { .price-info {
text-align: right; margin-top: 8px;
font-weight: bold; font-weight: bold;
margin-top: 8px;
}
.validity {
text-align: right; text-align: right;
}
.validity {
margin-top: 8px; margin-top: 8px;
text-align: right;
} }
</style> </style>

View File

@@ -17,20 +17,20 @@ defineOptions({
}); });
const props = defineProps<{ const props = defineProps<{
/** 银行卡信息 */ balance: number;
myCard: Record<string, any> | null; /** 累计代付手续费(平台 charge */
/** 是否门店账号(隐藏未确认收货冻结) */ fee: number;
isStoreUser: boolean; frozenAmount: number;
/** 是否平台账号(展示累计代付手续费) */ /** 是否平台账号(展示累计代付手续费) */
isPlatformUser: boolean; isPlatformUser: boolean;
balance: number; /** 是否门店账号(隐藏未确认收货冻结) */
isStoreUser: boolean;
/** 银行卡信息 */
myCard: null | Record<string, any>;
pendingEarnings: number; pendingEarnings: number;
totalEarnings: number; totalEarnings: number;
withdrawnAmount: number; withdrawnAmount: number;
withdrawnFrozenAmount: number; withdrawnFrozenAmount: number;
frozenAmount: number;
/** 累计代付手续费(平台 charge */
fee: number;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
@@ -84,56 +84,59 @@ const maskedCard = computed(() => {
}); });
/** /**
* 组装统计岛:核心字段优先,平台额外展示累计代付手续费 * 组装统计岛:顺序与改版前 AnalysisOverview 一致
* 每项配一个 SVG iconify 图标(规范:不用 emoji放在卡片右上角 * 累计收益 → 可提现余额 → 未确认收货冻结(非门店)→ 待结算收益 → 审核中金额 → 已提现金额
* 平台额外在末尾展示累计代付手续费
*/ */
const statItems = computed(() => { const statItems = computed(() => {
const items: { const items: {
emphasize?: boolean;
icon: string;
key: string; key: string;
label: string; label: string;
value: number; value: number;
icon: string;
emphasize?: boolean;
}[] = [ }[] = [
{
key: 'total',
label: '累计收益',
value: Number(props.totalEarnings || 0),
icon: 'lucide:trending-up',
},
{ {
key: 'balance', key: 'balance',
label: '可提现余额', label: '可提现余额',
value: Number(props.balance || 0), value: Number(props.balance || 0),
icon: 'lucide:scale', icon: 'lucide:scale',
}, },
];
if (!props.isStoreUser) {
items.push({
key: 'frozen',
label: '未确认收货冻结',
value: Number(props.frozenAmount || 0),
icon: 'lucide:snowflake',
});
}
items.push(
{
key: 'pending',
label: '待结算收益',
value: Number(props.pendingEarnings || 0),
icon: 'lucide:hourglass',
},
{ {
key: 'reviewing', key: 'reviewing',
label: '审核中金额', label: '审核中金额',
value: Number(props.withdrawnFrozenAmount || 0), value: Number(props.withdrawnFrozenAmount || 0),
icon: 'lucide:clock', icon: 'lucide:clock',
}, },
{
key: 'pending',
label: '待结算收益',
value: Number(props.pendingEarnings || 0),
icon: 'lucide:hourglass',
},
{ {
key: 'withdrawn', key: 'withdrawn',
label: '已提现金额', label: '已提现金额',
value: Number(props.withdrawnAmount || 0), value: Number(props.withdrawnAmount || 0),
icon: 'lucide:check-circle', icon: 'lucide:check-circle',
}, },
{ );
key: 'total',
label: '累计收益',
value: Number(props.totalEarnings || 0),
icon: 'lucide:trending-up',
},
];
if (!props.isStoreUser) {
items.splice(3, 0, {
key: 'frozen',
label: '未确认收货冻结',
value: Number(props.frozenAmount || 0),
icon: 'lucide:snowflake',
});
}
if (props.isPlatformUser) { if (props.isPlatformUser) {
items.push({ items.push({
key: 'fee', key: 'fee',
@@ -157,11 +160,13 @@ function formatMoney(val: number) {
<div class="account-info-card"> <div class="account-info-card">
<div class="card-header"> <div class="card-header">
<div class="header-title"> <div class="header-title">
<span class="title-dot" /> <span class="title-dot"></span>
{{ subjectName }} · 账户信息 {{ subjectName }} · 账户信息
</div> </div>
<div class="header-actions"> <div class="header-actions">
<Button type="primary" size="small" @click="emit('apply')">申请提现</Button> <Button type="primary" size="small" @click="emit('apply')">
申请提现
</Button>
<Button size="small" @click="emit('editCard')">编辑账户</Button> <Button size="small" @click="emit('editCard')">编辑账户</Button>
<Button size="small" type="text" @click="emit('refresh')"> <Button size="small" type="text" @click="emit('refresh')">
<template #icon> <template #icon>
@@ -189,26 +194,30 @@ function formatMoney(val: number) {
@mousemove="onCardMove" @mousemove="onCardMove"
@mouseleave="onCardLeave" @mouseleave="onCardLeave"
> >
<div class="bank-bg" /> <div class="bank-bg"></div>
<div class="bank-brush" /> <div class="bank-brush"></div>
<div class="bank-decorator decorator-a" /> <div class="bank-decorator decorator-a"></div>
<div class="bank-decorator decorator-b" /> <div class="bank-decorator decorator-b"></div>
<div <div
v-if="glowX >= 0" v-if="glowX >= 0"
class="bank-glow" class="bank-glow"
:style="{ left: `${glowX}px`, top: `${glowY}px` }" :style="{ left: `${glowX}px`, top: `${glowY}px` }"
/> ></div>
<div v-if="!prefersReduced" class="bank-sheen" /> <div v-if="!prefersReduced" class="bank-sheen"></div>
<div class="bank-content"> <div class="bank-content">
<header class="bank-row bank-row--top"> <header class="bank-row bank-row--top">
<div class="bank-brand"> <div class="bank-brand">
<span class="bank-chip" /> <span class="bank-chip"></span>
<span class="bank-name">{{ myCard?.bank_name || '未绑定银行卡' }}</span> <span class="bank-name">{{
myCard?.bank_name || '未绑定银行卡'
}}</span>
</div> </div>
<div class="bank-logo"> <div class="bank-logo">
<VbenIcon icon="lucide:credit-card" /> <VbenIcon icon="lucide:credit-card" />
<span class="bank-tag">{{ myCard?.bank_account_type_txt || '未配置' }}</span> <span class="bank-tag">{{
myCard?.bank_account_type_txt || '未配置'
}}</span>
</div> </div>
</header> </header>
@@ -217,7 +226,9 @@ function formatMoney(val: number) {
<div class="bank-row bank-row--bottom"> <div class="bank-row bank-row--bottom">
<div class="bank-meta"> <div class="bank-meta">
<VbenIcon class="bank-meta-icon" icon="lucide:user" /> <VbenIcon class="bank-meta-icon" icon="lucide:user" />
<span class="bank-holder">{{ myCard?.bank_user_name || '-' }}</span> <span class="bank-holder">{{
myCard?.bank_user_name || '-'
}}</span>
</div> </div>
<span class="bank-currency">CNY</span> <span class="bank-currency">CNY</span>
</div> </div>
@@ -240,7 +251,7 @@ function formatMoney(val: number) {
:class="{ 'stat-island--fee': item.emphasize }" :class="{ 'stat-island--fee': item.emphasize }"
:aria-label="item.label" :aria-label="item.label"
> >
<div class="stat-island-glow" /> <div class="stat-island-glow"></div>
<!-- 右上角图标规范SVG 图标固定尺寸 --> <!-- 右上角图标规范SVG 图标固定尺寸 -->
<VbenIcon class="stat-icon" :icon="item.icon" /> <VbenIcon class="stat-icon" :icon="item.icon" />
<div class="stat-label">{{ item.label }}</div> <div class="stat-label">{{ item.label }}</div>
@@ -255,27 +266,27 @@ function formatMoney(val: number) {
<style scoped lang="scss"> <style scoped lang="scss">
.account-info-card { .account-info-card {
margin-bottom: 12px;
padding: 14px 16px; padding: 14px 16px;
margin-bottom: 12px;
background: hsl(var(--card, var(--background)));
border: 1px solid hsl(var(--border)); border: 1px solid hsl(var(--border));
border-radius: 14px; border-radius: 14px;
background: hsl(var(--card, var(--background)));
box-shadow: 0 1px 2px rgb(0 0 0 / 4%); box-shadow: 0 1px 2px rgb(0 0 0 / 4%);
} }
.card-header { .card-header {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 8px;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 8px;
margin-bottom: 12px; margin-bottom: 12px;
} }
.header-title { .header-title {
display: inline-flex; display: inline-flex;
align-items: center;
gap: 8px; gap: 8px;
align-items: center;
font-size: 15px; font-size: 15px;
font-weight: 600; font-weight: 600;
color: hsl(var(--foreground)); color: hsl(var(--foreground));
@@ -284,37 +295,34 @@ function formatMoney(val: number) {
.title-dot { .title-dot {
width: 4px; width: 4px;
height: 16px; height: 16px;
border-radius: 2px;
/* 金色,呼应银行卡金质感 */ /* 金色,呼应银行卡金质感 */
background: linear-gradient(180deg, #d4a73a 0%, #b88a2a 100%); background: linear-gradient(180deg, #d4a73a 0%, #b88a2a 100%);
border-radius: 2px;
} }
.header-actions { .header-actions {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center;
gap: 6px; gap: 6px;
align-items: center;
} }
/* ===== 左右布局:左侧银行卡收紧,右侧统计岛一行四个 ===== */ /* ===== 左右布局:左侧银行卡收紧,右侧统计岛一行四个 ===== */
.card-layout { .card-layout {
display: flex; display: flex;
align-items: stretch;
gap: 12px; gap: 12px;
align-items: stretch;
} }
/* 银行卡:左侧适中宽度,纵向撑满 */ /* 银行卡宽度在左右布局里单独收紧,视觉样式见下方完整 .bank-card */
.bank-card {
flex: 0 0 360px;
min-width: 0;
}
/* 统计岛列:右侧一行四个,自动折行 */ /* 统计岛列:右侧一行四个,自动折行 */
.stats-col { .stats-col {
display: grid; display: grid;
flex: 1 1 auto;
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px; gap: 10px;
flex: 1 1 auto;
min-width: 0; min-width: 0;
} }
@@ -342,26 +350,30 @@ function formatMoney(val: number) {
/* ===== 银行卡:炭灰金质感(模拟真实银行卡) ===== */ /* ===== 银行卡:炭灰金质感(模拟真实银行卡) ===== */
.bank-card { .bank-card {
position: relative; position: relative;
overflow: hidden;
display: flex; display: flex;
flex: 0 0 360px;
flex-direction: column; flex-direction: column;
justify-content: space-between; justify-content: space-between;
min-width: 0;
padding: 16px 18px; padding: 16px 18px;
border-radius: 14px; overflow: hidden;
color: #f5f5f7; color: #f5f5f7;
isolation: isolate;
cursor: default; cursor: default;
transition: transform 0.25s ease, box-shadow 0.25s ease; border-radius: 14px;
box-shadow: box-shadow:
0 8px 20px rgb(0 0 0 / 30%), 0 8px 20px rgb(0 0 0 / 30%),
0 2px 6px rgb(0 0 0 / 20%); 0 2px 6px rgb(0 0 0 / 20%);
isolation: isolate;
transition:
transform 0.25s ease,
box-shadow 0.25s ease;
} }
.bank-card:hover { .bank-card:hover {
transform: translateY(-2px);
box-shadow: box-shadow:
0 14px 28px rgb(0 0 0 / 40%), 0 14px 28px rgb(0 0 0 / 40%),
0 4px 10px rgb(0 0 0 / 25%); 0 4px 10px rgb(0 0 0 / 25%);
transform: translateY(-2px);
} }
/* 底层:深炭灰渐变,模拟高端银行卡底色 */ /* 底层:深炭灰渐变,模拟高端银行卡底色 */
@@ -382,22 +394,21 @@ function formatMoney(val: number) {
position: absolute; position: absolute;
inset: 0; inset: 0;
z-index: 0; z-index: 0;
background:
repeating-linear-gradient(
90deg,
rgb(255 255 255 / 0.012) 0px,
rgb(255 255 255 / 0.012) 1px,
transparent 1px,
transparent 3px
);
pointer-events: none; pointer-events: none;
background: repeating-linear-gradient(
90deg,
rgb(255 255 255 / 1.2%) 0,
rgb(255 255 255 / 1.2%) 1px,
transparent 1px,
transparent 3px
);
} }
/* 顶部金色高光 + 底部暗影 */ /* 顶部金色高光 + 底部暗影 */
.bank-bg::before { .bank-bg::before {
content: '';
position: absolute; position: absolute;
inset: 0; inset: 0;
content: '';
background: background:
radial-gradient(120% 80% at 0% 0%, rgb(212 167 58 / 14%), transparent 55%), radial-gradient(120% 80% at 0% 0%, rgb(212 167 58 / 14%), transparent 55%),
radial-gradient(100% 80% at 100% 100%, rgb(0 0 0 / 35%), transparent 60%); radial-gradient(100% 80% at 100% 100%, rgb(0 0 0 / 35%), transparent 60%);
@@ -406,9 +417,9 @@ function formatMoney(val: number) {
/* 装饰几何 */ /* 装饰几何 */
.bank-decorator { .bank-decorator {
position: absolute; position: absolute;
border-radius: 50%;
pointer-events: none;
z-index: 0; z-index: 0;
pointer-events: none;
border-radius: 50%;
} }
.decorator-a { .decorator-a {
@@ -420,8 +431,8 @@ function formatMoney(val: number) {
} }
.decorator-b { .decorator-b {
bottom: -55px;
right: 50px; right: 50px;
bottom: -55px;
width: 110px; width: 110px;
height: 110px; height: 110px;
border: 16px solid rgb(255 255 255 / 5%); border: 16px solid rgb(255 255 255 / 5%);
@@ -430,11 +441,10 @@ function formatMoney(val: number) {
/* 鼠标光泽跟随(毛玻璃质感的核心) */ /* 鼠标光泽跟随(毛玻璃质感的核心) */
.bank-glow { .bank-glow {
position: absolute; position: absolute;
z-index: 1;
width: 220px; width: 220px;
height: 220px; height: 220px;
pointer-events: none; pointer-events: none;
z-index: 1;
transform: translate(-50%, -50%);
background: radial-gradient( background: radial-gradient(
circle, circle,
rgb(255 255 255 / 18%) 0%, rgb(255 255 255 / 18%) 0%,
@@ -443,6 +453,7 @@ function formatMoney(val: number) {
); );
mix-blend-mode: screen; mix-blend-mode: screen;
filter: blur(2px); filter: blur(2px);
transform: translate(-50%, -50%);
} }
/* 周期扫光(金色,更克制) */ /* 周期扫光(金色,更克制) */
@@ -469,6 +480,7 @@ function formatMoney(val: number) {
60% { 60% {
left: -60%; left: -60%;
} }
100% { 100% {
left: 130%; left: 130%;
} }
@@ -484,54 +496,54 @@ function formatMoney(val: number) {
.bank-row { .bank-row {
display: flex; display: flex;
gap: 8px;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 8px;
} }
.bank-brand { .bank-brand {
display: inline-flex; display: inline-flex;
align-items: center;
gap: 8px; gap: 8px;
align-items: center;
min-width: 0; min-width: 0;
} }
/* 金色芯片,模拟实体卡芯片 */ /* 金色芯片,模拟实体卡芯片 */
.bank-chip { .bank-chip {
position: relative;
flex-shrink: 0; flex-shrink: 0;
width: 24px; width: 24px;
height: 18px; height: 18px;
border-radius: 3px;
background: linear-gradient(135deg, #f0d27a 0%, #c89a35 50%, #a37c28 100%); background: linear-gradient(135deg, #f0d27a 0%, #c89a35 50%, #a37c28 100%);
border-radius: 3px;
box-shadow: box-shadow:
inset 0 0 0 1px rgb(0 0 0 / 25%), inset 0 0 0 1px rgb(0 0 0 / 25%),
inset 0 -2px 4px rgb(0 0 0 / 20%); inset 0 -2px 4px rgb(0 0 0 / 20%);
position: relative;
} }
/* 芯片纹理 */ /* 芯片纹理 */
.bank-chip::after { .bank-chip::after {
content: '';
position: absolute; position: absolute;
inset: 3px 4px; inset: 3px 4px;
border-left: 1px solid rgb(0 0 0 / 30%); content: '';
border-right: 1px solid rgb(0 0 0 / 30%); border-right: 1px solid rgb(0 0 0 / 30%);
border-left: 1px solid rgb(0 0 0 / 30%);
} }
.bank-name { .bank-name {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis;
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 600;
letter-spacing: 0.4px; letter-spacing: 0.4px;
text-overflow: ellipsis;
text-shadow: 0 1px 2px rgb(0 0 0 / 40%);
white-space: nowrap; white-space: nowrap;
text-shadow: 0 1px 2px rgb(0 0 0 / 40%);
} }
.bank-logo { .bank-logo {
display: inline-flex; display: inline-flex;
align-items: center;
gap: 6px; gap: 6px;
align-items: center;
color: #d4a73a; color: #d4a73a;
} }
@@ -567,8 +579,8 @@ function formatMoney(val: number) {
.bank-meta { .bank-meta {
display: inline-flex; display: inline-flex;
align-items: center;
gap: 5px; gap: 5px;
align-items: center;
min-width: 0; min-width: 0;
} }
@@ -583,16 +595,16 @@ function formatMoney(val: number) {
.bank-no { .bank-no {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap;
letter-spacing: 0.5px; letter-spacing: 0.5px;
white-space: nowrap;
} }
.bank-currency { .bank-currency {
flex-shrink: 0; flex-shrink: 0;
padding: 1px 6px; padding: 1px 6px;
font-size: 11px; font-size: 11px;
letter-spacing: 1px;
color: #d4a73a; color: #d4a73a;
letter-spacing: 1px;
border: 1px solid rgb(212 167 58 / 40%); border: 1px solid rgb(212 167 58 / 40%);
border-radius: 4px; border-radius: 4px;
} }
@@ -600,17 +612,17 @@ function formatMoney(val: number) {
/* ===== 统计岛:玻璃质感 + 右上角图标 + 淡荧光 ===== */ /* ===== 统计岛:玻璃质感 + 右上角图标 + 淡荧光 ===== */
.stat-island { .stat-island {
position: relative; position: relative;
overflow: hidden;
padding: 14px; padding: 14px;
border: 1px solid hsl(var(--border)); overflow: hidden;
border-radius: 12px; cursor: default;
background: linear-gradient( background: linear-gradient(
135deg, 135deg,
hsl(var(--primary) / 0.04), hsl(var(--primary) / 4%),
hsl(var(--card, var(--background))) hsl(var(--card, var(--background)))
); );
border: 1px solid hsl(var(--border));
border-radius: 12px;
backdrop-filter: blur(8px); backdrop-filter: blur(8px);
cursor: default;
transition: transition:
transform 0.2s ease, transform 0.2s ease,
box-shadow 0.2s ease, box-shadow 0.2s ease,
@@ -618,11 +630,11 @@ function formatMoney(val: number) {
} }
.stat-island:hover { .stat-island:hover {
transform: translateY(-3px); border-color: hsl(var(--primary) / 40%);
border-color: hsl(var(--primary) / 0.4);
box-shadow: box-shadow:
0 8px 20px hsl(var(--primary) / 0.14), 0 8px 20px hsl(var(--primary) / 14%),
0 2px 6px rgb(0 0 0 / 6%); 0 2px 6px rgb(0 0 0 / 6%);
transform: translateY(-3px);
} }
/* 角落局部高光,强化荧光感 */ /* 角落局部高光,强化荧光感 */
@@ -635,7 +647,7 @@ function formatMoney(val: number) {
pointer-events: none; pointer-events: none;
background: radial-gradient( background: radial-gradient(
circle, circle,
hsl(var(--primary) / 0.18) 0%, hsl(var(--primary) / 18%) 0%,
transparent 70% transparent 70%
); );
opacity: 0.5; opacity: 0.5;
@@ -648,12 +660,12 @@ function formatMoney(val: number) {
/* 累计代付手续费:稍强荧光 + primary 色字 */ /* 累计代付手续费:稍强荧光 + primary 色字 */
.stat-island--fee { .stat-island--fee {
border-color: hsl(var(--primary) / 0.3);
background: linear-gradient( background: linear-gradient(
135deg, 135deg,
hsl(var(--primary) / 0.1), hsl(var(--primary) / 10%),
hsl(var(--card, var(--background))) hsl(var(--card, var(--background)))
); );
border-color: hsl(var(--primary) / 30%);
} }
.stat-island--fee .stat-island-glow { .stat-island--fee .stat-island-glow {
@@ -667,7 +679,7 @@ function formatMoney(val: number) {
right: 12px; right: 12px;
width: 24px; width: 24px;
height: 24px; height: 24px;
color: hsl(var(--primary) / 0.7); color: hsl(var(--primary) / 70%);
transition: color 0.2s ease; transition: color 0.2s ease;
} }
@@ -682,11 +694,11 @@ function formatMoney(val: number) {
.stat-label { .stat-label {
position: relative; position: relative;
z-index: 1; z-index: 1;
margin-bottom: 6px;
padding-right: 32px; padding-right: 32px;
margin-bottom: 6px;
font-size: 12px; font-size: 12px;
color: hsl(var(--muted-foreground));
line-height: 1.2; line-height: 1.2;
color: hsl(var(--muted-foreground));
} }
.stat-val { .stat-val {
@@ -695,8 +707,8 @@ function formatMoney(val: number) {
font-family: 'DIN Alternate', ui-monospace, SFMono-Regular, Menlo, monospace; font-family: 'DIN Alternate', ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 18px; font-size: 18px;
font-weight: 700; font-weight: 700;
color: hsl(var(--foreground));
line-height: 1.2; line-height: 1.2;
color: hsl(var(--foreground));
} }
.stat-val-prefix { .stat-val-prefix {

View File

@@ -7,16 +7,16 @@ import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui'; 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 { getAiGenerationDetail } from '../api';
import GenerationFlowTimeline from './generation-flow-timeline.vue'; import GenerationFlowTimeline from './generation-flow-timeline.vue';
const emit = defineEmits<{ const emit = defineEmits<{
/** 打开页面级打分弹窗 */
review: [row: Record<string, any>];
/** 打开页面级归档弹窗 */ /** 打开页面级归档弹窗 */
archive: [row: Record<string, any>]; archive: [row: Record<string, any>];
/** 打开页面级打分弹窗 */
review: [row: Record<string, any>];
}>(); }>();
const loading = ref(false); 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 canReview = computed(() => Number(detail.value.status) === 1);
const canArchive = computed( const canArchive = computed(
() => () =>
@@ -113,7 +203,9 @@ function onArchiveClick() {
<Spin :spinning="loading"> <Spin :spinning="loading">
<div class="detail-scroll"> <div class="detail-scroll">
<Descriptions bordered size="small" :column="3" class="text-sm"> <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="状态"> <Descriptions.Item label="状态">
<Tag :color="statusColor(Number(detail.status))"> <Tag :color="statusColor(Number(detail.status))">
{{ detail.status_txt || detail.status }} {{ detail.status_txt || detail.status }}
@@ -133,8 +225,24 @@ function onArchiveClick() {
}} }}
</Tag> </Tag>
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="供应商">{{ detail.provider || '—' }}</Descriptions.Item> <Descriptions.Item label="缓存">
<Descriptions.Item label="模型">{{ detail.model || '—' }}</Descriptions.Item> <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="平台"> <Descriptions.Item label="平台">
{{ detail.platform_name || detail.platform_code || '—' }} {{ detail.platform_name || detail.platform_code || '—' }}
</Descriptions.Item> </Descriptions.Item>
@@ -142,13 +250,16 @@ function onArchiveClick() {
{{ detail.api_key_name || detail.api_key_id || '—' }} {{ detail.api_key_name || detail.api_key_id || '—' }}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="Tokens"> <Descriptions.Item label="Tokens">
{{ detail.total_tokens || 0 }} {{ detail.total_tokens || 0 }} P
P {{ detail.prompt_tokens || 0 }} / C {{ detail.completion_tokens || 0 }} {{ detail.prompt_tokens || 0 }} / C
{{ detail.completion_tokens || 0 }}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="耗时"> <Descriptions.Item label="耗时">
{{ formatDuration(Number(detail.duration_ms || 0)) }} {{ formatDuration(Number(detail.duration_ms || 0)) }}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="采纳">{{ detail.adopted_txt || '—' }}</Descriptions.Item> <Descriptions.Item label="采纳">
{{ detail.adopted_txt || '—' }}
</Descriptions.Item>
<Descriptions.Item label="正确性"> <Descriptions.Item label="正确性">
<Tag <Tag
:color=" :color="
@@ -163,10 +274,16 @@ function onArchiveClick() {
</Tag> </Tag>
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="得分"> <Descriptions.Item label="得分">
{{ Number(detail.quality_score) > 0 ? `${detail.quality_score}` : '—' }} {{
Number(detail.quality_score) > 0
? `${detail.quality_score}`
: '—'
}}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="归档"> <Descriptions.Item label="归档">
<Tag :color="Number(detail.is_archived) === 1 ? 'success' : 'default'"> <Tag
:color="Number(detail.is_archived) === 1 ? 'success' : 'default'"
>
{{ detail.archived_txt || '未归档' }} {{ detail.archived_txt || '未归档' }}
</Tag> </Tag>
<span <span
@@ -185,10 +302,53 @@ function onArchiveClick() {
v-if="Array.isArray(detail.steps) && detail.steps.length" 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" 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" /> <GenerationFlowTimeline :row="detail" />
</div> </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="mt-3 grid grid-cols-1 gap-3 lg:grid-cols-2">
<div class="json-panel"> <div class="json-panel">
<div class="json-title">输入快照</div> <div class="json-title">输入快照</div>
@@ -216,33 +376,118 @@ function onArchiveClick() {
<style scoped> <style scoped>
.detail-scroll { .detail-scroll {
max-height: min(72vh, 780px); max-height: min(72vh, 780px);
overflow: auto;
padding-right: 4px; padding-right: 4px;
overflow: auto;
} }
.json-panel { .json-panel {
overflow: hidden;
background: hsl(var(--card, var(--background)));
border: 1px solid hsl(var(--border)); border: 1px solid hsl(var(--border));
border-radius: 8px; border-radius: 8px;
background: hsl(var(--card, var(--background)));
overflow: hidden;
} }
.json-title { .json-title {
padding: 6px 10px; padding: 6px 10px;
font-size: 12px; font-size: 12px;
color: hsl(var(--muted-foreground)); color: hsl(var(--muted-foreground));
border-bottom: 1px solid hsl(var(--border));
background: hsl(var(--muted) / 20%); background: hsl(var(--muted) / 20%);
border-bottom: 1px solid hsl(var(--border));
} }
.json-pre { .json-pre {
margin: 0;
max-height: 220px; max-height: 220px;
overflow: auto;
padding: 8px 10px; padding: 8px 10px;
margin: 0;
overflow: auto;
font-size: 12px; font-size: 12px;
line-height: 1.45; 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; white-space: pre-wrap;
word-break: break-word;
} }
</style> </style>

View File

@@ -17,9 +17,9 @@ const props = defineProps<{
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
archive: [row: any];
detail: [row: any]; detail: [row: any];
review: [row: any]; review: [row: any];
archive: [row: any];
}>(); }>();
const loading = ref(false); const loading = ref(false);
@@ -43,11 +43,13 @@ async function load() {
...props.filters, ...props.filters,
}); });
const data = res?.data || res || {}; const data = res?.data || res || {};
items.value = Array.isArray(data.items) if (Array.isArray(data.items)) {
? data.items items.value = data.items;
: Array.isArray(data) } else if (Array.isArray(data)) {
? data items.value = data;
: []; } else {
items.value = [];
}
total.value = Number(data.total ?? items.value.length); total.value = Number(data.total ?? items.value.length);
} finally { } finally {
loading.value = false; loading.value = false;
@@ -88,10 +90,16 @@ defineExpose({ reload: load });
暂无生成记录 暂无生成记录
</div> </div>
<div v-else class="grid grid-cols-1 gap-3 xl:grid-cols-2"> <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="mb-3 flex items-start justify-between gap-2">
<div class="min-w-0"> <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}` }} {{ row.name || `记录 #${row.id}` }}
</div> </div>
<div class="mt-0.5 text-xs text-[hsl(var(--muted-foreground))]"> <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"> <div class="mb-3 flex flex-wrap gap-1.5">
<Tag :color="Number(row.via_agent) === 1 ? 'gold' : 'default'"> <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>
<Tag <Tag
v-if="Number(row.is_adopted) === 1" 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 || '已采纳' }} {{ row.adopted_txt || '已采纳' }}
</Tag> </Tag>
<Tag v-else>未采纳</Tag> <Tag v-else>未采纳</Tag>
<Tag v-if="Number(row.is_correct) === 1" color="success">正确</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-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> <Tag v-if="Number(row.is_archived) === 1" color="success">已归档</Tag>
</div> </div>
@@ -145,7 +170,8 @@ defineExpose({ reload: load });
<div <div
class="metric-value" class="metric-value"
:class="{ :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"> <template v-if="Number(row.match_drug_total) > 0">
@@ -156,7 +182,10 @@ defineExpose({ reload: load });
</div> </div>
<div class="metric-cell"> <div class="metric-cell">
<div class="metric-label">Tokens</div> <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 }} {{ row.total_tokens || 0 }}
</div> </div>
</div> </div>
@@ -171,7 +200,9 @@ defineExpose({ reload: load });
<div class="mb-2 space-y-1 text-xs text-[hsl(var(--muted-foreground))]"> <div class="mb-2 space-y-1 text-xs text-[hsl(var(--muted-foreground))]">
<div> <div>
模型{{ row.provider || '—' }} / {{ row.model || '—' }} 模型{{ 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>
<div v-if="row.platform_name || row.platform_code"> <div v-if="row.platform_name || row.platform_code">
平台{{ row.platform_name || row.platform_code }} 平台{{ row.platform_name || row.platform_code }}
@@ -186,8 +217,15 @@ defineExpose({ reload: load });
错误{{ row.error_msg }} 错误{{ row.error_msg }}
</div> </div>
<div class="mt-auto flex gap-3 border-t border-[hsl(var(--border))] pt-2"> <div
<Button size="small" type="link" class="!px-0" @click="emit('detail', row)"> 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>
<Button <Button
@@ -231,16 +269,16 @@ defineExpose({ reload: load });
<style scoped> <style scoped>
.gen-card { .gen-card {
border: 1px solid hsl(var(--primary) / 28%);
background: hsl(var(--card, var(--background))); background: hsl(var(--card, var(--background)));
border: 1px solid hsl(var(--primary) / 28%);
box-shadow: 0 0 6px hsl(var(--primary) / 16%); box-shadow: 0 0 6px hsl(var(--primary) / 16%);
} }
.metric-cell { .metric-cell {
border-radius: 8px;
border: 1px solid hsl(var(--border));
background: hsl(var(--muted) / 25%);
padding: 8px 10px; padding: 8px 10px;
background: hsl(var(--muted) / 25%);
border: 1px solid hsl(var(--border));
border-radius: 8px;
} }
.metric-label { .metric-label {

View File

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

View File

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

View File

@@ -46,6 +46,13 @@ export const gridOptions: VxeGridProps<RowType> = {
columns: [ columns: [
{ field: 'id', align: 'left', title: 'ID', width: 80 }, { field: 'id', align: 'left', title: 'ID', width: 80 },
{ field: 'scene_txt', align: 'left', title: '场景', width: 90 }, { 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: 'name', align: 'left', title: '名称', minWidth: 140 },
{ {
field: 'status', field: 'status',

View File

@@ -8,23 +8,23 @@ import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue';
import { Page, useVbenModal } from '@vben/common-ui'; import { 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 { useVbenForm } from '#/adapter/form';
import { useVbenVxeGrid } from '#/adapter/vxe-table'; import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { TableAction } from '#/components/table-action'; import { TableAction } from '#/components/table-action';
import { useViewMode, ViewModeSwitch } from '#/components/view-mode-switch'; import { useViewMode, ViewModeSwitch } from '#/components/view-mode-switch';
import { getAiModelOption } from '../platform/api/model';
import { getAiPlatformOption } from '../platform/api'; import { getAiPlatformOption } from '../platform/api';
import { getAiModelOption } from '../platform/api/model';
import { import {
exportAiTrainJsonl, exportAiTrainJsonl,
getAiGenerationList, getAiGenerationList,
getAiGenerationUsageStats, getAiGenerationUsageStats,
} from './api'; } from './api';
import ArchiveModal from './components/archive-modal.vue';
import DetailModal from './components/detail-modal.vue'; import DetailModal from './components/detail-modal.vue';
import GenerationCardList from './components/generation-card-list.vue'; import GenerationCardList from './components/generation-card-list.vue';
import ArchiveModal from './components/archive-modal.vue';
import ReviewModal from './components/review-modal.vue'; import ReviewModal from './components/review-modal.vue';
import { formOptions, normalizeAiGenerationFilters } from './config/search'; import { formOptions, normalizeAiGenerationFilters } from './config/search';
import { gridOptions as baseGridOptions } from './config/table'; import { gridOptions as baseGridOptions } from './config/table';
@@ -56,7 +56,7 @@ const stats = reactive({
/** 全量模型 option选平台时按 platform_id 过滤 */ /** 全量模型 option选平台时按 platform_id 过滤 */
const allModelOptions = ref< const allModelOptions = ref<
Array<{ label: string; value: string; platform_id?: number }> Array<{ label: string; platform_id?: number; value: string }>
>([]); >([]);
/** 从 schema 取 defaultValue保证首屏列表/卡片筛选口径一致 */ /** 从 schema 取 defaultValue保证首屏列表/卡片筛选口径一致 */
@@ -73,7 +73,9 @@ function collectDefaultSearchValues(): Record<string, any> {
/** 双视图共用的原始表单值(未 normalize */ /** 双视图共用的原始表单值(未 normalize */
const searchValues = ref<Record<string, any>>(collectDefaultSearchValues()); const searchValues = ref<Record<string, any>>(collectDefaultSearchValues());
/** 卡片列表用已 normalize 的筛选computed 避免模板每次新建对象导致死循环重拉 */ /** 卡片列表用已 normalize 的筛选computed 避免模板每次新建对象导致死循环重拉 */
const cardFilters = computed(() => normalizeAiGenerationFilters(searchValues.value)); const cardFilters = computed(() =>
normalizeAiGenerationFilters(searchValues.value),
);
function formatDuration(ms: number) { function formatDuration(ms: number) {
return formatDurationMs(ms); return formatDurationMs(ms);
@@ -107,7 +109,9 @@ async function refreshStats(formValues?: Record<string, any>) {
function refreshModelSchema(platformId?: number) { function refreshModelSchema(platformId?: number) {
const filtered = const filtered =
platformId && platformId > 0 platformId && platformId > 0
? allModelOptions.value.filter((m) => Number(m.platform_id) === platformId) ? allModelOptions.value.filter(
(m) => Number(m.platform_id) === platformId,
)
: allModelOptions.value; : allModelOptions.value;
searchFormApi.updateSchema?.([ searchFormApi.updateSchema?.([
{ {
@@ -144,7 +148,7 @@ const [SearchForm, searchFormApi] = useVbenForm({
: item, : item,
), ),
handleSubmit: async (values) => { handleSubmit: async (values) => {
searchValues.value = { ...(values || {}) }; searchValues.value = { ...values };
void refreshStats(searchValues.value); void refreshStats(searchValues.value);
if (viewMode.value === 'list') { if (viewMode.value === 'list') {
gridApi.reload(); gridApi.reload();
@@ -274,7 +278,9 @@ async function handleExportJsonl() {
try { try {
const formValues = const formValues =
(await searchFormApi.getValues?.()) || searchValues.value || {}; (await searchFormApi.getValues?.()) || searchValues.value || {};
const res = await exportAiTrainJsonl(normalizeAiGenerationFilters(formValues)); const res = await exportAiTrainJsonl(
normalizeAiGenerationFilters(formValues),
);
const data = res?.data || res || {}; const data = res?.data || res || {};
const content = String(data.content || ''); const content = String(data.content || '');
const filename = String(data.filename || `ai_train_${Date.now()}.jsonl`); const filename = String(data.filename || `ai_train_${Date.now()}.jsonl`);
@@ -343,25 +349,39 @@ onMounted(async () => {
<Row :gutter="12"> <Row :gutter="12">
<Col :xs="12" :sm="8" :md="4"> <Col :xs="12" :sm="8" :md="4">
<Card size="small"> <Card size="small">
<div class="text-xs text-[hsl(var(--muted-foreground))]"> Tokens</div> <div class="text-xs text-[hsl(var(--muted-foreground))]">
<div class="mt-1 text-lg font-semibold">{{ stats.total_tokens }}</div> Tokens
</div>
<div class="mt-1 text-lg font-semibold">
{{ stats.total_tokens }}
</div>
</Card> </Card>
</Col> </Col>
<Col :xs="12" :sm="8" :md="4"> <Col :xs="12" :sm="8" :md="4">
<Card size="small"> <Card size="small">
<div class="text-xs text-[hsl(var(--muted-foreground))]">Prompt</div> <div class="text-xs text-[hsl(var(--muted-foreground))]">
<div class="mt-1 text-lg font-semibold">{{ stats.prompt_tokens }}</div> Prompt
</div>
<div class="mt-1 text-lg font-semibold">
{{ stats.prompt_tokens }}
</div>
</Card> </Card>
</Col> </Col>
<Col :xs="12" :sm="8" :md="4"> <Col :xs="12" :sm="8" :md="4">
<Card size="small"> <Card size="small">
<div class="text-xs text-[hsl(var(--muted-foreground))]">Completion</div> <div class="text-xs text-[hsl(var(--muted-foreground))]">
<div class="mt-1 text-lg font-semibold">{{ stats.completion_tokens }}</div> Completion
</div>
<div class="mt-1 text-lg font-semibold">
{{ stats.completion_tokens }}
</div>
</Card> </Card>
</Col> </Col>
<Col :xs="12" :sm="8" :md="4"> <Col :xs="12" :sm="8" :md="4">
<Card size="small"> <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"> <div class="mt-1 text-lg font-semibold">
{{ formatDuration(stats.avg_duration_ms) }} {{ formatDuration(stats.avg_duration_ms) }}
</div> </div>
@@ -369,16 +389,24 @@ onMounted(async () => {
</Col> </Col>
<Col :xs="12" :sm="8" :md="4"> <Col :xs="12" :sm="8" :md="4">
<Card size="small"> <Card size="small">
<div class="text-xs text-[hsl(var(--muted-foreground))]">成功率</div> <div class="text-xs text-[hsl(var(--muted-foreground))]">
<div class="mt-1 text-lg font-semibold">{{ stats.success_rate }}%</div> 成功率
</div>
<div class="mt-1 text-lg font-semibold">
{{ stats.success_rate }}%
</div>
</Card> </Card>
</Col> </Col>
<Col :xs="12" :sm="8" :md="4"> <Col :xs="12" :sm="8" :md="4">
<Card size="small"> <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"> <div class="mt-1 text-lg font-semibold">
{{ stats.success_count }}/{{ stats.total_count }} {{ 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 }} 失败 {{ stats.fail_count }}
</span> </span>
</div> </div>
@@ -394,7 +422,9 @@ onMounted(async () => {
</div> </div>
<div class="mt-1 text-lg font-semibold"> <div class="mt-1 text-lg font-semibold">
{{ stats.adoption_rate }}% {{ 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 }} {{ stats.adopted_count }}/{{ stats.success_count }}
</span> </span>
</div> </div>
@@ -417,7 +447,9 @@ onMounted(async () => {
修改率 修改率
<span class="ml-1 opacity-70">采纳但改过原稿</span> <span class="ml-1 opacity-70">采纳但改过原稿</span>
</div> </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> </Card>
</Col> </Col>
<Col :xs="12" :sm="8" :md="5"> <Col :xs="12" :sm="8" :md="5">
@@ -426,7 +458,9 @@ onMounted(async () => {
药名未匹配率 药名未匹配率
<span class="ml-1 opacity-70">越低越好</span> <span class="ml-1 opacity-70">越低越好</span>
</div> </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> </Card>
</Col> </Col>
<Col :xs="12" :sm="8" :md="4"> <Col :xs="12" :sm="8" :md="4">
@@ -449,7 +483,12 @@ onMounted(async () => {
</div> </div>
<div class="mb-3 flex flex-wrap items-center justify-between gap-2"> <div class="mb-3 flex flex-wrap items-center justify-between gap-2">
<ViewModeSwitch v-model="viewMode" /> <ViewModeSwitch v-model="viewMode" />
<Button type="primary" ghost :loading="exporting" @click="handleExportJsonl"> <Button
type="primary"
ghost
:loading="exporting"
@click="handleExportJsonl"
>
导出训练 JSONL 导出训练 JSONL
</Button> </Button>
</div> </div>
@@ -462,7 +501,18 @@ onMounted(async () => {
</template> </template>
<template #via_agent="{ row }"> <template #via_agent="{ row }">
<Tag :color="Number(row.via_agent) === 1 ? 'gold' : 'default'"> <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> </Tag>
</template> </template>
<template #tokens="{ row }"> <template #tokens="{ row }">
@@ -493,7 +543,9 @@ onMounted(async () => {
<template #adopted="{ row }"> <template #adopted="{ row }">
<Tag <Tag
v-if="Number(row.is_adopted) === 1" 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 }} {{ row.adopted_txt }}
</Tag> </Tag>
@@ -505,50 +557,52 @@ onMounted(async () => {
<span v-else class="text-xs text-[hsl(var(--muted-foreground))]">未评</span> <span v-else class="text-xs text-[hsl(var(--muted-foreground))]">未评</span>
</template> </template>
<template #score="{ row }"> <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> <span v-else class="text-xs text-[hsl(var(--muted-foreground))]"></span>
</template> </template>
<template #archived="{ row }"> <template #archived="{ row }">
<Tag v-if="Number(row.is_archived) === 1" color="success">已归档</Tag> <Tag v-if="Number(row.is_archived) === 1" color="success">已归档</Tag>
<span v-else class="text-xs text-[hsl(var(--muted-foreground))]">未归档</span> <span v-else class="text-xs text-[hsl(var(--muted-foreground))]">未归档</span>
</template> </template>
<template #action="{ row }"> <template #action="{ row }">
<TableAction <TableAction
:actions="[ :actions="[
{ {
label: '详情', label: '详情',
type: 'link', type: 'link',
onClick: () => openDetail(row), onClick: () => openDetail(row),
}, },
{ {
label: '打分', label: '打分',
type: 'link', type: 'link',
ifShow: Number(row.status) === 1, ifShow: Number(row.status) === 1,
onClick: () => openReview(row), onClick: () => openReview(row),
}, },
{ {
label: '归档', label: '归档',
type: 'link', type: 'link',
ifShow: ifShow:
Number(row.status) === 1 && Number(row.status) === 1 &&
Number(row.is_correct) !== 0 && Number(row.is_correct) !== 0 &&
Number(row.quality_score) > 0, Number(row.quality_score) > 0,
onClick: () => openArchive(row), onClick: () => openArchive(row),
}, },
]" ]"
/>
</template>
</Grid>
<div v-else class="gen-card-wrap">
<GenerationCardList
ref="cardListRef"
:filters="cardFilters"
@detail="openDetail"
@review="openReview"
@archive="openArchive"
/> />
</div> </template>
</Grid>
<div v-else class="gen-card-wrap">
<GenerationCardList
ref="cardListRef"
:filters="cardFilters"
@detail="openDetail"
@review="openReview"
@archive="openArchive"
/>
</div>
</Page> </Page>
</template> </template>

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -0,0 +1,249 @@
<script lang="ts" setup>
/**
* 系统配置 - 处方签名:中药/西药/服务包/配送仓分别上传调配人、发药人
* 一次保存整份 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> <script lang="ts" setup>
/** /**
* 系统配置 - 小程序:空状态占位图 * 系统配置 - 患者端小程序:空状态占位图
*/ */
import { onMounted, ref } from 'vue'; import { onMounted, ref } from 'vue';
@@ -52,14 +52,18 @@ onMounted(load);
<template> <template>
<div class="cfg-panel" :class="{ 'opacity-60': loading }"> <div class="cfg-panel" :class="{ 'opacity-60': loading }">
<div class="cfg-panel-body"> <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 class="mb-2 text-sm text-[hsl(var(--muted-foreground))]">
用于小程序列表等无数据时的占位图 用于小程序列表等无数据时的占位图
</div> </div>
<FormAvatar v-model:value="miniprogramEmptyImage" /> <FormAvatar v-model:value="miniprogramEmptyImage" />
</div> </div>
<div class="cfg-panel-footer"> <div class="cfg-panel-footer">
<Button type="primary" :loading="saving" @click="handleSave">保存本模块配置</Button> <Button type="primary" :loading="saving" @click="handleSave">
保存本模块配置
</Button>
</div> </div>
</div> </div>
</template> </template>

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,13 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { Component } from 'vue';
import type { ModuleTabItem } from './components/module-tabs-panel.vue';
/** /**
* 系统配置页壳:左侧一级菜单 + 右侧动态面板 * 系统配置页壳:左侧一级模块 + 右侧二级 Tab与 AI 模块同一交互)
* 各模块自管 load/saveAI 在右侧保留二级 Tab * 各子面板自管 load/save旧菜单 key 会映射到新模块/Tab
*/ */
import { computed, onMounted, ref, type Component } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { Page } from '@vben/common-ui'; 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 AiModelConfigPanel from './components/ai-model-config-panel.vue';
import DictSearchConfigPanel from './components/dict-search-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 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 InputAuditConfigPanel from './components/input-audit-config-panel.vue';
import InvoiceNoticeConfigPanel from './components/invoice-notice-config-panel.vue'; import InvoiceNoticeConfigPanel from './components/invoice-notice-config-panel.vue';
import LogisticsConfigPanel from './components/logistics-config-panel.vue'; import LogisticsConfigPanel from './components/logistics-config-panel.vue';
import MedicalRecordConfigPanel from './components/medical-record-config-panel.vue'; import MedicalRecordConfigPanel from './components/medical-record-config-panel.vue';
import MiniprogramConfigPanel from './components/miniprogram-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 OaNotifyConfigPanel from './components/oa-notify-config-panel.vue';
import PriceAdjustConfigPanel from './components/price-adjust-config-panel.vue'; import PriceAdjustConfigPanel from './components/price-adjust-config-panel.vue';
import SalespersonConfigPanel from './components/salesperson-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' }); defineOptions({ name: 'SystemConfig' });
@@ -28,35 +37,146 @@ const MENU_STORAGE_KEY = 'system_config_active_tab';
interface MenuItem { interface MenuItem {
key: string; key: string;
title: string; title: string;
component: Component; /** 已自带二级 Tab 的整块面板AI */
component?: Component;
tabs?: ModuleTabItem[];
} }
const menuItems: MenuItem[] = [ const menuItems: MenuItem[] = [
{ key: 'price_adjust', title: '订单调价', component: PriceAdjustConfigPanel }, {
{ key: 'input_audit', title: '录入审核', component: InputAuditConfigPanel }, key: 'mp_patient',
{ key: 'miniprogram', title: '小程序', component: MiniprogramConfigPanel }, title: '患者端小程序',
{ key: 'logistics', title: '物流展示', component: LogisticsConfigPanel }, tabs: [
{ key: 'salesperson', title: '业务员权限', component: SalespersonConfigPanel }, { key: 'empty', title: '空状态图', component: MiniprogramConfigPanel },
{ key: 'doctor_login', title: '登录安全', component: DoctorLoginConfigPanel }, { key: 'home', title: '首页展示', component: HomeDisplayConfigPanel },
{ 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: 'mp_doctor',
{ key: 'ai_model', title: 'AI模型', component: AiModelConfigPanel }, 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 activeKey = ref('mp_patient');
const hit = menuItems.find((m) => m.key === activeKey.value);
return hit?.component || PriceAdjustConfigPanel; const currentMenu = computed(() => {
return menuItems.find((m) => m.key === activeKey.value) || menuItems[0];
}); });
function readStoredMenu() { function readStoredMenu() {
const stored = localStorage.getItem(MENU_STORAGE_KEY); const stored = localStorage.getItem(MENU_STORAGE_KEY);
if (stored && menuItems.some((m) => m.key === stored)) { if (!stored) {
activeKey.value = 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) { function selectMenu(key: string) {
@@ -84,7 +204,17 @@ onMounted(readStoredMenu);
</button> </button>
</aside> </aside>
<section class="sys-cfg-main"> <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> </section>
</div> </div>
</Card> </Card>
@@ -93,58 +223,65 @@ onMounted(readStoredMenu);
<style scoped> <style scoped>
.sys-cfg-card { .sys-cfg-card {
height: 100%;
min-height: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%;
min-height: 0;
} }
.sys-cfg-layout { .sys-cfg-layout {
display: flex; display: flex;
height: 100%; height: 100%;
min-height: 480px; min-height: 480px;
max-height: calc(100vh - 160px); max-height: calc(100vh - 160px);
} }
.sys-cfg-nav { .sys-cfg-nav {
width: 188px;
flex-shrink: 0; flex-shrink: 0;
overflow-y: auto; width: 188px;
border-right: 1px solid hsl(var(--border));
background: hsl(var(--muted) / 0.25);
padding: 8px; padding: 8px;
overflow-y: auto;
background: hsl(var(--muted) / 25%);
border-right: 1px solid hsl(var(--border));
} }
.sys-cfg-nav-item { .sys-cfg-nav-item {
display: block; display: block;
width: 100%; width: 100%;
text-align: left;
border: 1px solid transparent;
background: transparent;
color: hsl(var(--foreground));
border-radius: 8px;
padding: 10px 12px; padding: 10px 12px;
margin-bottom: 4px; margin-bottom: 4px;
cursor: pointer;
font-size: 14px; font-size: 14px;
color: hsl(var(--foreground));
text-align: left;
cursor: pointer;
background: transparent;
border: 1px solid transparent;
border-radius: 8px;
transition: transition:
background 0.15s ease, background 0.15s ease,
border-color 0.15s ease, border-color 0.15s ease,
color 0.15s ease; color 0.15s ease;
} }
.sys-cfg-nav-item:hover { .sys-cfg-nav-item:hover {
background: hsl(var(--accent)); background: hsl(var(--accent));
} }
.sys-cfg-nav-item.active { .sys-cfg-nav-item.active {
border-color: hsl(var(--primary) / 35%);
background: hsl(var(--primary) / 0.1);
color: hsl(var(--primary)); color: hsl(var(--primary));
background: hsl(var(--primary) / 10%);
border-color: hsl(var(--primary) / 35%);
box-shadow: 0 0 6px hsl(var(--primary) / 18%); box-shadow: 0 0 6px hsl(var(--primary) / 18%);
} }
.sys-cfg-main { .sys-cfg-main {
display: flex;
flex: 1; flex: 1;
flex-direction: column;
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
display: flex;
flex-direction: column;
} }
/* 子面板统一:内容滚动 + 底栏不滚走 */ /* 子面板统一:内容滚动 + 底栏不滚走 */
.sys-cfg-main :deep(.cfg-panel) { .sys-cfg-main :deep(.cfg-panel) {
display: flex; display: flex;
@@ -152,17 +289,24 @@ onMounted(readStoredMenu);
height: 100%; height: 100%;
min-height: 0; min-height: 0;
} }
.sys-cfg-main :deep(.cfg-panel-body) { .sys-cfg-main :deep(.cfg-panel-body) {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
overflow: auto;
padding: 16px 20px; 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) { .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; z-index: 2;
flex-shrink: 0;
padding: 12px 20px;
background: hsl(var(--card, var(--background)));
border-top: 1px solid hsl(var(--border));
} }
</style> </style>