This commit is contained in:
2026-07-20 11:56:02 +08:00
commit 2dcae4165b
176 changed files with 34633 additions and 0 deletions

787
pages/gxmu/tzbcy.vue Normal file
View File

@@ -0,0 +1,787 @@
<template>
<view class="page">
<scroll-view scroll-y class="page-scroll">
<common-hero :title="pageTitle" :use-image-bg="true"></common-hero>
<view class="hero-card">
<view class="hero-badge">挑战杯</view>
<view class="hero-title">{{ pageTitle }}</view>
<view class="hero-desc">按后台完整字段拆分为项目申报表项目说明公开展示信息表</view>
<view class="hero-actions">
<view class="hero-action" @tap="openCreate">立即申报</view>
<view class="hero-action hero-action--secondary" @tap="loadData">刷新列表</view>
</view>
</view>
<view v-if="loading" class="state-card">
<view class="state-title">正在加载申报记录...</view>
</view>
<view v-else-if="!list.length" class="state-card">
<view class="state-title">暂无申报记录</view>
<view class="state-desc">点击上方立即申报开始填写挑战杯项目</view>
</view>
<view v-else class="card-list">
<view v-for="item in list" :key="item.id" class="data-card" @tap="openEdit(item)">
<view class="card-top">
<view>
<view class="card-title">{{ item.projectName || '未命名项目' }}</view>
<view class="card-subtitle">{{ item.projectType || '-' }} · {{ item.projectGroup || '-' }}</view>
</view>
<view class="card-year">{{ item.year || '-' }}</view>
</view>
<view class="meta-line">学校{{ item.schoolName || '-' }}</view>
<view class="meta-line">负责人{{ getLeaderInfo(item).leader || '-' }} / {{ getLeaderInfo(item).phone || '-' }}</view>
</view>
</view>
</scroll-view>
<view v-if="showEdit" class="modal-mask" @tap="closeEdit">
<view class="modal-panel" @tap.stop>
<view class="modal-head">
<view class="modal-title">{{ current.id ? '编辑挑战杯申报' : '新增挑战杯申报' }}</view>
<view class="modal-close" @tap="closeEdit">关闭</view>
</view>
<scroll-view scroll-y class="modal-body">
<view class="tab-row">
<view class="tab-item" :class="{ 'tab-item--active': activeTab === 'base' }" @tap="activeTab = 'base'">项目申报表</view>
<view class="tab-item" :class="{ 'tab-item--active': activeTab === 'detail' }" @tap="activeTab = 'detail'">项目说明</view>
<view class="tab-item" :class="{ 'tab-item--active': activeTab === 'public' }" @tap="activeTab = 'public'">公开展示信息表</view>
</view>
<view v-if="activeTab === 'base'" class="form-section">
<view class="field-item">
<view class="field-label">年份</view>
<uv-input class="field-input field-input--readonly" :modelValue="current.year" border="none" readonly placeholder="当前申报年份" />
</view>
<view class="field-grid field-grid--2">
<view class="field-item">
<view class="field-label">所在省</view>
<uv-input class="field-input" :modelValue="current.provinceCity" border="none" @input="setField('provinceCity', $event)" placeholder="请输入所在省/市" />
</view>
<view class="field-item">
<view class="field-label">学校名称全称</view>
<uv-input class="field-input" :modelValue="current.schoolName" border="none" @input="setField('schoolName', $event)" placeholder="请输入学校名称" />
</view>
</view>
<view class="field-item">
<view class="field-label">项目名称</view>
<uv-input class="field-input" :modelValue="current.projectName" border="none" @input="setField('projectName', $event)" placeholder="请输入项目名称" />
</view>
<view class="field-item">
<view class="field-label">项目类型</view>
<view class="field-picker" @tap="openFormProjectTypePicker">
<text :class="current.projectType ? 'field-value' : 'field-placeholder'">
{{ getOptionLabel(current.projectType, formProjectTypeOptions) || '请选择项目类型' }}
</text>
</view>
</view>
<view class="field-item">
<view class="field-label">
项目分组
<view class="field-note">仅可选择 1 个组别按后台申报配置提供选项</view>
</view>
<view class="field-picker" @tap="openFormProjectGroupPicker">
<text :class="current.projectGroup ? 'field-value' : 'field-placeholder'">
{{ getOptionLabel(current.projectGroup, formProjectGroupOptions) || '请选择项目分组' }}
</text>
</view>
</view>
<view class="section-header">
<view class="section-subtitle">团队成员</view>
<view class="section-action" @tap="addTeamMember">新增成员</view>
</view>
<view v-if="!current.teamMembers.length" class="empty-inline">暂无团队成员请手动新增</view>
<view v-for="(item, index) in current.teamMembers" :key="`member-${index}`" class="group-card">
<view class="group-head">
<view class="group-title">成员 {{ index + 1 }}</view>
<view class="group-remove" @tap="removeTeamMember(index)">删除</view>
</view>
<view class="field-grid field-grid--2">
<view class="field-item"><view class="field-label">姓名</view><uv-input class="field-input" :modelValue="item.name" border="none" @input="setTeamMemberField(index, 'name', $event)" /></view>
<view class="field-item"><view class="field-label">性别</view><uv-input class="field-input" :modelValue="item.gender" border="none" @input="setTeamMemberField(index, 'gender', $event)" /></view>
<view class="field-item"><view class="field-label">学院</view><uv-input class="field-input" :modelValue="item.college" border="none" @input="setTeamMemberField(index, 'college', $event)" /></view>
<view class="field-item"><view class="field-label">年级专业</view><uv-input class="field-input" :modelValue="item.gradeMajor" border="none" @input="setTeamMemberField(index, 'gradeMajor', $event)" /></view>
<view class="field-item"><view class="field-label">手机</view><uv-input class="field-input" :modelValue="item.phone" border="none" @input="setTeamMemberField(index, 'phone', $event)" /></view>
<view class="field-item"><view class="field-label">备注</view><uv-input class="field-input" :modelValue="item.remark" border="none" @input="setTeamMemberField(index, 'remark', $event)" placeholder="负责人及学历等备注" /></view>
</view>
</view>
<view class="section-header">
<view class="section-subtitle">指导教师</view>
<view class="section-action" @tap="addAdvisor">新增教师</view>
</view>
<view v-if="!current.advisors.length" class="empty-inline">暂无指导教师请手动新增</view>
<view v-for="(item, index) in current.advisors" :key="`advisor-${index}`" class="group-card">
<view class="group-head">
<view class="group-title">指导教师 {{ index + 1 }}</view>
<view class="group-remove" @tap="removeAdvisor(index)">删除</view>
</view>
<view class="field-grid field-grid--2">
<view class="field-item"><view class="field-label">姓名</view><uv-input class="field-input" :modelValue="item.name" border="none" @input="setAdvisorField(index, 'name', $event)" /></view>
<view class="field-item"><view class="field-label">性别</view><uv-input class="field-input" :modelValue="item.gender" border="none" @input="setAdvisorField(index, 'gender', $event)" /></view>
<view class="field-item"><view class="field-label">学院</view><uv-input class="field-input" :modelValue="item.college" border="none" @input="setAdvisorField(index, 'college', $event)" /></view>
<view class="field-item"><view class="field-label">职称</view><uv-input class="field-input" :modelValue="item.title" border="none" @input="setAdvisorField(index, 'title', $event)" /></view>
<view class="field-item"><view class="field-label">职务</view><uv-input class="field-input" :modelValue="item.duty" border="none" @input="setAdvisorField(index, 'duty', $event)" /></view>
<view class="field-item"><view class="field-label">手机</view><uv-input class="field-input" :modelValue="item.phone" border="none" @input="setAdvisorField(index, 'phone', $event)" /></view>
</view>
</view>
<view class="field-item">
<view class="field-label">项目简介500 字以内</view>
<textarea class="field-textarea" :value="current.projectBrief" @input="setField('projectBrief', $event)" placeholder="请输入项目简介" />
</view>
</view>
<view v-else-if="activeTab === 'detail'" class="form-section">
<view class="field-item"><view class="field-label">社会价值500 字以内</view><textarea class="field-textarea" :value="current.socialValue" @input="setField('socialValue', $event)" /></view>
<view class="field-item"><view class="field-label">实践过程500 字以内</view><textarea class="field-textarea" :value="current.practiceProcess" @input="setField('practiceProcess', $event)" /></view>
<view class="field-item"><view class="field-label">创新意义500 字以内</view><textarea class="field-textarea" :value="current.innovationMeaning" @input="setField('innovationMeaning', $event)" /></view>
<view class="field-item"><view class="field-label">发展前景500 字以内</view><textarea class="field-textarea" :value="current.developmentProspect" @input="setField('developmentProspect', $event)" /></view>
<view class="field-item"><view class="field-label">团队协作500 字以内</view><textarea class="field-textarea" :value="current.teamCooperation" @input="setField('teamCooperation', $event)" /></view>
<view class="field-item"><view class="field-label">项目介绍材料</view><textarea class="field-textarea" :value="current.projectMaterials" @input="setField('projectMaterials', $event)" /></view>
<view class="field-item"><view class="field-label">其他相关证明材料</view><textarea class="field-textarea" :value="current.otherProofs" @input="setField('otherProofs', $event)" placeholder="选报,相关证明材料说明" /></view>
</view>
<view v-else class="form-section">
<view class="field-item">
<view class="field-label">项目名称</view>
<uv-input class="field-input" :modelValue="current.projectName" border="none" @input="setField('projectName', $event)" placeholder="请输入项目名称" />
</view>
<view class="field-item">
<view class="field-label">参赛学校</view>
<uv-input class="field-input" :modelValue="current.schoolName" border="none" @input="setField('schoolName', $event)" placeholder="请输入参赛学校" />
</view>
<view class="field-item">
<view class="field-label">项目类型</view>
<view class="field-picker" @tap="openPublicProjectTypePicker">
<text :class="current.publicProjectType ? 'field-value' : 'field-placeholder'">
{{ getOptionLabel(current.publicProjectType, publicProjectTypeOptions) || '请选择项目类型' }}
</text>
</view>
</view>
<view class="field-item">
<view class="field-label">项目分组</view>
<view class="field-picker" @tap="openPublicProjectGroupPicker">
<text :class="current.publicProjectGroup ? 'field-value' : 'field-placeholder'">
{{ getOptionLabel(current.publicProjectGroup, publicProjectGroupOptions) || '请选择项目分组' }}
</text>
</view>
</view>
<view class="field-item">
<view class="field-label">我们的项目</view>
<textarea class="field-textarea" :value="current.projectSummary" @input="setField('projectSummary', $event)" placeholder="150字以内阐述项目的实践来源、社会背景、基本情况等" />
</view>
<view class="field-item">
<view class="field-label">我们的团队</view>
<view class="upload-card">
<image v-if="current.teamIntro" class="upload-image" :src="current.teamIntro" mode="aspectFill" @tap="previewImage(current.teamIntro)" />
<view v-else class="upload-empty">请上传团队图片</view>
<view class="upload-actions">
<view class="upload-btn" @tap="chooseTeamImage">上传图片</view>
<view v-if="current.teamIntro" class="upload-btn upload-btn--ghost" @tap="clearField('teamIntro')">移除</view>
</view>
</view>
</view>
<view class="field-item"><view class="field-label">我们的口号</view><uv-input class="field-input" :modelValue="current.teamSlogan" border="none" @input="setField('teamSlogan', $event)" placeholder="20字以内" /></view>
<view class="field-item">
<view class="field-label">我们的实践日志选填</view>
<view class="upload-card">
<view v-if="current.practiceLog" class="video-box">
<video class="upload-video" :src="current.practiceLog" controls object-fit="cover"></video>
</view>
<view v-else class="upload-empty">请上传实践日志视频</view>
<view class="upload-actions">
<view class="upload-btn" @tap="choosePracticeVideo">上传视频</view>
<view v-if="current.practiceLog" class="upload-btn upload-btn--ghost" @tap="clearField('practiceLog')">移除</view>
</view>
</view>
</view>
</view>
</scroll-view>
<view class="modal-actions">
<view class="ghost-btn" @tap="closeEdit">取消</view>
<view class="primary-btn" @tap="save">保存</view>
</view>
</view>
</view>
<uv-picker
ref="formProjectTypePicker"
title="选择项目类型"
:columns="formProjectTypePickerColumns"
:defaultIndex="[formProjectTypeIndex]"
keyName="text"
@close="noop"
@cancel="noop"
@confirm="confirmFormProjectTypePicker"
></uv-picker>
<uv-picker
ref="formProjectGroupPicker"
title="选择项目分组"
:columns="formProjectGroupPickerColumns"
:defaultIndex="[formProjectGroupIndex]"
keyName="text"
@close="noop"
@cancel="noop"
@confirm="confirmFormProjectGroupPicker"
></uv-picker>
<uv-picker
ref="publicProjectTypePicker"
title="选择公开项目类型"
:columns="publicProjectTypePickerColumns"
:defaultIndex="[publicProjectTypeIndex]"
keyName="text"
@close="noop"
@cancel="noop"
@confirm="confirmPublicProjectTypePicker"
></uv-picker>
<uv-picker
ref="publicProjectGroupPicker"
title="选择公开项目分组"
:columns="publicProjectGroupPickerColumns"
:defaultIndex="[publicProjectGroupIndex]"
keyName="text"
@close="noop"
@cancel="noop"
@confirm="confirmPublicProjectGroupPicker"
></uv-picker>
</view>
</template>
<script>
import CommonHero from '../../components/common-hero/common-hero.vue'
import { addTzbcyForm, getDeclare, getTzbcyForm, uploadTempFile, userPageTzbcyForm } from './module-form-service'
const FALLBACK_PROJECT_TYPE_OPTIONS = [
{ label: 'I. 普通高校', value: '普通高校' },
{ label: 'II. 职业院校', value: '职业院校' },
{ label: 'III. 东盟留学生专项', value: '东盟留学生专项' }
]
const FALLBACK_PROJECT_GROUP_OPTIONS = [
{ label: 'A. 科技创新和未来产业', value: 'A. 科技创新和未来产业' },
{ label: 'B. 乡村振兴和农业农村现代化', value: 'B. 乡村振兴和农业农村现代化' },
{ label: 'C. 社会治理和公共服务', value: 'C. 社会治理和公共服务' },
{ label: 'D. 生态环保和可持续发展', value: 'D. 生态环保和可持续发展' },
{ label: 'E. 文化创意和区域合作', value: 'E. 文化创意和区域合作' }
]
const createTeamMember = () => ({
name: '',
gender: '',
college: '',
gradeMajor: '',
phone: '',
remark: ''
})
const createAdvisor = () => ({
name: '',
gender: '',
college: '',
title: '',
duty: '',
phone: ''
})
const createDefaultForm = () => ({
id: undefined,
year: '',
provinceCity: '',
schoolName: '广西医科大学',
projectName: '',
projectType: '',
projectGroup: '',
publicProjectType: '',
publicProjectGroup: '',
leader: '',
phone: '',
teamMembers: [],
advisors: [],
projectBrief: '',
socialValue: '',
practiceProcess: '',
innovationMeaning: '',
developmentProspect: '',
teamCooperation: '',
projectMaterials: '',
otherProofs: '',
projectSummary: '',
teamIntro: '',
teamSlogan: '',
practiceLog: ''
})
const parseMultiValueField = (value) => {
if (Array.isArray(value)) {
return value.map((item) => String(item || '').trim()).filter(Boolean)
}
if (typeof value !== 'string') {
return []
}
const trimmed = value.trim()
if (!trimmed) {
return []
}
try {
const parsed = JSON.parse(trimmed)
if (Array.isArray(parsed)) {
return parsed.map((item) => String(item || '').trim()).filter(Boolean)
}
} catch (error) {}
return [trimmed]
}
const buildChoiceOptions = (customValues, fallback, currentValue) => {
const values = (customValues && customValues.length ? customValues : fallback.map((item) => item.value))
.map((item) => String(item || '').trim())
.filter((item, index, arr) => item && arr.indexOf(item) === index)
if (currentValue && String(currentValue).trim() && !values.includes(String(currentValue).trim())) {
values.push(String(currentValue).trim())
}
return values.map((value) => {
const matched = fallback.find((item) => item.value === value)
return {
label: matched && matched.label ? matched.label : value,
value
}
})
}
export default {
components: {
CommonHero
},
data() {
return {
loading: false,
initializingDetail: false,
showEdit: false,
activeTab: 'base',
pageTitle: '挑战杯申报',
fixedYear: '',
declareId: '',
list: [],
current: createDefaultForm(),
declareConfig: null
}
},
computed: {
formProjectTypeOptions() {
const config = this.declareConfig || {}
return buildChoiceOptions(
parseMultiValueField(config.formProjectType || config.projectType),
FALLBACK_PROJECT_TYPE_OPTIONS,
this.current.projectType
)
},
formProjectGroupOptions() {
const config = this.declareConfig || {}
return buildChoiceOptions(
parseMultiValueField(config.formProjectGroup || config.projectGroup),
FALLBACK_PROJECT_GROUP_OPTIONS,
this.current.projectGroup
)
},
publicProjectTypeOptions() {
const config = this.declareConfig || {}
return buildChoiceOptions(
parseMultiValueField(config.publicProjectType || config.projectType),
FALLBACK_PROJECT_TYPE_OPTIONS,
this.current.publicProjectType
)
},
publicProjectGroupOptions() {
const config = this.declareConfig || {}
return buildChoiceOptions(
parseMultiValueField(config.publicProjectGroup || config.projectGroup),
FALLBACK_PROJECT_GROUP_OPTIONS,
this.current.publicProjectGroup
)
},
formProjectTypePickerColumns() {
return [this.formProjectTypeOptions.map((item) => ({ text: item.label, value: item.value }))]
},
formProjectGroupPickerColumns() {
return [this.formProjectGroupOptions.map((item) => ({ text: item.label, value: item.value }))]
},
publicProjectTypePickerColumns() {
return [this.publicProjectTypeOptions.map((item) => ({ text: item.label, value: item.value }))]
},
publicProjectGroupPickerColumns() {
return [this.publicProjectGroupOptions.map((item) => ({ text: item.label, value: item.value }))]
},
formProjectTypeIndex() {
return this.getOptionIndex(this.current.projectType, this.formProjectTypeOptions)
},
formProjectGroupIndex() {
return this.getOptionIndex(this.current.projectGroup, this.formProjectGroupOptions)
},
publicProjectTypeIndex() {
return this.getOptionIndex(this.current.publicProjectType, this.publicProjectTypeOptions)
},
publicProjectGroupIndex() {
return this.getOptionIndex(this.current.publicProjectGroup, this.publicProjectGroupOptions)
}
},
onLoad(options) {
this.fixedYear = String(options.year || '').trim()
this.declareId = String(options.id || '').trim()
this.pageTitle = decodeURIComponent(options.declareTitle || '挑战杯申报').trim() || '挑战杯申报'
this.loadData()
this.loadDeclareConfig()
this.loadInitialDetail(options)
},
methods: {
getOptionIndex(value, options) {
const index = (options || []).findIndex((item) => item.value === value)
return index > -1 ? index : 0
},
getOptionLabel(value, options) {
const matched = (options || []).find((item) => item.value === value)
return matched ? matched.label : value
},
normalizeDetail(detail) {
const defaults = createDefaultForm()
const normalizedProjectType = (detail && detail.projectType) || ''
const normalizedProjectGroup = (detail && detail.projectGroup) || ''
return {
...defaults,
...(detail || {}),
projectType: normalizedProjectType,
projectGroup: normalizedProjectGroup,
publicProjectType: (detail && detail.publicProjectType) || normalizedProjectType,
publicProjectGroup: (detail && detail.publicProjectGroup) || normalizedProjectGroup,
teamMembers: Array.isArray(detail && detail.teamMembers) ? detail.teamMembers.map((item) => ({ ...createTeamMember(), ...(item || {}) })) : [],
advisors: Array.isArray(detail && detail.advisors) ? detail.advisors.map((item) => ({ ...createAdvisor(), ...(item || {}) })) : []
}
},
getLeaderInfo(record) {
const members = Array.isArray(record && record.teamMembers) ? record.teamMembers : []
const firstMember = members.find((item) => String(item && (item.name || item.phone) || '').trim())
return {
leader: (record && record.leader) || (firstMember && firstMember.name) || '',
phone: (record && record.phone) || (firstMember && firstMember.phone) || ''
}
},
async loadDeclareConfig() {
if (!this.declareId) {
return
}
try {
const result = await getDeclare(this.declareId)
this.declareConfig = result || null
const declareYear = String((result && result.year) || '').trim()
if (declareYear) {
this.fixedYear = declareYear
this.current.year = declareYear
}
} catch (error) {
uni.showToast({ title: (error && error.message) || '申报配置加载失败', icon: 'none' })
}
},
async loadInitialDetail(options) {
const id = String((options && options.id) || '').trim()
if (!id) {
return
}
this.initializingDetail = true
try {
const detail = await getTzbcyForm(id)
this.current = {
...this.normalizeDetail(detail),
year: String((detail && detail.year) || this.fixedYear || '').trim()
}
if (this.current.year) {
this.fixedYear = this.current.year
}
// this.showEdit = true
} catch (error) {
uni.showToast({ title: (error && error.message) || '加载详情失败', icon: 'none' })
} finally {
this.initializingDetail = false
}
},
async loadData() {
this.loading = true
try {
const result = await userPageTzbcyForm({
page: 1,
limit: 50,
year: this.fixedYear || undefined
})
this.list = Array.isArray(result && result.list) ? result.list : []
} catch (error) {
uni.showToast({ title: (error && error.message) || '加载失败', icon: 'none' })
} finally {
this.loading = false
}
},
openCreate() {
this.current = {
...createDefaultForm(),
year: this.fixedYear || ''
}
this.activeTab = 'base'
this.showEdit = true
},
async openEdit(item) {
try {
const detail = item && item.id ? await getTzbcyForm(item.id) : item
this.current = {
...this.normalizeDetail(detail),
year: String((detail && detail.year) || this.fixedYear || '').trim()
}
this.activeTab = 'base'
this.showEdit = true
} catch (error) {
uni.showToast({ title: (error && error.message) || '加载详情失败', icon: 'none' })
}
},
closeEdit() {
this.showEdit = false
},
noop() {
return
},
setField(key, event) {
const value = typeof event === 'string' ? event : (event.detail && event.detail.value) || ''
this.current[key] = value || ''
},
clearField(key) {
this.current[key] = ''
},
async chooseTeamImage() {
try {
const result = await new Promise((resolve, reject) => {
uni.chooseImage({
count: 1,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: resolve,
fail: reject
})
})
const file = ((result && result.tempFiles) || [])[0]
if (!file) {
return
}
uni.showLoading({ title: '上传中', mask: true })
const uploaded = await uploadTempFile(file)
this.current.teamIntro = uploaded.url || uploaded.downloadUrl || uploaded.path || ''
uni.showToast({ title: '图片上传成功', icon: 'none' })
} catch (error) {
if (error && error.errMsg && String(error.errMsg).includes('cancel')) {
return
}
uni.showToast({ title: (error && error.message) || '图片上传失败', icon: 'none' })
} finally {
uni.hideLoading()
}
},
async choosePracticeVideo() {
try {
const result = await new Promise((resolve, reject) => {
uni.chooseVideo({
sourceType: ['album', 'camera'],
maxDuration: 120,
compressed: true,
success: resolve,
fail: reject
})
})
if (!result || !result.tempFilePath) {
return
}
const file = {
path: result.tempFilePath,
name: result.name || 'practice-log.mp4',
size: result.size || 0
}
uni.showLoading({ title: '上传中', mask: true })
const uploaded = await uploadTempFile(file)
this.current.practiceLog = uploaded.url || uploaded.downloadUrl || uploaded.path || ''
uni.showToast({ title: '视频上传成功', icon: 'none' })
} catch (error) {
if (error && error.errMsg && String(error.errMsg).includes('cancel')) {
return
}
uni.showToast({ title: (error && error.message) || '视频上传失败', icon: 'none' })
} finally {
uni.hideLoading()
}
},
previewImage(url) {
if (!url) {
return
}
uni.previewImage({
urls: [url],
current: url
})
},
setTeamMemberField(index, key, event) {
const value = typeof event === 'string' ? event : (event.detail && event.detail.value) || ''
this.current.teamMembers[index][key] = value || ''
},
addTeamMember() {
this.current.teamMembers = [...this.current.teamMembers, createTeamMember()]
},
removeTeamMember(index) {
this.current.teamMembers = this.current.teamMembers.filter((_, currentIndex) => currentIndex !== index)
},
setAdvisorField(index, key, event) {
const value = typeof event === 'string' ? event : (event.detail && event.detail.value) || ''
this.current.advisors[index][key] = value || ''
},
addAdvisor() {
this.current.advisors = [...this.current.advisors, createAdvisor()]
},
removeAdvisor(index) {
this.current.advisors = this.current.advisors.filter((_, currentIndex) => currentIndex !== index)
},
openFormProjectTypePicker() {
this.$nextTick(() => {
this.$refs.formProjectTypePicker && this.$refs.formProjectTypePicker.open()
})
},
openFormProjectGroupPicker() {
this.$nextTick(() => {
this.$refs.formProjectGroupPicker && this.$refs.formProjectGroupPicker.open()
})
},
openPublicProjectTypePicker() {
this.$nextTick(() => {
this.$refs.publicProjectTypePicker && this.$refs.publicProjectTypePicker.open()
})
},
openPublicProjectGroupPicker() {
this.$nextTick(() => {
this.$refs.publicProjectGroupPicker && this.$refs.publicProjectGroupPicker.open()
})
},
confirmFormProjectTypePicker(event) {
const value = (((event || {}).value || [])[0] || {}).value || ''
this.current.projectType = value
},
confirmFormProjectGroupPicker(event) {
const value = (((event || {}).value || [])[0] || {}).value || ''
this.current.projectGroup = value
},
confirmPublicProjectTypePicker(event) {
const value = (((event || {}).value || [])[0] || {}).value || ''
this.current.publicProjectType = value
},
confirmPublicProjectGroupPicker(event) {
const value = (((event || {}).value || [])[0] || {}).value || ''
this.current.publicProjectGroup = value
},
buildSavePayload() {
const leaderInfo = this.getLeaderInfo(this.current)
return {
...this.current,
year: this.fixedYear || this.current.year,
leader: leaderInfo.leader,
phone: leaderInfo.phone
}
},
hasValidTeamMember() {
return (this.current.teamMembers || []).some((item) => {
return [item.name, item.phone, item.college, item.gradeMajor].some((value) => String(value || '').trim())
})
},
hasValidAdvisor() {
return (this.current.advisors || []).some((item) => {
return [item.name, item.phone, item.college, item.title, item.duty].some((value) => String(value || '').trim())
})
},
async save() {
try {
if (!this.hasValidTeamMember()) {
uni.showToast({ title: '请至少填写 1 名团队成员', icon: 'none' })
return
}
if (!this.hasValidAdvisor()) {
uni.showToast({ title: '请至少填写 1 名指导教师', icon: 'none' })
return
}
const payload = this.buildSavePayload()
await addTzbcyForm(payload)
uni.showToast({ title: '保存成功', icon: 'none' })
this.closeEdit()
this.loadData()
} catch (error) {
uni.showToast({ title: (error && error.message) || '保存失败', icon: 'none' })
}
}
}
}
</script>
<style lang="scss" scoped>
.page { min-height: 100vh; background: linear-gradient(180deg, #f5fbff 0%, #eef7ff 100%); }
.page-scroll { height: 100vh; padding: 24rpx; box-sizing: border-box; }
.hero-card,.state-card,.data-card { border-radius: 28rpx; }
.hero-card { padding: 34rpx 36rpx; background: rgba(255,255,255,.5); box-shadow: 0 4rpx 12rpx 0 rgba(174,174,174,.4); backdrop-filter: blur(24rpx); -webkit-backdrop-filter: blur(24rpx); border: 1rpx solid rgba(255,255,255,.5); }
.hero-badge { display: inline-flex; padding: 10rpx 18rpx; border-radius: 999rpx; background: rgba(20,150,242,.1); font-size: 22rpx; color: #1496f2; }
.hero-title { margin-top: 8rpx; padding-left: 10rpx; font-size: 30rpx; font-weight: 700; line-height: 1.35; color: #18a0f7; border-left: 5px solid #0f94ef; }
.hero-desc { margin-top: 20rpx; font-size: 24rpx; line-height: 1.8; color: #7f7f7f; }
.hero-actions { display: flex; gap: 16rpx; margin-top: 24rpx; }
.hero-action { padding: 14rpx 24rpx; border-radius: 999rpx; background: rgba(20,150,242,.12); font-size: 24rpx; color: #1496f2; }
.hero-action--secondary { background: rgba(31,35,41,.08); color: #5f6b76; }
.state-card,.data-card { margin-top: 24rpx; padding: 24rpx; background: rgba(255,255,255,.96); box-shadow: 0 10rpx 28rpx rgba(34,94,142,.08); }
.state-title,.card-title { font-size: 30rpx; font-weight: 700; color: #1f2329; }
.state-desc,.card-subtitle,.meta-line { margin-top: 10rpx; font-size: 24rpx; line-height: 1.6; color: #786e69; }
.card-list { margin-top: 24rpx; display: flex; flex-direction: column; gap: 16rpx; }
.card-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 16rpx; }
.card-year { padding: 10rpx 16rpx; border-radius: 999rpx; background: #eef5ff; color: #1554ad; font-size: 22rpx; }
.modal-mask { position: fixed; inset: 0; background: rgba(15,23,42,.34); display: flex; align-items: flex-end; z-index: 99; }
.modal-panel { width: 100%; max-height: 92vh; border-radius: 32rpx 32rpx 0 0; background: #fff; display: flex; flex-direction: column; }
.modal-head { display: flex; justify-content: space-between; align-items: center; padding: 24rpx; border-bottom: 1rpx solid #eef1f4; }
.modal-title { font-size: 30rpx; font-weight: 700; color: #1f2329; }
.modal-close { font-size: 24rpx; color: #7c8a96; }
.modal-body { max-height: 70vh; padding: 24rpx; box-sizing: border-box; }
.tab-row { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12rpx; margin-bottom: 20rpx; }
.tab-item { height: 76rpx; line-height: 76rpx; border-radius: 18rpx; background: #eff5ff; color: #1554ad; font-size: 24rpx; text-align: center; }
.tab-item--active { background: linear-gradient(135deg, #0f7dd1 0%, #1496f2 100%); color: #fff; }
.form-section + .form-section { margin-top: 24rpx; }
.form-section-title,.section-subtitle { font-size: 28rpx; font-weight: 700; color: #1f2329; margin-bottom: 16rpx; }
.section-header { display: flex; align-items: center; justify-content: space-between; gap: 16rpx; margin-top: 24rpx; }
.section-action { flex-shrink: 0; font-size: 22rpx; font-weight: 600; color: #1496f2; }
.section-subtitle { margin-top: 24rpx; }
.empty-inline { margin-top: 12rpx; padding: 20rpx; border-radius: 16rpx; background: #f8fafc; color: #98a2b3; font-size: 24rpx; text-align: center; }
.group-card { margin-top: 16rpx; padding: 20rpx; border-radius: 20rpx; background: #f8fbff; border: 1rpx solid #dce4ec; }
.group-head { display: flex; align-items: center; justify-content: space-between; gap: 16rpx; margin-bottom: 16rpx; }
.group-title { margin-bottom: 16rpx; font-size: 24rpx; font-weight: 700; color: #1554ad; }
.group-remove { flex-shrink: 0; font-size: 22rpx; color: #d92d20; }
.field-grid { display: grid; gap: 16rpx; }
.field-grid--2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.field-item + .field-item { margin-top: 16rpx; }
.field-label { margin-bottom: 10rpx; font-size: 24rpx; color: #5f6b7a; }
.field-note { margin-top: 6rpx; font-size: 20rpx; line-height: 1.6; color: #98a2b3; }
.field-textarea { width: 100%; box-sizing: border-box; border-radius: 20rpx; border: 1rpx solid #dce4ec; background: #fff; padding: 20rpx; font-size: 24rpx; color: #1f2329; min-height: 180rpx; }
:deep(.field-input.uv-input) { width: 100%; box-sizing: border-box; border-radius: 20rpx; border: 1rpx solid #dce4ec !important; background: #fff !important; padding: 20rpx !important; }
:deep(.field-input.uv-input .uv-input__content__field-wrapper__field) { font-size: 24rpx; color: #1f2329; min-height: 40rpx; }
:deep(.field-input--readonly.uv-input) { background: #f8fafc !important; }
:deep(.field-input--readonly.uv-input .uv-input__content__field-wrapper__field) { color: #667085; }
.field-picker { width: 100%; box-sizing: border-box; padding: 20rpx; border-radius: 20rpx; border: 1rpx solid rgba(220,220,220,1); background: #F4FCFF; font-size: 24rpx; }
.field-placeholder { color: #98a2b3; }
.field-value { color: #1f2329; }
.upload-card { padding: 20rpx; border-radius: 20rpx; border: 1rpx solid #dce4ec; background: #fff; }
.upload-empty { display: flex; align-items: center; justify-content: center; min-height: 220rpx; border-radius: 16rpx; background: #f8fafc; color: #98a2b3; font-size: 24rpx; }
.upload-image { width: 100%; height: 280rpx; border-radius: 16rpx; background: #f3f4f6; }
.video-box { border-radius: 16rpx; overflow: hidden; background: #0f172a; }
.upload-video { width: 100%; height: 320rpx; }
.upload-actions { display: flex; gap: 16rpx; margin-top: 18rpx; }
.upload-btn { flex: 1; height: 76rpx; line-height: 76rpx; border-radius: 18rpx; background: linear-gradient(135deg, #0f7dd1 0%, #1496f2 100%); color: #fff; text-align: center; font-size: 24rpx; font-weight: 600; }
.upload-btn--ghost { background: #eef5ff; color: #1554ad; }
.modal-actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16rpx; padding: 24rpx; border-top: 1rpx solid #eef1f4; }
.ghost-btn,.primary-btn { height: 84rpx; line-height: 84rpx; border-radius: 22rpx; text-align: center; font-size: 26rpx; font-weight: 700; }
.ghost-btn { background: #f6f7fa; color: #50617a; }
.primary-btn { background: linear-gradient(135deg, #0f7dd1 0%, #1496f2 100%); color: #fff; }
</style>