feat(house): 增加房屋数据管理功能
- 新增全局主题美化样式文件 theme.css 并在 bootstrap.ts 中引入 - 实现工作台页面重构,包含渐变英雄头、统计卡片和快捷入口宫格 - 开发楼盘地图功能,在 dashboard 中集成腾讯地图 GL JS 散点显示 - 添加成交记录列表页面,支持关键词和区域筛选查询 - 新增新房预售许可证管理页面,包含预售证号、楼盘名等信息展示 - 扩展 house API 接口,增加交易记录和预售许可相关数据模型 - 优化数据明细页面,将占位内容替换为实际的成交记录表格 - 添加招聘数据看板和爬虫管理页面,支持数据源管理和抓取触发功能
This commit is contained in:
parent
b02d678dea
commit
f02a514e44
@ -1,6 +1,10 @@
|
||||
# admin.xpcool.com 变更记录
|
||||
> 倒序:最新在上。格式:YYYY-MM-DD | 类型 | 摘要
|
||||
|
||||
2026-08-27 | CHG | 业务页逐个美化:system/admin、role、menu、log、login-log 五个页面统一加页面标题(wb-page-head)并将筛选区与表格各自包入 t-card(圆角轻投影,复用 theme.css),仅改 template 布局、脚本/逻辑零改动;theme.css 补 .wb-page-head/.wb-page-title/.wb-page-sub/.wb-filter-card 共享样式
|
||||
2026-08-27 | CHG | 操作日志列表页全面增强(前端):api/system.ts 的 LogItem 增加 adminUsername/ipLocation/errorMessage/userAgent、getSystemLogList 扩展为完整 LogQuery(时间范围/账号/IP/归属地/方法/结果/耗时上下限/排序);views/system/log/index.vue 重写——顶部多维筛选(t-date-range-picker + 各 input/select + 耗时范围 + 排序)+ 表格新列(操作账号/IP归属地/错误信息/UA)+ method/statusCode 彩色 tag、耗时≥1000ms 标红;对齐后端 POST /api/service/admin/system/log 增强接口;typecheck 通过
|
||||
2026-08-27 | CHG | 全局主题美化 + 工作台重做:新增 src/styles/theme.css(靛蓝#4f5bff→紫#7c3aed 品牌色 !important 强制生效、更大圆角、轻投影、非纯白纵深背景、主按钮渐变、纤细滚动条、暗色模式适配),bootstrap.ts 末尾 import 接入;工作台 page 重做为渐变英雄头+玻璃统计卡+图标快捷宫格(内联 SVG,无新依赖),保留既有数据聚合与容错
|
||||
2026-08-27 | CHG | 看房可视化扩充:dashboard 加楼盘地图(map-panel.vue 腾讯地图 GL JS 散点 + 点击信息窗,key 占位 YOUR_TENCENT_MAP_KEY 待申请,合规用腾讯地图 + inline SVG 图标);数据明细页由占位改成交记录列表(api/house.ts 加 getTransactionList + TransactionItem);typecheck 通过
|
||||
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 | CFG | 新增 .gitea/workflows/deploy.yml(Gitea act_runner 自动部署):push main/dev → 下载 node22 二进制(npmmirror,覆盖镜像 node20,pnpm11 需 node:sqlite)→ corepack pnpm@11.16.0 → pnpm install → 直接 vite build(避开 turbo 卡死)→ cp dist 到 /data/www/admin.xpcool.com/;YAML 已校验
|
||||
|
||||
@ -23,7 +23,7 @@ export async function getUserInfoApi(): Promise<UserInfo> {
|
||||
return {
|
||||
avatar: '',
|
||||
desc: '',
|
||||
homePath: '/system/role',
|
||||
homePath: '/workbench',
|
||||
realName: data.nickname ?? data.username,
|
||||
roles: data.roles ?? [],
|
||||
token: '',
|
||||
|
||||
@ -25,10 +25,10 @@ export interface CommunityListResp {
|
||||
}
|
||||
|
||||
export function getCommunityList(data: {
|
||||
page: number;
|
||||
size: number;
|
||||
keyword?: string;
|
||||
page: number;
|
||||
region?: string;
|
||||
size: number;
|
||||
}) {
|
||||
return requestClient.post<CommunityListResp>(
|
||||
'/api/service/admin/house/community/list',
|
||||
@ -132,8 +132,8 @@ export function deleteListing(id: number) {
|
||||
}
|
||||
|
||||
export function batchMarkListing(data: {
|
||||
ids: number[];
|
||||
field: string;
|
||||
ids: number[];
|
||||
value: number;
|
||||
}) {
|
||||
return requestClient.post<{ affected: number }>(
|
||||
@ -185,9 +185,9 @@ export function getDashboardOverview(data: { region?: string }) {
|
||||
}
|
||||
|
||||
export function getMapPoints(data: {
|
||||
region?: string;
|
||||
priceMin?: number;
|
||||
priceMax?: number;
|
||||
priceMin?: number;
|
||||
region?: string;
|
||||
status?: number;
|
||||
}) {
|
||||
return requestClient.post<{ points: MapPoint[] }>(
|
||||
@ -198,8 +198,8 @@ export function getMapPoints(data: {
|
||||
|
||||
export function getPriceTrend(data: {
|
||||
communityId?: number;
|
||||
region?: string;
|
||||
limit?: number;
|
||||
region?: string;
|
||||
}) {
|
||||
return requestClient.post<{ trend: TrendPoint[] }>(
|
||||
'/api/service/admin/house/dashboard/price-trend',
|
||||
@ -212,3 +212,58 @@ export function getAggregateRegion() {
|
||||
'/api/service/admin/house/dashboard/aggregate-region',
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- 成交记录(/api/service/admin/house/transaction) ----------
|
||||
export interface TransactionItem {
|
||||
id: number;
|
||||
communityId: number;
|
||||
communityName: string;
|
||||
layout: string;
|
||||
area: number;
|
||||
dealPrice: number;
|
||||
dealUnitPrice: number;
|
||||
listDays: number;
|
||||
dealDate: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export function getTransactionList(data: {
|
||||
keyword?: string;
|
||||
page: number;
|
||||
region?: string;
|
||||
size: number;
|
||||
}) {
|
||||
return requestClient.post<{ list: TransactionItem[]; total: number }>(
|
||||
'/api/service/admin/house/transaction/list',
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- 新房预售证(/api/service/admin/house/presale) ----------
|
||||
export interface PresaleItem {
|
||||
id: number;
|
||||
presaleNo: string;
|
||||
communityName: string;
|
||||
developer: string;
|
||||
region: string;
|
||||
address: string;
|
||||
buildingNo: string;
|
||||
houseCount: number;
|
||||
area: number;
|
||||
purpose: string;
|
||||
issueDate: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export function getPresaleList(data: {
|
||||
keyword?: string;
|
||||
page: number;
|
||||
purpose?: string;
|
||||
region?: string;
|
||||
size: number;
|
||||
}) {
|
||||
return requestClient.post<{ list: PresaleItem[]; total: number }>(
|
||||
'/api/service/admin/house/presale/list',
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
214
apps/web-tdesign/src/api/recruitment.ts
Normal file
214
apps/web-tdesign/src/api/recruitment.ts
Normal file
@ -0,0 +1,214 @@
|
||||
// 招聘考试聚合模块 API 客户端。
|
||||
// 接口规范:全部 POST;URL 不含任何参数(查询/路径参数均禁止);入参一律走 body。
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
// ---------- 分类 / 状态枚举(与后端 CategoryNameMap/StatusNameMap 保持一致) ----------
|
||||
export const CATEGORY_OPTIONS = [
|
||||
{ label: '公务员', value: 1 },
|
||||
{ label: '事业单位', value: 2 },
|
||||
{ label: '国企', value: 3 },
|
||||
{ label: '央企', value: 4 },
|
||||
{ label: '私企', value: 5 },
|
||||
{ label: '其他', value: 6 },
|
||||
];
|
||||
|
||||
export const STATUS_OPTIONS = [
|
||||
{ label: '全部', value: -1 },
|
||||
{ label: '有效', value: 0 },
|
||||
{ label: '已更正', value: 1 },
|
||||
{ label: '已失效', value: 2 },
|
||||
];
|
||||
|
||||
// 贵州地区(默认贵阳)。
|
||||
export const REGION_OPTIONS = [
|
||||
'贵阳',
|
||||
'遵义',
|
||||
'六盘水',
|
||||
'安顺',
|
||||
'毕节',
|
||||
'铜仁',
|
||||
'黔西南',
|
||||
'黔东南',
|
||||
'黔南',
|
||||
'贵安新区',
|
||||
].map((r) => ({ label: r, value: r }));
|
||||
|
||||
// ---------- 数据类型 ----------
|
||||
export interface RecruitmentItem {
|
||||
id: number;
|
||||
title: string;
|
||||
sourceId: number;
|
||||
sourceName: string;
|
||||
orgId: number;
|
||||
orgName: string;
|
||||
category: number;
|
||||
categoryName: string;
|
||||
region: string;
|
||||
publishDate: string;
|
||||
deadline: string;
|
||||
examDate: string;
|
||||
url: string;
|
||||
content: string;
|
||||
attachments: string;
|
||||
status: number;
|
||||
statusName: string;
|
||||
groupKey: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface RecruitmentListResp {
|
||||
list: RecruitmentItem[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface CategoryAggItem {
|
||||
category: number;
|
||||
categoryName: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface RegionAggItem {
|
||||
region: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface TrendPointItem {
|
||||
date: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface RecruitmentStatsResp {
|
||||
total: number;
|
||||
todayNew: number;
|
||||
weekNew: number;
|
||||
byCategory: CategoryAggItem[];
|
||||
byRegion: RegionAggItem[];
|
||||
recentTrend: TrendPointItem[];
|
||||
}
|
||||
|
||||
export interface SourceItem {
|
||||
id: number;
|
||||
name: string;
|
||||
baseUrl: string;
|
||||
sourceType: number;
|
||||
category: number;
|
||||
region: string;
|
||||
enabled: number;
|
||||
lastSuccessAt: string;
|
||||
failCount: number;
|
||||
lastSummary: string;
|
||||
}
|
||||
|
||||
export interface SubscriptionItem {
|
||||
id: number;
|
||||
name: string;
|
||||
deviceKey: string;
|
||||
regions: string[];
|
||||
categories: number[];
|
||||
onlyNew: number;
|
||||
pushTime: string;
|
||||
enabled: number;
|
||||
}
|
||||
|
||||
// ---------- 列表 & 详情 ----------
|
||||
export interface RecruitmentQuery {
|
||||
page: number;
|
||||
size: number;
|
||||
region?: string;
|
||||
category?: number;
|
||||
keyword?: string;
|
||||
status?: number;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
orgName?: string;
|
||||
sourceId?: number;
|
||||
onlyNewToday?: boolean;
|
||||
}
|
||||
|
||||
export function getRecruitmentList(data: RecruitmentQuery) {
|
||||
return requestClient.post<RecruitmentListResp>(
|
||||
'/api/service/admin/recruitment/info/list',
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
export function getRecruitmentDetail(data: { id: number }) {
|
||||
return requestClient.post<{ info: RecruitmentItem }>(
|
||||
'/api/service/admin/recruitment/info/detail',
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- 看板 & 趋势 ----------
|
||||
export function getRecruitmentStats(data: { region?: string }) {
|
||||
return requestClient.post<RecruitmentStatsResp>(
|
||||
'/api/service/admin/recruitment/stats',
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
export function getRecruitmentTrend(data: {
|
||||
category?: number;
|
||||
days?: number;
|
||||
region?: string;
|
||||
}) {
|
||||
return requestClient.post<{ trend: TrendPointItem[] }>(
|
||||
'/api/service/admin/recruitment/trend',
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- 数据源 & 抓取 ----------
|
||||
export function getSourceList() {
|
||||
return requestClient.post<{ list: SourceItem[] }>(
|
||||
'/api/service/admin/recruitment/source/list',
|
||||
);
|
||||
}
|
||||
|
||||
export function triggerCrawl(data: { force?: boolean; sourceId?: number; }) {
|
||||
return requestClient.post<{ summary: string; triggered: number; }>(
|
||||
'/api/service/admin/recruitment/crawl/trigger',
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- 推送订阅 ----------
|
||||
export function getSubscriptionList() {
|
||||
return requestClient.post<{ list: SubscriptionItem[] }>(
|
||||
'/api/service/admin/recruitment/subscription/list',
|
||||
);
|
||||
}
|
||||
|
||||
export function saveSubscription(data: {
|
||||
categories?: number[];
|
||||
deviceKey: string;
|
||||
enabled?: number;
|
||||
id?: number;
|
||||
name: string;
|
||||
onlyNew?: number;
|
||||
pushTime?: string;
|
||||
regions?: string[];
|
||||
}) {
|
||||
return requestClient.post<{ id: number }>(
|
||||
'/api/service/admin/recruitment/subscription/save',
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteSubscription(data: { id: number }) {
|
||||
return requestClient.post(
|
||||
'/api/service/admin/recruitment/subscription/delete',
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
export function testPush(data: {
|
||||
body?: string;
|
||||
subscriptionId?: number;
|
||||
title?: string;
|
||||
}) {
|
||||
return requestClient.post<{ message: string; result: boolean; }>(
|
||||
'/api/service/admin/recruitment/push/test',
|
||||
data,
|
||||
);
|
||||
}
|
||||
@ -17,9 +17,9 @@ export interface AdminListResp {
|
||||
}
|
||||
|
||||
export function getAdminList(data: {
|
||||
keyword?: string;
|
||||
page: number;
|
||||
size: number;
|
||||
keyword?: string;
|
||||
}) {
|
||||
return requestClient.post<AdminListResp>(
|
||||
'/api/service/admin/admin/list',
|
||||
@ -28,10 +28,10 @@ export function getAdminList(data: {
|
||||
}
|
||||
|
||||
export function createAdmin(data: {
|
||||
username: string;
|
||||
password: string;
|
||||
nickname: string;
|
||||
password: string;
|
||||
roleIds: number[];
|
||||
username: string;
|
||||
}) {
|
||||
return requestClient.post<{ id: number }>(
|
||||
'/api/service/admin/admin/create',
|
||||
@ -41,7 +41,7 @@ export function createAdmin(data: {
|
||||
|
||||
export function updateAdmin(
|
||||
id: number,
|
||||
data: { nickname: string; status: number; roleIds: number[] },
|
||||
data: { nickname: string; roleIds: number[]; status: number; },
|
||||
) {
|
||||
return requestClient.post(`/api/service/admin/admin/update/${id}`, data);
|
||||
}
|
||||
@ -72,9 +72,9 @@ export interface RoleListResp {
|
||||
}
|
||||
|
||||
export function getRoleList(data: {
|
||||
keyword?: string;
|
||||
page: number;
|
||||
size: number;
|
||||
keyword?: string;
|
||||
}) {
|
||||
return requestClient.post<RoleListResp>(
|
||||
'/api/service/admin/system/role/list',
|
||||
@ -84,9 +84,9 @@ export function getRoleList(data: {
|
||||
|
||||
export function createRole(data: {
|
||||
code: string;
|
||||
menuIds: number[];
|
||||
name: string;
|
||||
status: number;
|
||||
menuIds: number[];
|
||||
}) {
|
||||
return requestClient.post<{ id: number }>(
|
||||
'/api/service/admin/system/role/create',
|
||||
@ -96,9 +96,12 @@ export function createRole(data: {
|
||||
|
||||
export function updateRole(
|
||||
id: number,
|
||||
data: { name: string; status: number; menuIds: number[] },
|
||||
data: { menuIds: number[]; name: string; status: number; },
|
||||
) {
|
||||
return requestClient.post(`/api/service/admin/system/role/update/${id}`, data);
|
||||
return requestClient.post(
|
||||
`/api/service/admin/system/role/update/${id}`,
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteRole(id: number) {
|
||||
@ -135,7 +138,10 @@ export function createMenu(data: Partial<MenuNode>) {
|
||||
}
|
||||
|
||||
export function updateMenu(id: number, data: Partial<MenuNode>) {
|
||||
return requestClient.post(`/api/service/admin/system/menu/update/${id}`, data);
|
||||
return requestClient.post(
|
||||
`/api/service/admin/system/menu/update/${id}`,
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteMenu(id: number) {
|
||||
@ -143,17 +149,22 @@ export function deleteMenu(id: number) {
|
||||
}
|
||||
|
||||
// ---------- 操作日志(/api/service/admin/system/log) ----------
|
||||
// 注意:接口规范为全部 POST、URL 不带参数、入参走 body。
|
||||
export interface LogItem {
|
||||
id: number;
|
||||
adminId: number;
|
||||
permission: string;
|
||||
method: string;
|
||||
path: string;
|
||||
ip: string;
|
||||
param: string;
|
||||
durationMs: number;
|
||||
statusCode: number;
|
||||
createdAt: string;
|
||||
adminUsername: string; // 操作账号(写入时冗余落库,免 JOIN)
|
||||
permission: string; // 权限标识
|
||||
method: string; // HTTP 方法
|
||||
path: string; // 请求路径
|
||||
ip: string; // 来源 IP
|
||||
ipLocation: string; // IP 归属地(如「中国 江苏省 苏州市 电信」)
|
||||
param: string; // 请求参数(已脱敏)
|
||||
durationMs: number; // 耗时(ms)
|
||||
statusCode: number; // HTTP 状态码
|
||||
errorMessage: string; // 失败原因(响应非 2xx 时填充)
|
||||
userAgent: string; // 浏览器 UA
|
||||
createdAt: string; // 操作时间
|
||||
}
|
||||
|
||||
export interface LogListResp {
|
||||
@ -161,13 +172,27 @@ export interface LogListResp {
|
||||
total: number;
|
||||
}
|
||||
|
||||
/** 操作日志分页查询(keyword 匹配权限码/路径/IP) */
|
||||
export function getSystemLogList(data: {
|
||||
// 操作日志分页查询参数;覆盖时间、账号、IP、归属地、方法、结果、耗时、排序等维度。
|
||||
export interface LogQuery {
|
||||
page: number;
|
||||
size: number;
|
||||
keyword?: string;
|
||||
adminId?: number;
|
||||
}) {
|
||||
adminId?: number; // 按管理员ID精确过滤
|
||||
username?: string; // 按账号模糊过滤
|
||||
ip?: string; // 按来源 IP 模糊过滤
|
||||
ipLocation?: string; // 按 IP 归属地模糊过滤
|
||||
method?: string; // 按 HTTP 方法精确过滤(GET/POST/PUT/DELETE)
|
||||
status?: number; // 0 全部 / 1 成功(2xx) / 2 失败(非2xx)
|
||||
keyword?: string; // 权限码/路径/IP 关键字
|
||||
startTime?: string; // 起始时间 yyyy-MM-dd HH:mm:ss
|
||||
endTime?: string; // 结束时间 yyyy-MM-dd HH:mm:ss
|
||||
minDuration?: number; // 耗时下限(ms),0 不限
|
||||
maxDuration?: number; // 耗时上限(ms),0 不限
|
||||
orderBy?: string; // createdAt(默认) | durationMs
|
||||
orderDir?: string; // desc(默认) | asc
|
||||
}
|
||||
|
||||
/** 操作日志分页查询 */
|
||||
export function getSystemLogList(data: LogQuery) {
|
||||
return requestClient.post<LogListResp>('/api/service/admin/system/log', data);
|
||||
}
|
||||
|
||||
@ -191,8 +216,8 @@ export interface LoginLogListResp {
|
||||
export function getLoginLogList(data: {
|
||||
page: number;
|
||||
size: number;
|
||||
username?: string;
|
||||
status?: number;
|
||||
username?: string;
|
||||
}) {
|
||||
return requestClient.post<LoginLogListResp>(
|
||||
'/api/service/admin/system/login-log',
|
||||
|
||||
@ -18,6 +18,8 @@ import { router } from './router';
|
||||
|
||||
// 引入组件库的少量全局样式变量
|
||||
import 'tdesign-vue-next/es/style/index.css';
|
||||
// 引入全局主题美化(品牌色/圆角/阴影/背景纵深),须置于上方样式之后
|
||||
import '#/styles/theme.css';
|
||||
|
||||
async function bootstrap(namespace: string) {
|
||||
// 初始化组件适配器
|
||||
|
||||
199
apps/web-tdesign/src/styles/theme.css
Normal file
199
apps/web-tdesign/src/styles/theme.css
Normal file
@ -0,0 +1,199 @@
|
||||
/**
|
||||
* 全局主题美化
|
||||
* 设计语言:靛蓝(indigo)主色 + 紫(violet)渐变点缀;柔和圆角;轻投影;有纵深的浅色背景。
|
||||
* 说明:Vben 的 useTDesignDesignTokens 会用 JS 把品牌色写到 documentElement 上,
|
||||
* 普通 :root 规则会被内联样式压过;因此对关键 --td-* 变量加 !important 强制生效。
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* —— 品牌主色(靛蓝 #4f5bff → 紫 #7c3aed 渐变体系) —— */
|
||||
--td-brand-color: #4f5bff !important;
|
||||
--td-brand-color-hover: #6b75ff !important;
|
||||
--td-brand-color-active: #3a45e0 !important;
|
||||
--td-brand-color-disabled: #c2c7ff !important;
|
||||
--td-brand-color-light: #eef0ff !important;
|
||||
--td-brand-color-light-hover: #e0e3ff !important;
|
||||
--td-brand-color-light-active: #d6daff !important;
|
||||
|
||||
/* —— 圆角:更现代、更柔 —— */
|
||||
--td-radius-small: 6px !important;
|
||||
--td-radius-medium: 10px !important;
|
||||
--td-radius-large: 14px !important;
|
||||
--td-radius-round: 999px !important;
|
||||
--td-radius-circle: 50% !important;
|
||||
|
||||
/* —— 投影:更轻、更有层次 —— */
|
||||
--td-shadow-1: 0 1px 2px rgb(16 24 40 / 4%),
|
||||
0 1px 3px rgb(16 24 40 / 6%) !important;
|
||||
--td-shadow-2: 0 2px 4px rgb(16 24 40 / 4%),
|
||||
0 8px 24px rgb(16 24 40 / 6%) !important;
|
||||
--td-shadow-3: 0 4px 8px rgb(16 24 40 / 5%),
|
||||
0 16px 40px rgb(16 24 40 / 8%) !important;
|
||||
|
||||
/* —— 边框:更淡 —— */
|
||||
--td-component-stroke: #eef0f5 !important;
|
||||
--td-border-level-1-color: #eef0f5 !important;
|
||||
--td-bg-color-page: #f5f7fb !important;
|
||||
}
|
||||
|
||||
/* 页面整体:不再纯白,带极淡的冷色纵深 */
|
||||
html {
|
||||
font-family: 'PingFang SC', 'Microsoft YaHei', 'Source Han Sans SC',
|
||||
'Noto Sans SC', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
|
||||
Helvetica, Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizelegibility;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #f5f7fb;
|
||||
background-image: radial-gradient(
|
||||
1200px 600px at 100% -10%,
|
||||
rgb(79 91 255 / 6%),
|
||||
transparent 60%
|
||||
),
|
||||
radial-gradient(
|
||||
900px 500px at -10% 110%,
|
||||
rgb(124 58 237 / 5%),
|
||||
transparent 55%
|
||||
);
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
/* 内容区卡片化:让所有 TDesign 卡片更精致 */
|
||||
.t-card {
|
||||
overflow: hidden;
|
||||
border-color: var(--td-border-level-1-color);
|
||||
border-radius: var(--td-radius-large);
|
||||
box-shadow: var(--td-shadow-2);
|
||||
}
|
||||
|
||||
.t-card__header {
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--td-border-level-1-color);
|
||||
}
|
||||
|
||||
.t-card__title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #1d2129;
|
||||
}
|
||||
|
||||
.t-card__body {
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
/* 表格更透气 */
|
||||
.t-table {
|
||||
--td-table-bg-color: transparent;
|
||||
}
|
||||
|
||||
.t-table__header th {
|
||||
font-weight: 600;
|
||||
color: #4e5969;
|
||||
background-color: #fafbff !important;
|
||||
}
|
||||
|
||||
/* 主按钮:带品牌渐变,更出彩 */
|
||||
.t-button--theme-primary:not(.t-button--variant-outline):not(
|
||||
.t-button--variant-text
|
||||
):not(.t-button--variant-dashed) {
|
||||
background: linear-gradient(135deg, #4f5bff 0%, #7c3aed 100%);
|
||||
border: none;
|
||||
box-shadow: 0 4px 12px rgb(79 91 255 / 28%);
|
||||
transition: transform 0.12s ease, box-shadow 0.12s ease, filter 0.12s ease;
|
||||
}
|
||||
|
||||
.t-button--theme-primary:not(.t-button--variant-outline):not(
|
||||
.t-button--variant-text
|
||||
):not(.t-button--variant-dashed):hover {
|
||||
box-shadow: 0 6px 16px rgb(79 91 255 / 36%);
|
||||
filter: brightness(1.05);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.t-button--theme-primary:not(.t-button--variant-outline):not(
|
||||
.t-button--variant-text
|
||||
):not(.t-button--variant-dashed):active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* 滚动条:纤细、克制 */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgb(29 33 41 / 18%);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgb(29 33 41 / 30%);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* 暗色模式:收敛背景纵深,保持卡片质感 */
|
||||
html[theme-mode='dark'] body {
|
||||
background-color: #16171a;
|
||||
background-image: radial-gradient(
|
||||
1200px 600px at 100% -10%,
|
||||
rgb(79 91 255 / 12%),
|
||||
transparent 60%
|
||||
),
|
||||
radial-gradient(
|
||||
900px 500px at -10% 110%,
|
||||
rgb(124 58 237 / 10%),
|
||||
transparent 55%
|
||||
);
|
||||
}
|
||||
|
||||
html[theme-mode='dark'] .t-card {
|
||||
border-color: rgb(255 255 255 / 8%);
|
||||
}
|
||||
|
||||
html[theme-mode='dark'] .t-card__header {
|
||||
border-bottom-color: rgb(255 255 255 / 8%);
|
||||
}
|
||||
|
||||
html[theme-mode='dark'] .t-table__header th {
|
||||
background-color: rgb(255 255 255 / 4%) !important;
|
||||
}
|
||||
|
||||
/* —— 业务页统一:页面标题 + 筛选/表格卡片 —— */
|
||||
.wb-page-head {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.wb-page-title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1d2129;
|
||||
}
|
||||
|
||||
.wb-page-sub {
|
||||
margin: 4px 0 0;
|
||||
font-size: 13px;
|
||||
color: #86909c;
|
||||
}
|
||||
|
||||
.wb-filter-card {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.wb-filter-card .t-card__body {
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
html[theme-mode='dark'] .wb-page-title {
|
||||
color: rgb(255 255 255 / 90%);
|
||||
}
|
||||
|
||||
html[theme-mode='dark'] .wb-page-sub {
|
||||
color: rgb(255 255 255 / 50%);
|
||||
}
|
||||
592
apps/web-tdesign/src/views/dashboard/workbench/index.vue
Normal file
592
apps/web-tdesign/src/views/dashboard/workbench/index.vue
Normal file
@ -0,0 +1,592 @@
|
||||
<script lang="ts" setup>
|
||||
import type { LoginLogItem, LogItem, MenuNode } from '#/api/system';
|
||||
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
import { useUserStore } from '@vben/stores';
|
||||
|
||||
import { MessagePlugin } from 'tdesign-vue-next';
|
||||
|
||||
import {
|
||||
getAdminList,
|
||||
getLoginLogList,
|
||||
getMenuTree,
|
||||
getRoleList,
|
||||
getSystemLogList,
|
||||
} from '#/api/system';
|
||||
|
||||
const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
|
||||
const loading = ref(false);
|
||||
const today = ref(formatToday());
|
||||
|
||||
// 概览统计(容错:单个接口失败不影响其余展示)
|
||||
const stats = reactive({
|
||||
userCount: 0,
|
||||
roleCount: 0,
|
||||
menuCount: 0,
|
||||
opLogCount: 0,
|
||||
});
|
||||
const opLogs = ref<LogItem[]>([]);
|
||||
const loginLogs = ref<LoginLogItem[]>([]);
|
||||
|
||||
// 简单线性图标(24x24,stroke 风格),避免额外依赖
|
||||
const ICONS: Record<string, string> = {
|
||||
user: 'M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm0 2c-4 0-7 2-7 5v1h14v-1c0-3-3-5-7-5Z',
|
||||
role: 'M12 3l7 3v5c0 4-3 7-7 9-4-2-7-5-7-9V6l7-3Z',
|
||||
menu: 'M8 6h12M8 12h12M8 18h12M4 6h.01M4 12h.01M4 18h.01',
|
||||
log: 'M12 7v5l3 2M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z',
|
||||
login: 'M14 7a4 4 0 1 1-3.4 6.1L9 16H6v-3H4v-3h6.6A4 4 0 0 1 14 7Z',
|
||||
};
|
||||
|
||||
const statCards = computed(() => [
|
||||
{
|
||||
label: '人员总数',
|
||||
value: stats.userCount,
|
||||
icon: ICONS.user,
|
||||
gradient: 'linear-gradient(135deg,#4f5bff,#6d6bff)',
|
||||
},
|
||||
{
|
||||
label: '角色总数',
|
||||
value: stats.roleCount,
|
||||
icon: ICONS.role,
|
||||
gradient: 'linear-gradient(135deg,#0ea5e9,#22d3ee)',
|
||||
},
|
||||
{
|
||||
label: '菜单总数',
|
||||
value: stats.menuCount,
|
||||
icon: ICONS.menu,
|
||||
gradient: 'linear-gradient(135deg,#10b981,#34d399)',
|
||||
},
|
||||
{
|
||||
label: '操作日志',
|
||||
value: stats.opLogCount,
|
||||
icon: ICONS.log,
|
||||
gradient: 'linear-gradient(135deg,#8b5cf6,#a855f7)',
|
||||
},
|
||||
]);
|
||||
|
||||
// 快捷入口:跳转到已有系统模块(URL 与后端菜单路由树一致)
|
||||
const quickNavs = [
|
||||
{
|
||||
title: '人员管理',
|
||||
path: '/system/admin',
|
||||
icon: ICONS.user,
|
||||
gradient: 'linear-gradient(135deg,#4f5bff,#6d6bff)',
|
||||
},
|
||||
{
|
||||
title: '角色管理',
|
||||
path: '/system/role',
|
||||
icon: ICONS.role,
|
||||
gradient: 'linear-gradient(135deg,#0ea5e9,#22d3ee)',
|
||||
},
|
||||
{
|
||||
title: '菜单管理',
|
||||
path: '/system/menu',
|
||||
icon: ICONS.menu,
|
||||
gradient: 'linear-gradient(135deg,#10b981,#34d399)',
|
||||
},
|
||||
{
|
||||
title: '操作日志',
|
||||
path: '/system/log/operation',
|
||||
icon: ICONS.log,
|
||||
gradient: 'linear-gradient(135deg,#8b5cf6,#a855f7)',
|
||||
},
|
||||
{
|
||||
title: '登录日志',
|
||||
path: '/system/log/login',
|
||||
icon: ICONS.login,
|
||||
gradient: 'linear-gradient(135deg,#f59e0b,#fbbf24)',
|
||||
},
|
||||
];
|
||||
|
||||
const opColumns = [
|
||||
{ colKey: 'adminId', title: '操作人ID', width: 90 },
|
||||
{ colKey: 'permission', title: '权限码', ellipsis: true },
|
||||
{ colKey: 'path', title: '接口', ellipsis: true },
|
||||
{ colKey: 'ip', title: 'IP', width: 130 },
|
||||
{ colKey: 'createdAt', title: '时间', width: 170 },
|
||||
];
|
||||
|
||||
const loginColumns = [
|
||||
{ colKey: 'username', title: '账号', width: 120 },
|
||||
{ colKey: 'ip', title: 'IP', width: 130 },
|
||||
{ colKey: 'statusText', title: '结果', width: 90 },
|
||||
{ colKey: 'createdAt', title: '时间', width: 170 },
|
||||
];
|
||||
|
||||
function formatToday() {
|
||||
const d = new Date();
|
||||
const week = ['日', '一', '二', '三', '四', '五', '六'][d.getDay()];
|
||||
const p = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}年${p(d.getMonth() + 1)}月${p(d.getDate())}日 星期${week}`;
|
||||
}
|
||||
|
||||
// 递归统计菜单树节点数
|
||||
function countMenu(nodes?: MenuNode[]): number {
|
||||
if (!nodes || nodes.length === 0) return 0;
|
||||
let n = 0;
|
||||
for (const node of nodes) {
|
||||
n += 1 + countMenu(node.children);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const [adminRes, roleRes, menuRes, opRes, loginRes] =
|
||||
await Promise.allSettled([
|
||||
getAdminList({ page: 1, size: 1 }),
|
||||
getRoleList({ page: 1, size: 1 }),
|
||||
getMenuTree(),
|
||||
getSystemLogList({ page: 1, size: 5 }),
|
||||
getLoginLogList({ page: 1, size: 5 }),
|
||||
]);
|
||||
if (adminRes.status === 'fulfilled')
|
||||
stats.userCount = adminRes.value.total ?? 0;
|
||||
if (roleRes.status === 'fulfilled')
|
||||
stats.roleCount = roleRes.value.total ?? 0;
|
||||
if (menuRes.status === 'fulfilled')
|
||||
stats.menuCount = countMenu(menuRes.value.tree);
|
||||
if (opRes.status === 'fulfilled') {
|
||||
stats.opLogCount = opRes.value.total ?? 0;
|
||||
opLogs.value = opRes.value.list ?? [];
|
||||
}
|
||||
if (loginRes.status === 'fulfilled')
|
||||
loginLogs.value = loginRes.value.list ?? [];
|
||||
} catch {
|
||||
MessagePlugin.warning('部分数据加载失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function go(path: string) {
|
||||
router.push(path).catch(() => MessagePlugin.warning('页面未找到'));
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<div class="wb">
|
||||
<!-- 英雄头:渐变 + 柔光,作为记忆点 -->
|
||||
<section class="wb-hero">
|
||||
<div class="wb-hero-glow"></div>
|
||||
<div class="wb-hero-inner">
|
||||
<div class="wb-hero-text">
|
||||
<h1 class="wb-hero-title">
|
||||
你好,{{ userStore.userInfo?.realName || '管理员' }}
|
||||
<span class="wb-wave">👋</span>
|
||||
</h1>
|
||||
<p class="wb-hero-sub">{{ today }} · 欢迎回到系统工作台</p>
|
||||
</div>
|
||||
<div class="wb-hero-badge">
|
||||
<span class="wb-badge-dot"></span>
|
||||
系统运行正常
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 概览统计 -->
|
||||
<section class="wb-stats">
|
||||
<div
|
||||
v-for="s in statCards"
|
||||
:key="s.label"
|
||||
class="wb-stat"
|
||||
:class="{ 'is-loading': loading }"
|
||||
>
|
||||
<div class="wb-stat-icon" :style="{ backgroundImage: s.gradient }">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.8"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path :d="s.icon" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="wb-stat-meta">
|
||||
<div class="wb-stat-num">{{ loading ? '' : s.value }}</div>
|
||||
<div class="wb-stat-label">{{ s.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 两栏:动态列表 + 快捷入口 -->
|
||||
<section class="wb-grid">
|
||||
<div class="wb-col">
|
||||
<t-card title="最新操作日志" :bordered="false" class="wb-card">
|
||||
<t-table
|
||||
row-key="id"
|
||||
:data="opLogs"
|
||||
:columns="opColumns"
|
||||
:loading="loading"
|
||||
size="small"
|
||||
:pagination="false"
|
||||
/>
|
||||
</t-card>
|
||||
<t-card title="最新登录日志" :bordered="false" class="wb-card">
|
||||
<t-table
|
||||
row-key="id"
|
||||
:data="loginLogs"
|
||||
:columns="loginColumns"
|
||||
:loading="loading"
|
||||
size="small"
|
||||
:pagination="false"
|
||||
>
|
||||
<template #statusText="{ row }">
|
||||
<t-tag
|
||||
:theme="row.status === 1 ? 'success' : 'danger'"
|
||||
variant="light"
|
||||
>
|
||||
{{ row.status === 1 ? '成功' : '失败' }}
|
||||
</t-tag>
|
||||
</template>
|
||||
</t-table>
|
||||
</t-card>
|
||||
</div>
|
||||
|
||||
<div class="wb-col wb-side">
|
||||
<t-card title="快捷入口" :bordered="false" class="wb-card">
|
||||
<div class="wb-quick">
|
||||
<button
|
||||
v-for="q in quickNavs"
|
||||
:key="q.path"
|
||||
class="wb-tile"
|
||||
type="button"
|
||||
@click="go(q.path)"
|
||||
>
|
||||
<span
|
||||
class="wb-tile-icon"
|
||||
:style="{ backgroundImage: q.gradient }"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.8"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path :d="q.icon" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="wb-tile-label">{{ q.title }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</t-card>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wb {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
/* —— 英雄头 —— */
|
||||
.wb-hero {
|
||||
position: relative;
|
||||
padding: 28px 32px;
|
||||
overflow: hidden;
|
||||
color: #fff;
|
||||
background: linear-gradient(120deg, #4f5bff 0%, #6d5bff 45%, #8b5cf6 100%);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 12px 32px rgb(79 91 255 / 28%);
|
||||
}
|
||||
|
||||
.wb-hero-glow {
|
||||
position: absolute;
|
||||
top: -60%;
|
||||
right: -10%;
|
||||
width: 360px;
|
||||
height: 360px;
|
||||
pointer-events: none;
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
rgb(255 255 255 / 35%),
|
||||
transparent 65%
|
||||
);
|
||||
border-radius: 50%;
|
||||
filter: blur(8px);
|
||||
animation: wb-float 9s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes wb-float {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateY(-18px) scale(1.08);
|
||||
}
|
||||
}
|
||||
|
||||
.wb-hero-inner {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.wb-hero-title {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.wb-wave {
|
||||
display: inline-block;
|
||||
transform-origin: 70% 70%;
|
||||
animation: wb-wave 2.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes wb-wave {
|
||||
0%,
|
||||
60%,
|
||||
100% {
|
||||
transform: rotate(0);
|
||||
}
|
||||
|
||||
10% {
|
||||
transform: rotate(14deg);
|
||||
}
|
||||
|
||||
20% {
|
||||
transform: rotate(-8deg);
|
||||
}
|
||||
|
||||
30% {
|
||||
transform: rotate(14deg);
|
||||
}
|
||||
|
||||
40% {
|
||||
transform: rotate(-4deg);
|
||||
}
|
||||
}
|
||||
|
||||
.wb-hero-sub {
|
||||
margin: 8px 0 0;
|
||||
font-size: 13px;
|
||||
opacity: 0.86;
|
||||
}
|
||||
|
||||
.wb-hero-badge {
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 7px 14px;
|
||||
font-size: 13px;
|
||||
background: rgb(255 255 255 / 16%);
|
||||
border-radius: 999px;
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.wb-badge-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #4ade80;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 0 4px rgb(74 222 128 / 28%);
|
||||
}
|
||||
|
||||
/* —— 统计卡 —— */
|
||||
.wb-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.wb-stat {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
padding: 18px 20px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--td-border-level-1-color, #eef0f5);
|
||||
border-radius: 14px;
|
||||
box-shadow: var(--td-shadow-2);
|
||||
transition:
|
||||
transform 0.16s ease,
|
||||
box-shadow 0.16s ease;
|
||||
}
|
||||
|
||||
.wb-stat:hover {
|
||||
box-shadow: var(--td-shadow-3);
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
|
||||
.wb-stat-icon {
|
||||
display: grid;
|
||||
flex: none;
|
||||
place-items: center;
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
color: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 6px 14px rgb(16 24 40 / 12%);
|
||||
}
|
||||
|
||||
.wb-stat-icon svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.wb-stat-num {
|
||||
min-height: 29px;
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
color: #1d2129;
|
||||
}
|
||||
|
||||
.wb-stat-label {
|
||||
margin-top: 2px;
|
||||
font-size: 13px;
|
||||
color: #86909c;
|
||||
}
|
||||
|
||||
.wb-stat.is-loading .wb-stat-num {
|
||||
width: 48px;
|
||||
background: linear-gradient(90deg, #eef0f5 25%, #f6f7fb 37%, #eef0f5 63%);
|
||||
background-size: 400% 100%;
|
||||
border-radius: 6px;
|
||||
animation: wb-shimmer 1.3s ease infinite;
|
||||
}
|
||||
|
||||
@keyframes wb-shimmer {
|
||||
0% {
|
||||
background-position: 100% 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: -100% 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* —— 两栏布局 —— */
|
||||
.wb-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.wb-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wb-card {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* —— 快捷入口宫格 —— */
|
||||
.wb-quick {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.wb-tile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 18px 12px;
|
||||
cursor: pointer;
|
||||
background: #fff;
|
||||
border: 1px solid var(--td-border-level-1-color, #eef0f5);
|
||||
border-radius: 12px;
|
||||
transition:
|
||||
transform 0.16s ease,
|
||||
box-shadow 0.16s ease,
|
||||
border-color 0.16s ease;
|
||||
}
|
||||
|
||||
.wb-tile:hover {
|
||||
border-color: transparent;
|
||||
box-shadow: var(--td-shadow-3);
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
|
||||
.wb-tile:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.wb-tile-icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
color: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 6px 14px rgb(16 24 40 / 12%);
|
||||
}
|
||||
|
||||
.wb-tile-icon svg {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.wb-tile-label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #4e5969;
|
||||
}
|
||||
|
||||
/* —— 响应式 —— */
|
||||
@media (max-width: 1100px) {
|
||||
.wb-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.wb-stats {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.wb-hero {
|
||||
padding: 22px 20px;
|
||||
}
|
||||
|
||||
.wb-hero-title {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.wb-hero-glow,
|
||||
.wb-wave,
|
||||
.wb-stat.is-loading .wb-stat-num {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.wb-stat,
|
||||
.wb-tile,
|
||||
.t-button {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -1,5 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import type { EchartsUIType } from '@vben/plugins/echarts';
|
||||
|
||||
import type { OverviewResp, RegionAgg, TrendPoint } from '#/api/house';
|
||||
|
||||
import { onMounted, ref } from 'vue';
|
||||
@ -13,6 +14,8 @@ import {
|
||||
getPriceTrend,
|
||||
} from '#/api/house';
|
||||
|
||||
import MapPanel from './map-panel.vue';
|
||||
|
||||
// 数据看板:概览卡片 + 区域均价柱状图 + 价格趋势折线图
|
||||
const overview = ref<OverviewResp>({
|
||||
communityCount: 0,
|
||||
@ -115,6 +118,11 @@ onMounted(load);
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4 rounded-lg border p-4">
|
||||
<h3 class="mb-2 text-sm font-medium">楼盘地图</h3>
|
||||
<MapPanel />
|
||||
</div>
|
||||
|
||||
<div class="mb-4 rounded-lg border p-4">
|
||||
<h3 class="mb-2 text-sm font-medium">区域在售均价</h3>
|
||||
<div style="height: 300px">
|
||||
|
||||
99
apps/web-tdesign/src/views/house/dashboard/map-panel.vue
Normal file
99
apps/web-tdesign/src/views/house/dashboard/map-panel.vue
Normal file
@ -0,0 +1,99 @@
|
||||
<script lang="ts" setup>
|
||||
import type { MapPoint } from '#/api/house';
|
||||
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import { getMapPoints } from '#/api/house';
|
||||
|
||||
// 楼盘地图:腾讯地图 GL JS + 楼盘散点(按小区聚合均价)。
|
||||
// 合规:腾讯地图为合规底图;key 需自行到 lbs.qq.com 申请后替换。
|
||||
// 楼栋级坐标:当前展示小区级点位,楼栋级坐标待采集器解析楼栋分布图后补充。
|
||||
const TENCENT_MAP_KEY = 'YOUR_TENCENT_MAP_KEY'; // TODO: 替换为你申请的腾讯地图 key
|
||||
|
||||
const mapEl = ref<HTMLDivElement>();
|
||||
|
||||
function loadScript(src: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const existing = document.querySelector(`script[src="${src}"]`);
|
||||
if (existing) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const s = document.createElement('script');
|
||||
s.src = src;
|
||||
s.onload = () => resolve();
|
||||
s.onerror = () => reject(new Error('腾讯地图 SDK 加载失败'));
|
||||
document.head.append(s);
|
||||
});
|
||||
}
|
||||
|
||||
// 楼盘点图标(inline SVG,橙色圆点 + 白色边框)
|
||||
const MARKER_SVG = `data:image/svg+xml;utf8,${encodeURIComponent(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24">' +
|
||||
'<circle cx="12" cy="12" r="9" fill="#EF9F27" stroke="#fff" stroke-width="2"/>' +
|
||||
'<circle cx="12" cy="12" r="4" fill="#fff"/></svg>',
|
||||
)}`;
|
||||
|
||||
async function initMap() {
|
||||
if (!mapEl.value) return;
|
||||
await loadScript(
|
||||
`https://map.qq.com/api/gljs?v=1.exp&key=${TENCENT_MAP_KEY}`,
|
||||
);
|
||||
const TMap = (window as any).TMap;
|
||||
if (!TMap) return;
|
||||
|
||||
const map = new TMap.Map(mapEl.value, {
|
||||
center: new TMap.LatLng(26.647, 106.63), // 贵阳市中心
|
||||
zoom: 11,
|
||||
});
|
||||
|
||||
const pts: MapPoint[] = (await getMapPoints({})).points ?? [];
|
||||
const geometries = pts.map((p) => ({
|
||||
id: String(p.communityId),
|
||||
styleId: 'community',
|
||||
position: new TMap.LatLng(p.lat, p.lng),
|
||||
properties: {
|
||||
name: p.name,
|
||||
avgPrice: p.avgUnitPrice,
|
||||
count: p.listingCount,
|
||||
},
|
||||
}));
|
||||
|
||||
const markerLayer = new TMap.MultiMarker({
|
||||
map,
|
||||
styles: {
|
||||
community: new TMap.MarkerStyle({
|
||||
width: 24,
|
||||
height: 24,
|
||||
anchor: { x: 12, y: 12 },
|
||||
src: MARKER_SVG,
|
||||
}),
|
||||
},
|
||||
geometries,
|
||||
});
|
||||
|
||||
// 点击楼盘显示信息窗
|
||||
const info = new TMap.InfoWindow({
|
||||
map,
|
||||
position: new TMap.LatLng(26.647, 106.63),
|
||||
});
|
||||
markerLayer.on('click', (e: any) => {
|
||||
const prop = e.geometry?.properties;
|
||||
if (!prop) return;
|
||||
info.setPosition(e.geometry.position);
|
||||
info.setContent(
|
||||
`<div style="padding:6px 10px;font-size:13px;line-height:1.5">
|
||||
<b>${prop.name}</b><br/>
|
||||
在售均价 ${Math.round(prop.avgPrice)} 元/㎡ · ${prop.count} 套在售
|
||||
</div>`,
|
||||
);
|
||||
info.open();
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(initMap);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="mapEl" class="w-full" style="height: 420px"></div>
|
||||
</template>
|
||||
@ -1,18 +1,102 @@
|
||||
<script lang="ts" setup>
|
||||
import type { TransactionItem } from '#/api/house';
|
||||
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
|
||||
// 数据明细:价格快照 / 成交记录 / 配套 / 学区划片。
|
||||
// 阶段 2 起接入采集器写入的数据后,此处按类别拆分为多个列表页。
|
||||
import { getTransactionList } from '#/api/house';
|
||||
|
||||
// 数据明细:成交记录列表(价格快照/配套/学区划片在后续阶段接入)
|
||||
const loading = ref(false);
|
||||
const list = ref<TransactionItem[]>([]);
|
||||
|
||||
const regionOptions = [
|
||||
'云岩区',
|
||||
'南明区',
|
||||
'观山湖区',
|
||||
'花溪区',
|
||||
'乌当区',
|
||||
'白云区',
|
||||
'清镇市',
|
||||
'修文县',
|
||||
'开阳县',
|
||||
'息烽县',
|
||||
].map((r) => ({ label: r, value: r }));
|
||||
|
||||
const query = reactive({ page: 1, size: 10, keyword: '', region: '' });
|
||||
const pagination = reactive({ current: 1, pageSize: 10, total: 0 });
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await getTransactionList(query);
|
||||
list.value = data.list ?? [];
|
||||
pagination.total = data.total ?? 0;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
query.keyword = '';
|
||||
query.region = '';
|
||||
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>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<div class="rounded-lg border border-dashed p-8 text-center text-muted-foreground">
|
||||
<p class="text-base font-medium">数据明细</p>
|
||||
<p class="mt-2 text-sm">
|
||||
价格快照、成交记录、配套 POI、学区划片将在采集器接入后于此展示。
|
||||
</p>
|
||||
<p class="mt-1 text-sm">当前阶段先完成小区 / 房源管理与数据看板。</p>
|
||||
<div class="mb-3 flex items-center gap-2">
|
||||
<t-input
|
||||
v-model="query.keyword"
|
||||
clearable
|
||||
placeholder="小区名/户型"
|
||||
style="width: 200px"
|
||||
@enter="load"
|
||||
/>
|
||||
<t-select
|
||||
v-model="query.region"
|
||||
clearable
|
||||
:options="regionOptions"
|
||||
placeholder="区县"
|
||||
style="width: 140px"
|
||||
/>
|
||||
<t-button theme="primary" @click="load">查询</t-button>
|
||||
<t-button variant="outline" @click="reset">重置</t-button>
|
||||
</div>
|
||||
|
||||
<t-table
|
||||
row-key="id"
|
||||
:data="list"
|
||||
:columns="columns"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
@page-change="onPageChange"
|
||||
/>
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
123
apps/web-tdesign/src/views/house/presale/index.vue
Normal file
123
apps/web-tdesign/src/views/house/presale/index.vue
Normal file
@ -0,0 +1,123 @@
|
||||
<script lang="ts" setup>
|
||||
import type { PresaleItem } from '#/api/house';
|
||||
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
|
||||
import { getPresaleList } from '#/api/house';
|
||||
|
||||
// 新房预售:预售许可证列表(数据源:贵阳市住建局预售公示)
|
||||
const loading = ref(false);
|
||||
const list = ref<PresaleItem[]>([]);
|
||||
|
||||
const regionOptions = [
|
||||
'云岩区',
|
||||
'南明区',
|
||||
'观山湖区',
|
||||
'花溪区',
|
||||
'乌当区',
|
||||
'白云区',
|
||||
'清镇市',
|
||||
'修文县',
|
||||
'开阳县',
|
||||
'息烽县',
|
||||
'贵安新区',
|
||||
].map((r) => ({ label: r, value: r }));
|
||||
|
||||
const purposeOptions = [
|
||||
{ label: '住宅', value: '住宅' },
|
||||
{ label: '商业', value: '商业' },
|
||||
];
|
||||
|
||||
const query = reactive({
|
||||
page: 1,
|
||||
size: 10,
|
||||
keyword: '',
|
||||
region: '',
|
||||
purpose: '',
|
||||
});
|
||||
const pagination = reactive({ current: 1, pageSize: 10, total: 0 });
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await getPresaleList(query);
|
||||
list.value = data.list ?? [];
|
||||
pagination.total = data.total ?? 0;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
query.keyword = '';
|
||||
query.region = '';
|
||||
query.purpose = '';
|
||||
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>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<div class="mb-3 flex items-center gap-2">
|
||||
<t-input
|
||||
v-model="query.keyword"
|
||||
clearable
|
||||
placeholder="楼盘名/开发商/预售证号"
|
||||
style="width: 240px"
|
||||
@enter="load"
|
||||
/>
|
||||
<t-select
|
||||
v-model="query.region"
|
||||
clearable
|
||||
:options="regionOptions"
|
||||
placeholder="区县"
|
||||
style="width: 140px"
|
||||
/>
|
||||
<t-select
|
||||
v-model="query.purpose"
|
||||
clearable
|
||||
:options="purposeOptions"
|
||||
placeholder="用途"
|
||||
style="width: 120px"
|
||||
/>
|
||||
<t-button theme="primary" @click="load">查询</t-button>
|
||||
<t-button variant="outline" @click="reset">重置</t-button>
|
||||
</div>
|
||||
|
||||
<t-table
|
||||
row-key="id"
|
||||
:data="list"
|
||||
:columns="columns"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
@page-change="onPageChange"
|
||||
/>
|
||||
</Page>
|
||||
</template>
|
||||
126
apps/web-tdesign/src/views/recruitment/crawler/index.vue
Normal file
126
apps/web-tdesign/src/views/recruitment/crawler/index.vue
Normal file
@ -0,0 +1,126 @@
|
||||
<script lang="ts" setup>
|
||||
import type { SourceItem } from '#/api/recruitment';
|
||||
|
||||
// 数据源与抓取运维:数据源列表 + 手动触发抓取 + 运行摘要。
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import { useAccess } from '@vben/access';
|
||||
import { Page } from '@vben/common-ui';
|
||||
|
||||
import { DialogPlugin, MessagePlugin } from 'tdesign-vue-next';
|
||||
|
||||
import { getSourceList, triggerCrawl } from '#/api/recruitment';
|
||||
|
||||
const { hasAccessByCodes } = useAccess();
|
||||
|
||||
const loading = ref(false);
|
||||
const list = ref<SourceItem[]>([]);
|
||||
const crawling = ref(false);
|
||||
const runningSummary = ref('');
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await getSourceList();
|
||||
list.value = data.list ?? [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onTriggerAll() {
|
||||
const d = DialogPlugin.confirm({
|
||||
header: '触发全量抓取',
|
||||
body: '将对所有「已启用」的数据源执行一次抓取(非强制回溯,仅取最新)。确认?',
|
||||
onConfirm: async () => {
|
||||
await doTrigger(0, false);
|
||||
d.destroy();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function onTriggerOne(row: SourceItem) {
|
||||
await doTrigger(row.id, false);
|
||||
}
|
||||
|
||||
async function doTrigger(sourceId: number, force: boolean) {
|
||||
crawling.value = true;
|
||||
try {
|
||||
const data = await triggerCrawl({ sourceId, force });
|
||||
runningSummary.value = data.summary;
|
||||
MessagePlugin.success(`已触发 ${data.triggered} 个数据源`);
|
||||
await load();
|
||||
} finally {
|
||||
crawling.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ colKey: 'id', title: 'ID', width: 70 },
|
||||
{ colKey: 'name', title: '数据源', minWidth: 200 },
|
||||
{ colKey: 'region', title: '地区', width: 100 },
|
||||
{ colKey: 'baseUrl', title: '地址', minWidth: 220, ellipsis: true },
|
||||
{ colKey: 'enabled', title: '启用', width: 80 },
|
||||
{ colKey: 'lastSuccessAt', title: '上次成功', width: 160 },
|
||||
{ colKey: 'failCount', title: '连续失败', width: 90 },
|
||||
{ colKey: 'op', title: '操作', width: 100, fixed: 'right' },
|
||||
];
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<div class="mb-3 flex items-center gap-2">
|
||||
<t-button
|
||||
v-if="hasAccessByCodes(['recruitment:crawler:trigger'])"
|
||||
theme="primary"
|
||||
:loading="crawling"
|
||||
@click="onTriggerAll"
|
||||
>
|
||||
触发全量抓取
|
||||
</t-button>
|
||||
<span class="text-xs opacity-60">定时任务:每日 03:00 自动抓取</span>
|
||||
</div>
|
||||
|
||||
<t-table row-key="id" :data="list" :columns="columns" :loading="loading">
|
||||
<template #enabled="{ row }">
|
||||
<t-tag
|
||||
:theme="row.enabled === 1 ? 'success' : 'default'"
|
||||
variant="light"
|
||||
size="small"
|
||||
>
|
||||
{{ row.enabled === 1 ? '已启用' : '已停用' }}
|
||||
</t-tag>
|
||||
</template>
|
||||
<template #lastSuccessAt="{ row }">
|
||||
{{ row.lastSuccessAt || '—' }}
|
||||
</template>
|
||||
<template #failCount="{ row }">
|
||||
<span :class="row.failCount > 0 ? 'text-[#E34D59]' : ''">{{
|
||||
row.failCount
|
||||
}}</span>
|
||||
</template>
|
||||
<template #op="{ row }">
|
||||
<t-button
|
||||
v-if="hasAccessByCodes(['recruitment:crawler:trigger'])"
|
||||
size="small"
|
||||
theme="primary"
|
||||
variant="text"
|
||||
:disabled="row.enabled !== 1"
|
||||
:loading="crawling"
|
||||
@click="onTriggerOne(row)"
|
||||
>
|
||||
抓取
|
||||
</t-button>
|
||||
</template>
|
||||
</t-table>
|
||||
|
||||
<div v-if="runningSummary" class="mt-4 rounded-lg border p-3">
|
||||
<p class="mb-1 text-sm font-medium">本次运行摘要</p>
|
||||
<pre class="whitespace-pre-wrap text-xs leading-relaxed opacity-80">{{
|
||||
runningSummary
|
||||
}}</pre>
|
||||
</div>
|
||||
</Page>
|
||||
</template>
|
||||
147
apps/web-tdesign/src/views/recruitment/dashboard/index.vue
Normal file
147
apps/web-tdesign/src/views/recruitment/dashboard/index.vue
Normal file
@ -0,0 +1,147 @@
|
||||
<script lang="ts" setup>
|
||||
// 招聘数据看板:概览卡片 + 分类柱状图 + 地区分布 + 近 30 天趋势。
|
||||
import type { EchartsUIType } from '@vben/plugins/echarts';
|
||||
|
||||
import type { RecruitmentStatsResp } from '#/api/recruitment';
|
||||
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
|
||||
|
||||
import { getRecruitmentStats, REGION_OPTIONS } from '#/api/recruitment';
|
||||
|
||||
const region = ref('');
|
||||
const stats = ref<RecruitmentStatsResp>({
|
||||
total: 0,
|
||||
todayNew: 0,
|
||||
weekNew: 0,
|
||||
byCategory: [],
|
||||
byRegion: [],
|
||||
recentTrend: [],
|
||||
});
|
||||
|
||||
const categoryChartRef = ref<EchartsUIType>();
|
||||
const regionChartRef = ref<EchartsUIType>();
|
||||
const trendChartRef = ref<EchartsUIType>();
|
||||
const { renderEcharts: renderCategory } = useEcharts(categoryChartRef);
|
||||
const { renderEcharts: renderRegion } = useEcharts(regionChartRef);
|
||||
const { renderEcharts: renderTrend } = useEcharts(trendChartRef);
|
||||
|
||||
async function load() {
|
||||
const data = await getRecruitmentStats({ region: region.value || undefined });
|
||||
stats.value = data;
|
||||
|
||||
renderCategory({
|
||||
tooltip: { trigger: 'axis' },
|
||||
grid: { left: 50, right: 20, top: 20, bottom: 40 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: data.byCategory.map((c) => c.categoryName),
|
||||
axisLabel: { rotate: 20 },
|
||||
},
|
||||
yAxis: { type: 'value', name: '条' },
|
||||
series: [
|
||||
{
|
||||
type: 'bar',
|
||||
data: data.byCategory.map((c) => c.count),
|
||||
itemStyle: { color: '#5B8FF9' },
|
||||
barMaxWidth: 44,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
renderRegion({
|
||||
tooltip: { trigger: 'axis' },
|
||||
grid: { left: 40, right: 20, top: 20, bottom: 40 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: data.byRegion.map((r) => r.region),
|
||||
axisLabel: { rotate: 30 },
|
||||
},
|
||||
yAxis: { type: 'value', name: '条' },
|
||||
series: [
|
||||
{
|
||||
type: 'bar',
|
||||
data: data.byRegion.map((r) => r.count),
|
||||
itemStyle: { color: '#5DCAA5' },
|
||||
barMaxWidth: 36,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
renderTrend({
|
||||
tooltip: { trigger: 'axis' },
|
||||
grid: { left: 40, right: 20, top: 30, bottom: 40 },
|
||||
xAxis: { type: 'category', data: data.recentTrend.map((t) => t.date) },
|
||||
yAxis: { type: 'value', name: '条' },
|
||||
series: [
|
||||
{
|
||||
type: 'line',
|
||||
data: data.recentTrend.map((t) => t.count),
|
||||
itemStyle: { color: '#EF9F27' },
|
||||
smooth: true,
|
||||
areaStyle: { opacity: 0.12 },
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<div class="mb-4 flex items-center gap-2">
|
||||
<t-select
|
||||
v-model="region"
|
||||
clearable
|
||||
:options="REGION_OPTIONS"
|
||||
placeholder="全部地区"
|
||||
style="width: 150px"
|
||||
@change="load"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mb-4 grid grid-cols-3 gap-3 md:grid-cols-3">
|
||||
<div class="rounded-lg border p-4">
|
||||
<p class="text-xs opacity-70">公告总数</p>
|
||||
<p class="mt-1 text-2xl font-medium">{{ stats.total }}</p>
|
||||
</div>
|
||||
<div class="rounded-lg border p-4">
|
||||
<p class="text-xs opacity-70">今日新增</p>
|
||||
<p class="mt-1 text-2xl font-medium text-[#5B8FF9]">
|
||||
{{ stats.todayNew }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-lg border p-4">
|
||||
<p class="text-xs opacity-70">本周新增</p>
|
||||
<p class="mt-1 text-2xl font-medium text-[#5DCAA5]">
|
||||
{{ stats.weekNew }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<div class="rounded-lg border p-4">
|
||||
<h3 class="mb-2 text-sm font-medium">分类分布</h3>
|
||||
<div style="height: 300px">
|
||||
<EchartsUI ref="categoryChartRef" style="height: 100%" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-lg border p-4">
|
||||
<h3 class="mb-2 text-sm font-medium">地区分布</h3>
|
||||
<div style="height: 300px">
|
||||
<EchartsUI ref="regionChartRef" style="height: 100%" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 rounded-lg border p-4">
|
||||
<h3 class="mb-2 text-sm font-medium">近 30 天发布趋势</h3>
|
||||
<div style="height: 320px">
|
||||
<EchartsUI ref="trendChartRef" style="height: 100%" />
|
||||
</div>
|
||||
</div>
|
||||
</Page>
|
||||
</template>
|
||||
256
apps/web-tdesign/src/views/recruitment/info/index.vue
Normal file
256
apps/web-tdesign/src/views/recruitment/info/index.vue
Normal file
@ -0,0 +1,256 @@
|
||||
<script lang="ts" setup>
|
||||
// 招聘公告列表:多维筛选 + 表格 + 详情抽屉。
|
||||
import type { RecruitmentItem } from '#/api/recruitment';
|
||||
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
|
||||
import { MessagePlugin } from 'tdesign-vue-next';
|
||||
|
||||
import {
|
||||
CATEGORY_OPTIONS,
|
||||
getRecruitmentDetail,
|
||||
getRecruitmentList,
|
||||
REGION_OPTIONS,
|
||||
STATUS_OPTIONS,
|
||||
} from '#/api/recruitment';
|
||||
|
||||
const loading = ref(false);
|
||||
const list = ref<RecruitmentItem[]>([]);
|
||||
const pagination = reactive({ current: 1, pageSize: 10, total: 0 });
|
||||
|
||||
// 详情抽屉
|
||||
const detailOpen = ref(false);
|
||||
const detailLoading = ref(false);
|
||||
const detail = ref<null | RecruitmentItem>(null);
|
||||
|
||||
const query = reactive({
|
||||
page: 1,
|
||||
size: 10,
|
||||
region: '',
|
||||
category: 0,
|
||||
keyword: '',
|
||||
status: -1, // -1=全部
|
||||
dateFrom: '',
|
||||
dateTo: '',
|
||||
orgName: '',
|
||||
sourceId: 0,
|
||||
onlyNewToday: false,
|
||||
});
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await getRecruitmentList({
|
||||
page: query.page,
|
||||
size: query.size,
|
||||
region: query.region || undefined,
|
||||
category: query.category || undefined,
|
||||
keyword: query.keyword || undefined,
|
||||
status: query.status < 0 ? undefined : query.status,
|
||||
dateFrom: query.dateFrom || undefined,
|
||||
dateTo: query.dateTo || undefined,
|
||||
orgName: query.orgName || undefined,
|
||||
sourceId: query.sourceId || undefined,
|
||||
onlyNewToday: query.onlyNewToday || undefined,
|
||||
});
|
||||
list.value = data.list ?? [];
|
||||
pagination.total = data.total ?? 0;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
Object.assign(query, {
|
||||
region: '',
|
||||
category: 0,
|
||||
keyword: '',
|
||||
status: -1,
|
||||
dateFrom: '',
|
||||
dateTo: '',
|
||||
orgName: '',
|
||||
sourceId: 0,
|
||||
onlyNewToday: false,
|
||||
});
|
||||
query.page = 1;
|
||||
pagination.current = 1;
|
||||
load();
|
||||
}
|
||||
|
||||
function onPageChange(p: { current: number; pageSize: number }) {
|
||||
query.page = p.current;
|
||||
query.size = p.pageSize;
|
||||
pagination.current = p.current;
|
||||
pagination.pageSize = p.pageSize;
|
||||
load();
|
||||
}
|
||||
|
||||
async function openDetail(row: RecruitmentItem) {
|
||||
detailOpen.value = true;
|
||||
detailLoading.value = true;
|
||||
detail.value = null;
|
||||
try {
|
||||
const data = await getRecruitmentDetail({ id: row.id });
|
||||
detail.value = data.info;
|
||||
} finally {
|
||||
detailLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ colKey: 'id', title: 'ID', width: 70 },
|
||||
{ colKey: 'title', title: '标题', minWidth: 260, ellipsis: true },
|
||||
{ colKey: 'categoryName', title: '分类', width: 90 },
|
||||
{ colKey: 'orgName', title: '发布单位', width: 180, ellipsis: true },
|
||||
{ colKey: 'region', title: '地区', width: 90 },
|
||||
{ colKey: 'publishDate', title: '发布日期', width: 110 },
|
||||
{ colKey: 'deadline', title: '截止', width: 110 },
|
||||
{ colKey: 'statusName', title: '状态', width: 90 },
|
||||
{ colKey: 'op', title: '操作', width: 90, fixed: 'right' },
|
||||
];
|
||||
|
||||
function copyUrl() {
|
||||
if (detail.value?.url) {
|
||||
navigator.clipboard?.writeText(detail.value.url);
|
||||
MessagePlugin.success('链接已复制');
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<div class="mb-3 flex flex-wrap items-center gap-2">
|
||||
<t-input
|
||||
v-model="query.keyword"
|
||||
clearable
|
||||
placeholder="标题/正文/单位关键字"
|
||||
style="width: 200px"
|
||||
@enter="load"
|
||||
/>
|
||||
<t-select
|
||||
v-model="query.region"
|
||||
clearable
|
||||
:options="REGION_OPTIONS"
|
||||
placeholder="地区"
|
||||
style="width: 120px"
|
||||
/>
|
||||
<t-select
|
||||
v-model="query.category"
|
||||
:options="CATEGORY_OPTIONS"
|
||||
placeholder="分类"
|
||||
style="width: 120px"
|
||||
/>
|
||||
<t-select
|
||||
v-model="query.status"
|
||||
:options="STATUS_OPTIONS"
|
||||
style="width: 110px"
|
||||
/>
|
||||
<t-date-picker
|
||||
v-model="query.dateFrom"
|
||||
clearable
|
||||
placeholder="发布起"
|
||||
style="width: 140px"
|
||||
value-type="YYYY-MM-DD"
|
||||
/>
|
||||
<t-date-picker
|
||||
v-model="query.dateTo"
|
||||
clearable
|
||||
placeholder="发布止"
|
||||
style="width: 140px"
|
||||
value-type="YYYY-MM-DD"
|
||||
/>
|
||||
<t-input
|
||||
v-model="query.orgName"
|
||||
clearable
|
||||
placeholder="发布主体模糊"
|
||||
style="width: 150px"
|
||||
@enter="load"
|
||||
/>
|
||||
<t-checkbox v-model="query.onlyNewToday">仅今日新增</t-checkbox>
|
||||
<t-button theme="primary" @click="load">查询</t-button>
|
||||
<t-button variant="outline" @click="reset">重置</t-button>
|
||||
</div>
|
||||
|
||||
<t-table
|
||||
row-key="id"
|
||||
:data="list"
|
||||
:columns="columns"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
@page-change="onPageChange"
|
||||
>
|
||||
<template #statusName="{ row }">
|
||||
<t-tag
|
||||
:theme="
|
||||
row.status === 0
|
||||
? 'success'
|
||||
: row.status === 1
|
||||
? 'warning'
|
||||
: 'default'
|
||||
"
|
||||
variant="light"
|
||||
size="small"
|
||||
>
|
||||
{{ row.statusName }}
|
||||
</t-tag>
|
||||
</template>
|
||||
<template #op="{ row }">
|
||||
<t-button
|
||||
size="small"
|
||||
theme="primary"
|
||||
variant="text"
|
||||
@click="openDetail(row)"
|
||||
>
|
||||
详情
|
||||
</t-button>
|
||||
</template>
|
||||
</t-table>
|
||||
|
||||
<t-drawer
|
||||
v-model:visible="detailOpen"
|
||||
:header="detail?.title || '公告详情'"
|
||||
size="640px"
|
||||
:footer="false"
|
||||
>
|
||||
<div v-if="detailLoading" class="p-6 text-center opacity-60">加载中…</div>
|
||||
<div v-else-if="detail">
|
||||
<div class="mb-3 flex flex-wrap gap-2 text-xs">
|
||||
<t-tag theme="primary" variant="light">
|
||||
{{
|
||||
detail.categoryName
|
||||
}}
|
||||
</t-tag>
|
||||
<t-tag theme="success" variant="light">{{ detail.statusName }}</t-tag>
|
||||
<span class="opacity-60">{{ detail.region }}</span>
|
||||
<span class="opacity-60">发布:{{ detail.publishDate }}</span>
|
||||
<span v-if="detail.deadline" class="opacity-60">截止:{{ detail.deadline }}</span>
|
||||
<span v-if="detail.examDate" class="opacity-60">考试:{{ detail.examDate }}</span>
|
||||
</div>
|
||||
<p class="mb-2 text-sm opacity-70">
|
||||
发布单位:{{ detail.orgName }}(来源:{{ detail.sourceName }})
|
||||
</p>
|
||||
<div
|
||||
v-if="detail.content"
|
||||
class="max-h-[50vh] overflow-auto whitespace-pre-wrap rounded border p-3 text-sm leading-relaxed"
|
||||
>
|
||||
{{ detail.content }}
|
||||
</div>
|
||||
<div v-if="detail.attachments" class="mt-3 text-xs opacity-70">
|
||||
附件:{{ detail.attachments }}
|
||||
</div>
|
||||
<t-button
|
||||
v-if="detail.url"
|
||||
class="mt-3"
|
||||
variant="outline"
|
||||
@click="copyUrl"
|
||||
>
|
||||
复制原文链接
|
||||
</t-button>
|
||||
</div>
|
||||
</t-drawer>
|
||||
</Page>
|
||||
</template>
|
||||
268
apps/web-tdesign/src/views/recruitment/push/index.vue
Normal file
268
apps/web-tdesign/src/views/recruitment/push/index.vue
Normal file
@ -0,0 +1,268 @@
|
||||
<script lang="ts" setup>
|
||||
// 推送订阅管理:订阅列表 + 新增/编辑 + 删除 + Bark 推送测试。
|
||||
import type { SubscriptionItem } from '#/api/recruitment';
|
||||
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
|
||||
import { useAccess } from '@vben/access';
|
||||
import { Page } from '@vben/common-ui';
|
||||
|
||||
import { DialogPlugin, MessagePlugin } from 'tdesign-vue-next';
|
||||
|
||||
import {
|
||||
CATEGORY_OPTIONS,
|
||||
deleteSubscription,
|
||||
getSubscriptionList,
|
||||
REGION_OPTIONS,
|
||||
saveSubscription,
|
||||
testPush,
|
||||
} from '#/api/recruitment';
|
||||
|
||||
// 分类 code 列表转中文名(模板内不支持类型注解,抽成方法)。
|
||||
function categoryLabels(cats: number[] | undefined): string {
|
||||
if (!cats || cats.length === 0) return '全部';
|
||||
return cats
|
||||
.map((c) => CATEGORY_OPTIONS.find((o) => o.value === c)?.label)
|
||||
.filter(Boolean)
|
||||
.join('、');
|
||||
}
|
||||
|
||||
const { hasAccessByCodes } = useAccess();
|
||||
|
||||
const loading = ref(false);
|
||||
const list = ref<SubscriptionItem[]>([]);
|
||||
|
||||
// 编辑弹窗
|
||||
const dialogOpen = ref(false);
|
||||
const submitting = ref(false);
|
||||
const form = reactive({
|
||||
id: 0,
|
||||
name: '',
|
||||
deviceKey: '',
|
||||
regions: [] as string[],
|
||||
categories: [] as number[],
|
||||
onlyNew: 1,
|
||||
pushTime: '08:00',
|
||||
enabled: 1,
|
||||
});
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await getSubscriptionList();
|
||||
list.value = data.list ?? [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
Object.assign(form, {
|
||||
id: 0,
|
||||
name: '',
|
||||
deviceKey: '',
|
||||
regions: [],
|
||||
categories: [],
|
||||
onlyNew: 1,
|
||||
pushTime: '08:00',
|
||||
enabled: 1,
|
||||
});
|
||||
dialogOpen.value = true;
|
||||
}
|
||||
|
||||
function openEdit(row: SubscriptionItem) {
|
||||
Object.assign(form, {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
deviceKey: row.deviceKey,
|
||||
regions: [...(row.regions ?? [])],
|
||||
categories: [...(row.categories ?? [])],
|
||||
onlyNew: row.onlyNew,
|
||||
pushTime: row.pushTime || '08:00',
|
||||
enabled: row.enabled,
|
||||
});
|
||||
dialogOpen.value = true;
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!form.name || !form.deviceKey) {
|
||||
MessagePlugin.warning('请填写名称与设备 Key');
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
try {
|
||||
await saveSubscription({
|
||||
id: form.id || undefined,
|
||||
name: form.name,
|
||||
deviceKey: form.deviceKey,
|
||||
regions: form.regions,
|
||||
categories: form.categories,
|
||||
onlyNew: form.onlyNew,
|
||||
pushTime: form.pushTime,
|
||||
enabled: form.enabled,
|
||||
});
|
||||
MessagePlugin.success(form.id ? '更新成功' : '新增成功');
|
||||
dialogOpen.value = false;
|
||||
await load();
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onDelete(row: SubscriptionItem) {
|
||||
const d = DialogPlugin.confirm({
|
||||
header: '删除该订阅?',
|
||||
onConfirm: async () => {
|
||||
await deleteSubscription({ id: row.id });
|
||||
MessagePlugin.success('已删除');
|
||||
await load();
|
||||
d.destroy();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function onTest(row: SubscriptionItem) {
|
||||
const data = await testPush({
|
||||
subscriptionId: row.id,
|
||||
title: '招聘聚合 · 推送测试',
|
||||
body: '这是一条来自 admin 后台的 Bark 推送测试消息。',
|
||||
});
|
||||
if (data.result) {
|
||||
MessagePlugin.success(data.message || '推送成功');
|
||||
} else {
|
||||
MessagePlugin.error(data.message || '推送失败');
|
||||
}
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ colKey: 'id', title: 'ID', width: 70 },
|
||||
{ colKey: 'name', title: '名称', width: 140 },
|
||||
{ colKey: 'deviceKey', title: '设备 Key', width: 200, ellipsis: true },
|
||||
{ colKey: 'regionsText', title: '地区', width: 160, ellipsis: true },
|
||||
{ colKey: 'categoriesText', title: '分类', width: 160, ellipsis: true },
|
||||
{ colKey: 'onlyNew', title: '仅新公告', width: 90 },
|
||||
{ colKey: 'pushTime', title: '推送时间', width: 90 },
|
||||
{ colKey: 'enabled', title: '启用', width: 80 },
|
||||
{ colKey: 'op', title: '操作', width: 200, fixed: 'right' },
|
||||
];
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<div class="mb-3 flex items-center gap-2">
|
||||
<t-button
|
||||
v-if="hasAccessByCodes(['recruitment:push:save'])"
|
||||
theme="primary"
|
||||
@click="openCreate"
|
||||
>
|
||||
新增订阅
|
||||
</t-button>
|
||||
</div>
|
||||
|
||||
<t-table row-key="id" :data="list" :columns="columns" :loading="loading">
|
||||
<template #regionsText="{ row }">
|
||||
{{ (row.regions ?? []).join('、') || '全部' }}
|
||||
</template>
|
||||
<template #categoriesText="{ row }">
|
||||
{{ categoryLabels(row.categories) }}
|
||||
</template>
|
||||
<template #onlyNew="{ row }">
|
||||
<t-tag
|
||||
:theme="row.onlyNew === 1 ? 'success' : 'default'"
|
||||
variant="light"
|
||||
size="small"
|
||||
>
|
||||
{{ row.onlyNew === 1 ? '是' : '否' }}
|
||||
</t-tag>
|
||||
</template>
|
||||
<template #enabled="{ row }">
|
||||
<t-tag
|
||||
:theme="row.enabled === 1 ? 'success' : 'default'"
|
||||
variant="light"
|
||||
size="small"
|
||||
>
|
||||
{{ row.enabled === 1 ? '启用' : '停用' }}
|
||||
</t-tag>
|
||||
</template>
|
||||
<template #op="{ row }">
|
||||
<t-button
|
||||
v-if="hasAccessByCodes(['recruitment:push:save'])"
|
||||
size="small"
|
||||
theme="primary"
|
||||
variant="text"
|
||||
@click="openEdit(row)"
|
||||
>
|
||||
编辑
|
||||
</t-button>
|
||||
<t-button
|
||||
v-if="hasAccessByCodes(['recruitment:push:test'])"
|
||||
size="small"
|
||||
theme="default"
|
||||
variant="text"
|
||||
@click="onTest(row)"
|
||||
>
|
||||
测试
|
||||
</t-button>
|
||||
<t-button
|
||||
v-if="hasAccessByCodes(['recruitment:push:delete'])"
|
||||
size="small"
|
||||
theme="danger"
|
||||
variant="text"
|
||||
@click="onDelete(row)"
|
||||
>
|
||||
删除
|
||||
</t-button>
|
||||
</template>
|
||||
</t-table>
|
||||
|
||||
<t-dialog
|
||||
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-item label="名称">
|
||||
<t-input v-model="form.name" placeholder="如:每日贵阳公务员" />
|
||||
</t-form-item>
|
||||
<t-form-item label="设备 Key">
|
||||
<t-input v-model="form.deviceKey" placeholder="Bark 设备 Key" />
|
||||
</t-form-item>
|
||||
<t-form-item label="地区">
|
||||
<t-select
|
||||
v-model="form.regions"
|
||||
:options="REGION_OPTIONS"
|
||||
multiple
|
||||
clearable
|
||||
placeholder="空 = 全部地区"
|
||||
/>
|
||||
</t-form-item>
|
||||
<t-form-item label="分类">
|
||||
<t-select
|
||||
v-model="form.categories"
|
||||
:options="CATEGORY_OPTIONS"
|
||||
multiple
|
||||
clearable
|
||||
placeholder="空 = 全部分类"
|
||||
/>
|
||||
</t-form-item>
|
||||
<t-form-item label="仅新公告">
|
||||
<t-switch v-model="form.onlyNew" :custom-value="[1, 0]" />
|
||||
</t-form-item>
|
||||
<t-form-item label="推送时间">
|
||||
<t-time-picker
|
||||
v-model="form.pushTime"
|
||||
format="HH:mm"
|
||||
value-type="HH:mm"
|
||||
/>
|
||||
</t-form-item>
|
||||
<t-form-item label="启用">
|
||||
<t-switch v-model="form.enabled" :custom-value="[1, 0]" />
|
||||
</t-form-item>
|
||||
</t-form>
|
||||
</t-dialog>
|
||||
</Page>
|
||||
</template>
|
||||
@ -23,7 +23,7 @@ const loading = ref(false);
|
||||
const list = ref<AdminItem[]>([]);
|
||||
const roleOptions = ref<{ label: string; value: number }[]>([]);
|
||||
const dialogOpen = ref(false);
|
||||
const editing = ref<null | AdminItem>(null);
|
||||
const editing = ref<AdminItem | null>(null);
|
||||
|
||||
const query = reactive({ page: 1, size: 10, keyword: '' });
|
||||
const pagination = reactive({ current: 1, pageSize: 10, total: 0 });
|
||||
@ -56,7 +56,13 @@ async function loadRoles() {
|
||||
|
||||
function openCreate() {
|
||||
editing.value = null;
|
||||
Object.assign(form, { username: '', password: '', nickname: '', status: 1, roleIds: [] });
|
||||
Object.assign(form, {
|
||||
username: '',
|
||||
password: '',
|
||||
nickname: '',
|
||||
status: 1,
|
||||
roleIds: [],
|
||||
});
|
||||
dialogOpen.value = true;
|
||||
}
|
||||
|
||||
@ -147,6 +153,12 @@ onMounted(() => {
|
||||
|
||||
<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="mb-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<t-input
|
||||
@ -166,7 +178,9 @@ onMounted(() => {
|
||||
新增人员
|
||||
</t-button>
|
||||
</div>
|
||||
</t-card>
|
||||
|
||||
<t-card :bordered="false" class="wb-table-card">
|
||||
<t-table
|
||||
row-key="id"
|
||||
:data="list"
|
||||
@ -175,8 +189,16 @@ onMounted(() => {
|
||||
:pagination="pagination"
|
||||
@page-change="onPageChange"
|
||||
>
|
||||
<template #statusText="{ row }">{{ row.status === 1 ? '启用' : '禁用' }}</template>
|
||||
<template #roleNamesText="{ row }">{{ (row.roleNames ?? []).join(', ') }}</template>
|
||||
<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'])"
|
||||
@ -207,6 +229,7 @@ onMounted(() => {
|
||||
</t-button>
|
||||
</template>
|
||||
</t-table>
|
||||
</t-card>
|
||||
|
||||
<t-dialog
|
||||
v-model:visible="dialogOpen"
|
||||
@ -215,10 +238,18 @@ onMounted(() => {
|
||||
>
|
||||
<t-form label-align="right" label-width="80px">
|
||||
<t-form-item label="用户名" required>
|
||||
<t-input v-model="form.username" :disabled="!!editing" placeholder="登录用户名" />
|
||||
<t-input
|
||||
v-model="form.username"
|
||||
:disabled="!!editing"
|
||||
placeholder="登录用户名"
|
||||
/>
|
||||
</t-form-item>
|
||||
<t-form-item v-if="!editing" label="密码" required>
|
||||
<t-input v-model="form.password" type="password" placeholder="至少 6 位" />
|
||||
<t-input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
placeholder="至少 6 位"
|
||||
/>
|
||||
</t-form-item>
|
||||
<t-form-item label="昵称">
|
||||
<t-input v-model="form.nickname" placeholder="显示名称" />
|
||||
@ -227,7 +258,12 @@ onMounted(() => {
|
||||
<t-switch v-model="form.status" :custom-value="[1, 0]" />
|
||||
</t-form-item>
|
||||
<t-form-item label="角色">
|
||||
<t-select v-model="form.roleIds" multiple :options="roleOptions" placeholder="选择角色" />
|
||||
<t-select
|
||||
v-model="form.roleIds"
|
||||
multiple
|
||||
:options="roleOptions"
|
||||
placeholder="选择角色"
|
||||
/>
|
||||
</t-form-item>
|
||||
</t-form>
|
||||
</t-dialog>
|
||||
|
||||
@ -1,138 +1,312 @@
|
||||
<script lang="ts" setup>
|
||||
import type { LogItem } from '#/api/system';
|
||||
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import { h, reactive, ref } from 'vue';
|
||||
|
||||
import { useAccess } from '@vben/access';
|
||||
import { Page } from '@vben/common-ui';
|
||||
|
||||
import { MessagePlugin } from 'tdesign-vue-next';
|
||||
import { MessagePlugin, Tag } from 'tdesign-vue-next';
|
||||
|
||||
import { useVbenVxeGrid, type VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import { getSystemLogList } from '#/api/system';
|
||||
|
||||
// 操作日志页面:常规分页 + 关键字筛选 + 列表展示
|
||||
// 操作日志页面:Vben Vxe Table 分页列表 + 多维筛选(筛选区保留 TDesign 组件,行为与原版一致)
|
||||
const { hasAccessByCodes } = useAccess();
|
||||
|
||||
const loading = ref(false);
|
||||
const list = ref<LogItem[]>([]);
|
||||
// 筛选条件
|
||||
const query = reactive({
|
||||
username: '', // 账号模糊
|
||||
ip: '', // 来源 IP 模糊
|
||||
ipLocation: '', // IP 归属地模糊
|
||||
keyword: '', // 权限码/路径/IP 关键字
|
||||
method: '', // HTTP 方法(空=全部)
|
||||
status: 0, // 0 全部 / 1 成功 / 2 失败
|
||||
minDuration: 0, // 耗时下限(ms)
|
||||
maxDuration: 0, // 耗时上限(ms)
|
||||
});
|
||||
// 时间范围单独维护(日期选择器返回 YYYY-MM-DD)
|
||||
const timeRange = ref<string[]>([]);
|
||||
// 排序(前端维护,映射到后端 orderBy/orderDir)
|
||||
const sortKey = ref('time_desc');
|
||||
|
||||
// 查询条件:keyword 匹配权限码/路径/IP
|
||||
const query = reactive({ page: 1, size: 10, keyword: '' });
|
||||
// t-table 分页状态
|
||||
const pagination = reactive({ current: 1, pageSize: 10, total: 0 });
|
||||
// 排序映射:前端 sortKey -> 后端 orderBy/orderDir
|
||||
const SORT_OPTIONS = [
|
||||
{ label: '操作时间 ↓ 新→旧', value: 'time_desc' },
|
||||
{ label: '操作时间 ↑ 旧→新', value: 'time_asc' },
|
||||
{ label: '耗时 ↓ 长→短', value: 'dur_desc' },
|
||||
{ label: '耗时 ↑ 短→长', value: 'dur_asc' },
|
||||
];
|
||||
const sortMap: Record<string, { orderBy: string; orderDir: string }> = {
|
||||
time_desc: { orderBy: 'createdAt', orderDir: 'desc' },
|
||||
time_asc: { orderBy: 'createdAt', orderDir: 'asc' },
|
||||
dur_desc: { orderBy: 'durationMs', orderDir: 'desc' },
|
||||
dur_asc: { orderBy: 'durationMs', orderDir: 'asc' },
|
||||
};
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await getSystemLogList(query);
|
||||
list.value = data.list ?? [];
|
||||
pagination.total = data.total ?? 0;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
// HTTP 方法可选项
|
||||
const METHOD_OPTIONS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'];
|
||||
|
||||
const gridOptions: VxeTableGridOptions = {
|
||||
columns: [
|
||||
{ type: 'seq', width: 50, title: '#' },
|
||||
{ field: 'id', title: 'ID', width: 70 },
|
||||
{ field: 'adminUsername', title: '操作账号', width: 120 },
|
||||
{ field: 'permission', title: '权限标识', width: 160 },
|
||||
{
|
||||
field: 'method',
|
||||
title: '方法',
|
||||
width: 80,
|
||||
slots: {
|
||||
default: ({ row }: { row: LogItem }) =>
|
||||
h(
|
||||
Tag,
|
||||
{
|
||||
theme:
|
||||
row.method === 'POST'
|
||||
? 'primary'
|
||||
: row.method === 'GET'
|
||||
? 'success'
|
||||
: row.method === 'PUT'
|
||||
? 'warning'
|
||||
: 'danger',
|
||||
variant: 'light',
|
||||
size: 'small',
|
||||
},
|
||||
row.method,
|
||||
),
|
||||
},
|
||||
},
|
||||
{ field: 'path', title: '请求路径', width: 240, showOverflow: 'tooltip' },
|
||||
{ field: 'ip', title: '来源 IP', width: 130 },
|
||||
{ field: 'ipLocation', title: 'IP 归属地', width: 170 },
|
||||
{
|
||||
field: 'durationMs',
|
||||
title: '耗时(ms)',
|
||||
width: 100,
|
||||
slots: {
|
||||
default: ({ row }: { row: LogItem }) =>
|
||||
h(
|
||||
'span',
|
||||
{
|
||||
class: row.durationMs >= 1000 ? 'text-red-500' : 'text-gray-600',
|
||||
},
|
||||
String(row.durationMs),
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'statusCode',
|
||||
title: '状态码',
|
||||
width: 90,
|
||||
slots: {
|
||||
default: ({ row }: { row: LogItem }) =>
|
||||
h(
|
||||
Tag,
|
||||
{
|
||||
theme: row.statusCode < 400 ? 'success' : 'danger',
|
||||
variant: 'light',
|
||||
size: 'small',
|
||||
},
|
||||
String(row.statusCode),
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'errorMessage',
|
||||
title: '错误信息',
|
||||
width: 200,
|
||||
showOverflow: 'tooltip',
|
||||
slots: {
|
||||
default: ({ row }: { row: LogItem }) =>
|
||||
h(
|
||||
'span',
|
||||
{
|
||||
class: row.errorMessage ? 'text-red-500' : 'text-gray-400',
|
||||
},
|
||||
row.errorMessage || '-',
|
||||
),
|
||||
},
|
||||
},
|
||||
{ field: 'userAgent', title: 'UA', width: 220, showOverflow: 'tooltip' },
|
||||
{
|
||||
field: 'param',
|
||||
title: '参数',
|
||||
width: 180,
|
||||
showOverflow: 'tooltip',
|
||||
slots: {
|
||||
default: ({ row }: { row: LogItem }) =>
|
||||
h(
|
||||
'span',
|
||||
{
|
||||
class: 'cursor-pointer text-gray-500',
|
||||
onClick: () => copyParam(row),
|
||||
},
|
||||
(row.param ?? '').slice(0, 30) || '-',
|
||||
),
|
||||
},
|
||||
},
|
||||
{ field: 'createdAt', title: '操作时间', width: 170 },
|
||||
],
|
||||
pagerConfig: { pageSize: 10 },
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({
|
||||
page,
|
||||
}: {
|
||||
page: { currentPage: number; pageSize: number };
|
||||
}) => {
|
||||
const sort = sortMap[sortKey.value] ?? {
|
||||
orderBy: 'createdAt',
|
||||
orderDir: 'desc',
|
||||
};
|
||||
const res = await getSystemLogList({
|
||||
page: page.currentPage,
|
||||
size: page.pageSize,
|
||||
username: query.username || undefined,
|
||||
ip: query.ip || undefined,
|
||||
ipLocation: query.ipLocation || undefined,
|
||||
keyword: query.keyword || undefined,
|
||||
method: query.method || undefined,
|
||||
status: query.status,
|
||||
startTime: timeRange.value?.[0]
|
||||
? `${timeRange.value[0]} 00:00:00`
|
||||
: undefined,
|
||||
endTime: timeRange.value?.[1]
|
||||
? `${timeRange.value[1]} 23:59:59`
|
||||
: undefined,
|
||||
minDuration: query.minDuration || undefined,
|
||||
maxDuration: query.maxDuration || undefined,
|
||||
orderBy: sort.orderBy,
|
||||
orderDir: sort.orderDir,
|
||||
});
|
||||
return { items: res.list, total: res.total };
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({ gridOptions });
|
||||
|
||||
/** 触发查询(保留分页条件,因 grid 自动管理分页) */
|
||||
function search() {
|
||||
gridApi.query();
|
||||
}
|
||||
|
||||
/** 重置筛选并回到第一页 */
|
||||
/** 重置筛选并回到第一页重新查询 */
|
||||
function reset() {
|
||||
query.username = '';
|
||||
query.ip = '';
|
||||
query.ipLocation = '';
|
||||
query.keyword = '';
|
||||
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();
|
||||
query.method = '';
|
||||
query.status = 0;
|
||||
query.minDuration = 0;
|
||||
query.maxDuration = 0;
|
||||
sortKey.value = 'time_desc';
|
||||
timeRange.value = [];
|
||||
gridApi.reload();
|
||||
}
|
||||
|
||||
/** 复制参数内容(调试用) */
|
||||
function onCopyParam(row: LogItem) {
|
||||
function copyParam(row: LogItem) {
|
||||
navigator.clipboard
|
||||
?.writeText(row.param ?? '')
|
||||
.then(() => MessagePlugin.success('参数已复制'));
|
||||
}
|
||||
|
||||
// 表格列定义:colKey 与行字段对应,插槽自定义渲染
|
||||
const columns = [
|
||||
{ colKey: 'id', title: 'ID', width: 70 },
|
||||
{ colKey: 'permission', title: '权限标识', width: 160 },
|
||||
{ colKey: 'method', title: '方法', width: 80 },
|
||||
{ colKey: 'path', title: '请求路径', width: 260 },
|
||||
{ colKey: 'adminId', title: '操作人', width: 80 },
|
||||
{ colKey: 'ip', title: 'IP', width: 130 },
|
||||
{ colKey: 'durationMs', title: '耗时(ms)', width: 90 },
|
||||
{ colKey: 'statusCode', title: '状态码', width: 90 },
|
||||
{ colKey: 'param', title: '参数', width: 180 },
|
||||
{ colKey: 'createdAt', title: '操作时间', width: 170 },
|
||||
];
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<!-- 顶部筛选区:关键字 + 查询 / 重置 -->
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- 顶部筛选区:时间 / 账号 / IP / 归属地 / 方法 / 结果 / 耗时 / 排序 -->
|
||||
<div class="mb-3 flex flex-wrap items-center gap-2">
|
||||
<t-date-range-picker
|
||||
v-model="timeRange"
|
||||
value-type="YYYY-MM-DD"
|
||||
clearable
|
||||
separator="~"
|
||||
:placeholder="['操作开始日期', '操作结束日期']"
|
||||
style="width: 280px"
|
||||
/>
|
||||
<t-input
|
||||
v-model="query.username"
|
||||
clearable
|
||||
placeholder="操作账号"
|
||||
style="width: 150px"
|
||||
@enter="search"
|
||||
/>
|
||||
<t-input
|
||||
v-model="query.keyword"
|
||||
clearable
|
||||
placeholder="权限标识 / 路径 / IP"
|
||||
style="width: 260px"
|
||||
@enter="load"
|
||||
style="width: 220px"
|
||||
@enter="search"
|
||||
/>
|
||||
<t-input
|
||||
v-model="query.ip"
|
||||
clearable
|
||||
placeholder="来源 IP"
|
||||
style="width: 150px"
|
||||
@enter="search"
|
||||
/>
|
||||
<t-input
|
||||
v-model="query.ipLocation"
|
||||
clearable
|
||||
placeholder="IP 归属地"
|
||||
style="width: 160px"
|
||||
@enter="search"
|
||||
/>
|
||||
<t-select
|
||||
v-model="query.method"
|
||||
clearable
|
||||
placeholder="方法"
|
||||
filterable
|
||||
style="width: 120px"
|
||||
:options="METHOD_OPTIONS.map((m) => ({ label: m, value: m }))"
|
||||
/>
|
||||
<t-select
|
||||
v-model="query.status"
|
||||
style="width: 120px"
|
||||
:options="[
|
||||
{ label: '全部结果', value: 0 },
|
||||
{ label: '成功(2xx)', value: 1 },
|
||||
{ label: '失败(非2xx)', value: 2 },
|
||||
]"
|
||||
/>
|
||||
<div class="flex items-center gap-1">
|
||||
<t-input-number
|
||||
v-model="query.minDuration"
|
||||
:min="0"
|
||||
theme="column"
|
||||
placeholder="耗时≥"
|
||||
style="width: 90px"
|
||||
/>
|
||||
<span class="text-gray-400">~</span>
|
||||
<t-input-number
|
||||
v-model="query.maxDuration"
|
||||
:min="0"
|
||||
theme="column"
|
||||
placeholder="耗时≤"
|
||||
style="width: 90px"
|
||||
/>
|
||||
<span class="text-gray-400">ms</span>
|
||||
</div>
|
||||
<t-select
|
||||
v-model="sortKey"
|
||||
style="width: 160px"
|
||||
:options="SORT_OPTIONS"
|
||||
/>
|
||||
<t-button
|
||||
v-if="hasAccessByCodes(['system:log:list'])"
|
||||
theme="primary"
|
||||
@click="load"
|
||||
@click="search"
|
||||
>
|
||||
查询
|
||||
</t-button>
|
||||
<t-button variant="outline" @click="reset">重置</t-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作日志列表 -->
|
||||
<t-table
|
||||
row-key="id"
|
||||
:data="list"
|
||||
:columns="columns"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
@page-change="onPageChange"
|
||||
>
|
||||
<template #method="{ row }">
|
||||
<t-tag
|
||||
:theme="row.method === 'POST' ? 'primary' : 'success'"
|
||||
variant="light"
|
||||
size="small"
|
||||
>
|
||||
{{ row.method }}
|
||||
</t-tag>
|
||||
</template>
|
||||
<template #statusCode="{ row }">
|
||||
<t-tag
|
||||
:theme="row.statusCode < 400 ? 'success' : 'danger'"
|
||||
variant="light"
|
||||
size="small"
|
||||
>
|
||||
{{ row.statusCode }}
|
||||
</t-tag>
|
||||
</template>
|
||||
<template #param="{ row }">
|
||||
<t-tooltip :content="row.param" placement="top">
|
||||
<span
|
||||
class="cursor-pointer text-gray-500"
|
||||
@click="onCopyParam(row)"
|
||||
>
|
||||
{{ (row.param ?? '').slice(0, 30) || '-' }}
|
||||
</span>
|
||||
</t-tooltip>
|
||||
</template>
|
||||
</t-table>
|
||||
<!-- 操作日志列表(Vben Vxe Table) -->
|
||||
<Grid />
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
@ -68,6 +68,12 @@ onMounted(load);
|
||||
|
||||
<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="mb-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
@ -97,8 +103,10 @@ onMounted(load);
|
||||
<t-button variant="outline" @click="reset">重置</t-button>
|
||||
</div>
|
||||
</div>
|
||||
</t-card>
|
||||
|
||||
<!-- 登录日志列表 -->
|
||||
<t-card :bordered="false" class="wb-table-card">
|
||||
<t-table
|
||||
row-key="id"
|
||||
:data="list"
|
||||
@ -117,8 +125,11 @@ onMounted(load);
|
||||
</t-tag>
|
||||
</template>
|
||||
<template #failReason="{ row }">
|
||||
<span :title="row.failReason">{{ (row.failReason || '-').slice(0, 30) }}</span>
|
||||
<span :title="row.failReason">{{
|
||||
(row.failReason || '-').slice(0, 30)
|
||||
}}</span>
|
||||
</template>
|
||||
</t-table>
|
||||
</t-card>
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
@ -8,12 +8,7 @@ import { Page } from '@vben/common-ui';
|
||||
|
||||
import { DialogPlugin, MessagePlugin } from 'tdesign-vue-next';
|
||||
|
||||
import {
|
||||
createMenu,
|
||||
deleteMenu,
|
||||
getMenuTree,
|
||||
updateMenu,
|
||||
} from '#/api/system';
|
||||
import { createMenu, deleteMenu, getMenuTree, updateMenu } from '#/api/system';
|
||||
|
||||
const { hasAccessByCodes } = useAccess();
|
||||
|
||||
@ -23,7 +18,7 @@ const tree = ref<MenuNode[]>([]);
|
||||
// t-enhanced-table 实例:异步数据加载完成后手动展开全部节点
|
||||
const tableRef = ref<any>(null);
|
||||
const dialogOpen = ref(false);
|
||||
const editing = ref<null | MenuNode>(null);
|
||||
const editing = ref<MenuNode | null>(null);
|
||||
|
||||
const form = reactive({
|
||||
parentId: 0,
|
||||
@ -123,6 +118,12 @@ onMounted(load);
|
||||
|
||||
<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="mb-3 flex justify-end">
|
||||
<t-button
|
||||
v-if="hasAccessByCodes(['system:menu:create'])"
|
||||
@ -132,7 +133,9 @@ onMounted(load);
|
||||
新增菜单
|
||||
</t-button>
|
||||
</div>
|
||||
</t-card>
|
||||
|
||||
<t-card :bordered="false" class="wb-table-card">
|
||||
<t-enhanced-table
|
||||
ref="tableRef"
|
||||
row-key="id"
|
||||
@ -150,7 +153,11 @@ onMounted(load);
|
||||
<template #name="{ row }">
|
||||
{{ row.name }}
|
||||
</template>
|
||||
<template #typeText="{ row }">{{ row.type === 1 ? '菜单' : '按钮' }}</template>
|
||||
<template #typeText="{ row }">
|
||||
{{
|
||||
row.type === 1 ? '菜单' : '按钮'
|
||||
}}
|
||||
</template>
|
||||
<template #op="{ row }">
|
||||
<t-button
|
||||
v-if="hasAccessByCodes(['system:menu:create'])"
|
||||
@ -181,6 +188,7 @@ onMounted(load);
|
||||
</t-button>
|
||||
</template>
|
||||
</t-enhanced-table>
|
||||
</t-card>
|
||||
|
||||
<t-dialog
|
||||
v-model:visible="dialogOpen"
|
||||
@ -201,16 +209,25 @@ onMounted(load);
|
||||
</t-radio-group>
|
||||
</t-form-item>
|
||||
<t-form-item v-if="form.type === 1" label="图标">
|
||||
<t-input v-model="form.icon" placeholder="iconify 名称,如 mdi:home" />
|
||||
<t-input
|
||||
v-model="form.icon"
|
||||
placeholder="iconify 名称,如 mdi:home"
|
||||
/>
|
||||
</t-form-item>
|
||||
<t-form-item label="路径">
|
||||
<t-input v-model="form.path" placeholder="菜单路由或接口映射" />
|
||||
</t-form-item>
|
||||
<t-form-item v-if="form.type === 1" label="组件">
|
||||
<t-input v-model="form.component" placeholder="如 system/admin/index" />
|
||||
<t-input
|
||||
v-model="form.component"
|
||||
placeholder="如 system/admin/index"
|
||||
/>
|
||||
</t-form-item>
|
||||
<t-form-item label="权限标识" required>
|
||||
<t-input v-model="form.permission" placeholder="如 system:admin:list" />
|
||||
<t-input
|
||||
v-model="form.permission"
|
||||
placeholder="如 system:admin:list"
|
||||
/>
|
||||
</t-form-item>
|
||||
<t-form-item label="排序">
|
||||
<t-input-number v-model="form.sort" :min="0" />
|
||||
|
||||
@ -26,7 +26,12 @@ const editing = ref<null | RoleItem>(null);
|
||||
|
||||
const query = reactive({ page: 1, size: 10, keyword: '' });
|
||||
const pagination = reactive({ current: 1, pageSize: 10, total: 0 });
|
||||
const form = reactive({ code: '', name: '', status: 1, menuIds: [] as number[] });
|
||||
const form = reactive({
|
||||
code: '',
|
||||
name: '',
|
||||
status: 1,
|
||||
menuIds: [] as number[],
|
||||
});
|
||||
|
||||
function toTree(nodes: MenuNode[]): any[] {
|
||||
return (nodes ?? []).map((n) => ({
|
||||
@ -126,6 +131,12 @@ onMounted(() => {
|
||||
|
||||
<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="mb-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<t-input
|
||||
@ -145,7 +156,9 @@ onMounted(() => {
|
||||
新增角色
|
||||
</t-button>
|
||||
</div>
|
||||
</t-card>
|
||||
|
||||
<t-card :bordered="false" class="wb-table-card">
|
||||
<t-table
|
||||
row-key="id"
|
||||
:data="list"
|
||||
@ -154,7 +167,11 @@ onMounted(() => {
|
||||
:pagination="pagination"
|
||||
@page-change="onPageChange"
|
||||
>
|
||||
<template #statusText="{ row }">{{ row.status === 1 ? '启用' : '禁用' }}</template>
|
||||
<template #statusText="{ row }">
|
||||
{{
|
||||
row.status === 1 ? '启用' : '禁用'
|
||||
}}
|
||||
</template>
|
||||
<template #op="{ row }">
|
||||
<t-button
|
||||
v-if="hasAccessByCodes(['system:role:update'])"
|
||||
@ -176,6 +193,7 @@ onMounted(() => {
|
||||
</t-button>
|
||||
</template>
|
||||
</t-table>
|
||||
</t-card>
|
||||
|
||||
<t-dialog
|
||||
v-model:visible="dialogOpen"
|
||||
@ -185,7 +203,11 @@ onMounted(() => {
|
||||
>
|
||||
<t-form label-align="right" label-width="80px">
|
||||
<t-form-item label="角色编码" required>
|
||||
<t-input v-model="form.code" :disabled="!!editing" placeholder="如 super_admin" />
|
||||
<t-input
|
||||
v-model="form.code"
|
||||
:disabled="!!editing"
|
||||
placeholder="如 super_admin"
|
||||
/>
|
||||
</t-form-item>
|
||||
<t-form-item label="角色名称" required>
|
||||
<t-input v-model="form.name" placeholder="显示名称" />
|
||||
|
||||
Loading…
Reference in New Issue
Block a user