update:退款&banner&用户订单

This commit is contained in:
ltx 2026-04-01 15:16:01 +08:00
parent 6186c7723c
commit 47ed9526b7
8 changed files with 1295 additions and 15 deletions

2
.env
View File

@ -8,4 +8,4 @@ VITE_OPEN = true
VITE_OPEN_CDN = false
# public path 配置线上环境路径(打包)、本地通过 http-server 访问时,请置空即可
VITE_PUBLIC_PATH = 'https://yuedong-wechatapplet.oss-cn-shanghai.aliyuncs.com/static/event_web/202603261000/'
VITE_PUBLIC_PATH = 'https://yuedong-wechatapplet.oss-cn-shanghai.aliyuncs.com/static/event_web/202604011515/'

138
src/api/match/banner.js Normal file
View File

@ -0,0 +1,138 @@
import { BaseApi } from '../baseApi';
import { http } from '../../utils/request';
// ==================== 轮播图服务 ====================
/**
* 获取轮播图配置模式
* @returns {Promise<{
* mode: number
* }>}
*/
export const bannerConfigGet = () => {
return http.get('/banner/config');
};
/**
* 更新轮播图配置模式
* @param {Object} data - 配置参数
* @param {number} data.mode - 配置模式 (0: 默认模式, 1: 自定义模式等)
*/
export const bannerConfigPut = (data) => {
return http.put('/banner/config', data);
};
/**
* 获取轮播图列表(前台展示用)
* @returns {Promise<Array<{
* id: number,
* title: string,
* imageUrl: string,
* linkType: string, // 链接类型 (Event=赛事链接, Url=外部链接)
* linkUrl: string, // 跳转链接
* eventId: number, // 关联赛事ID (linkType为Event时使用)
* sort: number, // 排序号
* status: string, // 状态 (Enable=启用, Disable=禁用)
* remark: string, // 备注
* createdTime: string
* }>>}
*/
export const bannerListGet = () => {
return http.get('/banner/list');
};
/**
* 分页查询(后台管理用)
* @param {Object} data - 查询参数
* @param {number} [data.pageNo] - 当前页码
* @param {number} [data.pageSize] - 每页条数
* @param {string} [data.title] - 标题 (模糊查询)
* @returns {Promise<{
* pageNo: number,
* pageSize: number,
* pages: number,
* total: number,
* rows: Array<{
* id: number,
* title: string,
* imageUrl: string,
* linkType: string,
* linkUrl: string,
* eventId: number,
* sort: number,
* status: string,
* remark: string,
* createdTime: string
* }>
* }>}
*/
export const bannerPagePost = (data) => {
return http.post('/banner/page', data);
};
/**
* 添加轮播图
* @param {Object} data - 轮播图信息
* @param {string} data.title - 标题
* @param {string} data.imageUrl - 图片URL
* @param {string} data.linkType - 链接类型 (Event=赛事链接, Url=外部链接)
* @param {string} [data.linkUrl] - 跳转链接 (linkType为Url时必填)
* @param {number} data.eventId - 关联赛事ID,字段名 eventId (linkType 为 Event 时必传)
* @param {number} [data.sort] - 排序号
* @param {string} [data.remark] - 备注
*/
export const bannerAddPost = (data) => {
return http.post('/banner', data);
};
/**
* 更新轮播图
* @param {Object} data - 轮播图信息
* @param {number} data.id - 轮播图ID
* @param {string} data.title - 标题
* @param {string} data.imageUrl - 图片URL
* @param {string} data.linkType - 链接类型
* @param {string} [data.linkUrl] - 跳转链接
* @param {number} data.eventId - 关联赛事ID,字段名 eventId (linkType 为 Event 时必传)
* @param {number} [data.sort] - 排序号
* @param {string} [data.remark] - 备注
* @param {string} [data.status] - 状态 (Enable, Disable)
*/
export const bannerEditPut = (data) => {
return http.put('/banner', data);
};
/**
* 批量删除轮播图
* @param {Array<number>} data - 轮播图ID数组
*/
export const bannerDelete = (data) => {
return http.delete('/banner', { data });
};
/**
* 排序
* @param {Array<number>} data - 轮播图ID数组 (按顺序排列)
*/
export const bannerSortPost = (data) => {
return http.post('/banner/sort', data);
};
/**
* 删除单个轮播图
* @param {Object} data - 删除参数
* @param {number} data.id - 轮播图ID
*/
export const bannerDeleteDelete = (data) => {
return http.delete('/banner/delete', { data });
};
/**
* 修改轮播图状态
* @param {Object} data - 状态参数
* @param {number} data.id - 轮播图ID
* @param {string} data.status - 状态 (Enable=启用, Disable=禁用)
*/
export const bannerSetstatusPatch = (data) => {
return http.patch('/banner/setstatus', data);
};

