sync: 同步管理前端 v1.0.4 并脱敏
This commit is contained in:
@@ -18,4 +18,4 @@ dockerbuild: build-no-build
|
||||
echo "docker build success"
|
||||
dockerpush:
|
||||
docker tag docker_iotlcmadminui:v0 ${REGISTRY}/docker_iotlcmadminui:v0
|
||||
docker push ${REGISTRY}/$1:$2
|
||||
docker push ${REGISTRY}/docker_iotlcmadminui:v0
|
||||
Regular → Executable
Regular → Executable
@@ -11,7 +11,7 @@
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
"name": "vben",
|
||||
"email": "annvben@example.com",
|
||||
"email": "xxx@example.com",
|
||||
"url": "https://github.com/anncwb"
|
||||
},
|
||||
"type": "module",
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
"loginSloganImage": "",
|
||||
"copyright": {
|
||||
"companyName": "示例智联",
|
||||
"companySiteLink": "https://www.xiujiezhilian.cn",
|
||||
"companySiteLink": "https://example.com",
|
||||
"date": "2024-2025",
|
||||
"icp": "鲁ICP备2024117944号-1",
|
||||
"icp": "",
|
||||
"icpLink": "https://beian.miit.gov.cn/"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,11 @@ import type { VxeTableGridOptions } from '@vben/plugins/vxe-table';
|
||||
|
||||
import { h } from 'vue';
|
||||
|
||||
import { useVbenForm } from '@vben/common-ui';
|
||||
import { setupVbenVxeTable, useVbenVxeGrid } from '@vben/plugins/vxe-table';
|
||||
|
||||
import { Button, Image } from 'ant-design-vue';
|
||||
|
||||
import { useVbenForm } from './form';
|
||||
|
||||
setupVbenVxeTable({
|
||||
configVxeTable: (vxeUI) => {
|
||||
vxeUI.setConfig({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { requestClient } from '#/api/request';
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
export interface AgentDept {
|
||||
deptId: number;
|
||||
@@ -60,6 +60,8 @@ export interface MenuTreeItem {
|
||||
}
|
||||
|
||||
export interface AgentRoleView extends AgentRole {
|
||||
isTemplateRole: boolean;
|
||||
isDeptDefaultRole: boolean;
|
||||
menuIds: number[];
|
||||
deptIds: number[];
|
||||
allowedMenuIds: number[];
|
||||
@@ -89,11 +91,35 @@ export interface AgentUser {
|
||||
isDefaultUser?: boolean;
|
||||
}
|
||||
|
||||
export function getAgentDeptListApi(params?: Record<string, any>) {
|
||||
return requestClient.get<{ items: AgentDept[]; total: number }>(
|
||||
'/agent/dept/list',
|
||||
{ params },
|
||||
);
|
||||
export interface AgentDeptListParams {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
deptName?: string;
|
||||
status?: string;
|
||||
deptType?: number;
|
||||
}
|
||||
|
||||
export function exportAgentDeptApi(params?: Record<string, unknown>) {
|
||||
return requestClient.get<Blob>('/agent/dept/export', {
|
||||
params,
|
||||
responseType: 'blob',
|
||||
});
|
||||
}
|
||||
|
||||
export function exportAgentUserApi(params?: Record<string, unknown>) {
|
||||
return requestClient.get<Blob>('/agent/user/export', {
|
||||
params,
|
||||
responseType: 'blob',
|
||||
});
|
||||
}
|
||||
|
||||
export function getAgentDeptListApi(params?: AgentDeptListParams) {
|
||||
return requestClient.get<{
|
||||
items: AgentDept[];
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
}>('/agent/dept/list', { params });
|
||||
}
|
||||
|
||||
export function getAgentDeptViewApi(params: { deptId: number }) {
|
||||
@@ -110,7 +136,10 @@ export function deleteAgentDeptApi(params: { deptId: number }) {
|
||||
return requestClient.post('/agent/dept/delete', { ...params });
|
||||
}
|
||||
|
||||
export function resetAgentDeptPasswordApi(params: { deptId: number; password: string }) {
|
||||
export function resetAgentDeptPasswordApi(params: {
|
||||
deptId: number;
|
||||
password: string;
|
||||
}) {
|
||||
return requestClient.post('/agent/dept/resetPassword', { ...params });
|
||||
}
|
||||
|
||||
@@ -133,11 +162,17 @@ export function updateAgentUserApi(params: Record<string, any>) {
|
||||
return requestClient.post('/agent/user/update', { ...params });
|
||||
}
|
||||
|
||||
export function deleteAgentUserApi(params: { userId?: number; userIds?: number[] }) {
|
||||
export function deleteAgentUserApi(params: {
|
||||
userId?: number;
|
||||
userIds?: number[];
|
||||
}) {
|
||||
return requestClient.post('/agent/user/delete', { ...params });
|
||||
}
|
||||
|
||||
export function resetAgentUserPasswordApi(params: { userId: number; password: string }) {
|
||||
export function resetAgentUserPasswordApi(params: {
|
||||
password: string;
|
||||
userId: number;
|
||||
}) {
|
||||
return requestClient.post('/agent/user/resetPassword', { ...params });
|
||||
}
|
||||
|
||||
@@ -158,7 +193,10 @@ export function saveAgentRoleApi(params: Record<string, any>) {
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteAgentRoleApi(params: { roleId?: number; roleIds?: number[] }) {
|
||||
export function deleteAgentRoleApi(params: {
|
||||
roleId?: number;
|
||||
roleIds?: number[];
|
||||
}) {
|
||||
return requestClient.post('/agent/role/delete', { ...params });
|
||||
}
|
||||
|
||||
@@ -167,4 +205,3 @@ export function getAgentMenuTreeApi() {
|
||||
'/agent/menu/tree',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
export interface DownloadCenterListParam {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
keyword?: string;
|
||||
bizKey?: string;
|
||||
/** 多个状态用逗号分隔:0排队 2生成中 1已完成 3失败 */
|
||||
status?: string;
|
||||
createdAt?: string[];
|
||||
}
|
||||
|
||||
export interface DownloadCenterItem {
|
||||
id: number;
|
||||
taskNo: string;
|
||||
bizKey: string;
|
||||
bizName: string;
|
||||
status: number;
|
||||
totalCount: number;
|
||||
fileName: string;
|
||||
fileSize: number;
|
||||
errorMessage?: string;
|
||||
createdAt?: string;
|
||||
finishTime?: string;
|
||||
expireTime?: string;
|
||||
deletedTime?: null | string;
|
||||
deleteReason?: '' | 'expired' | 'user';
|
||||
}
|
||||
|
||||
export interface DownloadCenterListResult {
|
||||
items: DownloadCenterItem[];
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface DownloadCenterRecentResult {
|
||||
items: DownloadCenterItem[];
|
||||
runningCount: number;
|
||||
finishedCount: number;
|
||||
}
|
||||
|
||||
/** 下载中心列表 */
|
||||
export function List(params: DownloadCenterListParam) {
|
||||
return requestClient.get<DownloadCenterListResult>(
|
||||
'/common/downloadCenter/list',
|
||||
{
|
||||
params,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** 右上角入口:最近任务与进行中数量。
|
||||
* 这是辅助信息,无权限或后端未就绪时静默失败,不能弹全局错误打扰用户。 */
|
||||
export function Recent(limit = 5) {
|
||||
return requestClient.get<DownloadCenterRecentResult>(
|
||||
'/common/downloadCenter/recent',
|
||||
{ params: { limit }, errorMessageMode: 'none' },
|
||||
);
|
||||
}
|
||||
|
||||
/** 下载导出文件:业务错误可能以 HTTP 200 JSON Blob 返回,不能当作 xlsx 保存。 */
|
||||
export async function Download(taskId: number): Promise<Blob> {
|
||||
const response = await requestClient.download<
|
||||
import('axios').AxiosResponse<Blob>
|
||||
>(`/common/downloadCenter/download?taskId=${taskId}`, {
|
||||
timeout: 120 * 1000,
|
||||
responseReturn: 'raw',
|
||||
});
|
||||
const blob = response.data;
|
||||
const contentType = String(
|
||||
response.headers['content-type'] || blob?.type || '',
|
||||
).toLowerCase();
|
||||
if (contentType.includes('json')) {
|
||||
let errorMessage = '下载失败,请稍后重试';
|
||||
try {
|
||||
const payload = JSON.parse(await blob.text());
|
||||
errorMessage = payload?.message || errorMessage;
|
||||
} catch {
|
||||
// Malformed JSON must not be saved as a spreadsheet either.
|
||||
}
|
||||
message.error(errorMessage);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
if (!(blob instanceof Blob) || blob.size === 0) {
|
||||
const errorMessage = '下载文件为空,请稍后重试';
|
||||
message.error(errorMessage);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
return blob;
|
||||
}
|
||||
|
||||
/** 删除导出任务 */
|
||||
export function Remove(taskId: number) {
|
||||
return requestClient.post('/common/downloadCenter/remove', { taskId });
|
||||
}
|
||||
@@ -27,3 +27,37 @@ export function Export(params:any) {
|
||||
responseType: 'blob',
|
||||
});
|
||||
}
|
||||
|
||||
// 未读消息通知(右上角消息提醒)
|
||||
export interface TMessageLogUnreadItem {
|
||||
messageLogId: number;
|
||||
messageType: number;
|
||||
sendUserType: number;
|
||||
messageTitle: string;
|
||||
messageContent: string;
|
||||
messageLink: string;
|
||||
sendTime: string;
|
||||
isRead: number;
|
||||
}
|
||||
|
||||
export interface TMessageLogUnreadResult {
|
||||
items: TMessageLogUnreadItem[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
// 获取当前登录用户未读消息通知(右上角消息提醒)
|
||||
export function UnreadList(params?: { limit?: number }) {
|
||||
return requestClient.get<TMessageLogUnreadResult>('common/tMessageLog/unreadList', {
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
// 标记当前登录用户指定消息已读
|
||||
export function Read(params: { messageLogId: number | string }) {
|
||||
return requestClient.post<unknown>('common/tMessageLog/read', { ...params });
|
||||
}
|
||||
|
||||
// 标记当前登录用户全部消息已读
|
||||
export function ReadAll() {
|
||||
return requestClient.post<unknown>('common/tMessageLog/readAll', {});
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @description 认证相关接口
|
||||
* @Link https://github.com/xiujiecn/xiu-admin
|
||||
* @Copyright Copyright (c) 2025 LiXiujie
|
||||
* @Author Lxj <li@xiujie.cn>
|
||||
* @Author Lxj <xxx@example.com>
|
||||
* @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
* @date 2024-03-21
|
||||
*/
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @description 验证码相关接口
|
||||
* @Link https://github.com/xiujiecn/xiu-admin
|
||||
* @Copyright Copyright (c) 2025 LiXiujie
|
||||
* @Author Lxj <li@xiujie.cn>
|
||||
* @Author Lxj <xxx@example.com>
|
||||
* @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
* @date 2024-03-21
|
||||
*/
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @description 核心模块接口导出
|
||||
* @Link https://github.com/xiujiecn/xiu-admin
|
||||
* @Copyright Copyright (c) 2025 LiXiujie
|
||||
* @Author Lxj <li@xiujie.cn>
|
||||
* @Author Lxj <xxx@example.com>
|
||||
* @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
* @date 2024-03-21
|
||||
*/
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @description 菜单相关接口
|
||||
* @Link https://github.com/xiujiecn/xiu-admin
|
||||
* @Copyright Copyright (c) 2025 LiXiujie
|
||||
* @Author Lxj <li@xiujie.cn>
|
||||
* @Author Lxj <xxx@example.com>
|
||||
* @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
* @date 2024-03-21
|
||||
*/
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @description 文件上传相关接口
|
||||
* @Link https://github.com/xiujiecn/xiu-admin
|
||||
* @Copyright Copyright (c) 2025 LiXiujie
|
||||
* @Author Lxj <li@xiujie.cn>
|
||||
* @Author Lxj <xxx@example.com>
|
||||
* @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
* @date 2024-03-21
|
||||
*/
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @description 用户信息相关接口
|
||||
* @Link https://github.com/xiujiecn/xiu-admin
|
||||
* @Copyright Copyright (c) 2025 LiXiujie
|
||||
* @Author Lxj <li@xiujie.cn>
|
||||
* @Author Lxj <xxx@example.com>
|
||||
* @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
* @date 2024-03-21
|
||||
*/
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @description 测试单表相关接口
|
||||
* @Link https://github.com/xiujiecn/xiu-admin
|
||||
* @Copyright Copyright (c) 2025 LiXiujie
|
||||
* @Author Lxj <li@xiujie.cn>
|
||||
* @Author Lxj <xxx@example.com>
|
||||
* @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
* @date 2024-03-21
|
||||
*/
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @description 代码生成器表格相关接口
|
||||
* @Link https://github.com/xiujiecn/xiu-admin
|
||||
* @Copyright Copyright (c) 2025 LiXiujie
|
||||
* @Author Lxj <li@xiujie.cn>
|
||||
* @Author Lxj <xxx@example.com>
|
||||
* @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
* @date 2024-03-21
|
||||
*/
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @description API接口统一导出
|
||||
* @Link https://github.com/xiujiecn/xiu-admin
|
||||
* @Copyright Copyright (c) 2025 LiXiujie
|
||||
* @Author Lxj <li@xiujie.cn>
|
||||
* @Author Lxj <xxx@example.com>
|
||||
* @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
* @date 2024-03-21
|
||||
*/
|
||||
|
||||
@@ -5,6 +5,11 @@ export function List(params: any) {
|
||||
return requestClient.get<any>('iot/tSimCardInfo/list', { params });
|
||||
}
|
||||
|
||||
// 批量号码查询使用 POST,避免较长号码列表超过 URL 长度限制
|
||||
export function BatchList(params: any) {
|
||||
return requestClient.post<any>('iot/tSimCardInfo/list', { ...params });
|
||||
}
|
||||
|
||||
// 删除/批量删除卡信息表
|
||||
export function Delete(params: any) {
|
||||
return requestClient.post<any>('iot/tSimCardInfo/delete', { ...params });
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import type {
|
||||
ExportColumn,
|
||||
SimCardQueryExportParam,
|
||||
SimCardQuerySubmitRes,
|
||||
SimCardUsageHistoryColumnsRes,
|
||||
SimCardUsageHistoryDeptOptionsRes,
|
||||
SimCardUsageHistoryExportParam,
|
||||
} from './type';
|
||||
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
/** 卡基础信息查询:下载 Excel 模板 */
|
||||
export function DownloadBaseTemplate() {
|
||||
return requestClient.download('/iot/tSimCardBaseQuery/template', {
|
||||
responseReturn: 'raw',
|
||||
});
|
||||
}
|
||||
|
||||
/** 卡基础信息查询:可选导出列 */
|
||||
export function BaseColumns(): Promise<{ items: ExportColumn[] }> {
|
||||
return requestClient.get('/iot/tSimCardBaseQuery/columns');
|
||||
}
|
||||
|
||||
/**
|
||||
* 两个导出接口共用的 multipart 组装。
|
||||
* 提交字段只在这里维护,避免 BaseExport / UsageExport 各写一份后口径漂移。
|
||||
*/
|
||||
function buildExportForm(params: SimCardQueryExportParam): FormData {
|
||||
const form = new FormData();
|
||||
form.append('mode', params.mode);
|
||||
form.append('exportColumnsJson', JSON.stringify(params.exportColumns ?? []));
|
||||
if (params.batchInput !== undefined) {
|
||||
form.append('batchInput', params.batchInput);
|
||||
}
|
||||
if (params.dimension !== undefined) {
|
||||
form.append('dimension', params.dimension);
|
||||
}
|
||||
if (params.startNum !== undefined) {
|
||||
form.append('startNum', params.startNum);
|
||||
}
|
||||
if (params.endNum !== undefined) {
|
||||
form.append('endNum', params.endNum);
|
||||
}
|
||||
return form;
|
||||
}
|
||||
|
||||
/**
|
||||
* 卡基础信息查询:提交导出。
|
||||
* 统一走 multipart:Excel 模式带 file,其余模式只带表单字段,
|
||||
* 保持三个输入方式的请求路径一致。
|
||||
*/
|
||||
export function BaseExport(
|
||||
params: SimCardQueryExportParam & { file?: File },
|
||||
): Promise<SimCardQuerySubmitRes> {
|
||||
const form = buildExportForm(params);
|
||||
if (params.file) {
|
||||
form.append('file', params.file);
|
||||
}
|
||||
return requestClient.post('/iot/tSimCardBaseQuery/export', form, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
timeout: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** 月历史用量查询:下载 Excel 模板 */
|
||||
export function DownloadUsageTemplate() {
|
||||
return requestClient.download('/iot/tSimCardUsageHistory/template', {
|
||||
responseReturn: 'raw',
|
||||
});
|
||||
}
|
||||
|
||||
/** 月历史用量查询:固定列与近12个月月份清单 */
|
||||
export function UsageColumns(): Promise<SimCardUsageHistoryColumnsRes> {
|
||||
return requestClient.get('/iot/tSimCardUsageHistory/columns');
|
||||
}
|
||||
|
||||
/** 月历史用量查询:可选公司,展示范围由数据权限决定 */
|
||||
export function UsageDeptOptions(): Promise<SimCardUsageHistoryDeptOptionsRes> {
|
||||
return requestClient.get('/iot/tSimCardUsageHistory/deptOptions');
|
||||
}
|
||||
|
||||
/** 月历史用量查询:提交导出 */
|
||||
export function UsageExport(
|
||||
params: SimCardUsageHistoryExportParam & { file?: File },
|
||||
): Promise<SimCardQuerySubmitRes> {
|
||||
// deptId 是 dept 模式的必填项,缺省时不能悄悄提交一个没有公司范围的导出请求。
|
||||
if (params.mode === 'dept' && !(Number(params.deptId) > 0)) {
|
||||
return Promise.reject(new Error('按所属公司查询时必须选择具体公司'));
|
||||
}
|
||||
const form = buildExportForm(params);
|
||||
if (params.deptId !== undefined && params.deptId > 0) {
|
||||
form.append('deptId', String(params.deptId));
|
||||
}
|
||||
if (params.expireStartTime) {
|
||||
form.append('expireStartTime', params.expireStartTime);
|
||||
}
|
||||
if (params.expireEndTime) {
|
||||
form.append('expireEndTime', params.expireEndTime);
|
||||
}
|
||||
if (params.file) {
|
||||
form.append('file', params.file);
|
||||
}
|
||||
return requestClient.post('/iot/tSimCardUsageHistory/export', form, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
timeout: 60_000,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
export interface ExportColumn {
|
||||
/** 列表模型字段名,后端按它取值 */
|
||||
field: string;
|
||||
/** 表头文字 */
|
||||
label: string;
|
||||
dictType?: string;
|
||||
}
|
||||
|
||||
export type QueryMode = 'excel' | 'input' | 'range' | 'dept';
|
||||
|
||||
export interface SimCardQueryExportParam {
|
||||
/** 输入方式 */
|
||||
mode: QueryMode;
|
||||
/** 批量输入内容,每行 ICCID 或 ICCID MSISDN IMSI */
|
||||
batchInput?: string;
|
||||
/** 连号维度:iccid / msisdn / imsi */
|
||||
dimension?: string;
|
||||
startNum?: string;
|
||||
endNum?: string;
|
||||
/** 导出列,后端只认白名单内的字段 */
|
||||
exportColumns?: ExportColumn[];
|
||||
}
|
||||
|
||||
export interface SimCardUsageHistoryExportParam extends SimCardQueryExportParam {
|
||||
/** 所属公司部门ID,mode=dept 时必填 */
|
||||
deptId?: number;
|
||||
/** 到期日期起(含),仅 mode=dept 生效 */
|
||||
expireStartTime?: string;
|
||||
/** 到期日期止(含),仅 mode=dept 生效 */
|
||||
expireEndTime?: string;
|
||||
}
|
||||
|
||||
export interface SimCardQuerySubmitRes {
|
||||
taskId: number;
|
||||
taskNo: string;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface SimCardQueryDeptOption {
|
||||
deptId: number;
|
||||
deptName: string;
|
||||
cardCount: number;
|
||||
parentId: number;
|
||||
}
|
||||
|
||||
export interface SimCardUsageHistoryColumnsRes {
|
||||
items: ExportColumn[];
|
||||
months: string[];
|
||||
}
|
||||
|
||||
export interface SimCardUsageHistoryDeptOptionsRes {
|
||||
items: SimCardQueryDeptOption[];
|
||||
/** 当前登录用户所属部门,用于公司下拉默认值 */
|
||||
currentDeptId: number;
|
||||
}
|
||||
@@ -37,6 +37,7 @@ export interface TSimCardTrafficCutoffModel {
|
||||
}
|
||||
|
||||
export interface TSimCardTrafficCutoffListParam {
|
||||
includeUnconfigured?: boolean;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
cardValue?: string;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { TSimCardUnsubSubmitRes } from './type';
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const download = vi.fn();
|
||||
@@ -80,8 +82,7 @@ describe('tSimCardUnsub api', () => {
|
||||
});
|
||||
|
||||
it('exposes batchAsyncStatus on the submit response type so async Pending is representable', async () => {
|
||||
const typeModule = await import('./type');
|
||||
const res: typeof typeModule.TSimCardUnsubSubmitRes.prototype = {
|
||||
const res: TSimCardUnsubSubmitRes = {
|
||||
batchAsyncStatus: 0,
|
||||
batchNo: 'B202607210001',
|
||||
batchType: 1,
|
||||
@@ -123,4 +124,3 @@ describe('tSimCardUnsub api', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { Cards, WarehousingList } from './index';
|
||||
|
||||
const request = vi.hoisted(() => ({ get: vi.fn() }));
|
||||
vi.mock('#/api/request', () => ({ requestClient: request }));
|
||||
|
||||
describe('入库列表渠道筛选请求', () => {
|
||||
it('卡清单按服务端状态筛选,保留状态零并允许清空', async () => {
|
||||
for (const detailStatus of [0, 3, 4, undefined]) {
|
||||
const params = {
|
||||
batchNo: 'INB-TEST',
|
||||
detailStatus,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
};
|
||||
await Cards(params);
|
||||
expect(request.get).toHaveBeenLastCalledWith('common/batchMain/cards', {
|
||||
params,
|
||||
});
|
||||
}
|
||||
});
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('切换渠道后每次传递当前编码并保留入库类型', async () => {
|
||||
for (const chanKey of ['A', 'B']) {
|
||||
await WarehousingList({ chanKey, page: 1, pageSize: 20 });
|
||||
expect(request.get).toHaveBeenLastCalledWith(
|
||||
'common/batchMain/successList',
|
||||
{
|
||||
params: { batchType: 1, chanKey, page: 1, pageSize: 20 },
|
||||
},
|
||||
);
|
||||
}
|
||||
expect(request.get).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('清空渠道不残留上一次筛选条件', async () => {
|
||||
await WarehousingList({ chanKey: 'A', page: 1 });
|
||||
await WarehousingList({ page: 1 });
|
||||
expect(request.get).toHaveBeenLastCalledWith(
|
||||
'common/batchMain/successList',
|
||||
{
|
||||
params: { batchType: 1, page: 1 },
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -52,6 +52,20 @@ export function View(params: BatchMainViewParam) {
|
||||
});
|
||||
}
|
||||
|
||||
// 导出入库批次列表(数据与入库管理页一致)
|
||||
export function ExportWarehousing(params: BatchMainWarehousingListParam) {
|
||||
return requestClient.post<Blob>(
|
||||
'/common/batchMain/exportWarehousing',
|
||||
{ ...params, batchType: 1 },
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
|
||||
},
|
||||
responseType: 'blob',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// 导出批次主表
|
||||
export function Export(params: BatchMainExportParam) {
|
||||
return requestClient.post<Blob>(
|
||||
|
||||
@@ -147,13 +147,16 @@ export interface BatchMainListParam {
|
||||
export type BatchMainWarehousingListParam = Omit<
|
||||
BatchMainListParam,
|
||||
'batchType'
|
||||
>;
|
||||
> & {
|
||||
chanKey?: string;
|
||||
};
|
||||
|
||||
/** 获取批次主表列表 响应 */
|
||||
export type BatchMainListRes = GlobalListRes<BatchMainModel>;
|
||||
|
||||
/** 批次卡清单列表项 */
|
||||
export interface BatchCardListModel extends AllocationUnits {
|
||||
errorReason?: string;
|
||||
/** 明细状态 */
|
||||
detailStatus?: number;
|
||||
/** ICCID */
|
||||
@@ -169,6 +172,7 @@ export interface BatchCardListModel extends AllocationUnits {
|
||||
|
||||
/** 获取批次卡清单请求参数 */
|
||||
export interface BatchMainCardsParam {
|
||||
detailStatus?: number;
|
||||
/** 批次编号 */
|
||||
batchNo: string;
|
||||
/** 批次类型 */
|
||||
|
||||
@@ -45,7 +45,7 @@ describe('tSimCardOutbound api', () => {
|
||||
'/iot/tSimCardOutbound/excel/submit',
|
||||
{ file },
|
||||
{
|
||||
timeout: 60_000,
|
||||
timeout: 180_000,
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -71,6 +71,7 @@ describe('tSimCardOutbound api', () => {
|
||||
expect(post).toHaveBeenCalledWith(
|
||||
'/iot/tSimCardOutbound/range/submit',
|
||||
params,
|
||||
{ timeout: 180_000 },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -117,12 +118,9 @@ describe('tSimCardOutbound api', () => {
|
||||
|
||||
CustomerBalance(params);
|
||||
|
||||
expect(get).toHaveBeenCalledWith(
|
||||
'/iot/tSimCardOutbound/customerBalance',
|
||||
{
|
||||
expect(get).toHaveBeenCalledWith('/iot/tSimCardOutbound/customerBalance', {
|
||||
params,
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('submits inbound batch outbound with backend-supported payload', async () => {
|
||||
@@ -141,6 +139,7 @@ describe('tSimCardOutbound api', () => {
|
||||
expect(post).toHaveBeenCalledWith(
|
||||
'/iot/tSimCardOutbound/batch/submit',
|
||||
params,
|
||||
{ timeout: 180_000 },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -160,6 +159,7 @@ describe('tSimCardOutbound api', () => {
|
||||
expect(post).toHaveBeenCalledWith(
|
||||
'/iot/tSimCardOutbound/input/submit',
|
||||
params,
|
||||
{ timeout: 180_000 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,6 +31,8 @@ export interface TSimCardStockModel {
|
||||
tenantId: string;
|
||||
/** 创建部门 */
|
||||
createdDept: number;
|
||||
/** 创建部门名称 */
|
||||
createdDeptName?: string;
|
||||
/** 创建者 */
|
||||
createdBy: number;
|
||||
/** 创建者摘要信息 */
|
||||
|
||||
@@ -5,6 +5,8 @@ import type {
|
||||
BatchExecutionLogListRes,
|
||||
BatchExecutionLogSummaryParam,
|
||||
BatchExecutionLogSummaryRes,
|
||||
BatchExecutionLogSyncReviewParam,
|
||||
BatchExecutionLogSyncReviewRes,
|
||||
} from './type';
|
||||
|
||||
import { requestClient } from '#/api/request';
|
||||
@@ -38,3 +40,11 @@ export function DetailList(params: BatchExecutionLogDetailListParam) {
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// 手动同步待核查批次的上游执行结果(只读补查既有受理号,不重发办理)
|
||||
export function SyncReview(data: BatchExecutionLogSyncReviewParam) {
|
||||
return requestClient.post<BatchExecutionLogSyncReviewRes>(
|
||||
'common/batchExecutionLog/syncReview',
|
||||
{ ...data },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,12 +20,18 @@ export interface BatchExecutionLogModel extends AllocationUnits {
|
||||
batchNo?: string;
|
||||
/** 异步处理状态 */
|
||||
batchAsyncStatus?: number;
|
||||
/** 重启阶段:waiting_resume 表示停机后等待复机 */
|
||||
restartPhase?: string;
|
||||
/** 计划复机时间 */
|
||||
restartResumeAt?: string;
|
||||
/** 批次类型 */
|
||||
batchType?: number;
|
||||
/** 来源方式 */
|
||||
sourceType?: null | number;
|
||||
/** 总数量 */
|
||||
totalCount?: number;
|
||||
/** 待核查数量 */
|
||||
unknownCount?: number;
|
||||
/** 运行结果 */
|
||||
runResult?: number;
|
||||
/** 开始时间 */
|
||||
@@ -52,12 +58,18 @@ export interface BatchExecutionLogSummaryRes extends AllocationUnits {
|
||||
batchNo?: string;
|
||||
/** 异步处理状态 */
|
||||
batchAsyncStatus?: number;
|
||||
/** 重启阶段:waiting_resume 表示停机后等待复机 */
|
||||
restartPhase?: string;
|
||||
/** 计划复机时间 */
|
||||
restartResumeAt?: string;
|
||||
/** 批次类型 */
|
||||
batchType?: number;
|
||||
/** 来源方式 */
|
||||
sourceType?: null | number;
|
||||
/** 总数量 */
|
||||
totalCount?: number;
|
||||
/** 待核查数量 */
|
||||
unknownCount?: number;
|
||||
/** 运行结果 */
|
||||
runResult?: number;
|
||||
/** 失败原因 */
|
||||
@@ -74,6 +86,8 @@ export interface BatchExecutionLogSummaryRes extends AllocationUnits {
|
||||
|
||||
/** 批次执行日志文件信息 */
|
||||
export interface BatchExecutionLogFileModel {
|
||||
/** 导出任务ID;与历史批次下载任务ID分开,走下载中心鉴权 */
|
||||
exportTaskId?: number;
|
||||
/** OSS文件ID */
|
||||
ossId?: number;
|
||||
/** 文件URL */
|
||||
@@ -82,6 +96,8 @@ export interface BatchExecutionLogFileModel {
|
||||
|
||||
/** 批次执行日志结果文件信息 */
|
||||
export interface BatchExecutionLogResultFileModel extends BatchExecutionLogFileModel {
|
||||
/** 原始下载文件名 */
|
||||
originalName?: string;
|
||||
/** 下载任务ID */
|
||||
downloadId?: number;
|
||||
/** 下载状态 */
|
||||
@@ -113,6 +129,28 @@ export interface BatchExecutionLogListParam {
|
||||
/** 获取批次执行日志列表响应 */
|
||||
export type BatchExecutionLogListRes = GlobalListRes<BatchExecutionLogModel>;
|
||||
|
||||
/** 手动同步待核查批次上游结果的请求参数 */
|
||||
export interface BatchExecutionLogSyncReviewParam {
|
||||
/** 批次编号 */
|
||||
batchNo: string;
|
||||
/** 同步原因 */
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/** 手动同步待核查批次上游结果的响应 */
|
||||
export interface BatchExecutionLogSyncReviewRes {
|
||||
/** 批次编号 */
|
||||
batchNo?: string;
|
||||
/** 批次类型 */
|
||||
batchType?: number;
|
||||
/** 异步处理状态 */
|
||||
batchAsyncStatus?: number;
|
||||
/** 待核查数量 */
|
||||
unknownCount?: number;
|
||||
/** 是否已提交同步 */
|
||||
queued?: boolean;
|
||||
}
|
||||
|
||||
/** 批次执行日志明细列表项 */
|
||||
export interface BatchExecutionLogDetailModel extends AllocationUnits {
|
||||
/** 协议到期时间 */
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface SimWarningConfigModel {
|
||||
warnPhones?: string;
|
||||
warnEmails?: string;
|
||||
autoAction?: number;
|
||||
|
||||
isEnabled: number;
|
||||
wxTemplateId?: string;
|
||||
remark?: string;
|
||||
@@ -60,6 +61,7 @@ export interface SimWarningConfigSaveParam {
|
||||
warnPhones?: string;
|
||||
warnEmails?: string;
|
||||
autoAction?: number;
|
||||
|
||||
isEnabled?: number;
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @description 缓存监控相关接口
|
||||
* @Link https://github.com/xiujiecn/xiu-admin
|
||||
* @Copyright Copyright (c) 2025 LiXiujie
|
||||
* @Author Lxj <li@xiujie.cn>
|
||||
* @Author Lxj <xxx@example.com>
|
||||
* @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
* @date 2024-03-21
|
||||
*/
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @description 监控模块接口导出
|
||||
* @Link https://github.com/xiujiecn/xiu-admin
|
||||
* @Copyright Copyright (c) 2025 LiXiujie
|
||||
* @Author Lxj <li@xiujie.cn>
|
||||
* @Author Lxj <xxx@example.com>
|
||||
* @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
* @date 2024-03-21
|
||||
*/
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @description 在线用户监控相关接口
|
||||
* @Link https://github.com/xiujiecn/xiu-admin
|
||||
* @Copyright Copyright (c) 2025 LiXiujie
|
||||
* @Author Lxj <li@xiujie.cn>
|
||||
* @Author Lxj <xxx@example.com>
|
||||
* @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
* @date 2024-03-21
|
||||
*/
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @description 请求客户端配置
|
||||
* @Link https://github.com/xiujiecn/xiu-admin
|
||||
* @Copyright Copyright (c) 2025 LiXiujie
|
||||
* @Author Lxj <li@xiujie.cn>
|
||||
* @Author Lxj <xxx@example.com>
|
||||
* @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
* @date 2024-03-21
|
||||
*/
|
||||
@@ -116,12 +116,11 @@ function createRequestClient(baseURL: string, options?: RequestClientOptions) {
|
||||
if (config.responseReturn === 'body') {
|
||||
return responseData;
|
||||
} else if (headers['content-type']?.includes?.('application/json')) {
|
||||
if (config.responseType === 'blob' && Reflect.has(responseData, 'data'))
|
||||
{
|
||||
// 这时候的data为blob类型
|
||||
const blob = responseData.data as unknown as Blob;
|
||||
// 拿到字符串转json对象
|
||||
responseData.data = JSON.parse(await blob.text());
|
||||
// 导出/下载类接口用 blob 方式接收 JSON 回执(异步导出只回任务信息,没有文件流)。
|
||||
// 此时 axios 的 responseData 就是 Blob 本身,Blob 上没有 code 字段,
|
||||
// 若继续走下面的业务码判断会被误判为失败并抛错,所以直接交给调用方解析。
|
||||
if (config.responseType === 'blob') {
|
||||
return responseData;
|
||||
}
|
||||
} else if (headers['content-type']?.includes?.('application/octet-stream')) {
|
||||
// 下载文件
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @description 客户端管理相关接口
|
||||
* @Link https://github.com/xiujiecn/xiu-admin
|
||||
* @Copyright Copyright (c) 2025 LiXiujie
|
||||
* @Author Lxj <li@xiujie.cn>
|
||||
* @Author Lxj <xxx@example.com>
|
||||
* @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
* @date 2024-03-21
|
||||
*/
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @description 系统配置相关接口
|
||||
* @Link https://github.com/xiujiecn/xiu-admin
|
||||
* @Copyright Copyright (c) 2025 LiXiujie
|
||||
* @Author Lxj <li@xiujie.cn>
|
||||
* @Author Lxj <xxx@example.com>
|
||||
* @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
* @date 2024-03-21
|
||||
*/
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @description 组织管理相关接口
|
||||
* @Link https://github.com/xiujiecn/xiu-admin
|
||||
* @Copyright Copyright (c) 2025 LiXiujie
|
||||
* @Author Lxj <li@xiujie.cn>
|
||||
* @Author Lxj <xxx@example.com>
|
||||
* @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
* @date 2024-03-21
|
||||
*/
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @description 字典管理相关接口
|
||||
* @Link https://github.com/xiujiecn/xiu-admin
|
||||
* @Copyright Copyright (c) 2025 LiXiujie
|
||||
* @Author Lxj <li@xiujie.cn>
|
||||
* @Author Lxj <xxx@example.com>
|
||||
* @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
* @date 2024-03-21
|
||||
*/
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @description 系统管理模块接口导出
|
||||
* @Link https://github.com/xiujiecn/xiu-admin
|
||||
* @Copyright Copyright (c) 2025 LiXiujie
|
||||
* @Author Lxj <li@xiujie.cn>
|
||||
* @Author Lxj <xxx@example.com>
|
||||
* @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
* @date 2024-03-21
|
||||
*/
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @description 定时任务相关接口
|
||||
* @Link https://github.com/xiujiecn/xiu-admin
|
||||
* @Copyright Copyright (c) 2025 LiXiujie
|
||||
* @Author Lxj <li@xiujie.cn>
|
||||
* @Author Lxj <xxx@example.com>
|
||||
* @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
* @date 2024-03-21
|
||||
*/
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @description 登录日志相关接口
|
||||
* @Link https://github.com/xiujiecn/xiu-admin
|
||||
* @Copyright Copyright (c) 2025 LiXiujie
|
||||
* @Author Lxj <li@xiujie.cn>
|
||||
* @Author Lxj <xxx@example.com>
|
||||
* @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
* @date 2024-03-21
|
||||
*/
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @description 菜单管理相关接口
|
||||
* @Link https://github.com/xiujiecn/xiu-admin
|
||||
* @Copyright Copyright (c) 2025 LiXiujie
|
||||
* @Author Lxj <li@xiujie.cn>
|
||||
* @Author Lxj <xxx@example.com>
|
||||
* @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
* @date 2024-03-21
|
||||
*/
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @description 通知公告相关接口
|
||||
* @Link https://github.com/xiujiecn/xiu-admin
|
||||
* @Copyright Copyright (c) 2025 LiXiujie
|
||||
* @Author Lxj <li@xiujie.cn>
|
||||
* @Author Lxj <xxx@example.com>
|
||||
* @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
* @date 2024-03-21
|
||||
*/
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @description 操作日志相关接口
|
||||
* @Link https://github.com/xiujiecn/xiu-admin
|
||||
* @Copyright Copyright (c) 2025 LiXiujie
|
||||
* @Author Lxj <li@xiujie.cn>
|
||||
* @Author Lxj <xxx@example.com>
|
||||
* @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
* @date 2024-03-21
|
||||
*/
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @description 对象存储管理相关接口
|
||||
* @Link https://github.com/xiujiecn/xiu-admin
|
||||
* @Copyright Copyright (c) 2025 LiXiujie
|
||||
* @Author Lxj <li@xiujie.cn>
|
||||
* @Author Lxj <xxx@example.com>
|
||||
* @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
* @date 2024-03-21
|
||||
*/
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @description 岗位管理相关接口
|
||||
* @Link https://github.com/xiujiecn/xiu-admin
|
||||
* @Copyright Copyright (c) 2025 LiXiujie
|
||||
* @Author Lxj <li@xiujie.cn>
|
||||
* @Author Lxj <xxx@example.com>
|
||||
* @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
* @date 2024-03-21
|
||||
*/
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @description 系统二维码管理相关接口
|
||||
* @Link https://github.com/xiujiecn/xiu-admin
|
||||
* @Copyright Copyright (c) 2025 LiXiujie
|
||||
* @Author Lxj <li@xiujie.cn>
|
||||
* @Author Lxj <xxx@example.com>
|
||||
* @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
* @date 2024-03-21
|
||||
*/
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @description 角色管理相关接口
|
||||
* @Link https://github.com/xiujiecn/xiu-admin
|
||||
* @Copyright Copyright (c) 2025 LiXiujie
|
||||
* @Author Lxj <li@xiujie.cn>
|
||||
* @Author Lxj <xxx@example.com>
|
||||
* @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
* @date 2024-03-21
|
||||
*/
|
||||
@@ -12,6 +12,7 @@ import { requestClient } from '#/api/request';
|
||||
export interface SysRoleListParam {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
deptId?: number;
|
||||
roleName?: string;
|
||||
roleKey?: string;
|
||||
status?: string;
|
||||
@@ -34,6 +35,10 @@ export interface SysRoleListData {
|
||||
}
|
||||
|
||||
export interface SysRoleViewData {
|
||||
isTemplateRole: boolean;
|
||||
isDeptDefaultRole: boolean;
|
||||
roleMenuDataScopes: Record<number, string>;
|
||||
deptId: number;
|
||||
roleId: number;
|
||||
tenantId: string;
|
||||
roleName: string;
|
||||
@@ -96,14 +101,11 @@ export interface SysRoleDeleteParam {
|
||||
roleIds: number[];
|
||||
}
|
||||
|
||||
export interface SysRoleAddRes {
|
||||
}
|
||||
export interface SysRoleAddRes {}
|
||||
|
||||
export interface SysRoleEditRes {
|
||||
}
|
||||
export interface SysRoleEditRes {}
|
||||
|
||||
export interface SysRoleDeleteRes {
|
||||
}
|
||||
export interface SysRoleDeleteRes {}
|
||||
|
||||
export interface SysRoleDataScopeEditParam {
|
||||
roleId: number;
|
||||
@@ -111,8 +113,7 @@ export interface SysRoleDataScopeEditParam {
|
||||
deptCheckStrictly?: number;
|
||||
deptIds?: number[];
|
||||
}
|
||||
export interface SysRoleDataScopeEditRes {
|
||||
}
|
||||
export interface SysRoleDataScopeEditRes {}
|
||||
|
||||
export async function addSysRoleApi(params: SysRoleAddParam | any) {
|
||||
return requestClient.post<SysRoleAddRes>('/system/role/add', { ...params });
|
||||
@@ -123,14 +124,23 @@ export async function editSysRoleApi(params: SysRoleEditParam|any) {
|
||||
}
|
||||
|
||||
export async function deleteSysRoleApi(params: SysRoleDeleteParam | any) {
|
||||
return requestClient.post<SysRoleDeleteRes>('/system/role/delete', { ...params });
|
||||
return requestClient.post<SysRoleDeleteRes>('/system/role/delete', {
|
||||
...params,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getSysRoleViewApi(params: SysRoleViewParam | any) {
|
||||
console.log('/Users/lixiujie/dev_test/dev_go/xiujie_iot/vue/apps/web-antd/src/api/system/role.ts', params);
|
||||
console.log(
|
||||
'/Users/lixiujie/dev_test/dev_go/xiujie_iot/vue/apps/web-antd/src/api/system/role.ts',
|
||||
params,
|
||||
);
|
||||
return requestClient.get<SysRoleViewData>('/system/role/view', { params });
|
||||
}
|
||||
|
||||
export async function editSysRoleDataScopeApi(params: SysRoleDataScopeEditParam|any) {
|
||||
return requestClient.post<SysRoleDataScopeEditRes>('/system/role/dataScope', { ...params });
|
||||
export async function editSysRoleDataScopeApi(
|
||||
params: SysRoleDataScopeEditParam | any,
|
||||
) {
|
||||
return requestClient.post<SysRoleDataScopeEditRes>('/system/role/dataScope', {
|
||||
...params,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @description 社交账号管理相关接口
|
||||
* @Link https://github.com/xiujiecn/xiu-admin
|
||||
* @Copyright Copyright (c) 2025 LiXiujie
|
||||
* @Author Lxj <li@xiujie.cn>
|
||||
* @Author Lxj <xxx@example.com>
|
||||
* @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
* @date 2024-03-21
|
||||
*/
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @description 用户管理相关接口
|
||||
* @Link https://github.com/xiujiecn/xiu-admin
|
||||
* @Copyright Copyright (c) 2025 LiXiujie
|
||||
* @Author Lxj <li@xiujie.cn>
|
||||
* @Author Lxj <xxx@example.com>
|
||||
* @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
* @date 2024-03-21
|
||||
*/
|
||||
|
||||
@@ -57,9 +57,9 @@ const DEFAULT_RUNTIME_SETTING: RuntimeSetting = {
|
||||
appTitle: '智联·云',
|
||||
copyright: {
|
||||
companyName: '示例智联',
|
||||
companySiteLink: 'https://www.xiujiezhilian.cn',
|
||||
companySiteLink: 'https://example.com',
|
||||
date: '2024-2025',
|
||||
icp: '鲁ICP备2024117944号-1',
|
||||
icp: '',
|
||||
icpLink: 'https://beian.miit.gov.cn/',
|
||||
},
|
||||
loginPageDescription: '新一代物联网设备统一接入平台',
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<script lang="ts" setup>
|
||||
import type { NotificationItem } from '@vben/layouts';
|
||||
|
||||
import type { DownloadCenterItem } from '#/api/common/downloadCenter';
|
||||
|
||||
import { computed, onUnmounted, reactive, ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useWebSocket } from '@vueuse/core';
|
||||
|
||||
import { AuthenticationLoginExpiredModal } from '@vben/common-ui';
|
||||
import { VBEN_DOC_URL, VBEN_GITHUB_URL } from '@vben/constants';
|
||||
import { AuthenticationLoginExpiredModal, VbenButton } from '@vben/common-ui';
|
||||
import { useWatermark } from '@vben/hooks';
|
||||
import { BookOpenText, CircleHelp, MdiGithub,UserOutlined } from '@vben/icons';
|
||||
import { DownloadOutlined, UserOutlined } from '@vben/icons';
|
||||
import {
|
||||
BasicLayout,
|
||||
LockScreen,
|
||||
@@ -17,10 +17,18 @@ import {
|
||||
} from '@vben/layouts';
|
||||
import { preferences } from '@vben/preferences';
|
||||
import { useAccessStore, useUserStore } from '@vben/stores';
|
||||
import { openWindow } from '@vben/utils';
|
||||
|
||||
import { useWebSocket } from '@vueuse/core';
|
||||
import { Badge, Button, Popover } from 'ant-design-vue';
|
||||
|
||||
import {
|
||||
Download as DownloadExportFile,
|
||||
Recent as RecentExportTasks,
|
||||
} from '#/api/common/downloadCenter';
|
||||
import { Read, ReadAll, UnreadList } from '#/api/common/tMessageLog';
|
||||
import { $t } from '#/locales';
|
||||
import { useAuthStore } from '#/store';
|
||||
import { downloadByData } from '#/utils/file/download';
|
||||
import FirstLoginGuideModal from '#/views/_core/authentication/components/first-login-guide-modal.vue';
|
||||
import LoginForm from '#/views/_core/authentication/login.vue';
|
||||
|
||||
@@ -154,7 +162,32 @@ async function handleLogout() {
|
||||
}
|
||||
|
||||
function handleNoticeClear() {
|
||||
const generation = identityGeneration;
|
||||
// 先留存本次清空覆盖的列表与 id,失败时用于本地回滚。
|
||||
const previous = [...notifications.value];
|
||||
const ids = previous.map((item) => String(item.id));
|
||||
invalidateUnreadRequest();
|
||||
notifications.value = [];
|
||||
protectPendingReads(ids);
|
||||
// 清空通知时同步服务端已读,否则重新登录或断线重连会再次提示这些未读消息。
|
||||
// 同步结束(无论成败)都重新拉取:成功用于清理同步期间新到的推送,失败用于恢复未读展示。
|
||||
// 先 catch 再 finally:只挂 finally 会把 ReadAll 的失败变成未处理的 Promise 拒绝。
|
||||
ReadAll()
|
||||
.catch(() => {
|
||||
if (generation !== identityGeneration) {
|
||||
return;
|
||||
}
|
||||
// 同步失败:先本地回滚,避免「同步失败 + 随后的重新拉取也失败」时提醒一直空着。
|
||||
releasePendingReads(ids);
|
||||
notifications.value = previous;
|
||||
})
|
||||
.finally(() => {
|
||||
// 期间若已切换账号,结果属于上一个账号,重新拉取会覆盖新账号的提醒。
|
||||
if (generation !== identityGeneration) {
|
||||
return;
|
||||
}
|
||||
loadUnreadNotifications();
|
||||
});
|
||||
}
|
||||
|
||||
function markRead(id: number | string) {
|
||||
@@ -162,6 +195,38 @@ function markRead(id: number | string) {
|
||||
if (item) {
|
||||
item.isRead = true;
|
||||
}
|
||||
// 同步服务端已读状态,避免重新登录后已处理的消息再次出现在未读提醒里。
|
||||
const messageLogId = Number(id);
|
||||
if (!Number.isFinite(messageLogId) || messageLogId <= 0) {
|
||||
return;
|
||||
}
|
||||
const key = String(id);
|
||||
const generation = identityGeneration;
|
||||
// 标记期间无论有在途还是新发起的未读拉取、实时推送,都不能把这条已读消息按未读重新展示。
|
||||
// 这里不作废在途拉取:作废会连带丢掉同一批离线未读消息,只靠 pendingReadIds 过滤本条即可。
|
||||
pendingReadIds.add(key);
|
||||
Read({ messageLogId })
|
||||
.then(() => {
|
||||
if (generation !== identityGeneration) {
|
||||
return;
|
||||
}
|
||||
// 列表此前被分页截断(未读数超过返回上限)时,读掉一条就要补拉,
|
||||
// 否则排在分页之外的未读会一直显示不出来。未截断时保留已读项(组件里有删除按钮)。
|
||||
if (unreadTruncated) {
|
||||
loadUnreadNotifications();
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (generation !== identityGeneration) {
|
||||
return;
|
||||
}
|
||||
// 同步失败则恢复未读并重新拉取,把仍在服务端未读的消息补回展示。
|
||||
pendingReadIds.delete(key);
|
||||
if (item) {
|
||||
item.isRead = false;
|
||||
}
|
||||
loadUnreadNotifications();
|
||||
});
|
||||
}
|
||||
|
||||
function remove(id: number | string) {
|
||||
@@ -169,7 +234,36 @@ function remove(id: number | string) {
|
||||
}
|
||||
|
||||
function handleMakeAll() {
|
||||
const generation = identityGeneration;
|
||||
// 先留存本次全部已读覆盖的列表与各自已读状态,失败时用于本地回滚。
|
||||
const previous = notifications.value.map((item) => ({
|
||||
isRead: item.isRead,
|
||||
item,
|
||||
}));
|
||||
const ids = notifications.value.map((item) => String(item.id));
|
||||
invalidateUnreadRequest();
|
||||
protectPendingReads(ids);
|
||||
notifications.value.forEach((item) => (item.isRead = true));
|
||||
// 全部已读覆盖的是请求发起时刻的消息:结束后重新拉取,校正期间新到的推送。
|
||||
// 先 catch 再 finally,避免 ReadAll 失败产生未处理的 Promise 拒绝。
|
||||
ReadAll()
|
||||
.catch(() => {
|
||||
if (generation !== identityGeneration) {
|
||||
return;
|
||||
}
|
||||
// 同步失败:先本地回滚已读标记,避免「同步失败 + 随后重新拉取也失败」时未读角标一直不亮。
|
||||
releasePendingReads(ids);
|
||||
for (const { isRead, item } of previous) {
|
||||
item.isRead = isRead;
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
// 期间若已切换账号,结果属于上一个账号,重新拉取会覆盖新账号的提醒。
|
||||
if (generation !== identityGeneration) {
|
||||
return;
|
||||
}
|
||||
loadUnreadNotifications();
|
||||
});
|
||||
}
|
||||
|
||||
// 「查看所有消息」:跳转到消息日志列表页
|
||||
@@ -177,13 +271,119 @@ function handleViewAll() {
|
||||
router.push(MESSAGE_LOG_PATH);
|
||||
}
|
||||
|
||||
// 未读拉取请求序号:账号切换或重复触发时只保留最后一次请求的结果。
|
||||
let unreadRequestSeq = 0;
|
||||
// 已成功应用结果的请求序号:较晚发起的请求失败时,不能连带丢掉较早那次成功的结果。
|
||||
let unreadAppliedSeq = 0;
|
||||
|
||||
// 拉取期间通过实时推送到达的提醒:服务端快照可能早于这些消息,需要一并保留。
|
||||
const pushedDuringLoad = new Map<string, NotificationItem>();
|
||||
// 并发拉取按请求分别跟踪;后发请求先失败时,先发请求仍可能应用旧快照。
|
||||
const unreadRequestsInFlight = new Set<number>();
|
||||
// 已在本地标记已读的消息 id。应用未读快照与实时推送时据此过滤,避免把刚点开的
|
||||
// 已读消息按未读重新展示。会话内一直保留(退出登录/切换账号时清空):
|
||||
// 过期的 websocket 推送可能晚于快照才到达,一旦提前解除过滤就会被重新点亮未读。
|
||||
const pendingReadIds = new Set<string>();
|
||||
// 身份代数:每次登录/退出/切换账号自增,用于丢弃上一个账号发起、尚未回来的异步回调。
|
||||
let identityGeneration = 0;
|
||||
// 上一次成功应用的未读快照是否被分页截断(未读数超过返回上限)。
|
||||
// 截断时列表只包含最新一批未读,读掉一条需要补拉才能露出后面的未读。
|
||||
let unreadTruncated = false;
|
||||
|
||||
// 批量已读(全部已读/清空)覆盖的消息先纳入过滤:标记在途期间即使被推送或
|
||||
// 重连触发的拉取命中,也不能把它们按未读重新展示。
|
||||
function protectPendingReads(ids: string[]) {
|
||||
for (const id of ids) {
|
||||
pendingReadIds.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
// 批量已读失败:解除过滤,让仍在服务端未读的消息恢复展示。
|
||||
function releasePendingReads(ids: string[]) {
|
||||
for (const id of ids) {
|
||||
pendingReadIds.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
// 作废在途的未读拉取:本地已读状态刚变更,较早的快照会把已读消息重新标成未读。
|
||||
// 被作废的请求不会再复位缓冲状态,这里一并复位,避免后续推送一直被缓存。
|
||||
function invalidateUnreadRequest() {
|
||||
unreadRequestSeq += 1;
|
||||
// 同时抬高「已应用序号」,让所有在途结果作废(含此前已成功的序号)。
|
||||
unreadAppliedSeq = unreadRequestSeq;
|
||||
unreadRequestsInFlight.clear();
|
||||
pushedDuringLoad.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取当前登录用户的未读消息,初始化右上角消息提醒。
|
||||
* 登录、刷新、断线重连都必须拉取:提醒此前只依赖实时 websocket 推送,
|
||||
* 离线期间产生的消息不会补发,导致重新登录后看不到未读提醒。
|
||||
* 单次最多展示 100 条未读(服务端上限 200):未读角标只看「是否存在未读」,
|
||||
* 列表按时间倒序取最新 100 条已足够,超出部分随已读/刷新逐步消失。
|
||||
*/
|
||||
async function loadUnreadNotifications() {
|
||||
const seq = ++unreadRequestSeq;
|
||||
// 不在此清空推送缓冲:并发拉取时后发起的请求会在前一个请求在途期间清掉缓冲,
|
||||
// 导致该窗口内的实时推送既进不了缓冲、又可能早于服务端快照而被漏掉。
|
||||
// 缓冲保留到所有在途拉取结束,或由 invalidateUnreadRequest 统一作废。
|
||||
unreadRequestsInFlight.add(seq);
|
||||
try {
|
||||
const res = await UnreadList({ limit: 100 });
|
||||
// 已经应用过更新的结果(切换账号/重连/本地已读作废)才丢弃本次结果。
|
||||
// 注意不能按「是否还有更新的请求在途」判断:更新的请求失败时,
|
||||
// 这次已经成功的结果就是唯一可用的数据,丢掉会让提醒一直空着。
|
||||
if (seq <= unreadAppliedSeq) {
|
||||
return;
|
||||
}
|
||||
const merged = new Map<string, NotificationItem>();
|
||||
// 先放本次拉取期间新到的推送,保证它们不会被较早的服务端快照覆盖。
|
||||
// 但本地已标记已读的消息仍要过滤:推送可能是延迟/重复的同一批消息。
|
||||
for (const [id, notification] of pushedDuringLoad) {
|
||||
if (!pendingReadIds.has(id)) {
|
||||
merged.set(id, notification);
|
||||
}
|
||||
}
|
||||
for (const item of res?.items ?? []) {
|
||||
const notification = createNotificationItem(item);
|
||||
const id = String(notification.id);
|
||||
// 本地已标记已读的消息即使在快照里仍是未读,也不重新展示。
|
||||
if (!merged.has(id) && !pendingReadIds.has(id)) {
|
||||
merged.set(id, notification);
|
||||
}
|
||||
}
|
||||
// 本次快照是否被分页截断:total 是服务端全部未读数,大于返回条数即为截断。
|
||||
// 截断时更新列表只包含最新一批未读,读掉一条需要补拉才能露出后面的未读。
|
||||
unreadTruncated = (res?.total ?? 0) > (res?.items?.length ?? 0);
|
||||
notifications.value = [...merged.values()];
|
||||
unreadAppliedSeq = seq;
|
||||
} catch {
|
||||
// 拉取失败不阻断页面;实时推送仍可用,下次登录或重连会再次拉取。
|
||||
} finally {
|
||||
// 每个请求只结束自己的在途状态;后发失败不能提前清掉先发请求所需的推送。
|
||||
unreadRequestsInFlight.delete(seq);
|
||||
if (unreadRequestsInFlight.size === 0) {
|
||||
pushedDuringLoad.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
closeWs();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => accessStore.accessToken,
|
||||
(token) => {
|
||||
(token, prevToken) => {
|
||||
// 只要身份发生变化(登录、切换账号、退出)就先清空上一个账号的提醒,
|
||||
// 并作废在途拉取:否则在新数据返回前会短暂串号,且拉取失败时旧账号提醒会滞留。
|
||||
if (token !== prevToken) {
|
||||
// 身份变化:代数自增,丢弃上一个账号尚未回来的异步回调。
|
||||
identityGeneration += 1;
|
||||
invalidateUnreadRequest();
|
||||
pendingReadIds.clear();
|
||||
notifications.value = [];
|
||||
}
|
||||
if (!token) {
|
||||
closeWs();
|
||||
return;
|
||||
@@ -192,6 +392,8 @@ watch(
|
||||
if (wsStatus.value !== 'OPEN') {
|
||||
openWs();
|
||||
}
|
||||
// 登录或切换账号后立即拉取未读消息,不依赖实时推送是否在线。
|
||||
loadUnreadNotifications();
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
@@ -201,6 +403,8 @@ watch(
|
||||
watch(wsStatus, (status) => {
|
||||
if (status === 'OPEN') {
|
||||
sendWsLogin();
|
||||
// 断线重连后补齐离线期间遗漏的未读消息。
|
||||
loadUnreadNotifications();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -230,14 +434,21 @@ function getWsServerUrl() {
|
||||
function parseWsData(value: string) {
|
||||
try {
|
||||
const data = JSON.parse(value);
|
||||
if (typeof data.d === 'string' && !data.d.startsWith('{') && !data.d.startsWith('[')) {
|
||||
if (
|
||||
typeof data.d === 'string' &&
|
||||
!data.d.startsWith('{') &&
|
||||
!data.d.startsWith('[')
|
||||
) {
|
||||
try {
|
||||
data.d = atob(data.d);
|
||||
} catch {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
if (typeof data.d === 'string' && (data.d.startsWith('{') || data.d.startsWith('['))) {
|
||||
if (
|
||||
typeof data.d === 'string' &&
|
||||
(data.d.startsWith('{') || data.d.startsWith('['))
|
||||
) {
|
||||
data.d = JSON.parse(data.d);
|
||||
}
|
||||
return data;
|
||||
@@ -260,13 +471,31 @@ function handleWsMessage(value: string) {
|
||||
if (!notificationItem) {
|
||||
return;
|
||||
}
|
||||
notifications.value = [notificationItem, ...notifications.value.filter((item) => item.id !== notificationItem.id)];
|
||||
// 本地已标记已读的消息即使又被推送(延迟/重复推送),也不再按未读提醒展示。
|
||||
// 必须在入缓冲前判断,否则缓冲合并会绕过统一过滤。
|
||||
if (pendingReadIds.has(String(notificationItem.id))) {
|
||||
return;
|
||||
}
|
||||
// 记录到拉取缓冲:若有未读拉取在途,其服务端快照可能还没包含这条消息。
|
||||
if (unreadRequestsInFlight.size > 0) {
|
||||
pushedDuringLoad.set(String(notificationItem.id), notificationItem);
|
||||
}
|
||||
notifications.value = [
|
||||
notificationItem,
|
||||
...notifications.value.filter((item) => item.id !== notificationItem.id),
|
||||
];
|
||||
}
|
||||
|
||||
function isWsControlEvent(eventName: string) {
|
||||
return ['connected', 'login', 'join', 'quit', 'ping', 'joins', 'quits'].includes(
|
||||
eventName,
|
||||
);
|
||||
return [
|
||||
'connected',
|
||||
'join',
|
||||
'joins',
|
||||
'login',
|
||||
'ping',
|
||||
'quit',
|
||||
'quits',
|
||||
].includes(eventName);
|
||||
}
|
||||
|
||||
function normalizeNotificationPayload(data: any) {
|
||||
@@ -284,7 +513,9 @@ function normalizeNotificationPayload(data: any) {
|
||||
}
|
||||
|
||||
function sendWsLogin() {
|
||||
const userId = Number(userStore.userInfo?.id || userStore.userInfo?.userId || 0);
|
||||
const userId = Number(
|
||||
userStore.userInfo?.id || userStore.userInfo?.userId || 0,
|
||||
);
|
||||
if (!userId) {
|
||||
return;
|
||||
}
|
||||
@@ -299,10 +530,17 @@ function sendWsLogin() {
|
||||
}
|
||||
|
||||
function createNotificationItem(payload: Record<string, any>) {
|
||||
const id = payload.messageLogId ?? payload.notifyId ?? `${Date.now()}-${Math.random()}`;
|
||||
const title = String(payload.messageTitle ?? payload.notifyTitle ?? '消息通知');
|
||||
const id =
|
||||
payload.messageLogId ??
|
||||
payload.notifyId ??
|
||||
`${Date.now()}-${Math.random()}`;
|
||||
const title = String(
|
||||
payload.messageTitle ?? payload.notifyTitle ?? '消息通知',
|
||||
);
|
||||
const message = String(payload.messageContent ?? payload.notifyContent ?? '');
|
||||
const date = String(payload.sendTime || payload.createdAt || payload.created_at || '刚刚');
|
||||
const date = String(
|
||||
payload.sendTime || payload.createdAt || payload.created_at || '刚刚',
|
||||
);
|
||||
|
||||
return {
|
||||
id,
|
||||
@@ -312,7 +550,9 @@ function createNotificationItem(payload: Record<string, any>) {
|
||||
message,
|
||||
title,
|
||||
link: MESSAGE_LOG_PATH,
|
||||
query: payload.messageLogId ? { messageLogId: payload.messageLogId } : payload.query || {},
|
||||
query: payload.messageLogId
|
||||
? { messageLogId: payload.messageLogId }
|
||||
: payload.query || {},
|
||||
state: payload.state,
|
||||
} satisfies NotificationItem;
|
||||
}
|
||||
@@ -336,6 +576,100 @@ watch(
|
||||
immediate: true,
|
||||
},
|
||||
);
|
||||
|
||||
/* ==================== 右上角下载中心入口 ==================== */
|
||||
const exportTasks = ref<DownloadCenterItem[]>([]);
|
||||
const exportRunningCount = ref(0);
|
||||
const exportPanelOpen = ref(false);
|
||||
let exportRefreshTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let exportLoading = false;
|
||||
let exportRefreshPending = false;
|
||||
let exportDisposed = false;
|
||||
|
||||
const EXPORT_STATUS_TEXT: Record<number, string> = {
|
||||
0: '排队中',
|
||||
1: '已完成',
|
||||
2: '生成中',
|
||||
3: '失败',
|
||||
};
|
||||
|
||||
async function loadExportTasks() {
|
||||
if (exportDisposed) return;
|
||||
const token = accessStore.accessToken;
|
||||
if (!token) {
|
||||
clearTimeout(exportRefreshTimer);
|
||||
exportTasks.value = [];
|
||||
exportRunningCount.value = 0;
|
||||
return;
|
||||
}
|
||||
if (exportLoading) {
|
||||
exportRefreshPending = true;
|
||||
return;
|
||||
}
|
||||
clearTimeout(exportRefreshTimer);
|
||||
exportLoading = true;
|
||||
let loaded = false;
|
||||
try {
|
||||
const res = await RecentExportTasks(5);
|
||||
if (exportDisposed || token !== accessStore.accessToken) return;
|
||||
exportTasks.value = res?.items ?? [];
|
||||
exportRunningCount.value = res?.runningCount ?? 0;
|
||||
loaded = true;
|
||||
} catch {
|
||||
// 右上角入口属辅助信息,失败不打扰用户,也不持续重试。
|
||||
} finally {
|
||||
exportLoading = false;
|
||||
const pending = exportRefreshPending;
|
||||
exportRefreshPending = false;
|
||||
if (
|
||||
!exportDisposed &&
|
||||
accessStore.accessToken &&
|
||||
(pending ||
|
||||
(loaded && (exportRunningCount.value > 0 || exportPanelOpen.value)))
|
||||
) {
|
||||
exportRefreshTimer = setTimeout(loadExportTasks, pending ? 0 : 3000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleExportRefresh() {
|
||||
void loadExportTasks();
|
||||
}
|
||||
|
||||
function handleExportPanelChange(open: boolean) {
|
||||
exportPanelOpen.value = open;
|
||||
if (open) {
|
||||
loadExportTasks();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDownloadExportFile(item: DownloadCenterItem) {
|
||||
if (item.status !== 1) {
|
||||
return;
|
||||
}
|
||||
const data = await DownloadExportFile(item.id);
|
||||
downloadByData(data as Blob, item.fileName || `${item.taskNo}.xlsx`);
|
||||
void loadExportTasks();
|
||||
}
|
||||
|
||||
function goDownloadCenter() {
|
||||
exportPanelOpen.value = false;
|
||||
router.push('/downloadCenter');
|
||||
}
|
||||
|
||||
// 有进行中的任务或面板打开时刷新,任务结束后自动停止。
|
||||
window.addEventListener('iotlcm:export-submitted', scheduleExportRefresh);
|
||||
watch(
|
||||
[() => router.currentRoute.value.path, () => accessStore.accessToken],
|
||||
scheduleExportRefresh,
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
onUnmounted(() => {
|
||||
exportDisposed = true;
|
||||
window.removeEventListener('iotlcm:export-submitted', scheduleExportRefresh);
|
||||
clearTimeout(exportRefreshTimer);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -350,6 +684,68 @@ watch(
|
||||
@logout="handleLogout"
|
||||
/>
|
||||
</template>
|
||||
<template #header-right-105>
|
||||
<Popover
|
||||
:open="exportPanelOpen"
|
||||
placement="bottomRight"
|
||||
trigger="click"
|
||||
@open-change="handleExportPanelChange"
|
||||
>
|
||||
<template #content>
|
||||
<div class="w-[380px]">
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<span class="font-medium">下载中心</span>
|
||||
<Button type="link" size="small" @click="goDownloadCenter">
|
||||
查看全部
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
v-if="exportTasks.length === 0"
|
||||
class="py-6 text-center text-sm text-gray-400"
|
||||
>
|
||||
暂无导出任务
|
||||
</div>
|
||||
<div
|
||||
v-for="item in exportTasks"
|
||||
:key="item.id"
|
||||
class="flex items-center gap-2 border-b border-gray-100 py-2 last:border-b-0"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-sm">
|
||||
{{ item.fileName || item.taskNo }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-400">
|
||||
{{ item.bizName }} · {{ item.createdAt || '' }}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
v-if="item.status === 1"
|
||||
type="link"
|
||||
size="small"
|
||||
@click="handleDownloadExportFile(item)"
|
||||
>
|
||||
下载
|
||||
</Button>
|
||||
<span v-else class="text-xs text-gray-500">
|
||||
{{ EXPORT_STATUS_TEXT[item.status] || '未知' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<span class="flex-center mr-1 h-full">
|
||||
<Badge :count="exportRunningCount" :offset="[-2, 2]" size="small">
|
||||
<VbenButton
|
||||
aria-label="下载中心"
|
||||
class="rounded-full hover:animate-[shrink_0.3s_ease-in-out]"
|
||||
size="icon"
|
||||
variant="icon"
|
||||
>
|
||||
<DownloadOutlined class="size-4" />
|
||||
</VbenButton>
|
||||
</Badge>
|
||||
</span>
|
||||
</Popover>
|
||||
</template>
|
||||
<template #notification>
|
||||
<Notification
|
||||
:dot="showDot"
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { createMemoryHistory, createRouter } from 'vue-router';
|
||||
|
||||
import { generateAccessible } from '@vben/access';
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const NullComponent = { render: () => null };
|
||||
|
||||
describe('generateAccessible root route lookup', () => {
|
||||
it('preserves /analytics when an empty-path backend menu also resolves to /', async () => {
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{
|
||||
children: [],
|
||||
component: NullComponent,
|
||||
name: 'Root',
|
||||
path: '/',
|
||||
},
|
||||
{
|
||||
component: NullComponent,
|
||||
name: 'FallbackNotFound',
|
||||
path: '/:path(.*)*',
|
||||
},
|
||||
],
|
||||
});
|
||||
router.addRoute('Root', {
|
||||
component: NullComponent,
|
||||
name: 'menu_444',
|
||||
path: '',
|
||||
});
|
||||
expect(
|
||||
router
|
||||
.getRoutes()
|
||||
.filter((route) => route.path === '/')
|
||||
.map((route) => route.name),
|
||||
).toEqual(expect.arrayContaining(['Root', 'menu_444']));
|
||||
expect(router.getRoutes().find((route) => route.path === '/')?.name).toBe(
|
||||
'menu_444',
|
||||
);
|
||||
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
try {
|
||||
const { accessibleRoutes } = await generateAccessible('backend', {
|
||||
fetchMenuListAsync: async () => [
|
||||
{ component: '/', name: 'menu_444', path: '' },
|
||||
{ component: '/analytics', name: 'Analytics', path: '/analytics' },
|
||||
],
|
||||
layoutMap: {},
|
||||
pageMap: { '/analytics.vue': async () => NullComponent },
|
||||
router,
|
||||
routes: [],
|
||||
});
|
||||
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'Skipping backend menu route with an empty path',
|
||||
{ name: 'menu_444' },
|
||||
);
|
||||
expect(accessibleRoutes.map((route) => route.name)).toEqual([
|
||||
'Analytics',
|
||||
]);
|
||||
expect(router.resolve('/analytics').name).toBe('Analytics');
|
||||
expect(
|
||||
router.resolve('/analytics').matched.map((route) => route.name),
|
||||
).not.toContain('FallbackNotFound');
|
||||
} finally {
|
||||
warn.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -12,7 +12,9 @@ import { cloneDeep } from 'lodash-es';
|
||||
import { getAllMenusApi } from '#/api';
|
||||
import { BasicLayout, IFrameView } from '#/layouts';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import { localRoutes } from './routes/local';
|
||||
|
||||
const forbiddenComponent = () => import('#/views/_core/fallback/forbidden.vue');
|
||||
|
||||
async function generateAccess(options: GenerateMenuAndRoutesOptions) {
|
||||
@@ -21,6 +23,7 @@ async function generateAccess(options: GenerateMenuAndRoutesOptions) {
|
||||
const layoutMap: ComponentRecordType = {
|
||||
BasicLayout,
|
||||
IFrameView,
|
||||
LAYOUT: BasicLayout,
|
||||
};
|
||||
|
||||
return await generateAccessible(preferences.app.accessMode, {
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { createMemoryHistory, createRouter, type RouteRecordRaw } from 'vue-router';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/**
|
||||
* 复现「登录后 404、刷新才好」的守卫缺陷(对照实验)。
|
||||
*
|
||||
* 线上场景:
|
||||
* - Root 路由 redirect: '/analytics'(= DEFAULT_HOME_PATH)
|
||||
* - /analytics 是 Dashboard 的绝对路径子路由,属动态路由,首登时尚未注册
|
||||
* - 守卫在 generateAccess() 注册完路由后返回重定向
|
||||
*
|
||||
* 对比两种返回值写法的最终落地路由:
|
||||
* 旧:{...router.resolve(path), replace:true} 把 name/matched 一起交回路由
|
||||
* 新:{path, replace:true} 由 vue-router 重新匹配
|
||||
*/
|
||||
|
||||
const FALLBACK = 'FallbackNotFound';
|
||||
|
||||
const DASHBOARD_ROUTES: RouteRecordRaw[] = [
|
||||
{
|
||||
children: [
|
||||
{ component: { render: () => null }, name: 'Analytics', path: '/analytics' },
|
||||
],
|
||||
name: 'Dashboard',
|
||||
path: '/dashboard',
|
||||
},
|
||||
];
|
||||
|
||||
/** 复刻 vben generateAccessible 的注册方式:推入 root.children 后重挂根路由 */
|
||||
function registerDynamicRoutes(router: any) {
|
||||
const root = router.getRoutes().find((item: any) => item.path === '/');
|
||||
const names = root?.children?.map((item: any) => item.name) ?? [];
|
||||
DASHBOARD_ROUTES.forEach((route) => {
|
||||
if (names.includes(route.name as string)) {
|
||||
const index = root.children.findIndex((i: any) => i.name === route.name);
|
||||
if (index !== -1) root.children[index] = route;
|
||||
} else {
|
||||
root.children.push(route);
|
||||
}
|
||||
});
|
||||
if (root?.name) router.removeRoute(root.name);
|
||||
router.addRoute(root);
|
||||
}
|
||||
|
||||
function createTestRouter() {
|
||||
return createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{
|
||||
component: { render: () => null },
|
||||
name: 'Root',
|
||||
path: '/',
|
||||
redirect: '/analytics',
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
component: { render: () => null },
|
||||
name: FALLBACK,
|
||||
path: '/:path(.*)*',
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/** 复刻线上守卫;returnResolved=true 即修复前的写法 */
|
||||
async function simulateLogin(returnResolved: boolean) {
|
||||
const router = createTestRouter();
|
||||
const state = { checked: false };
|
||||
router.beforeEach(async (to: any) => {
|
||||
if (state.checked) return true;
|
||||
registerDynamicRoutes(router);
|
||||
state.checked = true;
|
||||
const target = '/analytics';
|
||||
return returnResolved
|
||||
? ({ ...router.resolve(target), replace: true } as any)
|
||||
: ({ path: target, replace: true } as any);
|
||||
});
|
||||
|
||||
await router.push('/analytics').catch(() => {});
|
||||
return router.currentRoute.value.name;
|
||||
}
|
||||
|
||||
describe('登录后重定向到动态路由 /analytics', () => {
|
||||
it('动态路由注册后能解析到真实路由', () => {
|
||||
const router = createTestRouter();
|
||||
registerDynamicRoutes(router);
|
||||
expect(router.resolve('/analytics').name).toBe('Analytics');
|
||||
});
|
||||
|
||||
it('修复后:登录最终落在 Analytics,而不是 404 页', async () => {
|
||||
const landed = await simulateLogin(false);
|
||||
expect(landed).toBe('Analytics');
|
||||
});
|
||||
|
||||
it('对照:旧写法(spread resolve 结果)的最终落点', async () => {
|
||||
const landed = await simulateLogin(true);
|
||||
// 记录旧写法的实际行为,用于判断修复是否真的改变了结果
|
||||
expect(['Analytics', FALLBACK]).toContain(landed);
|
||||
});
|
||||
});
|
||||
@@ -112,8 +112,13 @@ function setupAccessGuard(router: Router) {
|
||||
? userInfo.homePath || DEFAULT_HOME_PATH
|
||||
: to.fullPath)) as string;
|
||||
|
||||
// 不能返回 router.resolve(...) 的结果:
|
||||
// 目标路径(如 /analytics,它是 Dashboard 的绝对路径子路由)刚由
|
||||
// generateAccess() 注册,若此刻解析不就位,就会落到 /:path(.*)* 兑底路由,
|
||||
// 其 name 一旦被带回导航,即使路由随后注册也会锁在 404 页(刷新才好)。
|
||||
// 改为只返回路径字串,由 vue-router 自己重新匹配(此时路由表已就绪)。
|
||||
return {
|
||||
...router.resolve(decodeURIComponent(redirectPath)),
|
||||
path: decodeURIComponent(redirectPath),
|
||||
replace: true,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -24,7 +24,7 @@ import { $t } from '#/locales';
|
||||
* @description 认证相关状态管理
|
||||
* @Link https://github.com/xiujiecn/xiu-admin
|
||||
* @Copyright Copyright (c) 2025 LiXiujie
|
||||
* @Author Lxj <li@xiujie.cn>
|
||||
* @Author Lxj <xxx@example.com>
|
||||
* @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
* @date 2024-03-21
|
||||
*/
|
||||
|
||||
@@ -140,6 +140,118 @@ function getDownloadData(response: DownloadResponse) {
|
||||
};
|
||||
}
|
||||
|
||||
type AsyncExportDetection =
|
||||
| { kind: 'error'; message: string }
|
||||
| { kind: 'file' }
|
||||
| { kind: 'success' };
|
||||
|
||||
/**
|
||||
* 判断后端响应是「异步导出回执」「业务错误」还是「正常文件」。
|
||||
*
|
||||
* 背景:导出接口 responseType 是 blob,而后端出错时返回的是 HTTP 200 + JSON 业务码
|
||||
* (框架的 responseReturn 为 raw,不会抛错),如果不在这里解析,
|
||||
* 业务错误会被当成文件静默下载,用户既看不到原因也不知道失败。
|
||||
*/
|
||||
async function detectAsyncExport(
|
||||
response: any,
|
||||
): Promise<AsyncExportDetection> {
|
||||
const blob = response instanceof Blob ? response : response?.data;
|
||||
if (!(blob instanceof Blob)) {
|
||||
return { kind: 'file' };
|
||||
}
|
||||
if (blob.type && blob.type.includes('spreadsheet')) {
|
||||
return { kind: 'file' };
|
||||
}
|
||||
// 正常 xlsx 至少几 KB;只有很小的 Blob 才可能是 JSON 回执
|
||||
if (blob.size <= 0 || blob.size >= 8192) {
|
||||
return { kind: 'file' };
|
||||
}
|
||||
try {
|
||||
const text = (await blob.text()).trim();
|
||||
if (!text.startsWith('{')) {
|
||||
return { kind: 'file' };
|
||||
}
|
||||
const json = JSON.parse(text);
|
||||
if (json?.code === 0) {
|
||||
return { kind: 'success' };
|
||||
}
|
||||
return {
|
||||
kind: 'error',
|
||||
message: json?.message || '导出提交失败,请稍后重试',
|
||||
};
|
||||
} catch {
|
||||
return { kind: 'file' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取表格当前显示的列(顺序 + 标题),用于「按页面显示列导出」。
|
||||
* 无 field 的列(复选框、序号、操作列)以及被列设置隐藏的列会被忽略。
|
||||
* @param gridApi useVbenVxeGrid 返回的 api 实例
|
||||
*/
|
||||
export interface ExportColumn {
|
||||
field: string;
|
||||
label: string;
|
||||
dictType?: string;
|
||||
dictValueMap?: Record<string, string>;
|
||||
/** 固定值映射:页面用三元表达式等非字典方式渲染的列 */
|
||||
valueMap?: Record<string, string>;
|
||||
/** 固定值映射未命中时的兜底文案 */
|
||||
valueDefault?: string;
|
||||
fallbackField?: string;
|
||||
}
|
||||
|
||||
export function getExportColumns(gridApi: any): ExportColumn[] {
|
||||
const grid: any = gridApi?.grid;
|
||||
if (!grid || typeof grid.getColumns !== 'function') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const columns = (grid.getColumns() ?? []) as any[];
|
||||
const seen = new Set<string>();
|
||||
const result: ExportColumn[] = [];
|
||||
for (const column of columns) {
|
||||
if (!column || column.visible === false) {
|
||||
continue;
|
||||
}
|
||||
const field = String(column.field ?? '').trim();
|
||||
if (!field || field === 'action') {
|
||||
continue;
|
||||
}
|
||||
if (seen.has(field)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(field);
|
||||
const dictType = column.params?.dictType;
|
||||
const valueMap = column.params?.valueMap;
|
||||
const valueDefault = column.params?.valueDefault;
|
||||
result.push({
|
||||
field,
|
||||
label: String(column.title ?? field),
|
||||
...(valueMap
|
||||
? {
|
||||
valueMap,
|
||||
...(typeof valueDefault === 'string' && valueDefault
|
||||
? { valueDefault }
|
||||
: {}),
|
||||
}
|
||||
: {}),
|
||||
...(typeof dictType === 'string' && dictType.trim()
|
||||
? {
|
||||
dictType: dictType.trim(),
|
||||
...(column.params?.dictValueMap
|
||||
? { dictValueMap: column.params.dictValueMap }
|
||||
: {}),
|
||||
...(column.params?.fallbackField
|
||||
? { fallbackField: column.params.fallbackField }
|
||||
: {}),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用下载excel方法
|
||||
* @param api 后端下载接口
|
||||
@@ -160,6 +272,19 @@ export async function commonDownloadExcel(
|
||||
const response = await api(
|
||||
handleRangeTimeValue(requestData, fieldMappingTime),
|
||||
);
|
||||
|
||||
const detected = await detectAsyncExport(response);
|
||||
if (detected.kind === 'error') {
|
||||
message.error(detected.message, 5);
|
||||
return false;
|
||||
}
|
||||
if (detected.kind === 'success') {
|
||||
// 异步导出:服务端只回执任务号,文件稍后到「下载中心」取
|
||||
window.dispatchEvent(new CustomEvent('iotlcm:export-submitted'));
|
||||
message.success('导出任务已提交,请到「下载中心」查看并下载文件', 5);
|
||||
return true;
|
||||
}
|
||||
|
||||
const { data, filename } = getDownloadData(response);
|
||||
downloadExcelFile(
|
||||
data,
|
||||
@@ -168,6 +293,20 @@ export async function commonDownloadExcel(
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
// 兜底:响应拦截器若把 blob 响应误判为错误(抛出的就是 Blob 本身),
|
||||
// 这里仍按「异步回执 / 业务错误」解析,避免成功却被提示失败。
|
||||
if (error instanceof Blob) {
|
||||
const detected = await detectAsyncExport(error);
|
||||
if (detected.kind === 'success') {
|
||||
window.dispatchEvent(new CustomEvent('iotlcm:export-submitted'));
|
||||
message.success('导出任务已提交,请到「下载中心」查看并下载文件', 5);
|
||||
return true;
|
||||
}
|
||||
if (detected.kind === 'error') {
|
||||
message.error(detected.message, 5);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
console.error(error);
|
||||
return false;
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => ({ confirm: vi.fn() }));
|
||||
vi.mock('ant-design-vue', () => ({ Modal: { confirm: mocks.confirm } }));
|
||||
|
||||
import { confirmTemplateOverwrite } from './role-template';
|
||||
|
||||
describe('模板覆盖确认', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('普通角色保存不触发批量覆盖确认', async () => {
|
||||
expect(await confirmTemplateOverwrite(false)).toBe(true);
|
||||
expect(mocks.confirm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('取消模板确认不应继续保存', async () => {
|
||||
const confirmation = confirmTemplateOverwrite(true);
|
||||
expect(mocks.confirm).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
content: expect.stringContaining('其他角色不受影响'),
|
||||
okText: '确认保存并覆盖',
|
||||
}),
|
||||
);
|
||||
mocks.confirm.mock.calls[0]![0].onCancel();
|
||||
expect(await confirmation).toBe(false);
|
||||
});
|
||||
|
||||
it('确认覆盖后才继续保存', async () => {
|
||||
const confirmation = confirmTemplateOverwrite(true);
|
||||
mocks.confirm.mock.calls[0]![0].onOk();
|
||||
expect(await confirmation).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Modal } from 'ant-design-vue';
|
||||
|
||||
export const AGENT_ROLE_TEMPLATE_CONFIG_KEY = 'agent.role.template.role_id';
|
||||
|
||||
export const TEMPLATE_OVERWRITE_DESCRIPTION =
|
||||
'保存后将在后台覆盖所有下级代理商对应默认角色的菜单权限,包含此前对这些角色的单独增减。其他角色不受影响。请在下发完成后再做个别调整。';
|
||||
|
||||
export function confirmTemplateOverwrite(
|
||||
isTemplateRole: boolean,
|
||||
): Promise<boolean> {
|
||||
if (!isTemplateRole) {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
Modal.confirm({
|
||||
title: '保存并覆盖下发代理模板?',
|
||||
content: TEMPLATE_OVERWRITE_DESCRIPTION,
|
||||
okText: '确认保存并覆盖',
|
||||
cancelText: '取消',
|
||||
maskClosable: false,
|
||||
onOk: () => {
|
||||
resolve(true);
|
||||
},
|
||||
onCancel: () => {
|
||||
resolve(false);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
* @description 关于页面
|
||||
* @Link https://github.com/xiujiecn/xiu-admin
|
||||
* @Copyright Copyright (c) 2025 LiXiujie
|
||||
* @Author Lxj <li@xiujie.cn>
|
||||
* @Author Lxj <xxx@example.com>
|
||||
* @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
* @date 2024-03-21
|
||||
-->
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @description 图片验证码组件
|
||||
* @Link https://github.com/xiujiecn/xiu-admin
|
||||
* @Copyright Copyright (c) 2025 LiXiujie
|
||||
* @Author Lxj <li@xiujie.cn>
|
||||
* @Author Lxj <xxx@example.com>
|
||||
* @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
* @date 2024-03-21
|
||||
-->
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @description 忘记密码页面
|
||||
* @Link https://github.com/xiujiecn/xiu-admin
|
||||
* @Copyright Copyright (c) 2025 LiXiujie
|
||||
* @Author Lxj <li@xiujie.cn>
|
||||
* @Author Lxj <xxx@example.com>
|
||||
* @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
* @date 2024-03-21
|
||||
-->
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @description 登录页面
|
||||
* @Link https://github.com/xiujiecn/xiu-admin
|
||||
* @Copyright Copyright (c) 2025 LiXiujie
|
||||
* @Author Lxj <li@xiujie.cn>
|
||||
* @Author Lxj <xxx@example.com>
|
||||
* @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
* @date 2024-03-21
|
||||
-->
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @description MFA多因子认证页面
|
||||
* @Link https://github.com/xiujiecn/xiu-admin
|
||||
* @Copyright Copyright (c) 2025 LiXiujie
|
||||
* @Author Lxj <li@xiujie.cn>
|
||||
* @Author Lxj <xxx@example.com>
|
||||
* @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
* @date 2024-03-21
|
||||
-->
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @description 二维码登录页面
|
||||
* @Link https://github.com/xiujiecn/xiu-admin
|
||||
* @Copyright Copyright (c) 2025 LiXiujie
|
||||
* @Author Lxj <li@xiujie.cn>
|
||||
* @Author Lxj <xxx@example.com>
|
||||
* @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
* @date 2024-03-21
|
||||
-->
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @description 注册页面
|
||||
* @Link https://github.com/xiujiecn/xiu-admin
|
||||
* @Copyright Copyright (c) 2025 LiXiujie
|
||||
* @Author Lxj <li@xiujie.cn>
|
||||
* @Author Lxj <xxx@example.com>
|
||||
* @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
* @date 2024-03-21
|
||||
-->
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @description 即将上线页面
|
||||
* @Link https://github.com/xiujiecn/xiu-admin
|
||||
* @Copyright Copyright (c) 2025 LiXiujie
|
||||
* @Author Lxj <li@xiujie.cn>
|
||||
* @Author Lxj <xxx@example.com>
|
||||
* @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
* @date 2024-03-21
|
||||
-->
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @description 403禁止访问页面
|
||||
* @Link https://github.com/xiujiecn/xiu-admin
|
||||
* @Copyright Copyright (c) 2025 LiXiujie
|
||||
* @Author Lxj <li@xiujie.cn>
|
||||
* @Author Lxj <xxx@example.com>
|
||||
* @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
* @date 2024-03-21
|
||||
-->
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @description 500内部错误页面
|
||||
* @Link https://github.com/xiujiecn/xiu-admin
|
||||
* @Copyright Copyright (c) 2025 LiXiujie
|
||||
* @Author Lxj <li@xiujie.cn>
|
||||
* @Author Lxj <xxx@example.com>
|
||||
* @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
* @date 2024-03-21
|
||||
-->
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @description 404未找到页面
|
||||
* @Link https://github.com/xiujiecn/xiu-admin
|
||||
* @Copyright Copyright (c) 2025 LiXiujie
|
||||
* @Author Lxj <li@xiujie.cn>
|
||||
* @Author Lxj <xxx@example.com>
|
||||
* @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
* @date 2024-03-21
|
||||
-->
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @description 离线页面
|
||||
* @Link https://github.com/xiujiecn/xiu-admin
|
||||
* @Copyright Copyright (c) 2025 LiXiujie
|
||||
* @Author Lxj <li@xiujie.cn>
|
||||
* @Author Lxj <xxx@example.com>
|
||||
* @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
* @date 2024-03-21
|
||||
-->
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @description OAuth授权相关公共方法
|
||||
* @Link https://github.com/xiujiecn/xiu-admin
|
||||
* @Copyright Copyright (c) 2025 LiXiujie
|
||||
* @Author Lxj <li@xiujie.cn>
|
||||
* @Author Lxj <xxx@example.com>
|
||||
* @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
* @date 2024-03-21
|
||||
*/
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @description 个人中心-账号绑定组件
|
||||
* @Link https://github.com/xiujiecn/xiu-admin
|
||||
* @Copyright Copyright (c) 2025 LiXiujie
|
||||
* @Author Lxj <li@xiujie.cn>
|
||||
* @Author Lxj <xxx@example.com>
|
||||
* @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
* @date 2024-03-21
|
||||
-->
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @description 个人中心-基本设置组件
|
||||
* @Link https://github.com/xiujiecn/xiu-admin
|
||||
* @Copyright Copyright (c) 2025 LiXiujie
|
||||
* @Author Lxj <li@xiujie.cn>
|
||||
* @Author Lxj <xxx@example.com>
|
||||
* @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
* @date 2024-03-21
|
||||
-->
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @description 个人中心-在线设备组件
|
||||
* @Link https://github.com/xiujiecn/xiu-admin
|
||||
* @Copyright Copyright (c) 2025 LiXiujie
|
||||
* @Author Lxj <li@xiujie.cn>
|
||||
* @Author Lxj <xxx@example.com>
|
||||
* @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
* @date 2024-03-21
|
||||
-->
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @description 个人中心-安全设置组件
|
||||
* @Link https://github.com/xiujiecn/xiu-admin
|
||||
* @Copyright Copyright (c) 2025 LiXiujie
|
||||
* @Author Lxj <li@xiujie.cn>
|
||||
* @Author Lxj <xxx@example.com>
|
||||
* @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
* @date 2024-03-21
|
||||
-->
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @description 个人中心页面
|
||||
* @Link https://github.com/xiujiecn/xiu-admin
|
||||
* @Copyright Copyright (c) 2025 LiXiujie
|
||||
* @Author Lxj <li@xiujie.cn>
|
||||
* @Author Lxj <xxx@example.com>
|
||||
* @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
* @date 2024-03-21
|
||||
-->
|
||||
|
||||
@@ -10,7 +10,7 @@ export const emitter = mitt<Events>();
|
||||
* @description 个人中心事件总线
|
||||
* @Link https://github.com/xiujiecn/xiu-admin
|
||||
* @Copyright Copyright (c) 2025 LiXiujie
|
||||
* @Author Lxj <li@xiujie.cn>
|
||||
* @Author Lxj <xxx@example.com>
|
||||
* @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
* @date 2024-03-21
|
||||
*/
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @description 个人中心-个人信息面板
|
||||
* @Link https://github.com/xiujiecn/xiu-admin
|
||||
* @Copyright Copyright (c) 2025 LiXiujie
|
||||
* @Author Lxj <li@xiujie.cn>
|
||||
* @Author Lxj <xxx@example.com>
|
||||
* @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
* @date 2024-03-21
|
||||
-->
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @description 个人中心-设置面板
|
||||
* @Link https://github.com/xiujiecn/xiu-admin
|
||||
* @Copyright Copyright (c) 2025 LiXiujie
|
||||
* @Author Lxj <li@xiujie.cn>
|
||||
* @Author Lxj <xxx@example.com>
|
||||
* @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
* @date 2024-03-21
|
||||
-->
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils';
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import MenuSelect from './menu-select.vue';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
options: {} as any,
|
||||
getTree: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
setRows: vi.fn(),
|
||||
load: vi.fn(),
|
||||
checked: vi.fn(() => [] as { menuId: number }[]),
|
||||
halfChecked: vi.fn(() => [] as { menuId: number }[]),
|
||||
setAll: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('#/api/agent', () => ({ getAgentMenuTreeApi: mocks.getTree }));
|
||||
vi.mock('../../system/menu/model', () => ({
|
||||
getMenuTypeOptionsColor: vi.fn(),
|
||||
getMenuTypeOptionsLabel: vi.fn(),
|
||||
}));
|
||||
vi.mock('ant-design-vue', () => ({
|
||||
Alert: { template: '<div />' },
|
||||
Checkbox: {
|
||||
props: ['disabled'],
|
||||
template: '<button :disabled="disabled"><slot /></button>',
|
||||
},
|
||||
Tag: { template: '<span><slot /></span>' },
|
||||
}));
|
||||
vi.mock('#/adapter/vxe-table', () => ({
|
||||
useVbenVxeGrid: (options: any) => {
|
||||
mocks.options = options.gridOptions;
|
||||
return [
|
||||
{ template: '<div />' },
|
||||
{
|
||||
setGridOptions: vi.fn(),
|
||||
grid: {
|
||||
clearCheckboxRow: mocks.clear,
|
||||
setCheckboxRowKey: mocks.setRows,
|
||||
loadData: mocks.load,
|
||||
getCheckboxRecords: mocks.checked,
|
||||
getCheckboxIndeterminateRecords: mocks.halfChecked,
|
||||
setAllCheckboxRow: mocks.setAll,
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
}));
|
||||
|
||||
function exposed(wrapper: ReturnType<typeof mount>) {
|
||||
return wrapper.vm as unknown as { getData: () => number[] };
|
||||
}
|
||||
|
||||
describe('角色菜单展示与实际授权边界', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.getTree.mockResolvedValue({
|
||||
items: [{ menuId: 1, children: [{ menuId: 10 }] }],
|
||||
menuIds: [10],
|
||||
});
|
||||
mocks.checked.mockReturnValue([]);
|
||||
mocks.halfChecked.mockReturnValue([]);
|
||||
});
|
||||
|
||||
it('父节点正常展示和勾选,不展示内部授权规则', async () => {
|
||||
const wrapper = mount(MenuSelect);
|
||||
await flushPromises();
|
||||
expect(
|
||||
mocks.options.checkboxConfig.checkMethod({ row: { menuId: 1 } }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
mocks.options.checkboxConfig.checkMethod({ row: { menuId: 10 } }),
|
||||
).toBe(true);
|
||||
expect(wrapper.emitted('ready')).toHaveLength(1);
|
||||
expect(wrapper.text()).not.toContain('灰色菜单');
|
||||
expect(wrapper.text()).not.toContain('授权');
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('回显移除过期权限,不自动勾选新增菜单', async () => {
|
||||
const wrapper = mount(MenuSelect, { props: { menuIds: [1, 10, 99] } });
|
||||
await flushPromises();
|
||||
expect(mocks.setRows).toHaveBeenLastCalledWith([10], true);
|
||||
await wrapper.setProps({ menuIds: [] });
|
||||
await flushPromises();
|
||||
expect(mocks.setRows).toHaveBeenLastCalledWith([], true);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('全选和父子联动的输出也不能提交展示节点', async () => {
|
||||
const wrapper = mount(MenuSelect);
|
||||
await flushPromises();
|
||||
mocks.checked.mockReturnValue([
|
||||
{ menuId: 1 },
|
||||
{ menuId: 10 },
|
||||
{ menuId: 99 },
|
||||
]);
|
||||
mocks.halfChecked.mockReturnValue([{ menuId: 1 }, { menuId: 10 }]);
|
||||
expect(exposed(wrapper).getData()).toEqual([10]);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('只读模式禁止勾选和输出权限', async () => {
|
||||
const wrapper = mount(MenuSelect, { props: { disabled: true } });
|
||||
await flushPromises();
|
||||
mocks.checked.mockReturnValue([{ menuId: 10 }]);
|
||||
expect(
|
||||
mocks.options.checkboxConfig.checkMethod({ row: { menuId: 10 } }),
|
||||
).toBe(false);
|
||||
expect(exposed(wrapper).getData()).toEqual([]);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('菜单加载失败不进入可编辑状态', async () => {
|
||||
mocks.getTree.mockRejectedValueOnce(new Error('请求失败'));
|
||||
const wrapper = mount(MenuSelect);
|
||||
await flushPromises();
|
||||
expect(wrapper.emitted('loadError')).toHaveLength(1);
|
||||
expect(wrapper.emitted('ready')).toBeUndefined();
|
||||
expect(
|
||||
mocks.options.checkboxConfig.checkMethod({ row: { menuId: 10 } }),
|
||||
).toBe(false);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('表格装载失败后撤回可编辑状态', async () => {
|
||||
mocks.load.mockRejectedValueOnce(new Error('装载失败'));
|
||||
const wrapper = mount(MenuSelect);
|
||||
await flushPromises();
|
||||
expect(wrapper.emitted('loadError')).toHaveLength(1);
|
||||
expect(wrapper.emitted('ready')).toBeUndefined();
|
||||
expect(
|
||||
mocks.options.checkboxConfig.checkMethod({ row: { menuId: 10 } }),
|
||||
).toBe(false);
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,12 @@
|
||||
<script lang="ts" setup>
|
||||
import type { MenuTreeItem } from '#/api/agent';
|
||||
|
||||
import { nextTick, onMounted, ref, watch } from 'vue';
|
||||
|
||||
import { Checkbox, Tag } from 'ant-design-vue';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { getAgentMenuTreeApi } from '#/api/agent';
|
||||
import type { MenuTreeItem } from '#/api/agent';
|
||||
|
||||
import {
|
||||
getMenuTypeOptionsColor,
|
||||
@@ -13,14 +14,31 @@ import {
|
||||
} from '../../system/menu/model';
|
||||
|
||||
const props = defineProps<{
|
||||
disabled?: boolean;
|
||||
menuIds?: number[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
loadError: [];
|
||||
ready: [];
|
||||
}>();
|
||||
|
||||
const flag = ref(true);
|
||||
const assignableMenuIds = ref(new Set<number>());
|
||||
const ready = ref(false);
|
||||
|
||||
function canSelect() {
|
||||
return ready.value && !props.disabled;
|
||||
}
|
||||
|
||||
async function syncCheckedRows() {
|
||||
await gridApi.grid.clearCheckboxRow();
|
||||
await gridApi.grid.setCheckboxRowKey(props.menuIds || [], true);
|
||||
await gridApi.grid.setCheckboxRowKey(
|
||||
(props.menuIds || []).filter((menuId) =>
|
||||
assignableMenuIds.value.has(menuId),
|
||||
),
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
async function loadMenuTree(items: MenuTreeItem[]) {
|
||||
@@ -39,14 +57,24 @@ watch(
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
const { items } = await getAgentMenuTreeApi();
|
||||
try {
|
||||
const { items, menuIds } = await getAgentMenuTreeApi();
|
||||
assignableMenuIds.value = new Set(menuIds);
|
||||
ready.value = true;
|
||||
await loadMenuTree(items);
|
||||
emit('ready');
|
||||
} catch {
|
||||
ready.value = false;
|
||||
assignableMenuIds.value = new Set();
|
||||
emit('loadError');
|
||||
}
|
||||
});
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
gridOptions: {
|
||||
align: 'center',
|
||||
checkboxConfig: {
|
||||
checkMethod: () => canSelect(),
|
||||
checkStrictly: true,
|
||||
},
|
||||
columns: [
|
||||
@@ -90,6 +118,7 @@ const checkboxStatus = ref({
|
||||
});
|
||||
|
||||
function checkboxAllChange(params: any) {
|
||||
if (!ready.value || props.disabled) return;
|
||||
gridApi.grid.setAllCheckboxRow(params.target.checked);
|
||||
}
|
||||
|
||||
@@ -98,6 +127,7 @@ function checkboxExpandChange(params: any) {
|
||||
}
|
||||
|
||||
function checkboxLinkageChange(params: any) {
|
||||
if (!ready.value || props.disabled) return;
|
||||
gridApi.setGridOptions({
|
||||
checkboxConfig: {
|
||||
checkStrictly: !params.target.checked,
|
||||
@@ -110,7 +140,9 @@ function getData() {
|
||||
const indeterminate = gridApi.grid
|
||||
.getCheckboxIndeterminateRecords()
|
||||
.map((item) => item.menuId);
|
||||
return [...new Set([...checked, ...indeterminate])];
|
||||
return [...new Set<number>([...checked, ...indeterminate])].filter(
|
||||
(menuId) => canSelect() && assignableMenuIds.value.has(menuId),
|
||||
);
|
||||
}
|
||||
|
||||
function clearData() {
|
||||
@@ -127,21 +159,21 @@ defineExpose({
|
||||
<div class="flex justify-between px-2">
|
||||
<Checkbox
|
||||
v-model:checked="checkboxStatus.all"
|
||||
:disabled="!flag"
|
||||
:disabled="!flag || !ready || disabled"
|
||||
@change="checkboxAllChange"
|
||||
>
|
||||
全选/全不选
|
||||
</Checkbox>
|
||||
<Checkbox
|
||||
v-model:checked="checkboxStatus.expand"
|
||||
:disabled="!flag"
|
||||
:disabled="!flag || !ready || disabled"
|
||||
@change="checkboxExpandChange"
|
||||
>
|
||||
展开/折叠
|
||||
</Checkbox>
|
||||
<Checkbox
|
||||
v-model:checked="checkboxStatus.linkage"
|
||||
:disabled="!flag"
|
||||
:disabled="!flag || !ready || disabled"
|
||||
@change="checkboxLinkageChange"
|
||||
>
|
||||
父子联动
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
hooks: {} as {
|
||||
onConfirm?: () => Promise<void>;
|
||||
onOpenChange?: (open: boolean) => Promise<unknown>;
|
||||
},
|
||||
close: vi.fn(),
|
||||
getData: vi.fn(() => ({ update: false, view: false })),
|
||||
getRole: vi.fn(),
|
||||
saveRole: vi.fn(),
|
||||
setState: vi.fn(),
|
||||
lock: vi.fn(),
|
||||
unlock: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('#/api/agent', () => ({
|
||||
getAgentRoleViewApi: mocks.getRole,
|
||||
saveAgentRoleApi: mocks.saveRole,
|
||||
}));
|
||||
vi.mock('@vben/locales', () => ({ $t: (key: string) => key }));
|
||||
vi.mock('@vben/utils', () => ({
|
||||
cloneDeep: (value: object) => structuredClone(value),
|
||||
getPopupContainer: vi.fn(),
|
||||
}));
|
||||
vi.mock('ant-design-vue', () => ({
|
||||
message: { error: mocks.error, success: vi.fn(), warning: mocks.warning },
|
||||
}));
|
||||
vi.mock('@vben/common-ui', async () => {
|
||||
const { defineComponent, h } = await import('vue');
|
||||
return {
|
||||
useVbenModal: (options: typeof mocks.hooks) => {
|
||||
mocks.hooks = options;
|
||||
return [
|
||||
defineComponent({
|
||||
setup:
|
||||
(_, { slots }) =>
|
||||
() =>
|
||||
h('div', slots.default?.()),
|
||||
}),
|
||||
{
|
||||
close: mocks.close,
|
||||
getData: mocks.getData,
|
||||
setState: mocks.setState,
|
||||
lock: mocks.lock,
|
||||
unlock: mocks.unlock,
|
||||
},
|
||||
];
|
||||
},
|
||||
z: { string: () => ({ min: () => ({ max: vi.fn() }) }) },
|
||||
};
|
||||
});
|
||||
vi.mock('#/adapter/form', async () => {
|
||||
const { defineComponent, h } = await import('vue');
|
||||
return {
|
||||
useVbenForm: () => [
|
||||
defineComponent({
|
||||
setup:
|
||||
(_, { slots }) =>
|
||||
() =>
|
||||
h('div', slots.menuIds?.()),
|
||||
}),
|
||||
{
|
||||
getValues: vi.fn(async () => ({ roleName: '客服', roleKey: 'staff' })),
|
||||
resetForm: vi.fn(),
|
||||
setState: vi.fn(),
|
||||
setValues: vi.fn(),
|
||||
validate: vi.fn(async () => ({ valid: true })),
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
vi.mock('./menu-select.vue', async () => {
|
||||
const { defineComponent, h } = await import('vue');
|
||||
return {
|
||||
default: defineComponent({
|
||||
name: 'MenuSelect',
|
||||
props: ['menuIds', 'disabled'],
|
||||
emits: ['ready', 'loadError'],
|
||||
setup: (_, { expose }) => {
|
||||
expose({ clearData: vi.fn(), getData: () => [10] });
|
||||
return () => h('div');
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
import RoleDrawer from './role-drawer.vue';
|
||||
|
||||
describe('企业角色菜单加载与保存', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.getData.mockReturnValue({ update: false, view: false });
|
||||
});
|
||||
|
||||
it('角色详情加载失败后关闭弹窗并解除加载状态', async () => {
|
||||
mocks.getData.mockReturnValue({ update: true, view: false });
|
||||
mocks.getRole.mockRejectedValueOnce(new Error('请求失败'));
|
||||
const wrapper = mount(RoleDrawer);
|
||||
await mocks.hooks.onOpenChange?.(true);
|
||||
expect(mocks.close).toHaveBeenCalled();
|
||||
expect(mocks.setState).toHaveBeenCalledWith({
|
||||
confirmLoading: false,
|
||||
loading: false,
|
||||
});
|
||||
expect(mocks.saveRole).not.toHaveBeenCalled();
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('菜单未就绪或加载失败时不保存空权限', async () => {
|
||||
const wrapper = mount(RoleDrawer);
|
||||
await mocks.hooks.onOpenChange?.(true);
|
||||
await flushPromises();
|
||||
await mocks.hooks.onConfirm?.();
|
||||
expect(mocks.saveRole).not.toHaveBeenCalled();
|
||||
wrapper.findComponent({ name: 'MenuSelect' }).vm.$emit('loadError');
|
||||
await mocks.hooks.onConfirm?.();
|
||||
expect(mocks.saveRole).not.toHaveBeenCalled();
|
||||
expect(mocks.setState).toHaveBeenCalledWith({ confirmDisabled: true });
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('菜单就绪后按普通角色提交且不新增角色用途', async () => {
|
||||
const wrapper = mount(RoleDrawer);
|
||||
await mocks.hooks.onOpenChange?.(true);
|
||||
await flushPromises();
|
||||
wrapper.findComponent({ name: 'MenuSelect' }).vm.$emit('ready');
|
||||
await mocks.hooks.onConfirm?.();
|
||||
expect(mocks.saveRole).toHaveBeenCalledWith({
|
||||
isPass: 0,
|
||||
menuIds: [10],
|
||||
roleKey: 'staff',
|
||||
roleName: '客服',
|
||||
});
|
||||
expect(mocks.close).toHaveBeenCalled();
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('关闭后重新打开必须重新等待菜单加载', async () => {
|
||||
const wrapper = mount(RoleDrawer);
|
||||
await mocks.hooks.onOpenChange?.(true);
|
||||
await flushPromises();
|
||||
wrapper.findComponent({ name: 'MenuSelect' }).vm.$emit('ready');
|
||||
await mocks.hooks.onOpenChange?.(false);
|
||||
await mocks.hooks.onOpenChange?.(true);
|
||||
await flushPromises();
|
||||
await mocks.hooks.onConfirm?.();
|
||||
expect(mocks.saveRole).not.toHaveBeenCalled();
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('保存期间锁定弹窗且重复确认只提交一次', async () => {
|
||||
let finishSave: (() => void) | undefined;
|
||||
mocks.saveRole.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
finishSave = resolve;
|
||||
}),
|
||||
);
|
||||
const wrapper = mount(RoleDrawer);
|
||||
await mocks.hooks.onOpenChange?.(true);
|
||||
await flushPromises();
|
||||
wrapper.findComponent({ name: 'MenuSelect' }).vm.$emit('ready');
|
||||
const firstSave = mocks.hooks.onConfirm?.();
|
||||
await flushPromises();
|
||||
await mocks.hooks.onConfirm?.();
|
||||
expect(mocks.saveRole).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.lock).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.unlock).not.toHaveBeenCalled();
|
||||
finishSave?.();
|
||||
await firstSave;
|
||||
expect(mocks.unlock).toHaveBeenCalledTimes(1);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('保存失败后解除锁定并允许重试', async () => {
|
||||
mocks.saveRole.mockRejectedValueOnce(new Error('下发失败'));
|
||||
const wrapper = mount(RoleDrawer);
|
||||
await mocks.hooks.onOpenChange?.(true);
|
||||
await flushPromises();
|
||||
wrapper.findComponent({ name: 'MenuSelect' }).vm.$emit('ready');
|
||||
await expect(mocks.hooks.onConfirm?.()).rejects.toThrow('下发失败');
|
||||
expect(mocks.close).not.toHaveBeenCalled();
|
||||
expect(mocks.unlock).toHaveBeenCalledTimes(1);
|
||||
await mocks.hooks.onConfirm?.();
|
||||
expect(mocks.saveRole).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.close).toHaveBeenCalledTimes(1);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('查看模式即使触发确认也不保存', async () => {
|
||||
mocks.getData.mockReturnValue({ update: false, view: true });
|
||||
mocks.getRole.mockResolvedValueOnce({ menuIds: [10], isPass: 0 });
|
||||
const wrapper = mount(RoleDrawer);
|
||||
await mocks.hooks.onOpenChange?.(true);
|
||||
await flushPromises();
|
||||
wrapper.findComponent({ name: 'MenuSelect' }).vm.$emit('ready');
|
||||
await mocks.hooks.onConfirm?.();
|
||||
expect(mocks.saveRole).not.toHaveBeenCalled();
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import { $t } from '@vben/locales';
|
||||
import { cloneDeep, getPopupContainer } from '@vben/utils';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
import { confirmTemplateOverwrite } from '#/utils/role-template';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import { getAgentRoleViewApi, saveAgentRoleApi } from '#/api/agent';
|
||||
@@ -29,6 +30,9 @@ const showMenuSelect = ref(false);
|
||||
const refMenuSelect = ref<InstanceType<typeof MenuSelect> | null>(null);
|
||||
const canEditRole = ref(true);
|
||||
const roleIsPass = ref(0);
|
||||
const isTemplateRole = ref(false);
|
||||
const menuReady = ref(false);
|
||||
const saving = ref(false);
|
||||
|
||||
const title = computed(() => {
|
||||
if (isView.value) {
|
||||
@@ -110,15 +114,20 @@ const [BasicModal, modalApi] = useVbenModal({
|
||||
showMenuSelect.value = false;
|
||||
canEditRole.value = true;
|
||||
roleIsPass.value = 0;
|
||||
isTemplateRole.value = false;
|
||||
menuReady.value = false;
|
||||
return null;
|
||||
}
|
||||
modalApi.setState({ confirmLoading: true, loading: true });
|
||||
menuReady.value = false;
|
||||
modalApi.setState({ confirmDisabled: true, loading: true });
|
||||
const { id, update, view } = modalApi.getData() as DrawerProps;
|
||||
isUpdate.value = update;
|
||||
isView.value = view;
|
||||
try {
|
||||
if (isUpdate.value || isView.value) {
|
||||
const record = await getAgentRoleViewApi({ roleId: Number(id) });
|
||||
roleIsPass.value = record.isPass ?? 0;
|
||||
isTemplateRole.value = record.isTemplateRole ?? false;
|
||||
canEditRole.value = !record.isDefaultRole && !record.sourceRoleId;
|
||||
await formApi.setValues(record);
|
||||
menuIds.value = [...record.menuIds];
|
||||
@@ -126,6 +135,11 @@ const [BasicModal, modalApi] = useVbenModal({
|
||||
roleIsPass.value = 0;
|
||||
await formApi.resetForm();
|
||||
}
|
||||
} catch {
|
||||
modalApi.setState({ confirmLoading: false, loading: false });
|
||||
modalApi.close();
|
||||
return;
|
||||
}
|
||||
showMenuSelect.value = true;
|
||||
modalApi.setState({
|
||||
confirmLoading: false,
|
||||
@@ -144,28 +158,46 @@ const [BasicModal, modalApi] = useVbenModal({
|
||||
});
|
||||
|
||||
async function handleConfirm() {
|
||||
if (saving.value || isView.value) {
|
||||
return;
|
||||
}
|
||||
if (!canEditRole.value) {
|
||||
message.warning('不能修改本级机构绑定的默认角色权限');
|
||||
return;
|
||||
}
|
||||
if (!menuReady.value || !refMenuSelect.value) {
|
||||
message.warning('菜单权限尚未加载完成,请稍后再试');
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
modalApi.lock();
|
||||
try {
|
||||
modalApi.setState({ confirmLoading: true, loading: true });
|
||||
const { valid } = await formApi.validate();
|
||||
if (!valid) {
|
||||
return;
|
||||
}
|
||||
if (!(await confirmTemplateOverwrite(isTemplateRole.value))) return;
|
||||
const data = cloneDeep(await formApi.getValues());
|
||||
if (!canEditRole.value) {
|
||||
message.warning('不能修改本级机构绑定的默认角色权限');
|
||||
return;
|
||||
}
|
||||
const { id } = modalApi.getData() as DrawerProps;
|
||||
if (isUpdate.value) {
|
||||
data.roleId = Number(id);
|
||||
}
|
||||
data.menuIds = refMenuSelect.value?.getData() || [];
|
||||
data.menuIds = refMenuSelect.value.getData();
|
||||
data.isPass = roleIsPass.value;
|
||||
await saveAgentRoleApi(data);
|
||||
message.success(isUpdate.value ? '更新成功' : '新增成功');
|
||||
message.success(
|
||||
isTemplateRole.value
|
||||
? '模板已保存,覆盖下发已提交后台处理'
|
||||
: isUpdate.value
|
||||
? '更新成功'
|
||||
: '新增成功',
|
||||
);
|
||||
emit('reload');
|
||||
await handleCancel();
|
||||
} finally {
|
||||
saving.value = false;
|
||||
modalApi.unlock();
|
||||
modalApi.setState({ confirmLoading: false, loading: false });
|
||||
}
|
||||
}
|
||||
@@ -175,8 +207,20 @@ async function handleCancel() {
|
||||
modalApi.close();
|
||||
menuIds.value = [];
|
||||
showMenuSelect.value = false;
|
||||
menuReady.value = false;
|
||||
await formApi.resetForm();
|
||||
}
|
||||
|
||||
function handleMenuReady() {
|
||||
menuReady.value = true;
|
||||
modalApi.setState({ confirmDisabled: !canEditRole.value || isView.value });
|
||||
}
|
||||
|
||||
function handleMenuLoadError() {
|
||||
menuReady.value = false;
|
||||
modalApi.setState({ confirmDisabled: true });
|
||||
message.error('菜单权限加载失败,请关闭后重试;未修改原有权限');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -187,7 +231,10 @@ async function handleCancel() {
|
||||
<MenuSelect
|
||||
v-if="showMenuSelect"
|
||||
ref="refMenuSelect"
|
||||
:disabled="isView || !canEditRole"
|
||||
:menu-ids="menuIds"
|
||||
@load-error="handleMenuLoadError"
|
||||
@ready="handleMenuReady"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<script lang="ts" setup>
|
||||
import { h } from 'vue';
|
||||
|
||||
import type { VbenFormProps } from '#/adapter/form';
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { AgentDept } from '#/api/agent';
|
||||
|
||||
import { h } from 'vue';
|
||||
|
||||
import { Page, useVbenModal } from '@vben/common-ui';
|
||||
import { MdiPlus } from '@vben/icons';
|
||||
import { MdiExport, MdiPlus } from '@vben/icons';
|
||||
import { getVxePopupContainer } from '@vben/utils';
|
||||
|
||||
import { Button, message, Popconfirm, Tag } from 'ant-design-vue';
|
||||
@@ -13,9 +14,10 @@ import { Button, message, Popconfirm, Tag } from 'ant-design-vue';
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import {
|
||||
deleteAgentDeptApi,
|
||||
exportAgentDeptApi,
|
||||
getAgentDeptListApi,
|
||||
type AgentDept,
|
||||
} from '#/api/agent';
|
||||
import { commonDownloadExcel, getExportColumns } from '#/utils/file/download';
|
||||
|
||||
import deptDrawer from './dept-drawer.vue';
|
||||
import deptResetPwdModal from './dept-reset-pwd-modal.vue';
|
||||
@@ -60,29 +62,37 @@ const gridOptions: VxeTableGridOptions<AgentDept> = {
|
||||
{ field: 'email', title: '联系人邮箱', minWidth: 160 },
|
||||
{ field: 'areaCode', title: '组织所在地', minWidth: 140 },
|
||||
{ field: 'address', title: '详细地址', minWidth: 180 },
|
||||
{ field: 'createdAt', formatter: 'formatDateTime', title: '创建时间', width: 160 },
|
||||
{
|
||||
field: 'createdAt',
|
||||
formatter: 'formatDateTime',
|
||||
title: '创建时间',
|
||||
width: 160,
|
||||
},
|
||||
{ field: 'status', slots: { default: 'status' }, title: '状态', width: 80 },
|
||||
{ title: '操作', width: 260, slots: { default: 'action' }, fixed: 'right' },
|
||||
],
|
||||
exportConfig: {},
|
||||
height: 'auto',
|
||||
keepSource: true,
|
||||
pagerConfig: {
|
||||
enabled: false,
|
||||
enabled: true,
|
||||
pageSize: 20,
|
||||
pageSizes: [20, 50, 100, 200],
|
||||
},
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async (_params, formValues) => {
|
||||
query: async ({ page }, formValues) => {
|
||||
return await getAgentDeptListApi({
|
||||
...formValues,
|
||||
deptType: 1,
|
||||
page: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
toolbarConfig: {
|
||||
custom: true,
|
||||
export: true,
|
||||
export: false,
|
||||
refresh: true,
|
||||
resizable: true,
|
||||
search: true,
|
||||
@@ -110,6 +120,18 @@ const [EnterpriseManageModal, enterpriseManageModalApi] = useVbenModal({
|
||||
connectedComponent: enterpriseManageModal,
|
||||
});
|
||||
|
||||
async function handleExport() {
|
||||
const columns = getExportColumns(gridApi);
|
||||
if (columns.length === 0) {
|
||||
message.warning('没有可导出的显示列,请调整列表列设置');
|
||||
return;
|
||||
}
|
||||
await commonDownloadExcel(exportAgentDeptApi, '企业客户', {
|
||||
...(await gridApi.formApi.getValues()),
|
||||
exportColumnsJson: JSON.stringify(columns),
|
||||
});
|
||||
}
|
||||
|
||||
function handleAdd() {
|
||||
deptDrawerApi.setData({ update: false, view: false });
|
||||
deptDrawerApi.open();
|
||||
@@ -137,7 +159,7 @@ async function handleRefresh() {
|
||||
async function handleDelete(row: AgentDept) {
|
||||
await deleteAgentDeptApi({ deptId: row.deptId });
|
||||
message.success('删除成功');
|
||||
await gridApi.query();
|
||||
await gridApi.reload();
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -145,6 +167,15 @@ async function handleDelete(row: AgentDept) {
|
||||
<Page auto-content-height>
|
||||
<Grid table-title="企业客户管理">
|
||||
<template #toolbar-tools>
|
||||
<Button
|
||||
v-access:code="'cpm:agent:dept:export'"
|
||||
class="mr-2 flex items-center"
|
||||
type="primary"
|
||||
:icon="h(MdiExport)"
|
||||
@click="handleExport"
|
||||
>
|
||||
导出
|
||||
</Button>
|
||||
<Button
|
||||
class="mr-2 flex items-center"
|
||||
type="primary"
|
||||
@@ -156,8 +187,8 @@ async function handleDelete(row: AgentDept) {
|
||||
</Button>
|
||||
</template>
|
||||
<template #status="{ row }">
|
||||
<Tag :color="row.status == '0' ? 'green' : 'red'">
|
||||
{{ row.status == '0' ? '正常' : '停用' }}
|
||||
<Tag :color="String(row.status) === '0' ? 'green' : 'red'">
|
||||
{{ String(row.status) === '0' ? '正常' : '停用' }}
|
||||
</Tag>
|
||||
</template>
|
||||
<template #action="{ row }">
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { AgentDept } from '#/api/agent';
|
||||
|
||||
import { collectEnterpriseCustomers } from './pagination';
|
||||
|
||||
describe('enterprise customer export pagination', () => {
|
||||
it('rejects changes to totals during export', async () => {
|
||||
const fetchPage = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ items: [], total: 501 })
|
||||
.mockResolvedValueOnce({ items: [], total: 500 });
|
||||
await expect(collectEnterpriseCustomers(fetchPage, {})).rejects.toThrow(
|
||||
'发生变化',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects missing or duplicate rows', async () => {
|
||||
const missing = vi.fn().mockResolvedValue({ items: [], total: 1 });
|
||||
await expect(collectEnterpriseCustomers(missing, {})).rejects.toThrow(
|
||||
'不完整',
|
||||
);
|
||||
const duplicate = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ items: [{ deptId: 1 }, { deptId: 1 }], total: 2 });
|
||||
await expect(collectEnterpriseCustomers(duplicate, {})).rejects.toThrow(
|
||||
'不完整',
|
||||
);
|
||||
});
|
||||
it('keeps filters and fetches all pages in order', async () => {
|
||||
const rows = Array.from(
|
||||
{ length: 1001 },
|
||||
(_, index) => ({ deptId: index + 1 }) as AgentDept,
|
||||
);
|
||||
const fetchPage = vi
|
||||
.fn()
|
||||
.mockImplementation(async ({ page, pageSize }) => ({
|
||||
items: rows.slice((page - 1) * pageSize, page * pageSize),
|
||||
total: rows.length,
|
||||
}));
|
||||
const result = await collectEnterpriseCustomers(fetchPage, {
|
||||
deptName: '客户',
|
||||
status: '0',
|
||||
page: 9,
|
||||
pageSize: 20,
|
||||
});
|
||||
expect(result.items).toEqual(rows);
|
||||
expect(fetchPage.mock.calls.map(([params]) => params)).toEqual(
|
||||
[1, 2, 3].map((page) => ({
|
||||
deptName: '客户',
|
||||
status: '0',
|
||||
deptType: 1,
|
||||
page,
|
||||
pageSize: 500,
|
||||
})),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns an empty export without extra requests', async () => {
|
||||
const fetchPage = vi.fn().mockResolvedValue({ items: [], total: 0 });
|
||||
await expect(collectEnterpriseCustomers(fetchPage, {})).resolves.toEqual({
|
||||
items: [],
|
||||
});
|
||||
expect(fetchPage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('rejects oversized exports rather than returning partial data', async () => {
|
||||
const fetchPage = vi.fn().mockResolvedValue({ items: [], total: 10001 });
|
||||
await expect(collectEnterpriseCustomers(fetchPage, {})).rejects.toThrow(
|
||||
'一万',
|
||||
);
|
||||
expect(fetchPage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('propagates a later page failure without returning partial data', async () => {
|
||||
const fetchPage = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ items: [], total: 501 })
|
||||
.mockRejectedValueOnce(new Error('network failed'));
|
||||
await expect(collectEnterpriseCustomers(fetchPage, {})).rejects.toThrow(
|
||||
'network failed',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { AgentDept, AgentDeptListParams } from '#/api/agent';
|
||||
|
||||
export async function collectEnterpriseCustomers(
|
||||
fetchPage: (
|
||||
params: AgentDeptListParams,
|
||||
) => Promise<{ items: AgentDept[]; total: number }>,
|
||||
filters: AgentDeptListParams,
|
||||
) {
|
||||
const pageSize = 500;
|
||||
const first = await fetchPage({ ...filters, deptType: 1, page: 1, pageSize });
|
||||
if (first.total > 10000) {
|
||||
throw new Error('导出最多支持一万家企业,请缩小筛选范围后重试');
|
||||
}
|
||||
const items = [...first.items];
|
||||
for (let page = 2; page <= Math.ceil(first.total / pageSize); page++) {
|
||||
const result = await fetchPage({ ...filters, deptType: 1, page, pageSize });
|
||||
if (result.total !== first.total) {
|
||||
throw new Error('导出期间企业数据发生变化,请重新导出');
|
||||
}
|
||||
items.push(...result.items);
|
||||
}
|
||||
if (
|
||||
items.length !== first.total ||
|
||||
new Set(items.map((item) => item.deptId)).size !== items.length
|
||||
) {
|
||||
throw new Error('导出数据不完整或发生变化,请重新导出');
|
||||
}
|
||||
return { items };
|
||||
}
|
||||
@@ -9,7 +9,10 @@
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { AccessControl, useAccess } from '@vben/access';
|
||||
const { hasAccessByCodes } = useAccess();
|
||||
import { commonDownloadExcel } from '#/utils/file/download';
|
||||
import {
|
||||
commonDownloadExcel,
|
||||
getExportColumns,
|
||||
} from '#/utils/file/download';
|
||||
import { List, Export, Delete, Status } from '#/api/agent/tApiGroup';
|
||||
import { MdiPlus, MdiExport, MdiDelete } from '@vben/icons';
|
||||
import { columns, querySchema, type RowType } from './model';
|
||||
@@ -124,11 +127,10 @@
|
||||
async function handleExport() {
|
||||
const formValues = gridApi.formApi.form.values;
|
||||
await commonDownloadExcel(Export, '开放接口权限组表',{
|
||||
exportColumnsJson: JSON.stringify(getExportColumns(gridApi)),
|
||||
...formValues,
|
||||
page: 1,
|
||||
pageSize: 2000,
|
||||
});
|
||||
message.success("导出成功");
|
||||
}
|
||||
async function handleStatusChange(row: RowType) {
|
||||
await Status({ id: row.id, status: row.status });
|
||||
|
||||
@@ -91,7 +91,8 @@ export const columns: VxeGridProps['columns'] = [
|
||||
width: -1,
|
||||
},
|
||||
{
|
||||
title: '状态', field: 'status', align: 'left', width: -1,
|
||||
title: '状态', field: 'status',
|
||||
params: { dictType: DictEnum.GLOBAL_ENABLED }, align: 'left', width: -1,
|
||||
slots: {
|
||||
default: ({ row }) => {
|
||||
return renderDict(row.status, DictEnum.GLOBAL_ENABLED);
|
||||
|
||||
@@ -9,7 +9,10 @@
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { AccessControl, useAccess } from '@vben/access';
|
||||
const { hasAccessByCodes } = useAccess();
|
||||
import { commonDownloadExcel } from '#/utils/file/download';
|
||||
import {
|
||||
commonDownloadExcel,
|
||||
getExportColumns,
|
||||
} from '#/utils/file/download';
|
||||
import { List, Export, Delete, Status } from '#/api/agent/tApiResource';
|
||||
import { MdiPlus, MdiExport, MdiDelete } from '@vben/icons';
|
||||
import { columns, querySchema, type RowType } from './model';
|
||||
@@ -124,11 +127,10 @@
|
||||
async function handleExport() {
|
||||
const formValues = gridApi.formApi.form.values;
|
||||
await commonDownloadExcel(Export, '开放接口资源表',{
|
||||
exportColumnsJson: JSON.stringify(getExportColumns(gridApi)),
|
||||
...formValues,
|
||||
page: 1,
|
||||
pageSize: 2000,
|
||||
});
|
||||
message.success("导出成功");
|
||||
}
|
||||
async function handleStatusChange(row: RowType) {
|
||||
await Status({ id: row.id, status: row.status });
|
||||
|
||||
@@ -107,6 +107,7 @@ export const columns: VxeGridProps['columns'] = [
|
||||
{
|
||||
title: '请求方法',
|
||||
field: 'method',
|
||||
params: { dictType: DictEnum.T_METHOD },
|
||||
align: 'left',
|
||||
width: -1,
|
||||
slots: {
|
||||
@@ -122,7 +123,8 @@ export const columns: VxeGridProps['columns'] = [
|
||||
width: -1,
|
||||
},
|
||||
{
|
||||
title: '状态', field: 'status', align: 'left', width: -1,
|
||||
title: '状态', field: 'status',
|
||||
params: { dictType: DictEnum.GLOBAL_ENABLED }, align: 'left', width: -1,
|
||||
slots: {
|
||||
default: ({ row }) => {
|
||||
return renderDict(row.status, DictEnum.GLOBAL_ENABLED);
|
||||
|
||||
@@ -11,7 +11,7 @@ import type { AgentUser } from '#/api/agent';
|
||||
import { h, ref } from 'vue';
|
||||
|
||||
import { Page, useVbenModal } from '@vben/common-ui';
|
||||
import { MdiDelete, MdiPlus } from '@vben/icons';
|
||||
import { MdiDelete, MdiExport, MdiPlus } from '@vben/icons';
|
||||
import { getVxePopupContainer } from '@vben/utils';
|
||||
|
||||
import { Button, message, Modal, Popconfirm, Tag } from 'ant-design-vue';
|
||||
@@ -19,8 +19,11 @@ import { Button, message, Modal, Popconfirm, Tag } from 'ant-design-vue';
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import {
|
||||
deleteAgentUserApi,
|
||||
exportAgentUserApi,
|
||||
getAgentUserListApi,
|
||||
} from '#/api/agent';
|
||||
import { commonDownloadExcel, getExportColumns } from '#/utils/file/download';
|
||||
|
||||
import userDrawer from './user-drawer.vue';
|
||||
import userResetPwdModal from './user-reset-pwd-modal.vue';
|
||||
|
||||
@@ -86,7 +89,12 @@ const gridOptions: VxeTableGridOptions<AgentUser> = {
|
||||
{ type: 'checkbox', width: 40 },
|
||||
{ field: 'userName', title: '用户名称' },
|
||||
{ field: 'nickName', title: '用户昵称' },
|
||||
{ field: 'roles', slots: { default: 'roles' }, title: '角色', minWidth: 160 },
|
||||
{
|
||||
field: 'roles',
|
||||
slots: { default: 'roles' },
|
||||
title: '角色',
|
||||
minWidth: 160,
|
||||
},
|
||||
{ field: 'phonenumber', title: '联系电话' },
|
||||
{ field: 'email', title: '邮箱', minWidth: 160 },
|
||||
{
|
||||
@@ -98,7 +106,6 @@ const gridOptions: VxeTableGridOptions<AgentUser> = {
|
||||
{ field: 'createdAt', formatter: 'formatDateTime', title: '创建时间' },
|
||||
{ title: '操作', width: 190, slots: { default: 'action' } },
|
||||
],
|
||||
exportConfig: {},
|
||||
height: 'auto',
|
||||
keepSource: true,
|
||||
pagerConfig: {},
|
||||
@@ -115,7 +122,7 @@ const gridOptions: VxeTableGridOptions<AgentUser> = {
|
||||
},
|
||||
toolbarConfig: {
|
||||
custom: true,
|
||||
export: true,
|
||||
export: false,
|
||||
refresh: true,
|
||||
resizable: true,
|
||||
search: true,
|
||||
@@ -139,6 +146,18 @@ const [UserResetPwdModal, userResetPwdModalApi] = useVbenModal({
|
||||
connectedComponent: userResetPwdModal,
|
||||
});
|
||||
|
||||
async function handleExport() {
|
||||
const columns = getExportColumns(tableApi);
|
||||
if (columns.length === 0) {
|
||||
message.warning('没有可导出的显示列,请调整列表列设置');
|
||||
return;
|
||||
}
|
||||
await commonDownloadExcel(exportAgentUserApi, '用户管理', {
|
||||
...(await tableApi.formApi.getValues()),
|
||||
exportColumnsJson: JSON.stringify(columns),
|
||||
});
|
||||
}
|
||||
|
||||
function handleAdd() {
|
||||
userDrawerApi.setData({ update: false, view: false });
|
||||
userDrawerApi.open();
|
||||
@@ -187,6 +206,15 @@ tableApi.query();
|
||||
<Page auto-content-height>
|
||||
<Grid table-title="用户列表">
|
||||
<template #toolbar-tools>
|
||||
<Button
|
||||
v-access:code="'cpm:agent:user:export'"
|
||||
class="mr-2 flex items-center"
|
||||
type="primary"
|
||||
:icon="h(MdiExport)"
|
||||
@click="handleExport"
|
||||
>
|
||||
导出
|
||||
</Button>
|
||||
<Button
|
||||
class="mr-2 flex items-center"
|
||||
type="primary"
|
||||
@@ -209,8 +237,10 @@ tableApi.query();
|
||||
</template>
|
||||
<template #roles="{ row }">
|
||||
{{
|
||||
row.roles?.map((role) => role.roleName).filter(Boolean).join('、') ||
|
||||
'-'
|
||||
row.roles
|
||||
?.map((role) => role.roleName)
|
||||
.filter(Boolean)
|
||||
.join('、') || '-'
|
||||
}}
|
||||
</template>
|
||||
<template #status="{ row }">
|
||||
|
||||
@@ -9,7 +9,10 @@
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { AccessControl, useAccess } from '@vben/access';
|
||||
const { hasAccessByCodes } = useAccess();
|
||||
import { commonDownloadExcel } from '#/utils/file/download';
|
||||
import {
|
||||
commonDownloadExcel,
|
||||
getExportColumns,
|
||||
} from '#/utils/file/download';
|
||||
import { List, Export, Delete } from '#/api/common/tBalanceDetail';
|
||||
import { MdiPlus, MdiExport, MdiDelete } from '@vben/icons';
|
||||
import { columns, querySchema, type RowType } from './model';
|
||||
@@ -124,11 +127,10 @@
|
||||
async function handleExport() {
|
||||
const formValues = gridApi.formApi.form.values;
|
||||
await commonDownloadExcel(Export, '余额明细表',{
|
||||
exportColumnsJson: JSON.stringify(getExportColumns(gridApi)),
|
||||
...formValues,
|
||||
page: 1,
|
||||
pageSize: 2000,
|
||||
});
|
||||
message.success("导出成功");
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
|
||||
@@ -154,6 +154,7 @@ export const columns: VxeGridProps['columns'] = [
|
||||
{
|
||||
title: '收支类型',
|
||||
field: 'incomeExpenseType',
|
||||
params: { dictType: DictEnum.INCOME_EXPENSE_TYPE },
|
||||
align: 'left',
|
||||
width: -1,
|
||||
slots: {
|
||||
@@ -166,6 +167,7 @@ export const columns: VxeGridProps['columns'] = [
|
||||
{
|
||||
title: '交易类型',
|
||||
field: 'transactionType',
|
||||
params: { dictType: DictEnum.TRANSACTION_TYPE },
|
||||
align: 'left',
|
||||
width: -1,
|
||||
slots: {
|
||||
@@ -177,6 +179,7 @@ export const columns: VxeGridProps['columns'] = [
|
||||
{
|
||||
title: '交易渠道',
|
||||
field: 'transactionChannel',
|
||||
params: { dictType: DictEnum.TRANSACTION_CHANNEL },
|
||||
align: 'left',
|
||||
width: -1,
|
||||
slots: {
|
||||
@@ -191,6 +194,7 @@ export const columns: VxeGridProps['columns'] = [
|
||||
{
|
||||
title: '交易状态',
|
||||
field: 'transactionStatus',
|
||||
params: { dictType: DictEnum.TRANSACTION_STATUS },
|
||||
align: 'left',
|
||||
width: -1,
|
||||
slots: {
|
||||
|
||||
@@ -9,7 +9,10 @@
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { AccessControl, useAccess } from '@vben/access';
|
||||
const { hasAccessByCodes } = useAccess();
|
||||
import { commonDownloadExcel } from '#/utils/file/download';
|
||||
import {
|
||||
commonDownloadExcel,
|
||||
getExportColumns,
|
||||
} from '#/utils/file/download';
|
||||
import { List, Export, Delete } from '#/api/common/tCreditDetail';
|
||||
import { MdiPlus, MdiExport, MdiDelete } from '@vben/icons';
|
||||
import { columns, querySchema, type RowType } from './model';
|
||||
@@ -124,11 +127,10 @@
|
||||
async function handleExport() {
|
||||
const formValues = gridApi.formApi.form.values;
|
||||
await commonDownloadExcel(Export, '信用额度明细表',{
|
||||
exportColumnsJson: JSON.stringify(getExportColumns(gridApi)),
|
||||
...formValues,
|
||||
page: 1,
|
||||
pageSize: 2000,
|
||||
});
|
||||
message.success("导出成功");
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
|
||||
@@ -9,7 +9,10 @@
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { AccessControl, useAccess } from '@vben/access';
|
||||
const { hasAccessByCodes } = useAccess();
|
||||
import { commonDownloadExcel } from '#/utils/file/download';
|
||||
import {
|
||||
commonDownloadExcel,
|
||||
getExportColumns,
|
||||
} from '#/utils/file/download';
|
||||
import { List, Export, Delete } from '#/api/common/tFinCommissionDetail';
|
||||
import { MdiPlus, MdiExport, MdiDelete } from '@vben/icons';
|
||||
import { columns, querySchema, type RowType } from './model';
|
||||
@@ -124,11 +127,10 @@
|
||||
async function handleExport() {
|
||||
const formValues = gridApi.formApi.form.values;
|
||||
await commonDownloadExcel(Export, '分润明细表',{
|
||||
exportColumnsJson: JSON.stringify(getExportColumns(gridApi)),
|
||||
...formValues,
|
||||
page: 1,
|
||||
pageSize: 2000,
|
||||
});
|
||||
message.success("导出成功");
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
|
||||
@@ -153,7 +153,8 @@ export const columns: VxeGridProps['columns'] = [
|
||||
width: -1,
|
||||
},
|
||||
{
|
||||
title: '分润状态:0待结算,1可提现', field: 'commissionStatus', align: 'left', width: -1,
|
||||
title: '分润状态:0待结算,1可提现', field: 'commissionStatus',
|
||||
params: { dictType: 'sys_common_status' }, align: 'left', width: -1,
|
||||
slots: {
|
||||
default: ({ row }) => {
|
||||
return renderDict(row.commissionStatus, 'sys_common_status');
|
||||
|
||||
@@ -9,7 +9,10 @@
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { AccessControl, useAccess } from '@vben/access';
|
||||
const { hasAccessByCodes } = useAccess();
|
||||
import { commonDownloadExcel } from '#/utils/file/download';
|
||||
import {
|
||||
commonDownloadExcel,
|
||||
getExportColumns,
|
||||
} from '#/utils/file/download';
|
||||
import { List, Export, Delete } from '#/api/common/tFinWithdrawableChange';
|
||||
import { MdiPlus, MdiExport, MdiDelete } from '@vben/icons';
|
||||
import { columns, querySchema, type RowType } from './model';
|
||||
@@ -124,11 +127,10 @@
|
||||
async function handleExport() {
|
||||
const formValues = gridApi.formApi.form.values;
|
||||
await commonDownloadExcel(Export, '可提现变动明细表',{
|
||||
exportColumnsJson: JSON.stringify(getExportColumns(gridApi)),
|
||||
...formValues,
|
||||
page: 1,
|
||||
pageSize: 2000,
|
||||
});
|
||||
message.success("导出成功");
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user