Compare commits
9 Commits
0807bb8514
...
f70f5dcff8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f70f5dcff8 | ||
|
|
f8463e9f84 | ||
|
|
a1bddc348d | ||
|
|
7e4bfee095 | ||
|
|
c9a3c02e12 | ||
|
|
8e16bdb2dc | ||
|
|
7dc9bc6c2a | ||
|
|
e069a823b3 | ||
|
|
8b86b73424 |
@ -1,6 +1,19 @@
|
||||
# admin.xpcool.com 变更记录
|
||||
> 倒序:最新在上。格式:YYYY-MM-DD | 类型 | 摘要
|
||||
|
||||
2026-08-27 | CHG | 登录接口豁免全量加密,永远只加密密码字段:loginApi 始终走 hybridEncryptLogin(提交 {username 明文, encryptedKey, encryptedData});request.ts PLAIN_ROUTE_FRAGMENTS 增 /auth/login(请求拦截器不再整体加密登录 body)。全量模式 5 项 + dev 15 项 E2E 验证通过
|
||||
|
||||
2026-08-27 | CHG | 全量请求/响应加密(生产 VITE_GLOB_API_ENCRYPT=true):①crypto.ts 增 encryptPayload(整体加密任意对象,返回 aesKeyB64 会话密钥)+ decryptPayload(AES-GCM 解密后端响应,密文=nonce||ciphertext||tag);②request.ts 增 installCryptoInterceptors——请求拦截器将整个 JSON body 混合加密(public-key 豁免、FormData/无 body 跳过)、响应解密拦截器最先注册(axios 响应拦截器按注册顺序执行)还原 {code,message,data},requestClient 与 baseRequestClient 均安装;apiFullBodyEncrypt/fetchPublicKeyForCrypto 导出;③auth.ts loginApi、system.ts createAdmin/resetAdminPassword 全量模式下直接传明文密码由拦截器整体加密(开发模式仍走密码字段混合加密);④.env.production VITE_GLOB_API_ENCRYPT=true / .env.development=false。⚠️TS 坑:Uint8Array.subarray 返回 ArrayBufferLike 泛型不满足 BufferSource,需 new Uint8Array() 拷贝;axios 类型从 @vben/request re-export 导入(web-tdesign 未声明 axios 依赖)。typecheck+oxlint 全绿,全量/仅密码两种模式 E2E 均通过
|
||||
|
||||
2026-08-27 | CHG | 登录/创建/重置密码全链路加密传输(RSA+AES 混合加密,与后端配套):①新增 src/utils/crypto.ts——Web Crypto API 零依赖:importKey('spki') 导入后端公钥、随机 AES-256-GCM 加密载荷(nonce 12B 前置、GCM tag 随密文)、RSA-OAEP(SHA-256) 加密 AES 密钥,输出 {encryptedKey, encryptedData};公钥 5 分钟内存缓存。②api/core/auth.ts:getPublicKeyApi(并发去重)+ loginApi 先拉公钥再加密 username/password 提交,网络传输无明文密码。③api/system.ts:createAdmin/resetAdminPassword 的 password 走 hybridEncryptField 加密(encryptField 复用公钥缓存)。④注意:Web Crypto 仅安全上下文可用(https/localhost/127.0.0.1),http://IP 访问会明确报错。typecheck + oxlint 全绿
|
||||
|
||||
2026-08-27 | CHG | 用户管理改造(页面标题改"用户管理",表单/列表加 Bark设备ID、pushplus token)+ 通知模块 3 页(规则:渠道多选+用户多选+标题/内容模板+测试按钮;渠道:启用+JSON 配置;日志:事件/渠道/结果/时间筛选)+ 自动任务 2 页(任务列表:cron 编辑/启停/手动执行;运行日志:按任务筛选+耗时/摘要/错误);api/notice.ts + autoJob.ts + system.ts 扩展(AdminItem 加渠道字段);typecheck 通过
|
||||
2026-08-27 | FIX | 前后端联查修复「界面/菜单/工作台异常」:①本地库缺 010_server_log_enhance 增量列(admin_operation_log 无 ip_location/admin_username 等)致操作日志接口 500,已导;②本地缺工作台/招聘菜单+admin_role_menu 关联,已导 013_workbench_menu+recruitment/003_menu;③工作台快捷入口 /system/log/* 是死链(真实路由 /log/*)已改;④打通 Drawer 重构遗留编译错:6 页 @vben-core/popup-ui 误引改 @vben/common-ui、preferences 补 languageToggleButtonPosition、清未用变量、menu/log 页 lint 债(7dc9bc6,钩子全绿);⑤发现审计 admin_username/user_agent 长期为空=线上跑旧二进制,新构建验证 248 号记录字段完整,历史空账号已按 user_id 回填(121 条);⑥本地 xxcool/opuser 密码已重置为 opuser123 便于联调
|
||||
|
||||
2026-08-27 | CHG | 三大改造:①移除国际化多语言仅留中文——preferences.ts 固定 locale=zh-CN;删除 header/toolbar/user-dropdown 三处语言切换入口、偏好面板语言选择项(general.vue/appLocale/widget.vue languageToggle 位置配置);config.ts 禁 languageToggle 且 order 移除;删除主应用与 packages/locales 的 en-US 语言包;locales/index.ts loadDayjsLocale 仅留 zh-CN。②新增主题色管理——theme.css 品牌色改为引用 --primary 变量(不再写死 #4f5bff),背景/主按钮渐变用 color-mix 跟随主题色,配合 useTDesignDesignTokens 使偏好面板 BuiltinTheme 切换主题色真正生效;constants.ts default 主题色与 config.ts colorPrimary 统一为靛蓝 hsl(235 100% 65%)。③业务弹窗统一为 vben 抽屉——6 页(system/{role,menu,admin}、recruitment/push、house/{listing,community})的 t-dialog 全部替换为 useVbenDrawer(<Drawer>),drawerApi.open/close/setState(标题)/lock(提交中);community/listing 抽屉加宽(class w-160/w-150)
|
||||
2026-08-27 | CHG | 服务器安全日志页(挂在系统监控菜单下,id=96):views/monitor/security/index.vue——顶部 4 统计卡片(SSH爆破/fail2ban封禁/成功登录/总数)+ TOP 攻击源标签区 + 筛选(来源IP/事件类型/时间范围 t-date-range-picker)+ Vben VxeTable 列表(时间/来源IP/来源端口/目标端口/事件类型标签/详情 tooltip,事件标签按类型配色);api/serverSecurity.ts(list/stats 全 POST,无 URL 参数);typecheck 通过
|
||||
2026-08-27 | CHG | 全量表格重构:views 下全部 11 个 t-table 页面统一改为 Vben Vxe Table(useVbenVxeGrid,#/adapter/vxe-table)+ Vben TableAction 行操作——system/{log(范式),login-log,admin,role}、recruitment/{info,crawler,push}、house/{data,listing,community,presale}、dashboard/workbench(两处本地小表 proxyConfig.enabled=false + gridApi.setGridOptions 写数据)。要点:gridOptions 需注解 VxeTableGridOptions(否则 type:'seq' 推断 string 报错);单元格自定义用 columns[].slots.default + h(TDesign组件);远程分页走 proxyConfig.ajax.query 返回 {items,total};筛选区保留手写 TDesign 组件不动;admin/role/listing/community 行操作 ActionItem 带 auth 权限码、删除 danger 标红;listing 多选批量标记改 vxe checkboxConfig + gridApi.grid.getCheckboxRecords();confirm 弹窗交互(删除/重置密码)保留 DialogPlugin 不变;typecheck 通过
|
||||
|
||||
2026-08-27 | FIX | 恢复丢失的前端代码:recovery 分支经 rebase/改名(dev↔main)折腾后工作树回退,未提交改动全丢;从悬空提交 f02a514(recovery 工作树快照,含 HEAD 缺失的全部文件)整体恢复 apps/web-tdesign/src——含 styles/theme.css 全局主题、dashboard/workbench 工作台页、system 五页卡片化美化、操作日志增强版(VxeTable)、house 预售/楼盘地图、recruitment 页面;另补 log 页卡片美化(wb-page-head+t-card)。已提交保护
|
||||
2026-08-26 | CFG | 安装 TDesign MCP(~/.workbuddy/mcp.json 新增 tdesign-mcp-server: `npx -y tdesign-mcp-server@latest`,v0.2.4 启动验证通过);约定:后续后台界面开发优先用 TDesign MCP 查官方文档(get-component-docs 等)
|
||||
2026-08-26 | FIX+CHG | 修复前端编译错误(access.ts layoutMap 重复声明致 vite Transform PARSE_ERROR);菜单管理树形改用 tdesign 官方方案:`t-enhanced-table`(t-table 不支持 tree)+ tree{childrenKey/treeNodeColumnIndex/defaultExpandAll/indent} + 异步数据后 `ref.expandAll()`(defaultExpandAll 仅首次渲染生效)——替代先前扁平化+缩进的自造方案
|
||||
|
||||
@ -14,3 +14,6 @@ VITE_DEVTOOLS=false
|
||||
|
||||
# 是否注入全局loading
|
||||
VITE_INJECT_APP_LOADING=true
|
||||
|
||||
# 是否启用全量请求/响应加密(生产 true:所有接口 body/响应整体加密,public-key 豁免;开发 false:仅加密密码字段)
|
||||
VITE_GLOB_API_ENCRYPT=false
|
||||
|
||||
@ -17,3 +17,6 @@ VITE_INJECT_APP_LOADING=true
|
||||
|
||||
# 打包后是否生成dist.zip
|
||||
VITE_ARCHIVER=true
|
||||
|
||||
# 全量请求/响应加密(与后端 encrypt.fullBody=true 配套)
|
||||
VITE_GLOB_API_ENCRYPT=true
|
||||
|
||||
67
apps/web-tdesign/src/api/autoJob.ts
Normal file
67
apps/web-tdesign/src/api/autoJob.ts
Normal file
@ -0,0 +1,67 @@
|
||||
// 自动任务模块 API 客户端(任务管理/运行日志)。
|
||||
// 接口规范:全部 POST;URL 不含参数;入参一律 body。
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
// ---------- 任务 ----------
|
||||
export interface AutoJobItem {
|
||||
id: number;
|
||||
name: string;
|
||||
code: string;
|
||||
jobType: string;
|
||||
cronExpr: string;
|
||||
enabled: number;
|
||||
remark: string;
|
||||
lastRunAt: string;
|
||||
lastResult: number;
|
||||
lastError: string;
|
||||
}
|
||||
|
||||
export function getAutoJobList() {
|
||||
return requestClient.post<{ list: AutoJobItem[] }>(
|
||||
'/api/service/admin/auto-job/list',
|
||||
);
|
||||
}
|
||||
|
||||
export function saveAutoJob(data: {
|
||||
cronExpr?: string;
|
||||
enabled?: number;
|
||||
id: number;
|
||||
remark?: string;
|
||||
}) {
|
||||
return requestClient.post('/api/service/admin/auto-job/save', data);
|
||||
}
|
||||
|
||||
export function triggerAutoJob(data: { id: number }) {
|
||||
return requestClient.post<{ ok: boolean; summary: string }>(
|
||||
'/api/service/admin/auto-job/trigger',
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- 运行日志 ----------
|
||||
export interface AutoJobLogItem {
|
||||
id: number;
|
||||
jobId: number;
|
||||
jobName: string;
|
||||
runAt: string;
|
||||
result: number;
|
||||
error: string;
|
||||
summary: string;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
export interface AutoJobLogListResp {
|
||||
list: AutoJobLogItem[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export function getAutoJobLogList(data: {
|
||||
jobId?: number;
|
||||
page: number;
|
||||
size: number;
|
||||
}) {
|
||||
return requestClient.post<AutoJobLogListResp>(
|
||||
'/api/service/admin/auto-job/log/list',
|
||||
data,
|
||||
);
|
||||
}
|
||||
@ -1,51 +1,70 @@
|
||||
import { baseRequestClient, requestClient } from '#/api/request';
|
||||
import {
|
||||
baseRequestClient,
|
||||
fetchPublicKeyForCrypto,
|
||||
requestClient,
|
||||
} from '#/api/request';
|
||||
import { hybridEncryptLogin } from '#/utils/crypto';
|
||||
|
||||
export namespace AuthApi {
|
||||
/** 登录接口参数 */
|
||||
/** 登录接口参数(明文账号密码仅在前端加密流程内消费,不会直接出网) */
|
||||
export interface LoginParams {
|
||||
password?: string;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
/** 登录接口返回值 */
|
||||
/** 登录/刷新接口返回值(后端 TokenPair) */
|
||||
export interface LoginResult {
|
||||
accessToken: string;
|
||||
}
|
||||
|
||||
export interface RefreshTokenResult {
|
||||
data: string;
|
||||
status: number;
|
||||
adminId: number;
|
||||
expiresIn: number;
|
||||
refreshToken: string;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录
|
||||
* 获取登录加密公钥(RSA 公钥 PEM,公开接口免鉴权),复用请求层缓存与并发去重。
|
||||
*/
|
||||
export async function getPublicKeyApi(): Promise<string> {
|
||||
return fetchPublicKeyForCrypto();
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录(密码加密传输,对称+非对称结合)。
|
||||
* 登录永远只加密密码字段:前端随机 AES 密钥加密 {username,password,ts},
|
||||
* RSA 公钥加密 AES 密钥,提交 {username, encryptedKey, encryptedData},
|
||||
* 该接口在全量加密模式下同样豁免整体加密,便于兼容与联调。
|
||||
*/
|
||||
export async function loginApi(data: AuthApi.LoginParams) {
|
||||
const publicKey = await getPublicKeyApi();
|
||||
const { encryptedKey, encryptedData } = await hybridEncryptLogin(
|
||||
publicKey,
|
||||
data.username ?? '',
|
||||
data.password ?? '',
|
||||
);
|
||||
return requestClient.post<AuthApi.LoginResult>(
|
||||
'/api/service/admin/system/auth/login',
|
||||
data,
|
||||
{ username: data.username, encryptedKey, encryptedData },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新accessToken
|
||||
* 刷新 accessToken(旋转令牌:旧 refreshToken 单次有效,返回新令牌对)
|
||||
* @param refreshToken 当前保存的刷新令牌,后端按其 JTI 撤销旧会话
|
||||
* @returns raw 模式 axios response:resp.data 为 {code,message,data} 信封
|
||||
*/
|
||||
export async function refreshTokenApi() {
|
||||
return baseRequestClient.post<AuthApi.RefreshTokenResult>(
|
||||
'/api/service/admin/system/auth/refresh',
|
||||
{
|
||||
withCredentials: true,
|
||||
},
|
||||
);
|
||||
export async function refreshTokenApi(refreshToken: string) {
|
||||
return baseRequestClient.post<{
|
||||
data: { code: number; data: AuthApi.LoginResult; message: string };
|
||||
status: number;
|
||||
}>('/api/service/admin/system/auth/refresh', { refreshToken });
|
||||
}
|
||||
|
||||
/**
|
||||
* 退出登录
|
||||
* 退出登录:携带 refreshToken 让后端撤销刷新会话(幂等)
|
||||
*/
|
||||
export async function logoutApi() {
|
||||
export async function logoutApi(refreshToken?: string) {
|
||||
return baseRequestClient.post('/api/service/admin/system/auth/logout', {
|
||||
withCredentials: true,
|
||||
refreshToken,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
130
apps/web-tdesign/src/api/notice.ts
Normal file
130
apps/web-tdesign/src/api/notice.ts
Normal file
@ -0,0 +1,130 @@
|
||||
// 通知模块 API 客户端(渠道/规则/日志/测试)。
|
||||
// 接口规范:全部 POST;URL 不含参数;入参一律 body。
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
// ---------- 渠道 ----------
|
||||
export interface NoticeChannelItem {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
enabled: number;
|
||||
config: string;
|
||||
remark: string;
|
||||
}
|
||||
|
||||
export function getNoticeChannelList() {
|
||||
return requestClient.post<{ list: NoticeChannelItem[] }>(
|
||||
'/api/service/admin/notice/channel/list',
|
||||
);
|
||||
}
|
||||
|
||||
export function saveNoticeChannel(data: {
|
||||
code: string;
|
||||
config?: string;
|
||||
enabled?: number;
|
||||
id?: number;
|
||||
name: string;
|
||||
remark?: string;
|
||||
}) {
|
||||
return requestClient.post<{ id: number }>(
|
||||
'/api/service/admin/notice/channel/save',
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- 规则 ----------
|
||||
export interface NoticeRuleItem {
|
||||
id: number;
|
||||
name: string;
|
||||
eventType: string;
|
||||
channelCodes: string[];
|
||||
userIds: number[];
|
||||
titleTemplate: string;
|
||||
bodyTemplate: string;
|
||||
enabled: number;
|
||||
}
|
||||
|
||||
export function getNoticeRuleList(data: { eventType?: string }) {
|
||||
return requestClient.post<{ list: NoticeRuleItem[] }>(
|
||||
'/api/service/admin/notice/rule/list',
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
export function saveNoticeRule(data: {
|
||||
bodyTemplate?: string;
|
||||
channelCodes: string[];
|
||||
enabled?: number;
|
||||
eventType: string;
|
||||
id?: number;
|
||||
name: string;
|
||||
titleTemplate?: string;
|
||||
userIds: number[];
|
||||
}) {
|
||||
return requestClient.post<{ id: number }>(
|
||||
'/api/service/admin/notice/rule/save',
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteNoticeRule(data: { id: number }) {
|
||||
return requestClient.post('/api/service/admin/notice/rule/delete', data);
|
||||
}
|
||||
|
||||
// ---------- 日志 ----------
|
||||
export interface NoticeLogItem {
|
||||
id: number;
|
||||
ruleId: number;
|
||||
eventType: string;
|
||||
channelCode: string;
|
||||
userId: number;
|
||||
target: string;
|
||||
title: string;
|
||||
body: string;
|
||||
result: number;
|
||||
error: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface NoticeLogListResp {
|
||||
list: NoticeLogItem[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export function getNoticeLogList(data: {
|
||||
channelCode?: string;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
eventType?: string;
|
||||
page: number;
|
||||
result?: number;
|
||||
size: number;
|
||||
}) {
|
||||
return requestClient.post<NoticeLogListResp>(
|
||||
'/api/service/admin/notice/log/list',
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- 测试 ----------
|
||||
export function testNotice(data: {
|
||||
body?: string;
|
||||
channelCode?: string;
|
||||
ruleId?: number;
|
||||
target?: string;
|
||||
title?: string;
|
||||
userIds?: number[];
|
||||
}) {
|
||||
return requestClient.post<{ message: string; result: boolean }>(
|
||||
'/api/service/admin/notice/test',
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
// 事件类型选项
|
||||
export const NOTICE_EVENT_OPTIONS = [
|
||||
{ label: '任务完成', value: 'job_done' },
|
||||
{ label: '任务失败', value: 'job_fail' },
|
||||
{ label: '安全告警', value: 'security_alert' },
|
||||
{ label: '招聘每日推送', value: 'recruit_daily' },
|
||||
];
|
||||
@ -1,7 +1,11 @@
|
||||
/**
|
||||
* 该文件可自行根据业务逻辑进行调整
|
||||
*/
|
||||
import type { RequestClientOptions } from '@vben/request';
|
||||
import type {
|
||||
AxiosResponse,
|
||||
InternalAxiosRequestConfig,
|
||||
RequestClientOptions,
|
||||
} from '@vben/request';
|
||||
|
||||
import { useAppConfig } from '@vben/hooks';
|
||||
import { preferences } from '@vben/preferences';
|
||||
@ -15,17 +19,123 @@ import { useAccessStore } from '@vben/stores';
|
||||
|
||||
import { message } from '#/adapter/tdesign';
|
||||
import { useAuthStore } from '#/store';
|
||||
import {
|
||||
cachePublicKey,
|
||||
decryptPayload,
|
||||
encryptPayload,
|
||||
getCachedPublicKey,
|
||||
} from '#/utils/crypto';
|
||||
|
||||
import { refreshTokenApi } from './core';
|
||||
|
||||
const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
|
||||
|
||||
/**
|
||||
* 是否启用全量请求/响应加密(生产环境 VITE_GLOB_API_ENCRYPT=true)。
|
||||
* 开发环境为 false:仅加密密码字段,便于抓包联调。
|
||||
*/
|
||||
export const apiFullBodyEncrypt =
|
||||
import.meta.env.VITE_GLOB_API_ENCRYPT === 'true';
|
||||
|
||||
/** 全量加密豁免端点(path 片段匹配):
|
||||
* - public-key:必须先明文拿到公钥才能加密;
|
||||
* - 登录接口:永远只加密密码字段(username 明文 + 密码字段混合加密),不参与整体加密。 */
|
||||
const PLAIN_ROUTE_FRAGMENTS = ['/public-key', '/auth/login'];
|
||||
|
||||
// 公钥拉取并发去重:同一时刻只发一次请求。
|
||||
let publicKeyPromise: null | Promise<string> = null;
|
||||
|
||||
/**
|
||||
* 获取登录加密公钥(RSA 公钥 PEM),带 5 分钟内存缓存 + 并发去重。
|
||||
* 供登录密码加密与全量加密拦截器共用;public-key 本身为明文豁免端点。
|
||||
*/
|
||||
export async function fetchPublicKeyForCrypto(): Promise<string> {
|
||||
const cached = getCachedPublicKey();
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
if (!publicKeyPromise) {
|
||||
publicKeyPromise = baseRequestClient
|
||||
.post<{
|
||||
data: { code: number; data: { publicKey: string }; message: string };
|
||||
}>('/api/service/admin/system/auth/public-key')
|
||||
.then((resp) => {
|
||||
const publicKey = resp.data?.data?.publicKey;
|
||||
if (!publicKey) {
|
||||
throw new Error('获取登录加密公钥失败:响应缺少 publicKey');
|
||||
}
|
||||
cachePublicKey(publicKey);
|
||||
return publicKey;
|
||||
})
|
||||
.finally(() => {
|
||||
publicKeyPromise = null;
|
||||
});
|
||||
}
|
||||
return publicKeyPromise;
|
||||
}
|
||||
|
||||
/** 请求配置扩展:携带本次请求的 AES 会话密钥,供响应解密使用 */
|
||||
interface CryptoRequestConfig extends InternalAxiosRequestConfig {
|
||||
__aesKey?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 安装全量加解密拦截器(生产模式):
|
||||
* - 请求:除豁免端点外,将整个 JSON body 混合加密为 {encryptedKey, encryptedData},
|
||||
* 并暂存 AES 会话密钥到请求配置;
|
||||
* - 响应:用同一会话密钥解密 {encryptedData},还原为标准响应信封后交下游拦截器。
|
||||
*/
|
||||
function installCryptoInterceptors(client: RequestClient) {
|
||||
// 响应解密:必须最先注册(axios 响应拦截器按注册顺序执行),在解包/鉴权/报错之前还原明文。
|
||||
client.addResponseInterceptor({
|
||||
fulfilled: async (response: AxiosResponse) => {
|
||||
if (!apiFullBodyEncrypt) {
|
||||
return response;
|
||||
}
|
||||
const cfg = response.config as CryptoRequestConfig;
|
||||
const data = response.data as { encryptedData?: string };
|
||||
if (cfg.__aesKey && data && typeof data.encryptedData === 'string') {
|
||||
const plain = await decryptPayload(cfg.__aesKey, data.encryptedData);
|
||||
response.data = plain;
|
||||
}
|
||||
return response;
|
||||
},
|
||||
});
|
||||
|
||||
// 请求加密:在发送前整体加密 body(豁免端点与无 body 请求跳过)。
|
||||
client.addRequestInterceptor({
|
||||
fulfilled: async (config) => {
|
||||
if (!apiFullBodyEncrypt) {
|
||||
return config;
|
||||
}
|
||||
const url = config.url ?? '';
|
||||
if (PLAIN_ROUTE_FRAGMENTS.some((f) => url.includes(f))) {
|
||||
return config;
|
||||
}
|
||||
if (config.data === undefined || config.data instanceof FormData) {
|
||||
return config;
|
||||
}
|
||||
const publicKey = await fetchPublicKeyForCrypto();
|
||||
const { encryptedKey, encryptedData, aesKeyB64 } = await encryptPayload(
|
||||
publicKey,
|
||||
config.data as Record<string, unknown>,
|
||||
);
|
||||
(config as CryptoRequestConfig).__aesKey = aesKeyB64;
|
||||
config.data = { encryptedKey, encryptedData };
|
||||
return config;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createRequestClient(baseURL: string, options?: RequestClientOptions) {
|
||||
const client = new RequestClient({
|
||||
...options,
|
||||
baseURL,
|
||||
});
|
||||
|
||||
// 全量加解密拦截器(生产模式):响应解密须最先注册。
|
||||
installCryptoInterceptors(client);
|
||||
|
||||
/**
|
||||
* 重新认证逻辑
|
||||
*/
|
||||
@ -46,13 +156,20 @@ function createRequestClient(baseURL: string, options?: RequestClientOptions) {
|
||||
|
||||
/**
|
||||
* 刷新token逻辑
|
||||
* 后端为旋转令牌:旧 refreshToken 单次有效,响应返回新令牌对。
|
||||
* baseRequestClient 为 raw 模式(axios response),body 在 resp.data,
|
||||
* 信封为 {code,message,data},令牌对在 resp.data.data。
|
||||
*/
|
||||
async function doRefreshToken() {
|
||||
const accessStore = useAccessStore();
|
||||
const resp = await refreshTokenApi();
|
||||
const newToken = resp.data;
|
||||
accessStore.setAccessToken(newToken);
|
||||
return newToken;
|
||||
const resp = await refreshTokenApi(accessStore.refreshToken ?? '');
|
||||
const pair = resp.data?.data;
|
||||
if (!pair?.accessToken) {
|
||||
throw new Error('refresh token response invalid');
|
||||
}
|
||||
accessStore.setAccessToken(pair.accessToken);
|
||||
accessStore.setRefreshToken(pair.refreshToken);
|
||||
return pair.accessToken;
|
||||
}
|
||||
|
||||
function formatToken(token: null | string) {
|
||||
@ -110,3 +227,5 @@ export const requestClient = createRequestClient(apiURL, {
|
||||
});
|
||||
|
||||
export const baseRequestClient = new RequestClient({ baseURL: apiURL });
|
||||
// 登录/刷新/登出等走 baseRequestClient 的请求同样需要全量加密(生产模式)。
|
||||
installCryptoInterceptors(baseRequestClient);
|
||||
|
||||
78
apps/web-tdesign/src/api/serverSecurity.ts
Normal file
78
apps/web-tdesign/src/api/serverSecurity.ts
Normal file
@ -0,0 +1,78 @@
|
||||
// 服务器安全日志模块 API 客户端。
|
||||
// 接口规范:全部 POST;URL 不含参数(查询/路径参数均禁止);入参一律走 body。
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
// ---------- 数据类型 ----------
|
||||
export interface SecurityLogItem {
|
||||
id: number;
|
||||
logTime: string;
|
||||
srcIp: string;
|
||||
srcPort: number;
|
||||
destPort: number;
|
||||
eventType: string;
|
||||
eventName: string;
|
||||
detail: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface SecurityLogListResp {
|
||||
list: SecurityLogItem[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface SecurityTopIp {
|
||||
srcIp: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface SecurityByType {
|
||||
eventType: string;
|
||||
eventName: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface SecurityLogStatsResp {
|
||||
total: number;
|
||||
failed: number;
|
||||
banned: number;
|
||||
accepted: number;
|
||||
topIps: SecurityTopIp[];
|
||||
byType: SecurityByType[];
|
||||
}
|
||||
|
||||
// ---------- 列表 ----------
|
||||
export interface SecurityLogQuery {
|
||||
page: number;
|
||||
size: number;
|
||||
srcIp?: string;
|
||||
eventType?: string;
|
||||
destPort?: number;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
}
|
||||
|
||||
export function getSecurityLogList(data: SecurityLogQuery) {
|
||||
return requestClient.post<SecurityLogListResp>(
|
||||
'/api/service/admin/server-security/log/list',
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- 统计 ----------
|
||||
export function getSecurityLogStats(data: {
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
}) {
|
||||
return requestClient.post<SecurityLogStatsResp>(
|
||||
'/api/service/admin/server-security/log/stats',
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- 事件类型选项(与后端 SecurityEventName 对应) ----------
|
||||
export const SECURITY_EVENT_OPTIONS = [
|
||||
{ label: 'SSH爆破尝试', value: 'failed_ssh' },
|
||||
{ label: 'SSH成功登录', value: 'accepted_ssh' },
|
||||
{ label: 'fail2ban封禁', value: 'banned' },
|
||||
{ label: 'fail2ban解封', value: 'unbanned' },
|
||||
];
|
||||
@ -1,11 +1,31 @@
|
||||
import { requestClient } from '#/api/request';
|
||||
import {
|
||||
apiFullBodyEncrypt,
|
||||
fetchPublicKeyForCrypto,
|
||||
requestClient,
|
||||
} from '#/api/request';
|
||||
import { hybridEncryptField } from '#/utils/crypto';
|
||||
|
||||
// ---------- 管理员(/api/service/admin/admin) ----------
|
||||
// ---------- 用户管理(/api/service/admin/admin) ----------
|
||||
|
||||
/**
|
||||
* 准备密码字段:
|
||||
* - 生产(全量加密):返回明文 password,由请求拦截器整体加密 body;
|
||||
* - 开发(仅密码加密):RSA+AES 混合加密为 {encryptedKey, encryptedData}。
|
||||
*/
|
||||
async function preparePasswordField(password: string) {
|
||||
if (apiFullBodyEncrypt) {
|
||||
return { password };
|
||||
}
|
||||
const publicKey = await fetchPublicKeyForCrypto();
|
||||
return hybridEncryptField(publicKey, password);
|
||||
}
|
||||
export interface AdminItem {
|
||||
id: number;
|
||||
username: string;
|
||||
nickname: string;
|
||||
status: number;
|
||||
barkDeviceId: string;
|
||||
pushplusToken: string;
|
||||
roleIds: number[];
|
||||
roleNames: string[];
|
||||
createdAt: string;
|
||||
@ -27,28 +47,41 @@ export function getAdminList(data: {
|
||||
);
|
||||
}
|
||||
|
||||
export function createAdmin(data: {
|
||||
export async function createAdmin(data: {
|
||||
barkDeviceId?: string;
|
||||
nickname: string;
|
||||
password: string;
|
||||
pushplusToken?: string;
|
||||
roleIds: number[];
|
||||
username: string;
|
||||
}) {
|
||||
return requestClient.post<{ id: number }>(
|
||||
'/api/service/admin/admin/create',
|
||||
data,
|
||||
);
|
||||
// 密码加密传输:生产整体加密 / 开发仅密码字段加密,其余字段明文。
|
||||
const pwdField = await preparePasswordField(data.password);
|
||||
return requestClient.post<{ id: number }>('/api/service/admin/admin/create', {
|
||||
...data,
|
||||
password: undefined,
|
||||
...pwdField,
|
||||
});
|
||||
}
|
||||
|
||||
export function updateAdmin(
|
||||
id: number,
|
||||
data: { nickname: string; roleIds: number[]; status: number; },
|
||||
data: {
|
||||
barkDeviceId?: string;
|
||||
nickname: string;
|
||||
pushplusToken?: string;
|
||||
roleIds: number[];
|
||||
status: number;
|
||||
},
|
||||
) {
|
||||
return requestClient.post(`/api/service/admin/admin/update/${id}`, data);
|
||||
}
|
||||
|
||||
export function resetAdminPassword(id: number, password: string) {
|
||||
export async function resetAdminPassword(id: number, password: string) {
|
||||
// 密码加密传输:生产整体加密 / 开发仅密码字段加密。
|
||||
const pwdField = await preparePasswordField(password);
|
||||
return requestClient.post(`/api/service/admin/admin/resetPwd/${id}`, {
|
||||
password,
|
||||
...pwdField,
|
||||
});
|
||||
}
|
||||
|
||||
@ -96,7 +129,7 @@ export function createRole(data: {
|
||||
|
||||
export function updateRole(
|
||||
id: number,
|
||||
data: { menuIds: number[]; name: string; status: number; },
|
||||
data: { menuIds: number[]; name: string; status: number },
|
||||
) {
|
||||
return requestClient.post(
|
||||
`/api/service/admin/system/role/update/${id}`,
|
||||
|
||||
@ -39,29 +39,13 @@ async function loadThirdPartyMessage(lang: SupportedLanguagesType) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载dayjs的语言包
|
||||
* 加载dayjs的语言包(固定中文)
|
||||
* @param lang
|
||||
*/
|
||||
async function loadDayjsLocale(lang: SupportedLanguagesType) {
|
||||
let locale;
|
||||
switch (lang) {
|
||||
case 'en-US': {
|
||||
locale = await import('dayjs/locale/en');
|
||||
break;
|
||||
}
|
||||
case 'zh-CN': {
|
||||
locale = await import('dayjs/locale/zh-cn');
|
||||
break;
|
||||
}
|
||||
// 默认使用英语
|
||||
default: {
|
||||
locale = await import('dayjs/locale/en');
|
||||
}
|
||||
}
|
||||
if (locale) {
|
||||
if (lang === 'zh-CN') {
|
||||
const locale = await import('dayjs/locale/zh-cn');
|
||||
dayjs.locale(locale);
|
||||
} else {
|
||||
console.error(`Failed to load dayjs locale for ${lang}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,13 +0,0 @@
|
||||
{
|
||||
"title": "Demos",
|
||||
"tdesign": "TDesign Vue",
|
||||
"vben": {
|
||||
"title": "Project",
|
||||
"about": "About",
|
||||
"document": "Document",
|
||||
"antdv": "Ant Design Vue Version",
|
||||
"antdv-next": "Antdv Next Version",
|
||||
"naive-ui": "Naive UI Version",
|
||||
"element-plus": "Element Plus Version"
|
||||
}
|
||||
}
|
||||
@ -1,15 +0,0 @@
|
||||
{
|
||||
"auth": {
|
||||
"login": "Login",
|
||||
"register": "Register",
|
||||
"codeLogin": "Code Login",
|
||||
"qrcodeLogin": "Qr Code Login",
|
||||
"forgetPassword": "Forget Password",
|
||||
"profile": "Profile"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"analytics": "Analytics",
|
||||
"workspace": "Workspace"
|
||||
}
|
||||
}
|
||||
@ -16,6 +16,8 @@ export const overridesPreferences = defineOverridesPreferences({
|
||||
accessMode: 'backend',
|
||||
// 后端暂未实现 refresh 接口,关闭 token 自动刷新
|
||||
enableRefreshToken: false,
|
||||
// 固定中文:移除国际化多语言,仅保留中文
|
||||
locale: 'zh-CN',
|
||||
},
|
||||
copyright: appCopyrightPreferences,
|
||||
});
|
||||
|
||||
@ -29,15 +29,17 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
params: Recordable<any>,
|
||||
onSuccess?: () => Promise<void> | void,
|
||||
) {
|
||||
// 异步处理用户登录操作并获取 accessToken
|
||||
// 异步处理用户登录操作并获取令牌对(accessToken + refreshToken)
|
||||
let userInfo: null | UserInfo = null;
|
||||
try {
|
||||
loginLoading.value = true;
|
||||
const { accessToken } = await loginApi(params);
|
||||
const { accessToken, refreshToken } = await loginApi(params);
|
||||
|
||||
// 如果成功获取到 accessToken
|
||||
if (accessToken) {
|
||||
accessStore.setAccessToken(accessToken);
|
||||
// 刷新令牌持久化(pinia persist),供 access token 过期时静默续期
|
||||
accessStore.setRefreshToken(refreshToken);
|
||||
// 获取用户信息并存储到 accessStore 中
|
||||
const [fetchUserInfoResult, accessCodes] = await Promise.all([
|
||||
fetchUserInfo(),
|
||||
@ -78,7 +80,9 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
|
||||
async function logout(redirect: boolean = true) {
|
||||
try {
|
||||
await logoutApi();
|
||||
// 携带 refreshToken 让后端撤销刷新会话(阻止该设备继续续期);
|
||||
// 后端幂等,即使令牌无效也返回成功,失败不阻塞本地登出。
|
||||
await logoutApi(accessStore.refreshToken ?? undefined);
|
||||
} catch {
|
||||
// 不做任何处理
|
||||
}
|
||||
|
||||
@ -1,19 +1,20 @@
|
||||
/**
|
||||
* 全局主题美化
|
||||
* 设计语言:靛蓝(indigo)主色 + 紫(violet)渐变点缀;柔和圆角;轻投影;有纵深的浅色背景。
|
||||
* 说明:Vben 的 useTDesignDesignTokens 会用 JS 把品牌色写到 documentElement 上,
|
||||
* 普通 :root 规则会被内联样式压过;因此对关键 --td-* 变量加 !important 强制生效。
|
||||
* 设计语言:跟随系统主题色(可在偏好设置中切换主题色);柔和圆角;轻投影;有纵深的浅色背景。
|
||||
* 说明:Vben 的 useTDesignDesignTokens 会用 JS 根据主题色动态把 --td-* 品牌色写到
|
||||
* documentElement 上,因此这里不再用固定色值覆盖品牌色,改为引用 --primary
|
||||
* 变量,保证切换主题色时整个系统颜色同步变化。
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* —— 品牌主色(靛蓝 #4f5bff → 紫 #7c3aed 渐变体系) —— */
|
||||
--td-brand-color: #4f5bff !important;
|
||||
--td-brand-color-hover: #6b75ff !important;
|
||||
--td-brand-color-active: #3a45e0 !important;
|
||||
--td-brand-color-disabled: #c2c7ff !important;
|
||||
--td-brand-color-light: #eef0ff !important;
|
||||
--td-brand-color-light-hover: #e0e3ff !important;
|
||||
--td-brand-color-light-active: #d6daff !important;
|
||||
/* —— 品牌主色:跟随系统主题色(--primary),支持在系统内切换 —— */
|
||||
--td-brand-color: var(--primary);
|
||||
--td-brand-color-hover: var(--primary-600);
|
||||
--td-brand-color-active: var(--primary-700);
|
||||
--td-brand-color-disabled: var(--primary-200);
|
||||
--td-brand-color-light: var(--primary-50);
|
||||
--td-brand-color-light-hover: var(--primary-100);
|
||||
--td-brand-color-light-active: var(--primary-200);
|
||||
|
||||
/* —— 圆角:更现代、更柔 —— */
|
||||
--td-radius-small: 6px !important;
|
||||
@ -49,12 +50,12 @@ body {
|
||||
background-color: #f5f7fb;
|
||||
background-image: radial-gradient(
|
||||
1200px 600px at 100% -10%,
|
||||
rgb(79 91 255 / 6%),
|
||||
color-mix(in srgb, var(--primary) 8%, transparent),
|
||||
transparent 60%
|
||||
),
|
||||
radial-gradient(
|
||||
900px 500px at -10% 110%,
|
||||
rgb(124 58 237 / 5%),
|
||||
color-mix(in srgb, var(--primary) 7%, transparent),
|
||||
transparent 55%
|
||||
);
|
||||
background-attachment: fixed;
|
||||
@ -94,20 +95,24 @@ body {
|
||||
background-color: #fafbff !important;
|
||||
}
|
||||
|
||||
/* 主按钮:带品牌渐变,更出彩 */
|
||||
/* 主按钮:带品牌渐变,更出彩(跟随主题色) */
|
||||
.t-button--theme-primary:not(.t-button--variant-outline):not(
|
||||
.t-button--variant-text
|
||||
):not(.t-button--variant-dashed) {
|
||||
background: linear-gradient(135deg, #4f5bff 0%, #7c3aed 100%);
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--primary) 0%,
|
||||
var(--primary-700) 100%
|
||||
);
|
||||
border: none;
|
||||
box-shadow: 0 4px 12px rgb(79 91 255 / 28%);
|
||||
box-shadow: 0 4px 12px color-mix(in srgb, var(--primary) 28%, transparent);
|
||||
transition: transform 0.12s ease, box-shadow 0.12s ease, filter 0.12s ease;
|
||||
}
|
||||
|
||||
.t-button--theme-primary:not(.t-button--variant-outline):not(
|
||||
.t-button--variant-text
|
||||
):not(.t-button--variant-dashed):hover {
|
||||
box-shadow: 0 6px 16px rgb(79 91 255 / 36%);
|
||||
box-shadow: 0 6px 16px color-mix(in srgb, var(--primary) 36%, transparent);
|
||||
filter: brightness(1.05);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
@ -142,12 +147,12 @@ html[theme-mode='dark'] body {
|
||||
background-color: #16171a;
|
||||
background-image: radial-gradient(
|
||||
1200px 600px at 100% -10%,
|
||||
rgb(79 91 255 / 12%),
|
||||
color-mix(in srgb, var(--primary) 14%, transparent),
|
||||
transparent 60%
|
||||
),
|
||||
radial-gradient(
|
||||
900px 500px at -10% 110%,
|
||||
rgb(124 58 237 / 10%),
|
||||
color-mix(in srgb, var(--primary) 12%, transparent),
|
||||
transparent 55%
|
||||
);
|
||||
}
|
||||
|
||||
213
apps/web-tdesign/src/utils/crypto.ts
Normal file
213
apps/web-tdesign/src/utils/crypto.ts
Normal file
@ -0,0 +1,213 @@
|
||||
/**
|
||||
* 登录密码「RSA + AES-GCM」混合加密工具(基于浏览器原生 Web Crypto API,零依赖)。
|
||||
*
|
||||
* 加密流程(对称+非对称结合):
|
||||
* 1. 随机生成 AES-256 会话密钥(对称加密,负责加密实际载荷);
|
||||
* 2. AES-GCM 加密 {username, password, ts},输出 base64(nonce || ciphertext);
|
||||
* 3. 用后端 RSA 公钥(RSA-OAEP/SHA-256,非对称加密)加密 AES 密钥,输出 base64。
|
||||
*
|
||||
* 注意:Web Crypto 仅在安全上下文可用(https 或 localhost / 127.0.0.1)。
|
||||
* 若以 http://IP 访问开发服务器,crypto.subtle 不可用,登录会给出明确错误提示。
|
||||
*/
|
||||
|
||||
/** 混合加密后的登录请求体 */
|
||||
export interface EncryptedLoginPayload {
|
||||
/** RSA-OAEP 加密的 AES-256 会话密钥(base64) */
|
||||
encryptedKey: string;
|
||||
/** AES-GCM 加密的明文载荷(base64,nonce 12B 前置) */
|
||||
encryptedData: string;
|
||||
}
|
||||
|
||||
/** 全量加密载荷:附带回传用会话密钥(base64 原始字节),供响应解密使用 */
|
||||
export interface EncryptedPayload extends EncryptedLoginPayload {
|
||||
/** AES-256 会话密钥原始字节(base64),仅前端持有,用于解密后端响应 */
|
||||
aesKeyB64: string;
|
||||
}
|
||||
|
||||
/** AES-GCM 推荐随机数长度(字节) */
|
||||
const AES_NONCE_SIZE = 12;
|
||||
/** 公钥内存缓存有效期(毫秒),避免每次登录都拉取 */
|
||||
const PUBKEY_TTL = 5 * 60 * 1000;
|
||||
|
||||
let cachedPubKey: null | { expireAt: number; pem: string } = null;
|
||||
|
||||
/** 缓存 RSA 公钥(5 分钟有效) */
|
||||
export function cachePublicKey(pem: string): void {
|
||||
cachedPubKey = { pem, expireAt: Date.now() + PUBKEY_TTL };
|
||||
}
|
||||
|
||||
/** 取缓存的公钥,过期返回 null */
|
||||
export function getCachedPublicKey(): null | string {
|
||||
if (cachedPubKey && Date.now() < cachedPubKey.expireAt) {
|
||||
return cachedPubKey.pem;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Uint8Array → base64(分段处理避免大数组栈溢出) */
|
||||
function bytesToBase64(bytes: Uint8Array): string {
|
||||
let bin = '';
|
||||
const chunk = 0x80_00;
|
||||
for (let i = 0; i < bytes.length; i += chunk) {
|
||||
bin += String.fromCodePoint(...bytes.subarray(i, i + chunk));
|
||||
}
|
||||
return btoa(bin);
|
||||
}
|
||||
|
||||
/** base64 → Uint8Array */
|
||||
function base64ToBytes(b64: string): Uint8Array {
|
||||
const bin = atob(b64);
|
||||
const bytes = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) {
|
||||
const code = bin.codePointAt(i);
|
||||
bytes[i] = code ?? 0;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/** 拼接两个字节数组 */
|
||||
function concatBytes(a: Uint8Array, b: Uint8Array): Uint8Array {
|
||||
const out = new Uint8Array(a.length + b.length);
|
||||
out.set(a, 0);
|
||||
out.set(b, a.length);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 解析 PEM 公钥为 Web Crypto CryptoKey(SPKI / RSA-OAEP / SHA-256) */
|
||||
async function importPublicKey(pem: string): Promise<CryptoKey> {
|
||||
const pemBody = pem
|
||||
.replaceAll('-----BEGIN PUBLIC KEY-----', '')
|
||||
.replaceAll('-----END PUBLIC KEY-----', '')
|
||||
.replaceAll(/\s+/g, '');
|
||||
const der = base64ToBytes(pemBody);
|
||||
return crypto.subtle.importKey(
|
||||
'spki',
|
||||
der.buffer as ArrayBuffer,
|
||||
{ name: 'RSA-OAEP', hash: 'SHA-256' },
|
||||
false,
|
||||
['encrypt'],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 混合加密核心实现:RSA 公钥加密随机 AES 密钥 + AES-GCM 加密 JSON 载荷。
|
||||
* @param publicKeyPem 后端下发的 RSA 公钥(PEM 字符串)
|
||||
* @param payloadObj 需加密的明文对象(含 ts 毫秒时间戳,后端做防重放校验)
|
||||
* @returns { encryptedKey, encryptedData, aesKeyB64 } 密文 + 会话密钥(响应解密用)
|
||||
*/
|
||||
export async function encryptPayload(
|
||||
publicKeyPem: string,
|
||||
payloadObj: Record<string, unknown>,
|
||||
): Promise<EncryptedPayload> {
|
||||
if (!globalThis.crypto?.subtle) {
|
||||
throw new Error(
|
||||
'当前环境不支持 Web Crypto(请使用 https 或 localhost 访问)',
|
||||
);
|
||||
}
|
||||
// 1. 随机生成 AES-256 会话密钥并导出原始字节(供 RSA 加密)
|
||||
const aesKey = await crypto.subtle.generateKey(
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
true,
|
||||
['encrypt'],
|
||||
);
|
||||
const aesRaw = new Uint8Array(await crypto.subtle.exportKey('raw', aesKey));
|
||||
|
||||
// 2. AES-GCM 加密载荷:密文结构 = nonce(12B) || ciphertext||tag,GCM 自带完整性校验
|
||||
const nonce = crypto.getRandomValues(new Uint8Array(AES_NONCE_SIZE));
|
||||
const payload = new TextEncoder().encode(JSON.stringify(payloadObj));
|
||||
const cipher = new Uint8Array(
|
||||
await crypto.subtle.encrypt(
|
||||
{ name: 'AES-GCM', iv: nonce },
|
||||
aesKey,
|
||||
payload,
|
||||
),
|
||||
);
|
||||
const encryptedData = bytesToBase64(concatBytes(nonce, cipher));
|
||||
|
||||
// 3. RSA-OAEP(SHA-256) 公钥加密 AES 密钥
|
||||
const publicKey = await importPublicKey(publicKeyPem);
|
||||
const encKey = new Uint8Array(
|
||||
await crypto.subtle.encrypt({ name: 'RSA-OAEP' }, publicKey, aesRaw),
|
||||
);
|
||||
|
||||
return {
|
||||
encryptedKey: bytesToBase64(encKey),
|
||||
encryptedData,
|
||||
aesKeyB64: bytesToBase64(aesRaw),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 解密后端响应密文(AES-256-GCM):密文结构 = base64(nonce(12B) || ciphertext||tag)。
|
||||
* @param aesKeyB64 请求加密时生成的会话密钥(base64 原始字节)
|
||||
* @param encryptedDataB64 后端返回的密文
|
||||
* @returns 解密后的 JSON 对象(统一响应信封 {code,message,data})
|
||||
*/
|
||||
export async function decryptPayload(
|
||||
aesKeyB64: string,
|
||||
encryptedDataB64: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
if (!globalThis.crypto?.subtle) {
|
||||
throw new Error(
|
||||
'当前环境不支持 Web Crypto(请使用 https 或 localhost 访问)',
|
||||
);
|
||||
}
|
||||
const aesKey = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
base64ToBytes(aesKeyB64).buffer as ArrayBuffer,
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
false,
|
||||
['decrypt'],
|
||||
);
|
||||
const data = base64ToBytes(encryptedDataB64);
|
||||
if (data.length <= AES_NONCE_SIZE) {
|
||||
throw new Error('响应密文格式非法');
|
||||
}
|
||||
// 拷贝为独立 ArrayBuffer 视图(subarray 可能携带 SharedArrayBuffer 泛型,不满足 BufferSource)
|
||||
const nonce = new Uint8Array(data.subarray(0, AES_NONCE_SIZE));
|
||||
const cipher = new Uint8Array(data.subarray(AES_NONCE_SIZE));
|
||||
const plain = await crypto.subtle.decrypt(
|
||||
{ name: 'AES-GCM', iv: nonce },
|
||||
aesKey,
|
||||
cipher,
|
||||
);
|
||||
return JSON.parse(new TextDecoder().decode(plain)) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 混合加密登录载荷:RSA 公钥加密随机 AES 密钥 + AES-GCM 加密明文。
|
||||
* @param publicKeyPem 后端下发的 RSA 公钥(PEM 字符串)
|
||||
* @param username 登录用户名(明文,仅存在于加密载荷中)
|
||||
* @param password 登录密码(明文,仅存在于加密载荷中)
|
||||
* @returns 可直接提交登录接口的密文请求体
|
||||
*/
|
||||
export async function hybridEncryptLogin(
|
||||
publicKeyPem: string,
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<EncryptedLoginPayload> {
|
||||
const { encryptedKey, encryptedData } = await encryptPayload(publicKeyPem, {
|
||||
username,
|
||||
password,
|
||||
ts: Date.now(),
|
||||
});
|
||||
return { encryptedKey, encryptedData };
|
||||
}
|
||||
|
||||
/**
|
||||
* 混合加密单个字段(创建管理员/重置密码等场景复用):
|
||||
* 载荷 JSON 结构 {"value":"...","ts":...},与后端 DecryptField 对应。
|
||||
* @param publicKeyPem 后端下发的 RSA 公钥(PEM 字符串)
|
||||
* @param value 需加密的字段值(如新密码)
|
||||
* @returns 可直接提交的密文请求体
|
||||
*/
|
||||
export async function hybridEncryptField(
|
||||
publicKeyPem: string,
|
||||
value: string,
|
||||
): Promise<EncryptedLoginPayload> {
|
||||
const { encryptedKey, encryptedData } = await encryptPayload(publicKeyPem, {
|
||||
value,
|
||||
ts: Date.now(),
|
||||
});
|
||||
return { encryptedKey, encryptedData };
|
||||
}
|
||||
@ -1,14 +1,16 @@
|
||||
<script lang="ts" setup>
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { LoginLogItem, LogItem, MenuNode } from '#/api/system';
|
||||
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { computed, h, onMounted, reactive, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
import { useUserStore } from '@vben/stores';
|
||||
|
||||
import { MessagePlugin } from 'tdesign-vue-next';
|
||||
import { MessagePlugin, Tag } from 'tdesign-vue-next';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import {
|
||||
getAdminList,
|
||||
getLoginLogList,
|
||||
@ -91,32 +93,66 @@ const quickNavs = [
|
||||
},
|
||||
{
|
||||
title: '操作日志',
|
||||
path: '/system/log/operation',
|
||||
path: '/log/operation',
|
||||
icon: ICONS.log,
|
||||
gradient: 'linear-gradient(135deg,#8b5cf6,#a855f7)',
|
||||
},
|
||||
{
|
||||
title: '登录日志',
|
||||
path: '/system/log/login',
|
||||
path: '/log/login',
|
||||
icon: ICONS.login,
|
||||
gradient: 'linear-gradient(135deg,#f59e0b,#fbbf24)',
|
||||
},
|
||||
];
|
||||
|
||||
const opColumns = [
|
||||
{ colKey: 'adminId', title: '操作人ID', width: 90 },
|
||||
{ colKey: 'permission', title: '权限码', ellipsis: true },
|
||||
{ colKey: 'path', title: '接口', ellipsis: true },
|
||||
{ colKey: 'ip', title: 'IP', width: 130 },
|
||||
{ colKey: 'createdAt', title: '时间', width: 170 },
|
||||
];
|
||||
// 最新操作日志(本地数据小表:关闭 proxyConfig,数据由 load() 写入)
|
||||
const opGridOptions: VxeTableGridOptions = {
|
||||
columns: [
|
||||
{ type: 'seq', width: 50, title: '#' },
|
||||
{ field: 'adminId', title: '操作人ID', width: 90 },
|
||||
{ field: 'permission', title: '权限码', showOverflow: 'tooltip' },
|
||||
{ field: 'path', title: '接口', showOverflow: 'tooltip' },
|
||||
{ field: 'ip', title: 'IP', width: 130 },
|
||||
{ field: 'createdAt', title: '时间', width: 170 },
|
||||
],
|
||||
pagerConfig: { enabled: false }, // 仅展示最新 5 条,不分页
|
||||
proxyConfig: { enabled: false }, // 本地数据模式
|
||||
};
|
||||
|
||||
const loginColumns = [
|
||||
{ colKey: 'username', title: '账号', width: 120 },
|
||||
{ colKey: 'ip', title: 'IP', width: 130 },
|
||||
{ colKey: 'statusText', title: '结果', width: 90 },
|
||||
{ colKey: 'createdAt', title: '时间', width: 170 },
|
||||
];
|
||||
const [OpGrid, opGridApi] = useVbenVxeGrid({ gridOptions: opGridOptions });
|
||||
|
||||
// 最新登录日志(本地数据小表)
|
||||
const loginGridOptions: VxeTableGridOptions = {
|
||||
columns: [
|
||||
{ type: 'seq', width: 50, title: '#' },
|
||||
{ field: 'username', title: '账号', width: 120 },
|
||||
{ field: 'ip', title: 'IP', width: 130 },
|
||||
{
|
||||
field: 'statusText',
|
||||
title: '结果',
|
||||
width: 90,
|
||||
slots: {
|
||||
default: ({ row }: { row: LoginLogItem }) =>
|
||||
h(
|
||||
Tag,
|
||||
{
|
||||
theme: row.status === 1 ? 'success' : 'danger',
|
||||
variant: 'light',
|
||||
size: 'small',
|
||||
},
|
||||
row.status === 1 ? '成功' : '失败',
|
||||
),
|
||||
},
|
||||
},
|
||||
{ field: 'createdAt', title: '时间', width: 170 },
|
||||
],
|
||||
pagerConfig: { enabled: false },
|
||||
proxyConfig: { enabled: false },
|
||||
};
|
||||
|
||||
const [LoginGrid, loginGridApi] = useVbenVxeGrid({
|
||||
gridOptions: loginGridOptions,
|
||||
});
|
||||
|
||||
function formatToday() {
|
||||
const d = new Date();
|
||||
@ -155,9 +191,13 @@ async function load() {
|
||||
if (opRes.status === 'fulfilled') {
|
||||
stats.opLogCount = opRes.value.total ?? 0;
|
||||
opLogs.value = opRes.value.list ?? [];
|
||||
// 本地数据模式:直接写入表格
|
||||
opGridApi.setGridOptions({ data: opLogs.value });
|
||||
}
|
||||
if (loginRes.status === 'fulfilled')
|
||||
if (loginRes.status === 'fulfilled') {
|
||||
loginLogs.value = loginRes.value.list ?? [];
|
||||
loginGridApi.setGridOptions({ data: loginLogs.value });
|
||||
}
|
||||
} catch {
|
||||
MessagePlugin.warning('部分数据加载失败');
|
||||
} finally {
|
||||
@ -224,33 +264,10 @@ onMounted(load);
|
||||
<section class="wb-grid">
|
||||
<div class="wb-col">
|
||||
<t-card title="最新操作日志" :bordered="false" class="wb-card">
|
||||
<t-table
|
||||
row-key="id"
|
||||
:data="opLogs"
|
||||
:columns="opColumns"
|
||||
:loading="loading"
|
||||
size="small"
|
||||
:pagination="false"
|
||||
/>
|
||||
<OpGrid />
|
||||
</t-card>
|
||||
<t-card title="最新登录日志" :bordered="false" class="wb-card">
|
||||
<t-table
|
||||
row-key="id"
|
||||
:data="loginLogs"
|
||||
:columns="loginColumns"
|
||||
:loading="loading"
|
||||
size="small"
|
||||
:pagination="false"
|
||||
>
|
||||
<template #statusText="{ row }">
|
||||
<t-tag
|
||||
:theme="row.status === 1 ? 'success' : 'danger'"
|
||||
variant="light"
|
||||
>
|
||||
{{ row.status === 1 ? '成功' : '失败' }}
|
||||
</t-tag>
|
||||
</template>
|
||||
</t-table>
|
||||
<LoginGrid />
|
||||
</t-card>
|
||||
</div>
|
||||
|
||||
@ -315,11 +332,7 @@ onMounted(load);
|
||||
width: 360px;
|
||||
height: 360px;
|
||||
pointer-events: none;
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
rgb(255 255 255 / 35%),
|
||||
transparent 65%
|
||||
);
|
||||
background: radial-gradient(circle, rgb(255 255 255 / 35%), transparent 65%);
|
||||
border-radius: 50%;
|
||||
filter: blur(8px);
|
||||
animation: wb-float 9s ease-in-out infinite;
|
||||
|
||||
@ -1,13 +1,15 @@
|
||||
<script lang="ts" setup>
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { CommunityItem } from '#/api/house';
|
||||
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import { h, reactive, ref } from 'vue';
|
||||
|
||||
import { useAccess } from '@vben/access';
|
||||
import { Page } from '@vben/common-ui';
|
||||
import { Page, useVbenDrawer, VbenTableAction } from '@vben/common-ui';
|
||||
|
||||
import { DialogPlugin, MessagePlugin } from 'tdesign-vue-next';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import {
|
||||
createCommunity,
|
||||
deleteCommunity,
|
||||
@ -15,21 +17,32 @@ import {
|
||||
updateCommunity,
|
||||
} from '#/api/house';
|
||||
|
||||
// 小区管理:列表 + 新增/编辑弹窗 + 删除(软删除)
|
||||
// 小区管理:列表 + 新增/编辑抽屉 + 删除(软删除)
|
||||
const { hasAccessByCodes } = useAccess();
|
||||
|
||||
const loading = ref(false);
|
||||
const list = ref<CommunityItem[]>([]);
|
||||
const dialogOpen = ref(false);
|
||||
const editing = ref<null | CommunityItem>(null);
|
||||
const editing = ref<CommunityItem | null>(null);
|
||||
|
||||
// 新增/编辑小区抽屉(vben 封装抽屉组件)
|
||||
const [Drawer, drawerApi] = useVbenDrawer({
|
||||
title: '新增小区',
|
||||
onConfirm: submit,
|
||||
});
|
||||
|
||||
const regionOptions = [
|
||||
'云岩区', '南明区', '观山湖区', '花溪区', '乌当区', '白云区',
|
||||
'清镇市', '修文县', '开阳县', '息烽县',
|
||||
'云岩区',
|
||||
'南明区',
|
||||
'观山湖区',
|
||||
'花溪区',
|
||||
'乌当区',
|
||||
'白云区',
|
||||
'清镇市',
|
||||
'修文县',
|
||||
'开阳县',
|
||||
'息烽县',
|
||||
].map((r) => ({ label: r, value: r }));
|
||||
|
||||
const query = reactive({ page: 1, size: 10, keyword: '', region: '' });
|
||||
const pagination = reactive({ current: 1, pageSize: 10, total: 0 });
|
||||
// 筛选条件(分页由 grid 自动管理)
|
||||
const query = reactive({ keyword: '', region: '' });
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
@ -47,88 +60,139 @@ const form = reactive({
|
||||
developer: '',
|
||||
});
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await getCommunityList(query);
|
||||
list.value = data.list ?? [];
|
||||
pagination.total = data.total ?? 0;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editing.value = null;
|
||||
Object.assign(form, {
|
||||
name: '', region: '', businessDistrict: '', address: '', lng: 0, lat: 0,
|
||||
buildYear: 0, households: 0, plotRatio: 0, greenRate: 0,
|
||||
propertyCompany: '', propertyFee: 0, developer: '',
|
||||
name: '',
|
||||
region: '',
|
||||
businessDistrict: '',
|
||||
address: '',
|
||||
lng: 0,
|
||||
lat: 0,
|
||||
buildYear: 0,
|
||||
households: 0,
|
||||
plotRatio: 0,
|
||||
greenRate: 0,
|
||||
propertyCompany: '',
|
||||
propertyFee: 0,
|
||||
developer: '',
|
||||
});
|
||||
dialogOpen.value = true;
|
||||
drawerApi.setState({ title: '新增小区' });
|
||||
drawerApi.open();
|
||||
}
|
||||
|
||||
function openEdit(row: CommunityItem) {
|
||||
editing.value = row;
|
||||
Object.assign(form, { ...row });
|
||||
dialogOpen.value = true;
|
||||
drawerApi.setState({ title: '编辑小区' });
|
||||
drawerApi.open();
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (editing.value) {
|
||||
await updateCommunity(editing.value.id, form);
|
||||
} else {
|
||||
await createCommunity(form);
|
||||
}
|
||||
// 编辑走更新、新增走创建
|
||||
editing.value
|
||||
? await updateCommunity(editing.value.id, form)
|
||||
: await createCommunity(form);
|
||||
MessagePlugin.success('保存成功');
|
||||
dialogOpen.value = false;
|
||||
load();
|
||||
drawerApi.close();
|
||||
gridApi.query();
|
||||
}
|
||||
|
||||
/** 删除小区(保留原确认弹窗行为) */
|
||||
function onDelete(row: CommunityItem) {
|
||||
const d = DialogPlugin.confirm({
|
||||
header: `确认删除「${row.name}」?`,
|
||||
onConfirm: async () => {
|
||||
await deleteCommunity(row.id);
|
||||
MessagePlugin.success('删除成功');
|
||||
load();
|
||||
gridApi.query();
|
||||
d.destroy();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ colKey: 'id', title: 'ID', width: 70 },
|
||||
{ colKey: 'name', title: '小区名称', width: 180 },
|
||||
{ colKey: 'region', title: '区县', width: 100 },
|
||||
{ colKey: 'businessDistrict', title: '板块', width: 120 },
|
||||
{ colKey: 'address', title: '地址' },
|
||||
{ colKey: 'buildYear', title: '建成年份', width: 90 },
|
||||
{ colKey: 'propertyCompany', title: '物业', width: 140 },
|
||||
{ colKey: 'op', title: '操作', width: 160 },
|
||||
];
|
||||
const gridOptions: VxeTableGridOptions = {
|
||||
columns: [
|
||||
{ type: 'seq', width: 50, title: '#' },
|
||||
{ field: 'id', title: 'ID', width: 70 },
|
||||
{ field: 'name', title: '小区名称', width: 180 },
|
||||
{ field: 'region', title: '区县', width: 100 },
|
||||
{ field: 'businessDistrict', title: '板块', width: 120 },
|
||||
{ field: 'address', title: '地址', showOverflow: 'tooltip' },
|
||||
{ field: 'buildYear', title: '建成年份', width: 90 },
|
||||
{ field: 'propertyCompany', title: '物业', width: 140 },
|
||||
{
|
||||
field: 'op',
|
||||
title: '操作',
|
||||
width: 160,
|
||||
fixed: 'right',
|
||||
slots: {
|
||||
default: ({ row }: { row: CommunityItem }) =>
|
||||
h(VbenTableAction, {
|
||||
actions: [
|
||||
// 编辑:按权限码显隐
|
||||
{
|
||||
text: '编辑',
|
||||
onClick: () => openEdit(row),
|
||||
auth: ['house:community:update'],
|
||||
},
|
||||
// 删除(危险操作标红)
|
||||
{
|
||||
text: '删除',
|
||||
danger: true,
|
||||
onClick: () => onDelete(row),
|
||||
auth: ['house:community:delete'],
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
pagerConfig: { pageSize: 10 },
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({
|
||||
page,
|
||||
}: {
|
||||
page: { currentPage: number; pageSize: number };
|
||||
}) => {
|
||||
const data = await getCommunityList({
|
||||
page: page.currentPage,
|
||||
size: page.pageSize,
|
||||
keyword: query.keyword || undefined,
|
||||
region: query.region || undefined,
|
||||
});
|
||||
return { items: data.list ?? [], total: data.total ?? 0 };
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function onPageChange(p: { current: number; pageSize: number }) {
|
||||
query.page = p.current;
|
||||
query.size = p.pageSize;
|
||||
pagination.current = p.current;
|
||||
pagination.pageSize = p.pageSize;
|
||||
load();
|
||||
const [Grid, gridApi] = useVbenVxeGrid({ gridOptions });
|
||||
|
||||
/** 触发查询(保留分页条件,因 grid 自动管理分页) */
|
||||
function search() {
|
||||
gridApi.query();
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
/** 重置筛选并回到第一页重新查询 */
|
||||
function reset() {
|
||||
query.keyword = '';
|
||||
query.region = '';
|
||||
gridApi.reload();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<t-input
|
||||
v-model="query.keyword"
|
||||
clearable
|
||||
placeholder="小区名称"
|
||||
style="width: 200px"
|
||||
@enter="load"
|
||||
@enter="search"
|
||||
/>
|
||||
<t-select
|
||||
v-model="query.region"
|
||||
@ -137,7 +201,8 @@ onMounted(load);
|
||||
placeholder="区县"
|
||||
style="width: 140px"
|
||||
/>
|
||||
<t-button theme="primary" @click="load">查询</t-button>
|
||||
<t-button theme="primary" @click="search">查询</t-button>
|
||||
<t-button variant="outline" @click="reset">重置</t-button>
|
||||
</div>
|
||||
<t-button
|
||||
v-if="hasAccessByCodes(['house:community:create'])"
|
||||
@ -148,49 +213,22 @@ onMounted(load);
|
||||
</t-button>
|
||||
</div>
|
||||
|
||||
<t-table
|
||||
row-key="id"
|
||||
:data="list"
|
||||
:columns="columns"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
@page-change="onPageChange"
|
||||
>
|
||||
<template #op="{ row }">
|
||||
<t-button
|
||||
v-if="hasAccessByCodes(['house:community:update'])"
|
||||
size="small"
|
||||
theme="primary"
|
||||
variant="text"
|
||||
@click="openEdit(row)"
|
||||
>
|
||||
编辑
|
||||
</t-button>
|
||||
<t-button
|
||||
v-if="hasAccessByCodes(['house:community:delete'])"
|
||||
size="small"
|
||||
theme="danger"
|
||||
variant="text"
|
||||
@click="onDelete(row)"
|
||||
>
|
||||
删除
|
||||
</t-button>
|
||||
</template>
|
||||
</t-table>
|
||||
<!-- 小区列表(Vben Vxe Table + TableAction) -->
|
||||
<Grid />
|
||||
|
||||
<t-dialog
|
||||
v-model:visible="dialogOpen"
|
||||
:header="editing ? '编辑小区' : '新增小区'"
|
||||
width="640px"
|
||||
:on-confirm="submit"
|
||||
>
|
||||
<!-- 新增/编辑小区抽屉(vben 封装抽屉组件) -->
|
||||
<Drawer class="w-160">
|
||||
<t-form label-align="right" label-width="90px">
|
||||
<div class="grid grid-cols-2 gap-x-4">
|
||||
<t-form-item label="小区名称" required>
|
||||
<t-input v-model="form.name" placeholder="如:中天未来方舟" />
|
||||
</t-form-item>
|
||||
<t-form-item label="区县">
|
||||
<t-select v-model="form.region" clearable :options="regionOptions" />
|
||||
<t-select
|
||||
v-model="form.region"
|
||||
clearable
|
||||
:options="regionOptions"
|
||||
/>
|
||||
</t-form-item>
|
||||
<t-form-item label="板块">
|
||||
<t-input v-model="form.businessDistrict" placeholder="如:会展城" />
|
||||
@ -208,13 +246,21 @@ onMounted(load);
|
||||
<t-input v-model="form.address" />
|
||||
</t-form-item>
|
||||
<t-form-item label="经度">
|
||||
<t-input-number v-model="form.lng" theme="normal" :decimal-places="6" />
|
||||
<t-input-number
|
||||
v-model="form.lng"
|
||||
theme="normal"
|
||||
:decimal-places="6"
|
||||
/>
|
||||
</t-form-item>
|
||||
<t-form-item label="纬度">
|
||||
<t-input-number v-model="form.lat" theme="normal" :decimal-places="6" />
|
||||
<t-input-number
|
||||
v-model="form.lat"
|
||||
theme="normal"
|
||||
:decimal-places="6"
|
||||
/>
|
||||
</t-form-item>
|
||||
</div>
|
||||
</t-form>
|
||||
</t-dialog>
|
||||
</Drawer>
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
@ -1,16 +1,14 @@
|
||||
<script lang="ts" setup>
|
||||
import type { TransactionItem } from '#/api/house';
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import { reactive } from 'vue';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { getTransactionList } from '#/api/house';
|
||||
|
||||
// 数据明细:成交记录列表(价格快照/配套/学区划片在后续阶段接入)
|
||||
const loading = ref(false);
|
||||
const list = ref<TransactionItem[]>([]);
|
||||
|
||||
// 数据明细:成交记录列表(Vben Vxe Table,价格快照/配套/学区划片在后续阶段接入)
|
||||
const regionOptions = [
|
||||
'云岩区',
|
||||
'南明区',
|
||||
@ -24,60 +22,66 @@ const regionOptions = [
|
||||
'息烽县',
|
||||
].map((r) => ({ label: r, value: r }));
|
||||
|
||||
const query = reactive({ page: 1, size: 10, keyword: '', region: '' });
|
||||
const pagination = reactive({ current: 1, pageSize: 10, total: 0 });
|
||||
// 筛选条件(分页由 grid 自动管理)
|
||||
const query = reactive({ keyword: '', region: '' });
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await getTransactionList(query);
|
||||
list.value = data.list ?? [];
|
||||
pagination.total = data.total ?? 0;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
const gridOptions: VxeTableGridOptions = {
|
||||
columns: [
|
||||
{ type: 'seq', width: 50, title: '#' },
|
||||
{ field: 'id', title: 'ID', width: 70 },
|
||||
{ field: 'communityName', title: '小区', width: 180 },
|
||||
{ field: 'layout', title: '户型', width: 100 },
|
||||
{ field: 'area', title: '面积㎡', width: 90 },
|
||||
{ field: 'dealPrice', title: '成交价(万)', width: 110 },
|
||||
{ field: 'dealUnitPrice', title: '成交单价', width: 110 },
|
||||
{ field: 'listDays', title: '挂牌天数', width: 90 },
|
||||
{ field: 'dealDate', title: '成交日期', width: 120 },
|
||||
{ field: 'source', title: '来源', width: 90 },
|
||||
],
|
||||
pagerConfig: { pageSize: 10 },
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({
|
||||
page,
|
||||
}: {
|
||||
page: { currentPage: number; pageSize: number };
|
||||
}) => {
|
||||
const data = await getTransactionList({
|
||||
page: page.currentPage,
|
||||
size: page.pageSize,
|
||||
keyword: query.keyword,
|
||||
region: query.region,
|
||||
});
|
||||
return { items: data.list ?? [], total: data.total ?? 0 };
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({ gridOptions });
|
||||
|
||||
/** 触发查询(保留分页条件,因 grid 自动管理分页) */
|
||||
function search() {
|
||||
gridApi.query();
|
||||
}
|
||||
|
||||
/** 重置筛选并回到第一页重新查询 */
|
||||
function reset() {
|
||||
query.keyword = '';
|
||||
query.region = '';
|
||||
query.page = 1;
|
||||
pagination.current = 1;
|
||||
load();
|
||||
gridApi.reload();
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ colKey: 'id', title: 'ID', width: 70 },
|
||||
{ colKey: 'communityName', title: '小区', width: 180 },
|
||||
{ colKey: 'layout', title: '户型', width: 100 },
|
||||
{ colKey: 'area', title: '面积㎡', width: 90 },
|
||||
{ colKey: 'dealPrice', title: '成交价(万)', width: 110 },
|
||||
{ colKey: 'dealUnitPrice', title: '成交单价', width: 110 },
|
||||
{ colKey: 'listDays', title: '挂牌天数', width: 90 },
|
||||
{ colKey: 'dealDate', title: '成交日期', width: 120 },
|
||||
{ colKey: 'source', title: '来源', width: 90 },
|
||||
];
|
||||
|
||||
function onPageChange(p: { current: number; pageSize: number }) {
|
||||
query.page = p.current;
|
||||
query.size = p.pageSize;
|
||||
pagination.current = p.current;
|
||||
pagination.pageSize = p.pageSize;
|
||||
load();
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<div class="mb-3 flex items-center gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<t-input
|
||||
v-model="query.keyword"
|
||||
clearable
|
||||
placeholder="小区名/户型"
|
||||
style="width: 200px"
|
||||
@enter="load"
|
||||
@enter="search"
|
||||
/>
|
||||
<t-select
|
||||
v-model="query.region"
|
||||
@ -86,17 +90,11 @@ onMounted(load);
|
||||
placeholder="区县"
|
||||
style="width: 140px"
|
||||
/>
|
||||
<t-button theme="primary" @click="load">查询</t-button>
|
||||
<t-button theme="primary" @click="search">查询</t-button>
|
||||
<t-button variant="outline" @click="reset">重置</t-button>
|
||||
</div>
|
||||
|
||||
<t-table
|
||||
row-key="id"
|
||||
:data="list"
|
||||
:columns="columns"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
@page-change="onPageChange"
|
||||
/>
|
||||
<!-- 成交记录列表(Vben Vxe Table) -->
|
||||
<Grid />
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
@ -1,13 +1,15 @@
|
||||
<script lang="ts" setup>
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { ListingItem } from '#/api/house';
|
||||
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import { h, reactive, ref } from 'vue';
|
||||
|
||||
import { useAccess } from '@vben/access';
|
||||
import { Page } from '@vben/common-ui';
|
||||
import { Page, useVbenDrawer, VbenTableAction } from '@vben/common-ui';
|
||||
|
||||
import { DialogPlugin, MessagePlugin } from 'tdesign-vue-next';
|
||||
import { DialogPlugin, MessagePlugin, Tag } from 'tdesign-vue-next';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import {
|
||||
batchMarkListing,
|
||||
deleteListing,
|
||||
@ -18,20 +20,29 @@ import {
|
||||
// 房源管理:多维筛选 + 多选批量标记(笋盘/低可信/状态)+ 编辑/删除
|
||||
const { hasAccessByCodes } = useAccess();
|
||||
|
||||
const loading = ref(false);
|
||||
const list = ref<ListingItem[]>([]);
|
||||
const selectedKeys = ref<number[]>([]);
|
||||
const dialogOpen = ref(false);
|
||||
const editing = ref<null | ListingItem>(null);
|
||||
const editing = ref<ListingItem | null>(null);
|
||||
|
||||
// 编辑房源抽屉(vben 封装抽屉组件)
|
||||
const [Drawer, drawerApi] = useVbenDrawer({
|
||||
title: '编辑房源',
|
||||
onConfirm: submit,
|
||||
});
|
||||
|
||||
const regionOptions = [
|
||||
'云岩区', '南明区', '观山湖区', '花溪区', '乌当区', '白云区',
|
||||
'清镇市', '修文县', '开阳县', '息烽县',
|
||||
'云岩区',
|
||||
'南明区',
|
||||
'观山湖区',
|
||||
'花溪区',
|
||||
'乌当区',
|
||||
'白云区',
|
||||
'清镇市',
|
||||
'修文县',
|
||||
'开阳县',
|
||||
'息烽县',
|
||||
].map((r) => ({ label: r, value: r }));
|
||||
|
||||
// 筛选条件(分页由 grid 自动管理)
|
||||
const query = reactive({
|
||||
page: 1,
|
||||
size: 10,
|
||||
keyword: '',
|
||||
region: '',
|
||||
layout: '',
|
||||
@ -42,47 +53,28 @@ const query = reactive({
|
||||
isBargain: 0,
|
||||
confidence: 0,
|
||||
});
|
||||
const pagination = reactive({ current: 1, pageSize: 10, total: 0 });
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await getListingList(query);
|
||||
list.value = data.list ?? [];
|
||||
pagination.total = data.total ?? 0;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
Object.assign(query, {
|
||||
keyword: '', region: '', layout: '', source: '',
|
||||
priceMin: undefined, priceMax: undefined, status: 0, isBargain: 0, confidence: 0,
|
||||
});
|
||||
query.page = 1;
|
||||
pagination.current = 1;
|
||||
load();
|
||||
}
|
||||
|
||||
function onSelectChange(keys: Array<string | number>) {
|
||||
selectedKeys.value = keys.map(Number);
|
||||
/** 收集当前勾选的房源 ID */
|
||||
function getSelectedIds(): number[] {
|
||||
const records = (gridApi.grid?.getCheckboxRecords() ?? []) as ListingItem[];
|
||||
return records.map((r) => Number(r.id));
|
||||
}
|
||||
|
||||
/** 批量标记(笋盘/低可信/状态) */
|
||||
async function onBatchMark(field: string, value: number) {
|
||||
if (selectedKeys.value.length === 0) {
|
||||
const ids = getSelectedIds();
|
||||
if (ids.length === 0) {
|
||||
MessagePlugin.warning('请先勾选房源');
|
||||
return;
|
||||
}
|
||||
await batchMarkListing({ ids: selectedKeys.value, field, value });
|
||||
await batchMarkListing({ ids, field, value });
|
||||
MessagePlugin.success('标记成功');
|
||||
load();
|
||||
gridApi.query();
|
||||
}
|
||||
|
||||
function openEdit(row: ListingItem) {
|
||||
editing.value = row;
|
||||
dialogOpen.value = true;
|
||||
drawerApi.open();
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
@ -97,57 +89,186 @@ async function submit() {
|
||||
});
|
||||
}
|
||||
MessagePlugin.success('保存成功');
|
||||
dialogOpen.value = false;
|
||||
load();
|
||||
drawerApi.close();
|
||||
gridApi.query();
|
||||
}
|
||||
|
||||
/** 删除房源(保留原确认弹窗行为) */
|
||||
function onDelete(row: ListingItem) {
|
||||
const d = DialogPlugin.confirm({
|
||||
header: '确认删除该房源?',
|
||||
onConfirm: async () => {
|
||||
await deleteListing(row.id);
|
||||
MessagePlugin.success('删除成功');
|
||||
load();
|
||||
gridApi.query();
|
||||
d.destroy();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ colKey: 'row-select', type: 'multiple', width: 46 },
|
||||
{ colKey: 'id', title: 'ID', width: 70 },
|
||||
{ colKey: 'communityName', title: '小区', width: 160 },
|
||||
{ colKey: 'layout', title: '户型', width: 100 },
|
||||
{ colKey: 'area', title: '面积㎡', width: 90 },
|
||||
{ colKey: 'totalPrice', title: '总价(万)', width: 100 },
|
||||
{ colKey: 'unitPrice', title: '单价', width: 100 },
|
||||
{ colKey: 'source', title: '来源', width: 90 },
|
||||
{ colKey: 'onMarketDays', title: '挂牌天数', width: 90 },
|
||||
{ colKey: 'statusText', title: '状态', width: 80 },
|
||||
{ colKey: 'flagText', title: '标记', width: 140 },
|
||||
{ colKey: 'op', title: '操作', width: 140 },
|
||||
];
|
||||
|
||||
function onPageChange(p: { current: number; pageSize: number }) {
|
||||
query.page = p.current;
|
||||
query.size = p.pageSize;
|
||||
pagination.current = p.current;
|
||||
pagination.pageSize = p.pageSize;
|
||||
load();
|
||||
/** 状态码 -> TDesign 标签主题色(1 在售 / 2 下架 / 3 成交) */
|
||||
function statusTheme(status: number) {
|
||||
if (status === 1) return 'success';
|
||||
if (status === 2) return 'default';
|
||||
return 'primary';
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
/** 状态码 -> 文案 */
|
||||
function statusText(status: number) {
|
||||
if (status === 1) return '在售';
|
||||
if (status === 2) return '下架';
|
||||
return '成交';
|
||||
}
|
||||
|
||||
const gridOptions: VxeTableGridOptions = {
|
||||
columns: [
|
||||
{ type: 'checkbox', width: 46 }, // 多选列(配合批量标记)
|
||||
{ field: 'id', title: 'ID', width: 70 },
|
||||
{ field: 'communityName', title: '小区', width: 160 },
|
||||
{ field: 'layout', title: '户型', width: 100 },
|
||||
{ field: 'area', title: '面积㎡', width: 90 },
|
||||
{ field: 'totalPrice', title: '总价(万)', width: 100 },
|
||||
{ field: 'unitPrice', title: '单价', width: 100 },
|
||||
{ field: 'source', title: '来源', width: 90 },
|
||||
{ field: 'onMarketDays', title: '挂牌天数', width: 90 },
|
||||
{
|
||||
field: 'statusText',
|
||||
title: '状态',
|
||||
width: 80,
|
||||
slots: {
|
||||
default: ({ row }: { row: ListingItem }) =>
|
||||
h(
|
||||
Tag,
|
||||
{
|
||||
theme: statusTheme(row.status),
|
||||
variant: 'light',
|
||||
size: 'small',
|
||||
},
|
||||
statusText(row.status),
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'flagText',
|
||||
title: '标记',
|
||||
width: 140,
|
||||
slots: {
|
||||
default: ({ row }: { row: ListingItem }) => {
|
||||
const tags: ReturnType<typeof h>[] = [];
|
||||
if (row.isBargain === 1) {
|
||||
tags.push(
|
||||
h(
|
||||
Tag,
|
||||
{ theme: 'warning', variant: 'light', size: 'small' },
|
||||
'笋盘',
|
||||
),
|
||||
);
|
||||
}
|
||||
if (row.confidence === 1) {
|
||||
tags.push(
|
||||
h(
|
||||
Tag,
|
||||
{ theme: 'danger', variant: 'light', size: 'small' },
|
||||
'低可信',
|
||||
),
|
||||
);
|
||||
}
|
||||
return h(
|
||||
'span',
|
||||
{ class: 'flex items-center justify-center gap-1' },
|
||||
{ default: () => tags },
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'op',
|
||||
title: '操作',
|
||||
width: 140,
|
||||
fixed: 'right',
|
||||
slots: {
|
||||
default: ({ row }: { row: ListingItem }) =>
|
||||
h(VbenTableAction, {
|
||||
actions: [
|
||||
// 编辑:按权限码显隐
|
||||
{
|
||||
text: '编辑',
|
||||
onClick: () => openEdit(row),
|
||||
auth: ['house:listing:update'],
|
||||
},
|
||||
// 删除(危险操作标红)
|
||||
{
|
||||
text: '删除',
|
||||
danger: true,
|
||||
onClick: () => onDelete(row),
|
||||
auth: ['house:listing:delete'],
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
checkboxConfig: { highlight: true }, // 勾选行高亮,便于批量标记定位
|
||||
pagerConfig: { pageSize: 10 },
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({
|
||||
page,
|
||||
}: {
|
||||
page: { currentPage: number; pageSize: number };
|
||||
}) => {
|
||||
const data = await getListingList({
|
||||
page: page.currentPage,
|
||||
size: page.pageSize,
|
||||
keyword: query.keyword || undefined,
|
||||
region: query.region || undefined,
|
||||
layout: query.layout || undefined,
|
||||
source: query.source || undefined,
|
||||
priceMin: query.priceMin || undefined,
|
||||
priceMax: query.priceMax || undefined,
|
||||
status: query.status || undefined,
|
||||
isBargain: query.isBargain || undefined,
|
||||
confidence: query.confidence || undefined,
|
||||
});
|
||||
return { items: data.list ?? [], total: data.total ?? 0 };
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({ gridOptions });
|
||||
|
||||
/** 触发查询(保留分页条件,因 grid 自动管理分页) */
|
||||
function search() {
|
||||
gridApi.query();
|
||||
}
|
||||
|
||||
/** 重置筛选并回到第一页重新查询 */
|
||||
function reset() {
|
||||
Object.assign(query, {
|
||||
keyword: '',
|
||||
region: '',
|
||||
layout: '',
|
||||
source: '',
|
||||
priceMin: undefined,
|
||||
priceMax: undefined,
|
||||
status: 0,
|
||||
isBargain: 0,
|
||||
confidence: 0,
|
||||
});
|
||||
gridApi.reload();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<div class="mb-3 flex flex-wrap items-center gap-2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<t-input
|
||||
v-model="query.keyword"
|
||||
clearable
|
||||
placeholder="房号/户型"
|
||||
style="width: 160px"
|
||||
@enter="load"
|
||||
@enter="search"
|
||||
/>
|
||||
<t-select
|
||||
v-model="query.region"
|
||||
@ -184,7 +305,7 @@ onMounted(load);
|
||||
]"
|
||||
style="width: 110px"
|
||||
/>
|
||||
<t-button theme="primary" @click="load">查询</t-button>
|
||||
<t-button theme="primary" @click="search">查询</t-button>
|
||||
<t-button variant="outline" @click="reset">重置</t-button>
|
||||
<span class="ml-auto flex items-center gap-2">
|
||||
<t-button
|
||||
@ -204,59 +325,11 @@ onMounted(load);
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<t-table
|
||||
row-key="id"
|
||||
:data="list"
|
||||
:columns="columns"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
:selected-row-keys="selectedKeys"
|
||||
@page-change="onPageChange"
|
||||
@select-change="onSelectChange"
|
||||
>
|
||||
<template #statusText="{ row }">
|
||||
<t-tag
|
||||
:theme="row.status === 1 ? 'success' : row.status === 2 ? 'default' : 'primary'"
|
||||
variant="light"
|
||||
size="small"
|
||||
>
|
||||
{{ row.status === 1 ? '在售' : row.status === 2 ? '下架' : '成交' }}
|
||||
</t-tag>
|
||||
</template>
|
||||
<template #flagText="{ row }">
|
||||
<span class="flex items-center gap-1">
|
||||
<t-tag v-if="row.isBargain === 1" theme="warning" variant="light" size="small">笋盘</t-tag>
|
||||
<t-tag v-if="row.confidence === 1" theme="danger" variant="light" size="small">低可信</t-tag>
|
||||
</span>
|
||||
</template>
|
||||
<template #op="{ row }">
|
||||
<t-button
|
||||
v-if="hasAccessByCodes(['house:listing:update'])"
|
||||
size="small"
|
||||
theme="primary"
|
||||
variant="text"
|
||||
@click="openEdit(row)"
|
||||
>
|
||||
编辑
|
||||
</t-button>
|
||||
<t-button
|
||||
v-if="hasAccessByCodes(['house:listing:delete'])"
|
||||
size="small"
|
||||
theme="danger"
|
||||
variant="text"
|
||||
@click="onDelete(row)"
|
||||
>
|
||||
删除
|
||||
</t-button>
|
||||
</template>
|
||||
</t-table>
|
||||
<!-- 房源列表(Vben Vxe Table + TableAction) -->
|
||||
<Grid />
|
||||
|
||||
<t-dialog
|
||||
v-model:visible="dialogOpen"
|
||||
header="编辑房源"
|
||||
width="560px"
|
||||
:on-confirm="submit"
|
||||
>
|
||||
<!-- 编辑房源抽屉(vben 封装抽屉组件) -->
|
||||
<Drawer class="w-150">
|
||||
<t-form v-if="editing" label-align="right" label-width="90px">
|
||||
<div class="grid grid-cols-2 gap-x-4">
|
||||
<t-form-item label="挂牌价(万)">
|
||||
@ -286,6 +359,6 @@ onMounted(load);
|
||||
</t-form-item>
|
||||
</div>
|
||||
</t-form>
|
||||
</t-dialog>
|
||||
</Drawer>
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
@ -1,16 +1,14 @@
|
||||
<script lang="ts" setup>
|
||||
import type { PresaleItem } from '#/api/house';
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import { reactive } from 'vue';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { getPresaleList } from '#/api/house';
|
||||
|
||||
// 新房预售:预售许可证列表(数据源:贵阳市住建局预售公示)
|
||||
const loading = ref(false);
|
||||
const list = ref<PresaleItem[]>([]);
|
||||
|
||||
// 新房预售:预售许可证列表(Vben Vxe Table,数据源:贵阳市住建局预售公示)
|
||||
const regionOptions = [
|
||||
'云岩区',
|
||||
'南明区',
|
||||
@ -30,68 +28,74 @@ const purposeOptions = [
|
||||
{ label: '商业', value: '商业' },
|
||||
];
|
||||
|
||||
const query = reactive({
|
||||
page: 1,
|
||||
size: 10,
|
||||
keyword: '',
|
||||
region: '',
|
||||
purpose: '',
|
||||
});
|
||||
const pagination = reactive({ current: 1, pageSize: 10, total: 0 });
|
||||
// 筛选条件(分页由 grid 自动管理)
|
||||
const query = reactive({ keyword: '', region: '', purpose: '' });
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await getPresaleList(query);
|
||||
list.value = data.list ?? [];
|
||||
pagination.total = data.total ?? 0;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
const gridOptions: VxeTableGridOptions = {
|
||||
columns: [
|
||||
{ type: 'seq', width: 50, title: '#' },
|
||||
{ field: 'presaleNo', title: '预售证号', width: 190 },
|
||||
{ field: 'communityName', title: '楼盘/项目名', width: 200 },
|
||||
{
|
||||
field: 'developer',
|
||||
title: '开发商',
|
||||
width: 200,
|
||||
showOverflow: 'tooltip',
|
||||
},
|
||||
{ field: 'region', title: '区县', width: 100 },
|
||||
{ field: 'address', title: '位置', width: 220, showOverflow: 'tooltip' },
|
||||
{ field: 'buildingNo', title: '楼栋号', width: 110 },
|
||||
{ field: 'houseCount', title: '套数', width: 70 },
|
||||
{ field: 'area', title: '面积㎡', width: 100 },
|
||||
{ field: 'purpose', title: '用途', width: 80 },
|
||||
{ field: 'issueDate', title: '发证日期', width: 120 },
|
||||
],
|
||||
pagerConfig: { pageSize: 10 },
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({
|
||||
page,
|
||||
}: {
|
||||
page: { currentPage: number; pageSize: number };
|
||||
}) => {
|
||||
const data = await getPresaleList({
|
||||
page: page.currentPage,
|
||||
size: page.pageSize,
|
||||
keyword: query.keyword || undefined,
|
||||
region: query.region || undefined,
|
||||
purpose: query.purpose || undefined,
|
||||
});
|
||||
return { items: data.list ?? [], total: data.total ?? 0 };
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({ gridOptions });
|
||||
|
||||
/** 触发查询(保留分页条件,因 grid 自动管理分页) */
|
||||
function search() {
|
||||
gridApi.query();
|
||||
}
|
||||
|
||||
/** 重置筛选并回到第一页重新查询 */
|
||||
function reset() {
|
||||
query.keyword = '';
|
||||
query.region = '';
|
||||
query.purpose = '';
|
||||
query.page = 1;
|
||||
pagination.current = 1;
|
||||
load();
|
||||
gridApi.reload();
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ colKey: 'presaleNo', title: '预售证号', width: 190 },
|
||||
{ colKey: 'communityName', title: '楼盘/项目名', width: 200 },
|
||||
{ colKey: 'developer', title: '开发商', width: 200 },
|
||||
{ colKey: 'region', title: '区县', width: 100 },
|
||||
{ colKey: 'address', title: '位置', width: 220, ellipsis: true },
|
||||
{ colKey: 'buildingNo', title: '楼栋号', width: 110 },
|
||||
{ colKey: 'houseCount', title: '套数', width: 70 },
|
||||
{ colKey: 'area', title: '面积㎡', width: 100 },
|
||||
{ colKey: 'purpose', title: '用途', width: 80 },
|
||||
{ colKey: 'issueDate', title: '发证日期', width: 120 },
|
||||
];
|
||||
|
||||
function onPageChange(p: { current: number; pageSize: number }) {
|
||||
query.page = p.current;
|
||||
query.size = p.pageSize;
|
||||
pagination.current = p.current;
|
||||
pagination.pageSize = p.pageSize;
|
||||
load();
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<div class="mb-3 flex items-center gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<t-input
|
||||
v-model="query.keyword"
|
||||
clearable
|
||||
placeholder="楼盘名/开发商/预售证号"
|
||||
style="width: 240px"
|
||||
@enter="load"
|
||||
@enter="search"
|
||||
/>
|
||||
<t-select
|
||||
v-model="query.region"
|
||||
@ -107,17 +111,11 @@ onMounted(load);
|
||||
placeholder="用途"
|
||||
style="width: 120px"
|
||||
/>
|
||||
<t-button theme="primary" @click="load">查询</t-button>
|
||||
<t-button theme="primary" @click="search">查询</t-button>
|
||||
<t-button variant="outline" @click="reset">重置</t-button>
|
||||
</div>
|
||||
|
||||
<t-table
|
||||
row-key="id"
|
||||
:data="list"
|
||||
:columns="columns"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
@page-change="onPageChange"
|
||||
/>
|
||||
<!-- 预售许可列表(Vben Vxe Table) -->
|
||||
<Grid />
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
200
apps/web-tdesign/src/views/monitor/security/index.vue
Normal file
200
apps/web-tdesign/src/views/monitor/security/index.vue
Normal file
@ -0,0 +1,200 @@
|
||||
<script lang="ts" setup>
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { SecurityLogStatsResp } from '#/api/serverSecurity';
|
||||
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import {
|
||||
getSecurityLogList,
|
||||
getSecurityLogStats,
|
||||
SECURITY_EVENT_OPTIONS,
|
||||
} from '#/api/serverSecurity';
|
||||
|
||||
// 服务器安全日志:展示宿主机采集上报的 SSH 爆破/登录/fail2ban 封禁记录
|
||||
const query = reactive({
|
||||
srcIp: '',
|
||||
eventType: '',
|
||||
dateRange: [] as string[],
|
||||
});
|
||||
|
||||
// 统计卡片(默认最近24小时)
|
||||
const stats = ref<SecurityLogStatsResp>({
|
||||
total: 0,
|
||||
failed: 0,
|
||||
banned: 0,
|
||||
accepted: 0,
|
||||
topIps: [],
|
||||
byType: [],
|
||||
});
|
||||
|
||||
/** 把日期数组转成后端时间范围(HH:MM:SS 补齐) */
|
||||
function rangeToParam(): { dateFrom?: string; dateTo?: string } {
|
||||
if (
|
||||
query.dateRange.length === 2 &&
|
||||
query.dateRange[0] &&
|
||||
query.dateRange[1]
|
||||
) {
|
||||
return {
|
||||
dateFrom: `${query.dateRange[0]} 00:00:00`,
|
||||
dateTo: `${query.dateRange[1]} 23:59:59`,
|
||||
};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
stats.value = (await getSecurityLogStats(
|
||||
rangeToParam(),
|
||||
)) as SecurityLogStatsResp;
|
||||
} catch {
|
||||
// 统计失败不阻塞列表
|
||||
}
|
||||
}
|
||||
|
||||
const gridOptions: VxeTableGridOptions = {
|
||||
columns: [
|
||||
{ type: 'seq', width: 50, title: '#' },
|
||||
{ field: 'logTime', title: '时间', width: 165 },
|
||||
{ field: 'srcIp', title: '来源IP', width: 140 },
|
||||
{ field: 'srcPort', title: '来源端口', width: 100 },
|
||||
{ field: 'destPort', title: '目标端口', width: 100 },
|
||||
{
|
||||
field: 'eventName',
|
||||
title: '事件类型',
|
||||
width: 130,
|
||||
slots: { default: 'event_type' },
|
||||
},
|
||||
{
|
||||
field: 'detail',
|
||||
title: '日志详情',
|
||||
minWidth: 320,
|
||||
showOverflow: 'tooltip',
|
||||
},
|
||||
],
|
||||
pagerConfig: { pageSize: 20 },
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({
|
||||
page,
|
||||
}: {
|
||||
page: { currentPage: number; pageSize: number };
|
||||
}) => {
|
||||
const data = await getSecurityLogList({
|
||||
page: page.currentPage,
|
||||
size: page.pageSize,
|
||||
srcIp: query.srcIp || undefined,
|
||||
eventType: query.eventType || undefined,
|
||||
...rangeToParam(),
|
||||
});
|
||||
return { items: data.list ?? [], total: data.total ?? 0 };
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({ gridOptions });
|
||||
|
||||
function search() {
|
||||
loadStats();
|
||||
gridApi.query();
|
||||
}
|
||||
|
||||
function reset() {
|
||||
query.srcIp = '';
|
||||
query.eventType = '';
|
||||
query.dateRange = [];
|
||||
loadStats();
|
||||
gridApi.reload();
|
||||
}
|
||||
|
||||
onMounted(loadStats);
|
||||
|
||||
// 事件类型徽标配色(爆破红/封禁橙/登录绿/解封蓝)
|
||||
const typeColor: Record<string, string> = {
|
||||
failed_ssh: 'danger',
|
||||
banned: 'warning',
|
||||
accepted_ssh: 'success',
|
||||
unbanned: 'primary',
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<!-- 统计卡片 -->
|
||||
<div class="mb-3 grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
<t-card :bordered="false" theme="danger">
|
||||
<div class="text-sm text-gray-500">SSH爆破尝试</div>
|
||||
<div class="mt-1 text-2xl font-bold">{{ stats.failed }}</div>
|
||||
</t-card>
|
||||
<t-card :bordered="false" theme="warning">
|
||||
<div class="text-sm text-gray-500">fail2ban封禁</div>
|
||||
<div class="mt-1 text-2xl font-bold">{{ stats.banned }}</div>
|
||||
</t-card>
|
||||
<t-card :bordered="false" theme="success">
|
||||
<div class="text-sm text-gray-500">SSH成功登录</div>
|
||||
<div class="mt-1 text-2xl font-bold">{{ stats.accepted }}</div>
|
||||
</t-card>
|
||||
<t-card :bordered="false">
|
||||
<div class="text-sm text-gray-500">日志总数</div>
|
||||
<div class="mt-1 text-2xl font-bold">{{ stats.total }}</div>
|
||||
</t-card>
|
||||
</div>
|
||||
|
||||
<!-- 筛选 -->
|
||||
<div class="mb-3 flex flex-wrap items-center gap-2">
|
||||
<t-input
|
||||
v-model="query.srcIp"
|
||||
clearable
|
||||
placeholder="来源IP"
|
||||
style="width: 160px"
|
||||
@enter="search"
|
||||
/>
|
||||
<t-select
|
||||
v-model="query.eventType"
|
||||
clearable
|
||||
:options="SECURITY_EVENT_OPTIONS"
|
||||
placeholder="事件类型"
|
||||
style="width: 150px"
|
||||
/>
|
||||
<t-date-range-picker
|
||||
v-model="query.dateRange"
|
||||
clearable
|
||||
placeholder="发生时间范围"
|
||||
style="width: 260px"
|
||||
/>
|
||||
<t-button theme="primary" @click="search">查询</t-button>
|
||||
<t-button variant="outline" @click="reset">重置</t-button>
|
||||
</div>
|
||||
|
||||
<!-- TOP 攻击源 -->
|
||||
<t-card v-if="stats.topIps.length > 0" :bordered="false" class="mb-3">
|
||||
<template #header>
|
||||
<span class="font-medium">TOP 攻击来源(爆破+封禁)</span>
|
||||
</template>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<div
|
||||
v-for="(ip, idx) in stats.topIps.slice(0, 8)"
|
||||
:key="ip.srcIp"
|
||||
class="flex items-center gap-2 rounded border border-gray-200 px-3 py-1.5 dark:border-gray-600"
|
||||
>
|
||||
<span class="text-gray-400">#{{ idx + 1 }}</span>
|
||||
<span class="font-mono">{{ ip.srcIp }}</span>
|
||||
<t-tag theme="danger" variant="light">{{ ip.count }}次</t-tag>
|
||||
</div>
|
||||
</div>
|
||||
</t-card>
|
||||
|
||||
<!-- 日志列表 -->
|
||||
<Grid>
|
||||
<template #event_type="{ row }">
|
||||
<t-tag :theme="typeColor[row.eventType] || 'default'" variant="light">
|
||||
{{ row.eventName }}
|
||||
</t-tag>
|
||||
</template>
|
||||
</Grid>
|
||||
</Page>
|
||||
</template>
|
||||
@ -1,31 +1,27 @@
|
||||
<script lang="ts" setup>
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { SourceItem } from '#/api/recruitment';
|
||||
|
||||
// 数据源与抓取运维:数据源列表 + 手动触发抓取 + 运行摘要。
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { h, onMounted, ref } from 'vue';
|
||||
|
||||
import { useAccess } from '@vben/access';
|
||||
import { Page } from '@vben/common-ui';
|
||||
import { Page, VbenTableAction } from '@vben/common-ui';
|
||||
|
||||
import { DialogPlugin, MessagePlugin } from 'tdesign-vue-next';
|
||||
import { DialogPlugin, MessagePlugin, Tag } from 'tdesign-vue-next';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { getSourceList, triggerCrawl } from '#/api/recruitment';
|
||||
|
||||
const { hasAccessByCodes } = useAccess();
|
||||
|
||||
const loading = ref(false);
|
||||
const list = ref<SourceItem[]>([]);
|
||||
const crawling = ref(false);
|
||||
const runningSummary = ref('');
|
||||
|
||||
/** 拉取数据源列表并写入本地数据表格 */
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await getSourceList();
|
||||
list.value = data.list ?? [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
const data = await getSourceList();
|
||||
gridApi.setGridOptions({ data: data.list ?? [] });
|
||||
}
|
||||
|
||||
function onTriggerAll() {
|
||||
@ -55,23 +51,86 @@ async function doTrigger(sourceId: number, force: boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ colKey: 'id', title: 'ID', width: 70 },
|
||||
{ colKey: 'name', title: '数据源', minWidth: 200 },
|
||||
{ colKey: 'region', title: '地区', width: 100 },
|
||||
{ colKey: 'baseUrl', title: '地址', minWidth: 220, ellipsis: true },
|
||||
{ colKey: 'enabled', title: '启用', width: 80 },
|
||||
{ colKey: 'lastSuccessAt', title: '上次成功', width: 160 },
|
||||
{ colKey: 'failCount', title: '连续失败', width: 90 },
|
||||
{ colKey: 'op', title: '操作', width: 100, fixed: 'right' },
|
||||
];
|
||||
// 数据源表格(本地数据模式:关闭 proxyConfig,数据由 load() 写入)
|
||||
const gridOptions: VxeTableGridOptions = {
|
||||
columns: [
|
||||
{ type: 'seq', width: 50, title: '#' },
|
||||
{ field: 'id', title: 'ID', width: 70 },
|
||||
{ field: 'name', title: '数据源', minWidth: 200 },
|
||||
{ field: 'region', title: '地区', width: 100 },
|
||||
{ field: 'baseUrl', title: '地址', minWidth: 220, showOverflow: 'tooltip' },
|
||||
{
|
||||
field: 'enabled',
|
||||
title: '启用',
|
||||
width: 80,
|
||||
slots: {
|
||||
default: ({ row }: { row: SourceItem }) =>
|
||||
h(
|
||||
Tag,
|
||||
{
|
||||
theme: row.enabled === 1 ? 'success' : 'default',
|
||||
variant: 'light',
|
||||
size: 'small',
|
||||
},
|
||||
row.enabled === 1 ? '已启用' : '已停用',
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'lastSuccessAt',
|
||||
title: '上次成功',
|
||||
width: 160,
|
||||
slots: {
|
||||
default: ({ row }: { row: SourceItem }) =>
|
||||
h('span', row.lastSuccessAt || '—'),
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'failCount',
|
||||
title: '连续失败',
|
||||
width: 90,
|
||||
slots: {
|
||||
default: ({ row }: { row: SourceItem }) =>
|
||||
h(
|
||||
'span',
|
||||
{ class: row.failCount > 0 ? 'text-red-500' : '' },
|
||||
String(row.failCount),
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'op',
|
||||
title: '操作',
|
||||
width: 100,
|
||||
fixed: 'right',
|
||||
slots: {
|
||||
default: ({ row }: { row: SourceItem }) =>
|
||||
h(VbenTableAction, {
|
||||
actions: [
|
||||
// 单源手动抓取:停用状态禁用,按权限码显隐
|
||||
{
|
||||
text: '抓取',
|
||||
onClick: () => onTriggerOne(row),
|
||||
auth: ['recruitment:crawler:trigger'],
|
||||
disabled: row.enabled !== 1,
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
pagerConfig: { enabled: false }, // 数据源通常较少,不分页
|
||||
proxyConfig: { enabled: false }, // 本地数据模式
|
||||
};
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({ gridOptions });
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<div class="mb-3 flex items-center gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<t-button
|
||||
v-if="hasAccessByCodes(['recruitment:crawler:trigger'])"
|
||||
theme="primary"
|
||||
@ -83,38 +142,8 @@ onMounted(load);
|
||||
<span class="text-xs opacity-60">定时任务:每日 03:00 自动抓取</span>
|
||||
</div>
|
||||
|
||||
<t-table row-key="id" :data="list" :columns="columns" :loading="loading">
|
||||
<template #enabled="{ row }">
|
||||
<t-tag
|
||||
:theme="row.enabled === 1 ? 'success' : 'default'"
|
||||
variant="light"
|
||||
size="small"
|
||||
>
|
||||
{{ row.enabled === 1 ? '已启用' : '已停用' }}
|
||||
</t-tag>
|
||||
</template>
|
||||
<template #lastSuccessAt="{ row }">
|
||||
{{ row.lastSuccessAt || '—' }}
|
||||
</template>
|
||||
<template #failCount="{ row }">
|
||||
<span :class="row.failCount > 0 ? 'text-[#E34D59]' : ''">{{
|
||||
row.failCount
|
||||
}}</span>
|
||||
</template>
|
||||
<template #op="{ row }">
|
||||
<t-button
|
||||
v-if="hasAccessByCodes(['recruitment:crawler:trigger'])"
|
||||
size="small"
|
||||
theme="primary"
|
||||
variant="text"
|
||||
:disabled="row.enabled !== 1"
|
||||
:loading="crawling"
|
||||
@click="onTriggerOne(row)"
|
||||
>
|
||||
抓取
|
||||
</t-button>
|
||||
</template>
|
||||
</t-table>
|
||||
<!-- 数据源列表(Vben Vxe Table) -->
|
||||
<Grid />
|
||||
|
||||
<div v-if="runningSummary" class="mt-4 rounded-lg border p-3">
|
||||
<p class="mb-1 text-sm font-medium">本次运行摘要</p>
|
||||
|
||||
@ -1,13 +1,15 @@
|
||||
<script lang="ts" setup>
|
||||
// 招聘公告列表:多维筛选 + 表格 + 详情抽屉。
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
// 招聘公告列表:多维筛选 + Vben Vxe Table + Vben TableAction 行操作 + 详情抽屉。
|
||||
import type { RecruitmentItem } from '#/api/recruitment';
|
||||
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import { h, reactive, ref } from 'vue';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
import { Page, VbenTableAction } from '@vben/common-ui';
|
||||
|
||||
import { MessagePlugin } from 'tdesign-vue-next';
|
||||
import { MessagePlugin, Tag } from 'tdesign-vue-next';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import {
|
||||
CATEGORY_OPTIONS,
|
||||
getRecruitmentDetail,
|
||||
@ -16,18 +18,13 @@ import {
|
||||
STATUS_OPTIONS,
|
||||
} from '#/api/recruitment';
|
||||
|
||||
const loading = ref(false);
|
||||
const list = ref<RecruitmentItem[]>([]);
|
||||
const pagination = reactive({ current: 1, pageSize: 10, total: 0 });
|
||||
|
||||
// 详情抽屉
|
||||
const detailOpen = ref(false);
|
||||
const detailLoading = ref(false);
|
||||
const detail = ref<null | RecruitmentItem>(null);
|
||||
|
||||
// 筛选条件(分页由 grid 自动管理)
|
||||
const query = reactive({
|
||||
page: 1,
|
||||
size: 10,
|
||||
region: '',
|
||||
category: 0,
|
||||
keyword: '',
|
||||
@ -39,29 +36,94 @@ const query = reactive({
|
||||
onlyNewToday: false,
|
||||
});
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await getRecruitmentList({
|
||||
page: query.page,
|
||||
size: query.size,
|
||||
region: query.region || undefined,
|
||||
category: query.category || undefined,
|
||||
keyword: query.keyword || undefined,
|
||||
status: query.status < 0 ? undefined : query.status,
|
||||
dateFrom: query.dateFrom || undefined,
|
||||
dateTo: query.dateTo || undefined,
|
||||
orgName: query.orgName || undefined,
|
||||
sourceId: query.sourceId || undefined,
|
||||
onlyNewToday: query.onlyNewToday || undefined,
|
||||
});
|
||||
list.value = data.list ?? [];
|
||||
pagination.total = data.total ?? 0;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
// 表格列定义(statusName/op 用 slots 函数式渲染)
|
||||
/** 状态码 -> TDesign 标签主题色(0 招集中 / 1 报名中 / 其余默认) */
|
||||
function statusTheme(status: number) {
|
||||
if (status === 0) return 'success';
|
||||
if (status === 1) return 'warning';
|
||||
return 'default';
|
||||
}
|
||||
|
||||
const gridOptions: VxeTableGridOptions = {
|
||||
columns: [
|
||||
{ type: 'seq', width: 50, title: '#' },
|
||||
{ field: 'id', title: 'ID', width: 70 },
|
||||
{ field: 'title', title: '标题', minWidth: 260, showOverflow: 'tooltip' },
|
||||
{ field: 'categoryName', title: '分类', width: 90 },
|
||||
{
|
||||
field: 'orgName',
|
||||
title: '发布单位',
|
||||
width: 180,
|
||||
showOverflow: 'tooltip',
|
||||
},
|
||||
{ field: 'region', title: '地区', width: 90 },
|
||||
{ field: 'publishDate', title: '发布日期', width: 110 },
|
||||
{ field: 'deadline', title: '截止', width: 110 },
|
||||
{
|
||||
field: 'statusName',
|
||||
title: '状态',
|
||||
width: 90,
|
||||
slots: {
|
||||
default: ({ row }: { row: RecruitmentItem }) =>
|
||||
h(
|
||||
Tag,
|
||||
{
|
||||
theme: statusTheme(row.status),
|
||||
variant: 'light',
|
||||
size: 'small',
|
||||
},
|
||||
row.statusName,
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'op',
|
||||
title: '操作',
|
||||
width: 90,
|
||||
fixed: 'right',
|
||||
slots: {
|
||||
default: ({ row }: { row: RecruitmentItem }) =>
|
||||
h(VbenTableAction, {
|
||||
actions: [{ text: '详情', onClick: () => openDetail(row) }],
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
pagerConfig: { pageSize: 10 },
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({
|
||||
page,
|
||||
}: {
|
||||
page: { currentPage: number; pageSize: number };
|
||||
}) => {
|
||||
const data = await getRecruitmentList({
|
||||
page: page.currentPage,
|
||||
size: page.pageSize,
|
||||
region: query.region || undefined,
|
||||
category: query.category || undefined,
|
||||
keyword: query.keyword || undefined,
|
||||
status: query.status < 0 ? undefined : query.status,
|
||||
dateFrom: query.dateFrom || undefined,
|
||||
dateTo: query.dateTo || undefined,
|
||||
orgName: query.orgName || undefined,
|
||||
sourceId: query.sourceId || undefined,
|
||||
onlyNewToday: query.onlyNewToday || undefined,
|
||||
});
|
||||
return { items: data.list ?? [], total: data.total ?? 0 };
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({ gridOptions });
|
||||
|
||||
/** 触发查询(保留分页条件,因 grid 自动管理分页) */
|
||||
function search() {
|
||||
gridApi.query();
|
||||
}
|
||||
|
||||
/** 重置筛选并回到第一页重新查询 */
|
||||
function reset() {
|
||||
Object.assign(query, {
|
||||
region: '',
|
||||
@ -74,17 +136,7 @@ function reset() {
|
||||
sourceId: 0,
|
||||
onlyNewToday: false,
|
||||
});
|
||||
query.page = 1;
|
||||
pagination.current = 1;
|
||||
load();
|
||||
}
|
||||
|
||||
function onPageChange(p: { current: number; pageSize: number }) {
|
||||
query.page = p.current;
|
||||
query.size = p.pageSize;
|
||||
pagination.current = p.current;
|
||||
pagination.pageSize = p.pageSize;
|
||||
load();
|
||||
gridApi.reload();
|
||||
}
|
||||
|
||||
async function openDetail(row: RecruitmentItem) {
|
||||
@ -99,37 +151,24 @@ async function openDetail(row: RecruitmentItem) {
|
||||
}
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ colKey: 'id', title: 'ID', width: 70 },
|
||||
{ colKey: 'title', title: '标题', minWidth: 260, ellipsis: true },
|
||||
{ colKey: 'categoryName', title: '分类', width: 90 },
|
||||
{ colKey: 'orgName', title: '发布单位', width: 180, ellipsis: true },
|
||||
{ colKey: 'region', title: '地区', width: 90 },
|
||||
{ colKey: 'publishDate', title: '发布日期', width: 110 },
|
||||
{ colKey: 'deadline', title: '截止', width: 110 },
|
||||
{ colKey: 'statusName', title: '状态', width: 90 },
|
||||
{ colKey: 'op', title: '操作', width: 90, fixed: 'right' },
|
||||
];
|
||||
|
||||
/** 复制公告原文链接 */
|
||||
function copyUrl() {
|
||||
if (detail.value?.url) {
|
||||
navigator.clipboard?.writeText(detail.value.url);
|
||||
MessagePlugin.success('链接已复制');
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<div class="mb-3 flex flex-wrap items-center gap-2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<t-input
|
||||
v-model="query.keyword"
|
||||
clearable
|
||||
placeholder="标题/正文/单位关键字"
|
||||
style="width: 200px"
|
||||
@enter="load"
|
||||
@enter="search"
|
||||
/>
|
||||
<t-select
|
||||
v-model="query.region"
|
||||
@ -168,47 +207,15 @@ onMounted(load);
|
||||
clearable
|
||||
placeholder="发布主体模糊"
|
||||
style="width: 150px"
|
||||
@enter="load"
|
||||
@enter="search"
|
||||
/>
|
||||
<t-checkbox v-model="query.onlyNewToday">仅今日新增</t-checkbox>
|
||||
<t-button theme="primary" @click="load">查询</t-button>
|
||||
<t-button theme="primary" @click="search">查询</t-button>
|
||||
<t-button variant="outline" @click="reset">重置</t-button>
|
||||
</div>
|
||||
|
||||
<t-table
|
||||
row-key="id"
|
||||
:data="list"
|
||||
:columns="columns"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
@page-change="onPageChange"
|
||||
>
|
||||
<template #statusName="{ row }">
|
||||
<t-tag
|
||||
:theme="
|
||||
row.status === 0
|
||||
? 'success'
|
||||
: row.status === 1
|
||||
? 'warning'
|
||||
: 'default'
|
||||
"
|
||||
variant="light"
|
||||
size="small"
|
||||
>
|
||||
{{ row.statusName }}
|
||||
</t-tag>
|
||||
</template>
|
||||
<template #op="{ row }">
|
||||
<t-button
|
||||
size="small"
|
||||
theme="primary"
|
||||
variant="text"
|
||||
@click="openDetail(row)"
|
||||
>
|
||||
详情
|
||||
</t-button>
|
||||
</template>
|
||||
</t-table>
|
||||
<!-- 招聘公告列表(Vben Vxe Table) -->
|
||||
<Grid />
|
||||
|
||||
<t-drawer
|
||||
v-model:visible="detailOpen"
|
||||
@ -218,12 +225,10 @@ onMounted(load);
|
||||
>
|
||||
<div v-if="detailLoading" class="p-6 text-center opacity-60">加载中…</div>
|
||||
<div v-else-if="detail">
|
||||
<div class="mb-3 flex flex-wrap gap-2 text-xs">
|
||||
<div class="flex flex-wrap gap-2 text-xs">
|
||||
<t-tag theme="primary" variant="light">
|
||||
{{
|
||||
detail.categoryName
|
||||
}}
|
||||
</t-tag>
|
||||
{{ detail.categoryName }}
|
||||
</t-tag>
|
||||
<t-tag theme="success" variant="light">{{ detail.statusName }}</t-tag>
|
||||
<span class="opacity-60">{{ detail.region }}</span>
|
||||
<span class="opacity-60">发布:{{ detail.publishDate }}</span>
|
||||
|
||||
@ -1,14 +1,16 @@
|
||||
<script lang="ts" setup>
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
// 推送订阅管理:订阅列表 + 新增/编辑 + 删除 + Bark 推送测试。
|
||||
import type { SubscriptionItem } from '#/api/recruitment';
|
||||
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import { h, onMounted, reactive } from 'vue';
|
||||
|
||||
import { useAccess } from '@vben/access';
|
||||
import { Page } from '@vben/common-ui';
|
||||
import { Page, useVbenDrawer, VbenTableAction } from '@vben/common-ui';
|
||||
|
||||
import { DialogPlugin, MessagePlugin } from 'tdesign-vue-next';
|
||||
import { DialogPlugin, MessagePlugin, Tag } from 'tdesign-vue-next';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import {
|
||||
CATEGORY_OPTIONS,
|
||||
deleteSubscription,
|
||||
@ -29,12 +31,11 @@ function categoryLabels(cats: number[] | undefined): string {
|
||||
|
||||
const { hasAccessByCodes } = useAccess();
|
||||
|
||||
const loading = ref(false);
|
||||
const list = ref<SubscriptionItem[]>([]);
|
||||
|
||||
// 编辑弹窗
|
||||
const dialogOpen = ref(false);
|
||||
const submitting = ref(false);
|
||||
// 编辑抽屉(vben 封装抽屉组件)
|
||||
const [Drawer, drawerApi] = useVbenDrawer({
|
||||
title: '新增订阅',
|
||||
onConfirm: submit,
|
||||
});
|
||||
const form = reactive({
|
||||
id: 0,
|
||||
name: '',
|
||||
@ -46,14 +47,10 @@ const form = reactive({
|
||||
enabled: 1,
|
||||
});
|
||||
|
||||
/** 拉取订阅列表并写入本地数据表格 */
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await getSubscriptionList();
|
||||
list.value = data.list ?? [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
const data = await getSubscriptionList();
|
||||
gridApi.setGridOptions({ data: data.list ?? [] });
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
@ -67,7 +64,8 @@ function openCreate() {
|
||||
pushTime: '08:00',
|
||||
enabled: 1,
|
||||
});
|
||||
dialogOpen.value = true;
|
||||
drawerApi.setState({ title: '新增订阅' });
|
||||
drawerApi.open();
|
||||
}
|
||||
|
||||
function openEdit(row: SubscriptionItem) {
|
||||
@ -81,7 +79,8 @@ function openEdit(row: SubscriptionItem) {
|
||||
pushTime: row.pushTime || '08:00',
|
||||
enabled: row.enabled,
|
||||
});
|
||||
dialogOpen.value = true;
|
||||
drawerApi.setState({ title: '编辑订阅' });
|
||||
drawerApi.open();
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
@ -89,7 +88,7 @@ async function submit() {
|
||||
MessagePlugin.warning('请填写名称与设备 Key');
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
drawerApi.lock(true);
|
||||
try {
|
||||
await saveSubscription({
|
||||
id: form.id || undefined,
|
||||
@ -102,13 +101,14 @@ async function submit() {
|
||||
enabled: form.enabled,
|
||||
});
|
||||
MessagePlugin.success(form.id ? '更新成功' : '新增成功');
|
||||
dialogOpen.value = false;
|
||||
drawerApi.close();
|
||||
await load();
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
drawerApi.lock(false);
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除订阅(保留原确认弹窗行为) */
|
||||
function onDelete(row: SubscriptionItem) {
|
||||
const d = DialogPlugin.confirm({
|
||||
header: '删除该订阅?',
|
||||
@ -121,6 +121,7 @@ function onDelete(row: SubscriptionItem) {
|
||||
});
|
||||
}
|
||||
|
||||
/** 发送 Bark 推送测试消息 */
|
||||
async function onTest(row: SubscriptionItem) {
|
||||
const data = await testPush({
|
||||
subscriptionId: row.id,
|
||||
@ -134,24 +135,118 @@ async function onTest(row: SubscriptionItem) {
|
||||
}
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ colKey: 'id', title: 'ID', width: 70 },
|
||||
{ colKey: 'name', title: '名称', width: 140 },
|
||||
{ colKey: 'deviceKey', title: '设备 Key', width: 200, ellipsis: true },
|
||||
{ colKey: 'regionsText', title: '地区', width: 160, ellipsis: true },
|
||||
{ colKey: 'categoriesText', title: '分类', width: 160, ellipsis: true },
|
||||
{ colKey: 'onlyNew', title: '仅新公告', width: 90 },
|
||||
{ colKey: 'pushTime', title: '推送时间', width: 90 },
|
||||
{ colKey: 'enabled', title: '启用', width: 80 },
|
||||
{ colKey: 'op', title: '操作', width: 200, fixed: 'right' },
|
||||
];
|
||||
// 订阅表格(本地数据模式:关闭 proxyConfig,数据由 load() 写入)
|
||||
const gridOptions: VxeTableGridOptions = {
|
||||
columns: [
|
||||
{ type: 'seq', width: 50, title: '#' },
|
||||
{ field: 'id', title: 'ID', width: 70 },
|
||||
{ field: 'name', title: '名称', width: 140 },
|
||||
{
|
||||
field: 'deviceKey',
|
||||
title: '设备 Key',
|
||||
width: 200,
|
||||
showOverflow: 'tooltip',
|
||||
},
|
||||
{
|
||||
field: 'regionsText',
|
||||
title: '地区',
|
||||
width: 160,
|
||||
showOverflow: 'tooltip',
|
||||
slots: {
|
||||
default: ({ row }: { row: SubscriptionItem }) =>
|
||||
h('span', (row.regions ?? []).join('、') || '全部'),
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'categoriesText',
|
||||
title: '分类',
|
||||
width: 160,
|
||||
showOverflow: 'tooltip',
|
||||
slots: {
|
||||
default: ({ row }: { row: SubscriptionItem }) =>
|
||||
h('span', categoryLabels(row.categories)),
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'onlyNew',
|
||||
title: '仅新公告',
|
||||
width: 90,
|
||||
slots: {
|
||||
default: ({ row }: { row: SubscriptionItem }) =>
|
||||
h(
|
||||
Tag,
|
||||
{
|
||||
theme: row.onlyNew === 1 ? 'success' : 'default',
|
||||
variant: 'light',
|
||||
size: 'small',
|
||||
},
|
||||
row.onlyNew === 1 ? '是' : '否',
|
||||
),
|
||||
},
|
||||
},
|
||||
{ field: 'pushTime', title: '推送时间', width: 90 },
|
||||
{
|
||||
field: 'enabled',
|
||||
title: '启用',
|
||||
width: 80,
|
||||
slots: {
|
||||
default: ({ row }: { row: SubscriptionItem }) =>
|
||||
h(
|
||||
Tag,
|
||||
{
|
||||
theme: row.enabled === 1 ? 'success' : 'default',
|
||||
variant: 'light',
|
||||
size: 'small',
|
||||
},
|
||||
row.enabled === 1 ? '启用' : '停用',
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'op',
|
||||
title: '操作',
|
||||
width: 200,
|
||||
fixed: 'right',
|
||||
slots: {
|
||||
default: ({ row }: { row: SubscriptionItem }) =>
|
||||
h(VbenTableAction, {
|
||||
actions: [
|
||||
// 编辑:按权限码显隐
|
||||
{
|
||||
text: '编辑',
|
||||
onClick: () => openEdit(row),
|
||||
auth: ['recruitment:push:save'],
|
||||
},
|
||||
// 测试推送
|
||||
{
|
||||
text: '测试',
|
||||
onClick: () => onTest(row),
|
||||
auth: ['recruitment:push:test'],
|
||||
},
|
||||
// 删除(危险操作标红)
|
||||
{
|
||||
text: '删除',
|
||||
danger: true,
|
||||
onClick: () => onDelete(row),
|
||||
auth: ['recruitment:push:delete'],
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
pagerConfig: { enabled: false }, // 订阅数量较少,不分页
|
||||
proxyConfig: { enabled: false }, // 本地数据模式
|
||||
};
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({ gridOptions });
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<div class="mb-3 flex items-center gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<t-button
|
||||
v-if="hasAccessByCodes(['recruitment:push:save'])"
|
||||
theme="primary"
|
||||
@ -161,69 +256,11 @@ onMounted(load);
|
||||
</t-button>
|
||||
</div>
|
||||
|
||||
<t-table row-key="id" :data="list" :columns="columns" :loading="loading">
|
||||
<template #regionsText="{ row }">
|
||||
{{ (row.regions ?? []).join('、') || '全部' }}
|
||||
</template>
|
||||
<template #categoriesText="{ row }">
|
||||
{{ categoryLabels(row.categories) }}
|
||||
</template>
|
||||
<template #onlyNew="{ row }">
|
||||
<t-tag
|
||||
:theme="row.onlyNew === 1 ? 'success' : 'default'"
|
||||
variant="light"
|
||||
size="small"
|
||||
>
|
||||
{{ row.onlyNew === 1 ? '是' : '否' }}
|
||||
</t-tag>
|
||||
</template>
|
||||
<template #enabled="{ row }">
|
||||
<t-tag
|
||||
:theme="row.enabled === 1 ? 'success' : 'default'"
|
||||
variant="light"
|
||||
size="small"
|
||||
>
|
||||
{{ row.enabled === 1 ? '启用' : '停用' }}
|
||||
</t-tag>
|
||||
</template>
|
||||
<template #op="{ row }">
|
||||
<t-button
|
||||
v-if="hasAccessByCodes(['recruitment:push:save'])"
|
||||
size="small"
|
||||
theme="primary"
|
||||
variant="text"
|
||||
@click="openEdit(row)"
|
||||
>
|
||||
编辑
|
||||
</t-button>
|
||||
<t-button
|
||||
v-if="hasAccessByCodes(['recruitment:push:test'])"
|
||||
size="small"
|
||||
theme="default"
|
||||
variant="text"
|
||||
@click="onTest(row)"
|
||||
>
|
||||
测试
|
||||
</t-button>
|
||||
<t-button
|
||||
v-if="hasAccessByCodes(['recruitment:push:delete'])"
|
||||
size="small"
|
||||
theme="danger"
|
||||
variant="text"
|
||||
@click="onDelete(row)"
|
||||
>
|
||||
删除
|
||||
</t-button>
|
||||
</template>
|
||||
</t-table>
|
||||
<!-- 订阅列表(Vben Vxe Table + TableAction) -->
|
||||
<Grid />
|
||||
|
||||
<t-dialog
|
||||
v-model:visible="dialogOpen"
|
||||
:header="form.id ? '编辑订阅' : '新增订阅'"
|
||||
width="560px"
|
||||
:confirm-btn="{ loading: submitting }"
|
||||
@confirm="submit"
|
||||
>
|
||||
<!-- 新增/编辑订阅抽屉(vben 封装抽屉组件) -->
|
||||
<Drawer>
|
||||
<t-form label-align="right" label-width="90px">
|
||||
<t-form-item label="名称">
|
||||
<t-input v-model="form.name" placeholder="如:每日贵阳公务员" />
|
||||
@ -263,6 +300,6 @@ onMounted(load);
|
||||
<t-switch v-model="form.enabled" :custom-value="[1, 0]" />
|
||||
</t-form-item>
|
||||
</t-form>
|
||||
</t-dialog>
|
||||
</Drawer>
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
@ -1,13 +1,15 @@
|
||||
<script lang="ts" setup>
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { AdminItem, RoleItem } from '#/api/system';
|
||||
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import { h, onMounted, reactive, ref } from 'vue';
|
||||
|
||||
import { useAccess } from '@vben/access';
|
||||
import { Page } from '@vben/common-ui';
|
||||
import { Page, useVbenDrawer, VbenTableAction } from '@vben/common-ui';
|
||||
|
||||
import { DialogPlugin, MessagePlugin } from 'tdesign-vue-next';
|
||||
import { DialogPlugin, MessagePlugin, Tag } from 'tdesign-vue-next';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import {
|
||||
createAdmin,
|
||||
deleteAdmin,
|
||||
@ -17,35 +19,30 @@ import {
|
||||
updateAdmin,
|
||||
} from '#/api/system';
|
||||
|
||||
// 用户管理页面:Vben Vxe Table + Vben TableAction 行操作(权限码控制显隐)
|
||||
const { hasAccessByCodes } = useAccess();
|
||||
|
||||
const loading = ref(false);
|
||||
const list = ref<AdminItem[]>([]);
|
||||
const roleOptions = ref<{ label: string; value: number }[]>([]);
|
||||
const dialogOpen = ref(false);
|
||||
const editing = ref<AdminItem | null>(null);
|
||||
|
||||
const query = reactive({ page: 1, size: 10, keyword: '' });
|
||||
const pagination = reactive({ current: 1, pageSize: 10, total: 0 });
|
||||
// 新增/编辑用户抽屉(vben 封装抽屉组件)
|
||||
const [Drawer, drawerApi] = useVbenDrawer({
|
||||
title: '新增用户',
|
||||
onConfirm: submit,
|
||||
});
|
||||
|
||||
// 筛选条件(分页由 grid 自动管理)
|
||||
const query = reactive({ keyword: '' });
|
||||
const form = reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
nickname: '',
|
||||
status: 1,
|
||||
barkDeviceId: '',
|
||||
pushplusToken: '',
|
||||
roleIds: [] as number[],
|
||||
});
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await getAdminList(query);
|
||||
list.value = data.list ?? [];
|
||||
pagination.total = data.total ?? 0;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRoles() {
|
||||
const data = await getRoleList({ page: 1, size: 100 });
|
||||
roleOptions.value = (data.list ?? []).map((r: RoleItem) => ({
|
||||
@ -61,9 +58,12 @@ function openCreate() {
|
||||
password: '',
|
||||
nickname: '',
|
||||
status: 1,
|
||||
barkDeviceId: '',
|
||||
pushplusToken: '',
|
||||
roleIds: [],
|
||||
});
|
||||
dialogOpen.value = true;
|
||||
drawerApi.setState({ title: '新增用户' });
|
||||
drawerApi.open();
|
||||
}
|
||||
|
||||
function openEdit(row: AdminItem) {
|
||||
@ -73,43 +73,51 @@ function openEdit(row: AdminItem) {
|
||||
password: '',
|
||||
nickname: row.nickname,
|
||||
status: row.status,
|
||||
barkDeviceId: row.barkDeviceId ?? '',
|
||||
pushplusToken: row.pushplusToken ?? '',
|
||||
roleIds: row.roleIds ?? [],
|
||||
});
|
||||
dialogOpen.value = true;
|
||||
drawerApi.setState({ title: '编辑用户' });
|
||||
drawerApi.open();
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (editing.value) {
|
||||
await updateAdmin(editing.value.id, {
|
||||
nickname: form.nickname,
|
||||
status: form.status,
|
||||
roleIds: form.roleIds,
|
||||
});
|
||||
} else {
|
||||
await createAdmin({
|
||||
username: form.username,
|
||||
password: form.password,
|
||||
nickname: form.nickname,
|
||||
roleIds: form.roleIds,
|
||||
});
|
||||
}
|
||||
// 编辑走更新、新增走创建
|
||||
editing.value
|
||||
? await updateAdmin(editing.value.id, {
|
||||
nickname: form.nickname,
|
||||
status: form.status,
|
||||
barkDeviceId: form.barkDeviceId,
|
||||
pushplusToken: form.pushplusToken,
|
||||
roleIds: form.roleIds,
|
||||
})
|
||||
: await createAdmin({
|
||||
username: form.username,
|
||||
password: form.password,
|
||||
nickname: form.nickname,
|
||||
barkDeviceId: form.barkDeviceId,
|
||||
pushplusToken: form.pushplusToken,
|
||||
roleIds: form.roleIds,
|
||||
});
|
||||
MessagePlugin.success('保存成功');
|
||||
dialogOpen.value = false;
|
||||
load();
|
||||
drawerApi.close();
|
||||
gridApi.query();
|
||||
}
|
||||
|
||||
/** 删除用户(保留原确认弹窗行为) */
|
||||
function onDelete(row: AdminItem) {
|
||||
const d = DialogPlugin.confirm({
|
||||
header: '确认删除该人员?',
|
||||
header: '确认删除该用户?',
|
||||
onConfirm: async () => {
|
||||
await deleteAdmin(row.id);
|
||||
MessagePlugin.success('删除成功');
|
||||
load();
|
||||
gridApi.query();
|
||||
d.destroy();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 重置密码(需要输入新密码,保留原弹窗交互) */
|
||||
function onResetPwd(row: AdminItem) {
|
||||
let password = '';
|
||||
const d = DialogPlugin.confirm({
|
||||
@ -127,115 +135,148 @@ function onResetPwd(row: AdminItem) {
|
||||
});
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ colKey: 'id', title: 'ID', width: 70 },
|
||||
{ colKey: 'username', title: '用户名' },
|
||||
{ colKey: 'nickname', title: '昵称' },
|
||||
{ colKey: 'statusText', title: '状态', width: 90 },
|
||||
{ colKey: 'roleNamesText', title: '角色' },
|
||||
{ colKey: 'createdAt', title: '创建时间', width: 170 },
|
||||
{ colKey: 'op', title: '操作', width: 200 },
|
||||
];
|
||||
const gridOptions: VxeTableGridOptions = {
|
||||
columns: [
|
||||
{ type: 'seq', width: 50, title: '#' },
|
||||
{ field: 'id', title: 'ID', width: 70 },
|
||||
{ field: 'username', title: '用户名' },
|
||||
{ field: 'nickname', title: '昵称' },
|
||||
{
|
||||
field: 'barkDeviceId',
|
||||
title: 'Bark设备',
|
||||
width: 120,
|
||||
showOverflow: 'tooltip',
|
||||
},
|
||||
{
|
||||
field: 'pushplusToken',
|
||||
title: 'pushplus',
|
||||
width: 120,
|
||||
showOverflow: 'tooltip',
|
||||
},
|
||||
{
|
||||
field: 'statusText',
|
||||
title: '状态',
|
||||
width: 90,
|
||||
slots: {
|
||||
default: ({ row }: { row: AdminItem }) =>
|
||||
h(
|
||||
Tag,
|
||||
{
|
||||
theme: row.status === 1 ? 'success' : 'danger',
|
||||
variant: 'light',
|
||||
size: 'small',
|
||||
},
|
||||
row.status === 1 ? '启用' : '禁用',
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'roleNamesText',
|
||||
title: '角色',
|
||||
slots: {
|
||||
default: ({ row }: { row: AdminItem }) =>
|
||||
h('span', (row.roleNames ?? []).join(', ') || '-'),
|
||||
},
|
||||
},
|
||||
{ field: 'createdAt', title: '创建时间', width: 170 },
|
||||
{
|
||||
field: 'op',
|
||||
title: '操作',
|
||||
width: 200,
|
||||
fixed: 'right',
|
||||
slots: {
|
||||
default: ({ row }: { row: AdminItem }) =>
|
||||
h(VbenTableAction, {
|
||||
actions: [
|
||||
// 编辑:按权限码显隐
|
||||
{
|
||||
text: '编辑',
|
||||
onClick: () => openEdit(row),
|
||||
auth: ['system:personnel:update'],
|
||||
},
|
||||
// 重置密码
|
||||
{
|
||||
text: '重置密码',
|
||||
onClick: () => onResetPwd(row),
|
||||
auth: ['system:personnel:resetPwd'],
|
||||
},
|
||||
// 删除(危险操作标红)
|
||||
{
|
||||
text: '删除',
|
||||
danger: true,
|
||||
onClick: () => onDelete(row),
|
||||
auth: ['system:personnel:delete'],
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
pagerConfig: { pageSize: 10 },
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({
|
||||
page,
|
||||
}: {
|
||||
page: { currentPage: number; pageSize: number };
|
||||
}) => {
|
||||
const data = await getAdminList({
|
||||
page: page.currentPage,
|
||||
size: page.pageSize,
|
||||
keyword: query.keyword || undefined,
|
||||
});
|
||||
return { items: data.list ?? [], total: data.total ?? 0 };
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function onPageChange(p: { current: number; pageSize: number }) {
|
||||
query.page = p.current;
|
||||
query.size = p.pageSize;
|
||||
pagination.current = p.current;
|
||||
pagination.pageSize = p.pageSize;
|
||||
load();
|
||||
const [Grid, gridApi] = useVbenVxeGrid({ gridOptions });
|
||||
|
||||
/** 触发查询(保留分页条件,因 grid 自动管理分页) */
|
||||
function search() {
|
||||
gridApi.query();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
load();
|
||||
loadRoles();
|
||||
});
|
||||
onMounted(loadRoles);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<div class="wb-page-head">
|
||||
<h2 class="wb-page-title">人员管理</h2>
|
||||
<p class="wb-page-sub">管理系统账号、昵称、状态与角色归属</p>
|
||||
<h2 class="wb-page-title">用户管理</h2>
|
||||
<p class="wb-page-sub">管理系统账号、昵称、状态、角色与通知渠道(Bark / pushplus)</p>
|
||||
</div>
|
||||
|
||||
<t-card :bordered="false" class="wb-filter-card">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<t-input
|
||||
v-model="query.keyword"
|
||||
clearable
|
||||
placeholder="用户名/昵称"
|
||||
style="width: 220px"
|
||||
@enter="load"
|
||||
@enter="search"
|
||||
/>
|
||||
<t-button theme="primary" @click="load">查询</t-button>
|
||||
<t-button theme="primary" @click="search">查询</t-button>
|
||||
</div>
|
||||
<t-button
|
||||
v-if="hasAccessByCodes(['system:personnel:create'])"
|
||||
theme="primary"
|
||||
@click="openCreate"
|
||||
>
|
||||
新增人员
|
||||
新增用户
|
||||
</t-button>
|
||||
</div>
|
||||
</t-card>
|
||||
|
||||
<!-- 用户列表(Vben Vxe Table + TableAction) -->
|
||||
<t-card :bordered="false" class="wb-table-card">
|
||||
<t-table
|
||||
row-key="id"
|
||||
:data="list"
|
||||
:columns="columns"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
@page-change="onPageChange"
|
||||
>
|
||||
<template #statusText="{ row }">
|
||||
{{
|
||||
row.status === 1 ? '启用' : '禁用'
|
||||
}}
|
||||
</template>
|
||||
<template #roleNamesText="{ row }">
|
||||
{{
|
||||
(row.roleNames ?? []).join(', ')
|
||||
}}
|
||||
</template>
|
||||
<template #op="{ row }">
|
||||
<t-button
|
||||
v-if="hasAccessByCodes(['system:personnel:update'])"
|
||||
size="small"
|
||||
theme="primary"
|
||||
variant="text"
|
||||
@click="openEdit(row)"
|
||||
>
|
||||
编辑
|
||||
</t-button>
|
||||
<t-button
|
||||
v-if="hasAccessByCodes(['system:personnel:resetPwd'])"
|
||||
size="small"
|
||||
theme="primary"
|
||||
variant="text"
|
||||
@click="onResetPwd(row)"
|
||||
>
|
||||
重置密码
|
||||
</t-button>
|
||||
<t-button
|
||||
v-if="hasAccessByCodes(['system:personnel:delete'])"
|
||||
size="small"
|
||||
theme="danger"
|
||||
variant="text"
|
||||
@click="onDelete(row)"
|
||||
>
|
||||
删除
|
||||
</t-button>
|
||||
</template>
|
||||
</t-table>
|
||||
<Grid />
|
||||
</t-card>
|
||||
|
||||
<t-dialog
|
||||
v-model:visible="dialogOpen"
|
||||
:header="editing ? '编辑人员' : '新增人员'"
|
||||
:on-confirm="submit"
|
||||
>
|
||||
<!-- 新增/编辑用户抽屉(vben 封装抽屉组件) -->
|
||||
<Drawer>
|
||||
<t-form label-align="right" label-width="80px">
|
||||
<t-form-item label="用户名" required>
|
||||
<t-input
|
||||
@ -254,6 +295,18 @@ onMounted(() => {
|
||||
<t-form-item label="昵称">
|
||||
<t-input v-model="form.nickname" placeholder="显示名称" />
|
||||
</t-form-item>
|
||||
<t-form-item label="Bark设备">
|
||||
<t-input
|
||||
v-model="form.barkDeviceId"
|
||||
placeholder="Bark 设备ID(形如 xxx/… 路径 key)"
|
||||
/>
|
||||
</t-form-item>
|
||||
<t-form-item label="pushplus">
|
||||
<t-input
|
||||
v-model="form.pushplusToken"
|
||||
placeholder="pushplus token"
|
||||
/>
|
||||
</t-form-item>
|
||||
<t-form-item label="状态">
|
||||
<t-switch v-model="form.status" :custom-value="[1, 0]" />
|
||||
</t-form-item>
|
||||
@ -266,6 +319,6 @@ onMounted(() => {
|
||||
/>
|
||||
</t-form-item>
|
||||
</t-form>
|
||||
</t-dialog>
|
||||
</Drawer>
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
182
apps/web-tdesign/src/views/system/auto-job/list/index.vue
Normal file
182
apps/web-tdesign/src/views/system/auto-job/list/index.vue
Normal file
@ -0,0 +1,182 @@
|
||||
<script lang="ts" setup>
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
|
||||
import { h, onMounted, reactive, ref } from 'vue';
|
||||
|
||||
import { Page, useVbenDrawer, VbenTableAction } from '@vben/common-ui';
|
||||
|
||||
import { DialogPlugin, MessagePlugin, Tag } from 'tdesign-vue-next';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import {
|
||||
getAutoJobList,
|
||||
saveAutoJob,
|
||||
triggerAutoJob,
|
||||
type AutoJobItem,
|
||||
} from '#/api/autoJob';
|
||||
|
||||
// 自动任务管理:DB 驱动的定时任务(cron 表达式/启停/手动触发),执行结果自动写日志并通知
|
||||
const editing = ref<AutoJobItem | null>(null);
|
||||
const [Drawer, drawerApi] = useVbenDrawer({
|
||||
title: '编辑任务',
|
||||
onConfirm: submit,
|
||||
});
|
||||
|
||||
const form = reactive({
|
||||
cronExpr: '',
|
||||
remark: '',
|
||||
});
|
||||
|
||||
function openEdit(row: AutoJobItem) {
|
||||
editing.value = row;
|
||||
Object.assign(form, { cronExpr: row.cronExpr, remark: row.remark });
|
||||
drawerApi.setState({ title: `编辑「${row.name}」` });
|
||||
drawerApi.open();
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!editing.value) return;
|
||||
await saveAutoJob({
|
||||
id: editing.value.id,
|
||||
cronExpr: form.cronExpr,
|
||||
remark: form.remark,
|
||||
});
|
||||
MessagePlugin.success('保存成功,调度已更新');
|
||||
drawerApi.close();
|
||||
loadList();
|
||||
}
|
||||
|
||||
/** 启停切换(直接生效,动态更新调度) */
|
||||
async function onToggle(row: AutoJobItem, enabled: number) {
|
||||
await saveAutoJob({ id: row.id, enabled });
|
||||
MessagePlugin.success(enabled === 1 ? '任务已启用' : '任务已停用');
|
||||
loadList();
|
||||
}
|
||||
|
||||
/** 手动触发一次 */
|
||||
function onTrigger(row: AutoJobItem) {
|
||||
const d = DialogPlugin.confirm({
|
||||
header: `立即执行「${row.name}」?`,
|
||||
body: '将立即运行一次并记录日志',
|
||||
onConfirm: async () => {
|
||||
const r = await triggerAutoJob({ id: row.id });
|
||||
MessagePlugin[r.ok ? 'success' : 'error'](
|
||||
r.ok ? `执行完成:${r.summary || '成功'}` : `执行失败:${r.summary}`,
|
||||
);
|
||||
d.destroy();
|
||||
loadList();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const gridOptions: VxeTableGridOptions = {
|
||||
columns: [
|
||||
{ type: 'seq', width: 50, title: '#' },
|
||||
{ field: 'name', title: '任务名称', width: 160 },
|
||||
{ field: 'code', title: '编码', width: 180, showOverflow: 'tooltip' },
|
||||
{ field: 'cronExpr', title: 'cron 表达式', width: 140 },
|
||||
{
|
||||
field: 'enabled',
|
||||
title: '启用',
|
||||
width: 90,
|
||||
slots: {
|
||||
default: ({ row }: { row: AutoJobItem }) =>
|
||||
h(
|
||||
Tag,
|
||||
{
|
||||
theme: row.enabled === 1 ? 'success' : 'default',
|
||||
variant: 'light',
|
||||
size: 'small',
|
||||
},
|
||||
row.enabled === 1 ? '启用' : '停用',
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'lastResultText',
|
||||
title: '上次结果',
|
||||
width: 100,
|
||||
slots: {
|
||||
default: ({ row }: { row: AutoJobItem }) => {
|
||||
if (row.lastResult === 0) return h('span', '-');
|
||||
return h(
|
||||
Tag,
|
||||
{
|
||||
theme: row.lastResult === 1 ? 'success' : 'danger',
|
||||
variant: 'light',
|
||||
size: 'small',
|
||||
},
|
||||
row.lastResult === 1 ? '成功' : '失败',
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
{ field: 'lastRunAt', title: '上次运行', width: 165 },
|
||||
{ field: 'lastError', title: '上次错误', minWidth: 160, showOverflow: 'tooltip' },
|
||||
{
|
||||
field: 'op',
|
||||
title: '操作',
|
||||
width: 200,
|
||||
fixed: 'right',
|
||||
slots: {
|
||||
default: ({ row }: { row: AutoJobItem }) =>
|
||||
h(VbenTableAction, {
|
||||
actions: [
|
||||
{
|
||||
text: row.enabled === 1 ? '停用' : '启用',
|
||||
onClick: () => onToggle(row, row.enabled === 1 ? 0 : 1),
|
||||
},
|
||||
{ text: '执行', onClick: () => onTrigger(row) },
|
||||
{ text: '编辑', onClick: () => openEdit(row) },
|
||||
],
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
proxyConfig: { enabled: false },
|
||||
};
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({ gridOptions });
|
||||
|
||||
async function loadList() {
|
||||
const data = await getAutoJobList();
|
||||
gridApi.setGridOptions({ data: data.list ?? [] });
|
||||
}
|
||||
|
||||
onMounted(loadList);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<div class="wb-page-head">
|
||||
<h2 class="wb-page-title">自动任务</h2>
|
||||
<p class="wb-page-sub">
|
||||
管理定时任务(招聘抓取/推送等):修改 cron、启停、手动执行;执行结果自动记录并可关联通知
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<t-card :bordered="false" class="wb-filter-card">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-sm text-gray-500">
|
||||
cron 表达式为 6 段式(秒 分 时 日 月 周),如 0 0 3 * * *
|
||||
</div>
|
||||
<t-button theme="primary" @click="loadList">刷新</t-button>
|
||||
</div>
|
||||
</t-card>
|
||||
|
||||
<t-card :bordered="false" class="wb-table-card">
|
||||
<Grid />
|
||||
</t-card>
|
||||
|
||||
<Drawer>
|
||||
<t-form label-align="right" label-width="90px">
|
||||
<t-form-item label="cron 表达式" required>
|
||||
<t-input v-model="form.cronExpr" placeholder="0 0 3 * * *" />
|
||||
</t-form-item>
|
||||
<t-form-item label="备注">
|
||||
<t-input v-model="form.remark" />
|
||||
</t-form-item>
|
||||
</t-form>
|
||||
</Drawer>
|
||||
</Page>
|
||||
</template>
|
||||
114
apps/web-tdesign/src/views/system/auto-job/log/index.vue
Normal file
114
apps/web-tdesign/src/views/system/auto-job/log/index.vue
Normal file
@ -0,0 +1,114 @@
|
||||
<script lang="ts" setup>
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
|
||||
import { h, onMounted, reactive, ref } from 'vue';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
|
||||
import { Tag } from 'tdesign-vue-next';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import {
|
||||
getAutoJobList,
|
||||
getAutoJobLogList,
|
||||
type AutoJobItem,
|
||||
type AutoJobLogItem,
|
||||
} from '#/api/autoJob';
|
||||
|
||||
// 任务运行日志:每次定时/手动执行的摘要、结果、耗时
|
||||
const jobOptions = ref<{ label: string; value: number }[]>([]);
|
||||
const query = reactive({ jobId: 0 as number | undefined });
|
||||
|
||||
const gridOptions: VxeTableGridOptions = {
|
||||
columns: [
|
||||
{ type: 'seq', width: 50, title: '#' },
|
||||
{ field: 'runAt', title: '运行时间', width: 165 },
|
||||
{
|
||||
field: 'jobName',
|
||||
title: '任务',
|
||||
width: 160,
|
||||
slots: {
|
||||
default: ({ row }: { row: AutoJobLogItem }) =>
|
||||
h('span', row.jobName || `#${row.jobId}`),
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'result',
|
||||
title: '结果',
|
||||
width: 90,
|
||||
slots: {
|
||||
default: ({ row }: { row: AutoJobLogItem }) =>
|
||||
h(
|
||||
Tag,
|
||||
{
|
||||
theme: row.result === 1 ? 'success' : 'danger',
|
||||
variant: 'light',
|
||||
size: 'small',
|
||||
},
|
||||
row.result === 1 ? '成功' : '失败',
|
||||
),
|
||||
},
|
||||
},
|
||||
{ field: 'durationMs', title: '耗时(ms)', width: 100 },
|
||||
{ field: 'summary', title: '摘要', minWidth: 220, showOverflow: 'tooltip' },
|
||||
{ field: 'error', title: '错误', minWidth: 160, showOverflow: 'tooltip' },
|
||||
],
|
||||
pagerConfig: { pageSize: 20 },
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({
|
||||
page,
|
||||
}: {
|
||||
page: { currentPage: number; pageSize: number };
|
||||
}) => {
|
||||
const data = await getAutoJobLogList({
|
||||
page: page.currentPage,
|
||||
size: page.pageSize,
|
||||
jobId: query.jobId || undefined,
|
||||
});
|
||||
return { items: data.list ?? [], total: data.total ?? 0 };
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({ gridOptions });
|
||||
|
||||
function search() {
|
||||
gridApi.query();
|
||||
}
|
||||
|
||||
async function loadJobs() {
|
||||
const data = await getAutoJobList();
|
||||
jobOptions.value = (data.list ?? []).map((j: AutoJobItem) => ({
|
||||
label: j.name,
|
||||
value: j.id,
|
||||
}));
|
||||
}
|
||||
|
||||
onMounted(loadJobs);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<div class="wb-page-head">
|
||||
<h2 class="wb-page-title">任务日志</h2>
|
||||
<p class="wb-page-sub">自动任务每次运行的摘要、结果与耗时</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-3 flex flex-wrap items-center gap-2">
|
||||
<t-select
|
||||
v-model="query.jobId"
|
||||
clearable
|
||||
:options="jobOptions"
|
||||
placeholder="按任务筛选"
|
||||
style="width: 200px"
|
||||
/>
|
||||
<t-button theme="primary" @click="search">查询</t-button>
|
||||
</div>
|
||||
|
||||
<t-card :bordered="false" class="wb-table-card">
|
||||
<Grid />
|
||||
</t-card>
|
||||
</Page>
|
||||
</template>
|
||||
@ -1,4 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { LogItem } from '#/api/system';
|
||||
|
||||
import { h, reactive, ref } from 'vue';
|
||||
@ -8,7 +9,7 @@ import { Page } from '@vben/common-ui';
|
||||
|
||||
import { MessagePlugin, Tag } from 'tdesign-vue-next';
|
||||
|
||||
import { useVbenVxeGrid, type VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { getSystemLogList } from '#/api/system';
|
||||
|
||||
// 操作日志页面:Vben Vxe Table 分页列表 + 多维筛选(筛选区保留 TDesign 组件,行为与原版一致)
|
||||
@ -47,6 +48,14 @@ const sortMap: Record<string, { orderBy: string; orderDir: string }> = {
|
||||
// HTTP 方法可选项
|
||||
const METHOD_OPTIONS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'];
|
||||
|
||||
/** HTTP 方法 -> 标签主题色 */
|
||||
function methodTheme(method: string) {
|
||||
if (method === 'POST') return 'primary';
|
||||
if (method === 'GET') return 'success';
|
||||
if (method === 'PUT') return 'warning';
|
||||
return 'danger';
|
||||
}
|
||||
|
||||
const gridOptions: VxeTableGridOptions = {
|
||||
columns: [
|
||||
{ type: 'seq', width: 50, title: '#' },
|
||||
@ -62,14 +71,7 @@ const gridOptions: VxeTableGridOptions = {
|
||||
h(
|
||||
Tag,
|
||||
{
|
||||
theme:
|
||||
row.method === 'POST'
|
||||
? 'primary'
|
||||
: row.method === 'GET'
|
||||
? 'success'
|
||||
: row.method === 'PUT'
|
||||
? 'warning'
|
||||
: 'danger',
|
||||
theme: methodTheme(row.method),
|
||||
variant: 'light',
|
||||
size: 'small',
|
||||
},
|
||||
@ -224,92 +226,92 @@ function copyParam(row: LogItem) {
|
||||
</div>
|
||||
|
||||
<t-card :bordered="false" class="wb-filter-card">
|
||||
<div class="mb-3 flex flex-wrap items-center gap-2">
|
||||
<t-date-range-picker
|
||||
v-model="timeRange"
|
||||
value-type="YYYY-MM-DD"
|
||||
clearable
|
||||
separator="~"
|
||||
:placeholder="['操作开始日期', '操作结束日期']"
|
||||
style="width: 280px"
|
||||
/>
|
||||
<t-input
|
||||
v-model="query.username"
|
||||
clearable
|
||||
placeholder="操作账号"
|
||||
style="width: 150px"
|
||||
@enter="search"
|
||||
/>
|
||||
<t-input
|
||||
v-model="query.keyword"
|
||||
clearable
|
||||
placeholder="权限标识 / 路径 / IP"
|
||||
style="width: 220px"
|
||||
@enter="search"
|
||||
/>
|
||||
<t-input
|
||||
v-model="query.ip"
|
||||
clearable
|
||||
placeholder="来源 IP"
|
||||
style="width: 150px"
|
||||
@enter="search"
|
||||
/>
|
||||
<t-input
|
||||
v-model="query.ipLocation"
|
||||
clearable
|
||||
placeholder="IP 归属地"
|
||||
style="width: 160px"
|
||||
@enter="search"
|
||||
/>
|
||||
<t-select
|
||||
v-model="query.method"
|
||||
clearable
|
||||
placeholder="方法"
|
||||
filterable
|
||||
style="width: 120px"
|
||||
:options="METHOD_OPTIONS.map((m) => ({ label: m, value: m }))"
|
||||
/>
|
||||
<t-select
|
||||
v-model="query.status"
|
||||
style="width: 120px"
|
||||
:options="[
|
||||
{ label: '全部结果', value: 0 },
|
||||
{ label: '成功(2xx)', value: 1 },
|
||||
{ label: '失败(非2xx)', value: 2 },
|
||||
]"
|
||||
/>
|
||||
<div class="flex items-center gap-1">
|
||||
<t-input-number
|
||||
v-model="query.minDuration"
|
||||
:min="0"
|
||||
theme="column"
|
||||
placeholder="耗时≥"
|
||||
style="width: 90px"
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<t-date-range-picker
|
||||
v-model="timeRange"
|
||||
value-type="YYYY-MM-DD"
|
||||
clearable
|
||||
separator="~"
|
||||
:placeholder="['操作开始日期', '操作结束日期']"
|
||||
style="width: 280px"
|
||||
/>
|
||||
<span class="text-gray-400">~</span>
|
||||
<t-input-number
|
||||
v-model="query.maxDuration"
|
||||
:min="0"
|
||||
theme="column"
|
||||
placeholder="耗时≤"
|
||||
style="width: 90px"
|
||||
<t-input
|
||||
v-model="query.username"
|
||||
clearable
|
||||
placeholder="操作账号"
|
||||
style="width: 150px"
|
||||
@enter="search"
|
||||
/>
|
||||
<span class="text-gray-400">ms</span>
|
||||
<t-input
|
||||
v-model="query.keyword"
|
||||
clearable
|
||||
placeholder="权限标识 / 路径 / IP"
|
||||
style="width: 220px"
|
||||
@enter="search"
|
||||
/>
|
||||
<t-input
|
||||
v-model="query.ip"
|
||||
clearable
|
||||
placeholder="来源 IP"
|
||||
style="width: 150px"
|
||||
@enter="search"
|
||||
/>
|
||||
<t-input
|
||||
v-model="query.ipLocation"
|
||||
clearable
|
||||
placeholder="IP 归属地"
|
||||
style="width: 160px"
|
||||
@enter="search"
|
||||
/>
|
||||
<t-select
|
||||
v-model="query.method"
|
||||
clearable
|
||||
placeholder="方法"
|
||||
filterable
|
||||
style="width: 120px"
|
||||
:options="METHOD_OPTIONS.map((m) => ({ label: m, value: m }))"
|
||||
/>
|
||||
<t-select
|
||||
v-model="query.status"
|
||||
style="width: 120px"
|
||||
:options="[
|
||||
{ label: '全部结果', value: 0 },
|
||||
{ label: '成功(2xx)', value: 1 },
|
||||
{ label: '失败(非2xx)', value: 2 },
|
||||
]"
|
||||
/>
|
||||
<div class="flex items-center gap-1">
|
||||
<t-input-number
|
||||
v-model="query.minDuration"
|
||||
:min="0"
|
||||
theme="column"
|
||||
placeholder="耗时≥"
|
||||
style="width: 90px"
|
||||
/>
|
||||
<span class="text-gray-400">~</span>
|
||||
<t-input-number
|
||||
v-model="query.maxDuration"
|
||||
:min="0"
|
||||
theme="column"
|
||||
placeholder="耗时≤"
|
||||
style="width: 90px"
|
||||
/>
|
||||
<span class="text-gray-400">ms</span>
|
||||
</div>
|
||||
<t-select
|
||||
v-model="sortKey"
|
||||
style="width: 160px"
|
||||
:options="SORT_OPTIONS"
|
||||
/>
|
||||
<t-button
|
||||
v-if="hasAccessByCodes(['system:log:list'])"
|
||||
theme="primary"
|
||||
@click="search"
|
||||
>
|
||||
查询
|
||||
</t-button>
|
||||
<t-button variant="outline" @click="reset">重置</t-button>
|
||||
</div>
|
||||
<t-select
|
||||
v-model="sortKey"
|
||||
style="width: 160px"
|
||||
:options="SORT_OPTIONS"
|
||||
/>
|
||||
<t-button
|
||||
v-if="hasAccessByCodes(['system:log:list'])"
|
||||
theme="primary"
|
||||
@click="search"
|
||||
>
|
||||
查询
|
||||
</t-button>
|
||||
<t-button variant="outline" @click="reset">重置</t-button>
|
||||
</div>
|
||||
</t-card>
|
||||
|
||||
<!-- 操作日志列表(Vben Vxe Table) -->
|
||||
|
||||
@ -1,69 +1,102 @@
|
||||
<script lang="ts" setup>
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { LoginLogItem } from '#/api/system';
|
||||
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import { h, reactive } from 'vue';
|
||||
|
||||
import { useAccess } from '@vben/access';
|
||||
import { Page } from '@vben/common-ui';
|
||||
|
||||
import { Tag } from 'tdesign-vue-next';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { getLoginLogList } from '#/api/system';
|
||||
|
||||
// 登录日志页面:账号 + 结果筛选、分页列表
|
||||
// 登录日志页面:Vben Vxe Table 分页列表 + 账号/结果筛选(筛选区保留 TDesign 组件,行为与原版一致)
|
||||
const { hasAccessByCodes } = useAccess();
|
||||
|
||||
const loading = ref(false);
|
||||
const list = ref<LoginLogItem[]>([]);
|
||||
|
||||
// 查询条件:username 匹配账号,status 按结果过滤(-1 全部)
|
||||
const query = reactive({ page: 1, size: 10, username: '', status: -1 });
|
||||
const pagination = reactive({ current: 1, pageSize: 10, total: 0 });
|
||||
const query = reactive({ username: '', status: -1 });
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await getLoginLogList({
|
||||
page: query.page,
|
||||
size: query.size,
|
||||
username: query.username || undefined,
|
||||
status: query.status >= 0 ? query.status : undefined,
|
||||
});
|
||||
list.value = data.list ?? [];
|
||||
pagination.total = data.total ?? 0;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
// 表格列定义(status/failReason 用 slots 函数式渲染)
|
||||
const gridOptions: VxeTableGridOptions = {
|
||||
columns: [
|
||||
{ type: 'seq', width: 50, title: '#' },
|
||||
{ field: 'id', title: 'ID', width: 70 },
|
||||
{ field: 'username', title: '登录账号', width: 140 },
|
||||
{ field: 'ip', title: '来源 IP', width: 140 },
|
||||
{
|
||||
field: 'userAgent',
|
||||
title: '浏览器 UA',
|
||||
width: 240,
|
||||
showOverflow: 'tooltip',
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
title: '结果',
|
||||
width: 90,
|
||||
slots: {
|
||||
default: ({ row }: { row: LoginLogItem }) =>
|
||||
h(
|
||||
Tag,
|
||||
{
|
||||
theme: row.status === 1 ? 'success' : 'danger',
|
||||
variant: 'light',
|
||||
size: 'small',
|
||||
},
|
||||
row.status === 1 ? '成功' : '失败',
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'failReason',
|
||||
title: '失败原因',
|
||||
width: 220,
|
||||
showOverflow: 'tooltip',
|
||||
slots: {
|
||||
default: ({ row }: { row: LoginLogItem }) =>
|
||||
h(
|
||||
'span',
|
||||
{ class: row.failReason ? '' : 'text-gray-400' },
|
||||
row.failReason || '-',
|
||||
),
|
||||
},
|
||||
},
|
||||
{ field: 'createdAt', title: '登录时间', width: 170 },
|
||||
],
|
||||
pagerConfig: { pageSize: 10 },
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({
|
||||
page,
|
||||
}: {
|
||||
page: { currentPage: number; pageSize: number };
|
||||
}) => {
|
||||
const res = await getLoginLogList({
|
||||
page: page.currentPage,
|
||||
size: page.pageSize,
|
||||
username: query.username || undefined,
|
||||
status: query.status >= 0 ? query.status : undefined,
|
||||
});
|
||||
return { items: res.list ?? [], total: res.total ?? 0 };
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({ gridOptions });
|
||||
|
||||
/** 触发查询(保留分页条件,因 grid 自动管理分页) */
|
||||
function search() {
|
||||
gridApi.query();
|
||||
}
|
||||
|
||||
/** 重置筛选并回到第一页 */
|
||||
/** 重置筛选并回到第一页重新查询 */
|
||||
function reset() {
|
||||
query.username = '';
|
||||
query.status = -1;
|
||||
query.page = 1;
|
||||
pagination.current = 1;
|
||||
load();
|
||||
gridApi.reload();
|
||||
}
|
||||
|
||||
/** 分页变化回调 */
|
||||
function onPageChange(p: { current: number; pageSize: number }) {
|
||||
query.page = p.current;
|
||||
query.size = p.pageSize;
|
||||
pagination.current = p.current;
|
||||
pagination.pageSize = p.pageSize;
|
||||
load();
|
||||
}
|
||||
|
||||
// 表格列定义
|
||||
const columns = [
|
||||
{ colKey: 'id', title: 'ID', width: 70 },
|
||||
{ colKey: 'username', title: '登录账号', width: 140 },
|
||||
{ colKey: 'ip', title: '来源 IP', width: 140 },
|
||||
{ colKey: 'userAgent', title: '浏览器 UA', width: 240 },
|
||||
{ colKey: 'status', title: '结果', width: 90 },
|
||||
{ colKey: 'failReason', title: '失败原因', width: 220 },
|
||||
{ colKey: 'createdAt', title: '登录时间', width: 170 },
|
||||
];
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -75,14 +108,14 @@ onMounted(load);
|
||||
|
||||
<t-card :bordered="false" class="wb-filter-card">
|
||||
<!-- 顶部筛选区:账号 + 结果 + 查询 / 重置 -->
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<t-input
|
||||
v-model="query.username"
|
||||
clearable
|
||||
placeholder="登录账号"
|
||||
style="width: 180px"
|
||||
@enter="load"
|
||||
@enter="search"
|
||||
/>
|
||||
<t-select
|
||||
v-model="query.status"
|
||||
@ -96,7 +129,7 @@ onMounted(load);
|
||||
<t-button
|
||||
v-if="hasAccessByCodes(['system:login-log:list'])"
|
||||
theme="primary"
|
||||
@click="load"
|
||||
@click="search"
|
||||
>
|
||||
查询
|
||||
</t-button>
|
||||
@ -105,31 +138,9 @@ onMounted(load);
|
||||
</div>
|
||||
</t-card>
|
||||
|
||||
<!-- 登录日志列表 -->
|
||||
<!-- 登录日志列表(Vben Vxe Table) -->
|
||||
<t-card :bordered="false" class="wb-table-card">
|
||||
<t-table
|
||||
row-key="id"
|
||||
:data="list"
|
||||
:columns="columns"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
@page-change="onPageChange"
|
||||
>
|
||||
<template #status="{ row }">
|
||||
<t-tag
|
||||
:theme="row.status === 1 ? 'success' : 'danger'"
|
||||
variant="light"
|
||||
size="small"
|
||||
>
|
||||
{{ row.status === 1 ? '成功' : '失败' }}
|
||||
</t-tag>
|
||||
</template>
|
||||
<template #failReason="{ row }">
|
||||
<span :title="row.failReason">{{
|
||||
(row.failReason || '-').slice(0, 30)
|
||||
}}</span>
|
||||
</template>
|
||||
</t-table>
|
||||
<Grid />
|
||||
</t-card>
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
@ -4,7 +4,7 @@ import type { MenuNode } from '#/api/system';
|
||||
import { nextTick, onMounted, reactive, ref } from 'vue';
|
||||
|
||||
import { useAccess } from '@vben/access';
|
||||
import { Page } from '@vben/common-ui';
|
||||
import { Page, useVbenDrawer } from '@vben/common-ui';
|
||||
|
||||
import { DialogPlugin, MessagePlugin } from 'tdesign-vue-next';
|
||||
|
||||
@ -17,9 +17,14 @@ const loading = ref(false);
|
||||
const tree = ref<MenuNode[]>([]);
|
||||
// t-enhanced-table 实例:异步数据加载完成后手动展开全部节点
|
||||
const tableRef = ref<any>(null);
|
||||
const dialogOpen = ref(false);
|
||||
const editing = ref<MenuNode | null>(null);
|
||||
|
||||
// 新增/编辑菜单抽屉(vben 封装抽屉组件)
|
||||
const [Drawer, drawerApi] = useVbenDrawer({
|
||||
title: '新增菜单',
|
||||
onConfirm: submit,
|
||||
});
|
||||
|
||||
const form = reactive({
|
||||
parentId: 0,
|
||||
name: '',
|
||||
@ -60,7 +65,8 @@ function openCreate(parent?: MenuNode) {
|
||||
status: 1,
|
||||
hidden: false,
|
||||
});
|
||||
dialogOpen.value = true;
|
||||
drawerApi.setState({ title: '新增菜单' });
|
||||
drawerApi.open();
|
||||
}
|
||||
|
||||
function openEdit(row: MenuNode) {
|
||||
@ -77,17 +83,17 @@ function openEdit(row: MenuNode) {
|
||||
status: row.status,
|
||||
hidden: row.hidden,
|
||||
});
|
||||
dialogOpen.value = true;
|
||||
drawerApi.setState({ title: '编辑菜单' });
|
||||
drawerApi.open();
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (editing.value) {
|
||||
await updateMenu(editing.value.id, { ...form });
|
||||
} else {
|
||||
await createMenu({ ...form });
|
||||
}
|
||||
// 编辑走更新、新增走创建
|
||||
editing.value
|
||||
? await updateMenu(editing.value.id, { ...form })
|
||||
: await createMenu({ ...form });
|
||||
MessagePlugin.success('保存成功');
|
||||
dialogOpen.value = false;
|
||||
drawerApi.close();
|
||||
load();
|
||||
}
|
||||
|
||||
@ -124,7 +130,7 @@ onMounted(load);
|
||||
</div>
|
||||
|
||||
<t-card :bordered="false" class="wb-filter-card">
|
||||
<div class="mb-3 flex justify-end">
|
||||
<div class="flex justify-end">
|
||||
<t-button
|
||||
v-if="hasAccessByCodes(['system:menu:create'])"
|
||||
theme="primary"
|
||||
@ -154,10 +160,8 @@ onMounted(load);
|
||||
{{ row.name }}
|
||||
</template>
|
||||
<template #typeText="{ row }">
|
||||
{{
|
||||
row.type === 1 ? '菜单' : '按钮'
|
||||
}}
|
||||
</template>
|
||||
{{ row.type === 1 ? '菜单' : '按钮' }}
|
||||
</template>
|
||||
<template #op="{ row }">
|
||||
<t-button
|
||||
v-if="hasAccessByCodes(['system:menu:create'])"
|
||||
@ -190,11 +194,8 @@ onMounted(load);
|
||||
</t-enhanced-table>
|
||||
</t-card>
|
||||
|
||||
<t-dialog
|
||||
v-model:visible="dialogOpen"
|
||||
:header="editing ? '编辑菜单' : '新增菜单'"
|
||||
:on-confirm="submit"
|
||||
>
|
||||
<!-- 新增/编辑菜单抽屉(vben 封装抽屉组件) -->
|
||||
<Drawer>
|
||||
<t-form label-align="right" label-width="80px">
|
||||
<t-form-item label="上级 ID">
|
||||
<t-input-number v-model="form.parentId" :min="0" />
|
||||
@ -239,6 +240,6 @@ onMounted(load);
|
||||
<t-switch v-model="form.hidden" />
|
||||
</t-form-item>
|
||||
</t-form>
|
||||
</t-dialog>
|
||||
</Drawer>
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
152
apps/web-tdesign/src/views/system/notice/channel/index.vue
Normal file
152
apps/web-tdesign/src/views/system/notice/channel/index.vue
Normal file
@ -0,0 +1,152 @@
|
||||
<script lang="ts" setup>
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
|
||||
import { h, onMounted, reactive, ref } from 'vue';
|
||||
|
||||
import { Page, useVbenDrawer, VbenTableAction } from '@vben/common-ui';
|
||||
|
||||
import { MessagePlugin, Tag } from 'tdesign-vue-next';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import {
|
||||
getNoticeChannelList,
|
||||
saveNoticeChannel,
|
||||
type NoticeChannelItem,
|
||||
} from '#/api/notice';
|
||||
|
||||
// 通知渠道:Bark / pushplus 等推送通道的启用开关与配置
|
||||
const editing = ref<NoticeChannelItem | null>(null);
|
||||
const [Drawer, drawerApi] = useVbenDrawer({
|
||||
title: '编辑渠道',
|
||||
onConfirm: submit,
|
||||
});
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
code: '',
|
||||
enabled: 1,
|
||||
config: '',
|
||||
remark: '',
|
||||
});
|
||||
|
||||
function openEdit(row: NoticeChannelItem) {
|
||||
editing.value = row;
|
||||
Object.assign(form, {
|
||||
name: row.name,
|
||||
code: row.code,
|
||||
enabled: row.enabled,
|
||||
config: row.config ?? '',
|
||||
remark: row.remark ?? '',
|
||||
});
|
||||
drawerApi.setState({ title: `编辑渠道「${row.name}」` });
|
||||
drawerApi.open();
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!editing.value) return;
|
||||
await saveNoticeChannel({ id: editing.value.id, ...form });
|
||||
MessagePlugin.success('保存成功');
|
||||
drawerApi.close();
|
||||
loadList();
|
||||
}
|
||||
|
||||
const gridOptions: VxeTableGridOptions = {
|
||||
columns: [
|
||||
{ type: 'seq', width: 50, title: '#' },
|
||||
{ field: 'code', title: '编码', width: 120 },
|
||||
{ field: 'name', title: '名称', width: 150 },
|
||||
{
|
||||
field: 'enabled',
|
||||
title: '启用',
|
||||
width: 90,
|
||||
slots: {
|
||||
default: ({ row }: { row: NoticeChannelItem }) =>
|
||||
h(
|
||||
Tag,
|
||||
{
|
||||
theme: row.enabled === 1 ? 'success' : 'default',
|
||||
variant: 'light',
|
||||
size: 'small',
|
||||
},
|
||||
row.enabled === 1 ? '启用' : '停用',
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'config',
|
||||
title: '配置(JSON)',
|
||||
minWidth: 220,
|
||||
showOverflow: 'tooltip',
|
||||
},
|
||||
{ field: 'remark', title: '备注', minWidth: 180, showOverflow: 'tooltip' },
|
||||
{
|
||||
field: 'op',
|
||||
title: '操作',
|
||||
width: 100,
|
||||
fixed: 'right',
|
||||
slots: {
|
||||
default: ({ row }: { row: NoticeChannelItem }) =>
|
||||
h(VbenTableAction, {
|
||||
actions: [{ text: '编辑', onClick: () => openEdit(row) }],
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
proxyConfig: { enabled: false },
|
||||
};
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({ gridOptions });
|
||||
|
||||
async function loadList() {
|
||||
const data = await getNoticeChannelList();
|
||||
gridApi.setGridOptions({ data: data.list ?? [] });
|
||||
}
|
||||
|
||||
onMounted(loadList);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<div class="wb-page-head">
|
||||
<h2 class="wb-page-title">通知渠道</h2>
|
||||
<p class="wb-page-sub">管理推送渠道的启用状态与配置(Bark 服务地址等)</p>
|
||||
</div>
|
||||
|
||||
<t-card :bordered="false" class="wb-filter-card">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-sm text-gray-500">
|
||||
停用渠道后,所有通知规则将不再通过该渠道发送
|
||||
</div>
|
||||
<t-button theme="primary" @click="loadList">刷新</t-button>
|
||||
</div>
|
||||
</t-card>
|
||||
|
||||
<t-card :bordered="false" class="wb-table-card">
|
||||
<Grid />
|
||||
</t-card>
|
||||
|
||||
<Drawer>
|
||||
<t-form label-align="right" label-width="90px">
|
||||
<t-form-item label="编码" required>
|
||||
<t-input v-model="form.code" disabled placeholder="bark / pushplus" />
|
||||
</t-form-item>
|
||||
<t-form-item label="名称" required>
|
||||
<t-input v-model="form.name" />
|
||||
</t-form-item>
|
||||
<t-form-item label="启用">
|
||||
<t-switch v-model="form.enabled" :custom-value="[1, 0]" />
|
||||
</t-form-item>
|
||||
<t-form-item label="配置(JSON)">
|
||||
<t-textarea
|
||||
v-model="form.config"
|
||||
:autosize="{ minRows: 2, maxRows: 4 }"
|
||||
placeholder='如 Bark:{"baseUrl":"http://bark:8080"}'
|
||||
/>
|
||||
</t-form-item>
|
||||
<t-form-item label="备注">
|
||||
<t-input v-model="form.remark" />
|
||||
</t-form-item>
|
||||
</t-form>
|
||||
</Drawer>
|
||||
</Page>
|
||||
</template>
|
||||
150
apps/web-tdesign/src/views/system/notice/log/index.vue
Normal file
150
apps/web-tdesign/src/views/system/notice/log/index.vue
Normal file
@ -0,0 +1,150 @@
|
||||
<script lang="ts" setup>
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
|
||||
import { h, reactive } from 'vue';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
|
||||
import { Tag } from 'tdesign-vue-next';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import {
|
||||
getNoticeLogList,
|
||||
type NoticeLogItem,
|
||||
} from '#/api/notice';
|
||||
|
||||
// 通知日志:查看每条通知的发送目标/渠道/结果
|
||||
const query = reactive({
|
||||
eventType: '',
|
||||
channelCode: '',
|
||||
result: 0,
|
||||
dateRange: [] as string[],
|
||||
});
|
||||
|
||||
function rangeToParam(): { dateFrom?: string; dateTo?: string } {
|
||||
if (query.dateRange.length === 2 && query.dateRange[0] && query.dateRange[1]) {
|
||||
return {
|
||||
dateFrom: `${query.dateRange[0]} 00:00:00`,
|
||||
dateTo: `${query.dateRange[1]} 23:59:59`,
|
||||
};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
const gridOptions: VxeTableGridOptions = {
|
||||
columns: [
|
||||
{ type: 'seq', width: 50, title: '#' },
|
||||
{ field: 'createdAt', title: '时间', width: 165 },
|
||||
{ field: 'eventType', title: '事件', width: 110 },
|
||||
{ field: 'channelCode', title: '渠道', width: 100 },
|
||||
{ field: 'target', title: '目标', width: 140, showOverflow: 'tooltip' },
|
||||
{ field: 'title', title: '标题', width: 180, showOverflow: 'tooltip' },
|
||||
{ field: 'body', title: '内容', minWidth: 200, showOverflow: 'tooltip' },
|
||||
{
|
||||
field: 'result',
|
||||
title: '结果',
|
||||
width: 90,
|
||||
slots: {
|
||||
default: ({ row }: { row: NoticeLogItem }) =>
|
||||
h(
|
||||
Tag,
|
||||
{
|
||||
theme: row.result === 1 ? 'success' : 'danger',
|
||||
variant: 'light',
|
||||
size: 'small',
|
||||
},
|
||||
row.result === 1 ? '成功' : '失败',
|
||||
),
|
||||
},
|
||||
},
|
||||
{ field: 'error', title: '错误', width: 160, showOverflow: 'tooltip' },
|
||||
],
|
||||
pagerConfig: { pageSize: 20 },
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({
|
||||
page,
|
||||
}: {
|
||||
page: { currentPage: number; pageSize: number };
|
||||
}) => {
|
||||
const data = await getNoticeLogList({
|
||||
page: page.currentPage,
|
||||
size: page.pageSize,
|
||||
eventType: query.eventType || undefined,
|
||||
channelCode: query.channelCode || undefined,
|
||||
result: query.result || undefined,
|
||||
...rangeToParam(),
|
||||
});
|
||||
return { items: data.list ?? [], total: data.total ?? 0 };
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({ gridOptions });
|
||||
|
||||
function search() {
|
||||
gridApi.query();
|
||||
}
|
||||
|
||||
function reset() {
|
||||
query.eventType = '';
|
||||
query.channelCode = '';
|
||||
query.result = 0;
|
||||
query.dateRange = [];
|
||||
gridApi.reload();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<div class="wb-page-head">
|
||||
<h2 class="wb-page-title">通知日志</h2>
|
||||
<p class="wb-page-sub">每条通知的发送记录:目标、渠道、结果与错误信息</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-3 flex flex-wrap items-center gap-2">
|
||||
<t-select
|
||||
v-model="query.eventType"
|
||||
clearable
|
||||
:options="[
|
||||
{ label: '任务完成', value: 'job_done' },
|
||||
{ label: '任务失败', value: 'job_fail' },
|
||||
{ label: '安全告警', value: 'security_alert' },
|
||||
{ label: '测试', value: 'test' },
|
||||
]"
|
||||
placeholder="事件类型"
|
||||
style="width: 140px"
|
||||
/>
|
||||
<t-input
|
||||
v-model="query.channelCode"
|
||||
clearable
|
||||
placeholder="渠道(bark/pushplus)"
|
||||
style="width: 150px"
|
||||
@enter="search"
|
||||
/>
|
||||
<t-select
|
||||
v-model="query.result"
|
||||
clearable
|
||||
:options="[
|
||||
{ label: '成功', value: 1 },
|
||||
{ label: '失败', value: 2 },
|
||||
]"
|
||||
placeholder="结果"
|
||||
style="width: 110px"
|
||||
/>
|
||||
<t-date-range-picker
|
||||
v-model="query.dateRange"
|
||||
clearable
|
||||
placeholder="时间范围"
|
||||
style="width: 260px"
|
||||
/>
|
||||
<t-button theme="primary" @click="search">查询</t-button>
|
||||
<t-button variant="outline" @click="reset">重置</t-button>
|
||||
</div>
|
||||
|
||||
<t-card :bordered="false" class="wb-table-card">
|
||||
<Grid />
|
||||
</t-card>
|
||||
</Page>
|
||||
</template>
|
||||
298
apps/web-tdesign/src/views/system/notice/rule/index.vue
Normal file
298
apps/web-tdesign/src/views/system/notice/rule/index.vue
Normal file
@ -0,0 +1,298 @@
|
||||
<script lang="ts" setup>
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
|
||||
import { h, onMounted, reactive, ref } from 'vue';
|
||||
|
||||
import { Page, useVbenDrawer, VbenTableAction } from '@vben/common-ui';
|
||||
|
||||
import { DialogPlugin, MessagePlugin, Tag } from 'tdesign-vue-next';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import {
|
||||
deleteNoticeRule,
|
||||
getNoticeChannelList,
|
||||
getNoticeRuleList,
|
||||
NOTICE_EVENT_OPTIONS,
|
||||
saveNoticeRule,
|
||||
testNotice,
|
||||
type NoticeChannelItem,
|
||||
type NoticeRuleItem,
|
||||
} from '#/api/notice';
|
||||
import { getAdminList } from '#/api/system';
|
||||
|
||||
// 通知规则:事件类型 → 渠道 → 接收用户 → 模板(统一推送中枢的"规则")
|
||||
const channelOptions = ref<{ label: string; value: string }[]>([]);
|
||||
const userOptions = ref<{ label: string; value: number }[]>([]);
|
||||
const editing = ref<NoticeRuleItem | null>(null);
|
||||
|
||||
const [Drawer, drawerApi] = useVbenDrawer({
|
||||
title: '新增规则',
|
||||
onConfirm: submit,
|
||||
});
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
eventType: '',
|
||||
channelCodes: [] as string[],
|
||||
userIds: [] as number[],
|
||||
titleTemplate: '',
|
||||
bodyTemplate: '',
|
||||
enabled: 1,
|
||||
});
|
||||
|
||||
async function loadBase() {
|
||||
const ch = await getNoticeChannelList();
|
||||
channelOptions.value = (ch.list ?? []).map((c: NoticeChannelItem) => ({
|
||||
label: `${c.name}${c.enabled === 1 ? '' : '(停用)'}`,
|
||||
value: c.code,
|
||||
}));
|
||||
const us = await getAdminList({ page: 1, size: 100 });
|
||||
userOptions.value = (us.list ?? []).map((u) => ({
|
||||
label: `${u.nickname || u.username}(${u.username})`,
|
||||
value: u.id,
|
||||
}));
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editing.value = null;
|
||||
Object.assign(form, {
|
||||
name: '',
|
||||
eventType: '',
|
||||
channelCodes: [],
|
||||
userIds: [],
|
||||
titleTemplate: '',
|
||||
bodyTemplate: '',
|
||||
enabled: 1,
|
||||
});
|
||||
drawerApi.setState({ title: '新增规则' });
|
||||
drawerApi.open();
|
||||
}
|
||||
|
||||
function openEdit(row: NoticeRuleItem) {
|
||||
editing.value = row;
|
||||
Object.assign(form, {
|
||||
name: row.name,
|
||||
eventType: row.eventType,
|
||||
channelCodes: row.channelCodes ?? [],
|
||||
userIds: row.userIds ?? [],
|
||||
titleTemplate: row.titleTemplate ?? '',
|
||||
bodyTemplate: row.bodyTemplate ?? '',
|
||||
enabled: row.enabled,
|
||||
});
|
||||
drawerApi.setState({ title: '编辑规则' });
|
||||
drawerApi.open();
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
editing.value
|
||||
? await saveNoticeRule({ ...form, id: editing.value.id })
|
||||
: await saveNoticeRule(form);
|
||||
MessagePlugin.success('保存成功');
|
||||
drawerApi.close();
|
||||
gridApi.query();
|
||||
}
|
||||
|
||||
function onDelete(row: NoticeRuleItem) {
|
||||
const d = DialogPlugin.confirm({
|
||||
header: `确认删除规则「${row.name}」?`,
|
||||
onConfirm: async () => {
|
||||
await deleteNoticeRule({ id: row.id });
|
||||
MessagePlugin.success('删除成功');
|
||||
gridApi.query();
|
||||
d.destroy();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 测试该规则:输入标题/内容发送一条 */
|
||||
function onTest(row: NoticeRuleItem) {
|
||||
let title = '';
|
||||
let body = '';
|
||||
const d = DialogPlugin.confirm({
|
||||
header: `测试规则「${row.name}」`,
|
||||
body: () =>
|
||||
h('div', { class: 'flex flex-col gap-2' }, [
|
||||
h('input', {
|
||||
class: 't-input__inner',
|
||||
placeholder: '标题(留空用模板默认)',
|
||||
style: 'padding:6px;border:1px solid #dcdcdc;border-radius:4px',
|
||||
onInput: (e: Event) => (title = (e.target as HTMLInputElement).value),
|
||||
}),
|
||||
h('input', {
|
||||
class: 't-input__inner',
|
||||
placeholder: '内容(留空用模板默认)',
|
||||
style: 'padding:6px;border:1px solid #dcdcdc;border-radius:4px;margin-top:6px',
|
||||
onInput: (e: Event) => (body = (e.target as HTMLInputElement).value),
|
||||
}),
|
||||
]),
|
||||
onConfirm: async () => {
|
||||
const r = await testNotice({
|
||||
ruleId: row.id,
|
||||
title: title || undefined,
|
||||
body: body || undefined,
|
||||
});
|
||||
MessagePlugin[r.result ? 'success' : 'error'](
|
||||
r.result ? `测试发送成功:${r.message}` : `发送失败:${r.message}`,
|
||||
);
|
||||
d.destroy();
|
||||
gridApi.query();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const gridOptions: VxeTableGridOptions = {
|
||||
columns: [
|
||||
{ type: 'seq', width: 50, title: '#' },
|
||||
{ field: 'name', title: '规则名称', width: 140 },
|
||||
{ field: 'eventType', title: '事件类型', width: 120 },
|
||||
{
|
||||
field: 'channelCodesText',
|
||||
title: '渠道',
|
||||
width: 160,
|
||||
slots: {
|
||||
default: ({ row }: { row: NoticeRuleItem }) =>
|
||||
h(
|
||||
'span',
|
||||
(row.channelCodes ?? []).map((c) => c.toUpperCase()).join(' / ') || '-',
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'userIdsText',
|
||||
title: '接收用户',
|
||||
width: 120,
|
||||
slots: {
|
||||
default: ({ row }: { row: NoticeRuleItem }) =>
|
||||
h('span', `ID: ${(row.userIds ?? []).join(',')}` || '-'),
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'enabled',
|
||||
title: '启用',
|
||||
width: 80,
|
||||
slots: {
|
||||
default: ({ row }: { row: NoticeRuleItem }) =>
|
||||
h(
|
||||
Tag,
|
||||
{
|
||||
theme: row.enabled === 1 ? 'success' : 'default',
|
||||
variant: 'light',
|
||||
size: 'small',
|
||||
},
|
||||
row.enabled === 1 ? '启用' : '停用',
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'op',
|
||||
title: '操作',
|
||||
width: 180,
|
||||
fixed: 'right',
|
||||
slots: {
|
||||
default: ({ row }: { row: NoticeRuleItem }) =>
|
||||
h(VbenTableAction, {
|
||||
actions: [
|
||||
{ text: '编辑', onClick: () => openEdit(row) },
|
||||
{ text: '测试', onClick: () => onTest(row) },
|
||||
{
|
||||
text: '删除',
|
||||
danger: true,
|
||||
onClick: () => onDelete(row),
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
proxyConfig: { enabled: false }, // 本地数据:onMounted 加载
|
||||
};
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({ gridOptions });
|
||||
|
||||
async function loadList() {
|
||||
const data = await getNoticeRuleList({});
|
||||
gridApi.setGridOptions({
|
||||
data: data.list ?? [],
|
||||
});
|
||||
}
|
||||
|
||||
function search() {
|
||||
loadList();
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadBase();
|
||||
await loadList();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<div class="wb-page-head">
|
||||
<h2 class="wb-page-title">通知规则</h2>
|
||||
<p class="wb-page-sub">
|
||||
定义事件类型 → 通知渠道 → 接收用户 → 标题/内容模板;任务完成/失败、安全告警等均走这里统一下发
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<t-card :bordered="false" class="wb-filter-card">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<t-button theme="primary" @click="search">刷新</t-button>
|
||||
</div>
|
||||
<t-button theme="primary" @click="openCreate">新增规则</t-button>
|
||||
</div>
|
||||
</t-card>
|
||||
|
||||
<t-card :bordered="false" class="wb-table-card">
|
||||
<Grid />
|
||||
</t-card>
|
||||
|
||||
<Drawer>
|
||||
<t-form label-align="right" label-width="90px">
|
||||
<t-form-item label="规则名称" required>
|
||||
<t-input v-model="form.name" placeholder="如:任务失败告警" />
|
||||
</t-form-item>
|
||||
<t-form-item label="事件类型" required>
|
||||
<t-select
|
||||
v-model="form.eventType"
|
||||
:options="NOTICE_EVENT_OPTIONS"
|
||||
placeholder="选择事件类型"
|
||||
/>
|
||||
</t-form-item>
|
||||
<t-form-item label="通知渠道" required>
|
||||
<t-select
|
||||
v-model="form.channelCodes"
|
||||
multiple
|
||||
:options="channelOptions"
|
||||
placeholder="选择渠道(Bark/pushplus)"
|
||||
/>
|
||||
</t-form-item>
|
||||
<t-form-item label="接收用户" required>
|
||||
<t-select
|
||||
v-model="form.userIds"
|
||||
multiple
|
||||
:options="userOptions"
|
||||
placeholder="选择接收用户"
|
||||
/>
|
||||
</t-form-item>
|
||||
<t-form-item label="标题模板">
|
||||
<t-input
|
||||
v-model="form.titleTemplate"
|
||||
placeholder="支持 {jobName} {summary} {error} 等占位符"
|
||||
/>
|
||||
</t-form-item>
|
||||
<t-form-item label="内容模板">
|
||||
<t-textarea
|
||||
v-model="form.bodyTemplate"
|
||||
:autosize="{ minRows: 3, maxRows: 6 }"
|
||||
placeholder="支持 {jobName} {summary} {error}"
|
||||
/>
|
||||
</t-form-item>
|
||||
<t-form-item label="启用">
|
||||
<t-switch v-model="form.enabled" :custom-value="[1, 0]" />
|
||||
</t-form-item>
|
||||
</t-form>
|
||||
</Drawer>
|
||||
</Page>
|
||||
</template>
|
||||
@ -1,13 +1,15 @@
|
||||
<script lang="ts" setup>
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { MenuNode, RoleItem } from '#/api/system';
|
||||
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import { h, onMounted, reactive, ref } from 'vue';
|
||||
|
||||
import { useAccess } from '@vben/access';
|
||||
import { Page } from '@vben/common-ui';
|
||||
import { Page, useVbenDrawer, VbenTableAction } from '@vben/common-ui';
|
||||
|
||||
import { DialogPlugin, MessagePlugin } from 'tdesign-vue-next';
|
||||
import { DialogPlugin, MessagePlugin, Tag } from 'tdesign-vue-next';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import {
|
||||
createRole,
|
||||
deleteRole,
|
||||
@ -16,16 +18,20 @@ import {
|
||||
updateRole,
|
||||
} from '#/api/system';
|
||||
|
||||
// 角色管理页面:Vben Vxe Table + Vben TableAction 行操作(权限码控制显隐)
|
||||
const { hasAccessByCodes } = useAccess();
|
||||
|
||||
const loading = ref(false);
|
||||
const list = ref<RoleItem[]>([]);
|
||||
const menuTree = ref<any[]>([]);
|
||||
const dialogOpen = ref(false);
|
||||
const editing = ref<null | RoleItem>(null);
|
||||
|
||||
const query = reactive({ page: 1, size: 10, keyword: '' });
|
||||
const pagination = reactive({ current: 1, pageSize: 10, total: 0 });
|
||||
// 新增/编辑角色抽屉(vben 封装抽屉组件)
|
||||
const [Drawer, drawerApi] = useVbenDrawer({
|
||||
title: '新增角色',
|
||||
onConfirm: submit,
|
||||
});
|
||||
|
||||
// 筛选条件(分页由 grid 自动管理)
|
||||
const query = reactive({ keyword: '' });
|
||||
const form = reactive({
|
||||
code: '',
|
||||
name: '',
|
||||
@ -33,6 +39,7 @@ const form = reactive({
|
||||
menuIds: [] as number[],
|
||||
});
|
||||
|
||||
/** 后端菜单树转 TDesign 树选择器数据 */
|
||||
function toTree(nodes: MenuNode[]): any[] {
|
||||
return (nodes ?? []).map((n) => ({
|
||||
label: n.name,
|
||||
@ -41,17 +48,6 @@ function toTree(nodes: MenuNode[]): any[] {
|
||||
}));
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await getRoleList(query);
|
||||
list.value = data.list ?? [];
|
||||
pagination.total = data.total ?? 0;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMenus() {
|
||||
const data = await getMenuTree();
|
||||
menuTree.value = toTree(data.tree ?? []);
|
||||
@ -60,7 +56,8 @@ async function loadMenus() {
|
||||
function openCreate() {
|
||||
editing.value = null;
|
||||
Object.assign(form, { code: '', name: '', status: 1, menuIds: [] });
|
||||
dialogOpen.value = true;
|
||||
drawerApi.setState({ title: '新增角色' });
|
||||
drawerApi.open();
|
||||
}
|
||||
|
||||
function openEdit(row: RoleItem) {
|
||||
@ -71,62 +68,120 @@ function openEdit(row: RoleItem) {
|
||||
status: row.status,
|
||||
menuIds: row.menuIds ?? [],
|
||||
});
|
||||
dialogOpen.value = true;
|
||||
drawerApi.setState({ title: '编辑角色' });
|
||||
drawerApi.open();
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (editing.value) {
|
||||
await updateRole(editing.value.id, {
|
||||
name: form.name,
|
||||
status: form.status,
|
||||
menuIds: form.menuIds,
|
||||
});
|
||||
} else {
|
||||
await createRole({
|
||||
code: form.code,
|
||||
name: form.name,
|
||||
status: form.status,
|
||||
menuIds: form.menuIds,
|
||||
});
|
||||
}
|
||||
// 编辑走更新、新增走创建
|
||||
editing.value
|
||||
? await updateRole(editing.value.id, {
|
||||
name: form.name,
|
||||
status: form.status,
|
||||
menuIds: form.menuIds,
|
||||
})
|
||||
: await createRole({
|
||||
code: form.code,
|
||||
name: form.name,
|
||||
status: form.status,
|
||||
menuIds: form.menuIds,
|
||||
});
|
||||
MessagePlugin.success('保存成功');
|
||||
dialogOpen.value = false;
|
||||
load();
|
||||
drawerApi.close();
|
||||
gridApi.query();
|
||||
}
|
||||
|
||||
/** 删除角色(保留原确认弹窗行为) */
|
||||
function onDelete(row: RoleItem) {
|
||||
const d = DialogPlugin.confirm({
|
||||
header: `确认删除角色「${row.name}」?`,
|
||||
onConfirm: async () => {
|
||||
await deleteRole(row.id);
|
||||
MessagePlugin.success('删除成功');
|
||||
load();
|
||||
gridApi.query();
|
||||
d.destroy();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ colKey: 'id', title: 'ID', width: 70 },
|
||||
{ colKey: 'code', title: '角色编码' },
|
||||
{ colKey: 'name', title: '角色名称' },
|
||||
{ colKey: 'statusText', title: '状态', width: 90 },
|
||||
{ colKey: 'createdAt', title: '创建时间', width: 170 },
|
||||
{ colKey: 'op', title: '操作', width: 150 },
|
||||
];
|
||||
const gridOptions: VxeTableGridOptions = {
|
||||
columns: [
|
||||
{ type: 'seq', width: 50, title: '#' },
|
||||
{ field: 'id', title: 'ID', width: 70 },
|
||||
{ field: 'code', title: '角色编码' },
|
||||
{ field: 'name', title: '角色名称' },
|
||||
{
|
||||
field: 'statusText',
|
||||
title: '状态',
|
||||
width: 90,
|
||||
slots: {
|
||||
default: ({ row }: { row: RoleItem }) =>
|
||||
h(
|
||||
Tag,
|
||||
{
|
||||
theme: row.status === 1 ? 'success' : 'danger',
|
||||
variant: 'light',
|
||||
size: 'small',
|
||||
},
|
||||
row.status === 1 ? '启用' : '禁用',
|
||||
),
|
||||
},
|
||||
},
|
||||
{ field: 'createdAt', title: '创建时间', width: 170 },
|
||||
{
|
||||
field: 'op',
|
||||
title: '操作',
|
||||
width: 150,
|
||||
fixed: 'right',
|
||||
slots: {
|
||||
default: ({ row }: { row: RoleItem }) =>
|
||||
h(VbenTableAction, {
|
||||
actions: [
|
||||
// 编辑:按权限码显隐
|
||||
{
|
||||
text: '编辑',
|
||||
onClick: () => openEdit(row),
|
||||
auth: ['system:role:update'],
|
||||
},
|
||||
// 删除(危险操作标红)
|
||||
{
|
||||
text: '删除',
|
||||
danger: true,
|
||||
onClick: () => onDelete(row),
|
||||
auth: ['system:role:delete'],
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
pagerConfig: { pageSize: 10 },
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({
|
||||
page,
|
||||
}: {
|
||||
page: { currentPage: number; pageSize: number };
|
||||
}) => {
|
||||
const data = await getRoleList({
|
||||
page: page.currentPage,
|
||||
size: page.pageSize,
|
||||
keyword: query.keyword || undefined,
|
||||
});
|
||||
return { items: data.list ?? [], total: data.total ?? 0 };
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function onPageChange(p: { current: number; pageSize: number }) {
|
||||
query.page = p.current;
|
||||
query.size = p.pageSize;
|
||||
pagination.current = p.current;
|
||||
pagination.pageSize = p.pageSize;
|
||||
load();
|
||||
const [Grid, gridApi] = useVbenVxeGrid({ gridOptions });
|
||||
|
||||
/** 触发查询(保留分页条件,因 grid 自动管理分页) */
|
||||
function search() {
|
||||
gridApi.query();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
load();
|
||||
loadMenus();
|
||||
});
|
||||
onMounted(loadMenus);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -137,16 +192,16 @@ onMounted(() => {
|
||||
</div>
|
||||
|
||||
<t-card :bordered="false" class="wb-filter-card">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<t-input
|
||||
v-model="query.keyword"
|
||||
clearable
|
||||
placeholder="角色编码/名称"
|
||||
style="width: 220px"
|
||||
@enter="load"
|
||||
@enter="search"
|
||||
/>
|
||||
<t-button theme="primary" @click="load">查询</t-button>
|
||||
<t-button theme="primary" @click="search">查询</t-button>
|
||||
</div>
|
||||
<t-button
|
||||
v-if="hasAccessByCodes(['system:role:create'])"
|
||||
@ -158,49 +213,13 @@ onMounted(() => {
|
||||
</div>
|
||||
</t-card>
|
||||
|
||||
<!-- 角色列表(Vben Vxe Table + TableAction) -->
|
||||
<t-card :bordered="false" class="wb-table-card">
|
||||
<t-table
|
||||
row-key="id"
|
||||
:data="list"
|
||||
:columns="columns"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
@page-change="onPageChange"
|
||||
>
|
||||
<template #statusText="{ row }">
|
||||
{{
|
||||
row.status === 1 ? '启用' : '禁用'
|
||||
}}
|
||||
</template>
|
||||
<template #op="{ row }">
|
||||
<t-button
|
||||
v-if="hasAccessByCodes(['system:role:update'])"
|
||||
size="small"
|
||||
theme="primary"
|
||||
variant="text"
|
||||
@click="openEdit(row)"
|
||||
>
|
||||
编辑
|
||||
</t-button>
|
||||
<t-button
|
||||
v-if="hasAccessByCodes(['system:role:delete'])"
|
||||
size="small"
|
||||
theme="danger"
|
||||
variant="text"
|
||||
@click="onDelete(row)"
|
||||
>
|
||||
删除
|
||||
</t-button>
|
||||
</template>
|
||||
</t-table>
|
||||
<Grid />
|
||||
</t-card>
|
||||
|
||||
<t-dialog
|
||||
v-model:visible="dialogOpen"
|
||||
:header="editing ? '编辑角色' : '新增角色'"
|
||||
width="560px"
|
||||
:on-confirm="submit"
|
||||
>
|
||||
<!-- 新增/编辑角色抽屉(vben 封装抽屉组件) -->
|
||||
<Drawer>
|
||||
<t-form label-align="right" label-width="80px">
|
||||
<t-form-item label="角色编码" required>
|
||||
<t-input
|
||||
@ -226,6 +245,6 @@ onMounted(() => {
|
||||
/>
|
||||
</t-form-item>
|
||||
</t-form>
|
||||
</t-dialog>
|
||||
</Drawer>
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
42
dev_log.txt
42
dev_log.txt
@ -1,42 +0,0 @@
|
||||
$ pnpm -F @vben/web-tdesign run dev
|
||||
. | [WARN] Unsupported engine: wanted: {"node":"^22.18.0 || ^24.12.0"} (current: {"node":"v23.0.0","pnpm":"11.16.0"})
|
||||
$ pnpm vite --mode development
|
||||
[Vben Form] BREAKING CHANGE: Named field slot control bindings moved to `slotProps.componentProps`. Replace `v-bind="slotProps"` with `v-bind="slotProps.componentProps"`. See https://doc.vben.pro/components/common-ui/vben-form.html
|
||||
Port 5999 is in use, trying another one...
|
||||
|
||||
[32m[1mVITE[22m v8.2.2[39m [2mready in [0m[1m2781[22m[2m[0m ms[22m
|
||||
|
||||
[32m➜[39m [1mLocal[22m: [36mhttp://localhost:[1m6000[22m/[39m
|
||||
[32m➜[39m [1mNetwork[22m: [36mhttp://192.168.110.65:[1m6000[22m/[39m [2mWLAN[22m
|
||||
[32m➜[39m [1mNetwork[22m: [36mhttp://172.20.192.1:[1m6000[22m/[39m [2mvEthernet (Default …[22m
|
||||
➜ Vben Admin Docs: https://doc.vben.pro
|
||||
[2m[32m ➜[39m[22m[2m press [22m[1mh + enter[22m[2m to show help[22m
|
||||
[2m17:20:13[22m [36m[1m[vite][22m[39m [32m.env.development changed, restarting server...[39m
|
||||
[Vben Form] BREAKING CHANGE: Named field slot control bindings moved to `slotProps.componentProps`. Replace `v-bind="slotProps"` with `v-bind="slotProps.componentProps"`. See https://doc.vben.pro/components/common-ui/vben-form.html
|
||||
[2m17:20:14[22m [36m[1m[vite][22m[39m server restarted.
|
||||
|
||||
[32m➜[39m [1mLocal[22m: [36mhttp://localhost:[1m20000[22m/[39m
|
||||
[32m➜[39m [1mNetwork[22m: [36mhttp://192.168.110.65:[1m20000[22m/[39m [2mWLAN[22m
|
||||
[32m➜[39m [1mNetwork[22m: [36mhttp://172.20.192.1:[1m20000[22m/[39m [2mvEthernet (Default …[22m
|
||||
➜ Vben Admin Docs: https://doc.vben.pro
|
||||
[2m17:20:53[22m [36m[1m[vite][22m[39m [32m.env.development changed, restarting server...[39m
|
||||
[Vben Form] BREAKING CHANGE: Named field slot control bindings moved to `slotProps.componentProps`. Replace `v-bind="slotProps"` with `v-bind="slotProps.componentProps"`. See https://doc.vben.pro/components/common-ui/vben-form.html
|
||||
Port 20100 is in use, trying another one...
|
||||
[2m17:20:54[22m [36m[1m[vite][22m[39m server restarted.
|
||||
|
||||
[32m➜[39m [1mLocal[22m: [36mhttp://localhost:[1m20101[22m/[39m
|
||||
[32m➜[39m [1mNetwork[22m: [36mhttp://192.168.110.65:[1m20101[22m/[39m [2mWLAN[22m
|
||||
[32m➜[39m [1mNetwork[22m: [36mhttp://172.20.192.1:[1m20101[22m/[39m [2mvEthernet (Default …[22m
|
||||
➜ Vben Admin Docs: https://doc.vben.pro
|
||||
[2m18:53:59[22m [36m[1m[vite][22m[39m [32mvite.config.ts changed, restarting server...[39m
|
||||
[Vben Form] BREAKING CHANGE: Named field slot control bindings moved to `slotProps.componentProps`. Replace `v-bind="slotProps"` with `v-bind="slotProps.componentProps"`. See https://doc.vben.pro/components/common-ui/vben-form.html
|
||||
[2m18:54:00[22m [36m[1m[vite][22m[39m server restarted.
|
||||
[2m18:54:09[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mpage reload [39m[2msrc/api/core/auth.ts[22m
|
||||
[2m18:54:13[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mpage reload [39m[2msrc/api/core/menu.ts[22m
|
||||
[2m18:54:16[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mpage reload [39m[2msrc/api/core/user.ts[22m
|
||||
[2m18:54:22[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mpage reload [39m[2msrc/api/system.ts[22m
|
||||
[2m18:54:25[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mpage reload [39m[2msrc/api/log.ts[22m
|
||||
E:\xxcool\project\admin.xpcool.com\apps\web-tdesign:
|
||||
[ERR_PNPM_RECURSIVE_RUN_FIRST_FAIL] @vben/web-tdesign@5.7.0 dev: `pnpm vite --mode development`
|
||||
Exit status 4294967295
|
||||
[ELIFECYCLE] Command failed with exit code 4294967295.
|
||||
@ -119,7 +119,7 @@ const defaultPreferences: Preferences = {
|
||||
theme: {
|
||||
builtinType: 'default',
|
||||
colorDestructive: 'hsl(348 100% 61%)',
|
||||
colorPrimary: 'hsl(212 100% 45%)',
|
||||
colorPrimary: 'hsl(235 100% 65%)',
|
||||
colorSuccess: 'hsl(144 57% 58%)',
|
||||
colorWarning: 'hsl(42 84% 61%)',
|
||||
mode: 'dark',
|
||||
@ -140,8 +140,9 @@ const defaultPreferences: Preferences = {
|
||||
fullscreenButtonPosition: 'header',
|
||||
globalSearch: true,
|
||||
globalSearchButtonPosition: 'header',
|
||||
languageToggle: true,
|
||||
languageToggleButtonPosition: 'header',
|
||||
// 移除国际化:不再显示语言切换按钮
|
||||
languageToggle: false,
|
||||
languageToggleButtonPosition: 'none',
|
||||
lockScreen: true,
|
||||
lockScreenButtonPosition: 'header',
|
||||
logoutButtonPosition: 'header',
|
||||
@ -158,7 +159,6 @@ const defaultPreferences: Preferences = {
|
||||
'globalSearch',
|
||||
'preferences',
|
||||
'themeToggle',
|
||||
'languageToggle',
|
||||
'timezone',
|
||||
'fullscreen',
|
||||
'refresh',
|
||||
|
||||
@ -9,7 +9,7 @@ interface BuiltinThemePreset {
|
||||
|
||||
const BUILT_IN_THEME_PRESETS: BuiltinThemePreset[] = [
|
||||
{
|
||||
color: 'hsl(212 100% 45%)',
|
||||
color: 'hsl(235 100% 65%)',
|
||||
type: 'default',
|
||||
},
|
||||
{
|
||||
|
||||
@ -8,7 +8,6 @@ import { preferences } from '@vben/preferences';
|
||||
import {
|
||||
AuthenticationColorToggle,
|
||||
AuthenticationLayoutToggle,
|
||||
LanguageToggle,
|
||||
ThemeToggle,
|
||||
} from '../widgets';
|
||||
|
||||
@ -21,12 +20,12 @@ defineOptions({
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
toolbarList: () => ['color', 'language', 'layout', 'theme'],
|
||||
// 移除国际化后,语言切换不再展示
|
||||
toolbarList: () => ['color', 'layout', 'theme'],
|
||||
});
|
||||
|
||||
const showColor = computed(() => props.toolbarList.includes('color'));
|
||||
const showLayout = computed(() => props.toolbarList.includes('layout'));
|
||||
const showLanguage = computed(() => props.toolbarList.includes('language'));
|
||||
const showTheme = computed(() => props.toolbarList.includes('theme'));
|
||||
</script>
|
||||
|
||||
@ -42,8 +41,7 @@ const showTheme = computed(() => props.toolbarList.includes('theme'));
|
||||
<AuthenticationColorToggle v-if="showColor" />
|
||||
<AuthenticationLayoutToggle v-if="showLayout" />
|
||||
</div>
|
||||
<!-- Always show Language and Theme toggles -->
|
||||
<LanguageToggle v-if="showLanguage && preferences.widget.languageToggle" />
|
||||
<!-- 仅展示主题切换 -->
|
||||
<ThemeToggle v-if="showTheme && preferences.widget.themeToggle" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -18,7 +18,6 @@ import { useMagicKeys, whenever } from '@vueuse/core';
|
||||
|
||||
import {
|
||||
GlobalSearch,
|
||||
LanguageToggle,
|
||||
LockScreenModal,
|
||||
Notification,
|
||||
PreferencesButton,
|
||||
@ -163,12 +162,6 @@ const rightSlots = computed(() => {
|
||||
preferences.widget.themeToggleButtonPosition === 'header',
|
||||
slotName: 'theme-toggle',
|
||||
},
|
||||
languageToggle: {
|
||||
visible:
|
||||
preferences.widget.languageToggle &&
|
||||
preferences.widget.languageToggleButtonPosition === 'header',
|
||||
slotName: 'language-toggle',
|
||||
},
|
||||
timezone: {
|
||||
visible:
|
||||
preferences.widget.timezone &&
|
||||
@ -337,14 +330,6 @@ function clearPreferencesAndLogout() {
|
||||
{{ $t('preferences.theme.title') }}
|
||||
</VbenTooltip>
|
||||
</template>
|
||||
<template v-else-if="slot.name === 'language-toggle'">
|
||||
<VbenTooltip side="bottom">
|
||||
<template #trigger>
|
||||
<LanguageToggle class="mr-1" />
|
||||
</template>
|
||||
{{ $t('preferences.widget.languageToggle') }}
|
||||
</VbenTooltip>
|
||||
</template>
|
||||
<template v-else-if="slot.name === 'fullscreen'">
|
||||
<VbenFullScreen
|
||||
class="mr-1"
|
||||
|
||||
@ -279,7 +279,7 @@ useEventListener('mousemove', () => {
|
||||
"
|
||||
:data-index="index"
|
||||
:data-search-item="index"
|
||||
class="group mb-3 flex-center w-full cursor-pointer rounded-lg bg-accent p-4"
|
||||
class="group flex-center w-full cursor-pointer rounded-lg bg-accent p-4"
|
||||
@click="handleEnter"
|
||||
@mouseenter="handleMouseenter"
|
||||
>
|
||||
|
||||
@ -2,7 +2,6 @@ export { default as Breadcrumb } from './breadcrumb.vue';
|
||||
export * from './check-updates';
|
||||
export { default as AuthenticationColorToggle } from './color-toggle.vue';
|
||||
export * from './global-search';
|
||||
export { default as LanguageToggle } from './language-toggle.vue';
|
||||
export { default as AuthenticationLayoutToggle } from './layout-toggle.vue';
|
||||
export * from './lock-screen';
|
||||
export * from './notification';
|
||||
|
||||
@ -1,9 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { LanguageOption } from '@vben/constants';
|
||||
import { onMounted, ref, unref } from 'vue';
|
||||
|
||||
import { onMounted, onUnmounted, ref, unref } from 'vue';
|
||||
|
||||
import { onSupportLanguagesChange } from '@vben/constants';
|
||||
import { $t } from '@vben/locales';
|
||||
import { useTimezoneStore } from '@vben/stores';
|
||||
|
||||
@ -15,7 +12,6 @@ defineOptions({
|
||||
name: 'PreferenceGeneralConfig',
|
||||
});
|
||||
|
||||
const appLocale = defineModel<string>('appLocale');
|
||||
const appTimezone = defineModel<string>('appTimezone');
|
||||
const appDynamicTitle = defineModel<boolean>('appDynamicTitle');
|
||||
const appWatermark = defineModel<boolean>('appWatermark');
|
||||
@ -26,12 +22,6 @@ const appEnableCopyPreferences = defineModel<boolean>(
|
||||
);
|
||||
const timezoneStore = useTimezoneStore();
|
||||
|
||||
const languageList = ref<LanguageOption[]>([]);
|
||||
const unsubscribe = onSupportLanguagesChange((langs) => {
|
||||
languageList.value = [...langs];
|
||||
});
|
||||
onUnmounted(unsubscribe);
|
||||
|
||||
const timezoneOptionsRef = ref<
|
||||
{
|
||||
label: string;
|
||||
@ -50,9 +40,6 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectItem v-model="appLocale" :items="languageList">
|
||||
{{ $t('preferences.language') }}
|
||||
</SelectItem>
|
||||
<SelectItem v-model="appTimezone" :items="timezoneOptionsRef">
|
||||
{{ $t('preferences.timezone') }}
|
||||
</SelectItem>
|
||||
|
||||
@ -19,9 +19,6 @@ const widgetGlobalSearchButtonPosition = defineModel<string>(
|
||||
const widgetFullscreenButtonPosition = defineModel<string>(
|
||||
'widgetFullscreenButtonPosition',
|
||||
);
|
||||
const widgetLanguageToggleButtonPosition = defineModel<string>(
|
||||
'widgetLanguageToggleButtonPosition',
|
||||
);
|
||||
const widgetNotificationButtonPosition = defineModel<string>(
|
||||
'widgetNotificationButtonPosition',
|
||||
);
|
||||
@ -91,7 +88,6 @@ const positionMap: Record<string, string> = {
|
||||
globalSearch: 'widgetGlobalSearchButtonPosition',
|
||||
preferences: 'appPreferencesButtonPosition',
|
||||
themeToggle: 'widgetThemeToggleButtonPosition',
|
||||
languageToggle: 'widgetLanguageToggleButtonPosition',
|
||||
timezone: 'widgetTimezoneButtonPosition',
|
||||
fullscreen: 'widgetFullscreenButtonPosition',
|
||||
refresh: 'widgetRefreshButtonPosition',
|
||||
@ -104,7 +100,6 @@ const labelMap: Record<string, string> = {
|
||||
globalSearch: 'preferences.widget.globalSearch',
|
||||
preferences: 'preferences.title',
|
||||
themeToggle: 'preferences.widget.themeToggle',
|
||||
languageToggle: 'preferences.widget.languageToggle',
|
||||
timezone: 'preferences.widget.timezone',
|
||||
fullscreen: 'preferences.widget.fullscreen',
|
||||
refresh: 'preferences.widget.refresh',
|
||||
@ -131,7 +126,6 @@ function getPosition(
|
||||
const modelMap: Record<string, any> = {
|
||||
widgetGlobalSearchButtonPosition,
|
||||
widgetFullscreenButtonPosition,
|
||||
widgetLanguageToggleButtonPosition,
|
||||
widgetNotificationButtonPosition,
|
||||
widgetThemeToggleButtonPosition,
|
||||
widgetLockScreenButtonPosition,
|
||||
@ -156,7 +150,6 @@ function handleUpdatePosition(
|
||||
const modelMap: Record<string, any> = {
|
||||
widgetGlobalSearchButtonPosition,
|
||||
widgetFullscreenButtonPosition,
|
||||
widgetLanguageToggleButtonPosition,
|
||||
widgetNotificationButtonPosition,
|
||||
widgetThemeToggleButtonPosition,
|
||||
widgetLockScreenButtonPosition,
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import type { SupportedLanguagesType } from '@vben/locales';
|
||||
import type { CustomPreferencesRecord } from '@vben/preferences';
|
||||
import type {
|
||||
BreadcrumbStyleType,
|
||||
@ -64,7 +63,6 @@ const emit = defineEmits<{ clearPreferencesAndLogout: [] }>();
|
||||
|
||||
const message = globalShareState.getMessage();
|
||||
|
||||
const appLocale = defineModel<SupportedLanguagesType>('appLocale');
|
||||
const appTimezone = defineModel<string>('appTimezone');
|
||||
const appDynamicTitle = defineModel<boolean>('appDynamicTitle');
|
||||
const appLayout = defineModel<LayoutType>('appLayout');
|
||||
@ -180,9 +178,6 @@ const widgetGlobalSearchButtonPosition = defineModel<string>(
|
||||
const widgetFullscreenButtonPosition = defineModel<string>(
|
||||
'widgetFullscreenButtonPosition',
|
||||
);
|
||||
const widgetLanguageToggleButtonPosition = defineModel<string>(
|
||||
'widgetLanguageToggleButtonPosition',
|
||||
);
|
||||
const widgetNotificationButtonPosition = defineModel<string>(
|
||||
'widgetNotificationButtonPosition',
|
||||
);
|
||||
@ -381,7 +376,6 @@ function handleCustomPreferencesUpdate(updates: CustomPreferencesRecord) {
|
||||
v-model:app-dynamic-title="appDynamicTitle"
|
||||
v-model:app-enable-check-updates="appEnableCheckUpdates"
|
||||
v-model:app-enable-copy-preferences="appEnableCopyPreferences"
|
||||
v-model:app-locale="appLocale"
|
||||
v-model:app-timezone="appTimezone"
|
||||
v-model:app-watermark="appWatermark"
|
||||
v-model:app-watermark-content="appWatermarkContent"
|
||||
@ -508,9 +502,6 @@ function handleCustomPreferencesUpdate(updates: CustomPreferencesRecord) {
|
||||
v-model:widget-global-search-button-position="
|
||||
widgetGlobalSearchButtonPosition
|
||||
"
|
||||
v-model:widget-language-toggle-button-position="
|
||||
widgetLanguageToggleButtonPosition
|
||||
"
|
||||
v-model:widget-lock-screen-button-position="
|
||||
widgetLockScreenButtonPosition
|
||||
"
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { Settings } from '@vben/icons';
|
||||
import { $t, loadLocaleMessages } from '@vben/locales';
|
||||
import { $t } from '@vben/locales';
|
||||
import { preferences, updatePreferences } from '@vben/preferences';
|
||||
import { capitalizeFirstLetter } from '@vben/utils';
|
||||
|
||||
@ -58,9 +58,6 @@ const listen = computed(() => {
|
||||
val: any,
|
||||
) => {
|
||||
updatePreferences({ [key]: { [subKey]: val } });
|
||||
if (key === 'app' && subKey === 'locale') {
|
||||
loadLocaleMessages(val);
|
||||
}
|
||||
};
|
||||
}
|
||||
} else {
|
||||
|
||||
@ -1,23 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import type { Component } from 'vue';
|
||||
|
||||
import type { LanguageOption } from '@vben/constants';
|
||||
import type { AnyFunction } from '@vben/types';
|
||||
|
||||
import { computed, onUnmounted, ref, useTemplateRef, watch } from 'vue';
|
||||
import { computed, useTemplateRef, watch } from 'vue';
|
||||
|
||||
import { onSupportLanguagesChange } from '@vben/constants';
|
||||
import { useHoverToggle, useRefresh } from '@vben/hooks';
|
||||
import {
|
||||
createIconifyIcon,
|
||||
Languages,
|
||||
LockKeyhole,
|
||||
LogOut,
|
||||
RotateCw,
|
||||
Search,
|
||||
Settings,
|
||||
} from '@vben/icons';
|
||||
import { $t, loadLocaleMessages } from '@vben/locales';
|
||||
import { $t } from '@vben/locales';
|
||||
import {
|
||||
preferences,
|
||||
updatePreferences,
|
||||
@ -167,12 +164,6 @@ const showThemeToggleInDropdown = computed(
|
||||
preferences.widget.themeToggleButtonPosition === 'user-dropdown',
|
||||
);
|
||||
|
||||
const showLanguageToggleInDropdown = computed(
|
||||
() =>
|
||||
preferences.widget.languageToggle &&
|
||||
preferences.widget.languageToggleButtonPosition === 'user-dropdown',
|
||||
);
|
||||
|
||||
const showTimezoneInDropdown = computed(
|
||||
() =>
|
||||
preferences.widget.timezone &&
|
||||
@ -203,7 +194,6 @@ const hasAnyInDropdown = computed(
|
||||
showLogoutInDropdown.value ||
|
||||
showGlobalSearchInDropdown.value ||
|
||||
showThemeToggleInDropdown.value ||
|
||||
showLanguageToggleInDropdown.value ||
|
||||
showTimezoneInDropdown.value ||
|
||||
showFullscreenInDropdown.value ||
|
||||
showNotificationInDropdown.value ||
|
||||
@ -277,26 +267,6 @@ function handleNotificationSelect(event?: Event) {
|
||||
refNotification.value?.toggle();
|
||||
}
|
||||
|
||||
// 语言切换 - 阻止 Radix 默认关闭外层 dropdown,就地展开/收起 locale 列表
|
||||
const showLanguageList = ref(false);
|
||||
const languageList = ref<LanguageOption[]>([]);
|
||||
const unsubLang = onSupportLanguagesChange((langs) => {
|
||||
languageList.value = [...langs];
|
||||
});
|
||||
onUnmounted(unsubLang);
|
||||
function handleLanguageToggleSelect(event?: Event) {
|
||||
event?.preventDefault();
|
||||
showLanguageList.value = !showLanguageList.value;
|
||||
}
|
||||
async function handleLocaleChange(event: Event, value: string) {
|
||||
// 阻止默认关闭,让用户能继续看到选择结果;选完手动收起
|
||||
event.preventDefault();
|
||||
updatePreferences({ app: { locale: value } });
|
||||
await loadLocaleMessages(value);
|
||||
showLanguageList.value = false;
|
||||
openPopover.value = false;
|
||||
}
|
||||
|
||||
if (preferences.shortcutKeys.enable) {
|
||||
const keys = useMagicKeys();
|
||||
const logoutKey = keys['Alt+KeyQ'];
|
||||
@ -448,7 +418,6 @@ if (preferences.shortcutKeys.enable) {
|
||||
v-if="
|
||||
showGlobalSearchInDropdown ||
|
||||
showThemeToggleInDropdown ||
|
||||
showLanguageToggleInDropdown ||
|
||||
showTimezoneInDropdown ||
|
||||
showFullscreenInDropdown ||
|
||||
showNotificationInDropdown ||
|
||||
@ -474,32 +443,6 @@ if (preferences.shortcutKeys.enable) {
|
||||
<ThemeToggle class="mr-2" />
|
||||
{{ $t('preferences.theme.title') }}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
v-if="showLanguageToggleInDropdown"
|
||||
class="mx-1 flex cursor-pointer items-center rounded-sm py-1 leading-8"
|
||||
@select="handleLanguageToggleSelect"
|
||||
>
|
||||
<VbenIconButton class="mr-2" @click="handleLanguageToggleSelect">
|
||||
<Languages class="size-4" />
|
||||
</VbenIconButton>
|
||||
{{ $t('preferences.widget.languageToggle') }}
|
||||
</DropdownMenuItem>
|
||||
<template v-if="showLanguageList">
|
||||
<DropdownMenuItem
|
||||
v-for="lang in languageList"
|
||||
:key="lang.value"
|
||||
class="mx-1 flex cursor-pointer items-center rounded-sm py-1 pl-8 leading-8"
|
||||
@select="(e: Event) => handleLocaleChange(e, lang.value)"
|
||||
>
|
||||
<span
|
||||
:class="
|
||||
lang.value === preferences.app.locale ? 'bg-foreground' : ''
|
||||
"
|
||||
class="mr-2 size-1.5 rounded-full"
|
||||
></span>
|
||||
{{ lang.label }}
|
||||
</DropdownMenuItem>
|
||||
</template>
|
||||
<DropdownMenuItem
|
||||
v-if="showTimezoneInDropdown"
|
||||
class="mx-1 flex cursor-pointer items-center rounded-sm py-1 leading-8"
|
||||
|
||||
@ -1,65 +0,0 @@
|
||||
{
|
||||
"welcomeBack": "Welcome Back",
|
||||
"pageTitle": "Plug-and-play Admin system",
|
||||
"pageDesc": "Efficient, versatile frontend template",
|
||||
"loginSuccess": "Login Successful",
|
||||
"loginSuccessDesc": "Welcome Back",
|
||||
"loginSubtitle": "Enter your account details to manage your projects",
|
||||
"selectAccount": "Quick Select Account",
|
||||
"username": "Username",
|
||||
"password": "Password",
|
||||
"usernameTip": "Please enter username",
|
||||
"passwordErrorTip": "Password is incorrect",
|
||||
"passwordTip": "Please enter password",
|
||||
"verifyRequiredTip": "Please complete the verification first",
|
||||
"rememberMe": "Remember Me",
|
||||
"createAnAccount": "Create an Account",
|
||||
"createAccount": "Create Account",
|
||||
"alreadyHaveAccount": "Already have an account?",
|
||||
"accountTip": "Don't have an account?",
|
||||
"signUp": "Sign Up",
|
||||
"signUpSubtitle": "Make managing your applications simple and fun",
|
||||
"confirmPassword": "Confirm Password",
|
||||
"confirmPasswordTip": "The passwords do not match",
|
||||
"agree": "I agree to",
|
||||
"privacyPolicy": "Privacy-policy",
|
||||
"terms": "Terms",
|
||||
"agreeTip": "Please agree to the Privacy Policy and Terms",
|
||||
"goToLogin": "Login instead",
|
||||
"passwordStrength": "Use 8 or more characters with a mix of letters, numbers & symbols",
|
||||
"forgetPassword": "Forget Password?",
|
||||
"forgetPasswordSubtitle": "Enter your email and we'll send you instructions to reset your password",
|
||||
"emailTip": "Please enter email",
|
||||
"emailValidErrorTip": "The email format you entered is incorrect",
|
||||
"sendResetLink": "Send Reset Link",
|
||||
"email": "Email",
|
||||
"qrcodeSubtitle": "Scan the QR code with your phone to login",
|
||||
"qrcodePrompt": "Click 'Confirm' after scanning to complete login",
|
||||
"qrcodeLogin": "QR Code Login",
|
||||
"wechatLogin": "Wechat Login",
|
||||
"qqLogin": "QQ Login",
|
||||
"githubLogin": "Github Login",
|
||||
"googleLogin": "Google Login",
|
||||
"dingdingLogin": "Dingding Login",
|
||||
"codeSubtitle": "Enter your phone number to start managing your project",
|
||||
"code": "Security code",
|
||||
"codeTip": "Security code required {0} characters",
|
||||
"mobile": "Mobile",
|
||||
"mobileLogin": "Mobile Login",
|
||||
"mobileTip": "Please enter mobile number",
|
||||
"mobileErrortip": "The phone number format is incorrect",
|
||||
"sendCode": "Get Security code",
|
||||
"sendText": "Resend in {0}s",
|
||||
"thirdPartyLogin": "Or continue with",
|
||||
"weChat": "WeChat",
|
||||
"qq": "QQ",
|
||||
"gitHub": "GitHub",
|
||||
"google": "Google",
|
||||
"loginAgainTitle": "Please Log In Again",
|
||||
"loginAgainSubTitle": "Your login session has expired. Please log in again to continue.",
|
||||
"layout": {
|
||||
"center": "Align Center",
|
||||
"alignLeft": "Align Left",
|
||||
"alignRight": "Align Right"
|
||||
}
|
||||
}
|
||||
@ -1,26 +0,0 @@
|
||||
{
|
||||
"back": "Back",
|
||||
"backToHome": "Back To Home",
|
||||
"login": "Login",
|
||||
"logout": "Logout",
|
||||
"prompt": "Prompt",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm",
|
||||
"reset": "Reset",
|
||||
"noData": "No Data",
|
||||
"refresh": "Refresh",
|
||||
"loadingMenu": "Loading Menu",
|
||||
"query": "Search",
|
||||
"search": "Search",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"detail": "Detail",
|
||||
"create": "Create",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"showSearchPanel": "Show search panel",
|
||||
"hideSearchPanel": "Hide search panel",
|
||||
"notShow": "Don't Show"
|
||||
}
|
||||
@ -1,245 +0,0 @@
|
||||
{
|
||||
"title": "Preferences",
|
||||
"subtitle": "Customize Preferences & Preview in Real Time",
|
||||
"enableStickyPreferencesNavigationBar": "Enable sticky preferences navigation bar",
|
||||
"disableStickyPreferencesNavigationBar": "Disable sticky preferences navigation bar",
|
||||
"resetTip": "Data has changed, click to reset",
|
||||
"resetTitle": "Reset Preferences",
|
||||
"resetSuccess": "Preferences reset successfully",
|
||||
"appearance": "Appearance",
|
||||
"layout": "Layout",
|
||||
"content": "Content",
|
||||
"other": "Other",
|
||||
"wide": "Wide",
|
||||
"compact": "Fixed",
|
||||
"followSystem": "Follow System",
|
||||
"vertical": "Vertical",
|
||||
"verticalTip": "Side vertical menu mode",
|
||||
"horizontal": "Horizontal",
|
||||
"horizontalTip": "Horizontal menu mode, all menus displayed at the top",
|
||||
"twoColumn": "Two Column",
|
||||
"twoColumnTip": "Vertical Two Column Menu Mode",
|
||||
"headerSidebarNav": "Header Vertical",
|
||||
"headerSidebarNavTip": "Header Full Width, Sidebar Navigation Mode",
|
||||
"headerTwoColumn": "Header Two Column",
|
||||
"headerTwoColumnTip": "Header Navigation & Sidebar Two Column co-exists",
|
||||
"mixedMenu": "Mixed Menu",
|
||||
"mixedMenuTip": "Vertical & Horizontal Menu Co-exists",
|
||||
"fullContent": "Full Content",
|
||||
"fullContentTip": "Only display content body, hide all menus",
|
||||
"normal": "Normal",
|
||||
"plain": "Plain",
|
||||
"rounded": "Rounded",
|
||||
"copyPreferences": "Copy Preferences",
|
||||
"enableCopyPreferences": "Show copy preferences button",
|
||||
"copyPreferencesSuccessTitle": "Copy successful",
|
||||
"copyPreferencesSuccess": "Copy successful, please override in `src/preferences.ts` under app",
|
||||
"clearAndLogout": "Clear Cache & Logout",
|
||||
"mode": "Mode",
|
||||
"general": "General",
|
||||
"language": "Language",
|
||||
"timezone": "Timezone",
|
||||
"dynamicTitle": "Dynamic Title",
|
||||
"watermark": "Watermark",
|
||||
"watermarkContent": "Please input Watermark content",
|
||||
"checkUpdates": "Periodic update check",
|
||||
"position": {
|
||||
"title": "Preferences Postion",
|
||||
"header": "Header",
|
||||
"auto": "Auto",
|
||||
"fixed": "Fixed",
|
||||
"userDropdown": "User Dropdown"
|
||||
},
|
||||
"sidebar": {
|
||||
"buttons": "Show Buttons",
|
||||
"buttonFixed": "Fixed",
|
||||
"buttonCollapsed": "Collapsed",
|
||||
"title": "Sidebar",
|
||||
"width": "Width",
|
||||
"visible": "Show Sidebar",
|
||||
"draggable": "Drag Sidebar Menu",
|
||||
"collapsed": "Collpase Menu",
|
||||
"collapsedShowTitle": "Show Menu Title",
|
||||
"autoActivateChild": "Auto Activate SubMenu",
|
||||
"autoActivateChildTip": "`Enabled` to automatically activate the submenu while click menu.",
|
||||
"expandOnHover": "Expand On Hover",
|
||||
"expandOnHoverTip": "When the mouse hovers over menu, \n `Enabled` to expand children menus \n `Disabled` to expand whole sidebar."
|
||||
},
|
||||
"tabbar": {
|
||||
"title": "Tabbar",
|
||||
"enable": "Enable Tab Bar",
|
||||
"icon": "Show Tabbar Icon",
|
||||
"showMore": "Show More Button",
|
||||
"showMaximize": "Show Maximize Button",
|
||||
"persist": "Persist Tabs",
|
||||
"visitHistory": "Visit History",
|
||||
"visitHistoryTip": "When enabled, the tab bar records tab visit history. \nClosing the current tab will automatically select the last opened tab.",
|
||||
"maxCount": "Max Count of Tabs",
|
||||
"maxCountTip": "When the number of tabs exceeds the maximum,\nthe oldest tab will be closed.\n Set to 0 to disable count checking.",
|
||||
"draggable": "Enable Draggable Sort",
|
||||
"wheelable": "Support Mouse Wheel",
|
||||
"middleClickClose": "Close Tab when Mouse Middle Button Click",
|
||||
"wheelableTip": "When enabled, the Tabbar area responds to vertical scrolling events of the scroll wheel.",
|
||||
"styleType": {
|
||||
"title": "Tabs Style",
|
||||
"chrome": "Chrome",
|
||||
"card": "Card",
|
||||
"plain": "Plain",
|
||||
"brisk": "Brisk"
|
||||
},
|
||||
"contextMenu": {
|
||||
"reload": "Reload",
|
||||
"close": "Close",
|
||||
"pin": "Pin",
|
||||
"unpin": "Unpin",
|
||||
"closeLeft": "Close Left Tabs",
|
||||
"closeRight": "Close Right Tabs",
|
||||
"closeOther": "Close Other Tabs",
|
||||
"closeAll": "Close All Tabs",
|
||||
"openInNewWindow": "Open in New Window",
|
||||
"maximize": "Maximize",
|
||||
"restoreMaximize": "Restore"
|
||||
}
|
||||
},
|
||||
"navigationMenu": {
|
||||
"title": "Navigation Menu",
|
||||
"style": "Navigation Menu Style",
|
||||
"accordion": "Sidebar Accordion Menu",
|
||||
"split": "Navigation Menu Separation",
|
||||
"splitTip": "When enabled, the sidebar displays the top bar's submenu"
|
||||
},
|
||||
"breadcrumb": {
|
||||
"title": "Breadcrumb",
|
||||
"home": "Show Home Button",
|
||||
"enable": "Enable Breadcrumb",
|
||||
"icon": "Show Breadcrumb Icon",
|
||||
"background": "background",
|
||||
"style": "Breadcrumb Style",
|
||||
"hideOnlyOne": "Hidden when only one"
|
||||
},
|
||||
"animation": {
|
||||
"title": "Animation",
|
||||
"loading": "Page Loading",
|
||||
"transition": "Page Transition",
|
||||
"progress": "Page Progress"
|
||||
},
|
||||
"theme": {
|
||||
"title": "Theme",
|
||||
"radius": "Radius",
|
||||
"fontSize": "Font Size",
|
||||
"fontSizeTip": "Adjust global font size with real-time preview",
|
||||
"light": "Light",
|
||||
"dark": "Dark",
|
||||
"darkSidebar": "Semi Dark Sidebar",
|
||||
"darkSidebarTip": "It can be enabled when the theme is light, and the layout is neither 'Horizontal' nor 'Full Content'.",
|
||||
"darkSidebarSub": "Semi Dark Sidebar Sub",
|
||||
"darkSidebarSubTip": "It can be enabled when the theme is light, the semi dark sidebar is enabled, and the layout uses 'Two-Column' menu mode.",
|
||||
"darkHeader": "Semi Dark Header",
|
||||
"weakMode": "Weak Mode",
|
||||
"grayMode": "Gray Mode",
|
||||
"builtin": {
|
||||
"title": "Built-in",
|
||||
"default": "Default",
|
||||
"violet": "Violet",
|
||||
"pink": "Pink",
|
||||
"rose": "Rose",
|
||||
"skyBlue": "Sky Blue",
|
||||
"deepBlue": "Deep Blue",
|
||||
"green": "Green",
|
||||
"deepGreen": "Deep Green",
|
||||
"orange": "Orange",
|
||||
"yellow": "Yellow",
|
||||
"zinc": "Zinc",
|
||||
"neutral": "Neutral",
|
||||
"slate": "Slate",
|
||||
"gray": "Gray",
|
||||
"custom": "Custom"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
"title": "Header",
|
||||
"visible": "Show Header",
|
||||
"modeStatic": "Static",
|
||||
"modeFixed": "Fixed",
|
||||
"modeAuto": "Auto hide & Show",
|
||||
"modeAutoScroll": "Scroll to Hide & Show",
|
||||
"menuAlign": "Menu Align",
|
||||
"menuAlignStart": "Start",
|
||||
"menuAlignEnd": "End",
|
||||
"menuAlignCenter": "Center"
|
||||
},
|
||||
"footer": {
|
||||
"title": "Footer",
|
||||
"visible": "Show Footer",
|
||||
"fixed": "Fixed at Bottom"
|
||||
},
|
||||
"copyright": {
|
||||
"title": "Copyright",
|
||||
"enable": "Enable Copyright",
|
||||
"companyName": "Company Name",
|
||||
"companySiteLink": "Company Site Link",
|
||||
"date": "Date",
|
||||
"icp": "ICP License Number",
|
||||
"icpLink": "ICP Site Link"
|
||||
},
|
||||
"shortcutKeys": {
|
||||
"title": "Shortcut Keys",
|
||||
"global": "Global",
|
||||
"search": "Global Search",
|
||||
"logout": "Logout",
|
||||
"escape": "Close Current Window",
|
||||
"preferences": "Preferences"
|
||||
},
|
||||
"widget": {
|
||||
"title": "Widget",
|
||||
"globalSearch": "Global Search",
|
||||
"globalSearchPosition": "Global Search Position",
|
||||
"fullscreen": "Enable Fullscreen",
|
||||
"fullscreenPosition": "Fullscreen Position",
|
||||
"themeToggle": "Enable Theme Toggle",
|
||||
"themeTogglePosition": "Theme Toggle Position",
|
||||
"languageToggle": "Language Toggle",
|
||||
"languageTogglePosition": "Language Toggle Position",
|
||||
"notification": "Enable Notification",
|
||||
"notificationPosition": "Notification Position",
|
||||
"sidebarToggle": "Enable Sidebar Toggle",
|
||||
"lockScreen": "Enable Lock Screen",
|
||||
"lockScreenPosition": "Lock Screen Button Position",
|
||||
"logoutButtonPosition": "Logout Button Position",
|
||||
"header": "Header",
|
||||
"userDropdown": "User Dropdown",
|
||||
"hidden": "Hidden",
|
||||
"refresh": "Enable Refresh",
|
||||
"refreshPosition": "Refresh Position",
|
||||
"timezone": "Enable Timezone",
|
||||
"timezonePosition": "Timezone Position"
|
||||
},
|
||||
"antd": {
|
||||
"tabLabel": "Antd Extension",
|
||||
"title": "Business Preferences",
|
||||
"fields": {
|
||||
"enableFormFullscreen": {
|
||||
"label": "Enable Form Fullscreen Mode",
|
||||
"tip": "Subprojects can read this extension configuration in their own business scenarios."
|
||||
},
|
||||
"tenantMode": {
|
||||
"label": "Tenant Mode",
|
||||
"options": {
|
||||
"single": {
|
||||
"label": "Single Tenant"
|
||||
},
|
||||
"multi": {
|
||||
"label": "Multi Tenant"
|
||||
}
|
||||
}
|
||||
},
|
||||
"defaultTableSize": {
|
||||
"label": "Default Page Size"
|
||||
},
|
||||
"reportTitle": {
|
||||
"label": "Default Report Title",
|
||||
"placeholder": "Please enter the default report title"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,4 +0,0 @@
|
||||
{
|
||||
"updatePassword": "Update Password",
|
||||
"updateBasicProfile": "Update Basic Profile"
|
||||
}
|
||||
@ -1,165 +0,0 @@
|
||||
{
|
||||
"formRules": {
|
||||
"required": "Please enter {0}",
|
||||
"selectRequired": "Please select {0}",
|
||||
"minLength": "{0} must be at least {1} characters",
|
||||
"maxLength": "{0} can be at most {1} characters",
|
||||
"length": "{0} must be {1} characters long",
|
||||
"alreadyExists": "{0} `{1}` already exists",
|
||||
"startWith": "{0} must start with `{1}`",
|
||||
"invalidURL": "Please input a valid URL",
|
||||
"sizeLimit": "The file size cannot exceed {0}MB",
|
||||
"previewWarning": "Unable to open the file, there is no available URL or preview address"
|
||||
},
|
||||
"actionTitle": {
|
||||
"edit": "Modify {0}",
|
||||
"create": "Create {0}",
|
||||
"delete": "Delete {0}",
|
||||
"view": "View {0}"
|
||||
},
|
||||
"actionMessage": {
|
||||
"deleteConfirm": "Are you sure to delete {0}?",
|
||||
"deleting": "Deleting {0} ...",
|
||||
"deleteSuccess": "{0} deleted successfully",
|
||||
"operationSuccess": "Operation succeeded",
|
||||
"operationFailed": "Operation failed"
|
||||
},
|
||||
"placeholder": {
|
||||
"input": "Please enter",
|
||||
"select": "Please select",
|
||||
"upload": "Click to upload"
|
||||
},
|
||||
"captcha": {
|
||||
"title": "Please complete the security verification",
|
||||
"sliderSuccessText": "Passed",
|
||||
"sliderDefaultText": "Slider and drag",
|
||||
"alt": "Supports img tag src attribute value",
|
||||
"sliderRotateDefaultTip": "Click picture to refresh",
|
||||
"sliderTranslateDefaultTip": "Click picture to refresh",
|
||||
"sliderRotateFailTip": "Validation failed",
|
||||
"sliderRotateSuccessTip": "Validation successful, time {0} seconds",
|
||||
"sliderTranslateFailTip": "Validation failed",
|
||||
"sliderTranslateSuccessTip": "Validation successful, time {0} seconds",
|
||||
"refreshAriaLabel": "Refresh captcha",
|
||||
"confirmAriaLabel": "Confirm selection",
|
||||
"confirm": "Confirm",
|
||||
"pointAriaLabel": "Click point",
|
||||
"clickInOrder": "Please click in order"
|
||||
},
|
||||
"iconPicker": {
|
||||
"placeholder": "Select an icon",
|
||||
"search": "Search icon..."
|
||||
},
|
||||
"jsonViewer": {
|
||||
"copy": "Copy",
|
||||
"copied": "Copied"
|
||||
},
|
||||
"crop": {
|
||||
"title": "Image Cropping",
|
||||
"titleTip": "Cropping Ratio {0}",
|
||||
"confirm": "Crop",
|
||||
"cancel": "Cancel cropping",
|
||||
"errorTip": "Cropping error"
|
||||
},
|
||||
"tiptap": {
|
||||
"placeholder": "Please enter content...",
|
||||
"prompts": {
|
||||
"image": "Enter image URL",
|
||||
"link": "Enter link URL"
|
||||
},
|
||||
"toolbar": {
|
||||
"bold": "Bold",
|
||||
"italic": "Italic",
|
||||
"underline": "Underline",
|
||||
"strike": "Strike",
|
||||
"code": "Code",
|
||||
"codeBlock": "Code Block",
|
||||
"heading": "Heading",
|
||||
"paragraph": "Paragraph",
|
||||
"heading1": "H1",
|
||||
"heading2": "H2",
|
||||
"heading3": "H3",
|
||||
"heading4": "H4",
|
||||
"bulletList": "Bullets",
|
||||
"orderedList": "Numbering",
|
||||
"blockquote": "Quote",
|
||||
"link": "Link",
|
||||
"unlink": "Unlink",
|
||||
"image": "Image",
|
||||
"imageUrl": "Image URL",
|
||||
"imageUpload": "Upload Image",
|
||||
"textColor": "Text Color",
|
||||
"highlightColor": "Highlight Color",
|
||||
"alignLeft": "Left",
|
||||
"alignCenter": "Center",
|
||||
"alignRight": "Right",
|
||||
"preview": "Preview",
|
||||
"undo": "Undo",
|
||||
"redo": "Redo",
|
||||
"clear": "Clear"
|
||||
},
|
||||
"upload": {
|
||||
"fileTooLarge": "File size exceeds the limit",
|
||||
"fileTypeNotAllowed": "File type is not allowed",
|
||||
"onlySingleImage": "Only single image upload is supported, the first one is selected",
|
||||
"uploadFailed": "Upload Failed"
|
||||
}
|
||||
},
|
||||
"fallback": {
|
||||
"pageNotFound": "Oops! Page Not Found",
|
||||
"pageNotFoundDesc": "Sorry, we couldn't find the page you were looking for.",
|
||||
"forbidden": "Oops! Access Denied",
|
||||
"forbiddenDesc": "Sorry, but you don't have permission to access this page.",
|
||||
"internalError": "Oops! Something Went Wrong",
|
||||
"internalErrorDesc": "Sorry, but the server encountered an error.",
|
||||
"offline": "Offline Page",
|
||||
"offlineError": "Oops! Network Error",
|
||||
"offlineErrorDesc": "Sorry, can't connect to the internet. Check your connection.",
|
||||
"comingSoon": "Coming Soon",
|
||||
"http": {
|
||||
"requestTimeout": "The request timed out. Please try again later.",
|
||||
"networkError": "A network error occurred. Please check your internet connection and try again.",
|
||||
"badRequest": "Bad Request. Please check your input and try again.",
|
||||
"unauthorized": "Unauthorized. Please log in to continue.",
|
||||
"forbidden": "Forbidden. You do not have permission to access this resource.",
|
||||
"notFound": "Not Found. The requested resource could not be found.",
|
||||
"internalServerError": "Internal Server Error. Something went wrong on our end. Please try again later."
|
||||
}
|
||||
},
|
||||
"widgets": {
|
||||
"document": "Document",
|
||||
"qa": "Q&A",
|
||||
"setting": "Settings",
|
||||
"logoutTip": "Do you want to logout?",
|
||||
"viewAll": "View All Messages",
|
||||
"notifications": "Notifications",
|
||||
"markAllAsRead": "Make All as Read",
|
||||
"clearNotifications": "Clear",
|
||||
"checkUpdatesTitle": "New Version Available",
|
||||
"checkUpdatesDescription": "Click to refresh and get the latest version",
|
||||
"search": {
|
||||
"title": "Search",
|
||||
"searchNavigate": "Search Navigation",
|
||||
"select": "Select",
|
||||
"navigate": "Navigate",
|
||||
"close": "Close",
|
||||
"noResults": "No Search Results Found",
|
||||
"noRecent": "No Search History",
|
||||
"recent": "Search History"
|
||||
},
|
||||
"lockScreen": {
|
||||
"title": "Lock Screen",
|
||||
"screenButton": "Lock",
|
||||
"password": "Password",
|
||||
"placeholder": "Please enter password",
|
||||
"unlock": "Click to unlock",
|
||||
"errorPasswordTip": "Password error, please re-enter",
|
||||
"backToLogin": "Back to login",
|
||||
"entry": "Enter the system"
|
||||
},
|
||||
"timezone": {
|
||||
"setTimezone": "Set Timezone",
|
||||
"setSuccess": "Timezone set successfully"
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user