feat: 辅料功能、康正源拉取价格接口

This commit is contained in:
李琦
2026-09-30 17:02:54 +08:00
parent bfe50a8d67
commit 87c480f391
51 changed files with 4086 additions and 380 deletions

4
apps/desktop/.npmrc Normal file
View File

@@ -0,0 +1,4 @@
# 桌面端本地打包:强制 electron / electron-builder 走国内镜像
# (仅根 .npmrc 时,pnpm exec electron-builder 子进程常仍打 GitHub 导致 ETIMEDOUT)
electron_mirror=https://npmmirror.com/mirrors/electron/
electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/

View File

@@ -34,16 +34,41 @@ pnpm build:antd-desktop
| `XK_DESKTOP_API_UPSTREAM` | 覆盖 `/api/admin` 转发上游(联调测试环境用) |
| `XK_DESKTOP_IM_UPSTREAM` | 覆盖 `/im-api` 转发上游 |
| `XK_DESKTOP_DEV_URL` | 覆盖开发模式加载的 dev server 地址(默认 `http://localhost:5666`) |
| `XK_DESKTOP_OPEN_API_VERSION_URL` | 覆盖桌面版本 OpenAPI 完整 URL(默认 `{API_UPSTREAM}/open-api/desktop/version/latest`) |
| `XK_DESKTOP_VERSION` | 打包时跳过交互,强制写入该 semver(CI 用;也可用 `--version=1.0.1`) |
| `CSC_LINK` / `CSC_KEY_PASSWORD` | 代码签名证书;**未配置时自动跳过签名,出未签名包,构建不报错** |
| `APPLE_ID` / `APPLE_APP_SPECIFIC_PASSWORD` / `APPLE_TEAM_ID` | mac 公证三件套;三者齐全才启用公证 |
未签名 mac 包可内部分发:首次打开需右键「打开」,且 mac 自动更新不可用(应用内会降级为提示前往下载页手动安装)。
## 打包与版本确认
`pnpm -F @vben/desktop run pack`(以及根目录 `build:desktop` / `build:antd-desktop`)会先交互确认版本:
```
上次打包版本号:1.0.0
1) 使用上次版本号(1.0.0)
2) 输入新的版本号
```
确认后写入 `apps/desktop/package.json` 的 `version`,再执行 electron-builder。
产物目录 `apps/desktop/release/`:
| 文件 | 用途 |
|---|---|
| `xk-desktop-{version}-win-x64.exe` | 自动更新 / latest.yml 引用(ASCII) |
| `萧康医药 {version}.exe` | 人工或后台上传用友好文件名(由 pack 脚本复制生成) |
## 发布自动更新
`release/` 里的以下产物上传到静态托管地址(`electron-builder.config.cjs` 的 `publish.url`):
- Windows:`latest.yml` + `xk-desktop-*.exe` + `*.blockmap`
- Windows:`latest.yml` + `xk-desktop-*.exe` + `*.blockmap`(自动更新);另可将 `萧康医药 {version}.exe` 作对外下载名
- mac:`latest-mac.yml` + `*.dmg` + `*.zip` + `*.blockmap`(zip 是 mac 自动更新必需格式)
前端每次发版后跟着跑一次 `pnpm build:desktop` 并上传产物,桌面端即可自动更新。
前端每次发版后跟着跑一次 `pnpm build:desktop` 并上传产物,再在后台「系统管理 → 桌面端更新」登记并发布。
桌面端行为:启动约 10 秒后检查一次,之后每 **3 小时**轮询;发现新版本后台下载,完成后提示重启安装(后台勾选「强制更新」则下载完直接重启)。检查前会请求 OpenAPI 元数据以覆盖 feed / 强制标记。
Windows 桌面端使用自绘顶栏(与系统窗口按钮同一行):左上角「萧康云医」菜单含刷新 / 检查更新 / 关于。

View File

