This commit is contained in:
2026-07-20 11:50:47 +08:00
parent 95d43746f6
commit 019ed28d37
401 changed files with 61622 additions and 23869 deletions
@@ -0,0 +1,930 @@
<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>