feat: 补齐素材文件夹、挂号改期、系统配置分组与工作台简报开关
素材库按 object_key 目录同步文件夹侧栏,选图和筛选可按目录收窄。 挂号列表支持管理端代改期,号源日历与患者端同源。 系统配置按端拆模块(患者首页展示、医生默认常用入口、工作台简报总开关、处方签名),旧菜单 key 做迁移以免刷新丢选中。 AI 生成记录补每日简报场景和缓存命中筛选;个人中心改走后端菜单 Profile。
This commit is contained in:
27
apps/web-antd/src/api/core/file-folder.ts
Normal file
27
apps/web-antd/src/api/core/file-folder.ts
Normal 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`);
|
||||
}
|
||||
@@ -16,6 +16,9 @@ export interface FileGalleryItem {
|
||||
original_name?: string;
|
||||
object_key?: string;
|
||||
source?: number;
|
||||
is_qrcode?: number;
|
||||
ref_count?: number;
|
||||
folder_id?: number;
|
||||
}
|
||||
|
||||
export interface FileTypeItem {
|
||||
@@ -32,28 +35,47 @@ export interface FileGalleryDetail extends FileGalleryItem {
|
||||
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_size?: number;
|
||||
pid?: number;
|
||||
type?: number;
|
||||
group_id?: number;
|
||||
keyword?: string;
|
||||
}) {
|
||||
exclude_qrcode?: number;
|
||||
unref_only?: number;
|
||||
folder_id?: number;
|
||||
unfiled?: number;
|
||||
}
|
||||
|
||||
export async function getFileGalleryList(params?: FileGalleryListParams) {
|
||||
return requestClient.get<{
|
||||
items: FileGalleryItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
total: number;
|
||||
}>(`${prefix}list`, { params });
|
||||
}
|
||||
|
||||
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) {
|
||||
return requestClient.post<any>(`${prefix}delete`, { id });
|
||||
export async function deleteFileGalleryItem(id: number | number[]) {
|
||||
const data = Array.isArray(id) ? { ids: id } : { id };
|
||||
return requestClient.post<any>(`${prefix}delete`, data);
|
||||
}
|
||||
|
||||
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() {
|
||||
return requestClient.post<{
|
||||
reclassified: number;
|
||||
file_name_filled: number;
|
||||
reclassified: number;
|
||||
}>(`${prefix}batch-archive`);
|
||||
}
|
||||
|
||||
@@ -76,15 +98,77 @@ export async function getFileTypes() {
|
||||
}
|
||||
|
||||
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) {
|
||||
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) {
|
||||
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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { FileGalleryItem } from '#/api/core/file-gallery';
|
||||
|
||||
import { computed, ref, watch } from '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 { useFileGalleryFilter } from '#/composables/use-file-gallery-filter';
|
||||
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(
|
||||
defineProps<{
|
||||
open: boolean;
|
||||
multiple?: boolean;
|
||||
maxCount?: number;
|
||||
/**
|
||||
* 限制可选择的文件类型(按 xk_file_type.value 过滤)
|
||||
* - 不传:显示全部类型(默认行为,保持向后兼容)
|
||||
@@ -21,6 +19,9 @@ const props = withDefaults(
|
||||
* 例如图片:[0];图片+视频:[0, 1];文件:[6]
|
||||
*/
|
||||
acceptTypes?: number[];
|
||||
maxCount?: number;
|
||||
multiple?: boolean;
|
||||
open: boolean;
|
||||
}>(),
|
||||
{
|
||||
multiple: false,
|
||||
@@ -30,8 +31,8 @@ const props = withDefaults(
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean];
|
||||
select: [urls: string[]];
|
||||
'update:open': [value: boolean];
|
||||
}>();
|
||||
|
||||
const loading = ref(false);
|
||||
@@ -80,8 +81,9 @@ function lockAcceptType() {
|
||||
if (!arr || arr.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (activeType.value !== arr[0]) {
|
||||
setActiveType(arr[0]!);
|
||||
const firstType = arr[0];
|
||||
if (firstType !== undefined && activeType.value !== firstType) {
|
||||
setActiveType(firstType);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,9 +106,11 @@ async function load() {
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getFileGalleryList(
|
||||
buildListParams(page.value, pageSize.value),
|
||||
);
|
||||
const res = await getFileGalleryList({
|
||||
...buildListParams(page.value, pageSize.value),
|
||||
// 选择器隐藏二维码:标记字段 + 路径启发式由后端 exclude_qrcode 处理
|
||||
exclude_qrcode: 1,
|
||||
});
|
||||
const data = (res as any)?.data ?? res;
|
||||
items.value = data?.items ?? [];
|
||||
total.value = data?.total ?? 0;
|
||||
@@ -128,7 +132,7 @@ watch(
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
let searchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let searchTimer: null | ReturnType<typeof setTimeout> = null;
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
@@ -153,7 +157,7 @@ watch(typesLoading, (val) => {
|
||||
function toggleSelect(url: string) {
|
||||
if (props.multiple) {
|
||||
const idx = selected.value.indexOf(url);
|
||||
if (idx >= 0) {
|
||||
if (idx !== -1) {
|
||||
selected.value = selected.value.filter((u) => u !== url);
|
||||
return;
|
||||
}
|
||||
@@ -171,7 +175,7 @@ function isSelected(url: string) {
|
||||
}
|
||||
|
||||
function handleOk() {
|
||||
if (!selected.value.length) {
|
||||
if (selected.value.length === 0) {
|
||||
return;
|
||||
}
|
||||
emit('select', [...selected.value]);
|
||||
@@ -196,7 +200,7 @@ function onShowSizeChange(_current: number, size: number) {
|
||||
void load();
|
||||
}
|
||||
|
||||
function onTabChange(key: string | number) {
|
||||
function onTabChange(key: number | string) {
|
||||
setActiveType(Number(key));
|
||||
page.value = 1;
|
||||
void load();
|
||||
@@ -308,8 +312,8 @@ function isImageItem(item: FileGalleryItem) {
|
||||
|
||||
.tab-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.picker-toolbar {
|
||||
@@ -321,18 +325,19 @@ function isImageItem(item: FileGalleryItem) {
|
||||
grid-template-columns: repeat(auto-fill, minmax(118px, 1fr));
|
||||
gap: 12px;
|
||||
max-height: 420px;
|
||||
overflow-y: auto;
|
||||
padding: 4px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.gallery-card {
|
||||
@include picker.picker-card-base;
|
||||
padding: 6px;
|
||||
|
||||
@include picker.picker-card-base;
|
||||
|
||||
&.active {
|
||||
border-color: #165dff;
|
||||
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%;
|
||||
height: 88px;
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.gallery-file-icon {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
height: 88px;
|
||||
border-radius: 6px;
|
||||
background: #f5f5f5;
|
||||
color: #4e5969;
|
||||
padding: 6px;
|
||||
color: #4e5969;
|
||||
background: #f5f5f5;
|
||||
border-radius: 6px;
|
||||
|
||||
.file-name {
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
word-break: break-all;
|
||||
line-height: 1.2;
|
||||
max-height: 28px;
|
||||
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 {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: #1d2129;
|
||||
align-items: center;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: #1d2129;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.gallery-pagination {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.dark {
|
||||
@@ -393,9 +398,9 @@ function isImageItem(item: FileGalleryItem) {
|
||||
@include picker.picker-card-dark-props;
|
||||
|
||||
&.active {
|
||||
border-color: #60a5fa;
|
||||
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%);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 { getFileTypes } from '#/api/core/file-gallery';
|
||||
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 { 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;
|
||||
|
||||
/** 文件夹侧栏:0=全部,-1=未入文件夹 */
|
||||
export const ALL_FOLDER_VALUE = 0;
|
||||
export const UNFILED_FOLDER_VALUE = -1;
|
||||
|
||||
export type FileGallerySidebarTab = 'folder' | 'group';
|
||||
|
||||
export interface FileGalleryTab {
|
||||
key: string;
|
||||
value: number;
|
||||
@@ -18,7 +27,9 @@ export interface FileGalleryTab {
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件库筛选(类型 tab + 分组 + 关键字)
|
||||
* 文件库筛选(类型 tab + 分类/文件夹侧栏 + 关键字)
|
||||
* 分类与文件夹互斥:buildListParams 只按当前 sidebarTab 带 group_id 或 folder_id
|
||||
* 文件夹筛选用 object_key 目录前缀,不是手工建夹
|
||||
* @param storageKeyInput localStorage 键;支持动态 getter,便于按 acceptTypes 隔离记忆
|
||||
*/
|
||||
export function useFileGalleryFilter(
|
||||
@@ -27,7 +38,11 @@ export function useFileGalleryFilter(
|
||||
const fileTypes = ref<FileTypeItem[]>([]);
|
||||
const activeType = ref<number>(ALL_TYPE_VALUE);
|
||||
const activeGroupId = ref<number>(ALL_GROUP_VALUE);
|
||||
const activeFolderId = ref<number>(ALL_FOLDER_VALUE);
|
||||
const sidebarTab = ref<FileGallerySidebarTab>('group');
|
||||
const keyword = ref('');
|
||||
/** 只看无引用(管理页用,选择器不传) */
|
||||
const unrefOnly = ref(false);
|
||||
const typesLoading = ref(false);
|
||||
|
||||
const { groupOptions, loadGroupOptions } = useFileGroupCache();
|
||||
@@ -48,7 +63,7 @@ export function useFileGalleryFilter(
|
||||
|
||||
/** 分组 Tab(独立于文件类型) */
|
||||
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) => ({
|
||||
key: `group-${item.id}`,
|
||||
value: item.id,
|
||||
@@ -80,14 +95,38 @@ export function useFileGalleryFilter(
|
||||
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) {
|
||||
const params: {
|
||||
folder_id?: number;
|
||||
group_id?: number;
|
||||
keyword?: string;
|
||||
page: number;
|
||||
page_size: number;
|
||||
pid: number;
|
||||
type?: number;
|
||||
group_id?: number;
|
||||
keyword?: string;
|
||||
unfiled?: number;
|
||||
unref_only?: number;
|
||||
} = {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
@@ -98,15 +137,27 @@ export function useFileGalleryFilter(
|
||||
params.type = activeType.value;
|
||||
}
|
||||
|
||||
if (activeGroupId.value > 0) {
|
||||
// 分类 Tab 用分组;文件夹 Tab 用 path 前缀,互不混用
|
||||
if (sidebarTab.value === 'group' && activeGroupId.value > 0) {
|
||||
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();
|
||||
if (kw) {
|
||||
params.keyword = kw;
|
||||
}
|
||||
|
||||
if (unrefOnly.value) {
|
||||
params.unref_only = 1;
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
@@ -122,6 +173,8 @@ export function useFileGalleryFilter(
|
||||
}
|
||||
}
|
||||
|
||||
restoreSidebarTab();
|
||||
|
||||
onMounted(() => {
|
||||
void loadFileTypes();
|
||||
void loadGroupOptions();
|
||||
@@ -131,13 +184,19 @@ export function useFileGalleryFilter(
|
||||
fileTypes,
|
||||
activeType,
|
||||
activeGroupId,
|
||||
activeFolderId,
|
||||
sidebarTab,
|
||||
keyword,
|
||||
unrefOnly,
|
||||
typesLoading,
|
||||
tabs,
|
||||
groupTabs,
|
||||
groupOptions,
|
||||
setActiveType,
|
||||
setActiveGroupId,
|
||||
setActiveFolderId,
|
||||
setSidebarTab,
|
||||
setUnrefOnly,
|
||||
buildListParams,
|
||||
loadFileTypes,
|
||||
loadGroupOptions,
|
||||
|
||||
@@ -101,16 +101,7 @@ const routes: RouteRecordRaw[] = [
|
||||
order: 9999,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Profile',
|
||||
path: '/profile',
|
||||
component: () => import('#/views/_core/profile/index.vue'),
|
||||
meta: {
|
||||
icon: 'lucide:user',
|
||||
hideInMenu: true,
|
||||
title: $t('page.auth.profile'),
|
||||
},
|
||||
},
|
||||
// 个人中心路由已改由后端 xk_menu 隐藏菜单下发(backend 访问控制下静态路由不会注册,写在这里等于死代码)
|
||||
];
|
||||
|
||||
export default routes;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 个人中心:左侧账号卡(头像/昵称/角色)+ 右侧 Tab(基本资料 / 修改密码)
|
||||
* 入口:顶栏头像下拉「个人中心」、工作台问候头;静态路由 /profile(hideInMenu,不走 xk_menu)
|
||||
* 入口:顶栏头像下拉「个人中心」、工作台问候头;路由由后端 xk_menu 隐藏菜单下发
|
||||
* (backend 访问控制模式,name=Profile 必须与菜单表一致,SQL 见 20260817/03_xk_menu_profile.sql)
|
||||
* 资料数据统一从 auth/my-profile 实时接口拉取,子组件保存成功后回调刷新
|
||||
*/
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
@@ -12,6 +13,9 @@ import { getMyProfile } from './api';
|
||||
import ProfileBase from './base-setting.vue';
|
||||
import ProfilePasswordSetting from './password-setting.vue';
|
||||
|
||||
// 路由 name 必须与 xk_menu.name(Profile)一致,backend 模式动态路由靠它命中本组件并支持 push({ name })
|
||||
defineOptions({ name: 'Profile' });
|
||||
|
||||
const tabsValue = ref<string>('basic');
|
||||
|
||||
const tabs = ref([
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 商品订单退款弹窗
|
||||
* 确认时自行校验并调 refund 接口;不再走已弃用的 validateAndSubmitForm
|
||||
*/
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
@@ -22,22 +26,18 @@ const [Modal, modalApi] = useVbenModal({
|
||||
modalApi.close();
|
||||
},
|
||||
onConfirm: async () => {
|
||||
formApi.validate().then(async (e: any) => {
|
||||
if (e.valid) {
|
||||
const values = await formApi.getValues();
|
||||
modalApi.setState({ loading: true, confirmLoading: true });
|
||||
refundApi(values)
|
||||
.then(() => {
|
||||
message.success('保存成功');
|
||||
gridApi.value?.reload();
|
||||
modalApi.close();
|
||||
})
|
||||
.finally(() => {
|
||||
modalApi.setState({ loading: false, confirmLoading: false });
|
||||
});
|
||||
}
|
||||
});
|
||||
await formApi.validateAndSubmitForm();
|
||||
const e = await formApi.validate();
|
||||
if (!e.valid) return;
|
||||
const values = await formApi.getValues();
|
||||
modalApi.setState({ loading: true, confirmLoading: true });
|
||||
try {
|
||||
await refundApi(values);
|
||||
message.success('保存成功');
|
||||
gridApi.value?.reload();
|
||||
modalApi.close();
|
||||
} finally {
|
||||
modalApi.setState({ loading: false, confirmLoading: false });
|
||||
}
|
||||
},
|
||||
onOpenChange(isOpen: boolean) {
|
||||
gridApi.value = isOpen ? modalApi.getData()?.gridApi : null;
|
||||
@@ -52,10 +52,7 @@ const [Modal, modalApi] = useVbenModal({
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<Modal
|
||||
title="退款"
|
||||
class="w-[80%] md:w-[50%] lg:w-[30%] h-[60%]"
|
||||
>
|
||||
<Modal title="退款" class="w-[80%] md:w-[50%] lg:w-[30%] h-[60%]">
|
||||
<Form />
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
@@ -38,3 +38,51 @@ export async function getRegisterListApi(data: Record<string, unknown>) {
|
||||
export async function refundRegisterApi(data: Record<string, unknown>) {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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('');
|
||||
/** 选中的时段ID;null=未选择(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>
|
||||
@@ -19,15 +19,16 @@ import { Button, Image, Tag } from 'ant-design-vue';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import StoreCardModal from '#/components/store-card/StoreCardModal.vue';
|
||||
import { TableAction } from '#/components/table-action';
|
||||
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 { getRegisterListApi } from './api';
|
||||
import Refund from './components/refund.vue';
|
||||
import RegisterDetailModal from './components/RegisterDetailModal.vue';
|
||||
import RegisterOrderCardList from './components/RegisterOrderCardList.vue';
|
||||
import RescheduleModal from './components/reschedule-modal.vue';
|
||||
import {
|
||||
REGISTER_REFUND_STATUS_MAP,
|
||||
REGISTER_STATUS_MAP,
|
||||
@@ -41,7 +42,9 @@ const hasTopTableDropDownActions = ref(false);
|
||||
|
||||
/** 列表/卡片双视图:通用 useViewMode 自带 localStorage 记忆 */
|
||||
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> = {
|
||||
checkboxChange() {
|
||||
@@ -104,7 +107,7 @@ const searchValues = ref<Record<string, any>>(collectDefaultSearchValues());
|
||||
const [SearchForm, searchFormApi] = useVbenForm({
|
||||
...effectiveFormOptions,
|
||||
handleSubmit: async (values) => {
|
||||
searchValues.value = { ...(values || {}) };
|
||||
searchValues.value = { ...values };
|
||||
if (viewMode.value === 'list') {
|
||||
gridApi.reload();
|
||||
}
|
||||
@@ -127,7 +130,11 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
/** 始终合并顶部统一搜索条件 */
|
||||
query: async ({ page }: { page: { currentPage: number; pageSize: number } }) => {
|
||||
query: async ({
|
||||
page,
|
||||
}: {
|
||||
page: { currentPage: number; pageSize: number };
|
||||
}) => {
|
||||
return await getRegisterListApi({
|
||||
page: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
@@ -190,6 +197,11 @@ const [RefundModal, RefundModalApi] = useVbenModal({
|
||||
connectedComponent: Refund,
|
||||
});
|
||||
|
||||
/** 改期弹窗:管理端代患者改期(拉号源日历选新日期/时段) */
|
||||
const [RescheduleModalComp, rescheduleModalApi] = useVbenModal({
|
||||
connectedComponent: RescheduleModal,
|
||||
});
|
||||
|
||||
/** 挂号详情弹窗:列表行「详情」与卡片点击共用 */
|
||||
const [RegisterDetailModalComp, registerDetailModalApi] = useVbenModal({
|
||||
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;
|
||||
}
|
||||
|
||||
/** 仅「已支付 + 待接诊 + 未取消」可改期(与后端 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) {
|
||||
const n = Number(price);
|
||||
if (!Number.isFinite(n)) {
|
||||
@@ -250,6 +272,7 @@ function rowRefundMeta(row: Record<string, any>) {
|
||||
<Page auto-content-height title="订单管理">
|
||||
<PrescriptionDetailModal />
|
||||
<RefundModal />
|
||||
<RescheduleModalComp />
|
||||
<StoreCardModalComp />
|
||||
<!-- 挂号详情弹窗:处方号/诊所名点击透传到已有弹窗 -->
|
||||
<RegisterDetailModalComp
|
||||
@@ -257,7 +280,9 @@ function rowRefundMeta(row: Record<string, any>) {
|
||||
@open-store="openStoreCard"
|
||||
/>
|
||||
<!-- 顶部统一搜索(双视图共用,替代原 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 />
|
||||
</div>
|
||||
<!-- 视图切换:列表 / 卡片看板 -->
|
||||
@@ -268,9 +293,7 @@ function rowRefundMeta(row: Record<string, any>) {
|
||||
<template #toolbar-actions>
|
||||
<TableAction :actions="[]" :drop-down-actions="[]">
|
||||
<template #more>
|
||||
<Button style="margin-left: 16px">
|
||||
批量操作
|
||||
</Button>
|
||||
<Button style="margin-left: 16px"> 批量操作 </Button>
|
||||
</template>
|
||||
</TableAction>
|
||||
</template>
|
||||
@@ -340,6 +363,13 @@ function rowRefundMeta(row: Record<string, any>) {
|
||||
icon: 'marketeq:eye',
|
||||
onClick: openRegisterDetail.bind(null, row),
|
||||
},
|
||||
{
|
||||
label: '改期',
|
||||
type: 'link',
|
||||
icon: 'ant-design:calendar-outlined',
|
||||
ifShow: canReschedule(row),
|
||||
onClick: openRescheduleModal.bind(null, row),
|
||||
},
|
||||
{
|
||||
label: '退款',
|
||||
type: 'link',
|
||||
|
||||
@@ -4,21 +4,21 @@ const prefix = 'workbench/';
|
||||
|
||||
/** 工作台布局项(后端按角色下发) */
|
||||
export type WorkbenchLayoutItem = {
|
||||
id: number;
|
||||
widget_code: string;
|
||||
title: string;
|
||||
sort: number;
|
||||
config: null | Record<string, any>;
|
||||
id: number;
|
||||
sort: number;
|
||||
title: string;
|
||||
widget_code: string;
|
||||
};
|
||||
|
||||
/** 医生今日摘要(与医生小程序工作台同口径) */
|
||||
export type DoctorSummaryResult = {
|
||||
range: string;
|
||||
prescription_count: number;
|
||||
wait_accept: number;
|
||||
accepting: number;
|
||||
completed: number;
|
||||
prescription_amount: string;
|
||||
prescription_count: number;
|
||||
range: string;
|
||||
wait_accept: number;
|
||||
};
|
||||
|
||||
/** 药师待审摘要 */
|
||||
@@ -28,16 +28,16 @@ export type PharmacistSummaryResult = {
|
||||
|
||||
/** 诊所今日摘要 */
|
||||
export type ClinicSummaryResult = {
|
||||
wait_accept: number;
|
||||
accepting: number;
|
||||
completed: number;
|
||||
wait_accept: number;
|
||||
wait_delivery: number;
|
||||
};
|
||||
|
||||
/** 订单管理员摘要 */
|
||||
export type OrderSummaryResult = {
|
||||
wait_delivery: number;
|
||||
refunding: number;
|
||||
wait_delivery: number;
|
||||
};
|
||||
|
||||
/** 平台今日总览(超管/系统管理员) */
|
||||
@@ -51,8 +51,8 @@ export type PlatformOverviewResult = {
|
||||
/** 待办中心单项(count>0 需要处理,path 为跳转路由) */
|
||||
export type TodoCenterItem = {
|
||||
code: string;
|
||||
label: string;
|
||||
count: number;
|
||||
label: string;
|
||||
path: string;
|
||||
};
|
||||
|
||||
@@ -63,35 +63,35 @@ export type TodoCenterResult = {
|
||||
|
||||
/** 未来7天预约:单日分布 */
|
||||
export type AppointmentUpcomingDay = {
|
||||
count: number;
|
||||
date: string;
|
||||
label: string;
|
||||
week: string;
|
||||
count: number;
|
||||
};
|
||||
|
||||
/** 未来7天预约统计(卡片) */
|
||||
export type AppointmentUpcomingResult = {
|
||||
total: number;
|
||||
days: AppointmentUpcomingDay[];
|
||||
total: number;
|
||||
};
|
||||
|
||||
/** 未来7天预约明细行 */
|
||||
export type AppointmentUpcomingItem = {
|
||||
register_id: number;
|
||||
doctor_name: string;
|
||||
end_time: string;
|
||||
is_pay: number;
|
||||
order_no: string;
|
||||
visit_date: string;
|
||||
week: string;
|
||||
patient_name: string;
|
||||
price: string;
|
||||
register_id: number;
|
||||
slot_label: string;
|
||||
slot_name: 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_text: string;
|
||||
store_name: string;
|
||||
visit_date: string;
|
||||
week: string;
|
||||
};
|
||||
|
||||
/** 未来7天预约明细分页结果 */
|
||||
@@ -103,17 +103,19 @@ export type AppointmentUpcomingListResult = {
|
||||
/** AI 今日简报(管理员=昨日经营+今日待办,医生=今日值班+挂号+预约) */
|
||||
export type AiDailyBriefResult = {
|
||||
brief_date: string;
|
||||
/** 1=生成成功 0=生成失败(失败时 error_msg 有值,data 快照仍可展示) */
|
||||
status: number;
|
||||
content: string;
|
||||
error_msg: string;
|
||||
/** 生成时收集的结构化业务数据快照(统计芯片直接用,保证与 AI 文本口径一致) */
|
||||
data: Record<string, any>;
|
||||
provider: string;
|
||||
model: string;
|
||||
generated_at: string;
|
||||
/** 系统配置总开关;false 时前端应隐藏且后端不会生成 */
|
||||
enabled?: boolean;
|
||||
error_msg: string;
|
||||
/** true=命中当日缓存(非今天首次生成,前端据此决定是否弹当日简报弹窗) */
|
||||
from_cache: boolean;
|
||||
generated_at: string;
|
||||
model: string;
|
||||
provider: string;
|
||||
/** 1=生成成功 0=生成失败(失败时 error_msg 有值,data 快照仍可展示) */
|
||||
status: number;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -39,13 +39,83 @@ const [BriefModal, briefModalApi] = useVbenModal({
|
||||
* 正文分段解析:后端约定 AI 输出「【昨日概览】...【重点提醒】...【今日建议】...」格式,
|
||||
* 按【标题】切段渲染更易读;AI 偶发不按格式输出时兜底整段展示,保证内容不丢
|
||||
*/
|
||||
const sections = computed(() => {
|
||||
const content = brief.value?.content || '';
|
||||
const sections = computed(() => parseBriefSections(brief.value?.content || ''));
|
||||
|
||||
/**
|
||||
* 简报分段:优先按【标题】切;整段是 JSON 时再拆一层(Agent 偶发包 JSON)
|
||||
*/
|
||||
function parseBriefSections(
|
||||
raw: string,
|
||||
): Array<{ text: string; title: string }> {
|
||||
const content = unwrapBriefContent(raw);
|
||||
if (!content) return [];
|
||||
const matches = [...content.matchAll(/【([^】]+)】([^【]*)/g)];
|
||||
if (matches.length === 0) return [{ title: '', text: content }];
|
||||
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 文本口径一致
|
||||
@@ -57,32 +127,53 @@ const chips = computed(() => {
|
||||
if (data.scope === 'admin') {
|
||||
const y = data.yesterday || {};
|
||||
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;
|
||||
return [
|
||||
{ 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:list-checks', label: '今日待办', value: todoTotal, highlight: todoTotal > 0 },
|
||||
{
|
||||
icon: 'lucide:stethoscope',
|
||||
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 schedule = data.schedule || {};
|
||||
const upcoming = data.upcoming || {};
|
||||
return [
|
||||
{
|
||||
icon: 'lucide:calendar-check',
|
||||
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',
|
||||
label: '今日到期预约',
|
||||
value: today.appointment_due_waiting ?? 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;
|
||||
fetchError.value = false;
|
||||
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();
|
||||
} catch {
|
||||
fetchError.value = true;
|
||||
@@ -161,7 +258,9 @@ onMounted(() => fetchBrief());
|
||||
<div v-else-if="fetchError" class="ai-brief__error">
|
||||
<VbenIcon icon="lucide:cloud-off" />
|
||||
<span>简报加载失败</span>
|
||||
<button type="button" class="ai-brief__retry" @click="fetchBrief()">重试</button>
|
||||
<button type="button" class="ai-brief__retry" @click="fetchBrief()">
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- AI 生成失败态:快照芯片仍可看,正文位置给重试引导 -->
|
||||
@@ -180,8 +279,16 @@ onMounted(() => fetchBrief());
|
||||
</div>
|
||||
<div class="ai-brief__error">
|
||||
<VbenIcon icon="lucide:bot-off" />
|
||||
<span>AI 生成失败{{ brief.error_msg ? `:${brief.error_msg}` : '' }}</span>
|
||||
<button type="button" class="ai-brief__retry" @click="fetchBrief(true)">重新生成</button>
|
||||
<span>AI 生成失败{{
|
||||
brief.error_msg ? `:${brief.error_msg}` : ''
|
||||
}}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="ai-brief__retry"
|
||||
@click="fetchBrief(true)"
|
||||
>
|
||||
重新生成
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -199,9 +306,21 @@ onMounted(() => fetchBrief());
|
||||
<span class="ai-chip__label">{{ chip.label }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="ai-brief__body" 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>
|
||||
<div
|
||||
class="ai-brief__body"
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -232,8 +351,14 @@ onMounted(() => fetchBrief());
|
||||
</span>
|
||||
</div>
|
||||
<div class="ai-popup__body">
|
||||
<div 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>
|
||||
<div
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -267,7 +392,7 @@ onMounted(() => fetchBrief());
|
||||
place-items: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
background: hsl(var(--primary) / 0.1);
|
||||
background: hsl(var(--primary) / 10%);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
@@ -297,12 +422,12 @@ onMounted(() => fetchBrief());
|
||||
height: 26px;
|
||||
padding: 0 10px;
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color 0.15s ease,
|
||||
border-color 0.15s ease,
|
||||
@@ -311,8 +436,8 @@ onMounted(() => fetchBrief());
|
||||
|
||||
.ai-brief__refresh:hover:not(:disabled) {
|
||||
color: hsl(var(--primary));
|
||||
border-color: hsl(var(--primary) / 0.4);
|
||||
background: hsl(var(--primary) / 0.06);
|
||||
background: hsl(var(--primary) / 6%);
|
||||
border-color: hsl(var(--primary) / 40%);
|
||||
}
|
||||
|
||||
.ai-brief__refresh:disabled {
|
||||
@@ -340,7 +465,7 @@ onMounted(() => fetchBrief());
|
||||
.ai-brief__loading-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: hsl(var(--primary) / 0.7);
|
||||
background: hsl(var(--primary) / 70%);
|
||||
border-radius: 50%;
|
||||
animation: ai-bounce 1.2s ease-in-out infinite;
|
||||
}
|
||||
@@ -370,33 +495,33 @@ onMounted(() => fetchBrief());
|
||||
}
|
||||
|
||||
.ai-brief__error :deep(svg) {
|
||||
flex-shrink: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ai-brief__retry {
|
||||
flex-shrink: 0;
|
||||
height: 26px;
|
||||
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;
|
||||
color: hsl(var(--primary));
|
||||
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;
|
||||
}
|
||||
|
||||
.ai-brief__retry:hover {
|
||||
background: hsl(var(--primary) / 0.12);
|
||||
background: hsl(var(--primary) / 12%);
|
||||
}
|
||||
|
||||
/* ===== 快照统计 pill ===== */
|
||||
.ai-brief__chips {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
@@ -405,14 +530,14 @@ onMounted(() => fetchBrief());
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
padding: 5px 10px;
|
||||
border: 1px solid hsl(var(--border) / 0.7);
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
border: 1px solid hsl(var(--border) / 70%);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.ai-chip--hl {
|
||||
border-color: hsl(var(--warning) / 0.45);
|
||||
background: hsl(var(--warning) / 0.1);
|
||||
background: hsl(var(--warning) / 10%);
|
||||
border-color: hsl(var(--warning) / 45%);
|
||||
}
|
||||
|
||||
.ai-chip__icon {
|
||||
@@ -442,9 +567,9 @@ onMounted(() => fetchBrief());
|
||||
max-height: 180px;
|
||||
padding: 12px 14px;
|
||||
overflow-y: auto;
|
||||
background: hsl(var(--muted) / 0.25);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
background: hsl(var(--muted) / 25%);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.ai-brief__section {
|
||||
@@ -456,18 +581,18 @@ onMounted(() => fetchBrief());
|
||||
.ai-brief__section-tag {
|
||||
align-self: flex-start;
|
||||
padding: 1px 8px;
|
||||
background: hsl(var(--primary) / 0.1);
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--primary));
|
||||
background: hsl(var(--primary) / 10%);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.ai-brief__section-text {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: hsl(var(--foreground) / 0.9);
|
||||
color: hsl(var(--foreground) / 90%);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
@@ -475,13 +600,13 @@ onMounted(() => fetchBrief());
|
||||
display: inline-flex;
|
||||
gap: 2px;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: hsl(var(--primary));
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.ai-brief__more :deep(svg) {
|
||||
@@ -528,16 +653,16 @@ onMounted(() => fetchBrief());
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.8;
|
||||
color: hsl(var(--foreground) / 0.92);
|
||||
color: hsl(var(--foreground) / 92%);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.ai-popup__meta {
|
||||
margin-top: 14px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid hsl(var(--border) / 0.5);
|
||||
margin-top: 14px;
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
border-top: 1px solid hsl(var(--border) / 50%);
|
||||
}
|
||||
|
||||
@keyframes ai-spin {
|
||||
@@ -553,6 +678,7 @@ onMounted(() => fetchBrief());
|
||||
opacity: 0.35;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
40% {
|
||||
opacity: 1;
|
||||
transform: translateY(-3px);
|
||||
|
||||
@@ -51,7 +51,7 @@ function goNoticeCenter() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 进入个人中心(静态路由 /profile,与顶栏头像下拉同一入口)
|
||||
* 进入个人中心(路由由后端 xk_menu 隐藏菜单下发,与顶栏头像下拉同一入口)
|
||||
* 头像/姓名区域与右侧按钮都走这里
|
||||
*/
|
||||
function goProfile() {
|
||||
@@ -142,21 +142,21 @@ onMounted(fetchUnreadCount);
|
||||
background:
|
||||
linear-gradient(
|
||||
120deg,
|
||||
hsl(var(--primary) / 0.1),
|
||||
hsl(var(--primary) / 0.03) 45%,
|
||||
hsl(var(--primary) / 10%),
|
||||
hsl(var(--primary) / 3%) 45%,
|
||||
transparent 70%
|
||||
),
|
||||
hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border) / 0.7);
|
||||
border: 1px solid hsl(var(--border) / 70%);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
/* 装饰光斑:低透明度 + 大模糊,暗色主题下自然减弱 */
|
||||
.ws-header__orb {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
border-radius: 50%;
|
||||
filter: blur(64px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ws-header__orb--1 {
|
||||
@@ -164,7 +164,7 @@ onMounted(fetchUnreadCount);
|
||||
right: 10%;
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
background: hsl(var(--primary) / 0.18);
|
||||
background: hsl(var(--primary) / 18%);
|
||||
}
|
||||
|
||||
.ws-header__orb--2 {
|
||||
@@ -172,7 +172,7 @@ onMounted(fetchUnreadCount);
|
||||
bottom: -80px;
|
||||
width: 160px;
|
||||
height: 160px;
|
||||
background: hsl(var(--primary) / 0.1);
|
||||
background: hsl(var(--primary) / 10%);
|
||||
}
|
||||
|
||||
.ws-header__left {
|
||||
@@ -193,7 +193,7 @@ onMounted(fetchUnreadCount);
|
||||
}
|
||||
|
||||
.ws-header__left--clickable:hover {
|
||||
background: hsl(var(--primary) / 0.08);
|
||||
background: hsl(var(--primary) / 8%);
|
||||
}
|
||||
|
||||
.ws-header__left--clickable:hover .ws-header__title {
|
||||
@@ -203,13 +203,13 @@ onMounted(fetchUnreadCount);
|
||||
/* 头像光环:primary 低透明双环 */
|
||||
.ws-header__avatar-ring {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
padding: 3px;
|
||||
background: hsl(var(--card));
|
||||
border-radius: 50%;
|
||||
box-shadow:
|
||||
0 0 0 2px hsl(var(--primary) / 0.25),
|
||||
0 4px 12px hsl(var(--primary) / 0.12);
|
||||
flex-shrink: 0;
|
||||
0 0 0 2px hsl(var(--primary) / 25%),
|
||||
0 4px 12px hsl(var(--primary) / 12%);
|
||||
}
|
||||
|
||||
.ws-header__text {
|
||||
@@ -218,24 +218,24 @@ onMounted(fetchUnreadCount);
|
||||
|
||||
.ws-header__title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 16px;
|
||||
font-weight: 650;
|
||||
color: hsl(var(--foreground));
|
||||
letter-spacing: -0.01em;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ws-header__role {
|
||||
display: inline-block;
|
||||
margin-left: 8px;
|
||||
padding: 1px 8px;
|
||||
margin-left: 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--primary));
|
||||
vertical-align: 2px;
|
||||
background: hsl(var(--primary) / 0.1);
|
||||
border: 1px solid hsl(var(--primary) / 0.2);
|
||||
color: hsl(var(--primary));
|
||||
background: hsl(var(--primary) / 10%);
|
||||
border: 1px solid hsl(var(--primary) / 20%);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
@@ -260,8 +260,8 @@ onMounted(fetchUnreadCount);
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
cursor: pointer;
|
||||
background: hsl(var(--muted) / 0.4);
|
||||
border: 1px solid hsl(var(--border) / 0.7);
|
||||
background: hsl(var(--muted) / 40%);
|
||||
border: 1px solid hsl(var(--border) / 70%);
|
||||
border-radius: 50%;
|
||||
transition:
|
||||
background-color 0.2s ease,
|
||||
@@ -270,15 +270,15 @@ onMounted(fetchUnreadCount);
|
||||
}
|
||||
|
||||
.ws-header__bell:hover {
|
||||
background: hsl(var(--primary) / 0.1);
|
||||
border-color: hsl(var(--primary) / 0.3);
|
||||
background: hsl(var(--primary) / 10%);
|
||||
border-color: hsl(var(--primary) / 30%);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.ws-header__bell-icon {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
color: hsl(var(--foreground) / 0.75);
|
||||
color: hsl(var(--foreground) / 75%);
|
||||
}
|
||||
|
||||
.ws-header__bell:hover .ws-header__bell-icon {
|
||||
|
||||
@@ -8,12 +8,12 @@ import { computed, ref } from 'vue';
|
||||
|
||||
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 { getPrescriptionInfoApi } from '#/views/doctor/doctor-reception/api';
|
||||
import { formatDoctorOrderText } from '#/utils/prescriptionDoctorOrder';
|
||||
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 RejectionReason from '#/views/pharmacist/audit-prescription/components/modal.vue';
|
||||
|
||||
@@ -98,8 +98,8 @@ async function handlePass() {
|
||||
const refresh = onAudited.value;
|
||||
refresh?.();
|
||||
modalApi.close();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
passing.value = false;
|
||||
}
|
||||
@@ -163,14 +163,16 @@ function buildPrintHtml(fileName: string): null | string {
|
||||
const printBox = document.querySelector('.print-box');
|
||||
if (!printBox) return null;
|
||||
const styleTags: string[] = [];
|
||||
document.querySelectorAll('style, link[rel="stylesheet"]').forEach((style) => {
|
||||
// link 取绝对地址(.href 属性自动补全),隐藏窗口加载临时文件时才能命中样式
|
||||
styleTags.push(
|
||||
style.tagName === 'LINK'
|
||||
? `<link rel="stylesheet" href="${(style as HTMLLinkElement).href}" />`
|
||||
: style.outerHTML,
|
||||
);
|
||||
});
|
||||
document
|
||||
.querySelectorAll('style, link[rel="stylesheet"]')
|
||||
.forEach((style) => {
|
||||
// link 取绝对地址(.href 属性自动补全),隐藏窗口加载临时文件时才能命中样式
|
||||
styleTags.push(
|
||||
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>`;
|
||||
}
|
||||
|
||||
@@ -249,7 +251,7 @@ const getImageSource = (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 (typeof content === 'string') {
|
||||
try {
|
||||
@@ -299,7 +301,9 @@ function parseRecipeContent(content: string | Record<string, unknown>) {
|
||||
allow-clear
|
||||
@change="
|
||||
(value: any) =>
|
||||
updatePrintSettings({ printerName: (value as string) || '' })
|
||||
updatePrintSettings({
|
||||
printerName: (value as string) || '',
|
||||
})
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
@@ -362,7 +366,9 @@ function parseRecipeContent(content: string | Record<string, unknown>) {
|
||||
<span class="drug-name">{{
|
||||
drug?.name || drug?.drug_name
|
||||
}}</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>
|
||||
方法:
|
||||
@@ -393,20 +399,14 @@ function parseRecipeContent(content: string | Record<string, unknown>) {
|
||||
>
|
||||
<template
|
||||
v-for="drug in [parseRecipeContent(recipe.content)]"
|
||||
:key="`west-${index}`"
|
||||
:key="drug?.id || drug?.name || `west-${index}`"
|
||||
>
|
||||
<div class="medicine-item">
|
||||
<span class="drug-name"
|
||||
>{{ drug?.name || drug?.drug_name
|
||||
}}<span
|
||||
v-if="drug?.specification"
|
||||
class="drug-spec"
|
||||
>{{ drug.specification }}</span
|
||||
></span
|
||||
>
|
||||
<span class="drug-quantity"
|
||||
>{{ drug?.number }}{{ drug?.unit?.name }}</span
|
||||
>
|
||||
<span class="drug-name">{{ drug?.name || drug?.drug_name
|
||||
}}<span v-if="drug?.specification" class="drug-spec">{{
|
||||
drug.specification
|
||||
}}</span></span>
|
||||
<span class="drug-quantity">{{ drug?.number }}{{ drug?.unit?.name }}</span>
|
||||
<div v-if="drug?.useWay" class="usage-info">
|
||||
{{ drug.useWay }}
|
||||
</div>
|
||||
@@ -432,23 +432,23 @@ function parseRecipeContent(content: string | Record<string, unknown>) {
|
||||
getImageSource(item.doctor_info.identity_info.sign_image)
|
||||
"
|
||||
alt="签名"
|
||||
style="width: 120px; height: 50px; display: inline-block"
|
||||
style="display: inline-block; width: 120px; height: 50px"
|
||||
/>
|
||||
<!-- 二次签名:相冲继续开方时展示第二张医生签名 -->
|
||||
<img
|
||||
v-if="
|
||||
item.doctor_second_sign == 1 &&
|
||||
item.doctor_second_sign === 1 &&
|
||||
item.doctor_info?.identity_info?.sign_image
|
||||
"
|
||||
:src="
|
||||
getImageSource(item.doctor_info.identity_info.sign_image)
|
||||
"
|
||||
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">{{
|
||||
item.content?.doctor?.name
|
||||
}}</span>
|
||||
<span
|
||||
v-else-if="!item.doctor_info?.identity_info?.sign_image"
|
||||
>{{ item.content?.doctor?.name }}</span>
|
||||
</div>
|
||||
<div class="signature">
|
||||
<label>审核药师:</label>
|
||||
@@ -458,21 +458,18 @@ function parseRecipeContent(content: string | Record<string, unknown>) {
|
||||
getImageSource(item.pharmacist_info.identity.sign_image)
|
||||
"
|
||||
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>
|
||||
</div>
|
||||
<div class="signature">
|
||||
<label>调配人:</label>
|
||||
<img
|
||||
v-if="item.doctor_info?.identity_info?.sign_image"
|
||||
:src="
|
||||
getImageSource(item.doctor_info.identity_info.sign_image)
|
||||
"
|
||||
v-if="item.dispenser_sign_image"
|
||||
:src="getImageSource(item.dispenser_sign_image)"
|
||||
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 class="signature">
|
||||
<label>核对人:</label>
|
||||
@@ -482,21 +479,18 @@ function parseRecipeContent(content: string | Record<string, unknown>) {
|
||||
getImageSource(item.pharmacist_info.identity.sign_image)
|
||||
"
|
||||
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>
|
||||
</div>
|
||||
<div class="signature">
|
||||
<label>发药人:</label>
|
||||
<img
|
||||
v-if="item.doctor_info?.identity_info?.sign_image"
|
||||
:src="
|
||||
getImageSource(item.doctor_info.identity_info.sign_image)
|
||||
"
|
||||
v-if="item.sender_sign_image"
|
||||
:src="getImageSource(item.sender_sign_image)"
|
||||
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="price-info"></div>
|
||||
@@ -504,7 +498,7 @@ function parseRecipeContent(content: string | Record<string, unknown>) {
|
||||
<div
|
||||
v-if="item.status === 2"
|
||||
class="validity"
|
||||
style="color: red; font-weight: bold"
|
||||
style="font-weight: bold; color: red"
|
||||
>
|
||||
该处方未通过审核
|
||||
</div>
|
||||
@@ -534,101 +528,123 @@ function parseRecipeContent(content: string | Record<string, unknown>) {
|
||||
.dark .signature img {
|
||||
filter: invert(100%);
|
||||
}
|
||||
|
||||
.prescription-container {
|
||||
padding: 20px;
|
||||
font-family: 'SimSun', serif;
|
||||
font-family: SimSun, serif;
|
||||
}
|
||||
|
||||
.prescription-header {
|
||||
border-bottom: 2px solid #000;
|
||||
padding-bottom: 15px;
|
||||
margin-bottom: 20px;
|
||||
border-bottom: 2px solid #000;
|
||||
}
|
||||
|
||||
.header-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.prescription-type {
|
||||
border: 1px solid #666;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid #666;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.clinic-name {
|
||||
text-align: center;
|
||||
font-size: 24px;
|
||||
margin: 15px 0;
|
||||
font-size: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.prescription-date {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.patient-info {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.medicine-list {
|
||||
min-height: 200px;
|
||||
padding: 15px 0;
|
||||
border-top: 1px dashed #666;
|
||||
border-bottom: 1px dashed #666;
|
||||
padding: 15px 0;
|
||||
}
|
||||
|
||||
.rp-title {
|
||||
font-weight: bold;
|
||||
font-size: 18px;
|
||||
margin-bottom: 10px;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.recipe-item {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.medicine-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin: 5px 0;
|
||||
}
|
||||
|
||||
.drug-name {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.drug-spec {
|
||||
margin-left: 6px;
|
||||
font-weight: normal;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.usage-info {
|
||||
width: 100%;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.preparation-info {
|
||||
margin-top: 8px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.footer-section {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.medical-advice {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.signature-area {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px 24px;
|
||||
margin: 15px 0;
|
||||
}
|
||||
|
||||
.signature {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.price-info {
|
||||
text-align: right;
|
||||
margin-top: 8px;
|
||||
font-weight: bold;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.validity {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.validity {
|
||||
margin-top: 8px;
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -17,20 +17,20 @@ defineOptions({
|
||||
});
|
||||
|
||||
const props = defineProps<{
|
||||
/** 银行卡信息 */
|
||||
myCard: Record<string, any> | null;
|
||||
/** 是否门店账号(隐藏未确认收货冻结) */
|
||||
isStoreUser: boolean;
|
||||
balance: number;
|
||||
/** 累计代付手续费(平台 charge) */
|
||||
fee: number;
|
||||
frozenAmount: number;
|
||||
/** 是否平台账号(展示累计代付手续费) */
|
||||
isPlatformUser: boolean;
|
||||
balance: number;
|
||||
/** 是否门店账号(隐藏未确认收货冻结) */
|
||||
isStoreUser: boolean;
|
||||
/** 银行卡信息 */
|
||||
myCard: null | Record<string, any>;
|
||||
pendingEarnings: number;
|
||||
totalEarnings: number;
|
||||
withdrawnAmount: number;
|
||||
withdrawnFrozenAmount: number;
|
||||
frozenAmount: number;
|
||||
/** 累计代付手续费(平台 charge) */
|
||||
fee: number;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -84,56 +84,59 @@ const maskedCard = computed(() => {
|
||||
});
|
||||
|
||||
/**
|
||||
* 组装统计岛:核心字段优先,平台额外展示累计代付手续费
|
||||
* 每项配一个 SVG iconify 图标(规范:不用 emoji),放在卡片右上角
|
||||
* 组装统计岛:顺序与改版前 AnalysisOverview 一致
|
||||
* 累计收益 → 可提现余额 → 未确认收货冻结(非门店)→ 待结算收益 → 审核中金额 → 已提现金额
|
||||
* 平台额外在末尾展示累计代付手续费
|
||||
*/
|
||||
const statItems = computed(() => {
|
||||
const items: {
|
||||
emphasize?: boolean;
|
||||
icon: string;
|
||||
key: string;
|
||||
label: string;
|
||||
value: number;
|
||||
icon: string;
|
||||
emphasize?: boolean;
|
||||
}[] = [
|
||||
{
|
||||
key: 'total',
|
||||
label: '累计收益',
|
||||
value: Number(props.totalEarnings || 0),
|
||||
icon: 'lucide:trending-up',
|
||||
},
|
||||
{
|
||||
key: 'balance',
|
||||
label: '可提现余额',
|
||||
value: Number(props.balance || 0),
|
||||
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',
|
||||
label: '审核中金额',
|
||||
value: Number(props.withdrawnFrozenAmount || 0),
|
||||
icon: 'lucide:clock',
|
||||
},
|
||||
{
|
||||
key: 'pending',
|
||||
label: '待结算收益',
|
||||
value: Number(props.pendingEarnings || 0),
|
||||
icon: 'lucide:hourglass',
|
||||
},
|
||||
{
|
||||
key: 'withdrawn',
|
||||
label: '已提现金额',
|
||||
value: Number(props.withdrawnAmount || 0),
|
||||
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) {
|
||||
items.push({
|
||||
key: 'fee',
|
||||
@@ -157,11 +160,13 @@ function formatMoney(val: number) {
|
||||
<div class="account-info-card">
|
||||
<div class="card-header">
|
||||
<div class="header-title">
|
||||
<span class="title-dot" />
|
||||
<span class="title-dot"></span>
|
||||
{{ subjectName }} · 账户信息
|
||||
</div>
|
||||
<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" type="text" @click="emit('refresh')">
|
||||
<template #icon>
|
||||
@@ -189,26 +194,30 @@ function formatMoney(val: number) {
|
||||
@mousemove="onCardMove"
|
||||
@mouseleave="onCardLeave"
|
||||
>
|
||||
<div class="bank-bg" />
|
||||
<div class="bank-brush" />
|
||||
<div class="bank-decorator decorator-a" />
|
||||
<div class="bank-decorator decorator-b" />
|
||||
<div class="bank-bg"></div>
|
||||
<div class="bank-brush"></div>
|
||||
<div class="bank-decorator decorator-a"></div>
|
||||
<div class="bank-decorator decorator-b"></div>
|
||||
<div
|
||||
v-if="glowX >= 0"
|
||||
class="bank-glow"
|
||||
:style="{ left: `${glowX}px`, top: `${glowY}px` }"
|
||||
/>
|
||||
<div v-if="!prefersReduced" class="bank-sheen" />
|
||||
></div>
|
||||
<div v-if="!prefersReduced" class="bank-sheen"></div>
|
||||
|
||||
<div class="bank-content">
|
||||
<header class="bank-row bank-row--top">
|
||||
<div class="bank-brand">
|
||||
<span class="bank-chip" />
|
||||
<span class="bank-name">{{ myCard?.bank_name || '未绑定银行卡' }}</span>
|
||||
<span class="bank-chip"></span>
|
||||
<span class="bank-name">{{
|
||||
myCard?.bank_name || '未绑定银行卡'
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="bank-logo">
|
||||
<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>
|
||||
</header>
|
||||
|
||||
@@ -217,7 +226,9 @@ function formatMoney(val: number) {
|
||||
<div class="bank-row bank-row--bottom">
|
||||
<div class="bank-meta">
|
||||
<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>
|
||||
<span class="bank-currency">CNY</span>
|
||||
</div>
|
||||
@@ -240,7 +251,7 @@ function formatMoney(val: number) {
|
||||
:class="{ 'stat-island--fee': item.emphasize }"
|
||||
:aria-label="item.label"
|
||||
>
|
||||
<div class="stat-island-glow" />
|
||||
<div class="stat-island-glow"></div>
|
||||
<!-- 右上角图标(规范:SVG 图标,固定尺寸) -->
|
||||
<VbenIcon class="stat-icon" :icon="item.icon" />
|
||||
<div class="stat-label">{{ item.label }}</div>
|
||||
@@ -255,27 +266,27 @@ function formatMoney(val: number) {
|
||||
|
||||
<style scoped lang="scss">
|
||||
.account-info-card {
|
||||
margin-bottom: 12px;
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 12px;
|
||||
background: hsl(var(--card, var(--background)));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 14px;
|
||||
background: hsl(var(--card, var(--background)));
|
||||
box-shadow: 0 1px 2px rgb(0 0 0 / 4%);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
@@ -284,37 +295,34 @@ function formatMoney(val: number) {
|
||||
.title-dot {
|
||||
width: 4px;
|
||||
height: 16px;
|
||||
border-radius: 2px;
|
||||
|
||||
/* 金色,呼应银行卡金质感 */
|
||||
background: linear-gradient(180deg, #d4a73a 0%, #b88a2a 100%);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* ===== 左右布局:左侧银行卡收紧,右侧统计岛一行四个 ===== */
|
||||
.card-layout {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 12px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
/* 银行卡:左侧适中宽度,纵向撑满 */
|
||||
.bank-card {
|
||||
flex: 0 0 360px;
|
||||
min-width: 0;
|
||||
}
|
||||
/* 银行卡宽度在左右布局里单独收紧,视觉样式见下方完整 .bank-card */
|
||||
|
||||
/* 统计岛列:右侧一行四个,自动折行 */
|
||||
.stats-col {
|
||||
display: grid;
|
||||
flex: 1 1 auto;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -342,26 +350,30 @@ function formatMoney(val: number) {
|
||||
/* ===== 银行卡:炭灰金质感(模拟真实银行卡) ===== */
|
||||
.bank-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex: 0 0 360px;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
min-width: 0;
|
||||
padding: 16px 18px;
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
color: #f5f5f7;
|
||||
isolation: isolate;
|
||||
cursor: default;
|
||||
transition: transform 0.25s ease, box-shadow 0.25s ease;
|
||||
border-radius: 14px;
|
||||
box-shadow:
|
||||
0 8px 20px rgb(0 0 0 / 30%),
|
||||
0 2px 6px rgb(0 0 0 / 20%);
|
||||
isolation: isolate;
|
||||
transition:
|
||||
transform 0.25s ease,
|
||||
box-shadow 0.25s ease;
|
||||
}
|
||||
|
||||
.bank-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow:
|
||||
0 14px 28px rgb(0 0 0 / 40%),
|
||||
0 4px 10px rgb(0 0 0 / 25%);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* 底层:深炭灰渐变,模拟高端银行卡底色 */
|
||||
@@ -382,22 +394,21 @@ function formatMoney(val: number) {
|
||||
position: absolute;
|
||||
inset: 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;
|
||||
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 {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
content: '';
|
||||
background:
|
||||
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%);
|
||||
@@ -406,9 +417,9 @@ function formatMoney(val: number) {
|
||||
/* 装饰几何 */
|
||||
.bank-decorator {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.decorator-a {
|
||||
@@ -420,8 +431,8 @@ function formatMoney(val: number) {
|
||||
}
|
||||
|
||||
.decorator-b {
|
||||
bottom: -55px;
|
||||
right: 50px;
|
||||
bottom: -55px;
|
||||
width: 110px;
|
||||
height: 110px;
|
||||
border: 16px solid rgb(255 255 255 / 5%);
|
||||
@@ -430,11 +441,10 @@ function formatMoney(val: number) {
|
||||
/* 鼠标光泽跟随(毛玻璃质感的核心) */
|
||||
.bank-glow {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
width: 220px;
|
||||
height: 220px;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
transform: translate(-50%, -50%);
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
rgb(255 255 255 / 18%) 0%,
|
||||
@@ -443,6 +453,7 @@ function formatMoney(val: number) {
|
||||
);
|
||||
mix-blend-mode: screen;
|
||||
filter: blur(2px);
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
/* 周期扫光(金色,更克制) */
|
||||
@@ -469,6 +480,7 @@ function formatMoney(val: number) {
|
||||
60% {
|
||||
left: -60%;
|
||||
}
|
||||
|
||||
100% {
|
||||
left: 130%;
|
||||
}
|
||||
@@ -484,54 +496,54 @@ function formatMoney(val: number) {
|
||||
|
||||
.bank-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.bank-brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 金色芯片,模拟实体卡芯片 */
|
||||
.bank-chip {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
height: 18px;
|
||||
border-radius: 3px;
|
||||
background: linear-gradient(135deg, #f0d27a 0%, #c89a35 50%, #a37c28 100%);
|
||||
border-radius: 3px;
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgb(0 0 0 / 25%),
|
||||
inset 0 -2px 4px rgb(0 0 0 / 20%);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 芯片纹理 */
|
||||
.bank-chip::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 3px 4px;
|
||||
border-left: 1px solid rgb(0 0 0 / 30%);
|
||||
content: '';
|
||||
border-right: 1px solid rgb(0 0 0 / 30%);
|
||||
border-left: 1px solid rgb(0 0 0 / 30%);
|
||||
}
|
||||
|
||||
.bank-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.4px;
|
||||
text-overflow: ellipsis;
|
||||
text-shadow: 0 1px 2px rgb(0 0 0 / 40%);
|
||||
white-space: nowrap;
|
||||
text-shadow: 0 1px 2px rgb(0 0 0 / 40%);
|
||||
}
|
||||
|
||||
.bank-logo {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
color: #d4a73a;
|
||||
}
|
||||
|
||||
@@ -567,8 +579,8 @@ function formatMoney(val: number) {
|
||||
|
||||
.bank-meta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -583,16 +595,16 @@ function formatMoney(val: number) {
|
||||
.bank-no {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
letter-spacing: 0.5px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bank-currency {
|
||||
flex-shrink: 0;
|
||||
padding: 1px 6px;
|
||||
font-size: 11px;
|
||||
letter-spacing: 1px;
|
||||
color: #d4a73a;
|
||||
letter-spacing: 1px;
|
||||
border: 1px solid rgb(212 167 58 / 40%);
|
||||
border-radius: 4px;
|
||||
}
|
||||
@@ -600,17 +612,17 @@ function formatMoney(val: number) {
|
||||
/* ===== 统计岛:玻璃质感 + 右上角图标 + 淡荧光 ===== */
|
||||
.stat-island {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 14px;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
cursor: default;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
hsl(var(--primary) / 0.04),
|
||||
hsl(var(--primary) / 4%),
|
||||
hsl(var(--card, var(--background)))
|
||||
);
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 12px;
|
||||
backdrop-filter: blur(8px);
|
||||
cursor: default;
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
box-shadow 0.2s ease,
|
||||
@@ -618,11 +630,11 @@ function formatMoney(val: number) {
|
||||
}
|
||||
|
||||
.stat-island:hover {
|
||||
transform: translateY(-3px);
|
||||
border-color: hsl(var(--primary) / 0.4);
|
||||
border-color: hsl(var(--primary) / 40%);
|
||||
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%);
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
|
||||
/* 角落局部高光,强化荧光感 */
|
||||
@@ -635,7 +647,7 @@ function formatMoney(val: number) {
|
||||
pointer-events: none;
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
hsl(var(--primary) / 0.18) 0%,
|
||||
hsl(var(--primary) / 18%) 0%,
|
||||
transparent 70%
|
||||
);
|
||||
opacity: 0.5;
|
||||
@@ -648,12 +660,12 @@ function formatMoney(val: number) {
|
||||
|
||||
/* 累计代付手续费:稍强荧光 + primary 色字 */
|
||||
.stat-island--fee {
|
||||
border-color: hsl(var(--primary) / 0.3);
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
hsl(var(--primary) / 0.1),
|
||||
hsl(var(--primary) / 10%),
|
||||
hsl(var(--card, var(--background)))
|
||||
);
|
||||
border-color: hsl(var(--primary) / 30%);
|
||||
}
|
||||
|
||||
.stat-island--fee .stat-island-glow {
|
||||
@@ -667,7 +679,7 @@ function formatMoney(val: number) {
|
||||
right: 12px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
color: hsl(var(--primary) / 0.7);
|
||||
color: hsl(var(--primary) / 70%);
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
@@ -682,11 +694,11 @@ function formatMoney(val: number) {
|
||||
.stat-label {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin-bottom: 6px;
|
||||
padding-right: 32px;
|
||||
margin-bottom: 6px;
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
line-height: 1.2;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.stat-val {
|
||||
@@ -695,8 +707,8 @@ function formatMoney(val: number) {
|
||||
font-family: 'DIN Alternate', ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: hsl(var(--foreground));
|
||||
line-height: 1.2;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.stat-val-prefix {
|
||||
|
||||
@@ -7,16 +7,16 @@ import { computed, ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { Button, Descriptions, Spin, Tag, message } from 'ant-design-vue';
|
||||
import { Button, Descriptions, message, Spin, Tag } from 'ant-design-vue';
|
||||
|
||||
import { getAiGenerationDetail } from '../api';
|
||||
import GenerationFlowTimeline from './generation-flow-timeline.vue';
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** 打开页面级打分弹窗 */
|
||||
review: [row: Record<string, any>];
|
||||
/** 打开页面级归档弹窗 */
|
||||
archive: [row: Record<string, any>];
|
||||
/** 打开页面级打分弹窗 */
|
||||
review: [row: Record<string, any>];
|
||||
}>();
|
||||
|
||||
const loading = ref(false);
|
||||
@@ -79,6 +79,96 @@ function prettyJson(v: any) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 每日简报详情:result_json 里可能是包了一层的 JSON,先拆成【标题】正文再展示
|
||||
*/
|
||||
const briefPreview = computed(() => {
|
||||
if (String(detail.value.scene || '') !== 'daily_brief') return '';
|
||||
const raw =
|
||||
detail.value?.result_json?.content ?? detail.value?.raw_response ?? '';
|
||||
return unwrapBriefContent(String(raw || ''));
|
||||
});
|
||||
|
||||
function unwrapBriefContent(raw: string, depth = 0): string {
|
||||
let text = String(raw || '').trim();
|
||||
if (!text || depth > 4) return text;
|
||||
const fence = text.match(/```(?:json)?\s*([\s\S]*?)```/);
|
||||
if (fence?.[1]) text = fence[1].trim();
|
||||
try {
|
||||
const decoded = JSON.parse(text);
|
||||
if (typeof decoded === 'string')
|
||||
return unwrapBriefContent(decoded, depth + 1);
|
||||
if (!decoded || typeof decoded !== 'object') return text;
|
||||
if (
|
||||
typeof (decoded as any).content === 'string' &&
|
||||
(decoded as any).content.trim()
|
||||
) {
|
||||
return unwrapBriefContent((decoded as any).content, depth + 1);
|
||||
}
|
||||
if (
|
||||
typeof (decoded as any).text === 'string' &&
|
||||
(decoded as any).text.trim()
|
||||
) {
|
||||
return unwrapBriefContent((decoded as any).text, depth + 1);
|
||||
}
|
||||
const parts: string[] = [];
|
||||
Object.entries(decoded).forEach(([key, value]) => {
|
||||
if (typeof value !== 'string' && typeof value !== 'number') return;
|
||||
const title = String(key).trim();
|
||||
const body = String(value).trim();
|
||||
if (
|
||||
!title ||
|
||||
!body ||
|
||||
['brief_status', 'content', 'status', 'text'].includes(title)
|
||||
)
|
||||
return;
|
||||
parts.push(`【${title}】${body}`);
|
||||
});
|
||||
return parts.length > 0 ? parts.join('') : text;
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
/** 详情接口顶层 kb_hits:本次注入 prompt 的知识库文档 / 金方 */
|
||||
const kbHits = computed<Record<string, any>[]>(() => {
|
||||
const list = detail.value?.kb_hits;
|
||||
return Array.isArray(list) ? list : [];
|
||||
});
|
||||
|
||||
/** Agent 路径或已有检索步骤时才展示命中区,直连生成没有知识库检索 */
|
||||
const showKbHitsPanel = computed(() => {
|
||||
if (kbHits.value.length > 0) return true;
|
||||
if (Number(detail.value.via_agent) === 1) return true;
|
||||
const steps = Array.isArray(detail.value.steps) ? detail.value.steps : [];
|
||||
return steps.some(
|
||||
(s: Record<string, any>) => String(s?.step_type || '') === 'kb_retrieval',
|
||||
);
|
||||
});
|
||||
|
||||
const kbHitsEmptyText = computed(() => {
|
||||
const reason = String(detail.value.kb_retrieval_detail || '').trim();
|
||||
if (reason.includes('未检索')) return reason;
|
||||
if (reason.includes('命中 0')) return reason || '检索完成,未命中文档或方剂';
|
||||
if (reason) {
|
||||
return `${reason}。本次未落库命中正文,请确认已开启知识库检索并重新生成`;
|
||||
}
|
||||
if (Number(detail.value.via_agent) === 1) {
|
||||
return '本次无知识库检索步骤';
|
||||
}
|
||||
return '直连路径未检索知识库';
|
||||
});
|
||||
|
||||
function kbHitKindLabel(kind: string) {
|
||||
return kind === 'formula' ? '金方' : '文档';
|
||||
}
|
||||
|
||||
function formatKbScore(v: unknown) {
|
||||
const n = Number(v);
|
||||
if (!n || Number.isNaN(n)) return '';
|
||||
return n >= 10 ? n.toFixed(1) : n.toFixed(3);
|
||||
}
|
||||
|
||||
const canReview = computed(() => Number(detail.value.status) === 1);
|
||||
const canArchive = computed(
|
||||
() =>
|
||||
@@ -113,7 +203,9 @@ function onArchiveClick() {
|
||||
<Spin :spinning="loading">
|
||||
<div class="detail-scroll">
|
||||
<Descriptions bordered size="small" :column="3" class="text-sm">
|
||||
<Descriptions.Item label="ID">{{ detail.id || '—' }}</Descriptions.Item>
|
||||
<Descriptions.Item label="ID">
|
||||
{{ detail.id || '—' }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
<Tag :color="statusColor(Number(detail.status))">
|
||||
{{ detail.status_txt || detail.status }}
|
||||
@@ -133,8 +225,24 @@ function onArchiveClick() {
|
||||
}}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="供应商">{{ detail.provider || '—' }}</Descriptions.Item>
|
||||
<Descriptions.Item label="模型">{{ detail.model || '—' }}</Descriptions.Item>
|
||||
<Descriptions.Item label="缓存">
|
||||
<Tag
|
||||
:color="
|
||||
Number(detail.is_cache_hit) === 1 ? 'processing' : 'default'
|
||||
"
|
||||
>
|
||||
{{
|
||||
detail.cache_txt ||
|
||||
(Number(detail.is_cache_hit) === 1 ? '命中缓存' : '未命中')
|
||||
}}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="供应商">
|
||||
{{ detail.provider || '—' }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="模型">
|
||||
{{ detail.model || '—' }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="平台">
|
||||
{{ detail.platform_name || detail.platform_code || '—' }}
|
||||
</Descriptions.Item>
|
||||
@@ -142,13 +250,16 @@ function onArchiveClick() {
|
||||
{{ detail.api_key_name || detail.api_key_id || '—' }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Tokens">
|
||||
总 {{ detail.total_tokens || 0 }}
|
||||
(P {{ detail.prompt_tokens || 0 }} / C {{ detail.completion_tokens || 0 }})
|
||||
总 {{ detail.total_tokens || 0 }} (P
|
||||
{{ detail.prompt_tokens || 0 }} / C
|
||||
{{ detail.completion_tokens || 0 }})
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="耗时">
|
||||
{{ formatDuration(Number(detail.duration_ms || 0)) }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="采纳">{{ detail.adopted_txt || '—' }}</Descriptions.Item>
|
||||
<Descriptions.Item label="采纳">
|
||||
{{ detail.adopted_txt || '—' }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="正确性">
|
||||
<Tag
|
||||
:color="
|
||||
@@ -163,10 +274,16 @@ function onArchiveClick() {
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="得分">
|
||||
{{ Number(detail.quality_score) > 0 ? `${detail.quality_score} 分` : '—' }}
|
||||
{{
|
||||
Number(detail.quality_score) > 0
|
||||
? `${detail.quality_score} 分`
|
||||
: '—'
|
||||
}}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="归档">
|
||||
<Tag :color="Number(detail.is_archived) === 1 ? 'success' : 'default'">
|
||||
<Tag
|
||||
:color="Number(detail.is_archived) === 1 ? 'success' : 'default'"
|
||||
>
|
||||
{{ detail.archived_txt || '未归档' }}
|
||||
</Tag>
|
||||
<span
|
||||
@@ -185,10 +302,53 @@ function onArchiveClick() {
|
||||
v-if="Array.isArray(detail.steps) && detail.steps.length"
|
||||
class="mt-3 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--muted)/18%)] px-2 py-3"
|
||||
>
|
||||
<div class="mb-2 text-xs text-[hsl(var(--muted-foreground))]">生成工作流</div>
|
||||
<div class="mb-2 text-xs text-[hsl(var(--muted-foreground))]">
|
||||
生成工作流
|
||||
</div>
|
||||
<GenerationFlowTimeline :row="detail" />
|
||||
</div>
|
||||
|
||||
<div v-if="showKbHitsPanel" class="kb-hits-panel mt-3">
|
||||
<div class="kb-hits-title">
|
||||
知识库命中
|
||||
<span class="kb-hits-count">{{ kbHits.length }} 条</span>
|
||||
</div>
|
||||
<div v-if="!kbHits.length" class="kb-hits-empty">
|
||||
{{ kbHitsEmptyText }}
|
||||
</div>
|
||||
<div v-else class="kb-hits-list">
|
||||
<article
|
||||
v-for="(hit, idx) in kbHits"
|
||||
:key="`${hit.kind}-${hit.doc_id}-${idx}`"
|
||||
class="kb-hit-card"
|
||||
:class="hit.kind === 'formula' ? 'is-formula' : 'is-doc'"
|
||||
>
|
||||
<div class="kb-hit-head">
|
||||
<Tag
|
||||
:color="hit.kind === 'formula' ? 'gold' : 'blue'"
|
||||
class="!mr-0"
|
||||
>
|
||||
{{ kbHitKindLabel(String(hit.kind || 'doc')) }}
|
||||
</Tag>
|
||||
<span class="kb-hit-title">{{ hit.title || '未命名' }}</span>
|
||||
<span v-if="hit.library" class="kb-hit-lib">{{
|
||||
hit.library
|
||||
}}</span>
|
||||
<span v-if="formatKbScore(hit.score)" class="kb-hit-score">
|
||||
{{ formatKbScore(hit.score) }}
|
||||
</span>
|
||||
</div>
|
||||
<pre v-if="hit.content" class="kb-hit-body">{{
|
||||
hit.content
|
||||
}}</pre>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="briefPreview" class="json-panel mt-3">
|
||||
<div class="json-title">简报正文</div>
|
||||
<pre class="json-pre">{{ briefPreview }}</pre>
|
||||
</div>
|
||||
<div class="mt-3 grid grid-cols-1 gap-3 lg:grid-cols-2">
|
||||
<div class="json-panel">
|
||||
<div class="json-title">输入快照</div>
|
||||
@@ -216,33 +376,118 @@ function onArchiveClick() {
|
||||
<style scoped>
|
||||
.detail-scroll {
|
||||
max-height: min(72vh, 780px);
|
||||
overflow: auto;
|
||||
padding-right: 4px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.json-panel {
|
||||
overflow: hidden;
|
||||
background: hsl(var(--card, var(--background)));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 8px;
|
||||
background: hsl(var(--card, var(--background)));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.json-title {
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
background: hsl(var(--muted) / 20%);
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.json-pre {
|
||||
margin: 0;
|
||||
max-height: 220px;
|
||||
overflow: auto;
|
||||
padding: 8px 10px;
|
||||
margin: 0;
|
||||
overflow: auto;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.kb-hits-panel {
|
||||
overflow: hidden;
|
||||
background: hsl(var(--card, var(--background)));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.kb-hits-title {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
background: hsl(var(--muted) / 20%);
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.kb-hits-count {
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.kb-hits-empty {
|
||||
padding: 16px 12px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.kb-hits-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.kb-hit-card {
|
||||
padding: 10px 12px;
|
||||
background: hsl(var(--background));
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.kb-hit-card.is-doc {
|
||||
border: 1px solid hsl(var(--primary) / 30%);
|
||||
box-shadow: 0 0 6px hsl(var(--primary) / 18%);
|
||||
}
|
||||
|
||||
.kb-hit-card.is-formula {
|
||||
border: 1px solid hsl(var(--warning) / 30%);
|
||||
box-shadow: 0 0 6px hsl(var(--warning) / 18%);
|
||||
}
|
||||
|
||||
.kb-hit-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.kb-hit-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.kb-hit-lib,
|
||||
.kb-hit-score {
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.kb-hit-score::before {
|
||||
content: '分 ';
|
||||
}
|
||||
|
||||
.kb-hit-body {
|
||||
max-height: 140px;
|
||||
margin: 8px 0 0;
|
||||
overflow: auto;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
color: hsl(var(--foreground));
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -17,9 +17,9 @@ const props = defineProps<{
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
archive: [row: any];
|
||||
detail: [row: any];
|
||||
review: [row: any];
|
||||
archive: [row: any];
|
||||
}>();
|
||||
|
||||
const loading = ref(false);
|
||||
@@ -43,11 +43,13 @@ async function load() {
|
||||
...props.filters,
|
||||
});
|
||||
const data = res?.data || res || {};
|
||||
items.value = Array.isArray(data.items)
|
||||
? data.items
|
||||
: Array.isArray(data)
|
||||
? data
|
||||
: [];
|
||||
if (Array.isArray(data.items)) {
|
||||
items.value = data.items;
|
||||
} else if (Array.isArray(data)) {
|
||||
items.value = data;
|
||||
} else {
|
||||
items.value = [];
|
||||
}
|
||||
total.value = Number(data.total ?? items.value.length);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
@@ -88,10 +90,16 @@ defineExpose({ reload: load });
|
||||
暂无生成记录
|
||||
</div>
|
||||
<div v-else class="grid grid-cols-1 gap-3 xl:grid-cols-2">
|
||||
<div v-for="row in items" :key="row.id" class="gen-card flex flex-col rounded-lg p-4">
|
||||
<div
|
||||
v-for="row in items"
|
||||
:key="row.id"
|
||||
class="gen-card flex flex-col rounded-lg p-4"
|
||||
>
|
||||
<div class="mb-3 flex items-start justify-between gap-2">
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-base font-medium text-[hsl(var(--foreground))]">
|
||||
<div
|
||||
class="truncate text-base font-medium text-[hsl(var(--foreground))]"
|
||||
>
|
||||
{{ row.name || `记录 #${row.id}` }}
|
||||
</div>
|
||||
<div class="mt-0.5 text-xs text-[hsl(var(--muted-foreground))]">
|
||||
@@ -106,19 +114,36 @@ defineExpose({ reload: load });
|
||||
|
||||
<div class="mb-3 flex flex-wrap gap-1.5">
|
||||
<Tag :color="Number(row.via_agent) === 1 ? 'gold' : 'default'">
|
||||
{{ row.via_agent_txt || (Number(row.via_agent) === 1 ? 'Agent' : '直连') }}
|
||||
{{
|
||||
row.via_agent_txt ||
|
||||
(Number(row.via_agent) === 1 ? 'Agent' : '直连')
|
||||
}}
|
||||
</Tag>
|
||||
<Tag
|
||||
:color="Number(row.is_cache_hit) === 1 ? 'processing' : 'default'"
|
||||
>
|
||||
{{
|
||||
row.cache_txt ||
|
||||
(Number(row.is_cache_hit) === 1 ? '命中缓存' : '未命中')
|
||||
}}
|
||||
</Tag>
|
||||
<Tag
|
||||
v-if="Number(row.is_adopted) === 1"
|
||||
:color="Number(row.is_modified_final) === 1 ? 'processing' : 'success'"
|
||||
:color="
|
||||
Number(row.is_modified_final) === 1 ? 'processing' : 'success'
|
||||
"
|
||||
>
|
||||
{{ row.adopted_txt || '已采纳' }}
|
||||
</Tag>
|
||||
<Tag v-else>未采纳</Tag>
|
||||
<Tag v-if="Number(row.is_correct) === 1" color="success">正确</Tag>
|
||||
<Tag v-else-if="Number(row.is_correct) === 2" color="error">不正确</Tag>
|
||||
<Tag v-else-if="Number(row.is_correct) === 2" color="error">
|
||||
不正确
|
||||
</Tag>
|
||||
<Tag v-else>未评</Tag>
|
||||
<Tag v-if="Number(row.quality_score) > 0">{{ row.quality_score }} 分</Tag>
|
||||
<Tag v-if="Number(row.quality_score) > 0">
|
||||
{{ row.quality_score }} 分
|
||||
</Tag>
|
||||
<Tag v-if="Number(row.is_archived) === 1" color="success">已归档</Tag>
|
||||
</div>
|
||||
|
||||
@@ -145,7 +170,8 @@ defineExpose({ reload: load });
|
||||
<div
|
||||
class="metric-value"
|
||||
:class="{
|
||||
'text-[hsl(var(--destructive))]': Number(row.match_drug_unmatched) > 0,
|
||||
'text-[hsl(var(--destructive))]':
|
||||
Number(row.match_drug_unmatched) > 0,
|
||||
}"
|
||||
>
|
||||
<template v-if="Number(row.match_drug_total) > 0">
|
||||
@@ -156,7 +182,10 @@ defineExpose({ reload: load });
|
||||
</div>
|
||||
<div class="metric-cell">
|
||||
<div class="metric-label">Tokens</div>
|
||||
<div class="metric-value" :title="`P ${row.prompt_tokens || 0} / C ${row.completion_tokens || 0}`">
|
||||
<div
|
||||
class="metric-value"
|
||||
:title="`P ${row.prompt_tokens || 0} / C ${row.completion_tokens || 0}`"
|
||||
>
|
||||
{{ row.total_tokens || 0 }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -171,7 +200,9 @@ defineExpose({ reload: load });
|
||||
<div class="mb-2 space-y-1 text-xs text-[hsl(var(--muted-foreground))]">
|
||||
<div>
|
||||
模型:{{ row.provider || '—' }} / {{ row.model || '—' }}
|
||||
<template v-if="row.api_key_name"> · 密钥 {{ row.api_key_name }}</template>
|
||||
<template v-if="row.api_key_name">
|
||||
· 密钥 {{ row.api_key_name }}
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="row.platform_name || row.platform_code">
|
||||
平台:{{ row.platform_name || row.platform_code }}
|
||||
@@ -186,8 +217,15 @@ defineExpose({ reload: load });
|
||||
错误:{{ row.error_msg }}
|
||||
</div>
|
||||
|
||||
<div class="mt-auto flex gap-3 border-t border-[hsl(var(--border))] pt-2">
|
||||
<Button size="small" type="link" class="!px-0" @click="emit('detail', row)">
|
||||
<div
|
||||
class="mt-auto flex gap-3 border-t border-[hsl(var(--border))] pt-2"
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
type="link"
|
||||
class="!px-0"
|
||||
@click="emit('detail', row)"
|
||||
>
|
||||
详情
|
||||
</Button>
|
||||
<Button
|
||||
@@ -231,16 +269,16 @@ defineExpose({ reload: load });
|
||||
|
||||
<style scoped>
|
||||
.gen-card {
|
||||
border: 1px solid hsl(var(--primary) / 28%);
|
||||
background: hsl(var(--card, var(--background)));
|
||||
border: 1px solid hsl(var(--primary) / 28%);
|
||||
box-shadow: 0 0 6px hsl(var(--primary) / 16%);
|
||||
}
|
||||
|
||||
.metric-cell {
|
||||
border-radius: 8px;
|
||||
border: 1px solid hsl(var(--border));
|
||||
background: hsl(var(--muted) / 25%);
|
||||
padding: 8px 10px;
|
||||
background: hsl(var(--muted) / 25%);
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
|
||||
@@ -10,6 +10,13 @@ export const AI_GENERATION_STATUS_OPTIONS = [
|
||||
export const AI_GENERATION_SCENE_OPTIONS = [
|
||||
{ label: '写病历', value: 'medical_record' },
|
||||
{ label: '出方', value: 'prescription' },
|
||||
{ label: '每日简报', value: 'daily_brief' },
|
||||
];
|
||||
|
||||
/** 是否命中业务缓存(简报按天缓存等) */
|
||||
export const AI_GENERATION_CACHE_OPTIONS = [
|
||||
{ label: '命中缓存', value: 1 },
|
||||
{ label: '未命中', value: 0 },
|
||||
];
|
||||
|
||||
/** 生成路径:Go Agent 中转 vs PHP 直连 */
|
||||
|
||||
@@ -5,6 +5,7 @@ import dayjs from 'dayjs';
|
||||
import {
|
||||
AI_GENERATION_ADOPTED_OPTIONS,
|
||||
AI_GENERATION_ARCHIVED_OPTIONS,
|
||||
AI_GENERATION_CACHE_OPTIONS,
|
||||
AI_GENERATION_CORRECT_OPTIONS,
|
||||
AI_GENERATION_SCENE_OPTIONS,
|
||||
AI_GENERATION_STATUS_OPTIONS,
|
||||
@@ -72,6 +73,18 @@ export const formOptions: VbenFormProps = {
|
||||
fieldName: 'scene',
|
||||
label: '场景',
|
||||
},
|
||||
{
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
placeholder: '是否命中缓存',
|
||||
allowClear: true,
|
||||
options: AI_GENERATION_CACHE_OPTIONS,
|
||||
class: 'w-full',
|
||||
},
|
||||
defaultValue: undefined,
|
||||
fieldName: 'is_cache_hit',
|
||||
label: '缓存',
|
||||
},
|
||||
{
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
@@ -160,7 +173,9 @@ export const formOptions: VbenFormProps = {
|
||||
/**
|
||||
* 将表单值转为接口参数:search_time → start_time/end_time(当天起止秒级时间戳)
|
||||
*/
|
||||
export function normalizeAiGenerationFilters(formValues: Record<string, any> = {}) {
|
||||
export function normalizeAiGenerationFilters(
|
||||
formValues: Record<string, any> = {},
|
||||
) {
|
||||
const params: Record<string, any> = { ...formValues };
|
||||
const range = params.search_time;
|
||||
delete params.search_time;
|
||||
@@ -168,10 +183,11 @@ export function normalizeAiGenerationFilters(formValues: Record<string, any> = {
|
||||
params.start_time = dayjs(range[0]).startOf('day').unix();
|
||||
params.end_time = dayjs(range[1]).endOf('day').unix();
|
||||
}
|
||||
Object.keys(params).forEach((k) => {
|
||||
if (params[k] === '' || params[k] === undefined || params[k] === null) {
|
||||
delete params[k];
|
||||
const cleaned: Record<string, any> = {};
|
||||
Object.entries(params).forEach(([k, v]) => {
|
||||
if (v !== '' && v !== undefined && v !== null) {
|
||||
cleaned[k] = v;
|
||||
}
|
||||
});
|
||||
return params;
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
@@ -46,6 +46,13 @@ export const gridOptions: VxeGridProps<RowType> = {
|
||||
columns: [
|
||||
{ field: 'id', align: 'left', title: 'ID', width: 80 },
|
||||
{ field: 'scene_txt', align: 'left', title: '场景', width: 90 },
|
||||
{
|
||||
field: 'cache_txt',
|
||||
align: 'left',
|
||||
title: '缓存',
|
||||
width: 160,
|
||||
slots: { default: 'cache' },
|
||||
},
|
||||
{ field: 'name', align: 'left', title: '名称', minWidth: 140 },
|
||||
{
|
||||
field: 'status',
|
||||
|
||||
@@ -8,23 +8,23 @@ import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue';
|
||||
|
||||
import { Page, useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { Button, Card, Col, Row, Tag, message } from 'ant-design-vue';
|
||||
import { Button, Card, Col, message, Row, Tag } from 'ant-design-vue';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { TableAction } from '#/components/table-action';
|
||||
import { useViewMode, ViewModeSwitch } from '#/components/view-mode-switch';
|
||||
|
||||
import { getAiModelOption } from '../platform/api/model';
|
||||
import { getAiPlatformOption } from '../platform/api';
|
||||
import { getAiModelOption } from '../platform/api/model';
|
||||
import {
|
||||
exportAiTrainJsonl,
|
||||
getAiGenerationList,
|
||||
getAiGenerationUsageStats,
|
||||
} from './api';
|
||||
import ArchiveModal from './components/archive-modal.vue';
|
||||
import DetailModal from './components/detail-modal.vue';
|
||||
import GenerationCardList from './components/generation-card-list.vue';
|
||||
import ArchiveModal from './components/archive-modal.vue';
|
||||
import ReviewModal from './components/review-modal.vue';
|
||||
import { formOptions, normalizeAiGenerationFilters } from './config/search';
|
||||
import { gridOptions as baseGridOptions } from './config/table';
|
||||
@@ -56,7 +56,7 @@ const stats = reactive({
|
||||
|
||||
/** 全量模型 option,选平台时按 platform_id 过滤 */
|
||||
const allModelOptions = ref<
|
||||
Array<{ label: string; value: string; platform_id?: number }>
|
||||
Array<{ label: string; platform_id?: number; value: string }>
|
||||
>([]);
|
||||
|
||||
/** 从 schema 取 defaultValue,保证首屏列表/卡片筛选口径一致 */
|
||||
@@ -73,7 +73,9 @@ function collectDefaultSearchValues(): Record<string, any> {
|
||||
/** 双视图共用的原始表单值(未 normalize) */
|
||||
const searchValues = ref<Record<string, any>>(collectDefaultSearchValues());
|
||||
/** 卡片列表用已 normalize 的筛选,computed 避免模板每次新建对象导致死循环重拉 */
|
||||
const cardFilters = computed(() => normalizeAiGenerationFilters(searchValues.value));
|
||||
const cardFilters = computed(() =>
|
||||
normalizeAiGenerationFilters(searchValues.value),
|
||||
);
|
||||
|
||||
function formatDuration(ms: number) {
|
||||
return formatDurationMs(ms);
|
||||
@@ -107,7 +109,9 @@ async function refreshStats(formValues?: Record<string, any>) {
|
||||
function refreshModelSchema(platformId?: number) {
|
||||
const filtered =
|
||||
platformId && platformId > 0
|
||||
? allModelOptions.value.filter((m) => Number(m.platform_id) === platformId)
|
||||
? allModelOptions.value.filter(
|
||||
(m) => Number(m.platform_id) === platformId,
|
||||
)
|
||||
: allModelOptions.value;
|
||||
searchFormApi.updateSchema?.([
|
||||
{
|
||||
@@ -144,7 +148,7 @@ const [SearchForm, searchFormApi] = useVbenForm({
|
||||
: item,
|
||||
),
|
||||
handleSubmit: async (values) => {
|
||||
searchValues.value = { ...(values || {}) };
|
||||
searchValues.value = { ...values };
|
||||
void refreshStats(searchValues.value);
|
||||
if (viewMode.value === 'list') {
|
||||
gridApi.reload();
|
||||
@@ -274,7 +278,9 @@ async function handleExportJsonl() {
|
||||
try {
|
||||
const formValues =
|
||||
(await searchFormApi.getValues?.()) || searchValues.value || {};
|
||||
const res = await exportAiTrainJsonl(normalizeAiGenerationFilters(formValues));
|
||||
const res = await exportAiTrainJsonl(
|
||||
normalizeAiGenerationFilters(formValues),
|
||||
);
|
||||
const data = res?.data || res || {};
|
||||
const content = String(data.content || '');
|
||||
const filename = String(data.filename || `ai_train_${Date.now()}.jsonl`);
|
||||
@@ -343,25 +349,39 @@ onMounted(async () => {
|
||||
<Row :gutter="12">
|
||||
<Col :xs="12" :sm="8" :md="4">
|
||||
<Card size="small">
|
||||
<div class="text-xs text-[hsl(var(--muted-foreground))]">总 Tokens</div>
|
||||
<div class="mt-1 text-lg font-semibold">{{ stats.total_tokens }}</div>
|
||||
<div class="text-xs text-[hsl(var(--muted-foreground))]">
|
||||
总 Tokens
|
||||
</div>
|
||||
<div class="mt-1 text-lg font-semibold">
|
||||
{{ stats.total_tokens }}
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col :xs="12" :sm="8" :md="4">
|
||||
<Card size="small">
|
||||
<div class="text-xs text-[hsl(var(--muted-foreground))]">Prompt</div>
|
||||
<div class="mt-1 text-lg font-semibold">{{ stats.prompt_tokens }}</div>
|
||||
<div class="text-xs text-[hsl(var(--muted-foreground))]">
|
||||
Prompt
|
||||
</div>
|
||||
<div class="mt-1 text-lg font-semibold">
|
||||
{{ stats.prompt_tokens }}
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col :xs="12" :sm="8" :md="4">
|
||||
<Card size="small">
|
||||
<div class="text-xs text-[hsl(var(--muted-foreground))]">Completion</div>
|
||||
<div class="mt-1 text-lg font-semibold">{{ stats.completion_tokens }}</div>
|
||||
<div class="text-xs text-[hsl(var(--muted-foreground))]">
|
||||
Completion
|
||||
</div>
|
||||
<div class="mt-1 text-lg font-semibold">
|
||||
{{ stats.completion_tokens }}
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col :xs="12" :sm="8" :md="4">
|
||||
<Card size="small">
|
||||
<div class="text-xs text-[hsl(var(--muted-foreground))]">平均耗时</div>
|
||||
<div class="text-xs text-[hsl(var(--muted-foreground))]">
|
||||
平均耗时
|
||||
</div>
|
||||
<div class="mt-1 text-lg font-semibold">
|
||||
{{ formatDuration(stats.avg_duration_ms) }}
|
||||
</div>
|
||||
@@ -369,16 +389,24 @@ onMounted(async () => {
|
||||
</Col>
|
||||
<Col :xs="12" :sm="8" :md="4">
|
||||
<Card size="small">
|
||||
<div class="text-xs text-[hsl(var(--muted-foreground))]">成功率</div>
|
||||
<div class="mt-1 text-lg font-semibold">{{ stats.success_rate }}%</div>
|
||||
<div class="text-xs text-[hsl(var(--muted-foreground))]">
|
||||
成功率
|
||||
</div>
|
||||
<div class="mt-1 text-lg font-semibold">
|
||||
{{ stats.success_rate }}%
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col :xs="12" :sm="8" :md="4">
|
||||
<Card size="small">
|
||||
<div class="text-xs text-[hsl(var(--muted-foreground))]">请求数</div>
|
||||
<div class="text-xs text-[hsl(var(--muted-foreground))]">
|
||||
请求数
|
||||
</div>
|
||||
<div class="mt-1 text-lg font-semibold">
|
||||
{{ stats.success_count }}/{{ stats.total_count }}
|
||||
<span class="ml-1 text-xs font-normal text-[hsl(var(--muted-foreground))]">
|
||||
<span
|
||||
class="ml-1 text-xs font-normal text-[hsl(var(--muted-foreground))]"
|
||||
>
|
||||
失败 {{ stats.fail_count }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -394,7 +422,9 @@ onMounted(async () => {
|
||||
</div>
|
||||
<div class="mt-1 text-lg font-semibold">
|
||||
{{ stats.adoption_rate }}%
|
||||
<span class="ml-1 text-xs font-normal text-[hsl(var(--muted-foreground))]">
|
||||
<span
|
||||
class="ml-1 text-xs font-normal text-[hsl(var(--muted-foreground))]"
|
||||
>
|
||||
{{ stats.adopted_count }}/{{ stats.success_count }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -417,7 +447,9 @@ onMounted(async () => {
|
||||
修改率
|
||||
<span class="ml-1 opacity-70">采纳但改过原稿</span>
|
||||
</div>
|
||||
<div class="mt-1 text-lg font-semibold">{{ stats.modified_rate }}%</div>
|
||||
<div class="mt-1 text-lg font-semibold">
|
||||
{{ stats.modified_rate }}%
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col :xs="12" :sm="8" :md="5">
|
||||
@@ -426,7 +458,9 @@ onMounted(async () => {
|
||||
药名未匹配率
|
||||
<span class="ml-1 opacity-70">越低越好</span>
|
||||
</div>
|
||||
<div class="mt-1 text-lg font-semibold">{{ stats.unmatched_rate }}%</div>
|
||||
<div class="mt-1 text-lg font-semibold">
|
||||
{{ stats.unmatched_rate }}%
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col :xs="12" :sm="8" :md="4">
|
||||
@@ -449,7 +483,12 @@ onMounted(async () => {
|
||||
</div>
|
||||
<div class="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<ViewModeSwitch v-model="viewMode" />
|
||||
<Button type="primary" ghost :loading="exporting" @click="handleExportJsonl">
|
||||
<Button
|
||||
type="primary"
|
||||
ghost
|
||||
:loading="exporting"
|
||||
@click="handleExportJsonl"
|
||||
>
|
||||
导出训练 JSONL
|
||||
</Button>
|
||||
</div>
|
||||
@@ -462,7 +501,18 @@ onMounted(async () => {
|
||||
</template>
|
||||
<template #via_agent="{ row }">
|
||||
<Tag :color="Number(row.via_agent) === 1 ? 'gold' : 'default'">
|
||||
{{ row.via_agent_txt || (Number(row.via_agent) === 1 ? 'Agent' : '直连') }}
|
||||
{{
|
||||
row.via_agent_txt ||
|
||||
(Number(row.via_agent) === 1 ? 'Agent' : '直连')
|
||||
}}
|
||||
</Tag>
|
||||
</template>
|
||||
<template #cache="{ row }">
|
||||
<Tag :color="Number(row.is_cache_hit) === 1 ? 'processing' : 'default'">
|
||||
{{
|
||||
row.cache_txt ||
|
||||
(Number(row.is_cache_hit) === 1 ? '命中缓存' : '未命中')
|
||||
}}
|
||||
</Tag>
|
||||
</template>
|
||||
<template #tokens="{ row }">
|
||||
@@ -493,7 +543,9 @@ onMounted(async () => {
|
||||
<template #adopted="{ row }">
|
||||
<Tag
|
||||
v-if="Number(row.is_adopted) === 1"
|
||||
:color="Number(row.is_modified_final) === 1 ? 'processing' : 'success'"
|
||||
:color="
|
||||
Number(row.is_modified_final) === 1 ? 'processing' : 'success'
|
||||
"
|
||||
>
|
||||
{{ row.adopted_txt }}
|
||||
</Tag>
|
||||
@@ -505,50 +557,52 @@ onMounted(async () => {
|
||||
<span v-else class="text-xs text-[hsl(var(--muted-foreground))]">未评</span>
|
||||
</template>
|
||||
<template #score="{ row }">
|
||||
<span v-if="Number(row.quality_score) > 0">{{ row.quality_score }}</span>
|
||||
<span v-if="Number(row.quality_score) > 0">{{
|
||||
row.quality_score
|
||||
}}</span>
|
||||
<span v-else class="text-xs text-[hsl(var(--muted-foreground))]">—</span>
|
||||
</template>
|
||||
<template #archived="{ row }">
|
||||
<Tag v-if="Number(row.is_archived) === 1" color="success">已归档</Tag>
|
||||
<span v-else class="text-xs text-[hsl(var(--muted-foreground))]">未归档</span>
|
||||
</template>
|
||||
<template #action="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '详情',
|
||||
type: 'link',
|
||||
onClick: () => openDetail(row),
|
||||
},
|
||||
{
|
||||
label: '打分',
|
||||
type: 'link',
|
||||
ifShow: Number(row.status) === 1,
|
||||
onClick: () => openReview(row),
|
||||
},
|
||||
{
|
||||
label: '归档',
|
||||
type: 'link',
|
||||
ifShow:
|
||||
Number(row.status) === 1 &&
|
||||
Number(row.is_correct) !== 0 &&
|
||||
Number(row.quality_score) > 0,
|
||||
onClick: () => openArchive(row),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
|
||||
<div v-else class="gen-card-wrap">
|
||||
<GenerationCardList
|
||||
ref="cardListRef"
|
||||
:filters="cardFilters"
|
||||
@detail="openDetail"
|
||||
@review="openReview"
|
||||
@archive="openArchive"
|
||||
<template #action="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '详情',
|
||||
type: 'link',
|
||||
onClick: () => openDetail(row),
|
||||
},
|
||||
{
|
||||
label: '打分',
|
||||
type: 'link',
|
||||
ifShow: Number(row.status) === 1,
|
||||
onClick: () => openReview(row),
|
||||
},
|
||||
{
|
||||
label: '归档',
|
||||
type: 'link',
|
||||
ifShow:
|
||||
Number(row.status) === 1 &&
|
||||
Number(row.is_correct) !== 0 &&
|
||||
Number(row.quality_score) > 0,
|
||||
onClick: () => openArchive(row),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Grid>
|
||||
|
||||
<div v-else class="gen-card-wrap">
|
||||
<GenerationCardList
|
||||
ref="cardListRef"
|
||||
:filters="cardFilters"
|
||||
@detail="openDetail"
|
||||
@review="openReview"
|
||||
@archive="openArchive"
|
||||
/>
|
||||
</div>
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
<script lang="ts" setup>
|
||||
import type { FileFolderItem } from '#/api/core/file-folder';
|
||||
import type {
|
||||
FileGallerySidebarTab,
|
||||
FileGalleryTab,
|
||||
} from '#/composables/use-file-gallery-filter';
|
||||
|
||||
/**
|
||||
* 素材库左侧栏:分类(xk_file_group)与文件夹(由 object_key 按 / 拆目录)互斥筛选
|
||||
* 文件夹按 pid 组成树,父级可折叠;列表区有最大高度,超出滚动
|
||||
*/
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
|
||||
import {
|
||||
CaretDownOutlined,
|
||||
CaretRightOutlined,
|
||||
FolderAddOutlined,
|
||||
FolderOpenOutlined,
|
||||
FolderOutlined,
|
||||
InboxOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import { Button, Input, message, Modal, Tabs } from 'ant-design-vue';
|
||||
|
||||
import { getFileFolderList } from '#/api/core/file-folder';
|
||||
import { createFileGroup } from '#/api/core/file-group';
|
||||
import {
|
||||
ALL_FOLDER_VALUE,
|
||||
UNFILED_FOLDER_VALUE,
|
||||
} from '#/composables/use-file-gallery-filter';
|
||||
import { useFileGroupCache } from '#/composables/use-file-group-cache';
|
||||
|
||||
defineOptions({ name: 'FileGalleryFolderSidebar' });
|
||||
|
||||
const props = defineProps<{
|
||||
activeFolderId: number;
|
||||
activeGroupId: number;
|
||||
groupTabs: FileGalleryTab[];
|
||||
sidebarTab: FileGallerySidebarTab;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
selectFolder: [id: number];
|
||||
selectGroup: [id: number];
|
||||
'update:sidebarTab': [tab: FileGallerySidebarTab];
|
||||
}>();
|
||||
|
||||
const { invalidateGroupOptions } = useFileGroupCache();
|
||||
const folders = ref<FileFolderItem[]>([]);
|
||||
const expandedIds = ref<number[]>([]);
|
||||
const createOpen = ref(false);
|
||||
const createName = ref('');
|
||||
const creating = ref(false);
|
||||
|
||||
function unwrapList(res: any): FileFolderItem[] {
|
||||
const data = res?.result ?? res?.data ?? res;
|
||||
if (Array.isArray(data)) {
|
||||
return data;
|
||||
}
|
||||
return Array.isArray(data?.items) ? data.items : [];
|
||||
}
|
||||
|
||||
/** 按 pid 分组,根目录 pid=0 */
|
||||
const childrenByPid = computed(() => {
|
||||
const map = new Map<number, FileFolderItem[]>();
|
||||
for (const item of folders.value) {
|
||||
const pid = item.pid > 0 ? item.pid : 0;
|
||||
const list = map.get(pid) ?? [];
|
||||
list.push(item);
|
||||
map.set(pid, list);
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
/** 当前展开状态下可见的文件夹行(含子节点标记) */
|
||||
const visibleFolderRows = computed(() => {
|
||||
const rows: { hasChildren: boolean; item: FileFolderItem }[] = [];
|
||||
const walk = (pid: number) => {
|
||||
for (const item of childrenByPid.value.get(pid) ?? []) {
|
||||
const hasChildren = (childrenByPid.value.get(item.id) ?? []).length > 0;
|
||||
rows.push({ item, hasChildren });
|
||||
if (hasChildren && expandedIds.value.includes(item.id)) {
|
||||
walk(item.id);
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(0);
|
||||
return rows;
|
||||
});
|
||||
|
||||
function isExpanded(id: number) {
|
||||
return expandedIds.value.includes(id);
|
||||
}
|
||||
|
||||
/** 点箭头只折叠/展开,不切换当前筛选 */
|
||||
function toggleExpand(id: number) {
|
||||
if (isExpanded(id)) {
|
||||
expandedIds.value = expandedIds.value.filter((item) => item !== id);
|
||||
return;
|
||||
}
|
||||
expandedIds.value = [...expandedIds.value, id];
|
||||
}
|
||||
|
||||
/** 选中深层文件夹时把祖先展开,避免当前项被藏起来 */
|
||||
function expandAncestors(folderId: number) {
|
||||
const byId = new Map(folders.value.map((item) => [item.id, item]));
|
||||
const next = new Set(expandedIds.value);
|
||||
let current = byId.get(folderId);
|
||||
while (current && current.pid > 0) {
|
||||
next.add(current.pid);
|
||||
current = byId.get(current.pid);
|
||||
}
|
||||
expandedIds.value = [...next];
|
||||
}
|
||||
|
||||
/** 拉文件夹树:后端会先按 object_key 同步目录 */
|
||||
async function loadFolders() {
|
||||
const res = await getFileFolderList();
|
||||
folders.value = unwrapList(res);
|
||||
if (props.activeFolderId > 0) {
|
||||
expandAncestors(props.activeFolderId);
|
||||
}
|
||||
}
|
||||
|
||||
function onTabChange(key: number | string) {
|
||||
const tab: FileGallerySidebarTab = key === 'folder' ? 'folder' : 'group';
|
||||
emit('update:sidebarTab', tab);
|
||||
if (tab === 'folder') {
|
||||
void loadFolders();
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
createName.value = '';
|
||||
createOpen.value = true;
|
||||
}
|
||||
|
||||
/** 仅分类 Tab 支持新建;文件夹由 OSS 路径自动生成 */
|
||||
async function submitCreate() {
|
||||
const name = createName.value.trim();
|
||||
if (!name) {
|
||||
message.warning('请填写分类名称');
|
||||
return;
|
||||
}
|
||||
creating.value = true;
|
||||
try {
|
||||
await createFileGroup({ name, sort: 0 });
|
||||
message.success('已新建分类');
|
||||
createOpen.value = false;
|
||||
await invalidateGroupOptions();
|
||||
} finally {
|
||||
creating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.activeFolderId,
|
||||
(id) => {
|
||||
if (id > 0) {
|
||||
expandAncestors(id);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
void loadFolders();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="folder-panel">
|
||||
<Tabs
|
||||
class="sidebar-tabs"
|
||||
size="small"
|
||||
:active-key="sidebarTab"
|
||||
@change="onTabChange"
|
||||
>
|
||||
<Tabs.TabPane key="group" tab="分类" />
|
||||
<Tabs.TabPane key="folder" tab="文件夹" />
|
||||
</Tabs>
|
||||
|
||||
<div class="folder-head">
|
||||
<span class="folder-title">{{
|
||||
sidebarTab === 'folder' ? '文件夹' : '分类'
|
||||
}}</span>
|
||||
<Button
|
||||
v-if="sidebarTab === 'group'"
|
||||
size="small"
|
||||
type="text"
|
||||
@click="openCreate"
|
||||
>
|
||||
<FolderAddOutlined />
|
||||
新建
|
||||
</Button>
|
||||
</div>
|
||||
<div v-if="sidebarTab === 'folder'" class="folder-hint">
|
||||
按上传路径 / 自动生成
|
||||
</div>
|
||||
|
||||
<div v-if="sidebarTab === 'group'" class="folder-list">
|
||||
<button
|
||||
v-for="tab in groupTabs"
|
||||
:key="tab.value"
|
||||
type="button"
|
||||
class="folder-item"
|
||||
:class="{ active: activeGroupId === tab.value }"
|
||||
@click="emit('selectGroup', tab.value)"
|
||||
>
|
||||
<FolderOpenOutlined
|
||||
v-if="activeGroupId === tab.value"
|
||||
class="folder-icon"
|
||||
/>
|
||||
<FolderOutlined v-else class="folder-icon" />
|
||||
<span class="folder-name">{{ tab.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="folder-list">
|
||||
<button
|
||||
type="button"
|
||||
class="folder-item"
|
||||
:class="{ active: activeFolderId === ALL_FOLDER_VALUE }"
|
||||
@click="emit('selectFolder', ALL_FOLDER_VALUE)"
|
||||
>
|
||||
<span class="folder-caret is-placeholder"></span>
|
||||
<FolderOpenOutlined
|
||||
v-if="activeFolderId === ALL_FOLDER_VALUE"
|
||||
class="folder-icon"
|
||||
/>
|
||||
<FolderOutlined v-else class="folder-icon" />
|
||||
<span class="folder-name">全部文件夹</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="folder-item"
|
||||
:class="{ active: activeFolderId === UNFILED_FOLDER_VALUE }"
|
||||
@click="emit('selectFolder', UNFILED_FOLDER_VALUE)"
|
||||
>
|
||||
<span class="folder-caret is-placeholder"></span>
|
||||
<InboxOutlined class="folder-icon" />
|
||||
<span class="folder-name">未入文件夹</span>
|
||||
</button>
|
||||
<button
|
||||
v-for="row in visibleFolderRows"
|
||||
:key="row.item.id"
|
||||
type="button"
|
||||
class="folder-item folder-tree-item"
|
||||
:class="{ active: activeFolderId === row.item.id }"
|
||||
:style="{ paddingLeft: `${10 + row.item.depth * 14}px` }"
|
||||
:title="row.item.path"
|
||||
@click="emit('selectFolder', row.item.id)"
|
||||
>
|
||||
<span
|
||||
class="folder-caret"
|
||||
:class="{ 'is-placeholder': !row.hasChildren }"
|
||||
@click.stop="row.hasChildren && toggleExpand(row.item.id)"
|
||||
>
|
||||
<CaretDownOutlined
|
||||
v-if="row.hasChildren && isExpanded(row.item.id)"
|
||||
/>
|
||||
<CaretRightOutlined v-else-if="row.hasChildren" />
|
||||
</span>
|
||||
<FolderOpenOutlined
|
||||
v-if="activeFolderId === row.item.id"
|
||||
class="folder-icon"
|
||||
/>
|
||||
<FolderOutlined v-else class="folder-icon" />
|
||||
<span class="folder-name">{{ row.item.name }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
v-model:open="createOpen"
|
||||
title="新建分类"
|
||||
ok-text="创建"
|
||||
cancel-text="取消"
|
||||
:confirm-loading="creating"
|
||||
@ok="submitCreate"
|
||||
>
|
||||
<Input
|
||||
v-model:value="createName"
|
||||
placeholder="例如:轮播图、证件照"
|
||||
@press-enter="submitCreate"
|
||||
/>
|
||||
</Modal>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.folder-panel {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
flex-direction: column;
|
||||
width: 228px;
|
||||
max-height: calc(100vh - 240px);
|
||||
padding: 12px;
|
||||
overflow: hidden;
|
||||
background: hsl(var(--card, var(--background)));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.sidebar-tabs {
|
||||
flex-shrink: 0;
|
||||
|
||||
:deep(.ant-tabs-nav) {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
:deep(.ant-tabs-tab) {
|
||||
padding: 6px 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.folder-head {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.folder-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.folder-hint {
|
||||
flex-shrink: 0;
|
||||
margin: -4px 4px 8px;
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.folder-list {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.folder-item {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
transition:
|
||||
background-color 0.2s ease,
|
||||
border-color 0.2s ease,
|
||||
color 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: hsl(var(--foreground));
|
||||
background: hsl(var(--muted) / 35%);
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: hsl(var(--primary));
|
||||
background: hsl(var(--primary) / 10%);
|
||||
border-color: hsl(var(--primary) / 30%);
|
||||
box-shadow: 0 0 6px hsl(var(--primary) / 18%);
|
||||
}
|
||||
}
|
||||
|
||||
.folder-caret {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
font-size: 10px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
|
||||
&:not(.is-placeholder) {
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
color: hsl(var(--foreground));
|
||||
background: hsl(var(--muted) / 50%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.folder-icon {
|
||||
flex-shrink: 0;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.folder-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.folder-panel {
|
||||
width: 100%;
|
||||
max-height: 280px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,9 +10,7 @@ import { Button, Image, message } from 'ant-design-vue';
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { TableAction } from '#/components/table-action';
|
||||
|
||||
import {
|
||||
deleteHomeZones,
|
||||
} from './api';
|
||||
import { deleteHomeZones } from './api';
|
||||
import HomeZoneModal from './components/modal.vue';
|
||||
import SortModal from './components/sort-modal.vue';
|
||||
import { formOptions as searchFormOptions } from './config/search';
|
||||
@@ -22,12 +20,10 @@ const hasTopTableDropDownActions = ref(false);
|
||||
|
||||
const gridEvents: VxeGridListeners<any> = {
|
||||
checkboxChange() {
|
||||
// eslint-disable-next-line no-use-before-define
|
||||
const records = gridApi.grid.getCheckboxRecords();
|
||||
hasTopTableDropDownActions.value = records.length > 0;
|
||||
},
|
||||
checkboxAll() {
|
||||
// eslint-disable-next-line no-use-before-define
|
||||
const records = gridApi.grid.getCheckboxRecords();
|
||||
hasTopTableDropDownActions.value = records.length > 0;
|
||||
},
|
||||
@@ -64,7 +60,7 @@ const showSortModal = () => {
|
||||
};
|
||||
|
||||
const deleteZonesApi = (row: any) => {
|
||||
let ids: (string | number)[] = [];
|
||||
let ids: (number | string)[] = [];
|
||||
if (row) {
|
||||
ids.push(row);
|
||||
} else {
|
||||
@@ -108,7 +104,7 @@ const deleteZonesApi = (row: any) => {
|
||||
:width="60"
|
||||
:height="60"
|
||||
:src="row.icon"
|
||||
:fallback="'/static/mine/avatar_1.png'"
|
||||
fallback="/static/mine/avatar_1.png"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -145,4 +141,3 @@ const deleteZonesApi = (row: any) => {
|
||||
</div>
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -6,7 +6,16 @@ export async function getSystemConfigList() {
|
||||
return requestClient.get<any>(`${prefix}list`);
|
||||
}
|
||||
|
||||
export async function saveSystemConfig(items: Array<{ config_key: string; config_value: string }>) {
|
||||
export async function saveSystemConfig(
|
||||
items: Array<{
|
||||
config_group?: string;
|
||||
config_key: string;
|
||||
config_value: any;
|
||||
description?: string;
|
||||
sort?: number;
|
||||
value_type?: string;
|
||||
}>,
|
||||
) {
|
||||
return requestClient.post<any>(`${prefix}save`, { items });
|
||||
}
|
||||
|
||||
@@ -22,3 +31,20 @@ export async function getSystemConfigByKeys(keys: string[]) {
|
||||
params: { keys: keys.join(',') },
|
||||
});
|
||||
}
|
||||
|
||||
const wxEntryPrefix = 'wx-workbench-entry/';
|
||||
|
||||
/** 某角色已分配的医生端功能入口(含 is_default_fav) */
|
||||
export async function getWxWorkbenchEntryByRoleId(roleId: number) {
|
||||
return requestClient.get<any>(`${wxEntryPrefix}get-by-role-id`, {
|
||||
params: { role_id: roleId },
|
||||
});
|
||||
}
|
||||
|
||||
/** 保存某角色的默认常用入口(只改 is_default_fav) */
|
||||
export async function saveWxWorkbenchDefaultFav(data: {
|
||||
entry_ids: number[];
|
||||
role_id: number;
|
||||
}) {
|
||||
return requestClient.post<any>(`${wxEntryPrefix}save-default-fav`, data);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,249 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 系统配置 - 处方签名:中药/西药/服务包/配送仓分别上传调配人、发药人
|
||||
* 一次保存整份 JSON(dispense_sign),配送仓按所选仓库 id 写入 warehouses
|
||||
*/
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
|
||||
import { Button, message, Select, Tabs } from 'ant-design-vue';
|
||||
|
||||
import FormAvatar from '#/components/form/components/avatar.vue';
|
||||
import { getDeliveryWarehouseOption } from '#/views/system/delivery-warehouse/api';
|
||||
|
||||
import { getSystemConfigByKeys, saveSystemConfig } from '../api';
|
||||
|
||||
defineOptions({ name: 'DispenseSignConfigPanel' });
|
||||
|
||||
type SignPair = { dispenser: string; sender: string };
|
||||
|
||||
type DispenseSignConfig = {
|
||||
pkg: SignPair;
|
||||
tcm: SignPair;
|
||||
warehouses: Record<string, SignPair>;
|
||||
wm: SignPair;
|
||||
};
|
||||
|
||||
const SCOPE_TABS = [
|
||||
{ key: 'tcm', title: '中药' },
|
||||
{ key: 'wm', title: '西药' },
|
||||
{ key: 'pkg', title: '产品服务包' },
|
||||
{ key: 'warehouse', title: '配送仓库' },
|
||||
];
|
||||
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const activeScope = ref('tcm');
|
||||
const warehouseId = ref<number | undefined>(undefined);
|
||||
const warehouseOptions = ref<{ label: string; value: number }[]>([]);
|
||||
const form = ref<DispenseSignConfig>(emptyConfig());
|
||||
|
||||
function emptyPair(): SignPair {
|
||||
return { dispenser: '', sender: '' };
|
||||
}
|
||||
|
||||
function emptyConfig(): DispenseSignConfig {
|
||||
return {
|
||||
tcm: emptyPair(),
|
||||
wm: emptyPair(),
|
||||
pkg: emptyPair(),
|
||||
warehouses: {},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePair(raw: any): SignPair {
|
||||
return {
|
||||
dispenser: String(raw?.dispenser || ''),
|
||||
sender: String(raw?.sender || ''),
|
||||
};
|
||||
}
|
||||
|
||||
/** 当前 Tab 对应的一对签名(配送仓按所选 id) */
|
||||
const currentPair = computed<SignPair>(() => {
|
||||
if (activeScope.value === 'warehouse') {
|
||||
const id = Number(warehouseId.value || 0);
|
||||
if (id <= 0) return emptyPair();
|
||||
return form.value.warehouses[String(id)] || emptyPair();
|
||||
}
|
||||
const key = activeScope.value as 'pkg' | 'tcm' | 'wm';
|
||||
return form.value[key] || emptyPair();
|
||||
});
|
||||
|
||||
function setCurrentPair(field: keyof SignPair, url: string) {
|
||||
if (activeScope.value === 'warehouse') {
|
||||
const id = Number(warehouseId.value || 0);
|
||||
if (id <= 0) return;
|
||||
const next = { ...(form.value.warehouses[String(id)] || emptyPair()) };
|
||||
next[field] = url;
|
||||
form.value.warehouses[String(id)] = next;
|
||||
return;
|
||||
}
|
||||
const key = activeScope.value as 'pkg' | 'tcm' | 'wm';
|
||||
form.value[key] = { ...form.value[key], [field]: url };
|
||||
}
|
||||
|
||||
/** 拉取配置 + 配送仓下拉 */
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const [cfgRes, opts] = await Promise.all([
|
||||
getSystemConfigByKeys(['dispense_sign']),
|
||||
getDeliveryWarehouseOption(),
|
||||
]);
|
||||
const raw = cfgRes?.dispense_sign;
|
||||
const parsed = typeof raw === 'string' ? safeParse(raw) : raw;
|
||||
form.value = {
|
||||
tcm: normalizePair(parsed?.tcm),
|
||||
wm: normalizePair(parsed?.wm),
|
||||
pkg: normalizePair(parsed?.pkg),
|
||||
warehouses: normalizeWarehouses(parsed?.warehouses),
|
||||
};
|
||||
const list = Array.isArray(opts) ? opts : opts?.data || [];
|
||||
warehouseOptions.value = list.map((item: any) => ({
|
||||
label: item.name || item.label || `#${item.id}`,
|
||||
value: Number(item.id || item.value),
|
||||
}));
|
||||
if (!warehouseId.value && warehouseOptions.value[0]) {
|
||||
warehouseId.value = warehouseOptions.value[0].value;
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function safeParse(text: string): any {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeWarehouses(raw: any): Record<string, SignPair> {
|
||||
if (!raw || typeof raw !== 'object') return {};
|
||||
const out: Record<string, SignPair> = {};
|
||||
Object.keys(raw).forEach((id) => {
|
||||
if (Number(id) > 0) out[id] = normalizePair(raw[id]);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 整份 JSON 一次入库,配送仓未选不影响其它品类 */
|
||||
async function handleSave() {
|
||||
saving.value = true;
|
||||
try {
|
||||
await saveSystemConfig([
|
||||
{
|
||||
config_key: 'dispense_sign',
|
||||
config_value: form.value as any,
|
||||
value_type: 'json',
|
||||
config_group: 'prescription',
|
||||
description:
|
||||
'处方笺调配人/发药人签名:tcm中药 wm西药 pkg服务包 warehouses按仓',
|
||||
sort: 90,
|
||||
} as any,
|
||||
]);
|
||||
message.success('保存成功');
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(warehouseId, (id) => {
|
||||
if (!id) return;
|
||||
const key = String(id);
|
||||
if (!form.value.warehouses[key]) {
|
||||
form.value.warehouses[key] = emptyPair();
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="cfg-panel" :class="{ 'opacity-60': loading }">
|
||||
<div class="cfg-panel-body">
|
||||
<Tabs v-model:active-key="activeScope">
|
||||
<Tabs.TabPane
|
||||
v-for="item in SCOPE_TABS"
|
||||
:key="item.key"
|
||||
:tab="item.title"
|
||||
/>
|
||||
</Tabs>
|
||||
<p class="sign-hint">
|
||||
上传后处方详情「调配人 /
|
||||
发药人」按品类展示。配送仓库商品使用该仓自己的签名。
|
||||
</p>
|
||||
<div v-if="activeScope === 'warehouse'" class="sign-warehouse">
|
||||
<div class="sign-label">配送仓库</div>
|
||||
<Select
|
||||
v-model:value="warehouseId"
|
||||
class="sign-select"
|
||||
:options="warehouseOptions"
|
||||
placeholder="请选择配送仓库"
|
||||
show-search
|
||||
option-filter-prop="label"
|
||||
/>
|
||||
</div>
|
||||
<div class="sign-grid">
|
||||
<div class="sign-card">
|
||||
<div class="sign-label">调配人签名</div>
|
||||
<FormAvatar
|
||||
:value="currentPair.dispenser"
|
||||
@update:value="(v: string) => setCurrentPair('dispenser', v)"
|
||||
/>
|
||||
</div>
|
||||
<div class="sign-card">
|
||||
<div class="sign-label">发药人签名</div>
|
||||
<FormAvatar
|
||||
:value="currentPair.sender"
|
||||
@update:value="(v: string) => setCurrentPair('sender', v)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cfg-panel-footer">
|
||||
<Button type="primary" :loading="saving" @click="handleSave">
|
||||
保存本模块配置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sign-hint {
|
||||
margin: 0 0 16px;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.sign-warehouse {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.sign-select {
|
||||
width: 280px;
|
||||
}
|
||||
|
||||
.sign-label {
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.sign-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.sign-card {
|
||||
min-width: 200px;
|
||||
padding: 14px 16px;
|
||||
background: hsl(var(--muted) / 25%);
|
||||
border: 1px solid hsl(var(--primary) / 30%);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 6px hsl(var(--primary) / 18%);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,100 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 系统配置 - 患者端小程序 / 首页展示:金刚区布局 + 订单提醒样式
|
||||
*/
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import { Button, message, Radio } from 'ant-design-vue';
|
||||
|
||||
import { getSystemConfigByKeys, saveSystemConfig } from '../api';
|
||||
|
||||
defineOptions({ name: 'HomeDisplayConfigPanel' });
|
||||
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const zoneLayout = ref<'scroll' | 'wrap2' | 'wrap'>('scroll');
|
||||
const orderRemindStyle = ref<'grid' | 'zone'>('grid');
|
||||
|
||||
/** 读取首页展示样式,缺省回落默认值 */
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getSystemConfigByKeys([
|
||||
'home_zone_layout',
|
||||
'home_order_remind_style',
|
||||
]);
|
||||
const data = res && typeof res === 'object' ? res : {};
|
||||
const layout = String(data.home_zone_layout || 'scroll');
|
||||
zoneLayout.value =
|
||||
layout === 'wrap' || layout === 'wrap2' ? layout : 'scroll';
|
||||
orderRemindStyle.value =
|
||||
String(data.home_order_remind_style || '') === 'zone' ? 'zone' : 'grid';
|
||||
} catch {
|
||||
zoneLayout.value = 'scroll';
|
||||
orderRemindStyle.value = 'grid';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 写入系统配置,患者端首页即时读取 */
|
||||
async function handleSave() {
|
||||
saving.value = true;
|
||||
try {
|
||||
await saveSystemConfig([
|
||||
{
|
||||
config_key: 'home_zone_layout',
|
||||
config_value: zoneLayout.value,
|
||||
value_type: 'string',
|
||||
config_group: 'home',
|
||||
description: '首页金刚区布局:scroll横向滑 / wrap换行 / wrap2一行两个',
|
||||
sort: 80,
|
||||
},
|
||||
{
|
||||
config_key: 'home_order_remind_style',
|
||||
config_value: orderRemindStyle.value,
|
||||
value_type: 'string',
|
||||
config_group: 'home',
|
||||
description: '首页订单提醒样式:grid一行两列卡片 / zone金刚区图标',
|
||||
sort: 81,
|
||||
},
|
||||
]);
|
||||
message.success('保存成功');
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="cfg-panel" :class="{ 'opacity-60': loading }">
|
||||
<div class="cfg-panel-body">
|
||||
<div class="mb-2 font-medium text-[hsl(var(--foreground))]">
|
||||
金刚区布局
|
||||
</div>
|
||||
<div class="mb-2 text-sm text-[hsl(var(--muted-foreground))]">
|
||||
控制患者端首页专区入口的排列方式。
|
||||
</div>
|
||||
<Radio.Group v-model:value="zoneLayout" class="mb-6">
|
||||
<Radio value="scroll">横向滑动</Radio>
|
||||
<Radio value="wrap">换行(一行五个)</Radio>
|
||||
<Radio value="wrap2">一行两个</Radio>
|
||||
</Radio.Group>
|
||||
<div class="mb-2 font-medium text-[hsl(var(--foreground))]">订单提醒</div>
|
||||
<div class="mb-2 text-sm text-[hsl(var(--muted-foreground))]">
|
||||
首页展示待支付挂号、待支付商品、已发货(快递待收货)、已到货待确认收货(自提)。
|
||||
</div>
|
||||
<Radio.Group v-model:value="orderRemindStyle">
|
||||
<Radio value="grid">一行两列卡片</Radio>
|
||||
<Radio value="zone">金刚区图标</Radio>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
<div class="cfg-panel-footer">
|
||||
<Button type="primary" :loading="saving" @click="handleSave">
|
||||
保存本模块配置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 系统配置 - 小程序:空状态占位图
|
||||
* 系统配置 - 患者端小程序:空状态占位图
|
||||
*/
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
@@ -52,14 +52,18 @@ onMounted(load);
|
||||
<template>
|
||||
<div class="cfg-panel" :class="{ 'opacity-60': loading }">
|
||||
<div class="cfg-panel-body">
|
||||
<div class="mb-2 font-medium text-[hsl(var(--foreground))]">空状态图片</div>
|
||||
<div class="mb-2 font-medium text-[hsl(var(--foreground))]">
|
||||
空状态图片
|
||||
</div>
|
||||
<div class="mb-2 text-sm text-[hsl(var(--muted-foreground))]">
|
||||
用于小程序列表等无数据时的占位图
|
||||
</div>
|
||||
<FormAvatar v-model:value="miniprogramEmptyImage" />
|
||||
</div>
|
||||
<div class="cfg-panel-footer">
|
||||
<Button type="primary" :loading="saving" @click="handleSave">保存本模块配置</Button>
|
||||
<Button type="primary" :loading="saving" @click="handleSave">
|
||||
保存本模块配置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 系统配置通用壳:右侧二级 Tab,和 AI 模块同一套交互
|
||||
* 各 Tab 仍是独立面板,自己管 load/save,底部保存条不互相覆盖
|
||||
*/
|
||||
import type { Component } from 'vue';
|
||||
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import { Tabs } from 'ant-design-vue';
|
||||
|
||||
export interface ModuleTabItem {
|
||||
key: string;
|
||||
title: string;
|
||||
component: Component;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
moduleKey: string;
|
||||
tabs: ModuleTabItem[];
|
||||
}>();
|
||||
|
||||
const subTab = ref(props.tabs[0]?.key || '');
|
||||
|
||||
function storageKey() {
|
||||
return `system_config_${props.moduleKey}_sub_tab`;
|
||||
}
|
||||
|
||||
function onSubTabChange(key: number | string) {
|
||||
subTab.value = String(key);
|
||||
localStorage.setItem(storageKey(), String(key));
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const stored = localStorage.getItem(storageKey());
|
||||
if (stored && props.tabs.some((tab) => tab.key === stored)) {
|
||||
subTab.value = stored;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="cfg-panel">
|
||||
<div class="cfg-panel-body module-tabs-body">
|
||||
<Tabs :active-key="subTab" @change="onSubTabChange">
|
||||
<Tabs.TabPane v-for="tab in tabs" :key="tab.key" :tab="tab.title">
|
||||
<component :is="tab.component" />
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.module-tabs-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 8px 0 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.module-tabs-body :deep(.ant-tabs) {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.module-tabs-body :deep(.ant-tabs-nav) {
|
||||
flex-shrink: 0;
|
||||
margin: 0 20px;
|
||||
}
|
||||
|
||||
.module-tabs-body :deep(.ant-tabs-content-holder) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.module-tabs-body :deep(.ant-tabs-content),
|
||||
.module-tabs-body :deep(.ant-tabs-tabpane-active) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* 子面板铺满 Tab,自己滚内容、底栏固定 */
|
||||
.module-tabs-body :deep(.cfg-panel) {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,86 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 系统配置 - 工作台:AI 今日简报总开关
|
||||
* 关闭后 PC / 医生小程序工作台都隐藏该模块,后端也不生成
|
||||
*/
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import { Button, message, Switch } from 'ant-design-vue';
|
||||
|
||||
import { getSystemConfigList, saveSystemConfig } from '../api';
|
||||
|
||||
defineOptions({ name: 'WorkbenchConfigPanel' });
|
||||
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const aiDailyBriefEnabled = ref(true);
|
||||
|
||||
function parseBoolConfig(val: unknown, defaultVal: boolean): boolean {
|
||||
if (val === undefined || val === null || val === '') return defaultVal;
|
||||
return val === '1' || val === 1 || val === true || val === 'true';
|
||||
}
|
||||
|
||||
/** 读取工作台简报开关,未落库时按开启回落,与后端 getValue 默认一致 */
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const list = await getSystemConfigList();
|
||||
const rows = Array.isArray(list) ? list : list?.data || [];
|
||||
for (const row of rows) {
|
||||
if (row.config_key === 'ai_daily_brief_enabled') {
|
||||
aiDailyBriefEnabled.value = parseBoolConfig(row.config_value, true);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 写入系统配置;关闭后工作台立刻不再生成简报 */
|
||||
async function handleSave() {
|
||||
saving.value = true;
|
||||
try {
|
||||
await saveSystemConfig([
|
||||
{
|
||||
config_key: 'ai_daily_brief_enabled',
|
||||
config_value: aiDailyBriefEnabled.value ? '1' : '0',
|
||||
value_type: 'bool',
|
||||
config_group: 'workbench',
|
||||
description:
|
||||
'工作台AI今日简报:1开启(生成并展示)0关闭(隐藏且不生成)',
|
||||
sort: 320,
|
||||
},
|
||||
]);
|
||||
message.success('保存成功');
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="cfg-panel" :class="{ 'opacity-60': loading }">
|
||||
<div class="cfg-panel-body">
|
||||
<div class="mb-2 font-medium text-[hsl(var(--foreground))]">
|
||||
AI 今日简报
|
||||
</div>
|
||||
<div class="mb-2 text-sm text-[hsl(var(--muted-foreground))]">
|
||||
控制 PC
|
||||
管理端与医生小程序工作台的「今日简报」。开启后进入工作台才生成(按天缓存);关闭后两端都隐藏该模块,且不再调用
|
||||
AI、不写生成记录。
|
||||
</div>
|
||||
<Switch
|
||||
v-model:checked="aiDailyBriefEnabled"
|
||||
checked-children="开启"
|
||||
un-checked-children="关闭"
|
||||
/>
|
||||
</div>
|
||||
<div class="cfg-panel-footer">
|
||||
<Button type="primary" :loading="saving" @click="handleSave">
|
||||
保存本模块配置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,218 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 系统配置 - 医生端小程序 / 默认常用入口
|
||||
* 按角色勾选首次访问自动写入「我的常用」的入口,只能勾选该角色已分配项
|
||||
*/
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
|
||||
import { Button, Checkbox, Empty, message, Tabs } from 'ant-design-vue';
|
||||
|
||||
import { getWxWorkbenchEntryByRoleId, saveWxWorkbenchDefaultFav } from '../api';
|
||||
|
||||
defineOptions({ name: 'WxWorkbenchDefaultFavPanel' });
|
||||
|
||||
const TAB_STORAGE_KEY = 'wx_workbench_default_fav_role';
|
||||
|
||||
/** 有工作台入口的角色,value 与 xk_role.id 一致 */
|
||||
const ROLE_TABS = [
|
||||
{ label: '诊所管理员', value: 8 },
|
||||
{ label: '超级管理员', value: 1 },
|
||||
{ label: '系统管理员', value: 2 },
|
||||
{ label: '省级管理员', value: 3 },
|
||||
{ label: '市级管理员', value: 4 },
|
||||
{ label: '业务员', value: 6 },
|
||||
{ label: '诊所推广员', value: 14 },
|
||||
];
|
||||
|
||||
type AssignedEntry = {
|
||||
code: string;
|
||||
entry_id: number;
|
||||
group_name: string;
|
||||
is_default_fav: number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
const activeRole = ref(String(ROLE_TABS[0]?.value || 8));
|
||||
const assigned = ref<AssignedEntry[]>([]);
|
||||
const selectedIds = ref<number[]>([]);
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
|
||||
/** 已分配入口按分组展示,方便对照小程序功能页 */
|
||||
const assignedGroups = computed(() => {
|
||||
const groups: { items: AssignedEntry[]; name: string }[] = [];
|
||||
assigned.value.forEach((item) => {
|
||||
const name = item.group_name || '未分组';
|
||||
let group = groups.find((g) => g.name === name);
|
||||
if (!group) {
|
||||
group = { name, items: [] };
|
||||
groups.push(group);
|
||||
}
|
||||
group.items.push(item);
|
||||
});
|
||||
return groups;
|
||||
});
|
||||
|
||||
/** 拉取当前角色已分配入口,并用 is_default_fav 回显勾选 */
|
||||
async function loadAssigned() {
|
||||
const roleId = Number(activeRole.value || 0);
|
||||
if (!roleId) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const rows = await getWxWorkbenchEntryByRoleId(roleId);
|
||||
const list = (Array.isArray(rows) ? rows : []) as AssignedEntry[];
|
||||
assigned.value = list;
|
||||
selectedIds.value = list
|
||||
.filter((item) => Number(item.is_default_fav) === 1)
|
||||
.map((item) => Number(item.entry_id));
|
||||
} catch {
|
||||
message.error('获取已分配入口失败');
|
||||
assigned.value = [];
|
||||
selectedIds.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存当前角色的默认常用(空数组表示该角色不自动写入) */
|
||||
async function handleSave() {
|
||||
const roleId = Number(activeRole.value || 0);
|
||||
if (!roleId) {
|
||||
message.error('请先选择角色');
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
await saveWxWorkbenchDefaultFav({
|
||||
role_id: roleId,
|
||||
entry_ids: selectedIds.value,
|
||||
});
|
||||
message.success('保存成功');
|
||||
await loadAssigned();
|
||||
} catch {
|
||||
message.error('保存失败');
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 勾选/取消某入口为默认常用(只改本地,点保存才入库) */
|
||||
function toggleDefault(id: number, checked: boolean) {
|
||||
if (checked) {
|
||||
if (!selectedIds.value.includes(id)) {
|
||||
selectedIds.value.push(id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
selectedIds.value = selectedIds.value.filter((item) => item !== id);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const cached = localStorage.getItem(TAB_STORAGE_KEY);
|
||||
const valid = ROLE_TABS.some((item) => String(item.value) === cached);
|
||||
if (valid && cached) {
|
||||
activeRole.value = cached;
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
activeRole,
|
||||
(key) => {
|
||||
localStorage.setItem(TAB_STORAGE_KEY, key);
|
||||
loadAssigned();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="cfg-panel" :class="{ 'opacity-60': loading }">
|
||||
<div class="cfg-panel-body">
|
||||
<Tabs v-model:active-key="activeRole">
|
||||
<Tabs.TabPane
|
||||
v-for="item in ROLE_TABS"
|
||||
:key="String(item.value)"
|
||||
:tab="item.label"
|
||||
/>
|
||||
</Tabs>
|
||||
<p class="fav-hint">
|
||||
勾选后,该角色用户第一次进入医生端小程序时,会自动写入「我的常用」。已加过再清空的用户不会被覆盖。仅能勾选该角色已分配的入口。
|
||||
</p>
|
||||
<template v-if="assignedGroups.length">
|
||||
<div
|
||||
v-for="group in assignedGroups"
|
||||
:key="group.name"
|
||||
class="fav-group"
|
||||
>
|
||||
<div class="fav-group__title">{{ group.name }}</div>
|
||||
<div class="fav-group__list">
|
||||
<label
|
||||
v-for="entry in group.items"
|
||||
:key="entry.entry_id"
|
||||
class="fav-item"
|
||||
>
|
||||
<Checkbox
|
||||
:checked="selectedIds.includes(Number(entry.entry_id))"
|
||||
@update:checked="
|
||||
(val: boolean) => toggleDefault(Number(entry.entry_id), val)
|
||||
"
|
||||
>
|
||||
{{ entry.name }}
|
||||
</Checkbox>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<Empty
|
||||
v-else
|
||||
class="fav-empty"
|
||||
description="该角色尚未分配功能入口,请先到「小程序功能入口」或角色管理里勾选"
|
||||
/>
|
||||
</div>
|
||||
<div class="cfg-panel-footer">
|
||||
<Button type="primary" :loading="saving" @click="handleSave">
|
||||
保存本模块配置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fav-hint {
|
||||
margin: 0 0 16px;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.fav-group {
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 16px;
|
||||
background: hsl(var(--muted) / 25%);
|
||||
border: 1px solid hsl(var(--primary) / 30%);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 6px hsl(var(--primary) / 18%);
|
||||
}
|
||||
|
||||
.fav-group__title {
|
||||
margin-bottom: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.fav-group__list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 16px;
|
||||
}
|
||||
|
||||
.fav-item {
|
||||
min-width: 140px;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.fav-empty {
|
||||
padding: 24px 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,9 +1,13 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Component } from 'vue';
|
||||
|
||||
import type { ModuleTabItem } from './components/module-tabs-panel.vue';
|
||||
|
||||
/**
|
||||
* 系统配置页壳:左侧一级菜单 + 右侧动态面板
|
||||
* 各模块自管 load/save;AI 在右侧保留二级 Tab
|
||||
* 系统配置页壳:左侧一级模块 + 右侧二级 Tab(与 AI 模块同一交互)
|
||||
* 各子面板自管 load/save;旧菜单 key 会映射到新模块/Tab
|
||||
*/
|
||||
import { computed, onMounted, ref, type Component } from 'vue';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
|
||||
@@ -11,15 +15,20 @@ import { Card } from 'ant-design-vue';
|
||||
|
||||
import AiModelConfigPanel from './components/ai-model-config-panel.vue';
|
||||
import DictSearchConfigPanel from './components/dict-search-config-panel.vue';
|
||||
import DispenseSignConfigPanel from './components/dispense-sign-config-panel.vue';
|
||||
import DoctorLoginConfigPanel from './components/doctor-login-config-panel.vue';
|
||||
import HomeDisplayConfigPanel from './components/home-display-config-panel.vue';
|
||||
import InputAuditConfigPanel from './components/input-audit-config-panel.vue';
|
||||
import InvoiceNoticeConfigPanel from './components/invoice-notice-config-panel.vue';
|
||||
import LogisticsConfigPanel from './components/logistics-config-panel.vue';
|
||||
import MedicalRecordConfigPanel from './components/medical-record-config-panel.vue';
|
||||
import MiniprogramConfigPanel from './components/miniprogram-config-panel.vue';
|
||||
import ModuleTabsPanel from './components/module-tabs-panel.vue';
|
||||
import OaNotifyConfigPanel from './components/oa-notify-config-panel.vue';
|
||||
import PriceAdjustConfigPanel from './components/price-adjust-config-panel.vue';
|
||||
import SalespersonConfigPanel from './components/salesperson-config-panel.vue';
|
||||
import WorkbenchConfigPanel from './components/workbench-config-panel.vue';
|
||||
import WxWorkbenchDefaultFavPanel from './components/wx-workbench-default-fav-panel.vue';
|
||||
|
||||
defineOptions({ name: 'SystemConfig' });
|
||||
|
||||
@@ -28,35 +37,146 @@ const MENU_STORAGE_KEY = 'system_config_active_tab';
|
||||
interface MenuItem {
|
||||
key: string;
|
||||
title: string;
|
||||
component: Component;
|
||||
/** 已自带二级 Tab 的整块面板(AI) */
|
||||
component?: Component;
|
||||
tabs?: ModuleTabItem[];
|
||||
}
|
||||
|
||||
const menuItems: MenuItem[] = [
|
||||
{ key: 'price_adjust', title: '订单调价', component: PriceAdjustConfigPanel },
|
||||
{ key: 'input_audit', title: '录入审核', component: InputAuditConfigPanel },
|
||||
{ key: 'miniprogram', title: '小程序', component: MiniprogramConfigPanel },
|
||||
{ key: 'logistics', title: '物流展示', component: LogisticsConfigPanel },
|
||||
{ key: 'salesperson', title: '业务员权限', component: SalespersonConfigPanel },
|
||||
{ key: 'doctor_login', title: '登录安全', component: DoctorLoginConfigPanel },
|
||||
{ key: 'dict_search', title: '字典搜索', component: DictSearchConfigPanel },
|
||||
{ key: 'medical_record', title: '词条', component: MedicalRecordConfigPanel },
|
||||
{ key: 'invoice_notice', title: '开票通知', component: InvoiceNoticeConfigPanel },
|
||||
{ key: 'oa_notify', title: 'OA通知', component: OaNotifyConfigPanel },
|
||||
{ key: 'ai_model', title: 'AI模型', component: AiModelConfigPanel },
|
||||
{
|
||||
key: 'mp_patient',
|
||||
title: '患者端小程序',
|
||||
tabs: [
|
||||
{ key: 'empty', title: '空状态图', component: MiniprogramConfigPanel },
|
||||
{ key: 'home', title: '首页展示', component: HomeDisplayConfigPanel },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'mp_doctor',
|
||||
title: '医生端小程序',
|
||||
tabs: [
|
||||
{
|
||||
key: 'default_fav',
|
||||
title: '默认常用入口',
|
||||
component: WxWorkbenchDefaultFavPanel,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'workbench',
|
||||
title: '工作台',
|
||||
component: WorkbenchConfigPanel,
|
||||
},
|
||||
{
|
||||
key: 'dispense_sign',
|
||||
title: '处方签名',
|
||||
component: DispenseSignConfigPanel,
|
||||
},
|
||||
{
|
||||
key: 'order',
|
||||
title: '订单',
|
||||
tabs: [
|
||||
{
|
||||
key: 'price_adjust',
|
||||
title: '订单调价',
|
||||
component: PriceAdjustConfigPanel,
|
||||
},
|
||||
{ key: 'logistics', title: '物流展示', component: LogisticsConfigPanel },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'security',
|
||||
title: '权限安全',
|
||||
tabs: [
|
||||
{ key: 'login', title: '登录安全', component: DoctorLoginConfigPanel },
|
||||
{
|
||||
key: 'salesperson',
|
||||
title: '业务员权限',
|
||||
component: SalespersonConfigPanel,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'medical',
|
||||
title: '病历词条',
|
||||
tabs: [
|
||||
{ key: 'entry', title: '词条', component: MedicalRecordConfigPanel },
|
||||
{
|
||||
key: 'dict_search',
|
||||
title: '字典搜索',
|
||||
component: DictSearchConfigPanel,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'notice',
|
||||
title: '通知',
|
||||
tabs: [
|
||||
{
|
||||
key: 'invoice',
|
||||
title: '开票通知',
|
||||
component: InvoiceNoticeConfigPanel,
|
||||
},
|
||||
{ key: 'oa', title: 'OA通知', component: OaNotifyConfigPanel },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'audit',
|
||||
title: '审核',
|
||||
component: InputAuditConfigPanel,
|
||||
},
|
||||
{
|
||||
key: 'ai_model',
|
||||
title: 'AI模型',
|
||||
component: AiModelConfigPanel,
|
||||
},
|
||||
];
|
||||
|
||||
const activeKey = ref('price_adjust');
|
||||
/** 旧左侧菜单 key → 新模块 + 二级 Tab,避免刷新后丢选中 */
|
||||
const LEGACY_MENU_MAP: Record<string, { menu: string; tab?: string }> = {
|
||||
price_adjust: { menu: 'order', tab: 'price_adjust' },
|
||||
input_audit: { menu: 'audit', tab: 'input_audit' },
|
||||
miniprogram: { menu: 'mp_patient' },
|
||||
logistics: { menu: 'order', tab: 'logistics' },
|
||||
salesperson: { menu: 'security', tab: 'salesperson' },
|
||||
doctor_login: { menu: 'security', tab: 'login' },
|
||||
dict_search: { menu: 'medical', tab: 'dict_search' },
|
||||
medical_record: { menu: 'medical', tab: 'entry' },
|
||||
invoice_notice: { menu: 'notice', tab: 'invoice' },
|
||||
oa_notify: { menu: 'notice', tab: 'oa' },
|
||||
ai_model: { menu: 'ai_model' },
|
||||
};
|
||||
|
||||
const currentPanel = computed(() => {
|
||||
const hit = menuItems.find((m) => m.key === activeKey.value);
|
||||
return hit?.component || PriceAdjustConfigPanel;
|
||||
const activeKey = ref('mp_patient');
|
||||
|
||||
const currentMenu = computed(() => {
|
||||
return menuItems.find((m) => m.key === activeKey.value) || menuItems[0];
|
||||
});
|
||||
|
||||
function readStoredMenu() {
|
||||
const stored = localStorage.getItem(MENU_STORAGE_KEY);
|
||||
if (stored && menuItems.some((m) => m.key === stored)) {
|
||||
activeKey.value = stored;
|
||||
if (!stored) {
|
||||
return;
|
||||
}
|
||||
if (menuItems.some((m) => m.key === stored)) {
|
||||
activeKey.value = stored;
|
||||
return;
|
||||
}
|
||||
const mapped = LEGACY_MENU_MAP[stored];
|
||||
if (!mapped) {
|
||||
return;
|
||||
}
|
||||
activeKey.value = mapped.menu;
|
||||
if (mapped.tab) {
|
||||
localStorage.setItem(`system_config_${mapped.menu}_sub_tab`, mapped.tab);
|
||||
} else if (stored === 'miniprogram') {
|
||||
// 旧「小程序」二级 Tab 迁到患者端,避免刷新后丢「首页展示」
|
||||
const oldSub = localStorage.getItem('system_config_miniprogram_sub_tab');
|
||||
if (oldSub) {
|
||||
localStorage.setItem('system_config_mp_patient_sub_tab', oldSub);
|
||||
}
|
||||
}
|
||||
localStorage.setItem(MENU_STORAGE_KEY, mapped.menu);
|
||||
}
|
||||
|
||||
function selectMenu(key: string) {
|
||||
@@ -84,7 +204,17 @@ onMounted(readStoredMenu);
|
||||
</button>
|
||||
</aside>
|
||||
<section class="sys-cfg-main">
|
||||
<component :is="currentPanel" :key="activeKey" />
|
||||
<component
|
||||
v-if="currentMenu.component"
|
||||
:is="currentMenu.component"
|
||||
:key="currentMenu.key"
|
||||
/>
|
||||
<ModuleTabsPanel
|
||||
v-else-if="currentMenu.tabs"
|
||||
:key="currentMenu.key"
|
||||
:module-key="currentMenu.key"
|
||||
:tabs="currentMenu.tabs"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -93,58 +223,65 @@ onMounted(readStoredMenu);
|
||||
|
||||
<style scoped>
|
||||
.sys-cfg-card {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.sys-cfg-layout {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
min-height: 480px;
|
||||
max-height: calc(100vh - 160px);
|
||||
}
|
||||
|
||||
.sys-cfg-nav {
|
||||
width: 188px;
|
||||
flex-shrink: 0;
|
||||
overflow-y: auto;
|
||||
border-right: 1px solid hsl(var(--border));
|
||||
background: hsl(var(--muted) / 0.25);
|
||||
width: 188px;
|
||||
padding: 8px;
|
||||
overflow-y: auto;
|
||||
background: hsl(var(--muted) / 25%);
|
||||
border-right: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.sys-cfg-nav-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: hsl(var(--foreground));
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: hsl(var(--foreground));
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
transition:
|
||||
background 0.15s ease,
|
||||
border-color 0.15s ease,
|
||||
color 0.15s ease;
|
||||
}
|
||||
|
||||
.sys-cfg-nav-item:hover {
|
||||
background: hsl(var(--accent));
|
||||
}
|
||||
|
||||
.sys-cfg-nav-item.active {
|
||||
border-color: hsl(var(--primary) / 35%);
|
||||
background: hsl(var(--primary) / 0.1);
|
||||
color: hsl(var(--primary));
|
||||
background: hsl(var(--primary) / 10%);
|
||||
border-color: hsl(var(--primary) / 35%);
|
||||
box-shadow: 0 0 6px hsl(var(--primary) / 18%);
|
||||
}
|
||||
|
||||
.sys-cfg-main {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 子面板统一:内容滚动 + 底栏不滚走 */
|
||||
.sys-cfg-main :deep(.cfg-panel) {
|
||||
display: flex;
|
||||
@@ -152,17 +289,24 @@ onMounted(readStoredMenu);
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.sys-cfg-main :deep(.cfg-panel-body) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 16px 20px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.sys-cfg-main :deep(.module-tabs-body) {
|
||||
padding: 8px 0 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sys-cfg-main :deep(.cfg-panel-footer) {
|
||||
flex-shrink: 0;
|
||||
border-top: 1px solid hsl(var(--border));
|
||||
background: hsl(var(--card, var(--background)));
|
||||
padding: 12px 20px;
|
||||
z-index: 2;
|
||||
flex-shrink: 0;
|
||||
padding: 12px 20px;
|
||||
background: hsl(var(--card, var(--background)));
|
||||
border-top: 1px solid hsl(var(--border));
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user