YD_Event.Web/src/views/match/manage/Steps/Step3ProjectConfig.vue
2026-04-14 10:26:25 +08:00

600 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="step-config">
<el-form ref="formRef" :model="localFormData" :rules="rules" label-position="top">
<el-card shadow="never" style="margin-bottom: 40px">
<template #header>
<div style="display: flex; justify-content: space-between; align-items: center">
<span><span style="color: red;" v-if="!readonly">*</span> 项目配置</span>
<el-button v-if="!readonly" type="primary" plain @click="addProject">选择项目</el-button>
</div>
</template>
<div class="project-card" v-for="(item, index) in localFormData.projects"
:key="item.projectId ?? item.id ?? index">
<div class="project-card__meta">
<div class="project-card__meta-name">{{ item.name }}</div>
<div class="project-card__meta-line">类型:{{ item.projectTypeName }}</div>
<div class="project-card__meta-line">时长: {{ item.durationMinutes ?? 0 }}分 {{ item.durationSeconds ?? 0 }}秒</div>
<div class="project-card__meta-line meta-muted">费用:{{ item.fee }} 元</div>
</div>
<div class="project-card__groups">
<div v-for="(group, gIndex) in (item.groups ?? [])"
:key="group.projectGroupId ?? group.id ?? gIndex" class="group-row">
<div class="group-row__fields">
<el-input v-if="!readonly" v-model="group.groupName" class="group-row__name" placeholder="组别名称" :disabled="readonly" />
<span v-else class="group-row__name">{{ group.groupName }}</span>
<el-date-picker value-format="YYYY-MM-DD" v-if="!readonly" class="group-row__range" type="daterange" range-separator="~"
start-placeholder="开始日期" end-placeholder="结束日期"
:model-value="[group.birthDateStart, group.birthDateEnd]"
@update:modelValue="(v) => { group.birthDateStart = v?.[0]; group.birthDateEnd = v?.[1]; }" :disabled="readonly" />
<span v-else class="group-row__range">{{ formatGroupDate(group) }}</span>
<el-select v-if="!readonly" v-model="group.gender" class="group-row__gender" placeholder="性别" :disabled="readonly">
<el-option label="全部" :value="3" />
<el-option label="男" :value="0" />
<el-option label="女" :value="1" />
</el-select>
<span v-else class="group-row__gender">{{ group.gender === 0 ? '男' : group.gender === 1 ? '女' : '全部' }}</span>
</div>
<div v-if="!readonly" class="group-row__actions">
<el-popconfirm title="确认删除该组别吗?" @confirm="removeGroup(index, gIndex)">
<template #reference>
<el-button text type="danger" icon="ele-Delete">删除</el-button>
</template>
</el-popconfirm>
</div>
</div>
<div v-if="!(item.groups ?? []).length" class="group-empty">暂无组别</div>
</div>
</div>
</el-card>
<el-card shadow="never">
<template #header>限报配置</template>
<div v-if="localFormData.projects?.length" class="project-limit-section">
<div class="project-limit-section__header">
<div class="project-limit-section__title">
<span class="required-star" v-if="!readonly">*</span>
项目限报配置
</div>
<div class="project-limit-section__desc" v-if="!readonly">为每个项目设置单独的限报数(必填)</div>
</div>
<div class="project-limit-table">
<el-table :data="localFormData.projects" stripe style="width: 100%">
<el-table-column label="项目" min-width="220">
<template #default="{ row, $index }">
<div class="project-limit-name">
<div class="project-limit-name__idx">#{{ $index + 1 }}</div>
<div class="project-limit-name__text">{{ row.name }}</div>
</div>
</template>
</el-table-column>
<el-table-column label="限报数" width="220" align="center">
<template #default="{ row, $index }">
<el-form-item v-if="!readonly"
:prop="`projects.${$index}.limitCount`"
:rules="[{ required: true, message: '请填写项目限报数', trigger: 'change' }]"
style="margin: 0; justify-content: center;"
>
<el-input-number v-model="row.limitCount" :min="0" :step="1" :precision="0" :controls="true" class="project-limit-input" :disabled="readonly" />
</el-form-item>
<span v-else>{{ row.limitCount }}</span>
</template>
</el-table-column>
</el-table>
</div>
</div>
<div class="limit-summary">
<div class="limit-summary__card">
<div class="limit-summary__label">
<span class="required-star" v-if="!readonly">*</span>
个人项目限报数
</div>
<el-form-item v-if="!readonly"
prop="registrationLimit.individualProjectLimit"
:rules="[{ required: true, message: '请填写个人项目限报数', trigger: 'change' }]"
style="margin: 0"
>
<el-input-number v-model="localFormData.registrationLimit.individualProjectLimit" :min="0" :step="1" :disabled="readonly" />
</el-form-item>
<span v-else>{{ localFormData.registrationLimit.individualProjectLimit }}</span>
</div>
<div class="limit-summary__card" v-if="!isOnlyIndividual">
<div class="limit-summary__label">
<span class="required-star" v-if="!readonly">*</span>
团队项目限报数
</div>
<el-form-item v-if="!readonly"
prop="registrationLimit.teamProjectLimit"
:rules="isOnlyIndividual ? [] : [{ required: true, message: '请填写团队项目限报数', trigger: 'change' }]"
style="margin: 0"
>
<el-input-number v-model="localFormData.registrationLimit.teamProjectLimit" :min="0" :step="1" :disabled="readonly" />
</el-form-item>
<span v-else>{{ localFormData.registrationLimit.teamProjectLimit }}</span>
</div>
</div>
</el-card>
</el-form>
<el-dialog title="选择项目" v-model="isShowDialog">
<ProTable ref="tableRef" row-key="id" @selectionChange="selectionChange" :request-api="projectPageGet"
:columns="columns" :pagination="true" :tool-button="false">
<!-- <template #index="scope">
{{ scope.$index + 1 }}
</template> -->
<template #projectType="scope">
{{ scope.row.projectTypeName }}
</template>
<template #completionCount="scope">
{{ scope.row.completionCount }}
</template>
</ProTable>
<template #footer>
<span class="dialog-footer">
<el-button @click="isShowDialog = false">取消</el-button>
<el-button type="primary" @click="onSubmit">确定</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, watch, nextTick, computed } from 'vue';
import ProTable from '/@/components/ProTable/index.vue';
import { projectPageGet, projectDetailGet } from '/@/api/match/project';
import { ElMessage } from 'element-plus';
import { formatDate } from '/@/utils/formatTime';
const props = defineProps({
modelValue: { type: Object, required: true },
readonly: { type: Boolean, default: false }
});
const emit = defineEmits(['update:modelValue']);
// 判断是否只允许个人报名
const isOnlyIndividual = computed(() => {
return props.modelValue?.registrationType === 0;
});
const formRef = ref(null);
const localFormData = ref({
projects: [],
registrationLimit: { individualProjectLimit: 1, teamProjectLimit: 1 },
});
const genderList = ref(['全部', '男', '女'])
// 格式化组别的年龄段(出生日期范围)
const formatGroupDate = (item) => {
const start = item?.birthDateStart;
const end = item?.birthDateEnd;
if (!start && !end) return '';
const fmt = (v) => {
if (!v) return '';
try { return formatDate(new Date(v), 'YYYY-mm-dd'); } catch { return String(v); }
};
if (start && end) return `${fmt(start)} ~ ${fmt(end)}`;
return fmt(start || end);
};
const addProject = () => {
isShowDialog.value = true;
// 打开弹窗时,用当前已选项目初始化 timeProjects,以便确定后保留勾选
timeProjects.value = [...(localFormData.value.projects ?? [])];
};
const timeProjects = ref([])
const selectionChange = (list) => {
console.log(list)
timeProjects.value = list
}
const onSubmit = async () => {
// 一次性拉取详情,避免 forEach(async) 导致多次触发更新/竞态
localFormData.value.projects = [];
const selected = timeProjects.value ?? [];
if (!selected.length) {
isShowDialog.value = false;
return;
}
const projects = await Promise.all(
selected.map(async (e) => {
const res = await projectDetailGet({ id: e.id });
return {
...(res.data ?? {}),
limitCount: res.data?.limitCount ?? 0,
};
})
);
localFormData.value.projects = projects;
isShowDialog.value = false;
};
const isShowDialog = ref(false);
const tableRef = ref(null);
// 弹窗打开后,每当 ProTable 加载完新数据,将已选项目回显为勾选状态
watch(isShowDialog, (visible) => {
if (!visible) return;
// 用 watchEffect 追踪 tableData 变化,数据每次刷新后重新回显
const stopWatch = watch(
() => tableRef.value?.tableData,
(tableData) => {
if (!tableData?.length) return;
nextTick(() => {
const elTable = tableRef.value?.element;
if (!elTable) return;
const alreadySelectedIds = new Set(
(localFormData.value.projects ?? []).map((p) => p.projectId ?? p.id)
);
tableData.forEach((row) => {
elTable.toggleRowSelection(row, alreadySelectedIds.has(row.id));
});
});
},
{ deep: true, immediate: true }
);
// 弹窗关闭时停止监听
const stopDialog = watch(isShowDialog, (v) => {
if (!v) { stopWatch(); stopDialog(); }
});
});
const columns = ref([
{
prop: 'index',
label: '序号',
align: 'center',
type: 'selection'
},
{
prop: 'name',
label: '项目名称',
align: 'center',
search: {
el: 'input',
},
},
{
prop: 'projectType',
label: '项目类型',
search: {
el: 'select',
},
enum: [
{ label: '全部', value: '' },
{ label: '个人速度', value: 1003 },
{ label: '个人花样', value: 1004 },
{ label: '团队速度', value: 1005 },
{ label: '团队花样', value: 1006 },
],
},
{
prop: 'completionCount',
label: '完成人数',
},
{ prop: 'fee', label: '费用(元)' },
]);
const removeProject = (idx) => localFormData.value.projects.splice(idx, 1);
const removeGroup = (projectIndex, groupIndex) => {
const p = localFormData.value.projects?.[projectIndex];
if (!p) return;
if (!Array.isArray(p.groups)) p.groups = [];
p.groups.splice(groupIndex, 1);
ElMessage.success('删除组别成功');
};
const isSyncingFromParent = ref(false);
const normalizeFromParent = async (v) => {
isSyncingFromParent.value = true;
localFormData.value.registrationLimit = v?.registrationLimit ?? { individualProjectLimit: 1, teamProjectLimit: 1 };
localFormData.value.projects = (v?.projects ?? []).map((p) => ({
...p,
limitCount: p?.limitCount ?? 0,
}));
await nextTick();
isSyncingFromParent.value = false;
// localFormData.value.projects = (v?.projects ?? []).map((p) => ({
// ...p,
// // 用文本编辑组别,提交时再解析成 groups[]
// groupsText: Array.isArray(p.groups)
// ? p.groups.map((g) => `${g.groupName}|${g.birthDateStart ?? ''}|${g.birthDateEnd ?? ''}|${g.gender ?? ''}`).join('\n')
// : '',
// }));
// if (!localFormData.value.projects.length) ElMessage.error('请选择项目!');
};
watch(
() => props.modelValue,
async (v) => await normalizeFromParent(v),
{ deep: true, immediate: true }
);
watch(
localFormData,
(v) => {
// 防止父子双向同步时形成递归更新
if (isSyncingFromParent.value) return;
// const projects = v.projects.map((p) => ({
// id: p.id,
// name: p.name,
// projectTypeId: p.projectTypeId,
// projectTypeName: p.projectTypeName,
// completionCount: p.completionCount,
// durationMinutes: p.durationMinutes,
// durationSeconds: p.durationSeconds,
// fee: p.fee,
// limitCount: p.limitCount,
// groups: p.groups,
// }));
emit('update:modelValue', { ...props.modelValue, registrationLimit: v.registrationLimit, projects: v.projects });
},
{ deep: true }
);
/* addProject and removeProject are declared above to avoid initialization order issues */
const rules = computed(() => {
const baseRules = {
'registrationLimit.individualProjectLimit': [{ required: true, message: '请填写个人项目限报数', trigger: 'change' }],
};
// 只有在不是仅允许个人报名时,才添加团队项目限报数的验证
if (!isOnlyIndividual.value) {
baseRules['registrationLimit.teamProjectLimit'] = [{ required: true, message: '请填写团队项目限报数', trigger: 'change' }];
}
return baseRules;
});
const validate = async () => {
if (!formRef.value) return false;
// 1. 验证必须选择至少一个项目
if (!localFormData.value.projects || localFormData.value.projects.length === 0) {
ElMessage.warning('请至少选择一个项目');
return false;
}
// 2. 验证表单字段(限报数等)
return await formRef.value.validate();
};
defineExpose({ validate });
</script>
<style scoped>
.step-config {
padding: 0 20px;
}
.step-config .el-form-item {
margin-bottom: 24px;
}
.project-limit-section {
margin-bottom: 16px;
}
.project-limit-section__header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.project-limit-section__title {
font-weight: 600;
color: var(--el-text-color-primary);
}
.project-limit-section__desc {
color: var(--el-text-color-secondary);
font-size: 12px;
white-space: nowrap;
}
.required-star {
color: var(--el-color-danger);
margin-right: 4px;
}
.project-limit-table {
border: 1px solid var(--el-border-color);
border-radius: 12px;
overflow: hidden;
background: #fff;
}
.project-limit-name {
display: flex;
align-items: center;
gap: 10px;
}
.project-limit-name__idx {
font-size: 12px;
color: var(--el-text-color-secondary);
background: var(--el-fill-color-light);
border: 1px solid var(--el-border-color-lighter);
padding: 2px 8px;
border-radius: 999px;
}
.project-limit-name__text {
font-weight: 600;
color: var(--el-text-color-primary);
}
.project-limit-input {
width: 160px;
}
.limit-summary {
margin-top: 14px;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.limit-summary__card {
border: 1px solid var(--el-border-color);
border-radius: 12px;
background: var(--el-fill-color-light);
padding: 12px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.limit-summary__label {
font-weight: 600;
color: var(--el-text-color-primary);
}
.project-card {
display: flex;
border: 1px solid var(--el-border-color);
border-radius: 14px;
overflow: hidden;
background: #fff;
margin-bottom: 16px;
}
.project-card__meta {
width: 210px;
padding: 16px 14px;
background: linear-gradient(180deg, #4f8ffb 0%, #2f73f5 100%);
color: #fff;
display: flex;
flex-direction: column;
gap: 8px;
}
.project-card__meta-name {
font-size: 16px;
font-weight: 700;
line-height: 1.2;
}
.project-card__meta-line {
font-size: 13px;
opacity: 0.95;
}
.meta-muted {
opacity: 0.85;
}
.project-card__groups {
flex: 1;
padding: 14px 16px;
display: flex;
flex-direction: column;
gap: 12px;
background: #fff;
}
.group-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 10px;
background: var(--el-fill-color-light);
}
.group-row__fields {
display: grid;
grid-template-columns: 140px minmax(280px, 1fr) 120px;
gap: 10px;
align-items: center;
flex: 1;
}
.group-row__name,
.group-row__range,
.group-row__gender {
width: 100%;
}
.group-row__actions {
opacity: 0;
transition: opacity 0.15s ease;
flex: none;
}
.group-row:hover .group-row__actions {
opacity: 1;
}
.group-empty {
color: var(--el-text-color-secondary);
font-size: 13px;
padding: 6px 2px;
}
.input-hint {
font-size: 12px;
color: #909399;
margin-top: 5px;
}
.switch-hint {
font-size: 12px;
color: #909399;
margin-top: 5px;
}
.type-info-card {
margin-top: 20px;
background: #f5f7fa;
}
.card-header {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
}
.type-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.type-item {
display: flex;
align-items: center;
gap: 15px;
padding: 10px 15px;
background: #fff;
border-radius: 6px;
border: 1px solid #e4e7ed;
}
.type-name {
font-weight: 600;
color: #409eff;
min-width: 120px;
}
.type-desc {
color: #606266;
font-size: 14px;
}
</style>