@@ -1,6 +1,6 @@
{
"name": "@vben/desktop",
"version": "1.0.0",
"version": "0.1.0",
"private": true,
"description": "萧康云医 PC 桌面端(Electron 壳,打包加载 web-antd 生产构建产物)",
"author": "萧康云医",
@@ -8,7 +8,8 @@
"scripts": {
"dev": "electron-vite dev",
"build": "electron-vite build",
"pack": "electron-builder --config electron-builder.config.cjs",
"pack": "node ./scripts/pack.mjs",
"pack:raw": "node ./scripts/run-builder.mjs",
"typecheck": "tsc --noEmit"
},
"dependencies": {

View File

@@ -0,0 +1,196 @@
/**
* 桌面打包脚本:交互确认版本号 → electron-builder → 复制友好文件名
*
* 交互:
* 上次打包版本号:x.y.z
* 1) 使用上次版本号
* 2) 输入新版本号
*
* 产物:
* - 自动更新用 ASCII 名:xk-desktop-{version}-win-x64.exe(latest.yml 引用)
* - 人工/后台上传用:萧康医药 {version}.exe
*
* 非交互(CI):XK_DESKTOP_VERSION=1.2.3 或 --version=1.2.3
*/
import { spawn } from 'node:child_process';
import { copyFileSync, existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { createInterface } from 'node:readline';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const desktopRoot = join(__dirname, '..');
const packageJsonPath = join(desktopRoot, 'package.json');
const releaseDir = join(desktopRoot, 'release');
/** semver 简校验 */
function isValidVersion(v) {
return /^\d+\.\d+\.\d+([-+][0-9A-Za-z.-]+)?$/.test(v);
}
function readPkg() {
return JSON.parse(readFileSync(packageJsonPath, 'utf8'));
}
function writePkgVersion(version) {
const pkg = readPkg();
pkg.version = version;
writeFileSync(packageJsonPath, `${JSON.stringify(pkg, null, 2)}\n`, 'utf8');
}
/** 从 argv / 环境变量取强制版本(跳过交互) */
function getForcedVersion() {
const arg = process.argv.find((a) => a.startsWith('--version='));
if (arg) return arg.slice('--version='.length).trim();
if (process.env.XK_DESKTOP_VERSION) return String(process.env.XK_DESKTOP_VERSION).trim();
return '';
}
function ask(question) {
const rl = createInterface({ input: process.stdin, output: process.stdout });
return new Promise((resolve) => {
rl.question(question, (answer) => {
rl.close();
resolve(String(answer || '').trim());
});
});
}
/**
* 交互确认本次打包版本
* @returns {Promise<string>}
*/
async function resolveVersion() {
const forced = getForcedVersion();
if (forced) {
if (!isValidVersion(forced)) {
throw new Error(`无效版本号: ${forced}(请用如 1.0.1)`);
}
console.log(`使用指定版本号: ${forced}`);
return forced;
}
if (!process.stdin.isTTY) {
const current = readPkg().version || '1.0.0';
console.log(`非交互环境,沿用 package.json 版本: ${current}`);
return current;
}
const last = readPkg().version || '1.0.0';
console.log('');
console.log('========== 桌面端版本确认 ==========');
console.log(`上次打包版本号:${last}`);
console.log('请选择:');
console.log(` 1) 使用上次版本号(${last})`);
console.log(' 2) 输入新的版本号');
console.log('');
let choice = await ask('请输入选项 [1/2](默认 1): ');
if (choice === '' || choice === '1') {
return last;
}
if (choice !== '2') {
console.log('未识别选项,默认使用上次版本号');
return last;
}
for (;;) {
const next = await ask('请输入新版本号(如 1.0.1): ');
if (isValidVersion(next)) {
return next;
}
console.log('格式不正确,请使用 semver,例如 1.0.1');
}
}
function runBuilder() {
// electron-builder 下载 Electron / NSIS 等会打 GitHub;国内网络常超时。
// 根 .npmrc 有镜像配置,但 spawn 出的进程不一定带上,这里强制写入环境变量。
const electronMirror =
process.env.ELECTRON_MIRROR || 'https://npmmirror.com/mirrors/electron/';
const builderBinariesMirror =
process.env.ELECTRON_BUILDER_BINARIES_MIRROR ||
'https://npmmirror.com/mirrors/electron-builder-binaries/';
const env = {
...process.env,
ELECTRON_MIRROR: electronMirror,
ELECTRON_BUILDER_BINARIES_MIRROR: builderBinariesMirror,
// 兼容 app-builder-lib 读取的 npm_config_* 形态
npm_config_electron_mirror: electronMirror,
npm_config_electron_builder_binaries_mirror: builderBinariesMirror,
};
console.log(`Electron 镜像: ${electronMirror}`);
console.log(`Builder 工具链镜像: ${builderBinariesMirror}`);
return new Promise((resolve, reject) => {
const child = spawn(
'pnpm exec electron-builder --config electron-builder.config.cjs',
{
cwd: desktopRoot,
env,
shell: true,
stdio: 'inherit',
},
);
child.on('error', reject);
child.on('close', (code) => {
if (code === 0) resolve();
else reject(new Error(`electron-builder 失败,退出码 ${code}`));
});
});
}
/**
* 复制 Windows 安装包为「萧康医药 {version}.exe」方便后台/人工上传
* 保留原 ASCII 文件名供 electron-updater 的 latest.yml 使用
*/
function copyFriendlyWinExe(version) {
if (!existsSync(releaseDir)) {
console.warn(`未找到 release 目录: ${releaseDir}`);
return;
}
const files = readdirSync(releaseDir);
// 优先精确匹配 builder artifactName
const exact = `xk-desktop-${version}-win-x64.exe`;
let src = files.includes(exact) ? exact : '';
if (!src) {
src =
files.find(
(f) =>
f.endsWith('.exe') &&
f.includes(version) &&
!f.includes('萧康') &&
!f.toLowerCase().includes('blockmap'),
) || '';
}
if (!src) {
console.warn('未找到 Windows 安装包,跳过友好文件名复制');
return;
}
const destName = `萧康医药 ${version}.exe`;
const from = join(releaseDir, src);
const to = join(releaseDir, destName);
copyFileSync(from, to);
console.log(`\n已生成上传用安装包: ${to}`);
console.log(`(自动更新仍使用: ${from})`);
}
async function main() {
const version = await resolveVersion();
const current = readPkg().version;
if (version !== current) {
writePkgVersion(version);
console.log(`已写入 package.json version = ${version}`);
} else {
console.log(`版本号保持 ${version}`);
}
console.log('\n开始 electron-builder 打包…\n');
await runBuilder();
copyFriendlyWinExe(version);
console.log('\n打包流程结束');
}
main().catch((err) => {
console.error(`\n❌ ${err.message || err}`);
process.exit(1);
});

View File

@@ -0,0 +1,44 @@
/**
* 直接跑 electron-builder(跳过版本交互),并注入国内镜像环境变量
* 避免打 GitHub 超时:connect ETIMEDOUT 20.x.x.x:443
*/
import { spawn } from 'node:child_process';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const desktopRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
const electronMirror =
process.env.ELECTRON_MIRROR || 'https://npmmirror.com/mirrors/electron/';
const builderBinariesMirror =
process.env.ELECTRON_BUILDER_BINARIES_MIRROR ||
'https://npmmirror.com/mirrors/electron-builder-binaries/';
const env = {
...process.env,
ELECTRON_MIRROR: electronMirror,
ELECTRON_BUILDER_BINARIES_MIRROR: builderBinariesMirror,
npm_config_electron_mirror: electronMirror,
npm_config_electron_builder_binaries_mirror: builderBinariesMirror,
};
console.log(`Electron 镜像: ${electronMirror}`);
console.log(`Builder 工具链镜像: ${builderBinariesMirror}`);
const child = spawn(
'pnpm exec electron-builder --config electron-builder.config.cjs',
{
cwd: desktopRoot,
env,
shell: true,
stdio: 'inherit',
},
);
child.on('close', (code) => {
process.exit(code ?? 1);
});
child.on('error', (err) => {
console.error(err);
process.exit(1);
});

View File

@@ -0,0 +1,65 @@
import { dialog, session } from 'electron';
import log from 'electron-log/main';
import { getMainWindow } from './window';
/**
* 清除缓存
* 托盘入口弹窗二选一:
* - 仅资源缓存:清 HTTP/磁盘缓存,保留 Cookie / localStorage(登录态还在)
* - 清除全部:连带存储数据一起清,登录态丢失,需重新登录
* 清完后 reload 主窗口,让页面立刻吃到干净会话
*/
export type ClearCacheMode = 'resource' | 'all';
/**
* 弹出选择对话框并执行清除
* 取消时不做任何事
*/
export async function promptAndClearCache(): Promise<void> {
const win = getMainWindow();
const { response } = await dialog.showMessageBox(win ?? undefined, {
type: 'question',
title: '清除缓存',
message: '请选择清除范围',
detail:
'「仅资源缓存」保留登录态,适合页面异常时刷新资源;\n「清除全部」会删除本地登录信息,需重新登录。',
buttons: ['仅资源缓存', '清除全部', '取消'],
defaultId: 0,
cancelId: 2,
});
if (response === 2) return;
const mode: ClearCacheMode = response === 1 ? 'all' : 'resource';
await clearCache(mode);
}
/**
* 按模式清除会话缓存,并 reload 主窗口
* @param mode resource=只清 HTTP 缓存;all=缓存+本地存储
*/
export async function clearCache(mode: ClearCacheMode): Promise<void> {
const sess = session.defaultSession;
try {
await sess.clearCache();
if (mode === 'all') {
// 清 cookies / localStorage / indexedDB 等,业务登录态会失效
await sess.clearStorageData();
}
log.info(`清除缓存完成:mode=${mode}`);
getMainWindow()?.webContents.reload();
await dialog.showMessageBox(getMainWindow() ?? undefined, {
type: 'info',
title: '清除缓存',
message: mode === 'all' ? '已清除全部缓存,请重新登录' : '已清除资源缓存',
});
} catch (error) {
log.error('清除缓存失败', error);
await dialog.showMessageBox(getMainWindow() ?? undefined, {
type: 'warning',
title: '清除缓存',
message: '清除缓存失败,请稍后重试',
detail: error instanceof Error ? error.message : String(error),
});
}
}

View File

@@ -34,12 +34,19 @@ export const API_UPSTREAM = process.env.XK_DESKTOP_API_UPSTREAM ?? 'https://api.
export const IM_UPSTREAM = process.env.XK_DESKTOP_IM_UPSTREAM ?? 'https://api.ws.g.xiaokang88.com';
/**
* 更新包下载页地址(与 electron-builder.config.cjs 的 publish.url 保持一致)
* 兼作 mac 未签名时「自动更新不可用 → 手动下载」的跳转页
* TODO(待确认):更新包静态托管地址
* 更新包兜底页(无 OpenAPI 安装包时的人工下载地址,一般不再走这里)
*/
export const DOWNLOAD_PAGE_URL = 'https://static.xiaokang88.com/xk-desktop/';
/**
* 桌面版本 OpenAPI:拉取最新发布元数据(version / download_url / force_update)
* 主进程用 net.fetch 直连 API_UPSTREAM(不走 app:// 反代,主进程无 CORS 问题)
* 可用 XK_DESKTOP_OPEN_API_VERSION_URL 覆盖整条 URL 便于联调
*/
export const OPEN_API_DESKTOP_VERSION_URL =
process.env.XK_DESKTOP_OPEN_API_VERSION_URL ??
`${API_UPSTREAM}/open-api/desktop/version/latest`;
/** 全局快捷键:唤起/隐藏主窗口(CommandOrControl 在 Windows 映射 Ctrl、mac 映射 Cmd) */
export const MAIN_WINDOW_SHORTCUT = 'CommandOrControl+Alt+X';

View File

@@ -1,8 +1,9 @@
import { app, Menu } from 'electron';
import { app } from 'electron';
import log from 'electron-log/main';
import { APP_ID } from './config';
import { registerIpcHandlers } from './ipc';
import { createAppMenu } from './menu';
import { registerAppProtocol, registerSchemePrivileges } from './protocol';
import { registerGlobalShortcut, unregisterGlobalShortcut } from './shortcut';
import { createTray } from './tray';
@@ -35,19 +36,8 @@ if (app.requestSingleInstanceLock()) {
// 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);
}
// 应用菜单:Win/Linux 顶栏业务菜单;mac 保留系统角色并挂刷新/更新/关于
createAppMenu();
registerAppProtocol();
registerIpcHandlers();

View File

@@ -2,6 +2,7 @@ import { app, ipcMain, Notification } from 'electron';
import { getAutoLaunchEnabled, setAutoLaunchEnabled } from './auto-launch';
import { applyBadge } from './badge';
import { showAboutDialog, reloadMainWindow } from './menu';
import { printHtml, type PrintHtmlOptions } from './printer';
import { getSettings, updateSettings, type DesktopSettings } from './settings';
import { refreshTrayMenu } from './tray';
@@ -109,6 +110,18 @@ export function registerIpcHandlers(): void {
checkForUpdates(true);
});
// 刷新页面(托盘外可选入口)
ipcMain.handle('desktop:reload', (event) => {
assertTrustedSender(event);
reloadMainWindow();
});
// 关于弹窗
ipcMain.handle('desktop:show-about', (event) => {
assertTrustedSender(event);
showAboutDialog();
});
// 渲染端主动唤起主窗口(如页内提醒的「查看」动作)
ipcMain.handle('desktop:focus-window', (event) => {
assertTrustedSender(event);

View File

@@ -0,0 +1,89 @@
import { app, dialog, Menu } from 'electron';
import { checkForUpdates } from './updater';
import { getMainWindow, markQuitting } from './window';
/**
* 应用菜单
* - macOS:系统菜单栏(不占窗口客户区),保留编辑/窗口角色以保证快捷键
* - Windows/Linux:不挂原生菜单栏,检查更新/刷新等走托盘 + Ctrl+R
*/
/** 刷新当前主窗口页面 */
export function reloadMainWindow(): void {
getMainWindow()?.webContents.reload();
}
/** 「关于」弹窗 */
export function showAboutDialog(): void {
dialog.showMessageBox({
type: 'info',
title: '关于',
message: '萧康云医',
detail: `版本:v${app.getVersion()}\n平台:${process.platform}`,
});
}
/**
* 注册应用菜单
* Win/Linux 设 null,避免占客户区;刷新快捷键在 window.ts 拦截
*/
export function createAppMenu(): void {
if (process.platform === 'darwin') {
Menu.setApplicationMenu(
Menu.buildFromTemplate([
{
label: app.name,
submenu: [
{ role: 'about' },
{ type: 'separator' },
{
label: '检查更新',
click: () => checkForUpdates(true),
},
{ type: 'separator' },
{ role: 'services' },
{ type: 'separator' },
{ role: 'hide' },
{ role: 'hideOthers' },
{ role: 'unhide' },
{ type: 'separator' },
{ role: 'quit' },
],
},
{ role: 'editMenu' },
{
label: '查看',
submenu: [
{
label: '刷新',
accelerator: 'CmdOrCtrl+R',
click: () => reloadMainWindow(),
},
{ type: 'separator' },
{ role: 'togglefullscreen' },
],
},
{ role: 'windowMenu' },
{
label: '帮助',
submenu: [
{
label: '关于萧康云医',
click: () => showAboutDialog(),
},
],
},
]),
);
return;
}
Menu.setApplicationMenu(null);
}
/** 供托盘等复用:退出 */
export function quitApp(): void {
markQuitting();
app.quit();
}

View File

@@ -2,19 +2,33 @@ import { app, Menu, nativeImage, Tray } from 'electron';
import log from 'electron-log/main';
import { getAutoLaunchEnabled, setAutoLaunchEnabled } from './auto-launch';
import { promptAndClearCache } from './cache';
import { getResourcePath } from './config';
import { checkForUpdates } from './updater';
import { markQuitting, showMainWindow } from './window';
import { getMainWindow, markQuitting, showMainWindow } from './window';
/**
* 系统托盘:应用常驻入口
* - Windows 单击托盘图标恢复主窗口;mac 单击弹出菜单(跟随平台惯例)
* - 菜单:显示主窗口 / 开机自启开关 / 检查更新 / 退出
* - 菜单:显示主窗口 / 开机自启 / 重新启动 / 切换账号 / 清除缓存 / 检查更新 / 退出
* 注意:托盘实例必须保持模块级引用,否则会被 GC 导致图标消失(Electron 经典坑)
*/
let tray: Tray | null = null;
/** 重新启动整个 Electron 进程(保留启动参数,常用于卡死/配置变更后自愈) */
function restartApp(): void {
markQuitting();
app.relaunch();
app.exit(0);
}
/** 唤起主窗口并通知渲染端打开「切换账号」弹窗(未登录时弹窗自行失败即可) */
function openSwitchAccount(): void {
showMainWindow();
getMainWindow()?.webContents.send('desktop:open-switch-account');
}
/** 构建托盘菜单:开机自启是动态勾选项,每次状态变化后需重建菜单 */
function buildTrayMenu(): Menu {
return Menu.buildFromTemplate([
@@ -34,6 +48,20 @@ function buildTrayMenu(): Menu {
refreshTrayMenu();
},
},
{
label: '重新启动',
click: () => restartApp(),
},
{
label: '切换账号',
click: () => openSwitchAccount(),
},
{
label: '清除缓存',
click: () => {
void promptAndClearCache();
},
},
{
label: '检查更新',
click: () => checkForUpdates(true),
@@ -63,7 +91,10 @@ export function refreshTrayMenu(): void {
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 });
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());

View File

@@ -1,104 +1,287 @@
import { app, dialog, shell } from 'electron';
import log from 'electron-log/main';
import { autoUpdater } from 'electron-updater';
/**
* 桌面端版本检查与安装包下载安装
* - 拉取 OpenAPI 最新已发布版本,与本地 app.getVersion 比较
* - 有新版本则下载后台登记的 download_url 安装包,下载完拉起安装程序并退出本应用
* - 启动 10 秒后自动检查一次 + 每 3 小时轮询 + 托盘/菜单手动触发
* - force_update=1 时不询问,直接下载安装;否则弹窗确认
*/
import { DOWNLOAD_PAGE_URL } from './config';
import { createWriteStream } from 'node:fs';
import { join } from 'node:path';
import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { app, dialog, net, shell } from 'electron';
import log from 'electron-log/main';
import { OPEN_API_DESKTOP_VERSION_URL } from './config';
import { getMainWindow, markQuitting, showMainWindow } from './window';
/**
* 自动更新(electron-updater + generic 静态托管)
* - 启动 10 秒后自动检查一次 + 每 4 小时轮询 + 托盘/渲染端手动触发
* - autoDownload:发现新版本自动后台下载,进度事件转发渲染端展示
* - 下载完成弹窗询问「立即重启安装 / 退出时自动安装」
* - mac 未签名包 electron-updater 签名校验会报错:捕获后降级为「打开下载页手动安装」
*/
/** OpenAPI 返回的最新版元数据(字段与后端 latest 对齐) */
interface DesktopLatestMeta {
version?: string;
download_url?: string;
force_update?: number;
changelog?: string;
title?: string;
}
/** 手动检查标记:手动触发时才提示「已是最新版本」,自动轮询保持安静 */
let isManualCheck = false;
/** 检查中防重入 */
let isChecking = false;
/** 下载中防重入 */
let isDownloading = 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)
);
/**
* 拉取 OpenAPI 最新版元数据
*/
async function fetchLatestMeta(): Promise<DesktopLatestMeta | null> {
const platform = process.platform;
const url = `${OPEN_API_DESKTOP_VERSION_URL}?platform=${encodeURIComponent(platform)}`;
try {
const response = await net.fetch(url, {
method: 'GET',
headers: { Accept: 'application/json' },
});
if (!response.ok) {
log.warn(`OpenAPI 版本查询失败 HTTP ${response.status}`);
return null;
}
const body = (await response.json()) as {
code?: number;
result?: DesktopLatestMeta | Record<string, never>;
};
if (body.code !== 0 || !body.result || typeof body.result !== 'object') {
return null;
}
const result = body.result as DesktopLatestMeta;
if (!result.version) {
return null;
}
return result;
} catch (error) {
log.warn('OpenAPI 版本查询异常', error);
return null;
}
}
/**
* 初始化自动更新:仅打包后生效(开发模式没有 app-update.yml,检查必然失败)
* 简易 semver 比较:remote > local 返回 true
* 只比主版本三段数字,后缀忽略(与后台 version_compare 主路径对齐)
*/
export function initUpdater(): void {
if (!app.isPackaged) return;
function isNewerVersion(remote: string, local: string): boolean {
const parse = (v: string): number[] => {
const core = String(v || '0')
.trim()
.replace(/^v/i, '')
.split(/[-+]/)[0];
return core.split('.').map((n) => {
const num = Number.parseInt(n, 10);
return Number.isFinite(num) ? num : 0;
});
};
const a = parse(remote);
const b = parse(local);
const len = Math.max(a.length, b.length);
for (let i = 0; i < len; i += 1) {
const x = a[i] ?? 0;
const y = b[i] ?? 0;
if (x > y) return true;
if (x < y) return false;
}
return false;
}
autoUpdater.logger = log;
autoUpdater.autoDownload = true;
autoUpdater.autoInstallOnAppQuit = true;
/** 根据下载 URL 推断安装包扩展名 */
function guessInstallerExt(url: string): string {
const clean = url.split('?')[0].toLowerCase();
if (clean.endsWith('.dmg')) return '.dmg';
if (clean.endsWith('.zip')) return '.zip';
if (clean.endsWith('.appimage')) return '.AppImage';
if (clean.endsWith('.exe')) return '.exe';
if (process.platform === 'win32') return '.exe';
if (process.platform === 'darwin') return '.dmg';
return '.zip';
}
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()})`,
});
/**
* 下载安装包到临时目录,进度转发渲染端
* @returns 本地文件绝对路径
*/
async function downloadInstaller(
url: string,
version: string,
): Promise<string> {
const dest = join(
app.getPath('temp'),
`xk-desktop-${version}${guessInstallerExt(url)}`,
);
log.info(`开始下载安装包: ${url} -> ${dest}`);
const response = await net.fetch(url);
if (!response.ok || !response.body) {
throw new Error(`下载失败 HTTP ${response.status}`);
}
const total = Number(response.headers.get('content-length') || 0);
let received = 0;
const nodeStream = Readable.fromWeb(
response.body as import('node:stream/web').ReadableStream,
);
nodeStream.on('data', (chunk: Buffer) => {
received += chunk.length;
if (total > 0) {
const percent = Math.min(99, Math.round((received / total) * 100));
sendToRenderer('progress', { percent });
}
isManualCheck = false;
});
await pipeline(nodeStream, createWriteStream(dest));
sendToRenderer('progress', { percent: 100 });
return dest;
}
autoUpdater.on('download-progress', (progress) => {
sendToRenderer('progress', { percent: Math.round(progress.percent) });
});
/**
* 拉起安装包并退出本应用(让安装程序替换文件)
*/
async function openInstallerAndQuit(filePath: string): Promise<void> {
const err = await shell.openPath(filePath);
if (err) {
throw new Error(`无法打开安装包: ${err}`);
}
markQuitting();
// 稍等让安装程序启动,再退出当前进程
setTimeout(() => {
app.quit();
}, 800);
}
autoUpdater.on('update-downloaded', async (info) => {
sendToRenderer('downloaded', { version: info.version });
// 唤起窗口再弹对话框,避免用户看不到安装确认
/**
* 发现新版本后的下载安装流程
*/
async function promptAndInstall(meta: DesktopLatestMeta): Promise<void> {
const version = String(meta.version || '').trim();
const downloadUrl = String(meta.download_url || '').trim();
if (!version || !downloadUrl) {
throw new Error('发布记录缺少版本号或安装包地址');
}
const force = Number(meta.force_update) === 1;
const changelog = String(meta.changelog || '').trim();
if (!force) {
showMainWindow();
const { response } = await dialog.showMessageBox({
type: 'info',
title: '发现新版本',
message: `发现新版本 v${version}`,
detail:
(changelog ? `${changelog}\n\n` : '') +
'下载完成后将自动打开安装程序,请按提示完成安装。',
buttons: ['立即下载安装', '稍后'],
defaultId: 0,
cancelId: 1,
});
if (response !== 0) {
sendToRenderer('available', { version, deferred: true });
return;
}
}
if (isDownloading) return;
isDownloading = true;
try {
sendToRenderer('available', { version });
const filePath = await downloadInstaller(downloadUrl, version);
sendToRenderer('downloaded', { version, path: filePath });
if (force) {
log.info(`强制更新:打开安装包 v${version}`);
await openInstallerAndQuit(filePath);
return;
}
showMainWindow();
const { response } = await dialog.showMessageBox({
type: 'question',
title: '发现新版本',
message: `新版本 v${info.version} 已下载完成`,
detail: '立即重启安装,或在退出应用时自动安装。',
buttons: ['立即重启安装', '退出时自动安装'],
title: '下载完成',
message: `新版本 v${version} 已下载完成`,
detail: '即将打开安装程序,请关闭本应用后完成安装。',
buttons: ['打开安装程序', '稍后'],
defaultId: 0,
cancelId: 1,
});
if (response === 0) {
// 放行窗口 close 拦截,否则 quitAndInstall 会被「关闭进托盘」逻辑挡住
markQuitting();
autoUpdater.quitAndInstall();
await openInstallerAndQuit(filePath);
}
});
} finally {
isDownloading = false;
}
}
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({
/**
* 初始化:仅打包后生效
*/
export function initUpdater(): void {
if (!app.isPackaged) return;
setTimeout(() => checkForUpdates(false), 10_000);
setInterval(() => checkForUpdates(false), 3 * 60 * 60 * 1000);
}
/**
* 触发一次版本检查
* @param manual 是否手动触发(手动时「已是最新/失败」都会弹窗反馈)
*/
export async function checkForUpdates(manual: boolean): Promise<void> {
if (!app.isPackaged) {
if (manual) {
dialog.showMessageBox({
type: 'info',
title: '检查更新',
message: '当前安装包不支持自动更新',
detail: '请前往下载页获取最新版本手动安装。',
buttons: ['打开下载页', '取消'],
defaultId: 0,
cancelId: 1,
message: '开发模式不支持检查更新',
});
if (response === 0) shell.openExternal(DOWNLOAD_PAGE_URL);
} else if (isManualCheck) {
}
return;
}
if (isChecking || isDownloading) return;
isChecking = true;
isManualCheck = manual;
try {
const meta = await fetchLatestMeta();
if (!meta?.version) {
sendToRenderer('not-available');
if (manual) {
dialog.showMessageBox({
type: 'info',
title: '检查更新',
message: `当前已是最新版本(v${app.getVersion()})`,
detail: '暂无已发布的桌面版本记录',
});
}
return;
}
const local = app.getVersion();
if (!isNewerVersion(meta.version, local)) {
sendToRenderer('not-available');
if (manual) {
dialog.showMessageBox({
type: 'info',
title: '检查更新',
message: `当前已是最新版本(v${local})`,
});
}
return;
}
const downloadUrl = String(meta.download_url || '').trim();
if (!downloadUrl) {
throw new Error('新版本未上传安装包,请联系管理员');
}
await promptAndInstall(meta);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
log.error('检查/下载更新失败', message);
sendToRenderer('error', { message });
if (isManualCheck) {
dialog.showMessageBox({
type: 'warning',
title: '检查更新',
@@ -106,34 +289,8 @@ export function initUpdater(): void {
detail: message,
});
}
} finally {
isManualCheck = false;
isChecking = false;
});
// 启动延时自动检查一次 + 每 4 小时轮询(内部工具常开着,轮询保证及时收到发版)
setTimeout(() => checkForUpdates(false), 10_000);
setInterval(() => checkForUpdates(false), 4 * 60 * 60 * 1000);
}
/**
* 触发一次更新检查
* @param manual 是否手动触发(手动时「已是最新/失败」都会弹窗反馈,自动轮询保持安静)
*/
export async function checkForUpdates(manual: boolean): Promise<void> {
if (!app.isPackaged) {
if (manual) {
dialog.showMessageBox({ type: 'info', title: '检查更新', message: '开发模式不支持检查更新' });
}
return;
}
if (isChecking) return;
isChecking = true;
isManualCheck = manual;
try {
await autoUpdater.checkForUpdates();
} catch {
// 错误已在 error 事件里统一处理
} finally {
isChecking = false;
}
}

View File

@@ -107,6 +107,7 @@ function buildErrorPageUrl(reason: string): string {
export function createMainWindow(options: { startHidden: boolean }): BrowserWindow {
setupPermissionHandlers();
// 使用系统默认标题栏;Win 无应用菜单栏(业务入口在托盘),避免占客户区高度
mainWindow = new BrowserWindow({
width: 1440,
height: 900,
@@ -163,10 +164,21 @@ export function createMainWindow(options: { startHidden: boolean }): BrowserWind
// 窗口聚焦即清除任务栏闪烁提醒(配合新消息 flashFrame)
mainWindow.on('focus', () => mainWindow?.flashFrame(false));
// F12 切换 DevTools:内部管理系统保留排障入口
// F12 切换 DevTools;Win 无原生菜单时用 Ctrl+R 刷新(对齐菜单「刷新」)
mainWindow.webContents.on('before-input-event', (_event, input) => {
if (input.type === 'keyDown' && input.key === 'F12') {
if (input.type !== 'keyDown') return;
if (input.key === 'F12') {
mainWindow?.webContents.toggleDevTools();
return;
}
if (
process.platform === 'win32' &&
(input.control || input.meta) &&
String(input.key).toLowerCase() === 'r' &&
!input.alt &&
!input.shift
) {
mainWindow?.webContents.reload();
}
});

View File

@@ -84,12 +84,20 @@ const desktopAPI = {
ipcRenderer.invoke('desktop:set-settings', patch),
/** 手动检查更新(结果由主进程弹窗反馈) */
checkUpdate: (): Promise<void> => ipcRenderer.invoke('desktop:check-update'),
/** 刷新主窗口页面 */
reload: (): Promise<void> => ipcRenderer.invoke('desktop:reload'),
/** 关于弹窗 */
showAbout: (): Promise<void> => ipcRenderer.invoke('desktop:show-about'),
/** 唤起并聚焦主窗口 */
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));
},
/** 监听托盘「切换账号」:主进程唤起窗口后发此事件,渲染层打开 SwitchAccountModal */
onOpenSwitchAccount: (callback: () => void): void => {
ipcRenderer.on('desktop:open-switch-account', () => callback());
},
/** 监听更新事件(available / progress / downloaded / not-available / error),渲染层做轻量提示 */
onUpdateEvent: (callback: (payload: { type: string; data?: unknown }) => void): void => {
ipcRenderer.on('desktop:update-event', (_event, payload) => callback(payload));

View File

@@ -97,12 +97,32 @@ export async function getFileTypes() {
return requestClient.get<FileTypeItem[]>(`${prefix}file-types`);
}
export async function syncFileGalleryFromOss() {
/** 规划 OSS 同步日期分组(full 全部 / incremental 从库内最新日期起) */
export async function planFileGalleryOssSync(data?: { mode?: 'full' | 'incremental' }) {
return requestClient.post<{
mode: 'full' | 'incremental';
prefixes: string[];
total: number;
since_date: string;
}>(`${prefix}sync-oss-plan`, data ?? {});
}
/** 从 OSS 同步一批(按日期 prefix + marker;全部组结束后 finalize=1) */
export async function syncFileGalleryFromOss(data?: {
limit?: number;
marker?: string;
prefix?: string;
finalize?: number;
}) {
return requestClient.post<{
scanned: number;
skipped: number;
synced: number;
}>(`${prefix}sync-oss`);
next_marker: string;
done: boolean;
folder_created: number;
prefix: string;
}>(`${prefix}sync-oss`, data ?? {});
}
/** 文件加入自定义分组 */

View File

@@ -7,8 +7,8 @@ import { useUserStore as useVbenUserStore } from '@vben/stores';
import { App, ConfigProvider, theme } from 'ant-design-vue';
import { antdLocale } from '#/locales';
import { GlobalContextMenu } from '#/components/context-menu';
import { antdLocale } from '#/locales';
import { useWebSocket } from '#/views/business/chat/composables/useWebSocket';
import { useChatStore } from '#/views/business/chat/stores/chat';
import { useUserStore } from '#/views/business/chat/stores/user';
@@ -20,15 +20,13 @@ const userStore = useUserStore();
const chatStore = useChatStore();
const vbenUserStore = useVbenUserStore();
// 用户信息加载状态跟踪
const userInfoLoaded = ref(false);
// 监听userInfo加载完成
watch(
() => vbenUserStore.userInfo,
(newUserInfo) => {
if (newUserInfo) {
console.log('用户信息已加载:', newUserInfo)
console.log('用户信息已加载:', newUserInfo);
userStore.currentUser = newUserInfo;
userStore.login(newUserInfo);
userInfoLoaded.value = true;
@@ -56,20 +54,12 @@ const tokenTheme = computed(() => {
});
const initWebsocket = () => {
// 确保用户信息已加载
if (!userStore.currentUser) return;
// 初始化数据库
chatStore.initDB();
// 加载好友列表(使用用户ID)
chatStore.loadFriends(userStore.presetUsers, userStore.currentUser?.doctor_id);
// 创建WebSocket连接
connectWebSocket();
};
// 当userInfo加载完成后再执行初始化
watch(userInfoLoaded, (loaded) => {
if (loaded) {
initWebsocket();

View File

@@ -1,18 +1,20 @@
<script setup lang="ts">
/**
* OSS 文件上传(单文件 URL 字符串)
* 可选:从素材库选择(acceptTypes 过滤,如文件=6、语音=3)
* 可选:从素材库选择;可开启 dragger 拖拽上传(安装包等大文件)
*/
import { computed, ref, watch } from 'vue';
import { preferences } from '@vben/preferences';
import { Button, Upload } from 'ant-design-vue';
import { Button, Upload, message } from 'ant-design-vue';
import GalleryPickLink from '#/components/form/components/gallery-pick-link.vue';
import { Icon } from '#/components/icon';
import { uploadFile } from '#/api/core/upload';
import { uploadToOss } from '#/utils/oss-upload';
defineOptions({ name: 'UploadOssFile' });
interface FileItem {
uid: string;
name: string;
@@ -23,10 +25,18 @@ interface FileItem {
interface Props {
modelValue?: string;
maxCount?: number;
/** 素材库类型过滤,如 [6] 文件、[3] 语音 */
/** 素材库类型过滤,如 [6] 文件、[3] 语音;传空数组则隐藏素材库入口 */
acceptTypes?: number[];
/** input accept,如 .pdf,.doc 或 audio/* */
accept?: string;
/** 单文件大小上限(MB),安装包等大文件可调高,默认 20 */
maxSizeMb?: number;
/** 是否显示素材库选择;大文件安装包一般关 */
showGallery?: boolean;
/** 是否使用拖拽上传区域(默认按钮模式,兼容旧表单) */
dragger?: boolean;
/** 拖拽区提示文案 */
tip?: string;
}
const props = withDefaults(defineProps<Props>(), {
@@ -34,12 +44,17 @@ const props = withDefaults(defineProps<Props>(), {
maxCount: 1,
acceptTypes: () => [6],
accept: '.pdf,.doc,.docx,.zip,.rar,.txt,application/pdf,audio/*,*/*',
maxSizeMb: 20,
showGallery: true,
dragger: false,
tip: '点击或拖拽文件到此处上传',
});
const emit = defineEmits<{
'update:modelValue': [value: string];
}>();
const UploadDragger = Upload.Dragger;
const fileList = ref<FileItem[]>([]);
function syncFileListFromValue(url: string) {
@@ -78,8 +93,10 @@ function updateModelValue() {
}
function beforeUpload(file: File) {
const maxSize = 20 * 1024 * 1024;
// 安装包可达数百 MB,上限由 props.maxSizeMb 控制
const maxSize = Math.max(1, props.maxSizeMb) * 1024 * 1024;
if (file.size > maxSize) {
message.error(`文件不能超过 ${props.maxSizeMb}MB`);
return Upload.LIST_IGNORE;
}
return true;
@@ -156,8 +173,34 @@ const showUploadButton = computed(
</script>
<template>
<div class="upload-oss-file-wrap">
<div class="upload-oss-file-wrap" :class="{ 'is-dragger': dragger }">
<!-- 拖拽模式:点击或拖入均可上传 -->
<UploadDragger
v-if="dragger && showUploadButton"
:file-list="[]"
:before-upload="beforeUpload"
:custom-request="customRequest"
:max-count="maxCount"
:accept="accept"
:multiple="false"
>
<p class="upload-oss-file-icon">
<Icon icon="ant-design:cloud-upload-outlined" />
</p>
<p class="upload-oss-file-text">{{ tip }}</p>
<p class="upload-oss-file-hint">支持 {{ accept }},单文件不超过 {{ maxSizeMb }}MB</p>
</UploadDragger>
<!-- 已上传:展示文件名,可移除后重新拖拽/选择 -->
<Upload
v-else-if="dragger"
:file-list="fileList"
list-type="text"
:max-count="maxCount"
@remove="handleRemove"
/>
<!-- 默认按钮模式(兼容旧表单) -->
<Upload
v-else
:file-list="fileList"
:before-upload="beforeUpload"
:custom-request="customRequest"
@@ -174,7 +217,7 @@ const showUploadButton = computed(
</Button>
</Upload>
<GalleryPickLink
v-if="showUploadButton"
v-if="showUploadButton && showGallery && acceptTypes.length > 0"
:max-count="1"
:accept-types="acceptTypes"
@select="onGallerySelect"
@@ -188,5 +231,24 @@ const showUploadButton = computed(
flex-direction: column;
gap: 8px;
align-items: flex-start;
width: 100%;
}
.upload-oss-file-wrap.is-dragger {
align-items: stretch;
}
.upload-oss-file-icon {
margin: 0 0 8px;
font-size: 36px;
color: hsl(var(--primary));
}
.upload-oss-file-text {
margin: 0;
font-size: 14px;
color: hsl(var(--foreground));
}
.upload-oss-file-hint {
margin: 6px 0 0;
font-size: 12px;
color: hsl(var(--muted-foreground));
}
</style>

View File

@@ -2,6 +2,124 @@ import type { FileTypeItem } from '#/api/core/file-gallery';
export const ALL_TYPE_ICON = 'ant-design:appstore-outlined';
/** 图片类后缀:误标 type=0 时仍可按扩展名判断是否真能当缩略图 */
export const IMAGE_EXTENSIONS = new Set([
'jpg',
'jpeg',
'png',
'gif',
'webp',
'bmp',
'svg',
'ico',
'heic',
'avif',
]);
/**
* 扩展名 → Iconify 图标(优先于类型表 icon)
* 覆盖安装包、压缩包、办公文档等非图片素材,避免一律 file-outlined
*/
export const EXTENSION_ICON_MAP: Record<string, string> = {
// 安装包
exe: 'mdi:microsoft-windows',
msi: 'mdi:microsoft-windows',
dmg: 'mdi:apple',
pkg: 'mdi:apple',
appimage: 'mdi:linux',
deb: 'mdi:ubuntu',
rpm: 'mdi:fedora',
// 压缩包
zip: 'ant-design:file-zip-outlined',
rar: 'ant-design:file-zip-outlined',
'7z': 'ant-design:file-zip-outlined',
tar: 'ant-design:file-zip-outlined',
gz: 'ant-design:file-zip-outlined',
tgz: 'ant-design:file-zip-outlined',
bz2: 'ant-design:file-zip-outlined',
// 文档
pdf: 'ant-design:file-pdf-outlined',
doc: 'ant-design:file-word-outlined',
docx: 'ant-design:file-word-outlined',
xls: 'ant-design:file-excel-outlined',
xlsx: 'ant-design:file-excel-outlined',
csv: 'ant-design:file-excel-outlined',
ppt: 'ant-design:file-ppt-outlined',
pptx: 'ant-design:file-ppt-outlined',
txt: 'ant-design:file-text-outlined',
md: 'ant-design:file-markdown-outlined',
// 媒体(类型 icon 缺失时兜底)
mp4: 'ant-design:video-camera-outlined',
mov: 'ant-design:video-camera-outlined',
avi: 'ant-design:video-camera-outlined',
mkv: 'ant-design:video-camera-outlined',
webm: 'ant-design:video-camera-outlined',
mp3: 'ant-design:audio-outlined',
wav: 'ant-design:audio-outlined',
aac: 'ant-design:audio-outlined',
flac: 'ant-design:audio-outlined',
// 图片(一般走缩略图,仅作兜底)
jpg: 'ant-design:file-image-outlined',
jpeg: 'ant-design:file-image-outlined',
png: 'ant-design:file-image-outlined',
gif: 'ant-design:file-image-outlined',
webp: 'ant-design:file-image-outlined',
bmp: 'ant-design:file-image-outlined',
svg: 'ant-design:file-image-outlined',
};
/** 从文件名或 URL 解析小写扩展名(无点、无 query) */
export function parseFileExtension(nameOrUrl?: string | null): string {
if (!nameOrUrl) {
return '';
}
const cleaned = String(nameOrUrl).split('?')[0]?.split('#')[0] || '';
const base = cleaned.includes('/')
? cleaned.slice(cleaned.lastIndexOf('/') + 1)
: cleaned;
const dot = base.lastIndexOf('.');
if (dot < 0 || dot === base.length - 1) {
return '';
}
return base.slice(dot + 1).toLowerCase();
}
export function isImageExtension(ext: string): boolean {
return IMAGE_EXTENSIONS.has(ext.toLowerCase());
}
/**
* 判断素材是否应按图片缩略图展示
* 防御:type=0 但后缀实为 exe/zip 时不要用 <img>
*/
export function shouldShowImageThumb(item: {
type?: number;
file_name?: string;
original_name?: string;
url?: string;
}): boolean {
if (item.type !== 0) {
return false;
}
const ext = parseFileExtension(
item.file_name || item.original_name || item.url,
);
// 无扩展名时沿用 type=0 的历史行为
if (!ext) {
return true;
}
return isImageExtension(ext);
}
/** 按扩展名取 Iconify;未命中返回空串 */
export function resolveFileIconByName(nameOrUrl?: string | null): string {
const ext = parseFileExtension(nameOrUrl);
if (!ext) {
return '';
}
return EXTENSION_ICON_MAP[ext] || '';
}
export function resolveTypeIcon(
typeValue: number | undefined,
fileTypes: FileTypeItem[],
@@ -14,6 +132,32 @@ export function resolveTypeIcon(
return matched?.icon || fallback;
}
/**
* 素材库卡片/详情统一图标:扩展名 → type_icon → 类型表 → fallback
*/
export function resolveGalleryItemIcon(
item: {
type?: number;
type_icon?: string;
file_name?: string;
original_name?: string;
url?: string;
},
fileTypes: FileTypeItem[] = [],
fallback = 'ant-design:file-outlined',
): string {
const byExt = resolveFileIconByName(
item.file_name || item.original_name || item.url,
);
if (byExt) {
return byExt;
}
if (item.type_icon) {
return item.type_icon;
}
return resolveTypeIcon(item.type, fileTypes, fallback);
}
export function iconToMenuItem(iconName: string) {
if (!iconName) {
return undefined;

View File

@@ -9,7 +9,6 @@ import { $t } from '#/locales';
const appName = computed(() => preferences.app.name);
const logo = computed(() => preferences.logo.source);
const logoDark = computed(() => preferences.logo.sourceDark);
// const logo = 'https://xiaokang88.oss-cn-hangzhou.aliyuncs.com/logo.png?Expires=1734595037&OSSAccessKeyId=TMP.3KhpnCAjxkdTGWSj1ZCdYe3e9c6MBfEVZwN1CiHdUKUAT2D1mPEpACYgYMkUiQDVqy9Zqb7VqgUoh21W37FC3CztogHJPN&Signature=KmymeelO2OZxaAmvm2ACDK5UfF8%3D';
</script>
<template>
@@ -20,8 +19,5 @@ const logoDark = computed(() => preferences.logo.sourceDark);
:page-description="$t('authentication.pageDesc')"
:page-title="$t('authentication.pageTitle')"
:version="preferences.app.version"
>
<!-- 自定义工具栏 -->
<!-- <template #toolbar></template> -->
</AuthPageLayout>
/>
</template>

View File

@@ -143,7 +143,12 @@ desktop.onNavigate((route) => {
router.push(route);
});
// 桌面端:自动更新事件轻量提示(下载完成后的安装确认由主进程弹窗负责)
// 桌面端:托盘「切换账号」→ 打开与头像下拉相同的切换账号弹窗
desktop.onOpenSwitchAccount(() => {
switchAccountModalRef.value?.openModal();
});
// 桌面端:自动更新事件轻量提示(下载完成后的安装确认由主进程弹窗负责;强制更新则主进程直接重启)
desktop.onUpdateEvent((payload) => {
if (payload.type === 'available') {
const version = (payload.data as { version?: string })?.version;
@@ -152,6 +157,25 @@ desktop.onUpdateEvent((payload) => {
description: `新版本 ${version ? `v${version} ` : ''}正在后台下载,完成后将提示安装`,
duration: 5,
});
} else if (payload.type === 'progress') {
const percent = (payload.data as { percent?: number })?.percent;
// 约每 25% 提示一次(25/50/75/100),避免刷屏与 0% 噪音
if (typeof percent === 'number' && percent >= 25 && percent % 25 === 0) {
notification.info({
message: '正在下载更新',
description: `已下载 ${percent}%`,
duration: 2,
key: 'desktop-update-progress',
});
}
} else if (payload.type === 'downloaded') {
const version = (payload.data as { version?: string })?.version;
notification.success({
message: '更新已下载完成',
description: `新版本 ${version ? `v${version} ` : ''}已就绪,请按提示重启安装`,
duration: 6,
key: 'desktop-update-progress',
});
}
});
@@ -164,6 +188,7 @@ onUnmounted(() => {
desktop.setBadge(0);
// 解绑系统通知跳转回调,避免登出后点击残留通知仍对已登出会话执行 router.push
desktop.offNavigate();
desktop.offOpenSwitchAccount();
});
const goNoticeList = () => {

View File

@@ -28,8 +28,11 @@ interface Window {
patch: Partial<{ printerName: string; silentPrint: boolean }>,
) => Promise<{ printerName: string; silentPrint: boolean }>;
checkUpdate: () => Promise<void>;
reload: () => Promise<void>;
showAbout: () => Promise<void>;
focusWindow: () => Promise<void>;
onNavigate: (callback: (route: string) => void) => void;
onOpenSwitchAccount: (callback: () => void) => void;
onUpdateEvent: (
callback: (payload: { data?: unknown; type: string }) => void,
) => void;

View File

@@ -36,12 +36,14 @@ function api() {
return typeof window === 'undefined' ? undefined : window.desktopAPI;
}
// onNavigate / onUpdateEvent 的桥接层每调用一次就会新增一个 ipc 监听,
// onNavigate / onUpdateEvent / onOpenSwitchAccount 的桥接层每调用一次就会新增一个 ipc 监听,
// 而布局组件会随登录/登出反复挂载。这里用「模块级单次桥接 + 可替换回调」避免监听器堆积
let navigateCallback: ((route: string) => void) | null = null;
let navigateBridged = false;
let updateCallback: ((payload: DesktopUpdateEvent) => void) | null = null;
let updateBridged = false;
let switchAccountCallback: (() => void) | null = null;
let switchAccountBridged = false;
export const desktop = {
/** 发送系统通知;route 为点击通知后要跳转的前端路由(不传则仅唤起窗口) */
@@ -95,6 +97,14 @@ export const desktop = {
checkUpdate(): void {
api()?.checkUpdate();
},
/** 刷新主窗口 */
reload(): void {
api()?.reload();
},
/** 关于弹窗 */
showAbout(): void {
api()?.showAbout();
},
/** 唤起并聚焦主窗口 */
focusWindow(): void {
api()?.focusWindow();
@@ -111,6 +121,18 @@ export const desktop = {
offNavigate(): void {
navigateCallback = null;
},
/** 监听托盘「切换账号」请求(重复调用只替换回调,不会堆积监听器) */
onOpenSwitchAccount(callback: () => void): void {
switchAccountCallback = callback;
if (!switchAccountBridged && api()) {
switchAccountBridged = true;
api()!.onOpenSwitchAccount(() => switchAccountCallback?.());
}
},
/** 解绑切换账号回调:登出卸载布局时调用 */
offOpenSwitchAccount(): void {
switchAccountCallback = null;
},
/** 监听自动更新事件(重复调用只替换回调,不会堆积监听器) */
onUpdateEvent(callback: (payload: DesktopUpdateEvent) => void): void {
updateCallback = callback;

View File

@@ -1,5 +1,6 @@
<script lang="ts" setup>
import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useVbenDrawer, useVbenModal } from '@vben/common-ui';
import { useUserStore } from '@vben/stores';
@@ -26,6 +27,7 @@ defineOptions({
name: 'DetailModal',
});
const router = useRouter();
const userStore = useUserStore();
const gridApi = ref();
// 订单信息
@@ -756,6 +758,17 @@ function prescriptionStatusColor() {
>
打印处方
</Button>
<Button
v-if="data?.id"
@click="
router.push({
path: '/log/hy-transit-callback',
query: { order_id: String(data.id) },
})
"
>
监管上报记录
</Button>
<Button
v-if="canRetrySettle"
:loading="retrySettling"

View File

@@ -5,6 +5,7 @@
* - auditMode:底部显示「通过审方 / 拒绝」,供药师审方页使用
*/
import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { Page, useVbenModal } from '@vben/common-ui';
@@ -18,6 +19,7 @@ import { getPrescriptionInfoApi } from '#/views/doctor/doctor-reception/api';
import { passApi } from '#/views/pharmacist/audit-prescription/api';
import RejectionReason from '#/views/pharmacist/audit-prescription/components/modal.vue';
const router = useRouter();
const item = ref<Record<string, any>>({});
const loading = ref(false);
const passing = ref(false);
@@ -513,6 +515,18 @@ function resolveExcipients(prescription: Record<string, any>, recipe: Record<str
<Button type="primary" @click="handleWindowPrint('#demo', '处方')">
打印处方
</Button>
<Button
v-if="item?.id"
class="ml-2"
@click="
router.push({
path: '/log/hy-transit-callback',
query: { prescription_id: String(item.id) },
})
"
>
监管上报
</Button>
<Popover
v-if="isDesktop"
trigger="click"

View File

@@ -0,0 +1,80 @@
<script lang="ts" setup>
/**
* 第三方监管中转详情:展示注入后的 payload / 原始业务 JSON
*/
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Descriptions, Tag } from 'ant-design-vue';
import { getHyOpenTransitDetail } from '#/views/log/hy-transit-log/api';
const detail = ref<Record<string, any>>({});
const loading = ref(false);
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
draggable: true,
showConfirmButton: false,
cancelText: '关闭',
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
detail.value = {};
return;
}
const data = modalApi.getData<{ id?: number }>() || {};
const id = Number(data.id || 0);
if (id <= 0) return;
loading.value = true;
try {
const res = await getHyOpenTransitDetail({ id });
detail.value = (res?.data || res || {}) as Record<string, any>;
} finally {
loading.value = false;
}
},
});
</script>
<template>
<Modal title="第三方监管中转详情" class="w-[720px]" :loading="loading">
<Descriptions :column="2" bordered size="small" class="mb-4">
<Descriptions.Item label="ID">{{ detail.id }}</Descriptions.Item>
<Descriptions.Item label="应用">{{ detail.app_id }}</Descriptions.Item>
<Descriptions.Item label="类型">{{ detail.step_txt || detail.step }}</Descriptions.Item>
<Descriptions.Item label="锚定日">{{ detail.anchor_date }}</Descriptions.Item>
<Descriptions.Item label="请求ID" :span="2">
{{ detail.client_request_id }}
</Descriptions.Item>
<Descriptions.Item label="拉取状态">
<Tag>{{ detail.pull_status }}</Tag>
</Descriptions.Item>
<Descriptions.Item label="监管明细ID">
{{ detail.transit_record_id || '-' }}
</Descriptions.Item>
</Descriptions>
<div class="mb-2 text-sm font-medium">完整报文(含平台字段)</div>
<pre class="json-box mb-4">{{
JSON.stringify(detail.payload || {}, null, 2)
}}</pre>
<div class="mb-2 text-sm font-medium">第三方原始业务</div>
<pre class="json-box">{{
JSON.stringify(detail.biz_payload || {}, null, 2)
}}</pre>
</Modal>
</template>
<style scoped>
.json-box {
max-height: 280px;
overflow: auto;
padding: 12px;
border: 1px solid hsl(var(--border));
border-radius: 8px;
background: hsl(var(--muted) / 0.25);
color: hsl(var(--foreground));
font-size: 12px;
line-height: 1.5;
}
</style>

View File

@@ -0,0 +1,184 @@
<script lang="ts" setup>
/**
* 第三方监管中转列表(待上报 / 已拉取)
* 菜单 name 须与 xk_menu.name=LogHyOpenTransit 一致
*/
import { onMounted } from 'vue';
import { useRoute } from 'vue-router';
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 { getHyOpenTransitList } from '#/views/log/hy-transit-log/api';
import FailDetailModal from '#/views/log/hy-transit-log/components/fail-detail-modal.vue';
import DetailModalComp from './components/detail-modal.vue';
defineOptions({ name: 'LogHyOpenTransit' });
const route = useRoute();
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: [
{
component: 'VbenSelect',
fieldName: 'step',
label: '类型',
componentProps: {
allowClear: true,
options: [
{ label: '在线复诊', value: 'referral' },
{ label: '在线处方', value: 'recipe' },
{ label: '处方核销', value: 'verification' },
],
},
},
{
component: 'VbenSelect',
fieldName: 'pull_status',
label: '拉取状态',
componentProps: {
allowClear: true,
options: [
{ label: '未拉取', value: 'pending' },
{ label: '已拉取', value: 'pulled' },
{ label: '已取消', value: 'cancelled' },
],
},
},
{
component: 'DatePicker',
fieldName: 'anchor_date',
label: '锚定日',
componentProps: {
allowClear: true,
valueFormat: 'YYYY-MM-DD',
class: 'w-full',
},
},
{
component: 'VbenInput',
fieldName: 'client_request_id',
label: '请求ID',
},
{
component: 'VbenInput',
fieldName: 'buss_id',
label: '业务ID',
},
],
},
gridOptions: {
columns: [
{ field: 'id', title: 'ID', width: 80 },
{ field: 'app_id', title: '应用', width: 80 },
{ field: 'step_txt', title: '类型', width: 100 },
{ field: 'anchor_date', title: '锚定日', width: 120 },
{ field: 'client_request_id', title: '请求ID', minWidth: 160 },
{ field: 'buss_id', title: '业务ID', width: 100 },
{ field: 'recipe_unique_id', title: '处方编号', minWidth: 140 },
{ field: 'patient_name', title: '患者', width: 90 },
{
field: 'pull_status_txt',
title: '拉取状态',
width: 100,
slots: { default: 'pull_status' },
},
{ field: 'transit_record_id', title: '监管明细ID', width: 110 },
{ field: 'created_at', title: '创建时间', width: 170 },
{
title: '操作',
width: 180,
fixed: 'right',
slots: { default: 'action' },
},
],
keepSource: true,
pagerConfig: {},
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getHyOpenTransitList({
page: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
height: 'auto',
toolbarConfig: { search: true, refresh: true, zoom: true },
},
});
const [OpenDetailModal, openDetailModalApi] = useVbenModal({
connectedComponent: DetailModalComp,
});
const [CallbackDetailModal, callbackDetailModalApi] = useVbenModal({
connectedComponent: FailDetailModal,
});
function pullColor(status?: string) {
if (status === 'pending') return 'orange';
if (status === 'pulled') return 'green';
return 'default';
}
function showOpenDetail(row: any) {
openDetailModalApi.setData({ id: row.id });
openDetailModalApi.open();
}
function showCallback(row: any) {
if (!row.transit_record_id) return;
callbackDetailModalApi.setData({ id: row.transit_record_id });
callbackDetailModalApi.open();
}
onMounted(async () => {
const boot: Record<string, string> = {};
for (const k of ['pull_status', 'step', 'anchor_date']) {
const v = route.query[k];
if (v != null && String(v) !== '') boot[k] = String(v);
}
if (Object.keys(boot).length === 0) return;
await gridApi.formApi?.setValues(boot);
gridApi.reload?.();
});
</script>
<template>
<Page auto-content-height title="第三方监管中转">
<OpenDetailModal />
<CallbackDetailModal />
<Grid>
<template #pull_status="{ row }">
<Tag :color="pullColor(row.pull_status)">
{{ row.pull_status_txt || row.pull_status }}
</Tag>
</template>
<template #action="{ row }">
<TableAction
:actions="[
{
label: '详情',
type: 'link',
onClick: () => showOpenDetail(row),
},
{
label: '回调',
type: 'link',
disabled: !row.transit_record_id,
onClick: () => showCallback(row),
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@@ -13,3 +13,17 @@ export async function getHyTransitRecordList(params: Record<string, any>) {
export async function getHyTransitRecordDetail(params: { id: number }) {
return requestClient.get<any>(`${prefix}hy-transit-record-detail`, { params });
}
/** 监管中转看板统计 */
export async function getHyTransitStats(params: { days?: number } = {}) {
return requestClient.get<any>(`${prefix}hy-transit-stats`, { params });
}
/** 第三方监管中转列表 */
export async function getHyOpenTransitList(params: Record<string, any>) {
return requestClient.get<any>(`${prefix}hy-open-transit-list`, { params });
}
export async function getHyOpenTransitDetail(params: { id: number }) {
return requestClient.get<any>(`${prefix}hy-open-transit-detail`, { params });
}

View File

@@ -1,5 +1,12 @@
import type { VbenFormProps } from '#/adapter/form';
import {
HY_CALLBACK_STATUS_OPTIONS,
HY_PUSH_STATUS_OPTIONS,
HY_SOURCE_OPTIONS,
HY_STEP_OPTIONS,
} from '../../config/constants';
export const formOptions: VbenFormProps = {
collapsed: false,
schema: [
@@ -10,18 +17,51 @@ export const formOptions: VbenFormProps = {
componentProps: {
allowClear: true,
placeholder: '全部',
options: [
{ label: '等待回调', value: 'waiting' },
{ label: '成功', value: 'success' },
{ label: '失败', value: 'failed' },
],
options: HY_CALLBACK_STATUS_OPTIONS,
},
},
{
component: 'VbenSelect',
fieldName: 'step',
label: '类型',
componentProps: {
allowClear: true,
options: HY_STEP_OPTIONS.filter((o) => o.value !== 'all'),
},
},
{
component: 'VbenSelect',
fieldName: 'push_status',
label: '上报状态',
componentProps: {
allowClear: true,
options: HY_PUSH_STATUS_OPTIONS,
},
},
{
component: 'VbenSelect',
fieldName: 'source',
label: '来源',
componentProps: {
allowClear: true,
options: HY_SOURCE_OPTIONS,
},
},
{
component: 'DatePicker',
fieldName: 'anchor_date',
label: '锚定日',
componentProps: {
allowClear: true,
valueFormat: 'YYYY-MM-DD',
placeholder: '选择锚定日',
class: 'w-full',
},
},
{
component: 'VbenInput',
fieldName: 'anchor_date',
label: '锚定日',
componentProps: { placeholder: 'YYYY-MM-DD' },
fieldName: 'patient_name',
label: '患者',
},
{
component: 'VbenInput',

View File

@@ -7,13 +7,34 @@ export const gridOptions: VxeGridProps = {
{ field: 'id', title: 'ID', width: 80 },
{ field: 'batch_id', title: '批次', width: 80 },
{ field: 'step_txt', title: '类型', width: 100 },
{ field: 'register_id', title: '挂号ID', width: 90 },
{ field: 'prescription_id', title: '处方ID', width: 90 },
{ field: 'order_id', title: '订单ID', width: 90 },
{ field: 'source_txt', title: '来源', width: 80 },
{
field: 'register_id',
title: '挂号ID',
width: 90,
slots: { default: 'register_id' },
},
{
field: 'prescription_id',
title: '处方ID',
width: 90,
slots: { default: 'prescription_id' },
},
{
field: 'order_id',
title: '订单ID',
width: 90,
slots: { default: 'order_id' },
},
{ field: 'prescription_no', title: '处方编号', width: 140 },
{ field: 'patient_name', title: '患者', width: 90 },
{ field: 'push_status_txt', title: '上报', width: 90 },
{ field: 'callback_status_txt', title: '回调', width: 90, slots: { default: 'callback_status' } },
{
field: 'callback_status_txt',
title: '回调',
width: 90,
slots: { default: 'callback_status' },
},
{
field: 'error_summary',
title: '校验/报错',
@@ -21,7 +42,12 @@ export const gridOptions: VxeGridProps = {
showOverflow: true,
slots: { default: 'error_summary' },
},
{ field: 'callback_at', title: '回调时间', width: 160, slots: { default: 'callback_at' } },
{
field: 'callback_at',
title: '回调时间',
width: 160,
slots: { default: 'callback_at' },
},
{ type: 'html', title: '操作', width: 100, slots: { default: 'action' } },
],
keepSource: true,

View File

@@ -1,197 +1,191 @@
<script lang="ts" setup>
/**
* 监管回调结果明细
* 菜单 name 须与 xk_menu.name=LogHyTransitCallback 一致
* 支持 URL query 预填:order_id / prescription_id / register_id / batch_id 等
*/
import { onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
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 { getHyTransitRecordList } from '../api';
import FailDetailModal from '../components/fail-detail-modal.vue';
import { formOptions } from './config/search';
import { hasErrorTextClass } from '../utils';
import { formOptions } from './config/search';
import { gridOptions as baseGridOptions } from './config/table';
defineOptions({ name: 'LogHyTransitCallback' });
const route = useRoute();
const router = useRouter();
function formatTs(ts?: number) {
if (!ts) return '-';
return new Date(ts * 1000).toLocaleString();
}
const statusColor: Record<string, string> = {
waiting: 'orange',
success: 'green',
failed: 'error',
};
function callbackTagColor(status?: string) {
return statusColor[status ?? ''] || 'default';
}
/** 从路由 query 取可预填的筛选项 */
function queryBootstrap(): Record<string, string> {
const keys = [
'order_id',
'prescription_id',
'register_id',
'batch_id',
'callback_status',
'step',
'source',
'anchor_date',
'prescription_no',
];
const out: Record<string, string> = {};
for (const k of keys) {
const v = route.query[k];
if (v != null && String(v) !== '') {
out[k] = String(v);
}
}
return out;
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions,
gridOptions: {
...baseGridOptions,
proxyConfig: {
...baseGridOptions.proxyConfig,
ajax: {
query: async ({ page }, formValues) => {
const res = await getHyTransitRecordList({
page: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
const callbackOpts = res?.form_options?.callback_status;
if (callbackOpts?.length) {
gridApi.formApi?.updateSchema([
{
fieldName: 'callback_status',
componentProps: {
allowClear: true,
placeholder: '全部',
options: callbackOpts,
},
const fo = res?.form_options || {};
const updates: any[] = [];
if (fo.callback_status?.length) {
updates.push({
fieldName: 'callback_status',
componentProps: {
allowClear: true,
placeholder: '全部',
options: fo.callback_status,
},
]);
});
}
if (fo.step?.length) {
updates.push({
fieldName: 'step',
componentProps: { allowClear: true, options: fo.step },
});
}
if (fo.push_status?.length) {
updates.push({
fieldName: 'push_status',
componentProps: { allowClear: true, options: fo.push_status },
});
}
if (fo.source?.length) {
updates.push({
fieldName: 'source',
componentProps: { allowClear: true, options: fo.source },
});
}
if (updates.length) {
gridApi.formApi?.updateSchema(updates);
}
return res;
},
},
},
},
});
const [DetailModal, detailModalApi] = useVbenModal({
connectedComponent: FailDetailModal,
});
const showDetail = (row: { id: number }) => {
detailModalApi.setData({ id: row.id });
detailModalApi.open();
};
/** 跳转商品订单详情(有 order_id 时) */
function goOrder(orderId?: number) {
if (!orderId) return;
router.push({
path: '/business/order/product-order',
query: { open_id: String(orderId) },
});
}
onMounted(async () => {
const boot = queryBootstrap();
if (Object.keys(boot).length === 0) return;
await gridApi.formApi?.setValues(boot);
gridApi.reload?.();
});
</script>
<template>
<Page auto-content-height title="监管回调结果">
<DetailModal />
<Grid>
<template #callback_status="{ row }">
<Tag :color="callbackTagColor(row.callback_status)">
{{ row.callback_status_txt || row.callback_status }}
</Tag>
<template #register_id="{ row }">
<span>{{ row.register_id || '-' }}</span>
</template>
<template #prescription_id="{ row }">
<span>{{ row.prescription_id || '-' }}</span>
</template>
<template #order_id="{ row }">
<a
v-if="row.order_id"
class="text-[hsl(var(--primary))] cursor-pointer"
@click="goOrder(row.order_id)"
>
{{ row.order_id }}
</a>
<span v-else>-</span>
</template>
<template #callback_status="{ row }">
<Tag :color="callbackTagColor(row.callback_status)">
{{ row.callback_status_txt || row.callback_status }}
</Tag>
</template>
<template #error_summary="{ row }">
<span :class="hasErrorTextClass(row.error_summary)">
{{ row.error_summary || '-' }}
</span>
</template>
<template #callback_at="{ row }">
{{ formatTs(row.callback_at) }}
</template>
<template #action="{ row }">
<TableAction
:actions="[
{
label: '查看回调',
type: 'link',
onClick: () => showDetail(row),
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@@ -0,0 +1,39 @@
/**
* 监管中转日志共用常量(拉取/回调筛选项)
*/
export const HY_STEP_OPTIONS = [
{ label: '咨询', value: 'consult' },
{ label: '复诊', value: 'referral' },
{ label: '处方', value: 'recipe' },
{ label: '核销', value: 'verification' },
{ label: '全部', value: 'all' },
];
export const HY_BATCH_PULL_STATUS_OPTIONS = [
{ label: '拉取中', value: 'pulling' },
{ label: '完成', value: 'done' },
{ label: '失败', value: 'failed' },
];
export const HY_BATCH_CALLBACK_STATUS_OPTIONS = [
{ label: '待回调', value: 'pending' },
{ label: '已回调', value: 'done' },
];
export const HY_CALLBACK_STATUS_OPTIONS = [
{ label: '等待回调', value: 'waiting' },
{ label: '成功', value: 'success' },
{ label: '失败', value: 'failed' },
];
export const HY_PUSH_STATUS_OPTIONS = [
{ label: '待上报', value: 'pending' },
{ label: '成功', value: 'success' },
{ label: '失败', value: 'failed' },
{ label: '跳过', value: 'skipped' },
];
export const HY_SOURCE_OPTIONS = [
{ label: '本系统', value: 'system' },
{ label: '第三方', value: 'open' },
];

View File

@@ -1,13 +1,24 @@
import type { VbenFormProps } from '#/adapter/form';
import {
HY_BATCH_CALLBACK_STATUS_OPTIONS,
HY_BATCH_PULL_STATUS_OPTIONS,
HY_STEP_OPTIONS,
} from '../../config/constants';
export const formOptions: VbenFormProps = {
collapsed: false,
schema: [
{
component: 'VbenInput',
component: 'DatePicker',
fieldName: 'anchor_date',
label: '锚定日',
componentProps: { placeholder: 'YYYY-MM-DD' },
componentProps: {
allowClear: true,
valueFormat: 'YYYY-MM-DD',
placeholder: '选择锚定日',
class: 'w-full',
},
},
{
component: 'VbenSelect',
@@ -15,13 +26,25 @@ export const formOptions: VbenFormProps = {
label: '接口类型',
componentProps: {
allowClear: true,
options: [
{ label: '咨询', value: 'consult' },
{ label: '复诊', value: 'referral' },
{ label: '处方', value: 'recipe' },
{ label: '核销', value: 'verification' },
{ label: '全部', value: 'all' },
],
options: HY_STEP_OPTIONS,
},
},
{
component: 'VbenSelect',
fieldName: 'pull_status',
label: '拉取状态',
componentProps: {
allowClear: true,
options: HY_BATCH_PULL_STATUS_OPTIONS,
},
},
{
component: 'VbenSelect',
fieldName: 'batch_callback_status',
label: '回调状态',
componentProps: {
allowClear: true,
options: HY_BATCH_CALLBACK_STATUS_OPTIONS,
},
},
{

View File

@@ -18,7 +18,18 @@ export const gridOptions: VxeGridProps = {
},
{ field: 'skipped_count', title: '跳过', width: 80 },
{ field: 'callback_status_txt', title: '回调状态', width: 100 },
{ field: 'started_at', title: '开始时间', width: 160, slots: { default: 'started_at' } },
{
field: 'started_at',
title: '开始时间',
width: 160,
slots: { default: 'started_at' },
},
{
field: 'finished_at',
title: '结束时间',
width: 160,
slots: { default: 'finished_at' },
},
{ type: 'html', title: '操作', width: 120, slots: { default: 'action' } },
],
keepSource: true,

View File

@@ -1,165 +1,112 @@
<script lang="ts" setup>
/**
* 监管拉取记录(批次列表)
* 菜单 name 须与 xk_menu.name=LogHyTransitPull 一致
*/
import { Page, useVbenModal } from '@vben/common-ui';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { TableAction } from '#/components/table-action';
import { getHyTransitBatchList } from '../api';
import RecordModal from '../components/record-list-modal.vue';
import { formOptions } from './config/search';
import { countErrorClass } from '../utils';
import { formOptions } from './config/search';
import { gridOptions as baseGridOptions } from './config/table';
defineOptions({ name: 'LogHyTransitPull' });
function formatTs(ts?: number) {
if (!ts) return '-';
return new Date(ts * 1000).toLocaleString();
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions,
gridOptions: {
...baseGridOptions,
proxyConfig: {
...baseGridOptions.proxyConfig,
ajax: {
query: async ({ page }, formValues) => {
const res = await getHyTransitBatchList({
page: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
const stepOpts = res?.form_options?.step;
if (stepOpts?.length) {
gridApi.formApi?.updateSchema([
{
fieldName: 'step',
componentProps: {
allowClear: true,
options: stepOpts,
},
},
]);
const fo = res?.form_options || {};
const updates: any[] = [];
if (fo.step?.length) {
updates.push({
fieldName: 'step',
componentProps: { allowClear: true, options: fo.step },
});
}
if (fo.pull_status?.length) {
updates.push({
fieldName: 'pull_status',
componentProps: { allowClear: true, options: fo.pull_status },
});
}
if (fo.batch_callback_status?.length) {
updates.push({
fieldName: 'batch_callback_status',
componentProps: {
allowClear: true,
options: fo.batch_callback_status,
},
});
}
if (updates.length) {
gridApi.formApi?.updateSchema(updates);
}
return res;
},
},
},
},
});
const [RecordListModal, recordModalApi] = useVbenModal({
connectedComponent: RecordModal,
});
const showRecords = (row: { id: number }) => {
recordModalApi.setData({ batchId: row.id });
recordModalApi.open();
};
</script>
<template>
<Page auto-content-height title="监管拉取记录">
<RecordListModal />
<Grid>
<template #failed_count="{ row }">
<span :class="countErrorClass(row.failed_count)">
<span
:class="countErrorClass(row.failed_count)"
class="cursor-pointer"
@click="showRecords(row)"
>
{{ row.failed_count }}
</span>
</template>
<template #started_at="{ row }">
{{ formatTs(row.started_at) }}
</template>
<template #finished_at="{ row }">
{{ formatTs(row.finished_at) }}
</template>
<template #action="{ row }">
<TableAction
:actions="[
{
label: '查看明细',
type: 'link',
onClick: () => showRecords(row),
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@@ -0,0 +1,47 @@
/**
* 监管中转看板 API(复用 log 前缀)
*/
import { requestClient } from '#/api/request';
const prefix = 'log/';
export type HyTransitMonitorSummary = {
batch_total: number;
record_total: number;
success: number;
failed: number;
waiting: number;
skipped: number;
open_pending: number;
open_pulled: number;
fail_rate: number | null;
};
export type HyTransitMonitorResult = {
days: number;
since_date: string;
summary: HyTransitMonitorSummary;
by_step: Array<{
step: string;
step_txt: string;
total: number;
success: number;
failed: number;
skipped: number;
}>;
trend: Array<{
label: string;
anchor_date: string;
total: number;
success: number;
failed: number;
skipped: number;
}>;
top_failed: Array<Record<string, any>>;
};
export async function getHyTransitMonitorStats(days = 7) {
return requestClient.get<HyTransitMonitorResult>(`${prefix}hy-transit-stats`, {
params: { days },
});
}

View File

@@ -0,0 +1,73 @@
<script setup lang="ts">
/**
* 监管中转按日趋势:总量 / 成功 / 失败
*/
import { nextTick, ref, watch } from 'vue';
import {
EchartsUI,
type EchartsUIType,
useEcharts,
} from '@vben/plugins/echarts';
import type { HyTransitMonitorResult } from '../api';
const props = defineProps<{
data: HyTransitMonitorResult['trend'];
}>();
const chartRef = ref<EchartsUIType>();
const { renderEcharts, resize } = useEcharts(chartRef);
async function renderChart() {
const labels = props.data.map((item) => item.label);
await renderEcharts({
tooltip: { trigger: 'axis' },
legend: { data: ['总量', '成功', '失败', '跳过'] },
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
xAxis: {
type: 'category',
data: labels,
axisLabel: { rotate: labels.length > 12 ? 45 : 0 },
},
yAxis: { type: 'value', minInterval: 1 },
series: [
{
name: '总量',
type: 'bar',
data: props.data.map((i) => i.total),
itemStyle: { color: 'hsl(var(--primary))' },
},
{
name: '成功',
type: 'line',
smooth: true,
data: props.data.map((i) => i.success),
itemStyle: { color: '#52c41a' },
},
{
name: '失败',
type: 'line',
smooth: true,
data: props.data.map((i) => i.failed),
itemStyle: { color: '#ff4d4f' },
},
{
name: '跳过',
type: 'line',
smooth: true,
data: props.data.map((i) => i.skipped),
itemStyle: { color: '#faad14' },
},
],
});
await nextTick();
resize();
}
watch(() => props.data, renderChart, { deep: true, immediate: true });
</script>
<template>
<EchartsUI ref="chartRef" class="h-[320px] w-full" />
</template>

View File

@@ -0,0 +1,240 @@
<script lang="ts" setup>
/**
* 监管中转看板:汇总卡片 + 趋势 + 失败明细
* 菜单 name 须与 xk_menu.name=HyTransitMonitor 一致
*/
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { AnalysisChartCard, Page } from '@vben/common-ui';
import { Button, Card, Radio, Spin, Table, Tag, message } from 'ant-design-vue';
import {
getHyTransitMonitorStats,
type HyTransitMonitorResult,
} from './api';
import TrendChart from './components/TrendChart.vue';
defineOptions({ name: 'HyTransitMonitor' });
const router = useRouter();
const loading = ref(false);
const days = ref(7);
const data = ref<HyTransitMonitorResult | null>(null);
let timer: ReturnType<typeof setInterval> | null = null;
const summaryCards = computed(() => {
const s = data.value?.summary;
if (!s) return [];
return [
{
key: 'record_total',
title: '明细总量',
value: s.record_total,
tip: `近 ${days.value} 天拉取`,
color: 'hsl(var(--primary))',
},
{
key: 'success',
title: '上报成功',
value: s.success,
tip: 'callback=success',
color: '#52c41a',
},
{
key: 'failed',
title: '上报失败',
value: s.failed,
tip: s.fail_rate == null ? 'callback=failed' : `失败率 ${s.fail_rate}%`,
color: '#ff4d4f',
},
{
key: 'waiting',
title: '等待回调',
value: s.waiting,
tip: 'callback=waiting',
color: '#faad14',
},
{
key: 'skipped',
title: '校验跳过',
value: s.skipped,
tip: 'push=skipped',
color: '#722ed1',
},
{
key: 'open_pending',
title: '第三方待拉',
value: s.open_pending,
tip: `已拉取 ${s.open_pulled}`,
color: '#13c2c2',
},
];
});
const failColumns = [
{ title: 'ID', dataIndex: 'id', width: 80 },
{ title: '类型', dataIndex: 'step_txt', width: 100 },
{ title: '来源', dataIndex: 'source_txt', width: 80 },
{ title: '患者', dataIndex: 'patient_name', width: 90 },
{ title: '处方编号', dataIndex: 'prescription_no', ellipsis: true },
{ title: '错误摘要', dataIndex: 'error_summary', ellipsis: true },
];
async function loadStats(silent = false) {
if (!silent) loading.value = true;
try {
const res = await getHyTransitMonitorStats(days.value);
data.value = (res?.data || res) as HyTransitMonitorResult;
} catch {
if (!silent) message.error('加载看板失败');
} finally {
if (!silent) loading.value = false;
}
}
function goFailedList() {
router.push({
path: '/log/hy-transit-callback',
query: { callback_status: 'failed' },
});
}
function goOpenPending() {
router.push({
path: '/log/hy-open-transit',
query: { pull_status: 'pending' },
});
}
onMounted(() => {
loadStats();
timer = setInterval(() => loadStats(true), 60_000);
});
onUnmounted(() => {
if (timer) clearInterval(timer);
timer = null;
});
</script>
<template>
<Page auto-content-height title="监管中转看板">
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
<div class="text-sm text-[hsl(var(--muted-foreground))]">
近 {{ days }} 天快照 · 每 60 秒自动刷新
<span v-if="data?.since_date" class="ml-2">
(自 {{ data.since_date }})
</span>
</div>
<div class="flex items-center gap-2">
<Radio.Group
v-model:value="days"
button-style="solid"
option-type="button"
:options="[
{ label: '近7天', value: 7 },
{ label: '近30天', value: 30 },
]"
@change="loadStats()"
/>
<Button type="primary" :loading="loading" @click="loadStats()">
刷新
</Button>
</div>
</div>
<Spin :spinning="loading">
<div class="monitor-stat-grid mb-4">
<Card
v-for="card in summaryCards"
:key="card.key"
class="monitor-stat-card"
:bordered="true"
>
<div class="text-sm text-[hsl(var(--muted-foreground))]">
{{ card.title }}
</div>
<div class="monitor-stat-value" :style="{ color: card.color }">
{{ Number(card.value || 0).toLocaleString() }}
</div>
<div class="mt-2 text-xs text-[hsl(var(--muted-foreground))]">
{{ card.tip }}
</div>
</Card>
</div>
<div class="mb-4 grid grid-cols-1 gap-4 xl:grid-cols-3">
<AnalysisChartCard class="xl:col-span-2" title="上报趋势">
<TrendChart :data="data?.trend || []" />
</AnalysisChartCard>
<Card title="按步骤汇总" :bordered="true">
<div
v-for="row in data?.by_step || []"
:key="row.step"
class="mb-3 flex items-center justify-between gap-2"
>
<span>{{ row.step_txt }}</span>
<div class="flex gap-1">
<Tag color="default">{{ row.total }}</Tag>
<Tag color="success">{{ row.success }}</Tag>
<Tag color="error">{{ row.failed }}</Tag>
</div>
</div>
<div class="mt-3 flex flex-wrap gap-2">
<Button size="small" @click="goFailedList">失败列表</Button>
<Button size="small" @click="goOpenPending">第三方待拉</Button>
</div>
</Card>
</div>
<Card title="最近失败明细" :bordered="true">
<Table
size="small"
:pagination="false"
:columns="failColumns"
:data-source="data?.top_failed || []"
:scroll="{ x: 720 }"
row-key="id"
/>
</Card>
</Spin>
</Page>
</template>
<style scoped>
.monitor-stat-grid {
display: grid;
grid-template-columns: repeat(1, minmax(0, 1fr));
gap: 12px;
}
@media (min-width: 768px) {
.monitor-stat-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (min-width: 1280px) {
.monitor-stat-grid {
grid-template-columns: repeat(6, minmax(0, 1fr));
}
}
.monitor-stat-card {
border: 1px solid hsl(var(--primary) / 30%);
box-shadow: 0 0 6px hsl(var(--primary) / 18%);
}
.monitor-stat-card :deep(.ant-card-body) {
padding: 16px 18px;
}
.monitor-stat-value {
margin-top: 6px;
font-size: 28px;
font-weight: 600;
line-height: 1.2;
}
</style>

View File

@@ -0,0 +1,38 @@
import { requestClient } from '#/api/request';
/**
* 桌面端版本发布管理
* 后台上传安装包 URL,控制是否发布 / 强制更新;桌面端 OpenAPI 拉取后下载安装
*/
const prefix = 'desktop-release/';
export async function getDesktopReleaseList(data: any) {
return requestClient.get<any>(`${prefix}list`, { params: data });
}
export async function getDesktopReleaseDetail(id: number) {
return requestClient.get<any>(`${prefix}detail`, { params: { id } });
}
export async function createDesktopRelease(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}create`, data);
}
export async function updateDesktopRelease(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}update`, data);
}
export async function deleteDesktopRelease(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}delete`, data);
}
/** 一键清理历史:每平台保留最近 keep 个,并删多余记录的 OSS 安装包 */
export async function purgeDesktopReleaseHistory(data?: { keep?: number }) {
return requestClient.post<{
kept: number;
deleted: number;
oss_deleted: number;
oss_skipped: number;
keep: number;
}>(`${prefix}purge-history`, data ?? { keep: 3 });
}

