Files
tuanwei-admin/src/views/gxmu/TZBCYForm/components/edit.vue
T
2026-07-20 11:50:47 +08:00

931 lines
28 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>
<a-drawer
placement="right"
width="80%"
:visible="visible"
:title="isUpdate ? '编辑' : '新增'"
:body-style="{ paddingBottom: '88px' }"
@update:visible="updateVisible"
>
<a-form ref="formRef" :model="form">
<a-form-item
label="年份"
name="year"
:rules="[{ required: true, message: '请选择年份' }]"
>
<a-date-picker
v-model:value="form.year"
picker="year"
value-format="YYYY"
style="width: 240px"
:disabled="!!props.fixedYear"
placeholder="请选择年份"
/>
</a-form-item>
<a-tabs v-model:activeKey="activeTab" type="card" class="gxmu-tag-tabs">
<a-tab-pane key="base" tab="项目申报表">
<div class="sheet-wrap">
<div class="sheet-header">
<div class="sheet-tag">附件 1</div>
<div class="sheet-main-title"
>{{ title || '' }}参赛项目申报表</div
>
</div>
<table class="sheet-table">
<colgroup>
<col style="width: 18%" />
<col style="width: 32%" />
<col style="width: 18%" />
<col style="width: 32%" />
</colgroup>
<tbody>
<tr>
<td class="sheet-label">所在省<br />(区、市)</td>
<td class="sheet-value">
<a-input
v-model:value="form.provinceCity"
placeholder="请输入所在省/市"
/>
</td>
<td class="sheet-label">学校名称<br />(全称)</td>
<td class="sheet-value">
<a-input
v-model:value="form.schoolName"
placeholder="请输入学校名称"
/>
</td>
</tr>
<tr>
<td class="sheet-label">项目名称</td>
<td class="sheet-value left" colspan="3">
<a-input
v-model:value="form.projectName"
placeholder="请输入项目名称"
/>
</td>
</tr>
<tr>
<td class="sheet-label">项目类型</td>
<td class="sheet-value left" colspan="3">
<a-radio-group
v-model:value="form.projectType"
class="inline-radio"
>
<a-radio
v-for="item in formProjectTypeOptions"
:key="`base-type-${item.value}`"
:value="item.value"
>
{{ item.label }}
</a-radio>
</a-radio-group>
</td>
</tr>
<tr>
<td class="sheet-label">
项目分组
<div class="sheet-note"
>(仅可选择1个组别,须在此栏中删除其他组别内容)</div
>
</td>
<td class="sheet-value left" colspan="3">
<a-radio-group
v-model:value="form.projectGroup"
class="group-radio"
>
<a-radio
v-for="item in formProjectGroupOptions"
:key="`base-group-${item.value}`"
:value="item.value"
>
{{ item.label }}
</a-radio>
</a-radio-group>
</td>
</tr>
</tbody>
</table>
<table class="sheet-table">
<colgroup>
<col style="width: 14%" />
<col style="width: 12%" />
<col style="width: 10%" />
<col style="width: 16%" />
<col style="width: 18%" />
<col style="width: 12%" />
<col style="width: 18%" />
</colgroup>
<tbody>
<tr>
<td class="sheet-label" :rowspan="teamMembersCount + 1"
>团队成员<br />(最多15人)</td
>
<td class="sheet-label">姓名</td>
<td class="sheet-label">性别</td>
<td class="sheet-label">学院</td>
<td class="sheet-label">年级、专业</td>
<td class="sheet-label">手机</td>
<td class="sheet-label"
>备注(负责人及各成员学历-本/硕/博)</td
>
</tr>
<tr
v-for="(item, index) in form.teamMembers"
:key="`member-${index}`"
>
<td class="sheet-value"
><a-input v-model:value="item.name"
/></td>
<td class="sheet-value">
<a-select style="width: 120px"
v-model:value="item.gender"
:options="genderOptions"
placeholder="请选择"
/>
</td>
<td class="sheet-value">
<a-select style="width: 120px"
allow-clear
show-search
v-model:value="item.college"
:options="collegeOptions"
:filter-option="filterSelectOption"
placeholder="请选择"
/>
</td>
<td class="sheet-value"
><a-input v-model:value="item.gradeMajor"
/></td>
<td class="sheet-value"
><a-input v-model:value="item.phone"
/></td>
<td class="sheet-value"
><a-input v-model:value="item.remark"
/></td>
</tr>
</tbody>
</table>
<table class="sheet-table">
<colgroup>
<col style="width: 14%" />
<col style="width: 12%" />
<col style="width: 10%" />
<col style="width: 16%" />
<col style="width: 18%" />
<col style="width: 16%" />
<col style="width: 14%" />
</colgroup>
<tbody>
<tr>
<td class="sheet-label" :rowspan="advisorCount + 1"
>指导教师<br />(最多5人)</td
>
<td class="sheet-label">姓名</td>
<td class="sheet-label">性别</td>
<td class="sheet-label">学院</td>
<td class="sheet-label">职称</td>
<td class="sheet-label">职务</td>
<td class="sheet-label">手机</td>
</tr>
<tr
v-for="(item, index) in form.advisors"
:key="`advisor-${index}`"
>
<td class="sheet-value"
><a-input v-model:value="item.name"
/></td>
<td class="sheet-value">
<a-select style="width: 120px"
v-model:value="item.gender"
:options="genderOptions"
placeholder="请选择"
/>
</td>
<td class="sheet-value">
<a-select style="width: 120px"
allow-clear
show-search
v-model:value="item.college"
:options="collegeOptions"
:filter-option="filterSelectOption"
placeholder="请选择"
/>
</td>
<td class="sheet-value"
><a-input v-model:value="item.title"
/></td>
<td class="sheet-value"
><a-input v-model:value="item.duty"
/></td>
<td class="sheet-value"
><a-input v-model:value="item.phone"
/></td>
</tr>
</tbody>
</table>
<table class="sheet-table">
<colgroup>
<col style="width: 18%" />
<col style="width: 82%" />
</colgroup>
<tbody>
<tr>
<td class="sheet-label">项目简介<br />(500字以内)</td>
<td class="sheet-value left">
<a-textarea
v-model:value="form.projectBrief"
:rows="4"
placeholder="请输入项目简介"
/>
</td>
</tr>
</tbody>
</table>
</div>
</a-tab-pane>
<a-tab-pane key="detail" tab="项目说明">
<div class="sheet-wrap">
<table class="sheet-table">
<colgroup>
<col style="width: 18%" />
<col style="width: 82%" />
</colgroup>
<tbody>
<tr>
<td class="sheet-label">社会价值<br />(500字以内)</td>
<td class="sheet-value left">
<a-textarea
v-model:value="form.socialValue"
:rows="4"
placeholder="请输入社会价值"
/>
</td>
</tr>
<tr>
<td class="sheet-label">实践过程<br />(500字以内)</td>
<td class="sheet-value left">
<a-textarea
v-model:value="form.practiceProcess"
:rows="4"
placeholder="请输入实践过程"
/>
</td>
</tr>
<tr>
<td class="sheet-label">创新意义<br />(500字以内)</td>
<td class="sheet-value left">
<a-textarea
v-model:value="form.innovationMeaning"
:rows="4"
placeholder="请输入创新意义"
/>
</td>
</tr>
<tr>
<td class="sheet-label">发展前景<br />(500字以内)</td>
<td class="sheet-value left">
<a-textarea
v-model:value="form.developmentProspect"
:rows="4"
placeholder="请输入发展前景"
/>
</td>
</tr>
<tr>
<td class="sheet-label">团队协作<br />(500字以内)</td>
<td class="sheet-value left">
<a-textarea
v-model:value="form.teamCooperation"
:rows="4"
placeholder="请输入团队协作"
/>
</td>
</tr>
<tr>
<td class="sheet-label">项目介绍材料</td>
<td class="sheet-value left">
<a-textarea
v-model:value="form.projectMaterials"
:rows="3"
placeholder="请输入项目介绍材料"
/>
</td>
</tr>
<tr>
<td class="sheet-label">其他相关证明材料</td>
<td class="sheet-value left">
<a-textarea
v-model:value="form.otherProofs"
:rows="3"
placeholder="选报,所有其他相关证明材料需扫描在同一 PDF 文档中"
/>
</td>
</tr>
</tbody>
</table>
</div>
</a-tab-pane>
<a-tab-pane key="public" tab="公开展示信息表">
<div class="sheet-wrap">
<div class="public-title">
<div class="public-tag">附件 2</div>
<div class="public-main-title"
>{{ title || '' }}参赛项目公开展示信息表</div
>
</div>
<table class="sheet-table">
<colgroup>
<col style="width: 18%" />
<col style="width: 82%" />
</colgroup>
<tbody>
<tr>
<td class="sheet-label">项目名称</td>
<td class="sheet-value left">
<a-input
v-model:value="form.projectName"
placeholder="请输入项目名称"
/>
</td>
</tr>
<tr>
<td class="sheet-label">参赛学校</td>
<td class="sheet-value left">
<a-input
v-model:value="form.schoolName"
placeholder="请输入参赛学校"
/>
</td>
</tr>
<tr>
<td class="sheet-label">项目类型</td>
<td class="sheet-value left">
<a-radio-group
v-model:value="form.publicProjectType"
class="inline-radio"
>
<a-radio
v-for="item in publicProjectTypeOptions"
:key="`public-type-${item.value}`"
:value="item.value"
>
{{ item.label }}
</a-radio>
</a-radio-group>
</td>
</tr>
<tr>
<td class="sheet-label">项目分组</td>
<td class="sheet-value left">
<a-radio-group
v-model:value="form.publicProjectGroup"
class="group-radio"
>
<a-radio
v-for="item in publicProjectGroupOptions"
:key="`public-group-${item.value}`"
:value="item.value"
>
{{ item.label }}
</a-radio>
</a-radio-group>
</td>
</tr>
<tr>
<td class="sheet-label">我们的项目</td>
<td class="sheet-value left">
<a-textarea
v-model:value="form.projectSummary"
:rows="3"
placeholder="150字以内,阐述项目的实践来源、社会背景、基本情况等"
/>
</td>
</tr>
<tr>
<td class="sheet-label">我们的团队</td>
<td class="sheet-value left">
<div class="media-upload-block">
<div v-if="form.teamIntro" class="media-preview-wrap">
<img
class="media-image"
:src="form.teamIntro"
alt="团队图片"
/>
</div>
<div v-else class="media-empty">请上传团队图片</div>
<div class="media-actions">
<a-upload
:show-upload-list="false"
accept="image/*"
:customRequest="uploadTeamImage"
>
<a-button type="primary">上传图片</a-button>
</a-upload>
<a-button v-if="form.teamIntro" @click="form.teamIntro = ''">
移除
</a-button>
</div>
</div>
</td>
</tr>
<tr>
<td class="sheet-label">我们的口号</td>
<td class="sheet-value left">
<a-input
v-model:value="form.teamSlogan"
placeholder="20字以内"
/>
</td>
</tr>
<tr>
<td class="sheet-label">我们的实践日志(选填)</td>
<td class="sheet-value left">
<div class="media-upload-block">
<div v-if="form.practiceLog" class="media-preview-wrap media-preview-wrap--video">
<video class="media-video" :src="form.practiceLog" controls></video>
</div>
<div v-else class="media-empty">请上传实践日志视频</div>
<div class="media-actions">
<a-upload
:show-upload-list="false"
accept="video/*"
:customRequest="uploadPracticeVideo"
>
<a-button type="primary">上传视频</a-button>
</a-upload>
<a-button v-if="form.practiceLog" @click="form.practiceLog = ''">
移除
</a-button>
</div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</a-tab-pane>
</a-tabs>
</a-form>
<div class="drawer-footer">
<a-space>
<a-button @click="updateVisible(false)">取消</a-button>
<a-button type="primary" :loading="loading" @click="save"
>保存</a-button
>
</a-space>
</div>
</a-drawer>
</template>
<script lang="ts" setup>
import { computed, reactive, ref, watch } from 'vue';
import { message } from 'ant-design-vue';
import { addTzbcyForm, updateTzbcyForm } from '@/api/gxmu/tzbcy-form';
import { uploadFile } from '@/api/system/file';
import type {
TzbcyAdvisor,
TzbcyForm,
TzbcyTeamMember
} from '@/api/gxmu/tzbcy-form/model';
import {
filterSelectOption,
useGxmuFormSelectOptions
} from '@/views/gxmu/useFormSelectOptions';
import {
TZBCY_PROJECT_GROUP_OPTIONS,
TZBCY_PROJECT_TYPE_OPTIONS
} from '../options';
interface ChoiceOption {
label: string;
value: string;
}
const emit = defineEmits<{
(e: 'done'): void;
(e: 'update:visible', visible: boolean): void;
}>();
const props = defineProps<{
visible: boolean;
data?: TzbcyForm | null;
title?: string;
fixedYear?: string | number;
declareFormProjectTypes?: string[];
declareFormProjectGroups?: string[];
declarePublicProjectTypes?: string[];
declarePublicProjectGroups?: string[];
}>();
const formRef = ref();
const loading = ref(false);
const isUpdate = ref(false);
const activeTab = ref('base');
const { genderOptions, collegeOptions, loadCollegeOptions } =
useGxmuFormSelectOptions();
const createTeamMember = (): TzbcyTeamMember => ({
name: '',
gender: '',
college: '',
gradeMajor: '',
phone: '',
remark: ''
});
const createAdvisor = (): TzbcyAdvisor => ({
name: '',
gender: '',
college: '',
title: '',
duty: '',
phone: ''
});
const createDefaultForm = (): TzbcyForm => ({
id: undefined,
year: '',
provinceCity: '',
schoolName: '广西医科大学',
projectName: '',
projectType: '',
projectGroup: '',
publicProjectType: '',
publicProjectGroup: '',
leader: '',
phone: '',
teamMembers: Array.from({ length: 15 }).map(() => createTeamMember()),
advisors: Array.from({ length: 5 }).map(() => createAdvisor()),
projectBrief: '',
socialValue: '',
practiceProcess: '',
innovationMeaning: '',
developmentProspect: '',
teamCooperation: '',
projectMaterials: '',
otherProofs: '',
projectSummary: '',
teamIntro: '',
teamSlogan: '',
practiceLog: ''
});
const fillList = <T extends Record<string, unknown>>(
list: T[] | undefined,
count: number,
createItem: () => T
) => {
const source = list?.map((item) => ({ ...createItem(), ...item })) ?? [];
return Array.from({ length: count }).map(
(_, index) => source[index] ?? createItem()
);
};
const normalizeForm = (record?: TzbcyForm | null): TzbcyForm => {
const defaults = createDefaultForm();
if (!record) {
return defaults;
}
const normalizedProjectType = record.projectType || '';
const normalizedProjectGroup = record.projectGroup || '';
return {
...defaults,
...record,
projectType: normalizedProjectType,
projectGroup: normalizedProjectGroup,
publicProjectType: record.publicProjectType || normalizedProjectType,
publicProjectGroup: record.publicProjectGroup || normalizedProjectGroup,
teamMembers: fillList(record.teamMembers, 15, createTeamMember),
advisors: fillList(record.advisors, 5, createAdvisor)
};
};
const form = reactive<TzbcyForm>(createDefaultForm());
const teamMembersCount = computed(() => form.teamMembers?.length || 0);
const advisorCount = computed(() => form.advisors?.length || 0);
const buildChoiceOptions = (
customValues: string[] | undefined,
fallback: readonly ChoiceOption[],
currentValue?: string
): ChoiceOption[] => {
const values = (customValues?.length
? customValues
: fallback.map((item) => item.value)
)
.map((item) => item.trim())
.filter((item, index, arr) => !!item && arr.indexOf(item) === index);
if (currentValue?.trim() && !values.includes(currentValue.trim())) {
values.push(currentValue.trim());
}
return values.map((value) => {
const matched = fallback.find((item) => item.value === value);
return {
label: matched?.label ?? value,
value
};
});
};
const formProjectTypeOptions = computed(() =>
buildChoiceOptions(
props.declareFormProjectTypes,
TZBCY_PROJECT_TYPE_OPTIONS,
form.projectType
)
);
const formProjectGroupOptions = computed(() =>
buildChoiceOptions(
props.declareFormProjectGroups,
TZBCY_PROJECT_GROUP_OPTIONS,
form.projectGroup
)
);
const publicProjectTypeOptions = computed(() =>
buildChoiceOptions(
props.declarePublicProjectTypes,
TZBCY_PROJECT_TYPE_OPTIONS,
form.publicProjectType
)
);
const publicProjectGroupOptions = computed(() =>
buildChoiceOptions(
props.declarePublicProjectGroups,
TZBCY_PROJECT_GROUP_OPTIONS,
form.publicProjectGroup
)
);
watch(
() => props.visible,
async (visible) => {
if (visible) {
try {
await loadCollegeOptions();
} catch (e: any) {
message.error(e.message || '下拉选项加载失败');
}
isUpdate.value = !!props.data?.id;
Object.assign(form, normalizeForm(props.data));
if (props.fixedYear) {
form.year = String(props.fixedYear);
}
} else {
formRef.value?.resetFields?.();
Object.assign(form, createDefaultForm());
}
activeTab.value = 'base';
}
);
const updateVisible = (value: boolean) => {
emit('update:visible', value);
};
const syncLeaderInfo = () => {
const firstMember = form.teamMembers?.find(
(item) => item.name?.trim() || item.phone?.trim()
);
form.leader = firstMember?.name?.trim() || '';
form.phone = firstMember?.phone?.trim() || '';
};
const save = () => {
syncLeaderInfo();
loading.value = true;
const saveOrUpdate = isUpdate.value ? updateTzbcyForm : addTzbcyForm;
saveOrUpdate({
...form,
year: props.fixedYear ? String(props.fixedYear) : form.year
})
.then((msg) => {
loading.value = false;
message.success(msg);
updateVisible(false);
emit('done');
})
.catch((e) => {
loading.value = false;
message.error(e.message);
});
};
const uploadTeamImage = (item) => {
const file = item.file as File;
if (!file.type.startsWith('image/')) {
message.error('请上传图片文件');
return;
}
uploadFile(file)
.then((data) => {
form.teamIntro = String(data.url || '');
message.success('图片上传成功');
})
.catch((e) => {
message.error(e.message);
});
};
const uploadPracticeVideo = (item) => {
const file = item.file as File;
if (!file.type.startsWith('video/')) {
message.error('请上传视频文件');
return;
}
uploadFile(file)
.then((data) => {
form.practiceLog = String(data.url || '');
message.success('视频上传成功');
})
.catch((e) => {
message.error(e.message);
});
};
</script>
<style lang="less" scoped>
@import '../../form-theme.less';
@import '../../tag-tabs.less';
.gxmu-field-theme();
.gxmu-tag-tabs();
.sheet-wrap {
border: 1px solid #d9d9d9;
padding: 12px;
background: #fff;
}
.sheet-header {
margin-bottom: 16px;
}
.sheet-tag {
font-size: 16px;
font-weight: 600;
margin-bottom: 8px;
}
.sheet-main-title {
font-size: 28px;
font-weight: 700;
text-align: center;
line-height: 1.4;
}
.sheet-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
margin-bottom: 12px;
}
.sheet-table td {
border: 1px solid #3b3b3b;
padding: 8px 6px;
font-size: 13px;
text-align: center;
vertical-align: middle;
}
.sheet-label {
font-weight: 600;
text-align: center;
}
.sheet-note {
font-weight: 400;
font-size: 12px;
color: #444;
margin-top: 6px;
line-height: 1.8;
}
.sheet-value {
text-align: center;
}
.sheet-value.left {
text-align: left;
}
.sheet-table .sheet-value :deep(.ant-input),
.sheet-table .sheet-value :deep(.ant-select-selector),
.sheet-table .sheet-value :deep(.ant-radio-group),
.sheet-table .sheet-value :deep(.ant-input-textarea),
.sheet-table .sheet-value :deep(.ant-input-affix-wrapper) {
border: 0;
box-shadow: none;
padding: 0;
background: transparent;
}
.sheet-table .sheet-value :deep(.ant-input) {
text-align: center;
}
.sheet-table .sheet-value.left :deep(.ant-input),
.sheet-table .sheet-value.left :deep(.ant-input-textarea) {
text-align: left;
}
.sheet-table .sheet-value :deep(textarea.ant-input) {
resize: none;
}
.inline-radio :deep(.ant-radio) {
margin-right: 12px;
}
.group-radio {
display: grid;
grid-template-columns: 1fr;
gap: 6px;
}
.media-upload-block {
display: flex;
flex-direction: column;
gap: 12px;
}
.media-preview-wrap {
width: 100%;
border-radius: 8px;
overflow: hidden;
background: #f5f7fa;
}
.media-preview-wrap--video {
background: #111827;
}
.media-image {
width: 100%;
max-height: 220px;
object-fit: cover;
display: block;
}
.media-video {
width: 100%;
max-height: 260px;
display: block;
}
.media-empty {
padding: 24px 12px;
border-radius: 8px;
background: #f5f7fa;
color: #909399;
text-align: center;
}
.media-actions {
display: flex;
gap: 12px;
align-items: center;
}
.public-title {
margin-bottom: 16px;
}
.public-tag {
font-size: 16px;
font-weight: 600;
margin-bottom: 8px;
}
.public-main-title {
font-size: 28px;
font-weight: 700;
text-align: center;
line-height: 1.4;
}
.drawer-footer {
position: absolute;
right: 0;
bottom: 0;
width: 100%;
border-top: 1px solid #f0f0f0;
padding: 12px 16px;
background: #fff;
text-align: right;
}
@media (max-width: 768px) {
.sheet-main-title,
.public-main-title {
font-size: 16px;
}
}
</style>