99
src/api/match/refund.js Normal file
View File

@ -0,0 +1,99 @@
import { BaseApi } from '../baseApi';
import { http } from '../../utils/request';
// ==================== 退款配置服务 ====================
/**
* 获取所有退款配置
* @returns {Promise<Array<{
* id: number,
* dayStart: number,
* dayEnd: number,
* deductPercentage: number,
* remark: string,
* status: string
* }>>}
*/
export const refundconfigListGet = () => {
return http.get('/refundconfig/list');
};
/**
* 分页查询退款配置
* @param {Object} params - 查询参数
* @param {string} [params.Status] - 状态 (Enable=启用, Disable=禁用)
* @param {number} [params.PageNo] - 当前页码
* @param {number} [params.PageSize] - 每页条数
* @returns {Promise<{
* pageNo: number,
* pageSize: number,
* pages: number,
* total: number,
* rows: Array<{
* id: number,
* dayStart: number,
* dayEnd: number,
* deductPercentage: number,
* remark: string,
* status: string
* }>
* }>}
*/
export const refundconfigPageGet = (params) => {
return http.get('/refundconfig/page', { params });
};
/**
* 添加退款配置
* @param {Object} data - 退款配置信息
* @param {number} [data.id] - 配置ID (添加时可不传)
* @param {number} data.dayStart - 开始天数 (如比赛开始前多少天)
* @param {number} data.dayEnd - 结束天数
* @param {number} data.deductPercentage - 扣除百分比 (如20表示扣除20%)
* @param {string} [data.remark] - 备注
* @param {string} [data.status] - 状态 (Enable=启用, Disable=禁用,默认Enable)
*/
export const refundconfigAddPost = (data) => {
return http.post('/refundconfig/add', data);
};
/**
* 更新退款配置
* @param {Object} data - 退款配置信息
* @param {number} data.id - 配置ID
* @param {number} data.dayStart - 开始天数
* @param {number} data.dayEnd - 结束天数
* @param {number} data.deductPercentage - 扣除百分比
* @param {string} [data.remark] - 备注
* @param {string} [data.status] - 状态 (Enable, Disable)
*/
export const refundconfigUpdatePut = (data) => {
return http.put('/refundconfig/update', data);
};
/**
* 批量删除退款配置
* @param {Array<number>} data - 配置ID数组
*/
export const refundconfigDeletePost = (data) => {
return http.post('/refundconfig/delete', data);
};
/**
* 删除单个退款配置
* @param {Object} data - 删除参数
* @param {number} data.id - 配置ID
*/
export const refundconfigDeleteDelete = (data) => {
return http.delete('/refundconfig/delete', { data });
};
/**
* 修改退款配置状态
* @param {Object} data - 状态参数
* @param {number} data.id - 配置ID
* @param {string} data.status - 状态 (Enable=启用, Disable=禁用)
*/
export const refundconfigSetstatusPatch = (data) => {
return http.patch('/refundconfig/setstatus', data);
};

View File