View File

@@ -0,0 +1,114 @@
<script lang="ts" setup>
/**
* 桌面版本新增/编辑弹窗(液态玻璃外壳 + 发版提示)
* 保存走 create / update;发布状态由表单 status 控制,后端写入 published_at
*/
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { createDesktopRelease, updateDesktopRelease } from '../api';
import { modalFormProps } from '../config/form';
const isUpdate = ref(false);
const onReload = ref<null | (() => void | Promise<void>)>(null);
const [Form, formApi] = useVbenForm(modalFormProps);
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
draggable: true,
onCancel() {
modalApi.close();
},
onConfirm: async () => {
const e = await formApi.validate();
if (!e.valid) return;
const values = await formApi.getValues();
modalApi.setState({ loading: true, confirmLoading: true });
try {
if (isUpdate.value) {
await updateDesktopRelease(values);
} else {
await createDesktopRelease(values);
}
message.success('保存成功');
await onReload.value?.();
modalApi.close();
} catch (err) {
console.error(err);
} finally {
modalApi.setState({ loading: false, confirmLoading: false });
}
},
onOpenChange(isOpen: boolean) {
const openData = isOpen ? modalApi.getData<Record<string, any>>() || {} : {};
onReload.value =
isOpen && typeof openData.onReload === 'function' ? openData.onReload : null;
if (!isOpen) {
formApi.reset();
return;
}
const { values, update } = openData;
isUpdate.value = !!update;
if (values && update) {
formApi.setValues({ ...values });
} else {
formApi.reset();
formApi.setValues({
platform: 'all',
download_url: '',
force_update: 0,
status: 0,
title: '',
changelog: '',
});
}
},
});
</script>
<template>
<Modal :title="`${isUpdate ? '编辑' : '发布'}桌面版本`" class="w-[560px] dr-modal">
<div class="dr-modal-tip">
<p class="dr-modal-tip__title">发版前请确认</p>
<p class="dr-modal-tip__desc">
上传对应平台的安装包(如「萧康医药 x.y.z.exe」),填写版本号后设为已发布。桌面端会检查版本并直接下载安装。
</p>
</div>
<Form />
</Modal>
</template>
<style scoped>
.dr-modal-tip {
margin-bottom: 16px;
padding: 12px 14px;
background: linear-gradient(
165deg,
hsl(var(--background) / 68%) 0%,
hsl(var(--background) / 42%) 100%
);
border: 1px solid hsl(var(--primary) / 30%);
border-radius: 14px;
box-shadow:
0 0 6px hsl(var(--primary) / 14%),
inset 0 1px 0 hsl(var(--background) / 80%);
backdrop-filter: blur(16px) saturate(1.5);
-webkit-backdrop-filter: blur(16px) saturate(1.5);
}
.dr-modal-tip__title {
margin: 0;
font-size: 13px;
font-weight: 680;
color: hsl(var(--foreground));
}
.dr-modal-tip__desc {
margin: 4px 0 0;
font-size: 12px;
line-height: 1.5;
color: hsl(var(--muted-foreground));
}
</style>

