From 3b4dd72b116694980d889bc975dd455e9d1fa157 Mon Sep 17 00:00:00 2001 From: ZhuRui Date: Sat, 25 Jul 2026 09:39:56 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0banner=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=20=E6=B7=BB=E5=8A=A0=E8=8F=9C=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pnpm-lock.yaml | 11 + src/api/upload.ts | 27 + src/assets/styles/index.less | 7 + src/config/fallbackRoutes.ts | 58 +++ src/layouts/BasicLayout.tsx | 2 + .../components/BannerFormModal.module.less | 113 +++++ .../banner/components/BannerFormModal.tsx | 468 ++++++++++++++++++ src/pages/events/banner/index.module.less | 20 +- src/pages/events/banner/index.tsx | 280 +++++++++-- .../events/banner/model/useBannerModel.ts | 228 ++++++++- .../components/OrderDetailModal.module.less | 196 ++++++++ .../orders/components/OrderDetailModal.tsx | 270 ++++++++++ src/pages/events/orders/index.module.less | 41 +- src/pages/events/orders/index.tsx | 230 ++++++++- .../events/orders/model/useOrderModel.ts | 288 ++++++++++- src/pages/events/users/index.module.less | 24 +- src/pages/events/users/index.tsx | 147 +++++- src/pages/events/users/model/useUserModel.ts | 97 +++- src/pages/finance/payments/index.tsx | 15 + src/pages/finance/reports/index.tsx | 15 + src/pages/finance/wallet/index.tsx | 15 + src/pages/finance/withdraw/index.tsx | 15 + src/pages/system/roles/index.tsx | 15 + src/pages/system/users/index.tsx | 15 + src/utils/oss.ts | 121 +++++ 25 files changed, 2561 insertions(+), 157 deletions(-) create mode 100644 src/api/upload.ts create mode 100644 src/pages/events/banner/components/BannerFormModal.module.less create mode 100644 src/pages/events/banner/components/BannerFormModal.tsx create mode 100644 src/pages/events/orders/components/OrderDetailModal.module.less create mode 100644 src/pages/events/orders/components/OrderDetailModal.tsx create mode 100644 src/pages/finance/payments/index.tsx create mode 100644 src/pages/finance/reports/index.tsx create mode 100644 src/pages/finance/wallet/index.tsx create mode 100644 src/pages/finance/withdraw/index.tsx create mode 100644 src/pages/system/roles/index.tsx create mode 100644 src/pages/system/users/index.tsx create mode 100644 src/utils/oss.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6be95c1..4d7894f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,12 @@ importers: ant-design-vue: specifier: ^4.0.0 version: 4.2.6(vue@3.5.40(typescript@5.9.3)) + china-area-data: + specifier: ^5.0.1 + version: 5.0.1 + dayjs: + specifier: ^1.11.21 + version: 1.11.21 express: specifier: ^5.2.1 version: 5.2.1 @@ -959,6 +965,9 @@ packages: resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + china-area-data@5.0.1: + resolution: {integrity: sha512-BQDPpiv5Nn+018ekcJK2oSD9PAD+E1bvXB0wgabc//dFVS/KvRqCgg0QOEUt3vBkx9XzB5a9BmkJCEZDBxVjVw==} + cli-cursor@5.0.0: resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} engines: {node: '>=18'} @@ -3159,6 +3168,8 @@ snapshots: chalk@5.6.2: {} + china-area-data@5.0.1: {} + cli-cursor@5.0.0: dependencies: restore-cursor: 5.1.0 diff --git a/src/api/upload.ts b/src/api/upload.ts new file mode 100644 index 0000000..e732da7 --- /dev/null +++ b/src/api/upload.ts @@ -0,0 +1,27 @@ +import { get } from '@/utils/request'; +import type { OssUploadTypeValue } from '@/utils/oss'; + +/** OSS 签名数据 */ +export interface OssSignData { + host: string; + dir: string; + policy: string; + signature: string; + x_oss_credential: string; + x_oss_date: string; + security_token: string; +} + +/** + * 获取 OSS 上传签名 + * @param type 上传类型(参见 OssUploadType) + */ +export async function fetchOssSign(type: OssUploadTypeValue): Promise { + // 后端签名接口地址(待对接时调整) + const res: any = await get('/sys/oss/getUploadSign', { type }); + const data = res?.data as OssSignData; + if (!data?.host) { + throw new Error(res?.msg || '获取上传凭证失败'); + } + return data; +} diff --git a/src/assets/styles/index.less b/src/assets/styles/index.less index 0b6120a..5619000 100644 --- a/src/assets/styles/index.less +++ b/src/assets/styles/index.less @@ -95,3 +95,10 @@ a { margin-top: 16px; overflow: hidden; } + +// ===== antd 组件覆盖 ===== + +// Tag 默认 margin-right: 8px,会挤开表格列或卡片内间距,统一去掉 +.ant-tag { + margin-right: 0; +} diff --git a/src/config/fallbackRoutes.ts b/src/config/fallbackRoutes.ts index 770ca7f..9282088 100644 --- a/src/config/fallbackRoutes.ts +++ b/src/config/fallbackRoutes.ts @@ -81,4 +81,62 @@ export const FALLBACK_MENU_NODES: MenuNode[] = [ }, ], }, + { + id: 'finance', + name: '账务管理', + path: 'finance', + icon: 'TransactionOutlined', + children: [ + { + id: 'finance_withdraw', + name: '提现申请', + path: 'finance/withdraw', + component: 'finance/withdraw', + }, + { + id: 'finance_wallet', + name: '用户钱包', + path: 'finance/wallet', + component: 'finance/wallet', + }, + { + id: 'finance_payments', + name: '支付流水', + path: 'finance/payments', + component: 'finance/payments', + }, + { + id: 'finance_reports', + name: '账务报表', + path: 'finance/reports', + component: 'finance/reports', + }, + ], + }, + { + id: 'system', + name: '系统管理', + path: 'system', + icon: 'SettingOutlined', + children: [ + { + id: 'system_users', + name: '用户管理', + path: 'system/users', + component: 'system/users', + }, + { + id: 'system_roles', + name: '角色权限', + path: 'system/roles', + component: 'system/roles', + }, + { + id: 'system_logs', + name: '操作日志', + path: 'system/logs', + component: 'system/logs', + }, + ], + }, ]; diff --git a/src/layouts/BasicLayout.tsx b/src/layouts/BasicLayout.tsx index 2fb0b43..d140312 100644 --- a/src/layouts/BasicLayout.tsx +++ b/src/layouts/BasicLayout.tsx @@ -9,6 +9,7 @@ import { MenuFoldOutlined, MenuUnfoldOutlined, SettingOutlined, + TransactionOutlined, TrophyOutlined, } from '@ant-design/icons-vue'; import { useMenuStore } from '@/stores/menuStore'; @@ -46,6 +47,7 @@ const ICON_MAP: Record = { DashboardOutlined, InfoCircleOutlined, SettingOutlined, + TransactionOutlined, TrophyOutlined, }; diff --git a/src/pages/events/banner/components/BannerFormModal.module.less b/src/pages/events/banner/components/BannerFormModal.module.less new file mode 100644 index 0000000..8dd12e8 --- /dev/null +++ b/src/pages/events/banner/components/BannerFormModal.module.less @@ -0,0 +1,113 @@ +.form { + :global { + .ant-form-item { + margin-bottom: 16px; + } + .ant-form-item-explain-error { + font-size: 12px; + } + } +} + +/* ===== 上传 ===== */ +.uploader { + position: relative; +} + +.uploadCard { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 100%; + height: 140px; + border: 1px dashed #d9d9d9; + border-radius: 6px; + background-color: #fafafa; + cursor: pointer; + transition: border-color 0.2s; + gap: 4px; + + &:hover { + border-color: #1677ff; + } +} + +.uploadCardError { + border-color: #ff4d4f; +} + +.uploadIcon { + font-size: 32px; + color: #999; + font-weight: 300; + line-height: 1; +} + +.uploadHint { + color: #666; + font-size: 14px; +} + +.uploadTip { + color: #999; + font-size: 12px; + text-align: center; + line-height: 1.6; +} + +/* ===== 预览 ===== */ +.previewWrapper { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + border: 1px solid #e8e8e8; + border-radius: 6px; + background-color: #fff; + padding: 12px; +} + +.previewImg { + width: 100%; + max-width: 420px; + max-height: 160px; + object-fit: contain; + border-radius: 4px; +} + +.previewActions { + display: flex; + gap: 8px; +} + +/* ===== 提示 ===== */ +.tip { + padding: 8px 12px; + margin-bottom: 16px; + background-color: #f0f5ff; + border: 1px solid #d6e4ff; + border-radius: 4px; + color: #1677ff; + font-size: 13px; +} + +/* ===== 时间行(双列)===== */ +.timeRow { + display: flex; + gap: 16px; + + .timeItem { + flex: 1; + } +} + +/* ===== 底部按钮 ===== */ +.footer { + display: flex; + justify-content: flex-end; + gap: 8px; + padding-top: 16px; + border-top: 1px solid #f0f0f0; + margin-top: 8px; +} diff --git a/src/pages/events/banner/components/BannerFormModal.tsx b/src/pages/events/banner/components/BannerFormModal.tsx new file mode 100644 index 0000000..dc71c8f --- /dev/null +++ b/src/pages/events/banner/components/BannerFormModal.tsx @@ -0,0 +1,468 @@ +import { defineComponent, ref, reactive, watch } from 'vue'; +import { + Modal, + Form, + Input, + Select, + DatePicker, + InputNumber, + Button, + message, + Spin, +} from 'ant-design-vue'; +import { JUMP_TYPE_OPTIONS, EVENT_OPTIONS, REGION_OPTIONS } from '../model/useBannerModel'; +import { uploadFilesOneByOne, OssUploadType } from '@/utils/oss'; +import styles from './BannerFormModal.module.less'; + +/** 默认表单数据 */ +const getDefaultForm = () => ({ + title: '', + imageUrl: '', + imageStatus: 'empty' as 'empty' | 'uploading' | 'success' | 'error', + jumpType: 'event_detail' as 'event_detail' | 'miniprogram_page' | 'custom_link' | 'none', + relatedEventId: '', + pagePath: '', + customUrl: '', + region: 'home', + displayTimeRange: null as [string, string] | null, + sort: 1, +}); + +/** 表单字段最大长度限制 */ +const TITLE_MAX = 50; +const PATH_MAX = 200; +const URL_MAX = 500; + +interface BannerFormModalProps { + visible: boolean; + record: any; + submitting?: boolean; + onClose: () => void; + onSave: (formData: any) => void; +} + +/** + * Banner 新增/编辑表单弹窗 + */ +export default defineComponent({ + name: 'BannerFormModal', + props: { + visible: { type: Boolean, default: false }, + record: { type: Object, default: null }, + submitting: { type: Boolean, default: false }, + onClose: { type: Function, required: true }, + onSave: { type: Function, required: true }, + }, + setup(props: BannerFormModalProps) { + const formRef = ref(); + const fileInputRef = ref(null); + const formData = reactive(getDefaultForm()); + + /** 根据 record 初始化表单 */ + const initFormFromRecord = (record: any) => { + const fresh = getDefaultForm(); + if (!record) { + Object.assign(formData, fresh); + return; + } + + let pagePath = ''; + let customUrl = ''; + if (record.jumpType === 'miniprogram_page') { + pagePath = record.jumpUrl || ''; + } else if (record.jumpType === 'custom_link') { + customUrl = record.jumpUrl || ''; + } + + Object.assign(formData, { + ...fresh, + title: record.title || '', + imageUrl: record.imageUrl || '', + imageStatus: record.imageUrl ? ('success' as const) : ('empty' as const), + jumpType: record.jumpType || 'event_detail', + relatedEventId: record.relatedEventId || '', + pagePath, + customUrl, + region: record.region && record.region !== record.city ? record.region : 'home', + displayTimeRange: + record.displayStartTime && record.displayEndTime + ? ([record.displayStartTime, record.displayEndTime] as [string, string]) + : null, + sort: record.sort ?? 1, + }); + }; + + /** 监听 visible 变化重置表单 */ + watch( + () => props.visible, + (val) => { + if (val) { + initFormFromRecord(props.record); + setTimeout(() => formRef.value?.clearValidate(), 0); + } + }, + { immediate: true }, + ); + + /** 监听 jumpType 变化时清理联动字段 */ + watch( + () => formData.jumpType, + (newType) => { + if (newType !== 'event_detail') formData.relatedEventId = ''; + if (newType !== 'miniprogram_page') formData.pagePath = ''; + if (newType !== 'custom_link') formData.customUrl = ''; + const fieldsToValidate: string[] = []; + if (newType === 'event_detail') fieldsToValidate.push('relatedEventId'); + if (newType === 'miniprogram_page') fieldsToValidate.push('pagePath'); + if (newType === 'custom_link') fieldsToValidate.push('customUrl'); + if (fieldsToValidate.length) formRef.value?.clearValidate(fieldsToValidate); + }, + ); + + /** 触发隐藏的文件选择器 */ + const triggerFilePicker = () => { + if (formData.imageStatus === 'uploading') return; + fileInputRef.value?.click(); + }; + + /** 处理文件选择 */ + const handleFileChange = async (e: Event) => { + const target = e.target as HTMLInputElement; + const file = target.files?.[0]; + // 重置 input 以便同一文件可再次选择 + target.value = ''; + + if (!file) return; + if (!/^image\//.test(file.type)) { + message.error('请选择图片文件'); + return; + } + const MAX_SIZE = 5 * 1024 * 1024; + if (file.size > MAX_SIZE) { + message.error('图片大小不能超过 5MB'); + return; + } + + // 本地预览 + const localUrl = URL.createObjectURL(file); + formData.imageUrl = localUrl; + formData.imageStatus = 'uploading'; + + try { + await uploadFilesOneByOne([file], OssUploadType.Banner, { + onSuccess: (url) => { + URL.revokeObjectURL(localUrl); + formData.imageUrl = url; + formData.imageStatus = 'success'; + message.success('图片上传成功'); + }, + onError: (err, idx) => { + console.error(`[Banner] 上传失败 (${idx}):`, err); + URL.revokeObjectURL(localUrl); + formData.imageUrl = ''; + formData.imageStatus = 'error'; + message.error('图片上传失败'); + }, + }); + } catch (err: any) { + console.error('[Banner] 获取上传凭证失败:', err); + URL.revokeObjectURL(localUrl); + formData.imageUrl = ''; + formData.imageStatus = 'error'; + message.error(err?.message || '图片上传失败'); + } + }; + + /** 删除已上传图片 */ + const handleRemoveImage = () => { + if (formData.imageUrl && formData.imageUrl.startsWith('blob:')) { + URL.revokeObjectURL(formData.imageUrl); + } + formData.imageUrl = ''; + formData.imageStatus = 'empty'; + }; + + /** 提交表单 */ + const handleSubmit = async () => { + try { + await formRef.value?.validate(); + } catch { + return; + } + + // 防御:上传中时禁止提交 + if (formData.imageStatus === 'uploading') { + message.warning('图片仍在上传中,请稍候'); + return; + } + + // 构造提交数据:根据 jumpType 整理 jumpUrl + const payload: any = { ...formData }; + if (payload.jumpType === 'event_detail') { + payload.jumpUrl = payload.relatedEventId; + } else if (payload.jumpType === 'miniprogram_page') { + payload.jumpUrl = payload.pagePath; + } else if (payload.jumpType === 'custom_link') { + payload.jumpUrl = payload.customUrl; + } else { + payload.jumpUrl = ''; + } + payload.displayStartTime = payload.displayTimeRange?.[0] || ''; + payload.displayEndTime = payload.displayTimeRange?.[1] || ''; + props.onSave(payload); + }; + + return () => ( + +
+ {/* ===== Banner 标题 ===== */} + + + + + {/* ===== 上传图片 ===== */} + { + if (!formData.imageUrl || formData.imageUrl.startsWith('blob:')) { + return Promise.reject(new Error('请上传 Banner 图片')); + } + return Promise.resolve(); + }, + }, + ]} + > +
+ + {formData.imageStatus === 'empty' || formData.imageStatus === 'error' ? ( +
+ + + 点击上传 + + 建议尺寸 1053*351 像素 +
+ 最多上传 1 张 +
+
+ ) : ( +
+ + banner preview + +
+ + +
+
+ )} +
+
+ + {/* ===== 跳转类型 ===== */} + + + + )} + + {formData.jumpType === 'miniprogram_page' && ( + + + + )} + + {formData.jumpType === 'custom_link' && ( + + + + )} + + {formData.jumpType === 'none' && ( +
选择"无链接"时,点击 Banner 不会跳转。
+ )} + + {/* ===== 所属区域 ===== */} + + + + - + + + (filterForm.refundStatus = val || '')} + /> + + ); + } + + return; +} /** * 用户列表 @@ -19,10 +72,40 @@ export default defineComponent({ handlePageChange, } = useUserModel(); + const { containerRef, height } = useContainerSize({ headerOffset: 55 }); + + const handleToggleStatus = (record: any) => { + const isActive = record.status === 'active'; + const actionText = isActive ? '禁用' : '启用'; + Modal.confirm({ + title: `${actionText}确认`, + content: `确认${actionText}用户"${record.nickName}"吗?`, + okText: '确认', + cancelText: '取消', + onOk: () => { + console.log(`${actionText}用户:`, record.userId); + // TODO: 替换为真实 API 调用 + }, + }); + }; + + /** 最终表格列:模型列 + 操作 */ + const tableColumns = [ + ...columns, + { + title: '操作', + key: 'action', + width: 100, + fixed: 'right' as const, + align: 'center' as const, + }, + ]; + return () => (
+ {/* ===== 筛选区 ===== */}
-
+ + +