@ -416,6 +416,52 @@ export const dynamicRoutes: Array<RouteRecordRaw> = [
icon: 'iconfont icon-shouye',
},
},
//订单管理
{
path: '/user/order',
name: 'userorder',
component: () => import('/@/views/marchant/order/index.vue'),
meta: {
title: '订单管理',
isLink: '',
isHide: false,
isKeepAlive: false,
isAffix: false,
isIframe: false,
roles: ['admin', 'common'],
icon: 'iconfont icon-shouye',
},
},
{
path: '/banner',
name: 'banner',
component: () => import('/@/views/match/banner/index.vue'),
meta: {
title: '轮播图管理',
isLink: '',
isHide: false,
isKeepAlive: false,
isAffix: false,
isIframe: false,
roles: ['admin', 'common'],
icon: 'iconfont icon-shouye',
},
},
{
path: '/refund',
name: 'refund',
component: () => import('/@/views/match/refund/index.vue'),
meta: {
title: '退款配置',
isLink: '',
isHide: false,
isKeepAlive: false,
isAffix: false,
isIframe: false,
roles: ['admin', 'common'],
icon: 'iconfont icon-shouye',
},
},
//人员管理
{
path: '/user/personnel',

View File

@ -0,0 +1,161 @@
<template>
<div class="system-menu-container layout-padding">
<ProTable ref="tableRef" :request-api="orderPageGet" :columns="columns" :pagination="true"
:tool-button="true">
<!-- <template #tools>
<el-button v-auth="'order:export'" type="primary" icon="ele-Plus" @click="onOpenMenu"> 导出 </el-button>
</template> -->
<template #index="scope">
{{ scope.$index + 1 }}
</template>
<template #paymentMethod="scope">
{{ scope.row.paymentMethod }}
</template>
<template #action="scope">
<el-button size="small" text type="primary" @click="onOpenMenu(scope.row.id)">
查看详情 </el-button>
</template>
</ProTable>
<!-- 弹窗 -->
<el-dialog title="订单详情" v-model="isShowDialog" width="1100px">
<el-skeleton :rows="6" animated v-if="detailLoading" />
<el-empty v-else-if="!detailTableData.length" description="暂无订单明细数据" />
<el-table v-else :data="detailTableData" border>
<el-table-column prop="projectName" label="项目" align="center" />
<el-table-column prop="groupName" label="组别" align="center" />
<el-table-column prop="participantNames" label="参赛队员" align="center" show-overflow-tooltip />
<el-table-column prop="fee" label="费用(元)" align="center" width="120" />
</el-table>
<template #footer>
<span class="dialog-footer">
<el-button @click="isShowDialog = false">取消</el-button>
<el-button type="primary" @click="isShowDialog = false">确定</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script setup >
import { ref, computed } from 'vue';
import { ElMessage } from 'element-plus';
import ProTable from '/@/components/ProTable/index.vue';
import { orderPageGet , orderDetailGet} from '/@/api/match/order';
// 定义表单引用类型
const formRef = ref();
const formData = ref({
orderNo: '',
eventId: 0,
eventName: '',
registrationMode: 0,
teamName: '',
contactName: '',
contactPhone: '',
totalAmount: 0,
discountAmount: 0,
payableAmount: 0,
status: 0,
createdTime: '',
paidTime: '',
items:[],
persons:[]
})
const columns = ref([
{ prop: 'orderNo', label: '订单编号', align: 'center', search: { el: 'input', }, fixed: 'left', width: 150 },
{ prop: 'eventName', label: '所属赛事', search: { el: 'input', }, },
{ prop: 'paymentMethod', label: '支付方式', search: { el: 'select', }, enum: [{ label: '全部', value: 0 }, { label: '线上', value: 1 }, { label: '线下', value: 2 },], },
{ prop: 'actualAmount', label: '实付金额' },
{ prop: 'createdTime', label: '支付时间', search: { el: 'date-picker', props: { type: 'daterange' } } },
{ prop: 'orderStatus', label: '订单状态' },
{ prop: 'action', label: '操作', align: 'center', fixed: 'right', width: 120, },
]);
const isShowDialog = ref(false);
const dialogTitle = ref('新增');
const detailLoading = ref(false);
const normalizeNames = (value) => {
if (Array.isArray(value)) return value.filter(Boolean).join('、');
if (typeof value === 'string') return value;
return '';
};
const detailTableData = computed(() => {
const items = formData.value?.items ?? [];
const persons = formData.value?.persons ?? [];
return items.map((item) => {
const personNamesByItem = persons
.filter((p) => p?.itemId === item?.id || p?.projectId === item?.projectId)
.map((p) => p?.name)
.filter(Boolean);
const participantNames = normalizeNames(
item?.participantNames ?? item?.personNames ?? item?.memberNames ?? personNamesByItem
) || '-';
const fee = item?.subtotal ?? ((Number(item?.unitPrice) || 0) * (Number(item?.personCount) || 0));
return {
projectName: item?.projectName ?? '-',
groupName: item?.groupName ?? item?.projectTypeName ?? '-',
participantNames,
fee,
};
});
});
const onOpenMenu = async(id) => {
if (!id) return;
isShowDialog.value = true;
detailLoading.value = true;
const { succeeded, data } = await orderDetailGet({ id });
if (!succeeded) {
ElMessage.error('订单详情加载失败');
formData.value.items = [];
formData.value.persons = [];
detailLoading.value = false;
return;
}
formData.value = {
...formData.value,
...(data ?? {}),
items: data?.items ?? [],
persons: data?.persons ?? [],
};
detailLoading.value = false;
isShowDialog.value = true;
};
</script>
<style scoped lang="scss">
.dialog-footer {
margin-top: 80px;
}
/* 时长组合框样式 */
.duration-row {
display: flex;
align-items: center;
gap: 8px;
}
.duration-row .el-input {
width: 120px;
}
.duration-separator {
color: #606266;
font-size: 14px;
}
.duration-label {
color: #909399;
font-size: 14px;
}
</style>

View File

@ -0,0 +1,627 @@
<template>
<div class="banner-config layout-padding system-menu-container">
<el-card shadow="hover" class="banner-card">
<div class="mode-bar">
<el-radio-group v-model="configMode" size="large">
<el-radio-button :label="MODE_MANUAL">手动配置</el-radio-button>
<el-radio-button :label="MODE_AUTO">自动配置</el-radio-button>
</el-radio-group>
</div>
<!-- 自动 -->
<div v-if="configMode === MODE_AUTO" class="auto-panel" v-loading="loading">
<p class="auto-desc">默认跳转最新的 4 场赛事</p>
<div class="footer-actions">
<el-button type="primary" :loading="saving" @click="onSave">确定</el-button>
</div>
</div>
<!-- 手动 -->
<div v-else class="manual-layout" v-loading="loading">
<div class="manual-left">
<div class="left-head">
<h3 class="left-title">轮播图配置</h3>
<p class="left-tip">最多添加四张轮播图,拖动可调整轮播顺序</p>
</div>
<ul ref="slotListRef" class="slot-list">
<li
v-for="(slot, index) in slots"
:key="slot._key"
class="slot-item"
:class="{ 'is-active': selectedIndex === index }"
@click="selectedIndex = index"
>
<span class="drag-handle" title="拖动排序">⋮⋮</span>
<div class="slot-thumb">
<img v-if="slot.imageUrl" :src="slot.imageUrl" alt="" />
<el-icon v-else :size="32" class="slot-placeholder-icon"><Picture /></el-icon>
</div>
<span class="slot-idx">{{ index + 1 }}</span>
</li>
</ul>
</div>
<div class="manual-right">
<el-radio-group v-model="configTab" class="sub-tabs">
<el-radio-button label="image">配置图片</el-radio-button>
<el-radio-button label="event">配置赛事</el-radio-button>
</el-radio-group>
<div class="sub-panel">
<div v-show="configTab === 'image'" class="upload-wrap">
<UploadImg
:image-url="currentSlot.imageUrl"
width="100%"
height="200px"
border-radius="8px"
@update:image-url="onImageUrlUpdate"
>
<template #empty>
<el-icon :size="28"><Plus /></el-icon>
<span>上传</span>
</template>
</UploadImg>
</div>
<div v-show="configTab === 'event'" class="event-wrap">
<el-select
:model-value="eventSelectValue"
class="event-select"
filterable
remote
clearable
:remote-method="remoteSearchEvent"
:loading="eventLoading"
placeholder="搜索并选择关联赛事"
@update:model-value="onEventSelectModel"
@change="onEventChange"
@visible-change="onEventSelectVisible"
>
<el-option
v-for="opt in eventOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
<p class="event-hint">前台点击该轮播将跳转至所选赛事详情</p>
</div>
</div>
<div class="footer-actions">
<el-button type="primary" :loading="saving" @click="onSave">确定</el-button>
</div>
</div>
</div>
</el-card>
</div>
</template>
<script setup name="matchBanner">
import { ref, computed, onMounted, onBeforeUnmount, nextTick, watch } from 'vue';
import { ElMessage } from 'element-plus';
import { Picture, Plus } from '@element-plus/icons-vue';
import Sortable from 'sortablejs';
import UploadImg from '/@/components/Upload/Img.vue';
import {
bannerConfigGet,
bannerConfigPut,
bannerPagePost,
bannerAddPost,
bannerEditPut,
bannerDelete,
bannerSortPost,
} from '/@/api/match/banner';
import { eventPagePost, eventDetailGet } from '/@/api/match/event';
const MODE_AUTO = 0;
const MODE_MANUAL = 1;
let slotKeySeed = 0;
const nextSlotKey = () => ++slotKeySeed;
const createEmptySlot = () => ({
_key: nextSlotKey(),
id: undefined,
imageUrl: '',
eventId: null,
eventName: '',
title: '',
});
const configMode = ref(MODE_MANUAL);
const configTab = ref('image');
const loading = ref(false);
const saving = ref(false);
const selectedIndex = ref(0);
const slots = ref([createEmptySlot(), createEmptySlot(), createEmptySlot(), createEmptySlot()]);
const initialBannerIds = ref([]);
const slotListRef = ref(null);
let sortableInstance = null;
const eventLoading = ref(false);
const eventOptions = ref([]);
const currentSlot = computed(() => slots.value[selectedIndex.value] ?? slots.value[0]);
/** 与接口、el-option 的 value 统一为 number,避免 123 与 "123" 导致无法回显 */
function toEventId(v) {
if (v === null || v === undefined || v === '') return null;
const n = Number(v);
return Number.isFinite(n) && n > 0 ? n : null;
}
/** el-select 绑定值(必须为与 options 中 value 同类型) */
const eventSelectValue = computed(() => toEventId(currentSlot.value?.eventId));
function onEventSelectModel(v) {
const s = slots.value[selectedIndex.value];
if (!s) return;
s.eventId = toEventId(v);
}
/** 用于下拉展示:优先用已拉取的赛事名,其次从标题「轮播:xxx」推断 */
function labelForSlotEvent(s) {
const n = (s.eventName || '').trim();
if (n) return n;
const t = (s.title || '').trim();
if (t.startsWith('轮播:')) return t.slice(3).trim() || t;
return t || `赛事 #${s.eventId}`;
}
/** 把当前四个槽位里已选的赛事都放进 options,保证 el-select 能回显 */
function syncEventOptionsFromSlots() {
const map = new Map();
for (const s of slots.value) {
const id = toEventId(s.eventId);
if (id !== s.eventId) s.eventId = id;
if (id == null) continue;
map.set(id, { value: id, label: labelForSlotEvent(s) });
}
eventOptions.value = Array.from(map.values());
}
/** 按 eventId 拉取赛事名称,便于后台编辑时与列表一致 */
async function hydrateSlotEventNames() {
await Promise.all(
slots.value.map(async (s) => {
const id = toEventId(s.eventId);
if (id == null) return;
try {
const res = await eventDetailGet(id);
const name = res?.data?.eventName;
if (name) s.eventName = name;
} catch {
// 保留标题/占位文案
}
})
);
}
function updateSelectedIndexAfterDrag(oldIndex, newIndex, sel) {
if (sel === oldIndex) return newIndex;
if (oldIndex < newIndex) {
if (sel > oldIndex && sel <= newIndex) return sel - 1;
} else if (oldIndex > newIndex) {
if (sel >= newIndex && sel < oldIndex) return sel + 1;
}
return sel;
}
function initSortable() {
const el = slotListRef.value;
if (!el) return;
sortableInstance?.destroy();
sortableInstance = Sortable.create(el, {
animation: 180,
handle: '.drag-handle',
onEnd(evt) {
const { oldIndex, newIndex } = evt;
if (oldIndex == null || newIndex == null || oldIndex === newIndex) return;
const list = slots.value;
const moved = list.splice(oldIndex, 1)[0];
list.splice(newIndex, 0, moved);
selectedIndex.value = updateSelectedIndexAfterDrag(oldIndex, newIndex, selectedIndex.value);
},
});
}
async function remoteSearchEvent(query) {
eventLoading.value = true;
try {
const res = await eventPagePost({
pageNo: 1,
pageSize: 40,
...(query ? { eventName: query } : {}),
});
const rows = res?.data?.rows ?? [];
const fromApi = rows
.map((r) => {
const id = toEventId(r.id ?? r.eventId);
if (id == null) return null;
const label = r.eventName ?? r.EventName ?? '';
return { label, value: id };
})
.filter(Boolean);
const map = new Map(fromApi.map((o) => [o.value, o]));
for (const s of slots.value) {
const sid = toEventId(s.eventId);
if (sid == null) continue;
if (!map.has(sid)) {
map.set(sid, { value: sid, label: labelForSlotEvent(s) });
}
}
eventOptions.value = Array.from(map.values());
} catch {
syncEventOptionsFromSlots();
} finally {
eventLoading.value = false;
}
}
function onEventSelectVisible(visible) {
if (visible) remoteSearchEvent('');
}
function onEventChange(id) {
const nid = toEventId(id);
const row = eventOptions.value.find((o) => toEventId(o.value) === nid);
const s = slots.value[selectedIndex.value];
if (!s) return;
s.eventId = nid;
s.eventName = row?.label ?? '';
if (nid) s.title = row?.label ? `轮播:${row.label}` : `轮播图${selectedIndex.value + 1}`;
else s.title = '';
syncEventOptionsFromSlots();
}
function onImageUrlUpdate(url) {
const s = slots.value[selectedIndex.value];
if (s) s.imageUrl = url || '';
}
async function loadConfig() {
const res = await bannerConfigGet();
if (res?.succeeded && res.data && typeof res.data.mode === 'number') {
configMode.value = res.data.mode;
}
}
async function loadBanners() {
const res = await bannerPagePost({ pageNo: 1, pageSize: 20 });
const rows = res?.data?.rows ?? [];
const sorted = [...rows].sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0));
initialBannerIds.value = sorted.map((r) => r.id).filter(Boolean);
const next = [];
for (let i = 0; i < 4; i++) {
const r = sorted[i];
if (r) {
const rawEv = r.eventId ?? r.EventId;
next.push({
_key: nextSlotKey(),
id: r.id,
imageUrl: r.imageUrl || '',
eventId: toEventId(rawEv),
eventName: r.title || '',
title: r.title || `轮播图${i + 1}`,
});
} else {
next.push(createEmptySlot());
}
}
slots.value = next;
selectedIndex.value = 0;
await hydrateSlotEventNames();
syncEventOptionsFromSlots();
}
async function loadAll() {
loading.value = true;
try {
await loadConfig();
await loadBanners();
} finally {
loading.value = false;
}
}
function validateManual() {
const list = slots.value;
for (let i = 0; i < list.length; i++) {
const s = list[i];
const partial = !!(s.imageUrl || s.eventId);
if (partial && (!s.imageUrl || !s.eventId)) {
ElMessage.warning(`第 ${i + 1} 张需同时上传图片并选择关联赛事`);
selectedIndex.value = i;
return false;
}
}
const filled = list.filter((s) => s.imageUrl && s.eventId);
if (!filled.length) {
ElMessage.warning('请至少完整配置一张轮播图(图片 + 赛事)');
return false;
}
return true;
}
async function onSave() {
saving.value = true;
try {
if (configMode.value === MODE_AUTO) {
const res = await bannerConfigPut({ mode: MODE_AUTO });
if (res?.succeeded === false) return;
ElMessage.success('保存成功');
await loadConfig();
return;
}
if (!validateManual()) return;
const resMode = await bannerConfigPut({ mode: MODE_MANUAL });
if (resMode?.succeeded === false) return;
const filledSlots = slots.value.filter((s) => s.imageUrl && s.eventId);
const keptIds = new Set();
for (let i = 0; i < filledSlots.length; i++) {
const s = filledSlots[i];
const sort = i + 1;
// 接口要求关联赛事使用 eventId 字段(数字)
const eventId = Number(s.eventId);
if (!Number.isFinite(eventId) || eventId <= 0) {
ElMessage.warning(`第 ${sort} 条轮播的关联赛事无效,请重新选择`);
return;
}
const payload = {
title: s.title || s.eventName || `轮播图${sort}`,
imageUrl: s.imageUrl,
linkType: 'Event',
eventId,
sort,
status: 'Enable',
};
if (s.id) {
const r = await bannerEditPut({ id: s.id, ...payload });
if (r?.succeeded === false) return;
keptIds.add(s.id);
} else {
const r = await bannerAddPost(payload);
if (r?.succeeded === false) return;
const raw = r?.data;
const newId = typeof raw === 'number' ? raw : raw?.id;
if (typeof newId === 'number') keptIds.add(newId);
}
}
const toRemove = initialBannerIds.value.filter((id) => !keptIds.has(id));
if (toRemove.length) {
const r = await bannerDelete(toRemove);
if (r?.succeeded === false) return;
}
loading.value = true;
try {
await loadBanners();
const orderedIds = slots.value.map((s) => s.id).filter((id) => id != null);
if (orderedIds.length) {
const rSort = await bannerSortPost(orderedIds);
if (rSort?.succeeded === false) return;
await loadBanners();
}
} finally {
loading.value = false;
}
ElMessage.success('保存成功');
} catch {
ElMessage.error('保存失败,请稍后重试');
} finally {
saving.value = false;
}
}
watch(
() => selectedIndex.value,
() => {
syncEventOptionsFromSlots();
}
);
watch(
() => configTab.value,
(tab) => {
if (tab === 'event' && configMode.value === MODE_MANUAL) {
syncEventOptionsFromSlots();
}
}
);
watch(
() => configMode.value,
async (m) => {
if (m === MODE_MANUAL) {
await nextTick();
initSortable();
}
}
);
onMounted(async () => {
await loadAll();
await nextTick();
if (configMode.value === MODE_MANUAL) initSortable();
});
onBeforeUnmount(() => {
sortableInstance?.destroy();
sortableInstance = null;
});
</script>
<style scoped lang="scss">
.banner-config {
min-height: 100%;
}
.banner-card {
max-width: 960px;
}
.mode-bar {
margin-bottom: 24px;
}
.auto-panel {
padding: 24px 8px 8px;
min-height: 200px;
}
.auto-desc {
color: var(--el-text-color-regular);
font-size: 14px;
margin: 0 0 32px;
line-height: 1.6;
}
.manual-layout {
display: flex;
gap: 28px;
align-items: flex-start;
flex-wrap: wrap;
}
.manual-left {
flex: 0 0 260px;
max-width: 100%;
}
.left-head {
margin-bottom: 16px;
}
.left-title {
margin: 0 0 8px;
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.left-tip {
margin: 0;
font-size: 12px;
color: var(--el-text-color-secondary);
line-height: 1.5;
}
.slot-list {
list-style: none;
margin: 0;
padding: 0;
}
.slot-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
margin-bottom: 10px;
border: 1px solid var(--el-border-color);
border-radius: 8px;
cursor: pointer;
background: var(--el-fill-color-blank);
transition:
border-color 0.2s,
background 0.2s;
&:hover {
border-color: var(--el-color-primary-light-5);
}
&.is-active {
border-color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
}
}
.drag-handle {
cursor: grab;
color: var(--el-text-color-placeholder);
font-size: 12px;
user-select: none;
line-height: 1;
letter-spacing: -2px;
&:active {
cursor: grabbing;
}
}
.slot-thumb {
width: 72px;
height: 44px;
border-radius: 4px;
overflow: hidden;
background: var(--el-fill-color-light);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.slot-placeholder-icon {
color: var(--el-text-color-placeholder);
}
.slot-idx {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-left: auto;
}
.manual-right {
flex: 1;
min-width: 280px;
display: flex;
flex-direction: column;
gap: 16px;
}
.sub-tabs {
width: 100%;
}
.sub-panel {
min-height: 220px;
padding: 8px 0;
}
.upload-wrap {
max-width: 480px;
}
.event-select {
width: 100%;
max-width: 480px;
}
.event-hint {
margin-top: 12px;
font-size: 12px;
color: var(--el-text-color-secondary);
line-height: 1.5;
}
.footer-actions {
display: flex;
justify-content: center;
padding-top: 8px;
margin-top: auto;
}
</style>

View File

@ -8,6 +8,15 @@
<template #index="scope">
{{ scope.$index + 1 }}
</template>
<template #paymentMethod="scope">
{{ scope.row.paymentMethod }}
</template>
<template #orderType="scope">
{{ scope.row.paymentMethod }}
</template>
<template #registrationMode="scope">
{{ scope.row.paymentMethod }}
</template>
<template #action="scope">
<el-button size="small" text type="primary" @click="onOpenMenu(scope.row.id)">
查看详情 </el-button>
@ -65,20 +74,20 @@ const formData = ref({
const columns = ref([
{ prop: 'orderNo', label: '订单编号', align: 'center', search: { el: 'input', }, fixed: 'left', width: 150 },
{ prop: 'unitName', label: '单位名称', align: 'center', },
{ prop: 'eventName', label: '所属赛事', search: { el: 'input', }, },
{ prop: 'orderType', label: '订单类型', search: { el: 'select', }, enum: [{ label: '全部', value: 0 }, { label: '收款', value: 1 }, { label: '退款', value: 2 },], },
{ prop: 'relatedOrderNo', label: '关联订单' },
{ prop: 'paymentMethod', label: '支付方式', search: { el: 'select', }, enum: [{ label: '全部', value: 0 }, { label: '线上', value: 1 }, { label: '线下', value: 2 },], },
{ prop: 'paymentProofUrl', label: '付款凭证' },
{ prop: 'registrationMode', label: '报名方式', search: { el: 'select', }, enum: [{ label: '全部', value: 0 }, { label: '个人', value: 1 }, { label: '团队', value: 2 },], },
{ prop: 'contactName', label: '报名用户' },
{ prop: 'contactPhone', label: '报名账号', search: { el: 'input' } },
{ prop: 'originalAmount', label: '原金额' },
{ prop: 'discountAmount', label: '优惠金额' },
{ prop: 'actualAmount', label: '实付金额' },
{ prop: 'orderStatus', label: '订单状态' },
{ prop: 'createdTime', label: '生成时间', search: { el: 'date-picker', props: { type: 'daterange' } } },
{ width: 180, prop: 'unitName', label: '单位名称', align: 'center', },
{ width: 120, prop: 'eventName', label: '所属赛事', search: { el: 'input', }, },
{ width: 120, prop: 'orderType', label: '订单类型', search: { el: 'select', }, enum: [{ label: '全部', value: 0 }, { label: '收款', value: 1 }, { label: '退款', value: 2 },], },
{ width: 120, prop: 'relatedOrderNo', label: '关联订单' },
{ width: 120, prop: 'paymentMethod', label: '支付方式', search: { el: 'select', }, enum: [{ label: '全部', value: 0 }, { label: '线上', value: 1 }, { label: '线下', value: 2 },], },
{ width: 120, prop: 'paymentProofUrl', label: '付款凭证' },
{ width: 120, prop: 'registrationMode', label: '报名方式', search: { el: 'select', }, enum: [{ label: '全部', value: 0 }, { label: '个人', value: 1 }, { label: '团队', value: 2 },], },
{ width: 120, prop: 'contactName', label: '报名用户' },
{ width: 120, prop: 'contactPhone', label: '报名账号', search: { el: 'input' } },
{ width: 120, prop: 'originalAmount', label: '原金额' },
{ width: 120, prop: 'discountAmount', label: '优惠金额' },
{ width: 120, prop: 'actualAmount', label: '实付金额' },
{ width: 120, prop: 'orderStatus', label: '订单状态' },
{ width: 120, prop: 'createdTime', label: '生成时间', search: { el: 'date-picker', props: { type: 'daterange' } } },
{ prop: 'action', label: '操作', align: 'center', fixed: 'right', width: 120, },
]);

View File

@ -0,0 +1,200 @@
<template>
<div class="refund-config layout-padding system-menu-container">
<el-card shadow="hover" class="refund-card" v-loading="loading">
<h3 class="page-title">退款配置</h3>
<p class="page-desc">按距开赛天数区间设置退款时扣除比例(百分比)</p>
<el-form label-position="right" label-width="200px" class="refund-form">
<el-form-item v-for="row in rows" :key="row.key" :label="row.label">
<div class="pct-row">
<el-input
v-model="row.deductPercentage"
class="pct-input"
placeholder="请输入"
clearable
@input="onPctInput(row)"
/>
<span class="pct-suffix">%</span>
</div>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="saving" @click="onSave">保存</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</template>
<script setup name="matchRefund">
import { ref, onMounted } from 'vue';
import { ElMessage } from 'element-plus';
import {
refundconfigListGet,
refundconfigAddPost,
refundconfigUpdatePut,
} from '/@/api/match/refund';
/** 固定三条区间:开赛前天数 [dayStart, dayEnd](含) */
const ROW_SPECS = [
{ key: 'before7', label: '开赛前7天扣除金额', dayStart: 7, dayEnd: 999 },
{ key: 'd3to7', label: '3-7天扣除金额', dayStart: 3, dayEnd: 7 },
{ key: 'within3', label: '3天内扣除金额', dayStart: 0, dayEnd: 3 },
];
function normalizeItem(raw) {
const id = raw?.id ?? raw?.Id;
const dayStart = Number(raw?.dayStart ?? raw?.DayStart);
const dayEnd = Number(raw?.dayEnd ?? raw?.DayEnd);
const deductPercentage = raw?.deductPercentage ?? raw?.DeductPercentage;
return {
id: id != null ? Number(id) : undefined,
dayStart,
dayEnd,
deductPercentage: deductPercentage == null || deductPercentage === '' ? '' : String(deductPercentage),
};
}
function extractList(res) {
const d = res?.data;
if (Array.isArray(d)) return d;
if (Array.isArray(d?.rows)) return d.rows;
return [];
}
function findByRange(list, dayStart, dayEnd) {
return list.find((item) => {
const n = normalizeItem(item);
return n.dayStart === dayStart && n.dayEnd === dayEnd;
});
}
const rows = ref(
ROW_SPECS.map((s) => ({
...s,
id: undefined,
deductPercentage: '',
}))
);
const loading = ref(false);
const saving = ref(false);
function onPctInput(row) {
let v = String(row.deductPercentage ?? '').replace(/[^\d.]/g, '');
const parts = v.split('.');
if (parts.length > 2) v = `${parts[0]}.${parts.slice(1).join('')}`;
if (parts[0]?.length > 1 && parts[0].startsWith('0') && !v.includes('.')) {
v = String(Number(parts[0]));
}
row.deductPercentage = v;
}
function parsePct(row) {
const n = Number(row.deductPercentage);
if (row.deductPercentage === '' || row.deductPercentage == null || Number.isNaN(n)) return null;
return n;
}
async function load() {
loading.value = true;
try {
const res = await refundconfigListGet();
const list = extractList(res).map((x) => normalizeItem(x));
for (const row of rows.value) {
const hit = findByRange(list, row.dayStart, row.dayEnd);
if (hit && hit.id != null) {
row.id = hit.id;
row.deductPercentage =
hit.deductPercentage === '' || hit.deductPercentage == null ? '' : String(hit.deductPercentage);
} else {
row.id = undefined;
row.deductPercentage = '';
}
}
} finally {
loading.value = false;
}
}
async function onSave() {
for (const row of rows.value) {
const n = parsePct(row);
if (n === null) {
ElMessage.warning(`请填写「${row.label}」的扣除比例`);
return;
}
if (n < 0 || n > 100) {
ElMessage.warning(`「${row.label}」扣除比例需在 0~100 之间`);
return;
}
}
saving.value = true;
try {
const tasks = rows.value.map(async (row) => {
const n = parsePct(row);
const payload = {
dayStart: row.dayStart,
dayEnd: row.dayEnd,
deductPercentage: n,
status: 'Enable',
remark: row.label,
};
if (row.id != null) {
return refundconfigUpdatePut({ ...payload, id: row.id });
}
return refundconfigAddPost(payload);
});
const results = await Promise.all(tasks);
const bad = results.find((r) => r && r.succeeded === false);
if (bad) {
ElMessage.error(bad.errors || '保存失败');
return;
}
ElMessage.success('保存成功');
await load();
} catch {
ElMessage.error('保存失败');
} finally {
saving.value = false;
}
}
onMounted(() => {
load();
});
</script>
<style scoped lang="scss">
.refund-config {
.refund-card {
max-width: 640px;
}
.page-title {
margin: 0 0 8px;
font-size: 18px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.page-desc {
margin: 0 0 24px;
font-size: 13px;
color: var(--el-text-color-secondary);
}
.refund-form {
.pct-row {
display: inline-flex;
align-items: center;
gap: 8px;
}
.pct-input {
width: 220px;
}
.pct-suffix {
color: var(--el-text-color-regular);
user-select: none;
}
}
}
</style>