Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1251a0be83 | |||
| 7cc56af0d5 | |||
| bf3f380ba8 | |||
| 0a1dcb3977 | |||
| 16f7ddfb0d | |||
| 9877999995 | |||
| 46a2deda87 | |||
| 256ba7bd0a | |||
| 3f621a75ad |
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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,7 +38,7 @@ 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 — 新增用户 */
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
type TournamentAdminBannerListVO,
|
||||
type BannerListQueryParams,
|
||||
} from './services';
|
||||
import { getProvinceCityOptions } from '@/utils/areaData';
|
||||
|
||||
// ============================================================
|
||||
// 常量
|
||||
@@ -24,15 +23,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 +45,6 @@ const STATUS_MAP: Record<string, { label: string; tone: StatusTagTone; dimmed?:
|
||||
'0': { label: '已禁用', tone: 'default', dimmed: true },
|
||||
};
|
||||
|
||||
/** 关联赛事选项(mock,TODO: 替换为真实 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
|
||||
// ============================================================
|
||||
@@ -102,10 +91,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 },
|
||||
];
|
||||
|
||||
@@ -183,11 +171,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 +196,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();
|
||||
|
||||
@@ -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 ? (
|
||||
<>
|
||||
|
||||
@@ -19,7 +19,7 @@ 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 EventRegulationModal from './components/EventRegulationModal';
|
||||
import EventDetailModal from './components/EventDetailModal';
|
||||
@@ -64,6 +64,9 @@ export default defineComponent({
|
||||
const { columns } = useEventListColumns();
|
||||
const { containerRef, height } = useContainerSize();
|
||||
|
||||
/** 省市两级 Cascader 数据(无区) */
|
||||
const provinceCityOptions = getProvinceCityCascaderOptions();
|
||||
|
||||
/** 渲染赛事名称(超长省略 + Tooltip) */
|
||||
const renderName = (text: string) => {
|
||||
const maxLen = 15;
|
||||
@@ -239,14 +242,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,
|
||||
|
||||
@@ -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;
|
||||
// }
|
||||
// }
|
||||
|
||||
@@ -174,15 +174,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 +257,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}
|
||||
|
||||
@@ -82,7 +82,7 @@ function renderBodyCell({
|
||||
if (column.key === 'action') {
|
||||
// 退款状态:1=无退款,2=退款成功,3=退款失败,-1=其他
|
||||
// 只有退款失败(3)时显示重新退款按钮
|
||||
const showReRefund = record.refundStatus === 3;
|
||||
const showReRefund = record.refundStatus === '3';
|
||||
return (
|
||||
<Space>
|
||||
<Button type="link" size="small" onClick={() => onView(record)}>
|
||||
|
||||
@@ -62,13 +62,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>
|
||||
|
||||
@@ -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>);
|
||||
}
|
||||
|
||||
|
||||
@@ -83,11 +83,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}>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -41,7 +41,7 @@ export function usePaymentsModel() {
|
||||
);
|
||||
|
||||
// ===== 汇总 =====
|
||||
const [summary, setSummary] = useState({ totalPay: '0.00', totalRefund: '0.00' });
|
||||
const [summary, setSummary] = useState({ totalAmount: '0.00', totalRefundAmount: '0.00' });
|
||||
|
||||
// ===== 表格状态 =====
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
@@ -104,21 +104,14 @@ export function usePaymentsModel() {
|
||||
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) });
|
||||
setSummary({
|
||||
totalAmount: res.data.exData?.totalAmount ?? '0.00',
|
||||
totalRefundAmount: res.data.exData?.totalRefundAmount ?? '0.00',
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// 网络层已统一提示
|
||||
@@ -134,7 +127,7 @@ export function usePaymentsModel() {
|
||||
filterForm.type = '';
|
||||
setPagination({ current: 1, pageSize: 10, total: 0 });
|
||||
setDataSource([]);
|
||||
setSummary({ totalPay: '0.00', totalRefund: '0.00' });
|
||||
setSummary({ totalAmount: '0.00', totalRefundAmount: '0.00' });
|
||||
setTimeout(() => handleSearch(), 350);
|
||||
}, 500);
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { defineComponent, onMounted } 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 WithdrawDetailModal from './components/WithdrawDetailModal';
|
||||
import pageStyles from '@/assets/styles/pageLayout.module.less';
|
||||
@@ -21,10 +21,10 @@ function renderBodyCell({
|
||||
onView: (record: any) => void;
|
||||
}) {
|
||||
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(待审核)→ 显示「审核」,其他 → 显示「查看」
|
||||
|
||||
@@ -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,4 +1,5 @@
|
||||
import { computed, reactive, toRef, Ref } from 'vue';
|
||||
import { computed, reactive, toRef, Ref, h } from 'vue';
|
||||
import { StatusTag, type StatusTagTone } from '@/components';
|
||||
import { useState, useDebounce, useThrottleFn } from '@/hooks';
|
||||
import { getWithdrawList, type WithdrawVO, type WithdrawQueryParams } from './services';
|
||||
|
||||
@@ -22,20 +23,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
|
||||
// ============================================================
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import { message } from 'ant-design-vue';
|
||||
import { useState, useDebounce, useThrottleFn } from '@/hooks';
|
||||
import {
|
||||
getRoleList,
|
||||
saveRole,
|
||||
updateRole,
|
||||
deleteRole,
|
||||
type TournamentAdminRolePageVO,
|
||||
type RoleListQueryParams,
|
||||
@@ -125,11 +127,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();
|
||||
handleSearch();
|
||||
} else {
|
||||
message.error(res.msg || '操作失败');
|
||||
}
|
||||
} catch {
|
||||
// 网络层已统一提示,不再重复 message.error
|
||||
} finally {
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* 该页面使用的所有接口集中管理
|
||||
*/
|
||||
import { get, post } from '@/utils/request';
|
||||
import { fetchPermissions } from '@/api/menu';
|
||||
import type {
|
||||
UserListQueryParams,
|
||||
UserSaveParams,
|
||||
@@ -29,6 +28,7 @@ export type {
|
||||
// ============================================================
|
||||
// URL 常量
|
||||
// ============================================================
|
||||
const userPage = '/admin/sys/user/page';
|
||||
const userSave = '/sys/user/save';
|
||||
const userUpdate = '/sys/user/update';
|
||||
const userActive = '/sys/user/active';
|
||||
@@ -39,47 +39,14 @@ const userUpdatePwd = '/sys/user/updatepwd';
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 用户列表 - 通过 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>);
|
||||
}
|
||||
|
||||
/** 新增用户 */
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user