fix: 优化表单检测逻辑

This commit is contained in:
ZhuRui
2026-08-06 09:23:18 +08:00
parent e3bd35e154
commit 8c7e8f807c
10 changed files with 326 additions and 135 deletions
@@ -13,10 +13,25 @@ import {
Spin,
} from 'ant-design-vue';
import { JUMP_TYPE_OPTIONS } from '../model/useBannerModel';
import {
TITLE_MAX,
PATH_MAX,
URL_MAX,
titleRules,
imageUrlRules,
typeRules,
linkUrlRules,
miniProgramPathRules,
areaRules,
startTimeRules,
endTimeRules,
sortRules,
} from '../model/form';
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 { isImageFile } from '@/utils/form';
import { getEventList, getEventDetail, type TournamentAdminVO } from '../../list/model/services';
import dayjs from 'dayjs';
import styles from './BannerFormModal.module.less';
@@ -40,11 +55,6 @@ const getDefaultForm = () => ({
sort: 1,
});
/** 表单字段最大长度限制 */
const TITLE_MAX = 50;
const PATH_MAX = 200;
const URL_MAX = 500;
/** 图片上传限制:最大 10MB */
const IMAGE_MAX_SIZE = 10 * 1024 * 1024;
@@ -351,7 +361,7 @@ export default defineComponent({
target.value = '';
if (!file) return;
if (!/^image\//.test(file.type)) {
if (!isImageFile(file)) {
message.error('请选择图片文件');
return;
}
@@ -468,14 +478,7 @@ export default defineComponent({
requiredMark
>
{/* ===== Banner 标题 ===== */}
<Form.Item
label="Banner标题"
name="title"
rules={[
{ required: true, message: '请输入' },
{ max: TITLE_MAX, message: `标题最多 ${TITLE_MAX} 个字符` },
]}
>
<Form.Item label="Banner标题" name="title" rules={titleRules}>
<Input
placeholder="请输入标题"
maxlength={TITLE_MAX}
@@ -488,23 +491,10 @@ export default defineComponent({
<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();
},
},
]}
rules={imageUrlRules(
() => formData.imageStatus,
() => formData.imageUrl,
)}
>
<div class={styles.uploader}>
<input
@@ -549,11 +539,7 @@ export default defineComponent({
</Form.Item>
{/* ===== 跳转类型 ===== */}
<Form.Item
label="跳转类型"
name="type"
rules={[{ required: true, message: '请选择跳转类型' }]}
>
<Form.Item label="跳转类型" name="type" rules={typeRules}>
<Select
options={JUMP_TYPE_OPTIONS as any}
placeholder="请选择"
@@ -584,14 +570,7 @@ export default defineComponent({
{/* ===== 联动字段:自定义链接 ===== */}
{formData.type === '2' && (
<Form.Item
label="跳转地址"
name="linkUrl"
rules={[
{ required: true, message: '请输入跳转地址' },
{ max: URL_MAX, message: `链接最多 ${URL_MAX} 个字符` },
]}
>
<Form.Item label="跳转地址" name="linkUrl" rules={linkUrlRules}>
<Input
placeholder="https://xxx"
maxlength={URL_MAX}
@@ -603,14 +582,7 @@ export default defineComponent({
{/* ===== 联动字段:小程序页面 ===== */}
{formData.type === '3' && (
<Form.Item
label="小程序路径"
name="linkUrl"
rules={[
{ required: true, message: '请输入小程序页面路径' },
{ max: PATH_MAX, message: `路径最多 ${PATH_MAX} 个字符` },
]}
>
<Form.Item label="小程序路径" name="linkUrl" rules={miniProgramPathRules}>
<Input
placeholder="/pages/index/index"
maxlength={PATH_MAX}
@@ -621,11 +593,7 @@ export default defineComponent({
)}
{/* ===== 所属区域(省市树种选择 + 全国) ===== */}
<Form.Item
label="所属区域"
name="area"
rules={[{ required: true, message: '请选择所属区域' }]}
>
<Form.Item label="所属区域" name="area" rules={areaRules}>
<Cascader
v-model:value={areaPath.value}
options={areaCascaderOptions}
@@ -641,16 +609,7 @@ export default defineComponent({
label="开始时间"
name="displayStartTime"
class={styles.timeItem}
rules={[
{
validator: async () => {
if (!formData.displayTimeRange?.[0]) {
return Promise.reject(new Error('请选择开始时间'));
}
return Promise.resolve();
},
},
]}
rules={startTimeRules(() => formData.displayTimeRange)}
>
<DatePicker
showTime
@@ -679,16 +638,7 @@ export default defineComponent({
label="结束时间"
name="displayEndTime"
class={styles.timeItem}
rules={[
{
validator: async () => {
if (!formData.displayTimeRange?.[1]) {
return Promise.reject(new Error('请选择结束时间'));
}
return Promise.resolve();
},
},
]}
rules={endTimeRules(() => formData.displayTimeRange)}
>
<DatePicker
showTime
@@ -715,14 +665,7 @@ export default defineComponent({
</div>
{/* ===== 排序 ===== */}
<Form.Item
label="排序"
name="sort"
rules={[
{ required: true, message: '请输入排序' },
{ type: 'number', min: 1, max: 999, message: '排序范围 1-999' },
]}
>
<Form.Item label="排序" name="sort" rules={sortRules}>
<InputNumber
placeholder="数字越小越靠前"
min={1}
+114
View File
@@ -0,0 +1,114 @@
/**
* Banner 表单域控制器
*
* 集中管理 Banner 新增/编辑弹窗的所有表单规则校验与常量。
* 各规则数组 / 工厂函数可直接传给 antd Form.Item 的 rules prop。
*/
import type { Rule } from 'ant-design-vue/es/form';
// ============================================================
// 常量
// ============================================================
/** 标题最大长度 */
export const TITLE_MAX = 50;
/** 小程序路径最大长度 */
export const PATH_MAX = 200;
/** 跳转链接最大长度 */
export const URL_MAX = 500;
// ============================================================
// 表单规则
// ============================================================
/** Banner 标题:必填 + 50 字符上限 */
export const titleRules: Rule[] = [
{ required: true, message: '请输入' },
{ max: TITLE_MAX, message: `标题最多 ${TITLE_MAX} 个字符` },
];
/**
* Banner 图片:必填 + 上传状态校验
*
* @param getImageStatus 获取当前上传状态的 getter
* @param getImageUrl 获取当前图片 URL 的 getter
*/
export function imageUrlRules(getImageStatus: () => string, getImageUrl: () => string): Rule[] {
return [
{ required: true, message: '请上传 Banner 图片' },
{
validator: async () => {
if (getImageStatus() === 'uploading') {
return Promise.reject(new Error('图片仍在上传中,请稍候'));
}
const url = getImageUrl();
if (url.startsWith('blob:') || url.startsWith('data:')) {
return Promise.reject(new Error('请等待图片上传完成'));
}
return Promise.resolve();
},
},
];
}
/** 跳转类型:必选 */
export const typeRules: Rule[] = [{ required: true, message: '请选择跳转类型' }];
/** 跳转地址(自定义链接 scene=2):必填 + 最大长度 */
export const linkUrlRules: Rule[] = [
{ required: true, message: '请输入跳转地址' },
{ max: URL_MAX, message: `链接最多 ${URL_MAX} 个字符` },
];
/** 小程序页面路径(scene=3):必填 + 最大长度 */
export const miniProgramPathRules: Rule[] = [
{ required: true, message: '请输入小程序页面路径' },
{ max: PATH_MAX, message: `路径最多 ${PATH_MAX} 个字符` },
];
/** 所属区域:必选 */
export const areaRules: Rule[] = [{ required: true, message: '请选择所属区域' }];
/** 排序:必填 + 1-999 整数 */
export const sortRules: Rule[] = [
{ required: true, message: '请输入排序' },
{ type: 'number', min: 1, max: 999, message: '排序范围 1-999' },
];
/**
* 展示开始时间规则:校验 displayTimeRange[0] 非空
*
* @param getDisplayTimeRange 获取展示时间范围的 getter
*/
export function startTimeRules(getDisplayTimeRange: () => [string, string] | null): Rule[] {
return [
{
validator: async () => {
const range = getDisplayTimeRange();
if (!range?.[0]) {
return Promise.reject(new Error('请选择开始时间'));
}
return Promise.resolve();
},
},
];
}
/**
* 展示结束时间规则:校验 displayTimeRange[1] 非空
*
* @param getDisplayTimeRange 获取展示时间范围的 getter
*/
export function endTimeRules(getDisplayTimeRange: () => [string, string] | null): Rule[] {
return [
{
validator: async () => {
const range = getDisplayTimeRange();
if (!range?.[1]) {
return Promise.reject(new Error('请选择结束时间'));
}
return Promise.resolve();
},
},
];
}
@@ -1,5 +1,6 @@
import { defineComponent, reactive } from 'vue';
import { Modal, Descriptions, Image, Radio, Input, Form } from 'ant-design-vue';
import { Modal, Descriptions, Image, Radio, Input, Form, message } from 'ant-design-vue';
import { validateEventAuditSubmit } from '../model/form';
import styles from './EventAuditModal.module.less';
const { TextArea } = Input;
@@ -58,11 +59,13 @@ export default defineComponent({
});
const handleOk = () => {
if (!auditForm.comment.trim()) {
// 不通过时强制要求填写审核内容
if (!auditForm.approved) {
// 这里交给校验,简化版本不做强制校验
}
const check = validateEventAuditSubmit({
approved: auditForm.approved,
comment: auditForm.comment,
});
if (!check.valid) {
message.warning(check.error!);
return;
}
if (props.onSubmit) {
props.onSubmit({ approved: auditForm.approved, comment: auditForm.comment });
+32
View File
@@ -0,0 +1,32 @@
/**
* 赛事审核 - 表单域控制器
*
* 集中管理赛事审核弹窗的提交校验逻辑。
*/
// ============================================================
// 提交校验
// ============================================================
export interface EventAuditForm {
approved: boolean;
comment: string;
}
export interface EventAuditSubmitCheckResult {
valid: boolean;
error?: string;
}
/**
* 校验赛事审核提交数据
*
* @param form 审核表单 { approved, comment }
*/
export function validateEventAuditSubmit(form: EventAuditForm): EventAuditSubmitCheckResult {
// 不通过时强制要求填写审核内容
if (!form.approved && !form.comment.trim()) {
return { valid: false, error: '审核不通过时请填写审核内容' };
}
return { valid: true };
}
+11 -11
View File
@@ -50,24 +50,24 @@ export function useLogColumns() {
width: 200,
align: 'center' as const,
},
{
title: '操作对象',
dataIndex: 'obj',
key: 'obj',
width: 240,
align: 'center' as const,
},
// {
// title: '操作对象',
// dataIndex: 'obj',
// key: 'obj',
// width: 240,
// align: 'center' as const,
// },
{
title: '操作内容',
dataIndex: 'content',
key: 'content',
dataIndex: 'obj',
key: 'obj',
width: 360,
align: 'center' as const,
},
{
title: '操作时间',
dataIndex: 'createDate',
key: 'createDate',
dataIndex: 'createTime',
key: 'createTime',
width: 170,
align: 'center' as const,
},
@@ -6,11 +6,15 @@ import { PlusOutlined } from '@ant-design/icons-vue';
import { uploadFile, OssUploadType } from '@/utils/oss';
import { getWithdrawInfo, postWithdrawAudit, type WithdrawInfoVO } from '../model/services';
import { getAuditStatusText, getPayStatusText } from '../model/useWithdrawModel';
import {
MAX_IMAGE_COUNT,
MAX_IMAGE_SIZE,
validateImageFile,
validateWithdrawAuditSubmit,
} from '../model/form';
import styles from './WithdrawDetailModal.module.less';
const { TextArea } = Input;
const MAX_IMAGE_COUNT = 3;
const MAX_IMAGE_SIZE = 5 * 1024 * 1024;
interface WithdrawDetailModalProps {
visible: boolean;
@@ -67,16 +71,9 @@ export default defineComponent({
const beforeUpload = async (file: UploadFile) => {
const rawFile = file as unknown as File;
if (imageList.value.length >= MAX_IMAGE_COUNT) {
message.warning(`最多上传 ${MAX_IMAGE_COUNT} 张图片`);
return false;
}
if (!/^image\//.test(rawFile.type)) {
message.error('请选择图片文件');
return false;
}
if (rawFile.size >= MAX_IMAGE_SIZE) {
message.error('图片大小不能超过 5MB');
const check = validateImageFile(rawFile, imageList.value.length);
if (!check.valid) {
message.error(check.error!);
return false;
}
@@ -114,21 +111,16 @@ export default defineComponent({
// ===== 提交审核 =====
const handleSubmit = async () => {
// 驳回:审核内容必填
if (!auditForm.pass && !auditForm.remark.trim()) {
message.warning('驳回时请填写审核内容');
return;
}
// 通过:必须上传图片
if (auditForm.pass) {
const hasDoneImage = imageList.value.some((item) => item.status === 'done' && item.url);
if (!hasDoneImage) {
message.warning('审核通过时必须上传审核图片');
return;
}
}
if (imageList.value.some((item) => item.status === 'uploading')) {
message.warning('图片仍在上传中,请稍候');
// 提交前校验
const hasDoneImage = imageList.value.some((item) => item.status === 'done' && item.url);
const isUploading = imageList.value.some((item) => item.status === 'uploading');
const check = validateWithdrawAuditSubmit(
{ pass: auditForm.pass, remark: auditForm.remark },
hasDoneImage,
isUploading,
);
if (!check.valid) {
message.warning(check.error!);
return;
}
+84
View File
@@ -0,0 +1,84 @@
/**
* 提现审核 - 表单域控制器
*
* 集中管理提现审核弹窗的图片上传校验、提交校验逻辑与常量。
*/
import { isImageFile } from '@/utils/form';
// ============================================================
// 常量
// ============================================================
/** 审核图片最大上传张数 */
export const MAX_IMAGE_COUNT = 3;
/** 审核图片单张最大体积:5MB */
export const MAX_IMAGE_SIZE = 5 * 1024 * 1024;
// ============================================================
// 图片上传校验
// ============================================================
export interface ImageUploadCheckResult {
valid: boolean;
error?: string;
}
/**
* 校验待上传的审核图片
* @param file 待上传的文件对象
* @param currentCount 当前已上传的图片数量
* @returns 校验结果
*/
export function validateImageFile(file: File, currentCount: number): ImageUploadCheckResult {
if (currentCount >= MAX_IMAGE_COUNT) {
return { valid: false, error: `最多上传 ${MAX_IMAGE_COUNT} 张图片` };
}
if (!isImageFile(file)) {
return { valid: false, error: '请选择图片文件' };
}
if (file.size >= MAX_IMAGE_SIZE) {
return { valid: false, error: '图片大小不能超过 5MB' };
}
return { valid: true };
}
// ============================================================
// 提交审核校验
// ============================================================
export interface WithdrawAuditForm {
pass: boolean;
remark: string;
}
export interface WithdrawAuditSubmitCheckResult {
valid: boolean;
error?: string;
}
/**
* 校验提现审核提交数据
*
* @param form 审核表单 { pass, remark }
* @param hasDoneImage 是否有上传成功的图片
* @param isUploading 是否有图片仍在上传中
*/
export function validateWithdrawAuditSubmit(
form: WithdrawAuditForm,
hasDoneImage: boolean,
isUploading: boolean,
): WithdrawAuditSubmitCheckResult {
// 驳回:审核内容必填
if (!form.pass && !form.remark.trim()) {
return { valid: false, error: '驳回时请填写审核内容' };
}
// 通过:必须上传图片
if (form.pass && !hasDoneImage) {
return { valid: false, error: '审核通过时必须上传审核图片' };
}
// 图片上传未完成
if (isUploading) {
return { valid: false, error: '图片仍在上传中,请稍候' };
}
return { valid: true };
}
+2 -6
View File
@@ -6,6 +6,7 @@ import { auth } from '@/hooks/useAuth';
import { useEffect, useState } from '@/hooks';
import { useUserStore } from '@/stores/userStore';
import { login as loginApi } from '@/api/login';
import { loginFormRules } from './model/form';
import styles from './index.module.less';
interface LoginForm {
@@ -40,11 +41,6 @@ export default defineComponent({
}
}, []);
const rules = {
username: [{ required: true, message: '请输入用户名' }],
password: [{ required: true, message: '请输入密码' }],
};
const handleSubmit = async () => {
if (!form.phone || !form.password) {
message.warning('请输入用户名和密码');
@@ -214,7 +210,7 @@ export default defineComponent({
<Form
class={styles.loginForm}
model={form}
rules={rules}
rules={loginFormRules}
layout="vertical"
onFinish={handleSubmit}
>
+16
View File
@@ -0,0 +1,16 @@
/**
* 登录页 - 表单域控制器
*
* 集中管理登录表单的校验规则。
*/
import type { Rule } from 'ant-design-vue/es/form';
// ============================================================
// 表单规则
// ============================================================
/** 登录表单的整体规则(form-level ruleskey 对应 FormItem 的 name */
export const loginFormRules: Record<string, Rule[]> = {
phone: [{ required: true, message: '请输入用户名' }],
password: [{ required: true, message: '请输入密码' }],
};
+11
View File
@@ -27,6 +27,12 @@ export const LOGIN_PASSWORD_REGEX = /^(?=.*[A-Za-z])(?=.*\d)[\w!@#$%^&*()_+\-=]{
*/
export const REAL_NAME_REGEX = /^[\u4e00-\u9fa5A-Za-z0-9·]{1,8}$/;
/**
* 图片 MIME 类型前缀
* - 用于上传前校验文件是否为图片
*/
export const IMAGE_TYPE_REGEX = /^image\//;
// ============================================================
// 校验函数
// ============================================================
@@ -45,3 +51,8 @@ export function isValidPassword(value: unknown): boolean {
export function isValidRealName(value: unknown): boolean {
return typeof value === 'string' && value.length > 0 && REAL_NAME_REGEX.test(value);
}
/** 校验文件是否为图片类型 */
export function isImageFile(file: { type: string }): boolean {
return IMAGE_TYPE_REGEX.test(file.type);
}