View File

@@ -0,0 +1,281 @@
<script lang="ts" setup>
/**
* 单个桌面版本卡片:苹果液态玻璃风格
* 展示版本号 / 平台 / 状态 / 更新说明摘要,操作在卡片底部
*/
import { computed } from 'vue';
import { IconifyIcon as VbenIcon } from '@vben/icons';
import { Popconfirm } from 'ant-design-vue';
import { PLATFORM_OPTIONS } from '../config/constants';
const props = defineProps<{
/** 是否当前最新已发布(高亮展示) */
featured?: boolean;
item: Record<string, any>;
}>();
const emit = defineEmits<{
delete: [];
edit: [];
}>();
const platformLabel = computed(() => {
const hit = PLATFORM_OPTIONS.find((o) => o.value === props.item.platform);
return hit?.label || props.item.platform_txt || props.item.platform || '';
});
const platformIcon = computed(() => {
const map: Record<string, string> = {
win32: 'lucide:monitor',
darwin: 'lucide:apple',
linux: 'lucide:terminal',
all: 'lucide:layers',
};
return map[String(props.item.platform)] || 'lucide:monitor';
});
const isPublished = computed(() => Number(props.item.status) === 1);
const isForce = computed(() => Number(props.item.force_update) === 1);
const changelogPreview = computed(() => {
const text = String(props.item.changelog || '').trim();
if (!text) return '暂无更新说明';
return text.length > 96 ? `${text.slice(0, 96)}…` : text;
});
</script>
<template>
<article
class="dr-card"
:class="{ 'is-featured': featured, 'is-draft': !isPublished }"
>
<div class="dr-card__sheen" aria-hidden="true"></div>
<header class="dr-card__head">
<div class="dr-card__ver">
<span class="dr-card__v">v{{ item.version }}</span>
<span v-if="featured" class="dr-pill dr-pill--live">当前线上</span>
<span v-else-if="isPublished" class="dr-pill dr-pill--ok">已发布</span>
<span v-else class="dr-pill">草稿</span>
<span v-if="isForce" class="dr-pill dr-pill--warn">强制更新</span>
</div>
<div class="dr-card__platform">
<VbenIcon :icon="platformIcon" class="dr-card__platform-icon" />
<span>{{ platformLabel }}</span>
</div>
</header>
<h3 class="dr-card__title">{{ item.title || `v${item.version}` }}</h3>
<p class="dr-card__changelog">{{ changelogPreview }}</p>
<div class="dr-card__meta">
<span v-if="item.published_at">发布 {{ item.published_at }}</span>
<span v-else>创建 {{ item.created_at }}</span>
<a
v-if="item.download_url"
class="dr-card__pkg"
:href="item.download_url"
target="_blank"
rel="noopener noreferrer"
>
安装包
</a>
</div>
<footer class="dr-card__foot">
<button type="button" class="dr-btn" @click="emit('edit')">编辑</button>
<Popconfirm title="确认删除该版本记录?" @confirm="emit('delete')">
<button type="button" class="dr-btn dr-btn--danger">删除</button>
</Popconfirm>
</footer>
</article>
</template>
<style scoped>
.dr-card {
position: relative;
display: flex;
flex-direction: column;
gap: 10px;
min-height: 220px;
padding: 18px 18px 14px;
overflow: hidden;
color: hsl(var(--foreground));
background: linear-gradient(
165deg,
hsl(var(--background) / 68%) 0%,
hsl(var(--background) / 42%) 100%
);
border: 1px solid hsl(var(--foreground) / 14%);
border-radius: 22px;
box-shadow:
0 10px 36px hsl(var(--foreground) / 10%),
inset 0 1px 0 hsl(var(--background) / 80%);
backdrop-filter: blur(28px) saturate(1.8);
-webkit-backdrop-filter: blur(28px) saturate(1.8);
transition:
transform 0.22s ease,
border-color 0.22s ease,
box-shadow 0.22s ease;
}
.dr-card:hover {
transform: translateY(-2px);
border-color: hsl(var(--primary) / 30%);
box-shadow:
0 14px 40px hsl(var(--foreground) / 12%),
0 0 6px hsl(var(--primary) / 18%),
inset 0 1px 0 hsl(var(--background) / 80%);
}
.dr-card.is-featured {
border-color: hsl(var(--primary) / 35%);
box-shadow:
0 12px 40px hsl(var(--primary) / 16%),
0 0 8px hsl(var(--primary) / 20%),
inset 0 1px 0 hsl(var(--background) / 80%);
}
.dr-card.is-draft {
opacity: 0.92;
}
.dr-card__sheen {
position: absolute;
inset: 0;
pointer-events: none;
background: linear-gradient(180deg, hsl(var(--foreground) / 8%) 0%, transparent 46%);
border-radius: inherit;
}
.dr-card__head {
position: relative;
display: flex;
gap: 10px;
align-items: flex-start;
justify-content: space-between;
}
.dr-card__ver {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.dr-card__v {
font-size: 22px;
font-weight: 720;
letter-spacing: -0.03em;
line-height: 1.1;
}
.dr-card__platform {
display: inline-flex;
flex-shrink: 0;
gap: 5px;
align-items: center;
padding: 4px 10px;
font-size: 12px;
color: hsl(var(--muted-foreground));
background: hsl(var(--muted) / 0.35);
border: 1px solid hsl(var(--border) / 0.6);
border-radius: 999px;
}
.dr-card__platform-icon {
width: 13px;
height: 13px;
}
.dr-pill {
display: inline-flex;
align-items: center;
padding: 2px 8px;
font-size: 11px;
font-weight: 600;
color: hsl(var(--muted-foreground));
background: hsl(var(--muted) / 0.4);
border: 1px solid hsl(var(--border) / 0.55);
border-radius: 999px;
}
.dr-pill--ok {
color: hsl(var(--success));
background: hsl(var(--success) / 0.12);
border-color: hsl(var(--success) / 0.3);
}
.dr-pill--live {
color: hsl(var(--primary));
background: hsl(var(--primary) / 0.12);
border-color: hsl(var(--primary) / 0.32);
box-shadow: 0 0 6px hsl(var(--primary) / 18%);
}
.dr-pill--warn {
color: hsl(var(--warning, 38 92% 40%));
background: hsl(var(--warning, 38 92% 50%) / 0.12);
border-color: hsl(var(--warning, 38 92% 50%) / 0.32);
}
.dr-card__title {
position: relative;
margin: 0;
font-size: 15px;
font-weight: 650;
letter-spacing: -0.01em;
}
.dr-card__changelog {
position: relative;
flex: 1;
margin: 0;
font-size: 13px;
line-height: 1.55;
color: hsl(var(--muted-foreground));
white-space: pre-wrap;
word-break: break-word;
}
.dr-card__meta {
position: relative;
display: flex;
flex-wrap: wrap;
gap: 8px 12px;
align-items: center;
font-size: 12px;
color: hsl(var(--muted-foreground) / 0.9);
}
.dr-card__pkg {
color: hsl(var(--primary));
text-decoration: none;
}
.dr-card__pkg:hover {
text-decoration: underline;
}
.dr-card__foot {
position: relative;
display: flex;
gap: 8px;
padding-top: 4px;
margin-top: auto;
border-top: 1px solid hsl(var(--border) / 0.45);
}
.dr-btn {
padding: 6px 12px;
font-size: 12px;
font-weight: 600;
color: hsl(var(--foreground));
cursor: pointer;
background: hsl(var(--background) / 0.55);
border: 1px solid hsl(var(--border) / 0.7);
border-radius: 999px;
transition:
background 0.15s ease,
border-color 0.15s ease,
color 0.15s ease;
}
.dr-btn:hover {
background: hsl(var(--primary) / 0.1);
border-color: hsl(var(--primary) / 0.35);
color: hsl(var(--primary));
}
.dr-btn--danger:hover {
background: hsl(var(--destructive, 0 84% 60%) / 0.1);
border-color: hsl(var(--destructive, 0 84% 60%) / 0.35);
color: hsl(var(--destructive, 0 84% 50%));
}
@media (prefers-reduced-motion: reduce) {
.dr-card,
.dr-btn {
transition: none;
}
.dr-card:hover {
transform: none;
}
}
</style>

View File

@@ -0,0 +1,18 @@
/** 桌面版本发布:状态 / 平台 / 强制更新选项 */
export const STATUS_OPTIONS = [
{ label: '草稿', value: 0 },
{ label: '已发布', value: 1 },
];
export const PLATFORM_OPTIONS = [
{ label: '全平台', value: 'all' },
{ label: 'Windows', value: 'win32' },
{ label: 'macOS', value: 'darwin' },
{ label: 'Linux', value: 'linux' },
];
export const FORCE_UPDATE_OPTIONS = [
{ label: '否', value: 0 },
{ label: '是(下载完直接打开安装程序)', value: 1 },
];

View File

@@ -0,0 +1,94 @@
import type { VbenFormProps } from '#/adapter/form';
import {
FORCE_UPDATE_OPTIONS,
PLATFORM_OPTIONS,
STATUS_OPTIONS,
} from './constants';
/**
* 桌面版本表单
* 在后台直接上传安装包(exe/dmg),桌面端按 OpenAPI 检查版本后下载安装
*/
export const modalFormProps: VbenFormProps = {
wrapperClass: 'grid-cols-12',
commonConfig: {
formItemClass: 'col-span-12',
componentProps: { class: 'w-full' },
},
layout: 'horizontal',
schema: [
{
component: 'VbenInput',
fieldName: 'id',
label: 'ID',
dependencies: { show: false, triggerFields: ['id'] },
},
{
component: 'VbenInput',
fieldName: 'version',
label: '版本号',
rules: 'required',
componentProps: { placeholder: '如 1.0.1(semver)' },
},
{
component: 'VbenSelect',
fieldName: 'platform',
label: '平台',
rules: 'selectRequired',
componentProps: { options: PLATFORM_OPTIONS },
defaultValue: 'all',
},
{
component: 'VbenInput',
fieldName: 'title',
label: '标题',
componentProps: { placeholder: '列表展示标题,默认 v{版本号}' },
defaultValue: '',
},
{
component: 'Textarea',
fieldName: 'changelog',
label: '更新说明',
componentProps: {
placeholder: '本次更新内容(可选)',
rows: 4,
},
defaultValue: '',
},
{
// 安装包直传 OSS,URL 写入 download_url;桌面端按该地址下载安装
component: 'UploadOssFile',
fieldName: 'download_url',
label: '安装包',
rules: 'required',
componentProps: {
accept: '.exe,.dmg,.zip,.AppImage,application/octet-stream',
maxSizeMb: 512,
showGallery: false,
acceptTypes: [],
dragger: true,
tip: '点击或拖拽安装包到此处上传',
},
help: '支持拖拽上传 Windows .exe / macOS .dmg 等安装包,客户端检查更新后会直接下载安装',
},
{
component: 'VbenSelect',
fieldName: 'force_update',
label: '强制更新',
rules: 'selectRequired',
componentProps: { options: FORCE_UPDATE_OPTIONS },
defaultValue: 0,
},
{
component: 'VbenSelect',
fieldName: 'status',
label: '状态',
rules: 'selectRequired',
componentProps: { options: STATUS_OPTIONS },
defaultValue: 0,
help: '请先上传安装包,再设为已发布;客户端约每 3 小时自动检查',
},
],
showDefaultActions: false,
};

View File

@@ -0,0 +1,548 @@
<script lang="ts" setup>
/**
* 桌面端版本发布(C 端风格)
* 不用表格:Hero + 液态玻璃卡片墙;在此上传安装包并发布,桌面端检查版本后下载安装
*/
import { computed, onMounted, ref } from 'vue';
import { Page, useVbenModal } from '@vben/common-ui';
import { IconifyIcon as VbenIcon } from '@vben/icons';
import { Modal, Spin, message } from 'ant-design-vue';
import {
deleteDesktopRelease,
getDesktopReleaseList,
purgeDesktopReleaseHistory,
} from './api';
import FormModal from './components/modal.vue';
import ReleaseCard from './components/release-card.vue';
import {
PLATFORM_OPTIONS,
STATUS_OPTIONS,
} from './config/constants';
defineOptions({ name: 'DesktopRelease' });
const loading = ref(false);
const purging = ref(false);
const items = ref<Record<string, any>[]>([]);
const filterPlatform = ref<string | ''>('');
const filterStatus = ref<number | ''>('');
/** 当前线上:已发布中版本号最高的一条(前端简易 semver 比较) */
const featuredId = computed(() => {
const published = items.value.filter((i) => Number(i.status) === 1);
if (!published.length) return null;
const sorted = [...published].sort((a, b) =>
String(b.version || '0').localeCompare(String(a.version || '0'), undefined, {
numeric: true,
sensitivity: 'base',
}),
);
return sorted[0]?.id ?? null;
});
const filteredItems = computed(() => {
return items.value.filter((row) => {
if (filterPlatform.value !== '' && row.platform !== filterPlatform.value) {
return false;
}
if (filterStatus.value !== '' && Number(row.status) !== Number(filterStatus.value)) {
return false;
}
return true;
});
});
const publishedCount = computed(
() => items.value.filter((i) => Number(i.status) === 1).length,
);
const draftCount = computed(
() => items.value.filter((i) => Number(i.status) !== 1).length,
);
/** 拉取全量(版本量通常很少,一次拉够分页即可) */
async function loadList() {
loading.value = true;
try {
const res = await getDesktopReleaseList({ page: 1, pageSize: 200 });
items.value = Array.isArray(res?.items) ? res.items : [];
} catch (err) {
console.error(err);
items.value = [];
} finally {
loading.value = false;
}
}
const [FormModalComp, formModalApi] = useVbenModal({
connectedComponent: FormModal,
});
function showModal(data: Record<string, any> = {}, isUpdate = false) {
formModalApi.setData({
values: data,
update: isUpdate,
onReload: loadList,
});
formModalApi.open();
}
async function handleDelete(id: number) {
try {
await deleteDesktopRelease({ ids: [id] });
message.success('已删除');
await loadList();
} catch (err) {
console.error(err);
}
}
/** 每平台保留最近 3 个版本,其余软删并清理独占的 OSS 安装包 */
function handlePurgeHistory() {
Modal.confirm({
title: '清理历史版本',
content:
'将按平台各保留版本号最高的 3 条记录,其余版本软删,并删除其独占的 OSS 安装包。此操作不可恢复,是否继续?',
okText: '确认清理',
okType: 'danger',
cancelText: '取消',
async onOk() {
purging.value = true;
try {
const res = await purgeDesktopReleaseHistory({ keep: 3 });
const data = (res as any)?.result ?? (res as any)?.data ?? res;
message.success(
`清理完成:保留 ${data?.kept ?? 0} 条,删除 ${data?.deleted ?? 0} 条,OSS 清理 ${data?.oss_deleted ?? 0} 个`,
);
await loadList();
} catch (err) {
console.error(err);
throw err;
} finally {
purging.value = false;
}
},
});
}
onMounted(() => {
loadList();
});
</script>
<template>
<Page auto-content-height>
<FormModalComp />
<div class="dr-shell">
<!-- Hero:产品感标题区 -->
<section class="dr-hero">
<div class="dr-hero__orb dr-hero__orb--a" aria-hidden="true"></div>
<div class="dr-hero__orb dr-hero__orb--b" aria-hidden="true"></div>
<div class="dr-hero__inner">
<div class="dr-hero__icon">
<VbenIcon icon="lucide:monitor-up" />
</div>
<div class="dr-hero__copy">
<p class="dr-hero__kicker">DESKTOP RELEASE</p>
<h1 class="dr-hero__title">桌面端更新</h1>
<p class="dr-hero__desc">
在此上传安装包并发布版本。客户端每 3
小时自动检查,也可在托盘菜单手动「检查更新」后直接下载安装。
</p>
</div>
<div class="dr-hero__stats">
<div class="dr-stat">
<span class="dr-stat__n">{{ publishedCount }}</span>
<span class="dr-stat__l">已发布</span>
</div>
<div class="dr-stat">
<span class="dr-stat__n">{{ draftCount }}</span>
<span class="dr-stat__l">草稿</span>
</div>
</div>
<button type="button" class="dr-cta" @click="showModal({}, false)">
<VbenIcon icon="lucide:plus" class="dr-cta__icon" />
发布新版本
</button>
<button
type="button"
class="dr-cta dr-cta--ghost"
:disabled="purging || loading || items.length === 0"
@click="handlePurgeHistory"
>
<VbenIcon icon="lucide:trash-2" class="dr-cta__icon" />
{{ purging ? '清理中…' : '清理历史版本' }}
</button>
</div>
</section>
<!-- 筛选胶囊 -->
<div class="dr-filters">
<div class="dr-chips">
<button
type="button"
class="dr-chip"
:class="{ 'is-on': filterPlatform === '' }"
@click="filterPlatform = ''"
>
全部平台
</button>
<button
v-for="opt in PLATFORM_OPTIONS"
:key="opt.value"
type="button"
class="dr-chip"
:class="{ 'is-on': filterPlatform === opt.value }"
@click="filterPlatform = String(opt.value)"
>
{{ opt.label }}
</button>
</div>
<div class="dr-chips">
<button
type="button"
class="dr-chip"
:class="{ 'is-on': filterStatus === '' }"
@click="filterStatus = ''"
>
全部状态
</button>
<button
v-for="opt in STATUS_OPTIONS"
:key="opt.value"
type="button"
class="dr-chip"
:class="{ 'is-on': filterStatus === opt.value }"
@click="filterStatus = opt.value"
>
{{ opt.label }}
</button>
</div>
<button type="button" class="dr-refresh" :disabled="loading" @click="loadList">
<VbenIcon icon="lucide:refresh-cw" />
刷新
</button>
</div>
<Spin :spinning="loading">
<div v-if="filteredItems.length" class="dr-grid">
<ReleaseCard
v-for="row in filteredItems"
:key="row.id"
:item="row"
:featured="row.id === featuredId"
@edit="showModal(row, true)"
@delete="handleDelete(row.id)"
/>
</div>
<div v-else class="dr-empty">
<div class="dr-empty__glass">
<VbenIcon icon="lucide:package-open" class="dr-empty__icon" />
<p class="dr-empty__title">还没有版本记录</p>
<p class="dr-empty__desc">点击「发布新版本」,上传安装包并填写版本信息</p>
<button type="button" class="dr-cta dr-cta--sm" @click="showModal({}, false)">
发布新版本
</button>
</div>
</div>
</Spin>
</div>
</Page>
</template>
<style scoped>
.dr-shell {
position: relative;
min-height: 100%;
padding: 8px 4px 24px;
}
.dr-hero {
position: relative;
margin-bottom: 18px;
overflow: hidden;
border: 1px solid hsl(var(--border) / 0.8);
border-radius: 24px;
background: linear-gradient(
145deg,
hsl(var(--card, var(--background)) / 0.55) 0%,
hsl(var(--background) / 0.35) 100%
);
box-shadow:
0 12px 40px hsl(var(--foreground) / 0.06),
inset 0 1px 0 hsl(var(--background) / 0.7);
}
.dr-hero__orb {
position: absolute;
pointer-events: none;
border-radius: 50%;
filter: blur(42px);
}
.dr-hero__orb--a {
top: -80px;
left: -50px;
width: 240px;
height: 240px;
background: hsl(var(--primary) / 0.22);
}
.dr-hero__orb--b {
right: -70px;
bottom: -100px;
width: 260px;
height: 260px;
background: hsl(var(--primary) / 0.1);
}
.dr-hero__inner {
position: relative;
display: flex;
flex-wrap: wrap;
gap: 16px 20px;
align-items: center;
padding: 22px 24px;
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
}
.dr-hero__icon {
display: grid;
flex-shrink: 0;
place-items: center;
width: 52px;
height: 52px;
color: hsl(var(--primary));
background: hsl(var(--primary) / 0.12);
border: 1px solid hsl(var(--primary) / 0.28);
border-radius: 16px;
box-shadow: 0 0 6px hsl(var(--primary) / 18%);
}
.dr-hero__icon :deep(svg) {
width: 24px;
height: 24px;
}
.dr-hero__copy {
flex: 1;
min-width: 220px;
}
.dr-hero__kicker {
margin: 0;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.1em;
color: hsl(var(--primary) / 0.8);
}
.dr-hero__title {
margin: 4px 0 0;
font-size: 26px;
font-weight: 740;
letter-spacing: -0.03em;
line-height: 1.15;
color: hsl(var(--foreground));
}
.dr-hero__desc {
max-width: 520px;
margin: 8px 0 0;
font-size: 13px;
line-height: 1.55;
color: hsl(var(--muted-foreground));
}
.dr-hero__stats {
display: flex;
gap: 10px;
}
.dr-stat {
min-width: 72px;
padding: 10px 14px;
text-align: center;
background: hsl(var(--background) / 0.45);
border: 1px solid hsl(var(--border) / 0.55);
border-radius: 14px;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
box-shadow: inset 0 1px 0 hsl(var(--background) / 0.6);
}
.dr-stat__n {
display: block;
font-size: 22px;
font-weight: 720;
letter-spacing: -0.03em;
color: hsl(var(--foreground));
}
.dr-stat__l {
font-size: 11px;
color: hsl(var(--muted-foreground));
}
.dr-cta {
display: inline-flex;
gap: 6px;
align-items: center;
padding: 10px 18px;
font-size: 14px;
font-weight: 650;
color: hsl(var(--primary-foreground, 0 0% 100%));
cursor: pointer;
background: hsl(var(--primary));
border: 1px solid hsl(var(--primary) / 0.5);
border-radius: 999px;
box-shadow: 0 0 6px hsl(var(--primary) / 28%);
transition:
transform 0.15s ease,
box-shadow 0.15s ease,
opacity 0.15s ease;
}
.dr-cta:hover {
transform: translateY(-1px);
box-shadow: 0 8px 24px hsl(var(--primary) / 0.28);
}
.dr-cta--ghost {
color: hsl(var(--foreground));
background: hsl(var(--background) / 0.55);
border: 1px solid hsl(var(--border) / 0.75);
box-shadow: none;
}
.dr-cta--ghost:hover:not(:disabled) {
color: hsl(var(--destructive, 0 84% 50%));
background: hsl(var(--destructive, 0 84% 60%) / 0.1);
border-color: hsl(var(--destructive, 0 84% 60%) / 0.35);
box-shadow: 0 0 6px hsl(var(--destructive, 0 84% 60%) / 18%);
}
.dr-cta--ghost:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.dr-cta--sm {
margin-top: 12px;
padding: 8px 16px;
font-size: 13px;
}
.dr-cta__icon {
width: 16px;
height: 16px;
}
.dr-filters {
display: flex;
flex-wrap: wrap;
gap: 10px 14px;
align-items: center;
margin-bottom: 16px;
}
.dr-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.dr-chip {
padding: 6px 12px;
font-size: 12px;
font-weight: 600;
color: hsl(var(--muted-foreground));
cursor: pointer;
background: hsl(var(--card, var(--background)) / 0.55);
border: 1px solid hsl(var(--border) / 0.6);
border-radius: 999px;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
transition:
color 0.15s ease,
border-color 0.15s ease,
background 0.15s ease;
}
.dr-chip.is-on,
.dr-chip:hover {
color: hsl(var(--primary));
background: hsl(var(--primary) / 0.1);
border-color: hsl(var(--primary) / 0.32);
box-shadow: 0 0 6px hsl(var(--primary) / 14%);
}
.dr-refresh {
display: inline-flex;
gap: 5px;
align-items: center;
margin-left: auto;
padding: 6px 12px;
font-size: 12px;
font-weight: 600;
color: hsl(var(--muted-foreground));
cursor: pointer;
background: transparent;
border: 1px solid hsl(var(--border) / 0.55);
border-radius: 999px;
}
.dr-refresh:hover:not(:disabled) {
color: hsl(var(--foreground));
border-color: hsl(var(--border));
}
.dr-refresh:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.dr-refresh :deep(svg) {
width: 13px;
height: 13px;
}
.dr-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 14px;
}
.dr-empty {
display: grid;
place-items: center;
min-height: 280px;
padding: 24px;
}
.dr-empty__glass {
max-width: 360px;
padding: 28px 24px;
text-align: center;
background: linear-gradient(
165deg,
hsl(var(--background) / 68%) 0%,
hsl(var(--background) / 42%) 100%
);
border: 1px solid hsl(var(--foreground) / 14%);
border-radius: 22px;
box-shadow:
0 10px 36px hsl(var(--foreground) / 10%),
inset 0 1px 0 hsl(var(--background) / 80%);
backdrop-filter: blur(28px) saturate(1.8);
-webkit-backdrop-filter: blur(28px) saturate(1.8);
}
.dr-empty__icon {
width: 36px;
height: 36px;
margin: 0 auto 10px;
color: hsl(var(--muted-foreground));
}
.dr-empty__title {
margin: 0;
font-size: 16px;
font-weight: 680;
color: hsl(var(--foreground));
}
.dr-empty__desc {
margin: 6px 0 0;
font-size: 13px;
color: hsl(var(--muted-foreground));
}
@media (max-width: 720px) {
.dr-hero__inner {
padding: 18px;
}
.dr-hero__title {
font-size: 22px;
}
.dr-refresh {
margin-left: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.dr-cta,
.dr-chip {
transition: none;
}
.dr-cta:hover {
transform: none;
}
}
</style>

View File

@@ -10,6 +10,10 @@ import MIcon from '#/components/icon/icon.vue';
import { XgVideoPlayer } from '#/components/video-player';
import type { FileGalleryDetail } from '#/api/core/file-gallery';
import { getFileGalleryDetail } from '#/api/core/file-gallery';
import {
resolveGalleryItemIcon,
shouldShowImageThumb,
} from '#/composables/use-file-type-icon';
defineOptions({ name: 'FileDetailDrawer' });
@@ -18,6 +22,15 @@ const detail = ref<FileGalleryDetail | null>(null);
const previewOpen = ref(false);
const previewFiles = ref<string[]>([]);
/** 详情预览区/分类行图标:扩展名优先(exe/zip 等) */
function detailIcon(item: FileGalleryDetail) {
return resolveGalleryItemIcon(item);
}
function showImagePreview(item: FileGalleryDetail) {
return shouldShowImageThumb(item);
}
const [Drawer, drawerApi] = useVbenDrawer({
class: 'w-[520px]',
showConfirmButton: false,
@@ -83,7 +96,12 @@ defineExpose({
<Spin :spinning="loading">
<template v-if="detail">
<div class="preview-wrap">
<Image v-if="detail.type === 0" :src="detail.url" :width="220" :preview="true" />
<Image
v-if="showImagePreview(detail)"
:src="detail.url"
:width="220"
:preview="true"
/>
<XgVideoPlayer
v-else-if="detail.type === 1"
:src="detail.url"
@@ -96,14 +114,14 @@ defineExpose({
:src="detail.url"
/>
<div v-else-if="detail.type === 5" class="fallback-preview">
<MIcon v-if="detail.type_icon" :icon="detail.type_icon" size="48" />
<MIcon :icon="detailIcon(detail)" size="48" />
<div class="btn-row">
<Button type="primary" @click="openPreview">预览 PDF</Button>
<Button @click="downloadFile">下载 PDF</Button>
</div>
</div>
<div v-else class="fallback-preview">
<MIcon v-if="detail.type_icon" :icon="detail.type_icon" size="48" />
<MIcon :icon="detailIcon(detail)" size="48" />
<a :href="detail.url" target="_blank" rel="noopener noreferrer">下载/打开文件</a>
</div>
</div>
@@ -111,7 +129,7 @@ defineExpose({
<Descriptions.Item label="文件名">{{ detail.file_name || '-' }}</Descriptions.Item>
<Descriptions.Item label="分类">
<span class="type-row">
<MIcon v-if="detail.type_icon" :icon="detail.type_icon" size="14" />
<MIcon :icon="detailIcon(detail)" size="14" />
{{ detail.type_text || '-' }}
</span>
</Descriptions.Item>

View File

@@ -28,9 +28,12 @@ import {
Checkbox,
Empty,
Input,
InputNumber,
message,
Modal,
Pagination,
Progress,
Radio,
Select,
Spin,
Switch,
@@ -46,6 +49,7 @@ import {
getFileGalleryStats,
markFileQrcode,
moveFileGalleryType,
planFileGalleryOssSync,
renameFileGalleryItem,
scanFileGalleryRefs,
syncFileGalleryFromOss,
@@ -54,7 +58,10 @@ import { showContextMenu } from '#/components/context-menu';
import MIcon from '#/components/icon/icon.vue';
import { useFileGalleryFilter } from '#/composables/use-file-gallery-filter';
import { useFileGroupCache } from '#/composables/use-file-group-cache';
import { resolveTypeIcon } from '#/composables/use-file-type-icon';
import {
resolveGalleryItemIcon,
shouldShowImageThumb,
} from '#/composables/use-file-type-icon';
import FileDetailDrawer from './components/file-detail-drawer.vue';
import FolderSidebar from './components/folder-sidebar.vue';
@@ -63,6 +70,23 @@ defineOptions({ name: 'FileManagement' });
type SelectMode = '' | 'delete' | 'move';
/** OSS 同步断点:刷新/中断后可从日期组 + marker 继续 */
const SYNC_CHECKPOINT_KEY = 'file-gallery-oss-sync-checkpoint';
interface OssSyncCheckpoint {
mode: 'incremental' | 'full';
limit: number;
prefixes: string[];
groupIndex: number;
marker: string;
batches: number;
scanned: number;
synced: number;
skipped: number;
percent: number;
updatedAt: number;
}
const loading = ref(false);
const archiving = ref(false);
const syncing = ref(false);
@@ -70,6 +94,24 @@ const scanning = ref(false);
const renameOpen = ref(false);
const renameValue = ref('');
const renameTarget = ref<FileGalleryItem | null>(null);
/** OSS 同步:模态框配置模式/每批条数,前端按日期组自动连续拉取 */
const syncOpen = ref(false);
const syncMode = ref<'incremental' | 'full'>('incremental');
const syncBatchSize = ref(200);
/** 有断点时:resume=继续 / fresh=重新开始 */
const syncStartMode = ref<'resume' | 'fresh'>('fresh');
const syncCheckpoint = ref<OssSyncCheckpoint | null>(null);
const syncProgress = ref({
batches: 0,
scanned: 0,
synced: 0,
skipped: 0,
folderCreated: 0,
groupIndex: 0,
groupTotal: 0,
currentPrefix: '',
percent: 0,
});
const items = ref<FileGalleryItem[]>([]);
const total = ref(0);
const page = ref(1);
@@ -136,15 +178,282 @@ async function reloadAll() {
await Promise.all([load(), loadStats()]);
}
async function handleSync() {
syncing.value = true;
/** 读取本地断点(损坏则忽略) */
function loadSyncCheckpoint(): OssSyncCheckpoint | null {
try {
const res = await syncFileGalleryFromOss();
const data = unwrap(res);
message.success(
`同步完成:扫描 ${data?.scanned ?? 0} 个,新增 ${data?.synced ?? 0} 个,跳过 ${data?.skipped ?? 0} 个`,
);
const raw = localStorage.getItem(SYNC_CHECKPOINT_KEY);
if (!raw) {
return null;
}
const data = JSON.parse(raw) as OssSyncCheckpoint;
if (
!data ||
!Array.isArray(data.prefixes) ||
data.prefixes.length === 0 ||
typeof data.groupIndex !== 'number' ||
data.groupIndex < 0 ||
data.groupIndex >= data.prefixes.length
) {
return null;
}
return data;
} catch {
return null;
}
}
function saveSyncCheckpoint(checkpoint: OssSyncCheckpoint) {
localStorage.setItem(SYNC_CHECKPOINT_KEY, JSON.stringify(checkpoint));
syncCheckpoint.value = checkpoint;
}
function clearSyncCheckpoint() {
localStorage.removeItem(SYNC_CHECKPOINT_KEY);
syncCheckpoint.value = null;
}
/** 打开 OSS 同步配置;若有未完成断点则默认「继续」 */
function openSyncModal() {
if (syncing.value) {
return;
}
const checkpoint = loadSyncCheckpoint();
syncCheckpoint.value = checkpoint;
if (checkpoint) {
syncStartMode.value = 'resume';
syncMode.value = checkpoint.mode;
syncBatchSize.value = checkpoint.limit || 200;
syncProgress.value = {
batches: checkpoint.batches || 0,
scanned: checkpoint.scanned || 0,
synced: checkpoint.synced || 0,
skipped: checkpoint.skipped || 0,
folderCreated: 0,
groupIndex: checkpoint.groupIndex || 0,
groupTotal: checkpoint.prefixes.length,
currentPrefix: (checkpoint.prefixes[checkpoint.groupIndex] || '').replace(
/\/$/,
'',
),
percent: checkpoint.percent || 0,
};
} else {
syncStartMode.value = 'fresh';
syncMode.value = 'incremental';
syncBatchSize.value = 200;
syncProgress.value = {
batches: 0,
scanned: 0,
synced: 0,
skipped: 0,
folderCreated: 0,
groupIndex: 0,
groupTotal: 0,
currentPrefix: '',
percent: 0,
};
}
syncOpen.value = true;
}
/** 计算进度:按日期组推进,组内批次数作轻微细分 */
function calcSyncPercent(groupIndex: number, groupTotal: number, groupDone: boolean) {
if (groupTotal <= 0) {
return groupDone ? 100 : 0;
}
const finished = groupIndex + (groupDone ? 1 : 0);
return Math.min(99, Math.floor((finished / groupTotal) * 100));
}
/**
* 跑日期组循环:支持从 groupIndex + marker 断点续跑
* 每批成功后写入 localStorage,中断/刷新后可继续
*/
async function runOssSyncGroups(
prefixes: string[],
limit: number,
mode: 'incremental' | 'full',
startGroupIndex: number,
startMarker: string,
) {
const groupTotal = prefixes.length;
syncProgress.value.groupTotal = groupTotal;
for (let i = startGroupIndex; i < prefixes.length; i++) {
const prefix = prefixes[i] || '';
syncProgress.value.groupIndex = i;
syncProgress.value.currentPrefix = prefix.replace(/\/$/, '');
syncProgress.value.percent = calcSyncPercent(i, groupTotal, false);
// 仅起始组沿用断点 marker,后续组从空 marker 开始
let marker = i === startGroupIndex ? startMarker : '';
let groupDone = false;
while (!groupDone) {
const res = await syncFileGalleryFromOss({ limit, marker, prefix });
const data = unwrap(res) as {
scanned?: number;
synced?: number;
skipped?: number;
next_marker?: string;
done?: boolean;
};
syncProgress.value.batches += 1;
syncProgress.value.scanned += data?.scanned ?? 0;
syncProgress.value.synced += data?.synced ?? 0;
syncProgress.value.skipped += data?.skipped ?? 0;
if (data?.done) {
groupDone = true;
marker = '';
} else {
marker = String(data?.next_marker ?? '');
if (!marker) {
groupDone = true;
}
}
if (!groupDone) {
const base = (i / groupTotal) * 100;
const step = (1 / groupTotal) * 100;
syncProgress.value.percent = Math.min(
99,
Math.floor(base + step * 0.5),
);
// 组内未完成:记下下一 marker,供断点续跑
saveSyncCheckpoint({
mode,
limit,
prefixes,
groupIndex: i,
marker,
batches: syncProgress.value.batches,
scanned: syncProgress.value.scanned,
synced: syncProgress.value.synced,
skipped: syncProgress.value.skipped,
percent: syncProgress.value.percent,
updatedAt: Date.now(),
});
}
}
syncProgress.value.percent = calcSyncPercent(i, groupTotal, true);
const nextIndex = i + 1;
if (nextIndex < prefixes.length) {
// 当前日期组完成,断点移到下一组开头
saveSyncCheckpoint({
mode,
limit,
prefixes,
groupIndex: nextIndex,
marker: '',
batches: syncProgress.value.batches,
scanned: syncProgress.value.scanned,
synced: syncProgress.value.synced,
skipped: syncProgress.value.skipped,
percent: syncProgress.value.percent,
updatedAt: Date.now(),
});
}
}
const finalRes = await syncFileGalleryFromOss({ finalize: 1 });
const finalData = unwrap(finalRes) as { folder_created?: number };
syncProgress.value.folderCreated = finalData?.folder_created ?? 0;
syncProgress.value.percent = 100;
clearSyncCheckpoint();
}
/**
* 开始或继续 OSS 同步
* 有断点且选择「继续」时,不再重新 plan,直接从 groupIndex+marker 续跑
*/
async function submitSyncFromOss() {
const limit = Math.max(1, Math.min(1000, Number(syncBatchSize.value) || 200));
syncBatchSize.value = limit;
syncing.value = true;
const resume =
syncStartMode.value === 'resume' && syncCheckpoint.value !== null;
try {
if (resume && syncCheckpoint.value) {
const cp = syncCheckpoint.value;
syncMode.value = cp.mode;
syncProgress.value = {
batches: cp.batches || 0,
scanned: cp.scanned || 0,
synced: cp.synced || 0,
skipped: cp.skipped || 0,
folderCreated: 0,
groupIndex: cp.groupIndex,
groupTotal: cp.prefixes.length,
currentPrefix: (cp.prefixes[cp.groupIndex] || '').replace(/\/$/, ''),
percent: cp.percent || 0,
};
await runOssSyncGroups(
cp.prefixes,
limit,
cp.mode,
cp.groupIndex,
cp.marker || '',
);
message.success(
`断点续同步完成:${cp.prefixes.length} 个日期组 / ${syncProgress.value.batches} 批,扫描 ${syncProgress.value.scanned},新增 ${syncProgress.value.synced},跳过 ${syncProgress.value.skipped}`,
);
} else {
clearSyncCheckpoint();
syncProgress.value = {
batches: 0,
scanned: 0,
synced: 0,
skipped: 0,
folderCreated: 0,
groupIndex: 0,
groupTotal: 0,
currentPrefix: '',
percent: 0,
};
const planRes = await planFileGalleryOssSync({ mode: syncMode.value });
const plan = unwrap(planRes) as {
prefixes?: string[];
total?: number;
};
const prefixes = Array.isArray(plan?.prefixes) ? plan.prefixes : [];
if (prefixes.length === 0) {
message.info(
syncMode.value === 'incremental'
? '增量范围内没有可同步的日期目录(库已是最新或 OSS 无日期目录)'
: 'OSS 上未发现 xk_upload_YYYYMMDD 日期目录',
);
syncProgress.value.percent = 100;
return;
}
// 开跑前先落一份断点,避免首批就失败时无法续
saveSyncCheckpoint({
mode: syncMode.value,
limit,
prefixes,
groupIndex: 0,
marker: '',
batches: 0,
scanned: 0,
synced: 0,
skipped: 0,
percent: 0,
updatedAt: Date.now(),
});
await runOssSyncGroups(prefixes, limit, syncMode.value, 0, '');
message.success(
`同步完成:${prefixes.length} 个日期组 / ${syncProgress.value.batches} 批,扫描 ${syncProgress.value.scanned},新增 ${syncProgress.value.synced},跳过 ${syncProgress.value.skipped}`,
);
}
syncOpen.value = false;
await reloadAll();
} catch (err) {
console.error(err);
// 中断时断点已在成功批次后写入,提示用户可继续
syncCheckpoint.value = loadSyncCheckpoint();
message.error('同步中断,进度已保存,可重新打开弹窗选择「继续断点」');
throw err;
} finally {
syncing.value = false;
}
@@ -445,8 +754,14 @@ function onShowSizeChange(_current: number, size: number) {
void load();
}
/** 卡片/标题图标:扩展名优先,避免 exe/zip 一律通用文件图标 */
function itemIcon(item: FileGalleryItem) {
return item.type_icon || resolveTypeIcon(item.type, fileTypes.value);
return resolveGalleryItemIcon(item, fileTypes.value);
}
/** type=0 但后缀非图片时不走缩略图,防止破图 */
function showImageThumb(item: FileGalleryItem) {
return shouldShowImageThumb(item);
}
watch(typesLoading, (val) => {
@@ -542,7 +857,7 @@ watch(typesLoading, (val) => {
<DeleteOutlined />
批量删除
</Button>
<Button :loading="syncing" @click="handleSync">从 OSS 同步</Button>
<Button :loading="syncing" @click="openSyncModal">从 OSS 同步</Button>
<Button type="primary" :loading="archiving" @click="handleArchive">
一键归档
</Button>
@@ -579,7 +894,7 @@ watch(typesLoading, (val) => {
无引用
</span>
<img
v-if="item.type === 0"
v-if="showImageThumb(item)"
:src="item.url"
alt=""
class="gallery-thumb"
@@ -632,6 +947,91 @@ watch(typesLoading, (val) => {
<DetailDrawer />
<Modal
v-model:open="syncOpen"
title="从 OSS 同步"
:ok-text="syncStartMode === 'resume' && syncCheckpoint ? '继续断点' : '开始同步'"
cancel-text="取消"
:confirm-loading="syncing"
:ok-button-props="{ disabled: syncing }"
:cancel-button-props="{ disabled: syncing }"
:mask-closable="!syncing"
:keyboard="!syncing"
@ok="submitSyncFromOss"
>
<div class="sync-form">
<div v-if="syncCheckpoint" class="sync-checkpoint">
<div class="sync-label">检测到未完成同步</div>
<Radio.Group v-model:value="syncStartMode" :disabled="syncing">
<Radio value="resume">继续断点</Radio>
<Radio value="fresh">重新开始</Radio>
</Radio.Group>
<div class="sync-hint">
断点:第
{{ syncCheckpoint.groupIndex + 1 }}/{{ syncCheckpoint.prefixes.length }}
组
<span v-if="syncCheckpoint.prefixes[syncCheckpoint.groupIndex]">
({{
String(syncCheckpoint.prefixes[syncCheckpoint.groupIndex]).replace(
/\/$/,
'',
)
}})
</span>
· 已扫描 {{ syncCheckpoint.scanned }} · 已新增
{{ syncCheckpoint.synced }}
</div>
</div>
<div class="sync-label">同步模式</div>
<Radio.Group
v-model:value="syncMode"
:disabled="syncing || (syncStartMode === 'resume' && !!syncCheckpoint)"
>
<Radio.Button value="incremental">增量(按日期)</Radio.Button>
<Radio.Button value="full">全量扫描</Radio.Button>
</Radio.Group>
<div class="sync-hint">
增量:从库内最新
<code>xk_upload_YYYYMMDD</code>
日期起同步(含当日)。全量:扫描全部日期目录。
</div>
<div class="sync-label">每批文件数</div>
<InputNumber
v-model:value="syncBatchSize"
:min="1"
:max="1000"
:disabled="syncing"
class="sync-batch-input"
placeholder="默认 200"
/>
<div class="sync-hint">
每个日期目录内按批拉取;每批成功后写入断点,中断/刷新后可继续。
</div>
<div v-if="syncing || syncProgress.batches > 0" class="sync-progress">
<Progress
:percent="syncProgress.percent"
:status="syncing ? 'active' : 'success'"
size="small"
/>
<div class="sync-progress-text">
<template v-if="syncProgress.groupTotal > 0">
日期组
{{ Math.min(syncProgress.groupIndex + 1, syncProgress.groupTotal) }}
/
{{ syncProgress.groupTotal }}
<span v-if="syncProgress.currentPrefix">
({{ syncProgress.currentPrefix }})
</span>
·
</template>
已完成 {{ syncProgress.batches }} 批 · 扫描
{{ syncProgress.scanned }} · 新增 {{ syncProgress.synced }} · 跳过
{{ syncProgress.skipped }}
</div>
</div>
</div>
</Modal>
<Modal
v-model:open="renameOpen"
title="重命名"
@@ -668,6 +1068,55 @@ watch(typesLoading, (val) => {
align-items: center;
}
.sync-form {
display: flex;
flex-direction: column;
gap: 8px;
}
.sync-label {
font-size: 13px;
color: hsl(var(--foreground));
}
.sync-batch-input {
width: 100%;
}
.sync-hint {
font-size: 12px;
line-height: 1.5;
color: hsl(var(--muted-foreground));
}
.sync-checkpoint {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px 12px;
border: 1px solid hsl(var(--primary) / 30%);
border-radius: 8px;
background: hsl(var(--primary) / 0.06);
box-shadow: 0 0 6px hsl(var(--primary) / 18%);
}
.sync-progress {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 8px;
padding: 10px 12px;
border: 1px solid hsl(var(--border));
border-radius: 8px;
background: hsl(var(--muted) / 0.25);
}
.sync-progress-text {
font-size: 12px;
line-height: 1.5;
color: hsl(var(--foreground));
}
.stats-row {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));

View File

@@ -0,0 +1,30 @@
import { requestClient } from '#/api/request';
/**
* 监管 OpenAPI 开放应用管理
*/
const prefix = 'hy-open-app/';
export async function getHyOpenAppList(data: any) {
return requestClient.get<any>(`${prefix}list`, { params: data });
}
export async function getHyOpenAppDetail(id: number) {
return requestClient.get<any>(`${prefix}detail`, { params: { id } });
}
export async function createHyOpenApp(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}create`, data);
}
export async function updateHyOpenApp(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}update`, data);
}
export async function deleteHyOpenApp(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}delete`, data);
}
export async function resetHyOpenAppSecret(data: { id: number }) {
return requestClient.post<any>(`${prefix}reset-secret`, data);
}

View File

@@ -0,0 +1,86 @@
<script lang="ts" setup>
/**
* 监管开放应用新增/编辑弹窗
*/
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { createHyOpenApp, updateHyOpenApp } from '../api';
import { modalFormProps } from '../config/form';
const isUpdate = ref(false);
const gridApi = ref();
const onCreated = ref<
null | ((payload: { app_key: string; app_secret: string; name?: string }) => void)
>(null);
const [Form, formApi] = useVbenForm(modalFormProps);
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
draggable: true,
onCancel() {
modalApi.close();
},
onConfirm: async () => {
const e = await formApi.validate();
if (!e.valid) return;
const values = await formApi.getValues();
modalApi.setState({ loading: true, confirmLoading: true });
try {
if (isUpdate.value) {
await updateHyOpenApp(values);
message.success('保存成功');
gridApi.value?.query?.();
modalApi.close();
} else {
const res = await createHyOpenApp(values);
const data = (res as any)?.data || res || {};
message.success('创建成功,请立即复制密钥');
gridApi.value?.query?.();
modalApi.close();
if (data.app_key && data.app_secret) {
onCreated.value?.({
app_key: String(data.app_key),
app_secret: String(data.app_secret),
name: String(data.name || values.name || ''),
});
}
}
} catch (err) {
console.error(err);
} finally {
modalApi.setState({ loading: false, confirmLoading: false });
}
},
onOpenChange(isOpen: boolean) {
const openData = isOpen ? modalApi.getData<Record<string, any>>() || {} : {};
gridApi.value = isOpen ? openData.gridApi : null;
onCreated.value = isOpen
? typeof openData.onCreated === 'function'
? openData.onCreated
: null
: null;
if (!isOpen) {
formApi.reset();
return;
}
const { values, update } = openData;
isUpdate.value = !!update;
if (values && update) {
formApi.setValues({ ...values });
} else {
formApi.reset();
formApi.setValues({ status: 1, remark: '' });
}
},
});
</script>
<template>
<Modal :title="`${isUpdate ? '编辑' : '新增'}监管开放应用`" class="w-[520px]">
<Form />
</Modal>
</template>

View File

@@ -0,0 +1,98 @@
<script lang="ts" setup>
/**
* 密钥一次性展示弹窗:创建/重置后提示复制,关闭后不可再看明文
*/
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Alert, Button, message } from 'ant-design-vue';
import SensitiveText from '#/components/sensitive-text/SensitiveText.vue';
const titleText = ref('请保存密钥');
const appKey = ref('');
const appSecret = ref('');
const appName = ref('');
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
draggable: true,
showCancelButton: false,
confirmText: '我已复制并关闭',
onConfirm() {
modalApi.close();
},
onOpenChange(isOpen: boolean) {
if (!isOpen) {
appKey.value = '';
appSecret.value = '';
appName.value = '';
return;
}
const data = modalApi.getData<Record<string, any>>() || {};
titleText.value = String(data.title || '请保存密钥');
appKey.value = String(data.app_key || '');
appSecret.value = String(data.app_secret || '');
appName.value = String(data.name || '');
},
});
async function copyText(text: string, tip: string) {
if (!text) return;
try {
await navigator.clipboard.writeText(text);
message.success(tip);
} catch {
message.error('复制失败,请手动选中复制');
}
}
</script>
<template>
<Modal :title="titleText" class="w-[560px]">
<Alert
type="warning"
show-icon
class="mb-4"
message="密钥仅展示一次,关闭后无法再次查看明文。请立即复制并妥善保管。"
/>
<div v-if="appName" class="mb-3 text-sm text-[hsl(var(--muted-foreground))]">
应用:{{ appName }}
</div>
<div class="cred-card mb-3">
<div class="mb-1 text-xs text-[hsl(var(--muted-foreground))]">App Key</div>
<div class="flex items-center justify-between gap-2">
<code class="break-all text-sm text-[hsl(var(--foreground))]">{{
appKey
}}</code>
<Button size="small" @click="copyText(appKey, 'App Key 已复制')">
复制
</Button>
</div>
</div>
<div class="cred-card">
<div class="mb-1 text-xs text-[hsl(var(--muted-foreground))]">
App Secret
</div>
<div class="flex items-center justify-between gap-2">
<SensitiveText
class="min-w-0 flex-1 break-all text-sm"
:record="{ app_secret: appSecret }"
field="app_secret"
/>
<Button size="small" @click="copyText(appSecret, 'App Secret 已复制')">
复制
</Button>
</div>
</div>
</Modal>
</template>
<style scoped>
.cred-card {
border: 1px solid hsl(var(--primary) / 30%);
border-radius: 8px;
padding: 12px 14px;
background: hsl(var(--card, var(--background)));
box-shadow: 0 0 6px hsl(var(--primary) / 18%);
}
</style>

View File

@@ -0,0 +1,5 @@
/** 监管开放应用状态选项 */
export const STATUS_OPTIONS = [
{ label: '启用', value: 1 },
{ label: '禁用', value: 0 },
];

View File

@@ -0,0 +1,46 @@
import type { VbenFormProps } from '#/adapter/form';
import { STATUS_OPTIONS } from './constants';
/**
* 应用表单:只填名称/备注/状态,密钥由后端生成
*/
export const modalFormProps: VbenFormProps = {
wrapperClass: 'grid-cols-12',
commonConfig: {
formItemClass: 'col-span-12',
componentProps: { class: 'w-full' },
},
layout: 'horizontal',
schema: [
{
component: 'VbenInput',
fieldName: 'id',
label: 'ID',
dependencies: { show: false, triggerFields: ['id'] },
},
{
component: 'VbenInput',
fieldName: 'name',
label: '应用名称',
rules: 'required',
componentProps: { placeholder: '如:某某医院 HIS' },
},
{
component: 'VbenInput',
fieldName: 'remark',
label: '备注',
componentProps: { placeholder: '对接说明(可选)' },
defaultValue: '',
},
{
component: 'VbenSelect',
fieldName: 'status',
label: '状态',
rules: 'selectRequired',
componentProps: { options: STATUS_OPTIONS },
defaultValue: 1,
},
],
showDefaultActions: false,
};

View File

@@ -0,0 +1,232 @@
<script lang="ts" setup>
/**
* 监管 OpenAPI 开放应用管理
* 后台创建应用并生成 app_key / app_secret,供第三方 HMAC 调用监管中转接口
*/
import type { VxeGridListeners } from '#/adapter/vxe-table';
import { ref } from 'vue';
import { Page, useVbenModal } from '@vben/common-ui';
import { Tag, message } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { TableAction } from '#/components/table-action';
import {
deleteHyOpenApp,
getHyOpenAppList,
resetHyOpenAppSecret,
} from './api';
import FormModal from './components/modal.vue';
import SecretModal from './components/secret-modal.vue';
import { STATUS_OPTIONS } from './config/constants';
defineOptions({ name: 'HyOpenApp' });
const hasTopTableDropDownActions = ref(false);
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: [
{
component: 'VbenInput',
fieldName: 'name',
label: '应用名称',
componentProps: { placeholder: '名称模糊搜' },
},
{
component: 'VbenInput',
fieldName: 'app_key',
label: 'App Key',
componentProps: { placeholder: '精确/模糊' },
},
{
component: 'VbenSelect',
fieldName: 'status',
label: '状态',
componentProps: { allowClear: true, options: STATUS_OPTIONS },
},
],
},
gridOptions: {
checkboxConfig: { highlight: true, labelField: '' },
columns: [
{ type: 'checkbox', width: 60 },
{ field: 'id', title: 'ID', width: 80 },
{ field: 'name', title: '应用名称', minWidth: 140 },
{ field: 'app_key', title: 'App Key', minWidth: 220 },
{
field: 'app_secret_mask',
title: 'App Secret',
minWidth: 140,
},
{
field: 'status',
title: '状态',
width: 90,
slots: { default: 'status' },
},
{ field: 'remark', title: '备注', minWidth: 140 },
{ field: 'created_at', title: '创建时间', width: 170 },
{
title: '操作',
slots: { default: 'action' },
width: 220,
fixed: 'right',
},
],
keepSource: true,
pagerConfig: {},
proxyConfig: {
ajax: {
query: async ({ page }: any, formValues: any) => {
return await getHyOpenAppList({
page: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
height: 'auto',
toolbarConfig: {
search: true,
refresh: true,
slots: { buttons: 'toolbar-actions' },
},
},
gridEvents: {
checkboxChange() {
hasTopTableDropDownActions.value =
gridApi.grid.getCheckboxRecords().length > 0;
},
checkboxAll() {
hasTopTableDropDownActions.value =
gridApi.grid.getCheckboxRecords().length > 0;
},
} as VxeGridListeners<any>,
});
const [FormModalComp, formModalApi] = useVbenModal({
connectedComponent: FormModal,
});
const [SecretModalComp, secretModalApi] = useVbenModal({
connectedComponent: SecretModal,
});
function onAppCreated(payload: {
app_key: string;
app_secret: string;
name?: string;
}) {
secretModalApi.setData({
title: '应用已创建,请保存密钥',
...payload,
});
secretModalApi.open();
}
const showModal = (data: any = {}, isUpdate = false) => {
formModalApi.setData({
values: data,
update: isUpdate,
gridApi,
onCreated: onAppCreated,
});
formModalApi.open();
};
async function handleResetSecret(row: any) {
try {
const res = await resetHyOpenAppSecret({ id: row.id });
const data = res?.data || res || {};
message.success('密钥已重置');
gridApi.query();
secretModalApi.setData({
title: '密钥已重置,请保存新密钥',
app_key: data.app_key || row.app_key,
app_secret: data.app_secret,
name: row.name,
});
secretModalApi.open();
} catch (err) {
console.error(err);
}
}
const handleDelete = async (ids: number[]) => {
await deleteHyOpenApp({ ids });
message.success('删除成功');
gridApi.query();
};
</script>
<template>
<Page auto-content-height>
<FormModalComp />
<SecretModalComp />
<Grid>
<template #toolbar-actions>
<TableAction
:actions="[
{
label: '新增应用',
type: 'primary',
onClick: () => showModal({}, false),
},
]"
:drop-down-actions="
hasTopTableDropDownActions
? [
{
label: '批量删除',
popConfirm: {
title: '确认删除选中应用?',
confirm: () => {
const rows = gridApi.grid.getCheckboxRecords();
handleDelete(rows.map((r: any) => r.id));
},
},
},
]
: []
"
/>
</template>
<template #status="{ row }">
<Tag :color="row.status === 1 ? 'success' : 'default'">
{{ row.status_txt || (row.status === 1 ? '启用' : '禁用') }}
</Tag>
</template>
<template #action="{ row }">
<TableAction
:actions="[
{
label: '编辑',
type: 'link',
onClick: () => showModal(row, true),
},
{
label: '重置密钥',
type: 'link',
popConfirm: {
title: '重置后旧密钥立即失效,确认?',
confirm: () => handleResetSecret(row),
},
},
{
label: '删除',
type: 'link',
danger: true,
popConfirm: {
title: '确认删除该应用?',
confirm: () => handleDelete([row.id]),
},
},
]"
/>
</template>
</Grid>
</Page>
</template>