refactor: 全部表格页改用 Vben Vxe Table(useVbenVxeGrid) + Vben TableAction 行操作

11 个 t-table 页面统一重构:system/{log,login-log,admin,role}、
recruitment/{info,crawler,push}、house/{data,listing,community,presale}、dashboard/workbench。
- 本地小表 proxyConfig.enabled=false + setGridOptions 写数据(workbench/crawler/push)
- listing 多选批量标记改 checkboxConfig + getCheckboxRecords
- 行操作走 ActionItem.auth 权限码、删除 danger 标红、确认弹窗保留 DialogPlugin
- 筛选区保留 TDesign 组件;typecheck 与 lint 全绿
This commit is contained in:
夏犀麟 2026-08-27 10:36:33 +08:00
parent 0807bb8514
commit 8b86b73424
12 changed files with 1082 additions and 842 deletions

View File

@ -1,6 +1,8 @@
# admin.xpcool.com 变更记录
> 倒序最新在上。格式YYYY-MM-DD | 类型 | 摘要
2026-08-27 | CHG | 全量表格重构views 下全部 11 个 t-table 页面统一改为 Vben Vxe TableuseVbenVxeGrid#/adapter/vxe-table+ Vben TableAction 行操作——system/{log(范式),login-log,admin,role}、recruitment/{info,crawler,push}、house/{data,listing,community,presale}、dashboard/workbench两处本地小表 proxyConfig.enabled=false + gridApi.setGridOptions 写数据。要点gridOptions 需注解 VxeTableGridOptions否则 type:'seq' 推断 string 报错);单元格自定义用 columns[].slots.default + h(TDesign组件);远程分页走 proxyConfig.ajax.query 返回 {items,total};筛选区保留手写 TDesign 组件不动admin/role/listing/community 行操作 ActionItem 带 auth 权限码、删除 danger 标红listing 多选批量标记改 vxe checkboxConfig + gridApi.grid.getCheckboxRecords()confirm 弹窗交互(删除/重置密码)保留 DialogPlugin 不变typecheck 通过
2026-08-27 | FIX | 恢复丢失的前端代码recovery 分支经 rebase/改名(dev↔main)折腾后工作树回退,未提交改动全丢;从悬空提交 f02a514recovery 工作树快照,含 HEAD 缺失的全部文件)整体恢复 apps/web-tdesign/src——含 styles/theme.css 全局主题、dashboard/workbench 工作台页、system 五页卡片化美化、操作日志增强版(VxeTable)、house 预售/楼盘地图、recruitment 页面;另补 log 页卡片美化(wb-page-head+t-card)。已提交保护
2026-08-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 仅首次渲染生效)——替代先前扁平化+缩进的自造方案

View File

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

View File

