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:
@@ -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
3
.npmrc
@@ -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
2
apps/desktop/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
out/
|
||||
release/
|
||||
46
apps/desktop/README.md
Normal file
46
apps/desktop/README.md
Normal 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` 并上传产物,桌面端即可自动更新。
|
||||
78
apps/desktop/electron-builder.config.cjs
Normal file
78
apps/desktop/electron-builder.config.cjs
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* electron-builder 打包配置
|
||||
* 用 JS 配置而不是 yml:mac 公证需要按「环境变量是否配齐」动态开关。
|
||||
*
|
||||
* 签名策略(按需求做成条件化,未配置证书时出未签名包且构建不报错):
|
||||
* - 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;
|
||||
26
apps/desktop/electron.vite.config.ts
Normal file
26
apps/desktop/electron.vite.config.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { defineConfig, externalizeDepsPlugin } from 'electron-vite';
|
||||
|
||||
/**
|
||||
* electron-vite 构建配置
|
||||
* 只构建主进程与预加载脚本,不配置 renderer:
|
||||
* - 开发模式渲染层直接加载 web-antd 的 vite dev server(http://localhost:5666)
|
||||
* - 打包模式渲染层加载 app://local/(web-antd 的生产 dist,由主进程自定义协议提供服务)
|
||||
* externalizeDepsPlugin:dependencies(electron-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
27
apps/desktop/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
18
apps/desktop/resources/entitlements.mac.plist
Normal file
18
apps/desktop/resources/entitlements.mac.plist
Normal 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 下 Electron(V8 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>
|
||||
BIN
apps/desktop/resources/icon.ico
Normal file
BIN
apps/desktop/resources/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 279 KiB |
BIN
apps/desktop/resources/icon.png
Normal file
BIN
apps/desktop/resources/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 94 KiB |
30
apps/desktop/src/main/auto-launch.ts
Normal file
30
apps/desktop/src/main/auto-launch.ts
Normal 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();
|
||||
}
|
||||
24
apps/desktop/src/main/badge.ts
Normal file
24
apps/desktop/src/main/badge.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { app, nativeImage } from 'electron';
|
||||
|
||||
import { getMainWindow } from './window';
|
||||
|
||||
/**
|
||||
* 未读角标:按平台分支实现
|
||||
* - mac:Dock 图标原生支持文字角标,直接 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, '');
|
||||
}
|
||||
}
|
||||
54
apps/desktop/src/main/config.ts
Normal file
54
apps/desktop/src/main/config.ts
Normal 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);
|
||||
}
|
||||
81
apps/desktop/src/main/index.ts
Normal file
81
apps/desktop/src/main/index.ts
Normal 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();
|
||||
}
|
||||
117
apps/desktop/src/main/ipc.ts
Normal file
117
apps/desktop/src/main/ipc.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
75
apps/desktop/src/main/printer.ts
Normal file
75
apps/desktop/src/main/printer.ts
Normal 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));
|
||||
});
|
||||
});
|
||||
}
|
||||
108
apps/desktop/src/main/protocol.ts
Normal file
108
apps/desktop/src/main/protocol.ts
Normal 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/web(electron-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);
|
||||
});
|
||||
}
|
||||
67
apps/desktop/src/main/settings.ts
Normal file
67
apps/desktop/src/main/settings.ts
Normal 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;
|
||||
}
|
||||
21
apps/desktop/src/main/shortcut.ts
Normal file
21
apps/desktop/src/main/shortcut.ts
Normal 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();
|
||||
}
|
||||
78
apps/desktop/src/main/tray.ts
Normal file
78
apps/desktop/src/main/tray.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
139
apps/desktop/src/main/updater.ts
Normal file
139
apps/desktop/src/main/updater.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
212
apps/desktop/src/main/window.ts
Normal file
212
apps/desktop/src/main/window.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
102
apps/desktop/src/preload/index.ts
Normal file
102
apps/desktop/src/preload/index.ts
Normal 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 提供的受限 API(contextBridge / ipcRenderer 等)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Windows 任务栏角标图:用 canvas 画「红底白字」数字返回 dataURL
|
||||
* 为什么在 preload 画:主进程没有 DOM/canvas;preload 运行在渲染进程内有 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;
|
||||
18
apps/desktop/tsconfig.json
Normal file
18
apps/desktop/tsconfig.json
Normal 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"]
|
||||
}
|
||||
@@ -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:",
|
||||
|
||||
BIN
apps/web-antd/public/audio/register-notify.mp3
Normal file
BIN
apps/web-antd/public/audio/register-notify.mp3
Normal file
Binary file not shown.
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
5
apps/web-antd/src/components/stat-islands/index.ts
Normal file
5
apps/web-antd/src/components/stat-islands/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* 通用统计岛组件导出:紧凑玻璃质感统计小卡(设计对齐提现管理账户卡统计岛)
|
||||
*/
|
||||
export { default as StatIslands } from './stat-islands.vue';
|
||||
export type { StatIslandItem } from './typing';
|
||||
166
apps/web-antd/src/components/stat-islands/stat-islands.vue
Normal file
166
apps/web-antd/src/components/stat-islands/stat-islands.vue
Normal 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>
|
||||
19
apps/web-antd/src/components/stat-islands/typing.ts
Normal file
19
apps/web-antd/src/components/stat-islands/typing.ts
Normal 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;
|
||||
}
|
||||
88
apps/web-antd/src/components/table-action/card-actions.vue
Normal file
88
apps/web-antd/src/components/table-action/card-actions.vue
Normal file
@@ -0,0 +1,88 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 卡片视图专用操作栏(与列表交互解耦)
|
||||
*
|
||||
* 列表单元格里 TableAction 平铺全部按钮没问题,但卡片 footer 空间小,
|
||||
* 按钮超过 2 个会触发 TableAction 的两列 grid 换行堆叠,显得杂乱。
|
||||
* 这里改成 C 端惯例:只露出前 maxVisible 个主操作(默认 1 个,通常是「详情」),
|
||||
* 其余操作全部合并进「···」下拉菜单。
|
||||
*
|
||||
* 权限(auth)/ 显隐(ifShow)/ 二次确认(popConfirm)逻辑不重写:
|
||||
* - 挑主操作前先按与 TableAction 相同的规则过滤,保证露出的一定是可见按钮;
|
||||
* - 渲染仍复用 TableAction(flex=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>
|
||||
@@ -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';
|
||||
|
||||
3
apps/web-antd/src/components/view-mode-switch/index.ts
Normal file
3
apps/web-antd/src/components/view-mode-switch/index.ts
Normal 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';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
37
apps/web-antd/src/types/desktop.d.ts
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* 桌面端(Electron)preload 注入的全局 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;
|
||||
};
|
||||
}
|
||||
122
apps/web-antd/src/util/desktop.ts
Normal file
122
apps/web-antd/src/util/desktop.ts
Normal 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));
|
||||
}
|
||||
},
|
||||
};
|
||||
144
apps/web-antd/src/utils/register-notify.ts
Normal file
144
apps/web-antd/src/utils/register-notify.ts
Normal 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);
|
||||
}
|
||||
41
apps/web-antd/src/utils/tagColor.ts
Normal file
41
apps/web-antd/src/utils/tagColor.ts
Normal 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` };
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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', '来电超时,自动拒绝');
|
||||
|
||||
@@ -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>
|
||||
@@ -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' },
|
||||
};
|
||||
|
||||
/** 审核状态映射(status:0待审核 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 },
|
||||
];
|
||||
@@ -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',
|
||||
// 就诊人姓名/手机号模糊(后端 PersonKeywordScopeService,whereHas 关联表)
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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);
|
||||
/** 审核状态 Tab:0待审核 / 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 Tab,Tab 单独合并) */
|
||||
const searchValues = ref<Record<string, any>>(collectDefaultSearchValues());
|
||||
|
||||
/** 卡片视图最终条件:搜索条件 + 状态 Tab(computed 保证 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 会接管默认行为,必须手动还原控件到 defaultValue(reset 为新 API,resetForm 已弃用)
|
||||
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();
|
||||
};
|
||||
|
||||
/** 列表槽用元数据取值 helper:v-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 + 收起态搜索一行 + 切换行约 220px,min-height 兜底) */
|
||||
.rx-card-wrap {
|
||||
height: calc(100% - 220px);
|
||||
min-height: 420px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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' };
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 会接管默认行为,必须手动还原控件到 defaultValue(reset 为新 API,resetForm 已弃用)
|
||||
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 走详情接口拿到 packages;values 作首屏兜底
|
||||
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();
|
||||
// };
|
||||
|
||||
/**
|
||||
* 列表槽用元数据取值 helper:v-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%);
|
||||
}
|
||||
|
||||
/* 卡片看板容器:撑满剩余高度,内部滚动(统计岛 + 收起态搜索一行 + 切换行约 220px,min-height 兜底) */
|
||||
.po-card-wrap {
|
||||
height: calc(100% - 220px);
|
||||
min-height: 420px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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())}`;
|
||||
|
||||
@@ -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>) {
|
||||
|
||||
@@ -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.sex:0默认 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>
|
||||
@@ -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>
|
||||
@@ -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.status:0待支付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_type:1微信 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 },
|
||||
];
|
||||
@@ -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: '订单号',
|
||||
},
|
||||
{
|
||||
// 就诊人姓名/手机号模糊(后端 PersonKeywordScopeService,whereHas 关联表)
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -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 会接管默认行为,必须手动还原控件到 defaultValue(reset 为新 API,resetForm 已弃用)
|
||||
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)}`;
|
||||
}
|
||||
|
||||
/** 列表槽用元数据取值 helper:v-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;
|
||||
}
|
||||
/* 卡片看板容器:撑满剩余高度,内部滚动(收起态搜索一行 + 切换行约 170px,min-height 兜底) */
|
||||
.rg-card-wrap {
|
||||
height: calc(100% - 170px);
|
||||
min-height: 420px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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 也视为失败:面板依赖 storeId,0 渲染不出任何内容
|
||||
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
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -181,6 +181,9 @@ onMounted(fetchAll);
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 卡底公告轮播条:父级按布局配置注入(NoticeTicker),数据源独立不受本卡 loading 影响 -->
|
||||
<slot name="notice"></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -1,5 +1,8 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* @deprecated 工作台公告已改用单行轮播条 NoticeTicker.vue(列表卡占一整列空间浪费),
|
||||
* 本组件不再被布局引用,保留文件仅供回滚参考,勿再新增引用
|
||||
*
|
||||
* 最新动态(Premium):紧凑公告列表,tinted 图标 + 类型 pill + 点击弹详情
|
||||
* 类型为什么前端推导:announcement/latest(xk_notice 表)没有类型字段,
|
||||
* 按标题关键词映射出「更新/活动/维护/公告」纯展示标签,后端无需改动
|
||||
|
||||
@@ -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>
|
||||
@@ -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',
|
||||
]);
|
||||
|
||||
@@ -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/块 */
|
||||
|
||||
@@ -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">
|
||||
|
||||
182
apps/web-antd/src/views/doctor/doctor/api/schedule.ts
Normal file
182
apps/web-antd/src/views/doctor/doctor/api/schedule.ts
Normal 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_id;store_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 多医生批量,二选一或同传自动合并去重)
|
||||
* action:apply_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 } : {}),
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
20
apps/web-antd/src/views/log/doctor-schedule-log/api/index.ts
Normal file
20
apps/web-antd/src/views/log/doctor-schedule-log/api/index.ts
Normal 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`,
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 },
|
||||
];
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
};
|
||||
87
apps/web-antd/src/views/log/doctor-schedule-log/index.vue
Normal file
87
apps/web-antd/src/views/log/doctor-schedule-log/index.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user