feat: 辅料功能、康正源拉取价格接口
This commit is contained in:
4
apps/desktop/.npmrc
Normal file
4
apps/desktop/.npmrc
Normal 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/
|
||||
@@ -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 桌面端使用自绘顶栏(与系统窗口按钮同一行):左上角「萧康云医」菜单含刷新 / 检查更新 / 关于。
|
||||
|
||||
@@ -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": {
|
||||
|
||||
196
apps/desktop/scripts/pack.mjs
Normal file
196
apps/desktop/scripts/pack.mjs
Normal 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);
|
||||
});
|
||||
44
apps/desktop/scripts/run-builder.mjs
Normal file
44
apps/desktop/scripts/run-builder.mjs
Normal 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);
|
||||
});
|
||||
65
apps/desktop/src/main/cache.ts
Normal file
65
apps/desktop/src/main/cache.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
89
apps/desktop/src/main/menu.ts
Normal file
89
apps/desktop/src/main/menu.ts
Normal 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();
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 ?? {});
|
||||
}
|
||||
|
||||
/** 文件加入自定义分组 */
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
3
apps/web-antd/src/types/desktop.d.ts
vendored
3
apps/web-antd/src/types/desktop.d.ts
vendored
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
184
apps/web-antd/src/views/log/hy-open-transit/index.vue
Normal file
184
apps/web-antd/src/views/log/hy-open-transit/index.vue
Normal 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>
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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' },
|
||||
];
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
47
apps/web-antd/src/views/log/hy-transit-monitor/api/index.ts
Normal file
47
apps/web-antd/src/views/log/hy-transit-monitor/api/index.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
240
apps/web-antd/src/views/log/hy-transit-monitor/index.vue
Normal file
240
apps/web-antd/src/views/log/hy-transit-monitor/index.vue
Normal 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>
|
||||
38
apps/web-antd/src/views/system/desktop-release/api/index.ts
Normal file
38
apps/web-antd/src/views/system/desktop-release/api/index.ts
Normal 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 });
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 },
|
||||
];
|
||||
@@ -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,
|
||||
};
|
||||
548
apps/web-antd/src/views/system/desktop-release/index.vue
Normal file
548
apps/web-antd/src/views/system/desktop-release/index.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
|
||||
30
apps/web-antd/src/views/system/hy-open-app/api/index.ts
Normal file
30
apps/web-antd/src/views/system/hy-open-app/api/index.ts
Normal 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);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,5 @@
|
||||
/** 监管开放应用状态选项 */
|
||||
export const STATUS_OPTIONS = [
|
||||
{ label: '启用', value: 1 },
|
||||
{ label: '禁用', value: 0 },
|
||||
];
|
||||
46
apps/web-antd/src/views/system/hy-open-app/config/form.ts
Normal file
46
apps/web-antd/src/views/system/hy-open-app/config/form.ts
Normal 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,
|
||||
};
|
||||
232
apps/web-antd/src/views/system/hy-open-app/index.vue
Normal file
232
apps/web-antd/src/views/system/hy-open-app/index.vue
Normal 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>
|
||||
Reference in New Issue
Block a user