feat: 订单双视图、工作台日历组件、医生排班管理与挂号语音提醒

- 订单双视图:商品订单/处方/挂号列表新增卡片视图(CardList)、视图切换组件 view-mode-switch、stat-islands 统计岛、constants 字典
- 工作台:新增工作日历 Widget、待办日历、即将到访预约、公告滚动 NoticeTicker、日历面板 CalendarPanel
- 医生排班:新增排班 API、ScheduleDrawer 抽屉、schedule-calendar 组件、门店设置弹窗
- 日志与通知:新增排班变更日志页、排班变更通知视图
- 挂号提醒:新增挂号语音播报资源与 register-notify 工具
- 桌面端:新增 apps/desktop 壳及 desktop 工具方法
- 其他:处方/订单导出、聊天设置与 WebSocket 等小幅优化
This commit is contained in:
李琦
2026-08-14 17:46:50 +08:00
parent f15bf10553
commit beba5e8feb
105 changed files with 16261 additions and 1253 deletions

View File

@@ -10,6 +10,7 @@ alwaysApply: true
3. 有封装好的方法、组件需要复用,不要重复造轮子
4. 小程序端的抽屉全部需要用 page-container 来防止用户意外退出页面(记得使用 v-if 而不是 v-show
5. 数据库的created_at、updated_at、deleted_at统一使用时间戳不要使用字符串并且我在查询器中一级格式化成字符串了无需再次格式化
6. 卡片强调样式:**禁止**用左侧竖色条(`border-left: Npx solid 色值` / 左侧色块做卡片强调或分类标识统一用「1px 同色系边框 + 微光」——`border: 1px solid hsl(var(--主题色) / 30%)` 配 `box-shadow: 0 0 6px hsl(var(--主题色) / 18%)`;历史页面暂不强制回改,新增/改动的卡片必须遵守
# 全局架构规范

3
.npmrc
View File

@@ -1 +1,4 @@
registry=https://registry.npmmirror.com
# Electron 二进制与 electron-builder 打包工具链的国内镜像(不配置在国内网络下大概率下载超时失败)
electron_mirror=https://npmmirror.com/mirrors/electron/
electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/

2
apps/desktop/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
out/
release/

46
apps/desktop/README.md Normal file
View File

@@ -0,0 +1,46 @@
# @vben/desktop — 萧康云医 PC 桌面端
Electron 壳工程:打包加载 `apps/web-antd` 的生产构建产物,提供桌面通知、开机自启、系统托盘、未读角标、静默打印、全局快捷键与自动更新能力。
## 常用命令(在仓库根目录执行)
```bash
# 开发:并行启动 web-antd dev server:5666与 Electron 壳(壳会自动等待/重试连接 dev server
pnpm dev:desktop
# 打包turbo 先构建 web-antd dist再构建主进程并出安装包产物在 apps/desktop/release/
pnpm build:desktop
```
- Windows 机器出 NSIS 安装包mac 包必须在 macOS 机器上执行同一命令electron-builder 不支持交叉编译 mac 包)。
- 不需要打桌面包的同事 / CI可设置环境变量 `ELECTRON_SKIP_BINARY_DOWNLOAD=1` 跳过 Electron 二进制下载,不影响 web 开发。
## 环境切换dev 走本地,打包走生产)
| 模式 | 页面来源 | API 走向 |
|---|---|---|
| `pnpm dev:desktop` | `http://localhost:5666`web-antd vite dev server | vite 代理转发到**本地后端**`/api/*``localhost:18001``/im-api/*``localhost:12080`(见 `apps/web-antd/vite.config.ts` |
| 打包后的安装包 | `app://local/`(内置 web-antd 生产 dist | 主进程协议处理器转发到**生产上游**`/api/admin/*``api.xiaokang88.com``/im-api/*``api.ws.g.xiaokang88.com/api`(见 `src/main/config.ts`,可用环境变量覆盖) |
即:开发调试自动对接本地后端(与网页开发完全一致),只有 `pnpm build:desktop` 出的安装包才请求生产环境,无需手动切换。
## 环境变量
| 变量 | 用途 |
|---|---|
| `XK_DESKTOP_API_UPSTREAM` | 覆盖 `/api/admin` 转发上游(联调测试环境用) |
| `XK_DESKTOP_IM_UPSTREAM` | 覆盖 `/im-api` 转发上游 |
| `XK_DESKTOP_DEV_URL` | 覆盖开发模式加载的 dev server 地址(默认 `http://localhost:5666` |
| `CSC_LINK` / `CSC_KEY_PASSWORD` | 代码签名证书;**未配置时自动跳过签名,出未签名包,构建不报错** |
| `APPLE_ID` / `APPLE_APP_SPECIFIC_PASSWORD` / `APPLE_TEAM_ID` | mac 公证三件套;三者齐全才启用公证 |
未签名 mac 包可内部分发:首次打开需右键「打开」,且 mac 自动更新不可用(应用内会降级为提示前往下载页手动安装)。
## 发布自动更新
`release/` 里的以下产物上传到静态托管地址(`electron-builder.config.cjs``publish.url`
- Windows`latest.yml` + `xk-desktop-*.exe` + `*.blockmap`
- mac`latest-mac.yml` + `*.dmg` + `*.zip` + `*.blockmap`zip 是 mac 自动更新必需格式)
前端每次发版后跟着跑一次 `pnpm build:desktop` 并上传产物,桌面端即可自动更新。

View File

@@ -0,0 +1,78 @@
/**
* electron-builder 打包配置
* 用 JS 配置而不是 ymlmac 公证需要按「环境变量是否配齐」动态开关。
*
* 签名策略(按需求做成条件化,未配置证书时出未签名包且构建不报错):
* - Windows / mac 代码签名:配置了 CSC_LINK + CSC_KEY_PASSWORD或 mac 钥匙串里有
* Developer ID 证书)时 electron-builder 自动签名;否则自动跳过,只输出告警
* - mac 公证APPLE_ID + APPLE_APP_SPECIFIC_PASSWORD + APPLE_TEAM_ID 三者齐全才启用
* - 未签名 mac 包仍可内部分发:首次打开需右键「打开」,且自动更新会降级为提示手动下载
*/
const canNotarize = Boolean(
process.env.APPLE_ID &&
process.env.APPLE_APP_SPECIFIC_PASSWORD &&
process.env.APPLE_TEAM_ID,
);
/** @type {import('electron-builder').Configuration} */
const config = {
// appId 必须与主进程 setAppUserModelId 一致,否则 Windows 系统通知不显示
appId: 'com.xiaokang.xkadmin',
productName: '萧康云医',
// 安装包文件名用 ASCII避免中文名在静态托管/下载链接上出编码问题
artifactName: 'xk-desktop-${version}-${os}-${arch}.${ext}',
directories: {
output: 'release',
buildResources: 'resources',
},
files: ['out/**'],
extraResources: [
// web-antd 生产构建产物:放 resources/web不进 asar为后续渲染层热更新留路
{ from: '../web-antd/dist', to: 'web' },
// 应用图标供运行时使用托盘、Windows 窗口图标)
{ from: 'resources/icon.png', to: 'icon.png' },
],
asar: true,
compression: 'normal',
win: {
// TODO(待确认):正式图标待提供,当前用 web-antd logo 预生成的 ico 占位
// 直接给 .ico 让 builder 跳过 png 转换(本机 builder 的 wasm 图标工具会报内存错误)
icon: 'resources/icon.ico',
target: [{ target: 'nsis', arch: ['x64'] }],
},
nsis: {
// 非一键安装:允许用户选择安装目录,符合企业内部软件分发习惯
oneClick: false,
perMachine: false,
allowToChangeInstallationDirectory: true,
createDesktopShortcut: true,
createStartMenuShortcut: true,
shortcutName: '萧康云医',
},
mac: {
icon: 'resources/icon.png',
category: 'public.app-category.medical',
// dmg 给用户安装zip 是 electron-updater 在 mac 自动更新的必需格式,缺一不可
target: [
{ target: 'dmg', arch: ['x64', 'arm64'] },
{ target: 'zip', arch: ['x64', 'arm64'] },
],
hardenedRuntime: true,
gatekeeperAssess: false,
entitlements: 'resources/entitlements.mac.plist',
entitlementsInherit: 'resources/entitlements.mac.plist',
// IM 音视频通话必需的隐私用途描述,缺失时 mac 上调用摄像头/麦克风直接崩溃
extendInfo: {
NSCameraUsageDescription: '视频问诊需要使用摄像头',
NSMicrophoneUsageDescription: '语音/视频通话需要使用麦克风',
},
notarize: canNotarize,
},
publish: {
provider: 'generic',
// TODO(待确认):更新包静态托管地址,需与主进程 config.ts 的 DOWNLOAD_PAGE_URL 保持一致
url: 'https://static.xiaokang88.com/xk-desktop/',
},
};
module.exports = config;

View File

@@ -0,0 +1,26 @@
import { defineConfig, externalizeDepsPlugin } from 'electron-vite';
/**
* electron-vite 构建配置
* 只构建主进程与预加载脚本,不配置 renderer
* - 开发模式渲染层直接加载 web-antd 的 vite dev serverhttp://localhost:5666
* - 打包模式渲染层加载 app://local/web-antd 的生产 dist由主进程自定义协议提供服务
* externalizeDepsPlugindependencieselectron-updater / electron-log保持外部化
* 由 electron-builder 按生产依赖收集进安装包,避免打包器转译这类含运行时文件读取的库出问题
*/
export default defineConfig({
main: {
plugins: [externalizeDepsPlugin()],
build: {
outDir: 'out/main',
lib: { entry: 'src/main/index.ts' },
},
},
preload: {
plugins: [externalizeDepsPlugin()],
build: {
outDir: 'out/preload',
lib: { entry: 'src/preload/index.ts' },
},
},
});

27
apps/desktop/package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "@vben/desktop",
"version": "1.0.0",
"private": true,
"description": "萧康云医 PC 桌面端Electron 壳,打包加载 web-antd 生产构建产物)",
"author": "萧康云医",
"main": "out/main/index.js",
"scripts": {
"dev": "electron-vite dev",
"build": "electron-vite build",
"pack": "electron-builder --config electron-builder.config.cjs",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"electron-log": "catalog:",
"electron-updater": "^6.8.9"
},
"devDependencies": {
"@types/node": "catalog:",
"@vben/web-antd": "workspace:*",
"electron": "^43.4.0",
"electron-builder": "catalog:",
"electron-vite": "catalog:",
"typescript": "catalog:",
"vite": "^7.3.6"
}
}

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- hardenedRuntime 下 ElectronV8 JIT运行必需的内存权限 -->
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
<!-- IM 音视频通话:摄像头 / 麦克风访问权限 -->
<key>com.apple.security.device.camera</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
</dict>
</plist>

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

View File

@@ -0,0 +1,30 @@
import { app } from 'electron';
import log from 'electron-log/main';
/**
* 开机自启基于系统登录项实现Windows 写 HKCU\...\Run 注册表、mac 写「系统设置 → 登录项」),
* 无需管理员权限;系统登录项本身就是持久化存储,无需额外落盘记录状态。
*/
/** 读取当前开机自启状态(直接以系统登录项为准,避免自维护状态与系统不同步) */
export function getAutoLaunchEnabled(): boolean {
// 开发模式下登录项指向 electron 开发二进制,无意义,统一视为关闭
if (!app.isPackaged) return false;
return app.getLoginItemSettings().openAtLogin;
}
/**
* 设置开机自启
* --hidden 参数让开机自启时静默进托盘(主进程入口据此不弹主窗口),避免开机打扰
*/
export function setAutoLaunchEnabled(enable: boolean): boolean {
if (!app.isPackaged) {
log.warn('开发模式不支持设置开机自启(登录项会错误指向开发二进制)');
return false;
}
app.setLoginItemSettings({
openAtLogin: enable,
args: enable ? ['--hidden'] : [],
});
return getAutoLaunchEnabled();
}

View File

@@ -0,0 +1,24 @@
import { app, nativeImage } from 'electron';
import { getMainWindow } from './window';
/**
* 未读角标:按平台分支实现
* - macDock 图标原生支持文字角标,直接 setBadge
* - Windows任务栏无原生数字角标用「覆盖图标」实现图由 preload 侧 canvas 绘制成 dataURL 传来,
* 因为主进程没有 DOM/canvas在渲染侧画图是社区标准做法
*/
export function applyBadge(count: number, image?: string): void {
if (process.platform === 'darwin') {
app.dock?.setBadge(count > 0 ? String(Math.min(count, 99)) : '');
return;
}
const win = getMainWindow();
if (!win) return;
if (count > 0 && image) {
win.setOverlayIcon(nativeImage.createFromDataURL(image), `${count} 条未读消息`);
} else {
// 数量归零或没有角标图时清除覆盖图标
win.setOverlayIcon(null, '');
}
}

View File

@@ -0,0 +1,54 @@
import { app } from 'electron';
import { join } from 'node:path';
/**
* 桌面端全局配置
* 打包模式下渲染层加载的是本地 dist所有「按环境调整」的地址都收敛在这里
* 并支持环境变量覆盖,便于联调测试环境时不改代码切换上游。
*/
/** 应用标识:必须与 electron-builder 的 appId 一致,否则 Windows 系统通知不显示 */
export const APP_ID = 'com.xiaokang.xkadmin';
/** 自定义协议,注册为特权协议后 app://local/ 等价一个「本地 https 站点」(安全上下文) */
export const APP_SCHEME = 'app';
export const APP_HOST = 'local';
export const APP_ORIGIN = `${APP_SCHEME}://${APP_HOST}`;
export const APP_INDEX_URL = `${APP_ORIGIN}/`;
/** 开发模式加载 web-antd 的 vite dev server端口对齐 apps/web-antd/.env.development 的 VITE_PORT=5666 */
export const DEV_SERVER_URL = process.env.XK_DESKTOP_DEV_URL ?? 'http://localhost:5666';
/**
* /api/admin/* 的转发上游(仅打包后的 app:// 协议使用dev 模式加载 vite dev server
* 由 vite 代理转发到本地后端 localhost:18001不经过这里
* 地址来源apps/web-antd/vite.config.ts 代理注释中的生产目标 https://api.xiaokang88.com/api/admin/
*/
export const API_UPSTREAM = process.env.XK_DESKTOP_API_UPSTREAM ?? 'https://api.xiaokang88.com';
/**
* /im-api/* 的转发上游(转发规则对齐开发代理:/im-api/* → 上游 /api/*;同样仅打包后生效,
* dev 模式由 vite 代理转发到本地 IM localhost:12080
* 地址来源vite.config.ts 代理注释的生产目标 https://api.ws.g.xiaokang88.com/api/,与生产 WS 同主机
*/
export const IM_UPSTREAM = process.env.XK_DESKTOP_IM_UPSTREAM ?? 'https://api.ws.g.xiaokang88.com';
/**
* 更新包下载页地址(与 electron-builder.config.cjs 的 publish.url 保持一致)
* 兼作 mac 未签名时「自动更新不可用 → 手动下载」的跳转页
* TODO(待确认):更新包静态托管地址
*/
export const DOWNLOAD_PAGE_URL = 'https://static.xiaokang88.com/xk-desktop/';
/** 全局快捷键:唤起/隐藏主窗口CommandOrControl 在 Windows 映射 Ctrl、mac 映射 Cmd */
export const MAIN_WINDOW_SHORTCUT = 'CommandOrControl+Alt+X';
/**
* 解析运行时资源路径(图标等)
* 打包后 extraResources 位于 process.resourcesPath开发时直接读工程 resources 目录
*/
export function getResourcePath(name: string): string {
return app.isPackaged
? join(process.resourcesPath, name)
: join(app.getAppPath(), 'resources', name);
}

View File

@@ -0,0 +1,81 @@
import { app, Menu } from 'electron';
import log from 'electron-log/main';
import { APP_ID } from './config';
import { registerIpcHandlers } from './ipc';
import { registerAppProtocol, registerSchemePrivileges } from './protocol';
import { registerGlobalShortcut, unregisterGlobalShortcut } from './shortcut';
import { createTray } from './tray';
import { initUpdater } from './updater';
import { createMainWindow, getMainWindow, markQuitting, showMainWindow } from './window';
/**
* 主进程入口:负责应用生命周期编排
* 启动顺序:单实例锁 → 特权协议注册(必须在 ready 前)→ ready 后依次初始化
* 协议服务 / IPC / 主窗口 / 托盘 / 全局快捷键 / 自动更新
*/
// 显式设置应用名userData / 日志目录会随之落到 %APPDATA%\萧康云医,
// 不设置的话 Electron 会取 package.json 的 name@vben/desktop生成嵌套目录不利于运维排查
app.setName('萧康云医');
// Windows 通知归属与任务栏分组标识,必须与 electron-builder 的 appId 一致,
// 否则系统通知不显示或显示为「electron.app.Electron」
app.setAppUserModelId(APP_ID);
// 单实例锁:避免多开导致托盘、自动更新、本地设置互相干扰
if (app.requestSingleInstanceLock()) {
// 特权协议必须在 app ready 之前注册,否则 app:// 无法成为安全上下文
registerSchemePrivileges();
// 重复启动(用户再次双击图标)时唤起已有窗口
app.on('second-instance', () => showMainWindow());
app.whenReady().then(() => {
// electron-log 初始化:主进程日志落 userData/logs便于排查线上问题
log.initialize();
// mac 必须保留应用菜单,否则 Cmd+C/V/Q 等系统快捷键失效Windows 隐藏菜单栏
if (process.platform === 'darwin') {
Menu.setApplicationMenu(
Menu.buildFromTemplate([
{ role: 'appMenu' },
{ role: 'editMenu' },
{ role: 'viewMenu' },
{ role: 'windowMenu' },
]),
);
} else {
Menu.setApplicationMenu(null);
}
registerAppProtocol();
registerIpcHandlers();
// 开机自启带 --hidden 参数:静默启动进托盘,不弹主窗口打扰用户
const startHidden = process.argv.includes('--hidden');
createMainWindow({ startHidden });
createTray();
registerGlobalShortcut();
initUpdater();
});
// mac 点击 Dock 图标恢复窗口(关窗只是隐藏,符合平台惯例)
app.on('activate', () => {
if (getMainWindow()) showMainWindow();
});
// 常驻托盘模式窗口全部关闭也不退出close 已被拦截为隐藏,这里是兜底),退出走托盘菜单/Cmd+Q
app.on('window-all-closed', () => {
// 故意留空:不调用 app.quit()
});
// 任何退出路径(托盘退出 / Cmd+Q / 自动更新安装)都先放行窗口 close 拦截
app.on('before-quit', () => markQuitting());
// 退出前注销全局快捷键,避免残留占用
app.on('will-quit', () => unregisterGlobalShortcut());
} else {
app.quit();
}

View File

@@ -0,0 +1,117 @@
import { app, ipcMain, Notification } from 'electron';
import { getAutoLaunchEnabled, setAutoLaunchEnabled } from './auto-launch';
import { applyBadge } from './badge';
import { printHtml, type PrintHtmlOptions } from './printer';
import { getSettings, updateSettings, type DesktopSettings } from './settings';
import { refreshTrayMenu } from './tray';
import { checkForUpdates } from './updater';
import { getMainWindow, isTrustedPageUrl, showMainWindow } from './window';
/**
* IPC 汇总preload 暴露的 desktopAPI 全部经由这里进入主进程
* 所有 handler 先校验调用方来源app:// 或开发服务器),
* 防止页面内嵌第三方 iframe 等场景越权调用桌面能力
*/
/** 校验 IPC 调用来源,不可信来源直接抛错拒绝 */
function assertTrustedSender(event: Electron.IpcMainInvokeEvent): void {
const url = event.senderFrame?.url ?? '';
if (!isTrustedPageUrl(url)) {
throw new Error(`拒绝来自不可信来源的 IPC 调用: ${url}`);
}
}
/** 系统通知参数route 用于点击通知后让渲染端跳转对应页面) */
interface NotifyPayload {
title: string;
body: string;
route?: string;
}
export function registerIpcHandlers(): void {
// 应用版本号(渲染端「关于/设置」展示用)
ipcMain.handle('desktop:get-version', (event) => {
assertTrustedSender(event);
return app.getVersion();
});
// 系统通知:点击后唤起主窗口,并把目标路由回传渲染端执行 router.push
ipcMain.handle('desktop:notify', (event, payload: NotifyPayload) => {
assertTrustedSender(event);
if (!Notification.isSupported()) return false;
const notification = new Notification({
title: payload.title,
body: payload.body,
});
notification.on('click', () => {
showMainWindow();
if (payload.route) {
getMainWindow()?.webContents.send('desktop:navigate', payload.route);
}
});
notification.show();
return true;
});
// 未读角标mac Dock 数字 / Windows 任务栏覆盖图标,图由 preload canvas 绘制)
ipcMain.handle('desktop:set-badge', (event, payload: { count: number; image?: string }) => {
assertTrustedSender(event);
applyBadge(payload.count, payload.image);
});
// 任务栏闪烁提醒:仅窗口未聚焦时触发(聚焦状态下闪烁是干扰),聚焦后由窗口 focus 事件自动清除
ipcMain.handle('desktop:flash-frame', (event) => {
assertTrustedSender(event);
const win = getMainWindow();
if (win && !win.isFocused()) win.flashFrame(true);
});
// 开机自启读取/设置(设置后同步刷新托盘菜单勾选态)
ipcMain.handle('desktop:get-auto-launch', (event) => {
assertTrustedSender(event);
return getAutoLaunchEnabled();
});
ipcMain.handle('desktop:set-auto-launch', (event, enable: boolean) => {
assertTrustedSender(event);
const result = setAutoLaunchEnabled(enable);
refreshTrayMenu();
return result;
});
// 打印机列表(渲染端打印设置下拉用)
ipcMain.handle('desktop:get-printers', async (event) => {
assertTrustedSender(event);
const win = getMainWindow();
if (!win) return [];
return await win.webContents.getPrintersAsync();
});
// 静默打印:渲染端拼好完整 HTML 传入,主进程隐藏窗口出纸
ipcMain.handle('desktop:print-html', (event, options: PrintHtmlOptions) => {
assertTrustedSender(event);
return printHtml(options);
});
// 桌面端设置(打印机选择、静默开关等)读取/增量更新
ipcMain.handle('desktop:get-settings', (event) => {
assertTrustedSender(event);
return getSettings();
});
ipcMain.handle('desktop:set-settings', (event, patch: Partial<DesktopSettings>) => {
assertTrustedSender(event);
return updateSettings(patch);
});
// 手动检查更新(渲染端设置页/关于入口)
ipcMain.handle('desktop:check-update', (event) => {
assertTrustedSender(event);
checkForUpdates(true);
});
// 渲染端主动唤起主窗口(如页内提醒的「查看」动作)
ipcMain.handle('desktop:focus-window', (event) => {
assertTrustedSender(event);
showMainWindow();
});
}

View File

@@ -0,0 +1,75 @@
import { unlink, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { app, BrowserWindow } from 'electron';
import log from 'electron-log/main';
/**
* 静默打印:隐藏窗口加载 HTML → webContents.print 直接出纸
* 为什么用隐藏窗口:渲染层的处方打印本来就是拼好的完整 HTML含内联样式
* 加载到独立隐藏窗口打印可以完全复用现有模板,且不阻塞/不干扰主窗口。
* 为什么落临时文件而不是 data: URL处方 HTML 可能较大data URL 有长度上限风险,
* 临时文件 loadFile 对任意大小都稳定,打完即删。
*/
export interface PrintHtmlOptions {
/** 完整的打印 HTML含样式由渲染层拼好传入 */
html: string;
/** 目标打印机名称;不传时使用系统默认打印机 */
deviceName?: string;
/** 是否静默打印true 不弹系统打印对话框) */
silent?: boolean;
}
export interface PrintResult {
ok: boolean;
message?: string;
}
/**
* 执行打印,返回是否成功与失败原因(渲染层据此提示用户)
*/
export async function printHtml(options: PrintHtmlOptions): Promise<PrintResult> {
const { html, deviceName, silent = true } = options;
// 临时文件带时间戳防并发打印互相覆盖
const tempFile = join(app.getPath('temp'), `xk-print-${Date.now()}-${Math.random().toString(36).slice(2)}.html`);
await writeFile(tempFile, html, 'utf8');
return new Promise<PrintResult>((resolve) => {
let settled = false;
const win = new BrowserWindow({
show: false,
webPreferences: { sandbox: true },
});
/** 统一收尾:销毁隐藏窗口、清理临时文件、只 resolve 一次 */
const finish = (ok: boolean, message?: string) => {
if (settled) return;
settled = true;
if (!win.isDestroyed()) win.destroy();
unlink(tempFile).catch(() => {});
if (!ok) log.warn(`静默打印失败: ${message}`);
resolve({ ok, message });
};
win.webContents.once('did-finish-load', () => {
win.webContents.print(
{
silent,
deviceName: deviceName || undefined,
printBackground: true,
},
(success, failureReason) => finish(success, success ? undefined : failureReason),
);
});
win.webContents.once('did-fail-load', (_event, _code, desc) => finish(false, desc));
// 兜底超时:防止打印机脱机等场景下隐藏窗口泄漏
setTimeout(() => finish(false, '打印超时(请检查打印机状态)'), 60_000);
win.loadFile(tempFile).catch((error: unknown) => {
finish(false, error instanceof Error ? error.message : String(error));
});
});
}

View File

@@ -0,0 +1,108 @@
import { existsSync, statSync } from 'node:fs';
import { join, normalize } from 'node:path';
import { pathToFileURL } from 'node:url';
import { app, net, protocol } from 'electron';
import { API_UPSTREAM, APP_SCHEME, IM_UPSTREAM } from './config';
/**
* app:// 自定义协议:桌面端的「本地 nginx」
* 1. 静态文件服务:把 web-antd 生产 dist 按 URL 路径映射到本地文件,带 SPA fallback
* (等价 nginx 的 try_files $uri /index.html因此无需把前端路由改成 hash 模式
* 2. 反向代理:/api/admin/* 与 /im-api/* 用 net.fetch 转发到线上上游 ——
* 渲染层看到的是同源请求,天然规避 CORS后端与 nginx 都不需要任何改动
*/
/** Electron net.fetch 支持但标准 RequestInit 类型缺失的扩展字段 */
type ElectronFetchInit = RequestInit & {
/** 转发出去的请求不再进入自定义协议处理器,防止死循环 */
bypassCustomProtocolHandlers?: boolean;
/** 携带流式 body 时 fetch 规范要求显式声明半双工 */
duplex?: 'half';
};
/**
* 注册特权协议,必须在 app ready 之前调用
* standard/secure 让 app:// 成为「安全上下文」navigator.clipboard、getUserMedia、
* IndexedDB、crypto.subtle 等浏览器能力才可用stream 支持流式响应(音频等大文件)
*/
export function registerSchemePrivileges(): void {
protocol.registerSchemesAsPrivileged([
{
scheme: APP_SCHEME,
privileges: {
standard: true,
secure: true,
supportFetchAPI: true,
stream: true,
corsEnabled: true,
},
},
]);
}
/**
* 解析 web dist 根目录
* 打包后位于 resources/webelectron-builder extraResources 拷入,不进 asar
* 未打包(本地验证生产包行为)时兜底读仓库内 web-antd/dist
*/
export function getWebRoot(): string {
return app.isPackaged
? join(process.resourcesPath, 'web')
: join(__dirname, '../../../web-antd/dist');
}
/**
* 把渲染层的同源请求转发到远端上游(等价 nginx proxy_pass
* 保留原请求的方法 / 头 / 流式请求体Bearer token 请求头原样透传
*/
function proxyRequest(target: string, request: Request): Promise<Response> {
const init: ElectronFetchInit = {
method: request.method,
headers: request.headers,
body: request.body,
bypassCustomProtocolHandlers: true,
};
if (request.body) init.duplex = 'half';
return net.fetch(target, init);
}
/**
* 静态文件解析:带路径穿越防护 + SPA fallback
* 命中不了真实文件的路径(前端路由地址、不存在的资源)统一回 index.html 交给前端路由
*/
function resolveStaticFile(webRoot: string, pathname: string): string {
const indexHtml = join(webRoot, 'index.html');
try {
const filePath = normalize(join(webRoot, decodeURIComponent(pathname)));
// 防路径穿越:解析结果必须仍在 webRoot 目录内
if (!filePath.startsWith(normalize(webRoot))) return indexHtml;
if (existsSync(filePath) && statSync(filePath).isFile()) return filePath;
} catch {
// decode 失败等异常场景直接回 index.html不让协议处理器抛错
}
return indexHtml;
}
/**
* 注册 app:// 协议处理器,在 app ready 之后调用
*/
export function registerAppProtocol(): void {
const webRoot = getWebRoot();
protocol.handle(APP_SCHEME, (request) => {
const url = new URL(request.url);
// 反代 /api/admin/*:生产构建的 VITE_GLOB_API_URL=/api/admin路径直接透传
if (url.pathname.startsWith('/api/')) {
return proxyRequest(`${API_UPSTREAM}${url.pathname}${url.search}`, request);
}
// 反代 /im-api/*:对齐开发代理规则,剥掉 /im-api 前缀后拼到上游 /api/*
if (url.pathname.startsWith('/im-api/')) {
const stripped = url.pathname.slice('/im-api'.length);
return proxyRequest(`${IM_UPSTREAM}/api${stripped}${url.search}`, request);
}
// 其余全部按静态资源处理net.fetch file:// 会按扩展名自动带上正确的 Content-Type
const fileUrl = pathToFileURL(resolveStaticFile(webRoot, url.pathname)).toString();
return net.fetch(fileUrl, { bypassCustomProtocolHandlers: true } as ElectronFetchInit);
});
}

View File

@@ -0,0 +1,67 @@
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { app } from 'electron';
import log from 'electron-log/main';
/**
* 桌面端本地设置持久化userData/desktop-settings.json
* 为什么不用 electron-store目前只需存打印机等少量键值直接读写 JSON 文件即可,
* 避免引入额外依赖electron-store 新版为纯 ESM与 CJS 主进程产物不兼容,还会拖入 ajv 等重依赖)。
*/
/** 桌面端设置项定义 */
export interface DesktopSettings {
/** 静默打印使用的打印机名称,空串表示未选择 */
printerName: string;
/** 是否启用静默打印true 时打印处方不弹系统打印对话框) */
silentPrint: boolean;
}
const DEFAULT_SETTINGS: DesktopSettings = {
printerName: '',
silentPrint: false,
};
/** 内存缓存,避免每次 IPC 调用都读磁盘 */
let cache: DesktopSettings | null = null;
/** 设置文件落在 userData 目录,随应用卸载数据目录一起清理 */
function getSettingsFilePath(): string {
return join(app.getPath('userData'), 'desktop-settings.json');
}
/**
* 读取设置:文件不存在或解析失败时回退默认值,保证任何情况下不影响应用启动
*/
export function getSettings(): DesktopSettings {
if (cache) return cache;
try {
const filePath = getSettingsFilePath();
if (existsSync(filePath)) {
// JSON.parse 返回 any显式收窄成 Partial 再与默认值合并,保证字段完整
const parsed = JSON.parse(readFileSync(filePath, 'utf8')) as Partial<DesktopSettings>;
const merged: DesktopSettings = { ...DEFAULT_SETTINGS, ...parsed };
cache = merged;
return merged;
}
} catch (error) {
log.warn('读取桌面端设置失败,回退默认值', error);
}
cache = { ...DEFAULT_SETTINGS };
return cache;
}
/**
* 增量更新设置并写盘:写盘失败只告警(内存态仍生效),下次成功写入时自动恢复
*/
export function updateSettings(patch: Partial<DesktopSettings>): DesktopSettings {
const next = { ...getSettings(), ...patch };
cache = next;
try {
writeFileSync(getSettingsFilePath(), JSON.stringify(next, null, 2), 'utf8');
} catch (error) {
log.warn('写入桌面端设置失败', error);
}
return next;
}

View File

@@ -0,0 +1,21 @@
import { globalShortcut } from 'electron';
import log from 'electron-log/main';
import { MAIN_WINDOW_SHORTCUT } from './config';
import { toggleMainWindow } from './window';
/**
* 全局快捷键:任何界面下按 CommandOrControl+Alt+X 快速唤起/隐藏主窗口
* 注册失败(快捷键被其他应用占用)只记录警告,不影响应用启动
*/
export function registerGlobalShortcut(): void {
const ok = globalShortcut.register(MAIN_WINDOW_SHORTCUT, () => toggleMainWindow());
if (!ok) {
log.warn(`全局快捷键 ${MAIN_WINDOW_SHORTCUT} 注册失败(可能被其他应用占用)`);
}
}
/** 应用退出前注销全部快捷键,避免残留占用 */
export function unregisterGlobalShortcut(): void {
globalShortcut.unregisterAll();
}

View File

@@ -0,0 +1,78 @@
import { app, Menu, nativeImage, Tray } from 'electron';
import log from 'electron-log/main';
import { getAutoLaunchEnabled, setAutoLaunchEnabled } from './auto-launch';
import { getResourcePath } from './config';
import { checkForUpdates } from './updater';
import { markQuitting, showMainWindow } from './window';
/**
* 系统托盘:应用常驻入口
* - Windows 单击托盘图标恢复主窗口mac 单击弹出菜单(跟随平台惯例)
* - 菜单:显示主窗口 / 开机自启开关 / 检查更新 / 退出
* 注意:托盘实例必须保持模块级引用,否则会被 GC 导致图标消失Electron 经典坑)
*/
let tray: Tray | null = null;
/** 构建托盘菜单:开机自启是动态勾选项,每次状态变化后需重建菜单 */
function buildTrayMenu(): Menu {
return Menu.buildFromTemplate([
{
label: '显示主窗口',
click: () => showMainWindow(),
},
{ type: 'separator' },
{
label: '开机自启',
type: 'checkbox',
checked: getAutoLaunchEnabled(),
// 开发模式登录项无意义,置灰避免误操作
enabled: app.isPackaged,
click: (menuItem) => {
setAutoLaunchEnabled(menuItem.checked);
refreshTrayMenu();
},
},
{
label: '检查更新',
click: () => checkForUpdates(true),
},
{ type: 'separator' },
{
label: '退出',
click: () => {
// 先标记退出,放行主窗口 close 拦截,再走正常退出流程
markQuitting();
app.quit();
},
},
]);
}
/** 刷新托盘菜单(开机自启状态从渲染端 IPC 修改后也要同步勾选态) */
export function refreshTrayMenu(): void {
tray?.setContextMenu(buildTrayMenu());
}
/**
* 创建系统托盘
* 图标用打包的应用图标缩放mac 菜单栏建议 18px、Windows 托盘 16px 的倍数由系统处理)
* TODO(待确认)正式图标到位后mac 建议换单色 Template 图以适配深浅菜单栏
*/
export function createTray(): void {
try {
const icon = nativeImage.createFromPath(getResourcePath('icon.png'));
const trayIcon = process.platform === 'darwin' ? icon.resize({ width: 18, height: 18 }) : icon.resize({ width: 16, height: 16 });
tray = new Tray(trayIcon);
tray.setToolTip('萧康云医');
tray.setContextMenu(buildTrayMenu());
// Windows 单击直接唤起主窗口mac 上设置了 contextMenu 后单击默认弹菜单,不重复绑定
if (process.platform === 'win32') {
tray.on('click', () => showMainWindow());
}
} catch (error) {
// 托盘创建失败(图标缺失等)不阻塞应用启动,仅损失常驻入口
log.error('创建系统托盘失败', error);
}
}

View File

@@ -0,0 +1,139 @@
import { app, dialog, shell } from 'electron';
import log from 'electron-log/main';
import { autoUpdater } from 'electron-updater';
import { DOWNLOAD_PAGE_URL } from './config';
import { getMainWindow, markQuitting, showMainWindow } from './window';
/**
* 自动更新electron-updater + generic 静态托管)
* - 启动 10 秒后自动检查一次 + 每 4 小时轮询 + 托盘/渲染端手动触发
* - autoDownload发现新版本自动后台下载进度事件转发渲染端展示
* - 下载完成弹窗询问「立即重启安装 / 退出时自动安装」
* - mac 未签名包 electron-updater 签名校验会报错:捕获后降级为「打开下载页手动安装」
*/
/** 手动检查标记:手动触发时才提示「已是最新版本」,自动轮询保持安静 */
let isManualCheck = false;
/** 检查中防重入 */
let isChecking = false;
/** 把更新事件转发给渲染端web 侧在 basic.vue 里做轻量提示) */
function sendToRenderer(type: string, data?: unknown): void {
getMainWindow()?.webContents.send('desktop:update-event', { type, data });
}
/** 判断是否 mac 未签名导致的更新失败(这类错误重试无意义,直接引导手动下载) */
function isMacSignatureError(message: string): boolean {
return (
process.platform === 'darwin' &&
/code signature|not signed|codesign|could not get code signature/i.test(message)
);
}
/**
* 初始化自动更新:仅打包后生效(开发模式没有 app-update.yml检查必然失败
*/
export function initUpdater(): void {
if (!app.isPackaged) return;
autoUpdater.logger = log;
autoUpdater.autoDownload = true;
autoUpdater.autoInstallOnAppQuit = true;
autoUpdater.on('update-available', (info) => {
log.info(`发现新版本: ${info.version}`);
sendToRenderer('available', { version: info.version });
});
autoUpdater.on('update-not-available', () => {
sendToRenderer('not-available');
if (isManualCheck) {
dialog.showMessageBox({
type: 'info',
title: '检查更新',
message: `当前已是最新版本v${app.getVersion()}`,
});
}
isManualCheck = false;
});
autoUpdater.on('download-progress', (progress) => {
sendToRenderer('progress', { percent: Math.round(progress.percent) });
});
autoUpdater.on('update-downloaded', async (info) => {
sendToRenderer('downloaded', { version: info.version });
// 唤起窗口再弹对话框,避免用户看不到安装确认
showMainWindow();
const { response } = await dialog.showMessageBox({
type: 'question',
title: '发现新版本',
message: `新版本 v${info.version} 已下载完成`,
detail: '立即重启安装,或在退出应用时自动安装。',
buttons: ['立即重启安装', '退出时自动安装'],
defaultId: 0,
cancelId: 1,
});
if (response === 0) {
// 放行窗口 close 拦截,否则 quitAndInstall 会被「关闭进托盘」逻辑挡住
markQuitting();
autoUpdater.quitAndInstall();
}
});
autoUpdater.on('error', async (error) => {
const message = error?.message ?? String(error);
log.error('自动更新失败', message);
sendToRenderer('error', { message });
// mac 未签名:自动更新走不通,引导用户手动下载安装
if (isMacSignatureError(message)) {
const { response } = await dialog.showMessageBox({
type: 'info',
title: '检查更新',
message: '当前安装包不支持自动更新',
detail: '请前往下载页获取最新版本手动安装。',
buttons: ['打开下载页', '取消'],
defaultId: 0,
cancelId: 1,
});
if (response === 0) shell.openExternal(DOWNLOAD_PAGE_URL);
} else if (isManualCheck) {
dialog.showMessageBox({
type: 'warning',
title: '检查更新',
message: '检查更新失败,请稍后重试',
detail: message,
});
}
isManualCheck = false;
isChecking = false;
});
// 启动延时自动检查一次 + 每 4 小时轮询(内部工具常开着,轮询保证及时收到发版)
setTimeout(() => checkForUpdates(false), 10_000);
setInterval(() => checkForUpdates(false), 4 * 60 * 60 * 1000);
}
/**
* 触发一次更新检查
* @param manual 是否手动触发(手动时「已是最新/失败」都会弹窗反馈,自动轮询保持安静)
*/
export async function checkForUpdates(manual: boolean): Promise<void> {
if (!app.isPackaged) {
if (manual) {
dialog.showMessageBox({ type: 'info', title: '检查更新', message: '开发模式不支持检查更新' });
}
return;
}
if (isChecking) return;
isChecking = true;
isManualCheck = manual;
try {
await autoUpdater.checkForUpdates();
} catch {
// 错误已在 error 事件里统一处理
} finally {
isChecking = false;
}
}

View File

@@ -0,0 +1,212 @@
import { join } from 'node:path';
import { app, BrowserWindow, Notification, session, shell, systemPreferences } from 'electron';
import log from 'electron-log/main';
import { APP_INDEX_URL, APP_ORIGIN, DEV_SERVER_URL, getResourcePath } from './config';
/**
* 主窗口管理
* - 加载目标:开发模式加载 web-antd 的 vite dev server未起服务时自动重试打包模式加载 app://local/
* - 安全基线contextIsolation + sandbox 开启webSecurity 不关闭window.open 一律交给系统浏览器
* - 关闭行为双平台都是「关窗隐藏、应用常驻」Windows 走托盘、mac 走 Dock真正退出走托盘菜单/Cmd+Q
* - 权限控制只放行业务需要的媒体IM 音视频)/ 通知 / 剪贴板 / 全屏,其余默认拒绝
*/
let mainWindow: BrowserWindow | null = null;
/** 标记「真正退出」:常驻模式下 close 只是隐藏,只有 quit 流程才放行关闭 */
let isQuitting = false;
/** Windows 首次关窗提示「已最小化到托盘」,只提示一次避免打扰 */
let hasShownHideTip = false;
/** 加载失败重试定时器,避免叠加多个重试 */
let retryTimer: NodeJS.Timeout | null = null;
/** 主窗口要加载的页面地址:打包后走自定义协议,开发走 dev server */
const PAGE_URL = app.isPackaged ? APP_INDEX_URL : DEV_SERVER_URL;
export function markQuitting(): void {
isQuitting = true;
}
export function getMainWindow(): BrowserWindow | null {
return mainWindow;
}
/** 判断 URL 是否属于信任的页面来源本地协议或开发服务器IPC 与权限校验共用 */
export function isTrustedPageUrl(url: string): boolean {
return url.startsWith(APP_ORIGIN) || url.startsWith(DEV_SERVER_URL);
}
/**
* 会话级权限控制:白名单内且来源可信才放行
* media 供 IM 音视频通话notifications 供 HTML5 Notification业务主要走主进程通知这里兜底
*/
function setupPermissionHandlers(): void {
const allowedPermissions = new Set([
'media',
'notifications',
'clipboard-read',
'clipboard-sanitized-write',
'fullscreen',
]);
session.defaultSession.setPermissionRequestHandler((webContents, permission, callback) => {
const trusted = isTrustedPageUrl(webContents.getURL());
if (!trusted || !allowedPermissions.has(permission)) {
callback(false);
return;
}
// mac 首次使用摄像头/麦克风必须先过系统 TCC 授权弹窗,否则页面侧拿不到设备
if (permission === 'media' && process.platform === 'darwin') {
Promise.all([
systemPreferences.askForMediaAccess('microphone'),
systemPreferences.askForMediaAccess('camera'),
])
.then(() => callback(true))
// TCC 弹窗异常时仍放行,让页面侧走自己的错误提示
.catch(() => callback(true));
return;
}
callback(true);
});
session.defaultSession.setPermissionCheckHandler((_webContents, permission, requestingOrigin) => {
return allowedPermissions.has(permission) && isTrustedPageUrl(requestingOrigin);
});
}
/** 加载失败时的内置错误页(数据 URL 形式,不依赖任何本地文件) */
function buildErrorPageUrl(reason: string): string {
const html = `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<title>加载失败</title>
<style>
body { margin: 0; display: flex; align-items: center; justify-content: center; height: 100vh;
font-family: "Microsoft YaHei", "PingFang SC", sans-serif; background: #f5f6f8; color: #333; }
.box { text-align: center; }
.title { font-size: 18px; font-weight: 600; margin-bottom: 8px; }
.desc { font-size: 13px; color: #999; }
</style>
</head>
<body>
<div class="box">
<div class="title">页面加载失败,正在自动重试…</div>
<div class="desc">请检查网络连接(${reason}</div>
</div>
</body>
</html>`;
return `data:text/html;charset=utf-8,${encodeURIComponent(html)}`;
}
/**
* 创建主窗口
* @param options.startHidden 开机自启(--hidden场景下静默启动不弹窗打扰用户
*/
export function createMainWindow(options: { startHidden: boolean }): BrowserWindow {
setupPermissionHandlers();
mainWindow = new BrowserWindow({
width: 1440,
height: 900,
minWidth: 1100,
minHeight: 700,
show: false,
autoHideMenuBar: true,
title: '萧康云医',
// Windows/Linux 窗口与任务栏图标mac 使用打包进 app 的 icns无需在此指定
icon: process.platform === 'darwin' ? undefined : getResourcePath('icon.png'),
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
spellcheck: false,
},
});
// 首次渲染完成再显示,避免白屏闪烁;静默启动场景保持隐藏(用户从托盘/快捷键唤起)
mainWindow.once('ready-to-show', () => {
if (!options.startHidden) mainWindow?.show();
});
// window.open / target=_blank外链一律交给系统默认浏览器不在应用内开新窗安全 + 体验)
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
if (url.startsWith('http://') || url.startsWith('https://')) {
shell.openExternal(url);
}
return { action: 'deny' };
});
// 页面内导航限制在信任来源内,跳外部地址时转交系统浏览器
mainWindow.webContents.on('will-navigate', (event, url) => {
if (isTrustedPageUrl(url)) return;
event.preventDefault();
if (url.startsWith('http://') || url.startsWith('https://')) {
shell.openExternal(url);
}
});
// 加载失败dev server 未启动 / 断网等)时展示内置错误页并定时重试
mainWindow.webContents.on('did-fail-load', (_event, errorCode, errorDesc, _url, isMainFrame) => {
if (!isMainFrame) return;
// ERR_ABORTED-3是导航被主动取消如重复 loadURL不算失败
if (errorCode === -3) return;
log.warn(`主窗口加载失败: ${errorCode} ${errorDesc}`);
mainWindow?.loadURL(buildErrorPageUrl(errorDesc || String(errorCode)));
if (retryTimer) clearTimeout(retryTimer);
// 开发模式快速重试等 vite 起服务;打包模式放慢节奏等网络恢复
retryTimer = setTimeout(() => mainWindow?.loadURL(PAGE_URL), app.isPackaged ? 5000 : 1500);
});
// 窗口聚焦即清除任务栏闪烁提醒(配合新消息 flashFrame
mainWindow.on('focus', () => mainWindow?.flashFrame(false));
// F12 切换 DevTools内部管理系统保留排障入口
mainWindow.webContents.on('before-input-event', (_event, input) => {
if (input.type === 'keyDown' && input.key === 'F12') {
mainWindow?.webContents.toggleDevTools();
}
});
// 关闭行为双平台都拦截为隐藏Windows 常驻托盘 / mac 常驻 Dock退出流程放行
mainWindow.on('close', (event) => {
if (isQuitting) return;
event.preventDefault();
mainWindow?.hide();
// Windows 首次隐藏时提示托盘位置,避免用户以为应用被关闭
if (process.platform === 'win32' && !hasShownHideTip && Notification.isSupported()) {
hasShownHideTip = true;
new Notification({
title: '萧康云医仍在运行',
body: '应用已最小化到系统托盘,点击托盘图标可重新打开窗口',
}).show();
}
});
mainWindow.on('closed', () => {
mainWindow = null;
});
mainWindow.loadURL(PAGE_URL);
return mainWindow;
}
/** 唤起主窗口:还原最小化、显示并聚焦(通知点击 / 托盘 / 第二实例 / 快捷键共用) */
export function showMainWindow(): void {
if (!mainWindow) return;
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.show();
mainWindow.focus();
}
/** 切换主窗口显隐(全局快捷键使用) */
export function toggleMainWindow(): void {
if (!mainWindow) return;
if (mainWindow.isVisible() && mainWindow.isFocused()) {
mainWindow.hide();
} else {
showMainWindow();
}
}

View File

@@ -0,0 +1,102 @@
import { contextBridge, ipcRenderer } from 'electron';
/**
* 预加载脚本:通过 contextBridge 向渲染层暴露受控的 desktopAPI
* - 渲染层web-antd通过 window.desktopAPI 使用桌面能力;浏览器环境没有该对象,
* 前端封装层apps/web-antd/src/util/desktop.ts会自动 no-op 降级
* - sandbox 模式下本文件必须是 CJS 产物electron-vite 已按包配置输出 CJS
* 且只能使用 Electron 提供的受限 APIcontextBridge / ipcRenderer 等)
*/
/**
* Windows 任务栏角标图:用 canvas 画「红底白字」数字返回 dataURL
* 为什么在 preload 画:主进程没有 DOM/canvaspreload 运行在渲染进程内有 DOM
* 由主进程 setOverlayIcon 消费,这是 Windows 数字角标的社区标准做法
*/
function drawBadgeImage(count: number): string | undefined {
try {
const size = 32;
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
if (!ctx) return undefined;
// 红色圆底
ctx.beginPath();
ctx.arc(size / 2, size / 2, size / 2, 0, Math.PI * 2);
ctx.fillStyle = '#f5222d';
ctx.fill();
// 白色数字,超过 99 显示 99+
const text = count > 99 ? '99+' : String(count);
ctx.fillStyle = '#ffffff';
ctx.font = `bold ${text.length > 2 ? 13 : 18}px Arial`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(text, size / 2, size / 2 + 1);
return canvas.toDataURL('image/png');
} catch {
// canvas 不可用时退化为无图(主进程会清除角标),不影响业务
return undefined;
}
}
/** 暴露给渲染层的桌面能力集合,字段语义与 web 侧 desktop.ts 封装一一对应 */
const desktopAPI = {
/** 渲染层判断是否运行在桌面端的唯一标记 */
isElectron: true as const,
/** 当前平台win32 / darwin / linux */
platform: process.platform,
/** 获取桌面端版本号(关于/设置展示用) */
getVersion: (): Promise<string> => ipcRenderer.invoke('desktop:get-version'),
/** 发送系统通知route 为点击通知后要跳转的前端路由 */
notify: (options: { title: string; body: string; route?: string }): Promise<boolean> =>
ipcRenderer.invoke('desktop:notify', options),
/** 设置未读角标数量0 表示清除Windows 角标图在此侧 canvas 生成 */
setBadge: (count: number): Promise<void> =>
ipcRenderer.invoke('desktop:set-badge', {
count,
image: count > 0 && process.platform === 'win32' ? drawBadgeImage(count) : undefined,
}),
/** 任务栏闪烁提醒(窗口未聚焦时生效,聚焦后自动清除) */
flashFrame: (): Promise<void> => ipcRenderer.invoke('desktop:flash-frame'),
/** 读取开机自启状态 */
getAutoLaunch: (): Promise<boolean> => ipcRenderer.invoke('desktop:get-auto-launch'),
/** 设置开机自启,返回设置后的实际状态 */
setAutoLaunch: (enable: boolean): Promise<boolean> =>
ipcRenderer.invoke('desktop:set-auto-launch', enable),
/** 获取系统打印机列表(打印设置下拉用) */
getPrinters: (): Promise<Array<{ name: string; displayName: string; isDefault: boolean }>> =>
ipcRenderer.invoke('desktop:get-printers'),
/** 静默打印:传入拼好的完整 HTML主进程隐藏窗口出纸 */
printHTML: (options: {
html: string;
deviceName?: string;
silent?: boolean;
}): Promise<{ ok: boolean; message?: string }> =>
ipcRenderer.invoke('desktop:print-html', options),
/** 读取桌面端设置(打印机名称、静默打印开关等) */
getSettings: (): Promise<{ printerName: string; silentPrint: boolean }> =>
ipcRenderer.invoke('desktop:get-settings'),
/** 增量更新桌面端设置,返回更新后的完整设置 */
setSettings: (
patch: Partial<{ printerName: string; silentPrint: boolean }>,
): Promise<{ printerName: string; silentPrint: boolean }> =>
ipcRenderer.invoke('desktop:set-settings', patch),
/** 手动检查更新(结果由主进程弹窗反馈) */
checkUpdate: (): Promise<void> => ipcRenderer.invoke('desktop:check-update'),
/** 唤起并聚焦主窗口 */
focusWindow: (): Promise<void> => ipcRenderer.invoke('desktop:focus-window'),
/** 监听「通知点击跳转」事件:主进程通知被点击后回传目标路由,渲染层执行 router.push */
onNavigate: (callback: (route: string) => void): void => {
ipcRenderer.on('desktop:navigate', (_event, route: string) => callback(route));
},
/** 监听更新事件available / progress / downloaded / not-available / error渲染层做轻量提示 */
onUpdateEvent: (callback: (payload: { type: string; data?: unknown }) => void): void => {
ipcRenderer.on('desktop:update-event', (_event, payload) => callback(payload));
},
};
contextBridge.exposeInMainWorld('desktopAPI', desktopAPI);
/** 供 web 侧类型声明引用apps/web-antd/src/types/desktop.d.ts 与此保持同步) */
export type DesktopAPI = typeof desktopAPI;

View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2023",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"types": ["node"],
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"isolatedModules": true,
"forceConsistentCasingInFileNames": true,
"useUnknownInCatchVariables": true
},
"include": ["src", "electron.vite.config.ts"]
}

View File

@@ -52,6 +52,7 @@
"js-base64": "^3.7.7",
"lodash-es": "^4.17.21",
"lucide-vue-next": "^0.487.0",
"lunar-typescript": "^1.8.6",
"markdown-it": "^14.1.0",
"md-editor-v3": "^5.4.5",
"pinia": "catalog:",

Binary file not shown.

View File

@@ -14,6 +14,8 @@ import {
watch,
} from 'vue';
import { useUserStore } from '@vben/stores';
import { LoadingOutlined, SearchOutlined } from '@ant-design/icons-vue';
import { useDebounceFn, useVModel } from '@vueuse/core';
import { Empty, Input, Spin, Tag } from 'ant-design-vue';
@@ -50,10 +52,49 @@ const props = withDefaults(
},
);
/**
* 模块级搜索结果缓存所有实例共享key = `${userId}|${storeType}|${keyword}`
* 门店数据变更低频TTL 内相同关键词直接命中缓存零等待,避免每敲一个字都等接口转圈。
* key 必须拼登录用户 id不同账号数据权限不同店管只能搜到本店可见门店
* 切换账号后不允许在 TTL 内命中上一账号的结果,拼 id 后 key 天然隔离
*/
const searchResultCache = new Map<
string,
{ at: number; items: StoreSearchItem[] }
>();
const SEARCH_CACHE_TTL = 5 * 60 * 1000;
const SEARCH_CACHE_MAX = 200;
function readSearchCache(key: string): null | StoreSearchItem[] {
const hit = searchResultCache.get(key);
if (!hit) return null;
if (Date.now() - hit.at > SEARCH_CACHE_TTL) {
searchResultCache.delete(key);
return null;
}
return hit.items;
}
function writeSearchCache(key: string, items: StoreSearchItem[]) {
// 简单容量控制:超限整体清空(低频触发,无需 LRU
if (searchResultCache.size >= SEARCH_CACHE_MAX) {
searchResultCache.clear();
}
searchResultCache.set(key, { at: Date.now(), items });
}
const emits = defineEmits<{
'update:value': [value: number[]];
}>();
const userStore = useUserStore();
/** 当前登录用户 id缓存隔离维度后端返回的 admin 行主键是 id兜底 vben 的 userId */
function currentUserId(): number | string {
const info = userStore.userInfo as null | Record<string, any>;
return info?.id ?? info?.userId ?? 0;
}
const mValue = useVModel(props, 'value', emits, {
passive: true,
defaultValue: [],
@@ -143,14 +184,36 @@ async function openPanel() {
updatePanelPlacement();
}
/** 请求序号:只采纳最后一次请求的响应,避免快速输入时慢响应覆盖新结果 */
let fetchSeq = 0;
async function fetchOptions() {
const keyword = searchKeyword.value.trim();
if (!keyword) {
// 递增序号让在途请求作废,避免清空后慢响应又把旧结果写回来
fetchSeq += 1;
loading.value = false;
options.value = [];
showDropdown.value = false;
highlightIndex.value = -1;
return;
}
const cacheKey = `${currentUserId()}|${props.storeType ?? 'all'}|${keyword}`;
const cachedItems = readSearchCache(cacheKey);
if (cachedItems) {
// 命中缓存:直接渲染,不发请求不转 loading体感「秒出」
fetchSeq += 1;
loading.value = false;
options.value = cachedItems;
await openPanel();
highlightIndex.value = visibleOptions.value.length > 0 ? 0 : -1;
await nextTick();
updatePanelPlacement();
return;
}
const seq = (fetchSeq += 1);
loading.value = true;
await openPanel();
try {
@@ -161,15 +224,21 @@ async function fetchOptions() {
: {}),
limit: 20,
});
options.value = res?.items ?? [];
if (seq !== fetchSeq) return;
const items = res?.items ?? [];
writeSearchCache(cacheKey, items);
options.value = items;
highlightIndex.value = visibleOptions.value.length > 0 ? 0 : -1;
await nextTick();
updatePanelPlacement();
} catch {
if (seq !== fetchSeq) return;
options.value = [];
highlightIndex.value = -1;
} finally {
loading.value = false;
if (seq === fetchSeq) {
loading.value = false;
}
}
}
@@ -399,7 +468,13 @@ watch(visibleOptions, (list) => {
color="blue"
@close="() => removeSelected(item.id)"
>
{{ item.name }}{{ item.id }}
<!-- 文本单独包一层才能做省略antd Tag 的内容是裸文本节点无法直接限宽 -->
<span
class="store-multi-search__tag-text"
:title="`${item.name}【${item.id}】`"
>
{{ item.name }}{{ item.id }}
</span>
</Tag>
</div>
</div>
@@ -416,6 +491,26 @@ watch(visibleOptions, (list) => {
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
/* 单个超长门店名不能撑破容器:限宽 + 省略号,完整名走 title 悬停提示 */
:deep(.ant-tag) {
display: inline-flex;
align-items: center;
max-width: 100%;
min-width: 0;
/* 间距交给 gap 控制,去掉 antd 自带右外边距避免换行时右侧不齐 */
margin-inline-end: 0;
}
:deep(.ant-tag-close-icon) {
flex-shrink: 0;
}
}
.store-multi-search__tag-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View File

@@ -666,12 +666,12 @@ onUnmounted(() => {
width: 100%;
height: 100%;
min-height: 220px;
//background: #ffffff;
/* background: #ffffff; */
border-radius: 12px;
overflow: hidden;
}
.loading-wrapper--dark {
//background: #1e2227;
/* background: #1e2227; */
}
.loading-canvas {
display: block;

View File

@@ -0,0 +1,5 @@
/**
* 通用统计岛组件导出:紧凑玻璃质感统计小卡(设计对齐提现管理账户卡统计岛)
*/
export { default as StatIslands } from './stat-islands.vue';
export type { StatIslandItem } from './typing';

View File

@@ -0,0 +1,166 @@
<script setup lang="ts">
/**
* 通用统计岛组件:一行多个紧凑玻璃质感统计小卡
*
* 设计语言与提现管理账户卡的统计岛finance/withdrawal AccountInfoCard完全一致
* 12px 圆角 + 1px 边框 + 主色渐变玻璃底 + 右上角 SVG 图标 + 角落径向荧光 +
* DIN 等宽大数字hover 主色边框浮起。用于替换各页面过大的 AnalysisOverview 大卡。
*/
import { IconifyIcon as VbenIcon } from '@vben/icons';
import type { StatIslandItem } from './typing';
withDefaults(
defineProps<{
items: StatIslandItem[];
/** 每行列数(桌面端),默认 4窄屏自动降为 2 列 */
columns?: number;
}>(),
{ columns: 4 },
);
/** 金额展示为两位小数;非数字原样透出(如「—」占位) */
function formatValue(val: number | string) {
const n = Number(val);
return Number.isFinite(n) ? n.toFixed(2) : String(val);
}
</script>
<template>
<div class="stat-islands" :style="{ '--stat-cols': columns }">
<article
v-for="item in items"
:key="item.key"
class="stat-island"
:class="{ 'stat-island--emphasize': item.emphasize }"
:aria-label="item.label"
>
<div class="stat-island-glow" />
<VbenIcon class="stat-icon" :icon="item.icon" />
<div class="stat-label">{{ item.label }}</div>
<div class="stat-val">
<span v-if="item.prefix !== ''" class="stat-val-prefix">{{ item.prefix ?? '¥' }}</span>
{{ formatValue(item.value) }}
</div>
</article>
</div>
</template>
<style scoped>
.stat-islands {
display: grid;
grid-template-columns: repeat(var(--stat-cols, 4), minmax(0, 1fr));
gap: 10px;
}
@media (max-width: 767px) {
.stat-islands {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
/* 统计岛:玻璃质感 + 右上角图标 + 淡荧光(与提现管理 stat-island 同规格) */
.stat-island {
position: relative;
overflow: hidden;
padding: 14px;
cursor: default;
background: linear-gradient(
135deg,
hsl(var(--primary) / 4%),
hsl(var(--card, var(--background)))
);
backdrop-filter: blur(8px);
border: 1px solid hsl(var(--border));
border-radius: 12px;
transition:
transform 0.2s ease,
box-shadow 0.2s ease,
border-color 0.2s ease;
}
.stat-island:hover {
border-color: hsl(var(--primary) / 40%);
box-shadow:
0 8px 20px hsl(var(--primary) / 14%),
0 2px 6px rgb(0 0 0 / 6%);
transform: translateY(-3px);
}
/* 角落局部高光,强化荧光感 */
.stat-island-glow {
position: absolute;
top: -40px;
right: -40px;
width: 110px;
height: 110px;
pointer-events: none;
background: radial-gradient(circle, hsl(var(--primary) / 18%) 0%, transparent 70%);
opacity: 0.5;
transition: opacity 0.2s ease;
}
.stat-island:hover .stat-island-glow {
opacity: 1;
}
/* 强调项:主色字 + 更强荧光 */
.stat-island--emphasize {
background: linear-gradient(
135deg,
hsl(var(--primary) / 10%),
hsl(var(--card, var(--background)))
);
border-color: hsl(var(--primary) / 30%);
}
.stat-island--emphasize .stat-island-glow {
opacity: 0.85;
}
.stat-island--emphasize .stat-val {
color: hsl(var(--primary));
}
/* 右上角图标:固定 24x24主色调 */
.stat-icon {
position: absolute;
top: 12px;
right: 12px;
width: 24px;
height: 24px;
color: hsl(var(--primary) / 70%);
transition: color 0.2s ease;
}
.stat-island:hover .stat-icon {
color: hsl(var(--primary));
}
.stat-label {
position: relative;
z-index: 1;
padding-right: 32px;
margin-bottom: 6px;
font-size: 12px;
line-height: 1.2;
color: hsl(var(--muted-foreground));
}
.stat-val {
position: relative;
z-index: 1;
font-family: 'DIN Alternate', ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 18px;
font-weight: 700;
line-height: 1.2;
color: hsl(var(--foreground));
}
.stat-val-prefix {
margin-right: 2px;
font-size: 12px;
font-weight: 500;
color: hsl(var(--muted-foreground));
}
</style>

View File

@@ -0,0 +1,19 @@
/**
* 统计岛组件类型定义(独立文件避免 index ↔ 组件循环引用)
*/
/** 统计岛单项配置 */
export interface StatIslandItem {
/** 唯一 key */
key: string;
/** 指标名(小灰字) */
label: string;
/** 指标值(数字,内部格式化两位小数) */
value: number | string;
/** iconify 图标名右上角规范SVG 图标不用 emoji */
icon: string;
/** 是否强调(主色字 + 更强荧光) */
emphasize?: boolean;
/** 值前缀,默认 ¥;传空串则不显示前缀 */
prefix?: string;
}

View File

@@ -0,0 +1,88 @@
<script setup lang="ts">
/**
* 卡片视图专用操作栏(与列表交互解耦)
*
* 列表单元格里 TableAction 平铺全部按钮没问题,但卡片 footer 空间小,
* 按钮超过 2 个会触发 TableAction 的两列 grid 换行堆叠,显得杂乱。
* 这里改成 C 端惯例:只露出前 maxVisible 个主操作(默认 1 个,通常是「详情」),
* 其余操作全部合并进「···」下拉菜单。
*
* 权限auth/ 显隐ifShow/ 二次确认popConfirm逻辑不重写
* - 挑主操作前先按与 TableAction 相同的规则过滤,保证露出的一定是可见按钮;
* - 渲染仍复用 TableActionflex=false 关闭多列 grid菜单项行为完全一致。
*/
import type { ActionItem } from './types';
import { computed } from 'vue';
import { useAccess } from '@vben/access';
import { isBoolean, isFunction } from '@vben/utils';
import { Button } from 'ant-design-vue';
import { Icon } from '#/components/icon';
import TableAction from './table-action.vue';
const props = withDefaults(
defineProps<{
actions?: ActionItem[];
dropDownActions?: ActionItem[];
/** 露出的主操作数量,其余进「···」菜单 */
maxVisible?: number;
}>(),
{
actions: () => [],
dropDownActions: () => [],
maxVisible: 1,
},
);
const { hasAccessByCodes } = useAccess();
/** 与 TableAction 同规则判断单个操作是否可见auth + ifShow */
function isActionVisible(action: ActionItem): boolean {
const authPass =
(action.auth || []).length === 0 || hasAccessByCodes(action.auth || []);
if (!authPass) return false;
const ifShow = action.ifShow;
if (isBoolean(ifShow)) return ifShow;
if (isFunction(ifShow)) return ifShow(action);
return true;
}
/** 过滤后的可见操作:前 maxVisible 个露出,其余与原下拉操作合并进「···」 */
const splitActions = computed(() => {
const visible = (props.actions || []).filter((action) =>
isActionVisible(action),
);
return {
primary: visible.slice(0, props.maxVisible),
// 下拉里的项 TableAction 会再过滤一遍 auth/ifShow这里直接透传原始项即可
rest: [...visible.slice(props.maxVisible), ...(props.dropDownActions || [])],
};
});
</script>
<template>
<TableAction
:actions="splitActions.primary"
:drop-down-actions="splitActions.rest"
:flex="false"
>
<!-- 默认的···图标点击区太小换成 link 文字更多与主操作按钮同视觉 -->
<template #more>
<Button size="small" type="link" class="card-actions__more">
更多
<Icon icon="ant-design:down-outlined" class="card-actions__more-icon" />
</Button>
</template>
</TableAction>
</template>
<style scoped lang="scss">
/* 箭头比文字略小一号;左间距由 TableAction 全局的 span+iconify 规则统一控制 */
.card-actions__more-icon {
font-size: 10px;
}
</style>

View File

@@ -1,2 +1,3 @@
export { default as CardActions } from './card-actions.vue';
export { default as TableAction } from './table-action.vue';
export type * from './types.d.ts';

View File

@@ -0,0 +1,3 @@
export { useViewMode } from './use-view-mode';
export type { ViewMode } from './use-view-mode';
export { default as ViewModeSwitch } from './view-mode-switch.vue';

View File

@@ -0,0 +1,49 @@
import type { Ref } from 'vue';
import { ref, watch } from 'vue';
/**
* 通用「列表/卡片」视图模式类型
* list=表格列表视图card=卡片看板视图
*/
export type ViewMode = 'card' | 'list';
/**
* 视图模式记忆 Hook
*
* 为什么封装:商品订单/处方订单/挂号订单等多个列表页都需要「列表/卡片」切换,
* 且要求刷新后记住用户上次选择。统一在这里读写 localStorage
* 各页面只需传入唯一 storageKey避免每页复制一份存取逻辑。
*
* @param storageKey localStorage 键名(建议 `<模块>-view-mode`,如 `product-order-view-mode`
* @param defaultMode 无记忆时的默认视图,默认列表
* @returns 响应式视图模式 ref直接 v-model 给 ViewModeSwitch 组件即可
*/
export function useViewMode(
storageKey: string,
defaultMode: ViewMode = 'list',
): Ref<ViewMode> {
// 初始化时同步读取本地记忆,避免先渲染默认视图再闪切
const readStored = (): ViewMode => {
try {
const raw = localStorage.getItem(storageKey);
if (raw === 'list' || raw === 'card') return raw;
} catch {
// localStorage 不可用(隐私模式等)时静默回退默认值
}
return defaultMode;
};
const viewMode = ref<ViewMode>(readStored());
// 切换后立即写回,下次进入页面直接还原
watch(viewMode, (mode) => {
try {
localStorage.setItem(storageKey, mode);
} catch {
// 写入失败不影响功能,忽略
}
});
return viewMode;
}

View File

@@ -0,0 +1,112 @@
<script setup lang="ts">
/**
* 通用「列表/卡片」视图切换组件
*
* 为什么封装:多个订单列表页都需要同样的切换控件,统一外观(图标+文字的分段控件)
* 与交互,配合 useViewMode(storageKey) 使用自带记忆能力。
*
* 用法:
* const viewMode = useViewMode('product-order-view-mode');
* <ViewModeSwitch v-model="viewMode" />
*/
import type { ViewMode } from './use-view-mode';
import { IconifyIcon } from '@vben/icons';
defineOptions({ name: 'ViewModeSwitch' });
withDefaults(
defineProps<{
/** 卡片视图文案,允许按页面语境自定义(如「卡片看板」) */
cardLabel?: string;
/** 列表视图文案 */
listLabel?: string;
}>(),
{
cardLabel: '卡片',
listLabel: '列表',
},
);
/** 当前视图模式v-model 双向绑定,记忆逻辑由 useViewMode 承担 */
const modelValue = defineModel<ViewMode>({ default: 'list' });
/** 切换视图:点击已选中项不重复触发 */
function switchTo(mode: ViewMode) {
if (modelValue.value !== mode) {
modelValue.value = mode;
}
}
</script>
<template>
<div class="view-mode-switch" role="radiogroup" aria-label="视图切换">
<button
type="button"
class="vms-item"
:class="{ active: modelValue === 'list' }"
role="radio"
:aria-checked="modelValue === 'list'"
@click="switchTo('list')"
>
<IconifyIcon icon="ant-design:bars-outlined" class="vms-icon" />
<span>{{ listLabel }}</span>
</button>
<button
type="button"
class="vms-item"
:class="{ active: modelValue === 'card' }"
role="radio"
:aria-checked="modelValue === 'card'"
@click="switchTo('card')"
>
<IconifyIcon icon="ant-design:appstore-outlined" class="vms-icon" />
<span>{{ cardLabel }}</span>
</button>
</div>
</template>
<style lang="less" scoped>
/* App 风格分段控件:胶囊容器 + 滑块式选中态,颜色全部走主题变量适配暗色 */
.view-mode-switch {
display: inline-flex;
gap: 2px;
padding: 3px;
background: hsl(var(--muted) / 60%);
border: 1px solid hsl(var(--border) / 60%);
border-radius: 10px;
}
.vms-item {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 14px;
font-size: 13px;
line-height: 20px;
color: hsl(var(--muted-foreground));
cursor: pointer;
background: transparent;
border: none;
border-radius: 8px;
transition:
background 0.18s ease,
color 0.18s ease,
box-shadow 0.18s ease;
&:hover {
color: hsl(var(--foreground));
}
&.active {
font-weight: 600;
color: hsl(var(--primary));
background: hsl(var(--background));
box-shadow: 0 1px 4px hsl(var(--foreground) / 10%);
}
}
.vms-icon {
font-size: 15px;
}
</style>

View File

@@ -26,6 +26,7 @@ import DoctorTransferFloat from '#/components/doctor-transfer-float/DoctorTransf
import HeaderVipBadge from '#/components/vip/HeaderVipBadge.vue';
import SwitchAccountModal from '#/layouts/components/SwitchAccountModal.vue';
import { useAuthStore } from '#/store';
import { desktop, isElectron } from '#/util/desktop';
import { formatTimeToRelative } from '#/util/tool';
import LoginForm from '#/views/_core/authentication/login.vue';
import NoticeActionHost from '#/views/notice/action/NoticeActionHost.vue';
@@ -62,6 +63,15 @@ const getNoticeList = async () => {
description: `您有 ${items.length} 条未读消息请注意查收`,
duration: 5,
});
// 桌面端且窗口未聚焦时补发系统通知(页内 Toast 此时用户看不到),点击跳消息中心
if (isElectron() && !document.hasFocus()) {
desktop.notify({
title: '您有新消息',
body: `您有 ${items.length} 条未读消息请注意查收`,
route: '/notice/list',
});
desktop.flashFrame();
}
}
notifications.value = items.map((item: any) => {
const color = resolveNoticeColor(item.type_color);
@@ -122,11 +132,35 @@ localStorage.setItem = (key, value) => {
window.addEventListener('storage', handleStorageChange);
const stopRefreshListen = onNoticeRefresh(() => restartInterval());
// 桌面端:未读数变化同步任务栏/Dock 角标(浏览器环境 no-op
watch(unreadCount, (count) => desktop.setBadge(count));
// 桌面端:系统通知点击后主进程回传目标路由,这里执行实际跳转
desktop.onNavigate((route) => {
router.push(route);
});
// 桌面端:自动更新事件轻量提示(下载完成后的安装确认由主进程弹窗负责)
desktop.onUpdateEvent((payload) => {
if (payload.type === 'available') {
const version = (payload.data as { version?: string })?.version;
notification.info({
message: '发现新版本',
description: `新版本 ${version ? `v${version} ` : ''}正在后台下载,完成后将提示安装`,
duration: 5,
});
}
});
onUnmounted(() => {
clearInterval(intervalId);
window.removeEventListener('storage', handleStorageChange);
localStorage.setItem = originalSetItem;
stopRefreshListen();
// 布局卸载(登出)时清空桌面角标,避免残留未读数
desktop.setBadge(0);
// 解绑系统通知跳转回调,避免登出后点击残留通知仍对已登出会话执行 router.push
desktop.offNavigate();
});
const goNoticeList = () => {

37
apps/web-antd/src/types/desktop.d.ts vendored Normal file
View File

@@ -0,0 +1,37 @@
/**
* 桌面端Electronpreload 注入的全局 API 类型声明
* 形状与 apps/desktop/src/preload/index.ts 暴露的 desktopAPI 保持同步;
* 浏览器环境该对象不存在(可选属性),业务代码统一走 #/util/desktop 封装访问。
*/
interface Window {
desktopAPI?: {
/** 桌面端标记,恒为 true */
isElectron: true;
/** 当前平台win32 / darwin / linux */
platform: string;
getVersion: () => Promise<string>;
notify: (options: { body: string; route?: string; title: string }) => Promise<boolean>;
setBadge: (count: number) => Promise<void>;
flashFrame: () => Promise<void>;
getAutoLaunch: () => Promise<boolean>;
setAutoLaunch: (enable: boolean) => Promise<boolean>;
getPrinters: () => Promise<
Array<{ displayName: string; isDefault: boolean; name: string }>
>;
printHTML: (options: {
deviceName?: string;
html: string;
silent?: boolean;
}) => Promise<{ message?: string; ok: boolean }>;
getSettings: () => Promise<{ printerName: string; silentPrint: boolean }>;
setSettings: (
patch: Partial<{ printerName: string; silentPrint: boolean }>,
) => Promise<{ printerName: string; silentPrint: boolean }>;
checkUpdate: () => Promise<void>;
focusWindow: () => Promise<void>;
onNavigate: (callback: (route: string) => void) => void;
onUpdateEvent: (
callback: (payload: { data?: unknown; type: string }) => void,
) => void;
};
}

View File

@@ -0,0 +1,122 @@
/**
* 桌面端Electron能力封装
* 桌面端由 apps/desktop 的 preload 脚本注入 window.desktopAPI
* 浏览器环境没有该对象,本封装的所有方法自动 no-op 降级,保证网页端行为完全不变。
* 业务代码禁止直接访问 window.desktopAPI统一走本文件便于类型收敛与降级兜底。
*/
/** 系统打印机信息Electron getPrintersAsync 的子集) */
export interface DesktopPrinter {
name: string;
displayName: string;
isDefault: boolean;
}
/** 桌面端本地设置(与 apps/desktop 主进程 settings.ts 保持同步) */
export interface DesktopSettings {
/** 静默打印使用的打印机名称,空串表示系统默认打印机 */
printerName: string;
/** 是否启用静默打印 */
silentPrint: boolean;
}
/** 自动更新事件(主进程 updater 转发,渲染层做轻量提示) */
export interface DesktopUpdateEvent {
type: 'available' | 'downloaded' | 'error' | 'not-available' | 'progress';
data?: unknown;
}
/** 判断当前是否运行在 Electron 桌面端 */
export function isElectron(): boolean {
return typeof window !== 'undefined' && !!window.desktopAPI?.isElectron;
}
/** 取桥接 API浏览器环境返回 undefined调用方全部可选链降级 */
function api() {
return typeof window === 'undefined' ? undefined : window.desktopAPI;
}
// onNavigate / onUpdateEvent 的桥接层每调用一次就会新增一个 ipc 监听,
// 而布局组件会随登录/登出反复挂载。这里用「模块级单次桥接 + 可替换回调」避免监听器堆积
let navigateCallback: ((route: string) => void) | null = null;
let navigateBridged = false;
let updateCallback: ((payload: DesktopUpdateEvent) => void) | null = null;
let updateBridged = false;
export const desktop = {
/** 发送系统通知route 为点击通知后要跳转的前端路由(不传则仅唤起窗口) */
notify(options: { body: string; route?: string; title: string }): void {
api()?.notify(options);
},
/** 设置未读角标数量0 表示清除mac Dock 数字 / Windows 任务栏覆盖图标) */
setBadge(count: number): void {
api()?.setBadge(count);
},
/** 任务栏闪烁提醒(窗口未聚焦时生效,聚焦后自动清除) */
flashFrame(): void {
api()?.flashFrame();
},
/** 读取开机自启状态(浏览器环境恒为 false */
async getAutoLaunch(): Promise<boolean> {
return (await api()?.getAutoLaunch()) ?? false;
},
/** 设置开机自启,返回设置后的实际状态 */
async setAutoLaunch(enable: boolean): Promise<boolean> {
return (await api()?.setAutoLaunch(enable)) ?? false;
},
/** 获取系统打印机列表(浏览器环境返回空数组) */
async getPrinters(): Promise<DesktopPrinter[]> {
return (await api()?.getPrinters()) ?? [];
},
/** 静默打印:传入拼好的完整 HTML由主进程隐藏窗口出纸 */
async printHTML(options: {
deviceName?: string;
html: string;
silent?: boolean;
}): Promise<{ message?: string; ok: boolean }> {
return (
(await api()?.printHTML(options)) ?? {
ok: false,
message: '当前环境不支持桌面打印',
}
);
},
/** 读取桌面端设置 */
async getSettings(): Promise<DesktopSettings> {
return (await api()?.getSettings()) ?? { printerName: '', silentPrint: false };
},
/** 增量更新桌面端设置,返回更新后的完整设置 */
async setSettings(patch: Partial<DesktopSettings>): Promise<DesktopSettings> {
return (
(await api()?.setSettings(patch)) ?? { printerName: '', silentPrint: false }
);
},
/** 手动检查更新(结果由主进程弹窗反馈) */
checkUpdate(): void {
api()?.checkUpdate();
},
/** 唤起并聚焦主窗口 */
focusWindow(): void {
api()?.focusWindow();
},
/** 监听系统通知点击后的路由跳转请求(重复调用只替换回调,不会堆积监听器) */
onNavigate(callback: (route: string) => void): void {
navigateCallback = callback;
if (!navigateBridged && api()) {
navigateBridged = true;
api()!.onNavigate((route) => navigateCallback?.(route));
}
},
/** 解绑路由跳转回调:布局卸载(登出)时调用,避免残留系统通知被点击后对已登出会话执行跳转 */
offNavigate(): void {
navigateCallback = null;
},
/** 监听自动更新事件(重复调用只替换回调,不会堆积监听器) */
onUpdateEvent(callback: (payload: DesktopUpdateEvent) => void): void {
updateCallback = callback;
if (!updateBridged && api()) {
updateBridged = true;
api()!.onUpdateEvent((payload) => updateCallback?.(payload as DesktopUpdateEvent));
}
},
};

View File

@@ -0,0 +1,144 @@
/**
* 挂号实时提醒工具(医生端)
*
* xk-api 在挂号支付成功后通过 Go WebSocket 推 message_type=15 的系统消息,
* useWebSocket 收到后调用 handleRegisterNotifyMessage 统一处理:
* - 桌面端Electron且窗口未聚焦弹系统级消息通知 + 任务栏闪烁 + 播放提示音,
* 点击通知唤起窗口并跳转挂号订单页(走 basic.vue 已接线的 desktop.onNavigate
* - 页面端(浏览器 / 桌面端窗口聚焦中):弹站内 antd notification + 播放提示音
*/
import { notification } from 'ant-design-vue';
import { router } from '#/router';
import { desktop, isElectron } from '#/util/desktop';
// 与 util/tool.ts 的 sys.mp3 同方式引入:经 Vite 处理,生产构建自动带部署 base 路径
import RegisterNotifyMp3 from '/public/audio/register-notify.mp3';
/** 与后端 App\Enum\chat\MessageTypeEnum::REGISTER_NOTIFY 对齐 */
export const REGISTER_NOTIFY_MESSAGE_TYPE = 15;
/** 挂号订单列表菜单路由xk_menu path通知点击后的统一落点 */
const REGISTER_ORDER_ROUTE = '/business/register/list';
/** 去重窗口:同一挂号单 10 秒内只提醒一次(防多标签页/重复投递叠加提示音) */
const DEDUPE_WINDOW_MS = 10_000;
const DEDUPE_KEY_PREFIX = 'xk-register-notify:';
/** 后端 RegisterDoctorWsNotifyJob 拼装的通知载荷message_content JSON */
interface RegisterNotifyPayload {
body: string;
order_no?: string;
register_id: number;
scene?: string;
title: string;
}
/** 提示音单例:复用同一个 Audio 对象,避免每次通知都新建请求 */
let audioInstance: HTMLAudioElement | null = null;
/**
* 播放通知提示音
* play() 返回的 Promise 在浏览器自动播放策略拒绝时会 reject用户从未与页面交互过
* 这里静默吞掉:医生登录后必然点击过页面,正常场景不会被拦
*/
function playNotifySound() {
try {
if (!audioInstance) {
audioInstance = new Audio(RegisterNotifyMp3);
}
audioInstance.currentTime = 0;
audioInstance.play().catch(() => {});
} catch {
// Audio 不可用(极老浏览器)时跳过声音,不影响通知展示
}
}
/**
* 解析 WS 消息里的通知载荷
* message_content 是后端 json_encode 的字符串;解析失败返回 null 直接忽略该条
*/
function parsePayload(raw: unknown): null | RegisterNotifyPayload {
try {
const data = typeof raw === 'string' ? JSON.parse(raw) : raw;
if (!data || typeof data !== 'object') return null;
const record = data as Record<string, unknown>;
const registerId = Number(record.register_id);
if (!Number.isFinite(registerId) || registerId <= 0) return null;
return {
register_id: registerId,
title: String(record.title || '新的挂号订单'),
body: String(record.body || ''),
order_no: String(record.order_no || ''),
scene: String(record.scene || ''),
};
} catch {
return null;
}
}
/**
* 同一挂号单去重localStorage 记录上次提醒时间戳
* 医生开多个标签/窗口时每个连接都会收到推送Go 服务发给用户所有在线设备),
* 不去重会同时响多次提示音localStorage 跨标签共享,天然适合做这个锁
*/
function shouldNotify(registerId: number): boolean {
const key = DEDUPE_KEY_PREFIX + registerId;
try {
const last = Number(localStorage.getItem(key) || 0);
const now = Date.now();
if (now - last < DEDUPE_WINDOW_MS) return false;
localStorage.setItem(key, String(now));
return true;
} catch {
// localStorage 不可用(隐私模式)时放行,宁可重复提醒不可漏提醒
return true;
}
}
/**
* 弹站内通知卡片
* key 用挂号单 ID 保证同单只显示一张卡片;点击卡片跳转挂号订单页并收起。
* 页面在后台标签时不自动关闭duration=0医生切回来仍能看到提醒
*/
function showInPageNotification(payload: RegisterNotifyPayload) {
const key = `register-notify-${payload.register_id}`;
notification.open({
key,
message: payload.title,
description: payload.body || payload.order_no,
duration: document.hidden ? 0 : 8,
onClick: () => {
router.push(REGISTER_ORDER_ROUTE).catch(() => {});
notification.close(key);
},
});
}
/**
* 挂号提醒统一入口useWebSocket 收到 message_type=15 时调用)
* 流程:解析载荷 → 跨标签去重 → 播放提示音 → 按运行环境选择通知形态
*/
export function handleRegisterNotifyMessage(data: {
message_content?: unknown;
}) {
const payload = parsePayload(data?.message_content);
if (!payload || !shouldNotify(payload.register_id)) return;
playNotifySound();
// 桌面端窗口未聚焦:系统通知 + 任务栏闪烁(页内卡片此时用户看不到),
// 点击通知由主进程唤起窗口并回传路由basic.vue 的 desktop.onNavigate 执行跳转)
if (isElectron() && !document.hasFocus()) {
desktop.notify({
title: payload.title,
body: payload.body || payload.order_no || '',
route: REGISTER_ORDER_ROUTE,
});
desktop.flashFrame();
return;
}
// 浏览器端 / 桌面端窗口聚焦中:站内提示
showInPageNotification(payload);
}

View File

@@ -0,0 +1,41 @@
/**
* antd Tag 预设色名 → css 色值工具
*
* 为什么需要Apple 风格卡片用「状态彩点 + tinted 胶囊」替代 antd Tag 色块,
* 但字典常量里沿用 antd 色名red/green/processing…这里统一转换成真实色值
* 供订单三页卡片视图与详情弹窗共用,避免各组件复制色板。
*/
export const TAG_COLOR_HEX: Record<string, string> = {
red: '#f5222d',
orange: '#fa8c16',
gold: '#faad14',
yellow: '#d4b106',
green: '#52c41a',
cyan: '#13c2c2',
blue: '#1677ff',
geekblue: '#2f54eb',
purple: '#722ed1',
pink: '#eb2f96',
magenta: '#eb2f96',
processing: '#1677ff',
success: '#52c41a',
warning: '#fa8c16',
error: '#f5222d',
gray: '#8c8c8c',
default: '#8c8c8c',
};
/** 未知色名兜底灰(单独常量,规避 Record 索引返回 undefined 的类型收窄问题) */
const DEFAULT_TAG_HEX = '#8c8c8c';
/** Tag 色名转 css 色值;自定义 hex 原样返回,未知色名回退灰 */
export function tagColorHex(color?: string): string {
if (!color) return DEFAULT_TAG_HEX;
return color.startsWith('#') ? color : (TAG_COLOR_HEX[color] ?? DEFAULT_TAG_HEX);
}
/** Apple tinted 胶囊内联样式:同色 10% 透明度底 + 同色文字8 位 hex alpha */
export function pillStyle(color?: string) {
const hex = tagColorHex(color);
return { color: hex, background: `${hex}1a` };
}

View File

@@ -235,6 +235,7 @@
<script setup>
import {ref, reactive, watch, onMounted} from 'vue'
import {desktop, isElectron} from '#/util/desktop'
import {useThemeStore} from '../stores/theme.ts'
import CustomModal from './CustomModal.vue'
@@ -341,6 +342,11 @@ const updateTheme = () => {
}
const updateNotificationPermission = async () => {
// Electron 桌面端走 desktop.notify() 系统级通知,不依赖浏览器 Notification 权限;
// 若照旧申请权限,被拒后会把开关强制关掉,导致桌面通知被浏览器权限逻辑误关
if (isElectron()) {
return
}
if (settings.desktopNotifications && 'Notification' in window) {
if (Notification.permission === 'default') {
const permission = await Notification.requestPermission()
@@ -352,7 +358,12 @@ const updateNotificationPermission = async () => {
}
}
// 检查更新:桌面端走 Electron 自动更新(结果由主进程弹窗反馈),网页端保持原提示
const checkUpdate = () => {
if (isElectron()) {
desktop.checkUpdate()
return
}
showNotification('当前已是最新版本', 'info')
}

View File

@@ -3,6 +3,10 @@ import { onMounted, onUnmounted, ref } from 'vue';
import { useAccessStore } from '@vben/stores';
import { getWebSocketUrl } from '#/util/tool';
import {
handleRegisterNotifyMessage,
REGISTER_NOTIFY_MESSAGE_TYPE,
} from '#/utils/register-notify';
import { useChatStore } from '../stores/chat';
import { useUserStore } from '../stores/user';
@@ -95,6 +99,12 @@ export function useWebSocket() {
return;
}
// 挂号实时提醒message_type=15 系统通知):弹桌面/站内通知+提示音,不进聊天窗口
if (Number(data.message_type) === REGISTER_NOTIFY_MESSAGE_TYPE) {
handleRegisterNotifyMessage(data);
return;
}
// 优先处理通话信令(无论是否在当前聊天窗口)
if (data.call_id && data.call_status) {
handleCallSignal(data);

View File

@@ -2,6 +2,7 @@ import { computed, ref, watch } from 'vue';
import { defineStore } from 'pinia';
import { desktop, isElectron } from '#/util/desktop';
import { playOnceAndDestroySys } from '#/util/tool';
import {
getChatFriendsListApi,
@@ -37,6 +38,33 @@ export const useChatStore = defineStore('chat', () => {
const lastMessageId = ref(0);
/**
* 判断是否需要发桌面系统通知
* 条件Electron 桌面端 + 窗口未聚焦(聚焦时页内已有提示音和 UI不重复打扰
* + 尊重聊天设置弹窗SettingsModal持久化在 chat-settings 里的开关
* @param settingKey 对应设置项desktopNotifications消息/ callNotifications来电
*/
const shouldDesktopNotify = (settingKey = 'desktopNotifications') => {
if (!isElectron() || document.hasFocus()) return false;
try {
const saved = localStorage.getItem('chat-settings');
if (saved && JSON.parse(saved)[settingKey] === false) return false;
} catch {
// 设置解析失败时按默认「开启」处理
}
return true;
};
/** 桌面端新消息提醒:系统通知(点击唤起窗口)+ 任务栏闪烁 */
const notifyDesktopNewMessage = (friend: any, preview: unknown) => {
if (!shouldDesktopNotify('desktopNotifications')) return;
desktop.notify({
title: friend?.name || friend?.nick_name || '新消息',
body: typeof preview === 'string' && preview ? preview : '[新消息]',
});
desktop.flashFrame();
};
// 计算属性
const isConnected = computed(() => connectionStatus.value === 'connected');
const isInCall = computed(() =>
@@ -453,6 +481,8 @@ export const useChatStore = defineStore('chat', () => {
friend.last_message = lastMessage;
// 使用js 播放这个音频一次 SysAudioMp3
playOnceAndDestroySys();
// 桌面端且窗口未聚焦时发系统通知 + 任务栏闪烁,避免错过 IM 消息
notifyDesktopNewMessage(friend, lastMessage);
// await chatDB.updateFriendLastMessage(
// friend.id,
@@ -829,6 +859,18 @@ export const useChatStore = defineStore('chat', () => {
logWithTime('log', '收到来电:', incomingCall.value);
// 桌面端且窗口未聚焦时发来电系统通知(尊重「通话通知」开关),点击唤起窗口接听
if (shouldDesktopNotify('callNotifications')) {
desktop.notify({
title: '来电提醒',
body:
incomingCall.value.callType === 'video'
? '收到视频通话邀请,点击查看'
: '收到语音通话邀请,点击查看',
});
desktop.flashFrame();
}
setCallTimeout(() => {
if (callStatus.value === 'ringing' && incomingCall.value) {
logWithTime('log', '来电超时,自动拒绝');

View File

@@ -0,0 +1,477 @@
<script lang="ts" setup>
/**
* 处方订单卡片看板C 端紧凑排版 + Apple 毛玻璃):与列表视图展示同一批字段,仅排版不同
*
* 与商品订单卡片同一套设计语言:多列自适应网格、毛玻璃 14px 圆角 hairline 卡片、
* 状态彩点、tinted 小胶囊、人员信息内联行、底部 hairline 操作栏。
* 数据自取props.formValues 变化时重置到第 1 页,触底自动加载下一页。
*/
import type { ActionItem } from '#/components/table-action';
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { Empty, message, Spin } from 'ant-design-vue';
import { TableAction } from '#/components/table-action';
import { pillStyle, tagColorHex } from '#/utils/tagColor';
import { getPrescriptionListApi } from '../api';
import {
PRESCRIPTION_IS_ONLINE_FALLBACK,
PRESCRIPTION_IS_ONLINE_MAP,
PRESCRIPTION_ORDER_TYPE_MAP,
PRESCRIPTION_STATUS_MAP,
} from '../config/constants';
const props = defineProps<{
/** 顶部统一搜索条件(含 status Tab / patient_keyword / doctor_keyword 等) */
formValues?: Record<string, any>;
}>();
const emit = defineEmits<{
/** 查看处方详情(复用父级弹窗) */
openDetail: [prescriptionId: number];
/** 处方溯源(复用父级弹窗) */
openSource: [prescriptionId: number];
/** 点诊所名打开诊所卡片 */
openStore: [storeId: number];
}>();
const items = ref<any[]>([]);
const page = ref(1);
const pageCount = ref(1);
const loading = ref(false);
const loadingMore = ref(false);
/** 请求序号:只应用最后一次响应,避免快速改筛选时旧结果覆盖新列表 */
let requestSeq = 0;
const scrollEl = ref<HTMLElement | null>(null);
const sentinelEl = ref<HTMLElement | null>(null);
let observer: IntersectionObserver | null = null;
const hasMore = computed(() => page.value < pageCount.value);
/** 处方类型元数据 */
function typeMeta(row: Record<string, any>) {
return PRESCRIPTION_ORDER_TYPE_MAP[Number(row?.prescription_type)] ?? null;
}
/** 审核状态元数据 */
function statusMeta(row: Record<string, any>) {
return PRESCRIPTION_STATUS_MAP[Number(row?.status)] ?? null;
}
/** 处方来源元数据(未知兜底线下) */
function isOnlineMeta(row: Record<string, any>) {
return PRESCRIPTION_IS_ONLINE_MAP[Number(row?.is_online)] ?? PRESCRIPTION_IS_ONLINE_FALLBACK;
}
/** 行操作:与列表 #action 槽一致(查看处方/处方溯源),通过 emit 复用父级弹窗 */
function buildActions(row: Record<string, any>): ActionItem[] {
return [
{
label: '查看处方',
type: 'link',
icon: 'marketeq:eye',
onClick: () => emit('openDetail', Number(row.id)),
},
{
label: '处方溯源',
type: 'link',
icon: 'mdi:timeline-text',
onClick: () => emit('openSource', Number(row.id)),
},
];
}
/**
* 拉取一页数据reset 时清空列表并回到第 1 页
* 用 requestSeq 丢弃过期响应,防止筛选连点时旧条件结果覆盖新条件
*/
async function fetchPage(targetPage: number, reset = false) {
const seq = ++requestSeq;
if (reset) {
loading.value = true;
} else {
loadingMore.value = true;
}
try {
const res = await getPrescriptionListApi({
page: targetPage,
pageSize: 12,
...(props.formValues || {}),
});
if (seq !== requestSeq) return;
items.value = reset ? (res?.items ?? []) : [...items.value, ...(res?.items ?? [])];
page.value = res?.page ?? targetPage;
pageCount.value = res?.page_count ?? 1;
} catch (error) {
if (seq !== requestSeq) return;
console.error(error);
message.error('加载处方列表失败');
} finally {
if (seq === requestSeq) {
loading.value = false;
loadingMore.value = false;
}
}
}
/** 筛选条件变化:重置回第 1 页 */
async function reload() {
page.value = 1;
items.value = [];
await fetchPage(1, true);
}
/** 操作后原地刷新:按已加载条数一次性重拉,保持滚动位置 */
async function refreshKeepPage() {
const loaded = Math.max(items.value.length, 12);
const seq = ++requestSeq;
loading.value = true;
try {
const res = await getPrescriptionListApi({
page: 1,
pageSize: loaded,
...(props.formValues || {}),
});
if (seq !== requestSeq) return;
items.value = res?.items ?? [];
page.value = Math.ceil((res?.items?.length ?? 0) / 12) || 1;
pageCount.value = Math.ceil((res?.total ?? 0) / 12) || 1;
} catch (error) {
if (seq === requestSeq) console.error(error);
} finally {
if (seq === requestSeq) loading.value = false;
}
}
defineExpose({ reload, refreshKeepPage });
async function loadMore() {
if (!hasMore.value || loading.value || loadingMore.value) return;
await fetchPage(page.value + 1, false);
}
/** 触底哨兵:滚动到底部附近自动加载下一页 */
function setupObserver() {
observer?.disconnect();
if (!sentinelEl.value) return;
observer = new IntersectionObserver(
(entries) => {
if (entries.some((e) => e.isIntersecting)) {
void loadMore();
}
},
{ root: scrollEl.value, rootMargin: '120px' },
);
observer.observe(sentinelEl.value);
}
watch(
() => props.formValues,
() => {
void reload();
},
{ deep: true },
);
onMounted(async () => {
await reload();
await nextTick();
setupObserver();
});
onBeforeUnmount(() => {
observer?.disconnect();
observer = null;
});
</script>
<template>
<div ref="scrollEl" class="rx-card-scroll">
<Spin :spinning="loading">
<div v-if="items.length" class="rx-card-grid">
<div v-for="row in items" :key="row.id" class="rx-card">
<!-- 角落径向荧光与提现管理统计岛同款质感装饰 -->
<div class="rx-card__glow" />
<!-- 头部诊所名可点+ 状态彩点 -->
<div class="rx-card__head">
<button
v-if="row.store?.id || row.store_id"
type="button"
class="rx-card__store"
@click="emit('openStore', Number(row.store?.id || row.store_id))"
>
<span class="rx-card__store-name">{{ row.store?.name || '—' }}</span>
<span class="rx-card__store-arrow"></span>
</button>
<span v-else class="rx-card__store-name">{{ row.store?.name || '—' }}</span>
<span
v-if="statusMeta(row)"
class="rx-card__status"
:style="{ color: tagColorHex(statusMeta(row)!.color) }"
>
<i
class="rx-card__status-dot"
:style="{ background: tagColorHex(statusMeta(row)!.color) }"
></i>
{{ statusMeta(row)!.label }}
</span>
</div>
<!-- 处方号 / 创建时间 -->
<div class="rx-card__sub">
<span class="truncate">{{ row.prescription_no || '—' }}</span>
<span class="rx-card__dot">·</span>
<span class="shrink-0">{{ row.created_at || '—' }}</span>
</div>
<!-- 语义胶囊处方类型 / 来源 -->
<div class="rx-card__pills">
<span v-if="typeMeta(row)" class="rx-pill" :style="pillStyle(typeMeta(row)!.color)">
{{ typeMeta(row)!.label }}
</span>
<span class="rx-pill" :style="pillStyle(isOnlineMeta(row).color)">
{{ isOnlineMeta(row).label }}
</span>
</div>
<!-- 医生 / 就诊人内联小字行省高度 -->
<div class="rx-card__people">
<span class="rx-kv">
<i>开方医生</i>
<em>{{ row.doctor_info?.name || '—' }}</em>
</span>
<span class="rx-kv">
<i>就诊人</i>
<em>{{ row.user_patient?.name || '—' }}</em>
</span>
</div>
<!-- 拒绝原因仅已拒绝时显示 -->
<div v-if="Number(row.status) === 2 && row.reject_reason" class="rx-card__reject">
拒绝原因{{ row.reject_reason }}
</div>
<!-- 弹性占位高度富余时把 footer 推到底同行等高卡对齐紧凑时保底 8px 间距 -->
<div class="rx-card__spacer"></div>
<!-- 底部操作与列表 #action 槽同语义 -->
<div class="rx-card__footer">
<TableAction :actions="buildActions(row)" :drop-down-actions="[]" />
</div>
</div>
</div>
<Empty v-else-if="!loading" description="暂无处方" />
</Spin>
<div ref="sentinelEl" class="h-8"></div>
<div v-if="loadingMore" class="py-3 text-center text-xs text-[hsl(var(--muted-foreground))]">
加载中
</div>
<div
v-else-if="items.length && !hasMore"
class="py-3 text-center text-xs text-[hsl(var(--muted-foreground))]"
>
没有更多了
</div>
</div>
</template>
<style scoped>
.rx-card-scroll {
height: 100%;
/* 左右留 6px卡片 hover 阴影不被容器裁出切边 */
padding: 4px 6px 20px;
overflow: auto;
}
/* 多列自适应网格:单卡最窄 340px宽屏自动 2-4 列C 端紧凑卡片密度);
min(340px, 100%) 防止窄容器(分屏)时轨道小于容器导致横向滚动 */
.rx-card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(340px, 100%), 1fr));
gap: 10px;
}
/* 玻璃质感紧凑卡片(与提现管理统计岛同款):主色渐变玻璃底 + 12px 圆角 + 1px 边框(禁止左侧竖色条) */
.rx-card {
position: relative;
display: flex;
flex-direction: column;
overflow: hidden;
background: linear-gradient(
135deg,
hsl(var(--primary) / 4%),
hsl(var(--card, var(--background)))
);
backdrop-filter: blur(8px);
border: 1px solid hsl(var(--border));
border-radius: 12px;
transition:
transform 0.2s ease,
box-shadow 0.2s ease,
border-color 0.2s ease;
}
.rx-card:hover {
border-color: hsl(var(--primary) / 40%);
box-shadow:
0 8px 20px hsl(var(--primary) / 14%),
0 2px 6px rgb(0 0 0 / 6%);
transform: translateY(-3px);
}
/* 角落径向荧光右上角淡主色光斑hover 增强 */
.rx-card__glow {
position: absolute;
top: -40px;
right: -40px;
width: 110px;
height: 110px;
pointer-events: none;
background: radial-gradient(circle, hsl(var(--primary) / 18%) 0%, transparent 70%);
opacity: 0.4;
transition: opacity 0.2s ease;
}
.rx-card:hover .rx-card__glow {
opacity: 1;
}
.rx-card__head {
display: flex;
gap: 8px;
align-items: center;
padding: 10px 12px 0;
}
.rx-card__store {
display: inline-flex;
min-width: 0;
align-items: center;
gap: 2px;
padding: 0;
cursor: pointer;
background: none;
border: none;
}
.rx-card__store-name {
overflow: hidden;
font-size: 14px;
font-weight: 600;
letter-spacing: -0.2px;
color: hsl(var(--foreground));
text-overflow: ellipsis;
white-space: nowrap;
}
.rx-card__store:hover .rx-card__store-name {
color: hsl(var(--primary));
}
.rx-card__store-arrow {
flex-shrink: 0;
font-size: 14px;
color: hsl(var(--muted-foreground));
}
.rx-card__status {
display: inline-flex;
flex-shrink: 0;
gap: 4px;
align-items: center;
margin-left: auto;
font-size: 12px;
font-weight: 600;
}
.rx-card__status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
}
.rx-card__sub {
display: flex;
gap: 4px;
align-items: center;
padding: 1px 12px 0;
font-size: 11px;
color: hsl(var(--muted-foreground));
}
.rx-card__dot {
flex-shrink: 0;
}
.rx-card__pills {
display: flex;
flex-wrap: wrap;
gap: 4px;
padding: 6px 12px 0;
}
.rx-pill {
display: inline-flex;
align-items: center;
padding: 1px 7px;
font-size: 10px;
font-weight: 500;
line-height: 16px;
border: none;
border-radius: 999px;
}
/* 医生/就诊人内联行 */
.rx-card__people {
display: flex;
flex-wrap: wrap;
gap: 2px 14px;
padding: 7px 12px 0;
font-size: 12px;
line-height: 1.6;
}
.rx-kv {
display: inline-flex;
gap: 4px;
align-items: baseline;
min-width: 0;
}
.rx-kv > i {
flex-shrink: 0;
font-size: 11px;
font-style: normal;
color: hsl(var(--muted-foreground) / 75%);
}
.rx-kv > em {
overflow: hidden;
font-style: normal;
font-weight: 500;
color: hsl(var(--foreground));
text-overflow: ellipsis;
white-space: nowrap;
}
/* 拒绝原因警示条warning tinted无左色条 */
.rx-card__reject {
padding: 4px 8px;
margin: 6px 12px 0;
font-size: 11px;
line-height: 1.6;
color: hsl(var(--warning, 38 92% 50%));
background: hsl(var(--warning, 38 92% 50%) / 10%);
border-radius: 8px;
}
/* 弹性占位:吃掉剩余高度让 footer 吸底(同行等高卡操作栏对齐) */
.rx-card__spacer {
flex: 1 1 auto;
min-height: 8px;
}
.rx-card__footer {
display: flex;
justify-content: flex-end;
padding: 4px 6px;
border-top: 1px solid hsl(var(--border) / 50%);
}
</style>

View File

@@ -0,0 +1,54 @@
/**
* 处方订单模块字典常量
* 列表 Tag 槽与卡片视图共用同一份映射,避免两处渲染语义漂移
*/
export interface TagMeta {
color: string;
label: string;
}
/** 处方类型映射(与列表 #status 槽第一行渲染语义一致) */
export const PRESCRIPTION_ORDER_TYPE_MAP: Record<number, TagMeta> = {
1: { label: '中药', color: 'orange' },
2: { label: '商品【西药】', color: 'blue' },
3: { label: '商品【保健食品】', color: 'purple' },
4: { label: '已退款', color: 'green' },
5: { label: '产品服务包', color: 'pink' },
6: { label: '非药品', color: 'cyan' },
7: { label: '医疗器械', color: 'geekblue' },
};
/** 审核状态映射status0待审核 1已通过 2已拒绝 3/4无需审核 */
export const PRESCRIPTION_STATUS_MAP: Record<number, TagMeta> = {
0: { label: '待审核', color: 'red' },
1: { label: '已通过', color: 'green' },
2: { label: '已拒绝', color: 'red' },
3: { label: '无需审核', color: 'purple' },
4: { label: '无需审核', color: 'green' },
};
/**
* 处方来源映射
* is_online 由挂号 type 经 RegisterTypeEnum::getIsOnlineByType 映射:
* 1=在线问诊 2=诊所在线复诊 3=药店在线复诊 0=线下
* (老页面漏了 3药店复诊被误显示为线下这里补全
*/
export const PRESCRIPTION_IS_ONLINE_MAP: Record<number, TagMeta> = {
1: { label: '在线问诊', color: 'blue' },
2: { label: '在线复诊(诊所)', color: 'green' },
3: { label: '在线复诊(药店)', color: 'green' },
};
/** 来源兜底:线下就诊 */
export const PRESCRIPTION_IS_ONLINE_FALLBACK: TagMeta = {
label: '线下就诊',
color: 'default',
};
/** 搜索下拉:处方来源选项 */
export const PRESCRIPTION_IS_ONLINE_OPTIONS = [
{ label: '在线问诊', value: 1 },
{ label: '在线复诊(诊所)', value: 2 },
{ label: '在线复诊(药店)', value: 3 },
{ label: '线下就诊', value: 0 },
];

View File

@@ -3,13 +3,42 @@ import type { VbenFormProps } from '#/adapter/form';
import dayjs from 'dayjs';
import { getOrderPrescriptionTypeOption } from '#/views/business/order/product-order/api';
import { PRESCRIPTION_IS_ONLINE_OPTIONS } from './constants';
export const formOptions: VbenFormProps = {
// 默认展开
collapsed: false,
// 默认收起只显示第一行,点「展开」看全部筛选(占位最小化)
collapsed: true,
commonConfig: {
// label 默认 100px本页筛选名最长 4 字,收窄到 70px 让控件占满格子(否则控件被挤得只剩一半宽)
labelWidth: 70,
// antd Select/RangePicker 默认按内容收缩,统一撑满格子,保证一行里各控件等宽整齐
componentProps: {
class: 'w-full',
},
},
// 响应式多列:抽出 grid 后 vben form 默认单列(一行一个字段),必须显式给栅格
wrapperClass: 'grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5',
schema: [
{
component: 'VbenInput',
// 放第一位:时间范围有默认值(本月至今),默认收起时必须始终可见,否则用户不知道当前查的是哪段数据
component: 'RangePicker',
componentProps: {
format: 'YYYY-MM-DD',
// 提交纯日期,后端 getWhereBetween 会把结束日扩到当天 23:59:59
valueFormat: 'YYYY-MM-DD',
},
defaultValue: [
dayjs().startOf('month').format('YYYY-MM-DD'),
dayjs().format('YYYY-MM-DD'),
],
fieldName: 'search_time',
label: '时间范围',
},
{
// 统一 antd 系控件:与 ApiSelect 同一套视觉(此前 VbenInput 是 shadcn 风格,高度圆角不一致)
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '输入订单号',
},
defaultValue: '',
@@ -36,18 +65,35 @@ export const formOptions: VbenFormProps = {
label: '订单类型',
},
{
component: 'RangePicker',
// 就诊人姓名/手机号模糊(后端 PersonKeywordScopeServicewhereHas 关联表)
component: 'Input',
componentProps: {
format: 'YYYY-MM-DD',
// 提交纯日期,后端 getWhereBetween 会把结束日扩到当天 23:59:59
valueFormat: 'YYYY-MM-DD',
allowClear: true,
placeholder: '姓名/手机号',
},
defaultValue: [
dayjs().startOf('month').format('YYYY-MM-DD'),
dayjs().format('YYYY-MM-DD'),
],
fieldName: 'search_time',
label: '时间范围',
fieldName: 'patient_keyword',
label: '就诊人',
},
{
// 医生姓名/手机号模糊(后端 PersonKeywordScopeService
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '姓名/手机号',
},
fieldName: 'doctor_keyword',
label: '医生',
},
{
// 处方来源(后端 queryField 等值匹配;选项与 constants 映射同语义)
component: 'Select',
componentProps: {
allowClear: true,
options: PRESCRIPTION_IS_ONLINE_OPTIONS,
placeholder: '请选择',
},
fieldName: 'is_online',
label: '处方来源',
},
],
// 控制表单是否显示折叠按钮
@@ -55,9 +101,7 @@ export const formOptions: VbenFormProps = {
submitButtonOptions: {
content: '查询',
},
// 是否在字段值改变时提交表单
// submitOnChange: true,
// submitOnChange: true,
// 按下回车时是否提交表单
submitOnEnter: false,
// 任意输入框回车即搜索下拉面板打开时的回车只选中选项core 层已处理),第二次回车才搜索
submitOnEnter: true,
};

View File

@@ -2,12 +2,18 @@ import type { VxeGridProps } from '#/adapter/vxe-table';
import { getPrescriptionListApi } from '../api';
/**
* 处方列表行类型:后端行字段众多(含关联 store/doctor_info/user_patient
* 用索引签名放宽,避免模板槽访问动态字段时 vue-tsc 报属性缺失
*/
interface RowType {
id: string;
name: string;
logo: string;
introduce: string;
id: number;
prescription_no: string;
prescription_type: number;
status: number;
is_online: number;
created_at: string;
[key: string]: any;
}
export const gridOptions: VxeGridProps<RowType> = {

View File

@@ -1,19 +1,36 @@
<script lang="ts" setup>
/**
* 处方订单管理页
*
* 支持「列表 / 卡片」双视图切换(通用 ViewModeSwitch + useViewMode 记忆偏好):
* - 搜索表单从 grid 内嵌拆出为顶部统一 SearchForm双视图共用同一份条件
* - 状态 Tabs待审核/已通过/已拒绝)与搜索条件合并后同时驱动两个视图
* - 卡片视图展示内容与列表一致,行操作复用同一批弹窗
*/
import type { VxeGridListeners } from '#/adapter/vxe-table';
import { ref, watch } from 'vue';
import { computed, ref, watch } from 'vue';
import { Page, useVbenModal } from '@vben/common-ui';
import { Button, Tabs, 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 StoreCardModal from '#/components/store-card/StoreCardModal.vue';
import PrescriptionDetail from '#/views/doctor/doctor-reception/components/PrescriptionDetail.vue';
import { getPrescriptionListApi } from './api';
import PrescriptionOrderCardList from './components/PrescriptionOrderCardList.vue';
import PrescriptionSource from './components/source.vue';
import {
PRESCRIPTION_IS_ONLINE_FALLBACK,
PRESCRIPTION_IS_ONLINE_MAP,
PRESCRIPTION_ORDER_TYPE_MAP,
PRESCRIPTION_STATUS_MAP,
} from './config/constants';
import { formOptions } from './config/search';
import { gridOptions as baseGridOptions } from './config/table';
@@ -21,6 +38,9 @@ const hasTopTableDropDownActions = ref(false);
/** 审核状态 Tab0待审核 / 1已通过 / 2已拒绝 */
const statusTab = ref('0');
/** 列表/卡片双视图:通用 useViewMode 自带 localStorage 记忆 */
const viewMode = useViewMode('prescription-order-view-mode');
const gridEvents: VxeGridListeners<any> = {
checkboxChange() {
const records = gridApi.grid.getCheckboxRecords();
@@ -32,6 +52,51 @@ const gridEvents: VxeGridListeners<any> = {
},
};
/**
* 从 schema 提取 defaultValue 作为首屏搜索条件
* 双视图首屏都要带默认时间范围,保证查询口径与表单显示一致
*/
function collectDefaultSearchValues(): Record<string, any> {
const values: Record<string, any> = {};
for (const item of formOptions.schema ?? []) {
if (item.fieldName && item.defaultValue !== undefined) {
values[item.fieldName] = item.defaultValue;
}
}
return values;
}
/** 列表与卡片共用的搜索条件(不含 status TabTab 单独合并) */
const searchValues = ref<Record<string, any>>(collectDefaultSearchValues());
/** 卡片视图最终条件:搜索条件 + 状态 Tabcomputed 保证 Tab 切换时卡片自动重拉) */
const cardFormValues = computed(() => ({
...searchValues.value,
status: Number(statusTab.value),
}));
/**
* 顶部统一搜索表单(从 grid 内嵌表单抽出,双视图共用)
* 列表模式提交后 reload 回第 1 页;卡片模式由 cardFormValues watch 自动拉数
*/
const [SearchForm, searchFormApi] = useVbenForm({
...formOptions,
handleSubmit: async (values) => {
searchValues.value = { ...(values || {}) };
if (viewMode.value === 'list') {
gridApi.reload();
}
},
handleReset: async () => {
// 自定义 handleReset 会接管默认行为,必须手动还原控件到 defaultValuereset 为新 APIresetForm 已弃用)
await searchFormApi.reset();
searchValues.value = collectDefaultSearchValues();
if (viewMode.value === 'list') {
gridApi.reload();
}
},
});
const [StoreCardModalComp, StoreCardModalApi] = useVbenModal({
connectedComponent: StoreCardModal,
});
@@ -43,17 +108,19 @@ function openStoreCard(storeId?: number) {
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions,
gridOptions: {
...baseGridOptions,
// 搜索已抽出为顶部统一表单,关闭 grid 自带的表单开关按钮
toolbarConfig: { ...baseGridOptions.toolbarConfig, search: false },
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
/** 始终合并顶部统一搜索条件 + 状态 Tab */
query: async ({ page }: { page: { currentPage: number; pageSize: number } }) => {
return await getPrescriptionListApi({
page: page.currentPage,
pageSize: page.pageSize,
status: Number(statusTab.value),
...formValues,
...searchValues.value,
});
},
},
@@ -62,11 +129,21 @@ const [Grid, gridApi] = useVbenVxeGrid({
gridEvents,
});
// Tab 切换:列表模式重查(卡片模式由 cardFormValues computed 触发 CardList watch
watch(statusTab, () => {
gridApi.query();
if (viewMode.value === 'list') {
gridApi.query();
}
});
function onStatusTabChange(key: string | number) {
// 记忆持久化由 useViewMode 内部完成;切回列表时 grid 被 v-if 卸载过需主动重查
watch(viewMode, (mode, prev) => {
if (mode === 'list' && prev === 'card') {
gridApi.query();
}
});
function onStatusTabChange(key: number | string) {
statusTab.value = String(key);
}
@@ -87,6 +164,17 @@ const openPrescriptionSourceModal = (id: number) => {
PrescriptionSourceModalApi.setData({ id });
PrescriptionSourceModalApi.open();
};
/** 列表槽用元数据取值 helperv-if 双重索引无法类型收窄,统一在这里兜底 */
function rowTypeMeta(row: Record<string, any>) {
return PRESCRIPTION_ORDER_TYPE_MAP[Number(row?.prescription_type)] ?? null;
}
function rowStatusMeta(row: Record<string, any>) {
return PRESCRIPTION_STATUS_MAP[Number(row?.status)] ?? null;
}
function rowIsOnlineMeta(row: Record<string, any>) {
return PRESCRIPTION_IS_ONLINE_MAP[Number(row?.is_online)] ?? PRESCRIPTION_IS_ONLINE_FALLBACK;
}
</script>
<template>
@@ -101,7 +189,15 @@ const openPrescriptionSourceModal = (id: number) => {
<Tabs.TabPane key="2" tab="已拒绝" />
</Tabs>
</div>
<Grid>
<!-- 顶部统一搜索双视图共用替代原 grid 内嵌表单 -->
<div class="mb-3 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--card,var(--background)))] p-3">
<SearchForm />
</div>
<!-- 视图切换列表 / 卡片看板 -->
<div class="mb-3 flex items-center justify-between gap-3">
<ViewModeSwitch v-model="viewMode" />
</div>
<Grid v-if="viewMode === 'list'">
<template #toolbar-actions>
<TableAction :actions="[]" :drop-down-actions="[]">
<template #more>
@@ -124,41 +220,25 @@ const openPrescriptionSourceModal = (id: number) => {
<span v-else>{{ row.store?.name || '' }}</span>
</template>
<template #status="{ row }">
<!-- 处方类型与审核状态渲染统一走 constants 映射与卡片视图同语义 -->
<div>
<Tag v-if="row.prescription_type === 1" color="orange">中药</Tag>
<Tag v-else-if="row.prescription_type === 2" color="blue">
商品西药
</Tag>
<Tag v-else-if="row.prescription_type === 3" color="purple">
商品保健食品
</Tag>
<Tag v-else-if="row.prescription_type === 4" color="green">
已退款
</Tag>
<Tag v-else-if="row.prescription_type === 5" color="pink">
产品服务包
</Tag>
<Tag v-else-if="row.prescription_type === 6" color="cyan">
非药品
</Tag>
<Tag v-else-if="row.prescription_type === 7" color="geekblue">
医疗器械
<Tag v-if="rowTypeMeta(row)" :color="rowTypeMeta(row)!.color">
{{ rowTypeMeta(row)!.label }}
</Tag>
</div>
<div class="mt-3">
<Tag v-if="row.status === 0" color="red">待审核</Tag>
<Tag v-else-if="row.status === 1" color="green">已通过</Tag>
<Tag v-else-if="row.status === 2" color="red"
>已拒绝{{ row.reject_reason }}</Tag
>
<Tag v-else-if="row.status === 3" color="purple">无需审核</Tag>
<Tag v-else-if="row.status === 4" color="green">无需审核</Tag>
<Tag v-if="rowStatusMeta(row)" :color="rowStatusMeta(row)!.color">
{{ rowStatusMeta(row)!.label
}}<template v-if="row.status === 2 && row.reject_reason">
{{ row.reject_reason }}</template
>
</Tag>
</div>
</template>
<template #is-online="{ row }">
<Tag v-if="row.is_online == 1" color="blue">在线问诊</Tag>
<Tag v-else-if="row.is_online == 2" color="green">在线复诊</Tag>
<Tag v-else color="default">线下就诊</Tag>
<Tag :color="rowIsOnlineMeta(row).color">
{{ rowIsOnlineMeta(row).label }}
</Tag>
</template>
<template #toolbar-tools></template>
<template #action="{ row }">
@@ -173,7 +253,7 @@ const openPrescriptionSourceModal = (id: number) => {
{
label: '处方溯源',
type: 'link',
icon: 'marketeq:eye',
icon: 'mdi:timeline-text',
onClick: openPrescriptionSourceModal.bind(null, row.id),
},
]"
@@ -181,11 +261,21 @@ const openPrescriptionSourceModal = (id: number) => {
/>
</template>
</Grid>
<!-- 卡片看板视图展示内容与列表一致弹窗复用父级纯查看操作无需操作后刷新句柄 -->
<div v-else class="rx-card-wrap">
<PrescriptionOrderCardList
:form-values="cardFormValues"
@open-detail="openPrescriptionDetail"
@open-source="openPrescriptionSourceModal"
@open-store="openStoreCard"
/>
</div>
</Page>
</template>
<style scoped lang="scss">
.custom-list {
list-style-type: none;
padding-left: 0;
/* 卡片看板容器撑满剩余高度内部滚动Tabs + 收起态搜索一行 + 切换行约 220pxmin-height 兜底) */
.rx-card-wrap {
height: calc(100% - 220px);
min-height: 420px;
}
</style>

View File

@@ -138,6 +138,10 @@ export interface OrderExportDataParams {
delivery_method?: number;
prescription_type?: number;
drug_keyword?: string;
/** 就诊人姓名/手机号模糊(后端 whereHas userPatient */
patient_keyword?: string;
/** 医生姓名/手机号模糊(后端 whereHas doctorInfo */
doctor_keyword?: string;
}
export interface OrderExportDataResult {

View File

@@ -0,0 +1,954 @@
<script lang="ts" setup>
/**
* 商品订单卡片看板C 端紧凑排版 + Apple 毛玻璃):与列表视图展示同一批字段,仅排版不同
*
* 排版语言参考 C 端 App 订单列表(外卖/电商):
* - 多列自适应网格minmax 自动 1-3 列),单卡窄而紧凑,减少纵向占用
* - 卡片:毛玻璃 + 14px 圆角 + hairline 细边框;状态用「彩点 + 彩字」
* - 商品行右侧直接放合计金额C 端惯例),人员/收货信息压成内联小字行
* - 语义标签tinted 小胶囊(同色低透明度底,无边框)
*
* 数据自取props.formValues 变化时重置到第 1 页,触底自动加载下一页。
* 行操作复用父级 buildRowActions与列表 #action 槽同一份定义)。
*/
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { Button, Empty, Image, message, Spin, Switch } from 'ant-design-vue';
import { CardActions } from '#/components/table-action';
import SensitiveText from '#/components/sensitive-text/SensitiveText.vue';
import { pillStyle, tagColorHex } from '#/utils/tagColor';
import { getOrderList } from '../api';
import {
IS_ONLINE_FALLBACK,
IS_ONLINE_MAP,
ORDER_STATUS_MAP,
ORDER_TYPE_MAP,
PRESCRIPTION_TYPE_MAP,
} from '../config/constants';
const props = defineProps<{
/** 顶部统一搜索条件(含 search_time / patient_keyword / doctor_keyword 等) */
formValues?: Record<string, any>;
/** 父级构造行操作(与列表 #action 槽同一份定义) */
buildRowActions: (row: Record<string, any>) => {
actions: any[];
dropDownActions: any[];
};
/** 金额是否可点调价(与列表 price-info 槽一致) */
canPercentAdjust?: (row: Record<string, any>) => boolean;
}>();
const emit = defineEmits<{
openStore: [storeId: number];
openDoctor: [row: Record<string, any>];
openUserPatients: [payload: { userId: number; nickname: string; avatarurl: string }];
openPatient: [payload: { upId: number; patientName: string }];
openWarehouse: [row: Record<string, any>, wh: { id: number; name: string }];
toggleFreeShipping: [row: Record<string, any>, checked: boolean];
percentAdjust: [row: Record<string, any>];
goRegister: [row: Record<string, any>];
}>();
const items = ref<any[]>([]);
const page = ref(1);
const pageCount = ref(1);
const loading = ref(false);
const loadingMore = ref(false);
/** 请求序号:只应用最后一次响应,避免快速改筛选时旧结果覆盖新列表 */
let requestSeq = 0;
/** 已手动展开商品明细的订单 id 集合 */
const expandedIds = ref<Set<number>>(new Set());
const scrollEl = ref<HTMLElement | null>(null);
const sentinelEl = ref<HTMLElement | null>(null);
let observer: IntersectionObserver | null = null;
const hasMore = computed(() => page.value < pageCount.value);
/** 处方类型元数据(全量 0-7含特色方无处方类型时返回 null */
function prescriptionTypeMeta(row: Record<string, any>) {
const type = Number(row?.prescription_type);
return Number.isFinite(type) ? (PRESCRIPTION_TYPE_MAP[type] ?? null) : null;
}
/** 状态元数据 */
function statusMeta(row: Record<string, any>) {
return ORDER_STATUS_MAP[Number(row?.status)] ?? null;
}
/** 就诊来源元数据0/未知兜底线下) */
function isOnlineMeta(row: Record<string, any>) {
return IS_ONLINE_MAP[Number(row?.is_online)] ?? IS_ONLINE_FALLBACK;
}
/** 非处方订单order_type=2/3显示订单类型 */
function orderTypeMeta(row: Record<string, any>) {
return ORDER_TYPE_MAP[Number(row?.order_type)] ?? null;
}
/** 卡片主类型胶囊:与列表 delivery-method 槽同口径——仅预约购药order_type=2显示订单类型其余显示处方类型 */
function headTypeMeta(row: Record<string, any>) {
if (Number(row?.order_type) === 2) {
return orderTypeMeta(row);
}
return prescriptionTypeMeta(row);
}
/** 首个商品(商品区首图/首名展示用) */
function firstItem(row: Record<string, any>) {
return Array.isArray(row.product_order_items) ? row.product_order_items[0] : null;
}
/** 商品总件数(中药按剂量折算与明细 chips 同口径) */
function totalPieces(row: Record<string, any>) {
const list = Array.isArray(row.product_order_items) ? row.product_order_items : [];
return list.reduce((sum: number, item: any) => {
const factor =
Number(row.prescription_type) === 1 && Number(item.dosage) > 0
? Number(item.dosage)
: 1;
return sum + Number(item.number || 0) * factor;
}, 0);
}
/** 收货地址拼装(与列表 formatExpressAddress 同口径) */
function expressAddress(row: Record<string, any>) {
if (Number(row.delivery_method) === 1) {
return row.store?.position || row.store?.name || '到店自提';
}
const region = row.address?.region || row.express_region || '';
const detail = row.address?.detail_address || row.express_address || '';
return [region, detail].filter(Boolean).join(' ') || '—';
}
/** 挂号金额(与列表 formatRegisterAmount 同口径) */
function registerAmount(row: Record<string, any>) {
if (row.register_id == null || row.register_id === '') return '—';
const n = Number(row.register_price);
return Number.isFinite(n) ? `¥${n.toFixed(2)}` : '—';
}
/* ---- 人员内联行解析(与列表 OrderUserInfoCell 同口径,卡片用紧凑内联行省高度) ---- */
/** 下单微信用户 ID */
function resolveUserId(row: Record<string, any>) {
return Number(row.user?.id || row.user_id || 0);
}
/** 就诊人 ID */
function resolveUpId(row: Record<string, any>) {
return Number(row.up_id || row.user_patient?.id || row.userPatient?.id || 0);
}
/** 就诊人姓名(多来源兜底) */
function patientNameOf(row: Record<string, any>) {
return row.user_patient?.name || row.userPatient?.name || row.patient || '—';
}
/** 是否有代付信息(有付款人 ID 或至少有付款人类型) */
function hasPayUser(row: Record<string, any>) {
const payUserId = Number(row.pay_user_id || row.pay_user?.id || 0);
return payUserId > 0 || Number(row.pay_user_type || 0) > 0;
}
/** 代付展示文案:[类型]名字(医生代付回落医生名,就诊人代付回落就诊人名) */
function payUserText(row: Record<string, any>) {
const t = Number(row.pay_user_type || 0);
const typeLabel = t === 2 ? '医生' : t === 3 ? '就诊人' : t === 1 ? '用户' : '—';
const nick = String(row.pay_user?.nickname || '').trim();
let name = nick;
if (!name && t === 2 && row.doctor?.name) name = String(row.doctor.name);
if (!name && t === 3) name = String(patientNameOf(row));
return `[${typeLabel}] ${name || '—'}`;
}
/** 点击就诊人:打开详情弹窗 */
function onPatientClick(row: Record<string, any>) {
const upId = resolveUpId(row);
if (!upId) return;
emit('openPatient', { upId, patientName: String(patientNameOf(row)) });
}
/** 点击下单用户:打开该用户就诊人列表 */
function onUserClick(row: Record<string, any>) {
const userId = resolveUserId(row);
if (!userId) return;
emit('openUserPatients', {
userId,
nickname: String(row.user?.nickname || ''),
avatarurl: String(row.user?.avatarurl || ''),
});
}
/** 切换某订单的商品明细展开态 */
function toggleExpand(row: Record<string, any>) {
const id = Number(row.id);
const next = new Set(expandedIds.value);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
expandedIds.value = next;
}
/**
* 拉取一页数据reset 时清空列表并回到第 1 页
* 用 requestSeq 丢弃过期响应,防止筛选连点时旧条件结果覆盖新条件
*/
async function fetchPage(targetPage: number, reset = false) {
const seq = ++requestSeq;
if (reset) {
loading.value = true;
} else {
loadingMore.value = true;
}
try {
const res = await getOrderList({
page: targetPage,
pageSize: 12,
...(props.formValues || {}),
});
if (seq !== requestSeq) return;
items.value = reset ? (res?.items ?? []) : [...items.value, ...(res?.items ?? [])];
page.value = res?.page ?? targetPage;
pageCount.value = res?.page_count ?? 1;
} catch (error) {
if (seq !== requestSeq) return;
console.error(error);
message.error('加载订单列表失败');
} finally {
if (seq === requestSeq) {
loading.value = false;
loadingMore.value = false;
}
}
}
/** 筛选条件变化:重置回第 1 页 */
async function reload() {
page.value = 1;
items.value = [];
await fetchPage(1, true);
}
/**
* 操作成功后的原地刷新:保留当前页码,避免退款/发货后跳回第 1 页
* 卡片是累积加载的,这里折衷为按已加载条数一次性重拉(页码 1、pageSize=已加载数)
*/
async function refreshKeepPage() {
const loaded = Math.max(items.value.length, 12);
const seq = ++requestSeq;
loading.value = true;
try {
const res = await getOrderList({
page: 1,
pageSize: loaded,
...(props.formValues || {}),
});
if (seq !== requestSeq) return;
items.value = res?.items ?? [];
// 折算页数:保持触底加载能继续接在后面
page.value = Math.ceil((res?.items?.length ?? 0) / 12) || 1;
pageCount.value = Math.ceil((res?.total ?? 0) / 12) || 1;
} catch (error) {
if (seq === requestSeq) console.error(error);
} finally {
if (seq === requestSeq) loading.value = false;
}
}
defineExpose({ reload, refreshKeepPage });
async function loadMore() {
if (!hasMore.value || loading.value || loadingMore.value) return;
await fetchPage(page.value + 1, false);
}
/** 触底哨兵:滚动到底部附近自动加载下一页 */
function setupObserver() {
observer?.disconnect();
if (!sentinelEl.value) return;
observer = new IntersectionObserver(
(entries) => {
if (entries.some((e) => e.isIntersecting)) {
void loadMore();
}
},
{ root: scrollEl.value, rootMargin: '120px' },
);
observer.observe(sentinelEl.value);
}
watch(
() => props.formValues,
() => {
void reload();
},
{ deep: true },
);
onMounted(async () => {
await reload();
await nextTick();
setupObserver();
});
onBeforeUnmount(() => {
observer?.disconnect();
observer = null;
});
</script>
<template>
<div ref="scrollEl" class="po-card-scroll">
<Spin :spinning="loading">
<div v-if="items.length" class="po-card-grid">
<div v-for="row in items" :key="row.id" class="po-card">
<!-- 角落径向荧光与提现管理统计岛同款质感装饰 -->
<div class="po-card__glow" />
<!-- 行1 头部诊所名可点+ 状态彩点 -->
<div class="po-card__head">
<button
v-if="row.store?.id"
type="button"
class="po-card__store"
@click="emit('openStore', row.store.id)"
>
<span class="po-card__store-name">{{ row.store?.name || '—' }}</span>
<span class="po-card__store-arrow"></span>
</button>
<span v-else class="po-card__store-name">{{ row.store?.name || '—' }}</span>
<span
v-if="statusMeta(row)"
class="po-card__status"
:style="{ color: tagColorHex(statusMeta(row)!.color) }"
>
<i
class="po-card__status-dot"
:style="{ background: tagColorHex(statusMeta(row)!.color) }"
></i>
{{ statusMeta(row)!.label }}
</span>
</div>
<!-- 行2 单号/时间 + 语义胶囊同一行紧凑小灰字在左胶囊接排换行 -->
<div class="po-card__meta">
<span class="truncate">{{ row.order_no }}</span>
<span class="po-card__dot">·</span>
<span class="shrink-0">{{ row.created_at || '—' }}</span>
</div>
<div class="po-card__pills">
<span v-if="headTypeMeta(row)" class="po-pill" :style="pillStyle(headTypeMeta(row)!.color)">
{{ headTypeMeta(row)!.label }}
</span>
<span class="po-pill" :style="pillStyle(isOnlineMeta(row).color)">
{{ isOnlineMeta(row).label }}
</span>
<span
v-if="row.is_proxy_pay || Number(row.pay_user_type) === 2"
class="po-pill"
:style="pillStyle('magenta')"
>
代支付
</span>
<span v-if="row.delivery_method === 1" class="po-pill" :style="pillStyle('blue')">
到店自提
</span>
<span v-else-if="row.delivery_method === 0" class="po-pill" :style="pillStyle('green')">
上门快递
</span>
<span class="po-pill" :style="pillStyle(row.is_sync_erp === 1 ? 'green' : 'red')">
{{ row.is_sync_erp === 1 ? 'Erp已同步' : 'Erp未同步' }}
</span>
<template
v-if="Array.isArray(row.delivery_warehouses) && row.delivery_warehouses.length"
>
<button
v-for="wh in row.delivery_warehouses"
:key="wh.id"
type="button"
class="po-pill po-pill--action"
:style="pillStyle('processing')"
@click="emit('openWarehouse', row, wh)"
>
{{ wh.name }}
</button>
</template>
</div>
<!-- 行3 商品行C 端惯例左图文右合计金额点击展开全部明细 -->
<div class="po-card__goods" role="button" @click="toggleExpand(row)">
<div v-if="firstItem(row)" class="po-card__goods-thumb">
<Image
v-if="firstItem(row)?.drug_image"
:src="firstItem(row).drug_image"
:preview="false"
width="100%"
height="100%"
/>
<span v-else class="po-card__goods-placeholder">
{{ prescriptionTypeMeta(row)?.label?.slice(0, 2) || '商品' }}
</span>
</div>
<div class="min-w-0 flex-1">
<div class="po-card__goods-name">
{{
firstItem(row)
? firstItem(row).display_drug_name || firstItem(row).drug_name
: '无商品明细'
}}
</div>
<div v-if="firstItem(row)" class="po-card__goods-count">
{{ row.product_order_items.length }} · {{ totalPieces(row) }}
<span class="po-card__goods-toggle">
{{ expandedIds.has(Number(row.id)) ? '收起' : '展开' }}
<i :class="expandedIds.has(Number(row.id)) ? 'up' : ''"></i>
</span>
</div>
</div>
<div class="po-card__amount" @click.stop>
<span
v-if="props.canPercentAdjust?.(row)"
class="po-card__amount-value po-card__amount-value--link"
role="button"
@click="emit('percentAdjust', row)"
>
<i>¥</i>{{ row.total_pay_price ?? '0.00' }}
</span>
<span v-else class="po-card__amount-value">
<i>¥</i>{{ row.total_pay_price ?? '0.00' }}
</span>
</div>
</div>
<!-- 展开后的全部商品 chips -->
<div
v-if="expandedIds.has(Number(row.id)) && Array.isArray(row.product_order_items) && row.product_order_items.length"
class="po-card__chips"
>
<span
v-for="(item, idx) in row.product_order_items"
:key="idx"
class="po-card__drug-chip"
>
{{ item.display_drug_name || item.drug_name }}
×{{
Number(row.prescription_type) === 1
? item.number * (item.dosage > 0 ? item.dosage : 1)
: item.number
}}
</span>
</div>
<!-- 行4 人员内联行用户/医生/就诊人/代付与列表 OrderUserInfoCell 同口径可点 -->
<div class="po-card__people">
<span class="po-kv">
<i>用户</i>
<button
v-if="resolveUserId(row)"
type="button"
class="po-link"
@click="onUserClick(row)"
>
{{ row.user?.nickname || '—' }}
</button>
<em v-else>{{ row.user?.nickname || '' }}</em>
</span>
<span class="po-kv">
<i>医生</i>
<button
v-if="row.doctor?.name"
type="button"
class="po-link"
@click="emit('openDoctor', row)"
>
{{ row.doctor.name }}
</button>
<em v-else></em>
</span>
<span class="po-kv">
<i>就诊人</i>
<button
v-if="resolveUpId(row)"
type="button"
class="po-link"
@click="onPatientClick(row)"
>
{{ patientNameOf(row) }}
</button>
<em v-else>{{ patientNameOf(row) }}</em>
</span>
<span v-if="hasPayUser(row)" class="po-kv">
<i>代付</i>
<em>{{ payUserText(row) }}</em>
</span>
</div>
<!-- 行5 收货内联行收件人 + 电话 + 地址一行截断 -->
<div class="po-card__express">
<i>收货</i>
<span class="truncate">
{{ row.address?.name || row.express_name || row.patient || '—' }}
<SensitiveText
:record="row.address"
field="mobile"
:fallback="{ record: row, field: 'patient_mobile' }"
/>
· {{ expressAddress(row) }}
</span>
</div>
<!-- 行6 推广员内联行有则显示 -->
<div v-if="row.salesperson?.id" class="po-card__express po-card__salesperson">
<i>推广</i>
<Image
:src="row.salesperson.avatar || '/img/user-default-avatar.png'"
:width="16"
:height="16"
:preview="false"
class="shrink-0 rounded-full object-cover"
/>
<span class="truncate">{{ row.salesperson.nick_name || '—' }}</span>
<span class="po-card__salesperson-status">
{{ row.salesperson.commission_status_text || '—' }}
</span>
<span class="shrink-0 font-medium text-orange-500">
¥{{ row.salesperson.commission_amount || '0.00' }}
</span>
</div>
<!-- 弹性占位高度富余时把 footer 推到底同行等高卡对齐紧凑时保底 8px 间距 -->
<div class="po-card__spacer"></div>
<!-- 行7 footer左分项金额/包邮开关右操作按钮 -->
<div class="po-card__footer">
<div class="po-card__amount-parts">
<span>
药品
<Button
v-if="props.canPercentAdjust?.(row)"
class="!h-auto !px-0 !py-0 !text-xs"
type="link"
@click="emit('percentAdjust', row)"
>
¥{{ row.items_price ?? '0.00' }}
</Button>
<template v-else>¥{{ row.items_price ?? '0.00' }}</template>
</span>
<span>
挂号
<Button
v-if="row.register_order_no"
class="!h-auto !px-0 !py-0 !text-xs"
type="link"
@click="emit('goRegister', row)"
>
{{ registerAmount(row) }}
</Button>
<template v-else>{{ registerAmount(row) }}</template>
</span>
<Switch
:checked="row.is_free_shipping === 1"
checked-children="包邮"
un-checked-children="不包邮"
size="small"
@change="(checked: any) => emit('toggleFreeShipping', row, Boolean(checked))"
/>
</div>
<!-- 卡片交互与列表解耦只露出详情主操作其余收进···菜单避免按钮堆叠 -->
<CardActions
:actions="props.buildRowActions(row).actions"
:drop-down-actions="props.buildRowActions(row).dropDownActions"
/>
</div>
</div>
</div>
<Empty v-else-if="!loading" description="暂无订单" />
</Spin>
<div ref="sentinelEl" class="h-8"></div>
<div v-if="loadingMore" class="py-3 text-center text-xs text-[hsl(var(--muted-foreground))]">
加载中
</div>
<div
v-else-if="items.length && !hasMore"
class="py-3 text-center text-xs text-[hsl(var(--muted-foreground))]"
>
没有更多了
</div>
</div>
</template>
<style scoped>
.po-card-scroll {
height: 100%;
/* 左右留 6px卡片 hover 阴影不被容器裁出切边 */
padding: 4px 6px 20px;
overflow: auto;
}
/* 多列自适应网格:单卡最窄 400px宽屏自动 2-3 列C 端紧凑卡片密度);
min(400px, 100%) 防止窄容器(分屏)时轨道小于容器导致横向滚动 */
.po-card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(400px, 100%), 1fr));
gap: 10px;
}
/* 玻璃质感紧凑卡片(与提现管理统计岛同款):主色渐变玻璃底 + 12px 圆角 + 1px 边框 */
.po-card {
position: relative;
display: flex;
flex-direction: column;
overflow: hidden;
background: linear-gradient(
135deg,
hsl(var(--primary) / 4%),
hsl(var(--card, var(--background)))
);
backdrop-filter: blur(8px);
border: 1px solid hsl(var(--border));
border-radius: 12px;
transition:
transform 0.2s ease,
box-shadow 0.2s ease,
border-color 0.2s ease;
}
.po-card:hover {
border-color: hsl(var(--primary) / 40%);
box-shadow:
0 8px 20px hsl(var(--primary) / 14%),
0 2px 6px rgb(0 0 0 / 6%);
transform: translateY(-3px);
}
/* 角落径向荧光右上角淡主色光斑hover 增强 */
.po-card__glow {
position: absolute;
top: -40px;
right: -40px;
width: 110px;
height: 110px;
pointer-events: none;
background: radial-gradient(circle, hsl(var(--primary) / 18%) 0%, transparent 70%);
opacity: 0.4;
transition: opacity 0.2s ease;
}
.po-card:hover .po-card__glow {
opacity: 1;
}
/* 行1 头部:店铺 + 状态彩点 */
.po-card__head {
display: flex;
gap: 8px;
align-items: center;
padding: 10px 12px 0;
}
.po-card__store {
display: inline-flex;
min-width: 0;
align-items: center;
gap: 2px;
padding: 0;
cursor: pointer;
background: none;
border: none;
}
.po-card__store-name {
overflow: hidden;
font-size: 14px;
font-weight: 600;
letter-spacing: -0.2px;
color: hsl(var(--foreground));
text-overflow: ellipsis;
white-space: nowrap;
}
.po-card__store:hover .po-card__store-name {
color: hsl(var(--primary));
}
.po-card__store-arrow {
flex-shrink: 0;
font-size: 14px;
color: hsl(var(--muted-foreground));
}
.po-card__status {
display: inline-flex;
flex-shrink: 0;
gap: 4px;
align-items: center;
margin-left: auto;
font-size: 12px;
font-weight: 600;
}
.po-card__status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
}
/* 行2 单号/时间小灰字 */
.po-card__meta {
display: flex;
gap: 4px;
align-items: center;
padding: 1px 12px 0;
font-size: 11px;
color: hsl(var(--muted-foreground));
}
.po-card__dot {
flex-shrink: 0;
}
/* tinted 胶囊行(紧凑小号) */
.po-card__pills {
display: flex;
flex-wrap: wrap;
gap: 4px;
padding: 6px 12px 0;
}
.po-pill {
display: inline-flex;
align-items: center;
padding: 1px 7px;
font-size: 10px;
font-weight: 500;
line-height: 16px;
border: none;
border-radius: 999px;
}
.po-pill--action {
cursor: pointer;
transition: filter 0.15s ease;
}
.po-pill--action:hover {
filter: brightness(1.08) saturate(1.2);
}
/* 行3 商品行左图文右合计C 端订单卡惯例) */
.po-card__goods {
display: flex;
gap: 10px;
align-items: center;
padding: 7px 10px;
margin: 8px 12px 0;
cursor: pointer;
background: hsl(var(--muted) / 30%);
border-radius: 10px;
transition: background-color 0.2s ease;
}
.po-card__goods:hover {
background: hsl(var(--muted) / 45%);
}
.po-card__goods-thumb {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
overflow: hidden;
background: hsl(var(--card, var(--background)));
border-radius: 8px;
box-shadow: 0 1px 2px rgb(0 0 0 / 6%);
}
.po-card__goods-thumb :deep(img) {
object-fit: cover;
}
.po-card__goods-placeholder {
font-size: 11px;
font-weight: 600;
color: hsl(var(--muted-foreground));
}
.po-card__goods-name {
overflow: hidden;
font-size: 13px;
font-weight: 500;
color: hsl(var(--foreground));
text-overflow: ellipsis;
white-space: nowrap;
}
.po-card__goods-count {
display: flex;
gap: 6px;
align-items: center;
margin-top: 1px;
font-size: 11px;
color: hsl(var(--muted-foreground));
}
.po-card__goods-toggle {
display: inline-flex;
flex-shrink: 0;
gap: 1px;
align-items: center;
font-size: 11px;
color: hsl(var(--primary));
}
.po-card__goods-toggle i {
font-style: normal;
transform: rotate(90deg);
transition: transform 0.2s ease;
}
.po-card__goods-toggle i.up {
transform: rotate(-90deg);
}
/* 合计金额(商品行右侧) */
.po-card__amount {
flex-shrink: 0;
margin-left: auto;
}
.po-card__amount-value {
/* DIN 等宽数字:与提现管理统计岛金额同款字体质感 */
font-family: 'DIN Alternate', ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 16px;
font-weight: 700;
letter-spacing: -0.3px;
color: hsl(var(--foreground));
}
.po-card__amount-value i {
margin-right: 1px;
font-size: 11px;
font-style: normal;
font-weight: 600;
}
.po-card__amount-value--link {
color: hsl(var(--primary));
cursor: pointer;
}
.po-card__chips {
display: flex;
flex-wrap: wrap;
gap: 4px;
padding: 6px 12px 0;
}
.po-card__drug-chip {
padding: 1px 8px;
font-size: 11px;
color: hsl(var(--muted-foreground));
background: hsl(var(--muted) / 35%);
border-radius: 999px;
}
/* 行4 人员内联行label 小灰字 + 可点链接,一行自动换行 */
.po-card__people {
display: flex;
flex-wrap: wrap;
gap: 2px 14px;
padding: 7px 12px 0;
font-size: 12px;
line-height: 1.6;
}
.po-kv {
display: inline-flex;
gap: 4px;
align-items: baseline;
min-width: 0;
}
.po-kv > i {
flex-shrink: 0;
font-size: 11px;
font-style: normal;
color: hsl(var(--muted-foreground) / 75%);
}
.po-kv > em {
overflow: hidden;
font-style: normal;
color: hsl(var(--foreground));
text-overflow: ellipsis;
white-space: nowrap;
}
.po-link {
max-width: 140px;
padding: 0;
overflow: hidden;
font-size: 12px;
color: hsl(var(--primary));
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
background: none;
border: none;
}
.po-link:hover {
text-decoration: underline;
}
/* 行5/6 收货、推广员内联行label + 单行截断 */
.po-card__express {
display: flex;
gap: 6px;
align-items: center;
min-width: 0;
padding: 4px 12px 0;
font-size: 12px;
color: hsl(var(--muted-foreground));
}
.po-card__express > i {
flex-shrink: 0;
font-size: 11px;
font-style: normal;
color: hsl(var(--muted-foreground) / 75%);
}
.po-card__salesperson-status {
flex-shrink: 0;
padding: 0 7px;
font-size: 10px;
line-height: 16px;
color: hsl(var(--muted-foreground));
background: hsl(var(--muted) / 40%);
border-radius: 999px;
}
/* 弹性占位:吃掉剩余高度让 footer 吸底 */
.po-card__spacer {
flex: 1 1 auto;
min-height: 8px;
}
/* 行7 footer左分项/包邮右操作按钮hairline 分隔 */
.po-card__footer {
display: flex;
flex-wrap: wrap;
gap: 4px 10px;
align-items: center;
justify-content: space-between;
padding: 4px 6px 4px 12px;
border-top: 1px solid hsl(var(--border) / 50%);
}
.po-card__amount-parts {
display: flex;
gap: 10px;
align-items: center;
font-size: 11px;
color: hsl(var(--muted-foreground));
}
</style>

View File

@@ -11,7 +11,6 @@ import {
Button,
Checkbox,
DatePicker,
Divider,
Input,
Modal as AntModal,
message,
@@ -77,7 +76,12 @@ const status = ref<number | undefined>(undefined);
const deliveryMethod = ref<number | undefined>(undefined);
const prescriptionType = ref<number | undefined>(undefined);
const drugKeyword = ref('');
/** 就诊人/医生关键字(姓名或手机号模糊,后端 whereHas 关联表筛选) */
const patientKeyword = ref('');
const doctorKeyword = ref('');
const granularity = ref<ExportGranularity>('order');
/** 订单维度导出时,是否附加「订单商品明细」工作簿(按订单分组的每单商品清单) */
const withDetailWorkbook = ref(false);
const selectedKeys = ref<string[]>(getDefaultSelectedKeys('order'));
const orderedSelectedKeys = ref<string[]>(
buildDefaultOrderedKeys(getDefaultSelectedKeys('order'), 'order'),
@@ -204,7 +208,10 @@ function resetFormDefaults() {
deliveryMethod.value = undefined;
prescriptionType.value = undefined;
drugKeyword.value = '';
patientKeyword.value = '';
doctorKeyword.value = '';
granularity.value = 'order';
withDetailWorkbook.value = false;
selectedKeys.value = getDefaultSelectedKeys('order');
orderedSelectedKeys.value = buildDefaultOrderedKeys(
selectedKeys.value,
@@ -515,6 +522,8 @@ async function handleDeleteScheme() {
}
const [Modal, modalApi] = useVbenModal({
// 退出全屏后的宽度:两列布局左列固定 400px弹窗必须足够宽右列才有空间
class: 'w-[1200px]',
fullscreen: true,
fullscreenButton: true,
draggable: true,
@@ -557,6 +566,12 @@ const [Modal, modalApi] = useVbenModal({
if (drugKeyword.value.trim()) {
params.drug_keyword = drugKeyword.value.trim();
}
if (patientKeyword.value.trim()) {
params.patient_keyword = patientKeyword.value.trim();
}
if (doctorKeyword.value.trim()) {
params.doctor_keyword = doctorKeyword.value.trim();
}
const res = await getOrderExportDataApi(params);
const rows = res?.rows ?? [];
@@ -565,6 +580,40 @@ const [Modal, modalApi] = useVbenModal({
return;
}
// 订单维度勾选附加工作簿:按同筛选条件再拉一次 item 粒度,用于按订单分组的商品明细 Sheet
let detailSheet:
| undefined
| {
fieldMeta: ReturnType<typeof getExportFields>;
rows: Record<string, unknown>[];
selectedFields: string[];
};
if (granularity.value === 'order' && withDetailWorkbook.value) {
loadingStep.value = '正在拉取商品明细…';
// 明细工作簿独立容错:明细接口失败只降级为「仅导出订单汇总」,不拖垮整个导出
try {
const detailRes = await getOrderExportDataApi({
...params,
granularity: 'item',
});
const detailRows = detailRes?.rows ?? [];
if (detailRows.length > 0) {
detailSheet = {
rows: detailRows,
// 明细工作簿用 item 粒度默认字段集(不提供单独的字段选择,保持交互简单)
selectedFields: buildDefaultOrderedKeys(
getDefaultSelectedKeys('item'),
'item',
),
fieldMeta: getExportFields('item'),
};
}
} catch (detailError) {
console.error('商品明细拉取失败,仅导出订单汇总:', detailError);
message.warning('商品明细拉取失败,本次仅导出订单汇总');
}
}
loadingStep.value = '正在生成 Excel…';
const fieldsMeta = getExportFields(granularity.value);
const buffer = await exportProductOrderExcel({
@@ -573,6 +622,7 @@ const [Modal, modalApi] = useVbenModal({
fieldMeta: fieldsMeta,
granularity: granularity.value,
exportedAt: new Date(),
detailSheet,
filters: {
search_time: timeRange,
order_no: orderNo.value.trim() || undefined,
@@ -641,7 +691,18 @@ const [Modal, modalApi] = useVbenModal({
:ok-text="loading ? loadingStep || '导出中' : '导出 Excel'"
title="导出商品订单"
>
<div class="flex max-h-[calc(100vh-220px)] flex-col gap-4 py-1">
<!--
布局说明不再用 max-h[calc] 写死高度矮屏下会把各区块 flex 压缩导致内容互相叠压
字段复选框被遮挡无法点选改为
- 小屏<lg自然高度纵向铺开滚动交给 vben modal body自带 overflow-y-auto
- 宽屏lg+全屏弹窗常态左右两列左列方案/筛选/导出设置独立滚动
右列整块留给导出字段内部滚动字段再多也不会被挤压
-->
<div class="flex min-h-0 flex-col gap-4 py-1 lg:h-full lg:flex-row lg:items-stretch">
<!-- 左列导出方案 + 筛选条件 + 导出设置 -->
<div
class="flex min-h-0 flex-col gap-4 lg:w-[400px] lg:flex-none lg:overflow-y-auto lg:pr-1"
>
<!-- 导出方案 -->
<div>
<div class="mb-2 text-sm font-medium">导出方案</div>
@@ -770,6 +831,22 @@ const [Modal, modalApi] = useVbenModal({
placeholder="药品名称/拼音首拼"
/>
</div>
<div>
<div class="mb-1 text-xs text-muted-foreground">就诊人</div>
<Input
v-model:value="patientKeyword"
allow-clear
placeholder="姓名/手机号"
/>
</div>
<div>
<div class="mb-1 text-xs text-muted-foreground">医生</div>
<Input
v-model:value="doctorKeyword"
allow-clear
placeholder="姓名/手机号"
/>
</div>
</div>
</div>
@@ -780,18 +857,22 @@ const [Modal, modalApi] = useVbenModal({
<Radio value="order">订单维度(一行一个订单)</Radio>
<Radio value="item">商品明细维度(一行一个商品)</Radio>
</Radio.Group>
<div v-if="granularity === 'order'" class="mt-2">
<Checkbox v-model:checked="withDetailWorkbook">
附加「订单商品明细」工作簿(按订单分组导出每单商品清单)
</Checkbox>
</div>
<div class="mt-1 text-xs text-muted-foreground">
当前粒度:{{ granularityLabel(granularity) }},导出文件将包含筛选条件说明与专业表头样式
</div>
</div>
</div>
<Divider class="my-0" />
<!-- 区块 C导出字段 -->
<!-- 区块 C导出字段右列宽屏下独占一列内部滚动 -->
<div class="flex min-h-0 flex-1 flex-col">
<div class="mb-2 flex items-center justify-between">
<div class="mb-2 flex flex-wrap items-center justify-between gap-2">
<span class="text-sm font-medium">导出字段</span>
<Space size="small">
<Space size="small" wrap>
<span class="text-xs text-muted-foreground">
已选 {{ selectedCount }} / {{ totalFieldCount }}
</span>
@@ -827,7 +908,8 @@ const [Modal, modalApi] = useVbenModal({
<span class="text-xs font-medium text-muted-foreground">{{ group }}</span>
</Checkbox>
</div>
<div class="grid grid-cols-3 gap-x-2 gap-y-1">
<!-- 列数随右列宽度自适应:宽屏铺开减少纵向滚动,窄屏两列保证可点面积 -->
<div class="grid grid-cols-2 gap-x-2 gap-y-1.5 md:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
<Checkbox v-for="field in fields" :key="field.key" :value="field.key">
<span class="text-sm">{{ field.label }}</span>
</Checkbox>
@@ -837,7 +919,7 @@ const [Modal, modalApi] = useVbenModal({
<div
v-if="fieldOrderPreview"
class="mt-2 rounded-md border border-dashed border-border bg-muted/10 px-3 py-2 text-xs leading-relaxed text-muted-foreground"
class="mt-2 max-h-24 flex-none overflow-y-auto rounded-md border border-dashed border-border bg-muted/10 px-3 py-2 text-xs leading-relaxed text-muted-foreground"
>
<span class="font-medium text-foreground">导出列顺序:</span>
{{ fieldOrderPreview }}

View File

@@ -3,7 +3,7 @@
* 订单列表:下单用户 / 医生 / 就诊人信息单元
* 下单用户 → 该用户就诊人列表 Modal就诊人 → 详情 Modal
*/
import { Avatar, Button, Tag } from 'ant-design-vue';
import { Avatar, Button } from 'ant-design-vue';
import { resolveAvatarUrl } from '#/views/system/store-input/utils/inputUser';

View File

@@ -0,0 +1,58 @@
/**
* 商品订单模块共用常量
*
* 处方类型/订单状态等 Tag 映射此前散落在 index.vue 模板的 if-else 链里,
* 漏掉了特色方(0),且列表/卡片/导出各处无法复用,统一收敛到这里。
*/
export interface TagMeta {
/** 显示文案 */
label: string;
/** antd Tag color */
color: string;
}
/**
* 处方类型prescription_type全量映射
* 对齐后端 ProductTypeEnum含不参与开方筛选的特色方(0)与历史遗留值(4=西药)
*/
export const PRESCRIPTION_TYPE_MAP: Record<number, TagMeta> = {
0: { label: '特色方', color: 'gold' },
1: { label: '中药', color: 'orange' },
2: { label: '西药', color: 'blue' },
3: { label: '保健食品', color: 'purple' },
4: { label: '西药', color: 'green' },
5: { label: '产品服务包', color: 'pink' },
6: { label: '非药品', color: 'cyan' },
7: { label: '医疗器械', color: 'geekblue' },
};
/** 订单状态status映射与列表原 if-else 链颜色语义一致 */
export const ORDER_STATUS_MAP: Record<number, TagMeta> = {
0: { label: '待支付', color: 'red' },
1: { label: '待发货', color: 'orange' },
2: { label: '待收货', color: 'blue' },
3: { label: '待评价', color: 'purple' },
4: { label: '已退款', color: 'green' },
5: { label: '退款中', color: 'pink' },
6: { label: '已收货', color: 'cyan' },
7: { label: '确认收货', color: 'green' },
8: { label: '拒绝退款', color: '#B22222' },
9: { label: '已取消', color: 'gray' },
};
/** 订单类型order_type映射非 1 的订单不展示处方类型 Tag */
export const ORDER_TYPE_MAP: Record<number, TagMeta> = {
1: { label: '处方订单', color: 'yellow' },
2: { label: '预约购药订单', color: 'yellow' },
3: { label: '商城处方订单', color: 'yellow' },
};
/** 就诊来源is_online映射0/其他值视为线下就诊 */
export const IS_ONLINE_MAP: Record<number, TagMeta> = {
1: { label: '在线问诊', color: 'blue' },
2: { label: '在线复诊(诊所)', color: 'green' },
3: { label: '在线复诊(药店)', color: 'green' },
};
/** 就诊来源兜底(线下) */
export const IS_ONLINE_FALLBACK: TagMeta = { label: '线下就诊', color: 'default' };

View File

@@ -7,12 +7,39 @@ import {
} from '#/views/business/order/product-order/api';
export const formOptions: VbenFormProps = {
// 默认展开
collapsed: false,
// 默认收起只显示第一行,点「展开」看全部筛选(字段多,全展开太占高度)
collapsed: true,
commonConfig: {
// label 默认 100px本页筛选名最长 4 字,收窄到 70px 让控件占满格子(否则控件被挤得只剩一半宽)
labelWidth: 70,
// antd Select/RangePicker 默认按内容收缩,统一撑满格子,保证一行里各控件等宽整齐
componentProps: {
class: 'w-full',
},
},
// 响应式多列:抽出 grid 后 vben form 默认单列(一行一个字段),必须显式给栅格
wrapperClass: 'grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5',
schema: [
{
component: 'VbenInput',
// 放第一位:时间范围有默认值(本月至今),默认收起时必须始终可见,否则用户不知道当前查的是哪段数据
component: 'RangePicker',
componentProps: {
format: 'YYYY-MM-DD',
// 提交纯日期,后端 getWhereBetween 会把结束日扩到当天 23:59:59
valueFormat: 'YYYY-MM-DD',
},
defaultValue: [
dayjs().startOf('month').format('YYYY-MM-DD'),
dayjs().format('YYYY-MM-DD'),
],
fieldName: 'search_time',
label: '时间范围',
},
{
// 统一 antd 系控件:与 ApiSelect/门店搜索同一套视觉(此前 VbenInput 是 shadcn 风格,高度圆角不一致)
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '输入订单号',
},
defaultValue: '',
@@ -47,8 +74,8 @@ export const formOptions: VbenFormProps = {
label: '订单状态',
},
{
// 医生代支付:按 pay_user_type=2 筛选,方便在列表中定位代付单
component: 'VbenSelect',
// 医生代支付:2=代支付含历史单流水回落1=用户自付
component: 'Select',
componentProps: {
allowClear: true,
options: [
@@ -61,11 +88,9 @@ export const formOptions: VbenFormProps = {
label: '付款方式',
},
{
component: 'VbenSelect',
component: 'Select',
componentProps: {
allowClear: true,
filterOption: true,
showSearch: true,
options: [
{
label: '快递到家',
@@ -101,8 +126,9 @@ export const formOptions: VbenFormProps = {
label: '订单类型',
},
{
component: 'VbenInput',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '药品名称/拼音首拼',
},
defaultValue: '',
@@ -110,23 +136,32 @@ export const formOptions: VbenFormProps = {
label: '药品',
},
{
component: 'RangePicker',
// 就诊人筛选:后端 whereHas userPatient 姓名/手机号模糊
component: 'Input',
componentProps: {
format: 'YYYY-MM-DD',
// 提交纯日期,后端 getWhereBetween 会把结束日扩到当天 23:59:59
valueFormat: 'YYYY-MM-DD',
allowClear: true,
placeholder: '姓名/手机号',
},
defaultValue: [
dayjs().startOf('month').format('YYYY-MM-DD'),
dayjs().format('YYYY-MM-DD'),
],
fieldName: 'search_time',
label: '时间范围',
defaultValue: '',
fieldName: 'patient_keyword',
label: '就诊人',
},
{
// 医生筛选:后端 whereHas doctorInfo 姓名/手机号模糊
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '姓名/手机号',
},
defaultValue: '',
fieldName: 'doctor_keyword',
label: '医生',
},
],
showCollapseButton: true,
submitButtonOptions: {
content: '查询',
},
submitOnEnter: false,
// 任意输入框回车即搜索下拉面板打开时的回车只选中选项core 层已处理),第二次回车才搜索
submitOnEnter: true,
};

View File

@@ -2,12 +2,18 @@ import type { VxeGridProps } from '#/adapter/vxe-table';
import { getOrderList } from '#/views/business/order/product-order/api';
/**
* 商品订单列表行类型:后端行字段众多(含关联 store/address/salesperson/items
* 用索引签名放宽,避免模板槽访问动态字段时 vue-tsc 报属性缺失
*/
interface RowType {
id: string;
name: string;
logo: string;
introduce: string;
id: number;
order_no: string;
status: number;
order_type: number;
prescription_type: number;
created_at: string;
[key: string]: any;
}
export const gridOptions: VxeGridProps<RowType> = {
@@ -115,6 +121,8 @@ export const gridOptions: VxeGridProps<RowType> = {
},
border: false,
toolbarConfig: {
// 是否显示搜索表单控制按钮
// @ts-ignore 正式环境时有完整的类型声明
search: true,
refresh: true,
print: false,

View File

@@ -2,18 +2,11 @@
import type { VbenFormProps } from '#/adapter/form';
import type { VxeGridListeners } from '#/adapter/vxe-table';
import { computed, ref } from 'vue';
import { computed, onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import {
AnalysisOverview,
type AnalysisOverviewItem,
Page,
useVbenDrawer,
useVbenModal,
} from '@vben/common-ui';
import { Page, useVbenDrawer, useVbenModal } from '@vben/common-ui';
import { useUserStore } from '@vben/stores';
import { SvgCakeIcon } from '@vben/icons';
import {
Button,
@@ -30,6 +23,9 @@ import {
import dayjs from 'dayjs';
import type { ActionItem } from '#/components/table-action';
import { useVbenForm } from '#/adapter/form';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { TableAction } from '#/components/table-action';
import SensitiveText from '#/components/sensitive-text/SensitiveText.vue';
@@ -67,16 +63,27 @@ import DetailModal from './components/detail.vue';
import OrderUserInfoCell from './components/cells/OrderUserInfoCell.vue';
import LogisticsModal from './components/logistics-modal.vue';
import FormModalDemo from './components/modal.vue';
import ProductOrderCardList from './components/ProductOrderCardList.vue';
import ProductOrderExportModal from './components/ProductOrderExportModal.vue';
import Refund from './components/refund.vue';
import {
ORDER_STATUS_MAP,
ORDER_TYPE_MAP,
PRESCRIPTION_TYPE_MAP,
} from './config/constants';
import { formOptions } from './config/search';
import { gridOptions } from './config/table';
import { useViewMode, ViewModeSwitch } from '#/components/view-mode-switch';
import { StatIslands, type StatIslandItem } from '#/components/stat-islands';
import { isPlatformSuperAdmin } from '#/views/system/admin/_shared/platform-admin-role';
const router = useRouter();
const route = useRoute();
const userStore = useUserStore();
const canViewInviterCommission = isPlatformSuperAdmin(userStore.userInfo);
// vben 登录态 roles 为 string[] 与本地 PlatformAdminUserInfo 结构不同,仅取 role_id 判断,直接断言
const canViewInviterCommission = isPlatformSuperAdmin(
userStore.userInfo as Parameters<typeof isPlatformSuperAdmin>[0],
);
const hasTopTableDropDownActions = ref(false);
@@ -149,7 +156,14 @@ function buildFormOptionsFromRoute(): VbenFormProps {
}
if (item.fieldName === 'search_time') {
if (timeScope === 'month') {
return { ...item, defaultValue: [dayjs().startOf('month'), dayjs()] };
// 与 RangePicker valueFormat 保持一致用字符串defaultValue 会直接进入首屏搜索条件
return {
...item,
defaultValue: [
dayjs().startOf('month').format('YYYY-MM-DD'),
dayjs().format('YYYY-MM-DD'),
],
};
}
if (timeScope === 'all') {
return { ...item, defaultValue: null };
@@ -160,31 +174,118 @@ function buildFormOptionsFromRoute(): VbenFormProps {
return { ...formOptions, schema };
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: buildFormOptionsFromRoute(),
gridOptions,
gridEvents,
/** 列表/卡片双视图:通用 useViewMode 自带 localStorage 记忆 */
const viewMode = useViewMode('product-order-view-mode');
const cardListRef = ref<InstanceType<typeof ProductOrderCardList> | null>(null);
/** 生效的搜索表单配置(含路由 store_id/time_scope 默认值注入) */
const effectiveFormOptions = buildFormOptionsFromRoute();
/**
* 从 schema 提取 defaultValue 作为首屏搜索条件
* 列表首屏 query 与卡片首屏 reload 都要带默认时间范围,否则查询口径与表单显示不一致
*/
function collectDefaultSearchValues(): Record<string, any> {
const values: Record<string, any> = {};
for (const item of effectiveFormOptions.schema ?? []) {
if (item.fieldName && item.defaultValue !== undefined) {
values[item.fieldName] = item.defaultValue;
}
}
return values;
}
/** 列表与卡片共用的搜索条件(提交/重置时整体替换,卡片靠 watch 自动拉数) */
const searchValues = ref<Record<string, any>>(collectDefaultSearchValues());
/**
* 顶部统一搜索表单(从 grid 内嵌表单抽出,双视图共用)
* 列表模式提交后 reload 回第 1 页;卡片模式改 searchValues 由 CardList watch 拉数
*/
const [SearchForm, searchFormApi] = useVbenForm({
...effectiveFormOptions,
handleSubmit: async (values) => {
searchValues.value = { ...(values || {}) };
if (viewMode.value === 'list') {
gridApi.reload();
} else {
// 卡片视图数据由 formValues watch 驱动,这里只需同步顶部销售统计
saleAmount(searchValues.value);
}
},
handleReset: async () => {
// 自定义 handleReset 会接管默认行为,必须手动还原控件到 defaultValuereset 为新 APIresetForm 已弃用)
await searchFormApi.reset();
searchValues.value = collectDefaultSearchValues();
if (viewMode.value === 'list') {
gridApi.reload();
} else {
saleAmount(searchValues.value);
}
},
});
const initTableAjax = () => {
gridApi.setGridOptions({
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions: {
...gridOptions,
// 搜索已抽出为顶部统一表单,关闭 grid 自带的表单开关按钮
toolbarConfig: { ...gridOptions.toolbarConfig, search: false },
proxyConfig: {
ajax: {
// 请求后端接口方法
query: async ({ page }, formValues) => {
saleAmount(formValues);
/** 始终合并顶部统一搜索条件;同时刷新顶部销售金额统计 */
query: async ({ page }: { page: { currentPage: number; pageSize: number } }) => {
saleAmount(searchValues.value);
return await getOrderList({
page: page.currentPage,
pageSize: page.pageSize,
...formValues,
...searchValues.value,
});
},
},
},
});
},
gridEvents,
});
// 记忆持久化由 useViewMode 内部完成;这里只处理切回列表时 grid 被 v-if 卸载过需重查
watch(viewMode, (mode, prev) => {
if (mode === 'list' && prev === 'card') {
gridApi.query();
}
});
/** 操作成功后的视图刷新:列表保当前页,卡片原地重拉已加载范围 */
async function refreshCurrentView() {
await (viewMode.value === 'list'
? gridApi.query()
: cardListRef.value?.refreshKeepPage?.());
}
/** 回第 1 页的强刷新(弹窗内部 reload 语义) */
function refreshCurrentViewFromPageOne() {
if (viewMode.value === 'list') {
gridApi.reload();
} else {
cardListRef.value?.reload?.();
}
}
/**
* 传给弹窗子组件的 gridApi 代理
* 发货/退款等弹窗内部只会调 reload()/query() 刷新列表,
* 卡片模式下需要转发给卡片列表,故不能直接把 gridApi 传进去
*/
const gridApiProxy = {
reload: () => refreshCurrentViewFromPageOne(),
query: () => refreshCurrentView(),
};
initTableAjax();
// 首屏是卡片模式时 grid 不渲染ajax query 不会触发),顶部销售统计需要手动拉一次
onMounted(() => {
if (viewMode.value === 'card') {
saleAmount(searchValues.value);
}
});
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: FormModalDemo,
});
@@ -208,7 +309,7 @@ const infoModal = (data: Record<string, any> = {}) => {
// 带 id 走详情接口拿到 packagesvalues 作首屏兜底
id: data?.id,
values: data,
gridApi,
gridApi: gridApiProxy,
// 详情内「发本包裹」:关详情后打开发货弹窗(仅平台包会传 0
onShipPackage: (warehouseId: number) => {
modalApi.close();
@@ -248,7 +349,7 @@ const wareSend = (data: Record<string, any> = {}, warehouseId?: number) => {
}
formModalApi.setData({
values,
gridApi,
gridApi: gridApiProxy,
});
formModalApi.open();
};
@@ -259,12 +360,12 @@ const expandAll = () => {
const collapseAll = () => {
gridApi.grid?.setAllRowExpand(false);
};
const overviewItems = ref<AnalysisOverviewItem[]>([]);
const overviewItems = ref<StatIslandItem[]>([]);
const income = ref(0);
const total = ref(0);
/** 按当前列表筛选条件刷新顶部销售金额/收益(支持门店多选 store_ids */
/** 按当前筛选条件刷新顶部销售金额/收益(支持门店多选 store_ids,双视图共用 */
const saleAmount = (formValues?: Record<string, any>) => {
const values = formValues ?? gridApi.formApi.latestSubmissionValues ?? {};
const values = formValues ?? searchValues.value ?? {};
saleAmountApi({
search_time: values.search_time,
store_ids: values.store_ids,
@@ -274,20 +375,20 @@ const saleAmount = (formValues?: Record<string, any>) => {
}).then((res) => {
income.value = res.income;
total.value = res.total;
// 紧凑统计岛(设计对齐提现管理),替代原 AnalysisOverview 大卡
overviewItems.value = [
{
icon: SvgCakeIcon,
title: '销售金额',
totalTitle: '销售金额',
totalValue: total.value,
key: 'total',
label: '销售金额',
value: total.value,
icon: 'lucide:trending-up',
},
{
icon: 'fluent-emoji:balance-scale',
title: '我的收益',
totalTitle: '我的收益',
totalValue: income.value,
key: 'income',
label: '我的收益',
value: income.value,
icon: 'lucide:wallet',
emphasize: true,
},
];
});
@@ -413,7 +514,7 @@ async function openOrderPercentAdjust(row: Record<string, any>) {
productOrderId: row.id,
quickOptions: normalizeQuickOptions(priceAdjustMeta.value.quickOptions),
onSuccess: () => {
gridApi.reload();
refreshCurrentViewFromPageOne();
},
});
percentAdjustDrawerApi.open();
@@ -446,7 +547,7 @@ function handleSimulatePay(row: Record<string, any>) {
onOk: async () => {
await simulatePayApi({ order_type: 'product', order_id: row.id });
message.success('模拟支付成功');
await gridApi.query();
await refreshCurrentView();
},
});
}
@@ -469,7 +570,7 @@ function handleRetryConfirmReceive(row: Record<string, any>) {
message.success(
res?.message || (isWaitReceive ? '确认收货成功' : '解冻重试成功'),
);
await gridApi.query();
await refreshCurrentView();
},
});
}
@@ -485,7 +586,7 @@ async function handleAccrueSalesperson(row: Record<string, any>) {
try {
await accrueSalespersonCommissionApi({ order_id: row.id });
message.success('分成成功');
await gridApi.query();
await refreshCurrentView();
} catch (error: any) {
message.error(error?.message || '分成失败');
} finally {
@@ -507,7 +608,7 @@ async function handleReverseSalesperson(row: Record<string, any>, salespersonId?
salesperson_id: spId,
});
message.success('退回分成成功');
await gridApi.query();
await refreshCurrentView();
} catch (error: any) {
message.error(error?.message || '退回分成失败');
} finally {
@@ -546,7 +647,7 @@ function formatCommissionRecordDetail(record: Record<string, any>) {
return `${typeText}${path} · ${record.commission_mode_text || '固定单价'} · ${rule}`;
}
const openPrescriptionDetail = (values) => {
const openPrescriptionDetail = (values: number) => {
// 打开西药处方模态框逻辑
PrescriptionDetailModalApi.setData({
values,
@@ -561,12 +662,12 @@ const openExportModal = () => {
exportModalApi.open();
};
const openRefundModal = (id) => {
const openRefundModal = (id: number) => {
RefundModalApi.setData({
values: {
order_id: id,
},
gridApi,
gridApi: gridApiProxy,
});
RefundModalApi.open();
};
@@ -576,7 +677,7 @@ async function handleCancelOrder(row: Record<string, any>) {
try {
await cancelOrderApi({ order_id: row.id });
message.success('订单已取消');
await gridApi.query();
await refreshCurrentView();
} catch (error: any) {
message.error(error?.message || '取消失败');
}
@@ -741,7 +842,7 @@ async function submitChangeWarehouse() {
});
message.success(`改仓成功(${items.length} 种药品)`);
changeWhOpen.value = false;
await gridApi.query();
await refreshCurrentView();
} catch (error: any) {
message.error(error?.message || '改仓失败');
} finally {
@@ -800,10 +901,119 @@ const fetchOrderAmountVerify = async () => {
}
};
const openOrderAmountVerify = () => {
verifyMismatchOnly.value = false;
fetchOrderAmountVerify();
};
// 金额核对入口暂时下线(工具栏按钮已注释),恢复入口时一并取消本函数注释
// const openOrderAmountVerify = () => {
// verifyMismatchOnly.value = false;
// fetchOrderAmountVerify();
// };
/**
* 列表槽用元数据取值 helperv-if 双重索引无法类型收窄,统一在这里兜底
*/
function rowPrescriptionTypeMeta(row: Record<string, any>) {
return PRESCRIPTION_TYPE_MAP[Number(row?.prescription_type)] ?? null;
}
function rowStatusMeta(row: Record<string, any>) {
return ORDER_STATUS_MAP[Number(row?.status)] ?? null;
}
/**
* 构造行操作(列表 #action 槽与卡片 footer 共用同一份定义)
* 返回 TableAction 的 actions/dropDownActions保证双视图按钮、权限auth、显隐条件完全一致
*/
function buildRowActions(row: Record<string, any>): {
actions: ActionItem[];
dropDownActions: ActionItem[];
} {
return {
actions: [
{
label: '详情',
type: 'link',
icon: 'marketeq:eye',
size: 'small',
onClick: infoModal.bind(null, row),
},
{
label: '物流动态',
type: 'link',
icon: 'mdi:truck-delivery-outline',
size: 'small',
// 上门快递且已支付、非取消/待支付:可单独查看物流
ifShow:
row.delivery_method === 0 &&
row.is_pay === 1 &&
row.status !== 0 &&
row.status !== 9,
onClick: openLogisticsModal.bind(null, row),
},
{
label: '溯源',
type: 'link',
icon: 'mdi:timeline-text',
size: 'small',
onClick: openOrderTrace.bind(null, row),
},
{
label: '处方',
type: 'link',
icon: 'marketeq:eye',
ifShow: row.order_type !== 2 && row.order_type !== 3,
onClick: openPrescriptionDetail.bind(null, row.p_id),
},
{
label: '模拟支付',
type: 'link',
icon: 'mdi:cash-check',
auth: ['Super Admin'],
ifShow: row.is_pay === 0,
onClick: handleSimulatePay.bind(null, row),
},
{
// 仅超管:待收货确认收货 / 已确认收货重试解冻
label: Number(row.status) === 2 ? '确认收货' : '重试解冻',
type: 'link',
icon: 'mdi:package-check',
auth: ['Super Admin'],
ifShow:
Number(row.is_pay) === 1 &&
Number(row.cancel_status) === 0 &&
Number(row.refund_status) === 0 &&
(Number(row.status) === 2 || Number(row.status) === 7),
onClick: handleRetryConfirmReceive.bind(null, row),
},
{
label: '发货',
type: 'link',
icon: 'ri:send-plane-fill',
auth: ['Super Admin', 'Admin'],
onClick: () => wareSend(row),
},
],
dropDownActions: [
{
label: '取消订单',
type: 'link',
icon: 'mdi:cancel',
auth: ['Super Admin', 'Admin'],
ifShow:
row.status === 0 && row.is_pay === 0 && row.cancel_status === 0,
popConfirm: {
title: '确定取消该待支付订单吗?',
confirm: handleCancelOrder.bind(null, row),
},
},
{
label: '退款',
type: 'link',
icon: 'mingcute:refund-dollar-fill',
auth: ['Super Admin', 'Admin'],
ifShow: row.is_pay === 1,
onClick: openRefundModal.bind(null, row.id),
},
],
};
}
</script>
<template>
@@ -866,9 +1076,21 @@ const openOrderAmountVerify = () => {
<ExportModal />
<PrescriptionDetailModal />
<TraceDrawer />
<ChinaErpSyncDrawer @synced="() => gridApi.query()" />
<AnalysisOverview :items="overviewItems" />
<Grid>
<ChinaErpSyncDrawer @synced="() => refreshCurrentView()" />
<!-- 紧凑统计岛一行 4 仅占 2 设计对齐提现管理账户卡统计岛 -->
<StatIslands class="mb-3" :items="overviewItems" :columns="4" />
<!-- 顶部统一搜索双视图共用替代原 grid 内嵌表单 -->
<div class="mb-3 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--card,var(--background)))] p-3">
<SearchForm />
</div>
<!-- 视图切换列表 / 卡片看板卡片模式下补充导出入口列表模式导出在表格工具栏 -->
<div class="mb-3 flex items-center justify-between gap-3">
<ViewModeSwitch v-model="viewMode" />
<Button v-if="viewMode === 'card'" type="primary" @click="openExportModal">
导出
</Button>
</div>
<Grid v-if="viewMode === 'list'">
<template #toolbar-actions>
<TableAction
:flex="false"
@@ -1230,7 +1452,7 @@ const openOrderAmountVerify = () => {
:checked="row.is_free_shipping === 1"
checked-children="包邮"
un-checked-children="不包邮"
@change="(checked) => toggleFreeShipping(row, checked)"
@change="(checked: any) => toggleFreeShipping(row, Boolean(checked))"
/>
</template>
<template #is-online="{ row }">
@@ -1240,37 +1462,15 @@ const openOrderAmountVerify = () => {
<Tag v-else color="default">线下就诊</Tag>
</template>
<template #delivery-method="{ row }">
<!-- 判断是否为非处方订单-->
<!-- 订单类型非处方订单order_type=2显示订单类型其余按处方类型全量映射含特色方 0 -->
<div v-if="row.order_type === 2">
<Tag v-if="row.order_type === 1" color="yellow">处方订单</Tag>
<Tag v-else-if="row.order_type === 2" color="yellow">
预约购药订单
</Tag>
<Tag v-else-if="row.order_type === 3" color="yellow">
商城处方订单
<Tag color="yellow">
{{ ORDER_TYPE_MAP[row.order_type]?.label || '预约购药订单' }}
</Tag>
</div>
<div v-else>
<Tag v-if="row.prescription_type === 1" color="orange">
处方订单【中药
</Tag>
<Tag v-else-if="row.prescription_type === 2" color="blue">
处方订单【西药】
</Tag>
<Tag v-else-if="row.prescription_type === 3" color="purple">
处方订单【保健食品】
</Tag>
<Tag v-else-if="row.prescription_type === 4" color="green">
处方订单【西药】
</Tag>
<Tag v-else-if="row.prescription_type === 5" color="pink">
处方订单【产品服务包】
</Tag>
<Tag v-else-if="row.prescription_type === 6" color="cyan">
处方订单【非药品】
</Tag>
<Tag v-else-if="row.prescription_type === 7" color="geekblue">
处方订单【医疗器械】
<Tag v-if="rowPrescriptionTypeMeta(row)" :color="rowPrescriptionTypeMeta(row)!.color">
处方订单【{{ rowPrescriptionTypeMeta(row)!.label }}
</Tag>
</div>
<div class="mt-3">
@@ -1280,114 +1480,17 @@ const openOrderAmountVerify = () => {
</Tag>
</div>
<div class="mt-3">
<Tag v-if="row.status === 0" color="red">待支付</Tag>
<Tag v-else-if="row.status === 1" color="orange">待发货</Tag>
<Tag v-else-if="row.status === 2" color="blue">待收货</Tag>
<Tag v-else-if="row.status === 3" color="purple">待评价</Tag>
<Tag v-else-if="row.status === 4" color="green">已退款</Tag>
<Tag v-else-if="row.status === 5" color="pink">退款中</Tag>
<Tag v-else-if="row.status === 6" color="cyan">已收货</Tag>
<Tag v-else-if="row.status === 7" color="green">确认收货</Tag>
<Tag v-else-if="row.status === 8" color="#B22222">拒绝退款</Tag>
<Tag v-else-if="row.status === 9" color="gray">已取消</Tag>
<Tag v-if="rowStatusMeta(row)" :color="rowStatusMeta(row)!.color">
{{ rowStatusMeta(row)!.label }}
</Tag>
</div>
</template>
<template #toolbar-tools></template>
<template #action="{ row }">
<!-- 行操作与卡片视图共用 buildRowActions双视图按钮/权限/显隐保持一致 -->
<TableAction
:actions="[
{
label: '详情',
type: 'link',
icon: 'marketeq:eye',
size: 'small',
// auth: ['order', 'sys:role:detail'],
onClick: infoModal.bind(null, row),
},
{
label: '物流动态',
type: 'link',
icon: 'mdi:truck-delivery-outline',
size: 'small',
// 上门快递且已支付、非取消/待支付:可单独查看物流
ifShow:
row.delivery_method === 0 &&
row.is_pay === 1 &&
row.status !== 0 &&
row.status !== 9,
onClick: openLogisticsModal.bind(null, row),
},
{
label: '溯源',
type: 'link',
icon: 'mdi:timeline-text',
size: 'small',
onClick: openOrderTrace.bind(null, row),
},
{
label: '处方',
type: 'link',
icon: 'marketeq:eye',
ifShow: row.order_type !== 2 && row.order_type !== 3,
// auth: ['超级订单', 'sys:user:save'],
onClick: openPrescriptionDetail.bind(null, row.p_id),
},
{
label: '模拟支付',
type: 'link',
icon: 'mdi:cash-check',
auth: ['Super Admin'],
ifShow: row.is_pay === 0,
onClick: handleSimulatePay.bind(null, row),
},
{
// 仅超管:待收货确认收货 / 已确认收货重试解冻
label: Number(row.status) === 2 ? '确认收货' : '重试解冻',
type: 'link',
icon: 'mdi:package-check',
auth: ['Super Admin'],
ifShow:
Number(row.is_pay) === 1 &&
Number(row.cancel_status) === 0 &&
Number(row.refund_status) === 0 &&
(Number(row.status) === 2 || Number(row.status) === 7),
onClick: handleRetryConfirmReceive.bind(null, row),
},
{
label: '发货',
type: 'link',
icon: 'ri:send-plane-fill',
auth: ['Super Admin','Admin'],
onClick: () => wareSend(row),
},
]"
:drop-down-actions="[
{
label: '取消订单',
type: 'link',
icon: 'mdi:cancel',
auth: ['Super Admin', 'Admin'],
ifShow:
row.status === 0 && row.is_pay === 0 && row.cancel_status === 0,
popConfirm: {
title: '确定取消该待支付订单吗?',
confirm: handleCancelOrder.bind(null, row),
},
},
{
label: '退款',
type: 'link',
icon: 'mingcute:refund-dollar-fill',
auth: ['Super Admin','Admin'],
// auth: ['超级订单', 'sys:user:save'],
ifShow: row.is_pay === 1,
onClick: openRefundModal.bind(null, row.id),
// popConfirm: {
// title: '确定发货吗?',
// confirm: wareSend.bind(null, row),
// },
},
]"
:actions="buildRowActions(row).actions"
:drop-down-actions="buildRowActions(row).dropDownActions"
/>
</template>
@@ -1415,7 +1518,7 @@ const openOrderAmountVerify = () => {
</div>
<div class="ml-3 mt-5 text-sm">
<span class="text-gray-500 dark:text-gray-400">商品编号:</span>
X00{{ index + 1 }}
X00{{ Number(index) + 1 }}
</div>
</div>
<div class="flex-grow space-y-2">
@@ -1495,6 +1598,23 @@ const openOrderAmountVerify = () => {
</div>
</template>
</Grid>
<!-- 卡片看板视图:展示内容与列表一致,行操作/弹窗全部复用父级逻辑 -->
<div v-else class="po-card-wrap">
<ProductOrderCardList
ref="cardListRef"
:form-values="searchValues"
:build-row-actions="buildRowActions"
:can-percent-adjust="canRowPercentAdjust"
@open-store="openStoreCard"
@open-doctor="showOrderDoctorCard"
@open-user-patients="openOrderUserPatients"
@open-patient="openOrderPatient"
@open-warehouse="openChangeWarehouse"
@toggle-free-shipping="toggleFreeShipping"
@percent-adjust="openOrderPercentAdjust"
@go-register="goRegisterOrder"
/>
</div>
<PercentAdjustDrawer />
<!-- 订单改仓:按药分别选仓 -->
<AntdModal
@@ -1582,4 +1702,10 @@ const openOrderAmountVerify = () => {
.tcm-drug-chip {
box-shadow: 0 1px 2px rgb(0 0 0 / 4%);
}
/* 卡片看板容器撑满剩余高度内部滚动统计岛 + 收起态搜索一行 + 切换行约 220pxmin-height 兜底 */
.po-card-wrap {
height: calc(100% - 220px);
min-height: 420px;
}
</style>

View File

@@ -24,6 +24,15 @@ export interface ExportProductOrderExcelOptions {
filters: OrderExportFilterInfo;
granularity: ExportGranularity;
exportedAt?: Date;
/**
* 附加「订单商品明细」工作簿(订单维度导出时可选)
* rows 为 item 粒度数据,按 order_no 分组渲染:组头行(订单概要)+ 组内商品明细行
*/
detailSheet?: {
rows: Record<string, unknown>[];
selectedFields: string[];
fieldMeta: ExportFieldMeta[];
};
}
const THIN_BORDER: Partial<ExcelJS.Borders> = {
@@ -224,6 +233,7 @@ export async function exportProductOrderExcel(
filters,
granularity,
exportedAt = new Date(),
detailSheet,
} = options;
const columns = selectedFields
@@ -386,9 +396,172 @@ export async function exportProductOrderExcel(
fitToHeight: 0,
};
// 附加「订单商品明细」工作簿:按订单分组展示每单商品
if (detailSheet && detailSheet.rows.length > 0) {
renderOrderDetailGroupSheet(workbook, detailSheet, exportedAt);
}
return workbook.xlsx.writeBuffer();
}
/**
* 渲染按订单分组的商品明细工作簿
*
* 结构:标题行 → 表头行 → [订单组头行 + 该订单全部商品明细行] 循环 → 整表汇总行
* 组头行合并整行,汇总该订单的商品件数与订单总价,用浅蓝底与明细行区分,
* 方便财务/运营核对「一单多品」时逐单核对,而不用在扁平明细里肉眼找订单边界。
*/
function renderOrderDetailGroupSheet(
workbook: ExcelJS.Workbook,
detailSheet: NonNullable<ExportProductOrderExcelOptions['detailSheet']>,
exportedAt: Date,
) {
const columns = detailSheet.selectedFields
.map((key) => detailSheet.fieldMeta.find((f) => f.key === key))
.filter((f): f is ExportFieldMeta => !!f);
if (columns.length === 0) return;
const sheet = workbook.addWorksheet('订单商品明细', {
views: [{ showGridLines: true }],
});
const colCount = columns.length;
// Row 1标题
sheet.mergeCells(1, 1, 1, colCount);
const titleCell = sheet.getCell(1, 1);
titleCell.value = `萧康云医 · 订单商品明细(按订单分组) 导出时间:${formatDateTime(exportedAt)}`;
titleCell.font = { bold: true, size: 13, color: { argb: 'FF1F4E79' } };
titleCell.fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FFF5F7FA' },
};
titleCell.alignment = { horizontal: 'center', vertical: 'middle' };
titleCell.border = THIN_BORDER;
sheet.getRow(1).height = 30;
// Row 2表头
const headerRowNum = 2;
columns.forEach((meta, i) => {
const cell = sheet.getCell(headerRowNum, i + 1);
cell.value = meta.label;
cell.font = { bold: true, color: { argb: 'FF000000' } };
cell.fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FFFFFF00' },
};
cell.alignment = { horizontal: 'center', vertical: 'middle' };
cell.border = THIN_BORDER;
});
sheet.getRow(headerRowNum).height = 26;
// 按 order_no 保序分组(后端按订单遍历输出明细行,顺序天然聚集)
const groups = new Map<string, Record<string, unknown>[]>();
for (const row of detailSheet.rows) {
const key = String(row.order_no ?? '');
const list = groups.get(key);
if (list) {
list.push(row);
} else {
groups.set(key, [row]);
}
}
let r = headerRowNum + 1;
groups.forEach((groupRows, orderNo) => {
const first = groupRows[0] ?? {};
// 组头行:订单概要(订单号/医生/状态/件数/订单总价)
sheet.mergeCells(r, 1, r, colCount);
const headCell = sheet.getCell(r, 1);
const pieces = [
`订单 ${orderNo || '—'}`,
first.store_name ? `门店 ${first.store_name}` : '',
first.doctor_name ? `医生 ${first.doctor_name}` : '',
first.status_text ? `状态 ${first.status_text}` : '',
`${groupRows.length} 件商品`,
first.total_pay_price != null && first.total_pay_price !== ''
? `订单总价 ¥${first.total_pay_price}`
: '',
].filter(Boolean);
headCell.value = pieces.join(' ');
headCell.font = { bold: true, size: 10 };
headCell.fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FFE8F0FE' },
};
headCell.alignment = { horizontal: 'left', vertical: 'middle' };
headCell.border = THIN_BORDER;
sheet.getRow(r).height = 22;
r += 1;
// 组内商品明细行
groupRows.forEach((row) => {
const excelRow = sheet.getRow(r);
columns.forEach((meta, colIdx) => {
const cell = excelRow.getCell(colIdx + 1);
cell.value = cellValue(row, meta.key, meta.format) as ExcelJS.CellValue;
applyCellFormat(cell, meta);
});
excelRow.height = 20;
r += 1;
});
});
const dataEndRow = r - 1;
// 整表汇总行:对 summary 字段(如行金额)求和
const summaryFields = columns.filter((c) => c.summary);
if (summaryFields.length > 0 && detailSheet.rows.length > 0) {
const summaryRowNum = r + 1;
columns.forEach((meta, colIdx) => {
const cell = sheet.getCell(summaryRowNum, colIdx + 1);
cell.border = {
...THIN_BORDER,
top: { style: 'double', color: { argb: 'FF000000' } },
};
cell.fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FFFFF9E6' },
};
cell.font = { bold: true };
if (colIdx === 0) {
cell.value = `汇总(${groups.size} 单 / ${detailSheet.rows.length} 件)`;
} else if (meta.summary) {
const sum = detailSheet.rows.reduce((acc, row) => {
const n = Number(row[meta.key]);
return acc + (Number.isFinite(n) ? n : 0);
}, 0);
cell.value = sum;
applyCellFormat(cell, meta);
}
});
sheet.getRow(summaryRowNum).height = 24;
}
autoFitColumns(sheet, colCount, columns, headerRowNum, dataEndRow);
// 冻结标题 + 表头两行,滚动时保持列名可见
sheet.views = [
{
state: 'frozen',
xSplit: 0,
ySplit: headerRowNum,
topLeftCell: `A${headerRowNum + 1}`,
activeCell: `A${headerRowNum + 1}`,
},
];
sheet.pageSetup = {
orientation: 'landscape',
fitToPage: true,
fitToWidth: 1,
fitToHeight: 0,
};
}
function formatDateTime(d: Date): string {
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;

View File

@@ -13,12 +13,19 @@ export interface RegisterOrderItem {
refund_status?: number;
refund_time?: string;
created_at: string;
doctor_info?: { name: string; depart?: { name: string } };
user_patient?: { name: string };
store?: { name: string };
store_id?: number;
doctor_info?: {
avatar?: string;
depart?: { name: string };
name: string;
};
user_patient?: { age?: number; mobile?: string; name: string; sex?: number };
store?: { id?: number; mobile?: string; name: string; position?: string };
salesperson_id?: number;
salesperson?: { id: number; nick_name: string; avatar?: string };
salesperson?: { avatar?: string; id: number; nick_name: string };
prescription?: { id: number; prescription_no: string }[];
/** 详情弹窗使用的支付/取消/拒诊等扩展字段,后端 selectField 全量返回 */
[key: string]: any;
}
export async function getRegisterListApi(data: Record<string, unknown>) {

View File

@@ -0,0 +1,397 @@
<script lang="ts" setup>
/**
* 挂号订单详情弹窗App 风格分组卡片)
*
* 数据来源:列表行数据(后端 list 已带 userPatient/user/store/doctorInfo.depart 关联),
* 无需再请求详情接口。父级通过 modalApi.setData({ row }) 传入。
*
* 展示分组:挂号信息 / 就诊人 / 下单用户 / 诊所 / 医生 / 价格与支付 / 关联处方 / 推广员
*/
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Avatar } from 'ant-design-vue';
import SensitiveText from '#/components/sensitive-text/SensitiveText.vue';
import { pillStyle, tagColorHex } from '#/utils/tagColor';
import {
REGISTER_PAY_TYPE_MAP,
REGISTER_REFUND_STATUS_MAP,
REGISTER_STATUS_MAP,
REGISTER_TYPE_MAP,
} from '../config/constants';
const emit = defineEmits<{
/** 点击处方号:由父级打开已有的处方详情弹窗 */
openPrescription: [prescriptionId: number];
/** 点击诊所名:由父级打开诊所卡片弹窗 */
openStore: [storeId: number];
}>();
const row = ref<Record<string, any>>({});
const [Modal, modalApi] = useVbenModal({
onOpenChange(isOpen) {
if (isOpen) {
// 本项目 vben 版本 getData 无泛型签名,取值后显式断言
const data = modalApi.getData() as Record<string, any> | undefined;
row.value = data?.row ?? {};
}
},
});
const typeMeta = computed(
() => REGISTER_TYPE_MAP[Number(row.value?.type)] ?? { label: '未知', color: 'default' },
);
const statusMeta = computed(
() => REGISTER_STATUS_MAP[Number(row.value?.status)] ?? { label: '未知', color: 'default' },
);
const refundMeta = computed(
() =>
REGISTER_REFUND_STATUS_MAP[Number(row.value?.refund_status)] ?? {
label: '未退款',
color: 'default',
},
);
/** 性别文案yii_user_patient.sex0默认 1男 2女 */
const patientSex = computed(() => {
const sex = Number(row.value?.userPatient?.sex ?? row.value?.user_patient?.sex);
if (sex === 1) return '男';
if (sex === 2) return '女';
return '—';
});
/** 就诊人/用户/诊所/医生关联兼容 snake_case 序列化Laravel toArray 默认蛇形) */
const patient = computed(() => row.value?.userPatient ?? row.value?.user_patient ?? {});
const orderUser = computed(() => row.value?.user ?? {});
const store = computed(() => row.value?.store ?? {});
const doctor = computed(() => row.value?.doctorInfo ?? row.value?.doctor_info ?? {});
const departName = computed(() => doctor.value?.depart?.name || '—');
const prescriptions = computed(() =>
Array.isArray(row.value?.prescription) ? row.value.prescription : [],
);
const salesperson = computed(() => row.value?.salesperson ?? null);
/** 金额格式化:非法值显示占位符 */
function money(value: unknown) {
const n = Number(value);
return Number.isFinite(n) ? `¥${n.toFixed(2)}` : '—';
}
</script>
<template>
<Modal title="挂号详情" class="w-[640px]" :footer="false">
<div class="rd-body">
<!-- 顶部概要订单号 + 状态/方式 -->
<div class="rd-hero">
<div class="min-w-0">
<div class="rd-hero__no">{{ row.order_no || '—' }}</div>
<div class="rd-hero__time">下单时间{{ row.created_at || '—' }}</div>
</div>
<div class="flex shrink-0 items-center gap-1.5">
<span class="rd-pill" :style="pillStyle(typeMeta.color)">{{ typeMeta.label }}</span>
<span class="rd-status" :style="{ color: tagColorHex(statusMeta.color) }">
<i class="rd-status__dot" :style="{ background: tagColorHex(statusMeta.color) }"></i>
{{ statusMeta.label }}
</span>
</div>
</div>
<!-- 就诊人信息 -->
<div class="rd-section">
<div class="rd-section__title">就诊人信息</div>
<div class="rd-grid">
<div class="rd-item">
<span class="rd-item__label">姓名</span>
<span class="rd-item__value">{{ patient.name || '—' }}</span>
</div>
<div class="rd-item">
<span class="rd-item__label">性别</span>
<span class="rd-item__value">{{ patientSex }}</span>
</div>
<div class="rd-item">
<span class="rd-item__label">年龄</span>
<span class="rd-item__value">{{ patient.age ?? '—' }}</span>
</div>
<div class="rd-item">
<span class="rd-item__label">手机号</span>
<span class="rd-item__value">
<!-- 就诊人手机为敏感字段统一走 SensitiveText 脱敏/授权查看 -->
<SensitiveText :record="patient" field="mobile" />
</span>
</div>
</div>
</div>
<!-- 下单用户信息 -->
<div class="rd-section">
<div class="rd-section__title">用户信息</div>
<div class="flex items-center gap-3">
<Avatar :src="orderUser.avatarurl || '/img/user-default-avatar.png'" :size="40" />
<div class="min-w-0">
<div class="rd-item__value truncate">{{ orderUser.nickname || '—' }}</div>
<div class="rd-muted">用户 ID{{ row.user_id || '—' }}</div>
</div>
</div>
</div>
<!-- 挂号诊所信息 -->
<div class="rd-section">
<div class="rd-section__title">挂号诊所</div>
<div class="rd-grid rd-grid--single">
<div class="rd-item">
<span class="rd-item__label">诊所</span>
<button
v-if="store.id"
type="button"
class="rd-link"
@click="emit('openStore', Number(store.id))"
>
{{ store.name || '—' }}
</button>
<span v-else class="rd-item__value">{{ store.name || '—' }}</span>
</div>
<div class="rd-item">
<span class="rd-item__label">地址</span>
<span class="rd-item__value">{{ store.position || '—' }}</span>
</div>
<div class="rd-item">
<span class="rd-item__label">电话</span>
<span class="rd-item__value">{{ store.mobile || '—' }}</span>
</div>
</div>
</div>
<!-- 医生信息 -->
<div class="rd-section">
<div class="rd-section__title">医生信息</div>
<div class="flex items-center gap-3">
<Avatar :src="doctor.avatar || '/img/user-default-avatar.png'" :size="40" />
<div class="min-w-0 flex-1">
<div class="rd-item__value truncate">{{ doctor.name || '—' }}</div>
<div class="rd-muted">科室{{ departName }}</div>
</div>
<div class="rd-muted shrink-0">挂号序号 #{{ row.order_number ?? '—' }}</div>
</div>
</div>
<!-- 价格与支付 -->
<div class="rd-section">
<div class="rd-section__title">价格与支付</div>
<div class="rd-grid">
<div class="rd-item">
<span class="rd-item__label">挂号金额</span>
<span class="rd-item__value rd-item__value--price">{{ money(row.price) }}</span>
</div>
<div class="rd-item">
<span class="rd-item__label">支付状态</span>
<span class="rd-pill" :style="pillStyle(row.is_pay === 1 ? 'green' : 'red')">
{{ row.is_pay === 1 ? '已支付' : '未支付' }}
</span>
</div>
<div class="rd-item">
<span class="rd-item__label">支付方式</span>
<span class="rd-item__value">
{{ row.is_pay === 1 ? REGISTER_PAY_TYPE_MAP[Number(row.pay_type)] || '—' : '—' }}
</span>
</div>
<div class="rd-item">
<span class="rd-item__label">支付时间</span>
<span class="rd-item__value">{{ row.pay_time || '未支付' }}</span>
</div>
<div class="rd-item">
<span class="rd-item__label">退款状态</span>
<span class="rd-pill" :style="pillStyle(refundMeta.color)">
{{ refundMeta.label }}
</span>
</div>
<div v-if="Number(row.refund_status) === 3" class="rd-item">
<span class="rd-item__label">退款时间</span>
<span class="rd-item__value">{{ row.refund_time || '—' }}</span>
</div>
</div>
<!-- 取消/拒诊补充信息有才显示 -->
<div v-if="row.cancel_status === 1 || row.refuse_reason" class="rd-extra">
<div v-if="row.cancel_status === 1">
已取消{{ row.cancel_time || '' }}
<template v-if="row.cancel_remark">{{ row.cancel_remark }}</template>
</div>
<div v-if="row.refuse_reason">拒诊原因{{ row.refuse_reason }}</div>
</div>
</div>
<!-- 关联处方有才显示 -->
<div v-if="prescriptions.length" class="rd-section">
<div class="rd-section__title">关联处方</div>
<div class="flex flex-wrap gap-2">
<button
v-for="item in prescriptions"
:key="item.id"
type="button"
class="rd-link rd-link--chip"
@click="emit('openPrescription', Number(item.id))"
>
{{ item.prescription_no }}
</button>
</div>
</div>
<!-- 推广员有才显示 -->
<div v-if="salesperson?.id" class="rd-section">
<div class="rd-section__title">推广员</div>
<div class="flex items-center gap-3">
<Avatar :src="salesperson.avatar || '/img/user-default-avatar.png'" :size="32" />
<span class="rd-item__value">{{ salesperson.nick_name || '—' }}</span>
</div>
</div>
</div>
</Modal>
</template>
<style scoped>
.rd-body {
display: flex;
flex-direction: column;
gap: 12px;
padding-bottom: 4px;
}
/* 顶部概要条Apple 风格:浅灰内嵌块,无边框) */
.rd-hero {
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
background: hsl(var(--muted) / 35%);
border-radius: 14px;
}
.rd-hero__no {
overflow: hidden;
font-size: 15px;
font-weight: 600;
color: hsl(var(--foreground));
text-overflow: ellipsis;
white-space: nowrap;
}
.rd-hero__time {
margin-top: 2px;
font-size: 12px;
color: hsl(var(--muted-foreground));
}
/* 分组块Apple 设置页风格,浅灰内嵌圆角块 + 小节标题(禁止左侧竖色条) */
.rd-section {
padding: 12px 14px;
background: hsl(var(--muted) / 25%);
border-radius: 14px;
}
.rd-section__title {
margin-bottom: 10px;
font-size: 12px;
font-weight: 600;
color: hsl(var(--muted-foreground));
text-transform: uppercase;
letter-spacing: 0.4px;
}
/* 两列信息栅格 */
.rd-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px 16px;
}
.rd-grid--single {
grid-template-columns: 1fr;
}
.rd-item {
display: flex;
gap: 8px;
align-items: baseline;
min-width: 0;
font-size: 13px;
}
.rd-item__label {
flex-shrink: 0;
color: hsl(var(--muted-foreground));
}
.rd-item__value {
min-width: 0;
overflow: hidden;
color: hsl(var(--foreground));
text-overflow: ellipsis;
}
.rd-item__value--price {
font-size: 15px;
font-weight: 700;
}
.rd-muted {
font-size: 12px;
color: hsl(var(--muted-foreground));
}
.rd-link {
padding: 0;
font-size: 13px;
color: hsl(var(--primary));
cursor: pointer;
background: none;
border: none;
}
.rd-link:hover {
text-decoration: underline;
}
.rd-link--chip {
padding: 3px 10px;
background: hsl(var(--primary) / 8%);
border: 1px solid hsl(var(--primary) / 25%);
border-radius: 8px;
}
.rd-link--chip:hover {
text-decoration: none;
box-shadow: 0 0 6px hsl(var(--primary) / 18%);
}
/* tinted 胶囊 / 状态彩点(与订单卡片同一套 Apple 语言) */
.rd-pill {
display: inline-flex;
align-items: center;
padding: 2px 9px;
font-size: 11px;
font-weight: 500;
line-height: 18px;
border-radius: 999px;
}
.rd-status {
display: inline-flex;
gap: 5px;
align-items: center;
font-size: 13px;
font-weight: 600;
}
.rd-status__dot {
width: 7px;
height: 7px;
border-radius: 50%;
}
.rd-extra {
padding: 8px 10px;
margin-top: 10px;
font-size: 12px;
line-height: 1.8;
color: hsl(var(--warning, 38 92% 50%));
background: hsl(var(--warning, 38 92% 50%) / 10%);
border-radius: 8px;
}
</style>

View File

@@ -0,0 +1,568 @@
<script lang="ts" setup>
/**
* 挂号订单卡片看板C 端紧凑排版 + Apple 毛玻璃):与列表视图展示同一批字段,仅排版不同
*
* 与商品/处方订单卡片同一套设计语言:多列自适应网格、毛玻璃 14px 圆角 hairline 卡片、
* 状态彩点、tinted 小胶囊、人员信息内联行、底部 hairline 操作栏。
* 点击卡片主体打开挂号详情弹窗(就诊人/用户/诊所/医生/挂号方式/价格)。
* 数据自取props.formValues 变化时重置到第 1 页,触底自动加载下一页。
*/
import type { ActionItem } from '#/components/table-action';
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { Empty, Image, message, Spin } from 'ant-design-vue';
import { TableAction } from '#/components/table-action';
import { pillStyle, tagColorHex } from '#/utils/tagColor';
import { getRegisterListApi } from '../api';
import {
REGISTER_REFUND_STATUS_MAP,
REGISTER_STATUS_MAP,
REGISTER_TYPE_MAP,
} from '../config/constants';
const props = defineProps<{
/** 顶部统一搜索条件(含 patient_keyword / doctor_keyword / type / is_pay 等) */
formValues?: Record<string, any>;
/** 是否可退款(与列表行操作同一份判定,父级传入保证口径一致) */
canRefund: (row: Record<string, any>) => boolean;
}>();
const emit = defineEmits<{
/** 点卡片打开挂号详情弹窗 */
openDetail: [row: Record<string, any>];
/** 退款(复用父级退款弹窗) */
refund: [registerId: number];
/** 点处方号打开处方详情 */
openPrescription: [prescriptionId: number];
/** 点诊所名打开诊所卡片 */
openStore: [storeId: number];
}>();
const items = ref<any[]>([]);
const page = ref(1);
const pageCount = ref(1);
const loading = ref(false);
const loadingMore = ref(false);
/** 请求序号:只应用最后一次响应,避免快速改筛选时旧结果覆盖新列表 */
let requestSeq = 0;
const scrollEl = ref<HTMLElement | null>(null);
const sentinelEl = ref<HTMLElement | null>(null);
let observer: IntersectionObserver | null = null;
const hasMore = computed(() => page.value < pageCount.value);
/** 挂号方式元数据 */
function typeMeta(row: Record<string, any>) {
return REGISTER_TYPE_MAP[Number(row?.type)] ?? null;
}
/** 挂号状态元数据 */
function statusMeta(row: Record<string, any>) {
return REGISTER_STATUS_MAP[Number(row?.status)] ?? null;
}
/** 退款状态元数据(未退款不显示胶囊,减少噪音) */
function refundMeta(row: Record<string, any>) {
const meta = REGISTER_REFUND_STATUS_MAP[Number(row?.refund_status)];
return meta && Number(row?.refund_status) !== 0 ? meta : null;
}
/** 挂号价格格式化(非法值显示占位符,¥ 符号由模板单独渲染) */
function money(value: unknown) {
const n = Number(value);
return Number.isFinite(n) ? n.toFixed(2) : '—';
}
/** 行操作:退款(显隐与列表一致),通过 emit 复用父级弹窗 */
function buildActions(row: Record<string, any>): ActionItem[] {
return [
{
label: '详情',
type: 'link',
icon: 'marketeq:eye',
onClick: () => emit('openDetail', row),
},
{
label: '退款',
type: 'link',
icon: 'mingcute:refund-dollar-fill',
ifShow: props.canRefund(row),
onClick: () => emit('refund', Number(row.id)),
},
];
}
/**
* 拉取一页数据reset 时清空列表并回到第 1 页
* 用 requestSeq 丢弃过期响应,防止筛选连点时旧条件结果覆盖新条件
*/
async function fetchPage(targetPage: number, reset = false) {
const seq = ++requestSeq;
if (reset) {
loading.value = true;
} else {
loadingMore.value = true;
}
try {
const res: any = await getRegisterListApi({
page: targetPage,
pageSize: 12,
...(props.formValues || {}),
});
if (seq !== requestSeq) return;
items.value = reset ? (res?.items ?? []) : [...items.value, ...(res?.items ?? [])];
page.value = res?.page ?? targetPage;
pageCount.value = res?.page_count ?? 1;
} catch (error) {
if (seq !== requestSeq) return;
console.error(error);
message.error('加载挂号列表失败');
} finally {
if (seq === requestSeq) {
loading.value = false;
loadingMore.value = false;
}
}
}
/** 筛选条件变化:重置回第 1 页 */
async function reload() {
page.value = 1;
items.value = [];
await fetchPage(1, true);
}
/** 操作后原地刷新:按已加载条数一次性重拉,保持滚动位置 */
async function refreshKeepPage() {
const loaded = Math.max(items.value.length, 12);
const seq = ++requestSeq;
loading.value = true;
try {
const res: any = await getRegisterListApi({
page: 1,
pageSize: loaded,
...(props.formValues || {}),
});
if (seq !== requestSeq) return;
items.value = res?.items ?? [];
page.value = Math.ceil((res?.items?.length ?? 0) / 12) || 1;
pageCount.value = Math.ceil((res?.total ?? 0) / 12) || 1;
} catch (error) {
if (seq === requestSeq) console.error(error);
} finally {
if (seq === requestSeq) loading.value = false;
}
}
defineExpose({ reload, refreshKeepPage });
async function loadMore() {
if (!hasMore.value || loading.value || loadingMore.value) return;
await fetchPage(page.value + 1, false);
}
/** 触底哨兵:滚动到底部附近自动加载下一页 */
function setupObserver() {
observer?.disconnect();
if (!sentinelEl.value) return;
observer = new IntersectionObserver(
(entries) => {
if (entries.some((e) => e.isIntersecting)) {
void loadMore();
}
},
{ root: scrollEl.value, rootMargin: '120px' },
);
observer.observe(sentinelEl.value);
}
watch(
() => props.formValues,
() => {
void reload();
},
{ deep: true },
);
onMounted(async () => {
await reload();
await nextTick();
setupObserver();
});
onBeforeUnmount(() => {
observer?.disconnect();
observer = null;
});
</script>
<template>
<div ref="scrollEl" class="rg-card-scroll">
<Spin :spinning="loading">
<div v-if="items.length" class="rg-card-grid">
<div
v-for="row in items"
:key="row.id"
class="rg-card"
role="button"
tabindex="0"
@click="emit('openDetail', row)"
@keydown.enter="emit('openDetail', row)"
>
<!-- 角落径向荧光与提现管理统计岛同款质感装饰 -->
<div class="rg-card__glow" />
<!-- 头部诊所名可点+ 状态彩点 -->
<div class="rg-card__head">
<button
v-if="row.store?.id || row.store_id"
type="button"
class="rg-card__store"
@click.stop="emit('openStore', Number(row.store?.id || row.store_id))"
>
<span class="rg-card__store-name">{{ row.store?.name || '—' }}</span>
<span class="rg-card__store-arrow"></span>
</button>
<span v-else class="rg-card__store-name">{{ row.store?.name || '—' }}</span>
<span
v-if="statusMeta(row)"
class="rg-card__status"
:style="{ color: tagColorHex(statusMeta(row)!.color) }"
>
<i
class="rg-card__status-dot"
:style="{ background: tagColorHex(statusMeta(row)!.color) }"
></i>
{{ statusMeta(row)!.label }}
</span>
</div>
<!-- 订单号 / 创建时间 -->
<div class="rg-card__sub">
<span class="truncate">{{ row.order_no || '—' }}</span>
<span class="rg-card__dot">·</span>
<span class="shrink-0">{{ row.created_at || '—' }}</span>
</div>
<!-- 语义胶囊行挂号方式/支付/退款右侧挂号费金额C 端惯例右上金额 -->
<div class="rg-card__pills">
<span v-if="typeMeta(row)" class="rg-pill" :style="pillStyle(typeMeta(row)!.color)">
{{ typeMeta(row)!.label }}
</span>
<span class="rg-pill" :style="pillStyle(row.is_pay === 1 ? 'green' : 'red')">
{{ row.is_pay === 1 ? '已支付' : '未支付' }}
</span>
<span v-if="refundMeta(row)" class="rg-pill" :style="pillStyle(refundMeta(row)!.color)">
{{ refundMeta(row)!.label }}
</span>
<span class="rg-card__amount-value"><i>¥</i>{{ money(row.price) }}</span>
</div>
<!-- 医生/科室/就诊人/推广员内联小字行省高度 -->
<div class="rg-card__people">
<span class="rg-kv">
<i>医生</i>
<em>{{ row.doctor_info?.name || '—' }}</em>
</span>
<span class="rg-kv">
<i>科室</i>
<em>{{ row.doctor_info?.depart?.name || '—' }}</em>
</span>
<span class="rg-kv">
<i>就诊人</i>
<em>{{ row.user_patient?.name || '—' }}</em>
</span>
<span class="rg-kv">
<i>推广</i>
<em class="rg-card__salesperson">
<Image
v-if="row.salesperson?.id"
:src="row.salesperson.avatar || '/img/user-default-avatar.png'"
:width="16"
:height="16"
:preview="false"
class="shrink-0 rounded-full object-cover"
@click.stop
/>
{{ row.salesperson?.nick_name || '无' }}
</em>
</span>
</div>
<!-- 关联处方有才显示 -->
<div v-if="Array.isArray(row.prescription) && row.prescription.length" class="rg-card__rx">
<button
v-for="item in row.prescription"
:key="item.id"
type="button"
class="rg-card__rx-chip"
@click.stop="emit('openPrescription', Number(item.id))"
>
{{ item.prescription_no }}
</button>
</div>
<!-- 弹性占位高度富余时把 footer 推到底同行等高卡对齐紧凑时保底 8px 间距 -->
<div class="rg-card__spacer"></div>
<!-- 底部操作详情/退款阻止冒泡避免触发卡片点击 -->
<div class="rg-card__footer" @click.stop>
<TableAction :actions="buildActions(row)" :drop-down-actions="[]" />
</div>
</div>
</div>
<Empty v-else-if="!loading" description="暂无挂号订单" />
</Spin>
<div ref="sentinelEl" class="h-8"></div>
<div v-if="loadingMore" class="py-3 text-center text-xs text-[hsl(var(--muted-foreground))]">
加载中
</div>
<div
v-else-if="items.length && !hasMore"
class="py-3 text-center text-xs text-[hsl(var(--muted-foreground))]"
>
没有更多了
</div>
</div>
</template>
<style scoped>
.rg-card-scroll {
height: 100%;
/* 左右留 6px通栏卡片 hover 阴影不被容器裁出切边 */
padding: 4px 6px 20px;
overflow: auto;
}
/* 多列自适应网格:单卡最窄 340px宽屏自动 2-4 列C 端紧凑卡片密度);
min(340px, 100%) 防止窄容器(分屏)时轨道小于容器导致横向滚动 */
.rg-card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(340px, 100%), 1fr));
gap: 10px;
}
/* 玻璃质感紧凑卡片(与提现管理统计岛同款):主色渐变玻璃底 + 12px 圆角 + 1px 边框;整卡可点看详情(禁止左侧竖色条) */
.rg-card {
position: relative;
display: flex;
flex-direction: column;
overflow: hidden;
cursor: pointer;
background: linear-gradient(
135deg,
hsl(var(--primary) / 4%),
hsl(var(--card, var(--background)))
);
backdrop-filter: blur(8px);
border: 1px solid hsl(var(--border));
border-radius: 12px;
transition:
transform 0.2s ease,
box-shadow 0.2s ease,
border-color 0.2s ease;
}
.rg-card:hover {
border-color: hsl(var(--primary) / 40%);
box-shadow:
0 8px 20px hsl(var(--primary) / 14%),
0 2px 6px rgb(0 0 0 / 6%);
transform: translateY(-3px);
}
/* 角落径向荧光右上角淡主色光斑hover 增强 */
.rg-card__glow {
position: absolute;
top: -40px;
right: -40px;
width: 110px;
height: 110px;
pointer-events: none;
background: radial-gradient(circle, hsl(var(--primary) / 18%) 0%, transparent 70%);
opacity: 0.4;
transition: opacity 0.2s ease;
}
.rg-card:hover .rg-card__glow {
opacity: 1;
}
.rg-card__head {
display: flex;
gap: 8px;
align-items: center;
padding: 10px 12px 0;
}
.rg-card__store {
display: inline-flex;
min-width: 0;
align-items: center;
gap: 2px;
padding: 0;
cursor: pointer;
background: none;
border: none;
}
.rg-card__store-name {
overflow: hidden;
font-size: 14px;
font-weight: 600;
letter-spacing: -0.2px;
color: hsl(var(--foreground));
text-overflow: ellipsis;
white-space: nowrap;
}
.rg-card__store:hover .rg-card__store-name {
color: hsl(var(--primary));
}
.rg-card__store-arrow {
flex-shrink: 0;
font-size: 14px;
color: hsl(var(--muted-foreground));
}
.rg-card__status {
display: inline-flex;
flex-shrink: 0;
gap: 4px;
align-items: center;
margin-left: auto;
font-size: 12px;
font-weight: 600;
}
.rg-card__status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
}
.rg-card__sub {
display: flex;
gap: 4px;
align-items: center;
padding: 1px 12px 0;
font-size: 11px;
color: hsl(var(--muted-foreground));
}
.rg-card__dot {
flex-shrink: 0;
}
/* 胶囊行:左胶囊右金额(金额 margin-left:auto 顶到行尾) */
.rg-card__pills {
display: flex;
flex-wrap: wrap;
gap: 4px;
align-items: center;
padding: 6px 12px 0;
}
.rg-pill {
display: inline-flex;
align-items: center;
padding: 1px 7px;
font-size: 10px;
font-weight: 500;
line-height: 16px;
border: none;
border-radius: 999px;
}
/* 医生/科室/就诊人/推广员内联行 */
.rg-card__people {
display: flex;
flex-wrap: wrap;
gap: 2px 14px;
padding: 7px 12px 0;
font-size: 12px;
line-height: 1.6;
}
.rg-kv {
display: inline-flex;
gap: 4px;
align-items: baseline;
min-width: 0;
}
.rg-kv > i {
flex-shrink: 0;
font-size: 11px;
font-style: normal;
color: hsl(var(--muted-foreground) / 75%);
}
.rg-kv > em {
overflow: hidden;
font-style: normal;
font-weight: 500;
color: hsl(var(--foreground));
text-overflow: ellipsis;
white-space: nowrap;
}
.rg-card__salesperson {
display: inline-flex;
gap: 4px;
align-items: center;
}
/* 关联处方 chips */
.rg-card__rx {
display: flex;
flex-wrap: wrap;
gap: 4px;
padding: 6px 12px 0;
}
.rg-card__rx-chip {
padding: 1px 8px;
font-size: 11px;
color: hsl(var(--primary));
cursor: pointer;
background: hsl(var(--primary) / 8%);
border: 1px solid hsl(var(--primary) / 25%);
border-radius: 999px;
transition: box-shadow 0.15s ease;
}
.rg-card__rx-chip:hover {
box-shadow: 0 0 6px hsl(var(--primary) / 18%);
}
/* 挂号费金额胶囊行右侧DIN 等宽数字,与提现管理统计岛同款字体质感) */
.rg-card__amount-value {
margin-left: auto;
font-family: 'DIN Alternate', ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 16px;
font-weight: 700;
letter-spacing: -0.3px;
color: hsl(var(--foreground));
}
.rg-card__amount-value i {
margin-right: 1px;
font-size: 11px;
font-style: normal;
font-weight: 600;
}
/* 弹性占位:吃掉剩余高度让 footer 吸底(同行等高卡操作栏对齐) */
.rg-card__spacer {
flex: 1 1 auto;
min-height: 8px;
}
/* 底部操作栏 */
.rg-card__footer {
display: flex;
justify-content: flex-end;
padding: 4px 6px;
cursor: default;
border-top: 1px solid hsl(var(--border) / 50%);
}
</style>

View File

@@ -0,0 +1,65 @@
/**
* 挂号订单模块字典常量
* 列表 Tag 槽、卡片视图、详情弹窗、搜索下拉共用同一份映射,避免语义漂移
*/
export interface TagMeta {
color: string;
label: string;
}
/** 挂号方式RegisterTypeEnum 口径0线下 1在线问诊 2诊所在线复诊 3药店在线复诊 */
export const REGISTER_TYPE_MAP: Record<number, TagMeta> = {
0: { label: '线下就诊', color: 'purple' },
1: { label: '在线问诊', color: 'blue' },
2: { label: '在线复诊(诊所)', color: 'green' },
3: { label: '在线复诊(药店)', color: 'green' },
};
/** 挂号状态yii_register.status0待支付1待接诊2接诊中3已结束4已取消5待评价6已评价7已拒诊 */
export const REGISTER_STATUS_MAP: Record<number, TagMeta> = {
0: { label: '待支付', color: 'red' },
1: { label: '待接诊', color: 'orange' },
2: { label: '接诊中', color: 'blue' },
3: { label: '已结束', color: 'default' },
4: { label: '已取消', color: 'default' },
5: { label: '待评价', color: 'purple' },
6: { label: '已评价', color: 'purple' },
7: { label: '已拒诊', color: 'red' },
};
/** 退款状态列表页口径1退款中 3已退款 */
export const REGISTER_REFUND_STATUS_MAP: Record<number, TagMeta> = {
0: { label: '未退款', color: 'default' },
1: { label: '退款中', color: 'pink' },
2: { label: '已拒绝', color: 'red' },
3: { label: '已退款', color: 'green' },
};
/** 支付方式yii_register.pay_type1微信 2易票联 */
export const REGISTER_PAY_TYPE_MAP: Record<number, string> = {
1: '微信支付',
2: '易票联支付',
};
/** 搜索下拉:挂号类型选项(由映射生成,保证与展示同语义) */
export const REGISTER_TYPE_OPTIONS = Object.entries(REGISTER_TYPE_MAP).map(
([value, meta]) => ({ label: meta.label, value: Number(value) }),
);
/** 搜索下拉:支付状态选项 */
export const REGISTER_IS_PAY_OPTIONS = [
{ label: '已支付', value: 1 },
{ label: '未支付', value: 0 },
];
/** 搜索下拉:挂号状态选项(由状态映射生成,避免手写重复) */
export const REGISTER_STATUS_OPTIONS = Object.entries(REGISTER_STATUS_MAP).map(
([value, meta]) => ({ label: meta.label, value: Number(value) }),
);
/** 搜索下拉:退款状态选项(挂号退款无「拒绝」流程,只提供实际会出现的值) */
export const REGISTER_REFUND_STATUS_OPTIONS = [
{ label: '未退款', value: 0 },
{ label: '退款中', value: 1 },
{ label: '已退款', value: 3 },
];

View File

@@ -2,20 +2,29 @@ import type { VbenFormProps } from '#/adapter/form';
import dayjs from 'dayjs';
import {
REGISTER_IS_PAY_OPTIONS,
REGISTER_REFUND_STATUS_OPTIONS,
REGISTER_STATUS_OPTIONS,
REGISTER_TYPE_OPTIONS,
} from './constants';
export const formOptions: VbenFormProps = {
// 默认展开
collapsed: false,
// 默认收起只显示第一行,点「展开」看全部筛选(字段多,全展开太占高度)
collapsed: true,
commonConfig: {
// label 默认 100px本页筛选名最长 4 字,收窄到 70px 让控件占满格子(否则控件被挤得只剩一半宽)
labelWidth: 70,
// antd Select/RangePicker 默认按内容收缩,统一撑满格子,保证一行里各控件等宽整齐
componentProps: {
class: 'w-full',
},
},
// 响应式多列:抽出 grid 后 vben form 默认单列(一行一个字段),必须显式给栅格
wrapperClass: 'grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5',
schema: [
{
component: 'VbenInput',
componentProps: {
placeholder: '输入订单号',
},
defaultValue: '',
fieldName: 'order_no',
label: '订单号',
},
{
// 放第一位:时间范围有默认值(本月至今),默认收起时必须始终可见,否则用户不知道当前查的是哪段数据
component: 'RangePicker',
componentProps: {
format: 'YYYY-MM-DD',
@@ -29,15 +38,87 @@ export const formOptions: VbenFormProps = {
fieldName: 'search_time',
label: '时间范围',
},
{
// 统一 antd 系控件:与下拉/日期控件同一套视觉(此前 VbenInput 是 shadcn 风格,高度圆角不一致)
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '输入订单号',
},
defaultValue: '',
fieldName: 'order_no',
label: '订单号',
},
{
// 就诊人姓名/手机号模糊(后端 PersonKeywordScopeServicewhereHas 关联表)
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '姓名/手机号',
},
fieldName: 'patient_keyword',
label: '就诊人',
},
{
// 医生姓名/手机号模糊(后端 PersonKeywordScopeService
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '姓名/手机号',
},
fieldName: 'doctor_keyword',
label: '医生',
},
{
// 挂号类型(后端 queryField 等值匹配)
component: 'Select',
componentProps: {
allowClear: true,
options: REGISTER_TYPE_OPTIONS,
placeholder: '请选择',
},
fieldName: 'type',
label: '挂号类型',
},
{
// 支付状态
component: 'Select',
componentProps: {
allowClear: true,
options: REGISTER_IS_PAY_OPTIONS,
placeholder: '请选择',
},
fieldName: 'is_pay',
label: '支付状态',
},
{
// 挂号状态
component: 'Select',
componentProps: {
allowClear: true,
options: REGISTER_STATUS_OPTIONS,
placeholder: '请选择',
},
fieldName: 'status',
label: '状态',
},
{
// 退款状态
component: 'Select',
componentProps: {
allowClear: true,
options: REGISTER_REFUND_STATUS_OPTIONS,
placeholder: '请选择',
},
fieldName: 'refund_status',
label: '退款状态',
},
],
// 控制表单是否显示折叠按钮
showCollapseButton: true,
submitButtonOptions: {
content: '查询',
},
// 是否在字段值改变时提交表单
// submitOnChange: true,
// submitOnChange: true,
// 按下回车时是否提交表单
// 任意输入框回车即搜索下拉面板打开时的回车只选中选项core 层已处理),第二次回车才搜索
submitOnEnter: true,
};

View File

@@ -1,44 +1,177 @@
<script lang="ts" setup>
import type { VxeGridListeners } from "#/adapter/vxe-table";
/**
* 挂号订单管理页
*
* 支持「列表 / 卡片」双视图切换(通用 ViewModeSwitch + useViewMode 记忆偏好):
* - 搜索表单从 grid 内嵌拆出为顶部统一 SearchForm双视图共用同一份条件
* - 新增就诊人/医生关键字、挂号类型、支付/挂号/退款状态筛选
* - 点击卡片或行内「详情」打开挂号详情弹窗(就诊人/用户/诊所/医生/挂号方式/价格)
*/
import type { VbenFormProps } from '#/adapter/form';
import type { VxeGridListeners } from '#/adapter/vxe-table';
import { nextTick, onMounted, ref } from "vue";
import { useRoute } from "vue-router";
import { ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { Page, useVbenModal } from "@vben/common-ui";
import { Page, useVbenModal } from '@vben/common-ui';
import { Button, Image, message, Modal as AntdModal, Tag } from "ant-design-vue";
import { Button, Image, Tag } from 'ant-design-vue';
import { useVbenVxeGrid } from "#/adapter/vxe-table";
import { TableAction } from "#/components/table-action";
import StoreCardModal from "#/components/store-card/StoreCardModal.vue";
import { simulatePayApi } from "#/views/business/order/api/order-ops";
import PrescriptionDetail from "#/views/doctor/doctor-reception/components/PrescriptionDetail.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 StoreCardModal from '#/components/store-card/StoreCardModal.vue';
import PrescriptionDetail from '#/views/doctor/doctor-reception/components/PrescriptionDetail.vue';
import { formOptions } from "./config/search";
import { gridOptions } from "./config/table";
import Refund from "./components/refund.vue";
import { getRegisterListApi } from './api';
import Refund from './components/refund.vue';
import RegisterDetailModal from './components/RegisterDetailModal.vue';
import RegisterOrderCardList from './components/RegisterOrderCardList.vue';
import {
REGISTER_REFUND_STATUS_MAP,
REGISTER_STATUS_MAP,
REGISTER_TYPE_MAP,
} from './config/constants';
import { formOptions } from './config/search';
import { gridOptions } from './config/table';
const route = useRoute();
const hasTopTableDropDownActions = ref(false);
/** 列表/卡片双视图:通用 useViewMode 自带 localStorage 记忆 */
const viewMode = useViewMode('register-order-view-mode');
const cardListRef = ref<InstanceType<typeof RegisterOrderCardList> | null>(null);
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;
}
},
};
const [Grid, gridApi] = useVbenVxeGrid({
formOptions,
gridOptions,
gridEvents
/**
* 从路由 query 生成带默认值的表单配置
* 商品订单页「挂号金额」跳转会带 order_no写进 schema defaultValue 让双视图首屏直接生效
*/
function buildFormOptionsFromRoute(): VbenFormProps {
const raw = route.query.order_no;
const orderNo = Array.isArray(raw) ? raw[0] : raw;
if (!orderNo) {
return formOptions;
}
const schema = (formOptions.schema ?? []).map((item) => {
if (item.fieldName === 'order_no') {
return { ...item, defaultValue: String(orderNo) };
}
// 指定订单号查询时放开默认时间范围,避免历史订单被本月范围滤掉
if (item.fieldName === 'search_time') {
return { ...item, defaultValue: null };
}
return item;
});
return { ...formOptions, schema };
}
/** 生效的搜索表单配置(含路由 order_no 默认值注入) */
const effectiveFormOptions = buildFormOptionsFromRoute();
/**
* 从 schema 提取 defaultValue 作为首屏搜索条件
* 双视图首屏都要带默认时间范围,保证查询口径与表单显示一致
*/
function collectDefaultSearchValues(): Record<string, any> {
const values: Record<string, any> = {};
for (const item of effectiveFormOptions.schema ?? []) {
if (item.fieldName && item.defaultValue !== undefined) {
values[item.fieldName] = item.defaultValue;
}
}
return values;
}
/** 列表与卡片共用的搜索条件(提交/重置时整体替换,卡片靠 watch 自动拉数) */
const searchValues = ref<Record<string, any>>(collectDefaultSearchValues());
/**
* 顶部统一搜索表单(从 grid 内嵌表单抽出,双视图共用)
* 列表模式提交后 reload 回第 1 页;卡片模式由 formValues watch 自动拉数
*/
const [SearchForm, searchFormApi] = useVbenForm({
...effectiveFormOptions,
handleSubmit: async (values) => {
searchValues.value = { ...(values || {}) };
if (viewMode.value === 'list') {
gridApi.reload();
}
},
handleReset: async () => {
// 自定义 handleReset 会接管默认行为,必须手动还原控件到 defaultValuereset 为新 APIresetForm 已弃用)
await searchFormApi.reset();
searchValues.value = collectDefaultSearchValues();
if (viewMode.value === 'list') {
gridApi.reload();
}
},
});
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions: {
...gridOptions,
// 搜索已抽出为顶部统一表单,关闭 grid 自带的表单开关按钮
toolbarConfig: { ...gridOptions.toolbarConfig, search: false },
proxyConfig: {
ajax: {
/** 始终合并顶部统一搜索条件 */
query: async ({ page }: { page: { currentPage: number; pageSize: number } }) => {
return await getRegisterListApi({
page: page.currentPage,
pageSize: page.pageSize,
...searchValues.value,
});
},
},
},
},
gridEvents,
});
// 记忆持久化由 useViewMode 内部完成;切回列表时 grid 被 v-if 卸载过需主动重查
watch(viewMode, (mode, prev) => {
if (mode === 'list' && prev === 'card') {
gridApi.query();
}
});
/** 操作成功后的视图刷新:列表保当前页,卡片原地重拉已加载范围 */
async function refreshCurrentView() {
await (viewMode.value === 'list'
? gridApi.query()
: cardListRef.value?.refreshKeepPage?.());
}
/** 回第 1 页的强刷新(退款弹窗内部 reload 语义) */
function refreshCurrentViewFromPageOne() {
if (viewMode.value === 'list') {
gridApi.reload();
} else {
cardListRef.value?.reload?.();
}
}
/**
* 传给退款弹窗的 gridApi 代理:弹窗内部只会调 reload()/query()
* 卡片模式下需要转发给卡片列表,故不能直接把 gridApi 传进去
*/
const gridApiProxy = {
reload: () => refreshCurrentViewFromPageOne(),
query: () => refreshCurrentView(),
};
const [StoreCardModalComp, StoreCardModalApi] = useVbenModal({
connectedComponent: StoreCardModal,
});
@@ -49,55 +182,37 @@ function openStoreCard(storeId?: number) {
StoreCardModalApi.open();
}
const route = useRoute();
onMounted(() => {
void nextTick(async () => {
const raw = route.query.order_no;
const orderNo = Array.isArray(raw) ? raw[0] : raw;
if (!orderNo || !gridApi.formApi?.setValues) {
return;
}
await gridApi.formApi.setValues({ order_no: String(orderNo) });
await gridApi.query();
});
});
const [PrescriptionDetailModal, PrescriptionDetailModalApi] = useVbenModal({
connectedComponent: PrescriptionDetail
connectedComponent: PrescriptionDetail,
});
const [RefundModal, RefundModalApi] = useVbenModal({
connectedComponent: Refund
connectedComponent: Refund,
});
/** 挂号详情弹窗:列表行「详情」与卡片点击共用 */
const [RegisterDetailModalComp, registerDetailModalApi] = useVbenModal({
connectedComponent: RegisterDetailModal,
});
function openRegisterDetail(row: Record<string, any>) {
registerDetailModalApi.setData({ row });
registerDetailModalApi.open();
}
function openPrescriptionDetail(id: number) {
PrescriptionDetailModalApi.setData({
values: id
values: id,
});
PrescriptionDetailModalApi.open();
}
function handleSimulatePay(row: Record<string, any>) {
AntdModal.confirm({
title: "模拟支付",
content: `确认为挂号订单 ${row.order_no} 模拟易票联支付回调?`,
okText: "确认",
cancelText: "取消",
onOk: async () => {
await simulatePayApi({ order_type: "register", order_id: row.id });
message.success("模拟支付成功");
await gridApi.query();
}
});
}
function openRefundModal(id: number) {
RefundModalApi.setData({
values: {
register_id: id
register_id: id,
},
gridApi
gridApi: gridApiProxy,
});
RefundModalApi.open();
}
@@ -109,10 +224,26 @@ function canRefund(row: Record<string, any>) {
function formatRegisterPrice(price: unknown) {
const n = Number(price);
if (!Number.isFinite(n)) {
return "—";
return '—';
}
return `¥${n.toFixed(2)}`;
}
/** 列表槽用元数据取值 helperv-if 双重索引无法类型收窄,统一在这里兜底 */
function rowTypeMeta(row: Record<string, any>) {
return REGISTER_TYPE_MAP[Number(row?.type)] ?? null;
}
function rowStatusMeta(row: Record<string, any>) {
return REGISTER_STATUS_MAP[Number(row?.status)] ?? null;
}
function rowRefundMeta(row: Record<string, any>) {
return (
REGISTER_REFUND_STATUS_MAP[Number(row?.refund_status ?? 0)] ?? {
label: '未退款',
color: 'default',
}
);
}
</script>
<template>
@@ -120,13 +251,25 @@ function formatRegisterPrice(price: unknown) {
<PrescriptionDetailModal />
<RefundModal />
<StoreCardModalComp />
<Grid>
<!-- 挂号详情弹窗处方号/诊所名点击透传到已有弹窗 -->
<RegisterDetailModalComp
@open-prescription="openPrescriptionDetail"
@open-store="openStoreCard"
/>
<!-- 顶部统一搜索双视图共用替代原 grid 内嵌表单 -->
<div class="mb-3 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--card,var(--background)))] p-3">
<SearchForm />
</div>
<!-- 视图切换列表 / 卡片看板 -->
<div class="mb-3 flex items-center justify-between gap-3">
<ViewModeSwitch v-model="viewMode" />
</div>
<Grid v-if="viewMode === 'list'">
<template #toolbar-actions>
<TableAction :actions="[]" :drop-down-actions="[]">
<template #more>
<Button style="margin-left: 16px">
批量操作
<Icon icon="ant-design:down-outlined" />
</Button>
</template>
</TableAction>
@@ -139,9 +282,9 @@ function formatRegisterPrice(price: unknown) {
class="!px-0"
@click="openStoreCard(row.store?.id || row.store_id)"
>
{{ row.store?.name || "—" }}
{{ row.store?.name || '—' }}
</Button>
<span v-else>{{ row.store?.name || "" }}</span>
<span v-else>{{ row.store?.name || '' }}</span>
</template>
<template #prescription="{ row }">
<div v-for="item in row.prescription" :key="item.id">
@@ -158,7 +301,7 @@ function formatRegisterPrice(price: unknown) {
:height="28"
class="rounded-full object-cover"
/>
<span>{{ row.salesperson.nick_name || "—" }}</span>
<span>{{ row.salesperson.nick_name || '—' }}</span>
</div>
<span v-else class="text-sm text-gray-500">无推广员</span>
</template>
@@ -167,38 +310,36 @@ function formatRegisterPrice(price: unknown) {
</template>
<template #is_pay="{ row }">
<Tag :color="row.is_pay === 1 ? 'green' : 'red'">
{{ row.is_pay === 1 ? "已支付" : "未支付" }}
{{ row.is_pay === 1 ? '已支付' : '未支付' }}
</Tag>
</template>
<template #refund_status="{ row }">
<Tag v-if="row.refund_status === 1" color="pink">退款中</Tag>
<Tag v-else-if="row.refund_status === 3" color="green">已退款</Tag>
<Tag v-else color="default">未退款</Tag>
<!-- 退款状态渲染统一走 constants 映射与卡片/详情同语义 -->
<Tag :color="rowRefundMeta(row).color">
{{ rowRefundMeta(row).label }}
</Tag>
</template>
<template #type="{ row }">
<div class="mt-3">
<Tag v-if="row.type === 0" color="purple">线下就诊</Tag>
<Tag v-else-if="row.type === 1" color="blue">在线问诊</Tag>
<Tag v-else-if="row.type === 3" color="green">在线复诊</Tag>
<Tag v-else color="default">未知</Tag>
</div>
<Tag v-if="rowTypeMeta(row)" :color="rowTypeMeta(row)!.color">
{{ rowTypeMeta(row)!.label }}
</Tag>
<Tag v-else color="default">未知</Tag>
</template>
<template #status="{ row }">
<div class="mt-3">
<Tag v-if="row.status === 0" color="red">代支付</Tag>
<Tag v-else-if="row.status === 1" color="green">待接诊</Tag>
<Tag v-else-if="row.status === 2" color="red">接诊中</Tag>
<Tag v-else-if="row.status === 3" color="purple">已结束</Tag>
<Tag v-else-if="row.status === 4" color="orange">已取消</Tag>
<Tag v-else-if="row.status === 5" color="purple">待评价</Tag>
<Tag v-else-if="row.status === 6" color="purple">已评价</Tag>
<Tag v-else-if="row.status === 7" color="red">已拒诊</Tag>
</div>
<Tag v-if="rowStatusMeta(row)" :color="rowStatusMeta(row)!.color">
{{ rowStatusMeta(row)!.label }}
</Tag>
</template>
<template #toolbar-tools></template>
<template #action="{ row }">
<TableAction
:actions="[
{
label: '详情',
type: 'link',
icon: 'marketeq:eye',
onClick: openRegisterDetail.bind(null, row),
},
{
label: '退款',
type: 'link',
@@ -211,36 +352,25 @@ function formatRegisterPrice(price: unknown) {
/>
</template>
</Grid>
<!-- 卡片看板视图展示内容与列表一致点卡片看详情弹窗复用父级 -->
<div v-else class="rg-card-wrap">
<RegisterOrderCardList
ref="cardListRef"
:form-values="searchValues"
:can-refund="canRefund"
@open-detail="openRegisterDetail"
@refund="openRefundModal"
@open-prescription="openPrescriptionDetail"
@open-store="openStoreCard"
/>
</div>
</Page>
</template>
<style scoped lang="scss">
.custom-list {
list-style-type: none;
padding-left: 0;
}
.custom-list-item {
background-color: rgba(64, 158, 255, 0.04);
border-radius: 4px;
margin-bottom: 8px;
padding: 8px 12px;
font-size: 14px;
transition: background-color 0.3s;
&:hover {
background-color: rgba(64, 158, 255, 0.1);
}
&::before {
content: '';
display: inline-block;
width: 6px;
height: 6px;
background-color: #409eff;
border-radius: 50%;
margin-right: 8px;
vertical-align: middle;
}
/* 卡片看板容器:撑满剩余高度,内部滚动(收起态搜索一行 + 切换行约 170pxmin-height 兜底) */
.rg-card-wrap {
height: calc(100% - 170px);
min-height: 420px;
}
</style>

View File

@@ -8,6 +8,7 @@ import {
Avatar,
Button,
Card,
Empty,
Image,
ImagePreviewGroup,
InputNumber,
@@ -17,6 +18,7 @@ import {
Popconfirm,
RadioButton,
RadioGroup,
Spin,
Tag,
} from 'ant-design-vue';
// eslint-disable-next-line n/no-extraneous-import
@@ -26,6 +28,8 @@ import DoctorQrCodePreview from '#/components/modal/DoctorQrCodePreview.vue';
import SensitiveText from '#/components/sensitive-text/SensitiveText.vue';
import StoreVipMemberCard from '#/components/vip/StoreVipMemberCard.vue';
import DoctorCardModal from '#/views/doctor/doctor/components/DoctorCardModal.vue';
import { getStoreRegisterSettingApi } from '#/views/doctor/doctor/api/schedule';
import SchedulePanel from '#/views/doctor/doctor/components/schedule-calendar/SchedulePanel.vue';
import {
deleteNavApi,
getStoreInfoApi,
@@ -130,7 +134,7 @@ const tabBar = computed<TabBarItem[]>(() => {
{ value: 6, label: '推广员' },
];
}
// 诊所显示全部:诊所资料、商品价格、诊所二维码、我的医生团队、轮播图、推广员
// 诊所显示全部:诊所资料、商品价格、诊所二维码、我的医生团队、轮播图、推广员、排班管理
return [
{ value: 1, label: '诊所资料' },
{ value: 2, label: '商品价格' },
@@ -138,9 +142,39 @@ const tabBar = computed<TabBarItem[]>(() => {
{ value: 4, label: '我的医生团队' },
{ value: 5, label: '轮播图' },
{ value: 6, label: '推广员' },
{ value: 7, label: '排班管理' },
];
});
/**
* 排班管理 tab 的本店 ID诊所管理员通过挂号设置接口自动解析本店
* 懒加载:首次切到该 tab 才请求,避免影响其他 tab 的加载速度。
* 必须有加载中/失败两个状态:接口失败或解析出 0 时若无提示tab 会永久空白且用户无法自救
*/
const scheduleStoreId = ref(0);
const scheduleLoading = ref(false);
const scheduleLoadFailed = ref(false);
const loadScheduleStoreId = () => {
if (scheduleStoreId.value > 0 || scheduleLoading.value) return;
scheduleLoading.value = true;
scheduleLoadFailed.value = false;
getStoreRegisterSettingApi()
.then((res: any) => {
scheduleStoreId.value = Number(res?.store_id || 0);
// 解析出 0 也视为失败:面板依赖 storeId0 渲染不出任何内容
scheduleLoadFailed.value = scheduleStoreId.value <= 0;
})
.catch(() => {
scheduleLoadFailed.value = true;
})
.finally(() => {
scheduleLoading.value = false;
});
};
watch(activeTabBar, (val) => {
if (val === 7) loadScheduleStoreId();
}, { immediate: true });
// 监听tabBar变化验证当前tab是否有效药店和诊所tab选项不同
watch(tabBar, (newTabBar) => {
const validValues = newTabBar.map(item => item.value);
@@ -762,6 +796,27 @@ const handleSwitchClinicType = () => {
</template>
<SalespersonCard />
</Card>
<Card v-else-if="activeTabBar === 7" title="排班管理">
<template #extra>
<span class="text-sm text-gray-500"
>按日期给医生排班套班次模板/休诊未单独排班的日期按医生默认规则放号</span
>
</template>
<!-- 本店 id 解析完成后再渲染面板面板依赖 storeId 拉医生/模板/日历 -->
<SchedulePanel
v-if="scheduleStoreId > 0"
:store-id="scheduleStoreId"
:store-name="data.name"
/>
<!-- 加载中 / 失败态失败给重试按钮避免 tab 永久空白无法自救 -->
<div v-else-if="scheduleLoading" class="schedule-tab-state">
<Spin tip="正在解析本店排班信息…" />
</div>
<Empty v-else-if="scheduleLoadFailed" description="排班信息加载失败">
<Button type="primary" @click="loadScheduleStoreId">重试</Button>
</Empty>
</Card>
</Page>
</template>
<style lang="scss" scoped>
@@ -839,4 +894,12 @@ const handleSwitchClinicType = () => {
transform: scale(0.95);
}
}
/* 排班 tab 加载态:居中占位,避免 Spin 贴左上角 */
.schedule-tab-state {
display: flex;
align-items: center;
justify-content: center;
padding: 48px 0;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -1,179 +0,0 @@
<script lang="ts" setup>
/**
* 当日待办列表弹窗
* 日历格子空间有限只露出前几条,这里查看/管理某一天的全部待办:勾选完成、编辑、删除
* 打开时通过 modalApi.setData 传入date(YYYY-MM-DD)、todos、onEdit(todo)、onCreate()、onChanged()
*/
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Button, Checkbox, Empty, message, Modal as AntModal, Tag } from 'ant-design-vue';
import dayjs from 'dayjs';
import { deleteCalendarTodo, toggleCalendarTodoDone } from '../api';
const date = ref('');
const todos = ref<any[]>([]);
const handlers = ref<Record<string, any>>({});
/** 弹窗标题带上星期几,快速确认日期没点错 */
const title = computed(() => {
if (!date.value) return '当日待办';
const d = dayjs(date.value);
const weeks = ['日', '一', '二', '三', '四', '五', '六'];
return `${d.format('M月D日')}(周${weeks[d.day()]})待办`;
});
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
draggable: true,
footer: false,
onOpenChange(isOpen: boolean) {
if (!isOpen) return;
const data = (modalApi.getData() || {}) as Record<string, any>;
date.value = data.date || '';
todos.value = data.todos || [];
handlers.value = {
onEdit: data.onEdit,
onCreate: data.onCreate,
onChanged: data.onChanged,
};
},
});
/** 勾选切换完成状态:本地同步更新,避免整月重拉造成闪烁 */
async function onToggleDone(todo: any, checked: boolean) {
await toggleCalendarTodoDone({ id: todo.id, is_done: checked ? 1 : 0 });
todo.is_done = checked ? 1 : 0;
handlers.value.onChanged?.();
}
/** 删除单条待办 */
function onDelete(todo: any) {
AntModal.confirm({
title: '删除待办',
content: `确定删除「${todo.title}」吗?`,
okText: '删除',
okType: 'danger',
cancelText: '取消',
onOk: async () => {
await deleteCalendarTodo({ id: todo.id });
message.success('删除成功');
todos.value = todos.value.filter((t) => t.id !== todo.id);
handlers.value.onChanged?.();
},
});
}
/** 编辑:关闭本弹窗后打开编辑弹窗,避免弹窗叠弹窗 */
function onEdit(todo: any) {
modalApi.close();
handlers.value.onEdit?.(todo);
}
/** 在该日期新建待办 */
function onCreate() {
modalApi.close();
handlers.value.onCreate?.(date.value);
}
/** 时间点展示全天00:00不显示具体时间 */
function timeText(todo: any) {
const t = dayjs.unix(Number(todo.todo_time));
return t.format('HH:mm') === '00:00' ? '全天' : t.format('HH:mm');
}
</script>
<template>
<Modal :title="title" class="w-[480px]">
<div class="day-todos">
<Empty v-if="todos.length === 0" description="这一天还没有待办" />
<div v-for="todo in todos" :key="todo.id" class="todo-item">
<Checkbox
:checked="Number(todo.is_done) === 1"
@change="(e: any) => onToggleDone(todo, e.target.checked)"
/>
<div class="todo-main" @click="onEdit(todo)">
<div class="todo-title" :class="{ done: Number(todo.is_done) === 1 }">
{{ todo.title }}
</div>
<div class="todo-meta">
<span>{{ timeText(todo) }}</span>
<Tag v-if="Number(todo.remind_at) > 0" color="processing" class="remind-tag">
提醒 {{ dayjs.unix(Number(todo.remind_at)).format('MM-DD HH:mm') }}
</Tag>
<Tag v-if="Number(todo.remind_status) === 2" color="error" class="remind-tag">
提醒失败
</Tag>
</div>
</div>
<Button type="text" size="small" danger @click="onDelete(todo)">删除</Button>
</div>
<div class="day-todos-footer">
<Button type="dashed" block @click="onCreate">+ 新建待办</Button>
</div>
</div>
</Modal>
</template>
<style lang="scss" scoped>
.day-todos {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 420px;
padding: 4px 2px;
overflow-y: auto;
}
.todo-item {
display: flex;
gap: 10px;
align-items: flex-start;
padding: 10px 12px;
border: 1px solid hsl(var(--border));
border-radius: 8px;
transition: border-color 0.2s;
&:hover {
border-color: hsl(var(--primary) / 50%);
}
}
.todo-main {
flex: 1;
min-width: 0;
cursor: pointer;
}
.todo-title {
overflow: hidden;
font-size: 14px;
color: hsl(var(--foreground));
text-overflow: ellipsis;
white-space: nowrap;
&.done {
color: hsl(var(--muted-foreground));
text-decoration: line-through;
}
}
.todo-meta {
display: flex;
gap: 6px;
align-items: center;
margin-top: 4px;
font-size: 12px;
color: hsl(var(--muted-foreground));
}
.remind-tag {
margin-inline-end: 0;
font-size: 11px;
line-height: 18px;
}
.day-todos-footer {
margin-top: 4px;
}
</style>

View File

@@ -1,597 +1,17 @@
<script lang="ts" setup>
/**
* 日程日历页
* - 基于 ant-design-vue Calendar 封装:农历/节气/节假日(中国节日按农历推算,国际节日按公历
* - 右键格子弹出菜单:新建待办 / 查看当日待办;格子内待办条目点击直接编辑
* - 待办支持站内信/邮箱/短信定时提醒(后端 calendar:todo-remind 每分钟扫描发送)
* 日程日历页:完整日历面板的页面壳
* 日历本体抽离为 CalendarPanel 组件(工作台 work_calendar widget 复用同一组件的 compact 紧凑模式
* 路由 name 必须保持 WorkCalendar 与 xk_menu.name 一致,否则菜单/缓存匹配失效
*/
import type { Dayjs } from 'dayjs';
import { Page } from '@vben/common-ui';
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue';
import { Page, useVbenModal } from '@vben/common-ui';
import { Button, Calendar, Spin, Tooltip } from 'ant-design-vue';
import dayjs from 'dayjs';
import { Solar } from 'lunar-typescript';
import { getCalendarTodoList } from './api';
import DayTodosModalComp from './components/day-todos-modal.vue';
import TodoModalComp from './components/todo-modal.vue';
import { getDayAlmanac } from './utils/almanac';
import CalendarPanel from './components/CalendarPanel.vue';
defineOptions({ name: 'WorkCalendar' });
/** 日历面板当前值(含选中日期与展示月份) */
const panelDate = ref<Dayjs>(dayjs());
const loading = ref(false);
/** 待办按日期分组key = YYYY-MM-DD */
const todosMap = ref<Map<string, any[]>>(new Map());
/** 待办新建/编辑弹窗 */
const [TodoFormModal, todoModalApi] = useVbenModal({
connectedComponent: TodoModalComp,
});
/** 当日待办列表弹窗 */
const [DayTodosModal, dayTodosModalApi] = useVbenModal({
connectedComponent: DayTodosModalComp,
});
/** 头部标题:公历年月 + 农历干支生肖年如「2026年2月 · 丙午马年」 */
const headerTitle = computed(() => {
const d = panelDate.value;
const lunar = Solar.fromYmd(d.year(), d.month() + 1, 1).getLunar();
return {
solar: d.format('YYYY年M月'),
lunar: `农历${lunar.getYearInGanZhi()}${lunar.getYearShengXiao()}`,
};
});
/**
* 拉取面板范围内的待办并按日期分组
* 范围取当月前后各留一段:日历 42 格会露出上月末尾与下月开头,宽松范围一次覆盖
*/
async function loadTodos() {
loading.value = true;
try {
const start = panelDate.value.startOf('month').subtract(7, 'day').startOf('day');
const end = panelDate.value.endOf('month').add(14, 'day').endOf('day');
const list = await getCalendarTodoList({
start_time: start.unix(),
end_time: end.unix(),
});
const map = new Map<string, any[]>();
(list || []).forEach((todo: any) => {
const key = dayjs.unix(Number(todo.todo_time)).format('YYYY-MM-DD');
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(todo);
});
todosMap.value = map;
} finally {
loading.value = false;
}
}
// 月份变化时重拉(同月内点选日期不触发,避免无谓请求)
watch(() => panelDate.value.format('YYYY-MM'), loadTodos, { immediate: true });
/** 组装单个格子的渲染数据:黄历信息 + 当日待办 */
function cellData(current: Dayjs) {
return {
almanac: getDayAlmanac(current.year(), current.month() + 1, current.date()),
todos: todosMap.value.get(current.format('YYYY-MM-DD')) || [],
isToday: current.isSame(dayjs(), 'day'),
};
}
/** 待办时间点文案00:00 视为全天 */
function todoTimeText(todo: any) {
const t = dayjs.unix(Number(todo.todo_time));
return t.format('HH:mm') === '00:00' ? '' : t.format('HH:mm');
}
// ---------------- 右键菜单 ----------------
/** 右键菜单状态:位置 + 目标日期 */
const ctxMenu = reactive({ visible: false, x: 0, y: 0, date: '' });
/** 打开右键菜单:贴边时向内收,避免菜单溢出视口 */
function openCtxMenu(e: MouseEvent, current: Dayjs) {
ctxMenu.date = current.format('YYYY-MM-DD');
ctxMenu.x = Math.min(e.clientX, window.innerWidth - 180);
ctxMenu.y = Math.min(e.clientY, window.innerHeight - 120);
ctxMenu.visible = true;
}
function closeCtxMenu() {
ctxMenu.visible = false;
}
/** 右键菜单目标日期的待办数量 */
const ctxTodoCount = computed(() => (todosMap.value.get(ctxMenu.date) || []).length);
onMounted(() => {
// 点击任意处 / 滚动时关闭右键菜单
window.addEventListener('click', closeCtxMenu);
window.addEventListener('scroll', closeCtxMenu, true);
});
onUnmounted(() => {
window.removeEventListener('click', closeCtxMenu);
window.removeEventListener('scroll', closeCtxMenu, true);
});
// ---------------- 待办操作 ----------------
/** 新建待办date 为默认日期(右键格子/当日弹窗/头部按钮带入) */
function createTodo(date?: string) {
closeCtxMenu();
todoModalApi
.setData({
update: false,
defaultDate: date || panelDate.value.format('YYYY-MM-DD'),
onSaved: loadTodos,
})
.open();
}
/** 编辑待办 */
function editTodo(todo: any) {
closeCtxMenu();
todoModalApi.setData({ update: true, values: todo, onSaved: loadTodos }).open();
}
/** 打开当日待办列表弹窗 */
function openDay(date: string) {
closeCtxMenu();
dayTodosModalApi
.setData({
date,
todos: todosMap.value.get(date) || [],
onEdit: editTodo,
onCreate: createTodo,
onChanged: loadTodos,
})
.open();
}
// ---------------- 月份切换 ----------------
function prevMonth() {
panelDate.value = panelDate.value.subtract(1, 'month');
}
function nextMonth() {
panelDate.value = panelDate.value.add(1, 'month');
}
function backToday() {
panelDate.value = dayjs();
}
</script>
<template>
<Page auto-content-height>
<div class="work-calendar">
<!-- 自定义头部年月标题 + 农历年 + 图例 + 操作区 -->
<div class="wc-header">
<div class="wc-header-left">
<span class="wc-title">{{ headerTitle.solar }}</span>
<span class="wc-lunar-year">{{ headerTitle.lunar }}</span>
</div>
<div class="wc-header-right">
<div class="wc-legend">
<span class="wc-legend-item"><i class="dot festival"></i>节日</span>
<span class="wc-legend-item"><i class="dot jieqi"></i>节气</span>
<span class="wc-legend-item"><i class="badge rest"></i>法定假</span>
<span class="wc-legend-item"><i class="badge work"></i>补班</span>
</div>
<Button size="small" @click="prevMonth"> 上月</Button>
<Button size="small" @click="backToday">今天</Button>
<Button size="small" @click="nextMonth">下月 </Button>
<Button type="primary" size="small" @click="createTodo()">+ 新建待办</Button>
</div>
</div>
<Spin :spinning="loading">
<Calendar v-model:value="panelDate" :fullscreen="true">
<!-- 隐藏 antd 默认头部统一用上方自定义头部 -->
<template #headerRender><span></span></template>
<template #dateFullCellRender="{ current }">
<div
class="cal-cell"
:class="{ 'is-today': cellData(current).isToday }"
@contextmenu.prevent="openCtxMenu($event, current)"
>
<div class="cal-cell-head">
<span class="cal-day">{{ current.date() }}</span>
<span
v-if="cellData(current).almanac.holiday"
class="cal-badge"
:class="cellData(current).almanac.holiday!.isWork ? 'work' : 'rest'"
>
{{ cellData(current).almanac.holiday!.isWork ? '班' : '休' }}
</span>
<Tooltip
v-if="cellData(current).almanac.festivals.length > 0"
:title="cellData(current).almanac.festivals.join('、')"
>
<span class="cal-lunar" :class="cellData(current).almanac.displayType">
{{ cellData(current).almanac.displayText }}
</span>
</Tooltip>
<span v-else class="cal-lunar" :class="cellData(current).almanac.displayType">
{{ cellData(current).almanac.displayText }}
</span>
<button
class="cal-add"
title="新建待办"
@click.stop="createTodo(current.format('YYYY-MM-DD'))"
>
+
</button>
</div>
<div class="cal-todos">
<div
v-for="todo in cellData(current).todos.slice(0, 3)"
:key="todo.id"
class="cal-todo"
:class="{ done: Number(todo.is_done) === 1 }"
@click.stop="editTodo(todo)"
>
<i class="cal-todo-dot"></i>
<span class="cal-todo-text">
<template v-if="todoTimeText(todo)">{{ todoTimeText(todo) }} </template>{{ todo.title }}
</span>
</div>
<div
v-if="cellData(current).todos.length > 3"
class="cal-more"
@click.stop="openDay(current.format('YYYY-MM-DD'))"
>
还有 {{ cellData(current).todos.length - 3 }}
</div>
</div>
</div>
</template>
</Calendar>
</Spin>
<!-- 右键菜单Teleport body 避免被日历容器裁剪 -->
<Teleport to="body">
<div
v-if="ctxMenu.visible"
class="wc-ctx-menu"
:style="{ left: `${ctxMenu.x}px`, top: `${ctxMenu.y}px` }"
@click.stop
>
<div class="wc-ctx-date">{{ dayjs(ctxMenu.date).format('M月D日') }}</div>
<div class="wc-ctx-item" @click="createTodo(ctxMenu.date)">
<span> 新建待办</span>
</div>
<div class="wc-ctx-item" @click="openDay(ctxMenu.date)">
<span> 查看当日待办</span>
<span v-if="ctxTodoCount > 0" class="wc-ctx-count">{{ ctxTodoCount }}</span>
</div>
</div>
</Teleport>
<TodoFormModal />
<DayTodosModal />
</div>
<CalendarPanel />
</Page>
</template>
<style lang="scss" scoped>
.work-calendar {
display: flex;
flex-direction: column;
height: 100%;
padding: 16px;
overflow: auto;
background: hsl(var(--card, var(--background)));
border: 1px solid hsl(var(--border));
border-radius: 10px;
}
/* ---------- 头部 ---------- */
.wc-header {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
justify-content: space-between;
padding-bottom: 12px;
border-bottom: 1px solid hsl(var(--border));
}
.wc-header-left {
display: flex;
gap: 10px;
align-items: baseline;
}
.wc-title {
font-size: 20px;
font-weight: 700;
color: hsl(var(--foreground));
}
.wc-lunar-year {
font-size: 13px;
color: hsl(var(--muted-foreground));
}
.wc-header-right {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.wc-legend {
display: flex;
gap: 10px;
align-items: center;
margin-right: 6px;
}
.wc-legend-item {
display: inline-flex;
gap: 4px;
align-items: center;
font-size: 12px;
color: hsl(var(--muted-foreground));
.dot {
width: 6px;
height: 6px;
border-radius: 50%;
&.festival {
background: hsl(var(--destructive));
}
&.jieqi {
background: hsl(var(--primary));
}
}
.badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
font-size: 10px;
font-style: normal;
border-radius: 4px;
&.rest {
color: hsl(var(--primary));
background: hsl(var(--primary) / 12%);
}
&.work {
color: hsl(var(--warning));
background: hsl(var(--warning) / 14%);
}
}
}
/* ---------- 日历格子 ---------- */
.cal-cell {
position: relative;
display: flex;
flex-direction: column;
min-height: 104px;
padding: 6px 8px;
margin: 2px;
overflow: hidden;
border-radius: 8px;
transition: background-color 0.2s;
&:hover {
background: hsl(var(--muted) / 40%);
.cal-add {
opacity: 1;
}
}
&.is-today {
background: hsl(var(--primary) / 8%);
box-shadow: inset 0 0 0 1.5px hsl(var(--primary) / 55%);
.cal-day {
color: hsl(var(--primary-foreground, 0 0% 100%));
background: hsl(var(--primary));
}
}
}
.cal-cell-head {
display: flex;
gap: 6px;
align-items: center;
min-width: 0;
}
.cal-day {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
font-size: 14px;
font-weight: 600;
color: hsl(var(--foreground));
border-radius: 50%;
}
.cal-lunar {
flex: 1;
min-width: 0;
overflow: hidden;
font-size: 12px;
color: hsl(var(--muted-foreground));
text-overflow: ellipsis;
white-space: nowrap;
&.festival {
font-weight: 600;
color: hsl(var(--destructive));
}
&.jieqi {
font-weight: 600;
color: hsl(var(--primary));
}
}
.cal-badge {
flex-shrink: 0;
padding: 0 4px;
font-size: 10px;
line-height: 16px;
border-radius: 4px;
&.rest {
color: hsl(var(--primary));
background: hsl(var(--primary) / 12%);
}
&.work {
color: hsl(var(--warning));
background: hsl(var(--warning) / 14%);
}
}
/* hover 才出现的快捷新建按钮,避免视觉噪音 */
.cal-add {
flex-shrink: 0;
width: 20px;
height: 20px;
font-size: 14px;
line-height: 1;
color: hsl(var(--primary));
cursor: pointer;
background: hsl(var(--primary) / 10%);
border: none;
border-radius: 4px;
opacity: 0;
transition: opacity 0.2s;
}
.cal-todos {
display: flex;
flex-direction: column;
gap: 3px;
margin-top: 6px;
}
.cal-todo {
display: flex;
gap: 5px;
align-items: center;
min-width: 0;
padding: 1px 6px;
font-size: 12px;
color: hsl(var(--foreground) / 85%);
cursor: pointer;
background: hsl(var(--primary) / 8%);
border-radius: 4px;
transition: background-color 0.2s;
&:hover {
background: hsl(var(--primary) / 16%);
}
&.done {
color: hsl(var(--muted-foreground));
text-decoration: line-through;
.cal-todo-dot {
background: hsl(var(--muted-foreground));
}
}
}
.cal-todo-dot {
flex-shrink: 0;
width: 5px;
height: 5px;
background: hsl(var(--primary));
border-radius: 50%;
}
.cal-todo-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cal-more {
padding-left: 6px;
font-size: 11px;
color: hsl(var(--primary));
cursor: pointer;
}
/* 非当月格子整体淡化 */
:deep(.ant-picker-cell:not(.ant-picker-cell-in-view)) {
.cal-cell {
opacity: 0.4;
}
}
/* 去掉 antd 日历自带的格子上边框与内边距,让自定义格子撑满 */
:deep(.ant-picker-calendar .ant-picker-panel) {
background: transparent;
border-top: none;
}
:deep(.ant-picker-calendar .ant-picker-cell) {
padding: 0;
}
:deep(.ant-picker-calendar .ant-picker-content th) {
padding: 10px 8px 6px;
font-size: 13px;
color: hsl(var(--muted-foreground));
}
/* ---------- 右键菜单 ---------- */
.wc-ctx-menu {
position: fixed;
z-index: 2100;
min-width: 168px;
padding: 6px;
background: hsl(var(--popover, var(--background)));
border: 1px solid hsl(var(--border));
border-radius: 8px;
box-shadow: 0 8px 24px rgb(0 0 0 / 14%);
}
.wc-ctx-date {
padding: 4px 10px 6px;
font-size: 12px;
color: hsl(var(--muted-foreground));
border-bottom: 1px solid hsl(var(--border));
}
.wc-ctx-item {
display: flex;
gap: 8px;
align-items: center;
justify-content: space-between;
padding: 7px 10px;
margin-top: 2px;
font-size: 13px;
color: hsl(var(--foreground));
cursor: pointer;
border-radius: 6px;
&:hover {
color: hsl(var(--primary));
background: hsl(var(--primary) / 10%);
}
}
.wc-ctx-count {
padding: 0 6px;
font-size: 11px;
color: hsl(var(--primary));
background: hsl(var(--primary) / 12%);
border-radius: 8px;
}
</style>

View File

@@ -3,31 +3,82 @@ import { HolidayUtil, Solar } from 'lunar-typescript';
/**
* 黄历信息计算工具(基于 lunar-typescript
* 设计说明:
* - 中国传统节日(春节/端午/中秋等)由农历推算,国际节日(元旦/圣诞/母亲节等)按公历
* 正好满足「中国节日算农历、其他节日按公历」的需求
* - 中国传统节日(春节/端午/中秋等)由农历推算,公历节日(元旦/国庆等)按公历
* - 农历文案与节日、节气三者并列返回互不覆盖:格子里农历始终显示,节日/节气展示在副行
* - 公历节日走白名单只保留中国也在过的,纯国外节日暂时注释掉(见 SOLAR_FESTIVAL_WHITELIST
* - 法定节假日调休(休/班)数据由 HolidayUtil 内置,覆盖国务院历年放假安排
* - 按「y-m-d」缓存计算结果日历一屏 42 格、频繁切月,避免重复实例化 Solar/Lunar
*/
/** 单日黄历信息 */
/** 节日条目kind 用于差异化配色(传统农历节日红、公历现代节日橙),产品要求节日颜色不能都一样 */
export interface FestivalItem {
kind: 'modern' | 'traditional';
name: string;
}
/** 单日黄历信息(日历格子用) */
export interface DayAlmanac {
/** 格子副标题:优先级 节日 > 节气 > 初一显示月名 > 农历日 */
displayText: string;
/** 副标题类型用于差异化配色festival节日 / jieqi节气 / lunar农历 */
displayType: 'festival' | 'jieqi' | 'lunar';
/** 农历文案:初一显示月名(如「七月」),其余显示农历日(如「三十」),始终展示、不被节日覆盖 */
lunarText: string;
/** 当天节气(无则空串),与节日并列展示互不覆盖 */
jieQi: string;
/** 过滤后的节日列表:农历传统节日在前、公历现代节日在后,格子展示第一个、悬浮提示展示全部 */
festivalItems: FestivalItem[];
/** 完整农历日期,如「正月初一」,悬浮提示用 */
lunarDate: string;
/** 当天节气(无则空串) */
jieQi: string;
/** 当天全部节日(农历节日 + 公历/国际节日),悬浮提示用 */
festivals: string[];
/** 法定节假日安排null=正常工作日/周末,否则含名称与是否补班 */
holiday: null | { isWork: boolean; name: string };
}
/** 单日黄历详情(侧栏「选中日」卡片用,信息比格子更全) */
export interface DayAlmanacDetail {
/** 星期文本,如「星期三」 */
week: string;
/** 完整农历日期,如「六月廿九」 */
lunarDate: string;
/** 干支纪年月日,如「丙午年 丙申月 庚辰日」 */
ganZhi: string;
/** 生肖,如「马」 */
shengXiao: string;
/** 黄历宜(民俗参考,侧栏氛围信息) */
yi: string[];
/** 黄历忌 */
ji: string[];
}
/**
* 公历节日白名单:只保留中国也在过的节日(法定/官方节日 + 已本土化的舶来节日)
* 产品要求暂时屏蔽国内基本不过的国外节日,注释保留在这里,后续要恢复解开对应注释即可
*/
const SOLAR_FESTIVAL_WHITELIST = new Set([
'元旦节',
'情人节',
'妇女节',
'植树节',
'消费者权益日',
'劳动节',
'青年节',
'儿童节',
'建党节',
'建军节',
'教师节',
'国庆节',
'母亲节',
'父亲节',
'平安夜',
'圣诞节',
// '愚人节',
// '万圣节前夜',
// '万圣节',
// '感恩节',
]);
/** 计算结果缓存:跨月切换时同一天不重复计算 */
const cache = new Map<string, DayAlmanac>();
/** 详情缓存与格子缓存分开:详情仅选中日才算,字段更多没必要提前算 42 格 */
const detailCache = new Map<string, DayAlmanacDetail>();
/**
* 获取某个公历日期的黄历信息(农历/节气/节日/法定调休)
* @param year 公历年
@@ -42,38 +93,57 @@ export function getDayAlmanac(year: number, month: number, day: number): DayAlma
const solar = Solar.fromYmd(year, month, day);
const lunar = solar.getLunar();
// 节日合并:农历节日在前(春节等中国节日优先展示),其后公历节日与国际纪念日
const festivals = [
...lunar.getFestivals(),
...solar.getFestivals(),
...lunar.getOtherFestivals(),
...solar.getOtherFestivals(),
// 节日组装:农历传统节日(含除夕/下元节等民俗,都是中国节日全保留)在前,
// 公历节日过白名单在后solar.getOtherFestivals() 是「世界XX日」类国际纪念日国内基本不过整体不展示
const festivalItems: FestivalItem[] = [
...[...lunar.getFestivals(), ...lunar.getOtherFestivals()].map((name) => ({
kind: 'traditional' as const,
name,
})),
...solar
.getFestivals()
.filter((name) => SOLAR_FESTIVAL_WHITELIST.has(name))
.map((name) => ({ kind: 'modern' as const, name })),
];
const jieQi = lunar.getJieQi();
// 初一显示月名(如「二月」),其余显示农历日(如「十五」)
const lunarDay =
lunar.getDay() === 1 ? `${lunar.getMonthInChinese()}` : lunar.getDayInChinese();
// 副标题择优:节日 > 节气 > 农历日,格子空间有限只显示一条
let displayText = lunarDay;
let displayType: DayAlmanac['displayType'] = 'lunar';
if (festivals.length > 0) {
displayText = festivals[0] as string;
displayType = 'festival';
} else if (jieQi) {
displayText = jieQi;
displayType = 'jieqi';
}
// 初一显示月名(如「二月」),其余显示农历日(如「十五」);农历始终展示,不再被节日/节气覆盖
const lunarText =
lunar.getDay() === 1 ? `${lunar.getMonthInChinese()}` : lunar.getDayInChinese();
const h = HolidayUtil.getHoliday(year, month, day);
const result: DayAlmanac = {
displayText,
displayType,
lunarText,
jieQi: lunar.getJieQi(),
festivalItems,
lunarDate: `${lunar.getMonthInChinese()}${lunar.getDayInChinese()}`,
jieQi,
festivals,
holiday: h ? { isWork: h.isWork(), name: h.getName() } : null,
};
cache.set(key, result);
return result;
}
/**
* 获取某个公历日期的黄历详情(星期/完整农历/干支/生肖/宜忌)
* 供侧栏「选中日」卡片展示,走独立缓存避免与格子计算互相拖累
* @param year 公历年
* @param month 公历月1-12
* @param day 公历日
*/
export function getDayAlmanacDetail(year: number, month: number, day: number): DayAlmanacDetail {
const key = `${year}-${month}-${day}`;
const cached = detailCache.get(key);
if (cached) return cached;
const solar = Solar.fromYmd(year, month, day);
const lunar = solar.getLunar();
const result: DayAlmanacDetail = {
week: `星期${solar.getWeekInChinese()}`,
lunarDate: `${lunar.getMonthInChinese()}${lunar.getDayInChinese()}`,
ganZhi: `${lunar.getYearInGanZhi()}${lunar.getMonthInGanZhi()}${lunar.getDayInGanZhi()}`,
shengXiao: lunar.getYearShengXiao(),
yi: lunar.getDayYi(),
ji: lunar.getDayJi(),
};
detailCache.set(key, result);
return result;
}

View File

@@ -61,6 +61,45 @@ export type TodoCenterResult = {
items: TodoCenterItem[];
};
/** 未来7天预约单日分布 */
export type AppointmentUpcomingDay = {
date: string;
label: string;
week: string;
count: number;
};
/** 未来7天预约统计卡片 */
export type AppointmentUpcomingResult = {
total: number;
days: AppointmentUpcomingDay[];
};
/** 未来7天预约明细行 */
export type AppointmentUpcomingItem = {
register_id: number;
order_no: string;
visit_date: string;
week: 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;
};
/** 未来7天预约明细分页结果 */
export type AppointmentUpcomingListResult = {
items: AppointmentUpcomingItem[];
total: number;
};
/**
* 获取当前登录角色的工作台布局
*/
@@ -115,3 +154,26 @@ export async function getPlatformOverviewApi() {
export async function getTodoCenterApi() {
return requestClient.get<TodoCenterResult>(`${prefix}todo-center`);
}
/**
* 未来7天预约号统计卡片总数 + 按天分布scope 后端按登录身份判定)
*/
export async function getAppointmentUpcomingApi() {
return requestClient.get<AppointmentUpcomingResult>(
`${prefix}appointment-upcoming`,
);
}
/**
* 未来7天预约明细卡片点击弹窗分页 + 日期筛选)
*/
export async function getAppointmentUpcomingListApi(data: {
page: number;
page_size: number;
visit_date?: string;
}) {
return requestClient.get<AppointmentUpcomingListResult>(
`${prefix}appointment-upcoming-list`,
{ params: data },
);
}

View File

@@ -0,0 +1,455 @@
<script lang="ts" setup>
/**
* 公告轮播条(单行):看板卡内嵌 / 独立卡两种形态
* 为什么做成单行轮播:公告曝光频率需求低但要常驻,列表卡占一整列太浪费;
* 压成一行自动轮播5s 一条hover 暂停、可手动翻页),点击弹详情,
* 把首屏空间让给快捷入口与日历。取代原 WidgetNotice 列表卡(该组件已弃用保留)
* 形态默认内嵌驾驶舱卡底部带上分隔线standalone 独立卡(无驾驶舱角色全宽一行)
*/
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { IconifyIcon as VbenIcon } from '@vben/icons';
import { getLatestNotice } from '#/views/system/notice/api';
interface Props {
/** 独立卡形态:无驾驶舱角色时公告条单独成行(带卡片壳) */
standalone?: boolean;
}
withDefaults(defineProps<Props>(), { standalone: false });
/** 公告项announcement/latest 返回结构) */
type NoticeItem = {
avatar: string;
content: string;
date: string;
title: string;
};
/** 展示用公告项:附加前端推导的类型标签 */
type DisplayNoticeItem = NoticeItem & {
typeLabel: string;
typeTone: 'danger' | 'info' | 'primary' | 'warning';
};
/**
* 按标题关键词推导公告类型(纯前端展示,后端无类型字段)
* 顺序:维护类优先(最需要注意)→ 更新 → 活动 → 默认公告
*/
function resolveNoticeType(title: string): Pick<DisplayNoticeItem, 'typeLabel' | 'typeTone'> {
const t = title || '';
if (/维护|停机|故障/.test(t)) return { typeLabel: '维护', typeTone: 'danger' };
if (/更新|升级|版本|上线/.test(t)) return { typeLabel: '更新', typeTone: 'info' };
if (/活动|福利|优惠|节/.test(t)) return { typeLabel: '活动', typeTone: 'warning' };
return { typeLabel: '公告', typeTone: 'primary' };
}
const items = ref<DisplayNoticeItem[]>([]);
/** 当前轮播下标 */
const idx = ref(0);
/** hover 时暂停自动轮播,避免用户正在读时被切走 */
const paused = ref(false);
let timer: null | ReturnType<typeof setInterval> = null;
const current = computed(() => items.value[idx.value] || null);
/** 详情弹层当前展示的公告 */
const activeItem = ref<DisplayNoticeItem | null>(null);
/** 公告详情弹层:只读展示,关闭默认页脚按钮 */
const [DetailModal, detailModalApi] = useVbenModal({
footer: false,
header: false,
closable: true,
class: 'w-[560px]',
});
/** 弹层类型 pill 的 tone class避免模板里拼接字符串 */
const activeToneClass = computed(() =>
activeItem.value ? `pill--${activeItem.value.typeTone}` : '',
);
/**
* 拉取最新公告并附加类型标签;失败静默隐藏整条(空数组时组件不渲染)
*/
async function fetchLatest() {
try {
const res = await getLatestNotice(10);
const list: NoticeItem[] = Array.isArray(res) ? res : [];
items.value = list.map((it) => ({ ...it, ...resolveNoticeType(it.title) }));
} catch {
items.value = [];
}
}
/** 下一条(自动轮播与手动共用,循环) */
function next() {
if (items.value.length === 0) return;
idx.value = (idx.value + 1) % items.value.length;
}
/** 上一条(循环) */
function prev() {
if (items.value.length === 0) return;
idx.value = (idx.value - 1 + items.value.length) % items.value.length;
}
/** 打开详情弹层content 已全量在手,无需再请求) */
function openDetail(item: DisplayNoticeItem) {
activeItem.value = item;
detailModalApi.open();
}
onMounted(async () => {
await fetchLatest();
// 只有多于一条才需要轮播
if (items.value.length > 1) {
timer = setInterval(() => {
if (!paused.value) next();
}, 5000);
}
});
onUnmounted(() => {
if (timer) clearInterval(timer);
});
</script>
<template>
<div
v-if="items.length > 0"
class="notice-ticker"
:class="{ standalone }"
@mouseenter="paused = true"
@mouseleave="paused = false"
>
<span class="nt-megaphone">
<VbenIcon icon="lucide:megaphone" />
</span>
<!-- 轮播视口固定一行高新条从下滚入旧条向上滚出 -->
<div class="nt-viewport" role="button" tabindex="0" @click="current && openDetail(current)">
<Transition name="nt-roll">
<div v-if="current" :key="idx" class="nt-line">
<span class="pill" :class="`pill--${current.typeTone}`">{{ current.typeLabel }}</span>
<span class="nt-title">{{ current.title }}</span>
<!-- 公告内容后端可控沿用原工作台富文本渲染单行截断 div 避免 span 内嵌块级标签 -->
<!-- eslint-disable-next-line vue/no-v-html -->
<div class="nt-summary" v-html="current.content"></div>
<span class="nt-date">{{ current.date }}</span>
</div>
</Transition>
</div>
<div class="nt-ctrl">
<button class="nt-ctrl-btn" title="上一条" type="button" @click.stop="prev">
<VbenIcon icon="lucide:chevron-up" />
</button>
<span class="nt-ctrl-count">{{ idx + 1 }}/{{ items.length }}</span>
<button class="nt-ctrl-btn" title="下一条" type="button" @click.stop="next">
<VbenIcon icon="lucide:chevron-down" />
</button>
</div>
<!-- 公告详情弹层premium 风格 hero + 富文本正文沿用原列表卡的弹层设计 -->
<DetailModal>
<div v-if="activeItem" class="notice-detail">
<div class="notice-detail__hero">
<span class="notice-detail__hero-icon">
<VbenIcon :icon="activeItem.avatar || 'lucide:megaphone'" />
</span>
<div class="notice-detail__hero-meta">
<div class="notice-detail__hero-top">
<span class="pill" :class="activeToneClass">
{{ activeItem.typeLabel }}
</span>
<span class="notice-detail__date">{{ activeItem.date }}</span>
</div>
<div class="notice-detail__title">{{ activeItem.title }}</div>
</div>
</div>
<!-- 公告内容后端可控沿用列表同源富文本渲染 -->
<!-- eslint-disable-next-line vue/no-v-html -->
<div class="notice-detail__content" v-html="activeItem.content"></div>
</div>
</DetailModal>
</div>
</template>
<style scoped>
/* 内嵌形态(默认):驾驶舱卡底部一行,上分隔线与数据区分层 */
.notice-ticker {
display: flex;
gap: 10px;
align-items: center;
margin-top: 14px;
padding-top: 12px;
border-top: 1px solid hsl(var(--border) / 0.6);
}
/* 独立卡形态:无驾驶舱角色时单独成行 */
.notice-ticker.standalone {
margin-top: 0;
padding: 12px 20px;
background: hsl(var(--card));
border: 1px solid hsl(var(--border) / 0.7);
border-radius: 12px;
}
.nt-megaphone {
display: grid;
flex-shrink: 0;
place-items: center;
width: 26px;
height: 26px;
color: hsl(var(--primary));
background: hsl(var(--primary) / 0.09);
border-radius: 8px;
}
.nt-megaphone :deep(svg) {
width: 14px;
height: 14px;
}
/* 轮播视口:固定一行高 + 裁剪滚动动画 */
.nt-viewport {
position: relative;
flex: 1;
height: 22px;
min-width: 0;
overflow: hidden;
cursor: pointer;
outline: none;
}
.nt-line {
position: absolute;
inset: 0;
display: flex;
gap: 8px;
align-items: center;
min-width: 0;
}
.nt-title {
flex-shrink: 0;
max-width: 40%;
overflow: hidden;
font-size: 13px;
font-weight: 550;
color: hsl(var(--foreground));
text-overflow: ellipsis;
white-space: nowrap;
}
.nt-viewport:hover .nt-title {
color: hsl(var(--primary));
}
/* 摘要吃掉剩余宽度单行截断 */
.nt-summary {
flex: 1;
min-width: 0;
overflow: hidden;
font-size: 12px;
color: hsl(var(--muted-foreground));
text-overflow: ellipsis;
white-space: nowrap;
}
.nt-summary :deep(a) {
color: hsl(var(--primary));
}
/* 富文本里的段落压平成行内,避免默认 margin 撑破单行视口 */
.nt-summary :deep(p) {
display: inline;
margin: 0;
}
.nt-date {
flex-shrink: 0;
font-size: 12px;
color: hsl(var(--muted-foreground) / 0.8);
white-space: nowrap;
}
/* 翻页控制:上一条/序号/下一条 */
.nt-ctrl {
display: flex;
flex-shrink: 0;
gap: 2px;
align-items: center;
}
.nt-ctrl-btn {
display: grid;
place-items: center;
width: 20px;
height: 20px;
color: hsl(var(--muted-foreground));
cursor: pointer;
background: transparent;
border: none;
border-radius: 6px;
transition: color 0.15s ease, background-color 0.15s ease;
}
.nt-ctrl-btn:hover {
color: hsl(var(--primary));
background: hsl(var(--primary) / 0.08);
}
.nt-ctrl-btn :deep(svg) {
width: 13px;
height: 13px;
}
.nt-ctrl-count {
min-width: 32px;
font-size: 11px;
color: hsl(var(--muted-foreground) / 0.8);
text-align: center;
font-variant-numeric: tabular-nums;
}
/* 垂直滚动动画:新条从下滚入、旧条向上滚出(广播 ticker 惯例) */
.nt-roll-enter-active,
.nt-roll-leave-active {
transition: transform 0.4s ease, opacity 0.4s ease;
}
.nt-roll-enter-from {
opacity: 0;
transform: translateY(100%);
}
.nt-roll-leave-to {
opacity: 0;
transform: translateY(-100%);
}
/* ===== 类型 pill主题变量着色亮/暗自适应 ===== */
.pill {
flex-shrink: 0;
padding: 1px 7px;
font-size: 11px;
font-weight: 600;
line-height: 16px;
border-radius: 999px;
}
.pill--primary {
color: hsl(var(--primary));
background: hsl(var(--primary) / 0.1);
}
.pill--info {
color: hsl(210 90% 45%);
background: hsl(210 90% 50% / 0.12);
}
.pill--warning {
color: hsl(var(--warning, 38 92% 40%));
background: hsl(var(--warning, 38 92% 50%) / 0.14);
}
.pill--danger {
color: hsl(var(--destructive));
background: hsl(var(--destructive) / 0.1);
}
/* ===== 详情弹层(沿用原列表卡设计语言) ===== */
.notice-detail {
padding: 4px 2px 8px;
}
/* hero 头:柔和主色光斑背景 */
.notice-detail__hero {
display: flex;
gap: 14px;
align-items: flex-start;
padding: 16px 18px;
margin-bottom: 16px;
background:
radial-gradient(
80% 120% at 0% 0%,
hsl(var(--primary) / 0.14),
transparent 60%
),
linear-gradient(
165deg,
hsl(var(--muted) / 0.4),
hsl(var(--card, var(--background)))
);
border: 1px solid hsl(var(--border));
border-radius: 14px;
}
.notice-detail__hero-icon {
display: grid;
flex-shrink: 0;
place-items: center;
width: 42px;
height: 42px;
color: hsl(var(--primary));
background: hsl(var(--primary) / 0.12);
border-radius: 12px;
}
.notice-detail__hero-icon :deep(svg) {
width: 20px;
height: 20px;
}
.notice-detail__hero-meta {
flex: 1;
min-width: 0;
}
.notice-detail__hero-top {
display: flex;
gap: 8px;
align-items: center;
}
.notice-detail__date {
font-size: 12px;
color: hsl(var(--muted-foreground));
}
.notice-detail__title {
margin-top: 6px;
font-size: 17px;
font-weight: 700;
color: hsl(var(--foreground));
letter-spacing: -0.02em;
overflow-wrap: break-word;
}
.notice-detail__content {
max-height: 52vh;
padding: 0 2px;
overflow-y: auto;
font-size: 13.5px;
line-height: 1.75;
color: hsl(var(--foreground) / 0.88);
overflow-wrap: break-word;
}
.notice-detail__content :deep(a) {
color: hsl(var(--primary));
}
.notice-detail__content :deep(img) {
max-width: 100%;
border-radius: 10px;
}
@media (prefers-reduced-motion: reduce) {
.nt-roll-enter-active,
.nt-roll-leave-active {
transition: none;
}
}
</style>

View File

@@ -181,6 +181,9 @@ onMounted(fetchAll);
/>
</div>
</template>
<!-- 卡底公告轮播条父级按布局配置注入NoticeTicker数据源独立不受本卡 loading 影响 -->
<slot name="notice"></slot>
</div>
</template>

View File

@@ -0,0 +1,399 @@
<script lang="ts" setup>
/**
* 未来7天预约号卡片总数 + 按天分布横条,点击打开明细弹窗(分页 + 日期筛选)
* scope 由后端按登录身份判定:医生=本人 / 诊所管理员=本店 / 平台管理员=全平台,
* 前端不传身份参数,防止越权拉取他人数据
*/
import type {
AppointmentUpcomingDay,
AppointmentUpcomingItem,
AppointmentUpcomingResult,
} from '../api';
import { onMounted, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { IconifyIcon as VbenIcon } from '@vben/icons';
import { Empty, Pagination, Spin, Tag } from 'ant-design-vue';
import { getAppointmentUpcomingApi, getAppointmentUpcomingListApi } from '../api';
import KpiCard from './KpiCard.vue';
import StatChip from './StatChip.vue';
interface Props {
title: string;
}
defineProps<Props>();
const data = ref<AppointmentUpcomingResult | null>(null);
const loading = ref(true);
const error = ref(false);
/** 明细弹窗状态:列表/分页/日期筛选('' = 全部7天 */
const listItems = ref<AppointmentUpcomingItem[]>([]);
const listTotal = ref(0);
const listLoading = ref(false);
const listPage = ref(1);
const listPageSize = ref(10);
const filterDate = ref('');
/** 明细弹窗footer 关闭(纯查看),标题带统计窗口说明 */
const [DetailModal, detailModalApi] = useVbenModal({
footer: false,
title: '未来7天预约明细',
class: 'w-[720px]',
});
/**
* 拉取卡片统计;失败进入错误态可重试
*/
async function fetchSummary() {
loading.value = true;
error.value = false;
try {
data.value = await getAppointmentUpcomingApi();
} catch {
error.value = true;
} finally {
loading.value = false;
}
}
/**
* 拉取明细分页(按当前筛选日期与页码)
*/
async function fetchList() {
listLoading.value = true;
try {
const res = await getAppointmentUpcomingListApi({
page: listPage.value,
page_size: listPageSize.value,
visit_date: filterDate.value,
});
listItems.value = res.items || [];
listTotal.value = res.total || 0;
} catch {
listItems.value = [];
listTotal.value = 0;
} finally {
listLoading.value = false;
}
}
/**
* 打开明细弹窗:默认展示全部 7 天,从第一页开始
*/
function openDetail(day?: AppointmentUpcomingDay) {
filterDate.value = day ? day.date : '';
listPage.value = 1;
detailModalApi.open();
fetchList();
}
/**
* 切换日期筛选(弹窗内 pill 点击):回到第一页重查
*/
function switchFilter(date: string) {
if (filterDate.value === date) return;
filterDate.value = date;
listPage.value = 1;
fetchList();
}
/**
* 翻页
*/
function onPageChange(page: number, pageSize: number) {
listPage.value = page;
listPageSize.value = pageSize;
fetchList();
}
/**
* 状态标签颜色:待支付/待接诊需要关注用暖色,其余中性
*/
function statusColor(item: AppointmentUpcomingItem) {
if (item.is_pay !== 1) return 'orange';
if (item.status === 1) return 'blue';
if (item.status === 2) return 'processing';
return 'default';
}
onMounted(fetchSummary);
</script>
<template>
<div class="appt-widget">
<KpiCard
:title="title"
subtitle="含今天"
:loading="loading"
:error="error"
@refresh="fetchSummary"
>
<template v-if="data">
<StatChip
:highlight="data.total > 0"
:value="data.total"
icon="lucide:calendar-clock"
label="未来7天预约"
@click="openDetail()"
/>
<!-- 按天分布横条每天一格点击直接按天筛选打开明细 -->
<div class="appt-days">
<div
v-for="day in data.days"
:key="day.date"
class="appt-day"
:class="{ 'appt-day--active': day.count > 0 }"
role="button"
tabindex="0"
@click="openDetail(day)"
@keydown.enter="openDetail(day)"
>
<span class="appt-day__label">{{ day.label }}</span>
<span class="appt-day__count">{{ day.count }}</span>
<span class="appt-day__week">{{ day.week }}</span>
</div>
</div>
</template>
</KpiCard>
<!-- 明细弹窗日期筛选 pill + 明细行 + 分页放卡片外层避免随加载态卸载 -->
<DetailModal>
<div class="appt-detail">
<div class="appt-detail__filters">
<span
class="appt-pill"
:class="{ 'appt-pill--active': filterDate === '' }"
role="button"
tabindex="0"
@click="switchFilter('')"
@keydown.enter="switchFilter('')"
>
全部7天
</span>
<span
v-for="day in data ? data.days : []"
:key="day.date"
class="appt-pill"
:class="{ 'appt-pill--active': filterDate === day.date }"
role="button"
tabindex="0"
@click="switchFilter(day.date)"
@keydown.enter="switchFilter(day.date)"
>
{{ day.label }}({{ day.count }})
</span>
</div>
<Spin :spinning="listLoading">
<ul v-if="listItems.length > 0" class="appt-detail__list">
<li v-for="item in listItems" :key="item.register_id" class="appt-row">
<div class="appt-row__date">
<span class="appt-row__day">{{ item.visit_date }}</span>
<span class="appt-row__week">{{ item.week }}</span>
</div>
<div class="appt-row__main">
<div class="appt-row__line1">
<span class="appt-row__patient">{{ item.patient_name || '未知就诊人' }}</span>
<span class="appt-row__slot">{{ item.slot_label }}</span>
</div>
<div class="appt-row__line2">
<span v-if="item.doctor_name">医生{{ item.doctor_name }}</span>
<span v-if="item.store_name">门店{{ item.store_name }}</span>
<span v-if="item.order_no">单号{{ item.order_no }}</span>
</div>
</div>
<Tag :color="statusColor(item)">{{ item.status_text }}</Tag>
</li>
</ul>
<Empty v-else-if="!listLoading" description="暂无预约" />
</Spin>
<div v-if="listTotal > 0" class="appt-detail__pager">
<Pagination
:current="listPage"
:page-size="listPageSize"
:total="listTotal"
:show-size-changer="false"
size="small"
@change="onPageChange"
/>
</div>
</div>
</DetailModal>
</div>
</template>
<style scoped>
/* ===== 卡片内按天分布横条 ===== */
.appt-days {
display: flex;
gap: 6px;
flex-wrap: wrap;
align-items: stretch;
}
.appt-day {
display: flex;
flex-direction: column;
gap: 2px;
align-items: center;
min-width: 52px;
padding: 8px 6px;
border: 1px solid hsl(var(--border) / 0.6);
border-radius: 10px;
cursor: pointer;
transition:
border-color 0.15s ease,
background 0.15s ease;
}
.appt-day:hover,
.appt-day:focus-visible {
border-color: hsl(var(--primary) / 0.4);
background: hsl(var(--primary) / 0.05);
outline: none;
}
/* 有预约的天:主色轻着色突出 */
.appt-day--active {
background: hsl(var(--primary) / 0.06);
border-color: hsl(var(--primary) / 0.25);
}
.appt-day__label {
font-size: 11px;
color: hsl(var(--muted-foreground));
}
.appt-day__count {
font-size: 16px;
font-weight: 650;
color: hsl(var(--foreground));
}
.appt-day--active .appt-day__count {
color: hsl(var(--primary));
}
.appt-day__week {
font-size: 10px;
color: hsl(var(--muted-foreground) / 0.8);
}
/* ===== 明细弹窗 ===== */
.appt-detail {
padding: 2px 2px 6px;
}
.appt-detail__filters {
display: flex;
gap: 6px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.appt-pill {
padding: 3px 10px;
border: 1px solid hsl(var(--border));
border-radius: 999px;
font-size: 12px;
color: hsl(var(--muted-foreground));
cursor: pointer;
transition:
color 0.15s ease,
border-color 0.15s ease,
background 0.15s ease;
}
.appt-pill:hover {
color: hsl(var(--primary));
border-color: hsl(var(--primary) / 0.4);
}
.appt-pill--active {
color: hsl(var(--primary));
background: hsl(var(--primary) / 0.08);
border-color: hsl(var(--primary) / 0.4);
}
.appt-detail__list {
margin: 0;
padding: 0;
list-style: none;
max-height: 48vh;
overflow-y: auto;
}
.appt-row {
display: flex;
gap: 12px;
align-items: center;
padding: 10px 6px;
}
.appt-row + .appt-row {
border-top: 1px solid hsl(var(--border) / 0.5);
}
.appt-row__date {
display: flex;
flex-direction: column;
gap: 1px;
align-items: center;
min-width: 78px;
flex-shrink: 0;
}
.appt-row__day {
font-size: 13px;
font-weight: 600;
color: hsl(var(--foreground));
}
.appt-row__week {
font-size: 11px;
color: hsl(var(--muted-foreground));
}
.appt-row__main {
flex: 1;
min-width: 0;
}
.appt-row__line1 {
display: flex;
gap: 8px;
align-items: center;
min-width: 0;
}
.appt-row__patient {
font-size: 13px;
font-weight: 550;
color: hsl(var(--foreground));
}
.appt-row__slot {
font-size: 12px;
color: hsl(var(--primary));
}
.appt-row__line2 {
display: flex;
gap: 12px;
flex-wrap: wrap;
margin-top: 2px;
font-size: 12px;
color: hsl(var(--muted-foreground));
}
.appt-detail__pager {
display: flex;
justify-content: flex-end;
margin-top: 12px;
}
</style>

View File

@@ -0,0 +1,386 @@
<script lang="ts" setup>
/**
* 工作台「日程待办」widget放在快捷入口同一行右侧
* - 露出未来 7 天(含今天)的日历待办:紧凑列表自适应高度,超出内部滚动
* - 勾选即完成;点条目弹编辑弹窗(复用日历模块 TodoModal头部可新建/跳转日程日历页
* - 无待办时收缩为小尺寸空态,不占工作台空间
*/
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useVbenModal } from '@vben/common-ui';
import { IconifyIcon as VbenIcon } from '@vben/icons';
import { Checkbox, Spin } from 'ant-design-vue';
import dayjs from 'dayjs';
import { getCalendarTodoList, toggleCalendarTodoDone } from '#/views/dashboard/calendar/api';
import TodoModalComp from '#/views/dashboard/calendar/components/todo-modal.vue';
interface Props {
title: string;
}
defineProps<Props>();
const router = useRouter();
const loading = ref(false);
/** 未来 7 天(含今天)的待办,未完成在前、时间升序 */
const list = ref<any[]>([]);
/** 待办新建/编辑弹窗:与日程日历页共用同一组件 */
const [TodoFormModal, todoModalApi] = useVbenModal({
connectedComponent: TodoModalComp,
});
/** 未完成数:标题角标 */
const undoneCount = computed(
() => list.value.filter((t) => Number(t.is_done) !== 1).length,
);
/**
* 拉取今天 0 点起 7 天内的待办
* 排序策略:未完成优先(工作台关注「还要做什么」),组内按时间升序
*/
async function fetchTodos() {
loading.value = true;
try {
const start = dayjs().startOf('day');
const end = dayjs().add(6, 'day').endOf('day');
const res = await getCalendarTodoList({
start_time: start.unix(),
end_time: end.unix(),
});
list.value = (Array.isArray(res) ? res : []).sort((a: any, b: any) => {
const doneDiff = Number(a.is_done) - Number(b.is_done);
if (doneDiff !== 0) return doneDiff;
return Number(a.todo_time) - Number(b.todo_time);
});
} catch {
list.value = [];
} finally {
loading.value = false;
}
}
/** 行日期标签:今天/明天,更远的显示 M/D 周X */
function dayLabel(todo: any) {
const t = dayjs.unix(Number(todo.todo_time)).startOf('day');
const diff = t.diff(dayjs().startOf('day'), 'day');
if (diff === 0) return '今天';
if (diff === 1) return '明天';
const weeks = ['日', '一', '二', '三', '四', '五', '六'];
return `${t.format('M/D')}${weeks[t.day()]}`;
}
/** 行时间标签00:00 视为全天 */
function timeLabel(todo: any) {
const t = dayjs.unix(Number(todo.todo_time));
return t.format('HH:mm') === '00:00' ? '全天' : t.format('HH:mm');
}
/** 勾选切换完成状态:本地同步字段避免整列表重拉闪烁 */
async function onToggle(todo: any, checked: boolean) {
await toggleCalendarTodoDone({ id: todo.id, is_done: checked ? 1 : 0 });
todo.is_done = checked ? 1 : 0;
}
/** 新建待办:默认今天,保存后重拉列表 */
function createTodo() {
todoModalApi
.setData({
update: false,
defaultDate: dayjs().format('YYYY-MM-DD'),
onSaved: fetchTodos,
})
.open();
}
/** 编辑待办 */
function editTodo(todo: any) {
todoModalApi.setData({ update: true, values: todo, onSaved: fetchTodos }).open();
}
/** 跳转完整日程日历页xk_menu path=/calendar */
function goCalendar() {
router.push('/calendar').catch(() => {});
}
onMounted(fetchTodos);
</script>
<template>
<div class="calendar-todo-widget">
<div class="ctw-head">
<span class="ctw-title">
<VbenIcon class="ctw-title-icon" icon="lucide:calendar-check-2" />
{{ title }}
<em v-if="undoneCount > 0" class="ctw-count">{{ undoneCount }}</em>
</span>
<div class="ctw-actions">
<button class="ctw-btn" @click="createTodo">
<VbenIcon icon="lucide:plus" />新建
</button>
<button class="ctw-btn ghost" title="打开日程日历" @click="goCalendar">
<VbenIcon icon="lucide:calendar-days" />日历
</button>
</div>
</div>
<Spin :spinning="loading">
<!-- 有待办紧凑列表超出内部滚动列表高度自适应内容 -->
<div v-if="list.length > 0" class="ctw-list">
<div
v-for="todo in list"
:key="todo.id"
class="ctw-item"
:class="{ done: Number(todo.is_done) === 1 }"
>
<Checkbox
class="ctw-item-check"
:checked="Number(todo.is_done) === 1"
@change="(e: any) => onToggle(todo, e.target.checked)"
/>
<div class="ctw-item-main" @click="editTodo(todo)">
<div class="ctw-item-title">{{ todo.title }}</div>
<div class="ctw-item-meta">
<span class="ctw-item-day" :class="{ today: dayLabel(todo) === '今天' }">
{{ dayLabel(todo) }}
</span>
<span class="ctw-item-time">
<VbenIcon icon="lucide:clock" />{{ timeLabel(todo) }}
</span>
<span v-if="Number(todo.remind_status) === 2" class="ctw-item-fail">提醒失败</span>
</div>
</div>
<VbenIcon class="ctw-item-arrow" icon="lucide:chevron-right" />
</div>
</div>
<!-- 空态小尺寸不占空间 -->
<div v-else class="ctw-empty">
<VbenIcon class="ctw-empty-icon" icon="lucide:list-checks" />
<span class="ctw-empty-text"> 7 天暂无待办</span>
<button class="ctw-btn" @click="createTodo">新建一条</button>
</div>
</Spin>
<TodoFormModal />
</div>
</template>
<style lang="scss" scoped>
/* 卡片外壳:与工作台其他 widget 一致(发丝线 + 12px 圆角,不用左侧色条) */
.calendar-todo-widget {
display: flex;
flex-direction: column;
height: 100%;
padding: 18px 20px;
background: hsl(var(--card));
border: 1px solid hsl(var(--border) / 70%);
border-radius: 12px;
}
/* 2/8 分时列宽较窄:允许标题与按钮组换行,避免挤压溢出 */
.ctw-head {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.ctw-title {
display: inline-flex;
gap: 6px;
align-items: center;
font-size: 15px;
font-weight: 600;
color: hsl(var(--foreground));
}
.ctw-title-icon {
font-size: 16px;
color: hsl(var(--primary));
}
.ctw-count {
padding: 0 7px;
font-size: 11px;
font-style: normal;
font-weight: 600;
line-height: 18px;
color: hsl(var(--primary));
background: hsl(var(--primary) / 12%);
border-radius: 999px;
}
.ctw-actions {
display: flex;
gap: 6px;
align-items: center;
}
.ctw-btn {
display: inline-flex;
gap: 3px;
align-items: center;
padding: 4px 10px;
font-size: 12px;
color: hsl(var(--primary));
cursor: pointer;
background: hsl(var(--primary) / 10%);
border: none;
border-radius: 999px;
transition: background-color 0.2s;
&:hover {
background: hsl(var(--primary) / 18%);
}
&.ghost {
color: hsl(var(--muted-foreground));
background: hsl(var(--muted) / 50%);
&:hover {
color: hsl(var(--foreground));
background: hsl(var(--muted) / 80%);
}
}
}
/* Spin 包裹层吃掉卡内剩余高度:与日历等高排布时列表在卡内弹性滚动(等高的关键) */
.calendar-todo-widget :deep(.ant-spin-nested-loading) {
flex: 1;
min-height: 0;
}
.calendar-todo-widget :deep(.ant-spin-nested-loading .ant-spin-container) {
display: flex;
flex-direction: column;
height: 100%;
}
/* 列表:吃满剩余高度,超出内部滚动(行高由同行的日历决定) */
.ctw-list {
display: flex;
flex: 1;
flex-direction: column;
gap: 6px;
min-height: 0;
padding-right: 4px;
overflow-y: auto;
}
/* 窄屏堆叠后行高不再受日历约束,限高防止长列表把工作台拉得过长 */
@media (max-width: 1024px) {
.ctw-list {
max-height: 320px;
}
}
.ctw-item {
display: flex;
gap: 8px;
align-items: flex-start;
padding: 8px 10px;
background: hsl(var(--muted) / 30%);
border: 1px solid transparent;
border-radius: 10px;
transition: border-color 0.2s, background-color 0.2s, box-shadow 0.2s;
&:hover {
background: hsl(var(--muted) / 45%);
border-color: hsl(var(--primary) / 35%);
box-shadow: 0 0 6px hsl(var(--primary) / 15%);
.ctw-item-arrow {
opacity: 1;
}
}
&.done {
.ctw-item-title {
color: hsl(var(--muted-foreground));
text-decoration: line-through;
}
}
}
.ctw-item-check {
margin-top: 1px;
}
.ctw-item-main {
flex: 1;
min-width: 0;
cursor: pointer;
}
.ctw-item-title {
overflow: hidden;
font-size: 13px;
font-weight: 500;
color: hsl(var(--foreground));
text-overflow: ellipsis;
white-space: nowrap;
}
.ctw-item-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
margin-top: 3px;
font-size: 11px;
color: hsl(var(--muted-foreground));
}
/* 日期标签:今天用主色强调,一眼区分紧急程度 */
.ctw-item-day {
padding: 0 6px;
font-weight: 600;
line-height: 16px;
background: hsl(var(--muted) / 60%);
border-radius: 4px;
&.today {
color: hsl(var(--primary));
background: hsl(var(--primary) / 12%);
}
}
.ctw-item-time {
display: inline-flex;
gap: 3px;
align-items: center;
}
.ctw-item-fail {
color: hsl(var(--destructive));
}
.ctw-item-arrow {
flex-shrink: 0;
margin-top: 3px;
font-size: 14px;
color: hsl(var(--muted-foreground));
opacity: 0;
transition: opacity 0.2s;
}
/* 空态:吃满剩余高度垂直居中(与日历等高时不显得头重脚轻) */
.ctw-empty {
display: flex;
flex: 1;
gap: 10px;
align-items: center;
justify-content: center;
padding: 22px 0;
}
.ctw-empty-icon {
font-size: 22px;
color: hsl(var(--muted-foreground) / 50%);
}
.ctw-empty-text {
font-size: 13px;
color: hsl(var(--muted-foreground));
}
</style>

View File

@@ -1,5 +1,8 @@
<script lang="ts" setup>
/**
* @deprecated 工作台公告已改用单行轮播条 NoticeTicker.vue列表卡占一整列空间浪费
* 本组件不再被布局引用,保留文件仅供回滚参考,勿再新增引用
*
* 最新动态Premium紧凑公告列表tinted 图标 + 类型 pill + 点击弹详情
* 类型为什么前端推导announcement/latestxk_notice 表)没有类型字段,
* 按标题关键词映射出「更新/活动/维护/公告」纯展示标签,后端无需改动

View File

@@ -0,0 +1,28 @@
<script lang="ts" setup>
/**
* 工作台「日程日历」widget快捷入口行下方全宽
* 直接复用日历模块的 CalendarPanel 紧凑模式(隐藏图例/格子变矮/每格 2 条待办),
* 面板自带月份大标题头部,故不再额外渲染 widget 标题title 仅为对齐布局接口保留)
*/
import CalendarPanel from '#/views/dashboard/calendar/components/CalendarPanel.vue';
interface Props {
title: string;
}
defineProps<Props>();
</script>
<template>
<div class="work-calendar-widget">
<CalendarPanel compact />
</div>
</template>
<style scoped>
/* widget 外壳与面板都撑满网格行高:与右侧快捷入口等高对齐(行高由日历内容决定) */
.work-calendar-widget {
height: 100%;
}
.work-calendar-widget :deep(.work-calendar) {
height: 100%;
}
</style>

View File

@@ -1,22 +1,27 @@
import type { Component } from 'vue';
import NoticeTicker from '../components/NoticeTicker.vue';
import WidgetAppointmentUpcoming from '../components/WidgetAppointmentUpcoming.vue';
import WidgetCalendarTodo from '../components/WidgetCalendarTodo.vue';
import WidgetClinicToday from '../components/WidgetClinicToday.vue';
import WidgetDoctorToday from '../components/WidgetDoctorToday.vue';
import WidgetNotice from '../components/WidgetNotice.vue';
import WidgetOrderPending from '../components/WidgetOrderPending.vue';
import WidgetPharmacistPending from '../components/WidgetPharmacistPending.vue';
import WidgetPlatformOverview from '../components/WidgetPlatformOverview.vue';
import WidgetQuickEntry from '../components/WidgetQuickEntry.vue';
import WidgetSalespersonSummary from '../components/WidgetSalespersonSummary.vue';
import WidgetTodoCenter from '../components/WidgetTodoCenter.vue';
import WidgetWorkCalendar from '../components/WidgetWorkCalendar.vue';
/**
* widget_code → 组件映射(与后端 WorkbenchWidgetEnum 对齐)
* 后端 layout 下发未知 code 时前端直接跳过,保证两端可独立灰度上线
* notice 已由列表卡WidgetNotice弃用保留换成单行轮播条 NoticeTicker
* 实际渲染位置由 index.vue 固定插槽控制(驾驶舱卡底内嵌 / 无驾驶舱独立一行)
*/
export const WIDGET_COMPONENT_MAP: Record<string, Component> = {
quick_nav: WidgetQuickEntry,
notice: WidgetNotice,
notice: NoticeTicker,
doctor_today: WidgetDoctorToday,
pharmacist_pending: WidgetPharmacistPending,
salesperson_summary: WidgetSalespersonSummary,
@@ -24,6 +29,10 @@ export const WIDGET_COMPONENT_MAP: Record<string, Component> = {
order_pending: WidgetOrderPending,
platform_overview: WidgetPlatformOverview,
todo_center: WidgetTodoCenter,
appointment_upcoming: WidgetAppointmentUpcoming,
// 日程待办(快捷入口行右侧)与日程日历(快捷入口行下方全宽),布局位置固定不进 KPI 区
calendar_todo: WidgetCalendarTodo,
work_calendar: WidgetWorkCalendar,
};
/** KPI 类模块(渲染在中部 KPI 区,其余按固定位置渲染) */
@@ -35,4 +44,5 @@ export const KPI_WIDGET_CODES = new Set([
'order_pending',
'platform_overview',
'todo_center',
'appointment_upcoming',
]);

View File

@@ -1,9 +1,11 @@
<script lang="ts" setup>
/**
* 工作台首页:按角色 layout 动态装载模块
* 超管/系统管理员(布局含 platform_overview / todo_center
* 问候头 → 顶部双列(左:总览+待办合并卡;右:公告)→ 快捷入口 → 其余 KPI
* 其他角色:问候头 → 快捷入口 → 角色 KPI 摘要 → 公告(维持原布局)
* 行结构(自上而下
* 第一行有驾驶舱platform_overview/todo_center时 = 左合并卡(卡底内嵌单行公告轮播)+ 右快捷入口(等高、内部滚动);
* 无驾驶舱时 = 公告轮播条全宽一行 + 快捷入口全宽一行
* 第二行:日程日历(左 6+ 日程待办(右 4两列等高待办列表内部滚动
* 之后:角色 KPI 摘要(按 sort 依次渲染)
* 布局由 workbench/layout 下发xk_role_workbench_widget 按角色配置),未配置的模块不渲染
*/
import type { WorkbenchLayoutItem } from './api';
@@ -11,6 +13,7 @@ import type { WorkbenchLayoutItem } from './api';
import { computed, onMounted, ref } from 'vue';
import { getWorkbenchLayoutApi } from './api';
import NoticeTicker from './components/NoticeTicker.vue';
import WidgetAdminHub from './components/WidgetAdminHub.vue';
import WorkspaceHeader from './components/WorkspaceHeader.vue';
import { KPI_WIDGET_CODES, WIDGET_COMPONENT_MAP } from './config/widgets';
@@ -40,11 +43,21 @@ const todoItem = computed(() =>
*/
const hasAdminHub = computed(() => !!overviewItem.value || !!todoItem.value);
/** 快捷入口区(全宽) */
/** 快捷入口:有驾驶舱时放第一行右列(与驾驶舱等高、内部滚动),否则全宽一行 */
const quickNavItem = computed(() =>
validItems.value.find((item) => item.widget_code === 'quick_nav'),
);
/** 日程待办:第二行右列(与日历等高,列表内部滚动) */
const calendarTodoItem = computed(() =>
validItems.value.find((item) => item.widget_code === 'calendar_todo'),
);
/** 日程日历第二行左列CalendarPanel 紧凑模式6/4 分中的 6 */
const workCalendarItem = computed(() =>
validItems.value.find((item) => item.widget_code === 'work_calendar'),
);
/** KPI 摘要区(中部,按 sort 依次渲染;总览/待办已合并进顶部驾驶舱,需排除) */
const kpiItems = computed(() =>
validItems.value.filter(
@@ -55,7 +68,7 @@ const kpiItems = computed(() =>
),
);
/** 公告区:有驾驶舱时放顶部右列,否则维持底部 */
/** 公告:单行轮播条(有驾驶舱时内嵌其卡底,无驾驶舱时独立全宽一行) */
const noticeItem = computed(() =>
validItems.value.find((item) => item.widget_code === 'notice'),
);
@@ -88,30 +101,64 @@ onMounted(fetchLayout);
</div>
<template v-if="loaded">
<!-- 管理驾驶舱超管/系统管理员左合并卡 + 右公告 -->
<!-- 第一有驾驶舱左合并卡卡底内嵌公告轮播条+ 右快捷入口等高内部滚动 -->
<div v-if="hasAdminHub" class="workspace-page__section" style="--delay: 1">
<div class="workspace-hero">
<div class="workspace-hero" :class="{ full: !quickNavItem }">
<div class="workspace-hero__main">
<WidgetAdminHub
:overview-title="overviewItem?.title || '平台总览'"
:todo-title="todoItem?.title || '待办中心'"
/>
>
<template v-if="noticeItem" #notice>
<NoticeTicker />
</template>
</WidgetAdminHub>
</div>
<div v-if="noticeItem" class="workspace-hero__side">
<div v-if="quickNavItem" class="workspace-hero__side">
<component
:is="WIDGET_COMPONENT_MAP[noticeItem.widget_code]"
:title="noticeItem.title"
:is="WIDGET_COMPONENT_MAP[quickNavItem.widget_code]"
:title="quickNavItem.title"
/>
</div>
</div>
</div>
<!-- 快捷入口全宽 -->
<div v-if="quickNavItem" class="workspace-page__section" style="--delay: 2">
<component
:is="WIDGET_COMPONENT_MAP[quickNavItem.widget_code]"
:title="quickNavItem.title"
/>
<!-- 无驾驶舱公告轮播条全宽一行 + 快捷入口全宽一行 -->
<template v-else>
<div v-if="noticeItem" class="workspace-page__section" style="--delay: 1">
<NoticeTicker standalone />
</div>
<div v-if="quickNavItem" class="workspace-page__section" style="--delay: 1">
<component
:is="WIDGET_COMPONENT_MAP[quickNavItem.widget_code]"
:title="quickNavItem.title"
/>
</div>
</template>
<!-- 第二行日程日历 6+ 日程待办 4等高缺一方时另一方全宽 -->
<div
v-if="workCalendarItem || calendarTodoItem"
class="workspace-page__section"
style="--delay: 2"
>
<div
class="workspace-calrow"
:class="{ 'has-side': workCalendarItem && calendarTodoItem }"
>
<div v-if="workCalendarItem" class="workspace-calrow__main">
<component
:is="WIDGET_COMPONENT_MAP[workCalendarItem.widget_code]"
:title="workCalendarItem.title"
/>
</div>
<div v-if="calendarTodoItem" class="workspace-calrow__side">
<component
:is="WIDGET_COMPONENT_MAP[calendarTodoItem.widget_code]"
:title="calendarTodoItem.title"
/>
</div>
</div>
</div>
<!-- 角色 KPI 摘要 -->
@@ -126,18 +173,6 @@ onMounted(fetchLayout);
:title="item.title"
/>
</div>
<!-- 最新动态无驾驶舱行时维持原底部位置 -->
<div
v-if="noticeItem && !hasAdminHub"
:style="{ '--delay': kpiItems.length + 3 }"
class="workspace-page__section"
>
<component
:is="WIDGET_COMPONENT_MAP[noticeItem.widget_code]"
:title="noticeItem.title"
/>
</div>
</template>
</div>
</div>
@@ -157,7 +192,7 @@ onMounted(fetchLayout);
margin: 0 auto;
}
/* 顶部驾驶舱行左合并卡58%+ 右公告42%),窄屏退化为上下堆叠 */
/* 顶部驾驶舱行左合并卡58%+ 右快捷入口42%),窄屏退化为上下堆叠 */
.workspace-hero {
display: grid;
grid-template-columns: minmax(0, 58fr) minmax(0, 42fr);
@@ -165,20 +200,97 @@ onMounted(fetchLayout);
align-items: stretch;
}
/* 未配置快捷入口时驾驶舱独占整行 */
.workspace-hero.full {
grid-template-columns: 1fr;
}
.workspace-hero__main,
.workspace-hero__side {
min-width: 0;
}
/* 公告卡撑满行高,与左侧合并卡底边对齐 */
.workspace-hero__side :deep(.notice-widget) {
height: 100%;
/* 行高完全由左侧驾驶舱决定:快捷入口绝对定位填充,不参与行高计算(钉住项多时只内部滚动,不把首行撑高) */
.workspace-hero__side {
position: relative;
}
/* 快捷入口改纵向弹性布局:头部固定,内容区吃掉剩余高度并内部滚动(与驾驶舱等高的关键) */
.workspace-hero__side :deep(.quick-entry) {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
}
/* 42% 列宽下瓷砖按格宽自适应列数align-content 顶对齐避免行间被拉开 */
.workspace-hero__side :deep(.quick-entry__grid) {
flex: 1;
grid-template-columns: repeat(auto-fill, minmax(92px, 1fr));
align-content: start;
min-height: 0;
overflow-y: auto;
}
/* 搜索结果同样吃满剩余高度滚动(解除组件内 320px 限高) */
.workspace-hero__side :deep(.quick-entry__results) {
flex: 1;
min-height: 0;
max-height: none;
overflow-y: auto;
}
@media (max-width: 1024px) {
.workspace-hero {
grid-template-columns: 1fr;
}
/* 堆叠后取消绝对定位(否则高度塌陷),瓷砖内容自然撑高不滚动 */
.workspace-hero__side :deep(.quick-entry) {
position: static;
}
.workspace-hero__side :deep(.quick-entry__grid) {
overflow-y: visible;
}
}
/* 第二行日程日历80%+ 日程待办20%)等高;行高由日历内容决定 */
.workspace-calrow {
display: grid;
grid-template-columns: 1fr;
gap: 16px;
align-items: stretch;
}
.workspace-calrow.has-side {
grid-template-columns: minmax(0, 8fr) minmax(0, 2fr);
}
.workspace-calrow__main,
.workspace-calrow__side {
min-width: 0;
}
/* 行高完全由日历决定:待办卡绝对定位填充,不参与行高计算(待办再多也只在卡内滚动) */
.workspace-calrow__side {
position: relative;
}
.workspace-calrow__side :deep(.calendar-todo-widget) {
position: absolute;
inset: 0;
}
@media (max-width: 1024px) {
.workspace-calrow.has-side {
grid-template-columns: 1fr;
}
/* 堆叠后取消绝对定位(否则高度塌陷),列表限高由组件内媒体查询接管 */
.workspace-calrow__side :deep(.calendar-todo-widget) {
position: static;
}
}
/* 区块交错入场:轻微上移淡入,节奏 60ms/块 */

View File

@@ -8,8 +8,9 @@ import { computed, ref } from 'vue';
import { Page, useVbenModal } from '@vben/common-ui';
import { Button, Spin, message } from 'ant-design-vue';
import { Button, Popover, Select, Spin, Switch, message } 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';
@@ -121,7 +122,78 @@ function handleReject() {
RejectionReasonModalApi.open();
}
function handleWindowPrint(_ele, fileName) {
/** 是否运行在 Electron 桌面端(决定是否展示打印设置入口与静默打印分支) */
const isDesktop = isElectron();
/** 桌面端打印设置(打印机 + 静默开关),由主进程持久化到本地 */
const printSettings = ref<{ printerName: string; silentPrint: boolean }>({
printerName: '',
silentPrint: false,
});
/** 打印机下拉选项(打开设置时从系统实时拉取) */
const printerOptions = ref<Array<{ label: string; value: string }>>([]);
if (isDesktop) {
desktop.getSettings().then((s) => {
printSettings.value = s;
});
}
/** 打开打印设置时拉取系统打印机列表(每次打开都刷新,应对打印机增删) */
async function handlePrintSettingOpen(open: boolean) {
if (!open) return;
const printers = await desktop.getPrinters();
printerOptions.value = printers.map((p) => ({
label: `${p.displayName || p.name}${p.isDefault ? '(默认)' : ''}`,
value: p.name,
}));
}
/** 更新打印设置并持久化(主进程写 userData/desktop-settings.json */
async function updatePrintSettings(patch: {
printerName?: string;
silentPrint?: boolean;
}) {
printSettings.value = await desktop.setSettings(patch);
}
/**
* 拼装完整打印 HTML.print-box 内容 + 页面全部样式(含 scoped 样式与外链样式表)
* 与 iframe 打印用的是同一套模板内容,保证静默打印与对话框打印效果一致
*/
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,
);
});
return `<!DOCTYPE html><html><head><meta charset="utf-8" /><title>${fileName || '文档打印'}</title>${styleTags.join('')}</head><body>${printBox.outerHTML}</body></html>`;
}
async function handleWindowPrint(_ele, fileName) {
// 桌面端且开启静默打印:拼同样的打印 HTML 交给主进程隐藏窗口直接出纸,不弹打印对话框
if (isDesktop && printSettings.value.silentPrint) {
const html = buildPrintHtml(fileName);
if (!html) {
message.error('找不到打印内容');
return;
}
const result = await desktop.printHTML({
html,
deviceName: printSettings.value.printerName || undefined,
silent: true,
});
if (result.ok) {
message.success('已发送到打印机');
} else {
message.error(`打印失败:${result.message || '未知错误'}`);
}
return;
}
const printBox = document.querySelector('.print-box');
if (!printBox) {
console.error('找不到具有 "print-box" 类的元素');
@@ -198,6 +270,46 @@ function parseRecipeContent(content: string | Record<string, unknown>) {
<Button type="primary" @click="handleWindowPrint('#demo', '处方')">
打印处方
</Button>
<!-- 打印设置仅桌面端可见配置静默打印开关与目标打印机 -->
<Popover
v-if="isDesktop"
trigger="click"
placement="bottomLeft"
@open-change="handlePrintSettingOpen"
>
<template #content>
<div class="flex w-64 flex-col gap-3">
<div class="flex items-center justify-between">
<span>静默打印</span>
<Switch
:checked="printSettings.silentPrint"
@change="
(checked: any) =>
updatePrintSettings({ silentPrint: !!checked })
"
/>
</div>
<div>
<div class="mb-1">打印机</div>
<Select
class="w-full"
:value="printSettings.printerName || undefined"
:options="printerOptions"
placeholder="系统默认打印机"
allow-clear
@change="
(value: any) =>
updatePrintSettings({ printerName: (value as string) || '' })
"
/>
</div>
<div class="text-xs text-muted-foreground">
开启后打印处方不再弹出打印对话框直接从所选打印机出纸
</div>
</div>
</template>
<Button class="ml-2">打印设置</Button>
</Popover>
<div class="prescription-container print-box">
<div class="prescription-header">
<div class="header-top">

View File

@@ -0,0 +1,182 @@
import { requestClient } from '#/api/request';
/**
* 医生排班配置 API后端 xk-api /doctor-schedule
* 排班规则:出诊模式(每天/按星期)× 时段类型(全天/上下午/自定义)× 各时段号源数 × 可预约天数
*/
const prefix = 'doctor-schedule/';
/** 排班时段项 */
export interface ScheduleSlot {
id?: number;
slot_name: string;
start_time: string;
end_time: string;
capacity: number;
}
/** 排班配置回显doctor_id 为 su_id */
export async function getDoctorScheduleApi(doctorId: number) {
return requestClient.get<any>(`${prefix}detail`, {
params: { doctor_id: doctorId },
});
}
/** 保存医生排班(规则 + 时段整体提交doctor_id 为 su_id */
export async function saveDoctorScheduleApi(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}save`, data);
}
/** 门店统一挂号设置回显(诊所管理员不传 store_id 固定本店) */
export async function getStoreRegisterSettingApi(storeId?: number) {
return requestClient.get<any>(`${prefix}get-store-setting`, {
params: storeId ? { store_id: storeId } : {},
});
}
/** 保存门店统一可预约天数 */
export async function saveStoreRegisterSettingApi(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}save-store-setting`, data);
}
/** 排班月历某天的时段(含已约数) */
export interface ScheduleDaySlot {
slot_id: number;
slot_name: string;
start_time: string;
end_time: string;
capacity: number;
used: number;
}
/** 排班月历中的一天 */
export interface ScheduleCalendarDay {
date: string;
date_ts: number;
week: number;
is_past: boolean;
is_today: boolean;
/** 排班来源day=按日期排班(覆盖) rule=默认规则 */
source: 'day' | 'rule';
is_rest: boolean;
is_work: boolean;
total: number;
used_total: number;
slots: ScheduleDaySlot[];
/** 全店视角:该日被按日排班安排值班的医生数(格子「值班 N 人」徽标) */
store_duty_count?: number;
}
/** 班次模板(店级通用 doctor_id=0医生专属 doctor_id>0添加该医生排班时优先按专属写入 */
export interface ShiftTemplate {
id: number;
store_id: number;
doctor_id?: number;
doctor_name?: string;
name: string;
slots: ScheduleSlot[];
sort: number;
status: number;
}
/** 排班月历管理端日历排班视图doctor_id 为 su_idstore_id 用于按店解析可约窗口提示) */
export async function getScheduleMonthCalendarApi(
doctorId: number,
month: string,
storeId?: number,
) {
return requestClient.get<any>(`${prefix}month-calendar`, {
params: { doctor_id: doctorId, month, ...(storeId ? { store_id: storeId } : {}) },
});
}
/**
* 批量按日期排班doctor_id 单医生 / doctor_ids 多医生批量,二选一或同传自动合并去重)
* actionapply_template 套班次模板 / custom 自定义时段 / rest 设休诊 / clear 恢复默认规则
* prefer_doctor_template=1仅套模板优先按各医生的专属模板写入template_id 作为无专属医生的通用兜底(可为 0要求人人有专属
*/
export async function saveScheduleDaysApi(data: {
action: 'apply_template' | 'clear' | 'custom' | 'rest';
dates: string[];
doctor_id?: number;
doctor_ids?: number[];
prefer_doctor_template?: number;
slots?: ScheduleSlot[];
store_id?: number;
template_id?: number;
}) {
return requestClient.post<any>(`${prefix}save-days`, data);
}
/** 出诊医生查询结果项working_days=在所查日期中出诊的天数) */
export interface WorkingDoctor {
id: number;
name: string;
avatar: string;
working_days: number;
}
/** 查询门店医生在指定日期集合的出诊命中天数(「选出诊医生」一键勾选用) */
export async function getWorkingDoctorsApi(storeId: number, dates: string[]) {
return requestClient.get<any>(`${prefix}working-doctors`, {
params: { store_id: storeId, dates },
});
}
/** 矩阵总览中的一格(某医生某天) */
export interface MatrixCell {
date: string;
/** 来源day=日排班覆盖 rule=默认规则 default=未配置(每天全天) */
source: 'day' | 'default' | 'rule';
is_work: boolean;
is_rest: boolean;
slots: ScheduleSlot[];
slot_names: string[];
total: number;
used: number;
}
/** 矩阵总览日期轴项 */
export interface MatrixDate {
date: string;
date_ts: number;
day: number;
week_text: string;
is_today: boolean;
is_past: boolean;
}
/** 矩阵总览医生行 */
export interface MatrixDoctor {
id: number;
name: string;
avatar: string;
cells: MatrixCell[];
}
/** 门店排班矩阵总览(医生×日期,默认一周 7 列) */
export async function getStoreMatrixApi(storeId: number, start: string, days = 7) {
return requestClient.get<any>(`${prefix}store-matrix`, {
params: { store_id: storeId, start, days },
});
}
/** 班次模板列表(店级,含禁用项) */
export async function getShiftTemplateListApi(storeId?: number) {
return requestClient.get<any>(`${prefix}template-list`, {
params: storeId ? { store_id: storeId } : {},
});
}
/** 保存班次模板id=0 新增) */
export async function saveShiftTemplateApi(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}save-template`, data);
}
/** 删除班次模板(软删,日排班行是时段快照不受影响) */
export async function deleteShiftTemplateApi(id: number, storeId?: number) {
return requestClient.post<any>(`${prefix}delete-template`, {
id,
...(storeId ? { store_id: storeId } : {}),
});
}

View File

@@ -0,0 +1,410 @@
<script lang="ts" setup>
/**
* 医生排班设置抽屉
* 配置项:出诊模式(每天/按星期多选)、时段类型(全天/上下午/自定义时段增删)、
* 各时段号源数、可预约天数0=跟随门店统一设置)、排班状态(停诊开关)
* 为什么自绘控件而不用 useVbenForm时段是动态数组自定义模式可增删行
* vben form schema 不适配动态列表编辑,参照 workbench-widget.vue 自绘先例
*/
import type { ScheduleSlot } from '../api/schedule';
import { computed, ref } from 'vue';
import { useVbenDrawer } from '@vben/common-ui';
import { IconifyIcon as VbenIcon } from '@vben/icons';
import {
Button,
Checkbox,
InputNumber,
message,
Radio,
Switch,
TimePicker,
} from 'ant-design-vue';
import dayjs from 'dayjs';
import { getDoctorScheduleApi, saveDoctorScheduleApi } from '../api/schedule';
/** 星期选项(值与后端 1-7 对齐,周一=1 */
const WEEK_OPTIONS = [
{ label: '周一', value: 1 },
{ label: '周二', value: 2 },
{ label: '周三', value: 3 },
{ label: '周四', value: 4 },
{ label: '周五', value: 5 },
{ label: '周六', value: 6 },
{ label: '周日', value: 7 },
];
const record = ref<any>({});
/** 出诊模式1每天 2按星期 */
const scheduleMode = ref<number>(1);
/** 按星期出诊日 */
const weekDays = ref<number[]>([]);
/** 时段类型1全天 2上下午 3自定义 */
const slotType = ref<number>(1);
/** 时段列表(随 slotType 切换重建) */
const slots = ref<ScheduleSlot[]>([]);
/** 可预约天数0=跟随门店统一) */
const bookDays = ref<number>(0);
/** 门店统一天数(提示用) */
const storeDefaultDays = ref<number>(7);
/** 排班状态true=正常放号 false=停诊 */
const enabled = ref<boolean>(true);
const drawerTitle = computed(() => {
const name = record.value?.name || record.value?.service_user?.name || '';
return name ? `排班设置 - ${name}` : '排班设置';
});
/** 医生 su_id排班按医生维度跨门店共享 */
const doctorSuId = computed(() =>
Number(record.value?.su_id || record.value?.service_user?.id || 0),
);
/**
* 按时段类型生成默认时段(切换类型时调用)
*/
function buildDefaultSlots(type: number): ScheduleSlot[] {
if (type === 1) {
return [
{ slot_name: '全天', start_time: '00:00', end_time: '23:59', capacity: 30 },
];
}
if (type === 2) {
return [
{ slot_name: '上午', start_time: '08:00', end_time: '12:00', capacity: 15 },
{ slot_name: '下午', start_time: '13:00', end_time: '18:00', capacity: 15 },
];
}
return [
{ slot_name: '', start_time: '09:00', end_time: '10:00', capacity: 5 },
];
}
/**
* 切换时段类型:保留全天容量总数体验,直接按类型重建时段行
*/
function onSlotTypeChange(type: number) {
slotType.value = type;
slots.value = buildDefaultSlots(type);
}
/** 自定义模式增加时段行(上限 10 个) */
function addSlot() {
if (slots.value.length >= 10) {
message.warning('最多支持 10 个时段');
return;
}
slots.value.push({
slot_name: '',
start_time: '',
end_time: '',
capacity: 5,
});
}
/** 自定义模式删除时段行 */
function removeSlot(index: number) {
if (slots.value.length <= 1) {
message.warning('至少保留一个时段');
return;
}
slots.value.splice(index, 1);
}
/** TimePicker 变更HH:mm 字符串双向) */
function onTimeChange(slot: ScheduleSlot, key: 'end_time' | 'start_time', val: any) {
slot[key] = val ? dayjs(val, 'HH:mm').format('HH:mm') : '';
}
/**
* 拉取排班配置回显
*/
async function fetchData() {
if (!doctorSuId.value) {
message.error('医生信息缺失su_id');
return;
}
const data = await getDoctorScheduleApi(doctorSuId.value);
scheduleMode.value = Number(data?.schedule_mode || 1);
weekDays.value = Array.isArray(data?.week_days) ? data.week_days : [];
slotType.value = Number(data?.slot_type || 1);
bookDays.value = Number(data?.book_days || 0);
storeDefaultDays.value = Number(data?.store_default_book_days || 7);
enabled.value = Number(data?.status ?? 1) === 1;
const list = Array.isArray(data?.slots) ? data.slots : [];
slots.value =
list.length > 0
? list.map((item: any) => ({
id: Number(item.id || 0),
slot_name: String(item.slot_name || ''),
start_time: String(item.start_time || ''),
end_time: String(item.end_time || ''),
capacity: Number(item.capacity || 0),
}))
: buildDefaultSlots(slotType.value);
}
/**
* 前端轻校验后提交(重叠/格式等强校验在服务端兜底)
*/
async function handleSave() {
if (scheduleMode.value === 2 && weekDays.value.length === 0) {
message.warning('按星期出诊时请至少选择一个出诊日');
return;
}
for (const slot of slots.value) {
if (slotType.value !== 1 && (!slot.start_time || !slot.end_time)) {
message.warning('请补全各时段的开始/结束时间');
return;
}
if (!slot.capacity || slot.capacity < 1) {
message.warning('各时段号源数量至少为 1');
return;
}
}
DrawerApi.lock();
try {
await saveDoctorScheduleApi({
doctor_id: doctorSuId.value,
schedule_mode: scheduleMode.value,
week_days: weekDays.value,
slot_type: slotType.value,
book_days: bookDays.value,
status: enabled.value ? 1 : 0,
slots: slots.value,
});
message.success('排班保存成功');
DrawerApi.close();
} catch {
// 错误提示由 requestClient 统一弹出
} finally {
DrawerApi.unlock();
}
}
const [Drawer, DrawerApi] = useVbenDrawer({
onOpenChange(isOpen) {
if (isOpen) {
record.value = DrawerApi.getData()?.record || {};
DrawerApi.setState({ loading: true });
fetchData()
.catch(() => message.error('获取排班配置失败'))
.finally(() => DrawerApi.setState({ loading: false }));
}
},
onConfirm: handleSave,
});
defineExpose({ DrawerApi });
</script>
<template>
<Drawer :title="drawerTitle" class="w-[520px]">
<div class="schedule-form">
<!-- 排班状态 -->
<div class="schedule-form__item">
<div class="schedule-form__label">排班状态</div>
<div class="schedule-form__control">
<Switch v-model:checked="enabled" />
<span class="schedule-form__tip">{{
enabled ? '正常放号' : '停诊(患者端不可预约)'
}}</span>
</div>
</div>
<!-- 出诊模式 -->
<div class="schedule-form__item">
<div class="schedule-form__label">出诊模式</div>
<div class="schedule-form__control">
<Radio.Group v-model:value="scheduleMode">
<Radio :value="1">每天出诊</Radio>
<Radio :value="2">按星期出诊</Radio>
</Radio.Group>
</div>
</div>
<!-- 按星期出诊日 -->
<div v-if="scheduleMode === 2" class="schedule-form__item">
<div class="schedule-form__label">出诊日</div>
<div class="schedule-form__control">
<Checkbox.Group v-model:value="weekDays" :options="WEEK_OPTIONS" />
</div>
</div>
<!-- 时段类型 -->
<div class="schedule-form__item">
<div class="schedule-form__label">时段类型</div>
<div class="schedule-form__control">
<Radio.Group
:value="slotType"
@update:value="(val: any) => onSlotTypeChange(Number(val))"
>
<Radio :value="1">全天</Radio>
<Radio :value="2">上下午</Radio>
<Radio :value="3">自定义时段</Radio>
</Radio.Group>
</div>
</div>
<!-- 时段列表 -->
<div class="schedule-form__item">
<div class="schedule-form__label">
号源时段
<Button
v-if="slotType === 3"
size="small"
type="link"
@click="addSlot"
>
<VbenIcon icon="lucide:plus" />
加时段
</Button>
</div>
<div class="schedule-form__slots">
<div v-for="(slot, index) in slots" :key="index" class="slot-row">
<span v-if="slotType !== 3" class="slot-row__name">
{{ slot.slot_name }}
</span>
<template v-if="slotType !== 1">
<TimePicker
:value="slot.start_time ? dayjs(slot.start_time, 'HH:mm') : null"
format="HH:mm"
placeholder="开始"
size="small"
@update:value="(val: any) => onTimeChange(slot, 'start_time', val)"
/>
<span class="slot-row__sep">~</span>
<TimePicker
:value="slot.end_time ? dayjs(slot.end_time, 'HH:mm') : null"
format="HH:mm"
placeholder="结束"
size="small"
@update:value="(val: any) => onTimeChange(slot, 'end_time', val)"
/>
</template>
<span v-else class="slot-row__name-full">00:00 ~ 23:59</span>
<InputNumber
v-model:value="slot.capacity"
:max="999"
:min="1"
size="small"
style="width: 90px"
/>
<span class="slot-row__unit">个号</span>
<Button
v-if="slotType === 3"
danger
size="small"
type="text"
@click="removeSlot(index)"
>
<VbenIcon icon="lucide:x" />
</Button>
</div>
</div>
<div v-if="slotType === 3" class="schedule-form__tip">
自定义时段名称留空时自动使用时间段命名时段之间不可重叠
</div>
</div>
<!-- 可预约天数 -->
<div class="schedule-form__item">
<div class="schedule-form__label">可预约天数含当天</div>
<div class="schedule-form__control">
<InputNumber v-model:value="bookDays" :max="30" :min="0" style="width: 120px" />
<span class="schedule-form__tip">
0 = 跟随门店统一设置当前 {{ storeDefaultDays }}
</span>
</div>
</div>
<!-- 日历排班指引本抽屉配置的是默认规则按日期排班走诊所管理入口 -->
<div class="schedule-form__notice">
此处为医生的<b>默认排班规则</b>如需按具体日期排班周排/月排套班次模板单日休诊
请到诊所药店管理 操作 排班管理的日历排班中设置按日期排班优先于默认规则生效
</div>
</div>
</Drawer>
</template>
<style scoped>
.schedule-form {
display: flex;
flex-direction: column;
gap: 18px;
}
.schedule-form__label {
display: flex;
gap: 4px;
align-items: center;
margin-bottom: 8px;
font-size: 13px;
font-weight: 600;
color: hsl(var(--foreground));
}
.schedule-form__control {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.schedule-form__tip {
font-size: 12px;
color: hsl(var(--muted-foreground));
}
.schedule-form__slots {
display: flex;
flex-direction: column;
gap: 8px;
}
.slot-row {
display: flex;
gap: 8px;
align-items: center;
padding: 8px 10px;
background: hsl(var(--muted) / 0.25);
border: 1px solid hsl(var(--border));
border-radius: 6px;
}
.slot-row__name {
min-width: 36px;
font-size: 13px;
color: hsl(var(--foreground));
}
.slot-row__name-full {
font-size: 13px;
color: hsl(var(--muted-foreground));
}
.slot-row__sep,
.slot-row__unit {
font-size: 12px;
color: hsl(var(--muted-foreground));
}
/* 日历排班指引:同色系边框 + 微光,不用左侧色条 */
.schedule-form__notice {
padding: 10px 12px;
font-size: 12px;
line-height: 1.7;
color: hsl(var(--muted-foreground));
background: hsl(var(--primary) / 0.04);
border: 1px solid hsl(var(--primary) / 30%);
border-radius: 8px;
box-shadow: 0 0 6px hsl(var(--primary) / 18%);
}
.schedule-form__notice b {
color: hsl(var(--foreground));
}
</style>

View File

@@ -0,0 +1,152 @@
<script lang="ts" setup>
/**
* 门店挂号统一设置弹窗:默认可预约天数(医生排班「跟随门店」时生效)
* 诊所管理员固定本店(后端强制);平台管理员需先选门店再配置
*/
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { useUserStore } from '@vben/stores';
import { InputNumber, message } from 'ant-design-vue';
import StorePicker from '#/components/form/components/store-picker.vue';
import {
getStoreRegisterSettingApi,
saveStoreRegisterSettingApi,
} from '../api/schedule';
const userStore = useUserStore();
/** 平台管理员user_type=2需要选门店诊所管理员后端固定本店 */
const isPlatform = computed(
() => Number(userStore.userInfo?.roles?.user_type) === 2,
);
const storeId = ref<number | undefined>(undefined);
const defaultBookDays = ref<number>(7);
const loaded = ref(false);
/**
* 拉取指定门店(或本店)的统一设置
*/
async function fetchSetting() {
loaded.value = false;
try {
const data = await getStoreRegisterSettingApi(storeId.value || undefined);
defaultBookDays.value = Number(data?.default_book_days || 7);
// 诊所管理员回显后端解析出的本店 id避免再传 0
if (!isPlatform.value && data?.store_id) {
storeId.value = Number(data.store_id);
}
loaded.value = true;
} catch {
message.error('获取门店统一设置失败');
}
}
/** 平台管理员切换门店后重新拉取 */
function onStoreChange(val: number | undefined) {
storeId.value = val;
if (val) fetchSetting();
}
/**
* 保存统一可预约天数
*/
async function handleSave() {
if (isPlatform.value && !storeId.value) {
message.warning('请先选择门店');
return;
}
if (!defaultBookDays.value || defaultBookDays.value < 1) {
message.warning('可预约天数至少为 1 天');
return;
}
ModalApi.lock();
try {
await saveStoreRegisterSettingApi({
store_id: storeId.value || 0,
default_book_days: defaultBookDays.value,
});
message.success('设置保存成功');
ModalApi.close();
} catch {
// 错误提示由 requestClient 统一弹出
} finally {
ModalApi.unlock();
}
}
const [Modal, ModalApi] = useVbenModal({
onOpenChange(isOpen) {
if (isOpen) {
storeId.value = undefined;
defaultBookDays.value = 7;
// 诊所管理员直接拉本店;平台管理员等选店后拉取
if (isPlatform.value) {
loaded.value = false;
} else {
fetchSetting();
}
}
},
onConfirm: handleSave,
});
defineExpose({ ModalApi });
</script>
<template>
<Modal class="w-[440px]" title="排班统一设置">
<div class="store-setting">
<div v-if="isPlatform" class="store-setting__item">
<div class="store-setting__label">选择门店</div>
<StorePicker :value="storeId" @update:value="onStoreChange" />
</div>
<div class="store-setting__item">
<div class="store-setting__label">默认可预约天数含当天</div>
<div class="store-setting__control">
<InputNumber
v-model:value="defaultBookDays"
:disabled="isPlatform && !loaded"
:max="30"
:min="1"
style="width: 120px"
/>
<span class="store-setting__tip">
医生排班可预约天数 0跟随门店时使用本值默认 7
</span>
</div>
</div>
</div>
</Modal>
</template>
<style scoped>
.store-setting {
display: flex;
flex-direction: column;
gap: 16px;
padding: 4px 2px;
}
.store-setting__label {
margin-bottom: 8px;
font-size: 13px;
font-weight: 600;
color: hsl(var(--foreground));
}
.store-setting__control {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.store-setting__tip {
font-size: 12px;
color: hsl(var(--muted-foreground));
}
</style>

View File

@@ -0,0 +1,520 @@
<script lang="ts" setup>
/**
* 添加医生排班弹窗(右键菜单快捷入口)
* 流程:右键日历日期 →「添加医生排班…」→ 本弹窗选医生(头像标签多选)→
* 选排班方式(套用班次模板 / 自定义时段)→ 提交 save-days 批量下发。
* 与底部操作条的区别:操作条依赖右栏批量集合,这里是一次性向导式选择,
* 适合「临时给某几天加几位医生的班」的快捷场景,不污染批量集合状态。
* 医生专属模板:勾选「优先按医生专属模板」时,有专属模板的医生按自己的
* 时段/号源写入(医生标签带「专」标),无专属的回落所选通用模板兜底。
*/
import type { ScheduleSlot, ShiftTemplate } from '../../api/schedule';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Avatar, Button, Empty, message, Segmented, Switch } from 'ant-design-vue';
import dayjs from 'dayjs';
import { Icon } from '#/components/icon';
import { saveScheduleDaysApi } from '../../api/schedule';
import SlotRowsEditor from './SlotRowsEditor.vue';
const emit = defineEmits<{ saved: [] }>();
/** 打开时由父组件 setData 传入的上下文 */
const storeId = ref(0);
const dates = ref<string[]>([]);
const doctors = ref<Array<{ avatar?: string; id: number; name: string }>>([]);
const templates = ref<ShiftTemplate[]>([]);
/** 已勾选医生集合 */
const selectedDoctorIds = ref<number[]>([]);
/** 排班方式template=套用模板 custom=自定义时段 */
const mode = ref<'custom' | 'template'>('template');
/** 套用模板模式下选中的通用模板(优先专属时作为无专属医生的兜底,可不选) */
const pickedTemplateId = ref<number | undefined>();
/** 自定义时段模式的时段集合 */
const customSlots = ref<ScheduleSlot[]>([]);
/** 优先按医生专属模板写入(仅套用模板模式生效) */
const preferPersonal = ref(true);
/** 店级通用模板(模板卡片列表数据源;专属模板不能被套给任意医生,不进列表) */
const generalTemplates = computed(() =>
templates.value.filter((t) => Number(t.doctor_id || 0) === 0),
);
/** 医生ID → 专属模板(判断「专」标与提交校验用) */
const personalTemplateMap = computed(() => {
const map = new Map<number, ShiftTemplate>();
for (const t of templates.value) {
const did = Number(t.doctor_id || 0);
if (did > 0) map.set(did, t);
}
return map;
});
/** 已选医生中有专属模板的人数(提示文案用) */
const selectedPersonalCount = computed(
() => selectedDoctorIds.value.filter((id) => personalTemplateMap.value.has(id)).length,
);
/** 已选医生中无专属模板的人数(优先专属时这些人需要通用兜底) */
const selectedWithoutPersonalCount = computed(
() => selectedDoctorIds.value.length - selectedPersonalCount.value,
);
/** 日期 chips最多铺 8 个,其余折叠成 +N右键选区最多 62 天,全铺会撑爆头部) */
const DATE_SHOW_LIMIT = 8;
const visibleDates = computed(() => dates.value.slice(0, DATE_SHOW_LIMIT));
const hiddenDateCount = computed(() => Math.max(0, dates.value.length - DATE_SHOW_LIMIT));
/** 单击医生标签:切换勾选 */
function toggleDoctor(id: number) {
const idx = selectedDoctorIds.value.indexOf(id);
if (idx === -1) {
selectedDoctorIds.value.push(id);
} else {
selectedDoctorIds.value.splice(idx, 1);
}
}
/** 全选/清空医生 */
function selectAll() {
selectedDoctorIds.value = doctors.value.map((d) => d.id);
}
function clearAll() {
selectedDoctorIds.value = [];
}
/** 模板时段摘要如「上午20 / 下午15」 */
function templateSummary(tpl: ShiftTemplate): string {
return (tpl.slots || [])
.map((s) => `${s.slot_name || `${s.start_time}-${s.end_time}`}${s.capacity}`)
.join(' / ');
}
/** 医生「专」标悬浮说明(专属模板名 + 时段摘要) */
function personalTitle(docId: number): string {
const tpl = personalTemplateMap.value.get(docId);
return tpl ? `专属模板:${tpl.name}${templateSummary(tpl)}` : '';
}
/** 提交:轻校验后调 save-days强校验含已约保护在服务端冲突会点名医生+日期) */
async function handleSubmit() {
if (selectedDoctorIds.value.length === 0) {
message.warning('请至少选择一位医生');
return;
}
const extra: Record<string, any> = {};
if (mode.value === 'template') {
if (preferPersonal.value) {
// 优先专属:没有专属模板的医生必须有通用兜底,否则后端会点名拒绝
if (selectedWithoutPersonalCount.value > 0 && !pickedTemplateId.value) {
message.warning(
`${selectedWithoutPersonalCount.value} 位已选医生没有专属模板,请选择一个通用班次模板兜底`,
);
return;
}
extra.prefer_doctor_template = 1;
extra.template_id = pickedTemplateId.value || 0;
} else {
if (!pickedTemplateId.value) {
message.warning('请选择一个班次模板');
return;
}
extra.template_id = pickedTemplateId.value;
}
} else {
if (customSlots.value.length === 0) {
message.warning('请至少添加一个时段');
return;
}
for (const slot of customSlots.value) {
if (!slot.start_time || !slot.end_time) {
message.warning('请补全各时段的开始/结束时间');
return;
}
if (!slot.capacity || slot.capacity < 1) {
message.warning('各时段号源数量至少为 1');
return;
}
}
extra.slots = customSlots.value;
}
ModalApi.lock();
try {
await saveScheduleDaysApi({
doctor_ids: selectedDoctorIds.value,
store_id: storeId.value,
dates: dates.value,
action: mode.value === 'template' ? 'apply_template' : 'custom',
...extra,
});
message.success(
`已为 ${selectedDoctorIds.value.length} 位医生排好 ${dates.value.length} 天的班`,
);
emit('saved');
ModalApi.close();
} catch {
// 错误提示由 requestClient 统一弹出(如已约保护拦截)
} finally {
ModalApi.unlock();
}
}
const [Modal, ModalApi] = useVbenModal({
class: 'w-[92%] md:w-[640px]',
confirmText: '保存排班',
onConfirm: handleSubmit,
onOpenChange(isOpen) {
if (!isOpen) return;
const data = ModalApi.getData() || {};
storeId.value = Number(data.storeId || 0);
dates.value = Array.isArray(data.dates) ? [...data.dates].sort() : [];
doctors.value = Array.isArray(data.doctors) ? data.doctors : [];
templates.value = Array.isArray(data.templates) ? data.templates : [];
// 每次打开重置选择:向导式一次性操作,不继承上次状态
selectedDoctorIds.value = [];
mode.value = 'template';
preferPersonal.value = true;
pickedTemplateId.value =
generalTemplates.value.length === 1 ? generalTemplates.value[0]!.id : undefined;
if (customSlots.value.length === 0) {
customSlots.value = [
{ slot_name: '', start_time: '08:00', end_time: '12:00', capacity: 10 },
];
}
},
});
defineExpose({ ModalApi });
</script>
<template>
<Modal title="添加医生排班">
<div class="adddoc">
<!-- 目标日期 -->
<div class="adddoc__section">
<div class="adddoc__label">目标日期{{ dates.length }} </div>
<div class="adddoc__dates">
<span v-for="d in visibleDates" :key="d" class="adddoc__date-chip">
{{ dayjs(d).format('M月D日') }}
</span>
<span v-if="hiddenDateCount > 0" class="adddoc__date-chip adddoc__date-chip--more">
+{{ hiddenDateCount }}
</span>
</div>
</div>
<!-- 选医生头像 + 姓名标签网格多选 -->
<div class="adddoc__section">
<div class="adddoc__label-row">
<span class="adddoc__label">选择医生已选 {{ selectedDoctorIds.length }} </span>
<span class="adddoc__label-tools">
<Button size="small" type="link" @click="selectAll">全选</Button>
<Button
:disabled="selectedDoctorIds.length === 0"
size="small"
type="link"
@click="clearAll"
>
清空
</Button>
</span>
</div>
<div class="adddoc__doctors">
<button
v-for="doc in doctors"
:key="doc.id"
class="adddoc__doc"
:class="{ 'adddoc__doc--active': selectedDoctorIds.includes(doc.id) }"
type="button"
@click="toggleDoctor(doc.id)"
>
<Avatar :size="22" :src="doc.avatar || undefined" class="adddoc__doc-avatar">
{{ doc.name.slice(0, 1) }}
</Avatar>
<span class="adddoc__doc-name">{{ doc.name }}</span>
<!-- 该医生有专属模板优先专属时按其自己的时段/号源写入 -->
<span
v-if="personalTemplateMap.has(doc.id)"
class="adddoc__doc-personal"
:title="personalTitle(doc.id)"
>
</span>
<Icon
v-if="selectedDoctorIds.includes(doc.id)"
class="adddoc__doc-check"
icon="lucide:check"
/>
</button>
<Empty
v-if="doctors.length === 0"
:image-style="{ height: '48px' }"
description="该门店暂无医生"
/>
</div>
</div>
<!-- 选排班方式 -->
<div class="adddoc__section">
<div class="adddoc__label">排班方式</div>
<Segmented
v-model:value="mode"
:options="[
{ label: '套用班次模板', value: 'template' },
{ label: '自定义时段', value: 'custom' },
]"
/>
<!-- 模板模式优先专属开关 + 通用模板单选卡片专属模板不进列表自动按医生生效 -->
<div v-if="mode === 'template'" class="adddoc__templates">
<div class="adddoc__personal-row">
<Switch v-model:checked="preferPersonal" size="small" />
<span class="adddoc__personal-label">优先按医生专属模板写入</span>
<span v-if="preferPersonal && selectedDoctorIds.length > 0" class="adddoc__personal-hint">
已选 {{ selectedPersonalCount }} 人有专属模板{{
selectedWithoutPersonalCount > 0
? `${selectedWithoutPersonalCount} 人将使用下方通用模板`
: ',无需选通用模板'
}}
</span>
</div>
<button
v-for="tpl in generalTemplates"
:key="tpl.id"
class="adddoc__tpl"
:class="{ 'adddoc__tpl--active': pickedTemplateId === tpl.id }"
type="button"
@click="pickedTemplateId = pickedTemplateId === tpl.id ? undefined : tpl.id"
>
<span class="adddoc__tpl-name">{{ tpl.name }}</span>
<span class="adddoc__tpl-summary">{{ templateSummary(tpl) }}</span>
<Icon
v-if="pickedTemplateId === tpl.id"
class="adddoc__tpl-check"
icon="lucide:check-circle-2"
/>
</button>
<Empty
v-if="generalTemplates.length === 0"
:image-style="{ height: '48px' }"
description="暂无启用中的通用班次模板,可切换为自定义时段"
/>
</div>
<!-- 自定义时段编辑器 -->
<div v-else class="adddoc__custom">
<SlotRowsEditor v-model="customSlots" />
</div>
</div>
</div>
</Modal>
</template>
<style scoped>
.adddoc {
display: flex;
flex-direction: column;
gap: 14px;
}
.adddoc__section {
display: flex;
flex-direction: column;
gap: 8px;
}
.adddoc__label {
font-size: 13px;
font-weight: 600;
color: hsl(var(--foreground));
}
.adddoc__label-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.adddoc__label-tools {
display: inline-flex;
gap: 2px;
}
.adddoc__dates {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.adddoc__date-chip {
padding: 1px 8px;
font-size: 12px;
color: hsl(var(--primary));
background: hsl(var(--primary) / 0.1);
border-radius: 6px;
}
.adddoc__date-chip--more {
color: hsl(var(--muted-foreground));
background: hsl(var(--muted) / 0.4);
}
/* 医生标签网格:自适应列宽,限高滚动防医生多时弹窗过长 */
.adddoc__doctors {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(150px, 100%), 1fr));
gap: 6px;
max-height: 200px;
padding: 2px;
overflow-y: auto;
}
.adddoc__doc {
display: flex;
gap: 6px;
align-items: center;
min-width: 0;
padding: 5px 8px;
text-align: left;
cursor: pointer;
user-select: none;
background: hsl(var(--muted) / 0.2);
border: 1px solid hsl(var(--border) / 0.7);
border-radius: 10px;
transition: border-color 0.15s, background 0.15s, box-shadow 0.15s;
}
.adddoc__doc:hover {
background: hsl(var(--primary) / 0.06);
border-color: hsl(var(--primary) / 0.35);
}
/* 选中态1px 主色边框 + 微光 */
.adddoc__doc--active {
background: hsl(var(--primary) / 0.1);
border-color: hsl(var(--primary) / 0.45);
box-shadow: 0 0 6px hsl(var(--primary) / 18%);
}
.adddoc__doc-avatar {
flex-shrink: 0;
font-size: 12px;
background: hsl(var(--primary) / 0.2);
}
.adddoc__doc-name {
flex: 1;
overflow: hidden;
font-size: 13px;
color: hsl(var(--foreground));
text-overflow: ellipsis;
white-space: nowrap;
}
.adddoc__doc--active .adddoc__doc-name {
font-weight: 600;
color: hsl(var(--primary));
}
.adddoc__doc-check {
flex-shrink: 0;
font-size: 14px;
color: hsl(var(--primary));
}
/* 「专」标:医生有专属模板的小徽标 */
.adddoc__doc-personal {
flex-shrink: 0;
padding: 0 4px;
font-size: 10px;
line-height: 16px;
color: hsl(var(--primary));
background: hsl(var(--primary) / 0.12);
border-radius: 4px;
}
/* 优先专属开关行 */
.adddoc__personal-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.adddoc__personal-label {
font-size: 12px;
color: hsl(var(--foreground));
}
.adddoc__personal-hint {
font-size: 12px;
color: hsl(var(--muted-foreground));
}
/* 模板单选卡片 */
.adddoc__templates {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 190px;
padding: 2px;
overflow-y: auto;
}
.adddoc__tpl {
display: flex;
gap: 8px;
align-items: center;
padding: 8px 10px;
text-align: left;
cursor: pointer;
background: hsl(var(--muted) / 0.15);
border: 1px solid hsl(var(--border) / 0.7);
border-radius: 10px;
transition: border-color 0.15s, background 0.15s, box-shadow 0.15s;
}
.adddoc__tpl:hover {
border-color: hsl(var(--primary) / 0.35);
}
.adddoc__tpl--active {
background: hsl(var(--primary) / 0.08);
border-color: hsl(var(--primary) / 0.45);
box-shadow: 0 0 6px hsl(var(--primary) / 18%);
}
.adddoc__tpl-name {
flex-shrink: 0;
font-size: 13px;
font-weight: 600;
color: hsl(var(--foreground));
}
.adddoc__tpl--active .adddoc__tpl-name {
color: hsl(var(--primary));
}
.adddoc__tpl-summary {
flex: 1;
overflow: hidden;
font-size: 12px;
color: hsl(var(--muted-foreground));
text-overflow: ellipsis;
white-space: nowrap;
}
.adddoc__tpl-check {
flex-shrink: 0;
font-size: 15px;
color: hsl(var(--primary));
}
.adddoc__custom {
padding-top: 2px;
}
</style>

View File

@@ -0,0 +1,93 @@
<script lang="ts" setup>
/**
* 排班管理弹窗(薄壳)
* 仅负责弹窗与传参,核心面板逻辑在 SchedulePanel同时被诊所设置页 tab 复用)。
* 入口:诊所管理行操作「排班管理」(全店视角)/ 医生管理行操作「排班日历」(锁定医生)。
* 锁定医生且关联多店时(平台管理员视角)顶部提供门店切换,切店后面板自动重载。
*/
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Select } from 'ant-design-vue';
import SchedulePanel from './SchedulePanel.vue';
/** 弹窗传参setData 注入) */
const storeId = ref<number>(0);
const storeName = ref<string>('');
const lockedDoctorId = ref<number>(0);
const lockedDoctorName = ref<string>('');
/** 锁定医生关联的门店列表(多店时展示切换下拉) */
const storeOptions = ref<Array<{ id: number; name: string }>>([]);
/** 面板重建 key同店重复打开时也强制重新初始化防止上次的选择/视图残留) */
const panelKey = ref<number>(0);
/** 切换门店同步门店名Panel 内部 watch storeId 自动整体重载) */
function onStoreChange(val: any) {
const hit = storeOptions.value.find((s) => s.id === Number(val));
storeName.value = hit?.name || '';
}
const [Modal, ModalApi] = useVbenModal({
footer: false,
fullscreenButton: true,
class: 'w-[1080px]',
onOpenChange(isOpen) {
if (isOpen) {
const data = ModalApi.getData() || {};
storeId.value = Number(data.storeId || 0);
storeName.value = String(data.storeName || '');
lockedDoctorId.value = Number(data.lockedDoctorId || 0);
lockedDoctorName.value = String(data.lockedDoctorName || '');
storeOptions.value = Array.isArray(data.storeOptions) ? data.storeOptions : [];
// 未显式传店但有门店列表时默认第一个(医生管理入口场景)
if (!storeId.value && storeOptions.value.length > 0) {
storeId.value = storeOptions.value[0]!.id;
storeName.value = storeOptions.value[0]!.name;
}
panelKey.value = Date.now();
}
},
});
defineExpose({ ModalApi });
</script>
<template>
<Modal :title="storeName ? `排班管理 - ${storeName}` : '排班管理'">
<!-- 锁定医生关联多店顶部门店切换模板按店隔离排班行记录来源门店 -->
<div v-if="storeOptions.length > 1" class="sched-shell__store">
<span class="sched-shell__store-label">所在门店</span>
<Select
v-model:value="storeId"
:field-names="{ label: 'name', value: 'id' }"
:options="storeOptions"
style="width: 220px"
@change="onStoreChange"
/>
</div>
<SchedulePanel
v-if="storeId > 0"
:key="panelKey"
:locked-doctor-id="lockedDoctorId"
:locked-doctor-name="lockedDoctorName"
:store-id="storeId"
:store-name="storeName"
/>
</Modal>
</template>
<style scoped>
.sched-shell__store {
display: flex;
gap: 6px;
align-items: center;
margin-bottom: 10px;
}
.sched-shell__store-label {
font-size: 12px;
color: hsl(var(--muted-foreground));
}
</style>

View File

@@ -0,0 +1,284 @@
<script lang="ts" setup>
/**
* 排班格子右键快捷菜单(月历视图 / 矩阵总览共用)
* 参照工作台日历 CalendarPanel 的右键菜单模式Teleport body + fixed 定位 +
* 贴边收缩 + 点击/滚动关闭 + 毛玻璃样式;菜单项按排班动作定制。
* 组件只负责「展示与发出动作」,动作落地(调 save-days由父组件完成
* 这样月历(单医生多日期)与矩阵(多医生多日期)可以共用同一菜单。
*/
import type { ShiftTemplate } from '../../api/schedule';
import { onMounted, onUnmounted, reactive, ref } from 'vue';
import { Icon } from '#/components/icon';
const props = defineProps<{
/** 剪贴板是否有可粘贴的排班(父组件内存快照) */
canPaste: boolean;
/** 粘贴项摘要(如「上午 20号 / 下午 15号」提示粘贴内容 */
pasteSummary: string;
/** 启用中的班次模板(内联子列表数据源) */
templates: ShiftTemplate[];
}>();
const emit = defineEmits<{
/** 动作统一出口:父组件根据当前选区执行 */
action: [
type: 'add_doctors' | 'apply_template' | 'clear' | 'copy' | 'custom' | 'paste' | 'rest',
payload?: { templateId?: number },
];
}>();
/** 菜单状态:位置 + 标题 + 右键格子是否可复制 */
const state = reactive({
visible: false,
x: 0,
y: 0,
/** 标题文本如「8月19日 · 已选 3 天」),由打开方组装 */
title: '',
/** 右键目标格子是否有可复制的时段(休诊/不出诊格禁用复制) */
copyEnabled: false,
/** 是否显示「添加医生排班」(月历视图才有意义;矩阵格已含医生维度、锁定医生入口无选人场景) */
addDoctorsEnabled: false,
});
/** 「套用班次模板」内联子列表展开态(每次打开菜单重置收起) */
const templatesOpen = ref(false);
/**
* 打开右键菜单:贴边时向内收缩,避免菜单溢出视口
* 估算高度 330含模板子列表收起态宽 210
*/
function open(
e: MouseEvent,
meta: { addDoctorsEnabled?: boolean; copyEnabled: boolean; title: string },
) {
state.title = meta.title;
state.copyEnabled = meta.copyEnabled;
state.addDoctorsEnabled = Boolean(meta.addDoctorsEnabled);
// 贴边收缩 + 下限保护:小视口时也不让菜单跑出屏幕左上角
state.x = Math.max(8, Math.min(e.clientX, window.innerWidth - 220));
state.y = Math.max(8, Math.min(e.clientY, window.innerHeight - 340));
templatesOpen.value = false;
state.visible = true;
}
function close() {
state.visible = false;
}
/** 发出动作并关闭菜单 */
function fire(
type: 'add_doctors' | 'apply_template' | 'clear' | 'copy' | 'custom' | 'paste' | 'rest',
payload?: { templateId?: number },
) {
emit('action', type, payload);
close();
}
/** 点击任意处 / 滚动时关闭capture 阶段监听滚动,覆盖弹窗内部滚动容器) */
onMounted(() => {
window.addEventListener('click', close);
window.addEventListener('scroll', close, true);
window.addEventListener('resize', close);
});
onUnmounted(() => {
window.removeEventListener('click', close);
window.removeEventListener('scroll', close, true);
window.removeEventListener('resize', close);
});
defineExpose({ open, close });
</script>
<template>
<Teleport to="body">
<div
v-if="state.visible"
class="sched-ctx"
:style="{ left: `${state.x}px`, top: `${state.y}px` }"
@click.stop
@contextmenu.prevent
>
<div class="sched-ctx__title">{{ state.title }}</div>
<!-- 套用班次模板点击内联展开模板子列表避免自实现 hover 子菜单的复杂度 -->
<div class="sched-ctx__item" @click="templatesOpen = !templatesOpen">
<span class="sched-ctx__label">
<Icon icon="lucide:layers" />
套用班次模板
</span>
<Icon :icon="templatesOpen ? 'lucide:chevron-down' : 'lucide:chevron-right'" />
</div>
<div v-if="templatesOpen" class="sched-ctx__sub">
<div
v-for="tpl in props.templates"
:key="tpl.id"
class="sched-ctx__item sched-ctx__item--sub"
@click="fire('apply_template', { templateId: tpl.id })"
>
<span class="sched-ctx__label">{{ tpl.name }}</span>
<span class="sched-ctx__hint">
{{ tpl.slots.reduce((sum, s) => sum + Number(s.capacity || 0), 0) }}
</span>
</div>
<div v-if="props.templates.length === 0" class="sched-ctx__empty">
暂无启用模板
</div>
</div>
<div class="sched-ctx__item" @click="fire('custom')">
<span class="sched-ctx__label">
<Icon icon="lucide:clock" />
自定义时段
</span>
</div>
<!-- 添加医生排班选医生 + 选模板/自定义时段一步给多位医生排当天班 -->
<div
v-if="state.addDoctorsEnabled"
class="sched-ctx__item"
@click="fire('add_doctors')"
>
<span class="sched-ctx__label">
<Icon icon="lucide:user-plus" />
添加医生排班
</span>
</div>
<div class="sched-ctx__divider"></div>
<div
class="sched-ctx__item"
:class="{ 'sched-ctx__item--disabled': !state.copyEnabled }"
@click="state.copyEnabled && fire('copy')"
>
<span class="sched-ctx__label">
<Icon icon="lucide:copy" />
复制该日排班
</span>
</div>
<div
class="sched-ctx__item"
:class="{ 'sched-ctx__item--disabled': !props.canPaste }"
@click="props.canPaste && fire('paste')"
>
<span class="sched-ctx__label">
<Icon icon="lucide:clipboard-paste" />
粘贴排班
</span>
<span v-if="props.canPaste" class="sched-ctx__hint">{{ props.pasteSummary }}</span>
</div>
<div class="sched-ctx__divider"></div>
<div class="sched-ctx__item sched-ctx__item--danger" @click="fire('rest')">
<span class="sched-ctx__label">
<Icon icon="lucide:ban" />
设为休诊
</span>
</div>
<div class="sched-ctx__item" @click="fire('clear')">
<span class="sched-ctx__label">
<Icon icon="lucide:rotate-ccw" />
恢复默认规则
</span>
</div>
</div>
</Teleport>
</template>
<style scoped>
/* 毛玻璃浮层:与工作台日历右键菜单同一设计语言 */
.sched-ctx {
position: fixed;
z-index: 2100;
min-width: 200px;
max-width: 260px;
padding: 6px;
background: hsl(var(--popover, var(--background)) / 82%);
border: 1px solid hsl(var(--border) / 60%);
border-radius: 12px;
box-shadow: 0 12px 32px rgb(0 0 0 / 16%);
backdrop-filter: blur(18px) saturate(160%);
}
.sched-ctx__title {
padding: 4px 10px 6px;
overflow: hidden;
font-size: 12px;
color: hsl(var(--muted-foreground));
text-overflow: ellipsis;
white-space: nowrap;
border-bottom: 1px solid hsl(var(--border));
}
.sched-ctx__item {
display: flex;
gap: 8px;
align-items: center;
justify-content: space-between;
padding: 7px 10px;
margin-top: 2px;
font-size: 13px;
color: hsl(var(--foreground));
cursor: pointer;
border-radius: 6px;
transition: background 0.12s;
}
.sched-ctx__item:hover {
background: hsl(var(--primary) / 0.08);
}
.sched-ctx__item--sub {
padding-left: 26px;
}
.sched-ctx__item--danger {
color: hsl(var(--destructive));
}
.sched-ctx__item--danger:hover {
background: hsl(var(--destructive) / 0.08);
}
.sched-ctx__item--disabled {
color: hsl(var(--muted-foreground) / 0.55);
cursor: not-allowed;
}
.sched-ctx__item--disabled:hover {
background: none;
}
.sched-ctx__label {
display: inline-flex;
gap: 6px;
align-items: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sched-ctx__hint {
flex-shrink: 0;
max-width: 90px;
overflow: hidden;
font-size: 11px;
color: hsl(var(--muted-foreground));
text-overflow: ellipsis;
white-space: nowrap;
}
/* 模板子列表:限高滚动,防模板多时菜单超出视口 */
.sched-ctx__sub {
max-height: 150px;
overflow-y: auto;
}
.sched-ctx__empty {
padding: 6px 10px 6px 26px;
font-size: 12px;
color: hsl(var(--muted-foreground) / 0.7);
}
.sched-ctx__divider {
height: 1px;
margin: 4px 6px;
background: hsl(var(--border) / 70%);
}
</style>

View File

@@ -0,0 +1,531 @@
<script lang="ts" setup>
/**
* 排班矩阵总览视图(行=医生 × 列=日期,周粒度)
* 医院排班表惯例:一屏看全店一周的出诊安排;格子支持标准桌面选择模型
* (点击单选 / Ctrl 追加 / Shift 矩形范围)后由父面板批量下发排班动作。
* 选择项是(医生,日期)组合,与月历的纯日期选择不同,所以批量提交时
* 由 getSelectionGroups 把「日期集合相同的医生」合并成组,减少请求次数。
*/
import type { MatrixCell, MatrixDate, MatrixDoctor } from '../../api/schedule';
import { computed, ref, watch } from 'vue';
import { Button, Empty, Tooltip } from 'ant-design-vue';
import dayjs from 'dayjs';
import { Icon } from '#/components/icon';
import { getStoreMatrixApi } from '../../api/schedule';
const props = defineProps<{
storeId: number;
}>();
const emit = defineEmits<{
/** 右键格子:上抛给父面板统一弹右键菜单(含选区归一逻辑) */
cellCtx: [event: MouseEvent, doctor: MatrixDoctor, cell: MatrixCell];
/** 选中格数变化:父面板操作条展示「已选 N 格」 */
selectionChange: [count: number];
}>();
/** 求某天所在周的周一(不依赖 dayjs locale 的 startOf('week') 起点差异) */
function mondayOf(d: dayjs.Dayjs) {
const dow = d.day(); // 0=周日 1=周一 ... 6=周六
return d.subtract(dow === 0 ? 6 : dow - 1, 'day').startOf('day');
}
/** 周起点(周一),矩阵固定 7 列一周 */
const weekStart = ref(mondayOf(dayjs()));
/** 日期轴与医生行 */
const dates = ref<MatrixDate[]>([]);
const doctors = ref<MatrixDoctor[]>([]);
const loading = ref(false);
/** 选中格集合key = `${doctorId}|${date}` */
const selectedKeys = ref<Set<string>>(new Set());
/** Shift 范围选择锚点(行/列下标) */
const anchor = ref<null | { col: number; row: number }>(null);
/** 最早回看 12 周 / 最远排 26 周,防无限翻页 */
const canPrev = computed(() => weekStart.value.isAfter(dayjs().subtract(12, 'week')));
const canNext = computed(() => weekStart.value.isBefore(dayjs().add(26, 'week')));
/** 周标题(如 8月17日 - 8月23日 */
const weekTitle = computed(() => {
const end = weekStart.value.add(6, 'day');
return `${weekStart.value.format('M月D日')} - ${end.format('M月D日')}`;
});
function cellKey(doctorId: number, date: string) {
return `${doctorId}|${date}`;
}
/** 拉取矩阵数据(切周/门店变化/父面板要求刷新时) */
async function fetchMatrix() {
if (!props.storeId) return;
loading.value = true;
try {
const data = await getStoreMatrixApi(props.storeId, weekStart.value.format('YYYY-MM-DD'), 7);
dates.value = Array.isArray(data?.dates) ? data.dates : [];
doctors.value = Array.isArray(data?.doctors) ? data.doctors : [];
} finally {
loading.value = false;
}
}
/** 切周:清空选择(跨周选择容易误操作) */
function switchWeek(offset: number) {
weekStart.value = weekStart.value.add(offset, 'week');
clearSelection();
fetchMatrix();
}
/** 回到本周 */
function backToThisWeek() {
weekStart.value = mondayOf(dayjs());
clearSelection();
fetchMatrix();
}
/** 某格是否可选(过去日期只读) */
function selectable(cell: MatrixCell, colIdx: number) {
return !(dates.value[colIdx]?.is_past ?? false) && !!cell;
}
function notifySelection() {
emit('selectionChange', selectedKeys.value.size);
}
/**
* 格子点击:标准桌面选择模型
* 普通点击=只选该格替换Ctrl/Cmd=追加/取消Shift=从锚点到当前格的矩形范围并入
*/
function onCellClick(e: MouseEvent, rowIdx: number, colIdx: number) {
const doctor = doctors.value[rowIdx];
const cell = doctor?.cells[colIdx];
if (!doctor || !cell || !selectable(cell, colIdx)) return;
const key = cellKey(doctor.id, cell.date);
const next = new Set(selectedKeys.value);
if (e.shiftKey && anchor.value) {
// 矩形范围:锚点与当前格围成的(医生行 × 日期列)区域全部并入(跳过过去日期)
const [r1, r2] = [Math.min(anchor.value.row, rowIdx), Math.max(anchor.value.row, rowIdx)];
const [c1, c2] = [Math.min(anchor.value.col, colIdx), Math.max(anchor.value.col, colIdx)];
for (let r = r1; r <= r2; r++) {
for (let c = c1; c <= c2; c++) {
const d = doctors.value[r];
const cc = d?.cells[c];
if (d && cc && selectable(cc, c)) {
next.add(cellKey(d.id, cc.date));
}
}
}
} else if (e.ctrlKey || e.metaKey) {
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
anchor.value = { row: rowIdx, col: colIdx };
} else {
next.clear();
next.add(key);
anchor.value = { row: rowIdx, col: colIdx };
}
selectedKeys.value = next;
notifySelection();
}
/** 右键格子:不在选区内则替换选区为该格,然后上抛父面板弹菜单 */
function onCellCtx(e: MouseEvent, rowIdx: number, colIdx: number) {
const doctor = doctors.value[rowIdx];
const cell = doctor?.cells[colIdx];
if (!doctor || !cell || !selectable(cell, colIdx)) return;
const key = cellKey(doctor.id, cell.date);
if (!selectedKeys.value.has(key)) {
selectedKeys.value = new Set([key]);
anchor.value = { row: rowIdx, col: colIdx };
notifySelection();
}
emit('cellCtx', e, doctor, cell);
}
/** 点击日期表头:全选/反选该列(某天全员操作,如临时停业) */
function toggleColumn(colIdx: number) {
const date = dates.value[colIdx];
if (!date || date.is_past) return;
const keys = doctors.value.map((d) => cellKey(d.id, date.date));
const allIn = keys.every((k) => selectedKeys.value.has(k));
const next = new Set(selectedKeys.value);
for (const k of keys) {
if (allIn) {
next.delete(k);
} else {
next.add(k);
}
}
selectedKeys.value = next;
notifySelection();
}
/** 点击医生名:全选/反选该行的可选日期(给某医生排整周) */
function toggleRow(rowIdx: number) {
const doctor = doctors.value[rowIdx];
if (!doctor) return;
const keys: string[] = [];
doctor.cells.forEach((cell, c) => {
if (selectable(cell, c)) keys.push(cellKey(doctor.id, cell.date));
});
if (keys.length === 0) return;
const allIn = keys.every((k) => selectedKeys.value.has(k));
const next = new Set(selectedKeys.value);
for (const k of keys) {
if (allIn) {
next.delete(k);
} else {
next.add(k);
}
}
selectedKeys.value = next;
notifySelection();
}
function clearSelection() {
selectedKeys.value = new Set();
anchor.value = null;
notifySelection();
}
/**
* 把选中格按「日期集合相同的医生」合并成组(父面板批量调 save-days 用)
* 矩形选择时全部医生日期一致 → 只有 1 组 1 次请求;散选时才会拆多组
*/
function getSelectionGroups(): Array<{ dates: string[]; doctorIds: number[] }> {
const byDoctor = new Map<number, string[]>();
for (const key of selectedKeys.value) {
const [didStr, date] = key.split('|');
const did = Number(didStr);
if (!byDoctor.has(did)) byDoctor.set(did, []);
byDoctor.get(did)!.push(date!);
}
const byDates = new Map<string, number[]>();
for (const [did, ds] of byDoctor) {
const k = [...ds].sort().join(',');
if (!byDates.has(k)) byDates.set(k, []);
byDates.get(k)!.push(did);
}
return [...byDates.entries()].map(([k, doctorIds]) => ({
dates: k.split(','),
doctorIds,
}));
}
/** 门店切换时重置到本周并重拉 */
watch(
() => props.storeId,
() => {
clearSelection();
fetchMatrix();
},
{ immediate: true },
);
defineExpose({ clearSelection, fetchMatrix, getSelectionGroups });
</script>
<template>
<div class="smx" :class="{ 'smx--loading': loading }">
<!-- 周导航 -->
<div class="smx__nav">
<Button :disabled="!canPrev" size="small" type="text" @click="switchWeek(-1)">
<Icon icon="lucide:chevron-left" />
</Button>
<span class="smx__week-text">{{ weekTitle }}</span>
<Button :disabled="!canNext" size="small" type="text" @click="switchWeek(1)">
<Icon icon="lucide:chevron-right" />
</Button>
<Button size="small" type="link" @click="backToThisWeek">本周</Button>
<span class="smx__nav-tip">点医生名选整行 · 点日期选整列 · Ctrl 散选 · Shift 框选</span>
</div>
<!-- 矩阵表格 -->
<div v-if="doctors.length > 0" class="smx__table-wrap">
<table class="smx__table">
<thead>
<tr>
<th class="smx__corner">医生</th>
<th
v-for="(d, colIdx) in dates"
:key="d.date"
class="smx__date-head"
:class="{ 'smx__date-head--today': d.is_today, 'smx__date-head--past': d.is_past }"
@click="toggleColumn(colIdx)"
>
<div class="smx__date-week">{{ d.week_text }}</div>
<div class="smx__date-day">
{{ d.day }}
<span v-if="d.is_today" class="smx__today-dot"></span>
</div>
</th>
</tr>
</thead>
<tbody>
<tr v-for="(doctor, rowIdx) in doctors" :key="doctor.id">
<td class="smx__doctor" @click="toggleRow(rowIdx)">
<Tooltip title="点击全选该医生整周">
<span class="smx__doctor-name">{{ doctor.name }}</span>
</Tooltip>
</td>
<td
v-for="(cell, colIdx) in doctor.cells"
:key="cell.date"
class="smx__cell"
:class="{
'smx__cell--past': dates[colIdx]?.is_past,
'smx__cell--selected': selectedKeys.has(`${doctor.id}|${cell.date}`),
'smx__cell--rest': cell.is_rest,
'smx__cell--day-source': cell.source === 'day' && !cell.is_rest,
}"
@click="onCellClick($event, rowIdx, colIdx)"
@contextmenu.prevent="onCellCtx($event, rowIdx, colIdx)"
>
<template v-if="cell.is_rest">
<span class="smx__chip smx__chip--rest">休诊</span>
</template>
<template v-else>
<!-- 未配置排班的默认兜底弱化成文字不伪装成排班 chip与月历视图口径一致 -->
<span v-if="cell.source === 'default'" class="smx__default">默认可约</span>
<template v-else>
<span
v-for="(name, i) in cell.slot_names.slice(0, 2)"
:key="i"
class="smx__chip"
:class="cell.source === 'day' ? 'smx__chip--day' : 'smx__chip--rule'"
>
{{ name }}
</span>
<span v-if="cell.slot_names.length > 2" class="smx__chip smx__chip--more">
+{{ cell.slot_names.length - 2 }}
</span>
</template>
<span class="smx__cell-num">
{{ cell.total }}<template v-if="cell.used > 0">·{{ cell.used }}</template>
</span>
</template>
</td>
</tr>
</tbody>
</table>
</div>
<Empty v-else description="该门店暂无医生" />
</div>
</template>
<style scoped>
.smx {
display: flex;
flex-direction: column;
gap: 8px;
transition: opacity 0.2s;
}
.smx--loading {
pointer-events: none;
opacity: 0.55;
}
.smx__nav {
display: flex;
flex-wrap: wrap;
gap: 4px;
align-items: center;
}
.smx__week-text {
min-width: 132px;
font-size: 14px;
font-weight: 600;
color: hsl(var(--foreground));
text-align: center;
}
.smx__nav-tip {
margin-left: auto;
font-size: 12px;
color: hsl(var(--muted-foreground));
}
/* 矩阵表格Apple 风圆角卡片容器 */
.smx__table-wrap {
overflow: auto hidden;
border: 1px solid hsl(var(--border));
border-radius: 12px;
}
.smx__table {
width: 100%;
min-width: 720px;
border-collapse: collapse;
table-layout: fixed;
}
.smx__corner {
width: 92px;
padding: 8px;
font-size: 12px;
font-weight: 600;
color: hsl(var(--muted-foreground));
background: hsl(var(--muted) / 0.35);
border-bottom: 1px solid hsl(var(--border));
}
.smx__date-head {
padding: 6px 4px;
text-align: center;
cursor: pointer;
user-select: none;
background: hsl(var(--muted) / 0.35);
border-bottom: 1px solid hsl(var(--border));
border-left: 1px solid hsl(var(--border) / 0.6);
transition: background 0.15s;
}
.smx__date-head:hover {
background: hsl(var(--primary) / 0.08);
}
.smx__date-head--past {
cursor: not-allowed;
opacity: 0.5;
}
.smx__date-week {
font-size: 11px;
color: hsl(var(--muted-foreground));
}
.smx__date-day {
position: relative;
font-size: 14px;
font-weight: 600;
color: hsl(var(--foreground));
}
.smx__date-head--today .smx__date-day {
color: hsl(var(--primary));
}
.smx__today-dot {
position: absolute;
bottom: -3px;
left: 50%;
width: 4px;
height: 4px;
background: hsl(var(--primary));
border-radius: 50%;
transform: translateX(-50%);
}
.smx__doctor {
padding: 6px 8px;
overflow: hidden;
cursor: pointer;
user-select: none;
border-top: 1px solid hsl(var(--border) / 0.6);
transition: background 0.15s;
}
.smx__doctor:hover {
background: hsl(var(--primary) / 0.06);
}
.smx__doctor-name {
display: block;
overflow: hidden;
font-size: 13px;
font-weight: 500;
color: hsl(var(--foreground));
text-overflow: ellipsis;
white-space: nowrap;
}
.smx__cell {
height: 56px;
padding: 4px;
vertical-align: top;
cursor: pointer;
user-select: none;
border-top: 1px solid hsl(var(--border) / 0.6);
border-left: 1px solid hsl(var(--border) / 0.6);
transition: background 0.15s, box-shadow 0.15s;
}
.smx__cell:not(.smx__cell--past):hover {
background: hsl(var(--primary) / 0.06);
}
.smx__cell--past {
cursor: not-allowed;
opacity: 0.45;
}
/* 选中态:主色内描边 + 微光(同月历视图语言) */
.smx__cell--selected {
background: hsl(var(--primary) / 0.1);
box-shadow: inset 0 0 0 2px hsl(var(--primary) / 0.55), 0 0 6px hsl(var(--primary) / 18%);
}
.smx__cell--day-source:not(.smx__cell--selected) {
box-shadow: inset 0 0 0 1px hsl(var(--primary) / 0.3);
}
.smx__cell--rest:not(.smx__cell--selected) {
background: hsl(var(--destructive) / 0.05);
}
.smx__chip {
display: inline-block;
max-width: 100%;
padding: 0 5px;
margin: 0 2px 2px 0;
overflow: hidden;
font-size: 11px;
line-height: 17px;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: top;
border-radius: 4px;
}
.smx__chip--day {
color: hsl(var(--primary));
background: hsl(var(--primary) / 0.12);
}
.smx__chip--rule {
color: hsl(var(--muted-foreground));
background: hsl(var(--muted) / 0.4);
}
.smx__chip--rest {
color: hsl(var(--destructive));
background: hsl(var(--destructive) / 0.12);
}
.smx__chip--more {
color: hsl(var(--muted-foreground));
}
.smx__cell-num {
display: block;
font-size: 10px;
color: hsl(var(--muted-foreground));
}
/* 未配置排班的默认兜底格:弱化文字,避免被误读为已排班 */
.smx__default {
display: block;
font-size: 11px;
color: hsl(var(--muted-foreground) / 0.5);
}
</style>

View File

@@ -0,0 +1,345 @@
<script lang="ts" setup>
/**
* 班次模板管理弹窗(店级通用 + 医生专属两档)
* 诊所把常用班次(全天班/上午班/两班制等)保存成模板,日历排班时批量套用;
* 「适用范围」绑定医生后成为该医生的专属模板(一医生一店仅一个),
* 「添加医生排班」勾选优先专属时按医生自己的时段/号源写入。
* 列表 + 内联编辑两种视图切换:模板数量少(每店 ≤20无需分页表格卡片列表更直观
*/
import type { ScheduleSlot, ShiftTemplate } from '../../api/schedule';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Button, Empty, Input, message, Popconfirm, Select, Switch, Tag } from 'ant-design-vue';
import { Icon } from '#/components/icon';
import {
deleteShiftTemplateApi,
getShiftTemplateListApi,
saveShiftTemplateApi,
} from '../../api/schedule';
import SlotRowsEditor from './SlotRowsEditor.vue';
const emit = defineEmits<{ changed: [] }>();
/** 当前门店(打开时由父组件传入) */
const storeId = ref<number>(0);
const storeName = ref<string>('');
/** 店内医生列表(专属模板绑定选择用,父组件传入) */
const doctors = ref<Array<{ id: number; name: string }>>([]);
/** 模板列表 */
const list = ref<ShiftTemplate[]>([]);
/** 编辑态null=列表视图否则为正在编辑的模板id=0 新增doctor_id=0 通用 / >0 专属) */
const editing = ref<null | {
id: number;
doctor_id: number;
name: string;
slots: ScheduleSlot[];
status: number;
}>(null);
/** 模板是否有变更(关闭时通知父组件刷新模板下拉) */
const dirty = ref(false);
/** 适用范围下拉:通用 + 店内医生(专属) */
const scopeOptions = computed(() => [
{ label: '通用(全店可套用)', value: 0 },
...doctors.value.map((d) => ({ label: `专属 · ${d.name}`, value: d.id })),
]);
/** 拉取模板列表 */
async function fetchList() {
ModalApi.setState({ loading: true });
try {
const data = await getShiftTemplateListApi(storeId.value);
list.value = Array.isArray(data?.list) ? data.list : [];
} finally {
ModalApi.setState({ loading: false });
}
}
/** 进入新增编辑态(默认给一个全天时段起步,默认通用范围) */
function startCreate() {
editing.value = {
id: 0,
doctor_id: 0,
name: '',
slots: [{ slot_name: '全天', start_time: '08:00', end_time: '18:00', capacity: 30 }],
status: 1,
};
}
/** 进入编辑态(深拷贝防止未保存时污染列表显示) */
function startEdit(row: ShiftTemplate) {
editing.value = {
id: row.id,
doctor_id: Number(row.doctor_id || 0),
name: row.name,
slots: JSON.parse(JSON.stringify(row.slots || [])),
status: Number(row.status),
};
}
/** 保存模板(新增/编辑共用) */
async function handleSave() {
if (!editing.value) return;
if (!editing.value.name.trim()) {
message.warning('请输入模板名称');
return;
}
for (const slot of editing.value.slots) {
if (!slot.start_time || !slot.end_time) {
message.warning('请补全各时段的开始/结束时间');
return;
}
if (!slot.capacity || slot.capacity < 1) {
message.warning('各时段号源数量至少为 1');
return;
}
}
ModalApi.lock();
try {
await saveShiftTemplateApi({
id: editing.value.id,
store_id: storeId.value,
doctor_id: editing.value.doctor_id,
name: editing.value.name.trim(),
slots: editing.value.slots,
status: editing.value.status,
});
message.success('模板保存成功');
dirty.value = true;
editing.value = null;
await fetchList();
} catch {
// 错误提示由 requestClient 统一弹出
} finally {
ModalApi.unlock();
}
}
/** 启用/禁用切换直接落库禁用后套用下拉不再显示doctor_id 必须回传否则会把专属绑定抹成通用) */
async function toggleStatus(row: ShiftTemplate, checked: boolean | number | string) {
try {
await saveShiftTemplateApi({
id: row.id,
store_id: storeId.value,
doctor_id: Number(row.doctor_id || 0),
name: row.name,
slots: row.slots,
status: checked ? 1 : 0,
});
dirty.value = true;
} finally {
await fetchList();
}
}
/** 删除模板(日排班行是时段快照,删除模板不影响已排的班) */
async function handleDelete(row: ShiftTemplate) {
await deleteShiftTemplateApi(row.id, storeId.value);
message.success('删除成功');
dirty.value = true;
await fetchList();
}
const [Modal, ModalApi] = useVbenModal({
footer: false,
class: 'w-[560px]',
onOpenChange(isOpen) {
if (isOpen) {
const data = ModalApi.getData() || {};
storeId.value = Number(data.storeId || 0);
storeName.value = String(data.storeName || '');
doctors.value = Array.isArray(data.doctors) ? data.doctors : [];
editing.value = null;
dirty.value = false;
fetchList();
} else if (dirty.value) {
emit('changed');
}
},
});
defineExpose({ ModalApi });
</script>
<template>
<Modal :title="storeName ? `班次模板 - ${storeName}` : '班次模板'">
<!-- 编辑视图 -->
<div v-if="editing" class="tpl-edit">
<div class="tpl-edit__item">
<div class="tpl-edit__label">模板名称</div>
<Input
v-model:value="editing.name"
:maxlength="16"
placeholder="如:全天班 / 上午班 / 两班制"
style="width: 260px"
/>
</div>
<div class="tpl-edit__item">
<div class="tpl-edit__label">适用范围</div>
<Select
v-model:value="editing.doctor_id"
:options="scopeOptions"
option-filter-prop="label"
show-search
style="width: 260px"
/>
<div class="tpl-edit__hint">
绑定医生后成为其专属模板一医生仅一个添加医生排班勾选优先专属时按该模板的时段/号源写入
</div>
</div>
<div class="tpl-edit__item">
<div class="tpl-edit__label">班次时段</div>
<SlotRowsEditor v-model="editing.slots" />
</div>
<div class="tpl-edit__actions">
<Button @click="editing = null">返回列表</Button>
<Button type="primary" @click="handleSave">保存模板</Button>
</div>
</div>
<!-- 列表视图 -->
<div v-else class="tpl-list">
<div class="tpl-list__head">
<span class="tpl-list__count"> {{ list.length }} 个模板</span>
<Button size="small" type="primary" @click="startCreate">
<Icon icon="lucide:plus" />
新增模板
</Button>
</div>
<Empty v-if="list.length === 0" description="暂无班次模板,点击右上角新增" />
<div v-for="row in list" :key="row.id" class="tpl-card">
<div class="tpl-card__main">
<div class="tpl-card__title">
<span class="tpl-card__name">{{ row.name }}</span>
<!-- 专属模板标出绑定医生通用不加标签减少噪音 -->
<Tag v-if="Number(row.doctor_id || 0) > 0" color="processing">
专属 · {{ row.doctor_name || `医生#${row.doctor_id}` }}
</Tag>
<Tag v-if="Number(row.status) !== 1" color="default">已禁用</Tag>
</div>
<div class="tpl-card__slots">
<span v-for="(slot, i) in row.slots" :key="i" class="tpl-card__slot">
{{ slot.slot_name || `${slot.start_time}-${slot.end_time}` }}
{{ slot.start_time }}~{{ slot.end_time }} · {{ slot.capacity }}
</span>
</div>
</div>
<div class="tpl-card__actions">
<Switch
:checked="Number(row.status) === 1"
checked-children="启用"
size="small"
un-checked-children="禁用"
@change="(checked: any) => toggleStatus(row, checked)"
/>
<Button size="small" type="link" @click="startEdit(row)">编辑</Button>
<Popconfirm title="确定删除该模板吗?已排的班不受影响" @confirm="handleDelete(row)">
<Button danger size="small" type="link">删除</Button>
</Popconfirm>
</div>
</div>
</div>
</Modal>
</template>
<style scoped>
.tpl-edit {
display: flex;
flex-direction: column;
gap: 14px;
}
.tpl-edit__label {
margin-bottom: 6px;
font-size: 13px;
font-weight: 600;
color: hsl(var(--foreground));
}
.tpl-edit__hint {
margin-top: 4px;
font-size: 12px;
line-height: 1.5;
color: hsl(var(--muted-foreground));
}
.tpl-edit__actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.tpl-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.tpl-list__head {
display: flex;
align-items: center;
justify-content: space-between;
}
.tpl-list__count {
font-size: 12px;
color: hsl(var(--muted-foreground));
}
/* 模板卡片1px 同色系边框 + 微光(禁止左侧竖色条的全局卡片规范) */
.tpl-card {
display: flex;
gap: 10px;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
background: hsl(var(--card, var(--background)));
border: 1px solid hsl(var(--primary) / 30%);
border-radius: 10px;
box-shadow: 0 0 6px hsl(var(--primary) / 18%);
}
.tpl-card__main {
min-width: 0;
flex: 1;
}
.tpl-card__title {
display: flex;
gap: 6px;
align-items: center;
}
.tpl-card__name {
font-size: 14px;
font-weight: 600;
color: hsl(var(--foreground));
}
.tpl-card__slots {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 6px;
}
.tpl-card__slot {
padding: 2px 8px;
font-size: 12px;
color: hsl(var(--muted-foreground));
background: hsl(var(--muted) / 0.35);
border-radius: 6px;
}
.tpl-card__actions {
display: flex;
flex: none;
gap: 2px;
align-items: center;
}
</style>

View File

@@ -0,0 +1,124 @@
<script lang="ts" setup>
/**
* 排班时段行编辑器(通用小组件)
* 复用场景:班次模板编辑、日历排班「自定义时段」——两处都是编辑 1~10 个
* 「名称 + 开始/结束时间 + 号源数」的时段行,抽出来避免复制粘贴两套一样的行编辑 UI
* 名称留空时后端会自动用「开始-结束」时间段命名(与 ScheduleDrawer 自定义模式约定一致)
*/
import type { ScheduleSlot } from '../../api/schedule';
import { useVModel } from '@vueuse/core';
import { Button, Input, InputNumber, message, TimePicker } from 'ant-design-vue';
import dayjs from 'dayjs';
import { Icon } from '#/components/icon';
const props = defineProps<{ modelValue: ScheduleSlot[] }>();
const emit = defineEmits<{ 'update:modelValue': [value: ScheduleSlot[]] }>();
/** v-model 代理,直接对数组做增删改 */
const slots = useVModel(props, 'modelValue', emit);
/** 新增一行(上限 10 个,与后端 normalizeSlots 限制一致) */
function addRow() {
if (slots.value.length >= 10) {
message.warning('最多支持 10 个时段');
return;
}
slots.value.push({ slot_name: '', start_time: '', end_time: '', capacity: 5 });
}
/** 删除一行(至少保留一个时段) */
function removeRow(index: number) {
if (slots.value.length <= 1) {
message.warning('至少保留一个时段');
return;
}
slots.value.splice(index, 1);
}
/** TimePicker 变更(内部 dayjs 与 HH:mm 字符串互转) */
function onTimeChange(slot: ScheduleSlot, key: 'end_time' | 'start_time', val: any) {
slot[key] = val ? dayjs(val, 'HH:mm').format('HH:mm') : '';
}
</script>
<template>
<div class="slot-rows">
<div v-for="(slot, index) in slots" :key="index" class="slot-rows__row">
<Input
v-model:value="slot.slot_name"
:maxlength="16"
class="slot-rows__name"
placeholder="名称(可空)"
size="small"
/>
<TimePicker
:value="slot.start_time ? dayjs(slot.start_time, 'HH:mm') : null"
format="HH:mm"
placeholder="开始"
size="small"
@update:value="(val: any) => onTimeChange(slot, 'start_time', val)"
/>
<span class="slot-rows__sep">~</span>
<TimePicker
:value="slot.end_time ? dayjs(slot.end_time, 'HH:mm') : null"
format="HH:mm"
placeholder="结束"
size="small"
@update:value="(val: any) => onTimeChange(slot, 'end_time', val)"
/>
<InputNumber
v-model:value="slot.capacity"
:max="999"
:min="1"
size="small"
style="width: 78px"
/>
<span class="slot-rows__unit">个号</span>
<Button danger size="small" type="text" @click="removeRow(index)">
<Icon icon="lucide:x" />
</Button>
</div>
<Button block size="small" type="dashed" @click="addRow">
<Icon icon="lucide:plus" />
加时段
</Button>
<div class="slot-rows__tip">名称留空自动按时间段命名时段之间不可重叠</div>
</div>
</template>
<style scoped>
.slot-rows {
display: flex;
flex-direction: column;
gap: 8px;
}
.slot-rows__row {
display: flex;
gap: 6px;
align-items: center;
padding: 6px 8px;
background: hsl(var(--muted) / 0.25);
border: 1px solid hsl(var(--border));
border-radius: 8px;
}
.slot-rows__name {
width: 96px;
flex: none;
}
.slot-rows__sep,
.slot-rows__unit {
font-size: 12px;
color: hsl(var(--muted-foreground));
}
.slot-rows__tip {
font-size: 12px;
color: hsl(var(--muted-foreground));
}
</style>

View File

@@ -3,7 +3,7 @@ import type { VxeGridListeners } from '#/adapter/vxe-table';
import { ref } from 'vue';
import { Page, useVbenModal } from '@vben/common-ui';
import { Page, useVbenDrawer, useVbenModal } from '@vben/common-ui';
import { Button, message, Tag } from 'ant-design-vue';
@@ -19,6 +19,9 @@ import DoctorBasicInfoCell from './components/cells/DoctorBasicInfoCell.vue';
import DoctorProfessionalInfoCell from './components/cells/DoctorProfessionalInfoCell.vue';
import FormModalDemo from './components/modal.vue';
import DoctorCardModal from './components/DoctorCardModal.vue';
import ScheduleCalendarModal from './components/schedule-calendar/ScheduleCalendarModal.vue';
import ScheduleDrawerComp from './components/ScheduleDrawer.vue';
import StoreSettingModalComp from './components/StoreSettingModal.vue';
import { formOptions } from './config/search';
import { gridOptions } from './config/table';
@@ -67,6 +70,16 @@ const [DoctorCardModals, DoctorCardModalApi] = useVbenModal({
connectedComponent: DoctorCardModal,
});
/** 排班设置抽屉(真排班:每天/按星期 × 全天/上下午/自定义时段) */
const [ScheduleDrawer, ScheduleDrawerApi] = useVbenDrawer({
connectedComponent: ScheduleDrawerComp,
});
/** 门店统一可预约天数设置弹窗 */
const [StoreSettingModal, StoreSettingModalApi] = useVbenModal({
connectedComponent: StoreSettingModalComp,
});
const showModal = (data = {}, isUpdate = false) => {
formModalApi.setData({
values: data,
@@ -121,6 +134,43 @@ const showDoctorCard = (row: Record<string, any>) => {
DoctorCardModalApi.open();
};
/** 打开排班设置抽屉 */
const showScheduleDrawer = (row: Record<string, any>) => {
ScheduleDrawerApi.setData({ record: row });
ScheduleDrawerApi.open();
};
/** 排班日历弹窗(按日期排班,锁定该医生) */
const scheduleCalendarModalRef = ref<InstanceType<typeof ScheduleCalendarModal>>();
/**
* 打开排班日历(锁定医生模式)
* 店上下文取医生关联门店(列表行已带 stores单店直接进多店弹窗顶部可切换
* 未关联门店时排班无模板归属也无患者入口,直接提示先绑定诊所
*/
const showScheduleCalendar = (row: Record<string, any>) => {
const stores = (Array.isArray(row.stores) ? row.stores : [])
.filter((s: any) => s?.store?.id)
.map((s: any) => ({ id: Number(s.store.id), name: String(s.store.name || '') }));
if (stores.length === 0) {
message.warning('该医生未关联任何诊所,请先绑定诊所后再排班');
return;
}
scheduleCalendarModalRef.value?.ModalApi?.setData({
storeId: stores[0]!.id,
storeName: stores[0]!.name,
storeOptions: stores,
lockedDoctorId: Number(row.su_id || 0),
lockedDoctorName: String(row.name || ''),
});
scheduleCalendarModalRef.value?.ModalApi?.open();
};
/** 打开门店统一设置弹窗 */
const showStoreSettingModal = () => {
StoreSettingModalApi.open();
};
const openPcWindows = (id: number) => {
openPcWindowsApi(id).then(() => {
message.success('开通成功!');
@@ -151,6 +201,9 @@ const refuse = (id: number) => {
<BindDepartModal />
<BindTitleModal />
<DoctorCardModals />
<ScheduleDrawer />
<ScheduleCalendarModal ref="scheduleCalendarModalRef" />
<StoreSettingModal />
<Grid>
<template #toolbar-actions>
<TableAction
@@ -161,6 +214,12 @@ const refuse = (id: number) => {
icon: 'ant-design:plus-outlined',
onClick: showModal.bind(null),
},
{
label: '排班统一设置',
type: 'default',
icon: 'ant-design:setting-outlined',
onClick: showStoreSettingModal,
},
]"
:drop-down-actions="[]"
>
@@ -221,6 +280,20 @@ const refuse = (id: number) => {
size: 'small',
onClick: showModal.bind(null, row, true),
},
{
label: '排班设置',
type: 'link',
icon: 'ant-design:calendar-outlined',
size: 'small',
onClick: showScheduleDrawer.bind(null, row),
},
{
label: '排班日历',
type: 'link',
icon: 'ant-design:schedule-outlined',
size: 'small',
onClick: showScheduleCalendar.bind(null, row),
},
{
label: '开通PC端',
type: 'link',

View File

@@ -304,7 +304,7 @@ const handleAddToPrescription = async () => {
.drug-name {
color: #d97706;
font-weight: 600;
//background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
/* background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%); */
padding: 4px 12px;
border-radius: 6px;
display: inline-block;

View File

@@ -0,0 +1,20 @@
/**
* 医生排班变更日志 API只读列表/详情/动作下拉)
*/
import { requestClient } from '#/api/request';
const prefix = 'doctor-schedule-log/';
export async function getDoctorScheduleLogList(data: any) {
return requestClient.get<any>(`${prefix}list`, { params: data });
}
export async function getDoctorScheduleLogDetail(id: number) {
return requestClient.get<any>(`${prefix}detail`, { params: { id } });
}
export async function getScheduleLogActionOption() {
return requestClient.get<{ label: string; value: number }[]>(
`${prefix}action-option`,
);
}

View File

@@ -0,0 +1,241 @@
<script lang="ts" setup>
/**
* 排班变更日志详情:基础信息 + 结构化前后快照对照
* before 是「按日期分组」的原排班(无键日期=原本走默认规则),
* after 是本次统一应用到全部选中日期的新排班(空对象=恢复默认规则)
*/
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Descriptions, Tag } from 'ant-design-vue';
import { getDoctorScheduleLogDetail } from '../api';
import { scheduleActionColor } from '../config/constants';
const data = ref<Record<string, any>>({});
const loading = ref(false);
/** 涉及日期数组(后端 dates JSON 已解码) */
const dates = computed<string[]>(() =>
Array.isArray(data.value?.dates) ? data.value.dates : [],
);
/** 变更后快照:{is_rest, slots} 或 空对象(恢复默认) */
const after = computed<Record<string, any>>(() =>
data.value?.after_snapshot && typeof data.value.after_snapshot === 'object'
? data.value.after_snapshot
: {},
);
/** 变更后是否为「恢复默认规则」(空对象) */
const afterIsClear = computed(() => Object.keys(after.value).length === 0);
/** 变更前快照:{日期: {is_rest, slots}},转成有序数组渲染 */
const beforeEntries = computed<Array<{ date: string; snap: Record<string, any> }>>(() => {
const snap = data.value?.before_snapshot;
if (!snap || typeof snap !== 'object') return [];
return Object.keys(snap)
.sort()
.map((date) => ({ date, snap: snap[date] || {} }));
});
/** 时段行文本:「上午 08:30-12:00 号源20」 */
function slotText(slot: Record<string, any>): string {
const name = slot?.slot_name || '时段';
return `${name} ${slot?.start_time || ''}-${slot?.end_time || ''} 号源${slot?.capacity ?? 0}`;
}
const [Modal, modalApi] = useVbenModal({
fullscreenButton: true,
draggable: true,
showConfirmButton: false,
cancelText: '关闭',
onCancel() {
modalApi.close();
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
data.value = {};
return;
}
const payload = modalApi.getData<{ id?: number; values?: Record<string, any> }>();
const id = Number(payload?.id || payload?.values?.id || 0);
if (id <= 0) {
data.value = payload?.values || {};
return;
}
loading.value = true;
modalApi.setState({ loading: true });
try {
const res = await getDoctorScheduleLogDetail(id);
data.value = res?.data || res || {};
} catch {
data.value = payload?.values || {};
} finally {
loading.value = false;
modalApi.setState({ loading: false });
}
},
});
</script>
<template>
<Modal class="w-[92%] md:w-[760px]" title="排班变更详情">
<Descriptions
:column="{ xxl: 2, xl: 2, lg: 2, md: 1, sm: 1, xs: 1 }"
bordered
size="small"
>
<Descriptions.Item label="操作时间">{{ data.created_at }}</Descriptions.Item>
<Descriptions.Item label="操作人">
{{ data.operator_name || '-' }}
<Tag class="ml-1">{{ data.user_type_txt || '-' }}</Tag>
</Descriptions.Item>
<Descriptions.Item label="门店">{{ data.store_name || `#${data.store_id || 0}` }}</Descriptions.Item>
<Descriptions.Item label="医生">{{ data.doctor_name || `#${data.doctor_id || 0}` }}</Descriptions.Item>
<Descriptions.Item label="变更动作">
<Tag :color="scheduleActionColor(Number(data.action))">
{{ data.action_txt || data.action }}
</Tag>
<template v-if="data.template_name">{{ data.template_name }}</template>
</Descriptions.Item>
<Descriptions.Item label="批次号">{{ data.batch_no || '-' }}</Descriptions.Item>
<Descriptions.Item :span="2" label="涉及日期">
<div class="date-chips">
<Tag v-for="d in dates" :key="d">{{ d }}</Tag>
<span v-if="dates.length === 0">-</span>
</div>
</Descriptions.Item>
<Descriptions.Item v-if="data.remark" :span="2" label="备注">
{{ data.remark }}
</Descriptions.Item>
</Descriptions>
<!-- 前后对照变更前按日期变更后统一 -->
<div class="diff">
<div class="diff__col">
<div class="diff__head">变更前</div>
<div v-if="beforeEntries.length === 0" class="diff__empty">
所选日期原本均走默认排班规则无单独日排班
</div>
<div v-else class="diff__list">
<div v-for="item in beforeEntries" :key="item.date" class="diff__row">
<span class="diff__date">{{ item.date }}</span>
<template v-if="Number(item.snap.is_rest) === 1">
<Tag color="red">休诊</Tag>
</template>
<template v-else>
<span
v-for="(slot, i) in item.snap.slots || []"
:key="i"
class="diff__slot"
>
{{ slotText(slot) }}
</span>
</template>
</div>
<div class="diff__note">未列出的日期原本走默认规则</div>
</div>
</div>
<div class="diff__col">
<div class="diff__head">变更后应用到全部选中日期</div>
<div v-if="afterIsClear" class="diff__empty">恢复默认排班规则</div>
<template v-else>
<Tag v-if="Number(after.is_rest) === 1" color="red">休诊</Tag>
<div v-else class="diff__list">
<span
v-for="(slot, i) in after.slots || []"
:key="i"
class="diff__slot diff__slot--after"
>
{{ slotText(slot) }}
</span>
</div>
</template>
</div>
</div>
</Modal>
</template>
<style scoped>
.date-chips {
display: flex;
flex-wrap: wrap;
gap: 4px;
max-height: 120px;
overflow-y: auto;
}
.diff {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-top: 12px;
}
@media (max-width: 640px) {
.diff {
grid-template-columns: 1fr;
}
}
.diff__col {
padding: 10px 12px;
border: 1px solid hsl(var(--border));
border-radius: 10px;
}
.diff__head {
margin-bottom: 8px;
font-size: 13px;
font-weight: 600;
color: hsl(var(--foreground));
}
.diff__empty {
font-size: 12px;
color: hsl(var(--muted-foreground));
}
.diff__list {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 260px;
overflow-y: auto;
}
.diff__row {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
font-size: 12px;
}
.diff__date {
min-width: 84px;
font-weight: 600;
color: hsl(var(--foreground));
font-variant-numeric: tabular-nums;
}
.diff__slot {
padding: 1px 8px;
font-size: 12px;
color: hsl(var(--muted-foreground));
background: hsl(var(--muted) / 0.35);
border-radius: 6px;
}
.diff__slot--after {
color: hsl(var(--primary));
background: hsl(var(--primary) / 0.1);
}
.diff__note {
font-size: 11px;
color: hsl(var(--muted-foreground) / 0.7);
}
</style>

View File

@@ -0,0 +1,22 @@
/**
* 排班变更日志字典(与后端 ScheduleLogActionEnum 对齐,接口失败时兜底)
*/
export const SCHEDULE_ACTION_OPTIONS = [
{ label: '套用班次模板', value: 1 },
{ label: '自定义时段', value: 2 },
{ label: '设为休诊', value: 3 },
{ label: '恢复默认规则', value: 4 },
];
/** 动作值 → Tag 颜色(休诊红、恢复灰、模板青、自定义蓝) */
export function scheduleActionColor(action: number): string {
if (action === 3) return 'red';
if (action === 4) return 'default';
if (action === 1) return 'cyan';
return 'blue';
}
export const USER_TYPE_OPTIONS = [
{ label: '门店', value: 1 },
{ label: '平台', value: 2 },
];

View File

@@ -0,0 +1,70 @@
import type { VbenFormProps } from '#/adapter/form';
import dayjs from 'dayjs';
import { SCHEDULE_ACTION_OPTIONS } from './constants';
/**
* 排班变更日志搜索表单
* 关键词模糊搜医生/操作人/门店/模板/批次号;动作精确筛;时间范围转时间戳
*/
export const formOptions: VbenFormProps = {
collapsed: false,
schema: [
{
component: 'VbenInput',
componentProps: {
placeholder: '医生 / 操作人 / 门店 / 模板 / 批次号',
allowClear: true,
},
fieldName: 'keyword',
label: '关键词',
},
{
component: 'Select',
componentProps: {
allowClear: true,
options: [...SCHEDULE_ACTION_OPTIONS],
placeholder: '全部',
},
fieldName: 'action',
label: '变更动作',
},
{
component: 'RangePicker',
componentProps: {
format: 'YYYY-MM-DD',
valueFormat: 'YYYY-MM-DD',
},
fieldName: 'search_time',
label: '操作时间',
},
],
showCollapseButton: true,
submitButtonOptions: {
content: '查询',
},
submitOnChange: false,
submitOnEnter: true,
};
/**
* search_time → start_time/end_time 时间戳;清理空值参数
*/
export function normalizeScheduleLogFilters(
formValues: Record<string, any> = {},
) {
const params: Record<string, any> = { ...formValues };
const range = params.search_time;
delete params.search_time;
if (Array.isArray(range) && range.length === 2 && range[0] && range[1]) {
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];
}
});
return params;
}

View File

@@ -0,0 +1,65 @@
import type { VxeGridProps } from '#/adapter/vxe-table';
import { getDoctorScheduleLogList } from '../api';
import { normalizeScheduleLogFilters } from './search';
/**
* 排班变更日志列定义:谁(操作人)在哪(门店)把谁(医生)的哪些天改成了什么(动作/模板)
*/
export const gridOptions: VxeGridProps<any> = {
columns: [
{ field: 'id', title: 'ID', width: 70 },
{ field: 'operator_name', title: '操作人', minWidth: 90 },
{
field: 'user_type_txt',
title: '端',
width: 70,
slots: { default: 'user_type' },
},
{ field: 'store_name', title: '门店', minWidth: 130 },
{ field: 'doctor_name', title: '医生', minWidth: 90 },
{
field: 'action_txt',
title: '变更动作',
width: 120,
slots: { default: 'action_type' },
},
{ field: 'template_name', title: '班次模板', minWidth: 100 },
{
field: 'dates_count',
title: '涉及天数',
width: 90,
slots: { default: 'dates_count' },
},
{ field: 'batch_no', title: '批次号', minWidth: 150 },
{ field: 'created_at', title: '操作时间', width: 170 },
{
title: '操作',
width: 80,
fixed: 'right',
slots: { default: 'action' },
},
],
keepSource: true,
pagerConfig: {},
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getDoctorScheduleLogList({
page: page.currentPage,
pageSize: page.pageSize,
...normalizeScheduleLogFilters(formValues || {}),
});
},
},
},
height: 'auto',
toolbarConfig: {
search: true,
refresh: true,
zoom: true,
slots: {
buttons: 'toolbar-actions',
},
},
};

View File

@@ -0,0 +1,87 @@
<script lang="ts" setup>
/**
* 医生排班变更日志列表(日志审计)
* 记录日历排班的每次修改:操作人、门店、医生、动作、涉及日期与前后快照;
* 同一次多医生批量操作通过批次号串联,详情弹窗展示前后排班对照
*/
import { onMounted } from 'vue';
import { Page, useVbenModal } from '@vben/common-ui';
import { Tag } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { TableAction } from '#/components/table-action';
import { getScheduleLogActionOption } from './api';
import DetailModal from './components/detail-modal.vue';
import { scheduleActionColor } from './config/constants';
import { formOptions } from './config/search';
import { gridOptions } from './config/table';
defineOptions({ name: 'DoctorScheduleLog' });
const [Grid, gridApi] = useVbenVxeGrid({
formOptions,
gridOptions,
});
const [DetailModalComp, detailModalApi] = useVbenModal({
connectedComponent: DetailModal,
});
/** 打开详情弹窗(传行数据兜底,详情接口失败时仍可看列表字段) */
function openDetail(row: Record<string, any>) {
detailModalApi.setData({ id: Number(row?.id || 0), values: row });
detailModalApi.open();
}
onMounted(async () => {
try {
const opts = await getScheduleLogActionOption();
const options = Array.isArray(opts) ? opts : [];
gridApi.formApi?.updateSchema?.([
{
fieldName: 'action',
componentProps: { allowClear: true, options, placeholder: '全部' },
},
]);
} catch {
// 接口失败时沿用 constants 兜底选项
}
});
</script>
<template>
<Page auto-content-height title="排班变更日志">
<DetailModalComp />
<Grid>
<template #toolbar-actions />
<template #user_type="{ row }">
<Tag :color="Number(row.user_type) === 2 ? 'geekblue' : 'green'">
{{ row.user_type_txt || row.user_type }}
</Tag>
</template>
<template #action_type="{ row }">
<Tag :color="scheduleActionColor(Number(row.action))">
{{ row.action_txt || row.action }}
</Tag>
</template>
<template #dates_count="{ row }">
{{ row.dates_count || (Array.isArray(row.dates) ? row.dates.length : 0) }}
</template>
<template #action="{ row }">
<TableAction
:actions="[
{
label: '详情',
type: 'link',
size: 'small',
onClick: () => openDetail(row),
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@@ -0,0 +1,295 @@
<script lang="ts" setup>
/**
* 消息中心 · 排班变更专属视图type_code=schedule_change
* 渲染 action_payload动作场景卡 + 涉及日期 chips + 新时段表 + 操作人/批次溯源
* 医生收到后一眼看清「哪家诊所把我哪些天改成了什么班」
*/
import { computed, ref } from 'vue';
const props = defineProps<{
notice: Record<string, any>;
}>();
/** 日期默认收起阈值:月排可能 30+ 天,全量平铺会把弹窗撑爆 */
const DATE_COLLAPSE_LIMIT = 12;
const datesExpanded = ref(false);
/** action_payload 可能是对象(接口已解析)或 JSON 字符串(兜底解析) */
const payload = computed<Record<string, any>>(() => {
const p = props.notice?.action_payload;
if (p && typeof p === 'object') return p;
try {
return JSON.parse(String(p || '{}')) || {};
} catch {
return {};
}
});
/** 动作场景:文案 + 色调(休诊红 / 恢复默认灰 / 排班青) */
const scene = computed(() => {
const action = Number(payload.value?.action || 0);
if (action === 3) return { label: '设为休诊', tone: 'danger' };
if (action === 4) return { label: '恢复默认规则', tone: 'normal' };
if (action === 1) {
const name = String(payload.value?.template_name || '');
return { label: name ? `套用模板「${name}` : '套用班次模板', tone: 'work' };
}
return { label: payload.value?.action_txt || '排班调整', tone: 'work' };
});
const dates = computed<string[]>(() =>
Array.isArray(payload.value?.dates) ? payload.value.dates : [],
);
/** 收起态只显示前 N 个日期 */
const visibleDates = computed(() =>
datesExpanded.value ? dates.value : dates.value.slice(0, DATE_COLLAPSE_LIMIT),
);
const slots = computed<Array<Record<string, any>>>(() =>
Array.isArray(payload.value?.slots) ? payload.value.slots : [],
);
/** 日期显示成「8/19 周三」这种紧凑格式 */
function formatDate(date: string): string {
const d = new Date(`${date}T00:00:00`);
if (Number.isNaN(d.getTime())) return date;
const weeks = ['日', '一', '二', '三', '四', '五', '六'];
return `${d.getMonth() + 1}/${d.getDate()}${weeks[d.getDay()]}`;
}
</script>
<template>
<div class="sched-notice">
<!-- 场景卡动作 + 医生 + 门店 -->
<div class="hero" :class="`tone-${scene.tone}`">
<div class="hero__glow" aria-hidden="true"></div>
<div class="hero__top">
<span class="scene-pill">{{ scene.label }}</span>
<span class="count-pill">{{ dates.length }} </span>
</div>
<h3 class="hero__title">
{{ payload.doctor_name || '您' }} 的出诊排班有调整
</h3>
<p class="hero__store">{{ payload.store_name || notice.title || '' }}</p>
</div>
<!-- 涉及日期 -->
<div v-if="dates.length > 0" class="section">
<div class="section__label">涉及日期</div>
<div class="date-chips">
<span v-for="d in visibleDates" :key="d" class="date-chip">
{{ formatDate(d) }}
</span>
<button
v-if="dates.length > DATE_COLLAPSE_LIMIT"
class="date-chip date-chip--more"
type="button"
@click="datesExpanded = !datesExpanded"
>
{{ datesExpanded ? '收起' : `+${dates.length - DATE_COLLAPSE_LIMIT}` }}
</button>
</div>
</div>
<!-- 新排班时段休诊/恢复默认无时段 -->
<div v-if="slots.length > 0" class="section">
<div class="section__label">调整后的出诊时段</div>
<div class="slot-list">
<div v-for="(slot, i) in slots" :key="i" class="slot-row">
<span class="slot-row__name">{{ slot.slot_name || `时段${i + 1}` }}</span>
<span class="slot-row__time">{{ slot.start_time }} - {{ slot.end_time }}</span>
<span class="slot-row__cap">号源 {{ slot.capacity }}</span>
</div>
</div>
</div>
<div v-else-if="Number(payload.action) === 3" class="section rest-hint">
以上日期已停诊患者端不再放号已有预约不受本次操作影响
</div>
<div v-else-if="Number(payload.action) === 4" class="section rest-hint">
以上日期回到您的默认排班规则按医生管理里配置的每天/按星期规则放号
</div>
<p v-if="notice.detail" class="detail">{{ notice.detail }}</p>
<div class="meta-line">
<span v-if="payload.operator_name">操作人{{ payload.operator_name }}</span>
<span v-if="payload.batch_no">批次号{{ payload.batch_no }}</span>
</div>
</div>
</template>
<style scoped>
.sched-notice {
padding: 2px 0 6px;
}
/* 场景 hero 卡:青色系(对应 schedule_change 类型色),休诊红、恢复灰 */
.hero {
position: relative;
padding: 18px 20px;
overflow: hidden;
color: #fff;
background: linear-gradient(135deg, #16323a 0%, #1f5560 48%, #2a97a8 100%);
border-radius: 16px;
box-shadow:
0 10px 28px hsl(190 45% 22% / 0.28),
inset 0 1px 0 hsl(0 0% 100% / 0.12);
}
.hero.tone-danger {
background: linear-gradient(135deg, #2a181c 0%, #5a2430 52%, #a33b4a 100%);
box-shadow:
0 10px 28px hsl(350 45% 22% / 0.28),
inset 0 1px 0 hsl(0 0% 100% / 0.12);
}
.hero.tone-normal {
background: linear-gradient(135deg, #23272e 0%, #3a4149 52%, #5d6b77 100%);
box-shadow:
0 10px 28px hsl(215 15% 22% / 0.28),
inset 0 1px 0 hsl(0 0% 100% / 0.12);
}
.hero__glow {
position: absolute;
inset: -40% auto auto -20%;
width: 70%;
height: 90%;
pointer-events: none;
background: radial-gradient(circle, hsl(0 0% 100% / 0.16), transparent 68%);
}
.hero__top {
position: relative;
z-index: 1;
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 12px;
}
.scene-pill,
.count-pill {
display: inline-flex;
padding: 3px 10px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
background: hsl(0 0% 100% / 0.16);
backdrop-filter: blur(6px);
border-radius: 999px;
}
.hero__title {
position: relative;
z-index: 1;
margin: 0;
font-size: 18px;
font-weight: 720;
letter-spacing: -0.02em;
}
.hero__store {
position: relative;
z-index: 1;
margin: 6px 0 0;
font-size: 13px;
opacity: 0.85;
}
.section {
margin-top: 14px;
}
.section__label {
margin-bottom: 8px;
font-size: 12px;
font-weight: 600;
color: hsl(var(--muted-foreground));
}
.date-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.date-chip {
padding: 3px 10px;
font-size: 12px;
color: hsl(var(--foreground));
background: hsl(var(--muted) / 0.35);
border: 1px solid hsl(var(--border));
border-radius: 8px;
}
.date-chip--more {
color: hsl(var(--primary));
cursor: pointer;
background: hsl(var(--primary) / 0.08);
border-color: hsl(var(--primary) / 0.35);
}
.slot-list {
overflow: hidden;
border: 1px solid hsl(var(--border));
border-radius: 12px;
}
.slot-row {
display: flex;
gap: 12px;
align-items: center;
padding: 10px 14px;
background: hsl(var(--card, var(--background)));
}
.slot-row + .slot-row {
border-top: 1px solid hsl(var(--border) / 0.6);
}
.slot-row__name {
min-width: 64px;
font-size: 13px;
font-weight: 600;
color: hsl(var(--foreground));
}
.slot-row__time {
flex: 1;
font-size: 13px;
color: hsl(var(--muted-foreground));
font-variant-numeric: tabular-nums;
}
.slot-row__cap {
font-size: 12px;
font-weight: 600;
color: hsl(var(--primary));
}
.rest-hint {
padding: 10px 14px;
font-size: 13px;
color: hsl(var(--muted-foreground));
background: hsl(var(--muted) / 0.25);
border: 1px solid hsl(var(--border));
border-radius: 12px;
}
.detail {
margin: 14px 0 0;
font-size: 13px;
line-height: 1.55;
color: hsl(var(--muted-foreground));
}
.meta-line {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 10px;
font-size: 12px;
color: hsl(var(--muted-foreground));
}
</style>

View File

@@ -10,6 +10,7 @@ import PeriodReportView from './PeriodReportView.vue';
import PriceChangeNoticeView from './PriceChangeNoticeView.vue';
import QueueFailureView from './QueueFailureView.vue';
import RichTextNoticeView from './RichTextNoticeView.vue';
import ScheduleChangeNoticeView from './ScheduleChangeNoticeView.vue';
import StoreInputNoticeView from './StoreInputNoticeView.vue';
import VipNoticeView from './VipNoticeView.vue';
import WarehouseProductNoticeView from './WarehouseProductNoticeView.vue';
@@ -25,6 +26,7 @@ export const NOTICE_TYPE_VIEW: Record<string, Component> = {
store_input_audit: StoreInputNoticeView,
warehouse_product_audit: WarehouseProductNoticeView,
price_change: PriceChangeNoticeView,
schedule_change: ScheduleChangeNoticeView,
system: RichTextNoticeView,
broadcast: RichTextNoticeView,
prescription_audit: RichTextNoticeView,

View File

@@ -41,6 +41,7 @@ import BindPrescriptionTypesModal from './components/BindPrescriptionTypesModal.
import StoreExternalFieldModal from './components/StoreExternalFieldModal.vue';
import SalespersonCommissionDrawer from './components/SalespersonCommissionDrawer.vue';
import AddDoctorFromStoreModal from './components/AddDoctorFromStoreModal.vue';
import ScheduleCalendarModal from '#/views/doctor/doctor/components/schedule-calendar/ScheduleCalendarModal.vue';
import StoreCardModal from '#/components/store-card/StoreCardModal.vue';
import StoreVipCell from '#/components/vip/StoreVipCell.vue';
import VipHubModal from '#/components/vip/VipHubModal.vue';
@@ -385,6 +386,20 @@ const openSalespersonCommission = (row: { id: number; name: string }) => {
salespersonDrawerRef.value?.open(row);
};
const scheduleCalendarModalRef = ref<InstanceType<typeof ScheduleCalendarModal>>();
/**
* 打开「排班管理」日历面板
* 诊所维度入口:面板内选医生 → 月历多选日期 → 套班次模板/自定义时段/休诊
*/
const openScheduleCalendar = (row: { id: number; name: string }) => {
scheduleCalendarModalRef.value?.ModalApi?.setData({
storeId: Number(row?.id || 0),
storeName: row?.name || '',
});
scheduleCalendarModalRef.value?.ModalApi?.open();
};
/**
* 更新包邮状态
*/
@@ -503,6 +518,7 @@ const handleSwitchClinicType = (row: any) => {
<BindPrescriptionTypesModalComponent />
<StoreExternalFieldModalComponent />
<SalespersonCommissionDrawer ref="salespersonDrawerRef" />
<ScheduleCalendarModal ref="scheduleCalendarModalRef" />
<Grid>
<template #toolbar-actions>
<TableAction
@@ -779,6 +795,14 @@ const handleSwitchClinicType = (row: any) => {
ifShow: canAddDoctor,
onClick: openAddDoctorModal.bind(null, row),
},
{
label: '排班管理',
type: 'link',
icon: 'ant-design:calendar-outlined',
size: 'small',
auth: ['Super Admin', 'Admin'],
onClick: () => openScheduleCalendar(row),
},
{
label: '绑定特色方医生',
type: 'link',

Some files were not shown because too many files have changed in this diff Show More