feat(house): 增加房屋数据管理功能

- 新增全局主题美化样式文件 theme.css 并在 bootstrap.ts 中引入
- 实现工作台页面重构,包含渐变英雄头、统计卡片和快捷入口宫格
- 开发楼盘地图功能,在 dashboard 中集成腾讯地图 GL JS 散点显示
- 添加成交记录列表页面,支持关键词和区域筛选查询
- 新增新房预售许可证管理页面,包含预售证号、楼盘名等信息展示
- 扩展 house API 接口,增加交易记录和预售许可相关数据模型
- 优化数据明细页面,将占位内容替换为实际的成交记录表格
- 添加招聘数据看板和爬虫管理页面,支持数据源管理和抓取触发功能
This commit is contained in:
夏犀麟 2026-08-27 01:48:52 +08:00
parent b02d678dea
commit f02a514e44
21 changed files with 2818 additions and 356 deletions

View File

@ -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 + TransactionItemtypecheck 通过
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.ymlGitea act_runner 自动部署push main/dev → 下载 node22 二进制npmmirror覆盖镜像 node20pnpm11 需 node:sqlite→ corepack pnpm@11.16.0 → pnpm install → 直接 vite build避开 turbo 卡死)→ cp dist 到 /data/www/admin.xpcool.com/YAML 已校验

View File

@ -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: '',

View File

@ -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,
);
}

View File

@ -0,0 +1,214 @@
// 招聘考试聚合模块 API 客户端。
// 接口规范:全部 POSTURL 不含任何参数(查询/路径参数均禁止);入参一律走 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,
);
}

View File

@ -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',

View File

@ -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) {
// 初始化组件适配器

View 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%);
}

View 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[]>([]);
// 线24x24stroke
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>

View File

@ -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">

View 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>

View File

@ -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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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" />

View File

@ -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="显示名称" />