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 变更记录 # admin.xpcool.com 变更记录
> 倒序最新在上。格式YYYY-MM-DD | 类型 | 摘要 > 倒序最新在上。格式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-27 | FIX | 恢复丢失的前端代码recovery 分支经 rebase/改名(dev↔main)折腾后工作树回退,未提交改动全丢;从悬空提交 f02a514recovery 工作树快照,含 HEAD 缺失的全部文件)整体恢复 apps/web-tdesign/src——含 styles/theme.css 全局主题、dashboard/workbench 工作台页、system 五页卡片化美化、操作日志增强版(VxeTable)、house 预售/楼盘地图、recruitment 页面;另补 log 页卡片美化(wb-page-head+t-card)。已提交保护
2026-08-26 | CFG | 安装 TDesign MCP~/.workbuddy/mcp.json 新增 tdesign-mcp-server: `npx -y tdesign-mcp-server@latest`v0.2.4 启动验证通过);约定:后续后台界面开发优先用 TDesign MCP 查官方文档get-component-docs 等) 2026-08-26 | CFG | 安装 TDesign MCP~/.workbuddy/mcp.json 新增 tdesign-mcp-server: `npx -y tdesign-mcp-server@latest`v0.2.4 启动验证通过);约定:后续后台界面开发优先用 TDesign MCP 查官方文档get-component-docs 等)
2026-08-26 | FIX+CHG | 修复前端编译错误access.ts layoutMap 重复声明致 vite Transform PARSE_ERROR菜单管理树形改用 tdesign 官方方案:`t-enhanced-table`t-table 不支持 tree+ tree{childrenKey/treeNodeColumnIndex/defaultExpandAll/indent} + 异步数据后 `ref.expandAll()`defaultExpandAll 仅首次渲染生效)——替代先前扁平化+缩进的自造方案 2026-08-26 | FIX+CHG | 修复前端编译错误access.ts layoutMap 重复声明致 vite Transform PARSE_ERROR菜单管理树形改用 tdesign 官方方案:`t-enhanced-table`t-table 不支持 tree+ tree{childrenKey/treeNodeColumnIndex/defaultExpandAll/indent} + 异步数据后 `ref.expandAll()`defaultExpandAll 仅首次渲染生效)——替代先前扁平化+缩进的自造方案

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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