Compare commits

..

No commits in common. "f70f5dcff8e0ac524f8c0631727f0adbad8b40ef" and "0807bb85140a560272dcff61fbd6b8a76c90239f" have entirely different histories.

52 changed files with 1804 additions and 3176 deletions

View File

@ -1,19 +1,6 @@
# admin.xpcool.com 变更记录 # admin.xpcool.com 变更记录
> 倒序最新在上。格式YYYY-MM-DD | 类型 | 摘要 > 倒序最新在上。格式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 会话密钥)+ decryptPayloadAES-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.tsgetPublicKeyApi并发去重+ loginApi 先拉公钥再加密 username/password 提交网络传输无明文密码。③api/system.tscreateAdmin/resetAdminPassword 的 password 走 hybridEncryptField 加密encryptField 复用公钥缓存。④注意Web Crypto 仅安全上下文可用https/localhost/127.0.0.1http://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=96views/monitor/security/index.vue——顶部 4 统计卡片SSH爆破/fail2ban封禁/成功登录/总数)+ TOP 攻击源标签区 + 筛选来源IP/事件类型/时间范围 t-date-range-picker+ Vben VxeTable 列表(时间/来源IP/来源端口/目标端口/事件类型标签/详情 tooltip事件标签按类型配色api/serverSecurity.tslist/stats 全 POST无 URL 参数typecheck 通过
2026-08-27 | CHG | 全量表格重构views 下全部 11 个 t-table 页面统一改为 Vben Vxe TableuseVbenVxeGrid#/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)折腾后工作树回退,未提交改动全丢;从悬空提交 f02a514recovery 工作树快照,含 HEAD 缺失的全部文件)整体恢复 apps/web-tdesign/src——含 styles/theme.css 全局主题、dashboard/workbench 工作台页、system 五页卡片化美化、操作日志增强版(VxeTable)、house 预售/楼盘地图、recruitment 页面;另补 log 页卡片美化(wb-page-head+t-card)。已提交保护 2026-08-27 | FIX | 恢复丢失的前端代码recovery 分支经 rebase/改名(dev↔main)折腾后工作树回退,未提交改动全丢;从悬空提交 f02a514recovery 工作树快照,含 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 | 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 仅首次渲染生效)——替代先前扁平化+缩进的自造方案 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 仅首次渲染生效)——替代先前扁平化+缩进的自造方案

View File

@ -14,6 +14,3 @@ VITE_DEVTOOLS=false
# 是否注入全局loading # 是否注入全局loading
VITE_INJECT_APP_LOADING=true VITE_INJECT_APP_LOADING=true
# 是否启用全量请求/响应加密(生产 true所有接口 body/响应整体加密public-key 豁免;开发 false仅加密密码字段
VITE_GLOB_API_ENCRYPT=false

View File

@ -17,6 +17,3 @@ VITE_INJECT_APP_LOADING=true
# 打包后是否生成dist.zip # 打包后是否生成dist.zip
VITE_ARCHIVER=true VITE_ARCHIVER=true
# 全量请求/响应加密(与后端 encrypt.fullBody=true 配套)
VITE_GLOB_API_ENCRYPT=true

View File

@ -1,67 +0,0 @@
// 自动任务模块 API 客户端(任务管理/运行日志)。
// 接口规范:全部 POSTURL 不含参数;入参一律 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,
);
}

View File

@ -1,70 +1,51 @@
import { import { baseRequestClient, requestClient } from '#/api/request';
baseRequestClient,
fetchPublicKeyForCrypto,
requestClient,
} from '#/api/request';
import { hybridEncryptLogin } from '#/utils/crypto';
export namespace AuthApi { export namespace AuthApi {
/** 登录接口参数(明文账号密码仅在前端加密流程内消费,不会直接出网) */ /** 登录接口参数 */
export interface LoginParams { export interface LoginParams {
password?: string; password?: string;
username?: string; username?: string;
} }
/** 登录/刷新接口返回值(后端 TokenPair */ /** 登录接口返回值 */
export interface LoginResult { export interface LoginResult {
accessToken: string; accessToken: string;
adminId: number; }
expiresIn: number;
refreshToken: string; export interface RefreshTokenResult {
data: string;
status: number;
} }
} }
/** /**
* 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) { 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>( return requestClient.post<AuthApi.LoginResult>(
'/api/service/admin/system/auth/login', '/api/service/admin/system/auth/login',
{ username: data.username, encryptedKey, encryptedData }, data,
); );
} }
/** /**
* accessToken refreshToken * accessToken
* @param refreshToken JTI
* @returns raw axios responseresp.data {code,message,data}
*/ */
export async function refreshTokenApi(refreshToken: string) { export async function refreshTokenApi() {
return baseRequestClient.post<{ return baseRequestClient.post<AuthApi.RefreshTokenResult>(
data: { code: number; data: AuthApi.LoginResult; message: string }; '/api/service/admin/system/auth/refresh',
status: number; {
}>('/api/service/admin/system/auth/refresh', { refreshToken }); withCredentials: true,
},
);
} }
/** /**
* 退 refreshToken * 退
*/ */
export async function logoutApi(refreshToken?: string) { export async function logoutApi() {
return baseRequestClient.post('/api/service/admin/system/auth/logout', { return baseRequestClient.post('/api/service/admin/system/auth/logout', {
refreshToken, withCredentials: true,
}); });
} }

View File

@ -1,130 +0,0 @@
// 通知模块 API 客户端(渠道/规则/日志/测试)。
// 接口规范:全部 POSTURL 不含参数;入参一律 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' },
];

View File

