Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1e9b013065 | |||
| 5f6325689f | |||
| 48ab5d013b | |||
| ce1a5e3f1f | |||
| 8c023e63ec | |||
| a581f64ccb | |||
| 8c317d5b84 | |||
| 91d4410c2a | |||
| 045e491ade | |||
| 64fdaba93d | |||
| 50d5f54015 | |||
| a77592dec6 | |||
| 00867ff960 | |||
| 4c08bd1f8c | |||
| ab6f5c787e | |||
| 79a7ea8c56 | |||
| b5ba0ca3fb | |||
| 9b5e2807b5 | |||
| 3a30e81183 | |||
| 28386f5429 | |||
| b0b8689acb | |||
| 8012cb82fc | |||
| 73b2da2d1e | |||
| 6f8e5036f7 | |||
| daab8ec41a | |||
| b513d701d8 | |||
| f8eaa554b3 | |||
| 9cee70bc62 | |||
| ffd917815d | |||
| 3841fcc7e5 | |||
| ac7bfeacfe |
+4
-1
@@ -1,2 +1,5 @@
|
||||
# 生产环境
|
||||
VITE_API_BASE_URL = https://api.cpms.example.com/admin
|
||||
VITE_API_BASE_URL = https://cpmsapi.yunvip123.com
|
||||
|
||||
# 是否开启本地调试
|
||||
LOCAL_DEBUG=false
|
||||
|
||||
Generated
+1721
File diff suppressed because it is too large
Load Diff
+10
-1
@@ -6,10 +6,15 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"serve": "vite",
|
||||
"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",
|
||||
"preview": "vite preview",
|
||||
"preview:prod": "pnpm build:prod && vite preview",
|
||||
"type-check": "vue-tsc --noEmit",
|
||||
"lint": "eslint src --ext .js,.ts,.tsx",
|
||||
"lint:fix": "eslint src --ext .js,.ts,.tsx --fix",
|
||||
@@ -42,17 +47,21 @@
|
||||
"@typescript-eslint/parser": "^6.0.0",
|
||||
"@vitejs/plugin-vue": "^4.5.0",
|
||||
"@vitejs/plugin-vue-jsx": "^4.0.0",
|
||||
"@vitest/coverage-v8": "2.1.9",
|
||||
"@vue/test-utils": "^2.4.11",
|
||||
"eslint": "^8.0.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-plugin-prettier": "^5.0.0",
|
||||
"eslint-plugin-vue": "^9.0.0",
|
||||
"husky": "^9.1.7",
|
||||
"jsdom": "^19.0.0",
|
||||
"less": "^4.6.4",
|
||||
"lint-staged": "^15.0.0",
|
||||
"prettier": "^3.0.0",
|
||||
"typescript": "^5.3.0",
|
||||
"vite": "^5.0.0",
|
||||
"vite-plugin-remove-console": "^2.2.0",
|
||||
"vitest": "^2.1.9",
|
||||
"vue-tsc": "^2.0.0"
|
||||
},
|
||||
"lint-staged": {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# ============================================================
|
||||
# CPMS 运营平台 - 权限标识字符串列表
|
||||
# 生成规则:
|
||||
# 1. 每个页面都有一个 "页面名-查看" 的默认权限
|
||||
# 2. 表格操作列中的按钮生成对应操作权限
|
||||
# 3. 同一关键字的两个状态按钮(如上架/下架、启用/禁用)合并为一个权限
|
||||
# 4. 筛选栏中有"新增"按钮的页面,生成新增权限
|
||||
# 5. 标识为英文,附带中文说明(含按钮所在位置)
|
||||
# ============================================================
|
||||
|
||||
# ─── 赛事管理模块 ─────────────────────────────────────────
|
||||
|
||||
events.list.view # 赛事列表-查看(页面默认查看权限)
|
||||
events.list.toggle_status # 赛事列表-上架/下架(表格操作列,根据赛事状态显示"上架"或"下架"按钮,合并为一个权限)
|
||||
events.regulation.view # 赛事规程-查看(赛事列表表格中的"赛事规程"列,点击"查看"链接)
|
||||
|
||||
events.orders.view # 订单管理-查看(页面默认查看权限)
|
||||
events.orders.refund # 订单管理-重新退款(表格操作列,退款状态为3时显示"重新退款"按钮)
|
||||
|
||||
events.users.view # 用户列表-查看(页面默认查看权限)
|
||||
events.users.toggle_status # 用户列表-启用/禁用(表格操作列,根据用户状态显示"启用"或"禁用"按钮,合并为一个权限)
|
||||
|
||||
events.banner.view # Banner配置-查看(页面默认查看权限)
|
||||
events.banner.add # Banner配置-新增(筛选栏中的"新增"按钮)
|
||||
events.banner.edit # Banner配置-编辑(表格操作列中的"编辑"按钮)
|
||||
events.banner.toggle_status # Banner配置-启用/禁用(表格操作列,根据状态显示"启用"或"禁用"按钮,合并为一个权限)
|
||||
events.banner.delete # Banner配置-删除(表格操作列中的"删除"按钮,红色危险样式)
|
||||
|
||||
events.logs.view # 赛事操作日志-查看(页面默认查看权限,无操作列按钮)
|
||||
|
||||
# ─── 账务管理模块 ─────────────────────────────────────────
|
||||
|
||||
finance.withdraw.view # 提现申请-查看(页面默认查看权限)
|
||||
finance.withdraw.audit # 提现申请-审核(表格操作列,审核状态为"待审核"时显示"审核"按钮)
|
||||
|
||||
finance.wallet.view # 用户钱包-查看(页面默认查看权限)
|
||||
finance.wallet.detail # 用户钱包-查看明细(表格操作列中的"明细"按钮,打开交易明细弹窗)
|
||||
|
||||
finance.payments.view # 支付流水-查看(页面默认查看权限,无操作列按钮,仅展示数据+金额汇总)
|
||||
|
||||
finance.reports.view # 账务报表-查看(页面默认查看权限,无操作列按钮,展示统计卡+趋势图+明细表)
|
||||
|
||||
# ─── 系统管理模块 ─────────────────────────────────────────
|
||||
|
||||
system.users.view # 用户管理-查看(页面默认查看权限)
|
||||
system.users.add # 用户管理-新增(筛选栏中的"新增"按钮)
|
||||
system.users.edit # 用户管理-编辑(表格操作列中的"编辑"按钮)
|
||||
system.users.toggle_status # 用户管理-启用/禁用(表格操作列,根据用户状态显示"启用"或"禁用"按钮,合并为一个权限)
|
||||
|
||||
system.roles.view # 角色权限-查看(页面默认查看权限)
|
||||
system.roles.add # 角色权限-新增(筛选栏中的"新增角色"按钮)
|
||||
system.roles.edit # 角色权限-编辑(表格操作列中的"编辑"按钮)
|
||||
system.roles.delete # 角色权限-删除(表格操作列中的"删除"按钮,红色危险样式)
|
||||
|
||||
system.logs.view # 操作日志-查看(页面默认查看权限,无操作列按钮)
|
||||
|
||||
system.params.view # 参数配置-查看
|
||||
Generated
+1045
-1
File diff suppressed because it is too large
Load Diff
@@ -76,6 +76,10 @@ export interface EventListQueryParams {
|
||||
idCardImg?: string;
|
||||
/** 是否要求提供证件号码;0=无需,1=需要 */
|
||||
idCardNo?: string;
|
||||
/** 报名人数起始(含) */
|
||||
scBegin?: string;
|
||||
/** 报名人数结束(含) */
|
||||
scEnd?: string;
|
||||
}
|
||||
|
||||
/** 分页数据 */
|
||||
|
||||
@@ -101,4 +101,6 @@ export interface WithdrawInfoVO {
|
||||
auditImg: string;
|
||||
/** 审核图片列表 */
|
||||
auditImgList: string[];
|
||||
/** 审核结果 */
|
||||
checkMsg: string;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -83,13 +83,37 @@
|
||||
}
|
||||
|
||||
.divisionList {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 12px;
|
||||
}
|
||||
|
||||
.coverWrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
|
||||
:global(.ant-image) {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.divisionItem {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
padding: 4px 12px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: #1677ff;
|
||||
background: #e6f4ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,6 +81,13 @@ const ID_CARD_IMG_COLUMN: any = {
|
||||
record ? renderIdImages(record.idCardImgList || [], record.idCardImg || '') : <span>--</span>,
|
||||
};
|
||||
|
||||
type ConditionalColumnKey = 'isRequireIdCardNo' | 'isRequireIdCardImg';
|
||||
|
||||
const CONDITIONAL_PLAYER_COLUMNS: { key: ConditionalColumnKey; column: any }[] = [
|
||||
{ key: 'isRequireIdCardNo', column: ID_CARD_COLUMN },
|
||||
{ key: 'isRequireIdCardImg', column: ID_CARD_IMG_COLUMN },
|
||||
];
|
||||
|
||||
export default defineComponent({
|
||||
name: 'EventDetailModal',
|
||||
props: {
|
||||
@@ -94,12 +101,12 @@ export default defineComponent({
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
|
||||
/** 动态列:根据赛事是否要求证件号/证件图片决定显示 */
|
||||
const playerColumns = computed(() => {
|
||||
const cols = [...BASE_PLAYER_COLUMNS];
|
||||
if (detail.value?.isRequireIdCardNo === '1') cols.push(ID_CARD_COLUMN);
|
||||
if (detail.value?.isRequireIdCardImg === '1') cols.push(ID_CARD_IMG_COLUMN);
|
||||
return cols;
|
||||
});
|
||||
const playerColumns = computed(() => [
|
||||
...BASE_PLAYER_COLUMNS,
|
||||
...CONDITIONAL_PLAYER_COLUMNS.filter(({ key }) => detail.value?.[key] === '1').map(
|
||||
({ column }) => column,
|
||||
),
|
||||
]);
|
||||
|
||||
// ===== 每个组别的选手数据(key = categoryId) =====
|
||||
const categoryPlayers = ref<Record<string, TournamentAdminInfoSignupVO[]>>({});
|
||||
@@ -206,7 +213,9 @@ export default defineComponent({
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label="赛事说明" span={3}>
|
||||
{detail.value.descr}
|
||||
<div style={{ whiteSpace: 'pre-wrap', maxHeight: '280px', overflowY: 'auto' }}>
|
||||
{detail.value.descr}
|
||||
</div>
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label="报名起止时间" span={3} class={styles.wideLabel}>
|
||||
@@ -261,11 +270,13 @@ export default defineComponent({
|
||||
{/* ===== 封面 ===== */}
|
||||
<div class={styles.section}>
|
||||
<div class={styles.sectionTitle}>封面</div>
|
||||
<Image
|
||||
style={{ maxHeight: '380px' }}
|
||||
src={detail.value.img || defaultCover}
|
||||
fallback={defaultCover}
|
||||
/>
|
||||
<div class={styles.coverWrapper}>
|
||||
<Image
|
||||
style={{ maxHeight: '380px' }}
|
||||
src={detail.value.img || defaultCover}
|
||||
fallback={defaultCover}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ===== 赛事公告 ===== */}
|
||||
|
||||
@@ -25,6 +25,8 @@
|
||||
padding: 12px 16px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #f0f0f0;
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.imageGrid {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { defineComponent } from 'vue';
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
InputNumber,
|
||||
Table,
|
||||
Form,
|
||||
Space,
|
||||
@@ -89,7 +90,7 @@ export default defineComponent({
|
||||
const renderAction = (record: any) => {
|
||||
const status = record.status;
|
||||
|
||||
if (status === '0' || status === '1') {
|
||||
if (['0', '1', '2'].includes(status)) {
|
||||
return (
|
||||
<Space>
|
||||
<Button type="link" size="small" onClick={() => openDetailModal(record)}>
|
||||
@@ -266,6 +267,45 @@ export default defineComponent({
|
||||
onUpdate:value={(val: any) => (filterForm.region = val || [])}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="报名人数">
|
||||
<Space.Compact>
|
||||
<InputNumber
|
||||
placeholder="请输入"
|
||||
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
|
||||
style={{
|
||||
width: '40px',
|
||||
textAlign: 'center',
|
||||
backgroundColor: '#fafafa',
|
||||
pointerEvents: 'none',
|
||||
color: 'rgba(0,0,0,0.45)',
|
||||
}}
|
||||
value="至"
|
||||
disabled
|
||||
/>
|
||||
<InputNumber
|
||||
placeholder="请输入"
|
||||
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>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" onClick={handleSearch} loading={loading.value}>
|
||||
|
||||
@@ -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) */
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -58,11 +58,7 @@ const PLAYER_COLUMNS = [
|
||||
key: 'idCardImg',
|
||||
width: 170,
|
||||
customRender: ({ record }: { record: TournamentAdminOrderInfoPageVO }) => {
|
||||
const urls = record.idCardImgList?.length
|
||||
? record.idCardImgList
|
||||
: record.idCardImg
|
||||
? [record.idCardImg]
|
||||
: [];
|
||||
const urls = record.idCardImgList || [];
|
||||
if (!urls.length) return <span>--</span>;
|
||||
return (
|
||||
<Space>
|
||||
|
||||
@@ -180,7 +180,7 @@ export default defineComponent({
|
||||
},
|
||||
{
|
||||
key: 'totalBalance',
|
||||
label: '平台余额(所有用户余额总和)',
|
||||
label: '平台余额(所有用户余额总和 = 总收入 - 提现金额)',
|
||||
value: s.totalBalance,
|
||||
bgColor: '#e6f4ff',
|
||||
accentColor: '#1677ff',
|
||||
@@ -224,7 +224,11 @@ export default defineComponent({
|
||||
<div class={styles.statValue} style={{ color: item.accentColor }}>
|
||||
¥{formatMoney(item.value)}
|
||||
</div>
|
||||
{/* 趋势文字暂时隐藏 */}
|
||||
{/* {item.key === 'totalBalance' && (
|
||||
<div class={styles.statDesc} style={{ color: 'rgba(0, 0, 0, 0.45)' }}>
|
||||
= 总收入 - 提现金额
|
||||
</div>
|
||||
)} */}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -126,7 +126,9 @@ export function useReportsModel() {
|
||||
// ===== 图表数据 =====
|
||||
const chartData = computed(() => {
|
||||
const list = data.value?.monthList || [];
|
||||
return list.map((item) => ({
|
||||
// 按月份升序排列,确保图表 X 轴时间顺序正确
|
||||
const sorted = [...list].sort((a, b) => a.month.localeCompare(b.month));
|
||||
return sorted.map((item) => ({
|
||||
month: dayjs(item.month).format('MM月'),
|
||||
order: toNumber(item.orderAmount),
|
||||
refund: toNumber(item.refundAmount),
|
||||
|
||||
@@ -160,7 +160,8 @@ export default defineComponent({
|
||||
placeholder="请输入"
|
||||
style={{ width: '140px' }}
|
||||
value={filterForm.minBalance || undefined}
|
||||
precision={0}
|
||||
precision={2}
|
||||
step={0.01}
|
||||
min={0}
|
||||
controls={false}
|
||||
onUpdate:value={(val: any) =>
|
||||
|
||||
@@ -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}>
|
||||
@@ -267,6 +301,7 @@ export default defineComponent({
|
||||
<div class={styles.infoColumn}>
|
||||
{renderItem('审核时间:', d.auditDate)}
|
||||
{renderItem('审核人:', d.auditNickname)}
|
||||
{d.checkMsg && renderItem('审核内容:', d.checkMsg)}
|
||||
<div class={styles.descItem}>
|
||||
<span class={styles.descLabel}>审核图片:</span>
|
||||
{d.auditImgList?.length ? (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
|
||||
@@ -60,15 +60,15 @@ export default defineComponent({
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
await formRef.value?.validate();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
props.onSave({
|
||||
paramCode: formData.paramCode,
|
||||
paramValue: formData.paramValue,
|
||||
remark: formData.remark,
|
||||
});
|
||||
props.onSave({
|
||||
paramCode: formData.paramCode,
|
||||
paramValue: formData.paramValue,
|
||||
remark: formData.remark,
|
||||
});
|
||||
} catch {
|
||||
// 校验未通过,Form 已展示错误,无需处理
|
||||
}
|
||||
};
|
||||
|
||||
/** 关闭 */
|
||||
@@ -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>
|
||||
|
||||
@@ -1,36 +1,33 @@
|
||||
// 参数值单元格:文字最多 2 行省略,末尾带"查看"链接
|
||||
:global {
|
||||
.param-value-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
cursor: default;
|
||||
}
|
||||
.cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.param-value-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);
|
||||
}
|
||||
.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);
|
||||
}
|
||||
|
||||
.param-value-view {
|
||||
flex-shrink: 0;
|
||||
color: #1677ff;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
line-height: 1.5;
|
||||
white-space: nowrap;
|
||||
.view {
|
||||
flex-shrink: 0;
|
||||
color: #1677ff;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
line-height: 1.5;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
&:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,14 +5,17 @@ import { saveParam, updateParam } from './model/services';
|
||||
import { useState, useThrottleFn, useContainerSize, useEffect } from '@/hooks';
|
||||
import ParamFormModal from './components/ParamFormModal';
|
||||
import pageStyles from '@/assets/styles/pageLayout.module.less';
|
||||
import './index.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);
|
||||
@@ -38,18 +41,18 @@ const ParamValueCell = defineComponent({
|
||||
return () => {
|
||||
const raw = props.text;
|
||||
return (
|
||||
<div class="param-value-cell">
|
||||
<div ref={textRef} class="param-value-text">
|
||||
<div class={cellStyles.cell}>
|
||||
<div ref={textRef} class={cellStyles.text}>
|
||||
{raw}
|
||||
</div>
|
||||
{overflow.value ? (
|
||||
<span class="param-value-view" onClick={() => setModalOpen(true)}>
|
||||
<span class={cellStyles.view} onClick={() => setModalOpen(true)}>
|
||||
查看
|
||||
</span>
|
||||
) : 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,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
Vendored
+1
-1
@@ -1,4 +1,4 @@
|
||||
declare module 'china-area-data' {
|
||||
declare module 'china-area-data/data.json' {
|
||||
const data: Record<string, Record<string, string>>;
|
||||
export default data;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import areaData from 'china-area-data';
|
||||
import areaData from 'china-area-data/data.json';
|
||||
|
||||
export interface CascadeOption {
|
||||
value: string;
|
||||
|
||||
+3
-3
@@ -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, '');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user