Compare commits

..

6 Commits

9 changed files with 242 additions and 116 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": "vite build --mode production",
"build:prod": "node --max-old-space-size=4096 ./node_modules/vite/bin/vite.js build --mode production",
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage",
@@ -50,7 +50,7 @@ const getDefaultForm = () => ({
type: '1' as '1' | '2' | '3' | '0',
linkUrl: '',
relatedEventId: undefined as string | undefined,
area: '全国',
area: '',
displayTimeRange: null as [string, string] | null,
sort: 1,
});
@@ -84,18 +84,49 @@ function base64ToFile(dataUrl: string, fileName: string): File {
// 工具函数
// ============================================================
/** 从 Cascader 路径中提取城市名(最后一级),用于区域筛选 */
function extractCityName(path: string[]): string {
if (!path || path.length === 0 || path[0] === '全国') return '';
return path[path.length - 1];
/** 在 Cascader options 树中按路径查找末端节点 */
function findNodeByPath(options: any[], path: string[]): any | null {
let level = options;
let node: any = null;
for (const seg of path) {
node = level.find((o) => o.value === seg);
if (!node) return null;
level = node.children || [];
}
return node;
}
/** 从 Cascader 路径中提取存储用的区域字符串 */
function extractAreaValue(path: string[]): string {
if (!path || path.length === 0) return '全国';
if (path[0] === '全国') return '全国';
// 直辖市路径如 ["北京市"],普通城市如 ["广东省", "深圳市"]
return path[path.length - 1];
/** 递归收集节点下的所有叶子节点 value */
function collectLeafValues(node: any, acc: string[]) {
if (node.children && node.children.length > 0) {
node.children.forEach((child: any) => collectLeafValues(child, acc));
} else {
acc.push(String(node.value));
}
}
/**
* 将 Cascader 多选选中的路径数组转为叶子节点 value 数组(去重)
* 多选时可能选中父节点(省),这里递归展开为最下级的叶子节点(市)
* 如 [["广东省"], ["北京市"]] -> ["广州市", "深圳市", ..., "北京市"]
*/
function extractLeafValues(options: any[], paths: string[][]): string[] {
const result: string[] = [];
const seen = new Set<string>();
for (const path of paths) {
if (!path || path.length === 0) continue;
const node = findNodeByPath(options, path);
if (!node) continue;
const leaves: string[] = [];
collectLeafValues(node, leaves);
leaves.forEach((v) => {
if (!seen.has(v)) {
seen.add(v);
result.push(v);
}
});
}
return result;
}
/** 根据跳转类型计算 parms 字段的值 */
@@ -128,8 +159,8 @@ export default defineComponent({
const fileInputRef = ref<HTMLInputElement | null>(null);
const formData = reactive(getDefaultForm());
/** Cascader 区域的路径数组:如 ["全国"]、["广东省", "深圳市"] */
const areaPath = ref<string[]>(['全国']);
/** Cascader 区域的路径数组(多选):如 [["全国"]]、[["广东省", "深圳市"], ["北京市"]] */
const areaPath = ref<string[][]>([]);
/** 是否由用户主动切换了跳转类型 */
const typeChanged = ref(false);
@@ -152,22 +183,22 @@ export default defineComponent({
return [{ value: '', label: '不关联' }, ...eventOptions.value];
});
// ---- 赛事搜索 API 调用 ----
// ---- 赛事列表获取(keyword 可为空:选择跳转类型/所属区域后自动获取) ----
const doSearch = async (keyword: string) => {
if (!keyword.trim()) {
setEventOptions([]);
return;
}
setEventSearchLoading(true);
try {
const cityName = extractCityName(areaPath.value);
// 当前选中的叶子区域名(多选去重后)
const leafNames = extractLeafValues(areaCascaderOptions, areaPath.value);
const params: Record<string, string> = {
page: '1',
limit: '999',
name: keyword.trim(),
};
if (cityName) {
params.areaName = cityName;
if (keyword.trim()) {
params.name = keyword.trim();
}
// 区域筛选:接口 areaName 为单值,仅在恰好选中一个区域时传递
if (leafNames.length === 1 && leafNames[0] !== '全国') {
params.areaName = leafNames[0];
}
const res = await getEventList(params as any);
if (res.code == 200) {
@@ -182,7 +213,7 @@ export default defineComponent({
setEventOptions([]);
}
} catch (e) {
console.error('[Banner] 搜索赛事失败:', e);
console.error('[Banner] 获取赛事列表失败:', e);
setEventOptions([]);
} finally {
setEventSearchLoading(false);
@@ -202,26 +233,24 @@ export default defineComponent({
}, 350);
};
/** 所属区域 Cascader 变化 */
/** 所属区域 Cascader 变化(多选) */
const onAreaChange = (val: any) => {
const path: string[] = val || [];
const newArea = extractAreaValue(path);
areaPath.value = path;
formData.area = newArea;
const paths: string[][] = Array.isArray(val) ? val : [];
areaPath.value = paths;
// 多选后只保留最下级的叶子节点,并用逗号分隔成字符串
const leafNames = extractLeafValues(areaCascaderOptions, paths);
formData.area = leafNames.join(',');
// 切换到非"全国"区域时清空已关联赛事
if (newArea !== '全国') {
if (formData.area !== '全国') {
formData.relatedEventId = undefined;
formRef.value?.clearValidate('relatedEventId');
}
// 区域变化时重新搜索(如果当前有关键词)
if (eventKeyword.value.trim()) {
if (searchTimer) clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
doSearch(eventKeyword.value);
}, 0);
}
// 区域变化时自动重新获取赛事列表
if (searchTimer) clearTimeout(searchTimer);
doSearch(eventKeyword.value);
};
// ---- 根据 record 初始化表单 ----
@@ -229,21 +258,30 @@ export default defineComponent({
const fresh = getDefaultForm();
if (!record) {
Object.assign(formData, fresh);
areaPath.value = ['全国'];
areaPath.value = [];
eventKeyword.value = '';
setEventOptions([]);
// 新增时默认跳转类型为"赛事详情页",自动加载赛事下拉
if (formData.type === '1') {
doSearch('');
}
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 areaNames = rawArea
.split(',')
.map((s: string) => s.trim())
.filter(Boolean);
const leafNames = areaNames.map((name: string) =>
name.includes('-') ? name.split('-').pop()! : name,
);
const paths: string[][] = leafNames.length
? leafNames.map((name: string) => cityNameToCascaderPath(name))
: [['全国']];
areaPath.value = paths;
const normalizedArea = leafNames.length ? leafNames.join(',') : '全国';
// 跳转类型
const type = String(record.type || record.jumpType || '1');
@@ -285,7 +323,7 @@ export default defineComponent({
type,
linkUrl: record.params || record.linkUrl || record.jumpUrl || '',
relatedEventId,
area: cityName,
area: normalizedArea,
displayTimeRange:
(record.showTimeBegin || record.displayStartTime) &&
(record.showTimeEnd || record.displayEndTime)
@@ -341,6 +379,12 @@ export default defineComponent({
if (activeNames.length) {
await formRef.value?.validateFields(activeNames).catch(() => {});
}
// 切换到"赛事详情页"时自动加载赛事下拉
if (formData.type === '1') {
if (searchTimer) clearTimeout(searchTimer);
doSearch(eventKeyword.value);
}
};
syncTypeFields();
@@ -614,8 +658,9 @@ export default defineComponent({
<Cascader
v-model:value={areaPath.value}
options={areaCascaderOptions}
multiple
placeholder="请选择所属区域"
allowClear={false}
allowClear
onChange={onAreaChange}
/>
</Form.Item>
+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;
}
}
+68 -2
View File
@@ -1,4 +1,4 @@
import { defineComponent } from 'vue';
import { defineComponent, ref, nextTick } from 'vue';
import {
Button,
Input,
@@ -12,10 +12,70 @@ import {
Image,
} from 'ant-design-vue';
import { useBannerModel, BANNER_STATUS_OPTIONS } from './model/useBannerModel';
import { useContainerSize } from '@/hooks';
import { useContainerSize, useState, useEffect } from '@/hooks';
import { usePermissionStore } from '@/stores/permissionStore';
import BannerFormModal from './components/BannerFormModal';
import pageStyles from '@/assets/styles/pageLayout.module.less';
import cellStyles from './index.module.less';
/**
* 所属区域单元格:最多 2 行截断,溢出时显示"查看"按钮
*/
const AreaCell = defineComponent({
name: 'AreaCell',
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.replace(/,/g, ' ')}
</div>
</Modal>
</div>
);
};
},
});
/**
* bodyCell 渲染函数
@@ -72,6 +132,12 @@ function renderBodyCell({
);
}
// 所属区域列
if (column.dataIndex === 'area') {
const raw = text || '';
return raw ? <AreaCell text={raw} /> : <span>--</span>;
}
// 跳转类型列
if (column.dataIndex === 'type') {
return renderJumpType(text);
@@ -116,7 +116,7 @@ export function useBannerModel() {
key: 'type',
width: 120,
},
{ title: '所属区域', dataIndex: 'area', key: 'area', width: 140 },
{ title: '所属区域', dataIndex: 'area', key: 'area', width: 300 },
{ title: '展示时间', key: 'displayTime', width: 260 },
{ title: '创建时间', dataIndex: 'createDate', key: 'createDate', width: 160 },
{ title: '状态', dataIndex: 'status', key: 'status', width: 100, align: 'center' as const },
+19 -18
View File
@@ -2,6 +2,7 @@ import { defineComponent } from 'vue';
import {
Button,
Input,
InputNumber,
Table,
Form,
Space,
@@ -20,8 +21,6 @@ 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';
@@ -270,15 +269,17 @@ export default defineComponent({
</Form.Item>
<Form.Item label="报名人数">
<Space.Compact>
<Input
value={filterForm.scBegin}
<InputNumber
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))}
style={{ width: '140px' }}
value={filterForm.scBegin || undefined}
precision={0}
step={0}
min={0}
controls={false}
onUpdate:value={(val: any) =>
(filterForm.scBegin = val != null ? String(val) : '')
}
onPressEnter={handleSearch}
/>
<Input
@@ -292,15 +293,15 @@ export default defineComponent({
value="至"
disabled
/>
<Input
value={filterForm.scEnd}
<InputNumber
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))}
style={{ width: '140px' }}
value={filterForm.scEnd || undefined}
precision={0}
step={0}
min={0}
controls={false}
onUpdate:value={(val: any) => (filterForm.scEnd = val != null ? String(val) : '')}
onPressEnter={handleSearch}
/>
</Space.Compact>
@@ -1,43 +0,0 @@
/** 报名人数筛选项 — 整数输入处理(页面特有逻辑) */
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();
}
}
+13 -1
View File
@@ -1,10 +1,22 @@
import { defineComponent, ref, nextTick } from 'vue';
import { DatePicker, Select, Table } from 'ant-design-vue';
import * as echarts from 'echarts';
import * as echarts from 'echarts/core';
import { BarChart, LineChart } from 'echarts/charts';
import { GridComponent, TooltipComponent, LegendComponent } from 'echarts/components';
import { CanvasRenderer } from 'echarts/renderers';
import { useReportsModel } from './model/useReportsModel';
import { useEffect, useContainerSize } from '@/hooks';
import styles from './index.module.less';
echarts.use([
BarChart,
LineChart,
GridComponent,
TooltipComponent,
LegendComponent,
CanvasRenderer,
]);
const { RangePicker } = DatePicker;
/**
+15 -3
View File
@@ -7,6 +7,12 @@ import { resolve } from 'path';
// https://vitejs.dev/config/
export default defineConfig(({ mode }) => {
// 增大 Node 内存限制,防止构建时 OOM
const v8Flags = '--max-old-space-size=4096';
if (!process.env.NODE_OPTIONS?.includes('max-old-space-size')) {
process.env.NODE_OPTIONS = [process.env.NODE_OPTIONS, v8Flags].filter(Boolean).join(' ');
}
const env = loadEnv(mode, process.cwd(), '');
const isProd = mode === 'production';
@@ -54,12 +60,10 @@ export default defineConfig(({ mode }) => {
build: {
outDir: 'dist',
sourcemap: mode !== 'production',
// 生产构建移除 console 和注释(esbuild minify 阶段)
minify: isProd ? 'esbuild' : false,
chunkSizeWarningLimit: 1500,
rollupOptions: {
output: {
// 更细粒度的分包,确保按需加载
manualChunks(id) {
if (id.includes('node_modules')) {
if (id.includes('ant-design-vue') || id.includes('@ant-design')) {
@@ -68,7 +72,15 @@ export default defineConfig(({ mode }) => {
if (id.includes('vue') || id.includes('@vue') || id.includes('vue-router')) {
return 'vue-vendor';
}
// 其他第三方依赖单独打包,未使用的不会进入
if (id.includes('echarts')) {
return 'echarts';
}
if (id.includes('china-area-data')) {
return 'area-data';
}
if (id.includes('big.js')) {
return 'bigjs';
}
return 'vendor';
}
},