1 Commits

Author SHA1 Message Date
chenzhen 49263792bc feat: 用户列表& 操作日志联调接口 2026-08-04 16:52:25 +08:00
29 changed files with 1108 additions and 1144 deletions
+3 -3
View File
@@ -8,12 +8,12 @@ export interface TournamentAdminBannerListVO {
img: string;
title: string;
type: string;
params: string;
linkUrl: string;
area: string;
showTimeBegin: string;
showTimeEnd: string;
status: string;
createDate: string;
createTime: string;
}
/** Banner 列表查询参数 */
@@ -28,7 +28,7 @@ export interface BannerSaveParams {
img: string;
title: string;
type: string;
params: string;
linkUrl: string;
area: string;
showTimeBegin: string;
showTimeEnd: string;
+33 -33
View File
@@ -13,12 +13,12 @@ export interface MockBannerVO {
img: string;
title: string;
type: string;
params: string;
linkUrl: string;
area: string;
showTimeBegin: string;
showTimeEnd: string;
status: string;
createDate: string;
createTime: 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',
params: 'EV20260701001',
area: '深圳市',
linkUrl: '',
area: '广东省深圳市',
showTimeBegin: '2026-08-01 00:00',
showTimeEnd: '2026-08-31 23:59',
status: '1',
createDate: '2026-07-20 08:00:00',
createTime: '2026-07-20 08:00:00',
},
{
id: '2',
img: 'https://picsum.photos/seed/banner2/80/44',
title: '马拉松城市联赛限时优惠',
type: '2',
params: 'https://example.com/marathon',
area: '杭州市',
linkUrl: 'https://example.com/marathon',
area: '浙江省杭州市',
showTimeBegin: '2026-08-15 00:00',
showTimeEnd: '2026-09-15 23:59',
status: '1',
createDate: '2026-07-19 11:30:00',
createTime: '2026-07-19 11:30:00',
},
{
id: '3',
img: 'https://picsum.photos/seed/banner3/80/44',
title: '健身运动会专属福利',
type: '1',
params: 'EV20260701003',
linkUrl: '',
area: '北京市',
showTimeBegin: '2026-07-01 00:00',
showTimeEnd: '2026-07-30 23:59',
status: '1',
createDate: '2026-07-18 14:20:00',
createTime: '2026-07-18 14:20:00',
},
{
id: '4',
img: 'https://picsum.photos/seed/banner4/80/44',
title: '平台品牌宣传活动',
type: '2',
params: 'https://example.com/promo',
linkUrl: 'https://example.com/promo',
area: '上海市',
showTimeBegin: '2026-06-01 00:00',
showTimeEnd: '2026-06-30 23:59',
status: '0',
createDate: '2026-07-17 18:05:00',
createTime: '2026-07-17 18:05:00',
},
{
id: '5',
img: 'https://picsum.photos/seed/banner5/80/44',
title: '2026全国青少年羽毛球锦标赛火热报名中',
type: '1',
params: 'EV20260701005',
area: '深圳市',
linkUrl: '',
area: '广东省深圳市',
showTimeBegin: '2026-09-01 00:00',
showTimeEnd: '2026-09-30 23:59',
status: '1',
createDate: '2026-07-16 21:15:00',
createTime: '2026-07-16 21:15:00',
},
{
id: '6',
img: 'https://picsum.photos/seed/banner6/80/44',
title: '马拉松城市联赛限时优惠',
type: '3',
params: '/pages/activity/detail',
area: '杭州市',
linkUrl: '/pages/activity/detail',
area: '浙江省杭州市',
showTimeBegin: '2026-10-01 00:00',
showTimeEnd: '2026-10-31 23:59',
status: '1',
createDate: '2026-07-15 00:30:00',
createTime: '2026-07-15 00:30:00',
},
{
id: '7',
img: 'https://picsum.photos/seed/banner7/80/44',
title: '全民健身日特别活动',
type: '1',
params: 'EV20260701007',
linkUrl: '',
area: '北京市',
showTimeBegin: '2026-08-08 00:00',
showTimeEnd: '2026-08-08 23:59',
status: '1',
createDate: '2026-07-14 03:50:00',
createTime: '2026-07-14 03:50:00',
},
{
id: '8',
img: 'https://picsum.photos/seed/banner8/80/44',
title: '新春运动季大促销',
type: '2',
params: 'https://example.com/spring',
linkUrl: 'https://example.com/spring',
area: '上海市',
showTimeBegin: '2026-01-15 00:00',
showTimeEnd: '2026-02-15 23:59',
status: '0',
createDate: '2026-07-13 07:05:00',
createTime: '2026-07-13 07:05:00',
},
{
id: '9',
img: 'https://picsum.photos/seed/banner9/80/44',
title: '2026全国青少年羽毛球锦标赛火热报名中',
type: '0',
params: '',
area: '全国',
type: '1',
linkUrl: '',
area: '广东省深圳市',
showTimeBegin: '2026-08-01 00:00',
showTimeEnd: '2026-08-31 23:59',
status: '1',
createDate: '2026-07-12 10:25:00',
createTime: '2026-07-12 10:25:00',
},
{
id: '10',
img: 'https://picsum.photos/seed/banner10/80/44',
title: '平台品牌宣传活动',
type: '2',
params: 'https://example.com/brand',
area: '杭州市',
linkUrl: 'https://example.com/brand',
area: '浙江省杭州市',
showTimeBegin: '2026-11-01 00:00',
showTimeEnd: '2026-11-30 23:59',
status: '1',
createDate: '2026-07-11 13:40:00',
createTime: '2026-07-11 13:40:00',
},
{
id: '11',
img: 'https://picsum.photos/seed/banner11/80/44',
title: '秋季健身挑战赛',
type: '1',
params: 'EV20260701011',
linkUrl: '',
area: '北京市',
showTimeBegin: '2026-09-01 00:00',
showTimeEnd: '2026-09-30 23:59',
status: '1',
createDate: '2026-07-10 17:00:00',
createTime: '2026-07-10 17:00:00',
},
{
id: '12',
img: 'https://picsum.photos/seed/banner12/80/44',
title: '双十一运动装备大促',
type: '2',
params: 'https://example.com/double11',
linkUrl: 'https://example.com/double11',
area: '上海市',
showTimeBegin: '2026-11-01 00:00',
showTimeEnd: '2026-11-11 23:59',
status: '1',
createDate: '2026-07-09 20:15:00',
createTime: '2026-07-09 20:15:00',
},
];
@@ -1,4 +1,4 @@
import { defineComponent, ref, reactive, computed } from 'vue';
import { defineComponent, ref, reactive } from 'vue';
import { useEffect, useState } from '@/hooks';
import {
Modal,
@@ -12,30 +12,30 @@ import {
message,
Spin,
} from 'ant-design-vue';
import { JUMP_TYPE_OPTIONS } from '../model/useBannerModel';
import { getProvinceCityCascaderOptions, cityNameToCascaderPath } from '@/utils/areaData';
import { JUMP_TYPE_OPTIONS, EVENT_OPTIONS } from '../model/useBannerModel';
import {
getProvinceCityCascaderOptions,
parseAreaToCascaderPath,
formatCascaderArea,
} 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 areaCascaderOptions: any[] = [
{ value: '全国', label: '全国' },
...getProvinceCityCascaderOptions(),
];
/** 省市两级 Cascader 选项 */
const cascaderAreaOptions = getProvinceCityCascaderOptions();
/** 默认表单数据 */
const getDefaultForm = () => ({
title: '',
imageUrl: '',
imageStatus: 'empty' as 'empty' | 'uploading' | 'success' | 'error',
type: '1' as '1' | '2' | '3' | '0',
type: '1' as '1' | '2' | '3',
linkUrl: '',
relatedEventId: undefined as string | undefined,
area: '全国',
area: '',
displayTimeRange: null as [string, string] | null,
sort: 1,
});
@@ -47,6 +47,8 @@ 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;
@@ -70,37 +72,6 @@ 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 新增/编辑表单弹窗
*/
@@ -117,165 +88,36 @@ export default defineComponent({
const formRef = ref<any>();
const fileInputRef = ref<HTMLInputElement | null>(null);
const formData = reactive(getDefaultForm());
/** Cascader 区域的路径数组:如 ["全国"]、["广东省", "深圳市"] */
const areaPath = ref<string[]>(['全国']);
/** 是否由用户主动切换了跳转类型 */
/** Cascader 区域的路径数组(与 formData.area 字符串双向同步) */
const areaPath = ref<string[]>([]);
/** 是否由用户主动切换了跳转类型(跳过初始加载的 type 联动校验) */
const typeChanged = ref(false);
// ---- 裁剪弹窗状态 ----
/** 裁剪弹窗状态 */
const [cropperVisible, setCropperVisible] = useState(false);
/** 待裁剪图片的本地预览 URL */
const [uploadImageUrl, setUploadImageUrl] = useState('');
// ---- 赛事搜索状态 ----
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 初始化表单 ----
/** 根据 record 初始化表单 */
const initFormFromRecord = (record: any) => {
const fresh = getDefaultForm();
if (!record) {
Object.assign(formData, fresh);
areaPath.value = ['全国'];
eventKeyword.value = '';
setEventOptions([]);
areaPath.value = [];
return;
}
// 区域:兼容旧格式 "广东省-深圳市"、新格式 "深圳市"、"全国"
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([]);
}
const area = record.area || record.region || '';
areaPath.value = parseAreaToCascaderPath(area);
Object.assign(formData, {
...fresh,
title: record.title || '',
imageUrl: record.img || record.imageUrl || '',
imageStatus: record.img || record.imageUrl ? ('success' as const) : ('empty' as const),
type,
linkUrl: record.params || record.linkUrl || record.jumpUrl || '',
relatedEventId,
area: cityName,
type: String(record.type || record.jumpType || '1'),
linkUrl: record.linkUrl || record.jumpUrl || '',
relatedEventId: record.relatedEventId || undefined,
area,
displayTimeRange:
(record.showTimeBegin || record.displayStartTime) &&
(record.showTimeEnd || record.displayEndTime)
@@ -288,9 +130,10 @@ export default defineComponent({
});
};
// ---- 监听 visible 变化重置表单 ----
/** 监听 visible 变化重置表单 */
useEffect(() => {
if (props.visible) {
// 标记首次渲染,跳过 type 联动校验
typeChanged.value = false;
initFormFromRecord(props.record);
setCropperVisible(false);
@@ -299,16 +142,9 @@ export default defineComponent({
}
}, [() => props.visible]);
// ---- 清理防抖计时器 ----
useEffect(() => {
if (!props.visible && searchTimer) {
clearTimeout(searchTimer);
searchTimer = null;
}
}, [() => props.visible]);
// ---- 监听 type 变化,清理联动字段 ----
/** 监听 type 变化时清理联动字段值和校验 */
useEffect(() => {
// 首次加载跳过校验
if (!typeChanged.value) {
if (formData.type !== '1') formData.relatedEventId = undefined;
if (formData.type !== '2' && formData.type !== '3') formData.linkUrl = '';
@@ -326,28 +162,25 @@ 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;
@@ -360,17 +193,21 @@ 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';
@@ -379,26 +216,27 @@ 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) {
@@ -407,16 +245,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();
@@ -433,8 +271,15 @@ export default defineComponent({
img: formData.imageUrl || '',
title: formData.title || '',
type: formData.type,
params: buildParams(formData.type, formData.relatedEventId, formData.linkUrl),
area: formData.area,
linkUrl:
formData.type === '1'
? formData.relatedEventId || ''
: formData.type === '2'
? formData.linkUrl || ''
: formData.type === '3'
? formData.linkUrl || ''
: '',
area: formData.area || formatCascaderArea(areaPath.value),
showTimeBegin: formData.displayTimeRange?.[0] || '',
showTimeEnd: formData.displayTimeRange?.[1] || '',
sort: String(formData.sort ?? 1),
@@ -454,295 +299,246 @@ export default defineComponent({
centered
wrapClassName={styles.bannerFormModalMain}
>
{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 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.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>
)}
{/* ===== 上传图片(必填,带裁剪) ===== */}
<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>
</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">
<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>
</>
)}
</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>
</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>
{/* ===== 图片裁剪弹窗 ===== */}
@@ -759,6 +555,7 @@ export default defineComponent({
<div style={{ height: '500px' }}>
<ImageCropper
src={uploadImageUrl.value}
// aspectRatio={CROP_ASPECT_RATIO}
autoCrop={true}
autoCropArea={0.8}
viewMode={1}
+5 -5
View File
@@ -26,11 +26,11 @@ export type {
// ============================================================
// URL 常量
// ============================================================
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 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 delay = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
+20 -13
View File
@@ -11,6 +11,7 @@ import {
type TournamentAdminBannerListVO,
type BannerListQueryParams,
} from './services';
import { getProvinceCityOptions } from '@/utils/areaData';
// ============================================================
// 常量
@@ -23,17 +24,15 @@ export const BANNER_STATUS_OPTIONS = [
{ value: '0', label: '禁用' },
] as const;
/** 跳转类型选项(value 对应 API type: 0=无链接,1=赛事详情,2=自定义连接,3=小程序页面) */
/** 跳转类型选项(value 对应 API type: 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' },
@@ -45,6 +44,18 @@ 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
// ============================================================
@@ -91,9 +102,10 @@ 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: 'createDate', key: 'createDate', width: 160 },
{ title: '创建时间', dataIndex: 'createTime', key: 'createTime', width: 160 },
{ title: '状态', dataIndex: 'status', key: 'status', width: 100, align: 'center' as const },
];
@@ -171,16 +183,11 @@ export function useBannerModel() {
if (formSubmitting.value) return;
setFormSubmitting(true);
try {
const payload = {
const params = {
img: formPayload.img || formPayload.imageUrl || '',
title: formPayload.title || '',
type: String(formPayload.type || formPayload.jumpType || '1'),
params:
formPayload.params ??
formPayload.parms ??
formPayload.linkUrl ??
formPayload.jumpUrl ??
'',
linkUrl: formPayload.linkUrl || formPayload.jumpUrl || '',
area: formPayload.area || formPayload.region || '',
showTimeBegin:
formPayload.showTimeBegin ||
@@ -196,8 +203,8 @@ export function useBannerModel() {
};
const isEdit = editingRecord.value !== null;
const res = isEdit
? await updateBanner({ ...payload, id: editingRecord.value.id })
: await saveBanner(payload);
? await updateBanner({ ...params, id: editingRecord.value.id })
: await saveBanner(params);
if (res.code == 200) {
message.success(isEdit ? '编辑成功' : '新增成功');
handleCloseModal();
@@ -49,11 +49,6 @@
font-weight: 500;
}
.tableWrap {
max-height: 200px;
overflow-y: auto;
}
.paginationRow {
display: flex;
justify-content: flex-end;
@@ -70,30 +65,4 @@
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,5 +1,6 @@
import { defineComponent, reactive, ref } from 'vue';
import { Modal, Descriptions, Image, Table, Pagination, Spin } from 'ant-design-vue';
import { Modal, Descriptions, Image, Table, Pagination, Space, Spin } from 'ant-design-vue';
import { EyeOutlined } from '@ant-design/icons-vue';
import { message } from 'ant-design-vue';
import {
getEventDetail,
@@ -29,19 +30,23 @@ 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>
<div class={styles.idImageGrid}>
<Space>
{urls.map((url) => (
<div class={styles.idImageCell}>
<Image src={url} />
</div>
<Image
key={url}
width={48}
height={32}
src={url}
v-slots={{ previewMask: () => <EyeOutlined /> }}
/>
))}
</div>
</Space>
</Image.PreviewGroup>
);
};
@@ -57,18 +62,11 @@ const PLAYER_COLUMNS = [
customRender: ({ text }: { text: string }) => formatGender(text),
},
{ title: '手机号', dataIndex: 'phone', key: 'phone', width: 130 },
{
title: '证件号',
dataIndex: 'idCard',
key: 'idCard',
width: 180,
customRender: ({ record }: { record?: TournamentAdminInfoSignupVO }) =>
record?.idCard ? record.idCard : <span>-</span>,
},
{ title: '证件号', dataIndex: 'idCard', key: 'idCard', width: 180 },
{
title: '证件图片',
key: 'idCardImgs',
width: 280,
width: 180,
customRender: ({ record }: { record?: TournamentAdminInfoSignupVO }) =>
record ? renderIdImages(record.idCardImgList || [], record.idCardImg || '') : <span>-</span>,
},
@@ -223,70 +221,59 @@ export default defineComponent({
</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>
<Image src={detail.value.img} />
</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>
<div class={styles.textBlock}>{detail.value.notice}</div>
</div>
{/* ===== 组别信息 ===== */}
{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 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}
</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>
)}
</div>
</>
) : null}
</Modal>
@@ -60,7 +60,7 @@ export default defineComponent({
destroyOnClose
wrapClassName={styles.eventRegulationModalMain}
>
<Spin spinning={loading.value} size="large">
<Spin spinning={loading.value}>
<div style={{ minHeight: loading.value ? '200px' : undefined }}>
{ruleData.value ? (
<>
+5 -7
View File
@@ -19,7 +19,7 @@ import {
ID_CARD_IMAGE_OPTIONS,
} from './model/useEventListModel';
import { useEventListColumns } from './model/useEventListColumns';
import { getProvinceCityCascaderOptions } from '@/utils/areaData';
import { regionOptions } from '@/utils/areaData';
import { useContainerSize } from '@/hooks';
import EventRegulationModal from './components/EventRegulationModal';
import EventDetailModal from './components/EventDetailModal';
@@ -64,9 +64,6 @@ export default defineComponent({
const { columns } = useEventListColumns();
const { containerRef, height } = useContainerSize();
/** 省市两级 Cascader 数据(无区) */
const provinceCityOptions = getProvinceCityCascaderOptions();
/** 渲染赛事名称(超长省略 + Tooltip) */
const renderName = (text: string) => {
const maxLen = 15;
@@ -242,13 +239,14 @@ 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={provinceCityOptions}
options={regionOptions}
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,
@@ -4,6 +4,7 @@ import { useState, useDebounce } from '@/hooks';
import { useRequest } from '@/hooks/useRequest';
import { useEffect } from '@/hooks/useEffect';
import { usePagination } from '@/hooks/usePagination';
import { hasValue, isEmptyValue, safeTransform } from '@/utils';
import type { TournamentAdminVO, EventListQueryParams, PageData, ApiResult } from './services';
import { getEventList, toggleEventOnline } from './services';
import {
@@ -193,23 +194,3 @@ export function useEventListModel() {
handleToggleShelf,
};
}
function hasValue(v: any): boolean {
if (v == null) return false;
if (Array.isArray(v)) return v.length > 0 && v.some((item) => item != null);
if (typeof v === 'string') return v.trim() !== '';
return true;
}
function isEmptyValue(v: any): boolean {
return v == null || (typeof v === 'string' && v.trim() === '');
}
function safeTransform(value: any, transform?: (v: any) => any): any {
if (!transform) return value;
try {
return transform(value) ?? value;
} catch {
return value;
}
}
+25 -32
View File
@@ -10,21 +10,14 @@ import {
Tooltip,
Pagination,
} from 'ant-design-vue';
import {
useLogModel,
ACTION_TYPE_OPTIONS,
ACTION_SOURCE_OPTIONS,
ACTION_SOURCE_MAP,
} from './model/useLogModel';
import { useLogModel, ACTION_TYPE_OPTIONS, ACTION_SOURCE_OPTIONS } from './model/useLogModel';
import { useLogColumns } from './model/useLogColumns';
import { useState, useContainerSize } from '@/hooks';
import pageStyles from '@/assets/styles/pageLayout.module.less';
import './index.module.less';
const { RangePicker } = DatePicker;
// ============================================================
// LogContentCell: 操作内容单元格
// ============================================================
const LogContentCell = defineComponent({
name: 'LogContentCell',
props: { text: { type: String, required: true } },
@@ -63,15 +56,14 @@ const LogContentCell = defineComponent({
},
});
// ============================================================
// bodyCell 渲染
// ============================================================
function renderBodyCell({ column, text }: { column: any; text: any }) {
const value = text || '-';
if (column.key === 'source') return <span>{ACTION_SOURCE_MAP[text] || value}</span>;
return <span>{value}</span>;
}
/**
* 操作日志页面(纯视图层)
*
* 架构分层:
* - 数据层:services.tsAPI 调用)
* - 逻辑层:useLogModel.ts(状态 + 业务逻辑 + 交互处理)
* - 视图层:useLogColumns.ts(表格列配置)+ 本组件(纯 UI 渲染)
*/
export default defineComponent({
name: 'EventLogs',
setup() {
@@ -79,17 +71,24 @@ export default defineComponent({
filterForm,
loading,
dataSource,
columns,
pagination,
handleSearch,
handleReset,
handlePageChange,
} = useLogModel();
const { columns } = useLogColumns();
const { containerRef, height } = useContainerSize({ headerOffset: 55 });
onMounted(() => {
handleSearch();
});
/** 渲染 bodyCell */
const renderBodyCell = ({ column, text }: { column: any; text: any }) => {
if (column.key === 'content') {
const raw = text || '';
return raw ? <LogContentCell text={raw} /> : <span>-</span>;
}
// 其他列使用 columns 中的 customRender
return <span>{text || '-'}</span>;
};
return () => (
<div class={pageStyles.containerMain}>
@@ -182,21 +181,15 @@ export default defineComponent({
pagination={false}
>
{{
bodyCell: (args: any) => {
if (args.column.key === 'content') {
const raw = args.text || '';
return raw ? <LogContentCell text={raw} /> : <span>-</span>;
}
return renderBodyCell(args);
},
bodyCell: renderBodyCell,
}}
</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}
+75
View File
@@ -0,0 +1,75 @@
/**
* 操作日志页 - 配置文件
* 集中管理筛选默认值、字段映射、选项配置
*/
// ============================================================
// 筛选默认值
// ============================================================
export const FILTER_DEFAULTS = {
dateRange: null as [string, string] | null,
type: '',
source: '',
sourceNickname: '',
sourcePhone: '',
tournamentName: '',
} as const;
// ============================================================
// 字段映射配置
// 用于 buildQueryParams 函数,实现配置化参数转换
// ============================================================
type FieldMapping = [keyof typeof FILTER_DEFAULTS, string, ((v: any) => any)?];
export const FIELD_MAPPINGS: FieldMapping[] = [
['type', 'type'],
['source', 'source'],
['sourceNickname', 'sourceNickname', (v) => v?.trim?.() ?? v],
['sourcePhone', 'sourcePhone', (v) => v?.trim?.() ?? v],
['tournamentName', 'tournamentName', (v) => v?.trim?.() ?? v],
];
// 特殊字段映射(不在 FIELD_MAPPINGS 中)
export const DATE_RANGE_MAPPING = {
formKey: 'dateRange' as const,
beginKey: 'dateBegin' as const,
endKey: 'dateEnd' as const,
};
// ============================================================
// 选项配置
// ============================================================
/** 操作类型选项(type: 1=创建赛事,2=删除赛事,3=取消报名,4=编辑选手,5=对阵管理,6=批量修改组别) */
export const ACTION_TYPE_OPTIONS = [
{ value: '', label: '全部' },
{ value: '1', label: '创建赛事' },
{ value: '2', label: '删除赛事' },
{ value: '3', label: '取消报名' },
{ value: '4', label: '编辑选手' },
{ value: '5', label: '对阵管理' },
{ value: '6', label: '批量修改组别' },
] as const;
/** 操作类型文案映射 */
export const ACTION_TYPE_MAP: Record<string, string> = {
'1': '创建赛事',
'2': '删除赛事',
'3': '取消报名',
'4': '编辑选手',
'5': '对阵管理',
'6': '批量修改组别',
};
/** 操作来源选项(value 对应 API source: 1=PC2=小程序) */
export const ACTION_SOURCE_OPTIONS = [
{ value: '', label: '全部' },
{ value: '1', label: 'PC' },
{ value: '2', label: '小程序' },
] as const;
/** 操作来源文案映射 */
export const ACTION_SOURCE_MAP: Record<string, string> = {
'1': 'PC',
'2': '小程序',
};
+1 -1
View File
@@ -20,7 +20,7 @@ export type {
// ============================================================
// URL 常量
// ============================================================
const operationLogs = '/tournament/operation/page';
const operationLogs = '/admin/manager/operation/page';
// ============================================================
// API 函数
@@ -0,0 +1,77 @@
import { type VNodeChild } from 'vue';
import { ACTION_TYPE_MAP, ACTION_SOURCE_MAP } from './config';
const renderType = (text: string): string => ACTION_TYPE_MAP[text] || text || '-';
const renderSource = (text: string): string => ACTION_SOURCE_MAP[text] || text || '-';
/**
* 操作日志页 - 表格列配置(视图层)
*
* 职责:纯表格列配置,不包含业务逻辑
* 操作内容列通过 customRender 占位,在 index.tsx 中通过 bodyCell slot 渲染
*/
export function useLogColumns() {
const columns = [
{
title: '操作类型',
dataIndex: 'type',
key: 'type',
width: 140,
align: 'center' as const,
customRender: ({ text }: { text: string }): VNodeChild => renderType(text),
},
{
title: '操作来源',
dataIndex: 'source',
key: 'source',
width: 100,
align: 'center' as const,
customRender: ({ text }: { text: string }): VNodeChild => renderSource(text),
},
{
title: '操作人昵称',
dataIndex: 'nickname',
key: 'nickname',
width: 120,
align: 'center' as const,
},
{
title: '操作人手机号',
dataIndex: 'phone',
key: 'phone',
width: 140,
align: 'center' as const,
},
{
title: '赛事名称',
dataIndex: 'tournamentName',
key: 'tournamentName',
width: 200,
align: 'center' as const,
},
{
title: '操作对象',
dataIndex: 'obj',
key: 'obj',
width: 240,
align: 'center' as const,
},
{
title: '操作内容',
dataIndex: 'content',
key: 'content',
width: 360,
align: 'center' as const,
},
{
title: '操作时间',
dataIndex: 'createDate',
key: 'createDate',
width: 170,
align: 'center' as const,
},
];
return { columns };
}
+91 -94
View File
@@ -1,46 +1,44 @@
import { computed, reactive, toRef, Ref } from 'vue';
import { message } from 'ant-design-vue';
import { useState, useDebounce, useThrottleFn } from '@/hooks';
import { computed, reactive, toRef, Ref, type UnwrapRef } from 'vue';
import { useDebounce } from '@/hooks';
import { useRequest } from '@/hooks/useRequest';
import { useEffect } from '@/hooks/useEffect';
import { usePagination, type UsePaginationReturn } from '@/hooks/usePagination';
import { hasValue, isEmptyValue, safeTransform } from '@/utils';
import {
getOperationLogs,
type TournamentAdminOperationPageVO,
type OperationLogQueryParams,
type PageData,
type ApiResult,
} from './services';
import {
FILTER_DEFAULTS,
FIELD_MAPPINGS,
DATE_RANGE_MAPPING,
ACTION_TYPE_OPTIONS,
ACTION_TYPE_MAP,
ACTION_SOURCE_OPTIONS,
ACTION_SOURCE_MAP,
} from './config';
// ============================================================
// 常量
// ============================================================
export { ACTION_TYPE_OPTIONS, ACTION_TYPE_MAP, ACTION_SOURCE_OPTIONS, ACTION_SOURCE_MAP };
/** 操作类型选项(TODO: API type 字段待定意,暂时留空) */
export const ACTION_TYPE_OPTIONS = [{ value: '', label: '全部' }] as const;
/** 操作来源选项(value 对应 API source: 1=PC2=小程序) */
export const ACTION_SOURCE_OPTIONS = [
{ value: '', label: '全部' },
{ value: '1', label: 'PC' },
{ value: '2', label: '小程序' },
] as const;
/** 操作来源文案映射 */
export const ACTION_SOURCE_MAP: Record<string, string> = {
'1': 'PC',
'2': '小程序',
};
// ============================================================
// Model
// ============================================================
// 提取 Pagination Ref 类型
type PaginationRef = UnwrapRef<UsePaginationReturn>;
type PageSizeRef = PaginationRef['pageSize'];
/**
* 操作日志页数据模型
* 职责:状态管理 + 数据获取 + 业务逻辑 + 交互处理
*
* 架构分层:
* - 数据层:services.tsAPI 调用)
* - 逻辑层:本文件(状态 + 业务逻辑)
* - 视图层:useLogColumns.ts + index.tsx(纯 UI 渲染)
*/
export function useLogModel() {
// ===== 筛选条件(key 名对齐 API 查询参数) =====
const filterForm = reactive({
dateRange: null as [string, string] | null,
type: '',
source: '',
sourceNickname: '',
sourcePhone: '',
tournamentName: '',
});
const filterForm = reactive({ ...FILTER_DEFAULTS });
const pagination = usePagination({ defaultCurrent: 1, defaultPageSize: 10 });
const { debouncedValue: debouncedNickname } = useDebounce(
toRef(filterForm, 'sourceNickname') as Ref<string>,
@@ -55,42 +53,59 @@ export function useLogModel() {
{ delay: 300 },
);
// ===== 表格状态 =====
const [loading, setLoading] = useState<boolean>(false);
const [dataSource, setDataSource] = useState<TournamentAdminOperationPageVO[]>([]);
const [pagination, setPagination] = useState({ current: 1, pageSize: 10, total: 0 });
// ===== 表格列配置(dataIndex 对齐 TournamentAdminOperationPageVO =====
const columns = [
{ title: '操作类型', dataIndex: 'opName', key: 'opName', width: 140 },
{ title: '操作来源', dataIndex: 'source', key: 'source', width: 100 },
{ title: '操作人昵称', dataIndex: 'nickname', key: 'nickname', width: 120 },
{ title: '操作人手机号', dataIndex: 'phone', key: 'phone', width: 140 },
{ title: '赛事名称', dataIndex: 'tournamentName', key: 'tournamentName', width: 200 },
{ title: '操作对象', dataIndex: 'obj', key: 'obj', width: 240 },
{ title: '操作内容', dataIndex: 'content', key: 'content', width: 360 },
{ title: '操作时间', dataIndex: 'createDate', key: 'createDate', width: 170 },
];
// ===== 构建 API 查询参数 =====
const buildQueryParams = (): OperationLogQueryParams => {
const params: OperationLogQueryParams = {
page: String(pagination.value.current),
limit: String(pagination.value.pageSize),
};
if (filterForm.dateRange) {
params.dateBegin = filterForm.dateRange[0];
params.dateEnd = filterForm.dateRange[1];
const { page, pageSize } = pagination.params.value;
// 基础参数
const params = FIELD_MAPPINGS.reduce(
(acc, [formKey, paramKey, transform]) => {
const rawValue = filterForm[formKey];
if (!hasValue(rawValue)) return acc;
const value = safeTransform(rawValue, transform);
if (isEmptyValue(value)) return acc;
return { ...acc, [paramKey]: value };
},
{} as Record<string, any>,
);
// 时间范围特殊处理
const dateRange = filterForm[DATE_RANGE_MAPPING.formKey];
if (Array.isArray(dateRange) && dateRange.length === 2) {
params[DATE_RANGE_MAPPING.beginKey] = dateRange[0];
params[DATE_RANGE_MAPPING.endKey] = dateRange[1];
}
if (filterForm.type) params.type = filterForm.type;
if (filterForm.source) params.source = filterForm.source;
if (filterForm.sourceNickname.trim()) params.sourceNickname = filterForm.sourceNickname.trim();
if (filterForm.sourcePhone.trim()) params.sourcePhone = filterForm.sourcePhone.trim();
if (filterForm.tournamentName.trim()) params.tournamentName = filterForm.tournamentName.trim();
return params;
return { page: String(page), limit: String(pageSize), ...params } as OperationLogQueryParams;
};
// ===== 计算属性 =====
const {
data,
loading,
run: fetchList,
} = useRequest<ApiResult<PageData<TournamentAdminOperationPageVO>>>(
() => getOperationLogs(buildQueryParams()),
{
refreshDeps: [],
formatResult: (res) => res,
},
);
// 提取列表数据
const listData = computed<PageData<TournamentAdminOperationPageVO> | undefined>(() => {
const res = data.value;
return res ? (res as any).data : undefined;
});
const dataSource = computed(() => listData.value?.list || []);
// 同步分页总条数
useEffect(() => {
const total = (data.value as any)?.data?.total;
if (total !== undefined) pagination.setTotal(total);
}, [data]);
const hasFilter = computed(() => {
return (
filterForm.dateRange !== null ||
@@ -102,49 +117,31 @@ export function useLogModel() {
);
});
// ===== 方法 =====
const handleSearch = () => fetchList();
const handleSearch = useThrottleFn(async () => {
setLoading(true);
try {
const params = buildQueryParams();
console.log('操作日志查询参数:', params);
const res = await getOperationLogs(params);
if (res.code == 200) {
setDataSource(res.data.list);
setPagination({ ...pagination.value, total: res.data.total });
} else {
message.error(res.msg || '查询失败');
}
} catch (e: any) {
console.error('操作日志查询失败:', e);
} finally {
setLoading(false);
}
}, 500);
const handleReset = useThrottleFn(() => {
const handleReset = () => {
filterForm.dateRange = null;
filterForm.type = '';
filterForm.source = '';
filterForm.sourceNickname = '';
filterForm.sourcePhone = '';
filterForm.tournamentName = '';
setPagination({ current: 1, pageSize: 10, total: 0 });
setDataSource([]);
setTimeout(() => handleSearch(), 350);
}, 500);
pagination.reset();
setTimeout(() => fetchList(), 0);
};
const handlePageChange = (page: number, pageSize: number) => {
setPagination({ ...pagination.value, current: page, pageSize });
handleSearch();
pagination.setCurrent(page);
if (pageSize !== (pagination.pageSize as PageSizeRef)) {
pagination.setPageSize(pageSize);
}
fetchList();
};
return {
filterForm,
loading,
dataSource,
columns,
pagination,
hasFilter,
handleSearch,
@@ -79,17 +79,6 @@
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;
@@ -192,8 +181,8 @@
}
}
// .orderModalWrap {
// :global(.ant-modal-body) {
// padding-top: 16px;
// }
// }
.orderModalWrap {
:global(.ant-modal-body) {
padding-top: 16px;
}
}
@@ -255,18 +255,16 @@ export default defineComponent({
<div class={styles.section}>
<div class={styles.sectionTitle}></div>
<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}>
<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 }}>
<Pagination
current={playerPage.value}
pageSize={playerPageSize}
+1 -1
View File
@@ -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)}>
+2 -21
View File
@@ -1,10 +1,11 @@
import { computed, reactive, toRef, Ref, h } from 'vue';
import Big from 'big.js';
import { StatusTag, type StatusTagTone } from '@/components';
import { useState, useDebounce } from '@/hooks';
import { useDebounce } from '@/hooks';
import { useRequest } from '@/hooks/useRequest';
import { useEffect } from '@/hooks/useEffect';
import { usePagination } from '@/hooks/usePagination';
import { hasValue, isEmptyValue, safeTransform } from '@/utils';
import type {
TournamentAdminOrderPageVO,
OrderListQueryParams,
@@ -239,23 +240,3 @@ export function useOrderModel() {
handlePageChange,
};
}
function hasValue(v: any): boolean {
if (v == null) return false;
if (Array.isArray(v)) return v.length > 0 && v.some((item) => item != null);
if (typeof v === 'string') return v.trim() !== '';
return true;
}
function isEmptyValue(v: any): boolean {
return v == null || (typeof v === 'string' && v.trim() === '');
}
function safeTransform(value: any, transform?: (v: any) => any): any {
if (!transform) return value;
try {
return transform(value) ?? value;
} catch {
return value;
}
}
+38 -67
View File
@@ -1,20 +1,18 @@
import { defineComponent, onMounted } from 'vue';
import {
Button,
Input,
Table,
Form,
Space,
Select,
Pagination,
Modal,
message,
} from 'ant-design-vue';
import { defineComponent } from 'vue';
import { Button, Input, Table, Form, Space, Select, Pagination } from 'ant-design-vue';
import { useUserModel, USER_STATUS_OPTIONS } from './model/useUserModel';
import { toggleUserActive } from './model/services';
import { useUserColumns } from './model/useUserColumns';
import { useContainerSize } from '@/hooks';
import pageStyles from '@/assets/styles/pageLayout.module.less';
/**
* 用户列表页面(纯视图层)
*
* 架构分层:
* - 数据层:services.tsAPI 调用)
* - 逻辑层:useUserModel.ts(状态 + 业务逻辑 + 交互处理)
* - 视图层:useUserColumns.ts(表格列配置)+ 本组件(纯 UI 渲染)
*/
export default defineComponent({
name: 'EventUsers',
setup() {
@@ -22,36 +20,33 @@ export default defineComponent({
filterForm,
loading,
dataSource,
columns,
pagination,
togglingId,
handleSearch,
handleReset,
handlePageChange,
handleToggleStatus,
} = useUserModel();
const { columns } = useUserColumns();
const { containerRef, height } = useContainerSize();
const handleToggleStatus = (record: any) => {
/** 渲染操作列 */
const renderAction = (record: any) => {
const isActive = record.status === '1';
const actionText = isActive ? '停用' : '启用';
Modal.confirm({
title: `${actionText}确认`,
content: `确认${actionText}用户"${record.realName}"吗?`,
okText: '确认',
cancelText: '取消',
onOk: async () => {
try {
const res = await toggleUserActive({ userId: record.id, status: isActive ? '0' : '1' });
if (res.code == 200) {
message.success(`${actionText}`);
handleSearch();
} else message.error(res.msg || '操作失败');
} catch (e: any) {
console.error(`用户${actionText}失败:`, e);
}
},
});
return (
<Button
type="link"
size="small"
loading={togglingId.value === record.id}
onClick={() => handleToggleStatus(record)}
>
{isActive ? '停用' : '启用'}
</Button>
);
};
/** 完整表格列 */
const tableColumns = [
...columns,
{
@@ -63,33 +58,17 @@ export default defineComponent({
},
];
onMounted(() => {
handleSearch();
});
return () => (
<div class={pageStyles.containerMain}>
<div class={pageStyles.filter}>
<Form style={{ rowGap: '10px' }} layout="inline" model={filterForm}>
<Form.Item label="昵称" name="nickname">
<Form.Item label="用户姓名/手机号" name="text">
<Input
value={filterForm.nickname}
placeholder="请输入昵称"
style={{ width: '180px' }}
value={filterForm.text}
placeholder="请输入"
style={{ width: '200px' }}
allowClear
onUpdate:value={(val: any) =>
(filterForm.nickname = val.target?.value ?? val ?? '')
}
onPressEnter={handleSearch}
/>
</Form.Item>
<Form.Item label="手机号" name="phone">
<Input
value={filterForm.phone}
placeholder="请输入手机号"
style={{ width: '180px' }}
allowClear
onUpdate:value={(val: any) => (filterForm.phone = val.target?.value ?? val ?? '')}
onUpdate:value={(val: any) => (filterForm.text = val.target?.value ?? val ?? '')}
onPressEnter={handleSearch}
/>
</Form.Item>
@@ -112,6 +91,7 @@ export default defineComponent({
</Form.Item>
</Form>
</div>
<div class={pageStyles.table}>
<div ref={containerRef} class={pageStyles.tableBody}>
<Table
@@ -124,16 +104,7 @@ export default defineComponent({
{{
bodyCell: (args: any) => {
if (args.column.key === 'action') {
const isActive = args.record.status === '1';
return (
<Button
type="link"
size="small"
onClick={() => handleToggleStatus(args.record)}
>
{isActive ? '停用' : '启用'}
</Button>
);
return renderAction(args.record);
}
},
}}
@@ -141,9 +112,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}
+53
View File
@@ -0,0 +1,53 @@
/**
* 用户列表页 - 配置文件
* 集中管理筛选默认值、字段映射、选项配置
*/
// ============================================================
// 筛选默认值
// ============================================================
export const FILTER_DEFAULTS = {
text: '',
roleId: '',
status: '',
} as const;
// ============================================================
// 字段映射配置
// 用于 buildQueryParams 函数,实现配置化参数转换
// ============================================================
type FieldMapping = [keyof typeof FILTER_DEFAULTS, string, ((v: any) => any)?];
export const FIELD_MAPPINGS: FieldMapping[] = [
['text', 'text', (v) => v?.trim?.() ?? v],
['roleId', 'roleId'],
['status', 'status'],
];
// ============================================================
// 选项配置
// ============================================================
export const USER_STATUS_OPTIONS = [
{ value: '', label: '全部' },
{ value: '0', label: '停用' },
{ value: '1', label: '正常' },
] as const;
/** 角色选项(TODO: 对接角色 API) */
export const ROLE_OPTIONS = [
{ value: '', label: '全部' },
{ value: '1', label: '超级管理员' },
{ value: '2', label: '赛事管理员' },
{ value: '3', label: '裁判' },
{ value: '4', label: '财务' },
] as const;
// ============================================================
// 状态映射(用于 StatusTag 渲染)
// ============================================================
import type { StatusTagTone } from '@/components';
export const STATUS_MAP: Record<string, { label: string; tone: StatusTagTone }> = {
'1': { label: '正常', tone: 'success' },
'0': { label: '停用', tone: 'danger' },
};
+30 -49
View File
@@ -1,10 +1,24 @@
/**
* 赛事用户列表 - 接口服务
* 接口文档: 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';
export type {
TournamentAdminUserVO,
UserListQueryParams,
UserSaveParams,
UserUpdateParams,
UserActiveParams,
@@ -13,53 +27,14 @@ import type {
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 {
UserSaveParams,
UserUpdateParams,
UserActiveParams,
UserUpdatePwdParams,
PageData,
ApiResult,
};
// ============================================================
// URL 常量
// ============================================================
const userList = '/admin/manager/user/page';
const userSave = '/sys/user/save';
const userUpdate = '/sys/user/update';
const userActive = '/sys/user/active';
const userUpdatePwd = '/sys/user/updatepwd';
const userSave = '/admin/sys/user/save';
const userUpdate = '/admin/sys/user/update';
const userActive = '/admin/manager/user/active';
const userUpdatePwd = '/admin/sys/user/updatepwd';
const delay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
@@ -67,10 +42,16 @@ const delay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
// API 函数
// ============================================================
/** 赛事用户分页列表 */
export function getUserList(
params: EventUserListQueryParams,
): Promise<ApiResult<PageData<EventUserVO>>> {
/** 用户分页列表 */
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) };
}
return get(userList, params as Record<string, any>);
}
@@ -0,0 +1,107 @@
import { h, type VNodeChild } from 'vue';
import { Image, Space } from 'ant-design-vue';
import type { ImageProps } from 'ant-design-vue/es/image';
import { StatusTag } from '@/components';
import { STATUS_MAP } from './config';
// 性别映射
const GENDER_MAP: Record<string, string> = {
'1': '男',
'2': '女',
};
/**
* 用户列表页 - 表格列配置(视图层)
*
* 职责:纯表格列配置,不包含业务逻辑
* 操作列通过 customRender 占位,在 index.tsx 中通过 bodyCell slot 渲染
*/
export function useUserColumns() {
const columns = [
{
title: '用户昵称',
dataIndex: 'nickname',
key: 'nickname',
width: 140,
align: 'center' as const,
},
{
title: '用户姓名',
dataIndex: 'realName',
key: 'realName',
width: 110,
align: 'center' as const,
},
{
title: '手机号',
dataIndex: 'phone',
key: 'phone',
width: 140,
align: 'center' as const,
},
{
title: '性别',
dataIndex: 'gender',
key: 'gender',
width: 80,
align: 'center' as const,
customRender: ({ text }: { text: string }) => GENDER_MAP[text] || '',
},
{
title: '证件号',
dataIndex: 'idCard',
key: 'idCard',
width: 120,
align: 'center' as const,
},
{
title: '证件图片',
dataIndex: 'idCardImgList',
key: 'idCardImgList',
width: 120,
align: 'center' as const,
customRender: ({ text }: { text: string[] }): VNodeChild => {
const imgList = Array.isArray(text) ? text : [];
if (imgList.length === 0) return '-';
return h(
Space,
{ size: 4 },
{
default: () =>
imgList.map((url, index) =>
h<ImageProps>(Image, {
key: index,
src: url,
alt: `证件图${index + 1}`,
width: 40,
height: 40,
style: { objectFit: 'cover', borderRadius: '4px', cursor: 'pointer' },
preview: true,
}),
),
},
);
},
},
{
title: '创建时间',
dataIndex: 'createTime',
key: 'createTime',
width: 170,
align: 'center' as const,
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 90,
align: 'center' as const,
customRender: ({ text }: { text: string }) => {
const info = STATUS_MAP[text] || { label: text, tone: 'default' as const };
return h(StatusTag, { label: info.label, tone: info.tone });
},
},
];
return { columns };
}
+140 -96
View File
@@ -1,124 +1,168 @@
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 { getUserList, type TournamentAdminUserVO, type UserListQueryParams } from './services';
import { computed, reactive, toRef, Ref, type UnwrapRef } from 'vue';
import { Modal, message } from 'ant-design-vue';
import { useState, useDebounce } from '@/hooks';
import { useRequest } from '@/hooks/useRequest';
import { useEffect } from '@/hooks/useEffect';
import { usePagination, type UsePaginationReturn } from '@/hooks/usePagination';
import { hasValue, isEmptyValue, safeTransform } from '@/utils';
import {
getUserList,
toggleUserActive,
type TournamentAdminUserVO,
type UserListQueryParams,
type PageData,
type ApiResult,
} from './services';
import {
FILTER_DEFAULTS,
FIELD_MAPPINGS,
USER_STATUS_OPTIONS,
ROLE_OPTIONS,
STATUS_MAP,
} from './config';
// ============================================================
// 常量
// ============================================================
export { USER_STATUS_OPTIONS, ROLE_OPTIONS, STATUS_MAP };
export const USER_STATUS_OPTIONS = [
{ value: '', label: '全部' },
{ value: '0', label: '停用' },
{ value: '1', label: '正常' },
] as const;
const STATUS_MAP: Record<string, { label: string; tone: StatusTagTone }> = {
'1': { label: '正常', tone: 'success' },
'0': { label: '停用', tone: 'danger' },
};
// ============================================================
// Model
// ============================================================
// 提取 Pagination Ref 类型
type PaginationRef = UnwrapRef<UsePaginationReturn>;
type PageSizeRef = PaginationRef['pageSize'];
/**
* 用户列表页数据模型
* 职责:状态管理 + 数据获取 + 业务逻辑 + 交互处理
*
* 架构分层:
* - 数据层:services.tsAPI 调用)
* - 逻辑层:本文件(状态 + 业务逻辑)
* - 视图层:useUserColumns.ts + index.tsx(纯 UI 渲染)
*/
export function useUserModel() {
const filterForm = reactive({
nickname: '',
phone: '',
status: '',
// ===== 筛选表单状态 =====
const filterForm = reactive({ ...FILTER_DEFAULTS });
// ===== 弹窗状态 =====
const [togglingId, setTogglingId] = useState('');
// ===== 分页管理 =====
const pagination = usePagination({ defaultCurrent: 1, defaultPageSize: 10 });
// ===== 防抖处理 =====
const { debouncedValue: debouncedText } = useDebounce(toRef(filterForm, 'text') as Ref<string>, {
delay: 300,
});
const { debouncedValue: debouncedNickname } = useDebounce(
toRef(filterForm, 'nickname') as Ref<string>,
{ delay: 300 },
);
const { debouncedValue: debouncedPhone } = useDebounce(
toRef(filterForm, 'phone') 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 columns = [
{ title: '昵称', dataIndex: 'nickname', key: 'nickname', width: 120 },
{ title: '用户姓名', dataIndex: 'realName', key: 'realName', width: 120 },
{ title: '手机号', dataIndex: 'phone', key: 'phone', width: 140 },
{ title: '证件号', dataIndex: 'idCard', key: 'idCard', width: 180 },
{ title: '创建时间', dataIndex: 'createTime', key: 'createTime', width: 170 },
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 90,
align: 'center' as const,
customRender: ({ text }: { text: any }) => {
const key = String(text ?? '');
const info = STATUS_MAP[key] || { label: key || '-', tone: 'default' as StatusTagTone };
return h(StatusTag, { label: info.label, tone: info.tone });
},
},
];
// ===== 构建查询参数 =====
const buildQueryParams = (): UserListQueryParams => {
const params: UserListQueryParams = {
page: String(pagination.value.current),
limit: String(pagination.value.pageSize),
};
const nickname = debouncedNickname.value?.trim();
if (nickname) params.nickname = nickname;
const phone = debouncedPhone.value?.trim();
if (phone) params.phone = phone;
if (filterForm.status) params.status = filterForm.status;
return params;
const { page, pageSize } = pagination.params.value;
const params = FIELD_MAPPINGS.reduce(
(acc, [formKey, paramKey, transform]) => {
const rawValue = filterForm[formKey];
if (!hasValue(rawValue)) return acc;
const value = safeTransform(rawValue, transform);
if (isEmptyValue(value)) return acc;
return { ...acc, [paramKey]: value };
},
{} as Record<string, any>,
);
return { page: String(page), limit: String(pageSize), ...params } as UserListQueryParams;
};
const hasFilter = computed(
() => !!filterForm.nickname || !!filterForm.phone || !!filterForm.status,
// ===== 数据请求 =====
const {
data,
loading,
run: fetchList,
} = useRequest<ApiResult<PageData<TournamentAdminUserVO>>>(
() => getUserList(buildQueryParams()),
{
refreshDeps: [],
formatResult: (res) => res,
},
);
const handleSearch = useThrottleFn(async () => {
setLoading(true);
try {
const params = buildQueryParams();
const res = await getUserList(params);
if (res.code == 200) {
setDataSource(res.data.list);
setPagination({ ...pagination.value, total: res.data.total });
} else message.error(res.msg || '查询失败');
} catch (e: any) {
console.error('用户列表查询失败:', e);
} finally {
setLoading(false);
}
}, 500);
// 提取列表数据
const listData = computed<PageData<TournamentAdminUserVO> | undefined>(() => {
const res = data.value;
return res ? (res as any).data : undefined;
});
const handleReset = useThrottleFn(() => {
filterForm.nickname = '';
filterForm.phone = '';
const dataSource = computed(() => listData.value?.list || []);
// 同步分页总条数
useEffect(() => {
const total = (data.value as any)?.data?.total;
if (total !== undefined) pagination.setTotal(total);
}, [data]);
// ===== 计算属性 =====
const hasFilter = computed(() => {
const f = filterForm;
return !!debouncedText.value?.trim() || !!f.roleId || !!f.status;
});
// ===== 事件处理 =====
const handleSearch = () => fetchList();
const handleReset = () => {
filterForm.text = '';
filterForm.roleId = '';
filterForm.status = '';
setPagination({ current: 1, pageSize: 10, total: 0 });
setDataSource([]);
setTimeout(() => handleSearch(), 350);
}, 500);
pagination.reset();
setTimeout(() => fetchList(), 0);
};
const handlePageChange = (page: number, pageSize: number) => {
setPagination({ ...pagination.value, current: page, pageSize });
handleSearch();
pagination.setCurrent(page);
if (pageSize !== (pagination.pageSize as PageSizeRef)) {
pagination.setPageSize(pageSize);
}
fetchList();
};
// ===== 启用/停用用户 =====
const handleToggleStatus = async (record: TournamentAdminUserVO) => {
const isActive = record.status === '1';
const actionText = isActive ? '停用' : '启用';
Modal.confirm({
title: `${actionText}确认`,
content: `确认${actionText}用户"${record.realName}"吗?`,
okText: '确认',
cancelText: '取消',
onOk: async () => {
setTogglingId(record.id);
try {
const res = await toggleUserActive({ userId: record.id, status: isActive ? '0' : '1' });
if (res.code == 200) {
message.success(`${actionText}成功`);
fetchList();
} else {
message.error(res.msg || '操作失败');
}
} catch (e: any) {
console.error(`用户${actionText}失败:`, e);
message.error(`用户${actionText}失败`);
} finally {
setTogglingId('');
}
},
});
};
return {
filterForm,
loading,
dataSource,
columns,
pagination,
togglingId,
hasFilter,
handleSearch,
handleReset,
handlePageChange,
handleToggleStatus,
};
}
+2 -2
View File
@@ -27,8 +27,8 @@ export type {
// ============================================================
// URL 常量
// ============================================================
const walletList = '/admin/finance/wallet/summary';
const walletTransaction = '/admin/finance/wallet/page';
const walletList = '/finance/wallet/summary';
const walletTransaction = '/finance/wallet/page';
const delay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
+3 -3
View File
@@ -25,9 +25,9 @@ export type {
// ============================================================
// URL 常量
// ============================================================
const withdrawList = '/admin/finance/withdraw/page';
const withdrawInfo = '/admin/finance/withdraw/info';
const withdrawAudit = '/admin/finance/withdraw/audit';
const withdrawList = '/finance/withdraw/page';
const withdrawInfo = '/finance/withdraw/info';
const withdrawAudit = '/finance/withdraw/audit';
const delay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
-70
View File
@@ -131,73 +131,3 @@ 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];
}
+29
View File
@@ -2,6 +2,35 @@
* 通用工具函数
*/
/**
* 判断值是否有意义(非空、非空字符串、非空数组)
*/
export function hasValue(v: unknown): boolean {
if (v == null) return false;
if (Array.isArray(v)) return v.length > 0 && v.some((item) => item != null);
if (typeof v === 'string') return v.trim() !== '';
return true;
}
/**
* 判断值是否为空(null、undefined、空字符串)
*/
export function isEmptyValue(v: unknown): boolean {
return v == null || (typeof v === 'string' && v.trim() === '');
}
/**
* 安全转换值(带异常处理和默认值)
*/
export function safeTransform<T>(value: T, transform?: (v: T) => unknown): T | unknown {
if (!transform) return value;
try {
return transform(value) ?? value;
} catch {
return value;
}
}
/**
* 生成唯一 ID
*/