Compare commits

..

8 Commits

15 changed files with 309 additions and 43 deletions
+1 -1
View File
@@ -9,7 +9,7 @@
"serve:prod": "vite --mode production",
"build:dev": "vue-tsc --noEmit && vite build --mode development",
"build:test": "vue-tsc --noEmit && vite build --mode test",
"build:prod": "vue-tsc --noEmit && vite build --mode production",
"build:prod": "vite build --mode production",
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage",
+4
View File
@@ -76,6 +76,10 @@ export interface EventListQueryParams {
idCardImg?: string;
/** 是否要求提供证件号码;0=无需,1=需要 */
idCardNo?: string;
/** 报名人数起始(含) */
scBegin?: string;
/** 报名人数结束(含) */
scEnd?: string;
}
/** 分页数据 */
+39
View File
@@ -20,6 +20,8 @@ import {
} from './model/useEventListModel';
import { useEventListColumns } from './model/useEventListColumns';
import { getProvinceCityCascaderOptions } from '@/utils/areaData';
import { toIntegerOnly } from '@/utils/form';
import { handleIntegerInput, handleIntegerBlur, blockNonDigitKey } from './model/integerInput';
import { useContainerSize } from '@/hooks';
import { usePermissionStore } from '@/stores/permissionStore';
import EventRegulationModal from './components/EventRegulationModal';
@@ -266,6 +268,43 @@ export default defineComponent({
onUpdate:value={(val: any) => (filterForm.region = val || [])}
/>
</Form.Item>
<Form.Item label="报名人数">
<Space.Compact>
<Input
value={filterForm.scBegin}
placeholder="请输入"
style={{ width: '100px' }}
allowClear
onInput={(e: Event) => handleIntegerInput(e, (v) => (filterForm.scBegin = v))}
onKeydown={blockNonDigitKey}
onBlur={(e: Event) => handleIntegerBlur(e, (v) => (filterForm.scBegin = v))}
onUpdate:value={(val: string) => (filterForm.scBegin = toIntegerOnly(val))}
onPressEnter={handleSearch}
/>
<Input
style={{
width: '40px',
textAlign: 'center',
backgroundColor: '#fafafa',
pointerEvents: 'none',
color: 'rgba(0,0,0,0.45)',
}}
value="至"
disabled
/>
<Input
value={filterForm.scEnd}
placeholder="请输入"
style={{ width: '100px' }}
allowClear
onInput={(e: Event) => handleIntegerInput(e, (v) => (filterForm.scEnd = v))}
onKeydown={blockNonDigitKey}
onBlur={(e: Event) => handleIntegerBlur(e, (v) => (filterForm.scEnd = v))}
onUpdate:value={(val: string) => (filterForm.scEnd = toIntegerOnly(val))}
onPressEnter={handleSearch}
/>
</Space.Compact>
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" onClick={handleSearch} loading={loading.value}>
+4
View File
@@ -15,6 +15,8 @@ export const FILTER_DEFAULTS = {
isPublic: '',
idCardNo: '',
idCardImg: '',
scBegin: '',
scEnd: '',
};
export type FieldMapping = [
@@ -35,6 +37,8 @@ export const FIELD_MAPPINGS: FieldMapping[] = [
['createDateRange', 'createDateEnd', (v) => v?.[1]],
['startDateRange', 'startDate', (v) => v?.[0]],
['startDateRange', 'startDateEnd', (v) => v?.[1]],
['scBegin', 'scBegin'],
['scEnd', 'scEnd'],
];
/** 赛事状态选项(value 对应 API status) */
@@ -0,0 +1,43 @@
/** 报名人数筛选项 — 整数输入处理(页面特有逻辑) */
import { toIntegerOnly } from '@/utils/form';
/** 整数规范化:去除前导零,如 055 → 55;空值保持为空 */
function normalizeIntegerString(value: unknown): string {
const digits = toIntegerOnly(value);
if (!digits) return '';
return String(parseInt(digits, 10));
}
/** 整数 Input 事件处理:同步过滤 DOM 值与外部状态 */
export function handleIntegerInput(e: Event, setter: (value: string) => void): void {
const el = e.target as HTMLInputElement | null;
if (!el) return;
const next = toIntegerOnly(el.value);
if (el.value !== next) {
el.value = next;
}
setter(next);
}
/** 整数 Input 失焦处理:规范化数字并同步状态 */
export function handleIntegerBlur(e: Event, setter: (value: string) => void): void {
const el = e.target as HTMLInputElement | null;
if (!el) return;
const next = normalizeIntegerString(el.value);
if (el.value !== next) {
el.value = next;
}
setter(next);
}
/** 阻止非数字键入(含小数点),配合 handleIntegerInput 处理粘贴 */
export function blockNonDigitKey(e: KeyboardEvent): void {
if (e.ctrlKey || e.metaKey || e.altKey) return;
if (e.key.length !== 1) return;
if (!/^\d$/.test(e.key)) {
e.preventDefault();
}
}
@@ -92,11 +92,24 @@ export function useEventListModel() {
f.region.length > 0 ||
!!f.isPublic ||
!!f.idCardNo ||
!!f.idCardImg
!!f.idCardImg ||
!!f.scBegin?.trim() ||
!!f.scEnd?.trim()
);
});
/** 报名人数区间:起始大于结束时对调 */
const normalizeSignupCountRange = () => {
const begin = filterForm.scBegin.trim();
const end = filterForm.scEnd.trim();
if (begin && end && Number(begin) > Number(end)) {
filterForm.scBegin = end;
filterForm.scEnd = begin;
}
};
const handleSearch = () => {
normalizeSignupCountRange();
pagination.reset();
fetchList();
};
@@ -143,9 +143,14 @@
}
// ===== 上传 =====
.uploader {
.uploadWrapper {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
}
.uploader {
:global {
.ant-upload-list-picture-card-container {
width: 96px;
@@ -159,6 +164,21 @@
}
}
.pasteTip {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
background: #e6f7ff;
border-radius: 4px;
color: #1677ff;
font-size: 12px;
span {
line-height: 1.5;
}
}
.uploadTrigger {
display: flex;
flex-direction: column;
@@ -1,8 +1,8 @@
import { defineComponent, reactive, ref } from 'vue';
import { defineComponent, reactive, ref, onMounted, onUnmounted } from 'vue';
import { useEffect } from '@/hooks';
import { Modal, Radio, Input, Button, Upload, Image, Spin, message } from 'ant-design-vue';
import type { UploadFile } from 'ant-design-vue';
import { PlusOutlined } from '@ant-design/icons-vue';
import { PlusOutlined, CopyOutlined } 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';
@@ -41,6 +41,34 @@ export default defineComponent({
const auditForm = reactive({ pass: true, remark: '' });
const imageList = ref<UploadFile[]>([]);
const submitting = ref(false);
const uploadAreaRef = ref<HTMLElement | null>(null);
// ===== 粘贴图片上传 =====
const handlePaste = async (e: ClipboardEvent) => {
if (!props.visible || !uploadAreaRef.value) return;
const items = e.clipboardData?.items;
if (!items) return;
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.type.indexOf('image') !== -1) {
e.preventDefault();
const file = item.getAsFile();
if (file) {
await beforeUpload(file as unknown as UploadFile);
}
}
}
};
onMounted(() => {
document.addEventListener('paste', handlePaste);
});
onUnmounted(() => {
document.removeEventListener('paste', handlePaste);
});
// ===== 拉取详情 =====
const fetchDetail = async (id: number) => {
@@ -239,21 +267,27 @@ export default defineComponent({
{auditForm.pass && <span class={styles.required}>*</span>}
审核图片
</span>
<Upload
listType="picture-card"
fileList={imageList.value}
beforeUpload={beforeUpload}
onRemove={handleRemove}
accept="image/*"
class={styles.uploader}
>
{imageList.value.length >= MAX_IMAGE_COUNT ? null : (
<div class={styles.uploadTrigger}>
<PlusOutlined />
<div class={styles.uploadText}>请选择图片</div>
</div>
)}
</Upload>
<div ref={uploadAreaRef} class={styles.uploadWrapper}>
<Upload
listType="picture-card"
fileList={imageList.value}
beforeUpload={beforeUpload}
onRemove={handleRemove}
accept="image/*"
class={styles.uploader}
>
{imageList.value.length >= MAX_IMAGE_COUNT ? null : (
<div class={styles.uploadTrigger}>
<PlusOutlined />
<div class={styles.uploadText}>选择或粘贴图片</div>
</div>
)}
</Upload>
<div class={styles.pasteTip}>
<CopyOutlined />
<span>支持 Ctrl+V 粘贴图片</span>
</div>
</div>
</div>
<div class={styles.footer}>
<Button type="primary" onClick={handleSubmit} loading={submitting.value}>
+33
View File
@@ -0,0 +1,33 @@
.cell {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
cursor: default;
}
.text {
flex: 1;
min-width: 0;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.5;
word-break: break-all;
color: rgba(0, 0, 0, 0.88);
}
.view {
flex-shrink: 0;
color: #1677ff;
cursor: pointer;
user-select: none;
line-height: 1.5;
white-space: nowrap;
&:hover {
opacity: 0.85;
}
}
+77 -3
View File
@@ -1,11 +1,81 @@
import { defineComponent } from 'vue';
import { Button, Input, Table, DatePicker, Form, Space, Select, Pagination } from 'ant-design-vue';
import { defineComponent, ref, nextTick } from 'vue';
import {
Button,
Input,
Table,
DatePicker,
Form,
Space,
Select,
Pagination,
Modal,
} from 'ant-design-vue';
import { useLogModel, ACTION_TYPE_OPTIONS, ACTION_TYPE_MAP } from './model/useLogModel';
import { useContainerSize } from '@/hooks';
import { useContainerSize, useState, useEffect } from '@/hooks';
import pageStyles from '@/assets/styles/pageLayout.module.less';
import cellStyles from './index.module.less';
const { RangePicker } = DatePicker;
/**
* 操作内容单元格:最多 2 行截断,溢出时显示"查看"按钮
*/
const LogContentCell = defineComponent({
name: 'LogContentCell',
props: { text: { type: String, required: true } },
setup(props) {
const textRef = ref<HTMLElement | null>(null);
const [overflow, setOverflow] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const checkOverflow = () => {
const el = textRef.value;
if (el) setOverflow(el.scrollHeight > el.clientHeight);
};
let observer: ResizeObserver | null = null;
useEffect(() => {
if (!textRef.value) return;
nextTick(checkOverflow);
observer = new ResizeObserver(checkOverflow);
observer.observe(textRef.value);
return () => {
observer?.disconnect();
observer = null;
};
}, [() => textRef.value]);
return () => {
const raw = props.text;
return (
<div class={cellStyles.cell}>
<div ref={textRef} class={cellStyles.text}>
{raw}
</div>
{overflow.value ? (
<span class={cellStyles.view} onClick={() => setModalOpen(true)}>
查看
</span>
) : null}
<Modal
visible={modalOpen.value}
title="操作内容"
footer={null}
width={600}
centered
destroyOnClose
onCancel={() => setModalOpen(false)}
>
<div style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-all', lineHeight: 1.8 }}>
{raw}
</div>
</Modal>
</div>
);
};
},
});
/**
* bodyCell 渲染
*/
@@ -13,6 +83,10 @@ function renderBodyCell({ column, text }: { column: any; text: any }) {
if (column.key === 'type') {
return <span>{ACTION_TYPE_MAP[text as number] || text || '--'}</span>;
}
if (column.dataIndex === 'content') {
const raw = text || '';
return raw ? <LogContentCell text={raw} /> : <span>--</span>;
}
return <span>{text || '--'}</span>;
}
+3 -1
View File
@@ -24,6 +24,7 @@ export const ACTION_TYPE_OPTIONS = [
{ value: '3', label: '禁用用户' },
{ value: '4', label: '启用用户' },
{ value: '7', label: '系统参数' },
{ value: '8', label: '用户登录' },
] as const;
/** 操作类型文案映射 */
@@ -33,6 +34,7 @@ export const ACTION_TYPE_MAP: Record<number, string> = {
3: '禁用用户',
4: '启用用户',
7: '系统参数',
8: '用户登录',
};
// ============================================================
@@ -103,7 +105,7 @@ export function useLogModel() {
const columns = [
{ title: '操作类型', dataIndex: 'type', key: 'type', width: 140 },
{ title: '操作人', dataIndex: 'nickname', key: 'nickname', width: 140 },
{ title: '操作内容', dataIndex: 'content', key: 'content', minWidth: 360 },
{ title: '操作内容', dataIndex: 'content', key: 'content', width: 480 },
{ title: '操作时间', dataIndex: 'createDate', key: 'createDate', width: 180 },
];
@@ -104,13 +104,13 @@ export default defineComponent({
</Form.Item>
<Form.Item label="参数值" name="paramValue" rules={paramValueRules}>
<Input
<TextArea
value={formData.paramValue}
onUpdate:value={(val: any) => {
formData.paramValue = val?.target?.value ?? val ?? '';
}}
placeholder="请输入参数值"
allowClear
rows={2}
/>
</Form.Item>
@@ -123,7 +123,7 @@ export default defineComponent({
placeholder="请输入备注说明(选填,最多200字)"
maxlength={200}
showCount
rows={3}
rows={4}
/>
</Form.Item>
</Form>
+12 -12
View File
@@ -8,11 +8,14 @@ import pageStyles from '@/assets/styles/pageLayout.module.less';
import cellStyles from './index.module.less';
/**
* 参数值单元格:最多 2 行截断,溢出时显示"查看"按钮
* 文本单元格:最多 2 行截断,溢出时显示"查看"按钮
*/
const ParamValueCell = defineComponent({
name: 'ParamValueCell',
props: { text: { type: String, required: true } },
const TruncatedTextCell = defineComponent({
name: 'TruncatedTextCell',
props: {
text: { type: String, required: true },
modalTitle: { type: String, required: true },
},
setup(props) {
const textRef = ref<HTMLElement | null>(null);
const [overflow, setOverflow] = useState(false);
@@ -49,7 +52,7 @@ const ParamValueCell = defineComponent({
) : null}
<Modal
visible={modalOpen.value}
title="参数值"
title={props.modalTitle}
footer={null}
width={600}
centered
@@ -90,14 +93,11 @@ function renderBodyCell({
</Space>
);
}
// 参数值列:两行截断 + 查看弹窗
if (column.dataIndex === 'paramValue') {
// 参数值 / 备注列:两行截断 + 查看弹窗
if (column.dataIndex === 'paramValue' || column.dataIndex === 'remark') {
const raw = text || '';
return raw ? <ParamValueCell text={raw} /> : <span>--</span>;
}
// 备注列:空值显示 --
if (column.dataIndex === 'remark' && !text) {
return <span>--</span>;
const modalTitle = column.dataIndex === 'paramValue' ? '参数值' : '备注说明';
return raw ? <TruncatedTextCell text={raw} modalTitle={modalTitle} /> : <span>--</span>;
}
return <span>{text ?? '--'}</span>;
}
@@ -28,7 +28,7 @@ export function useParamsColumns() {
title: '备注说明',
dataIndex: 'remark',
key: 'remark',
minWidth: 240,
width: 240,
},
];
+3 -3
View File
@@ -34,8 +34,8 @@ export const REAL_NAME_REGEX = /^[\u4e00-\u9fa5A-Za-z0-9·]{1,8}$/;
export const IMAGE_TYPE_REGEX = /^image\//;
/**
* 纯数字(整数)
* - 用于过滤非数字字符
* 非数字字符
* - 用于过滤 0-9 以外的字符(含小数点、字母、符号等)
*/
export const INTEGER_ONLY_REGEX = /\D/g;
@@ -63,7 +63,7 @@ export function isImageFile(file: { type: string }): boolean {
return IMAGE_TYPE_REGEX.test(file.type);
}
/** 过滤非数字字符,只保留整数 */
/** 只保留数字字符 */
export function toIntegerOnly(value: unknown): string {
return String(value ?? '').replace(INTEGER_ONLY_REGEX, '');
}