14 Commits

63 changed files with 1949 additions and 1178 deletions
+17
View File
@@ -0,0 +1,17 @@
import type { AuthData } from './menu';
/** Promise 级缓存:并发调用共享同一个请求,失败时清除以便重试 */
let authDataPromise: Promise<AuthData> | null = null;
export function getAuthDataPromise() {
return authDataPromise;
}
export function setAuthDataPromise(promise: Promise<AuthData> | null) {
authDataPromise = promise;
}
/** 清除权限数据缓存(退出登录时调用,确保下次登录不读到旧数据) */
export function clearAuthDataCache() {
authDataPromise = null;
}
+3 -3
View File
@@ -8,12 +8,12 @@ export interface TournamentAdminBannerListVO {
img: string;
title: string;
type: string;
linkUrl: string;
params: string;
area: string;
showTimeBegin: string;
showTimeEnd: string;
status: string;
createTime: string;
createDate: string;
}
/** Banner 列表查询参数 */
@@ -28,7 +28,7 @@ export interface BannerSaveParams {
img: string;
title: string;
type: string;
linkUrl: string;
params: string;
area: string;
showTimeBegin: string;
showTimeEnd: string;
+8 -1
View File
@@ -1,5 +1,12 @@
import { post } from '@/utils/request';
import type { LoginResult } from './types';
export type { LoginResult } from './types';
export const login = (params: { phone: string; password: string }) => {
return post(`/op/login`, params);
return post(`/op/login`, params) as Promise<{
code: number;
msg: string;
data: LoginResult;
}>;
};
+8
View File
@@ -0,0 +1,8 @@
export interface LoginResult {
token: string;
phone?: string;
nickName?: string;
realName?: string;
userName?: string;
name?: string;
}
+4 -1
View File
@@ -16,8 +16,11 @@ export interface SysOperationLogVO {
/** 赛事操作日志列表项 */
export interface TournamentAdminOperationPageVO {
opName: string;
/** 操作类型:1=创建赛事、2=删除赛事、3=取消报名、4=编辑选手、5=对阵管理、6=批量修改组别 */
type: string;
/** 操作来源:1=PC、2=小程序 */
source: string;
opName: string;
nickname: string;
phone: string;
tournamentName: string;
+49 -23
View File
@@ -1,15 +1,11 @@
import { get } from '@/utils/request';
import type { MenuNode, PermissionCode } from '@/types';
import { FALLBACK_MENU_NODES } from '@/config/fallbackRoutes';
import { getAuthDataPromise, setAuthDataPromise, clearAuthDataCache } from '@/api/authCache';
import { forceReLogin } from '@/hooks/useAuth';
import { useUserStore } from '@/stores/userStore';
// ============================================================
// 权限数据缓存(/op/permission 只请求一次)
// ============================================================
/**
* Promise 级缓存:并发调用共享同一个请求,失败时清除以便重试
*/
let authDataPromise: Promise<AuthData> | null = null;
export { clearAuthDataCache } from '@/api/authCache';
/**
* 权限数据(角色 + 权限码)
@@ -17,6 +13,42 @@ let authDataPromise: Promise<AuthData> | null = null;
export interface AuthData {
roleList: string[];
permsList: string[];
nickname: string;
phone: string;
}
const AUTH_FETCH_FAIL_MSG = '账户权限获取失败,请重新登录';
function normalizeAuthData(res: any): AuthData {
if (res?.code != null && res.code != 200) {
throw new Error(res.msg || AUTH_FETCH_FAIL_MSG);
}
const data = res?.data || {};
const roleList = data.roleList || [];
const permsList = data.permsList || [];
if (permsList.length === 0) {
throw new Error(AUTH_FETCH_FAIL_MSG);
}
const nickname = data.nickName || '';
const phone = typeof data.phone === 'string' ? data.phone : '';
if (nickname || phone) {
const { setUser } = useUserStore();
setUser({
...(nickname ? { nickname } : {}),
...(phone ? { phone } : {}),
});
}
return { roleList, permsList, nickname, phone };
}
function handleAuthFetchFailure() {
clearAuthDataCache();
forceReLogin(AUTH_FETCH_FAIL_MSG);
}
/**
@@ -25,24 +57,18 @@ export interface AuthData {
* Promise 级缓存:并发调用共享同一个请求,失败时清除缓存以允许重试
*/
export function fetchAuthData(): Promise<AuthData> {
if (authDataPromise) return authDataPromise;
authDataPromise = get('/op/permission')
.then((res: any) => ({
roleList: res.data?.roleList || [],
permsList: res.data?.permsList || [],
}))
const cached = getAuthDataPromise();
if (cached) return cached;
const promise = get('/op/permission')
.then((res) => normalizeAuthData(res))
.catch((err) => {
authDataPromise = null;
handleAuthFetchFailure();
throw err;
});
return authDataPromise;
}
/**
* 清除权限数据缓存(退出登录时调用,确保下次登录不读到旧数据)
*/
export function clearAuthDataCache() {
authDataPromise = null;
setAuthDataPromise(promise);
return promise;
}
/**
@@ -95,7 +121,7 @@ function filterMenuNodesByPermission(nodes: MenuNode[], permsSet: Set<string>):
* 2. 用权限码过滤 FALLBACK_MENU_NODES
* 3. 返回过滤后的菜单树
*
* 异常时抛出,由调用方(menuStore.loadMenu)降级使用未过滤的兜底路由
* 权限获取失败时由 fetchAuthData 统一强制重新登录
*/
export function fetchMenuTree(): Promise<MenuNode[]> {
return fetchAuthData().then(({ permsList }) => {
+14 -3
View File
@@ -5,16 +5,27 @@
import { get } from '@/utils/request';
import { USE_MOCK, MOCK_DELAY } from '@/config/mock';
import { buildMockPaymentFlowPage } from '@/config/mock/paymentFlow';
import type { PaymentFlowQueryParams, PaymentFlowVO, PageData, ApiResult } from './types';
import type {
PaymentFlowQueryParams,
PaymentFlowVO,
PaymentFlowPageData,
ApiResult,
} from './types';
export type { PaymentFlowVO, PaymentFlowQueryParams, PageData, ApiResult } from './types';
export type {
PaymentFlowVO,
PaymentFlowQueryParams,
PaymentFlowPageData,
PaymentFlowExData,
ApiResult,
} from './types';
const delay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
/** GET /admin/finance/paymentFlow/page — 支付流水分页 */
export async function getPaymentFlowList(
params: PaymentFlowQueryParams,
): Promise<ApiResult<PageData<PaymentFlowVO>>> {
): Promise<ApiResult<PaymentFlowPageData>> {
if (USE_MOCK) {
await delay(MOCK_DELAY);
const page = Number(params.page) || 1;
+16
View File
@@ -41,6 +41,22 @@ export interface PageData<T> {
total: number;
list: T[];
}
/** 支付流水分页扩展汇总(与 total 同级) */
export interface PaymentFlowExData {
/** 总支付金额 */
totalAmount: string;
/** 总退款金额 */
totalRefundAmount: string;
}
/** 支付流水分页响应 */
export interface PaymentFlowPageData {
total: number;
list: PaymentFlowVO[];
exData: PaymentFlowExData;
}
export interface ApiResult<T> {
code: number;
msg: string;
+1 -1
View File
@@ -38,7 +38,7 @@ export async function getRoleList(
const limit = Number(params.limit) || 10;
return { code: 200, msg: 'success', data: buildMockRoleListPage(page, limit) };
}
return get('/sys/role/page', params as Record<string, any>);
return get('/admin/sys/role/page', params as Record<string, any>);
}
/** GET /sys/role/usageUserPage — 角色下用户列表分页 */
+13
View File
@@ -41,6 +41,19 @@ export interface RoleUsageUserQueryParams {
roleId?: string;
}
/** 保存角色 — POST /admin/sys/role/save */
export interface RoleSaveParams {
name: string;
menuIds: string[];
}
/** 更新角色 — POST /admin/sys/role/update */
export interface RoleUpdateParams {
id: string;
name: string;
menuIds: string[];
}
export interface PageData<T> {
total: number;
list: T[];
+8 -8
View File
@@ -28,7 +28,7 @@ export type {
const delay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
/** GET /sys/user/page — 用户分页 */
/** GET /admin/sys/user/page — 用户分页 */
export async function getUserList(
params: UserListQueryParams,
): Promise<ApiResult<PageData<TournamentAdminUserVO>>> {
@@ -38,31 +38,31 @@ export async function getUserList(
const limit = Number(params.limit) || 10;
return { code: 200, msg: 'success', data: buildMockUserListPage(page, limit) };
}
return get('/sys/user/page', params as Record<string, any>);
return get('/admin/sys/user/page', params as Record<string, any>);
}
/** POST /sys/user/save — 新增用户 */
/** POST /admin/sys/user/save — 新增用户 */
export function saveUser(params: UserSaveParams): Promise<ApiResult<Record<string, never>>> {
if (USE_MOCK) return delay(MOCK_DELAY).then(() => ({ code: 200, msg: 'success', data: {} }));
return post('/sys/user/save', params);
return post('/admin/sys/user/save', params);
}
/** POST /sys/user/update — 更新用户 */
/** POST /admin/sys/user/update — 更新用户 */
export function updateUser(params: UserUpdateParams): Promise<ApiResult<Record<string, never>>> {
if (USE_MOCK) return delay(MOCK_DELAY).then(() => ({ code: 200, msg: 'success', data: {} }));
return post('/sys/user/update', params);
return post('/admin/sys/user/update', params);
}
/** POST /sys/user/active — 启用/禁用(直接调接口) */
export function toggleUserActive(
params: UserActiveParams,
): Promise<ApiResult<Record<string, never>>> {
return post('/sys/user/active', params);
return post('/admin/sys/user/active', params);
}
/** POST /sys/user/updatepwd — 修改密码(直接调接口) */
export function updateUserPwd(
params: UserUpdatePwdParams,
): Promise<ApiResult<Record<string, never>>> {
return post('/sys/user/updatepwd', params);
return post('/admin/sys/user/updatepwd', params);
}
+33 -33
View File
@@ -13,12 +13,12 @@ export interface MockBannerVO {
img: string;
title: string;
type: string;
linkUrl: string;
params: string;
area: string;
showTimeBegin: string;
showTimeEnd: string;
status: string;
createTime: string;
createDate: string;
}
export const MOCK_BANNER_LIST: MockBannerVO[] = [
@@ -27,143 +27,143 @@ export const MOCK_BANNER_LIST: MockBannerVO[] = [
img: 'https://picsum.photos/seed/banner1/80/44',
title: '2026全国青少年羽毛球锦标赛火热报名中',
type: '1',
linkUrl: '',
area: '广东省深圳市',
params: 'EV20260701001',
area: '深圳市',
showTimeBegin: '2026-08-01 00:00',
showTimeEnd: '2026-08-31 23:59',
status: '1',
createTime: '2026-07-20 08:00:00',
createDate: '2026-07-20 08:00:00',
},
{
id: '2',
img: 'https://picsum.photos/seed/banner2/80/44',
title: '马拉松城市联赛限时优惠',
type: '2',
linkUrl: 'https://example.com/marathon',
area: '浙江省杭州市',
params: 'https://example.com/marathon',
area: '杭州市',
showTimeBegin: '2026-08-15 00:00',
showTimeEnd: '2026-09-15 23:59',
status: '1',
createTime: '2026-07-19 11:30:00',
createDate: '2026-07-19 11:30:00',
},
{
id: '3',
img: 'https://picsum.photos/seed/banner3/80/44',
title: '健身运动会专属福利',
type: '1',
linkUrl: '',
params: 'EV20260701003',
area: '北京市',
showTimeBegin: '2026-07-01 00:00',
showTimeEnd: '2026-07-30 23:59',
status: '1',
createTime: '2026-07-18 14:20:00',
createDate: '2026-07-18 14:20:00',
},
{
id: '4',
img: 'https://picsum.photos/seed/banner4/80/44',
title: '平台品牌宣传活动',
type: '2',
linkUrl: 'https://example.com/promo',
params: 'https://example.com/promo',
area: '上海市',
showTimeBegin: '2026-06-01 00:00',
showTimeEnd: '2026-06-30 23:59',
status: '0',
createTime: '2026-07-17 18:05:00',
createDate: '2026-07-17 18:05:00',
},
{
id: '5',
img: 'https://picsum.photos/seed/banner5/80/44',
title: '2026全国青少年羽毛球锦标赛火热报名中',
type: '1',
linkUrl: '',
area: '广东省深圳市',
params: 'EV20260701005',
area: '深圳市',
showTimeBegin: '2026-09-01 00:00',
showTimeEnd: '2026-09-30 23:59',
status: '1',
createTime: '2026-07-16 21:15:00',
createDate: '2026-07-16 21:15:00',
},
{
id: '6',
img: 'https://picsum.photos/seed/banner6/80/44',
title: '马拉松城市联赛限时优惠',
type: '3',
linkUrl: '/pages/activity/detail',
area: '浙江省杭州市',
params: '/pages/activity/detail',
area: '杭州市',
showTimeBegin: '2026-10-01 00:00',
showTimeEnd: '2026-10-31 23:59',
status: '1',
createTime: '2026-07-15 00:30:00',
createDate: '2026-07-15 00:30:00',
},
{
id: '7',
img: 'https://picsum.photos/seed/banner7/80/44',
title: '全民健身日特别活动',
type: '1',
linkUrl: '',
params: 'EV20260701007',
area: '北京市',
showTimeBegin: '2026-08-08 00:00',
showTimeEnd: '2026-08-08 23:59',
status: '1',
createTime: '2026-07-14 03:50:00',
createDate: '2026-07-14 03:50:00',
},
{
id: '8',
img: 'https://picsum.photos/seed/banner8/80/44',
title: '新春运动季大促销',
type: '2',
linkUrl: 'https://example.com/spring',
params: 'https://example.com/spring',
area: '上海市',
showTimeBegin: '2026-01-15 00:00',
showTimeEnd: '2026-02-15 23:59',
status: '0',
createTime: '2026-07-13 07:05:00',
createDate: '2026-07-13 07:05:00',
},
{
id: '9',
img: 'https://picsum.photos/seed/banner9/80/44',
title: '2026全国青少年羽毛球锦标赛火热报名中',
type: '1',
linkUrl: '',
area: '广东省深圳市',
type: '0',
params: '',
area: '全国',
showTimeBegin: '2026-08-01 00:00',
showTimeEnd: '2026-08-31 23:59',
status: '1',
createTime: '2026-07-12 10:25:00',
createDate: '2026-07-12 10:25:00',
},
{
id: '10',
img: 'https://picsum.photos/seed/banner10/80/44',
title: '平台品牌宣传活动',
type: '2',
linkUrl: 'https://example.com/brand',
area: '浙江省杭州市',
params: 'https://example.com/brand',
area: '杭州市',
showTimeBegin: '2026-11-01 00:00',
showTimeEnd: '2026-11-30 23:59',
status: '1',
createTime: '2026-07-11 13:40:00',
createDate: '2026-07-11 13:40:00',
},
{
id: '11',
img: 'https://picsum.photos/seed/banner11/80/44',
title: '秋季健身挑战赛',
type: '1',
linkUrl: '',
params: 'EV20260701011',
area: '北京市',
showTimeBegin: '2026-09-01 00:00',
showTimeEnd: '2026-09-30 23:59',
status: '1',
createTime: '2026-07-10 17:00:00',
createDate: '2026-07-10 17:00:00',
},
{
id: '12',
img: 'https://picsum.photos/seed/banner12/80/44',
title: '双十一运动装备大促',
type: '2',
linkUrl: 'https://example.com/double11',
params: 'https://example.com/double11',
area: '上海市',
showTimeBegin: '2026-11-01 00:00',
showTimeEnd: '2026-11-11 23:59',
status: '1',
createTime: '2026-07-09 20:15:00',
createDate: '2026-07-09 20:15:00',
},
];
+18 -3
View File
@@ -2,7 +2,7 @@
* 支付流水假数据
* 对应接口:GET /admin/finance/paymentFlow/page
*/
import type { PaymentFlowVO, PaymentFlowQueryParams, PageData } from '@/api/payments';
import type { PaymentFlowVO, PaymentFlowQueryParams, PaymentFlowPageData } from '@/api/payments';
const MOCK_LIST: PaymentFlowVO[] = [
{
@@ -127,7 +127,7 @@ export function buildMockPaymentFlowPage(
page: number,
limit: number,
params: PaymentFlowQueryParams,
): PageData<PaymentFlowVO> {
): PaymentFlowPageData {
let filtered = [...MOCK_LIST];
if (params.nickname) {
@@ -143,7 +143,22 @@ export function buildMockPaymentFlowPage(
inDateRange(item.createDate, params.createDateBegin, params.createDateEnd),
);
let totalAmount = 0;
let totalRefundAmount = 0;
for (const item of filtered) {
const amt = Number(item.amount) || 0;
if (item.type === 1) totalAmount += amt;
else if (item.type === 2) totalRefundAmount += amt;
}
const total = filtered.length;
const start = (page - 1) * limit;
return { total, list: filtered.slice(start, start + limit) };
return {
total,
list: filtered.slice(start, start + limit),
exData: {
totalAmount: totalAmount.toFixed(2),
totalRefundAmount: totalRefundAmount.toFixed(2),
},
};
}
+43 -12
View File
@@ -1,14 +1,18 @@
// src/hooks/useAuth.ts
import { notification } from 'ant-design-vue';
import { useState } from './useState';
import { useEffect } from './useEffect';
import { useMenuStore } from '@/stores/menuStore';
import { usePermissionStore } from '@/stores/permissionStore';
import { useTabsStore } from '@/stores/tabsStore';
import { clearAuthDataCache } from '@/api/menu';
import { useUserStore } from '@/stores/userStore';
import { clearAuthDataCache } from '@/api/authCache';
import router from '@/router';
const TOKEN_KEY = 'MY_APP_AUTH_TOKEN';
let isForceLoggingOut = false;
function createAuth() {
const [token, setToken] = useState<string | null>(window.localStorage.getItem(TOKEN_KEY));
@@ -23,10 +27,26 @@ function createAuth() {
const isLoggedIn = () => !!token.value;
const clearSession = () => {
setToken(null);
const { clearUser } = useUserStore();
const { clearMenu } = useMenuStore();
const { clearPermissions } = usePermissionStore();
const { clearTabs } = useTabsStore();
clearUser();
clearTabs();
clearMenu();
clearPermissions();
clearAuthDataCache();
};
/**
* 登录:保存 token → 拉取菜单+权限 → 动态注册路由 → 跳转首页
*/
const login = async (newToken: string, targetPath?: string) => {
isForceLoggingOut = false;
setToken(newToken);
const { loadMenu, homePath, isRoutePathAvailable } = useMenuStore();
@@ -35,6 +55,9 @@ function createAuth() {
// 并行拉取菜单和权限
await Promise.all([loadMenu(), loadPermissions()]);
// 权限获取失败时会清空会话,此时不再跳转首页
if (!isLoggedIn()) return;
const nextPath =
targetPath && isRoutePathAvailable(targetPath) ? targetPath : homePath.value || '/404';
@@ -46,17 +69,7 @@ function createAuth() {
* 退出:清除 token → 清除菜单+权限 → 清除动态路由 → 跳转登录页
*/
const logout = () => {
setToken(null);
const { clearMenu } = useMenuStore();
const { clearPermissions } = usePermissionStore();
const { clearTabs } = useTabsStore();
clearTabs();
clearMenu();
clearPermissions();
clearAuthDataCache();
clearSession();
router.push('/login');
};
@@ -65,7 +78,25 @@ function createAuth() {
isLoggedIn,
login,
logout,
clearSession,
};
}
export const auth = createAuth();
/**
* 强制重新登录:提示错误 → 清空会话 → 跳转登录页
* 用于权限获取失败、登录态失效等需要彻底重置会话的场景
*/
export function forceReLogin(msg = '账户权限获取失败,请重新登录') {
if (isForceLoggingOut) return;
isForceLoggingOut = true;
notification.error({
message: '提示',
description: msg,
duration: 2600,
});
auth.clearSession();
router.push('/login');
}
+1 -1
View File
@@ -62,7 +62,7 @@
align-items: center;
}
.userPhone {
.displayName {
font-size: 14px;
color: rgba(0, 0, 0, 0.65);
}
+6 -2
View File
@@ -78,7 +78,8 @@ export default defineComponent({
const [collapsed, setCollapsed] = useState(false);
const { menuItems } = useMenuStore();
const { phone } = useUserStore();
const { phone, nickname } = useUserStore();
const displayName = computed(() => nickname.value || phone.value);
const {
tabs,
cachedViews,
@@ -138,6 +139,9 @@ export default defineComponent({
if (res.code == 200) {
message.success('密码修改成功');
setChangePwdVisible(false);
window.setTimeout(() => {
auth.logout();
}, 1500);
} else {
message.error(res.msg || '修改失败');
}
@@ -229,7 +233,7 @@ export default defineComponent({
{/* 右侧: 账号信息 + 退出 */}
<div class={styles.headerRight}>
<span class={styles.userPhone}>{phone.value}</span>
<span class={styles.displayName}>{displayName.value}</span>
<Dropdown trigger={['hover']} placement="bottomLeft">
{{
default: () => <img src={logoutSvg} class={styles.logoutIcon} />,
@@ -1,4 +1,4 @@
import { defineComponent, ref, reactive } from 'vue';
import { defineComponent, ref, reactive, computed } from 'vue';
import { useEffect, useState } from '@/hooks';
import {
Modal,
@@ -12,30 +12,30 @@ import {
message,
Spin,
} from 'ant-design-vue';
import { JUMP_TYPE_OPTIONS, EVENT_OPTIONS } from '../model/useBannerModel';
import {
getProvinceCityCascaderOptions,
parseAreaToCascaderPath,
formatCascaderArea,
} from '@/utils/areaData';
import { JUMP_TYPE_OPTIONS } from '../model/useBannerModel';
import { getProvinceCityCascaderOptions, cityNameToCascaderPath } from '@/utils/areaData';
import { ImageCropper } from '@yp-component/root';
import { FileImageOutlined } from '@ant-design/icons-vue';
import { uploadFile, OssUploadType } from '@/utils/oss';
import { getEventList, getEventDetail, type TournamentAdminVO } from '../../list/model/services';
import dayjs from 'dayjs';
import styles from './BannerFormModal.module.less';
/** 省市两级 Cascader 选项 */
const cascaderAreaOptions = getProvinceCityCascaderOptions();
/** 所属区域 Cascader 选项:全国 + 省市两级 */
const areaCascaderOptions: any[] = [
{ value: '全国', label: '全国' },
...getProvinceCityCascaderOptions(),
];
/** 默认表单数据 */
const getDefaultForm = () => ({
title: '',
imageUrl: '',
imageStatus: 'empty' as 'empty' | 'uploading' | 'success' | 'error',
type: '1' as '1' | '2' | '3',
type: '1' as '1' | '2' | '3' | '0',
linkUrl: '',
relatedEventId: undefined as string | undefined,
area: '',
area: '全国',
displayTimeRange: null as [string, string] | null,
sort: 1,
});
@@ -47,8 +47,6 @@ const URL_MAX = 500;
/** 图片上传限制:最大 10MB */
const IMAGE_MAX_SIZE = 10 * 1024 * 1024;
/** 裁剪比例:1053:351 = 3:1 */
const CROP_ASPECT_RATIO = 1053 / 351;
interface BannerFormModalProps {
visible: boolean;
@@ -72,6 +70,37 @@ function base64ToFile(dataUrl: string, fileName: string): File {
return new File([u8arr], fileName, { type: mime });
}
// ============================================================
// 工具函数
// ============================================================
/** 从 Cascader 路径中提取城市名(最后一级),用于区域筛选 */
function extractCityName(path: string[]): string {
if (!path || path.length === 0 || path[0] === '全国') return '';
return path[path.length - 1];
}
/** 从 Cascader 路径中提取存储用的区域字符串 */
function extractAreaValue(path: string[]): string {
if (!path || path.length === 0) return '全国';
if (path[0] === '全国') return '全国';
// 直辖市路径如 ["北京市"],普通城市如 ["广东省", "深圳市"]
return path[path.length - 1];
}
/** 根据跳转类型计算 parms 字段的值 */
function buildParams(type: string, relatedEventId: string | undefined, linkUrl: string): string {
if (type === '1') return relatedEventId ?? '';
if (type === '2') return linkUrl || '';
if (type === '3') return linkUrl || '';
// type === '0' 无链接
return '';
}
// ============================================================
// 组件
// ============================================================
/**
* Banner 新增/编辑表单弹窗
*/
@@ -88,36 +117,165 @@ export default defineComponent({
const formRef = ref<any>();
const fileInputRef = ref<HTMLInputElement | null>(null);
const formData = reactive(getDefaultForm());
/** Cascader 区域的路径数组(与 formData.area 字符串双向同步) */
const areaPath = ref<string[]>([]);
/** 是否由用户主动切换了跳转类型(跳过初始加载的 type 联动校验) */
/** Cascader 区域的路径数组:如 ["全国"]、["广东省", "深圳市"] */
const areaPath = ref<string[]>(['全国']);
/** 是否由用户主动切换了跳转类型 */
const typeChanged = ref(false);
/** 裁剪弹窗状态 */
// ---- 裁剪弹窗状态 ----
const [cropperVisible, setCropperVisible] = useState(false);
/** 待裁剪图片的本地预览 URL */
const [uploadImageUrl, setUploadImageUrl] = useState('');
/** 根据 record 初始化表单 */
// ---- 赛事搜索状态 ----
const eventKeyword = ref('');
const [eventOptions, setEventOptions] = useState<{ value: string; label: string }[]>([]);
const [eventSearchLoading, setEventSearchLoading] = useState(false);
/** 编辑时加载赛事详情的独立 loading */
const [editLoading, setEditLoading] = useState(false);
/** 手动防抖计时器 */
let searchTimer: ReturnType<typeof setTimeout> | null = null;
/** 赛事下拉选项:第一项固定为"不关联" */
const eventSelectOptions = computed(() => {
return [{ value: '', label: '不关联' }, ...eventOptions.value];
});
// ---- 赛事搜索 API 调用 ----
const doSearch = async (keyword: string) => {
if (!keyword.trim()) {
setEventOptions([]);
return;
}
setEventSearchLoading(true);
try {
const cityName = extractCityName(areaPath.value);
const params: Record<string, string> = {
page: '1',
limit: '999',
name: keyword.trim(),
};
if (cityName) {
params.areaName = cityName;
}
const res = await getEventList(params as any);
if (res.code == 200) {
const list: TournamentAdminVO[] = res.data?.list || [];
setEventOptions(
list.map((item) => ({
value: item.id,
label: item.name,
})),
);
} else {
setEventOptions([]);
}
} catch (e) {
console.error('[Banner] 搜索赛事失败:', e);
setEventOptions([]);
} finally {
setEventSearchLoading(false);
}
};
/** 赛事搜索输入(带 350ms 手动防抖) */
const onEventSearch = (value: string) => {
eventKeyword.value = value;
if (searchTimer) clearTimeout(searchTimer);
if (!value.trim()) {
setEventOptions([]);
return;
}
searchTimer = setTimeout(() => {
doSearch(value);
}, 350);
};
/** 所属区域 Cascader 变化 */
const onAreaChange = (val: any) => {
const path: string[] = val || [];
const newArea = extractAreaValue(path);
areaPath.value = path;
formData.area = newArea;
// 切换到非"全国"区域时清空已关联赛事
if (newArea !== '全国') {
formData.relatedEventId = undefined;
formRef.value?.clearValidate('relatedEventId');
}
// 区域变化时重新搜索(如果当前有关键词)
if (eventKeyword.value.trim()) {
if (searchTimer) clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
doSearch(eventKeyword.value);
}, 0);
}
};
// ---- 根据 record 初始化表单 ----
const initFormFromRecord = (record: any) => {
const fresh = getDefaultForm();
if (!record) {
Object.assign(formData, fresh);
areaPath.value = [];
areaPath.value = ['全国'];
eventKeyword.value = '';
setEventOptions([]);
return;
}
const area = record.area || record.region || '';
areaPath.value = parseAreaToCascaderPath(area);
// 区域:兼容旧格式 "广东省-深圳市"、新格式 "深圳市"、"全国"
const rawArea = record.area || record.region || '';
let cityName: string;
if (rawArea.includes('-')) {
cityName = rawArea.split('-').pop() || '全国';
} else {
cityName = rawArea || '全国';
}
areaPath.value = cityNameToCascaderPath(cityName);
// 跳转类型
const type = String(record.type || record.jumpType || '1');
// 关联赛事ID(编辑时从 params/linkUrl 读取)
let relatedEventId: string | undefined;
if (type === '1') {
const eventId = record.params || record.linkUrl || record.jumpUrl || '';
if (eventId) {
relatedEventId = eventId;
// 异步获取赛事名称用于回显
setEditLoading(true);
getEventDetail(eventId)
.then((detailRes) => {
if (detailRes.code == 200) {
const eventName = detailRes.data?.name || eventId;
setEventOptions([{ value: eventId, label: eventName }]);
}
})
.catch(() => {
setEventOptions([{ value: eventId, label: eventId }]);
})
.finally(() => {
setEditLoading(false);
});
}
}
eventKeyword.value = '';
if (type !== '1' || !relatedEventId) {
setEventOptions([]);
}
Object.assign(formData, {
...fresh,
title: record.title || '',
imageUrl: record.img || record.imageUrl || '',
imageStatus: record.img || record.imageUrl ? ('success' as const) : ('empty' as const),
type: String(record.type || record.jumpType || '1'),
linkUrl: record.linkUrl || record.jumpUrl || '',
relatedEventId: record.relatedEventId || undefined,
area,
type,
linkUrl: record.params || record.linkUrl || record.jumpUrl || '',
relatedEventId,
area: cityName,
displayTimeRange:
(record.showTimeBegin || record.displayStartTime) &&
(record.showTimeEnd || record.displayEndTime)
@@ -130,10 +288,9 @@ export default defineComponent({
});
};
/** 监听 visible 变化重置表单 */
// ---- 监听 visible 变化重置表单 ----
useEffect(() => {
if (props.visible) {
// 标记首次渲染,跳过 type 联动校验
typeChanged.value = false;
initFormFromRecord(props.record);
setCropperVisible(false);
@@ -142,9 +299,16 @@ export default defineComponent({
}
}, [() => props.visible]);
/** 监听 type 变化时清理联动字段值和校验 */
// ---- 清理防抖计时器 ----
useEffect(() => {
if (!props.visible && searchTimer) {
clearTimeout(searchTimer);
searchTimer = null;
}
}, [() => props.visible]);
// ---- 监听 type 变化,清理联动字段 ----
useEffect(() => {
// 首次加载跳过校验
if (!typeChanged.value) {
if (formData.type !== '1') formData.relatedEventId = undefined;
if (formData.type !== '2' && formData.type !== '3') formData.linkUrl = '';
@@ -162,25 +326,28 @@ export default defineComponent({
const activeNames: string[] = [];
if (formData.type === '1') activeNames.push('relatedEventId');
if (formData.type === '2' || formData.type === '3') activeNames.push('linkUrl');
// 清除所有联动字段的校验状态
formRef.value?.clearValidate(['relatedEventId', 'linkUrl']);
// 如果有已填值,重新校验当前字段
if (activeNames.length) {
setTimeout(() => formRef.value?.validateFields(activeNames).catch(() => {}), 0);
}
}, [() => formData.type]);
/** 触发隐藏的文件选择器 */
/** 重新校验 Banner 图片字段,上传完成后清除或更新错误提示 */
const revalidateImageUrl = () => {
setTimeout(() => {
formRef.value?.validateFields(['imageUrl']).catch(() => {});
}, 0);
};
// ---- 图片上传相关 ----
const triggerFilePicker = () => {
if (formData.imageStatus === 'uploading') return;
fileInputRef.value?.click();
};
/** 处理文件选择 → 打开裁剪弹窗 */
const handleFileChange = (e: Event) => {
const target = e.target as HTMLInputElement;
const file = target.files?.[0];
// 重置 input 以便同一文件可再次选择
target.value = '';
if (!file) return;
@@ -193,21 +360,17 @@ export default defineComponent({
return;
}
// 生成本地预览 URL,打开裁剪弹窗
setUploadImageUrl(URL.createObjectURL(file));
setCropperVisible(true);
};
/** 裁剪确认回调:将裁剪后的图片上传到 OSS */
const handleCropConfirm = async (dataUrl: string) => {
setCropperVisible(false);
// 释放旧的本地预览
if (formData.imageUrl && formData.imageUrl.startsWith('blob:')) {
URL.revokeObjectURL(formData.imageUrl);
}
// 先用 base64 做本地预览
const localUrl = dataUrl;
formData.imageUrl = localUrl;
formData.imageStatus = 'uploading';
@@ -216,27 +379,26 @@ export default defineComponent({
const file = base64ToFile(dataUrl, `banner_${Date.now()}.jpg`);
const result = await uploadFile(file, OssUploadType.Banner as any);
// 释放本地预览
URL.revokeObjectURL(localUrl);
formData.imageUrl = result.url;
formData.imageStatus = 'success';
message.success('图片上传成功');
revalidateImageUrl();
} catch (err: any) {
console.error('[Banner] 上传失败:', err);
URL.revokeObjectURL(localUrl);
formData.imageUrl = '';
formData.imageStatus = 'error';
message.error(err?.message || '图片上传失败');
revalidateImageUrl();
}
// 清理裁剪资源
if (uploadImageUrl.value) {
URL.revokeObjectURL(uploadImageUrl.value);
}
setUploadImageUrl('');
};
/** 裁剪弹窗取消 */
const onCropperCancel = () => {
setCropperVisible(false);
if (uploadImageUrl.value) {
@@ -245,16 +407,16 @@ export default defineComponent({
setUploadImageUrl('');
};
/** 删除已上传图片 */
const handleRemoveImage = () => {
if (formData.imageUrl && formData.imageUrl.startsWith('blob:')) {
URL.revokeObjectURL(formData.imageUrl);
}
formData.imageUrl = '';
formData.imageStatus = 'empty';
revalidateImageUrl();
};
/** 提交表单 */
// ---- 提交表单 ----
const handleSubmit = async () => {
try {
await formRef.value?.validate();
@@ -271,15 +433,8 @@ export default defineComponent({
img: formData.imageUrl || '',
title: formData.title || '',
type: formData.type,
linkUrl:
formData.type === '1'
? formData.relatedEventId || ''
: formData.type === '2'
? formData.linkUrl || ''
: formData.type === '3'
? formData.linkUrl || ''
: '',
area: formData.area || formatCascaderArea(areaPath.value),
params: buildParams(formData.type, formData.relatedEventId, formData.linkUrl),
area: formData.area,
showTimeBegin: formData.displayTimeRange?.[0] || '',
showTimeEnd: formData.displayTimeRange?.[1] || '',
sort: String(formData.sort ?? 1),
@@ -299,246 +454,295 @@ export default defineComponent({
centered
wrapClassName={styles.bannerFormModalMain}
>
<Form ref={formRef} layout="vertical" model={formData} class={styles.form} requiredMark>
{/* ===== Banner 标题 ===== */}
<Form.Item
label="Banner标题"
name="title"
rules={[
{ required: true, message: '请输入' },
{ max: TITLE_MAX, message: `标题最多 ${TITLE_MAX} 个字符` },
]}
>
<Input
placeholder="请输入标题"
maxlength={TITLE_MAX}
allowClear
v-model:value={formData.title}
/>
</Form.Item>
{/* ===== 上传图片(必填,带裁剪) ===== */}
<Form.Item
label="上传图片"
name="imageUrl"
rules={[
{ required: true, message: '请上传 Banner 图片' },
{
validator: async () => {
if (!formData.imageUrl || formData.imageUrl.startsWith('blob:')) {
return Promise.reject(new Error('请上传 Banner 图片'));
}
return Promise.resolve();
},
},
]}
>
<div class={styles.uploader}>
<input
ref={fileInputRef}
type="file"
accept="image/*"
style={{ display: 'none' }}
onChange={handleFileChange}
/>
{formData.imageStatus === 'empty' || formData.imageStatus === 'error' ? (
<div
class={`${styles.uploadCard} ${
formData.imageStatus === 'error' ? styles.uploadCardError : ''
}`}
onClick={triggerFilePicker}
>
<span class={styles.uploadIcon}>
<FileImageOutlined />
</span>
<span class={styles.uploadHint}></span>
<span class={styles.uploadTip}>
1053*351
<br />
JPG/PNG 10MB
</span>
</div>
) : (
<div class={styles.previewWrapper}>
<Spin spinning={formData.imageStatus === 'uploading'} tip="上传中...">
<img class={styles.previewImg} src={formData.imageUrl} alt="banner preview" />
</Spin>
<div class={styles.uploadAgainBtn} onClick={handleRemoveImage}>
</div>
</div>
)}
</div>
</Form.Item>
{/* ===== 跳转类型 ===== */}
<Form.Item
label="跳转类型"
name="type"
rules={[{ required: true, message: '请选择跳转类型' }]}
>
<Select
options={JUMP_TYPE_OPTIONS as any}
placeholder="请选择"
v-model:value={formData.type}
/>
</Form.Item>
{/* ===== 联动字段 ===== */}
{formData.type === '1' && (
<Form.Item
label="关联赛事"
name="relatedEventId"
rules={[{ required: true, message: '请选择关联赛事' }]}
>
<Select
options={EVENT_OPTIONS as any}
placeholder="请选择关联赛事"
allowClear
v-model:value={formData.relatedEventId}
/>
</Form.Item>
)}
{formData.type === '2' && (
<Form.Item
label="跳转地址"
name="linkUrl"
rules={[
{ required: true, message: '请输入跳转地址' },
{ max: URL_MAX, message: `链接最多 ${URL_MAX} 个字符` },
]}
>
<Input
placeholder="https://xxx"
maxlength={URL_MAX}
allowClear
v-model:value={formData.linkUrl}
/>
</Form.Item>
)}
{formData.type === '3' && (
<Form.Item
label="小程序路径"
name="linkUrl"
rules={[
{ required: true, message: '请输入小程序页面路径' },
{ max: PATH_MAX, message: `路径最多 ${PATH_MAX} 个字符` },
]}
>
<Input
placeholder="/pages/index/index"
maxlength={PATH_MAX}
allowClear
v-model:value={formData.linkUrl}
/>
</Form.Item>
)}
{/* ===== 所属区域 ===== */}
<Form.Item
label="所属区域"
name="area"
rules={[{ required: true, message: '请选择所属区域' }]}
>
<Cascader
v-model:value={areaPath.value}
options={cascaderAreaOptions}
placeholder="请选择省/市"
changeOnSelect={false}
allowClear
onChange={(val: any) => {
formData.area = formatCascaderArea(val || []);
}}
/>
</Form.Item>
{/* ===== 展示时间(开始/结束) ===== */}
<div class={styles.timeRow}>
<Form.Item
label="开始时间"
name="displayStartTime"
class={styles.timeItem}
rules={[{ required: true, message: '请选择开始时间' }]}
>
<DatePicker
placeholder="年-月-日"
format="YYYY-MM-DD"
value={
formData.displayTimeRange?.[0] ? dayjs(formData.displayTimeRange[0]) : undefined
}
onUpdate:value={(v: any) => {
formData.displayTimeRange = [
v ? v.format('YYYY-MM-DD') : '',
formData.displayTimeRange?.[1] || '',
];
}}
style={{ width: '100%' }}
/>
</Form.Item>
<Form.Item
label="结束时间"
name="displayEndTime"
class={styles.timeItem}
rules={[
{ required: true, message: '请选择结束时间' },
{
validator: async () => {
const [s, e] = formData.displayTimeRange || [];
if (s && e && new Date(e) < new Date(s)) {
return Promise.reject(new Error('结束时间不能早于开始时间'));
}
return Promise.resolve();
},
},
]}
>
<DatePicker
placeholder="年-月-日"
format="YYYY-MM-DD"
value={
formData.displayTimeRange?.[1] ? dayjs(formData.displayTimeRange[1]) : undefined
}
onUpdate:value={(v: any) => {
formData.displayTimeRange = [
formData.displayTimeRange?.[0] || '',
v ? v.format('YYYY-MM-DD') : '',
];
}}
style={{ width: '100%' }}
/>
</Form.Item>
{editLoading.value ? (
<div style={{ textAlign: 'center', padding: '80px 0' }}>
<Spin size="large" />
</div>
) : (
<>
<Form
ref={formRef}
layout="vertical"
model={formData}
class={styles.form}
requiredMark
>
{/* ===== Banner 标题 ===== */}
<Form.Item
label="Banner标题"
name="title"
rules={[
{ required: true, message: '请输入' },
{ max: TITLE_MAX, message: `标题最多 ${TITLE_MAX} 个字符` },
]}
>
<Input
placeholder="请输入标题"
maxlength={TITLE_MAX}
allowClear
v-model:value={formData.title}
/>
</Form.Item>
{/* ===== 排序 ===== */}
<Form.Item
label="排序"
name="sort"
rules={[
{ required: true, message: '请输入排序' },
{ type: 'number', min: 1, max: 999, message: '排序范围 1-999' },
]}
>
<InputNumber
placeholder="数字越小越靠前"
min={1}
max={999}
precision={0}
style={{ width: '200px' }}
v-model:value={formData.sort}
/>
</Form.Item>
</Form>
{/* ===== 上传图片(必填,带裁剪) ===== */}
<Form.Item
label="上传图片"
name="imageUrl"
rules={[
{ required: true, message: '请上传 Banner 图片' },
{
validator: async () => {
if (formData.imageStatus === 'uploading') {
return Promise.reject(new Error('图片仍在上传中,请稍候'));
}
if (
formData.imageUrl?.startsWith('blob:') ||
formData.imageUrl?.startsWith('data:')
) {
return Promise.reject(new Error('请等待图片上传完成'));
}
return Promise.resolve();
},
},
]}
>
<div class={styles.uploader}>
<input
ref={fileInputRef}
type="file"
accept="image/*"
style={{ display: 'none' }}
onChange={handleFileChange}
/>
{formData.imageStatus === 'empty' || formData.imageStatus === 'error' ? (
<div
class={`${styles.uploadCard} ${
formData.imageStatus === 'error' ? styles.uploadCardError : ''
}`}
onClick={triggerFilePicker}
>
<span class={styles.uploadIcon}>
<FileImageOutlined />
</span>
<span class={styles.uploadHint}></span>
<span class={styles.uploadTip}>
1053*351
<br />
JPG/PNG 10MB
</span>
</div>
) : (
<div class={styles.previewWrapper}>
<Spin spinning={formData.imageStatus === 'uploading'} tip="上传中...">
<img
class={styles.previewImg}
src={formData.imageUrl}
alt="banner preview"
/>
</Spin>
<div class={styles.uploadAgainBtn} onClick={handleRemoveImage}>
</div>
</div>
)}
</div>
</Form.Item>
{/* ===== 底部按钮 ===== */}
<div class={styles.footer}>
<Button onClick={props.onClose}></Button>
<Button type="primary" loading={props.submitting} onClick={handleSubmit}>
</Button>
</div>
{/* ===== 跳转类型 ===== */}
<Form.Item
label="跳转类型"
name="type"
rules={[{ required: true, message: '请选择跳转类型' }]}
>
<Select
options={JUMP_TYPE_OPTIONS as any}
placeholder="请选择"
v-model:value={formData.type}
/>
</Form.Item>
{/* ===== 联动字段:赛事详情页 → 模糊搜索关联赛事 ===== */}
{formData.type === '1' && (
<Form.Item label="关联赛事" name="relatedEventId">
<Select
v-model:value={formData.relatedEventId}
showSearch
filterOption={false}
allowClear={!eventSearchLoading.value}
loading={eventSearchLoading.value}
placeholder="请输入关键字搜索目标赛事"
options={eventSelectOptions.value as any}
onSearch={onEventSearch}
onFocus={() => {
if (formData.relatedEventId && eventOptions.value.length === 0) {
doSearch(formData.relatedEventId);
}
}}
/>
</Form.Item>
)}
{/* ===== 联动字段:自定义链接 ===== */}
{formData.type === '2' && (
<Form.Item
label="跳转地址"
name="linkUrl"
rules={[
{ required: true, message: '请输入跳转地址' },
{ max: URL_MAX, message: `链接最多 ${URL_MAX} 个字符` },
]}
>
<Input
placeholder="https://xxx"
maxlength={URL_MAX}
allowClear
v-model:value={formData.linkUrl}
/>
</Form.Item>
)}
{/* ===== 联动字段:小程序页面 ===== */}
{formData.type === '3' && (
<Form.Item
label="小程序路径"
name="linkUrl"
rules={[
{ required: true, message: '请输入小程序页面路径' },
{ max: PATH_MAX, message: `路径最多 ${PATH_MAX} 个字符` },
]}
>
<Input
placeholder="/pages/index/index"
maxlength={PATH_MAX}
allowClear
v-model:value={formData.linkUrl}
/>
</Form.Item>
)}
{/* ===== 所属区域(省市树种选择 + 全国) ===== */}
<Form.Item
label="所属区域"
name="area"
rules={[{ required: true, message: '请选择所属区域' }]}
>
<Cascader
v-model:value={areaPath.value}
options={areaCascaderOptions}
placeholder="请选择所属区域"
allowClear={false}
onChange={onAreaChange}
/>
</Form.Item>
{/* ===== 展示时间(开始/结束) ===== */}
<div class={styles.timeRow}>
<Form.Item
label="开始时间"
name="displayStartTime"
class={styles.timeItem}
rules={[
{
validator: async () => {
if (!formData.displayTimeRange?.[0]) {
return Promise.reject(new Error('请选择开始时间'));
}
return Promise.resolve();
},
},
]}
>
<DatePicker
showTime
placeholder="请选择开始时间"
format="YYYY-MM-DD HH:mm:ss"
value={
formData.displayTimeRange?.[0]
? dayjs(formData.displayTimeRange[0])
: undefined
}
onUpdate:value={(v: any) => {
const newStart = v ? v.format('YYYY-MM-DD HH:mm:ss') : '';
const currentEnd = formData.displayTimeRange?.[1] || '';
if (newStart && currentEnd && dayjs(newStart).isAfter(dayjs(currentEnd))) {
formData.displayTimeRange = [currentEnd, newStart];
} else {
formData.displayTimeRange = [newStart, currentEnd];
}
formRef.value?.clearValidate(['displayStartTime', 'displayEndTime']);
}}
style={{ width: '100%' }}
/>
</Form.Item>
<Form.Item
label="结束时间"
name="displayEndTime"
class={styles.timeItem}
rules={[
{
validator: async () => {
if (!formData.displayTimeRange?.[1]) {
return Promise.reject(new Error('请选择结束时间'));
}
return Promise.resolve();
},
},
]}
>
<DatePicker
showTime
placeholder="请选择结束时间"
format="YYYY-MM-DD HH:mm:ss"
value={
formData.displayTimeRange?.[1]
? dayjs(formData.displayTimeRange[1])
: undefined
}
onUpdate:value={(v: any) => {
const newEnd = v ? v.format('YYYY-MM-DD HH:mm:ss') : '';
const currentStart = formData.displayTimeRange?.[0] || '';
if (newEnd && currentStart && dayjs(newEnd).isBefore(dayjs(currentStart))) {
formData.displayTimeRange = [newEnd, currentStart];
} else {
formData.displayTimeRange = [currentStart, newEnd];
}
formRef.value?.clearValidate(['displayStartTime', 'displayEndTime']);
}}
style={{ width: '100%' }}
/>
</Form.Item>
</div>
{/* ===== 排序 ===== */}
<Form.Item
label="排序"
name="sort"
rules={[
{ required: true, message: '请输入排序' },
{ type: 'number', min: 1, max: 999, message: '排序范围 1-999' },
]}
>
<InputNumber
placeholder="数字越小越靠前"
min={1}
max={999}
precision={0}
style={{ width: '200px' }}
v-model:value={formData.sort}
/>
</Form.Item>
</Form>
{/* ===== 底部按钮 ===== */}
<div class={styles.footer}>
<Button onClick={props.onClose}></Button>
<Button type="primary" loading={props.submitting} onClick={handleSubmit}>
</Button>
</div>
</>
)}
</Modal>
{/* ===== 图片裁剪弹窗 ===== */}
@@ -555,7 +759,6 @@ export default defineComponent({
<div style={{ height: '500px' }}>
<ImageCropper
src={uploadImageUrl.value}
// aspectRatio={CROP_ASPECT_RATIO}
autoCrop={true}
autoCropArea={0.8}
viewMode={1}
+42 -20
View File
@@ -1,4 +1,4 @@
import { defineComponent, onMounted } from 'vue';
import { defineComponent } from 'vue';
import {
Button,
Input,
@@ -13,6 +13,7 @@ import {
} from 'ant-design-vue';
import { useBannerModel, BANNER_STATUS_OPTIONS } from './model/useBannerModel';
import { useContainerSize } from '@/hooks';
import { usePermissionStore } from '@/stores/permissionStore';
import BannerFormModal from './components/BannerFormModal';
import pageStyles from '@/assets/styles/pageLayout.module.less';
@@ -28,6 +29,7 @@ function renderBodyCell({
onEdit,
onToggleStatus,
onDelete,
permissions,
}: {
column: any;
text: any;
@@ -37,6 +39,11 @@ function renderBodyCell({
onEdit: (record: any) => void;
onToggleStatus: (record: any) => void;
onDelete: (record: any) => void;
permissions: {
canEdit: boolean;
canToggleStatus: boolean;
canDelete: boolean;
};
}) {
// 图片列
if (column.key === 'image') {
@@ -92,17 +99,26 @@ function renderBodyCell({
// 操作列
if (column.key === 'action') {
const isEnabled = record.status === '1';
const { canEdit, canToggleStatus, canDelete } = permissions;
if (!canEdit && !canToggleStatus && !canDelete) return <span>-</span>;
return (
<Space>
<Button type="link" size="small" onClick={() => onEdit(record)}>
</Button>
<Button type="link" size="small" onClick={() => onToggleStatus(record)}>
{isEnabled ? '禁用' : '启用'}
</Button>
<Button type="link" size="small" danger onClick={() => onDelete(record)}>
</Button>
{canEdit && (
<Button type="link" size="small" onClick={() => onEdit(record)}>
</Button>
)}
{canToggleStatus && (
<Button type="link" size="small" onClick={() => onToggleStatus(record)}>
{isEnabled ? '禁用' : '启用'}
</Button>
)}
{canDelete && (
<Button type="link" size="small" danger onClick={() => onDelete(record)}>
</Button>
)}
</Space>
);
}
@@ -136,6 +152,13 @@ export default defineComponent({
} = useBannerModel();
const { containerRef, height } = useContainerSize();
const { hasPermission } = usePermissionStore();
const canAdd = hasPermission('events.banner.add');
const operationPermissions = {
canEdit: hasPermission('events.banner.edit'),
canToggleStatus: hasPermission('events.banner.toggle_status'),
canDelete: hasPermission('events.banner.delete'),
};
const confirmDelete = (record: any) => {
Modal.confirm({
@@ -171,10 +194,6 @@ export default defineComponent({
},
];
onMounted(() => {
handleSearch();
});
return () => (
<div class={pageStyles.containerMain}>
<div class={pageStyles.filter}>
@@ -204,9 +223,11 @@ export default defineComponent({
</Button>
<Button onClick={handleReset}></Button>
<Button type="primary" onClick={handleAdd}>
</Button>
{canAdd && (
<Button type="primary" onClick={handleAdd}>
</Button>
)}
</Space>
</Form.Item>
</Form>
@@ -230,6 +251,7 @@ export default defineComponent({
onEdit: handleEdit,
onToggleStatus: confirmToggleStatus,
onDelete: confirmDelete,
permissions: operationPermissions,
}),
}}
</Table>
@@ -237,9 +259,9 @@ export default defineComponent({
<div class={pageStyles.pagination}>
<Pagination
current={pagination.value.current}
pageSize={pagination.value.pageSize}
total={pagination.value.total}
current={(pagination as any).current.value}
pageSize={(pagination as any).pageSize.value}
total={(pagination as any).total.value}
showSizeChanger
showTotal={(total: number) => `${total}`}
onChange={handlePageChange}
+5 -5
View File
@@ -26,11 +26,11 @@ export type {
// ============================================================
// URL 常量
// ============================================================
const bannerList = '/manager/banner/list';
const bannerDel = '/manager/banner/del';
const bannerSave = '/manager/banner/save';
const bannerUpdate = '/manager/banner/update';
const bannerActive = '/manager/banner/active';
const bannerList = '/admin/manager/banner/list';
const bannerDel = '/admin/manager/banner/del';
const bannerSave = '/admin/manager/banner/save';
const bannerUpdate = '/admin/manager/banner/update';
const bannerActive = '/admin/manager/banner/active';
const delay = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
+45 -60
View File
@@ -2,6 +2,9 @@ import { computed, reactive, toRef, Ref, h } from 'vue';
import { message } from 'ant-design-vue';
import { StatusTag, type StatusTagTone } from '@/components';
import { useState, useDebounce, useThrottleFn } from '@/hooks';
import { useRequest } from '@/hooks/useRequest';
import { useEffect } from '@/hooks/useEffect';
import { usePagination } from '@/hooks/usePagination';
import {
getBannerList,
delBanner,
@@ -11,7 +14,6 @@ import {
type TournamentAdminBannerListVO,
type BannerListQueryParams,
} from './services';
import { getProvinceCityOptions } from '@/utils/areaData';
// ============================================================
// 常量
@@ -24,15 +26,17 @@ export const BANNER_STATUS_OPTIONS = [
{ value: '0', label: '禁用' },
] as const;
/** 跳转类型选项(value 对应 API type: 1=赛事详情,2=自定义连接,3=小程序页面) */
/** 跳转类型选项(value 对应 API type: 0=无链接,1=赛事详情,2=自定义连接,3=小程序页面) */
export const JUMP_TYPE_OPTIONS = [
{ value: '1', label: '赛事详情页' },
{ value: '2', label: '自定义链接' },
{ value: '3', label: '小程序页面' },
{ value: '0', label: '无链接' },
] as const;
/** 跳转类型映射 */
const JUMP_TYPE_MAP: Record<string, { label: string; tone: StatusTagTone }> = {
'0': { label: '无链接', tone: 'default' },
'1': { label: '赛事详情页', tone: 'primary' },
'2': { label: '自定义链接', tone: 'purple' },
'3': { label: '小程序页面', tone: 'cyan' },
@@ -44,18 +48,6 @@ const STATUS_MAP: Record<string, { label: string; tone: StatusTagTone; dimmed?:
'0': { label: '已禁用', tone: 'default', dimmed: true },
};
/** 关联赛事选项(mockTODO: 替换为真实 API */
export const EVENT_OPTIONS = [
{ value: 'EV20260701001', label: '2026全国青少年羽毛球锦标赛暨体育文化交流大会' },
{ value: 'EV20260701002', label: '国际马拉松城市联赛' },
{ value: 'EV20260701003', label: '全民健身运动会' },
{ value: 'EV20260701004', label: '上海国际马拉松公开赛' },
{ value: 'EV20260701005', label: '北京城市定向越野挑战赛' },
];
/** 所属区域选项(动态生成,直辖市仅市名,其他省市) */
export const REGION_OPTIONS = getProvinceCityOptions();
// ============================================================
// Model
// ============================================================
@@ -72,21 +64,32 @@ export function useBannerModel() {
{ delay: 300 },
);
// ===== 表格状态(API 返回全量,前端分页) =====
const [loading, setLoading] = useState<boolean>(false);
const [allData, setAllData] = useState<TournamentAdminBannerListVO[]>([]);
const [pagination, setPagination] = useState({
current: 1,
pageSize: 10,
total: 0,
// ===== 数据请求(API 返回全量,前端分页) =====
const {
data,
loading,
run: fetchList,
} = useRequest<TournamentAdminBannerListVO[]>(() => getBannerList(buildQueryParams()), {
refreshDeps: [],
formatResult: (res) => (res.code == 200 ? res.data : []),
});
const allData = computed(() => data.value || []);
// ===== 前端分页 =====
const pagination = usePagination({ defaultCurrent: 1, defaultPageSize: 10 });
/** 当前页数据(客户端切片) */
const dataSource = computed(() => {
const start = (pagination.value.current - 1) * pagination.value.pageSize;
return allData.value.slice(start, start + pagination.value.pageSize);
const start = ((pagination as any).current.value - 1) * (pagination as any).pageSize.value;
return allData.value.slice(start, start + (pagination as any).pageSize.value);
});
// 同步前端分页 total
useEffect(() => {
pagination.setTotal(allData.value.length);
}, [allData]);
// ===== 弹窗状态 =====
const [modalVisible, setModalVisible] = useState<boolean>(false);
const [editingRecord, setEditingRecord] = useState<any>(null);
@@ -102,10 +105,9 @@ export function useBannerModel() {
key: 'type',
width: 120,
},
{ title: '跳转地址', dataIndex: 'linkUrl', key: 'linkUrl', width: 200 },
{ title: '所属区域', dataIndex: 'area', key: 'area', width: 140 },
{ title: '展示时间', key: 'displayTime', width: 260 },
{ title: '创建时间', dataIndex: 'createTime', key: 'createTime', width: 160 },
{ title: '创建时间', dataIndex: 'createDate', key: 'createDate', width: 160 },
{ title: '状态', dataIndex: 'status', key: 'status', width: 100, align: 'center' as const },
];
@@ -131,37 +133,21 @@ export function useBannerModel() {
// ===== 方法 =====
/** 查询 */
const handleSearch = useThrottleFn(async () => {
setLoading(true);
try {
const queryParams = buildQueryParams();
console.log('Banner列表查询参数:', queryParams);
const res = await getBannerList(queryParams);
if (res.code == 200) {
setAllData(res.data);
setPagination({ current: 1, pageSize: 10, total: res.data.length });
} else {
message.error(res.msg || '查询失败');
}
} catch (error: any) {
console.error('Banner查询失败:', error);
} finally {
setLoading(false);
}
}, 500);
const handleSearch = () => fetchList();
/** 重置 */
const handleReset = useThrottleFn(() => {
filterForm.title = '';
filterForm.status = '';
setPagination({ current: 1, pageSize: 10, total: 0 });
setAllData([]);
setTimeout(() => handleSearch(), 350);
pagination.reset();
setTimeout(fetchList, 350);
}, 500);
const handlePageChange = (page: number, pageSize: number) => {
setPagination({ ...pagination.value, current: page, pageSize });
pagination.setCurrent(page);
if (pageSize !== (pagination as any).pageSize.value) {
pagination.setPageSize(pageSize);
}
};
/** 打开新增弹窗 */
@@ -183,11 +169,16 @@ export function useBannerModel() {
if (formSubmitting.value) return;
setFormSubmitting(true);
try {
const params = {
const payload = {
img: formPayload.img || formPayload.imageUrl || '',
title: formPayload.title || '',
type: String(formPayload.type || formPayload.jumpType || '1'),
linkUrl: formPayload.linkUrl || formPayload.jumpUrl || '',
params:
formPayload.params ??
formPayload.parms ??
formPayload.linkUrl ??
formPayload.jumpUrl ??
'',
area: formPayload.area || formPayload.region || '',
showTimeBegin:
formPayload.showTimeBegin ||
@@ -203,8 +194,8 @@ export function useBannerModel() {
};
const isEdit = editingRecord.value !== null;
const res = isEdit
? await updateBanner({ ...params, id: editingRecord.value.id })
: await saveBanner(params);
? await updateBanner({ ...payload, id: editingRecord.value.id })
: await saveBanner(payload);
if (res.code == 200) {
message.success(isEdit ? '编辑成功' : '新增成功');
handleCloseModal();
@@ -221,39 +212,33 @@ export function useBannerModel() {
/** 删除 */
const handleDelete = useThrottleFn(async (record: any) => {
setLoading(true);
try {
const res = await delBanner(record.id);
if (res.code == 200) {
message.success('删除成功');
handleSearch();
fetchList();
} else {
message.error(res.msg || '删除失败');
}
} catch (e: any) {
console.error('Banner删除失败:', e);
} finally {
setLoading(false);
}
}, 500);
/** 启用/禁用切换 */
const handleToggleStatus = useThrottleFn(async (record: any) => {
setLoading(true);
try {
const newStatus = record.status === '1' ? '0' : '1';
const actionText = newStatus === '1' ? '启用' : '禁用';
const res = await toggleBannerActive({ id: record.id, status: newStatus });
if (res.code == 200) {
message.success(`${actionText}成功`);
handleSearch();
fetchList();
} else {
message.error(res.msg || '操作失败');
}
} catch (e: any) {
console.error('Banner状态切换失败:', e);
} finally {
setLoading(false);
}
}, 500);
@@ -49,6 +49,11 @@
font-weight: 500;
}
.tableWrap {
max-height: 200px;
overflow-y: auto;
}
.paginationRow {
display: flex;
justify-content: flex-end;
@@ -65,4 +70,30 @@
font-size: 13px;
color: rgba(0, 0, 0, 0.65);
}
.idImageGrid {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.idImageCell {
width: 128px;
height: 82px;
border-radius: 4px;
overflow: hidden;
border: 1px solid #f0f0f0;
flex-shrink: 0;
:global(.ant-image) {
width: 100%;
height: 100%;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
}
}
@@ -1,6 +1,5 @@
import { defineComponent, reactive, ref } from 'vue';
import { Modal, Descriptions, Image, Table, Pagination, Space, Spin } from 'ant-design-vue';
import { EyeOutlined } from '@ant-design/icons-vue';
import { Modal, Descriptions, Image, Table, Pagination, Spin } from 'ant-design-vue';
import { message } from 'ant-design-vue';
import {
getEventDetail,
@@ -30,23 +29,19 @@ const formatGender = (v: string) => GENDER_MAP[v] ?? v;
const PAGE_SIZE = 10;
/** 渲染身份证图片 */
/** 渲染身份证图片(cover 填充,与封面显示方式一致) */
const renderIdImages = (imgList: string[], singleImg: string) => {
const urls = imgList.length > 0 ? imgList : singleImg ? [singleImg] : [];
if (urls.length === 0) return <span>-</span>;
return (
<Image.PreviewGroup>
<Space>
<div class={styles.idImageGrid}>
{urls.map((url) => (
<Image
key={url}
width={48}
height={32}
src={url}
v-slots={{ previewMask: () => <EyeOutlined /> }}
/>
<div class={styles.idImageCell}>
<Image src={url} />
</div>
))}
</Space>
</div>
</Image.PreviewGroup>
);
};
@@ -62,11 +57,18 @@ const PLAYER_COLUMNS = [
customRender: ({ text }: { text: string }) => formatGender(text),
},
{ title: '手机号', dataIndex: 'phone', key: 'phone', width: 130 },
{ title: '证件号', dataIndex: 'idCard', key: 'idCard', width: 180 },
{
title: '证件号',
dataIndex: 'idCard',
key: 'idCard',
width: 180,
customRender: ({ record }: { record?: TournamentAdminInfoSignupVO }) =>
record?.idCard ? record.idCard : <span>-</span>,
},
{
title: '证件图片',
key: 'idCardImgs',
width: 180,
width: 280,
customRender: ({ record }: { record?: TournamentAdminInfoSignupVO }) =>
record ? renderIdImages(record.idCardImgList || [], record.idCardImg || '') : <span>-</span>,
},
@@ -221,59 +223,70 @@ export default defineComponent({
</div>
{/* ===== 封面 ===== */}
<div class={styles.section}>
<div class={styles.sectionTitle}></div>
<Image src={detail.value.img} />
</div>
{detail.value.img && (
<div class={styles.section}>
<div class={styles.sectionTitle}></div>
<Image style={{ maxHeight: '380px' }} src={detail.value.img} />
</div>
)}
{/* ===== 赛事公告 ===== */}
<div class={styles.section}>
<div class={styles.sectionTitle}></div>
<div class={styles.textBlock}>{detail.value.notice}</div>
</div>
{detail.value.notice && (
<div class={styles.section}>
<div class={styles.sectionTitle}></div>
<div class={styles.textBlock}>{detail.value.notice}</div>
</div>
)}
{/* ===== 组别信息 ===== */}
<div class={styles.section}>
<div class={styles.sectionTitle}></div>
{detail.value.categoryList.map((cat: InnerCategoryVO) => (
<div key={cat.id} class={styles.group}>
<div class={styles.groupTitle}>
{cat.name}{CATEGORY_TYPE_MAP[cat.type] ?? cat.type}
</div>
<Table
columns={PLAYER_COLUMNS}
dataSource={categoryPlayers.value[cat.id] || []}
loading={categoryPlayerLoading.value[cat.id]}
scroll={{ x: 'max-content', y: 200 }}
size="small"
pagination={false}
bordered
/>
<div class={styles.paginationRow}>
<Pagination
current={categoryPlayerPage.value[cat.id] || 1}
pageSize={PAGE_SIZE}
total={categoryPlayerTotal.value[cat.id] || 0}
showSizeChanger={false}
size="small"
onChange={(page: number) => handlePlayerPageChange(cat.id, page)}
/>
</div>
</div>
))}
</div>
{/* ===== 分组信息 ===== */}
<div class={styles.section}>
<div class={styles.sectionTitle}></div>
<div class={styles.divisionList}>
{detail.value.groupInfoList.map((d: string) => (
<div key={d} class={styles.divisionItem}>
{d}
{detail.value.categoryList && detail.value.categoryList.length > 0 && (
<div class={styles.section}>
<div class={styles.sectionTitle}></div>
{detail.value.categoryList.map((cat: InnerCategoryVO) => (
<div key={cat.id} class={styles.group}>
<div class={styles.groupTitle}>
{cat.name}{CATEGORY_TYPE_MAP[cat.type] ?? cat.type}
</div>
<div class={styles.tableWrap}>
<Table
columns={PLAYER_COLUMNS}
dataSource={categoryPlayers.value[cat.id] || []}
loading={categoryPlayerLoading.value[cat.id]}
size="small"
pagination={false}
bordered
/>
</div>
{categoryPlayerTotal.value[cat.id] > 0 && (
<div class={styles.paginationRow}>
<Pagination
current={categoryPlayerPage.value[cat.id] || 1}
pageSize={PAGE_SIZE}
total={categoryPlayerTotal.value[cat.id] || 0}
showSizeChanger={false}
size="small"
onChange={(page: number) => handlePlayerPageChange(cat.id, page)}
/>
</div>
)}
</div>
))}
</div>
</div>
)}
{/* ===== 分组信息 ===== */}
{detail.value.groupInfoList && detail.value.groupInfoList.length > 0 && (
<div class={styles.section}>
<div class={styles.sectionTitle}></div>
<div class={styles.divisionList}>
{detail.value.groupInfoList.map((d: string) => (
<div key={d} class={styles.divisionItem}>
{d}
</div>
))}
</div>
</div>
)}
</>
) : null}
</Modal>
@@ -60,7 +60,7 @@ export default defineComponent({
destroyOnClose
wrapClassName={styles.eventRegulationModalMain}
>
<Spin spinning={loading.value}>
<Spin spinning={loading.value} size="large">
<div style={{ minHeight: loading.value ? '200px' : undefined }}>
{ruleData.value ? (
<>
+40 -24
View File
@@ -19,8 +19,9 @@ import {
ID_CARD_IMAGE_OPTIONS,
} from './model/useEventListModel';
import { useEventListColumns } from './model/useEventListColumns';
import { regionOptions } from '@/utils/areaData';
import { getProvinceCityCascaderOptions } from '@/utils/areaData';
import { useContainerSize } from '@/hooks';
import { usePermissionStore } from '@/stores/permissionStore';
import EventRegulationModal from './components/EventRegulationModal';
import EventDetailModal from './components/EventDetailModal';
import EventAuditModal from './components/EventAuditModal';
@@ -63,6 +64,12 @@ export default defineComponent({
const { columns } = useEventListColumns();
const { containerRef, height } = useContainerSize();
const { hasPermission } = usePermissionStore();
const canToggleStatus = hasPermission('events.list.toggle_status');
const canViewRegulation = hasPermission('events.regulation.view');
/** 省市两级 Cascader 数据(无区) */
const provinceCityOptions = getProvinceCityCascaderOptions();
/** 渲染赛事名称(超长省略 + Tooltip) */
const renderName = (text: string) => {
@@ -88,14 +95,16 @@ export default defineComponent({
<Button type="link" size="small" onClick={() => openDetailModal(record)}>
</Button>
<Button
type="link"
size="small"
loading={togglingId.value === record.id}
onClick={() => handleToggleShelf(record)}
>
</Button>
{canToggleStatus && (
<Button
type="link"
size="small"
loading={togglingId.value === record.id}
onClick={() => handleToggleShelf(record)}
>
</Button>
)}
</Space>
);
}
@@ -106,14 +115,16 @@ export default defineComponent({
<Button type="link" size="small" onClick={() => openDetailModal(record)}>
</Button>
<Button
type="link"
size="small"
loading={togglingId.value === record.id}
onClick={() => handleToggleShelf(record)}
>
</Button>
{canToggleStatus && (
<Button
type="link"
size="small"
loading={togglingId.value === record.id}
onClick={() => handleToggleShelf(record)}
>
</Button>
)}
</Space>
);
}
@@ -142,9 +153,15 @@ export default defineComponent({
width: 100,
align: 'center' as const,
customRender: ({ record }: { record: any }) => (
<a style={{ cursor: 'pointer' }} onClick={() => openRegulationModal(record)}>
</a>
<>
{canViewRegulation ? (
<a style={{ cursor: 'pointer' }} onClick={() => openRegulationModal(record)}>
</a>
) : (
<span>-</span>
)}
</>
),
},
{
@@ -239,14 +256,13 @@ export default defineComponent({
onUpdate:value={(val: any) => (filterForm.idCardImg = val || '')}
/>
</Form.Item>
<Form.Item label="省市区" name="region">
<Form.Item label="省市区" name="region">
<Cascader
value={filterForm.region as any}
options={regionOptions}
options={provinceCityOptions}
style={{ width: '220px' }}
placeholder="请选择省市区"
placeholder="请选择城市"
allowClear
changeOnSelect
onUpdate:value={(val: any) => (filterForm.region = val || [])}
/>
</Form.Item>
@@ -38,7 +38,7 @@ export function useEventListColumns() {
`${record.startTimeBegin}${record.startTimeEnd}`,
},
{
title: '省市区',
title: '省市区',
dataIndex: 'venueArea',
key: 'venueArea',
width: 200,
+27 -12
View File
@@ -1,4 +1,4 @@
import { defineComponent, ref, onMounted, onUnmounted, nextTick } from 'vue';
import { defineComponent, ref, nextTick } from 'vue';
import {
Button,
Input,
@@ -12,7 +12,7 @@ import {
} from 'ant-design-vue';
import { useLogModel, ACTION_TYPE_OPTIONS, ACTION_SOURCE_OPTIONS } from './model/useLogModel';
import { useLogColumns } from './model/useLogColumns';
import { useState, useContainerSize } from '@/hooks';
import { useState, useEffect, useContainerSize } from '@/hooks';
import pageStyles from '@/assets/styles/pageLayout.module.less';
import './index.module.less';
@@ -29,15 +29,17 @@ const LogContentCell = defineComponent({
if (el) setOverflow(el.scrollHeight > el.clientHeight);
};
let observer: ResizeObserver | null = null;
onMounted(() => {
nextTick(checkOverflow);
useEffect(() => {
const el = textRef.value;
if (el) {
observer = new ResizeObserver(checkOverflow);
observer.observe(el);
}
});
onUnmounted(() => observer?.disconnect());
if (!el) return;
nextTick(checkOverflow);
observer = new ResizeObserver(checkOverflow);
observer.observe(el);
return () => {
observer?.disconnect();
observer = null;
};
}, [() => textRef.value]);
return () => {
const raw = props.text;
return (
@@ -81,12 +83,25 @@ export default defineComponent({
const { containerRef, height } = useContainerSize({ headerOffset: 55 });
/** 渲染 bodyCell */
const renderBodyCell = ({ column, text }: { column: any; text: any }) => {
const renderBodyCell = ({
column,
text,
record,
index,
}: {
column: any;
text: any;
record: any;
index: number;
}) => {
if (column.key === 'content') {
const raw = text || '';
return raw ? <LogContentCell text={raw} /> : <span>-</span>;
}
// 其他列使用 columns 中的 customRender
// 有 customRender 的列使用其渲染函数(如操作类型/操作来源的代码→中文映射)
if (column.customRender) {
return column.customRender({ text, record, index, column });
}
return <span>{text || '-'}</span>;
};
@@ -79,6 +79,17 @@
margin-bottom: 12px;
}
.tableWrap {
max-height: 200px;
overflow-y: auto;
}
.paginationRow {
display: flex;
justify-content: flex-end;
margin-top: 8px;
}
.refundSummary {
display: flex;
gap: 32px;
@@ -181,8 +192,8 @@
}
}
.orderModalWrap {
:global(.ant-modal-body) {
padding-top: 16px;
}
}
// .orderModalWrap {
// :global(.ant-modal-body) {
// padding-top: 16px;
// }
// }
@@ -17,6 +17,7 @@ interface OrderDetailModalProps {
uniqueId: string;
onClose: () => void;
onReRefund: () => void;
canReRefund?: boolean;
}
const GENDER_MAP: Record<string, string> = { '1': '男', '2': '女' };
@@ -118,6 +119,7 @@ export default defineComponent({
uniqueId: { type: String, default: '' },
onClose: { type: Function, default: null },
onReRefund: { type: Function, default: null },
canReRefund: { type: Boolean, default: false },
},
setup(props: OrderDetailModalProps) {
const [detail, setDetail] = useState<TournamentAdminOrderInfoVO | null>(null);
@@ -174,15 +176,17 @@ export default defineComponent({
return records.reduce((sum, r) => sum + parseFloat(r.refundAmount || '0'), 0);
});
useEffect(async () => {
const fetchData = async () => {
const params = { orderNo: props.orderNo, uniqueId: props.uniqueId };
const [detailRes] = await Promise.all([getOrderDetail(params), fetchPlayers(1)]);
if (detailRes.code == 200) {
setDetail(detailRes.data);
}
setDetailLoading(false);
};
useEffect(() => {
fetchData();
}, []);
const handlePlayerPageChange = (page: number) => {
@@ -255,16 +259,18 @@ export default defineComponent({
<div class={styles.section}>
<div class={styles.sectionTitle}></div>
<Table
columns={PLAYER_COLUMNS}
dataSource={players.value}
loading={playersLoading.value}
size="small"
bordered
pagination={false}
scroll={{ x: 'max-content', y: 200 }}
/>
<div style={{ textAlign: 'right', marginTop: 18 }}>
<div class={styles.tableWrap}>
<Table
columns={PLAYER_COLUMNS}
dataSource={players.value}
loading={playersLoading.value}
size="small"
bordered
pagination={false}
scroll={{ x: 'max-content' }}
/>
</div>
<div class={styles.paginationRow}>
<Pagination
current={playerPage.value}
pageSize={playerPageSize}
@@ -356,7 +362,7 @@ export default defineComponent({
<div class={styles.modalFooter}>
<Button onClick={props.onClose}></Button>
{/* 只有存在退款失败记录时才显示重新退款按钮 */}
{hasRefundFailed.value ? (
{props.canReRefund && hasRefundFailed.value ? (
<Button danger onClick={props.onReRefund}>
退
</Button>
+8 -1
View File
@@ -15,6 +15,7 @@ import {
import { useOrderModel, ORDER_STATUS_OPTIONS, REFUND_STATUS_OPTIONS } from './model/useOrderModel';
import { retryRefundOrder } from './model/services';
import { useContainerSize, useState } from '@/hooks';
import { usePermissionStore } from '@/stores/permissionStore';
import OrderDetailModal from './components/OrderDetailModal';
import pageStyles from '@/assets/styles/pageLayout.module.less';
@@ -58,12 +59,14 @@ function renderBodyCell({
record,
onView,
onReRefund,
canReRefund,
}: {
column: any;
text: any;
record: any;
onView: (record: any) => void;
onReRefund: (record: any) => void;
canReRefund: boolean;
}) {
// 赛事名称:最多15字符,超出显示 Tooltip
if (column.dataIndex === 'name') {
@@ -82,7 +85,7 @@ function renderBodyCell({
if (column.key === 'action') {
// 退款状态:1=无退款,2=退款成功,3=退款失败,-1=其他
// 只有退款失败(3)时显示重新退款按钮
const showReRefund = record.refundStatus === 3;
const showReRefund = canReRefund && record.refundStatus === '3';
return (
<Space>
<Button type="link" size="small" onClick={() => onView(record)}>
@@ -119,6 +122,8 @@ export default defineComponent({
} = useOrderModel();
const { containerRef, height } = useContainerSize();
const { hasPermission } = usePermissionStore();
const canReRefund = hasPermission('events.orders.refund');
// 详情弹窗状态
const [detailVisible, setDetailVisible] = useState<boolean>(false);
@@ -257,6 +262,7 @@ export default defineComponent({
...args,
onView: handleView,
onReRefund: handleReRefund,
canReRefund,
}),
}}
</Table>
@@ -286,6 +292,7 @@ export default defineComponent({
setDetailVisible(false);
promptReRefund(currentRecord.value);
}}
canReRefund={canReRefund}
/>
)}
</div>
+12 -5
View File
@@ -3,6 +3,7 @@ import { Button, Input, Table, Form, Space, Select, Pagination } from 'ant-desig
import { useUserModel, USER_STATUS_OPTIONS } from './model/useUserModel';
import { useUserColumns } from './model/useUserColumns';
import { useContainerSize } from '@/hooks';
import { usePermissionStore } from '@/stores/permissionStore';
import pageStyles from '@/assets/styles/pageLayout.module.less';
/**
@@ -30,9 +31,13 @@ export default defineComponent({
const { columns } = useUserColumns();
const { containerRef, height } = useContainerSize();
const { hasPermission } = usePermissionStore();
const canToggleStatus = hasPermission('events.users.toggle_status');
/** 渲染操作列 */
const renderAction = (record: any) => {
if (!canToggleStatus) return <span>-</span>;
const isActive = record.status === '1';
return (
<Button
@@ -62,13 +67,15 @@ export default defineComponent({
<div class={pageStyles.containerMain}>
<div class={pageStyles.filter}>
<Form style={{ rowGap: '10px' }} layout="inline" model={filterForm}>
<Form.Item label="用户姓名/手机号" name="text">
<Form.Item label="昵称" name="nickname">
<Input
value={filterForm.text}
placeholder="请输入"
style={{ width: '200px' }}
value={filterForm.nickname}
placeholder="请输入昵称"
style={{ width: '180px' }}
allowClear
onUpdate:value={(val: any) => (filterForm.text = val.target?.value ?? val ?? '')}
onUpdate:value={(val: any) =>
(filterForm.nickname = val.target?.value ?? val ?? '')
}
onPressEnter={handleSearch}
/>
</Form.Item>
+37 -18
View File
@@ -1,31 +1,56 @@
/**
* 用户列表 - 接口服务
* 该页面使用的所有接口集中管理
* 赛事用户列表 - 接口服务
* 接口文档: GET /admin/manager/user/page
*/
import { get, post } from '@/utils/request';
import { USE_MOCK, MOCK_DELAY } from '@/config/mock';
import { buildMockUserListPage } from '@/config/mock/userList';
import type {
UserListQueryParams,
UserSaveParams,
UserUpdateParams,
UserActiveParams,
UserUpdatePwdParams,
TournamentAdminUserVO,
PageData,
ApiResult,
} from '@/api/users/types';
// ============================================================
// 本页专用类型(与系统用户管理页的 TournamentAdminUserVO 不同)
// ============================================================
/** 赛事用户列表项 */
export interface EventUserVO {
id: number;
nickname: string;
phone: string;
realName: string;
idCard: string;
idCardImg: string;
idCardImgList: string[];
status: number;
createTime: string;
}
/** 赛事用户列表查询参数 */
export interface EventUserListQueryParams {
page: string;
limit: string;
nickname?: string;
phone?: string;
status?: string;
}
// 为兼容旧引用,也导出旧类型名
export type TournamentAdminUserVO = EventUserVO;
export type UserListQueryParams = EventUserListQueryParams;
export type {
TournamentAdminUserVO,
UserListQueryParams,
UserSaveParams,
UserUpdateParams,
UserActiveParams,
UserUpdatePwdParams,
PageData,
ApiResult,
} from '@/api/users/types';
};
// ============================================================
// URL 常量
@@ -42,16 +67,10 @@ const delay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
// API 函数
// ============================================================
/** 用户分页列表 */
export async function getUserList(
params: UserListQueryParams,
): Promise<ApiResult<PageData<TournamentAdminUserVO>>> {
if (USE_MOCK) {
await delay(MOCK_DELAY);
const page = Number(params.page) || 1;
const limit = Number(params.limit) || 10;
return { code: 200, msg: 'success', data: buildMockUserListPage(page, limit) };
}
/** 赛事用户分页列表 */
export function getUserList(
params: EventUserListQueryParams,
): Promise<ApiResult<PageData<EventUserVO>>> {
return get(userList, params as Record<string, any>);
}
+6 -10
View File
@@ -1,4 +1,4 @@
import { defineComponent, onMounted } from 'vue';
import { defineComponent } from 'vue';
import { Button, Input, Table, Form, Space, Pagination, Select, DatePicker } from 'ant-design-vue';
import { usePaymentsModel } from './model/usePaymentsModel';
import { useContainerSize } from '@/hooks';
@@ -24,10 +24,6 @@ export default defineComponent({
const { containerRef, height } = useContainerSize({ headerOffset: 55 });
onMounted(() => {
handleSearch();
});
return () => (
<div class={pageStyles.containerMain}>
<div class={pageStyles.filter}>
@@ -83,11 +79,11 @@ export default defineComponent({
<div class={pageStyles.summary}>
<span class={pageStyles.summaryItem}>
<span class={pageStyles.summaryLabel}></span>
<span class={pageStyles.summaryValue}>{summary.value.totalPay}</span>
<span class={pageStyles.summaryValue}>{summary.value.totalAmount}</span>
</span>
<span class={pageStyles.summaryItem}>
<span class={pageStyles.summaryLabel}>退</span>
<span class={pageStyles.summaryValue}>{summary.value.totalRefund}</span>
<span class={pageStyles.summaryValue}>{summary.value.totalRefundAmount}</span>
</span>
</div>
<div ref={containerRef} class={pageStyles.tableBody}>
@@ -101,9 +97,9 @@ export default defineComponent({
</div>
<div class={pageStyles.pagination}>
<Pagination
current={pagination.value.current}
pageSize={pagination.value.pageSize}
total={pagination.value.total}
current={(pagination as any).current.value}
pageSize={(pagination as any).pageSize.value}
total={(pagination as any).total.value}
showSizeChanger
showTotal={(total: number) => `${total}`}
onChange={handlePageChange}
+5 -4
View File
@@ -8,21 +8,22 @@ import { buildMockPaymentFlowPage } from '@/config/mock/paymentFlow';
import type {
PaymentFlowQueryParams,
PaymentFlowVO,
PageData,
PaymentFlowPageData,
ApiResult,
} from '@/api/payments/types';
export type {
PaymentFlowVO,
PaymentFlowQueryParams,
PageData,
PaymentFlowPageData,
PaymentFlowExData,
ApiResult,
} from '@/api/payments/types';
// ============================================================
// URL 常量
// ============================================================
const paymentFlowList = '/finance/paymentFlow/page';
const paymentFlowList = '/admin/finance/paymentFlow/page';
const delay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
@@ -33,7 +34,7 @@ const delay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
/** 支付流水分页 */
export async function getPaymentFlowList(
params: PaymentFlowQueryParams,
): Promise<ApiResult<PageData<PaymentFlowVO>>> {
): Promise<ApiResult<PaymentFlowPageData>> {
if (USE_MOCK) {
await delay(MOCK_DELAY);
const page = Number(params.page) || 1;
@@ -1,5 +1,5 @@
import { computed, reactive, toRef, Ref } from 'vue';
import { useState, useDebounce, useThrottleFn } from '@/hooks';
import { useDebounce, useThrottleFn, useRequest, usePagination, useEffect } from '@/hooks';
import { getPaymentFlowList, type PaymentFlowVO, type PaymentFlowQueryParams } from './services';
import dayjs from 'dayjs';
@@ -40,13 +40,50 @@ export function usePaymentsModel() {
{ delay: 300 },
);
// ===== 汇总 =====
const [summary, setSummary] = useState({ totalPay: '0.00', totalRefund: '0.00' });
// ===== 分页 =====
const pagination = usePagination({ defaultCurrent: 1, defaultPageSize: 10 });
// ===== 表格状态 =====
const [loading, setLoading] = useState<boolean>(false);
const [dataSource, setDataSource] = useState<PaymentFlowVO[]>([]);
const [pagination, setPagination] = useState({ current: 1, pageSize: 10, total: 0 });
// ===== 构建 API 查询参数 =====
const buildQueryParams = (): PaymentFlowQueryParams => {
const params: PaymentFlowQueryParams = {
page: String((pagination as any).current.value),
limit: String((pagination as any).pageSize.value),
};
if (filterForm.timeRange) {
const [start, end] = filterForm.timeRange;
if (start) params.createDateBegin = dayjs(start).format('YYYY-MM-DD');
if (end) params.createDateEnd = dayjs(end).format('YYYY-MM-DD');
}
if (debouncedNickname.value.trim()) params.nickname = debouncedNickname.value.trim();
if (debouncedPhone.value.trim()) params.phone = debouncedPhone.value.trim();
if (filterForm.type) params.type = filterForm.type;
return params;
};
// ===== 请求 =====
const {
data,
loading,
run: fetchList,
} = useRequest(() => getPaymentFlowList(buildQueryParams()), {
refreshDeps: [],
formatResult: (res) => res,
});
const listData = computed(() => (data.value as any)?.data);
const dataSource = computed(() => listData.value?.list || []);
// 汇总:从 listData(即 res.data,包含 exData)派生
const summary = computed(() => ({
totalAmount: listData.value?.exData?.totalAmount ?? '0.00',
totalRefundAmount: listData.value?.exData?.totalRefundAmount ?? '0.00',
}));
// 同步 total 到 pagination
useEffect(() => {
const total = (data.value as any)?.data?.total;
if (total !== undefined) pagination.setTotal(total);
}, [data]);
// ===== 表格列(dataIndex 对齐 API =====
const columns = [
@@ -81,66 +118,25 @@ export function usePaymentsModel() {
);
});
// ===== 构建 API 查询参数 =====
const buildQueryParams = (): PaymentFlowQueryParams => {
const params: PaymentFlowQueryParams = {
page: String(pagination.value.current),
limit: String(pagination.value.pageSize),
};
if (filterForm.timeRange) {
const [start, end] = filterForm.timeRange;
if (start) params.createDateBegin = dayjs(start).format('YYYY-MM-DD');
if (end) params.createDateEnd = dayjs(end).format('YYYY-MM-DD');
}
if (debouncedNickname.value.trim()) params.nickname = debouncedNickname.value.trim();
if (debouncedPhone.value.trim()) params.phone = debouncedPhone.value.trim();
if (filterForm.type) params.type = filterForm.type;
return params;
};
// ===== 方法 =====
const handleSearch = useThrottleFn(async () => {
setLoading(true);
try {
const queryParams = buildQueryParams();
console.log('支付流水查询参数:', queryParams);
const res = await getPaymentFlowList(queryParams);
if (res.code == 200) {
console.log('支付流水查询结果:', { total: res.data.total, count: res.data.list.length });
setDataSource(res.data.list);
setPagination({ ...pagination.value, total: res.data.total });
// 汇总:type=1 金额为收入,type=2 金额为退款
let totalPay = 0;
let totalRefund = 0;
for (const item of res.data.list) {
const amt = Number(item.amount) || 0;
if (item.type === 1) totalPay += amt;
else if (item.type === 2) totalRefund += amt;
}
setSummary({ totalPay: totalPay.toFixed(2), totalRefund: totalRefund.toFixed(2) });
}
} catch {
// 网络层已统一提示
} finally {
setLoading(false);
}
}, 500);
const handleSearch = () => fetchList();
const handleReset = useThrottleFn(() => {
filterForm.timeRange = null;
filterForm.nickname = '';
filterForm.phone = '';
filterForm.type = '';
setPagination({ current: 1, pageSize: 10, total: 0 });
setDataSource([]);
setSummary({ totalPay: '0.00', totalRefund: '0.00' });
setTimeout(() => handleSearch(), 350);
pagination.reset();
setTimeout(fetchList, 350);
}, 500);
const handlePageChange = (page: number, pageSize: number) => {
setPagination({ ...pagination.value, current: page, pageSize });
handleSearch();
pagination.setCurrent(page);
if (pageSize !== (pagination as any).pageSize.value) {
pagination.setPageSize(pageSize);
}
setTimeout(fetchList, 0);
};
return {
+16 -15
View File
@@ -1,7 +1,8 @@
import { defineComponent, ref, onMounted, onBeforeUnmount, watch, nextTick } from 'vue';
import { defineComponent, ref, nextTick } from 'vue';
import { Select, Table } from 'ant-design-vue';
import * as echarts from 'echarts';
import { useReportsModel } from './model/useReportsModel';
import { useEffect } from '@/hooks';
import styles from './index.module.less';
/**
@@ -140,23 +141,23 @@ export default defineComponent({
chartInstance?.resize();
};
onMounted(() => {
nextTick(() => {
renderChart();
});
// ECharts 挂载/销毁 + 窗口 resize 监听
useEffect(() => {
const el = chartRef.value;
if (!el) return;
nextTick(() => renderChart());
window.addEventListener('resize', handleResize);
});
return () => {
window.removeEventListener('resize', handleResize);
chartInstance?.dispose();
chartInstance = null;
};
}, [() => chartRef.value]);
onBeforeUnmount(() => {
window.removeEventListener('resize', handleResize);
chartInstance?.dispose();
chartInstance = null;
});
// 时间范围切换时刷新图表(接入 API 后生效)
watch(range, () => {
// 时间范围切换时刷新图表
useEffect(() => {
renderChart();
});
}, [range]);
return () => {
/** 根据增长率构建箭头 + 颜色(正值 ↑ 绿,负值 ↓ 红,null 时固定文本) */
+16 -9
View File
@@ -1,8 +1,9 @@
import { defineComponent, onMounted } from 'vue';
import { defineComponent } from 'vue';
import { Button, Input, Table, Form, Space, Pagination } from 'ant-design-vue';
import { WalletOutlined, LockOutlined, RiseOutlined } from '@ant-design/icons-vue';
import { useWalletModel } from './model/useWalletModel';
import { useContainerSize } from '@/hooks';
import { usePermissionStore } from '@/stores/permissionStore';
import WalletDetailModal from './components/WalletDetailModal';
import styles from './index.module.less';
import pageStyles from '@/assets/styles/pageLayout.module.less';
@@ -20,13 +21,17 @@ function renderBodyCell({
column,
record,
onViewDetail,
canViewDetail,
}: {
column: any;
text: any;
record: any;
onViewDetail: (record: any) => void;
canViewDetail: boolean;
}) {
if (column.key === 'action') {
if (!canViewDetail) return <span>-</span>;
return (
<Button type="link" size="small" onClick={() => onViewDetail(record)}>
@@ -56,6 +61,8 @@ export default defineComponent({
} = useWalletModel();
const { containerRef, height } = useContainerSize({ headerOffset: 55 });
const { hasPermission } = usePermissionStore();
const canViewDetail = hasPermission('finance.wallet.detail');
const tableColumns = [
...columns,
@@ -68,10 +75,6 @@ export default defineComponent({
},
];
onMounted(() => {
handleSearch();
});
return () => {
const statCards: StatCardItem[] = [
{
@@ -200,15 +203,19 @@ export default defineComponent({
>
{{
bodyCell: (args: any) =>
renderBodyCell({ ...args, onViewDetail: handleViewDetail }),
renderBodyCell({
...args,
onViewDetail: handleViewDetail,
canViewDetail,
}),
}}
</Table>
</div>
<div class={pageStyles.pagination}>
<Pagination
current={pagination.value.current}
pageSize={pagination.value.pageSize}
total={pagination.value.total}
current={(pagination as any).current.value}
pageSize={(pagination as any).pageSize.value}
total={(pagination as any).total.value}
showSizeChanger
showTotal={(total: number) => `${total}`}
onChange={handlePageChange}
+2 -2
View File
@@ -27,8 +27,8 @@ export type {
// ============================================================
// URL 常量
// ============================================================
const walletList = '/finance/wallet/summary';
const walletTransaction = '/finance/wallet/page';
const walletList = '/admin/finance/wallet/summary';
const walletTransaction = '/admin/finance/wallet/page';
const delay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
@@ -1,5 +1,12 @@
import { computed, reactive, toRef, Ref } from 'vue';
import { useState, useDebounce, useThrottleFn } from '@/hooks';
import {
useState,
useDebounce,
useThrottleFn,
useRequest,
usePagination,
useEffect,
} from '@/hooks';
import { getWalletList, type WalletSummaryVO, type WalletQueryParams } from './services';
// ============================================================
@@ -58,17 +65,58 @@ export function useWalletModel() {
{ delay: 300 },
);
// ===== 顶部统计(汇总值从接口返回的第一条记录中提取) =====
const [summary, setSummary] = useState({
totalBalance: '0.00',
frozenAmount: '0.00',
totalWithdraw: '0.00',
// ===== 分页 =====
const pagination = usePagination({ defaultCurrent: 1, defaultPageSize: 10 });
// ===== 构建 API 查询参数 =====
const buildQueryParams = (): WalletQueryParams => {
// 余额筛选:若起始 > 截止,互换两值
const min = Number(filterForm.minBalance);
const max = Number(filterForm.maxBalance);
if (filterForm.minBalance && filterForm.maxBalance && min > max) {
filterForm.minBalance = filterForm.maxBalance;
filterForm.maxBalance = String(min);
}
const params: WalletQueryParams = {
page: String((pagination as any).current.value),
limit: String((pagination as any).pageSize.value),
};
if (debouncedNickname.value.trim()) params.nickname = debouncedNickname.value.trim();
if (debouncedPhone.value.trim()) params.phone = debouncedPhone.value.trim();
if (filterForm.minBalance) params.amountBegin = filterForm.minBalance;
if (filterForm.maxBalance) params.amountEnd = filterForm.maxBalance;
return params;
};
// ===== 请求 =====
const {
data,
loading,
run: fetchList,
} = useRequest(() => getWalletList(buildQueryParams()), {
refreshDeps: [],
formatResult: (res) => res,
});
// ===== 表格状态 =====
const [loading, setLoading] = useState<boolean>(false);
const [dataSource, setDataSource] = useState<WalletSummaryVO[]>([]);
const [pagination, setPagination] = useState({ current: 1, pageSize: 10, total: 0 });
const listData = computed(() => (data.value as any)?.data);
const dataSource = computed(() => listData.value?.list || []);
// 汇总:从第一条记录提取
const summary = computed(() => {
const first = dataSource.value?.[0] as any;
return {
totalBalance: first?.totalAmount ?? '0.00',
frozenAmount: first?.totalFrozenAmount ?? '0.00',
totalWithdraw: first?.totalWithdrawalAmount ?? '0.00',
};
});
// 同步 total 到 pagination
useEffect(() => {
const total = (data.value as any)?.data?.total;
if (total !== undefined) pagination.setTotal(total);
}, [data]);
// ===== 详情弹窗状态 =====
const [detailVisible, setDetailVisible] = useState<boolean>(false);
@@ -112,70 +160,25 @@ export function useWalletModel() {
);
});
// ===== 构建 API 查询参数 =====
const buildQueryParams = (): WalletQueryParams => {
const params: WalletQueryParams = {
page: String(pagination.value.current),
limit: String(pagination.value.pageSize),
};
if (debouncedNickname.value.trim()) params.nickname = debouncedNickname.value.trim();
if (debouncedPhone.value.trim()) params.phone = debouncedPhone.value.trim();
if (filterForm.minBalance) params.amountBegin = filterForm.minBalance;
if (filterForm.maxBalance) params.amountEnd = filterForm.maxBalance;
return params;
};
// ===== 方法 =====
const handleSearch = useThrottleFn(async () => {
setLoading(true);
try {
// 余额筛选:若起始 > 截止,互换两值
const min = Number(filterForm.minBalance);
const max = Number(filterForm.maxBalance);
if (filterForm.minBalance && filterForm.maxBalance && min > max) {
filterForm.minBalance = filterForm.maxBalance;
filterForm.maxBalance = String(min);
}
const params = buildQueryParams();
console.log('钱包列表查询参数:', params);
const res = await getWalletList(params);
if (res.code == 200) {
console.log('钱包列表查询结果:', { total: res.data.total, count: res.data.list.length });
setDataSource(res.data.list);
setPagination({ ...pagination.value, total: res.data.total });
// 从第一条记录提取汇总值
if (res.data.list.length > 0) {
const first = res.data.list[0];
setSummary({
totalBalance: first.totalAmount || '0.00',
frozenAmount: first.totalFrozenAmount || '0.00',
totalWithdraw: first.totalWithdrawalAmount || '0.00',
});
}
}
} catch {
// 网络层已统一提示
} finally {
setLoading(false);
}
}, 500);
const handleSearch = () => fetchList();
const handleReset = useThrottleFn(() => {
filterForm.nickname = '';
filterForm.phone = '';
filterForm.minBalance = '';
filterForm.maxBalance = '';
setPagination({ current: 1, pageSize: 10, total: 0 });
setDataSource([]);
setSummary({ totalBalance: '0.00', frozenAmount: '0.00', totalWithdraw: '0.00' });
setTimeout(() => handleSearch(), 350);
pagination.reset();
setTimeout(fetchList, 350);
}, 500);
const handlePageChange = (page: number, pageSize: number) => {
setPagination({ ...pagination.value, current: page, pageSize });
handleSearch();
pagination.setCurrent(page);
if (pageSize !== (pagination as any).pageSize.value) {
pagination.setPageSize(pageSize);
}
setTimeout(fetchList, 0);
};
const handleViewDetail = (record: any) => {
@@ -2,7 +2,7 @@ import { defineComponent, reactive, ref, watch } from 'vue';
import { Modal, Radio, Input, Button, Upload, Image, Spin, message } from 'ant-design-vue';
import { PlusOutlined } from '@ant-design/icons-vue';
import { getWithdrawInfo, postWithdrawAudit, type WithdrawInfoVO } from '../model/services';
import { AUDIT_STATUS_MAP, PAY_STATUS_MAP } from '../model/useWithdrawModel';
import { getAuditStatusText, getPayStatusText } from '../model/useWithdrawModel';
import styles from './WithdrawDetailModal.module.less';
const { TextArea } = Input;
@@ -135,8 +135,8 @@ export default defineComponent({
{renderItem('费率:', `${(Number(d.feeRate) * 100).toFixed(2)}%`)}
{renderItem('手续费:', `¥${d.feeAmount}`)}
{renderItem('到账金额:', `¥${d.receivedAmount}`, true)}
{renderItem('审核状态:', AUDIT_STATUS_MAP[d.auditStatus] ?? '-', true)}
{renderItem('列账状态:', PAY_STATUS_MAP[d.payStatus] ?? '-')}
{renderItem('审核状态:', getAuditStatusText(d.auditStatus), true)}
{renderItem('列账状态:', getPayStatusText(d.payStatus))}
</div>
</div>
+14 -13
View File
@@ -1,7 +1,8 @@
import { defineComponent, onMounted } from 'vue';
import { defineComponent } from 'vue';
import { Button, Input, Table, Form, Space, Pagination, Select } from 'ant-design-vue';
import { useWithdrawModel, AUDIT_STATUS_MAP, PAY_STATUS_MAP } from './model/useWithdrawModel';
import { useWithdrawModel, renderAuditStatus, renderPayStatus } from './model/useWithdrawModel';
import { useContainerSize } from '@/hooks';
import { usePermissionStore } from '@/stores/permissionStore';
import WithdrawDetailModal from './components/WithdrawDetailModal';
import pageStyles from '@/assets/styles/pageLayout.module.less';
@@ -13,22 +14,24 @@ function renderBodyCell({
record,
onAudit,
onView,
canAudit,
}: {
column: any;
text: any;
record: any;
onAudit: (record: any) => void;
onView: (record: any) => void;
canAudit: boolean;
}) {
if (column.key === 'auditStatus') {
return <span>{AUDIT_STATUS_MAP[record.auditStatus] || record.auditStatus || '-'}</span>;
return renderAuditStatus(record.auditStatus);
}
if (column.key === 'payStatus') {
return <span>{PAY_STATUS_MAP[record.payStatus] ?? '-'}</span>;
return renderPayStatus(record.payStatus);
}
if (column.key === 'action') {
// auditStatus=0(待审核)→ 显示「审核」,其他 → 显示「查看」
if (record.auditStatus === 0) {
if (canAudit && record.auditStatus === 0) {
return (
<Button type="link" size="small" onClick={() => onAudit(record)}>
@@ -69,6 +72,8 @@ export default defineComponent({
} = useWithdrawModel();
const { containerRef, height } = useContainerSize({ headerOffset: 55 });
const { hasPermission } = usePermissionStore();
const canAudit = hasPermission('finance.withdraw.audit');
/** 最终表格列:模型列 + 操作 */
const tableColumns = [
@@ -82,11 +87,6 @@ export default defineComponent({
},
];
// 首次进入自动加载
onMounted(() => {
handleSearch();
});
return () => (
<div class={pageStyles.containerMain}>
{/* ===== 筛选区 ===== */}
@@ -167,6 +167,7 @@ export default defineComponent({
...args,
onAudit: handleAudit,
onView: handleView,
canAudit,
}),
}}
</Table>
@@ -175,9 +176,9 @@ export default defineComponent({
{/* 独立分页,右下方 */}
<div class={pageStyles.pagination}>
<Pagination
current={pagination.value.current}
pageSize={pagination.value.pageSize}
total={pagination.value.total}
current={(pagination as any).current.value}
pageSize={(pagination as any).pageSize.value}
total={(pagination as any).total.value}
showSizeChanger
showTotal={(total: number) => `${total}`}
onChange={handlePageChange}
+3 -3
View File
@@ -25,9 +25,9 @@ export type {
// ============================================================
// URL 常量
// ============================================================
const withdrawList = '/finance/withdraw/page';
const withdrawInfo = '/finance/withdraw/info';
const withdrawAudit = '/finance/withdraw/audit';
const withdrawList = '/admin/finance/withdraw/page';
const withdrawInfo = '/admin/finance/withdraw/info';
const withdrawAudit = '/admin/finance/withdraw/audit';
const delay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
@@ -1,5 +1,13 @@
import { computed, reactive, toRef, Ref } from 'vue';
import { useState, useDebounce, useThrottleFn } from '@/hooks';
import { computed, reactive, toRef, Ref, h } from 'vue';
import { StatusTag, type StatusTagTone } from '@/components';
import {
useState,
useDebounce,
useThrottleFn,
useRequest,
usePagination,
useEffect,
} from '@/hooks';
import { getWithdrawList, type WithdrawVO, type WithdrawQueryParams } from './services';
// ============================================================
@@ -22,20 +30,45 @@ export const PAY_STATUS_OPTIONS = [
{ value: '2', label: '支付失败' },
] as const;
/** 审核状态文案映射 */
export const AUDIT_STATUS_MAP: Record<number, string> = {
0: '待审核',
1: '审核通过',
2: '审核驳回',
/** 审核状态文案 + 色调映射 */
const AUDIT_STATUS_MAP: Record<number, { label: string; tone: StatusTagTone }> = {
0: { label: '待审核', tone: 'warning' },
1: { label: '审核通过', tone: 'success' },
2: { label: '审核驳回', tone: 'danger' },
};
/** 到账状态文案映射 */
export const PAY_STATUS_MAP: Record<number, string> = {
0: '未支付',
1: '支付成功',
2: '支付失败',
/** 到账状态文案 + 色调映射 */
const PAY_STATUS_MAP: Record<number, { label: string; tone: StatusTagTone }> = {
0: { label: '未支付', tone: 'default' },
1: { label: '支付成功', tone: 'success' },
2: { label: '支付失败', tone: 'danger' },
};
/** 渲染审核状态 StatusTag */
export function renderAuditStatus(status: number) {
const info = AUDIT_STATUS_MAP[status] || {
label: String(status ?? '-'),
tone: 'default' as const,
};
return h(StatusTag, { label: info.label, tone: info.tone });
}
/** 渲染到账状态 StatusTag */
export function renderPayStatus(status: number) {
const info = PAY_STATUS_MAP[status] || { label: String(status ?? '-'), tone: 'default' as const };
return h(StatusTag, { label: info.label, tone: info.tone });
}
/** 获取审核状态纯文本(详情弹窗用) */
export function getAuditStatusText(status: number): string {
return AUDIT_STATUS_MAP[status]?.label ?? String(status ?? '-');
}
/** 获取到账状态纯文本(详情弹窗用) */
export function getPayStatusText(status: number): string {
return PAY_STATUS_MAP[status]?.label ?? String(status ?? '-');
}
// ============================================================
// Model
// ============================================================
@@ -67,10 +100,41 @@ export function useWithdrawModel() {
{ delay: 300 },
);
// ===== 表格状态 =====
const [loading, setLoading] = useState<boolean>(false);
const [dataSource, setDataSource] = useState<WithdrawVO[]>([]);
const [pagination, setPagination] = useState({ current: 1, pageSize: 10, total: 0 });
// ===== 分页 =====
const pagination = usePagination({ defaultCurrent: 1, defaultPageSize: 10 });
// ===== 构建 API 查询参数 =====
const buildQueryParams = (): WithdrawQueryParams => {
const params: WithdrawQueryParams = {
page: String((pagination as any).current.value),
limit: String((pagination as any).pageSize.value),
};
if (debouncedNickname.value.trim()) params.nickname = debouncedNickname.value.trim();
if (debouncedPhone.value.trim()) params.phone = debouncedPhone.value.trim();
if (debouncedRealName.value.trim()) params.realName = debouncedRealName.value.trim();
if (filterForm.auditStatus) params.auditStatus = filterForm.auditStatus;
if (filterForm.payStatus) params.payStatus = filterForm.payStatus;
return params;
};
// ===== 请求 =====
const {
data,
loading,
run: fetchList,
} = useRequest(() => getWithdrawList(buildQueryParams()), {
refreshDeps: [],
formatResult: (res) => res,
});
const listData = computed(() => (data.value as any)?.data);
const dataSource = computed(() => listData.value?.list || []);
// 同步 total 到 pagination
useEffect(() => {
const total = (data.value as any)?.data?.total;
if (total !== undefined) pagination.setTotal(total);
}, [data]);
// ===== 审核弹窗状态 =====
const [auditVisible, setAuditVisible] = useState<boolean>(false);
@@ -113,40 +177,9 @@ export function useWithdrawModel() {
);
});
// ===== 构建 API 查询参数 =====
const buildQueryParams = (): WithdrawQueryParams => {
const params: WithdrawQueryParams = {
page: String(pagination.value.current),
limit: String(pagination.value.pageSize),
};
if (debouncedNickname.value.trim()) params.nickname = debouncedNickname.value.trim();
if (debouncedPhone.value.trim()) params.phone = debouncedPhone.value.trim();
if (debouncedRealName.value.trim()) params.realName = debouncedRealName.value.trim();
if (filterForm.auditStatus) params.auditStatus = filterForm.auditStatus;
if (filterForm.payStatus) params.payStatus = filterForm.payStatus;
return params;
};
// ===== 方法 =====
/** 查询(节流 500ms */
const handleSearch = useThrottleFn(async () => {
setLoading(true);
try {
const queryParams = buildQueryParams();
console.log('提现列表查询参数:', queryParams);
const res = await getWithdrawList(queryParams);
if (res.code == 200) {
console.log('提现列表查询结果:', { total: res.data.total, count: res.data.list.length });
setDataSource(res.data.list);
setPagination({ ...pagination.value, total: res.data.total });
}
} catch {
// 网络层已统一提示
} finally {
setLoading(false);
}
}, 500);
const handleSearch = () => fetchList();
/** 重置(重置后自动查询) */
const handleReset = useThrottleFn(() => {
@@ -155,14 +188,16 @@ export function useWithdrawModel() {
filterForm.realName = '';
filterForm.auditStatus = '';
filterForm.payStatus = '';
setPagination({ current: 1, pageSize: 10, total: 0 });
setDataSource([]);
setTimeout(() => handleSearch(), 350);
pagination.reset();
setTimeout(fetchList, 350);
}, 500);
const handlePageChange = (page: number, pageSize: number) => {
setPagination({ ...pagination.value, current: page, pageSize });
handleSearch();
pagination.setCurrent(page);
if (pageSize !== (pagination as any).pageSize.value) {
pagination.setPageSize(pageSize);
}
setTimeout(fetchList, 0);
};
/** 打开审核弹窗 */
+10 -1
View File
@@ -55,7 +55,16 @@ export default defineComponent({
try {
const res = await loginApi({ phone: form.phone, password: form.password });
setUser({ phone: form.phone, nickname: res.data.nickname, token: res.data.token });
if (res.code != 200) {
message.error(res.msg || '登录失败');
return;
}
setUser({
phone: res.data.phone || form.phone,
nickname: res.data.nickName || '',
token: res.data.token,
});
if (rememberMe.value) {
localStorage.setItem(
+4 -9
View File
@@ -1,4 +1,4 @@
import { defineComponent, onMounted } from 'vue';
import { defineComponent } from 'vue';
import { Button, Input, Table, DatePicker, Form, Space, Select, Pagination } from 'ant-design-vue';
import { useLogModel, ACTION_TYPE_OPTIONS, ACTION_TYPE_MAP } from './model/useLogModel';
import { useContainerSize } from '@/hooks';
@@ -35,11 +35,6 @@ export default defineComponent({
const { containerRef, height } = useContainerSize({ headerOffset: 55 });
// 首次进入自动加载
onMounted(() => {
handleSearch();
});
return () => (
<div class={pageStyles.containerMain}>
{/* ===== 筛选区 ===== */}
@@ -102,9 +97,9 @@ export default defineComponent({
{/* 独立分页 */}
<div class={pageStyles.pagination}>
<Pagination
current={pagination.value.current}
pageSize={pagination.value.pageSize}
total={pagination.value.total}
current={(pagination as any).current.value}
pageSize={(pagination as any).pageSize.value}
total={(pagination as any).total.value}
showSizeChanger
showTotal={(total: number) => `${total}`}
onChange={handlePageChange}
+1 -1
View File
@@ -22,7 +22,7 @@ export type {
// ============================================================
// URL 常量
// ============================================================
const operationLogList = '/sys/operation/page';
const operationLogList = '/admin/sys/operation/page';
const delay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
+55 -45
View File
@@ -1,10 +1,15 @@
import { computed, reactive, toRef, Ref } from 'vue';
import dayjs from 'dayjs';
import { useState, useDebounce, useThrottleFn } from '@/hooks';
import { useDebounce, useThrottleFn } from '@/hooks';
import { useRequest } from '@/hooks/useRequest';
import { useEffect } from '@/hooks/useEffect';
import { usePagination } from '@/hooks/usePagination';
import {
getOperationLogList,
type SysOperationLogVO,
type OperationLogQueryParams,
type PageData,
type ApiResult,
} from './services';
// ============================================================
@@ -49,33 +54,14 @@ export function useLogModel() {
{ delay: 300 },
);
// ===== 表格状态 =====
const [loading, setLoading] = useState<boolean>(false);
const [dataSource, setDataSource] = useState<SysOperationLogVO[]>([]);
const [pagination, setPagination] = useState({ current: 1, pageSize: 10, total: 0 });
// ===== 表格列配置(字段名对齐 API =====
const columns = [
{ title: '操作类型', dataIndex: 'type', key: 'type', width: 140 },
{ title: '操作人', dataIndex: 'nickname', key: 'nickname', width: 140 },
{ title: '操作内容', dataIndex: 'content', key: 'content', minWidth: 360 },
{ title: '操作时间', dataIndex: 'createDate', key: 'createDate', width: 180 },
];
// ===== 计算属性 =====
const hasFilter = computed(() => {
return (
filterForm.actionTimeRange !== null ||
filterForm.actionType !== '' ||
debouncedOperator.value.trim() !== ''
);
});
// ===== 分页 =====
const pagination = usePagination({ defaultCurrent: 1, defaultPageSize: 10 });
// ===== 构建 API 查询参数 =====
const buildQueryParams = (): OperationLogQueryParams => {
const params: OperationLogQueryParams = {
page: String(pagination.value.current),
limit: String(pagination.value.pageSize),
page: String((pagination as any).current.value),
limit: String((pagination as any).pageSize.value),
};
// 时间范围 → dateBegin / dateEnd
@@ -98,38 +84,62 @@ export function useLogModel() {
return params;
};
// ===== 数据请求 =====
const {
data,
loading,
run: fetchList,
} = useRequest<ApiResult<PageData<SysOperationLogVO>>>(
() => getOperationLogList(buildQueryParams()),
{ refreshDeps: [], formatResult: (res) => res },
);
const dataSource = computed(() => {
const res = data.value;
return res ? (res as any).data?.list || [] : [];
});
useEffect(() => {
const total = (data.value as any)?.data?.total;
if (total !== undefined) pagination.setTotal(total);
}, [data]);
// ===== 表格列配置(字段名对齐 API =====
const columns = [
{ title: '操作类型', dataIndex: 'type', key: 'type', width: 140 },
{ title: '操作人', dataIndex: 'nickname', key: 'nickname', width: 140 },
{ title: '操作内容', dataIndex: 'content', key: 'content', minWidth: 360 },
{ title: '操作时间', dataIndex: 'createDate', key: 'createDate', width: 180 },
];
// ===== 计算属性 =====
const hasFilter = computed(() => {
return (
filterForm.actionTimeRange !== null ||
filterForm.actionType !== '' ||
debouncedOperator.value.trim() !== ''
);
});
// ===== 方法 =====
/** 查询(节流 500ms */
const handleSearch = useThrottleFn(async () => {
setLoading(true);
try {
const queryParams = buildQueryParams();
const res = await getOperationLogList(queryParams);
if (res.code == 200) {
setDataSource(res.data.list);
setPagination({ ...pagination.value, total: res.data.total });
}
} catch {
// 网络层已统一提示
} finally {
setLoading(false);
}
}, 500);
const handleSearch = () => fetchList();
/** 重置(重置后自动查询) */
const handleReset = useThrottleFn(() => {
filterForm.actionTimeRange = null;
filterForm.actionType = '';
filterForm.searchOperator = '';
setPagination({ current: 1, pageSize: 10, total: 0 });
setDataSource([]);
setTimeout(() => handleSearch(), 350);
pagination.reset();
setTimeout(fetchList, 350);
}, 500);
const handlePageChange = (page: number, pageSize: number) => {
setPagination({ ...pagination.value, current: page, pageSize });
handleSearch();
pagination.setCurrent(page);
if (pageSize !== (pagination as any).pageSize.value) {
pagination.setPageSize(pageSize);
}
setTimeout(fetchList, 0);
};
return {
@@ -2,12 +2,7 @@ import { defineComponent, ref, reactive, watch } from 'vue';
import { Modal, Form, Input, Tree, Button, Spin, message } from 'ant-design-vue';
import type { TreeProps } from 'ant-design-vue';
import { getPermissionTree, type PermissionTreeNode } from '../model/services';
import {
roleNameRules,
buildTreeData,
extractLeafPermissions,
type AntdTreeNode,
} from '../controller';
import { roleNameRules, buildTreeData, type AntdTreeNode } from '../controller';
import styles from './RoleFormModal.module.less';
interface RoleFormModalProps {
@@ -110,18 +105,26 @@ export default defineComponent({
return;
}
const isEdit = !!props.record;
// 仅提取叶节点 { id, selected } 传给后端
const leafPermissions = extractLeafPermissions(rawApiTree.value);
// 从原始权限树中收集所有叶节点 ID(children 为空或不存在)
const leafNodeIds = new Set<string>();
const collectLeafIds = (nodes: PermissionTreeNode[]) => {
for (const node of nodes) {
if (!node.children || node.children.length === 0) {
leafNodeIds.add(String(node.id));
} else {
collectLeafIds(node.children);
}
}
};
collectLeafIds(rawApiTree.value);
// 从用户当前勾选的 checkedKeys 中只取叶节点 ID
const menuIds = formData.checkedKeys.filter((key: string) => leafNodeIds.has(key));
props.onSave({
roleName: formData.roleName,
roleId: props.record?.roleId,
// 完整选中 key 列表(含父节点),供向后兼容
permissions: formData.checkedKeys,
// 仅叶节点数据,后端实际需要的格式
leafPermissions,
isEdit,
menuIds,
});
};
@@ -143,7 +143,6 @@ export default defineComponent({
columns={columns}
dataSource={dataSource.value}
loading={loading.value}
scroll={{ y: 300 }}
size="small"
pagination={false}
bordered
@@ -164,7 +163,6 @@ export default defineComponent({
pageSize={pagination.value.pageSize}
total={pagination.value.total}
size="small"
showSizeChanger
showTotal={(total: number) => `${total}`}
onChange={handlePageChange}
/>
-30
View File
@@ -66,33 +66,3 @@ export function buildTreeData(apiTree: PermissionTreeNode[]): {
const treeData = apiTree.map((node) => convertNode(node, leafKeys));
return { treeData, checkedKeys: Array.from(leafKeys) };
}
/**
* 从后端权限树中提取叶节点数据,整理为提交格式。
*
* 只返回叶节点,格式为 `{ id, selected }` 数组。
* 父节点的勾选状态由后端根据叶节点数据推导,前端不传父节点。
*/
export function extractLeafPermissions(
apiTree: PermissionTreeNode[],
): { id: number; selected: boolean }[] {
const result: { id: number; selected: boolean }[] = [];
const walk = (nodes: PermissionTreeNode[]) => {
for (const node of nodes) {
const isLeaf = !node.children || node.children.length === 0;
if (isLeaf) {
result.push({
id: node.id,
// null 视为 false,即未授权
selected: node.selected === true,
});
} else {
walk(node.children!);
}
}
};
walk(apiTree);
return result;
}
+35 -18
View File
@@ -1,8 +1,9 @@
import { defineComponent, onMounted } from 'vue';
import { defineComponent } from 'vue';
import { Button, Input, Table, Form, Space, Select, Modal, Pagination } from 'ant-design-vue';
import type { ModalProps } from 'ant-design-vue';
import { useRoleModel } from './model/useRoleModel';
import { useContainerSize } from '@/hooks';
import { usePermissionStore } from '@/stores/permissionStore';
import RoleFormModal from './components/RoleFormModal';
import UserListModal from './components/UserListModal';
import pageStyles from '@/assets/styles/pageLayout.module.less';
@@ -16,6 +17,7 @@ function renderBodyCell({
onViewUsers,
onEdit,
onDelete,
permissions,
}: {
column: any;
text: any;
@@ -23,6 +25,10 @@ function renderBodyCell({
onViewUsers: (record: any) => void;
onEdit: (record: any) => void;
onDelete: (record: any) => void;
permissions: {
canEdit: boolean;
canDelete: boolean;
};
}) {
if (column.dataIndex === 'userCount') {
return (
@@ -33,14 +39,21 @@ function renderBodyCell({
}
if (column.key === 'action') {
const { canEdit, canDelete } = permissions;
if (!canEdit && !canDelete) return <span>-</span>;
return (
<Space>
<Button type="link" size="small" onClick={() => onEdit(record)}>
</Button>
<Button type="link" size="small" danger onClick={() => onDelete(record)}>
</Button>
{canEdit && (
<Button type="link" size="small" onClick={() => onEdit(record)}>
</Button>
)}
{canDelete && (
<Button type="link" size="small" danger onClick={() => onDelete(record)}>
</Button>
)}
</Space>
);
}
@@ -100,6 +113,12 @@ export default defineComponent({
} = useRoleModel();
const { containerRef, height } = useContainerSize();
const { hasPermission } = usePermissionStore();
const canAdd = hasPermission('system.roles.add');
const operationPermissions = {
canEdit: hasPermission('system.roles.edit'),
canDelete: hasPermission('system.roles.delete'),
};
const triggerDelete = (record: any) => confirmDelete({ record, onOk: handleDelete });
@@ -114,11 +133,6 @@ export default defineComponent({
},
];
// ===== 初始化 =====
onMounted(() => {
handleSearch();
});
return () => (
<div class={pageStyles.containerMain}>
{/* ===== 筛选区 ===== */}
@@ -140,9 +154,11 @@ export default defineComponent({
<Button type="primary" onClick={handleSearch} loading={loading.value}>
</Button>
<Button type="primary" onClick={handleAdd}>
</Button>
{canAdd && (
<Button type="primary" onClick={handleAdd}>
</Button>
)}
</Space>
</Form.Item>
</Form>
@@ -165,6 +181,7 @@ export default defineComponent({
onViewUsers: handleViewUsers,
onEdit: handleEdit,
onDelete: triggerDelete,
permissions: operationPermissions,
}),
}}
</Table>
@@ -173,9 +190,9 @@ export default defineComponent({
{/* 分页 */}
<div class={pageStyles.pagination}>
<Pagination
current={pagination.value.current}
pageSize={pagination.value.pageSize}
total={pagination.value.total}
current={(pagination as any).current.value}
pageSize={(pagination as any).pageSize.value}
total={(pagination as any).total.value}
showSizeChanger
showTotal={(total: number) => `${total}`}
onChange={handlePageChange}
+27 -34
View File
@@ -3,13 +3,11 @@
* 该页面使用的所有接口集中管理
*/
import { get, post } from '@/utils/request';
import { USE_MOCK, MOCK_DELAY } from '@/config/mock';
import { buildMockRoleListPage } from '@/config/mock/roleList';
import { buildMockRoleUsageUserPage } from '@/config/mock/roleUsageUser';
import { buildMockPermissionTree } from '@/config/mock/permissionTree';
import type {
RoleListQueryParams,
RoleUsageUserQueryParams,
RoleSaveParams,
RoleUpdateParams,
TournamentAdminRolePageVO,
RoleUsageUserVO,
PermissionTreeNode,
@@ -22,6 +20,8 @@ export type {
RoleUsageUserVO,
RoleListQueryParams,
RoleUsageUserQueryParams,
RoleSaveParams,
RoleUpdateParams,
PermissionTreeNode,
PageData,
ApiResult,
@@ -30,56 +30,49 @@ export type {
// ============================================================
// URL 常量
// ============================================================
const roleList = '/sys/role/page';
const roleUsageUserList = '/sys/role/usageUserPage';
const rolePermissionList = '/sys/role/permissionList';
const roleDelete = '/sys/role/del';
const delay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
const roleList = '/admin/sys/role/page';
const roleSave = '/admin/sys/role/save';
const roleUpdate = '/admin/sys/role/update';
const roleUsageUserList = '/admin/sys/role/usageUserPage';
const rolePermissionList = '/admin/sys/role/permissionList';
const roleDelete = '/admin/sys/role/del';
// ============================================================
// API 函数
// ============================================================
/** 角色列表分页 */
export async function getRoleList(
/** 角色列表分页 — GET /admin/sys/role/page */
export function getRoleList(
params: RoleListQueryParams,
): Promise<ApiResult<PageData<TournamentAdminRolePageVO>>> {
if (USE_MOCK) {
await delay(MOCK_DELAY);
const page = Number(params.page) || 1;
const limit = Number(params.limit) || 10;
return { code: 200, msg: 'success', data: buildMockRoleListPage(page, limit) };
}
return get(roleList, params as Record<string, any>);
}
/** 角色下用户列表分页 */
export async function getRoleUsageUserList(
/** 新增角色 — POST /admin/sys/role/save */
export function saveRole(params: RoleSaveParams): Promise<ApiResult<Record<string, never>>> {
return post(roleSave, params);
}
/** 更新角色 — POST /admin/sys/role/update */
export function updateRole(params: RoleUpdateParams): Promise<ApiResult<Record<string, never>>> {
return post(roleUpdate, params);
}
/** 角色下用户列表分页 — GET /admin/sys/role/usageUserPage */
export function getRoleUsageUserList(
params: RoleUsageUserQueryParams,
): Promise<ApiResult<PageData<RoleUsageUserVO>>> {
if (USE_MOCK) {
await delay(MOCK_DELAY);
const page = Number(params.page) || 1;
const limit = Number(params.limit) || 10;
return { code: 200, msg: 'success', data: buildMockRoleUsageUserPage(page, limit) };
}
return get(roleUsageUserList, params as Record<string, any>);
}
/** 角色权限树 */
export async function getPermissionTree(
/** 角色权限树 — GET /admin/sys/role/permissionList */
export function getPermissionTree(
roleId: number | string,
): Promise<ApiResult<PermissionTreeNode[]>> {
if (USE_MOCK) {
await delay(MOCK_DELAY);
const id = Number(roleId) || 0;
return { code: 200, msg: 'success', data: buildMockPermissionTree(id) };
}
return get(rolePermissionList, { roleId: String(roleId) });
}
/** 删除角色 */
/** 删除角色 — POST /admin/sys/role/del */
export function deleteRole(id: number): Promise<ApiResult<null>> {
return post(roleDelete, { id });
}
+71 -47
View File
@@ -1,11 +1,18 @@
import { computed, reactive, toRef, Ref } from 'vue';
import { message } from 'ant-design-vue';
import { useState, useDebounce, useThrottleFn } from '@/hooks';
import { useRequest } from '@/hooks/useRequest';
import { useEffect } from '@/hooks/useEffect';
import { usePagination } from '@/hooks/usePagination';
import {
getRoleList,
saveRole,
updateRole,
deleteRole,
type TournamentAdminRolePageVO,
type RoleListQueryParams,
type PageData,
type ApiResult,
} from './services';
// ============================================================
@@ -25,10 +32,38 @@ export function useRoleModel() {
delay: 300,
});
// ===== 表格状态 =====
const [loading, setLoading] = useState<boolean>(false);
const [dataSource, setDataSource] = useState<TournamentAdminRolePageVO[]>([]);
const [pagination, setPagination] = useState({ current: 1, pageSize: 10, total: 0 });
// ===== 分页 =====
const pagination = usePagination({ defaultCurrent: 1, defaultPageSize: 10 });
// ===== 构建 API 查询参数 =====
const buildQueryParams = (): RoleListQueryParams => {
const params: RoleListQueryParams = {
page: String((pagination as any).current.value),
limit: String((pagination as any).pageSize.value),
};
if (filterForm.name.trim()) params.name = filterForm.name.trim();
return params;
};
// ===== 数据请求 =====
const {
data,
loading,
run: fetchList,
} = useRequest<ApiResult<PageData<TournamentAdminRolePageVO>>>(
() => getRoleList(buildQueryParams()),
{ refreshDeps: [], formatResult: (res) => res },
);
const dataSource = computed(() => {
const res = data.value;
return res ? (res as any).data?.list || [] : [];
});
useEffect(() => {
const total = (data.value as any)?.data?.total;
if (total !== undefined) pagination.setTotal(total);
}, [data]);
// ===== 弹窗状态 =====
const [formVisible, setFormVisible] = useState<boolean>(false);
@@ -53,53 +88,27 @@ export function useRoleModel() {
{ title: '创建时间', dataIndex: 'createTime', key: 'createTime', width: 200 },
];
// ===== 构建 API 查询参数 =====
const buildQueryParams = (): RoleListQueryParams => {
const params: RoleListQueryParams = {
page: String(pagination.value.current),
limit: String(pagination.value.pageSize),
};
if (filterForm.name.trim()) params.name = filterForm.name.trim();
return params;
};
// ===== 计算属性 =====
const hasFilter = computed(() => debouncedName.value.trim() !== '');
const isEdit = computed(() => editingRecord.value !== null);
// ===== 方法 =====
/** 查询(节流 500ms */
const handleSearch = useThrottleFn(async () => {
setLoading(true);
try {
const queryParams = buildQueryParams();
console.log('角色列表查询参数:', queryParams);
const res = await getRoleList(queryParams);
if (res.code == 200) {
setDataSource(res.data.list);
setPagination({ ...pagination.value, total: res.data.total });
} else {
message.error(res.msg || '查询失败');
}
} catch (error: any) {
console.error('角色列表查询失败:', error);
} finally {
setLoading(false);
}
}, 500);
const handleSearch = () => fetchList();
/** 重置(重置后自动查询) */
const handleReset = useThrottleFn(() => {
filterForm.name = '';
setPagination({ current: 1, pageSize: 10, total: 0 });
setDataSource([]);
setTimeout(() => handleSearch(), 350);
pagination.reset();
setTimeout(fetchList, 350);
}, 500);
const handlePageChange = (page: number, pageSize: number) => {
setPagination({ ...pagination.value, current: page, pageSize });
handleSearch();
pagination.setCurrent(page);
if (pageSize !== (pagination as any).pageSize.value) {
pagination.setPageSize(pageSize);
}
setTimeout(fetchList, 0);
};
/** 新增 */
@@ -125,11 +134,29 @@ export function useRoleModel() {
if (formSubmitting.value) return;
setFormSubmitting(true);
try {
console.log(isEdit.value ? '编辑角色' : '新增角色', formPayload);
// TODO: 替换为真实 API 调用(/sys/role/save、/sys/role/update
message.success(isEdit.value ? '编辑成功' : '新增成功');
handleCloseForm();
handleSearch();
// menuIds 由 RoleFormModal 从用户勾选的 checkedKeys 中过滤叶节点后传入
const menuIds: string[] = formPayload.menuIds || [];
let res: any;
if (isEdit.value) {
res = await updateRole({
id: String(formPayload.roleId),
name: formPayload.roleName,
menuIds,
});
} else {
res = await saveRole({
name: formPayload.roleName,
menuIds,
});
}
if (res.code == 200) {
message.success(isEdit.value ? '编辑成功' : '新增成功');
handleCloseForm();
fetchList();
} else {
message.error(res.msg || '操作失败');
}
} catch {
// 网络层已统一提示,不再重复 message.error
} finally {
@@ -139,19 +166,16 @@ export function useRoleModel() {
/** 删除 */
const handleDelete = useThrottleFn(async (record: any) => {
setLoading(true);
try {
const res = await deleteRole(record.roleId);
if (res.code == 200) {
setDataSource(dataSource.value.filter((item: any) => item.roleId !== record.roleId));
message.success('删除成功');
fetchList();
} else {
message.error(res.msg || '删除失败');
}
} catch {
// 网络层已统一提示,不再重复 message.error
} finally {
setLoading(false);
}
}, 500);
@@ -1,8 +1,8 @@
import { defineComponent, ref, reactive } from 'vue';
import { useEffect, useState } from '@/hooks';
import { Modal, Form, Input, Select, Button } from 'ant-design-vue';
import { getRoleList, type TournamentAdminRolePageVO } from '../../roles/model/services';
import {
ROLE_FORM_OPTIONS,
PASSWORD_PLACEHOLDER,
realNameRules,
phoneRules,
@@ -55,12 +55,84 @@ export default defineComponent({
/** 编辑模式下是否处于"重置密码"状态 */
const [isResetting, setIsResetting] = useState(false);
// ---- 角色搜索状态 ----
const roleKeyword = ref('');
const [roleOptions, setRoleOptions] = useState<{ value: string; label: string }[]>([]);
const [roleSearchLoading, setRoleSearchLoading] = useState(false);
/** 手动防抖计时器 */
let roleSearchTimer: ReturnType<typeof setTimeout> | null = null;
/** 已加载的全量角色缓存 */
const [allRoleOptions, setAllRoleOptions] = useState<{ value: string; label: string }[]>([]);
// ---- 角色搜索 API 调用(用户主动搜索,带 loading) ----
const doRoleSearch = async (keyword: string) => {
setRoleSearchLoading(true);
try {
const params: Record<string, string> = { page: '1', limit: '999', name: keyword.trim() };
const res = await getRoleList(params);
if (res.code == 200) {
const list: TournamentAdminRolePageVO[] = res.data?.list || [];
setRoleOptions(
list.map((item) => ({
value: String(item.roleId),
label: item.roleName,
})),
);
}
} catch (e) {
console.error('[UserForm] 搜索角色失败:', e);
} finally {
setRoleSearchLoading(false);
}
};
/** 初始加载全量角色(弹窗打开时调用,不带 loading,不覆盖已选值) */
const fetchInitialRoles = async () => {
try {
const res = await getRoleList({ page: '1', limit: '999' });
if (res.code == 200) {
const list: TournamentAdminRolePageVO[] = res.data?.list || [];
const opts = list.map((item) => ({
value: String(item.roleId),
label: item.roleName,
}));
// 确保已选角色在列表中
const selectedId = formData.roleId;
if (selectedId && !opts.some((o) => o.value === selectedId)) {
const cached = allRoleOptions.value.find((o) => o.value === selectedId);
if (cached) opts.unshift(cached);
}
setAllRoleOptions(opts);
setRoleOptions(opts);
}
} catch (e) {
console.error('[UserForm] 加载角色列表失败:', e);
}
};
/** 角色搜索输入(带 350ms 手动防抖) */
const onRoleSearch = (value: string) => {
roleKeyword.value = value;
if (roleSearchTimer) clearTimeout(roleSearchTimer);
if (!value.trim()) {
// 清空搜索:恢复全量角色
setRoleOptions(allRoleOptions.value);
return;
}
roleSearchTimer = setTimeout(() => {
doRoleSearch(value);
}, 350);
};
/** 根据 record 初始化表单 */
const initFormFromRecord = (record: any) => {
if (!record) {
// 新增:预填默认密码 + 确认密码
Object.assign(formData, getAddForm());
setIsResetting(false);
roleKeyword.value = '';
fetchInitialRoles();
return;
}
const fresh = getDefaultForm();
@@ -71,6 +143,22 @@ export default defineComponent({
roleId: record.roleId || undefined,
});
setIsResetting(false);
// 编辑模式:先回显已选角色,再静默加载全量角色
roleKeyword.value = '';
if (record.roleId) {
const selectedOpt = {
value: String(record.roleId),
label: record.roleName || String(record.roleId),
};
setAllRoleOptions([selectedOpt]);
setRoleOptions([selectedOpt]);
} else {
setAllRoleOptions([]);
setRoleOptions([]);
}
// 后台静默加载全量角色列表(API 返回后合并到选项,loading 为 false 不影响显示)
fetchInitialRoles();
};
/** 监听 visible 变化重置表单 */
@@ -82,6 +170,14 @@ export default defineComponent({
}
}, [() => props.visible]);
/** 关闭时清理防抖计时器 */
useEffect(() => {
if (!props.visible && roleSearchTimer) {
clearTimeout(roleSearchTimer);
roleSearchTimer = null;
}
}, [() => props.visible]);
/** 点击"重置密码" */
const handleResetPassword = () => {
setIsResetting(true);
@@ -172,9 +268,19 @@ export default defineComponent({
</Form.Item>
<Form.Item label="角色" name="roleId" rules={roleRules} class={styles.col}>
<Select
options={ROLE_FORM_OPTIONS as any}
placeholder="请选择角色"
v-model:value={formData.roleId}
showSearch
filterOption={false}
allowClear={!roleSearchLoading.value}
loading={roleSearchLoading.value}
placeholder="请输入关键字搜索角色"
options={roleOptions.value as any}
onSearch={onRoleSearch}
onFocus={() => {
if (formData.roleId && roleOptions.value.length === 0) {
doRoleSearch(formData.roleId);
}
}}
/>
</Form.Item>
</div>
@@ -210,9 +316,19 @@ export default defineComponent({
{/* 角色:单独一行 */}
<Form.Item label="角色" name="roleId" rules={roleRules}>
<Select
options={ROLE_FORM_OPTIONS as any}
placeholder="请选择角色"
v-model:value={formData.roleId}
showSearch
filterOption={false}
allowClear={!roleSearchLoading.value}
loading={roleSearchLoading.value}
placeholder="请输入关键字搜索角色"
options={roleOptions.value as any}
onSearch={onRoleSearch}
onFocus={() => {
if (formData.roleId && roleOptions.value.length === 0) {
doRoleSearch(formData.roleId);
}
}}
style={{ width: '200px' }}
/>
</Form.Item>
-8
View File
@@ -14,14 +14,6 @@ import { isChinaPhone, isValidPassword, isValidRealName } from '@/utils/form';
/** 密码占位符(写死长度,避免暴露真实密码长度) */
export const PASSWORD_PLACEHOLDER = '********';
/** 角色下拉选项(表单使用,value 对应 API roleId */
export const ROLE_FORM_OPTIONS = [
{ value: '1', label: '超级管理员' },
{ value: '2', label: '赛事管理员' },
{ value: '3', label: '裁判' },
{ value: '4', label: '财务' },
] as const;
// ============================================================
// 真实姓名规则
// ============================================================
+38 -20
View File
@@ -1,4 +1,4 @@
import { defineComponent, onMounted } from 'vue';
import { defineComponent } from 'vue';
import {
Button,
Input,
@@ -10,9 +10,10 @@ import {
Pagination,
message,
} from 'ant-design-vue';
import { useUserModel, ROLE_OPTIONS, USER_STATUS_OPTIONS } from './model/useUserModel';
import { useUserModel, USER_STATUS_OPTIONS } from './model/useUserModel';
import { toggleUserActive, saveUser, updateUser } from './model/services';
import { useState, useThrottleFn, useContainerSize } from '@/hooks';
import { usePermissionStore } from '@/stores/permissionStore';
import UserFormModal from './components/UserFormModal';
import pageStyles from '@/assets/styles/pageLayout.module.less';
@@ -24,24 +25,36 @@ function renderBodyCell({
record,
onEdit,
onToggleStatus,
permissions,
}: {
column: any;
text: any;
record: any;
onEdit: (record: any) => void;
onToggleStatus: (record: any) => void;
permissions: {
canEdit: boolean;
canToggleStatus: boolean;
};
}) {
// 操作列
if (column.key === 'action') {
const isActive = record.status === '1';
const { canEdit, canToggleStatus } = permissions;
if (!canEdit && !canToggleStatus) return <span>-</span>;
return (
<Space>
<Button type="link" size="small" onClick={() => onEdit(record)}>
</Button>
<Button type="link" size="small" onClick={() => onToggleStatus(record)}>
{isActive ? '停用' : '启用'}
</Button>
{canEdit && (
<Button type="link" size="small" onClick={() => onEdit(record)}>
</Button>
)}
{canToggleStatus && (
<Button type="link" size="small" onClick={() => onToggleStatus(record)}>
{isActive ? '停用' : '启用'}
</Button>
)}
</Space>
);
}
@@ -60,12 +73,19 @@ export default defineComponent({
dataSource,
columns,
pagination,
roleOptions,
handleSearch,
handleReset,
handlePageChange,
} = useUserModel();
const { containerRef, height } = useContainerSize();
const { hasPermission } = usePermissionStore();
const canAdd = hasPermission('system.users.add');
const operationPermissions = {
canEdit: hasPermission('system.users.edit'),
canToggleStatus: hasPermission('system.users.toggle_status'),
};
// ===== 弹窗状态(组件级) =====
const [modalVisible, setModalVisible] = useState<boolean>(false);
@@ -175,11 +195,6 @@ export default defineComponent({
},
];
// ===== 初始化 =====
onMounted(() => {
handleSearch();
});
return () => (
<div class={pageStyles.containerMain}>
{/* ===== 筛选区 ===== */}
@@ -198,7 +213,7 @@ export default defineComponent({
<Form.Item label="角色" name="roleId">
<Select
value={filterForm.roleId}
options={ROLE_OPTIONS as any}
options={roleOptions.value as any}
style={{ width: '140px' }}
allowClear
placeholder="全部"
@@ -220,9 +235,11 @@ export default defineComponent({
<Button type="primary" onClick={handleSearch} loading={loading.value}>
</Button>
<Button type="primary" onClick={handleAdd}>
</Button>
{canAdd && (
<Button type="primary" onClick={handleAdd}>
</Button>
)}
</Space>
</Form.Item>
</Form>
@@ -244,6 +261,7 @@ export default defineComponent({
...args,
onEdit: handleEdit,
onToggleStatus: handleToggleStatus,
permissions: operationPermissions,
}),
}}
</Table>
@@ -252,9 +270,9 @@ export default defineComponent({
{/* 独立分页 */}
<div class={pageStyles.pagination}>
<Pagination
current={pagination.value.current}
pageSize={pagination.value.pageSize}
total={pagination.value.total}
current={(pagination as any).current.value}
pageSize={(pagination as any).pageSize.value}
total={(pagination as any).total.value}
showSizeChanger
showTotal={(total: number) => `${total}`}
onChange={handlePageChange}
+11 -44
View File
@@ -3,7 +3,6 @@
* 该页面使用的所有接口集中管理
*/
import { get, post } from '@/utils/request';
import { fetchPermissions } from '@/api/menu';
import type {
UserListQueryParams,
UserSaveParams,
@@ -29,57 +28,25 @@ export type {
// ============================================================
// URL 常量
// ============================================================
const userSave = '/sys/user/save';
const userUpdate = '/sys/user/update';
const userActive = '/sys/user/active';
const userUpdatePwd = '/sys/user/updatepwd';
const userPage = '/admin/sys/user/page';
const userSave = '/admin/sys/user/save';
const userUpdate = '/admin/sys/user/update';
const userActive = '/admin/sys/user/active';
const userUpdatePwd = '/admin/sys/user/updatepwd';
// ============================================================
// API 函数
// ============================================================
/**
* 用户列表 - 通过 fetchPermissions 接口获取用户权限列表
* 接口: GET /admin/sys/role/permissionList
* 返回权限编码列表,前端做分页处理
* 用户列表分页
* 接口: GET /admin/sys/user/page
* 参数: page, limit, text(姓名/手机号), roleId(角色ID), status(0=停用,1=正常)
*/
export async function getUserList(
_params: UserListQueryParams,
export function getUserList(
params: UserListQueryParams,
): Promise<ApiResult<PageData<TournamentAdminUserVO>>> {
// 调用 fetchPermissions 获取用户权限数据
const list = await fetchPermissions();
// 将权限编码字符串转为类用户列表格式,支持分页
const page = Number(_params.page) || 1;
const limit = Number(_params.limit) || 10;
const start = (page - 1) * limit;
const end = start + limit;
const pagedList = list.slice(start, end);
// 将权限编码映射为用户 VO 格式(后端返回的权限码可能包含用户信息)
const userListData: TournamentAdminUserVO[] = pagedList.map((item, index) => {
// 如果 item 是对象则直接使用,如果是字符串则构造基本结构
if (typeof item === 'object' && item !== null) {
return item as unknown as TournamentAdminUserVO;
}
return {
id: String(start + index + 1),
realName: String(item),
phone: '',
roleName: '',
status: '1',
createTime: '',
} as TournamentAdminUserVO;
});
return {
code: 200,
msg: 'success',
data: {
total: list.length,
list: userListData,
},
};
return get(userPage, params as Record<string, any>);
}
/** 新增用户 */
+78 -55
View File
@@ -1,22 +1,23 @@
import { computed, reactive, toRef, Ref, h } from 'vue';
import { computed, reactive, toRef, Ref, h, ref } from 'vue';
import { message } from 'ant-design-vue';
import { StatusTag, type StatusTagTone } from '@/components';
import { useState, useDebounce, useThrottleFn } from '@/hooks';
import { getUserList, type TournamentAdminUserVO, type UserListQueryParams } from './services';
import { useDebounce, useThrottleFn } from '@/hooks';
import { useRequest } from '@/hooks/useRequest';
import { useEffect } from '@/hooks/useEffect';
import { usePagination } from '@/hooks/usePagination';
import {
getUserList,
type TournamentAdminUserVO,
type UserListQueryParams,
type PageData,
type ApiResult,
} from './services';
import { getRoleList, type TournamentAdminRolePageVO } from '../../roles/model/services';
// ============================================================
// 常量
// ============================================================
/** 角色选项(筛选) */
export const ROLE_OPTIONS = [
{ value: '', label: '全部' },
{ value: '1', label: '超级管理员' },
{ value: '2', label: '赛事管理员' },
{ value: '3', label: '裁判' },
{ value: '4', label: '财务' },
] as const;
/** 状态选项(筛选) */
export const USER_STATUS_OPTIONS = [
{ value: '', label: '全部' },
@@ -45,20 +46,66 @@ export function useUserModel() {
status: '' as '' | '0' | '1',
});
// ===== 动态角色选项 =====
const roleOptions = ref<{ value: string; label: string }[]>([{ value: '', label: '全部' }]);
/** 加载全部角色列表(用于筛选下拉) */
const fetchRoleOptions = async () => {
try {
const res = await getRoleList({ page: '1', limit: '999' });
if (res.code == 200) {
const list: TournamentAdminRolePageVO[] = res.data?.list || [];
const opts = list.map((item) => ({
value: String(item.roleId),
label: item.roleName,
}));
roleOptions.value = [{ value: '', label: '全部' }, ...opts];
}
} catch (e) {
console.error('[UserModel] 加载角色列表失败:', e);
}
};
// 文本筛选防抖 300ms
const { debouncedValue: debouncedText } = useDebounce(toRef(filterForm, 'text') as Ref<string>, {
delay: 300,
});
// ===== 表格状态 =====
const [loading, setLoading] = useState<boolean>(false);
const [dataSource, setDataSource] = useState<TournamentAdminUserVO[]>([]);
const [pagination, setPagination] = useState({
current: 1,
pageSize: 10,
total: 0,
// ===== 分页 =====
const pagination = usePagination({ defaultCurrent: 1, defaultPageSize: 10 });
// ===== 构建 API 查询参数 =====
const buildQueryParams = (): UserListQueryParams => {
const params: UserListQueryParams = {
page: String((pagination as any).current.value),
limit: String((pagination as any).pageSize.value),
};
if (filterForm.text.trim()) params.text = filterForm.text.trim();
if (filterForm.roleId) params.roleId = filterForm.roleId;
if (filterForm.status) params.status = filterForm.status;
return params;
};
// ===== 数据请求 =====
const {
data,
loading,
run: fetchList,
} = useRequest<ApiResult<PageData<TournamentAdminUserVO>>>(
() => getUserList(buildQueryParams()),
{ refreshDeps: [], formatResult: (res) => res },
);
const dataSource = computed(() => {
const res = data.value;
return res ? (res as any).data?.list || [] : [];
});
useEffect(() => {
const total = (data.value as any)?.data?.total;
if (total !== undefined) pagination.setTotal(total);
}, [data]);
// ===== 表格列配置 =====
const columns = [
{ title: '姓名', dataIndex: 'realName', key: 'realName', width: 120 },
@@ -78,18 +125,6 @@ export function useUserModel() {
{ title: '创建时间', dataIndex: 'createTime', key: 'createTime', width: 180 },
];
// ===== 构建 API 查询参数 =====
const buildQueryParams = (): UserListQueryParams => {
const params: UserListQueryParams = {
page: String(pagination.value.current),
limit: String(pagination.value.pageSize),
};
if (filterForm.text.trim()) params.text = filterForm.text.trim();
if (filterForm.roleId) params.roleId = filterForm.roleId;
if (filterForm.status) params.status = filterForm.status;
return params;
};
// ===== 计算属性 =====
const hasFilter = computed(() => {
return (
@@ -99,49 +134,37 @@ export function useUserModel() {
// ===== 方法 =====
/** 查询(节流 500ms */
const handleSearch = useThrottleFn(async () => {
setLoading(true);
try {
const queryParams = buildQueryParams();
console.log('用户列表查询参数:', queryParams);
const res = await getUserList(queryParams);
if (res.code == 200) {
setDataSource(res.data.list);
setPagination({ ...pagination.value, total: res.data.total });
} else {
message.error(res.msg || '查询失败');
}
} catch (error: any) {
console.error('用户列表查询失败:', error);
} finally {
setLoading(false);
}
}, 500);
const handleSearch = () => fetchList();
/** 重置(节流 500ms,重置后自动查询) */
const handleReset = useThrottleFn(() => {
filterForm.text = '';
filterForm.roleId = '';
filterForm.status = '';
setPagination({ current: 1, pageSize: 10, total: 0 });
setDataSource([]);
pagination.reset();
// 等待 debounce(300ms) 生效后自动查询
setTimeout(() => handleSearch(), 350);
setTimeout(fetchList, 350);
}, 500);
/** 分页变更 */
const handlePageChange = (page: number, pageSize: number) => {
setPagination({ ...pagination.value, current: page, pageSize });
handleSearch();
pagination.setCurrent(page);
if (pageSize !== (pagination as any).pageSize.value) {
pagination.setPageSize(pageSize);
}
setTimeout(fetchList, 0);
};
// ===== 初始化:加载角色选项 =====
fetchRoleOptions();
return {
filterForm,
loading,
dataSource,
columns,
pagination,
roleOptions,
hasFilter,
handleSearch,
handleReset,
+11
View File
@@ -39,6 +39,11 @@ router.beforeEach(async (to, _from, next) => {
await Promise.all([loadMenu(), loadPermissions()]);
}
if (!auth.isLoggedIn()) {
next();
return;
}
const redirectPath = getRedirectPath(to.query.redirect);
const targetPath =
redirectPath && isRoutePathAvailable(redirectPath)
@@ -63,6 +68,12 @@ router.beforeEach(async (to, _from, next) => {
const { loadPermissions } = usePermissionStore();
await Promise.all([loadMenu(), loadPermissions()]);
// 权限获取失败时会清空会话,此时回登录页而不是继续进入业务页
if (!auth.isLoggedIn()) {
next({ path: '/login', replace: true });
return;
}
// 动态路由刚注册,需要用新路由表重新匹配当前路径
next({ path: to.fullPath, replace: true });
return;
+3
View File
@@ -3,6 +3,7 @@ import type { RouteRecordRaw } from 'vue-router';
import type { MenuNode, MenuItemRaw } from '@/types';
import router, { resetRouter } from '@/router';
import { fetchMenuTree } from '@/api/menu';
import { auth } from '@/hooks/useAuth';
import { pageModules } from '@/router/glob';
import { FALLBACK_MENU_NODES } from '@/config/fallbackRoutes';
@@ -276,6 +277,8 @@ export function useMenuStore() {
state.loaded = true;
} catch (err) {
console.error('加载菜单失败:', err);
// 权限获取失败时会强制重新登录,此时不再降级兜底菜单,避免错误路由状态
if (!auth.isLoggedIn()) return;
// 降级:使用本地兜底菜单
applyMenuTree(FALLBACK_MENU_NODES);
state.loaded = true;
+2
View File
@@ -1,6 +1,7 @@
import { reactive, computed, toRefs } from 'vue';
import type { PermissionCode } from '@/types';
import { fetchAuthData } from '@/api/menu';
import { auth } from '@/hooks/useAuth';
interface PermissionState {
/** 权限编码集合 */
@@ -31,6 +32,7 @@ export function usePermissionStore() {
state.loaded = true;
} catch (err) {
console.error('加载权限失败:', err);
if (!auth.isLoggedIn()) return;
state.codes = new Set();
state.roles = [];
state.loaded = true;
+28 -2
View File
@@ -9,9 +9,32 @@ interface UserState {
token: string;
}
const USER_PROFILE_KEY = 'cpms_user_profile';
function loadUserProfile(): Pick<UserState, 'phone' | 'nickname'> {
try {
const raw = window.localStorage.getItem(USER_PROFILE_KEY);
if (!raw) return { phone: '', nickname: '' };
const data = JSON.parse(raw);
return {
phone: typeof data.phone === 'string' ? data.phone : '',
nickname: typeof data.nickname === 'string' ? data.nickname : '',
};
} catch {
window.localStorage.removeItem(USER_PROFILE_KEY);
return { phone: '', nickname: '' };
}
}
function persistUserProfile(profile: Pick<UserState, 'phone' | 'nickname'>) {
window.localStorage.setItem(USER_PROFILE_KEY, JSON.stringify(profile));
}
const savedProfile = loadUserProfile();
const state = reactive<UserState>({
phone: '',
nickname: '',
phone: savedProfile.phone,
nickname: savedProfile.nickname,
token: '',
});
@@ -20,12 +43,15 @@ export function useUserStore() {
if (user.phone !== undefined) state.phone = user.phone;
if (user.nickname !== undefined) state.nickname = user.nickname;
if (user.token !== undefined) state.token = user.token;
persistUserProfile({ phone: state.phone, nickname: state.nickname });
};
const clearUser = () => {
state.phone = '';
state.nickname = '';
state.token = '';
window.localStorage.removeItem(USER_PROFILE_KEY);
};
return {
+70
View File
@@ -131,3 +131,73 @@ export function parseAreaToCascaderPath(area: string): string[] {
if (!area) return [];
return area.split('-');
}
// ============================================================
// 城市级别选项(仅市名,加"全国")
// ============================================================
/** 城市 Select 选项 */
export interface CityOption {
value: string;
label: string;
}
/**
* 获取仅城市名的选项列表,第一项为"全国"
* 用于 Banner 弹窗中"所属区域"下拉选择
* - 直辖市:直接取市名(如"北京市")
* - 普通省份:取市名(如"深圳市")
*/
export function getCityOnlyOptions(): CityOption[] {
const options: CityOption[] = [{ value: '全国', label: '全国' }];
const provinces = areaData['86'];
if (!provinces) return options;
for (const [provinceCode, provinceName] of Object.entries(provinces)) {
if (MUNICIPALITY_CODES.includes(provinceCode)) {
options.push({ value: provinceName as string, label: provinceName as string });
} else {
const cities = areaData[provinceCode];
if (cities) {
for (const [, cityName] of Object.entries(cities)) {
options.push({ value: cityName as string, label: cityName as string });
}
}
}
}
return options;
}
/**
* 根据城市名反查 Cascader 路径数组
* "深圳市" → ["广东省", "深圳市"]
* "北京市" → ["北京市"]
* "全国" → ["全国"]
*/
export function cityNameToCascaderPath(cityName: string): string[] {
if (!cityName || cityName === '全国') return ['全国'];
const provinces = areaData['86'];
if (!provinces) return [cityName];
for (const [provinceCode, provinceName] of Object.entries(provinces)) {
const pname = provinceName as string;
if (MUNICIPALITY_CODES.includes(provinceCode)) {
if (pname === cityName) return [pname];
} else {
const cities = areaData[provinceCode];
if (cities) {
for (const [, cname] of Object.entries(cities)) {
if ((cname as string) === cityName) {
return [pname, cname as string];
}
}
}
}
}
// 兜底:找不到省份隶属,直接用市名
return [cityName];
}
+2 -4
View File
@@ -1,4 +1,4 @@
import { auth } from '@/hooks/';
import { auth, forceReLogin } from '@/hooks/';
import { message } from 'ant-design-vue';
const baseUrl = import.meta.env?.VITE_API_BASE_URL || '';
@@ -8,9 +8,7 @@ const activeControllers = new Set<AbortController>();
/** 统一处理 401 未授权逻辑 */
const handleUnauthorized = (msg?: string) => {
auth.logout();
message.error(msg || '登录状态已过期,请重新登录');
window.location.href = '/login';
forceReLogin(msg || '登录状态已过期,请重新登录');
};
const request = (