@ -1,11 +1,7 @@
/** /**
* *
*/ */
import type { import type { RequestClientOptions } from '@vben/request';
AxiosResponse,
InternalAxiosRequestConfig,
RequestClientOptions,
} from '@vben/request';
import { useAppConfig } from '@vben/hooks'; import { useAppConfig } from '@vben/hooks';
import { preferences } from '@vben/preferences'; import { preferences } from '@vben/preferences';
@ -19,123 +15,17 @@ import { useAccessStore } from '@vben/stores';
import { message } from '#/adapter/tdesign'; import { message } from '#/adapter/tdesign';
import { useAuthStore } from '#/store'; import { useAuthStore } from '#/store';
import {
cachePublicKey,
decryptPayload,
encryptPayload,
getCachedPublicKey,
} from '#/utils/crypto';
import { refreshTokenApi } from './core'; import { refreshTokenApi } from './core';
const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD); 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) { function createRequestClient(baseURL: string, options?: RequestClientOptions) {
const client = new RequestClient({ const client = new RequestClient({
...options, ...options,
baseURL, baseURL,
}); });
// 全量加解密拦截器(生产模式):响应解密须最先注册。
installCryptoInterceptors(client);
/** /**
* *
*/ */
@ -156,20 +46,13 @@ function createRequestClient(baseURL: string, options?: RequestClientOptions) {
/** /**
* token逻辑 * token逻辑
* refreshToken
* baseRequestClient raw axios responsebody resp.data
* {code,message,data} resp.data.data
*/ */
async function doRefreshToken() { async function doRefreshToken() {
const accessStore = useAccessStore(); const accessStore = useAccessStore();
const resp = await refreshTokenApi(accessStore.refreshToken ?? ''); const resp = await refreshTokenApi();
const pair = resp.data?.data; const newToken = resp.data;
if (!pair?.accessToken) { accessStore.setAccessToken(newToken);
throw new Error('refresh token response invalid'); return newToken;
}
accessStore.setAccessToken(pair.accessToken);
accessStore.setRefreshToken(pair.refreshToken);
return pair.accessToken;
} }
function formatToken(token: null | string) { function formatToken(token: null | string) {
@ -227,5 +110,3 @@ export const requestClient = createRequestClient(apiURL, {
}); });
export const baseRequestClient = new RequestClient({ baseURL: apiURL }); export const baseRequestClient = new RequestClient({ baseURL: apiURL });
// 登录/刷新/登出等走 baseRequestClient 的请求同样需要全量加密(生产模式)。
installCryptoInterceptors(baseRequestClient);

View File

@ -1,78 +0,0 @@
// 服务器安全日志模块 API 客户端。
// 接口规范:全部 POSTURL 不含参数(查询/路径参数均禁止);入参一律走 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' },
];

View File

@ -1,31 +1,11 @@
import { import { requestClient } from '#/api/request';
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 { export interface AdminItem {
id: number; id: number;
username: string; username: string;
nickname: string; nickname: string;
status: number; status: number;
barkDeviceId: string;
pushplusToken: string;
roleIds: number[]; roleIds: number[];
roleNames: string[]; roleNames: string[];
createdAt: string; createdAt: string;
@ -47,41 +27,28 @@ export function getAdminList(data: {
); );
} }
export async function createAdmin(data: { export function createAdmin(data: {
barkDeviceId?: string;
nickname: string; nickname: string;
password: string; password: string;
pushplusToken?: string;
roleIds: number[]; roleIds: number[];
username: string; username: string;
}) { }) {
// 密码加密传输:生产整体加密 / 开发仅密码字段加密,其余字段明文。 return requestClient.post<{ id: number }>(
const pwdField = await preparePasswordField(data.password); '/api/service/admin/admin/create',
return requestClient.post<{ id: number }>('/api/service/admin/admin/create', { data,
...data, );
password: undefined,
...pwdField,
});
} }
export function updateAdmin( export function updateAdmin(
id: number, id: number,
data: { data: { nickname: string; roleIds: number[]; status: number; },
barkDeviceId?: string;
nickname: string;
pushplusToken?: string;
roleIds: number[];
status: number;
},
) { ) {
return requestClient.post(`/api/service/admin/admin/update/${id}`, data); return requestClient.post(`/api/service/admin/admin/update/${id}`, data);
} }
export async function resetAdminPassword(id: number, password: string) { export function resetAdminPassword(id: number, password: string) {
// 密码加密传输:生产整体加密 / 开发仅密码字段加密。
const pwdField = await preparePasswordField(password);
return requestClient.post(`/api/service/admin/admin/resetPwd/${id}`, { return requestClient.post(`/api/service/admin/admin/resetPwd/${id}`, {
...pwdField, password,
}); });
} }
@ -129,7 +96,7 @@ export function createRole(data: {
export function updateRole( export function updateRole(
id: number, id: number,
data: { menuIds: number[]; name: string; status: number }, data: { menuIds: number[]; name: string; status: number; },
) { ) {
return requestClient.post( return requestClient.post(
`/api/service/admin/system/role/update/${id}`, `/api/service/admin/system/role/update/${id}`,

View File

@ -39,13 +39,29 @@ async function loadThirdPartyMessage(lang: SupportedLanguagesType) {
} }
/** /**
* dayjs的语言包 * dayjs的语言包
* @param lang * @param lang
*/ */
async function loadDayjsLocale(lang: SupportedLanguagesType) { async function loadDayjsLocale(lang: SupportedLanguagesType) {
if (lang === 'zh-CN') { let locale;
const locale = await import('dayjs/locale/zh-cn'); 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) {
dayjs.locale(locale); dayjs.locale(locale);
} else {
console.error(`Failed to load dayjs locale for ${lang}`);
} }
} }

View File

@ -0,0 +1,13 @@
{
"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"
}
}

View File

@ -0,0 +1,15 @@
{
"auth": {
"login": "Login",
"register": "Register",
"codeLogin": "Code Login",
"qrcodeLogin": "Qr Code Login",
"forgetPassword": "Forget Password",
"profile": "Profile"
},
"dashboard": {
"title": "Dashboard",
"analytics": "Analytics",
"workspace": "Workspace"
}
}

View File

@ -16,8 +16,6 @@ export const overridesPreferences = defineOverridesPreferences({
accessMode: 'backend', accessMode: 'backend',
// 后端暂未实现 refresh 接口,关闭 token 自动刷新 // 后端暂未实现 refresh 接口,关闭 token 自动刷新
enableRefreshToken: false, enableRefreshToken: false,
// 固定中文:移除国际化多语言,仅保留中文
locale: 'zh-CN',
}, },
copyright: appCopyrightPreferences, copyright: appCopyrightPreferences,
}); });

View File

@ -29,17 +29,15 @@ export const useAuthStore = defineStore('auth', () => {
params: Recordable<any>, params: Recordable<any>,
onSuccess?: () => Promise<void> | void, onSuccess?: () => Promise<void> | void,
) { ) {
// 异步处理用户登录操作并获取令牌对accessToken + refreshToken // 异步处理用户登录操作并获取 accessToken
let userInfo: null | UserInfo = null; let userInfo: null | UserInfo = null;
try { try {
loginLoading.value = true; loginLoading.value = true;
const { accessToken, refreshToken } = await loginApi(params); const { accessToken } = await loginApi(params);
// 如果成功获取到 accessToken // 如果成功获取到 accessToken
if (accessToken) { if (accessToken) {
accessStore.setAccessToken(accessToken); accessStore.setAccessToken(accessToken);
// 刷新令牌持久化pinia persist供 access token 过期时静默续期
accessStore.setRefreshToken(refreshToken);
// 获取用户信息并存储到 accessStore 中 // 获取用户信息并存储到 accessStore 中
const [fetchUserInfoResult, accessCodes] = await Promise.all([ const [fetchUserInfoResult, accessCodes] = await Promise.all([
fetchUserInfo(), fetchUserInfo(),
@ -80,9 +78,7 @@ export const useAuthStore = defineStore('auth', () => {
async function logout(redirect: boolean = true) { async function logout(redirect: boolean = true) {
try { try {
// 携带 refreshToken 让后端撤销刷新会话(阻止该设备继续续期); await logoutApi();
// 后端幂等,即使令牌无效也返回成功,失败不阻塞本地登出。
await logoutApi(accessStore.refreshToken ?? undefined);
} catch { } catch {
// 不做任何处理 // 不做任何处理
} }

View File

@ -1,20 +1,19 @@
/** /**
* 全局主题美化 * 全局主题美化
* 设计语言跟随系统主题色可在偏好设置中切换主题色柔和圆角轻投影有纵深的浅色背景 * 设计语言靛蓝(indigo)主色 + (violet)渐变点缀柔和圆角轻投影有纵深的浅色背景
* 说明Vben useTDesignDesignTokens 会用 JS 根据主题色动态把 --td-* 品牌色写到 * 说明Vben useTDesignDesignTokens 会用 JS 把品牌色写到 documentElement
* documentElement 因此这里不再用固定色值覆盖品牌色改为引用 --primary * 普通 :root 规则会被内联样式压过因此对关键 --td-* 变量加 !important 强制生效
* 变量保证切换主题色时整个系统颜色同步变化
*/ */
:root { :root {
/* —— 品牌主色:跟随系统主题色(--primary支持在系统内切换 —— */ /* —— 品牌主色(靛蓝 #4f5bff → 紫 #7c3aed 渐变体系) —— */
--td-brand-color: var(--primary); --td-brand-color: #4f5bff !important;
--td-brand-color-hover: var(--primary-600); --td-brand-color-hover: #6b75ff !important;
--td-brand-color-active: var(--primary-700); --td-brand-color-active: #3a45e0 !important;
--td-brand-color-disabled: var(--primary-200); --td-brand-color-disabled: #c2c7ff !important;
--td-brand-color-light: var(--primary-50); --td-brand-color-light: #eef0ff !important;
--td-brand-color-light-hover: var(--primary-100); --td-brand-color-light-hover: #e0e3ff !important;
--td-brand-color-light-active: var(--primary-200); --td-brand-color-light-active: #d6daff !important;
/* —— 圆角:更现代、更柔 —— */ /* —— 圆角:更现代、更柔 —— */
--td-radius-small: 6px !important; --td-radius-small: 6px !important;
@ -50,12 +49,12 @@ body {
background-color: #f5f7fb; background-color: #f5f7fb;
background-image: radial-gradient( background-image: radial-gradient(
1200px 600px at 100% -10%, 1200px 600px at 100% -10%,
color-mix(in srgb, var(--primary) 8%, transparent), rgb(79 91 255 / 6%),
transparent 60% transparent 60%
), ),
radial-gradient( radial-gradient(
900px 500px at -10% 110%, 900px 500px at -10% 110%,
color-mix(in srgb, var(--primary) 7%, transparent), rgb(124 58 237 / 5%),
transparent 55% transparent 55%
); );
background-attachment: fixed; background-attachment: fixed;
@ -95,24 +94,20 @@ body {
background-color: #fafbff !important; background-color: #fafbff !important;
} }
/* 主按钮:带品牌渐变,更出彩(跟随主题色) */ /* 主按钮:带品牌渐变,更出彩 */
.t-button--theme-primary:not(.t-button--variant-outline):not( .t-button--theme-primary:not(.t-button--variant-outline):not(
.t-button--variant-text .t-button--variant-text
):not(.t-button--variant-dashed) { ):not(.t-button--variant-dashed) {
background: linear-gradient( background: linear-gradient(135deg, #4f5bff 0%, #7c3aed 100%);
135deg,
var(--primary) 0%,
var(--primary-700) 100%
);
border: none; border: none;
box-shadow: 0 4px 12px color-mix(in srgb, var(--primary) 28%, transparent); box-shadow: 0 4px 12px rgb(79 91 255 / 28%);
transition: transform 0.12s ease, box-shadow 0.12s ease, filter 0.12s ease; 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--theme-primary:not(.t-button--variant-outline):not(
.t-button--variant-text .t-button--variant-text
):not(.t-button--variant-dashed):hover { ):not(.t-button--variant-dashed):hover {
box-shadow: 0 6px 16px color-mix(in srgb, var(--primary) 36%, transparent); box-shadow: 0 6px 16px rgb(79 91 255 / 36%);
filter: brightness(1.05); filter: brightness(1.05);
transform: translateY(-1px); transform: translateY(-1px);
} }
@ -147,12 +142,12 @@ html[theme-mode='dark'] body {
background-color: #16171a; background-color: #16171a;
background-image: radial-gradient( background-image: radial-gradient(
1200px 600px at 100% -10%, 1200px 600px at 100% -10%,
color-mix(in srgb, var(--primary) 14%, transparent), rgb(79 91 255 / 12%),
transparent 60% transparent 60%
), ),
radial-gradient( radial-gradient(
900px 500px at -10% 110%, 900px 500px at -10% 110%,
color-mix(in srgb, var(--primary) 12%, transparent), rgb(124 58 237 / 10%),
transparent 55% transparent 55%
); );
} }

View File

@ -1,213 +0,0 @@
/**
* 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 加密的明文载荷base64nonce 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 CryptoKeySPKI / 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||tagGCM 自带完整性校验
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 };
}

View File

@ -1,16 +1,14 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { LoginLogItem, LogItem, MenuNode } from '#/api/system'; import type { LoginLogItem, LogItem, MenuNode } from '#/api/system';
import { computed, h, onMounted, reactive, ref } from 'vue'; import { computed, onMounted, reactive, ref } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { Page } from '@vben/common-ui'; import { Page } from '@vben/common-ui';
import { useUserStore } from '@vben/stores'; import { useUserStore } from '@vben/stores';
import { MessagePlugin, Tag } from 'tdesign-vue-next'; import { MessagePlugin } from 'tdesign-vue-next';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { import {
getAdminList, getAdminList,
getLoginLogList, getLoginLogList,
@ -93,66 +91,32 @@ const quickNavs = [
}, },
{ {
title: '操作日志', title: '操作日志',
path: '/log/operation', path: '/system/log/operation',
icon: ICONS.log, icon: ICONS.log,
gradient: 'linear-gradient(135deg,#8b5cf6,#a855f7)', gradient: 'linear-gradient(135deg,#8b5cf6,#a855f7)',
}, },
{ {
title: '登录日志', title: '登录日志',
path: '/log/login', path: '/system/log/login',
icon: ICONS.login, icon: ICONS.login,
gradient: 'linear-gradient(135deg,#f59e0b,#fbbf24)', gradient: 'linear-gradient(135deg,#f59e0b,#fbbf24)',
}, },
]; ];
// proxyConfig load() const opColumns = [
const opGridOptions: VxeTableGridOptions = { { colKey: 'adminId', title: '操作人ID', width: 90 },
columns: [ { colKey: 'permission', title: '权限码', ellipsis: true },
{ type: 'seq', width: 50, title: '#' }, { colKey: 'path', title: '接口', ellipsis: true },
{ field: 'adminId', title: '操作人ID', width: 90 }, { colKey: 'ip', title: 'IP', width: 130 },
{ field: 'permission', title: '权限码', showOverflow: 'tooltip' }, { colKey: 'createdAt', title: '时间', width: 170 },
{ field: 'path', title: '接口', showOverflow: 'tooltip' }, ];
{ field: 'ip', title: 'IP', width: 130 },
{ field: 'createdAt', title: '时间', width: 170 },
],
pagerConfig: { enabled: false }, // 5
proxyConfig: { enabled: false }, //
};
const [OpGrid, opGridApi] = useVbenVxeGrid({ gridOptions: opGridOptions }); const loginColumns = [
{ colKey: 'username', title: '账号', width: 120 },
// { colKey: 'ip', title: 'IP', width: 130 },
const loginGridOptions: VxeTableGridOptions = { { colKey: 'statusText', title: '结果', width: 90 },
columns: [ { colKey: 'createdAt', title: '时间', width: 170 },
{ 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() { function formatToday() {
const d = new Date(); const d = new Date();
@ -191,13 +155,9 @@ async function load() {
if (opRes.status === 'fulfilled') { if (opRes.status === 'fulfilled') {
stats.opLogCount = opRes.value.total ?? 0; stats.opLogCount = opRes.value.total ?? 0;
opLogs.value = opRes.value.list ?? []; opLogs.value = opRes.value.list ?? [];
//
opGridApi.setGridOptions({ data: opLogs.value });
} }
if (loginRes.status === 'fulfilled') { if (loginRes.status === 'fulfilled')
loginLogs.value = loginRes.value.list ?? []; loginLogs.value = loginRes.value.list ?? [];
loginGridApi.setGridOptions({ data: loginLogs.value });
}
} catch { } catch {
MessagePlugin.warning('部分数据加载失败'); MessagePlugin.warning('部分数据加载失败');
} finally { } finally {
@ -264,10 +224,33 @@ onMounted(load);
<section class="wb-grid"> <section class="wb-grid">
<div class="wb-col"> <div class="wb-col">
<t-card title="最新操作日志" :bordered="false" class="wb-card"> <t-card title="最新操作日志" :bordered="false" class="wb-card">
<OpGrid /> <t-table
row-key="id"
:data="opLogs"
:columns="opColumns"
:loading="loading"
size="small"
:pagination="false"
/>
</t-card> </t-card>
<t-card title="最新登录日志" :bordered="false" class="wb-card"> <t-card title="最新登录日志" :bordered="false" class="wb-card">
<LoginGrid /> <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>
</t-card> </t-card>
</div> </div>
@ -332,7 +315,11 @@ onMounted(load);
width: 360px; width: 360px;
height: 360px; height: 360px;
pointer-events: none; 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%; border-radius: 50%;
filter: blur(8px); filter: blur(8px);
animation: wb-float 9s ease-in-out infinite; animation: wb-float 9s ease-in-out infinite;

View File

@ -1,15 +1,13 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { CommunityItem } from '#/api/house'; import type { CommunityItem } from '#/api/house';
import { h, reactive, ref } from 'vue'; import { onMounted, reactive, ref } from 'vue';
import { useAccess } from '@vben/access'; import { useAccess } from '@vben/access';
import { Page, useVbenDrawer, VbenTableAction } from '@vben/common-ui'; import { Page } from '@vben/common-ui';
import { DialogPlugin, MessagePlugin } from 'tdesign-vue-next'; import { DialogPlugin, MessagePlugin } from 'tdesign-vue-next';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { import {
createCommunity, createCommunity,
deleteCommunity, deleteCommunity,
@ -17,32 +15,21 @@ import {
updateCommunity, updateCommunity,
} from '#/api/house'; } from '#/api/house';
// + / + // + / +
const { hasAccessByCodes } = useAccess(); const { hasAccessByCodes } = useAccess();
const editing = ref<CommunityItem | null>(null); const loading = ref(false);
const list = ref<CommunityItem[]>([]);
// /vben const dialogOpen = ref(false);
const [Drawer, drawerApi] = useVbenDrawer({ const editing = ref<null | CommunityItem>(null);
title: '新增小区',
onConfirm: submit,
});
const regionOptions = [ const regionOptions = [
'云岩区', '云岩区', '南明区', '观山湖区', '花溪区', '乌当区', '白云区',
'南明区', '清镇市', '修文县', '开阳县', '息烽县',
'观山湖区',
'花溪区',
'乌当区',
'白云区',
'清镇市',
'修文县',
'开阳县',
'息烽县',
].map((r) => ({ label: r, value: r })); ].map((r) => ({ label: r, value: r }));
// grid const query = reactive({ page: 1, size: 10, keyword: '', region: '' });
const query = reactive({ keyword: '', region: '' }); const pagination = reactive({ current: 1, pageSize: 10, total: 0 });
const form = reactive({ const form = reactive({
name: '', name: '',
@ -60,139 +47,88 @@ const form = reactive({
developer: '', 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() { function openCreate() {
editing.value = null; editing.value = null;
Object.assign(form, { Object.assign(form, {
name: '', name: '', region: '', businessDistrict: '', address: '', lng: 0, lat: 0,
region: '', buildYear: 0, households: 0, plotRatio: 0, greenRate: 0,
businessDistrict: '', propertyCompany: '', propertyFee: 0, developer: '',
address: '',
lng: 0,
lat: 0,
buildYear: 0,
households: 0,
plotRatio: 0,
greenRate: 0,
propertyCompany: '',
propertyFee: 0,
developer: '',
}); });
drawerApi.setState({ title: '新增小区' }); dialogOpen.value = true;
drawerApi.open();
} }
function openEdit(row: CommunityItem) { function openEdit(row: CommunityItem) {
editing.value = row; editing.value = row;
Object.assign(form, { ...row }); Object.assign(form, { ...row });
drawerApi.setState({ title: '编辑小区' }); dialogOpen.value = true;
drawerApi.open();
} }
async function submit() { async function submit() {
// if (editing.value) {
editing.value await updateCommunity(editing.value.id, form);
? await updateCommunity(editing.value.id, form) } else {
: await createCommunity(form); await createCommunity(form);
}
MessagePlugin.success('保存成功'); MessagePlugin.success('保存成功');
drawerApi.close(); dialogOpen.value = false;
gridApi.query(); load();
} }
/** 删除小区(保留原确认弹窗行为) */
function onDelete(row: CommunityItem) { function onDelete(row: CommunityItem) {
const d = DialogPlugin.confirm({ const d = DialogPlugin.confirm({
header: `确认删除「${row.name}」?`, header: `确认删除「${row.name}」?`,
onConfirm: async () => { onConfirm: async () => {
await deleteCommunity(row.id); await deleteCommunity(row.id);
MessagePlugin.success('删除成功'); MessagePlugin.success('删除成功');
gridApi.query(); load();
d.destroy(); d.destroy();
}, },
}); });
} }
const gridOptions: VxeTableGridOptions = { const columns = [
columns: [ { colKey: 'id', title: 'ID', width: 70 },
{ type: 'seq', width: 50, title: '#' }, { colKey: 'name', title: '小区名称', width: 180 },
{ field: 'id', title: 'ID', width: 70 }, { colKey: 'region', title: '区县', width: 100 },
{ field: 'name', title: '小区名称', width: 180 }, { colKey: 'businessDistrict', title: '板块', width: 120 },
{ field: 'region', title: '区县', width: 100 }, { colKey: 'address', title: '地址' },
{ field: 'businessDistrict', title: '板块', width: 120 }, { colKey: 'buildYear', title: '建成年份', width: 90 },
{ field: 'address', title: '地址', showOverflow: 'tooltip' }, { colKey: 'propertyCompany', title: '物业', width: 140 },
{ field: 'buildYear', title: '建成年份', width: 90 }, { colKey: 'op', title: '操作', width: 160 },
{ 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 };
},
},
},
};
const [Grid, gridApi] = useVbenVxeGrid({ gridOptions }); function onPageChange(p: { current: number; pageSize: number }) {
query.page = p.current;
/** 触发查询(保留分页条件,因 grid 自动管理分页) */ query.size = p.pageSize;
function search() { pagination.current = p.current;
gridApi.query(); pagination.pageSize = p.pageSize;
load();
} }
/** 重置筛选并回到第一页重新查询 */ onMounted(load);
function reset() {
query.keyword = '';
query.region = '';
gridApi.reload();
}
</script> </script>
<template> <template>
<Page auto-content-height> <Page auto-content-height>
<div class="flex items-center justify-between"> <div class="mb-3 flex items-center justify-between">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<t-input <t-input
v-model="query.keyword" v-model="query.keyword"
clearable clearable
placeholder="小区名称" placeholder="小区名称"
style="width: 200px" style="width: 200px"
@enter="search" @enter="load"
/> />
<t-select <t-select
v-model="query.region" v-model="query.region"
@ -201,8 +137,7 @@ function reset() {
placeholder="区县" placeholder="区县"
style="width: 140px" style="width: 140px"
/> />
<t-button theme="primary" @click="search">查询</t-button> <t-button theme="primary" @click="load">查询</t-button>
<t-button variant="outline" @click="reset">重置</t-button>
</div> </div>
<t-button <t-button
v-if="hasAccessByCodes(['house:community:create'])" v-if="hasAccessByCodes(['house:community:create'])"
@ -213,22 +148,49 @@ function reset() {
</t-button> </t-button>
</div> </div>
<!-- 小区列表Vben Vxe Table + TableAction --> <t-table
<Grid /> 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 封装抽屉组件 --> <t-dialog
<Drawer class="w-160"> v-model:visible="dialogOpen"
:header="editing ? '编辑小区' : '新增小区'"
width="640px"
:on-confirm="submit"
>
<t-form label-align="right" label-width="90px"> <t-form label-align="right" label-width="90px">
<div class="grid grid-cols-2 gap-x-4"> <div class="grid grid-cols-2 gap-x-4">
<t-form-item label="小区名称" required> <t-form-item label="小区名称" required>
<t-input v-model="form.name" placeholder="如:中天未来方舟" /> <t-input v-model="form.name" placeholder="如:中天未来方舟" />
</t-form-item> </t-form-item>
<t-form-item label="区县"> <t-form-item label="区县">
<t-select <t-select v-model="form.region" clearable :options="regionOptions" />
v-model="form.region"
clearable
:options="regionOptions"
/>
</t-form-item> </t-form-item>
<t-form-item label="板块"> <t-form-item label="板块">
<t-input v-model="form.businessDistrict" placeholder="如:会展城" /> <t-input v-model="form.businessDistrict" placeholder="如:会展城" />
@ -246,21 +208,13 @@ function reset() {
<t-input v-model="form.address" /> <t-input v-model="form.address" />
</t-form-item> </t-form-item>
<t-form-item label="经度"> <t-form-item label="经度">
<t-input-number <t-input-number v-model="form.lng" theme="normal" :decimal-places="6" />
v-model="form.lng"
theme="normal"
:decimal-places="6"
/>
</t-form-item> </t-form-item>
<t-form-item label="纬度"> <t-form-item label="纬度">
<t-input-number <t-input-number v-model="form.lat" theme="normal" :decimal-places="6" />
v-model="form.lat"
theme="normal"
:decimal-places="6"
/>
</t-form-item> </t-form-item>
</div> </div>
</t-form> </t-form>
</Drawer> </t-dialog>
</Page> </Page>
</template> </template>

View File

@ -1,14 +1,16 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table'; import type { TransactionItem } from '#/api/house';
import { reactive } from 'vue'; import { onMounted, reactive, ref } from 'vue';
import { Page } from '@vben/common-ui'; import { Page } from '@vben/common-ui';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getTransactionList } from '#/api/house'; import { getTransactionList } from '#/api/house';
// Vben Vxe Table// // //
const loading = ref(false);
const list = ref<TransactionItem[]>([]);
const regionOptions = [ const regionOptions = [
'云岩区', '云岩区',
'南明区', '南明区',
@ -22,66 +24,60 @@ const regionOptions = [
'息烽县', '息烽县',
].map((r) => ({ label: r, value: r })); ].map((r) => ({ label: r, value: r }));
// grid const query = reactive({ page: 1, size: 10, keyword: '', region: '' });
const query = reactive({ keyword: '', region: '' }); const pagination = reactive({ current: 1, pageSize: 10, total: 0 });
const gridOptions: VxeTableGridOptions = { async function load() {
columns: [ loading.value = true;
{ type: 'seq', width: 50, title: '#' }, try {
{ field: 'id', title: 'ID', width: 70 }, const data = await getTransactionList(query);
{ field: 'communityName', title: '小区', width: 180 }, list.value = data.list ?? [];
{ field: 'layout', title: '户型', width: 100 }, pagination.total = data.total ?? 0;
{ field: 'area', title: '面积㎡', width: 90 }, } finally {
{ field: 'dealPrice', title: '成交价(万)', width: 110 }, loading.value = false;
{ 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() { function reset() {
query.keyword = ''; query.keyword = '';
query.region = ''; query.region = '';
gridApi.reload(); query.page = 1;
pagination.current = 1;
load();
} }
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> </script>
<template> <template>
<Page auto-content-height> <Page auto-content-height>
<div class="flex items-center gap-2"> <div class="mb-3 flex items-center gap-2">
<t-input <t-input
v-model="query.keyword" v-model="query.keyword"
clearable clearable
placeholder="小区名/户型" placeholder="小区名/户型"
style="width: 200px" style="width: 200px"
@enter="search" @enter="load"
/> />
<t-select <t-select
v-model="query.region" v-model="query.region"
@ -90,11 +86,17 @@ function reset() {
placeholder="区县" placeholder="区县"
style="width: 140px" style="width: 140px"
/> />
<t-button theme="primary" @click="search">查询</t-button> <t-button theme="primary" @click="load">查询</t-button>
<t-button variant="outline" @click="reset">重置</t-button> <t-button variant="outline" @click="reset">重置</t-button>
</div> </div>
<!-- 成交记录列表Vben Vxe Table --> <t-table
<Grid /> row-key="id"
:data="list"
:columns="columns"
:loading="loading"
:pagination="pagination"
@page-change="onPageChange"
/>
</Page> </Page>
</template> </template>

View File

@ -1,15 +1,13 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { ListingItem } from '#/api/house'; import type { ListingItem } from '#/api/house';
import { h, reactive, ref } from 'vue'; import { onMounted, reactive, ref } from 'vue';
import { useAccess } from '@vben/access'; import { useAccess } from '@vben/access';
import { Page, useVbenDrawer, VbenTableAction } from '@vben/common-ui'; import { Page } from '@vben/common-ui';
import { DialogPlugin, MessagePlugin, Tag } from 'tdesign-vue-next'; import { DialogPlugin, MessagePlugin } from 'tdesign-vue-next';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { import {
batchMarkListing, batchMarkListing,
deleteListing, deleteListing,
@ -20,29 +18,20 @@ import {
// + //+ / // + //+ /
const { hasAccessByCodes } = useAccess(); const { hasAccessByCodes } = useAccess();
const editing = ref<ListingItem | null>(null); const loading = ref(false);
const list = ref<ListingItem[]>([]);
// vben const selectedKeys = ref<number[]>([]);
const [Drawer, drawerApi] = useVbenDrawer({ const dialogOpen = ref(false);
title: '编辑房源', const editing = ref<null | ListingItem>(null);
onConfirm: submit,
});
const regionOptions = [ const regionOptions = [
'云岩区', '云岩区', '南明区', '观山湖区', '花溪区', '乌当区', '白云区',
'南明区', '清镇市', '修文县', '开阳县', '息烽县',
'观山湖区',
'花溪区',
'乌当区',
'白云区',
'清镇市',
'修文县',
'开阳县',
'息烽县',
].map((r) => ({ label: r, value: r })); ].map((r) => ({ label: r, value: r }));
// grid
const query = reactive({ const query = reactive({
page: 1,
size: 10,
keyword: '', keyword: '',
region: '', region: '',
layout: '', layout: '',
@ -53,28 +42,47 @@ const query = reactive({
isBargain: 0, isBargain: 0,
confidence: 0, confidence: 0,
}); });
const pagination = reactive({ current: 1, pageSize: 10, total: 0 });
/** 收集当前勾选的房源 ID */ async function load() {
function getSelectedIds(): number[] { loading.value = true;
const records = (gridApi.grid?.getCheckboxRecords() ?? []) as ListingItem[]; try {
return records.map((r) => Number(r.id)); 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);
} }
/** 批量标记(笋盘/低可信/状态) */ /** 批量标记(笋盘/低可信/状态) */
async function onBatchMark(field: string, value: number) { async function onBatchMark(field: string, value: number) {
const ids = getSelectedIds(); if (selectedKeys.value.length === 0) {
if (ids.length === 0) {
MessagePlugin.warning('请先勾选房源'); MessagePlugin.warning('请先勾选房源');
return; return;
} }
await batchMarkListing({ ids, field, value }); await batchMarkListing({ ids: selectedKeys.value, field, value });
MessagePlugin.success('标记成功'); MessagePlugin.success('标记成功');
gridApi.query(); load();
} }
function openEdit(row: ListingItem) { function openEdit(row: ListingItem) {
editing.value = row; editing.value = row;
drawerApi.open(); dialogOpen.value = true;
} }
async function submit() { async function submit() {
@ -89,186 +97,57 @@ async function submit() {
}); });
} }
MessagePlugin.success('保存成功'); MessagePlugin.success('保存成功');
drawerApi.close(); dialogOpen.value = false;
gridApi.query(); load();
} }
/** 删除房源(保留原确认弹窗行为) */
function onDelete(row: ListingItem) { function onDelete(row: ListingItem) {
const d = DialogPlugin.confirm({ const d = DialogPlugin.confirm({
header: '确认删除该房源?', header: '确认删除该房源?',
onConfirm: async () => { onConfirm: async () => {
await deleteListing(row.id); await deleteListing(row.id);
MessagePlugin.success('删除成功'); MessagePlugin.success('删除成功');
gridApi.query(); load();
d.destroy(); d.destroy();
}, },
}); });
} }
/** 状态码 -> TDesign 标签主题色1 在售 / 2 下架 / 3 成交) */ const columns = [
function statusTheme(status: number) { { colKey: 'row-select', type: 'multiple', width: 46 },
if (status === 1) return 'success'; { colKey: 'id', title: 'ID', width: 70 },
if (status === 2) return 'default'; { colKey: 'communityName', title: '小区', width: 160 },
return 'primary'; { 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();
} }
/** 状态码 -> 文案 */ 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> </script>
<template> <template>
<Page auto-content-height> <Page auto-content-height>
<div class="flex flex-wrap items-center gap-2"> <div class="mb-3 flex flex-wrap items-center gap-2">
<t-input <t-input
v-model="query.keyword" v-model="query.keyword"
clearable clearable
placeholder="房号/户型" placeholder="房号/户型"
style="width: 160px" style="width: 160px"
@enter="search" @enter="load"
/> />
<t-select <t-select
v-model="query.region" v-model="query.region"
@ -305,7 +184,7 @@ function reset() {
]" ]"
style="width: 110px" style="width: 110px"
/> />
<t-button theme="primary" @click="search">查询</t-button> <t-button theme="primary" @click="load">查询</t-button>
<t-button variant="outline" @click="reset">重置</t-button> <t-button variant="outline" @click="reset">重置</t-button>
<span class="ml-auto flex items-center gap-2"> <span class="ml-auto flex items-center gap-2">
<t-button <t-button
@ -325,11 +204,59 @@ function reset() {
</span> </span>
</div> </div>
<!-- 房源列表Vben Vxe Table + TableAction --> <t-table
<Grid /> 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 封装抽屉组件 --> <t-dialog
<Drawer class="w-150"> v-model:visible="dialogOpen"
header="编辑房源"
width="560px"
:on-confirm="submit"
>
<t-form v-if="editing" label-align="right" label-width="90px"> <t-form v-if="editing" label-align="right" label-width="90px">
<div class="grid grid-cols-2 gap-x-4"> <div class="grid grid-cols-2 gap-x-4">
<t-form-item label="挂牌价(万)"> <t-form-item label="挂牌价(万)">
@ -359,6 +286,6 @@ function reset() {
</t-form-item> </t-form-item>
</div> </div>
</t-form> </t-form>
</Drawer> </t-dialog>
</Page> </Page>
</template> </template>

View File

@ -1,14 +1,16 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table'; import type { PresaleItem } from '#/api/house';
import { reactive } from 'vue'; import { onMounted, reactive, ref } from 'vue';
import { Page } from '@vben/common-ui'; import { Page } from '@vben/common-ui';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getPresaleList } from '#/api/house'; import { getPresaleList } from '#/api/house';
// Vben Vxe Table //
const loading = ref(false);
const list = ref<PresaleItem[]>([]);
const regionOptions = [ const regionOptions = [
'云岩区', '云岩区',
'南明区', '南明区',
@ -28,74 +30,68 @@ const purposeOptions = [
{ label: '商业', value: '商业' }, { label: '商业', value: '商业' },
]; ];
// grid const query = reactive({
const query = reactive({ keyword: '', region: '', purpose: '' }); page: 1,
size: 10,
const gridOptions: VxeTableGridOptions = { keyword: '',
columns: [ region: '',
{ type: 'seq', width: 50, title: '#' }, purpose: '',
{ 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 pagination = reactive({ current: 1, pageSize: 10, total: 0 });
},
},
},
};
const [Grid, gridApi] = useVbenVxeGrid({ gridOptions }); async function load() {
loading.value = true;
/** 触发查询(保留分页条件,因 grid 自动管理分页) */ try {
function search() { const data = await getPresaleList(query);
gridApi.query(); list.value = data.list ?? [];
pagination.total = data.total ?? 0;
} finally {
loading.value = false;
}
} }
/** 重置筛选并回到第一页重新查询 */
function reset() { function reset() {
query.keyword = ''; query.keyword = '';
query.region = ''; query.region = '';
query.purpose = ''; query.purpose = '';
gridApi.reload(); query.page = 1;
pagination.current = 1;
load();
} }
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> </script>
<template> <template>
<Page auto-content-height> <Page auto-content-height>
<div class="flex items-center gap-2"> <div class="mb-3 flex items-center gap-2">
<t-input <t-input
v-model="query.keyword" v-model="query.keyword"
clearable clearable
placeholder="楼盘名/开发商/预售证号" placeholder="楼盘名/开发商/预售证号"
style="width: 240px" style="width: 240px"
@enter="search" @enter="load"
/> />
<t-select <t-select
v-model="query.region" v-model="query.region"
@ -111,11 +107,17 @@ function reset() {
placeholder="用途" placeholder="用途"
style="width: 120px" style="width: 120px"
/> />
<t-button theme="primary" @click="search">查询</t-button> <t-button theme="primary" @click="load">查询</t-button>
<t-button variant="outline" @click="reset">重置</t-button> <t-button variant="outline" @click="reset">重置</t-button>
</div> </div>
<!-- 预售许可列表Vben Vxe Table --> <t-table
<Grid /> row-key="id"
:data="list"
:columns="columns"
:loading="loading"
:pagination="pagination"
@page-change="onPageChange"
/>
</Page> </Page>
</template> </template>

View File

@ -1,200 +0,0 @@
<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>

View File

@ -1,27 +1,31 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { SourceItem } from '#/api/recruitment'; import type { SourceItem } from '#/api/recruitment';
// + + // + +
import { h, onMounted, ref } from 'vue'; import { onMounted, ref } from 'vue';
import { useAccess } from '@vben/access'; import { useAccess } from '@vben/access';
import { Page, VbenTableAction } from '@vben/common-ui'; import { Page } from '@vben/common-ui';
import { DialogPlugin, MessagePlugin, Tag } from 'tdesign-vue-next'; import { DialogPlugin, MessagePlugin } from 'tdesign-vue-next';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getSourceList, triggerCrawl } from '#/api/recruitment'; import { getSourceList, triggerCrawl } from '#/api/recruitment';
const { hasAccessByCodes } = useAccess(); const { hasAccessByCodes } = useAccess();
const loading = ref(false);
const list = ref<SourceItem[]>([]);
const crawling = ref(false); const crawling = ref(false);
const runningSummary = ref(''); const runningSummary = ref('');
/** 拉取数据源列表并写入本地数据表格 */
async function load() { async function load() {
loading.value = true;
try {
const data = await getSourceList(); const data = await getSourceList();
gridApi.setGridOptions({ data: data.list ?? [] }); list.value = data.list ?? [];
} finally {
loading.value = false;
}
} }
function onTriggerAll() { function onTriggerAll() {
@ -51,86 +55,23 @@ async function doTrigger(sourceId: number, force: boolean) {
} }
} }
// proxyConfig load() const columns = [
const gridOptions: VxeTableGridOptions = { { colKey: 'id', title: 'ID', width: 70 },
columns: [ { colKey: 'name', title: '数据源', minWidth: 200 },
{ type: 'seq', width: 50, title: '#' }, { colKey: 'region', title: '地区', width: 100 },
{ field: 'id', title: 'ID', width: 70 }, { colKey: 'baseUrl', title: '地址', minWidth: 220, ellipsis: true },
{ field: 'name', title: '数据源', minWidth: 200 }, { colKey: 'enabled', title: '启用', width: 80 },
{ field: 'region', title: '地区', width: 100 }, { colKey: 'lastSuccessAt', title: '上次成功', width: 160 },
{ field: 'baseUrl', title: '地址', minWidth: 220, showOverflow: 'tooltip' }, { colKey: 'failCount', title: '连续失败', width: 90 },
{ { colKey: 'op', title: '操作', width: 100, fixed: 'right' },
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); onMounted(load);
</script> </script>
<template> <template>
<Page auto-content-height> <Page auto-content-height>
<div class="flex items-center gap-2"> <div class="mb-3 flex items-center gap-2">
<t-button <t-button
v-if="hasAccessByCodes(['recruitment:crawler:trigger'])" v-if="hasAccessByCodes(['recruitment:crawler:trigger'])"
theme="primary" theme="primary"
@ -142,8 +83,38 @@ onMounted(load);
<span class="text-xs opacity-60">定时任务每日 03:00 自动抓取</span> <span class="text-xs opacity-60">定时任务每日 03:00 自动抓取</span>
</div> </div>
<!-- 数据源列表Vben Vxe Table --> <t-table row-key="id" :data="list" :columns="columns" :loading="loading">
<Grid /> <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>
<div v-if="runningSummary" class="mt-4 rounded-lg border p-3"> <div v-if="runningSummary" class="mt-4 rounded-lg border p-3">
<p class="mb-1 text-sm font-medium">本次运行摘要</p> <p class="mb-1 text-sm font-medium">本次运行摘要</p>

View File

@ -1,15 +1,13 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table'; // + +
// + Vben Vxe Table + Vben TableAction +
import type { RecruitmentItem } from '#/api/recruitment'; import type { RecruitmentItem } from '#/api/recruitment';
import { h, reactive, ref } from 'vue'; import { onMounted, reactive, ref } from 'vue';
import { Page, VbenTableAction } from '@vben/common-ui'; import { Page } from '@vben/common-ui';
import { MessagePlugin, Tag } from 'tdesign-vue-next'; import { MessagePlugin } from 'tdesign-vue-next';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { import {
CATEGORY_OPTIONS, CATEGORY_OPTIONS,
getRecruitmentDetail, getRecruitmentDetail,
@ -18,13 +16,18 @@ import {
STATUS_OPTIONS, STATUS_OPTIONS,
} from '#/api/recruitment'; } 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 detailOpen = ref(false);
const detailLoading = ref(false); const detailLoading = ref(false);
const detail = ref<null | RecruitmentItem>(null); const detail = ref<null | RecruitmentItem>(null);
// grid
const query = reactive({ const query = reactive({
page: 1,
size: 10,
region: '', region: '',
category: 0, category: 0,
keyword: '', keyword: '',
@ -36,70 +39,12 @@ const query = reactive({
onlyNewToday: false, onlyNewToday: false,
}); });
// statusName/op slots async function load() {
/** 状态码 -> TDesign 标签主题色0 招集中 / 1 报名中 / 其余默认) */ loading.value = true;
function statusTheme(status: number) { try {
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({ const data = await getRecruitmentList({
page: page.currentPage, page: query.page,
size: page.pageSize, size: query.size,
region: query.region || undefined, region: query.region || undefined,
category: query.category || undefined, category: query.category || undefined,
keyword: query.keyword || undefined, keyword: query.keyword || undefined,
@ -110,20 +55,13 @@ const gridOptions: VxeTableGridOptions = {
sourceId: query.sourceId || undefined, sourceId: query.sourceId || undefined,
onlyNewToday: query.onlyNewToday || undefined, onlyNewToday: query.onlyNewToday || undefined,
}); });
return { items: data.list ?? [], total: data.total ?? 0 }; list.value = data.list ?? [];
}, pagination.total = data.total ?? 0;
}, } finally {
}, loading.value = false;
}; }
const [Grid, gridApi] = useVbenVxeGrid({ gridOptions });
/** 触发查询(保留分页条件,因 grid 自动管理分页) */
function search() {
gridApi.query();
} }
/** 重置筛选并回到第一页重新查询 */
function reset() { function reset() {
Object.assign(query, { Object.assign(query, {
region: '', region: '',
@ -136,7 +74,17 @@ function reset() {
sourceId: 0, sourceId: 0,
onlyNewToday: false, onlyNewToday: false,
}); });
gridApi.reload(); 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();
} }
async function openDetail(row: RecruitmentItem) { async function openDetail(row: RecruitmentItem) {
@ -151,24 +99,37 @@ 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() { function copyUrl() {
if (detail.value?.url) { if (detail.value?.url) {
navigator.clipboard?.writeText(detail.value.url); navigator.clipboard?.writeText(detail.value.url);
MessagePlugin.success('链接已复制'); MessagePlugin.success('链接已复制');
} }
} }
onMounted(load);
</script> </script>
<template> <template>
<Page auto-content-height> <Page auto-content-height>
<div class="flex flex-wrap items-center gap-2"> <div class="mb-3 flex flex-wrap items-center gap-2">
<t-input <t-input
v-model="query.keyword" v-model="query.keyword"
clearable clearable
placeholder="标题/正文/单位关键字" placeholder="标题/正文/单位关键字"
style="width: 200px" style="width: 200px"
@enter="search" @enter="load"
/> />
<t-select <t-select
v-model="query.region" v-model="query.region"
@ -207,15 +168,47 @@ function copyUrl() {
clearable clearable
placeholder="发布主体模糊" placeholder="发布主体模糊"
style="width: 150px" style="width: 150px"
@enter="search" @enter="load"
/> />
<t-checkbox v-model="query.onlyNewToday">仅今日新增</t-checkbox> <t-checkbox v-model="query.onlyNewToday">仅今日新增</t-checkbox>
<t-button theme="primary" @click="search">查询</t-button> <t-button theme="primary" @click="load">查询</t-button>
<t-button variant="outline" @click="reset">重置</t-button> <t-button variant="outline" @click="reset">重置</t-button>
</div> </div>
<!-- 招聘公告列表Vben Vxe Table --> <t-table
<Grid /> 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>
<t-drawer <t-drawer
v-model:visible="detailOpen" v-model:visible="detailOpen"
@ -225,9 +218,11 @@ function copyUrl() {
> >
<div v-if="detailLoading" class="p-6 text-center opacity-60">加载中</div> <div v-if="detailLoading" class="p-6 text-center opacity-60">加载中</div>
<div v-else-if="detail"> <div v-else-if="detail">
<div class="flex flex-wrap gap-2 text-xs"> <div class="mb-3 flex flex-wrap gap-2 text-xs">
<t-tag theme="primary" variant="light"> <t-tag theme="primary" variant="light">
{{ detail.categoryName }} {{
detail.categoryName
}}
</t-tag> </t-tag>
<t-tag theme="success" variant="light">{{ detail.statusName }}</t-tag> <t-tag theme="success" variant="light">{{ detail.statusName }}</t-tag>
<span class="opacity-60">{{ detail.region }}</span> <span class="opacity-60">{{ detail.region }}</span>

View File

@ -1,16 +1,14 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
// + / + + Bark // + / + + Bark
import type { SubscriptionItem } from '#/api/recruitment'; import type { SubscriptionItem } from '#/api/recruitment';
import { h, onMounted, reactive } from 'vue'; import { onMounted, reactive, ref } from 'vue';
import { useAccess } from '@vben/access'; import { useAccess } from '@vben/access';
import { Page, useVbenDrawer, VbenTableAction } from '@vben/common-ui'; import { Page } from '@vben/common-ui';
import { DialogPlugin, MessagePlugin, Tag } from 'tdesign-vue-next'; import { DialogPlugin, MessagePlugin } from 'tdesign-vue-next';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { import {
CATEGORY_OPTIONS, CATEGORY_OPTIONS,
deleteSubscription, deleteSubscription,
@ -31,11 +29,12 @@ function categoryLabels(cats: number[] | undefined): string {
const { hasAccessByCodes } = useAccess(); const { hasAccessByCodes } = useAccess();
// vben const loading = ref(false);
const [Drawer, drawerApi] = useVbenDrawer({ const list = ref<SubscriptionItem[]>([]);
title: '新增订阅',
onConfirm: submit, //
}); const dialogOpen = ref(false);
const submitting = ref(false);
const form = reactive({ const form = reactive({
id: 0, id: 0,
name: '', name: '',
@ -47,10 +46,14 @@ const form = reactive({
enabled: 1, enabled: 1,
}); });
/** 拉取订阅列表并写入本地数据表格 */
async function load() { async function load() {
loading.value = true;
try {
const data = await getSubscriptionList(); const data = await getSubscriptionList();
gridApi.setGridOptions({ data: data.list ?? [] }); list.value = data.list ?? [];
} finally {
loading.value = false;
}
} }
function openCreate() { function openCreate() {
@ -64,8 +67,7 @@ function openCreate() {
pushTime: '08:00', pushTime: '08:00',
enabled: 1, enabled: 1,
}); });
drawerApi.setState({ title: '新增订阅' }); dialogOpen.value = true;
drawerApi.open();
} }
function openEdit(row: SubscriptionItem) { function openEdit(row: SubscriptionItem) {
@ -79,8 +81,7 @@ function openEdit(row: SubscriptionItem) {
pushTime: row.pushTime || '08:00', pushTime: row.pushTime || '08:00',
enabled: row.enabled, enabled: row.enabled,
}); });
drawerApi.setState({ title: '编辑订阅' }); dialogOpen.value = true;
drawerApi.open();
} }
async function submit() { async function submit() {
@ -88,7 +89,7 @@ async function submit() {
MessagePlugin.warning('请填写名称与设备 Key'); MessagePlugin.warning('请填写名称与设备 Key');
return; return;
} }
drawerApi.lock(true); submitting.value = true;
try { try {
await saveSubscription({ await saveSubscription({
id: form.id || undefined, id: form.id || undefined,
@ -101,14 +102,13 @@ async function submit() {
enabled: form.enabled, enabled: form.enabled,
}); });
MessagePlugin.success(form.id ? '更新成功' : '新增成功'); MessagePlugin.success(form.id ? '更新成功' : '新增成功');
drawerApi.close(); dialogOpen.value = false;
await load(); await load();
} finally { } finally {
drawerApi.lock(false); submitting.value = false;
} }
} }
/** 删除订阅(保留原确认弹窗行为) */
function onDelete(row: SubscriptionItem) { function onDelete(row: SubscriptionItem) {
const d = DialogPlugin.confirm({ const d = DialogPlugin.confirm({
header: '删除该订阅?', header: '删除该订阅?',
@ -121,7 +121,6 @@ function onDelete(row: SubscriptionItem) {
}); });
} }
/** 发送 Bark 推送测试消息 */
async function onTest(row: SubscriptionItem) { async function onTest(row: SubscriptionItem) {
const data = await testPush({ const data = await testPush({
subscriptionId: row.id, subscriptionId: row.id,
@ -135,118 +134,24 @@ async function onTest(row: SubscriptionItem) {
} }
} }
// proxyConfig load() const columns = [
const gridOptions: VxeTableGridOptions = { { colKey: 'id', title: 'ID', width: 70 },
columns: [ { colKey: 'name', title: '名称', width: 140 },
{ type: 'seq', width: 50, title: '#' }, { colKey: 'deviceKey', title: '设备 Key', width: 200, ellipsis: true },
{ field: 'id', title: 'ID', width: 70 }, { colKey: 'regionsText', title: '地区', width: 160, ellipsis: true },
{ field: 'name', title: '名称', width: 140 }, { colKey: 'categoriesText', title: '分类', width: 160, ellipsis: true },
{ { colKey: 'onlyNew', title: '仅新公告', width: 90 },
field: 'deviceKey', { colKey: 'pushTime', title: '推送时间', width: 90 },
title: '设备 Key', { colKey: 'enabled', title: '启用', width: 80 },
width: 200, { colKey: 'op', title: '操作', width: 200, fixed: 'right' },
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); onMounted(load);
</script> </script>
<template> <template>
<Page auto-content-height> <Page auto-content-height>
<div class="flex items-center gap-2"> <div class="mb-3 flex items-center gap-2">
<t-button <t-button
v-if="hasAccessByCodes(['recruitment:push:save'])" v-if="hasAccessByCodes(['recruitment:push:save'])"
theme="primary" theme="primary"
@ -256,11 +161,69 @@ onMounted(load);
</t-button> </t-button>
</div> </div>
<!-- 订阅列表Vben Vxe Table + TableAction --> <t-table row-key="id" :data="list" :columns="columns" :loading="loading">
<Grid /> <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 封装抽屉组件 --> <t-dialog
<Drawer> v-model:visible="dialogOpen"
:header="form.id ? '编辑订阅' : '新增订阅'"
width="560px"
:confirm-btn="{ loading: submitting }"
@confirm="submit"
>
<t-form label-align="right" label-width="90px"> <t-form label-align="right" label-width="90px">
<t-form-item label="名称"> <t-form-item label="名称">
<t-input v-model="form.name" placeholder="如:每日贵阳公务员" /> <t-input v-model="form.name" placeholder="如:每日贵阳公务员" />
@ -300,6 +263,6 @@ onMounted(load);
<t-switch v-model="form.enabled" :custom-value="[1, 0]" /> <t-switch v-model="form.enabled" :custom-value="[1, 0]" />
</t-form-item> </t-form-item>
</t-form> </t-form>
</Drawer> </t-dialog>
</Page> </Page>
</template> </template>

View File

@ -1,15 +1,13 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { AdminItem, RoleItem } from '#/api/system'; import type { AdminItem, RoleItem } from '#/api/system';
import { h, onMounted, reactive, ref } from 'vue'; import { onMounted, reactive, ref } from 'vue';
import { useAccess } from '@vben/access'; import { useAccess } from '@vben/access';
import { Page, useVbenDrawer, VbenTableAction } from '@vben/common-ui'; import { Page } from '@vben/common-ui';
import { DialogPlugin, MessagePlugin, Tag } from 'tdesign-vue-next'; import { DialogPlugin, MessagePlugin } from 'tdesign-vue-next';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { import {
createAdmin, createAdmin,
deleteAdmin, deleteAdmin,
@ -19,30 +17,35 @@ import {
updateAdmin, updateAdmin,
} from '#/api/system'; } from '#/api/system';
// Vben Vxe Table + Vben TableAction
const { hasAccessByCodes } = useAccess(); const { hasAccessByCodes } = useAccess();
const loading = ref(false);
const list = ref<AdminItem[]>([]);
const roleOptions = ref<{ label: string; value: number }[]>([]); const roleOptions = ref<{ label: string; value: number }[]>([]);
const dialogOpen = ref(false);
const editing = ref<AdminItem | null>(null); const editing = ref<AdminItem | null>(null);
// /vben const query = reactive({ page: 1, size: 10, keyword: '' });
const [Drawer, drawerApi] = useVbenDrawer({ const pagination = reactive({ current: 1, pageSize: 10, total: 0 });
title: '新增用户',
onConfirm: submit,
});
// grid
const query = reactive({ keyword: '' });
const form = reactive({ const form = reactive({
username: '', username: '',
password: '', password: '',
nickname: '', nickname: '',
status: 1, status: 1,
barkDeviceId: '',
pushplusToken: '',
roleIds: [] as number[], 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() { async function loadRoles() {
const data = await getRoleList({ page: 1, size: 100 }); const data = await getRoleList({ page: 1, size: 100 });
roleOptions.value = (data.list ?? []).map((r: RoleItem) => ({ roleOptions.value = (data.list ?? []).map((r: RoleItem) => ({
@ -58,12 +61,9 @@ function openCreate() {
password: '', password: '',
nickname: '', nickname: '',
status: 1, status: 1,
barkDeviceId: '',
pushplusToken: '',
roleIds: [], roleIds: [],
}); });
drawerApi.setState({ title: '新增用户' }); dialogOpen.value = true;
drawerApi.open();
} }
function openEdit(row: AdminItem) { function openEdit(row: AdminItem) {
@ -73,51 +73,43 @@ function openEdit(row: AdminItem) {
password: '', password: '',
nickname: row.nickname, nickname: row.nickname,
status: row.status, status: row.status,
barkDeviceId: row.barkDeviceId ?? '',
pushplusToken: row.pushplusToken ?? '',
roleIds: row.roleIds ?? [], roleIds: row.roleIds ?? [],
}); });
drawerApi.setState({ title: '编辑用户' }); dialogOpen.value = true;
drawerApi.open();
} }
async function submit() { async function submit() {
// if (editing.value) {
editing.value await updateAdmin(editing.value.id, {
? await updateAdmin(editing.value.id, {
nickname: form.nickname, nickname: form.nickname,
status: form.status, status: form.status,
barkDeviceId: form.barkDeviceId,
pushplusToken: form.pushplusToken,
roleIds: form.roleIds, roleIds: form.roleIds,
}) });
: await createAdmin({ } else {
await createAdmin({
username: form.username, username: form.username,
password: form.password, password: form.password,
nickname: form.nickname, nickname: form.nickname,
barkDeviceId: form.barkDeviceId,
pushplusToken: form.pushplusToken,
roleIds: form.roleIds, roleIds: form.roleIds,
}); });
}
MessagePlugin.success('保存成功'); MessagePlugin.success('保存成功');
drawerApi.close(); dialogOpen.value = false;
gridApi.query(); load();
} }
/** 删除用户(保留原确认弹窗行为) */
function onDelete(row: AdminItem) { function onDelete(row: AdminItem) {
const d = DialogPlugin.confirm({ const d = DialogPlugin.confirm({
header: '确认删除该用户', header: '确认删除该人员',
onConfirm: async () => { onConfirm: async () => {
await deleteAdmin(row.id); await deleteAdmin(row.id);
MessagePlugin.success('删除成功'); MessagePlugin.success('删除成功');
gridApi.query(); load();
d.destroy(); d.destroy();
}, },
}); });
} }
/** 重置密码(需要输入新密码,保留原弹窗交互) */
function onResetPwd(row: AdminItem) { function onResetPwd(row: AdminItem) {
let password = ''; let password = '';
const d = DialogPlugin.confirm({ const d = DialogPlugin.confirm({
@ -135,148 +127,115 @@ function onResetPwd(row: AdminItem) {
}); });
} }
const gridOptions: VxeTableGridOptions = { const columns = [
columns: [ { colKey: 'id', title: 'ID', width: 70 },
{ type: 'seq', width: 50, title: '#' }, { colKey: 'username', title: '用户名' },
{ field: 'id', title: 'ID', width: 70 }, { colKey: 'nickname', title: '昵称' },
{ field: 'username', title: '用户名' }, { colKey: 'statusText', title: '状态', width: 90 },
{ field: 'nickname', title: '昵称' }, { colKey: 'roleNamesText', title: '角色' },
{ { colKey: 'createdAt', title: '创建时间', width: 170 },
field: 'barkDeviceId', { colKey: 'op', title: '操作', width: 200 },
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 };
},
},
},
};
const [Grid, gridApi] = useVbenVxeGrid({ gridOptions }); function onPageChange(p: { current: number; pageSize: number }) {
query.page = p.current;
/** 触发查询(保留分页条件,因 grid 自动管理分页) */ query.size = p.pageSize;
function search() { pagination.current = p.current;
gridApi.query(); pagination.pageSize = p.pageSize;
load();
} }
onMounted(loadRoles); onMounted(() => {
load();
loadRoles();
});
</script> </script>
<template> <template>
<Page auto-content-height> <Page auto-content-height>
<div class="wb-page-head"> <div class="wb-page-head">
<h2 class="wb-page-title">用户管理</h2> <h2 class="wb-page-title">人员管理</h2>
<p class="wb-page-sub">管理系统账号昵称状态角色与通知渠道Bark / pushplus</p> <p class="wb-page-sub">管理系统账号昵称状态与角色归属</p>
</div> </div>
<t-card :bordered="false" class="wb-filter-card"> <t-card :bordered="false" class="wb-filter-card">
<div class="flex items-center justify-between"> <div class="mb-3 flex items-center justify-between">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<t-input <t-input
v-model="query.keyword" v-model="query.keyword"
clearable clearable
placeholder="用户名/昵称" placeholder="用户名/昵称"
style="width: 220px" style="width: 220px"
@enter="search" @enter="load"
/> />
<t-button theme="primary" @click="search">查询</t-button> <t-button theme="primary" @click="load">查询</t-button>
</div> </div>
<t-button <t-button
v-if="hasAccessByCodes(['system:personnel:create'])" v-if="hasAccessByCodes(['system:personnel:create'])"
theme="primary" theme="primary"
@click="openCreate" @click="openCreate"
> >
新增用户 新增人员
</t-button> </t-button>
</div> </div>
</t-card> </t-card>
<!-- 用户列表Vben Vxe Table + TableAction -->
<t-card :bordered="false" class="wb-table-card"> <t-card :bordered="false" class="wb-table-card">
<Grid /> <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>
</t-card> </t-card>
<!-- 新增/编辑用户抽屉vben 封装抽屉组件 --> <t-dialog
<Drawer> v-model:visible="dialogOpen"
:header="editing ? '编辑人员' : '新增人员'"
:on-confirm="submit"
>
<t-form label-align="right" label-width="80px"> <t-form label-align="right" label-width="80px">
<t-form-item label="用户名" required> <t-form-item label="用户名" required>
<t-input <t-input
@ -295,18 +254,6 @@ onMounted(loadRoles);
<t-form-item label="昵称"> <t-form-item label="昵称">
<t-input v-model="form.nickname" placeholder="显示名称" /> <t-input v-model="form.nickname" placeholder="显示名称" />
</t-form-item> </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-form-item label="状态">
<t-switch v-model="form.status" :custom-value="[1, 0]" /> <t-switch v-model="form.status" :custom-value="[1, 0]" />
</t-form-item> </t-form-item>
@ -319,6 +266,6 @@ onMounted(loadRoles);
/> />
</t-form-item> </t-form-item>
</t-form> </t-form>
</Drawer> </t-dialog>
</Page> </Page>
</template> </template>

View File

@ -1,182 +0,0 @@
<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>

View File

@ -1,114 +0,0 @@
<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>

View File

@ -1,5 +1,4 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { LogItem } from '#/api/system'; import type { LogItem } from '#/api/system';
import { h, reactive, ref } from 'vue'; import { h, reactive, ref } from 'vue';
@ -9,7 +8,7 @@ import { Page } from '@vben/common-ui';
import { MessagePlugin, Tag } from 'tdesign-vue-next'; import { MessagePlugin, Tag } from 'tdesign-vue-next';
import { useVbenVxeGrid } from '#/adapter/vxe-table'; import { useVbenVxeGrid, type VxeTableGridOptions } from '#/adapter/vxe-table';
import { getSystemLogList } from '#/api/system'; import { getSystemLogList } from '#/api/system';
// Vben Vxe Table + TDesign // Vben Vxe Table + TDesign
@ -48,14 +47,6 @@ const sortMap: Record<string, { orderBy: string; orderDir: string }> = {
// HTTP // HTTP
const METHOD_OPTIONS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH']; 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 = { const gridOptions: VxeTableGridOptions = {
columns: [ columns: [
{ type: 'seq', width: 50, title: '#' }, { type: 'seq', width: 50, title: '#' },
@ -71,7 +62,14 @@ const gridOptions: VxeTableGridOptions = {
h( h(
Tag, Tag,
{ {
theme: methodTheme(row.method), theme:
row.method === 'POST'
? 'primary'
: row.method === 'GET'
? 'success'
: row.method === 'PUT'
? 'warning'
: 'danger',
variant: 'light', variant: 'light',
size: 'small', size: 'small',
}, },
@ -226,7 +224,7 @@ function copyParam(row: LogItem) {
</div> </div>
<t-card :bordered="false" class="wb-filter-card"> <t-card :bordered="false" class="wb-filter-card">
<div class="flex flex-wrap items-center gap-2"> <div class="mb-3 flex flex-wrap items-center gap-2">
<t-date-range-picker <t-date-range-picker
v-model="timeRange" v-model="timeRange"
value-type="YYYY-MM-DD" value-type="YYYY-MM-DD"

View File

@ -1,102 +1,69 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { LoginLogItem } from '#/api/system'; import type { LoginLogItem } from '#/api/system';
import { h, reactive } from 'vue'; import { onMounted, reactive, ref } from 'vue';
import { useAccess } from '@vben/access'; import { useAccess } from '@vben/access';
import { Page } from '@vben/common-ui'; import { Page } from '@vben/common-ui';
import { Tag } from 'tdesign-vue-next';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getLoginLogList } from '#/api/system'; import { getLoginLogList } from '#/api/system';
// Vben Vxe Table + / TDesign // +
const { hasAccessByCodes } = useAccess(); const { hasAccessByCodes } = useAccess();
// username status -1 const loading = ref(false);
const query = reactive({ username: '', status: -1 }); const list = ref<LoginLogItem[]>([]);
// status/failReason slots // username status -1
const gridOptions: VxeTableGridOptions = { const query = reactive({ page: 1, size: 10, username: '', status: -1 });
columns: [ const pagination = reactive({ current: 1, pageSize: 10, total: 0 });
{ type: 'seq', width: 50, title: '#' },
{ field: 'id', title: 'ID', width: 70 }, async function load() {
{ field: 'username', title: '登录账号', width: 140 }, loading.value = true;
{ field: 'ip', title: '来源 IP', width: 140 }, try {
{ const data = await getLoginLogList({
field: 'userAgent', page: query.page,
title: '浏览器 UA', size: query.size,
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, username: query.username || undefined,
status: query.status >= 0 ? query.status : undefined, status: query.status >= 0 ? query.status : undefined,
}); });
return { items: res.list ?? [], total: res.total ?? 0 }; list.value = data.list ?? [];
}, pagination.total = data.total ?? 0;
}, } finally {
}, loading.value = false;
}; }
const [Grid, gridApi] = useVbenVxeGrid({ gridOptions });
/** 触发查询(保留分页条件,因 grid 自动管理分页) */
function search() {
gridApi.query();
} }
/** 重置筛选并回到第一页重新查询 */ /** 重置筛选并回到第一页 */
function reset() { function reset() {
query.username = ''; query.username = '';
query.status = -1; query.status = -1;
gridApi.reload(); 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();
}
//
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> </script>
<template> <template>
@ -108,14 +75,14 @@ function reset() {
<t-card :bordered="false" class="wb-filter-card"> <t-card :bordered="false" class="wb-filter-card">
<!-- 顶部筛选区账号 + 结果 + 查询 / 重置 --> <!-- 顶部筛选区账号 + 结果 + 查询 / 重置 -->
<div class="flex items-center justify-between"> <div class="mb-3 flex items-center justify-between">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<t-input <t-input
v-model="query.username" v-model="query.username"
clearable clearable
placeholder="登录账号" placeholder="登录账号"
style="width: 180px" style="width: 180px"
@enter="search" @enter="load"
/> />
<t-select <t-select
v-model="query.status" v-model="query.status"
@ -129,7 +96,7 @@ function reset() {
<t-button <t-button
v-if="hasAccessByCodes(['system:login-log:list'])" v-if="hasAccessByCodes(['system:login-log:list'])"
theme="primary" theme="primary"
@click="search" @click="load"
> >
查询 查询
</t-button> </t-button>
@ -138,9 +105,31 @@ function reset() {
</div> </div>
</t-card> </t-card>
<!-- 登录日志列表Vben Vxe Table --> <!-- 登录日志列表 -->
<t-card :bordered="false" class="wb-table-card"> <t-card :bordered="false" class="wb-table-card">
<Grid /> <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>
</t-card> </t-card>
</Page> </Page>
</template> </template>

View File

@ -4,7 +4,7 @@ import type { MenuNode } from '#/api/system';
import { nextTick, onMounted, reactive, ref } from 'vue'; import { nextTick, onMounted, reactive, ref } from 'vue';
import { useAccess } from '@vben/access'; import { useAccess } from '@vben/access';
import { Page, useVbenDrawer } from '@vben/common-ui'; import { Page } from '@vben/common-ui';
import { DialogPlugin, MessagePlugin } from 'tdesign-vue-next'; import { DialogPlugin, MessagePlugin } from 'tdesign-vue-next';
@ -17,14 +17,9 @@ const loading = ref(false);
const tree = ref<MenuNode[]>([]); const tree = ref<MenuNode[]>([]);
// t-enhanced-table // t-enhanced-table
const tableRef = ref<any>(null); const tableRef = ref<any>(null);
const dialogOpen = ref(false);
const editing = ref<MenuNode | null>(null); const editing = ref<MenuNode | null>(null);
// /vben
const [Drawer, drawerApi] = useVbenDrawer({
title: '新增菜单',
onConfirm: submit,
});
const form = reactive({ const form = reactive({
parentId: 0, parentId: 0,
name: '', name: '',
@ -65,8 +60,7 @@ function openCreate(parent?: MenuNode) {
status: 1, status: 1,
hidden: false, hidden: false,
}); });
drawerApi.setState({ title: '新增菜单' }); dialogOpen.value = true;
drawerApi.open();
} }
function openEdit(row: MenuNode) { function openEdit(row: MenuNode) {
@ -83,17 +77,17 @@ function openEdit(row: MenuNode) {
status: row.status, status: row.status,
hidden: row.hidden, hidden: row.hidden,
}); });
drawerApi.setState({ title: '编辑菜单' }); dialogOpen.value = true;
drawerApi.open();
} }
async function submit() { async function submit() {
// if (editing.value) {
editing.value await updateMenu(editing.value.id, { ...form });
? await updateMenu(editing.value.id, { ...form }) } else {
: await createMenu({ ...form }); await createMenu({ ...form });
}
MessagePlugin.success('保存成功'); MessagePlugin.success('保存成功');
drawerApi.close(); dialogOpen.value = false;
load(); load();
} }
@ -130,7 +124,7 @@ onMounted(load);
</div> </div>
<t-card :bordered="false" class="wb-filter-card"> <t-card :bordered="false" class="wb-filter-card">
<div class="flex justify-end"> <div class="mb-3 flex justify-end">
<t-button <t-button
v-if="hasAccessByCodes(['system:menu:create'])" v-if="hasAccessByCodes(['system:menu:create'])"
theme="primary" theme="primary"
@ -160,7 +154,9 @@ onMounted(load);
{{ row.name }} {{ row.name }}
</template> </template>
<template #typeText="{ row }"> <template #typeText="{ row }">
{{ row.type === 1 ? '菜单' : '按钮' }} {{
row.type === 1 ? '菜单' : '按钮'
}}
</template> </template>
<template #op="{ row }"> <template #op="{ row }">
<t-button <t-button
@ -194,8 +190,11 @@ onMounted(load);
</t-enhanced-table> </t-enhanced-table>
</t-card> </t-card>
<!-- 新增/编辑菜单抽屉vben 封装抽屉组件 --> <t-dialog
<Drawer> v-model:visible="dialogOpen"
:header="editing ? '编辑菜单' : '新增菜单'"
:on-confirm="submit"
>
<t-form label-align="right" label-width="80px"> <t-form label-align="right" label-width="80px">
<t-form-item label="上级 ID"> <t-form-item label="上级 ID">
<t-input-number v-model="form.parentId" :min="0" /> <t-input-number v-model="form.parentId" :min="0" />
@ -240,6 +239,6 @@ onMounted(load);
<t-switch v-model="form.hidden" /> <t-switch v-model="form.hidden" />
</t-form-item> </t-form-item>
</t-form> </t-form>
</Drawer> </t-dialog>
</Page> </Page>
</template> </template>

View File

@ -1,152 +0,0 @@
<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>

View File

@ -1,150 +0,0 @@
<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>

View File

@ -1,298 +0,0 @@
<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>

View File

@ -1,15 +1,13 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MenuNode, RoleItem } from '#/api/system'; import type { MenuNode, RoleItem } from '#/api/system';
import { h, onMounted, reactive, ref } from 'vue'; import { onMounted, reactive, ref } from 'vue';
import { useAccess } from '@vben/access'; import { useAccess } from '@vben/access';
import { Page, useVbenDrawer, VbenTableAction } from '@vben/common-ui'; import { Page } from '@vben/common-ui';
import { DialogPlugin, MessagePlugin, Tag } from 'tdesign-vue-next'; import { DialogPlugin, MessagePlugin } from 'tdesign-vue-next';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { import {
createRole, createRole,
deleteRole, deleteRole,
@ -18,20 +16,16 @@ import {
updateRole, updateRole,
} from '#/api/system'; } from '#/api/system';
// Vben Vxe Table + Vben TableAction
const { hasAccessByCodes } = useAccess(); const { hasAccessByCodes } = useAccess();
const loading = ref(false);
const list = ref<RoleItem[]>([]);
const menuTree = ref<any[]>([]); const menuTree = ref<any[]>([]);
const dialogOpen = ref(false);
const editing = ref<null | RoleItem>(null); const editing = ref<null | RoleItem>(null);
// /vben const query = reactive({ page: 1, size: 10, keyword: '' });
const [Drawer, drawerApi] = useVbenDrawer({ const pagination = reactive({ current: 1, pageSize: 10, total: 0 });
title: '新增角色',
onConfirm: submit,
});
// grid
const query = reactive({ keyword: '' });
const form = reactive({ const form = reactive({
code: '', code: '',
name: '', name: '',
@ -39,7 +33,6 @@ const form = reactive({
menuIds: [] as number[], menuIds: [] as number[],
}); });
/** 后端菜单树转 TDesign 树选择器数据 */
function toTree(nodes: MenuNode[]): any[] { function toTree(nodes: MenuNode[]): any[] {
return (nodes ?? []).map((n) => ({ return (nodes ?? []).map((n) => ({
label: n.name, label: n.name,
@ -48,6 +41,17 @@ 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() { async function loadMenus() {
const data = await getMenuTree(); const data = await getMenuTree();
menuTree.value = toTree(data.tree ?? []); menuTree.value = toTree(data.tree ?? []);
@ -56,8 +60,7 @@ async function loadMenus() {
function openCreate() { function openCreate() {
editing.value = null; editing.value = null;
Object.assign(form, { code: '', name: '', status: 1, menuIds: [] }); Object.assign(form, { code: '', name: '', status: 1, menuIds: [] });
drawerApi.setState({ title: '新增角色' }); dialogOpen.value = true;
drawerApi.open();
} }
function openEdit(row: RoleItem) { function openEdit(row: RoleItem) {
@ -68,120 +71,62 @@ function openEdit(row: RoleItem) {
status: row.status, status: row.status,
menuIds: row.menuIds ?? [], menuIds: row.menuIds ?? [],
}); });
drawerApi.setState({ title: '编辑角色' }); dialogOpen.value = true;
drawerApi.open();
} }
async function submit() { async function submit() {
// if (editing.value) {
editing.value await updateRole(editing.value.id, {
? await updateRole(editing.value.id, {
name: form.name, name: form.name,
status: form.status, status: form.status,
menuIds: form.menuIds, menuIds: form.menuIds,
}) });
: await createRole({ } else {
await createRole({
code: form.code, code: form.code,
name: form.name, name: form.name,
status: form.status, status: form.status,
menuIds: form.menuIds, menuIds: form.menuIds,
}); });
}
MessagePlugin.success('保存成功'); MessagePlugin.success('保存成功');
drawerApi.close(); dialogOpen.value = false;
gridApi.query(); load();
} }
/** 删除角色(保留原确认弹窗行为) */
function onDelete(row: RoleItem) { function onDelete(row: RoleItem) {
const d = DialogPlugin.confirm({ const d = DialogPlugin.confirm({
header: `确认删除角色「${row.name}」?`, header: `确认删除角色「${row.name}」?`,
onConfirm: async () => { onConfirm: async () => {
await deleteRole(row.id); await deleteRole(row.id);
MessagePlugin.success('删除成功'); MessagePlugin.success('删除成功');
gridApi.query(); load();
d.destroy(); d.destroy();
}, },
}); });
} }
const gridOptions: VxeTableGridOptions = { const columns = [
columns: [ { colKey: 'id', title: 'ID', width: 70 },
{ type: 'seq', width: 50, title: '#' }, { colKey: 'code', title: '角色编码' },
{ field: 'id', title: 'ID', width: 70 }, { colKey: 'name', title: '角色名称' },
{ field: 'code', title: '角色编码' }, { colKey: 'statusText', title: '状态', width: 90 },
{ field: 'name', title: '角色名称' }, { colKey: 'createdAt', title: '创建时间', width: 170 },
{ { colKey: 'op', title: '操作', width: 150 },
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 };
},
},
},
};
const [Grid, gridApi] = useVbenVxeGrid({ gridOptions }); function onPageChange(p: { current: number; pageSize: number }) {
query.page = p.current;
/** 触发查询(保留分页条件,因 grid 自动管理分页) */ query.size = p.pageSize;
function search() { pagination.current = p.current;
gridApi.query(); pagination.pageSize = p.pageSize;
load();
} }
onMounted(loadMenus); onMounted(() => {
load();
loadMenus();
});
</script> </script>
<template> <template>
@ -192,16 +137,16 @@ onMounted(loadMenus);
</div> </div>
<t-card :bordered="false" class="wb-filter-card"> <t-card :bordered="false" class="wb-filter-card">
<div class="flex items-center justify-between"> <div class="mb-3 flex items-center justify-between">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<t-input <t-input
v-model="query.keyword" v-model="query.keyword"
clearable clearable
placeholder="角色编码/名称" placeholder="角色编码/名称"
style="width: 220px" style="width: 220px"
@enter="search" @enter="load"
/> />
<t-button theme="primary" @click="search">查询</t-button> <t-button theme="primary" @click="load">查询</t-button>
</div> </div>
<t-button <t-button
v-if="hasAccessByCodes(['system:role:create'])" v-if="hasAccessByCodes(['system:role:create'])"
@ -213,13 +158,49 @@ onMounted(loadMenus);
</div> </div>
</t-card> </t-card>
<!-- 角色列表Vben Vxe Table + TableAction -->
<t-card :bordered="false" class="wb-table-card"> <t-card :bordered="false" class="wb-table-card">
<Grid /> <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>
</t-card> </t-card>
<!-- 新增/编辑角色抽屉vben 封装抽屉组件 --> <t-dialog
<Drawer> v-model:visible="dialogOpen"
:header="editing ? '编辑角色' : '新增角色'"
width="560px"
:on-confirm="submit"
>
<t-form label-align="right" label-width="80px"> <t-form label-align="right" label-width="80px">
<t-form-item label="角色编码" required> <t-form-item label="角色编码" required>
<t-input <t-input
@ -245,6 +226,6 @@ onMounted(loadMenus);
/> />
</t-form-item> </t-form-item>
</t-form> </t-form>
</Drawer> </t-dialog>
</Page> </Page>
</template> </template>

42
dev_log.txt Normal file
View File

@ -0,0 +1,42 @@
$ 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...
VITE v8.2.2 ready in 2781 ms
➜ Local: http://localhost:6000/
➜ Network: http://192.168.110.65:6000/ WLAN
➜ Network: http://172.20.192.1:6000/ vEthernet (Default …
➜ Vben Admin Docs: https://doc.vben.pro
 ➜ press h + enter to show help
17:20:13 [vite] .env.development changed, restarting server...
[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
17:20:14 [vite] server restarted.
➜ Local: http://localhost:20000/
➜ Network: http://192.168.110.65:20000/ WLAN
➜ Network: http://172.20.192.1:20000/ vEthernet (Default …
➜ Vben Admin Docs: https://doc.vben.pro
17:20:53 [vite] .env.development changed, restarting server...
[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...
17:20:54 [vite] server restarted.
➜ Local: http://localhost:20101/
➜ Network: http://192.168.110.65:20101/ WLAN
➜ Network: http://172.20.192.1:20101/ vEthernet (Default …
➜ Vben Admin Docs: https://doc.vben.pro
18:53:59 [vite] vite.config.ts changed, restarting server...
[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
18:54:00 [vite] server restarted.
18:54:09 [vite] (client) page reload src/api/core/auth.ts
18:54:13 [vite] (client) page reload src/api/core/menu.ts
18:54:16 [vite] (client) page reload src/api/core/user.ts
18:54:22 [vite] (client) page reload src/api/system.ts
18:54:25 [vite] (client) page reload src/api/log.ts
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.

View File

@ -119,7 +119,7 @@ const defaultPreferences: Preferences = {
theme: { theme: {
builtinType: 'default', builtinType: 'default',
colorDestructive: 'hsl(348 100% 61%)', colorDestructive: 'hsl(348 100% 61%)',
colorPrimary: 'hsl(235 100% 65%)', colorPrimary: 'hsl(212 100% 45%)',
colorSuccess: 'hsl(144 57% 58%)', colorSuccess: 'hsl(144 57% 58%)',
colorWarning: 'hsl(42 84% 61%)', colorWarning: 'hsl(42 84% 61%)',
mode: 'dark', mode: 'dark',
@ -140,9 +140,8 @@ const defaultPreferences: Preferences = {
fullscreenButtonPosition: 'header', fullscreenButtonPosition: 'header',
globalSearch: true, globalSearch: true,
globalSearchButtonPosition: 'header', globalSearchButtonPosition: 'header',
// 移除国际化:不再显示语言切换按钮 languageToggle: true,
languageToggle: false, languageToggleButtonPosition: 'header',
languageToggleButtonPosition: 'none',
lockScreen: true, lockScreen: true,
lockScreenButtonPosition: 'header', lockScreenButtonPosition: 'header',
logoutButtonPosition: 'header', logoutButtonPosition: 'header',
@ -159,6 +158,7 @@ const defaultPreferences: Preferences = {
'globalSearch', 'globalSearch',
'preferences', 'preferences',
'themeToggle', 'themeToggle',
'languageToggle',
'timezone', 'timezone',
'fullscreen', 'fullscreen',
'refresh', 'refresh',

View File

@ -9,7 +9,7 @@ interface BuiltinThemePreset {
const BUILT_IN_THEME_PRESETS: BuiltinThemePreset[] = [ const BUILT_IN_THEME_PRESETS: BuiltinThemePreset[] = [
{ {
color: 'hsl(235 100% 65%)', color: 'hsl(212 100% 45%)',
type: 'default', type: 'default',
}, },
{ {

View File

@ -8,6 +8,7 @@ import { preferences } from '@vben/preferences';
import { import {
AuthenticationColorToggle, AuthenticationColorToggle,
AuthenticationLayoutToggle, AuthenticationLayoutToggle,
LanguageToggle,
ThemeToggle, ThemeToggle,
} from '../widgets'; } from '../widgets';
@ -20,12 +21,12 @@ defineOptions({
}); });
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
// toolbarList: () => ['color', 'language', 'layout', 'theme'],
toolbarList: () => ['color', 'layout', 'theme'],
}); });
const showColor = computed(() => props.toolbarList.includes('color')); const showColor = computed(() => props.toolbarList.includes('color'));
const showLayout = computed(() => props.toolbarList.includes('layout')); const showLayout = computed(() => props.toolbarList.includes('layout'));
const showLanguage = computed(() => props.toolbarList.includes('language'));
const showTheme = computed(() => props.toolbarList.includes('theme')); const showTheme = computed(() => props.toolbarList.includes('theme'));
</script> </script>
@ -41,7 +42,8 @@ const showTheme = computed(() => props.toolbarList.includes('theme'));
<AuthenticationColorToggle v-if="showColor" /> <AuthenticationColorToggle v-if="showColor" />
<AuthenticationLayoutToggle v-if="showLayout" /> <AuthenticationLayoutToggle v-if="showLayout" />
</div> </div>
<!-- 仅展示主题切换 --> <!-- Always show Language and Theme toggles -->
<LanguageToggle v-if="showLanguage && preferences.widget.languageToggle" />
<ThemeToggle v-if="showTheme && preferences.widget.themeToggle" /> <ThemeToggle v-if="showTheme && preferences.widget.themeToggle" />
</div> </div>
</template> </template>

View File

@ -18,6 +18,7 @@ import { useMagicKeys, whenever } from '@vueuse/core';
import { import {
GlobalSearch, GlobalSearch,
LanguageToggle,
LockScreenModal, LockScreenModal,
Notification, Notification,
PreferencesButton, PreferencesButton,
@ -162,6 +163,12 @@ const rightSlots = computed(() => {
preferences.widget.themeToggleButtonPosition === 'header', preferences.widget.themeToggleButtonPosition === 'header',
slotName: 'theme-toggle', slotName: 'theme-toggle',
}, },
languageToggle: {
visible:
preferences.widget.languageToggle &&
preferences.widget.languageToggleButtonPosition === 'header',
slotName: 'language-toggle',
},
timezone: { timezone: {
visible: visible:
preferences.widget.timezone && preferences.widget.timezone &&
@ -330,6 +337,14 @@ function clearPreferencesAndLogout() {
{{ $t('preferences.theme.title') }} {{ $t('preferences.theme.title') }}
</VbenTooltip> </VbenTooltip>
</template> </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'"> <template v-else-if="slot.name === 'fullscreen'">
<VbenFullScreen <VbenFullScreen
class="mr-1" class="mr-1"

View File

@ -279,7 +279,7 @@ useEventListener('mousemove', () => {
" "
:data-index="index" :data-index="index"
:data-search-item="index" :data-search-item="index"
class="group flex-center w-full cursor-pointer rounded-lg bg-accent p-4" class="group mb-3 flex-center w-full cursor-pointer rounded-lg bg-accent p-4"
@click="handleEnter" @click="handleEnter"
@mouseenter="handleMouseenter" @mouseenter="handleMouseenter"
> >

View File

@ -2,6 +2,7 @@ export { default as Breadcrumb } from './breadcrumb.vue';
export * from './check-updates'; export * from './check-updates';
export { default as AuthenticationColorToggle } from './color-toggle.vue'; export { default as AuthenticationColorToggle } from './color-toggle.vue';
export * from './global-search'; export * from './global-search';
export { default as LanguageToggle } from './language-toggle.vue';
export { default as AuthenticationLayoutToggle } from './layout-toggle.vue'; export { default as AuthenticationLayoutToggle } from './layout-toggle.vue';
export * from './lock-screen'; export * from './lock-screen';
export * from './notification'; export * from './notification';

View File

@ -1,6 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref, unref } from 'vue'; import type { LanguageOption } from '@vben/constants';
import { onMounted, onUnmounted, ref, unref } from 'vue';
import { onSupportLanguagesChange } from '@vben/constants';
import { $t } from '@vben/locales'; import { $t } from '@vben/locales';
import { useTimezoneStore } from '@vben/stores'; import { useTimezoneStore } from '@vben/stores';
@ -12,6 +15,7 @@ defineOptions({
name: 'PreferenceGeneralConfig', name: 'PreferenceGeneralConfig',
}); });
const appLocale = defineModel<string>('appLocale');
const appTimezone = defineModel<string>('appTimezone'); const appTimezone = defineModel<string>('appTimezone');
const appDynamicTitle = defineModel<boolean>('appDynamicTitle'); const appDynamicTitle = defineModel<boolean>('appDynamicTitle');
const appWatermark = defineModel<boolean>('appWatermark'); const appWatermark = defineModel<boolean>('appWatermark');
@ -22,6 +26,12 @@ const appEnableCopyPreferences = defineModel<boolean>(
); );
const timezoneStore = useTimezoneStore(); const timezoneStore = useTimezoneStore();
const languageList = ref<LanguageOption[]>([]);
const unsubscribe = onSupportLanguagesChange((langs) => {
languageList.value = [...langs];
});
onUnmounted(unsubscribe);
const timezoneOptionsRef = ref< const timezoneOptionsRef = ref<
{ {
label: string; label: string;
@ -40,6 +50,9 @@ onMounted(async () => {
</script> </script>
<template> <template>
<SelectItem v-model="appLocale" :items="languageList">
{{ $t('preferences.language') }}
</SelectItem>
<SelectItem v-model="appTimezone" :items="timezoneOptionsRef"> <SelectItem v-model="appTimezone" :items="timezoneOptionsRef">
{{ $t('preferences.timezone') }} {{ $t('preferences.timezone') }}
</SelectItem> </SelectItem>

View File

@ -19,6 +19,9 @@ const widgetGlobalSearchButtonPosition = defineModel<string>(
const widgetFullscreenButtonPosition = defineModel<string>( const widgetFullscreenButtonPosition = defineModel<string>(
'widgetFullscreenButtonPosition', 'widgetFullscreenButtonPosition',
); );
const widgetLanguageToggleButtonPosition = defineModel<string>(
'widgetLanguageToggleButtonPosition',
);
const widgetNotificationButtonPosition = defineModel<string>( const widgetNotificationButtonPosition = defineModel<string>(
'widgetNotificationButtonPosition', 'widgetNotificationButtonPosition',
); );
@ -88,6 +91,7 @@ const positionMap: Record<string, string> = {
globalSearch: 'widgetGlobalSearchButtonPosition', globalSearch: 'widgetGlobalSearchButtonPosition',
preferences: 'appPreferencesButtonPosition', preferences: 'appPreferencesButtonPosition',
themeToggle: 'widgetThemeToggleButtonPosition', themeToggle: 'widgetThemeToggleButtonPosition',
languageToggle: 'widgetLanguageToggleButtonPosition',
timezone: 'widgetTimezoneButtonPosition', timezone: 'widgetTimezoneButtonPosition',
fullscreen: 'widgetFullscreenButtonPosition', fullscreen: 'widgetFullscreenButtonPosition',
refresh: 'widgetRefreshButtonPosition', refresh: 'widgetRefreshButtonPosition',
@ -100,6 +104,7 @@ const labelMap: Record<string, string> = {
globalSearch: 'preferences.widget.globalSearch', globalSearch: 'preferences.widget.globalSearch',
preferences: 'preferences.title', preferences: 'preferences.title',
themeToggle: 'preferences.widget.themeToggle', themeToggle: 'preferences.widget.themeToggle',
languageToggle: 'preferences.widget.languageToggle',
timezone: 'preferences.widget.timezone', timezone: 'preferences.widget.timezone',
fullscreen: 'preferences.widget.fullscreen', fullscreen: 'preferences.widget.fullscreen',
refresh: 'preferences.widget.refresh', refresh: 'preferences.widget.refresh',
@ -126,6 +131,7 @@ function getPosition(
const modelMap: Record<string, any> = { const modelMap: Record<string, any> = {
widgetGlobalSearchButtonPosition, widgetGlobalSearchButtonPosition,
widgetFullscreenButtonPosition, widgetFullscreenButtonPosition,
widgetLanguageToggleButtonPosition,
widgetNotificationButtonPosition, widgetNotificationButtonPosition,
widgetThemeToggleButtonPosition, widgetThemeToggleButtonPosition,
widgetLockScreenButtonPosition, widgetLockScreenButtonPosition,
@ -150,6 +156,7 @@ function handleUpdatePosition(
const modelMap: Record<string, any> = { const modelMap: Record<string, any> = {
widgetGlobalSearchButtonPosition, widgetGlobalSearchButtonPosition,
widgetFullscreenButtonPosition, widgetFullscreenButtonPosition,
widgetLanguageToggleButtonPosition,
widgetNotificationButtonPosition, widgetNotificationButtonPosition,
widgetThemeToggleButtonPosition, widgetThemeToggleButtonPosition,
widgetLockScreenButtonPosition, widgetLockScreenButtonPosition,

View File

@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import type { SupportedLanguagesType } from '@vben/locales';
import type { CustomPreferencesRecord } from '@vben/preferences'; import type { CustomPreferencesRecord } from '@vben/preferences';
import type { import type {
BreadcrumbStyleType, BreadcrumbStyleType,
@ -63,6 +64,7 @@ const emit = defineEmits<{ clearPreferencesAndLogout: [] }>();
const message = globalShareState.getMessage(); const message = globalShareState.getMessage();
const appLocale = defineModel<SupportedLanguagesType>('appLocale');
const appTimezone = defineModel<string>('appTimezone'); const appTimezone = defineModel<string>('appTimezone');
const appDynamicTitle = defineModel<boolean>('appDynamicTitle'); const appDynamicTitle = defineModel<boolean>('appDynamicTitle');
const appLayout = defineModel<LayoutType>('appLayout'); const appLayout = defineModel<LayoutType>('appLayout');
@ -178,6 +180,9 @@ const widgetGlobalSearchButtonPosition = defineModel<string>(
const widgetFullscreenButtonPosition = defineModel<string>( const widgetFullscreenButtonPosition = defineModel<string>(
'widgetFullscreenButtonPosition', 'widgetFullscreenButtonPosition',
); );
const widgetLanguageToggleButtonPosition = defineModel<string>(
'widgetLanguageToggleButtonPosition',
);
const widgetNotificationButtonPosition = defineModel<string>( const widgetNotificationButtonPosition = defineModel<string>(
'widgetNotificationButtonPosition', 'widgetNotificationButtonPosition',
); );
@ -376,6 +381,7 @@ function handleCustomPreferencesUpdate(updates: CustomPreferencesRecord) {
v-model:app-dynamic-title="appDynamicTitle" v-model:app-dynamic-title="appDynamicTitle"
v-model:app-enable-check-updates="appEnableCheckUpdates" v-model:app-enable-check-updates="appEnableCheckUpdates"
v-model:app-enable-copy-preferences="appEnableCopyPreferences" v-model:app-enable-copy-preferences="appEnableCopyPreferences"
v-model:app-locale="appLocale"
v-model:app-timezone="appTimezone" v-model:app-timezone="appTimezone"
v-model:app-watermark="appWatermark" v-model:app-watermark="appWatermark"
v-model:app-watermark-content="appWatermarkContent" v-model:app-watermark-content="appWatermarkContent"
@ -502,6 +508,9 @@ function handleCustomPreferencesUpdate(updates: CustomPreferencesRecord) {
v-model:widget-global-search-button-position=" v-model:widget-global-search-button-position="
widgetGlobalSearchButtonPosition widgetGlobalSearchButtonPosition
" "
v-model:widget-language-toggle-button-position="
widgetLanguageToggleButtonPosition
"
v-model:widget-lock-screen-button-position=" v-model:widget-lock-screen-button-position="
widgetLockScreenButtonPosition widgetLockScreenButtonPosition
" "

View File

@ -2,7 +2,7 @@
import { computed } from 'vue'; import { computed } from 'vue';
import { Settings } from '@vben/icons'; import { Settings } from '@vben/icons';
import { $t } from '@vben/locales'; import { $t, loadLocaleMessages } from '@vben/locales';
import { preferences, updatePreferences } from '@vben/preferences'; import { preferences, updatePreferences } from '@vben/preferences';
import { capitalizeFirstLetter } from '@vben/utils'; import { capitalizeFirstLetter } from '@vben/utils';
@ -58,6 +58,9 @@ const listen = computed(() => {
val: any, val: any,
) => { ) => {
updatePreferences({ [key]: { [subKey]: val } }); updatePreferences({ [key]: { [subKey]: val } });
if (key === 'app' && subKey === 'locale') {
loadLocaleMessages(val);
}
}; };
} }
} else { } else {

View File

@ -1,20 +1,23 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Component } from 'vue'; import type { Component } from 'vue';
import type { LanguageOption } from '@vben/constants';
import type { AnyFunction } from '@vben/types'; import type { AnyFunction } from '@vben/types';
import { computed, useTemplateRef, watch } from 'vue'; import { computed, onUnmounted, ref, useTemplateRef, watch } from 'vue';
import { onSupportLanguagesChange } from '@vben/constants';
import { useHoverToggle, useRefresh } from '@vben/hooks'; import { useHoverToggle, useRefresh } from '@vben/hooks';
import { import {
createIconifyIcon, createIconifyIcon,
Languages,
LockKeyhole, LockKeyhole,
LogOut, LogOut,
RotateCw, RotateCw,
Search, Search,
Settings, Settings,
} from '@vben/icons'; } from '@vben/icons';
import { $t } from '@vben/locales'; import { $t, loadLocaleMessages } from '@vben/locales';
import { import {
preferences, preferences,
updatePreferences, updatePreferences,
@ -164,6 +167,12 @@ const showThemeToggleInDropdown = computed(
preferences.widget.themeToggleButtonPosition === 'user-dropdown', preferences.widget.themeToggleButtonPosition === 'user-dropdown',
); );
const showLanguageToggleInDropdown = computed(
() =>
preferences.widget.languageToggle &&
preferences.widget.languageToggleButtonPosition === 'user-dropdown',
);
const showTimezoneInDropdown = computed( const showTimezoneInDropdown = computed(
() => () =>
preferences.widget.timezone && preferences.widget.timezone &&
@ -194,6 +203,7 @@ const hasAnyInDropdown = computed(
showLogoutInDropdown.value || showLogoutInDropdown.value ||
showGlobalSearchInDropdown.value || showGlobalSearchInDropdown.value ||
showThemeToggleInDropdown.value || showThemeToggleInDropdown.value ||
showLanguageToggleInDropdown.value ||
showTimezoneInDropdown.value || showTimezoneInDropdown.value ||
showFullscreenInDropdown.value || showFullscreenInDropdown.value ||
showNotificationInDropdown.value || showNotificationInDropdown.value ||
@ -267,6 +277,26 @@ function handleNotificationSelect(event?: Event) {
refNotification.value?.toggle(); 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) { if (preferences.shortcutKeys.enable) {
const keys = useMagicKeys(); const keys = useMagicKeys();
const logoutKey = keys['Alt+KeyQ']; const logoutKey = keys['Alt+KeyQ'];
@ -418,6 +448,7 @@ if (preferences.shortcutKeys.enable) {
v-if=" v-if="
showGlobalSearchInDropdown || showGlobalSearchInDropdown ||
showThemeToggleInDropdown || showThemeToggleInDropdown ||
showLanguageToggleInDropdown ||
showTimezoneInDropdown || showTimezoneInDropdown ||
showFullscreenInDropdown || showFullscreenInDropdown ||
showNotificationInDropdown || showNotificationInDropdown ||
@ -443,6 +474,32 @@ if (preferences.shortcutKeys.enable) {
<ThemeToggle class="mr-2" /> <ThemeToggle class="mr-2" />
{{ $t('preferences.theme.title') }} {{ $t('preferences.theme.title') }}
</DropdownMenuItem> </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 <DropdownMenuItem
v-if="showTimezoneInDropdown" v-if="showTimezoneInDropdown"
class="mx-1 flex cursor-pointer items-center rounded-sm py-1 leading-8" class="mx-1 flex cursor-pointer items-center rounded-sm py-1 leading-8"

View File

@ -0,0 +1,65 @@
{
"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"
}
}

View File

@ -0,0 +1,26 @@
{
"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"
}

View File

@ -0,0 +1,245 @@
{
"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"
}
}
}
}

View File

@ -0,0 +1,4 @@
{
"updatePassword": "Update Password",
"updateBasicProfile": "Update Basic Profile"
}

View File

@ -0,0 +1,165 @@
{
"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"
}
}
}