@ -1,13 +1,15 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { CommunityItem } from '#/api/house';
import { onMounted, reactive, ref } from 'vue';
import { h, reactive, ref } from 'vue';
import { useAccess } from '@vben/access';
import { Page } from '@vben/common-ui';
import { Page, VbenTableAction } from '@vben/common-ui';
import { DialogPlugin, MessagePlugin } from 'tdesign-vue-next';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import {
createCommunity,
deleteCommunity,
@ -18,18 +20,24 @@ import {
// + / +
const { hasAccessByCodes } = useAccess();
const loading = ref(false);
const list = ref<CommunityItem[]>([]);
const dialogOpen = ref(false);
const editing = ref<null | CommunityItem>(null);
const editing = ref<CommunityItem | null>(null);
const regionOptions = [
'云岩区', '南明区', '观山湖区', '花溪区', '乌当区', '白云区',
'清镇市', '修文县', '开阳县', '息烽县',
'云岩区',
'南明区',
'观山湖区',
'花溪区',
'乌当区',
'白云区',
'清镇市',
'修文县',
'开阳县',
'息烽县',
].map((r) => ({ label: r, value: r }));
const query = reactive({ page: 1, size: 10, keyword: '', region: '' });
const pagination = reactive({ current: 1, pageSize: 10, total: 0 });
// grid
const query = reactive({ keyword: '', region: '' });
const form = reactive({
name: '',
@ -47,23 +55,22 @@ const form = reactive({
developer: '',
});
async function load() {
loading.value = true;
try {
const data = await getCommunityList(query);
list.value = data.list ?? [];
pagination.total = data.total ?? 0;
} finally {
loading.value = false;
}
}
function openCreate() {
editing.value = null;
Object.assign(form, {
name: '', region: '', businessDistrict: '', address: '', lng: 0, lat: 0,
buildYear: 0, households: 0, plotRatio: 0, greenRate: 0,
propertyCompany: '', propertyFee: 0, developer: '',
name: '',
region: '',
businessDistrict: '',
address: '',
lng: 0,
lat: 0,
buildYear: 0,
households: 0,
plotRatio: 0,
greenRate: 0,
propertyCompany: '',
propertyFee: 0,
developer: '',
});
dialogOpen.value = true;
}
@ -75,48 +82,98 @@ function openEdit(row: CommunityItem) {
}
async function submit() {
if (editing.value) {
await updateCommunity(editing.value.id, form);
} else {
await createCommunity(form);
}
//
editing.value
? await updateCommunity(editing.value.id, form)
: await createCommunity(form);
MessagePlugin.success('保存成功');
dialogOpen.value = false;
load();
gridApi.query();
}
/** 删除小区(保留原确认弹窗行为) */
function onDelete(row: CommunityItem) {
const d = DialogPlugin.confirm({
header: `确认删除「${row.name}」?`,
onConfirm: async () => {
await deleteCommunity(row.id);
MessagePlugin.success('删除成功');
load();
gridApi.query();
d.destroy();
},
});
}
const columns = [
{ colKey: 'id', title: 'ID', width: 70 },
{ colKey: 'name', title: '小区名称', width: 180 },
{ colKey: 'region', title: '区县', width: 100 },
{ colKey: 'businessDistrict', title: '板块', width: 120 },
{ colKey: 'address', title: '地址' },
{ colKey: 'buildYear', title: '建成年份', width: 90 },
{ colKey: 'propertyCompany', title: '物业', width: 140 },
{ colKey: 'op', title: '操作', width: 160 },
];
const gridOptions: VxeTableGridOptions = {
columns: [
{ type: 'seq', width: 50, title: '#' },
{ field: 'id', title: 'ID', width: 70 },
{ field: 'name', title: '小区名称', width: 180 },
{ field: 'region', title: '区县', width: 100 },
{ field: 'businessDistrict', title: '板块', width: 120 },
{ field: 'address', title: '地址', showOverflow: 'tooltip' },
{ field: 'buildYear', title: '建成年份', width: 90 },
{ field: 'propertyCompany', title: '物业', width: 140 },
{
field: 'op',
title: '操作',
width: 160,
fixed: 'right',
slots: {
default: ({ row }: { row: CommunityItem }) =>
h(VbenTableAction, {
actions: [
//
{
text: '编辑',
onClick: () => openEdit(row),
auth: ['house:community:update'],
},
//
{
text: '删除',
danger: true,
onClick: () => onDelete(row),
auth: ['house:community:delete'],
},
],
}),
},
},
],
pagerConfig: { pageSize: 10 },
proxyConfig: {
ajax: {
query: async ({
page,
}: {
page: { currentPage: number; pageSize: number };
}) => {
const data = await getCommunityList({
page: page.currentPage,
size: page.pageSize,
keyword: query.keyword || undefined,
region: query.region || undefined,
});
return { items: data.list ?? [], total: data.total ?? 0 };
},
},
},
};
function onPageChange(p: { current: number; pageSize: number }) {
query.page = p.current;
query.size = p.pageSize;
pagination.current = p.current;
pagination.pageSize = p.pageSize;
load();
const [Grid, gridApi] = useVbenVxeGrid({ gridOptions });
/** 触发查询(保留分页条件,因 grid 自动管理分页) */
function search() {
gridApi.query();
}
onMounted(load);
/** 重置筛选并回到第一页重新查询 */
function reset() {
query.keyword = '';
query.region = '';
gridApi.reload();
}
</script>
<template>
@ -128,7 +185,7 @@ onMounted(load);
clearable
placeholder="小区名称"
style="width: 200px"
@enter="load"
@enter="search"
/>
<t-select
v-model="query.region"
@ -137,7 +194,8 @@ onMounted(load);
placeholder="区县"
style="width: 140px"
/>
<t-button theme="primary" @click="load">查询</t-button>
<t-button theme="primary" @click="search">查询</t-button>
<t-button variant="outline" @click="reset">重置</t-button>
</div>
<t-button
v-if="hasAccessByCodes(['house:community:create'])"
@ -148,35 +206,8 @@ onMounted(load);
</t-button>
</div>
<t-table
row-key="id"
:data="list"
:columns="columns"
:loading="loading"
:pagination="pagination"
@page-change="onPageChange"
>
<template #op="{ row }">
<t-button
v-if="hasAccessByCodes(['house:community:update'])"
size="small"
theme="primary"
variant="text"
@click="openEdit(row)"
>
编辑
</t-button>
<t-button
v-if="hasAccessByCodes(['house:community:delete'])"
size="small"
theme="danger"
variant="text"
@click="onDelete(row)"
>
删除
</t-button>
</template>
</t-table>
<!-- 小区列表Vben Vxe Table + TableAction -->
<Grid />
<t-dialog
v-model:visible="dialogOpen"
@ -190,7 +221,11 @@ onMounted(load);
<t-input v-model="form.name" placeholder="如:中天未来方舟" />
</t-form-item>
<t-form-item label="区县">
<t-select v-model="form.region" clearable :options="regionOptions" />
<t-select
v-model="form.region"
clearable
:options="regionOptions"
/>
</t-form-item>
<t-form-item label="板块">
<t-input v-model="form.businessDistrict" placeholder="如:会展城" />
@ -208,10 +243,18 @@ onMounted(load);
<t-input v-model="form.address" />
</t-form-item>
<t-form-item label="经度">
<t-input-number v-model="form.lng" theme="normal" :decimal-places="6" />
<t-input-number
v-model="form.lng"
theme="normal"
:decimal-places="6"
/>
</t-form-item>
<t-form-item label="纬度">
<t-input-number v-model="form.lat" theme="normal" :decimal-places="6" />
<t-input-number
v-model="form.lat"
theme="normal"
:decimal-places="6"
/>
</t-form-item>
</div>
</t-form>

View File

@ -1,16 +1,14 @@
<script lang="ts" setup>
import type { TransactionItem } from '#/api/house';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { onMounted, reactive, ref } from 'vue';
import { reactive } from 'vue';
import { Page } from '@vben/common-ui';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getTransactionList } from '#/api/house';
// //
const loading = ref(false);
const list = ref<TransactionItem[]>([]);
// Vben Vxe Table//
const regionOptions = [
'云岩区',
'南明区',
@ -24,49 +22,55 @@ const regionOptions = [
'息烽县',
].map((r) => ({ label: r, value: r }));
const query = reactive({ page: 1, size: 10, keyword: '', region: '' });
const pagination = reactive({ current: 1, pageSize: 10, total: 0 });
// grid
const query = reactive({ keyword: '', region: '' });
async function load() {
loading.value = true;
try {
const data = await getTransactionList(query);
list.value = data.list ?? [];
pagination.total = data.total ?? 0;
} finally {
loading.value = false;
}
const gridOptions: VxeTableGridOptions = {
columns: [
{ type: 'seq', width: 50, title: '#' },
{ field: 'id', title: 'ID', width: 70 },
{ field: 'communityName', title: '小区', width: 180 },
{ field: 'layout', title: '户型', width: 100 },
{ field: 'area', title: '面积㎡', width: 90 },
{ field: 'dealPrice', title: '成交价(万)', width: 110 },
{ field: 'dealUnitPrice', title: '成交单价', width: 110 },
{ field: 'listDays', title: '挂牌天数', width: 90 },
{ field: 'dealDate', title: '成交日期', width: 120 },
{ field: 'source', title: '来源', width: 90 },
],
pagerConfig: { pageSize: 10 },
proxyConfig: {
ajax: {
query: async ({
page,
}: {
page: { currentPage: number; pageSize: number };
}) => {
const data = await getTransactionList({
page: page.currentPage,
size: page.pageSize,
keyword: query.keyword,
region: query.region,
});
return { items: data.list ?? [], total: data.total ?? 0 };
},
},
},
};
const [Grid, gridApi] = useVbenVxeGrid({ gridOptions });
/** 触发查询(保留分页条件,因 grid 自动管理分页) */
function search() {
gridApi.query();
}
/** 重置筛选并回到第一页重新查询 */
function reset() {
query.keyword = '';
query.region = '';
query.page = 1;
pagination.current = 1;
load();
gridApi.reload();
}
const columns = [
{ colKey: 'id', title: 'ID', width: 70 },
{ colKey: 'communityName', title: '小区', width: 180 },
{ colKey: 'layout', title: '户型', width: 100 },
{ colKey: 'area', title: '面积㎡', width: 90 },
{ colKey: 'dealPrice', title: '成交价(万)', width: 110 },
{ colKey: 'dealUnitPrice', title: '成交单价', width: 110 },
{ colKey: 'listDays', title: '挂牌天数', width: 90 },
{ colKey: 'dealDate', title: '成交日期', width: 120 },
{ colKey: 'source', title: '来源', width: 90 },
];
function onPageChange(p: { current: number; pageSize: number }) {
query.page = p.current;
query.size = p.pageSize;
pagination.current = p.current;
pagination.pageSize = p.pageSize;
load();
}
onMounted(load);
</script>
<template>
@ -77,7 +81,7 @@ onMounted(load);
clearable
placeholder="小区名/户型"
style="width: 200px"
@enter="load"
@enter="search"
/>
<t-select
v-model="query.region"
@ -86,17 +90,11 @@ onMounted(load);
placeholder="区县"
style="width: 140px"
/>
<t-button theme="primary" @click="load">查询</t-button>
<t-button theme="primary" @click="search">查询</t-button>
<t-button variant="outline" @click="reset">重置</t-button>
</div>
<t-table
row-key="id"
:data="list"
:columns="columns"
:loading="loading"
:pagination="pagination"
@page-change="onPageChange"
/>
<!-- 成交记录列表Vben Vxe Table -->
<Grid />
</Page>
</template>

View File

@ -1,13 +1,15 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { ListingItem } from '#/api/house';
import { onMounted, reactive, ref } from 'vue';
import { h, reactive, ref } from 'vue';
import { useAccess } from '@vben/access';
import { Page } from '@vben/common-ui';
import { Page, VbenTableAction } from '@vben/common-ui';
import { DialogPlugin, MessagePlugin } from 'tdesign-vue-next';
import { DialogPlugin, MessagePlugin, Tag } from 'tdesign-vue-next';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import {
batchMarkListing,
deleteListing,
@ -18,20 +20,24 @@ import {
// + //+ /
const { hasAccessByCodes } = useAccess();
const loading = ref(false);
const list = ref<ListingItem[]>([]);
const selectedKeys = ref<number[]>([]);
const dialogOpen = ref(false);
const editing = ref<null | ListingItem>(null);
const editing = ref<ListingItem | null>(null);
const regionOptions = [
'云岩区', '南明区', '观山湖区', '花溪区', '乌当区', '白云区',
'清镇市', '修文县', '开阳县', '息烽县',
'云岩区',
'南明区',
'观山湖区',
'花溪区',
'乌当区',
'白云区',
'清镇市',
'修文县',
'开阳县',
'息烽县',
].map((r) => ({ label: r, value: r }));
// grid
const query = reactive({
page: 1,
size: 10,
keyword: '',
region: '',
layout: '',
@ -42,42 +48,23 @@ const query = reactive({
isBargain: 0,
confidence: 0,
});
const pagination = reactive({ current: 1, pageSize: 10, total: 0 });
async function load() {
loading.value = true;
try {
const data = await getListingList(query);
list.value = data.list ?? [];
pagination.total = data.total ?? 0;
} finally {
loading.value = false;
}
}
function reset() {
Object.assign(query, {
keyword: '', region: '', layout: '', source: '',
priceMin: undefined, priceMax: undefined, status: 0, isBargain: 0, confidence: 0,
});
query.page = 1;
pagination.current = 1;
load();
}
function onSelectChange(keys: Array<string | number>) {
selectedKeys.value = keys.map(Number);
/** 收集当前勾选的房源 ID */
function getSelectedIds(): number[] {
const records = (gridApi.grid?.getCheckboxRecords() ?? []) as ListingItem[];
return records.map((r) => Number(r.id));
}
/** 批量标记(笋盘/低可信/状态) */
async function onBatchMark(field: string, value: number) {
if (selectedKeys.value.length === 0) {
const ids = getSelectedIds();
if (ids.length === 0) {
MessagePlugin.warning('请先勾选房源');
return;
}
await batchMarkListing({ ids: selectedKeys.value, field, value });
await batchMarkListing({ ids, field, value });
MessagePlugin.success('标记成功');
load();
gridApi.query();
}
function openEdit(row: ListingItem) {
@ -98,45 +85,174 @@ async function submit() {
}
MessagePlugin.success('保存成功');
dialogOpen.value = false;
load();
gridApi.query();
}
/** 删除房源(保留原确认弹窗行为) */
function onDelete(row: ListingItem) {
const d = DialogPlugin.confirm({
header: '确认删除该房源?',
onConfirm: async () => {
await deleteListing(row.id);
MessagePlugin.success('删除成功');
load();
gridApi.query();
d.destroy();
},
});
}
const columns = [
{ colKey: 'row-select', type: 'multiple', width: 46 },
{ colKey: 'id', title: 'ID', width: 70 },
{ colKey: 'communityName', title: '小区', width: 160 },
{ colKey: 'layout', title: '户型', width: 100 },
{ colKey: 'area', title: '面积㎡', width: 90 },
{ colKey: 'totalPrice', title: '总价(万)', width: 100 },
{ colKey: 'unitPrice', title: '单价', width: 100 },
{ colKey: 'source', title: '来源', width: 90 },
{ colKey: 'onMarketDays', title: '挂牌天数', width: 90 },
{ colKey: 'statusText', title: '状态', width: 80 },
{ colKey: 'flagText', title: '标记', width: 140 },
{ colKey: 'op', title: '操作', width: 140 },
];
function onPageChange(p: { current: number; pageSize: number }) {
query.page = p.current;
query.size = p.pageSize;
pagination.current = p.current;
pagination.pageSize = p.pageSize;
load();
/** 状态码 -> TDesign 标签主题色1 在售 / 2 下架 / 3 成交) */
function statusTheme(status: number) {
if (status === 1) return 'success';
if (status === 2) return 'default';
return 'primary';
}
onMounted(load);
/** 状态码 -> 文案 */
function statusText(status: number) {
if (status === 1) return '在售';
if (status === 2) return '下架';
return '成交';
}
const gridOptions: VxeTableGridOptions = {
columns: [
{ type: 'checkbox', width: 46 }, //
{ field: 'id', title: 'ID', width: 70 },
{ field: 'communityName', title: '小区', width: 160 },
{ field: 'layout', title: '户型', width: 100 },
{ field: 'area', title: '面积㎡', width: 90 },
{ field: 'totalPrice', title: '总价(万)', width: 100 },
{ field: 'unitPrice', title: '单价', width: 100 },
{ field: 'source', title: '来源', width: 90 },
{ field: 'onMarketDays', title: '挂牌天数', width: 90 },
{
field: 'statusText',
title: '状态',
width: 80,
slots: {
default: ({ row }: { row: ListingItem }) =>
h(
Tag,
{
theme: statusTheme(row.status),
variant: 'light',
size: 'small',
},
statusText(row.status),
),
},
},
{
field: 'flagText',
title: '标记',
width: 140,
slots: {
default: ({ row }: { row: ListingItem }) => {
const tags: ReturnType<typeof h>[] = [];
if (row.isBargain === 1) {
tags.push(
h(
Tag,
{ theme: 'warning', variant: 'light', size: 'small' },
'笋盘',
),
);
}
if (row.confidence === 1) {
tags.push(
h(
Tag,
{ theme: 'danger', variant: 'light', size: 'small' },
'低可信',
),
);
}
return h(
'span',
{ class: 'flex items-center justify-center gap-1' },
{ default: () => tags },
);
},
},
},
{
field: 'op',
title: '操作',
width: 140,
fixed: 'right',
slots: {
default: ({ row }: { row: ListingItem }) =>
h(VbenTableAction, {
actions: [
//
{
text: '编辑',
onClick: () => openEdit(row),
auth: ['house:listing:update'],
},
//
{
text: '删除',
danger: true,
onClick: () => onDelete(row),
auth: ['house:listing:delete'],
},
],
}),
},
},
],
checkboxConfig: { highlight: true }, // 便
pagerConfig: { pageSize: 10 },
proxyConfig: {
ajax: {
query: async ({
page,
}: {
page: { currentPage: number; pageSize: number };
}) => {
const data = await getListingList({
page: page.currentPage,
size: page.pageSize,
keyword: query.keyword || undefined,
region: query.region || undefined,
layout: query.layout || undefined,
source: query.source || undefined,
priceMin: query.priceMin || undefined,
priceMax: query.priceMax || undefined,
status: query.status || undefined,
isBargain: query.isBargain || undefined,
confidence: query.confidence || undefined,
});
return { items: data.list ?? [], total: data.total ?? 0 };
},
},
},
};
const [Grid, gridApi] = useVbenVxeGrid({ gridOptions });
/** 触发查询(保留分页条件,因 grid 自动管理分页) */
function search() {
gridApi.query();
}
/** 重置筛选并回到第一页重新查询 */
function reset() {
Object.assign(query, {
keyword: '',
region: '',
layout: '',
source: '',
priceMin: undefined,
priceMax: undefined,
status: 0,
isBargain: 0,
confidence: 0,
});
gridApi.reload();
}
</script>
<template>
@ -147,7 +263,7 @@ onMounted(load);
clearable
placeholder="房号/户型"
style="width: 160px"
@enter="load"
@enter="search"
/>
<t-select
v-model="query.region"
@ -184,7 +300,7 @@ onMounted(load);
]"
style="width: 110px"
/>
<t-button theme="primary" @click="load">查询</t-button>
<t-button theme="primary" @click="search">查询</t-button>
<t-button variant="outline" @click="reset">重置</t-button>
<span class="ml-auto flex items-center gap-2">
<t-button
@ -204,52 +320,8 @@ onMounted(load);
</span>
</div>
<t-table
row-key="id"
:data="list"
:columns="columns"
:loading="loading"
:pagination="pagination"
:selected-row-keys="selectedKeys"
@page-change="onPageChange"
@select-change="onSelectChange"
>
<template #statusText="{ row }">
<t-tag
:theme="row.status === 1 ? 'success' : row.status === 2 ? 'default' : 'primary'"
variant="light"
size="small"
>
{{ row.status === 1 ? '在售' : row.status === 2 ? '下架' : '成交' }}
</t-tag>
</template>
<template #flagText="{ row }">
<span class="flex items-center gap-1">
<t-tag v-if="row.isBargain === 1" theme="warning" variant="light" size="small">笋盘</t-tag>
<t-tag v-if="row.confidence === 1" theme="danger" variant="light" size="small">低可信</t-tag>
</span>
</template>
<template #op="{ row }">
<t-button
v-if="hasAccessByCodes(['house:listing:update'])"
size="small"
theme="primary"
variant="text"
@click="openEdit(row)"
>
编辑
</t-button>
<t-button
v-if="hasAccessByCodes(['house:listing:delete'])"
size="small"
theme="danger"
variant="text"
@click="onDelete(row)"
>
删除
</t-button>
</template>
</t-table>
<!-- 房源列表Vben Vxe Table + TableAction -->
<Grid />
<t-dialog
v-model:visible="dialogOpen"

View File

@ -1,16 +1,14 @@
<script lang="ts" setup>
import type { PresaleItem } from '#/api/house';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { onMounted, reactive, ref } from 'vue';
import { reactive } from 'vue';
import { Page } from '@vben/common-ui';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getPresaleList } from '#/api/house';
//
const loading = ref(false);
const list = ref<PresaleItem[]>([]);
// Vben Vxe Table
const regionOptions = [
'云岩区',
'南明区',
@ -30,57 +28,63 @@ const purposeOptions = [
{ label: '商业', value: '商业' },
];
const query = reactive({
page: 1,
size: 10,
keyword: '',
region: '',
purpose: '',
// grid
const query = reactive({ keyword: '', region: '', purpose: '' });
const gridOptions: VxeTableGridOptions = {
columns: [
{ type: 'seq', width: 50, title: '#' },
{ field: 'presaleNo', title: '预售证号', width: 190 },
{ field: 'communityName', title: '楼盘/项目名', width: 200 },
{
field: 'developer',
title: '开发商',
width: 200,
showOverflow: 'tooltip',
},
{ field: 'region', title: '区县', width: 100 },
{ field: 'address', title: '位置', width: 220, showOverflow: 'tooltip' },
{ field: 'buildingNo', title: '楼栋号', width: 110 },
{ field: 'houseCount', title: '套数', width: 70 },
{ field: 'area', title: '面积㎡', width: 100 },
{ field: 'purpose', title: '用途', width: 80 },
{ field: 'issueDate', title: '发证日期', width: 120 },
],
pagerConfig: { pageSize: 10 },
proxyConfig: {
ajax: {
query: async ({
page,
}: {
page: { currentPage: number; pageSize: number };
}) => {
const data = await getPresaleList({
page: page.currentPage,
size: page.pageSize,
keyword: query.keyword || undefined,
region: query.region || undefined,
purpose: query.purpose || undefined,
});
const pagination = reactive({ current: 1, pageSize: 10, total: 0 });
return { items: data.list ?? [], total: data.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;
}
const [Grid, gridApi] = useVbenVxeGrid({ gridOptions });
/** 触发查询(保留分页条件,因 grid 自动管理分页) */
function search() {
gridApi.query();
}
/** 重置筛选并回到第一页重新查询 */
function reset() {
query.keyword = '';
query.region = '';
query.purpose = '';
query.page = 1;
pagination.current = 1;
load();
gridApi.reload();
}
const columns = [
{ colKey: 'presaleNo', title: '预售证号', width: 190 },
{ colKey: 'communityName', title: '楼盘/项目名', width: 200 },
{ colKey: 'developer', title: '开发商', width: 200 },
{ colKey: 'region', title: '区县', width: 100 },
{ colKey: 'address', title: '位置', width: 220, ellipsis: true },
{ colKey: 'buildingNo', title: '楼栋号', width: 110 },
{ colKey: 'houseCount', title: '套数', width: 70 },
{ colKey: 'area', title: '面积㎡', width: 100 },
{ colKey: 'purpose', title: '用途', width: 80 },
{ colKey: 'issueDate', title: '发证日期', width: 120 },
];
function onPageChange(p: { current: number; pageSize: number }) {
query.page = p.current;
query.size = p.pageSize;
pagination.current = p.current;
pagination.pageSize = p.pageSize;
load();
}
onMounted(load);
</script>
<template>
@ -91,7 +95,7 @@ onMounted(load);
clearable
placeholder="楼盘名/开发商/预售证号"
style="width: 240px"
@enter="load"
@enter="search"
/>
<t-select
v-model="query.region"
@ -107,17 +111,11 @@ onMounted(load);
placeholder="用途"
style="width: 120px"
/>
<t-button theme="primary" @click="load">查询</t-button>
<t-button theme="primary" @click="search">查询</t-button>
<t-button variant="outline" @click="reset">重置</t-button>
</div>
<t-table
row-key="id"
:data="list"
:columns="columns"
:loading="loading"
:pagination="pagination"
@page-change="onPageChange"
/>
<!-- 预售许可列表Vben Vxe Table -->
<Grid />
</Page>
</template>

View File

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

View File

@ -1,13 +1,15 @@
<script lang="ts" setup>
// + +
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
// + Vben Vxe Table + Vben TableAction +
import type { RecruitmentItem } from '#/api/recruitment';
import { onMounted, reactive, ref } from 'vue';
import { h, reactive, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { Page, VbenTableAction } from '@vben/common-ui';
import { MessagePlugin } from 'tdesign-vue-next';
import { MessagePlugin, Tag } from 'tdesign-vue-next';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import {
CATEGORY_OPTIONS,
getRecruitmentDetail,
@ -16,18 +18,13 @@ import {
STATUS_OPTIONS,
} from '#/api/recruitment';
const loading = ref(false);
const list = ref<RecruitmentItem[]>([]);
const pagination = reactive({ current: 1, pageSize: 10, total: 0 });
//
const detailOpen = ref(false);
const detailLoading = ref(false);
const detail = ref<null | RecruitmentItem>(null);
// grid
const query = reactive({
page: 1,
size: 10,
region: '',
category: 0,
keyword: '',
@ -39,12 +36,70 @@ const query = reactive({
onlyNewToday: false,
});
async function load() {
loading.value = true;
try {
// statusName/op slots
/** 状态码 -> TDesign 标签主题色0 招集中 / 1 报名中 / 其余默认) */
function statusTheme(status: number) {
if (status === 0) return 'success';
if (status === 1) return 'warning';
return 'default';
}
const gridOptions: VxeTableGridOptions = {
columns: [
{ type: 'seq', width: 50, title: '#' },
{ field: 'id', title: 'ID', width: 70 },
{ field: 'title', title: '标题', minWidth: 260, showOverflow: 'tooltip' },
{ field: 'categoryName', title: '分类', width: 90 },
{
field: 'orgName',
title: '发布单位',
width: 180,
showOverflow: 'tooltip',
},
{ field: 'region', title: '地区', width: 90 },
{ field: 'publishDate', title: '发布日期', width: 110 },
{ field: 'deadline', title: '截止', width: 110 },
{
field: 'statusName',
title: '状态',
width: 90,
slots: {
default: ({ row }: { row: RecruitmentItem }) =>
h(
Tag,
{
theme: statusTheme(row.status),
variant: 'light',
size: 'small',
},
row.statusName,
),
},
},
{
field: 'op',
title: '操作',
width: 90,
fixed: 'right',
slots: {
default: ({ row }: { row: RecruitmentItem }) =>
h(VbenTableAction, {
actions: [{ text: '详情', onClick: () => openDetail(row) }],
}),
},
},
],
pagerConfig: { pageSize: 10 },
proxyConfig: {
ajax: {
query: async ({
page,
}: {
page: { currentPage: number; pageSize: number };
}) => {
const data = await getRecruitmentList({
page: query.page,
size: query.size,
page: page.currentPage,
size: page.pageSize,
region: query.region || undefined,
category: query.category || undefined,
keyword: query.keyword || undefined,
@ -55,13 +110,20 @@ async function load() {
sourceId: query.sourceId || undefined,
onlyNewToday: query.onlyNewToday || undefined,
});
list.value = data.list ?? [];
pagination.total = data.total ?? 0;
} finally {
loading.value = false;
}
return { items: data.list ?? [], total: data.total ?? 0 };
},
},
},
};
const [Grid, gridApi] = useVbenVxeGrid({ gridOptions });
/** 触发查询(保留分页条件,因 grid 自动管理分页) */
function search() {
gridApi.query();
}
/** 重置筛选并回到第一页重新查询 */
function reset() {
Object.assign(query, {
region: '',
@ -74,17 +136,7 @@ function reset() {
sourceId: 0,
onlyNewToday: false,
});
query.page = 1;
pagination.current = 1;
load();
}
function onPageChange(p: { current: number; pageSize: number }) {
query.page = p.current;
query.size = p.pageSize;
pagination.current = p.current;
pagination.pageSize = p.pageSize;
load();
gridApi.reload();
}
async function openDetail(row: RecruitmentItem) {
@ -99,26 +151,13 @@ async function openDetail(row: RecruitmentItem) {
}
}
const columns = [
{ colKey: 'id', title: 'ID', width: 70 },
{ colKey: 'title', title: '标题', minWidth: 260, ellipsis: true },
{ colKey: 'categoryName', title: '分类', width: 90 },
{ colKey: 'orgName', title: '发布单位', width: 180, ellipsis: true },
{ colKey: 'region', title: '地区', width: 90 },
{ colKey: 'publishDate', title: '发布日期', width: 110 },
{ colKey: 'deadline', title: '截止', width: 110 },
{ colKey: 'statusName', title: '状态', width: 90 },
{ colKey: 'op', title: '操作', width: 90, fixed: 'right' },
];
/** 复制公告原文链接 */
function copyUrl() {
if (detail.value?.url) {
navigator.clipboard?.writeText(detail.value.url);
MessagePlugin.success('链接已复制');
}
}
onMounted(load);
</script>
<template>
@ -129,7 +168,7 @@ onMounted(load);
clearable
placeholder="标题/正文/单位关键字"
style="width: 200px"
@enter="load"
@enter="search"
/>
<t-select
v-model="query.region"
@ -168,47 +207,15 @@ onMounted(load);
clearable
placeholder="发布主体模糊"
style="width: 150px"
@enter="load"
@enter="search"
/>
<t-checkbox v-model="query.onlyNewToday">仅今日新增</t-checkbox>
<t-button theme="primary" @click="load">查询</t-button>
<t-button theme="primary" @click="search">查询</t-button>
<t-button variant="outline" @click="reset">重置</t-button>
</div>
<t-table
row-key="id"
:data="list"
:columns="columns"
:loading="loading"
:pagination="pagination"
@page-change="onPageChange"
>
<template #statusName="{ row }">
<t-tag
:theme="
row.status === 0
? 'success'
: row.status === 1
? 'warning'
: 'default'
"
variant="light"
size="small"
>
{{ row.statusName }}
</t-tag>
</template>
<template #op="{ row }">
<t-button
size="small"
theme="primary"
variant="text"
@click="openDetail(row)"
>
详情
</t-button>
</template>
</t-table>
<!-- 招聘公告列表Vben Vxe Table -->
<Grid />
<t-drawer
v-model:visible="detailOpen"
@ -220,9 +227,7 @@ onMounted(load);
<div v-else-if="detail">
<div class="mb-3 flex flex-wrap gap-2 text-xs">
<t-tag theme="primary" variant="light">
{{
detail.categoryName
}}
{{ detail.categoryName }}
</t-tag>
<t-tag theme="success" variant="light">{{ detail.statusName }}</t-tag>
<span class="opacity-60">{{ detail.region }}</span>

View File

@ -1,14 +1,16 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
// + / + + Bark
import type { SubscriptionItem } from '#/api/recruitment';
import { onMounted, reactive, ref } from 'vue';
import { h, onMounted, reactive, ref } from 'vue';
import { useAccess } from '@vben/access';
import { Page } from '@vben/common-ui';
import { Page, VbenTableAction } from '@vben/common-ui';
import { DialogPlugin, MessagePlugin } from 'tdesign-vue-next';
import { DialogPlugin, MessagePlugin, Tag } from 'tdesign-vue-next';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import {
CATEGORY_OPTIONS,
deleteSubscription,
@ -29,9 +31,6 @@ function categoryLabels(cats: number[] | undefined): string {
const { hasAccessByCodes } = useAccess();
const loading = ref(false);
const list = ref<SubscriptionItem[]>([]);
//
const dialogOpen = ref(false);
const submitting = ref(false);
@ -46,14 +45,10 @@ const form = reactive({
enabled: 1,
});
/** 拉取订阅列表并写入本地数据表格 */
async function load() {
loading.value = true;
try {
const data = await getSubscriptionList();
list.value = data.list ?? [];
} finally {
loading.value = false;
}
gridApi.setGridOptions({ data: data.list ?? [] });
}
function openCreate() {
@ -109,6 +104,7 @@ async function submit() {
}
}
/** 删除订阅(保留原确认弹窗行为) */
function onDelete(row: SubscriptionItem) {
const d = DialogPlugin.confirm({
header: '删除该订阅?',
@ -121,6 +117,7 @@ function onDelete(row: SubscriptionItem) {
});
}
/** 发送 Bark 推送测试消息 */
async function onTest(row: SubscriptionItem) {
const data = await testPush({
subscriptionId: row.id,
@ -134,17 +131,111 @@ async function onTest(row: SubscriptionItem) {
}
}
const columns = [
{ colKey: 'id', title: 'ID', width: 70 },
{ colKey: 'name', title: '名称', width: 140 },
{ colKey: 'deviceKey', title: '设备 Key', width: 200, ellipsis: true },
{ colKey: 'regionsText', title: '地区', width: 160, ellipsis: true },
{ colKey: 'categoriesText', title: '分类', width: 160, ellipsis: true },
{ colKey: 'onlyNew', title: '仅新公告', width: 90 },
{ colKey: 'pushTime', title: '推送时间', width: 90 },
{ colKey: 'enabled', title: '启用', width: 80 },
{ colKey: 'op', title: '操作', width: 200, fixed: 'right' },
];
// proxyConfig load()
const gridOptions: VxeTableGridOptions = {
columns: [
{ type: 'seq', width: 50, title: '#' },
{ field: 'id', title: 'ID', width: 70 },
{ field: 'name', title: '名称', width: 140 },
{
field: 'deviceKey',
title: '设备 Key',
width: 200,
showOverflow: 'tooltip',
},
{
field: 'regionsText',
title: '地区',
width: 160,
showOverflow: 'tooltip',
slots: {
default: ({ row }: { row: SubscriptionItem }) =>
h('span', (row.regions ?? []).join('、') || '全部'),
},
},
{
field: 'categoriesText',
title: '分类',
width: 160,
showOverflow: 'tooltip',
slots: {
default: ({ row }: { row: SubscriptionItem }) =>
h('span', categoryLabels(row.categories)),
},
},
{
field: 'onlyNew',
title: '仅新公告',
width: 90,
slots: {
default: ({ row }: { row: SubscriptionItem }) =>
h(
Tag,
{
theme: row.onlyNew === 1 ? 'success' : 'default',
variant: 'light',
size: 'small',
},
row.onlyNew === 1 ? '是' : '否',
),
},
},
{ field: 'pushTime', title: '推送时间', width: 90 },
{
field: 'enabled',
title: '启用',
width: 80,
slots: {
default: ({ row }: { row: SubscriptionItem }) =>
h(
Tag,
{
theme: row.enabled === 1 ? 'success' : 'default',
variant: 'light',
size: 'small',
},
row.enabled === 1 ? '启用' : '停用',
),
},
},
{
field: 'op',
title: '操作',
width: 200,
fixed: 'right',
slots: {
default: ({ row }: { row: SubscriptionItem }) =>
h(VbenTableAction, {
actions: [
//
{
text: '编辑',
onClick: () => openEdit(row),
auth: ['recruitment:push:save'],
},
//
{
text: '测试',
onClick: () => onTest(row),
auth: ['recruitment:push:test'],
},
//
{
text: '删除',
danger: true,
onClick: () => onDelete(row),
auth: ['recruitment:push:delete'],
},
],
}),
},
},
],
pagerConfig: { enabled: false }, //
proxyConfig: { enabled: false }, //
};
const [Grid, gridApi] = useVbenVxeGrid({ gridOptions });
onMounted(load);
</script>
@ -161,61 +252,8 @@ onMounted(load);
</t-button>
</div>
<t-table row-key="id" :data="list" :columns="columns" :loading="loading">
<template #regionsText="{ row }">
{{ (row.regions ?? []).join('、') || '全部' }}
</template>
<template #categoriesText="{ row }">
{{ categoryLabels(row.categories) }}
</template>
<template #onlyNew="{ row }">
<t-tag
:theme="row.onlyNew === 1 ? 'success' : 'default'"
variant="light"
size="small"
>
{{ row.onlyNew === 1 ? '是' : '否' }}
</t-tag>
</template>
<template #enabled="{ row }">
<t-tag
:theme="row.enabled === 1 ? 'success' : 'default'"
variant="light"
size="small"
>
{{ row.enabled === 1 ? '启用' : '停用' }}
</t-tag>
</template>
<template #op="{ row }">
<t-button
v-if="hasAccessByCodes(['recruitment:push:save'])"
size="small"
theme="primary"
variant="text"
@click="openEdit(row)"
>
编辑
</t-button>
<t-button
v-if="hasAccessByCodes(['recruitment:push:test'])"
size="small"
theme="default"
variant="text"
@click="onTest(row)"
>
测试
</t-button>
<t-button
v-if="hasAccessByCodes(['recruitment:push:delete'])"
size="small"
theme="danger"
variant="text"
@click="onDelete(row)"
>
删除
</t-button>
</template>
</t-table>
<!-- 订阅列表Vben Vxe Table + TableAction -->
<Grid />
<t-dialog
v-model:visible="dialogOpen"

View File

@ -1,13 +1,15 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { AdminItem, RoleItem } from '#/api/system';
import { onMounted, reactive, ref } from 'vue';
import { h, onMounted, reactive, ref } from 'vue';
import { useAccess } from '@vben/access';
import { Page } from '@vben/common-ui';
import { Page, VbenTableAction } from '@vben/common-ui';
import { DialogPlugin, MessagePlugin } from 'tdesign-vue-next';
import { DialogPlugin, MessagePlugin, Tag } from 'tdesign-vue-next';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import {
createAdmin,
deleteAdmin,
@ -17,16 +19,15 @@ import {
updateAdmin,
} from '#/api/system';
// Vben Vxe Table + Vben TableAction
const { hasAccessByCodes } = useAccess();
const loading = ref(false);
const list = ref<AdminItem[]>([]);
const roleOptions = ref<{ label: string; value: number }[]>([]);
const dialogOpen = ref(false);
const editing = ref<AdminItem | null>(null);
const query = reactive({ page: 1, size: 10, keyword: '' });
const pagination = reactive({ current: 1, pageSize: 10, total: 0 });
// grid
const query = reactive({ keyword: '' });
const form = reactive({
username: '',
password: '',
@ -35,17 +36,6 @@ const form = reactive({
roleIds: [] as number[],
});
async function load() {
loading.value = true;
try {
const data = await getAdminList(query);
list.value = data.list ?? [];
pagination.total = data.total ?? 0;
} finally {
loading.value = false;
}
}
async function loadRoles() {
const data = await getRoleList({ page: 1, size: 100 });
roleOptions.value = (data.list ?? []).map((r: RoleItem) => ({
@ -79,37 +69,38 @@ function openEdit(row: AdminItem) {
}
async function submit() {
if (editing.value) {
await updateAdmin(editing.value.id, {
//
editing.value
? await updateAdmin(editing.value.id, {
nickname: form.nickname,
status: form.status,
roleIds: form.roleIds,
});
} else {
await createAdmin({
})
: await createAdmin({
username: form.username,
password: form.password,
nickname: form.nickname,
roleIds: form.roleIds,
});
}
MessagePlugin.success('保存成功');
dialogOpen.value = false;
load();
gridApi.query();
}
/** 删除人员(保留原确认弹窗行为) */
function onDelete(row: AdminItem) {
const d = DialogPlugin.confirm({
header: '确认删除该人员?',
onConfirm: async () => {
await deleteAdmin(row.id);
MessagePlugin.success('删除成功');
load();
gridApi.query();
d.destroy();
},
});
}
/** 重置密码(需要输入新密码,保留原弹窗交互) */
function onResetPwd(row: AdminItem) {
let password = '';
const d = DialogPlugin.confirm({
@ -127,28 +118,98 @@ function onResetPwd(row: AdminItem) {
});
}
const columns = [
{ colKey: 'id', title: 'ID', width: 70 },
{ colKey: 'username', title: '用户名' },
{ colKey: 'nickname', title: '昵称' },
{ colKey: 'statusText', title: '状态', width: 90 },
{ colKey: 'roleNamesText', title: '角色' },
{ colKey: 'createdAt', title: '创建时间', width: 170 },
{ colKey: 'op', title: '操作', width: 200 },
];
const gridOptions: VxeTableGridOptions = {
columns: [
{ type: 'seq', width: 50, title: '#' },
{ field: 'id', title: 'ID', width: 70 },
{ field: 'username', title: '用户名' },
{ field: 'nickname', title: '昵称' },
{
field: 'statusText',
title: '状态',
width: 90,
slots: {
default: ({ row }: { row: AdminItem }) =>
h(
Tag,
{
theme: row.status === 1 ? 'success' : 'danger',
variant: 'light',
size: 'small',
},
row.status === 1 ? '启用' : '禁用',
),
},
},
{
field: 'roleNamesText',
title: '角色',
slots: {
default: ({ row }: { row: AdminItem }) =>
h('span', (row.roleNames ?? []).join(', ') || '-'),
},
},
{ field: 'createdAt', title: '创建时间', width: 170 },
{
field: 'op',
title: '操作',
width: 200,
fixed: 'right',
slots: {
default: ({ row }: { row: AdminItem }) =>
h(VbenTableAction, {
actions: [
//
{
text: '编辑',
onClick: () => openEdit(row),
auth: ['system:personnel:update'],
},
//
{
text: '重置密码',
onClick: () => onResetPwd(row),
auth: ['system:personnel:resetPwd'],
},
//
{
text: '删除',
danger: true,
onClick: () => onDelete(row),
auth: ['system:personnel:delete'],
},
],
}),
},
},
],
pagerConfig: { pageSize: 10 },
proxyConfig: {
ajax: {
query: async ({
page,
}: {
page: { currentPage: number; pageSize: number };
}) => {
const data = await getAdminList({
page: page.currentPage,
size: page.pageSize,
keyword: query.keyword || undefined,
});
return { items: data.list ?? [], total: data.total ?? 0 };
},
},
},
};
function onPageChange(p: { current: number; pageSize: number }) {
query.page = p.current;
query.size = p.pageSize;
pagination.current = p.current;
pagination.pageSize = p.pageSize;
load();
const [Grid, gridApi] = useVbenVxeGrid({ gridOptions });
/** 触发查询(保留分页条件,因 grid 自动管理分页) */
function search() {
gridApi.query();
}
onMounted(() => {
load();
loadRoles();
});
onMounted(loadRoles);
</script>
<template>
@ -166,9 +227,9 @@ onMounted(() => {
clearable
placeholder="用户名/昵称"
style="width: 220px"
@enter="load"
@enter="search"
/>
<t-button theme="primary" @click="load">查询</t-button>
<t-button theme="primary" @click="search">查询</t-button>
</div>
<t-button
v-if="hasAccessByCodes(['system:personnel:create'])"
@ -180,55 +241,9 @@ onMounted(() => {
</div>
</t-card>
<!-- 人员列表Vben Vxe Table + TableAction -->
<t-card :bordered="false" class="wb-table-card">
<t-table
row-key="id"
:data="list"
:columns="columns"
:loading="loading"
:pagination="pagination"
@page-change="onPageChange"
>
<template #statusText="{ row }">
{{
row.status === 1 ? '启用' : '禁用'
}}
</template>
<template #roleNamesText="{ row }">
{{
(row.roleNames ?? []).join(', ')
}}
</template>
<template #op="{ row }">
<t-button
v-if="hasAccessByCodes(['system:personnel:update'])"
size="small"
theme="primary"
variant="text"
@click="openEdit(row)"
>
编辑
</t-button>
<t-button
v-if="hasAccessByCodes(['system:personnel:resetPwd'])"
size="small"
theme="primary"
variant="text"
@click="onResetPwd(row)"
>
重置密码
</t-button>
<t-button
v-if="hasAccessByCodes(['system:personnel:delete'])"
size="small"
theme="danger"
variant="text"
@click="onDelete(row)"
>
删除
</t-button>
</template>
</t-table>
<Grid />
</t-card>
<t-dialog

View File

@ -1,69 +1,102 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { LoginLogItem } from '#/api/system';
import { onMounted, reactive, ref } from 'vue';
import { h, reactive } from 'vue';
import { useAccess } from '@vben/access';
import { Page } from '@vben/common-ui';
import { Tag } from 'tdesign-vue-next';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getLoginLogList } from '#/api/system';
// +
// Vben Vxe Table + / TDesign
const { hasAccessByCodes } = useAccess();
const loading = ref(false);
const list = ref<LoginLogItem[]>([]);
// username status -1
const query = reactive({ page: 1, size: 10, username: '', status: -1 });
const pagination = reactive({ current: 1, pageSize: 10, total: 0 });
const query = reactive({ username: '', status: -1 });
async function load() {
loading.value = true;
try {
const data = await getLoginLogList({
page: query.page,
size: query.size,
// status/failReason slots
const gridOptions: VxeTableGridOptions = {
columns: [
{ type: 'seq', width: 50, title: '#' },
{ field: 'id', title: 'ID', width: 70 },
{ field: 'username', title: '登录账号', width: 140 },
{ field: 'ip', title: '来源 IP', width: 140 },
{
field: 'userAgent',
title: '浏览器 UA',
width: 240,
showOverflow: 'tooltip',
},
{
field: 'status',
title: '结果',
width: 90,
slots: {
default: ({ row }: { row: LoginLogItem }) =>
h(
Tag,
{
theme: row.status === 1 ? 'success' : 'danger',
variant: 'light',
size: 'small',
},
row.status === 1 ? '成功' : '失败',
),
},
},
{
field: 'failReason',
title: '失败原因',
width: 220,
showOverflow: 'tooltip',
slots: {
default: ({ row }: { row: LoginLogItem }) =>
h(
'span',
{ class: row.failReason ? '' : 'text-gray-400' },
row.failReason || '-',
),
},
},
{ field: 'createdAt', title: '登录时间', width: 170 },
],
pagerConfig: { pageSize: 10 },
proxyConfig: {
ajax: {
query: async ({
page,
}: {
page: { currentPage: number; pageSize: number };
}) => {
const res = await getLoginLogList({
page: page.currentPage,
size: page.pageSize,
username: query.username || undefined,
status: query.status >= 0 ? query.status : undefined,
});
list.value = data.list ?? [];
pagination.total = data.total ?? 0;
} finally {
loading.value = false;
}
return { items: res.list ?? [], total: res.total ?? 0 };
},
},
},
};
const [Grid, gridApi] = useVbenVxeGrid({ gridOptions });
/** 触发查询(保留分页条件,因 grid 自动管理分页) */
function search() {
gridApi.query();
}
/** 重置筛选并回到第一页 */
/** 重置筛选并回到第一页重新查询 */
function reset() {
query.username = '';
query.status = -1;
query.page = 1;
pagination.current = 1;
load();
gridApi.reload();
}
/** 分页变化回调 */
function onPageChange(p: { current: number; pageSize: number }) {
query.page = p.current;
query.size = p.pageSize;
pagination.current = p.current;
pagination.pageSize = p.pageSize;
load();
}
//
const columns = [
{ colKey: 'id', title: 'ID', width: 70 },
{ colKey: 'username', title: '登录账号', width: 140 },
{ colKey: 'ip', title: '来源 IP', width: 140 },
{ colKey: 'userAgent', title: '浏览器 UA', width: 240 },
{ colKey: 'status', title: '结果', width: 90 },
{ colKey: 'failReason', title: '失败原因', width: 220 },
{ colKey: 'createdAt', title: '登录时间', width: 170 },
];
onMounted(load);
</script>
<template>
@ -82,7 +115,7 @@ onMounted(load);
clearable
placeholder="登录账号"
style="width: 180px"
@enter="load"
@enter="search"
/>
<t-select
v-model="query.status"
@ -96,7 +129,7 @@ onMounted(load);
<t-button
v-if="hasAccessByCodes(['system:login-log:list'])"
theme="primary"
@click="load"
@click="search"
>
查询
</t-button>
@ -105,31 +138,9 @@ onMounted(load);
</div>
</t-card>
<!-- 登录日志列表 -->
<!-- 登录日志列表Vben Vxe Table -->
<t-card :bordered="false" class="wb-table-card">
<t-table
row-key="id"
:data="list"
:columns="columns"
:loading="loading"
:pagination="pagination"
@page-change="onPageChange"
>
<template #status="{ row }">
<t-tag
:theme="row.status === 1 ? 'success' : 'danger'"
variant="light"
size="small"
>
{{ row.status === 1 ? '成功' : '失败' }}
</t-tag>
</template>
<template #failReason="{ row }">
<span :title="row.failReason">{{
(row.failReason || '-').slice(0, 30)
}}</span>
</template>
</t-table>
<Grid />
</t-card>
</Page>
</template>

View File

@ -1,13 +1,15 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MenuNode, RoleItem } from '#/api/system';
import { onMounted, reactive, ref } from 'vue';
import { h, onMounted, reactive, ref } from 'vue';
import { useAccess } from '@vben/access';
import { Page } from '@vben/common-ui';
import { Page, VbenTableAction } from '@vben/common-ui';
import { DialogPlugin, MessagePlugin } from 'tdesign-vue-next';
import { DialogPlugin, MessagePlugin, Tag } from 'tdesign-vue-next';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import {
createRole,
deleteRole,
@ -16,16 +18,15 @@ import {
updateRole,
} from '#/api/system';
// Vben Vxe Table + Vben TableAction
const { hasAccessByCodes } = useAccess();
const loading = ref(false);
const list = ref<RoleItem[]>([]);
const menuTree = ref<any[]>([]);
const dialogOpen = ref(false);
const editing = ref<null | RoleItem>(null);
const query = reactive({ page: 1, size: 10, keyword: '' });
const pagination = reactive({ current: 1, pageSize: 10, total: 0 });
// grid
const query = reactive({ keyword: '' });
const form = reactive({
code: '',
name: '',
@ -33,6 +34,7 @@ const form = reactive({
menuIds: [] as number[],
});
/** 后端菜单树转 TDesign 树选择器数据 */
function toTree(nodes: MenuNode[]): any[] {
return (nodes ?? []).map((n) => ({
label: n.name,
@ -41,17 +43,6 @@ function toTree(nodes: MenuNode[]): any[] {
}));
}
async function load() {
loading.value = true;
try {
const data = await getRoleList(query);
list.value = data.list ?? [];
pagination.total = data.total ?? 0;
} finally {
loading.value = false;
}
}
async function loadMenus() {
const data = await getMenuTree();
menuTree.value = toTree(data.tree ?? []);
@ -75,58 +66,115 @@ function openEdit(row: RoleItem) {
}
async function submit() {
if (editing.value) {
await updateRole(editing.value.id, {
//
editing.value
? await updateRole(editing.value.id, {
name: form.name,
status: form.status,
menuIds: form.menuIds,
});
} else {
await createRole({
})
: await createRole({
code: form.code,
name: form.name,
status: form.status,
menuIds: form.menuIds,
});
}
MessagePlugin.success('保存成功');
dialogOpen.value = false;
load();
gridApi.query();
}
/** 删除角色(保留原确认弹窗行为) */
function onDelete(row: RoleItem) {
const d = DialogPlugin.confirm({
header: `确认删除角色「${row.name}」?`,
onConfirm: async () => {
await deleteRole(row.id);
MessagePlugin.success('删除成功');
load();
gridApi.query();
d.destroy();
},
});
}
const columns = [
{ colKey: 'id', title: 'ID', width: 70 },
{ colKey: 'code', title: '角色编码' },
{ colKey: 'name', title: '角色名称' },
{ colKey: 'statusText', title: '状态', width: 90 },
{ colKey: 'createdAt', title: '创建时间', width: 170 },
{ colKey: 'op', title: '操作', width: 150 },
];
const gridOptions: VxeTableGridOptions = {
columns: [
{ type: 'seq', width: 50, title: '#' },
{ field: 'id', title: 'ID', width: 70 },
{ field: 'code', title: '角色编码' },
{ field: 'name', title: '角色名称' },
{
field: 'statusText',
title: '状态',
width: 90,
slots: {
default: ({ row }: { row: RoleItem }) =>
h(
Tag,
{
theme: row.status === 1 ? 'success' : 'danger',
variant: 'light',
size: 'small',
},
row.status === 1 ? '启用' : '禁用',
),
},
},
{ field: 'createdAt', title: '创建时间', width: 170 },
{
field: 'op',
title: '操作',
width: 150,
fixed: 'right',
slots: {
default: ({ row }: { row: RoleItem }) =>
h(VbenTableAction, {
actions: [
//
{
text: '编辑',
onClick: () => openEdit(row),
auth: ['system:role:update'],
},
//
{
text: '删除',
danger: true,
onClick: () => onDelete(row),
auth: ['system:role:delete'],
},
],
}),
},
},
],
pagerConfig: { pageSize: 10 },
proxyConfig: {
ajax: {
query: async ({
page,
}: {
page: { currentPage: number; pageSize: number };
}) => {
const data = await getRoleList({
page: page.currentPage,
size: page.pageSize,
keyword: query.keyword || undefined,
});
return { items: data.list ?? [], total: data.total ?? 0 };
},
},
},
};
function onPageChange(p: { current: number; pageSize: number }) {
query.page = p.current;
query.size = p.pageSize;
pagination.current = p.current;
pagination.pageSize = p.pageSize;
load();
const [Grid, gridApi] = useVbenVxeGrid({ gridOptions });
/** 触发查询(保留分页条件,因 grid 自动管理分页) */
function search() {
gridApi.query();
}
onMounted(() => {
load();
loadMenus();
});
onMounted(loadMenus);
</script>
<template>
@ -144,9 +192,9 @@ onMounted(() => {
clearable
placeholder="角色编码/名称"
style="width: 220px"
@enter="load"
@enter="search"
/>
<t-button theme="primary" @click="load">查询</t-button>
<t-button theme="primary" @click="search">查询</t-button>
</div>
<t-button
v-if="hasAccessByCodes(['system:role:create'])"
@ -158,41 +206,9 @@ onMounted(() => {
</div>
</t-card>
<!-- 角色列表Vben Vxe Table + TableAction -->
<t-card :bordered="false" class="wb-table-card">
<t-table
row-key="id"
:data="list"
:columns="columns"
:loading="loading"
:pagination="pagination"
@page-change="onPageChange"
>
<template #statusText="{ row }">
{{
row.status === 1 ? '启用' : '禁用'
}}
</template>
<template #op="{ row }">
<t-button
v-if="hasAccessByCodes(['system:role:update'])"
size="small"
theme="primary"
variant="text"
@click="openEdit(row)"
>
编辑
</t-button>
<t-button
v-if="hasAccessByCodes(['system:role:delete'])"
size="small"
theme="danger"
variant="text"
@click="onDelete(row)"
>
删除
</t-button>
</template>
</t-table>
<Grid />
</t-card>
<t-dialog