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

395
pages/gxmu/config.js Normal file
View File

@@ -0,0 +1,395 @@
const GENDER_OPTIONS = ['男', '女']
const YES_NO_OPTIONS = ['是', '否']
export const GXMU_MODULES = [
{
title: '十佳青年岗位能手',
code: 'gxmu_sjqn',
icon: '青',
group: '个人奖项',
desc: '面向青年岗位骨干与先进典型的申报入口。'
},
{
title: '十佳团支部书记',
code: 'gxmu_sjtbzbsj',
icon: '书',
group: '个人奖项',
desc: '聚焦基层团支部书记的履职表现与带动成效。'
},
{
title: '五四红旗团委',
code: 'gxmu_wshqtw',
icon: '委',
group: '组织奖项',
desc: '用于先进团委集体申报与材料汇总展示。'
},
{
title: '五四红旗团支部',
code: 'gxmu_wshqtzb',
icon: '支',
group: '组织奖项',
desc: '聚焦先进团支部建设成果与品牌工作展示。'
},
{
title: '优秀共青团干部',
code: 'gxmu_yxgqtdgb',
icon: '干',
group: '个人奖项',
desc: '面向团学骨干和团务干部的申报入口。'
},
{
title: '优秀共青团员',
code: 'gxmu_yxgqty',
icon: '员',
group: '个人奖项',
desc: '面向优秀团员个人事迹与成长表现申报。'
},
{
title: '挑战杯',
code: 'gxmu_tzbcy_form',
icon: '挑',
group: '竞赛项目',
desc: '面向挑战杯项目材料填报与过程归档。'
},
{
title: '青马工程',
code: 'gxmu_qmgc_form',
icon: '青',
group: '培养项目',
desc: '用于青马工程学员培养信息和成果申报。'
},
{
title: '未来学术之星',
code: 'gxmu_wxxzx_project',
icon: '星',
group: '创新项目',
desc: '用于未来学术之星项目申报和材料整理。'
}
]
const createFields = (fields) =>
fields.map((field) => ({
required: false,
placeholder: `请输入${field.label}`,
...field
}))
export const GXMU_FORM_MAP = {
gxmu_sjqn: {
title: '十佳青年岗位能手申报表',
subtitle: '参考后台 SJQNForm 字段结构,适用于个人申报信息填写。',
sections: [
{
title: '基础信息',
fields: createFields([
{ key: 'year', label: '年份', type: 'year', required: true },
{ key: 'applyType', label: '申报类别', required: true },
{ key: 'name', label: '姓名', required: true },
{ key: 'gender', label: '性别', type: 'select', options: GENDER_OPTIONS },
{ key: 'birthMonth', label: '出生年月', type: 'month' },
{ key: 'nation', label: '民族' },
{ key: 'politics', label: '政治面貌' },
{ key: 'education', label: '学历' },
{ key: 'position', label: '职务' },
{ key: 'title', label: '职称' },
{ key: 'unit', label: '所在单位' }
])
},
{
title: '申报内容',
fields: createFields([
{ key: 'awards', label: '近三年曾获校级及以上奖励', type: 'textarea' },
{ key: 'experience', label: '工作(学习)经历', type: 'textarea' },
{ key: 'mainStory', label: '主要事迹', type: 'textarea' }
])
}
]
},
gxmu_sjtbzbsj: {
title: '十佳团支部书记申报表',
subtitle: '参考后台 SJTBZBSJForm 字段结构,适用于个人申报信息填写。',
sections: [
{
title: '基础信息',
fields: createFields([
{ key: 'year', label: '年份', type: 'year', required: true },
{ key: 'name', label: '姓名', required: true },
{ key: 'gender', label: '性别', type: 'select', options: GENDER_OPTIONS },
{ key: 'birthMonth', label: '出生年月', type: 'month' },
{ key: 'nation', label: '民族' },
{ key: 'politics', label: '政治面貌' },
{ key: 'collegeClass', label: '学院班级/单位科室' },
{ key: 'branch', label: '所在团支部' },
{ key: 'eduEval', label: '上一年度团员教育评议等次' }
])
},
{
title: '申报内容',
fields: createFields([
{ key: 'awards', label: '近三年曾获校级及以上奖励', type: 'textarea' },
{ key: 'experience', label: '工作(学习)经历', type: 'textarea' },
{ key: 'mainStory', label: '主要事迹', type: 'textarea' }
])
}
]
},
gxmu_wshqtw: {
title: '五四红旗团委申报表',
subtitle: '参考后台 WSHQTWForm 字段结构,适用于组织集体申报。',
sections: [
{
title: '组织基本信息',
fields: createFields([
{ key: 'year', label: '年份', type: 'year', required: true },
{ key: 'orgName', label: '二级团组织全称', required: true },
{ key: 'leader', label: '负责人' },
{ key: 'phone', label: '联系电话', type: 'phone' },
{ key: 'memberTotal', label: '现有团员总数', type: 'number' },
{ key: 'memberDeveloped2024', label: '上一年度发展团员人数', type: 'number' },
{ key: 'smartSystemLogin', label: '是否已登录智慧团建系统', type: 'select', options: YES_NO_OPTIONS },
{ key: 'committeeCount', label: '团委委员人数', type: 'number' },
{ key: 'fulltimeCadreCount', label: '专职团干部数(其中教师/学生)' },
{ key: 'parttimeCadreCount', label: '兼职团干部数(其中教师/学生)' },
{ key: 'lastElectionTime', label: '团委最近一次换届时间', type: 'date' },
{ key: 'branchCount', label: '团支部数', type: 'number' }
])
},
{
title: '年度建设情况',
fields: createFields([
{ key: 'feeReceivable2024', label: '上一年度应收团费', type: 'number' },
{ key: 'feeReceived2024', label: '上一年度实收团费', type: 'number' },
{ key: 'feePayable2024', label: '上一年度应上缴团费', type: 'number' },
{ key: 'feePaid2024', label: '上一年度实际上缴团费', type: 'number' },
{ key: 'standardizedWork2024', label: '是否开展规范化建设工作', type: 'select', options: YES_NO_OPTIONS },
{ key: 'recommendActivist2024', label: '推荐入党积极分子人数', type: 'number' },
{ key: 'activistConfirmed', label: '确定为入党积极分子数', type: 'number' },
{ key: 'recommendDevTarget2024', label: '推荐党的发展对象人数', type: 'number' },
{ key: 'devTargetConfirmed', label: '确定为党的发展对象数', type: 'number' }
])
},
{
title: '主要成果',
fields: createFields([
{ key: 'honorsFiveYears', label: '近五年获得校级及以上荣誉情况', type: 'textarea' },
{ key: 'workSummaryThreeYears', label: '近三年开展的主要工作及取得的效果', type: 'textarea' }
])
}
]
},
gxmu_wshqtzb: {
title: '五四红旗团支部申报表',
subtitle: '参考后台 WSHQTZBForm 字段结构,适用于基层团支部集体申报。',
sections: [
{
title: '组织基本信息',
fields: createFields([
{ key: 'year', label: '年份', type: 'year', required: true },
{ key: 'branchName', label: '团支部全称', required: true },
{ key: 'secondOrg', label: '所属二级团组织' },
{ key: 'secretary', label: '团支部书记' },
{ key: 'politics', label: '政治面貌' },
{ key: 'contact', label: '联系方式', type: 'phone' },
{ key: 'establishTime', label: '成立时间', type: 'date' },
{ key: 'lastElectionTime', label: '最近一次换届时间', type: 'date' },
{ key: 'smartSystemLogin', label: '是否已登录智慧团建系统', type: 'select', options: YES_NO_OPTIONS },
{ key: 'memberTotal', label: '现有团员总数', type: 'number' },
{ key: 'memberDeveloped2024', label: '上一年度发展团员数', type: 'number' }
])
},
{
title: '年度建设情况',
fields: createFields([
{ key: 'feeReceivable2024', label: '上一年度应收团费', type: 'number' },
{ key: 'feeReceived2024', label: '上一年度实收团费', type: 'number' },
{ key: 'feePayable2024', label: '上一年度应上缴团费', type: 'number' },
{ key: 'feePaid2024', label: '上一年度实际上缴团费', type: 'number' },
{ key: 'recommendActivist2024', label: '推荐入党积极分子人数', type: 'number' },
{ key: 'activistConfirmed', label: '确定为入党积极分子数', type: 'number' },
{ key: 'recommendDevTarget2024', label: '推荐党的发展对象人数', type: 'number' },
{ key: 'devTargetConfirmed', label: '确定为党的发展对象数', type: 'number' },
{ key: 'branchCommitteeMeetingCount', label: '团支部委员会会议召开次数', type: 'number' },
{ key: 'branchMemberMeetingCount', label: '团支部团员大会召开次数', type: 'number' },
{ key: 'eduEvalDone', label: '是否开展团员教育评议', type: 'select', options: YES_NO_OPTIONS },
{ key: 'annualRegDone', label: '是否开展团员年度团籍注册', type: 'select', options: YES_NO_OPTIONS },
{ key: 'classCount', label: '开展团课次数', type: 'number' },
{ key: 'smartSystem100', label: '是否100%录入智慧团建', type: 'select', options: YES_NO_OPTIONS }
])
},
{
title: '主要成果',
fields: createFields([
{ key: 'honorsFiveYears', label: '近五年获得院级及以上荣誉情况', type: 'textarea' },
{ key: 'workSummaryThreeYears', label: '近三年开展的主要工作及取得的效果', type: 'textarea' }
])
}
]
},
gxmu_yxgqtdgb: {
title: '优秀共青团干部申报表',
subtitle: '参考后台 YXGQTDGBForm 字段结构,适用于团干部个人申报。',
sections: [
{
title: '基础信息',
fields: createFields([
{ key: 'year', label: '年份', type: 'year', required: true },
{ key: 'name', label: '姓名', required: true },
{ key: 'gender', label: '性别', type: 'select', options: GENDER_OPTIONS },
{ key: 'nation', label: '民族' },
{ key: 'birthMonth', label: '出生年月', type: 'month' },
{ key: 'politics', label: '政治面貌' },
{ key: 'position', label: '职务' },
{ key: 'identity', label: '身份' },
{ key: 'organization', label: '所在团组织' },
{ key: 'contact', label: '联系方式', type: 'phone' },
{ key: 'memberNo', label: '发展团员编号' },
{ key: 'currentDutyTime', label: '任现团内职务时间', type: 'month' },
{ key: 'cadreYears', label: '担任团干部年限' },
{ key: 'assessment2024', label: '上一年度工作考核结果' },
{ key: 'volunteerRegTime', label: '成为注册志愿者时间', type: 'month' }
])
},
{
title: '申报内容',
fields: createFields([
{ key: 'cadreExperience', label: '从事团干部经历', type: 'textarea' },
{ key: 'honorsFiveYears', label: '近五年获得校级及以上荣誉情况', type: 'textarea' },
{ key: 'mainStory', label: '主要事迹', type: 'textarea' }
])
}
]
},
gxmu_yxgqty: {
title: '优秀共青团员申报表',
subtitle: '参考后台 YXGQTYForm 字段结构,适用于团员个人申报。',
sections: [
{
title: '基础信息',
fields: createFields([
{ key: 'year', label: '年份', type: 'year', required: true },
{ key: 'name', label: '姓名', required: true },
{ key: 'gender', label: '性别', type: 'select', options: GENDER_OPTIONS },
{ key: 'nation', label: '民族' },
{ key: 'politics', label: '政治面貌' },
{ key: 'birthMonth', label: '出生年月', type: 'month' },
{ key: 'joinTime', label: '入团时间', type: 'month' },
{ key: 'collegeMajorClass', label: '所在学院、专业、班级' },
{ key: 'position', label: '职务' },
{ key: 'volunteerRegTime', label: '成为注册志愿者时间', type: 'month' },
{ key: 'eduEval2024', label: '上一年度团员教育评议等次' },
{ key: 'smartSystem', label: '是否已登录智慧团建系统', type: 'select', options: YES_NO_OPTIONS },
{ key: 'contact', label: '联系电话', type: 'phone' },
{ key: 'totalVolunteerHours', label: '累计志愿服务时长' },
{ key: 'volunteerHours2024', label: '上一年度志愿服务时长' },
{ key: 'memberNo', label: '发展团员编号' }
])
},
{
title: '申报内容',
fields: createFields([
{ key: 'honorsFiveYears', label: '近五年获得荣誉情况', type: 'textarea' },
{ key: 'mainStory', label: '主要事迹', type: 'textarea' }
])
}
]
},
gxmu_tzbcy_form: {
title: '挑战杯申报表',
subtitle: '面向挑战杯项目申报、团队信息和成果材料整理。',
sections: [
{
title: '项目信息',
fields: createFields([
{ key: 'year', label: '年份', type: 'year', required: true },
{ key: 'projectName', label: '项目名称', required: true },
{ key: 'projectType', label: '项目类型', required: true },
{ key: 'projectGroup', label: '项目分组', required: true },
{ key: 'leaderName', label: '项目负责人', required: true },
{ key: 'leaderCollege', label: '负责人学院' },
{ key: 'leaderPhone', label: '负责人电话', type: 'phone' },
{ key: 'guidanceTeacher', label: '指导老师' }
])
},
{
title: '项目内容',
fields: createFields([
{ key: 'projectSummary', label: '项目简介', type: 'textarea', required: true },
{ key: 'innovationPoint', label: '创新亮点', type: 'textarea' },
{ key: 'teamMembers', label: '团队成员', type: 'textarea' },
{ key: 'honors', label: '已有成果与奖励', type: 'textarea' }
])
}
]
},
gxmu_qmgc_form: {
title: '青马工程申报表',
subtitle: '用于青马工程学员基础信息、培养情况和成果申报。',
sections: [
{
title: '基础信息',
fields: createFields([
{ key: 'year', label: '年份', type: 'year', required: true },
{ key: 'name', label: '姓名', required: true },
{ key: 'gender', label: '性别', type: 'select', options: GENDER_OPTIONS },
{ key: 'college', label: '学院', required: true },
{ key: 'className', label: '班级' },
{ key: 'phone', label: '联系电话', type: 'phone' },
{ key: 'politics', label: '政治面貌' }
])
},
{
title: '培养情况',
fields: createFields([
{ key: 'trainingExperience', label: '培养经历', type: 'textarea', required: true },
{ key: 'practiceExperience', label: '实践经历', type: 'textarea' },
{ key: 'mainAchievement', label: '主要成果', type: 'textarea' },
{ key: 'selfEvaluation', label: '个人总结', type: 'textarea' }
])
}
]
},
gxmu_wxxzx_project: {
title: '未来学术之星申报表',
subtitle: '用于未来学术之星项目申报、课题信息和支撑材料整理。',
sections: [
{
title: '项目基础信息',
fields: createFields([
{ key: 'year', label: '年份', type: 'year', required: true },
{ key: 'projectName', label: '项目名称', required: true },
{ key: 'applicantName', label: '申请人', required: true },
{ key: 'college', label: '学院', required: true },
{ key: 'major', label: '专业' },
{ key: 'phone', label: '联系电话', type: 'phone' },
{ key: 'guidanceTeacher', label: '指导老师' }
])
},
{
title: '申报内容',
fields: createFields([
{ key: 'researchDirection', label: '研究方向', type: 'textarea' },
{ key: 'projectBasis', label: '项目基础', type: 'textarea', required: true },
{ key: 'researchPlan', label: '研究计划', type: 'textarea' },
{ key: 'expectedResult', label: '预期成果', type: 'textarea' }
])
}
]
}
}
export const getModuleMeta = (code) => GXMU_MODULES.find((item) => item.code === code)
export const getFormConfig = (code) => GXMU_FORM_MAP[code]
export const createInitialFormData = (code) => {
const config = getFormConfig(code)
if (!config) {
return {}
}
return config.sections.reduce((result, section) => {
section.fields.forEach((field) => {
result[field.key] = field.defaultValue || ''
})
return result
}, {})
}

View File

@@ -0,0 +1,886 @@
<template>
<view class="page">
<scroll-view scroll-y class="page-scroll">
<common-hero title="跨校活动情报看板" :use-image-bg="true"></common-hero>
<view class="hero-panel">
<view class="hero-copy">
<view class="hero-kicker">跨校活动情报看板</view>
<view class="hero-title">把兄弟高校的活动打法整理成一眼可读的案例板</view>
<view class="hero-desc">
以信息流和案例卡片形式集中展示同类活动的公开线索方便快速判断选题趋势包装方式和传播动作
</view>
<view class="hero-actions">
<view class="hero-btn hero-btn--primary" @tap="quickFilter('高热')">本周热点</view>
<view class="hero-btn" @tap="quickMatchPlanning">拟办活动对标</view>
</view>
</view>
<view class="hero-stats">
<view class="hero-stat hero-stat--primary">
<view class="stat-label">案例总量</view>
<view class="stat-value">{{ totalCount }}</view>
<view class="stat-desc">当前静态示例数据</view>
</view>
<view class="hero-stat">
<view class="stat-label">高热案例</view>
<view class="stat-value">{{ highHeatCount }}</view>
<view class="stat-desc">适合优先拆解传播路径</view>
</view>
<view class="hero-stat">
<view class="stat-label">覆盖类型</view>
<view class="stat-value">{{ categoryCount }}</view>
<view class="stat-desc">品牌活动 / 实践 / 科创 / 文体</view>
</view>
<view class="hero-stat">
<view class="stat-label">来源院校</view>
<view class="stat-value">{{ schoolCount }}</view>
<view class="stat-desc">按院校公开渠道聚合</view>
</view>
</view>
</view>
<view class="filter-panel">
<view class="filter-group">
<view class="filter-label">活动类型</view>
<scroll-view scroll-x class="chip-scroll" show-scrollbar="false">
<view class="chip-row">
<view
v-for="item in categoryOptions"
:key="item"
class="filter-chip"
:class="{ 'filter-chip--active': selectedCategory === item }"
@tap="selectedCategory = item"
>
{{ item }}
</view>
</view>
</scroll-view>
</view>
<view class="filter-group">
<view class="filter-label">热度等级</view>
<scroll-view scroll-x class="chip-scroll" show-scrollbar="false">
<view class="chip-row">
<view
v-for="item in heatOptions"
:key="item"
class="filter-chip"
:class="{ 'filter-chip--active': selectedHeat === item }"
@tap="selectedHeat = item"
>
{{ item }}
</view>
</view>
</scroll-view>
</view>
<view class="search-box">
<uv-input
v-model.trim="keyword"
class="search-input"
placeholder="搜索标题、院校、标签"
confirm-type="search"
:maxlength="-1"
border="none"
/>
<view v-if="keyword" class="search-clear" @tap="keyword = ''">清空</view>
</view>
</view>
<view class="section-grid">
<view class="stream-card">
<view class="section-title">情报信息流</view>
<view class="stream-list">
<view
v-for="item in streamItems"
:key="item.id"
class="stream-item"
>
<view class="stream-cover" :class="coverClassMap[item.id % coverClassMap.length]">
<view class="stream-cover-category">{{ item.category }}</view>
<view class="stream-cover-school">{{ item.school }}</view>
</view>
<view class="stream-content">
<view class="stream-top">
<view class="stream-title">{{ item.title }}</view>
<view class="heat-tag" :class="heatClassMap[item.heat]">{{ item.heat }}</view>
</view>
<view class="stream-meta">{{ item.school }} · {{ item.source }} · {{ item.publishAt }}</view>
<view class="stream-summary">{{ item.summary }}</view>
<view class="tag-list">
<view v-for="tag in item.tags" :key="tag" class="tag-chip">{{ tag }}</view>
</view>
</view>
</view>
</view>
</view>
<view class="signal-card">
<view class="section-title">策略信号</view>
<view class="signal-list">
<view v-for="item in insightSignals" :key="item.title" class="signal-item">
<view class="signal-title">{{ item.title }}</view>
<view class="signal-desc">{{ item.desc }}</view>
</view>
</view>
<view class="signal-divider"></view>
<view class="section-title section-title--small">建议关注</view>
<view class="watch-list">
<view v-for="item in watchList" :key="item" class="watch-item">{{ item }}</view>
</view>
</view>
</view>
<view id="cases-section" class="cases-card">
<view class="cases-head">
<view class="section-title">案例卡片墙</view>
<view class="case-count"> {{ filteredCases.length }} </view>
</view>
<view v-if="filteredCases.length" class="cases-list">
<view
v-for="item in filteredCases"
:key="item.id"
class="case-card"
>
<view class="case-cover" :class="coverClassMap[item.id % coverClassMap.length]">
<view class="case-school">{{ item.school }}</view>
<view class="case-cover-badge">{{ item.highlight }}</view>
</view>
<view class="case-body">
<view class="case-head">
<view class="case-tag">{{ item.category }}</view>
<view class="heat-tag" :class="heatClassMap[item.heat]">{{ item.heat }}</view>
</view>
<view class="case-title">{{ item.title }}</view>
<view class="case-summary">{{ item.summary }}</view>
<view class="case-info">来源{{ item.source }}</view>
<view class="case-info">发布时间{{ item.publishAt }}</view>
<view class="tag-list">
<view v-for="tag in item.tags" :key="tag" class="tag-chip"># {{ tag }}</view>
</view>
<view class="case-footer">
<view class="signal-badge">
<view class="signal-badge-label">可借鉴动作</view>
<view class="signal-badge-value">{{ item.highlight }}</view>
</view>
<view class="origin-link" @tap="handleOpenLink(item)">查看原文</view>
</view>
</view>
</view>
</view>
<view v-else class="empty-card">暂无匹配的案例</view>
</view>
</scroll-view>
</view>
</template>
<script>
import CommonHero from '../../components/common-hero/common-hero.vue'
const CASES = [
{
id: 1,
title: '青年宣讲训练营联合短视频挑战赛',
school: '华东某高校团委',
category: '品牌活动',
heat: '高热',
source: '校团委公众号',
publishAt: '03-26 18:20',
summary:
'以“训练营 + 路演 + 短视频共创”三段式推进,先做骨干培训,再通过挑战赛扩散校园参与度,适合复制到主题教育和品牌宣讲场景。',
tags: ['宣讲', '短视频', '路演', '骨干培训'],
link: 'https://example.com/cross-school/case-01',
highlight: '训练营 + 二创传播'
},
{
id: 2,
title: '劳动教育月采用主会场直播联动分会场打卡',
school: '华南某高校学生会',
category: '实践活动',
heat: '跟进',
source: '青春校园网',
publishAt: '03-25 09:40',
summary:
'通过主会场直播统一调性,再让各学院自带话题完成线下打卡,既保留主视觉控制力,也扩大二级学院参与面。',
tags: ['劳动教育', '直播', '学院联动'],
link: 'https://example.com/cross-school/case-02',
highlight: '直播总控 + 分会场扩散'
},
{
id: 3,
title: '科创竞赛经验周报做成案例地图与备赛清单',
school: '华中某高校创新中心',
category: '科创竞赛',
heat: '高热',
source: '双创平台',
publishAt: '03-24 21:15',
summary:
'不只发布赛事通知,而是补充往届项目地图、导师方向、材料清单和时间线,显著降低学生获取备赛信息的门槛。',
tags: ['挑战杯', '项目库', '备赛', '知识沉淀'],
link: 'https://example.com/cross-school/case-03',
highlight: '赛事通知升级为攻略型内容'
},
{
id: 4,
title: '校园歌会从报名表单升级为城市主题内容企划',
school: '西南某高校团委',
category: '文体活动',
heat: '观察',
source: '学校新闻网',
publishAt: '03-23 16:05',
summary:
'围绕城市记忆与青年表达设置主题分场,通过海报、话题征集和观众投票提前积累内容资产,活动尚未开始就形成讨论度。',
tags: ['歌会', '主题策划', '投票互动'],
link: 'https://example.com/cross-school/case-04',
highlight: '先做内容议题再做活动报名'
},
{
id: 5,
title: '社会实践出征仪式叠加成果展和队伍名片墙',
school: '西北某高校实践中心',
category: '实践活动',
heat: '跟进',
source: '实践育人专栏',
publishAt: '03-22 11:30',
summary:
'将传统仪式升级为“成果展 + 队伍画像 + 媒体素材包”组合,便于后续持续发布实践过程稿和回访报道。',
tags: ['社会实践', '成果展', '队伍画像'],
link: 'https://example.com/cross-school/case-05',
highlight: '出征现场即完成素材采集'
},
{
id: 6,
title: '青马工程读书班采用任务制和周主题海报并行',
school: '东北某高校马克思主义学院',
category: '品牌活动',
heat: '高热',
source: '学院团学平台',
publishAt: '03-21 20:10',
summary:
'将阅读、研讨、海报共创和阶段展示拆成清晰任务包,持续释放可视化成果,比单次讲座更容易维持项目关注度。',
tags: ['青马工程', '阅读打卡', '任务制'],
link: 'https://example.com/cross-school/case-06',
highlight: '过程任务化,成果可视化'
}
]
const INSIGHT_SIGNALS = [
{
title: '“活动报名”正在转向“内容预热”',
desc: '高热案例普遍会在报名期前先释放主题海报、人物故事或任务线索,先做认知再做转化。'
},
{
title: '学院联动成为扩大参与面的主流方式',
desc: '不少活动不再单点发通知,而是设计统一主视觉和话题标签,把执行动作分发给二级学院。'
},
{
title: '成果展示正在前置',
desc: '从出征仪式到训练营,多数案例会在启动阶段同步准备成果墙、名片墙或可转发素材。'
}
]
const WATCH_LIST = [
'活动是否配置统一 hashtag 与二创任务',
'是否提供报名后自动领取的素材包',
'是否能把案例沉淀为往届经验库',
'是否区分预热、爆发、回顾三个传播阶段'
]
export default {
components: {
CommonHero
},
data() {
return {
categoryOptions: ['全部', '品牌活动', '实践活动', '科创竞赛', '文体活动'],
heatOptions: ['全部', '高热', '跟进', '观察'],
selectedCategory: '全部',
selectedHeat: '全部',
keyword: '',
cases: CASES,
insightSignals: INSIGHT_SIGNALS,
watchList: WATCH_LIST,
coverClassMap: ['cover-a', 'cover-b', 'cover-c', 'cover-d'],
heatClassMap: {
高热: 'heat-tag--hot',
跟进: 'heat-tag--follow',
观察: 'heat-tag--watch'
}
}
},
computed: {
filteredCases() {
const text = String(this.keyword || '').trim().toLowerCase()
return this.cases.filter((item) => {
const matchCategory = this.selectedCategory === '全部' || item.category === this.selectedCategory
const matchHeat = this.selectedHeat === '全部' || item.heat === this.selectedHeat
const searchText = [item.title, item.school, item.summary, item.tags.join(' ')].join(' ').toLowerCase()
return matchCategory && matchHeat && (!text || searchText.includes(text))
})
},
totalCount() {
return this.cases.length
},
highHeatCount() {
return this.cases.filter((item) => item.heat === '高热').length
},
categoryCount() {
return new Set(this.cases.map((item) => item.category)).size
},
schoolCount() {
return new Set(this.cases.map((item) => item.school)).size
},
streamItems() {
return this.filteredCases.slice(0, 4)
}
},
methods: {
quickFilter(heat) {
this.selectedHeat = heat
},
quickMatchPlanning() {
this.selectedCategory = '品牌活动'
this.selectedHeat = '高热'
uni.showToast({
title: '已切换到高热品牌活动案例',
icon: 'none'
})
},
handleOpenLink(item) {
if (!item || !item.link) {
uni.showToast({
title: '原文链接待接入',
icon: 'none'
})
return
}
uni.setClipboardData({
data: item.link,
success: () => {
uni.showToast({
title: '原文链接已复制',
icon: 'none'
})
}
})
}
}
}
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
background:
radial-gradient(circle at top left, rgba(80, 180, 255, 0.18), transparent 28%),
linear-gradient(180deg, #f5fbff 0%, #f5fbff 36%, #fff 100%);
}
.page-scroll {
height: 100vh;
//padding: 28rpx 24rpx 40rpx;
box-sizing: border-box;
}
.hero-panel {
padding: 30rpx;
border-radius: 30rpx;
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.94), rgba(245, 251, 255, 0.94)),
repeating-linear-gradient(
90deg,
rgba(21, 144, 255, 0.04) 0,
rgba(21, 144, 255, 0.04) 2rpx,
transparent 2rpx,
transparent 30rpx
);
box-shadow: 0 16rpx 48rpx rgba(21, 144, 255, 0.1);
border: 1rpx solid rgba(21, 144, 255, 0.14);
}
.hero-kicker {
display: inline-flex;
align-items: center;
padding: 10rpx 18rpx;
border-radius: 999rpx;
background: rgba(21, 144, 255, 0.08);
color: #1496f2;
font-size: 22rpx;
font-weight: 600;
letter-spacing: 1rpx;
}
.hero-title {
margin-top: 22rpx;
color: #1f2d3d;
font-size: 44rpx;
line-height: 1.3;
font-weight: 700;
}
.hero-desc {
margin-top: 16rpx;
color: #5f7f9f;
font-size: 25rpx;
line-height: 1.8;
}
.hero-actions {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
margin-top: 24rpx;
}
.hero-btn {
padding: 16rpx 24rpx;
border-radius: 999rpx;
background: #f3f9ff;
border: 1rpx solid rgba(21, 144, 255, 0.16);
color: #1496f2;
font-size: 24rpx;
font-weight: 600;
}
.hero-btn--primary {
background: linear-gradient(135deg, #32bfff, #1496f2);
color: #fff;
border-color: transparent;
box-shadow: 0 10rpx 26rpx rgba(21, 144, 255, 0.22);
}
.hero-stats {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16rpx;
margin-top: 26rpx;
}
.hero-stat {
padding: 22rpx;
border-radius: 24rpx;
background: rgba(255, 255, 255, 0.92);
border: 1rpx solid rgba(21, 144, 255, 0.12);
box-shadow: 0 10rpx 28rpx rgba(21, 144, 255, 0.07);
}
.hero-stat--primary {
background: linear-gradient(160deg, #32bfff 0%, #1496f2 100%);
color: #fff;
}
.hero-stat--primary .stat-label,
.hero-stat--primary .stat-desc {
color: rgba(255, 255, 255, 0.84);
}
.stat-label {
color: #5f7f9f;
font-size: 22rpx;
}
.stat-value {
margin: 10rpx 0 8rpx;
font-size: 48rpx;
line-height: 1;
font-weight: 700;
}
.stat-desc {
color: #5f7f9f;
font-size: 21rpx;
line-height: 1.6;
}
.demo-alert,
.filter-panel,
.stream-card,
.signal-card,
.cases-card {
margin-top: 20rpx;
padding: 24rpx;
border-radius: 28rpx;
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 16rpx 40rpx rgba(21, 144, 255, 0.08);
}
.demo-alert {
border: 1rpx solid rgba(21, 144, 255, 0.18);
background: rgba(255, 251, 248, 0.95);
}
.demo-alert-title {
font-size: 26rpx;
font-weight: 700;
color: #1f2d3d;
}
.demo-alert-desc {
margin-top: 10rpx;
font-size: 23rpx;
line-height: 1.7;
color: #5f7f9f;
}
.filter-group + .filter-group {
margin-top: 18rpx;
}
.filter-label {
color: #5f7f9f;
font-size: 22rpx;
font-weight: 600;
}
.chip-scroll {
width: 100%;
margin-top: 14rpx;
white-space: nowrap;
}
.chip-row {
display: inline-flex;
gap: 12rpx;
padding-right: 8rpx;
}
.filter-chip {
display: inline-flex;
align-items: center;
padding: 10rpx 20rpx;
border: 1rpx solid rgba(21, 144, 255, 0.16);
border-radius: 999rpx;
background: #f5fbff;
color: #1496f2;
font-size: 22rpx;
}
.filter-chip--active {
color: #fff;
border-color: transparent;
background: linear-gradient(135deg, #32bfff, #1496f2);
box-shadow: 0 8rpx 20rpx rgba(21, 144, 255, 0.22);
}
.search-box {
display: flex;
align-items: center;
gap: 14rpx;
margin-top: 18rpx;
padding: 0 18rpx;
height: 80rpx;
border-radius: 999rpx;
background: #f8fbff;
border: 1rpx solid rgba(21, 144, 255, 0.12);
}
:deep(.search-input.uv-input) {
flex: 1;
height: 100%;
padding: 0 !important;
background: transparent !important;
border: 0 !important;
}
:deep(.search-input.uv-input .uv-input__content__field-wrapper__field) {
font-size: 24rpx;
color: #1f2d3d;
}
.search-clear {
flex-shrink: 0;
font-size: 22rpx;
color: #1496f2;
}
.section-grid {
margin-top: 20rpx;
}
.section-title {
font-size: 30rpx;
font-weight: 700;
color: #1f2d3d;
}
.section-title--small {
font-size: 26rpx;
}
.stream-list,
.signal-list,
.watch-list,
.cases-list {
margin-top: 18rpx;
}
.stream-item,
.signal-item,
.case-card,
.watch-item {
border: 1rpx solid rgba(21, 144, 255, 0.1);
background: linear-gradient(180deg, #f5fbff, #fff);
}
.stream-item {
padding: 18rpx;
border-radius: 24rpx;
}
.stream-item + .stream-item,
.signal-item + .signal-item,
.watch-item + .watch-item,
.case-card + .case-card {
margin-top: 16rpx;
}
.stream-cover,
.case-cover {
position: relative;
overflow: hidden;
border-radius: 20rpx;
}
.stream-cover {
height: 180rpx;
padding: 18rpx;
}
.case-cover {
height: 220rpx;
padding: 20rpx;
}
.cover-a {
background: linear-gradient(135deg, #9be7ff 0%, #32bfff 52%, #1496f2 100%);
}
.cover-b {
background: linear-gradient(135deg, #c7f9cc 0%, #57cc99 52%, #38a3a5 100%);
}
.cover-c {
background: linear-gradient(135deg, #ffd6a5 0%, #ffadad 50%, #ff7b7b 100%);
}
.cover-d {
background: linear-gradient(135deg, #cdb4db 0%, #a2d2ff 52%, #8ecae6 100%);
}
.stream-cover::after,
.case-cover::after {
content: '';
position: absolute;
right: -40rpx;
top: -40rpx;
width: 180rpx;
height: 180rpx;
border-radius: 50%;
background: rgba(255, 255, 255, 0.18);
}
.stream-cover-category,
.stream-cover-school,
.case-school,
.case-cover-badge {
position: relative;
z-index: 1;
color: #fff;
}
.stream-cover-category,
.case-cover-badge {
display: inline-flex;
padding: 8rpx 14rpx;
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.18);
font-size: 20rpx;
font-weight: 600;
}
.stream-cover-school,
.case-school {
margin-top: 18rpx;
font-size: 30rpx;
font-weight: 700;
line-height: 1.4;
}
.stream-content,
.case-body {
margin-top: 16rpx;
}
.stream-top,
.case-head,
.cases-head,
.case-footer {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16rpx;
}
.stream-title,
.case-title,
.signal-title {
color: #1f2d3d;
font-weight: 700;
line-height: 1.5;
}
.stream-title,
.case-title {
font-size: 29rpx;
}
.stream-meta,
.case-info,
.signal-desc,
.watch-item,
.stream-summary,
.case-summary {
color: #5f7f9f;
font-size: 23rpx;
line-height: 1.75;
}
.stream-meta {
margin-top: 8rpx;
}
.stream-summary,
.case-summary {
margin-top: 12rpx;
color: #1f2d3d;
}
.tag-list {
display: flex;
flex-wrap: wrap;
gap: 10rpx;
margin-top: 14rpx;
}
.tag-chip,
.case-tag,
.heat-tag {
display: inline-flex;
align-items: center;
padding: 6rpx 14rpx;
border-radius: 999rpx;
font-size: 20rpx;
}
.tag-chip,
.case-tag {
background: #e8f4ff;
color: #1496f2;
}
.heat-tag {
flex-shrink: 0;
font-weight: 600;
}
.heat-tag--hot {
background: #fff1f0;
color: #e34d59;
}
.heat-tag--follow {
background: #eef6ff;
color: #2563eb;
}
.heat-tag--watch {
background: #f3f4f6;
color: #6b7280;
}
.signal-item,
.watch-item {
padding: 18rpx;
border-radius: 22rpx;
}
.signal-desc {
margin-top: 8rpx;
}
.signal-divider {
height: 1rpx;
margin: 20rpx 0;
background: rgba(21, 144, 255, 0.1);
}
.case-count {
flex-shrink: 0;
font-size: 22rpx;
color: #5f7f9f;
}
.case-card {
border-radius: 26rpx;
overflow: hidden;
}
.case-cover-badge {
position: absolute;
left: 20rpx;
bottom: 20rpx;
}
.case-body {
padding: 0 20rpx 20rpx;
}
.case-title {
margin-top: 14rpx;
}
.case-info {
margin-top: 10rpx;
}
.signal-badge {
flex: 1;
min-width: 0;
padding: 14rpx 18rpx;
border-radius: 20rpx;
background: #f7fbff;
}
.signal-badge-label {
font-size: 20rpx;
color: #7b8794;
}
.signal-badge-value {
margin-top: 8rpx;
font-size: 24rpx;
font-weight: 700;
color: #1f2d3d;
line-height: 1.5;
}
.origin-link {
flex-shrink: 0;
padding-top: 18rpx;
font-size: 24rpx;
font-weight: 600;
color: #1496f2;
}
.empty-card {
margin-top: 18rpx;
padding: 30rpx 24rpx;
border-radius: 24rpx;
background: #f8fbff;
font-size: 24rpx;
color: #90a0b7;
text-align: center;
}
</style>

View File

@@ -0,0 +1,10 @@
import { API_BASE_URL } from '../../utils/request'
import { apiRequest } from '../assistant/chat-service'
export function listDeclare(params) {
return apiRequest({
url: `${API_BASE_URL}/gxmu/declare`,
method: 'GET',
data: params
})
}

892
pages/gxmu/form.vue Normal file
View File

@@ -0,0 +1,892 @@
<template>
<view v-if="formConfig" class="page">
<scroll-view scroll-y class="page-scroll">
<common-hero :title="pageTitle" :use-image-bg="true"></common-hero>
<view class="hero-wrap">
<view class="hero-card">
<view class="hero-title">{{ pageTitle }}</view>
<view class="hero-desc">{{ moduleMeta && moduleMeta.desc ? moduleMeta.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>
<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">{{ getRecordTitle(item) }}</view>
<view class="card-subtitle">{{ getRecordSubtitle(item) }}</view>
</view>
<view class="card-year">{{ item.year || fixedYear || '-' }}</view>
</view>
<view class="meta-line">{{ getRecordMeta(item) }}</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">{{ formData.id ? `编辑${pageTitle}` : `新增${pageTitle}` }}</view>
<view class="modal-close" @tap="closeEdit">关闭</view>
</view>
<scroll-view scroll-y class="modal-body">
<view class="hero-meta hero-meta--form">
<view class="meta-item meta-item--form">
<view class="meta-label meta-label--form">申报年度</view>
<view class="meta-value meta-value--form">{{ fixedYear || formData.year || '-' }}</view>
</view>
<view class="meta-item meta-item--form">
<view class="meta-label meta-label--form">草稿状态</view>
<view class="meta-value meta-value--form">{{ draftSavedAt ? '已保存' : '未保存' }}</view>
</view>
</view>
<view v-for="section in formConfig.sections" :key="section.title" class="section-card">
<view class="section-head">
<view class="section-title">{{ section.title }}</view>
</view>
<view class="field-list">
<view v-for="field in section.fields" :key="field.key" class="field-item">
<view class="field-label">
{{ field.label }}
<text v-if="field.required" class="field-required">*</text>
</view>
<textarea
v-if="field.type === 'textarea'"
class="field-textarea"
:placeholder="field.placeholder"
:maxlength="-1"
:value="formData[field.key]"
@input="handleInput(field, $event)"
/>
<view
v-else-if="isGenderField(field)"
class="field-picker"
@tap="openGenderPicker(field)"
>
<text :class="formData[field.key] ? 'field-value' : 'field-placeholder'">
{{ formData[field.key] || field.placeholder }}
</text>
</view>
<view
v-else-if="isNationField(field)"
class="field-item-picker-wrap"
>
<picker
mode="selector"
:range="nationLabels"
:value="getNationIndex(field)"
:disabled="!nationOptions.length"
@change="handleNationPickerChange(field, $event)"
>
<view class="field-picker" :class="{ 'field-picker--disabled': !nationOptions.length }">
<text :class="formData[field.key] ? 'field-value' : 'field-placeholder'">
{{ formData[field.key] || (nationOptions.length ? '请选择民族' : '民族加载中') }}
</text>
</view>
</picker>
</view>
<view
v-else-if="isUvPickerField(field)"
class="field-picker"
:class="{ 'field-picker--disabled': isPickerFieldDisabled(field) }"
@tap="openSelectPicker(field)"
>
<text :class="formData[field.key] ? 'field-value' : 'field-placeholder'">
{{ getPickerDisplayText(field) }}
</text>
</view>
<view
v-else-if="isDateField(field)"
class="field-picker"
@tap="openDatePicker(field)"
>
<text :class="formData[field.key] ? 'field-value' : 'field-placeholder'">
{{ formData[field.key] || getDatePlaceholder(field) }}
</text>
</view>
<uv-input
v-else
class="field-input"
:modelValue="formData[field.key]"
:type="getInputType(field)"
:placeholder="field.placeholder"
placeholder-style="color: #c0c4cc;"
:maxlength="-1"
@input="setFieldValue(field.key, $event)"
/>
</view>
</view>
</view>
</scroll-view>
<view class="modal-actions">
<view class="ghost-btn" @tap="saveDraft">保存草稿</view>
<view class="primary-btn" @tap="submitForm">提交申报</view>
</view>
</view>
</view>
</view>
<view v-else class="empty-page">
<common-hero :title="pageTitle || '申报表'" :use-image-bg="true"></common-hero>
<view class="empty-body">
<view class="empty-title">未找到对应表单</view>
<view class="empty-desc">请返回上一页重新选择申报模块</view>
</view>
</view>
<uv-picker
ref="genderPicker"
title="选择性别"
:columns="genderPickerColumns"
:defaultIndex="[genderPickerIndex]"
keyName="text"
@close="noop"
@cancel="noop"
@confirm="confirmGenderPicker"
></uv-picker>
<uv-picker
ref="selectPicker"
:title="selectPickerTitle"
:columns="selectPickerColumns"
:defaultIndex="[selectPickerIndex]"
keyName="text"
@close="noop"
@cancel="noop"
@confirm="confirmSelectPicker"
></uv-picker>
<uv-picker
ref="datePicker"
:title="datePickerTitle"
:columns="datePickerColumns"
:defaultIndex="datePickerIndexs"
keyName="text"
@change="handleDatePickerChange"
@cancel="noop"
@confirm="confirmDatePicker"
></uv-picker>
</template>
<script>
import CommonHero from '../../components/common-hero/common-hero.vue'
import { createInitialFormData, getFormConfig, getModuleMeta } from '../../utils/gxmu/config'
import {
addSjqnForm,
addSjtbzbsjForm,
addWshqtwForm,
addWshqtzbForm,
addYxgqtdgbForm,
addYxgqtyForm,
getSjqnForm,
getSjtbzbsjForm,
getWshqtwForm,
getWshqtzbForm,
getYxgqtdgbForm,
getYxgqtyForm,
listDictData,
updateSjqnForm,
updateSjtbzbsjForm,
updateWshqtwForm,
updateWshqtzbForm,
updateYxgqtdgbForm,
updateYxgqtyForm,
userPageSjqnForm,
userPageSjtbzbsjForm,
userPageWshqtwForm,
userPageWshqtzbForm,
userPageYxgqtdgbForm,
userPageYxgqtyForm
} from './module-form-service'
const STORAGE_PREFIX = 'gxmu_form_draft_'
const POLITICS_DICT_ID = 172
const NATION_DICT_ID = 173
const MODULE_API_MAP = {
gxmu_sjqn: {
get: getSjqnForm,
add: addSjqnForm,
update: updateSjqnForm,
page: userPageSjqnForm
},
gxmu_sjtbzbsj: {
get: getSjtbzbsjForm,
add: addSjtbzbsjForm,
update: updateSjtbzbsjForm,
page: userPageSjtbzbsjForm
},
gxmu_wshqtw: {
get: getWshqtwForm,
add: addWshqtwForm,
update: updateWshqtwForm,
page: userPageWshqtwForm
},
gxmu_wshqtzb: {
get: getWshqtzbForm,
add: addWshqtzbForm,
update: updateWshqtzbForm,
page: userPageWshqtzbForm
},
gxmu_yxgqtdgb: {
get: getYxgqtdgbForm,
add: addYxgqtdgbForm,
update: updateYxgqtdgbForm,
page: userPageYxgqtdgbForm
},
gxmu_yxgqty: {
get: getYxgqtyForm,
add: addYxgqtyForm,
update: updateYxgqtyForm,
page: userPageYxgqtyForm
}
}
export default {
components: {
CommonHero
},
data() {
return {
moduleCode: '',
moduleMeta: null,
formConfig: null,
formData: {},
draftSavedAt: '',
fixedYear: '',
declareTitle: '',
nationOptions: [],
politicsOptions: [],
recordId: '',
loading: false,
showEdit: false,
list: [],
genderFieldKey: '',
genderPickerColumns: [[]],
selectFieldKey: '',
selectPickerTitle: '',
selectPickerColumns: [[]],
dateFieldKey: '',
datePickerTitle: '',
datePickerColumns: [[]],
datePickerIndexs: []
}
},
computed: {
pageTitle() {
return this.declareTitle || (this.formConfig ? this.formConfig.title : '申报表')
},
nationLabels() {
return this.nationOptions
},
genderPickerIndex() {
const options = (this.genderPickerColumns[0] || []).map((item) => item.value)
const index = options.indexOf(this.formData[this.genderFieldKey])
return index > -1 ? index : 0
},
selectPickerIndex() {
const options = (this.selectPickerColumns[0] || []).map((item) => item.value)
const index = options.indexOf(this.formData[this.selectFieldKey])
return index > -1 ? index : 0
}
},
onLoad(options) {
const code = options.code || ''
const config = getFormConfig(code)
const meta = getModuleMeta(code)
const fixedYear = String(options.year || '').trim()
const declareTitle = decodeURIComponent(options.declareTitle || '').trim()
const recordId = String(options.id || '').trim()
console.log('this.moduleMeta', meta)
this.moduleCode = code
this.formConfig = config || null
this.moduleMeta = meta || null
this.formData = createInitialFormData(code)
this.fixedYear = fixedYear
this.declareTitle = declareTitle
this.recordId = recordId
if (config) {
uni.setNavigationBarTitle({
title: declareTitle || (meta ? meta.title : '申报表')
})
this.loadDictOptions()
this.setupPickerState()
this.loadData()
this.loadInitialDetail()
}
},
methods: {
getApiConfig() {
return MODULE_API_MAP[this.moduleCode] || null
},
createFreshFormData() {
const data = createInitialFormData(this.moduleCode)
data.year = this.fixedYear || data.year || String(new Date().getFullYear())
return data
},
async loadInitialDetail() {
if (!this.recordId) {
return
}
try {
await this.loadRemoteRecord(this.recordId)
this.showEdit = true
} catch (error) {
uni.showToast({
title: (error && error.message) || '表单详情加载失败',
icon: 'none'
})
}
},
async loadRemoteRecord(id) {
const api = this.getApiConfig()
if (!api || !api.get || !id) {
return
}
const detail = await api.get(id)
if (!detail) {
return
}
this.formData = {
...this.createFreshFormData(),
...detail
}
if (this.fixedYear) {
this.formData.year = this.fixedYear
}
this.setupPickerState()
},
async loadData() {
const api = this.getApiConfig()
if (!api || !api.page) {
this.list = []
return
}
this.loading = true
try {
const result = await api.page({
page: 1,
limit: 50,
year: this.fixedYear || undefined
})
this.list = Array.isArray(result && result.list) ? result.list : []
} catch (error) {
this.list = []
uni.showToast({
title: (error && error.message) || '加载失败',
icon: 'none'
})
} finally {
this.loading = false
}
},
openCreate() {
this.formData = this.createFreshFormData()
this.draftSavedAt = ''
this.restoreDraft()
this.setupPickerState()
this.showEdit = true
},
async openEdit(item) {
try {
const id = item && item.id ? item.id : ''
if (id) {
await this.loadRemoteRecord(id)
} else {
this.formData = {
...this.createFreshFormData(),
...(item || {})
}
}
this.showEdit = true
} catch (error) {
uni.showToast({
title: (error && error.message) || '加载详情失败',
icon: 'none'
})
}
},
closeEdit() {
this.showEdit = false
},
getRecordTitle(item) {
if (!item) {
return this.pageTitle
}
const fields = ['name', 'title', 'orgName', 'branchName', 'applyType']
for (const key of fields) {
const value = String(item[key] || '').trim()
if (value) {
return value
}
}
return this.pageTitle
},
getRecordSubtitle(item) {
const fields = ['applyType', 'unit', 'collegeClass', 'organization', 'secondOrg', 'branch', 'position']
const parts = fields
.map((key) => String((item && item[key]) || '').trim())
.filter(Boolean)
.slice(0, 1)
return parts.length ? parts.join(' · ') : (this.moduleMeta && this.moduleMeta.group) || '申报记录'
},
getRecordMeta(item) {
const pairs = [
['政治面貌', item && item.politics],
['联系方式', item && (item.contact || item.phone)],
['民族', item && item.nation]
]
const matched = pairs.find((entry) => String(entry[1] || '').trim())
if (matched) {
return `${matched[0]}${matched[1]}`
}
return `记录编号:${(item && item.id) || '-'}`
},
noop() {
return
},
setupPickerState() {
this.genderPickerColumns = [[
{ text: '男', value: '男' },
{ text: '女', value: '女' }
]]
},
async loadDictOptions() {
try {
const [nationResult, politicsResult] = await Promise.all([
listDictData({ dictId: NATION_DICT_ID }),
listDictData({ dictId: POLITICS_DICT_ID })
])
const normalize = (result) =>
(Array.isArray(result) ? result : [])
.map((item) => String(item && (item.dictDataName || item.name || item.label || item.value) || '').trim())
.filter(Boolean)
this.nationOptions = normalize(nationResult)
this.politicsOptions = normalize(politicsResult)
} catch (error) {
this.nationOptions = []
this.politicsOptions = []
}
},
getStorageKey() {
return `${STORAGE_PREFIX}${this.moduleCode}_${this.fixedYear || 'default'}`
},
getLegacyStorageKey() {
return `${STORAGE_PREFIX}${this.moduleCode}`
},
restoreDraft() {
const draft =
uni.getStorageSync(this.getStorageKey()) ||
uni.getStorageSync(this.getLegacyStorageKey())
if (!draft || !draft.formData) {
return
}
this.formData = {
...this.formData,
...draft.formData
}
if (this.fixedYear) {
this.formData.year = this.fixedYear
}
this.draftSavedAt = draft.savedAt || ''
},
handleInput(field, event) {
const value = typeof event === 'string' ? event : (event.detail && event.detail.value) || ''
this.formData[field.key] = value
},
setFieldValue(key, event) {
const value = typeof event === 'string' ? event : (event.detail && event.detail.value) || ''
this.formData[key] = value
},
findFieldByKey(key) {
if (!this.formConfig || !Array.isArray(this.formConfig.sections)) {
return null
}
for (const section of this.formConfig.sections) {
const matched = (section.fields || []).find((field) => field.key === key)
if (matched) {
return matched
}
}
return null
},
isGenderField(field) {
return field && field.key === 'gender'
},
isPoliticsField(field) {
return field && field.key === 'politics'
},
isNationField(field) {
return field && field.key === 'nation'
},
isUvPickerField(field) {
return field && (field.type === 'select' || this.isPoliticsField(field) || this.isNationField(field))
},
isPickerFieldDisabled(field) {
return (this.isPoliticsField(field) || this.isNationField(field)) && !this.getSelectOptions(field).length
},
getPickerDisplayText(field) {
if (this.formData[field.key]) {
return this.formData[field.key]
}
if (this.isPoliticsField(field)) {
return this.getSelectOptions(field).length ? '请选择政治面貌' : '政治面貌加载中'
}
if (this.isNationField(field)) {
return this.getSelectOptions(field).length ? '请选择民族' : '民族加载中'
}
return field.placeholder
},
getSelectOptions(field) {
if (this.isGenderField(field)) {
return field.options || ['男', '女']
}
if (this.isPoliticsField(field)) {
return this.politicsOptions.length ? this.politicsOptions : (field.options || [])
}
return field.options || []
},
getNationIndex(field) {
const index = this.nationOptions.findIndex((item) => item === this.formData[field.key])
return index > -1 ? index : 0
},
handleNationPickerChange(field, event) {
const index = Number((event && event.detail && event.detail.value) || 0)
this.formData[field.key] = this.nationOptions[index] || ''
},
openSelectPicker(field) {
const options = this.getSelectOptions(field)
if (!options.length) {
return
}
this.selectFieldKey = field.key
this.selectPickerTitle = `选择${field.label}`
this.selectPickerColumns = [
options.map((item) => ({ text: item, value: item }))
]
this.$nextTick(() => {
this.$refs.selectPicker && this.$refs.selectPicker.open()
})
},
confirmSelectPicker(event) {
const value = (((event || {}).value || [])[0] || {}).value || ''
if (this.selectFieldKey) {
this.formData[this.selectFieldKey] = value
}
},
openGenderPicker(field) {
this.genderFieldKey = field.key
this.genderPickerColumns = [
this.getSelectOptions(field).map((item) => ({ text: item, value: item }))
]
this.$nextTick(() => {
this.$refs.genderPicker && this.$refs.genderPicker.open()
})
},
confirmGenderPicker(event) {
const value = (((event || {}).value || [])[0] || {}).value || ''
if (this.genderFieldKey) {
this.formData[this.genderFieldKey] = value
}
},
isDateField(field) {
return ['year', 'month', 'date'].includes(field.type)
},
getDateRange(field) {
const start = String(field.start || '2000-01-01').trim()
const end = String(field.end || '2099-12-31').trim()
return {
startYear: Number(start.slice(0, 4)) || 2000,
endYear: Number(end.slice(0, 4)) || 2099
}
},
getDaysInMonth(year, month) {
return new Date(year, month, 0).getDate()
},
buildDatePickerState(field, value, nextIndexs = []) {
const { startYear, endYear } = this.getDateRange(field)
const years = []
for (let year = startYear; year <= endYear; year += 1) {
years.push({ text: `${year}`, value: `${year}` })
}
const months = Array.from({ length: 12 }, (_, index) => {
const month = String(index + 1).padStart(2, '0')
return { text: month, value: month }
})
const safeValue = String(value || '').trim()
let selectedYear = safeValue.slice(0, 4) || `${startYear}`
let selectedMonth = safeValue.slice(5, 7) || '01'
let selectedDay = safeValue.slice(8, 10) || '01'
if (Array.isArray(nextIndexs) && nextIndexs.length) {
if (typeof nextIndexs[0] === 'number' && years[nextIndexs[0]]) {
selectedYear = years[nextIndexs[0]].value
}
if (typeof nextIndexs[1] === 'number' && months[nextIndexs[1]]) {
selectedMonth = months[nextIndexs[1]].value
}
}
const dayTotal = this.getDaysInMonth(Number(selectedYear), Number(selectedMonth))
const days = Array.from({ length: dayTotal }, (_, index) => {
const day = String(index + 1).padStart(2, '0')
return { text: day, value: day }
})
if (Array.isArray(nextIndexs) && nextIndexs.length > 2 && typeof nextIndexs[2] === 'number' && days[nextIndexs[2]]) {
selectedDay = days[nextIndexs[2]].value
}
const yearIndex = Math.max(0, years.findIndex((item) => item.value === selectedYear))
const monthIndex = Math.max(0, months.findIndex((item) => item.value === selectedMonth))
const dayIndex = Math.max(0, days.findIndex((item) => item.value === selectedDay))
if (field.type === 'year') {
return {
columns: [years],
indexs: [yearIndex]
}
}
if (field.type === 'month') {
return {
columns: [years, months],
indexs: [yearIndex, monthIndex]
}
}
return {
columns: [years, months, days],
indexs: [yearIndex, monthIndex, dayIndex]
}
},
openDatePicker(field) {
this.dateFieldKey = field.key
this.datePickerTitle = `选择${field.label}`
const pickerState = this.buildDatePickerState(field, this.formData[field.key])
this.datePickerColumns = pickerState.columns
this.datePickerIndexs = pickerState.indexs
this.$nextTick(() => {
this.$refs.datePicker && this.$refs.datePicker.open()
})
},
handleDatePickerChange(event) {
const field = this.findFieldByKey(this.dateFieldKey)
if (!field || field.type !== 'date') {
this.datePickerIndexs = (event && event.indexs) || this.datePickerIndexs
return
}
const nextIndexs = (event && event.indexs) || []
const pickerState = this.buildDatePickerState(field, this.formData[field.key], nextIndexs)
this.datePickerColumns = pickerState.columns
this.datePickerIndexs = pickerState.indexs
},
formatDatePickerValue(values, field) {
const year = (values[0] && values[0].value) || ''
if (field.type === 'year') {
return year
}
const month = (values[1] && values[1].value) || '01'
if (field.type === 'month') {
return `${year}-${month}`
}
const day = (values[2] && values[2].value) || '01'
return `${year}-${month}-${day}`
},
confirmDatePicker(event) {
const field = this.findFieldByKey(this.dateFieldKey)
if (!field) {
return
}
this.formData[field.key] = this.formatDatePickerValue((event && event.value) || [], field)
this.datePickerIndexs = (event && event.indexs) || this.datePickerIndexs
},
getDatePlaceholder(field) {
if (field.type === 'year') {
return '请选择年份'
}
if (field.type === 'month') {
return '请选择年月'
}
return '请选择日期'
},
getInputType(field) {
if (field.type === 'number' || field.type === 'phone') {
return 'number'
}
return 'text'
},
validateForm() {
const requiredFields = []
this.formConfig.sections.forEach((section) => {
section.fields.forEach((field) => {
if (field.required && !String(this.formData[field.key] || '').trim()) {
requiredFields.push(field.label)
}
})
})
if (!requiredFields.length) {
return true
}
uni.showToast({
title: `请完善:${requiredFields[0]}`,
icon: 'none'
})
return false
},
saveDraft() {
const savedAt = this.formatNow()
uni.setStorageSync(this.getStorageKey(), {
moduleCode: this.moduleCode,
formData: this.formData,
savedAt
})
this.draftSavedAt = savedAt
uni.showToast({
title: '草稿已保存',
icon: 'success'
})
},
clearDraftStorage() {
uni.removeStorageSync(this.getStorageKey())
uni.removeStorageSync(this.getLegacyStorageKey())
this.draftSavedAt = ''
},
async submitForm() {
if (!this.validateForm()) {
return
}
const api = this.getApiConfig()
if (!api) {
this.clearDraftStorage()
uni.showModal({
title: '提交成功',
content: `${this.moduleMeta ? this.moduleMeta.title : '当前模块'}表单已提交,后续可继续接入后台接口。`,
showCancel: false
})
return
}
try {
const payload = {
...this.formData,
year: this.fixedYear || this.formData.year
}
const request = payload.id ? api.update : api.add
await request({
...payload,
id: payload.id || undefined
})
this.clearDraftStorage()
uni.showToast({
title: '提交成功',
icon: 'none'
})
this.closeEdit()
this.loadData()
} catch (error) {
uni.showToast({
title: (error && error.message) || '提交失败',
icon: 'none'
})
}
},
formatNow() {
const now = new Date()
const pad = (value) => String(value).padStart(2, '0')
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}`
}
}
}
</script>
<style lang="scss" scoped>
.page { min-height: 100vh; background: linear-gradient(180deg, #f5fbff 0%, #eef7ff 100%); }
.page-scroll { height: 100vh; box-sizing: border-box; }
.hero-wrap{ padding:24rpx 24rpx 0; background: url("@/static/indexBg.jpg") no-repeat cover}
.hero-card,.state-card,.data-card { border-radius: 28rpx; }
.hero-card {
padding: 34rpx 36rpx;
background: rgba(255, 255, 255, 0.5);
box-shadow: 0 4rpx 12rpx 0 rgba(174, 174, 174, 0.4);
backdrop-filter: blur(24rpx);
-webkit-backdrop-filter: blur(24rpx);
border: 1rpx solid rgba(255, 255, 255, 0.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 {
position: relative;
margin-top: 8rpx;
padding-left: 10rpx;
font-size: 30rpx;
font-weight: 700;
line-height: 1.35;
color: #18a0f7;
border-left: 5px solid #0f94ef;
}
//.hero-title::before {
// content: '';
// position: absolute;
// left: 0;
// top: 8rpx;
// width: 8rpx;
// height: 52rpx;
// border-radius: 999rpx;
// background: linear-gradient(180deg, #0f94ef 0%, #22b4ff 100%);
//}
.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 { padding: 0 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; }
.hero-meta--form { grid-template-columns: repeat(2, minmax(0, 1fr)); margin-bottom: 20rpx; }
.meta-item--form { background: #f3f7fd; }
.meta-label--form { color: #6b7785; }
.meta-value--form { color: #1f2329; }
.section-card { margin-top: 24rpx; padding: 26rpx; border-radius: 30rpx; background: rgba(255,255,255,.94); border: 1rpx solid rgba(20,150,242,.08); box-shadow: 0 10rpx 28rpx rgba(34,94,142,.08); }
.section-head { display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: 18rpx; }
.section-title { font-size: 30rpx; font-weight: 700; color: #1f2329; }
.field-list { display: flex; flex-direction: column; gap: 22rpx; }
.field-label { margin-bottom: 12rpx; font-size: 25rpx; font-weight: 600; color: #2b3037; }
.field-required { margin-left: 8rpx; color: #1496f2; }
.field-textarea,.field-picker { width: 100%; box-sizing: border-box; padding: 22rpx 24rpx; border-radius: 22rpx; font-size: 24rpx; color: #2a2e35; }
.field-textarea { background: #f8f4f1; border: 1rpx solid #eadfd7; }
.field-picker { background: #F4FCFF; border: 1rpx solid rgba(220,220,220,1); }
.field-picker--disabled { background: #f7f9fb; color: #aaafb7; }
.field-textarea { min-height: 180rpx; }
:deep(.field-input.uv-input) { width: 100%; min-height: 88rpx; padding: 0 24rpx; border-radius: 12rpx; border: 1px solid #dcdfe6; background: #ffffff; box-sizing: border-box; transition: border-color 0.2s ease; }
:deep(.field-input.uv-input .uv-input__content__field-wrapper__field) { height: 88rpx; min-height: 88rpx; font-size: 28rpx; line-height: 88rpx; color: #303133; }
.field-placeholder { color: #aa9f98; }
.field-value { color: #2a2e35; }
.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: #FFA2DA; color: #ffffff; border: none; border-radius: 999px; }
.primary-btn { background: linear-gradient(147.22deg, rgba(0,180,255,1) 37.65%, rgba(88,206,255,1) 80.42%); color: #fff; border-radius: 999px; }
.empty-page { min-height: 100vh; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 48rpx; text-align: center; }
.empty-title { font-size: 30rpx; font-weight: 700; color: #1f2329; }
.empty-desc { margin-top: 12rpx; font-size: 24rpx; line-height: 1.7; color: #7b8694; }
</style>

697
pages/gxmu/index.vue Normal file
View File

@@ -0,0 +1,697 @@
<template>
<view class="page">
<scroll-view scroll-y class="page-scroll">
<common-hero title="五四评优" :use-image-bg="true"></common-hero>
<view class="section">
<view class="section-head">
<view>
<view class="section-title">项目列表</view>
<view class="section-subtitle">当前仅展示可申报项目</view>
</view>
<view class="refresh-btn" :class="{ 'refresh-btn--loading': loading }" @tap="loadData()">
{{ loading ? '刷新中' : '刷新' }}
</view>
</view>
<view v-if="loading" class="state-card">
<view class="state-title">正在加载申报项目...</view>
<view class="state-desc">请稍候正在同步后台开放的申报配置</view>
</view>
<view v-else-if="loadError" class="state-card state-card--error">
<view class="state-title">加载失败</view>
<view class="state-desc">{{ loadError }}</view>
<view class="state-action" @tap="loadData()">重新加载</view>
</view>
<view v-else-if="!declareList.length" class="state-card">
<view class="state-title">当前暂无可申报项目</view>
<view class="state-desc">后台还没有开放可申报数据稍后再来查看</view>
</view>
<view v-else>
<view class="filter-bar">
<picker class="filter-item" mode="selector" :range="yearLabels" :value="yearIndex" @change="handleYearChange">
<view class="filter-trigger">
<text class="filter-text">{{ selectedYear ? `${selectedYear}` : '全部年份' }}</text>
<text class="filter-arrow"></text>
</view>
</picker>
<view class="filter-item" @tap="openModulePicker">
<view class="filter-trigger">
<text class="filter-text">{{ selectedModule ? getDeclareModuleLabel(selectedModule) : '全部模块' }}</text>
<text class="filter-arrow"></text>
</view>
</view>
</view>
<view v-if="filteredDeclareList.length" class="declare-list">
<view
v-for="item in filteredDeclareList"
:key="item.id || `${item.module}-${item.year}`"
class="declare-card"
:class="{ 'declare-card--disabled': !isModuleSupported(item.module) }"
@tap="goApply(item)"
>
<view class="card-top">
<view class="card-main">
<view class="card-title">{{ getDeclareTitle(item) }}</view>
<view class="card-subtitle">{{ getDeclareModuleLabel(item.module) }}</view>
</view>
<view
class="card-status"
:class="isModuleSupported(item.module) ? 'card-status--active' : 'card-status--disabled'"
>
{{ isModuleSupported(item.module) ? '可申报' : '待支持' }}
</view>
</view>
<view class="card-year">
{{ item.year ? `${item.year}` : '未设置年度' }}
</view>
<view class="card-desc">{{ getDeclareDescription(item.module) }}</view>
<view class="card-meta">
<view class="meta-row">
<view class="meta-row-label">开始时间</view>
<view class="meta-row-value">{{ formatDateTime(item.startTime) }}</view>
</view>
<view class="meta-row">
<view class="meta-row-label">结束时间</view>
<view class="meta-row-value">{{ formatDateTime(item.endTime) }}</view>
</view>
</view>
<view class="card-footer">
<view class="card-tag">{{ getDeclareGroup(item.module) }}</view>
<view class="card-action">
{{ isModuleSupported(item.module) ? '进入申报' : '暂未开放' }}
</view>
</view>
</view>
</view>
<view v-else class="state-card">
<view class="state-title">暂无匹配项目</view>
<view class="state-desc">请调整年份或模块筛选条件后重试</view>
</view>
</view>
</view>
</scroll-view>
<uv-picker
ref="modulePicker"
title="选择模块"
:columns="modulePickerColumns"
:defaultIndex="[moduleIndex]"
keyName="text"
@close="noop"
@cancel="noop"
@confirm="confirmModulePicker"
></uv-picker>
</view>
</template>
<script>
import CommonHero from '../../components/common-hero/common-hero.vue'
import { getFormConfig, getModuleMeta } from '../../utils/gxmu/config'
import { listDeclare } from '../../utils/gxmu/declare-service'
export default {
components: {
CommonHero
},
data() {
return {
loading: false,
loadError: '',
declareList: [],
selectedYear: '',
selectedModule: ''
}
},
computed: {
supportedCount() {
return this.declareList.filter((item) => this.isModuleSupported(item.module)).length
},
latestYear() {
const year = this.declareList.reduce((result, item) => {
const current = Number(item.year || 0)
return current > result ? current : result
}, 0)
return year || new Date().getFullYear()
},
yearOptions() {
const set = new Set(
this.declareList
.map((item) => String(item.year || '').trim())
.filter(Boolean)
)
return [...set].sort((left, right) => Number(right) - Number(left))
},
moduleOptions() {
const set = new Set(
this.declareList
.map((item) => String(item.module || '').trim())
.filter(Boolean)
)
return [...set]
},
yearLabels() {
return ['全部年份', ...this.yearOptions.map((item) => `${item}`)]
},
moduleLabels() {
return ['全部模块', ...this.moduleOptions.map((item) => this.getDeclareModuleLabel(item))]
},
modulePickerColumns() {
return [[
{ text: '全部模块', value: '' },
...this.moduleOptions.map((item) => ({
text: this.getDeclareModuleLabel(item),
value: item
}))
]]
},
yearIndex() {
if (!this.selectedYear) {
return 0
}
const index = this.yearOptions.findIndex((item) => item === this.selectedYear)
return index > -1 ? index + 1 : 0
},
moduleIndex() {
if (!this.selectedModule) {
return 0
}
const index = this.moduleOptions.findIndex((item) => item === this.selectedModule)
return index > -1 ? index + 1 : 0
},
filteredDeclareList() {
return this.declareList.filter((item) => {
const yearMatched = !this.selectedYear || String(item.year || '').trim() === this.selectedYear
const moduleMatched = !this.selectedModule || String(item.module || '').trim() === this.selectedModule
return yearMatched && moduleMatched
})
}
},
onLoad() {
this.loadData()
},
onPullDownRefresh() {
this.loadData(true)
},
methods: {
showToast(title) {
uni.showToast({
title,
icon: 'none'
})
},
getModuleMetaSafe(code) {
return getModuleMeta(code) || {
title: code || '未命名模块',
icon: (code || '申').slice(0, 1),
group: '申报项目',
desc: '当前申报项目已开放,点击可查看申报内容。'
}
},
getDeclareTitle(item) {
const title = String(item.title || '').trim()
if (title) {
return title
}
return this.getDeclareModuleLabel(item.module)
},
getDeclareModuleLabel(module) {
return this.getModuleMetaSafe(module).title
},
getDeclareDescription(module) {
return this.getModuleMetaSafe(module).desc
},
getDeclareGroup(module) {
return this.getModuleMetaSafe(module).group
},
isModuleSupported(module) {
return !!getFormConfig(module)
},
getTimestamp(value) {
if (!value) {
return 0
}
const result = new Date(String(value).replace(/-/g, '/')).getTime()
return Number.isNaN(result) ? 0 : result
},
formatDateTime(value) {
if (!value) {
return '-'
}
const date = new Date(String(value).replace(/-/g, '/'))
const pad = (item) => String(item).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
},
handleYearChange(event) {
const index = Number((event.detail && event.detail.value) || 0)
this.selectedYear = index === 0 ? '' : (this.yearOptions[index - 1] || '')
},
handleModuleChange(event) {
const index = Number((event.detail && event.detail.value) || 0)
this.selectedModule = index === 0 ? '' : (this.moduleOptions[index - 1] || '')
},
openModulePicker() {
this.$nextTick(() => {
this.$refs.modulePicker && this.$refs.modulePicker.open()
})
},
confirmModulePicker(event) {
const value = (((event || {}).value || [])[0] || {}).value || ''
this.selectedModule = value
},
noop() {
return
},
syncFilterState() {
if (this.selectedYear && !this.yearOptions.includes(this.selectedYear)) {
this.selectedYear = ''
}
if (this.selectedModule && !this.moduleOptions.includes(this.selectedModule)) {
this.selectedModule = ''
}
},
async loadData(fromPullDown = false) {
if (this.loading) {
return
}
this.loading = true
this.loadError = ''
try {
const result = await listDeclare({ usable: true })
const list = Array.isArray(result) ? result : []
this.declareList = [...list].sort((left, right) => {
const yearDiff = Number(right.year || 0) - Number(left.year || 0)
if (yearDiff !== 0) {
return yearDiff
}
return this.getTimestamp(right.startTime || right.createTime) - this.getTimestamp(left.startTime || left.createTime)
})
this.syncFilterState()
} catch (error) {
this.loadError = (error && error.message) || '申报项目加载失败'
this.showToast(this.loadError)
} finally {
this.loading = false
if (fromPullDown) {
uni.stopPullDownRefresh()
}
}
},
goApply(item) {
if (!item.module) {
this.showToast('当前申报项目缺少模块标识')
return
}
if (!this.isModuleSupported(item.module)) {
this.showToast('当前申报项目在小程序端暂未配置')
return
}
const moduleRouteMap = {
gxmu_tzbcy_form: '/pages/gxmu/tzbcy',
gxmu_qmgc_form: '/pages/gxmu/qmgc',
gxmu_wxxzx_project: '/pages/gxmu/wxxzx'
}
const moduleRoute = moduleRouteMap[item.module]
if (moduleRoute) {
const query = [
`code=${encodeURIComponent(item.module)}`,
`year=${encodeURIComponent(item.year || '')}`,
`declareTitle=${encodeURIComponent(this.getDeclareTitle(item))}`,
`id=${encodeURIComponent(item.id)}`
]
uni.navigateTo({
url: `${moduleRoute}?${query.join('&')}`
})
return
}
const query = [
`code=${encodeURIComponent(item.module)}`,
`year=${encodeURIComponent(item.year || '')}`,
`declareTitle=${encodeURIComponent(this.getDeclareTitle(item))}`
]
uni.navigateTo({
url: `/pages/gxmu/form?${query.join('&')}`
})
},
goStatsCenter() {
uni.navigateTo({
url: '/pages/gxmu/stats'
})
},
goCrossSchoolBoard() {
uni.navigateTo({
url: '/pages/gxmu/cross-school-board'
})
},
goTzbDatabase() {
uni.navigateTo({
url: '/pages/gxmu/tzb-database'
})
}
}
}
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
background:
radial-gradient(circle at top left, rgba(20, 150, 242, 0.14), transparent 30%),
linear-gradient(180deg, #f5fbff 0%, #eef7ff 100%);
}
.page-scroll {
height: 100vh;
padding: 28rpx 24rpx 40rpx;
}
.hero-card {
padding: 34rpx 30rpx;
border-radius: 32rpx;
background: linear-gradient(145deg, #0d6fba 0%, #1496f2 56%, #5bc2ff 100%);
box-shadow: 0 18rpx 44rpx rgba(20, 118, 194, 0.2);
color: #f7fcff;
}
.hero-badge {
display: inline-flex;
padding: 10rpx 18rpx;
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.16);
font-size: 22rpx;
letter-spacing: 1rpx;
}
.hero-title {
margin-top: 22rpx;
font-size: 42rpx;
line-height: 1.35;
font-weight: 700;
}
.hero-desc {
margin-top: 16rpx;
font-size: 25rpx;
line-height: 1.7;
color: rgba(255, 247, 244, 0.86);
}
.hero-meta {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16rpx;
margin-top: 28rpx;
}
.hero-actions {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
margin-top: 22rpx;
}
.hero-action {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 16rpx 26rpx;
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.18);
font-size: 24rpx;
font-weight: 600;
color: #f7fcff;
}
.hero-action--secondary {
background: rgba(13, 36, 61, 0.18);
}
.hero-action--tertiary {
background: rgba(8, 74, 61, 0.24);
}
.meta-item {
padding: 18rpx 16rpx;
border-radius: 22rpx;
background: rgba(255, 255, 255, 0.12);
}
.meta-value {
font-size: 32rpx;
font-weight: 700;
}
.meta-label {
margin-top: 6rpx;
font-size: 22rpx;
color: rgba(255, 247, 244, 0.8);
}
.section {
margin-top: 30rpx;
}
.section-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20rpx;
padding: 0 6rpx;
}
.section-title {
font-size: 34rpx;
font-weight: 700;
color: #1f2329;
}
.section-subtitle {
margin-top: 8rpx;
font-size: 22rpx;
color: #8b827d;
}
.refresh-btn {
flex-shrink: 0;
padding: 12rpx 24rpx;
border-radius: 999rpx;
background: rgba(20, 150, 242, 0.08);
color: #1496f2;
font-size: 22rpx;
}
.refresh-btn--loading {
opacity: 0.7;
}
.state-card {
margin-top: 18rpx;
padding: 30rpx 26rpx;
border-radius: 28rpx;
background: rgba(255, 255, 255, 0.9);
border: 1rpx solid rgba(20, 150, 242, 0.08);
box-shadow: 0 10rpx 28rpx rgba(34, 94, 142, 0.08);
}
.state-card--error {
border-color: rgba(20, 150, 242, 0.18);
background: rgba(240, 248, 255, 0.96);
}
.state-title {
font-size: 30rpx;
font-weight: 700;
color: #1f2329;
}
.state-desc {
margin-top: 12rpx;
font-size: 24rpx;
line-height: 1.7;
color: #786e69;
}
.state-action {
margin-top: 18rpx;
font-size: 24rpx;
font-weight: 600;
color: #1496f2;
}
.filter-bar {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14rpx;
margin-top: 18rpx;
}
.filter-item {
display: block;
}
.filter-trigger {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 84rpx;
padding: 0 20rpx;
border-radius: 22rpx;
background: rgba(255, 255, 255, 0.92);
border: 1rpx solid rgba(21, 84, 173, 0.08);
font-size: 24rpx;
color: #1f2329;
text-align: center;
}
.filter-text {
flex: 1;
min-width: 0;
text-align: center;
}
.filter-arrow {
flex-shrink: 0;
margin-left: 12rpx;
font-size: 20rpx;
color: #7b8ba1;
}
.declare-list {
display: flex;
flex-direction: column;
gap: 18rpx;
margin-top: 18rpx;
}
.declare-card {
padding: 26rpx;
border-radius: 28rpx;
background:
linear-gradient(180deg, rgba(240, 247, 255, 0.95) 0%, #ffffff 44%),
#ffffff;
border: 1rpx solid rgba(21, 84, 173, 0.08);
box-shadow: 0 10rpx 28rpx rgba(76, 49, 35, 0.08);
}
.declare-card--disabled {
opacity: 0.72;
}
.card-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16rpx;
}
.card-main {
flex: 1;
min-width: 0;
}
.card-title {
font-size: 31rpx;
font-weight: 700;
line-height: 1.45;
color: #1f2329;
}
.card-subtitle {
margin-top: 8rpx;
font-size: 22rpx;
line-height: 1.6;
color: #5f6b7a;
}
.card-status {
flex-shrink: 0;
padding: 10rpx 16rpx;
border-radius: 999rpx;
font-size: 20rpx;
font-weight: 600;
}
.card-status--active {
background: #edf5ff;
color: #1554ad;
}
.card-status--disabled {
background: #f5f1ee;
color: #8f837d;
}
.card-year {
display: inline-flex;
align-items: center;
padding: 10rpx 18rpx;
margin-top: 18rpx;
border-radius: 999rpx;
background: #eef5ff;
color: #1554ad;
font-size: 22rpx;
font-weight: 600;
}
.card-desc {
margin-top: 16rpx;
font-size: 24rpx;
line-height: 1.7;
color: #716863;
}
.card-meta {
display: flex;
flex-direction: column;
gap: 12rpx;
margin-top: 18rpx;
}
.meta-row {
padding: 18rpx 20rpx;
border-radius: 22rpx;
background: rgba(246, 248, 250, 0.92);
}
.meta-row-label {
font-size: 20rpx;
color: #8b827d;
}
.meta-row-value {
margin-top: 8rpx;
font-size: 24rpx;
line-height: 1.6;
color: #1f2329;
word-break: break-all;
}
.card-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
margin-top: 22rpx;
}
.card-tag {
padding: 10rpx 18rpx;
border-radius: 999rpx;
background: #f8f1ea;
color: #8a5a22;
font-size: 22rpx;
}
.card-action {
font-size: 24rpx;
font-weight: 600;
color: #1496f2;
}
</style>

View File

@@ -0,0 +1,212 @@
import { API_BASE_URL } from '../../utils/request'
import { apiRequest, getToken } from '../assistant/chat-service'
function request(url, method = 'GET', data) {
return apiRequest({
url: `${API_BASE_URL}${url}`,
method,
data
})
}
export function userPageTzbcyForm(params) {
return request('/gxmu/tzbcy-form/userPage', 'GET', params)
}
export function getTzbcyForm(id) {
return request(`/gxmu/tzbcy-form/${id}`, 'GET')
}
export function addTzbcyForm(data) {
return request('/gxmu/tzbcy-form', 'POST', data)
}
export function updateTzbcyForm(data) {
return request('/gxmu/tzbcy-form', 'PUT', data)
}
export function removeTzbcyForm(id) {
return request(`/gxmu/tzbcy-form/${id}`, 'DELETE')
}
export function userPageSjqnForm(params) {
return request('/gxmu/sjqn-form/userPage', 'GET', params)
}
export function getSjqnForm(id) {
return request(`/gxmu/sjqn-form/${id}`, 'GET')
}
export function addSjqnForm(data) {
return request('/gxmu/sjqn-form', 'POST', data)
}
export function updateSjqnForm(data) {
return request('/gxmu/sjqn-form', 'PUT', data)
}
export function removeSjqnForm(id) {
return request(`/gxmu/sjqn-form/${id}`, 'DELETE')
}
export function getDeclare(id) {
return request(`/gxmu/declare/${id}`, 'GET')
}
export function listDictData(params) {
return request('/system/dict-data', 'GET', params)
}
export function userPageQmgcForm(params) {
return request('/gxmu/qmgc-form/userPage', 'GET', params)
}
export function getQmgcForm(id) {
return request(`/gxmu/qmgc-form/${id}`, 'GET')
}
export function addQmgcForm(data) {
return request('/gxmu/qmgc-form', 'POST', data)
}
export function updateQmgcForm(data) {
return request('/gxmu/qmgc-form', 'PUT', data)
}
export function removeQmgcForm(id) {
return request(`/gxmu/qmgc-form/${id}`, 'DELETE')
}
export function userPageWxxzxForm(params) {
return request('/gxmu/wxxzx-form/userPage', 'GET', params)
}
export function getWxxzxForm(id) {
return request(`/gxmu/wxxzx-form/${id}`, 'GET')
}
export function addWxxzxForm(data) {
return request('/gxmu/wxxzx-form', 'POST', data)
}
export function updateWxxzxForm(data) {
return request('/gxmu/wxxzx-form', 'PUT', data)
}
export function removeWxxzxForm(id) {
return request(`/gxmu/wxxzx-form/${id}`, 'DELETE')
}
export function userPageSjtbzbsjForm(params) {
return request('/gxmu/sjtbzbsj-form/userPage', 'GET', params)
}
export function getSjtbzbsjForm(id) {
return request(`/gxmu/sjtbzbsj-form/${id}`, 'GET')
}
export function addSjtbzbsjForm(data) {
return request('/gxmu/sjtbzbsj-form', 'POST', data)
}
export function updateSjtbzbsjForm(data) {
return request('/gxmu/sjtbzbsj-form', 'PUT', data)
}
export function userPageWshqtwForm(params) {
return request('/gxmu/wshqtw-form/userPage', 'GET', params)
}
export function getWshqtwForm(id) {
return request(`/gxmu/wshqtw-form/${id}`, 'GET')
}
export function addWshqtwForm(data) {
return request('/gxmu/wshqtw-form', 'POST', data)
}
export function updateWshqtwForm(data) {
return request('/gxmu/wshqtw-form', 'PUT', data)
}
export function userPageWshqtzbForm(params) {
return request('/gxmu/wshqtzb-form/userPage', 'GET', params)
}
export function getWshqtzbForm(id) {
return request(`/gxmu/wshqtzb-form/${id}`, 'GET')
}
export function addWshqtzbForm(data) {
return request('/gxmu/wshqtzb-form', 'POST', data)
}
export function updateWshqtzbForm(data) {
return request('/gxmu/wshqtzb-form', 'PUT', data)
}
export function userPageYxgqtdgbForm(params) {
return request('/gxmu/yxgqtdgb-form/userPage', 'GET', params)
}
export function getYxgqtdgbForm(id) {
return request(`/gxmu/yxgqtdgb-form/${id}`, 'GET')
}
export function addYxgqtdgbForm(data) {
return request('/gxmu/yxgqtdgb-form', 'POST', data)
}
export function updateYxgqtdgbForm(data) {
return request('/gxmu/yxgqtdgb-form', 'PUT', data)
}
export function userPageYxgqtyForm(params) {
return request('/gxmu/yxgqty-form/userPage', 'GET', params)
}
export function getYxgqtyForm(id) {
return request(`/gxmu/yxgqty-form/${id}`, 'GET')
}
export function addYxgqtyForm(data) {
return request('/gxmu/yxgqty-form', 'POST', data)
}
export function updateYxgqtyForm(data) {
return request('/gxmu/yxgqty-form', 'PUT', data)
}
export function uploadTempFile(file) {
const token = getToken()
return new Promise((resolve, reject) => {
uni.uploadFile({
url: `${API_BASE_URL}/file/upload`,
filePath: file.path,
name: 'file',
header: token
? {
Authorization: token
}
: {},
formData: {
tenantId: '10049'
},
success: (response) => {
try {
const result = JSON.parse(response.data || '{}')
if (result.code === 0 && result.data) {
resolve(result.data)
return
}
reject(new Error(result.message || '上传失败'))
} catch (error) {
reject(new Error('上传响应解析失败'))
}
},
fail: (error) => {
reject(new Error(error.errMsg || '上传失败'))
}
})
})
}

432
pages/gxmu/qmgc.vue Normal file
View File

@@ -0,0 +1,432 @@
<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">GXMU · 青马工程</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>
<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.name || '未命名学员' }}</view><view class="card-subtitle">{{ item.schoolInfo || '-' }}</view></view>
<view class="card-year">{{ item.year || '-' }}</view>
</view>
<view class="meta-line">手机号{{ item.phone || '-' }}</view>
<view class="meta-line">团学职务{{ item.leaguePosition || '-' }}</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="form-section">
<view class="form-section-title">登记表</view>
<view class="field-item"><view class="field-label">年份</view><uv-input class="field-input" :modelValue="current.year" border="none" @input="setField('year', $event)" /></view>
<view class="field-item"><view class="field-label">姓名</view><uv-input class="field-input" :modelValue="current.name" border="none" @input="setField('name', $event)" /></view>
<view class="field-item">
<view class="field-label">性别</view>
<view class="field-picker" @tap="openGenderPicker">
{{ current.gender || '请选择性别' }}
</view>
</view>
<view class="field-item">
<view class="field-label">民族</view>
<picker mode="selector" :range="nationLabels" :value="nationIndex" :disabled="!nationOptions.length" @change="handleNationChange">
<view class="field-picker" :class="{ 'field-picker--disabled': !nationOptions.length }">
{{ current.nation || (nationOptions.length ? '请选择民族' : '民族加载中') }}
</view>
</picker>
</view>
<view class="field-item">
<view class="field-label">出生年月</view>
<view class="field-picker" @tap="openBirthMonthPicker">
{{ current.birthMonth || '请选择出生年月' }}
</view>
</view>
<view class="field-item">
<view class="field-label">政治面貌</view>
<view class="field-picker" :class="{ 'field-picker--disabled': !politicsOptions.length }" @tap="openPoliticsPicker">
{{ current.politics || (politicsOptions.length ? '请选择政治面貌' : '政治面貌加载中') }}
</view>
</view>
<view class="field-item"><view class="field-label">籍贯</view><uv-input class="field-input" :modelValue="current.nativePlace" border="none" @input="setField('nativePlace', $event)" /></view>
<view class="field-item"><view class="field-label">手机号码</view><uv-input class="field-input" :modelValue="current.phone" border="none" @input="setField('phone', $event)" /></view>
<view class="field-item"><view class="field-label">微信号</view><uv-input class="field-input" :modelValue="current.wechat" border="none" @input="setField('wechat', $event)" /></view>
<view class="field-item"><view class="field-label">电子邮箱</view><uv-input class="field-input" :modelValue="current.email" border="none" @input="setField('email', $event)" /></view>
<view class="field-item"><view class="field-label">QQ号</view><uv-input class="field-input" :modelValue="current.qq" border="none" @input="setField('qq', $event)" /></view>
<view class="field-item"><view class="field-label">身份证号</view><uv-input class="field-input" :modelValue="current.idCardNo" border="none" @input="setField('idCardNo', $event)" /></view>
<view class="field-item"><view class="field-label">爱好特长</view><uv-input class="field-input" :modelValue="current.hobby" border="none" @input="setField('hobby', $event)" /></view>
<view class="field-item">
<view class="field-label">是否有志到广西基层工作</view>
<view class="field-picker" @tap="openWillingPicker">
{{ current.willingToWorkInGuangxi || '请选择' }}
</view>
</view>
<view class="field-item"><view class="field-label">学校院系年级专业</view><uv-input class="field-input" :modelValue="current.schoolInfo" border="none" @input="setField('schoolInfo', $event)" /></view>
<view class="field-item"><view class="field-label">担任团学职务情况</view><uv-input class="field-input" :modelValue="current.leaguePosition" border="none" @input="setField('leaguePosition', $event)" /></view>
<view class="field-item"><view class="field-label">个人简历</view><textarea class="field-textarea" :value="current.resume" @input="setField('resume', $event)" /></view>
<view class="field-item"><view class="field-label">奖惩情况</view><textarea class="field-textarea" :value="current.awards" @input="setField('awards', $event)" /></view>
<view class="field-item"><view class="field-label">综合成绩情况</view><textarea class="field-textarea" :value="current.academicPerformance" @input="setField('academicPerformance', $event)" /></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="willingPicker"
keyName="text"
:columns="willingPickerColumns"
:defaultIndex="[willingPickerIndex]"
closeOnClickOverlay
@confirm="confirmWillingPicker"
></uv-picker>
<uv-picker
ref="genderPicker"
title="选择性别"
keyName="text"
:columns="genderPickerColumns"
:defaultIndex="[genderPickerIndex]"
closeOnClickOverlay
@confirm="confirmGenderPicker"
></uv-picker>
<uv-picker
ref="politicsPicker"
title="选择政治面貌"
keyName="text"
:columns="politicsPickerColumns"
:defaultIndex="[politicsPickerIndex]"
closeOnClickOverlay
@confirm="confirmPoliticsPicker"
></uv-picker>
<uv-picker
ref="birthMonthPicker"
title="选择出生年月"
keyName="text"
:columns="birthMonthPickerColumns"
:defaultIndex="birthMonthPickerIndexs"
closeOnClickOverlay
@change="handleBirthMonthPickerChange"
@confirm="confirmBirthMonthPicker"
></uv-picker>
</view>
</template>
<script>
import CommonHero from '../../components/common-hero/common-hero.vue'
import { addQmgcForm, getQmgcForm, listDictData, updateQmgcForm, userPageQmgcForm } from './module-form-service'
const POLITICS_DICT_ID = 172
const NATION_DICT_ID = 173
const createDefaultForm = () => ({
id: undefined,
year: '',
name: '',
gender: '',
nation: '',
photo: '',
birthMonth: '',
politics: '',
nativePlace: '',
phone: '',
wechat: '',
email: '',
qq: '',
idCardNo: '',
hobby: '',
willingToWorkInGuangxi: '',
schoolInfo: '',
leaguePosition: '',
resume: '',
awards: '',
academicPerformance: '',
secondaryLeagueOpinion: '',
secondaryLeagueOpinionDate: '',
secondaryPartyOpinion: '',
secondaryPartyOpinionDate: '',
schoolLeagueOpinion: '',
schoolLeagueOpinionDate: ''
})
const normalizeDictOptions = (list) =>
(Array.isArray(list) ? list : [])
.map((item) => {
const label = String((item && (item.dictDataName || item.label || item.name || item.value)) || '').trim()
return {
label,
value: label
}
})
.filter((item) => item.label)
export default {
components: {
CommonHero
},
data() {
return {
loading: false,
showEdit: false,
pageTitle: '青马工程申报',
fixedYear: '',
list: [],
current: createDefaultForm(),
nationOptions: [],
politicsOptions: [],
willingPickerColumns: [[
{ text: '是', value: '是' },
{ text: '否', value: '否' }
]],
genderPickerColumns: [[
{ text: '男', value: '男' },
{ text: '女', value: '女' }
]],
birthMonthPickerColumns: [[]],
birthMonthPickerIndexs: [0, 0]
}
},
computed: {
nationLabels() {
return this.nationOptions.map((item) => item.label)
},
politicsLabels() {
return this.politicsOptions.map((item) => item.label)
},
nationIndex() {
const index = this.nationOptions.findIndex((item) => item.value === this.current.nation)
return index > -1 ? index : 0
},
politicsIndex() {
const index = this.politicsOptions.findIndex((item) => item.value === this.current.politics)
return index > -1 ? index : 0
},
genderPickerIndex() {
const options = (this.genderPickerColumns[0] || []).map((item) => item.value)
const index = options.indexOf(this.current.gender)
return index > -1 ? index : 0
},
politicsPickerColumns() {
return [
this.politicsOptions.map((item) => ({
text: item.label,
value: item.value
}))
]
},
politicsPickerIndex() {
const options = (this.politicsPickerColumns[0] || []).map((item) => item.value)
const index = options.indexOf(this.current.politics)
return index > -1 ? index : 0
},
willingPickerIndex() {
const options = (this.willingPickerColumns[0] || []).map((item) => item.value)
const index = options.indexOf(this.current.willingToWorkInGuangxi)
return index > -1 ? index : 0
}
},
onLoad(options) {
this.fixedYear = String(options.year || '').trim()
this.pageTitle = decodeURIComponent(options.declareTitle || '青马工程申报').trim() || '青马工程申报'
this.loadDictOptions()
this.loadData()
},
methods: {
async loadDictOptions() {
try {
const [nationList, politicsList] = await Promise.all([
listDictData({ dictId: NATION_DICT_ID }),
listDictData({ dictId: POLITICS_DICT_ID })
])
this.nationOptions = normalizeDictOptions(nationList)
this.politicsOptions = normalizeDictOptions(politicsList)
} catch (error) {
uni.showToast({ title: (error && error.message) || '字典加载失败', icon: 'none' })
}
},
async loadData() {
this.loading = true
try {
const result = await userPageQmgcForm({
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.showEdit = true
},
async openEdit(item) {
try {
const detail = item && item.id ? await getQmgcForm(item.id) : item
this.current = { ...createDefaultForm(), ...(detail || {}), year: (detail && detail.year) || this.fixedYear || '' }
this.showEdit = true
} catch (error) {
uni.showToast({ title: (error && error.message) || '加载详情失败', icon: 'none' })
}
},
closeEdit() { this.showEdit = false },
setField(key, event) {
const value = typeof event === 'string' ? event : (event.detail && event.detail.value) || ''
this.current[key] = value || ''
},
handleNationChange(event) {
const index = Number((event.detail && event.detail.value) || 0)
const option = this.nationOptions[index]
this.current.nation = (option && option.value) || ''
},
openGenderPicker() {
this.$nextTick(() => {
this.$refs.genderPicker && this.$refs.genderPicker.open()
})
},
confirmGenderPicker(event) {
const value = (((event || {}).value || [])[0] || {}).value || ''
this.current.gender = value
},
openPoliticsPicker() {
if (!this.politicsOptions.length) {
return
}
this.$nextTick(() => {
this.$refs.politicsPicker && this.$refs.politicsPicker.open()
})
},
confirmPoliticsPicker(event) {
const value = (((event || {}).value || [])[0] || {}).value || ''
this.current.politics = value
},
buildBirthMonthPickerState(value, nextIndexs = []) {
const nowYear = new Date().getFullYear()
const years = []
for (let year = nowYear; year >= 1950; year -= 1) {
years.push({ text: `${year}`, value: `${year}` })
}
const months = Array.from({ length: 12 }, (_, index) => {
const month = String(index + 1).padStart(2, '0')
return { text: month, value: month }
})
let selectedYear = String(this.current.birthMonth || '').slice(0, 4) || `${nowYear}`
let selectedMonth = String(this.current.birthMonth || '').slice(5, 7) || '01'
if (typeof nextIndexs[0] === 'number' && years[nextIndexs[0]]) {
selectedYear = years[nextIndexs[0]].value
}
if (typeof nextIndexs[1] === 'number' && months[nextIndexs[1]]) {
selectedMonth = months[nextIndexs[1]].value
}
const yearIndex = Math.max(0, years.findIndex((item) => item.value === selectedYear))
const monthIndex = Math.max(0, months.findIndex((item) => item.value === selectedMonth))
return {
columns: [years, months],
indexs: [yearIndex, monthIndex]
}
},
openBirthMonthPicker() {
const pickerState = this.buildBirthMonthPickerState(this.current.birthMonth)
this.birthMonthPickerColumns = pickerState.columns
this.birthMonthPickerIndexs = pickerState.indexs
this.$nextTick(() => {
this.$refs.birthMonthPicker && this.$refs.birthMonthPicker.open()
})
},
handleBirthMonthPickerChange(event) {
const pickerState = this.buildBirthMonthPickerState(this.current.birthMonth, (event && event.indexs) || [])
this.birthMonthPickerColumns = pickerState.columns
this.birthMonthPickerIndexs = pickerState.indexs
},
confirmBirthMonthPicker(event) {
const values = (event && event.value) || []
const year = (values[0] && values[0].value) || ''
const month = (values[1] && values[1].value) || ''
if (year && month) {
this.current.birthMonth = `${year}-${month}`
}
this.birthMonthPickerIndexs = (event && event.indexs) || this.birthMonthPickerIndexs
},
openWillingPicker() {
this.$nextTick(() => {
this.$refs.willingPicker && this.$refs.willingPicker.open()
})
},
confirmWillingPicker(event) {
const values = (event && event.value) || []
const option = values[0] || null
this.current.willingToWorkInGuangxi = (option && option.value) || ''
},
async save() {
try {
const payload = { ...this.current, year: this.fixedYear || this.current.year }
if (payload.id) {
await updateQmgcForm(payload)
} else {
await addQmgcForm(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; }
.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; }
.form-section-title { font-size: 28rpx; font-weight: 700; color: #1f2329; margin-bottom: 16rpx; }
.field-item + .field-item { margin-top: 16rpx; }
.field-label { margin-bottom: 10rpx; font-size: 24rpx; color: #5f6b7a; }
.field-picker { width: 100%; box-sizing: border-box; min-height: 84rpx; padding: 20rpx; border-radius: 20rpx; border: 1rpx solid rgba(220,220,220,1); background: #F4FCFF; font-size: 24rpx; color: #1f2329; }
.field-picker--disabled { color: #a8b3bf; background: #f7f9fb; }
.field-textarea { width: 100%; box-sizing: border-box; border-radius: 20rpx; border: 1rpx solid #dce4ec; background: #fff; padding: 20rpx; font-size: 24rpx; color: #1f2329; }
: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; }
.field-textarea { min-height: 180rpx; }
.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>

1297
pages/gxmu/review-list.vue Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,10 @@
import { API_BASE_URL, apiRequest } from '../assistant/chat-service'
export function getChallengeCupStatistics(params) {
return apiRequest({
url: `${API_BASE_URL}/gxmu/tzbcy-form/statistics`,
method: 'GET',
data: params,
withSignature: false
})
}

735
pages/gxmu/stats.vue Normal file
View File

@@ -0,0 +1,735 @@
<template>
<view class="page">
<scroll-view scroll-y class="page-scroll" refresher-enabled :refresher-triggered="refreshing" @refresherrefresh="handleRefresh">
<common-hero title="数据统计与报表中心" :use-image-bg="true"></common-hero>
<view class="hero-card">
<view class="hero-title">挑战杯数据统计</view>
<view class="hero-actions">
<picker class="picker-wrap" mode="selector" :range="yearOptionLabels" :value="selectedYearIndex" @change="handleYearChange">
<view class="action-pill">
<text>{{ selectedYear ? `${selectedYear}` : '全部年份' }}</text>
<text class="action-pill-arrow"></text>
</view>
</picker>
<view class="action-pill action-pill--ghost" :class="{ 'action-pill--loading': loading }" @tap="loadData()">
{{ loading ? '刷新中' : '刷新数据' }}
</view>
</view>
</view>
<view v-if="stats.awardCountDescription" class="alert-card">
<view class="alert-icon">i</view>
<view class="alert-text">{{ stats.awardCountDescription }}</view>
</view>
<view v-if="loading && !hasLoaded" class="state-card">
<view class="state-title">正在加载统计数据...</view>
<view class="state-desc">请稍候正在同步挑战杯报表中心数据</view>
</view>
<view v-else-if="loadError" class="state-card state-card--error">
<view class="state-title">加载失败</view>
<view class="state-desc">{{ loadError }}</view>
<view class="state-action" @tap="loadData()">重新加载</view>
</view>
<block v-else>
<view class="metrics-grid">
<view class="metric-card">
<view class="metric-label">申报作品数量</view>
<view class="metric-value">{{ summary.declarationCount }}</view>
<view class="metric-foot">当前筛选范围内的项目总数</view>
</view>
<view class="metric-card metric-card--accent">
<view class="metric-label">参与申报人数</view>
<view class="metric-value">{{ summary.participantCount }}</view>
<view class="metric-foot">按团队成员去重统计</view>
</view>
<view class="metric-card metric-card--warm">
<view class="metric-label">指导老师数量</view>
<view class="metric-value">{{ summary.advisorCount }}</view>
<view class="metric-foot">按指导老师去重统计</view>
</view>
<view class="metric-card metric-card--success">
<view class="metric-label">获奖数量</view>
<view class="metric-value">{{ summary.awardCount }}</view>
<view class="metric-foot">当前按审核通过作品数统计</view>
</view>
</view>
<view class="mini-metrics">
<view class="mini-metric-card">
<text>申报学校数量</text>
<text class="mini-metric-value">{{ summary.schoolCount }}</text>
</view>
<view class="mini-metric-card">
<text>项目类型数</text>
<text class="mini-metric-value">{{ stats.typeDistribution.length }}</text>
</view>
<view class="mini-metric-card">
<text>项目分组数</text>
<text class="mini-metric-value">{{ stats.groupDistribution.length }}</text>
</view>
</view>
<view class="section-card">
<view class="section-head">
<view>
<view class="section-title">年度申报趋势</view>
<view class="section-subtitle">展示各年度挑战杯作品申报数量变化</view>
</view>
</view>
<view v-if="stats.yearDistribution.length" class="trend-list">
<view v-for="item in stats.yearDistribution" :key="item.label" class="trend-item">
<view class="trend-top">
<view class="trend-label">{{ item.label }}</view>
<view class="trend-value">{{ item.value }}</view>
</view>
<view class="bar-track">
<view class="bar-fill bar-fill--blue" :style="{ width: getBarWidth(item.value, maxYearValue) }"></view>
</view>
</view>
</view>
<view v-else class="empty-card">暂无年度数据</view>
</view>
<view class="section-card">
<view class="section-head">
<view>
<view class="section-title">项目类型分布</view>
<view class="section-subtitle">按项目类型统计申报数量</view>
</view>
</view>
<view v-if="stats.typeDistribution.length" class="distribution-list">
<view v-for="item in stats.typeDistribution" :key="item.label" class="distribution-item">
<view class="distribution-main">
<view class="distribution-label">{{ item.label }}</view>
<view class="distribution-value">{{ item.value }}</view>
</view>
<view class="bar-track">
<view class="bar-fill bar-fill--cyan" :style="{ width: getBarWidth(item.value, maxTypeValue) }"></view>
</view>
</view>
</view>
<view v-else class="empty-card">暂无类型分布</view>
</view>
<view class="section-card">
<view class="section-head">
<view>
<view class="section-title">项目分组分布</view>
<view class="section-subtitle">按项目分组统计申报数量</view>
</view>
</view>
<view v-if="stats.groupDistribution.length" class="distribution-list">
<view v-for="item in stats.groupDistribution" :key="item.label" class="distribution-item">
<view class="distribution-main">
<view class="distribution-label">{{ item.label }}</view>
<view class="distribution-value">{{ item.value }}</view>
</view>
<view class="bar-track">
<view class="bar-fill bar-fill--orange" :style="{ width: getBarWidth(item.value, maxGroupValue) }"></view>
</view>
</view>
</view>
<view v-else class="empty-card">暂无分组分布</view>
</view>
<view class="section-card">
<view class="section-head">
<view>
<view class="section-title">学校申报排行</view>
<view class="section-subtitle">展示学校维度的申报数量排名</view>
</view>
</view>
<view v-if="stats.schoolDistribution.length" class="ranking-list">
<view v-for="(item, index) in stats.schoolDistribution" :key="item.label" class="ranking-item">
<view class="ranking-order">{{ index + 1 }}</view>
<view class="ranking-main">
<view class="distribution-main">
<view class="distribution-label">{{ item.label }}</view>
<view class="distribution-value">{{ item.value }}</view>
</view>
<view class="bar-track">
<view class="bar-fill bar-fill--green" :style="{ width: getBarWidth(item.value, maxSchoolValue) }"></view>
</view>
</view>
</view>
</view>
<view v-else class="empty-card">暂无学校排行</view>
</view>
<view class="section-card">
<view class="section-head">
<view>
<view class="section-title">分布明细</view>
<view class="section-subtitle">{{ selectedYear ? `${selectedYear}` : '全部年份' }}统计口径下的分类数量</view>
</view>
</view>
<view class="detail-grid">
<view class="detail-panel">
<view class="detail-title">项目类型</view>
<view v-if="stats.typeDistribution.length" class="detail-list">
<view v-for="item in stats.typeDistribution" :key="`type-${item.label}`" class="detail-row">
<text class="detail-label">{{ item.label }}</text>
<text class="detail-value">{{ item.value }}</text>
</view>
</view>
<view v-else class="detail-empty">暂无数据</view>
</view>
<view class="detail-panel">
<view class="detail-title">项目分组</view>
<view v-if="stats.groupDistribution.length" class="detail-list">
<view v-for="item in stats.groupDistribution" :key="`group-${item.label}`" class="detail-row">
<text class="detail-label">{{ item.label }}</text>
<text class="detail-value">{{ item.value }}</text>
</view>
</view>
<view v-else class="detail-empty">暂无数据</view>
</view>
</view>
</view>
</block>
</scroll-view>
</view>
</template>
<script>
import CommonHero from '../../components/common-hero/common-hero.vue'
import { getChallengeCupStatistics } from './stats-service'
const createInitialStats = () => ({
availableYears: [],
summary: {
declarationCount: 0,
participantCount: 0,
advisorCount: 0,
awardCount: 0,
schoolCount: 0
},
awardCountDescription: '',
yearDistribution: [],
typeDistribution: [],
groupDistribution: [],
schoolDistribution: []
})
export default {
components: {
CommonHero
},
data() {
return {
loading: false,
refreshing: false,
hasLoaded: false,
loadError: '',
selectedYear: '',
stats: createInitialStats()
}
},
computed: {
summary() {
return this.stats.summary || createInitialStats().summary
},
yearOptionLabels() {
const years = Array.isArray(this.stats.availableYears) ? this.stats.availableYears : []
return ['全部年份', ...years.map((item) => `${item}`)]
},
selectedYearIndex() {
if (!this.selectedYear) {
return 0
}
const years = Array.isArray(this.stats.availableYears) ? this.stats.availableYears : []
const index = years.findIndex((item) => Number(item) === Number(this.selectedYear))
return index >= 0 ? index + 1 : 0
},
maxYearValue() {
return this.getMaxValue(this.stats.yearDistribution)
},
maxTypeValue() {
return this.getMaxValue(this.stats.typeDistribution)
},
maxGroupValue() {
return this.getMaxValue(this.stats.groupDistribution)
},
maxSchoolValue() {
return this.getMaxValue(this.stats.schoolDistribution)
}
},
onLoad() {
this.loadData()
},
methods: {
showToast(title) {
uni.showToast({
title,
icon: 'none'
})
},
getMaxValue(list) {
const values = (Array.isArray(list) ? list : []).map((item) => Number(item.value || 0))
return values.length ? Math.max(...values, 1) : 1
},
getBarWidth(value, maxValue) {
const current = Number(value || 0)
const max = Number(maxValue || 1)
const percent = max > 0 ? (current / max) * 100 : 0
return `${Math.max(percent, current > 0 ? 8 : 0)}%`
},
handleYearChange(event) {
const index = Number(event.detail.value || 0)
if (index === 0) {
this.selectedYear = ''
} else {
const year = this.stats.availableYears[index - 1]
this.selectedYear = year || ''
}
this.loadData()
},
handleRefresh() {
this.refreshing = true
this.loadData(true)
},
async loadData(fromRefresh = false) {
if (this.loading) {
if (fromRefresh) {
this.refreshing = false
}
return
}
this.loading = true
this.loadError = ''
try {
const params = this.selectedYear ? { year: Number(this.selectedYear) } : undefined
const result = await getChallengeCupStatistics(params)
this.stats = {
...createInitialStats(),
...(result || {}),
summary: {
...createInitialStats().summary,
...((result && result.summary) || {})
},
availableYears: Array.isArray(result && result.availableYears) ? result.availableYears : [],
yearDistribution: Array.isArray(result && result.yearDistribution) ? result.yearDistribution : [],
typeDistribution: Array.isArray(result && result.typeDistribution) ? result.typeDistribution : [],
groupDistribution: Array.isArray(result && result.groupDistribution) ? result.groupDistribution : [],
schoolDistribution: Array.isArray(result && result.schoolDistribution) ? result.schoolDistribution : []
}
if (
this.selectedYear &&
!this.stats.availableYears.some((item) => Number(item) === Number(this.selectedYear))
) {
this.selectedYear = ''
}
this.hasLoaded = true
} catch (error) {
this.loadError = (error && error.message) || '统计数据加载失败'
this.showToast(this.loadError)
} finally {
this.loading = false
if (fromRefresh) {
this.refreshing = false
}
}
}
}
}
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
background:
radial-gradient(circle at top left, rgba(37, 99, 235, 0.14), transparent 28%),
linear-gradient(180deg, #f7fbff 0%, #fffdf8 100%);
}
.page-scroll {
height: 100vh;
//padding: 28rpx 24rpx 40rpx;
box-sizing: border-box;
}
.hero-card {
padding: 34rpx 36rpx;
border-radius: 28rpx;
background: rgba(255, 255, 255, 0.5);
box-shadow: 0 4rpx 12rpx 0 rgba(174, 174, 174, 0.4);
backdrop-filter: blur(24rpx);
-webkit-backdrop-filter: blur(24rpx);
border: 1rpx solid rgba(255, 255, 255, 0.5);
}
.hero-badge {
display: inline-flex;
padding: 10rpx 18rpx;
border-radius: 999rpx;
background: rgba(20,150,242,.1);
font-size: 22rpx;
color: #1496f2;
letter-spacing: 1rpx;
}
.hero-title {
margin-top: 8rpx;
padding-left: 10rpx;
font-size: 30rpx;
line-height: 1.35;
font-weight: 700;
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;
flex-wrap: wrap;
gap: 16rpx;
margin-top: 24rpx;
}
.picker-wrap {
display: block;
}
.action-pill {
display: inline-flex;
align-items: center;
gap: 10rpx;
padding: 16rpx 24rpx;
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.18);
font-size: 24rpx;
font-weight: 600;
}
.action-pill--ghost {
background: rgba(10, 20, 45, 0.16);
}
.action-pill--loading {
opacity: 0.7;
}
.action-pill-arrow {
font-size: 18rpx;
opacity: 0.85;
}
.alert-card {
display: flex;
align-items: flex-start;
gap: 16rpx;
margin-top: 22rpx;
padding: 24rpx;
border-radius: 28rpx;
background: #eef6ff;
border: 1rpx solid rgba(37, 99, 235, 0.12);
}
.alert-icon {
flex-shrink: 0;
width: 36rpx;
height: 36rpx;
border-radius: 50%;
background: #2563eb;
color: #fff;
font-size: 22rpx;
font-weight: 700;
line-height: 36rpx;
text-align: center;
}
.alert-text {
font-size: 24rpx;
line-height: 1.7;
color: #31537c;
}
.state-card {
margin-top: 24rpx;
padding: 32rpx 28rpx;
border-radius: 28rpx;
background: rgba(255, 255, 255, 0.94);
border: 1rpx solid rgba(37, 99, 235, 0.08);
box-shadow: 0 10rpx 28rpx rgba(34, 94, 142, 0.08);
}
.state-card--error {
background: rgba(247, 250, 255, 0.98);
border-color: rgba(37, 99, 235, 0.16);
}
.state-title {
font-size: 30rpx;
font-weight: 700;
color: #1f2329;
}
.state-desc {
margin-top: 12rpx;
font-size: 24rpx;
line-height: 1.7;
color: #6c7480;
}
.state-action {
margin-top: 18rpx;
font-size: 24rpx;
font-weight: 600;
color: #2563eb;
}
.metrics-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18rpx;
margin-top: 24rpx;
}
.metric-card {
padding: 24rpx;
border-radius: 28rpx;
background: linear-gradient(135deg, #eff6ff 0%, #ffffff 100%);
border: 1rpx solid #dbeafe;
box-shadow: 0 10rpx 28rpx rgba(15, 23, 42, 0.05);
}
.metric-card--accent {
background: linear-gradient(135deg, #f0fdf4 0%, #ffffff 100%);
border-color: #bbf7d0;
}
.metric-card--warm {
background: linear-gradient(135deg, #fff7ed 0%, #ffffff 100%);
border-color: #fed7aa;
}
.metric-card--success {
background: linear-gradient(135deg, #ecfeff 0%, #ffffff 100%);
border-color: #a5f3fc;
}
.metric-label {
font-size: 24rpx;
color: #64748b;
}
.metric-value {
margin-top: 12rpx;
font-size: 46rpx;
font-weight: 700;
line-height: 1;
color: #0f172a;
}
.metric-foot {
margin-top: 14rpx;
font-size: 22rpx;
line-height: 1.6;
color: #94a3b8;
}
.mini-metrics {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16rpx;
margin-top: 18rpx;
}
.mini-metric-card {
padding: 22rpx 18rpx;
border-radius: 24rpx;
background: rgba(255, 255, 255, 0.9);
box-shadow: 0 8rpx 20rpx rgba(15, 23, 42, 0.05);
font-size: 22rpx;
line-height: 1.6;
color: #64748b;
}
.mini-metric-value {
display: block;
margin-top: 8rpx;
font-size: 34rpx;
font-weight: 700;
color: #0f172a;
}
.section-card {
margin-top: 24rpx;
padding: 28rpx 24rpx;
border-radius: 30rpx;
background: rgba(255, 255, 255, 0.94);
box-shadow: 0 14rpx 34rpx rgba(15, 23, 42, 0.06);
}
.section-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18rpx;
}
.section-title {
font-size: 32rpx;
font-weight: 700;
color: #162033;
}
.section-subtitle {
margin-top: 8rpx;
font-size: 22rpx;
line-height: 1.6;
color: #8b94a3;
}
.empty-card {
margin-top: 20rpx;
padding: 28rpx 24rpx;
border-radius: 24rpx;
background: #f8fbff;
font-size: 24rpx;
color: #90a0b7;
}
.trend-list,
.distribution-list,
.ranking-list,
.detail-list {
margin-top: 20rpx;
}
.trend-item,
.distribution-item {
& + & {
margin-top: 18rpx;
}
}
.trend-top,
.distribution-main,
.detail-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
}
.trend-label,
.distribution-label,
.detail-label {
flex: 1;
min-width: 0;
font-size: 24rpx;
line-height: 1.6;
color: #1f2937;
word-break: break-all;
}
.trend-value,
.distribution-value,
.detail-value {
flex-shrink: 0;
font-size: 24rpx;
font-weight: 700;
color: #0f172a;
}
.bar-track {
height: 16rpx;
margin-top: 12rpx;
border-radius: 999rpx;
background: #edf2f7;
overflow: hidden;
}
.bar-fill {
height: 100%;
border-radius: 999rpx;
}
.bar-fill--blue {
background: linear-gradient(90deg, #3b82f6 0%, #2563eb 100%);
}
.bar-fill--cyan {
background: linear-gradient(90deg, #38bdf8 0%, #0ea5e9 100%);
}
.bar-fill--orange {
background: linear-gradient(90deg, #fbbf24 0%, #f59e0b 100%);
}
.bar-fill--green {
background: linear-gradient(90deg, #34d399 0%, #10b981 100%);
}
.ranking-item {
display: flex;
align-items: flex-start;
gap: 16rpx;
& + & {
margin-top: 18rpx;
}
}
.ranking-order {
flex-shrink: 0;
width: 40rpx;
height: 40rpx;
border-radius: 50%;
background: #ecf4ff;
color: #2563eb;
font-size: 22rpx;
font-weight: 700;
line-height: 40rpx;
text-align: center;
}
.ranking-main {
flex: 1;
min-width: 0;
}
.detail-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18rpx;
margin-top: 20rpx;
}
.detail-panel {
padding: 22rpx;
border-radius: 24rpx;
background: #f9fbfe;
}
.detail-title {
font-size: 26rpx;
font-weight: 700;
color: #1f2937;
}
.detail-row {
padding: 16rpx 0;
border-bottom: 1rpx solid rgba(148, 163, 184, 0.18);
}
.detail-row:last-child {
border-bottom: none;
padding-bottom: 0;
}
.detail-empty {
margin-top: 18rpx;
font-size: 22rpx;
color: #94a3b8;
}
</style>

View File

@@ -0,0 +1,19 @@
import { API_BASE_URL, apiRequest } from '../assistant/chat-service'
export function pageTzbProjectList(params) {
return apiRequest({
url: `${API_BASE_URL}/gxmu/tzb-project-list/page`,
method: 'GET',
data: params,
withSignature: false
})
}
export function pageTzbTalentList(params) {
return apiRequest({
url: `${API_BASE_URL}/gxmu/tzb-talent-list/page`,
method: 'GET',
data: params,
withSignature: false
})
}

205
pages/gxmu/tzb-database.vue Normal file
View File

@@ -0,0 +1,205 @@
<template>
<view class="page">
<scroll-view scroll-y class="page-scroll">
<common-hero title="创新竞赛历史项目数据库" :use-image-bg="true"></common-hero>
<view class="hero-card">
<view class="hero-title">项目库与人才库</view>
<view class="hero-desc">
沉淀挑战杯历史项目案例获奖信息与参赛人才画像支持按项目和人才两个维度快速检索与复用
</view>
<!-- <view class="hero-actions">-->
<!-- <view class="hero-action" @tap="openPage('/pages/gxmu/tzb-project-list')">进入项目库</view>-->
<!-- <view class="hero-action hero-action&#45;&#45;secondary" @tap="openPage('/pages/gxmu/tzb-talent-list')">进入人才库</view>-->
<!-- </view>-->
</view>
<view class="entry-list">
<view class="entry-card" @tap="openPage('/pages/gxmu/tzb-project-list')">
<view class="entry-top">
<view class="entry-icon"></view>
<view class="entry-tag">项目库</view>
</view>
<view class="entry-title">挑战杯项目库</view>
<view class="entry-desc">查看历届项目名称学校奖项比赛级别届次与详情链接</view>
<view class="entry-foot">适合做选题借鉴对标分析与历史案例复盘</view>
</view>
<view class="entry-card" @tap="openPage('/pages/gxmu/tzb-talent-list')">
<view class="entry-top">
<view class="entry-icon entry-icon--green"></view>
<view class="entry-tag entry-tag--green">人才库</view>
</view>
<view class="entry-title">挑战杯人才库</view>
<view class="entry-desc">查看参赛人才学校人才类型参赛项目获奖情况与原始详情链接</view>
<view class="entry-foot">适合做导师联络队伍画像和成果人才线索挖掘</view>
</view>
</view>
</scroll-view>
</view>
</template>
<script>
import CommonHero from '../../components/common-hero/common-hero.vue'
export default {
components: {
CommonHero
},
methods: {
openPage(url) {
uni.navigateTo({
url
})
}
}
}
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
background:
radial-gradient(circle at top left, rgba(20, 150, 242, 0.14), transparent 30%),
linear-gradient(180deg, #f5fbff 0%, #eef7ff 100%);
}
.page-scroll {
height: 100vh;
padding: 28rpx 24rpx 40rpx;
box-sizing: border-box;
}
.hero-card {
padding: 34rpx 36rpx;
border-radius: 28rpx;
background: rgba(255, 255, 255, 0.5);
box-shadow: 0 4rpx 12rpx 0 rgba(174, 174, 174, 0.4);
backdrop-filter: blur(24rpx);
-webkit-backdrop-filter: blur(24rpx);
border: 1rpx solid rgba(255, 255, 255, 0.5);
}
.hero-badge {
display: inline-flex;
padding: 10rpx 18rpx;
border-radius: 999rpx;
background: rgba(20,150,242,.1);
font-size: 22rpx;
color: #1496f2;
letter-spacing: 1rpx;
}
.hero-title {
margin-top: 8rpx;
padding-left: 10rpx;
font-size: 30rpx;
line-height: 1.35;
font-weight: 700;
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;
flex-wrap: wrap;
gap: 16rpx;
margin-top: 24rpx;
}
.hero-action {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 14rpx 24rpx;
border-radius: 999rpx;
background: rgba(20,150,242,.12);
font-size: 24rpx;
font-weight: 600;
color: #1496f2;
}
.hero-action--secondary {
background: rgba(31,35,41,.08);
color: #5f6b76;
}
.entry-list {
display: flex;
flex-direction: column;
gap: 18rpx;
margin-top: 24rpx;
}
.entry-card {
padding: 28rpx;
border-radius: 30rpx;
background:
linear-gradient(180deg, rgba(240, 247, 255, 0.95) 0%, #ffffff 44%),
#ffffff;
border: 1rpx solid rgba(21, 84, 173, 0.08);
box-shadow: 0 10rpx 28rpx rgba(76, 49, 35, 0.08);
}
.entry-top {
display: flex;
align-items: center;
justify-content: space-between;
}
.entry-icon {
width: 76rpx;
height: 76rpx;
border-radius: 24rpx;
background: linear-gradient(135deg, #1496f2 0%, #4bb7ff 100%);
color: #fff;
font-size: 32rpx;
font-weight: 700;
line-height: 76rpx;
text-align: center;
}
.entry-icon--green {
background: linear-gradient(135deg, #12b981 0%, #57d5a4 100%);
}
.entry-tag {
padding: 10rpx 18rpx;
border-radius: 999rpx;
background: #edf5ff;
color: #1554ad;
font-size: 22rpx;
font-weight: 600;
}
.entry-tag--green {
background: #e9fbf5;
color: #0f9f71;
}
.entry-title {
margin-top: 22rpx;
font-size: 32rpx;
font-weight: 700;
color: #1f2329;
}
.entry-desc {
margin-top: 14rpx;
font-size: 24rpx;
line-height: 1.7;
color: #5f6b7a;
}
.entry-foot {
margin-top: 18rpx;
font-size: 22rpx;
line-height: 1.6;
color: #8b827d;
}
</style>

View File

@@ -0,0 +1,661 @@
<template>
<view class="page">
<scroll-view
scroll-y
class="page-scroll"
refresher-enabled
:refresher-triggered="refreshing"
@refresherrefresh="handleRefresh"
@scrolltolower="loadMore"
>
<common-hero title="挑战杯项目库" :use-image-bg="true"></common-hero>
<view class="hero-card">
<view class="hero-title">创新竞赛历史项目数据库</view>
<view class="hero-desc">
按项目名称学校奖项年份和比赛级别查看历届挑战杯案例支持复制详情链接用于进一步追踪原始页面
</view>
<view class="hero-meta">
<view class="meta-item">
<view class="meta-value">{{ list.length }}</view>
<view class="meta-label">当前已加载</view>
</view>
<view class="meta-item">
<view class="meta-value">{{ yearOptions.length }}</view>
<view class="meta-label">年份筛选</view>
</view>
<view class="meta-item">
<view class="meta-value">{{ levelOptions.length }}</view>
<view class="meta-label">级别筛选</view>
</view>
</view>
</view>
<view class="filter-card">
<view class="search-box">
<uv-input
v-model.trim="filters.keywords"
class="search-input"
placeholder="项目名称/学校/奖项"
confirm-type="search"
@confirm="applyFilters"
:maxlength="-1"
border="none"
/>
<view class="search-btn" @tap="applyFilters">查询</view>
</view>
<view class="field-row">
<uv-input
v-model.trim="filters.schoolName"
class="field-input"
placeholder="高校名称"
confirm-type="search"
@confirm="applyFilters"
:maxlength="-1"
border="none"
/>
</view>
<view class="picker-row">
<picker class="picker-item" mode="selector" :range="yearLabels" :value="yearIndex" @change="handleYearChange">
<view class="picker-trigger">{{ filters.matchYear ? `${filters.matchYear}` : '参赛年份' }}</view>
</picker>
<picker class="picker-item" mode="selector" :range="levelLabels" :value="levelIndex" @change="handleLevelChange">
<view class="picker-trigger">{{ filters.matchLevel || '比赛级别' }}</view>
</picker>
</view>
<view class="action-row">
<view class="action-btn action-btn--primary" @tap="applyFilters">查询</view>
<view class="action-btn" @tap="resetFilters">重置</view>
</view>
</view>
<view v-if="loading && !list.length" class="state-card">
<view class="state-title">正在加载项目库...</view>
<view class="state-desc">请稍候正在同步挑战杯历史项目数据</view>
</view>
<view v-else-if="loadError && !list.length" class="state-card state-card--error">
<view class="state-title">加载失败</view>
<view class="state-desc">{{ loadError }}</view>
<view class="state-action" @tap="loadData(true)">重新加载</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 || item.sourceId" class="data-card">
<view class="card-top">
<view class="card-cover" :style="getCoverStyle(item.coverImage)">
<view class="cover-tag">{{ item.matchLevel || '未分类' }}</view>
</view>
<view class="card-main">
<view class="card-title">{{ item.projectName || '未命名项目' }}</view>
<view class="card-school">{{ item.schoolName || '未知学校' }}</view>
<view class="tag-row">
<view class="tag-chip">{{ item.awardName || '未录入奖项' }}</view>
<view class="tag-chip tag-chip--blue">{{ item.matchYear || '-' }}</view>
<view class="tag-chip tag-chip--soft">{{ item.matchTerm || '未知届次' }}</view>
</view>
</view>
</view>
<view class="info-grid">
<view class="info-item">
<view class="info-label">来源ID</view>
<view class="info-value">{{ item.sourceId || '-' }}</view>
</view>
<view class="info-item">
<view class="info-label">来源页码</view>
<view class="info-value">{{ item.pageNo || '-' }}</view>
</view>
<view class="info-item">
<view class="info-label">同步时间</view>
<view class="info-value">{{ formatDateTime(item.lastSyncTime) }}</view>
</view>
</view>
<view class="card-footer">
<view class="footer-link" @tap="copyLink(item.detailUrl)">复制详情链接</view>
</view>
</view>
</view>
<view v-if="list.length" class="load-more">
<view v-if="loadingMore" class="load-more-text">正在加载更多...</view>
<view v-else-if="hasMore" class="load-more-text" @tap="loadMore">点击加载更多</view>
<view v-else class="load-more-text load-more-text--end">没有更多数据了</view>
</view>
</scroll-view>
</view>
</template>
<script>
import CommonHero from '../../components/common-hero/common-hero.vue'
import { pageTzbProjectList } from './tzb-database-service'
const DEFAULT_FILTERS = () => ({
keywords: '',
schoolName: '',
matchYear: '',
matchLevel: ''
})
export default {
components: {
CommonHero
},
data() {
return {
loading: false,
loadingMore: false,
refreshing: false,
loadError: '',
page: 1,
limit: 10,
total: 0,
hasMore: true,
list: [],
filters: DEFAULT_FILTERS(),
yearOptions: [2025, 2024, 2023, 2022, 2021, 2020, 2019],
levelOptions: ['国赛', '省赛', '校赛']
}
},
computed: {
yearLabels() {
return ['全部年份', ...this.yearOptions.map((item) => `${item}`)]
},
levelLabels() {
return ['全部级别', ...this.levelOptions]
},
yearIndex() {
if (!this.filters.matchYear) {
return 0
}
const index = this.yearOptions.findIndex((item) => Number(item) === Number(this.filters.matchYear))
return index >= 0 ? index + 1 : 0
},
levelIndex() {
if (!this.filters.matchLevel) {
return 0
}
const index = this.levelOptions.findIndex((item) => item === this.filters.matchLevel)
return index >= 0 ? index + 1 : 0
}
},
onLoad() {
this.loadData(true)
},
methods: {
showToast(title) {
uni.showToast({
title,
icon: 'none'
})
},
formatDateTime(value) {
if (!value) {
return '-'
}
const date = new Date(String(value).replace(/-/g, '/'))
if (Number.isNaN(date.getTime())) {
return value
}
const pad = (item) => String(item).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
},
getCoverStyle(url) {
if (url) {
return `background-image: linear-gradient(rgba(15, 23, 42, 0.12), rgba(15, 23, 42, 0.32)), url(${url});`
}
return 'background: linear-gradient(135deg, #60a5fa 0%, #2563eb 100%);'
},
handleYearChange(event) {
const index = Number(event.detail.value || 0)
this.filters.matchYear = index === 0 ? '' : this.yearOptions[index - 1]
},
handleLevelChange(event) {
const index = Number(event.detail.value || 0)
this.filters.matchLevel = index === 0 ? '' : this.levelOptions[index - 1]
},
handleRefresh() {
this.refreshing = true
this.loadData(true)
},
applyFilters() {
this.loadData(true)
},
resetFilters() {
this.filters = DEFAULT_FILTERS()
this.loadData(true)
},
async loadData(reset = false) {
if (this.loading || this.loadingMore) {
if (reset) {
this.refreshing = false
}
return
}
const nextPage = reset ? 1 : this.page
if (!reset && !this.hasMore) {
return
}
if (reset) {
this.loading = true
this.loadError = ''
} else {
this.loadingMore = true
}
try {
const params = {
page: nextPage,
limit: this.limit,
}
if (this.filters.keywords) {
params.keywords = this.filters.keywords
}
if (this.filters.schoolName) {
params.schoolName = this.filters.schoolName
}
if (this.filters.matchYear) {
params.matchYear = this.filters.matchYear
}
if (this.filters.matchLevel) {
params.matchLevel = this.filters.matchLevel
}
const result = await pageTzbProjectList(params)
const nextList = (result && result.list) || []
const count = Number((result && result.count) || 0)
this.total = count
this.page = nextPage + 1
this.list = reset ? nextList : this.list.concat(nextList)
this.hasMore = this.list.length < count
} catch (error) {
this.loadError = (error && error.message) || '项目库加载失败'
this.showToast(this.loadError)
} finally {
this.loading = false
this.loadingMore = false
this.refreshing = false
}
},
loadMore() {
if (!this.loading && !this.loadingMore && this.hasMore) {
this.loadData(false)
}
},
copyLink(url) {
if (!url) {
this.showToast('暂无详情链接')
return
}
uni.setClipboardData({
data: url,
success: () => {
this.showToast('详情链接已复制')
}
})
}
}
}
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
background:
radial-gradient(circle at top left, rgba(37, 99, 235, 0.14), transparent 28%),
linear-gradient(180deg, #f7fbff 0%, #fffdf8 100%);
}
.page-scroll {
height: 100vh;
//padding: 28rpx 24rpx 40rpx;
box-sizing: border-box;
}
.hero-card,
.filter-card,
.state-card,
.data-card {
border-radius: 30rpx;
box-shadow: 0 14rpx 34rpx rgba(15, 23, 42, 0.06);
}
.hero-card {
padding: 34rpx 36rpx;
border-radius: 28rpx;
background: rgba(255, 255, 255, 0.5);
box-shadow: 0 4rpx 12rpx 0 rgba(174, 174, 174, 0.4);
backdrop-filter: blur(24rpx);
-webkit-backdrop-filter: blur(24rpx);
border: 1rpx solid rgba(255, 255, 255, 0.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;
line-height: 1.35;
font-weight: 700;
color: #18a0f7;
border-left: 5px solid #0f94ef;
}
.hero-desc {
margin-top: 20rpx;
font-size: 24rpx;
line-height: 1.8;
color: #7f7f7f;
}
.hero-meta {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16rpx;
margin-top: 26rpx;
}
.meta-item {
padding: 18rpx 16rpx;
border-radius: 22rpx;
background: rgba(255, 255, 255, 0.12);
}
.meta-value {
font-size: 32rpx;
font-weight: 700;
}
.meta-label {
margin-top: 6rpx;
font-size: 22rpx;
color: rgba(255, 247, 244, 0.84);
}
.filter-card {
margin-top: 22rpx;
padding: 24rpx;
background: rgba(255, 255, 255, 0.94);
}
.search-box,
.field-input,
.picker-trigger {
background: #f8fbff;
border: 1rpx solid rgba(37, 99, 235, 0.1);
}
.search-box {
display: flex;
align-items: center;
gap: 16rpx;
padding: 0 18rpx;
height: 84rpx;
border-radius: 999rpx;
}
:deep(.search-input.uv-input) {
flex: 1;
height: 100%;
padding: 0 !important;
background: transparent !important;
border: 0 !important;
}
:deep(.search-input.uv-input .uv-input__content__field-wrapper__field) {
font-size: 24rpx;
color: #1f2937;
}
.search-btn {
flex-shrink: 0;
font-size: 24rpx;
font-weight: 600;
color: #2563eb;
}
.field-row,
.picker-row,
.action-row {
margin-top: 16rpx;
}
:deep(.field-input.uv-input) {
width: 100%;
height: 84rpx;
padding: 0 20rpx !important;
border-radius: 22rpx;
box-sizing: border-box;
background: #f8fbff !important;
border: 1rpx solid rgba(37, 99, 235, 0.1) !important;
}
:deep(.field-input.uv-input .uv-input__content__field-wrapper__field) {
font-size: 24rpx;
color: #1f2937;
}
.picker-row {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14rpx;
}
.picker-item {
display: block;
}
.picker-trigger {
display: flex;
align-items: center;
justify-content: center;
height: 84rpx;
border-radius: 22rpx;
font-size: 24rpx;
color: #1f2937;
}
.action-row {
display: flex;
gap: 14rpx;
}
.action-btn {
flex: 1;
height: 82rpx;
border-radius: 22rpx;
background: #eef5ff;
color: #2563eb;
font-size: 24rpx;
font-weight: 600;
line-height: 82rpx;
text-align: center;
}
.action-btn--primary {
background: linear-gradient(135deg, #3b82f6, #2563eb);
color: #fff;
}
.state-card {
margin-top: 22rpx;
padding: 32rpx 28rpx;
background: rgba(255, 255, 255, 0.94);
}
.state-card--error {
background: rgba(247, 250, 255, 0.98);
border: 1rpx solid rgba(37, 99, 235, 0.16);
}
.state-title {
font-size: 30rpx;
font-weight: 700;
color: #1f2329;
}
.state-desc {
margin-top: 12rpx;
font-size: 24rpx;
line-height: 1.7;
color: #6c7480;
}
.state-action {
margin-top: 18rpx;
font-size: 24rpx;
font-weight: 600;
color: #2563eb;
}
.card-list {
display: flex;
flex-direction: column;
gap: 18rpx;
margin-top: 22rpx;
}
.data-card {
padding: 24rpx;
background: rgba(255, 255, 255, 0.95);
}
.card-top {
display: flex;
gap: 18rpx;
}
.card-cover {
flex-shrink: 0;
width: 168rpx;
height: 168rpx;
border-radius: 24rpx;
background-size: cover;
background-position: center;
overflow: hidden;
display: flex;
align-items: flex-start;
justify-content: flex-start;
padding: 16rpx;
box-sizing: border-box;
}
.cover-tag {
padding: 8rpx 14rpx;
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.18);
color: #fff;
font-size: 20rpx;
font-weight: 600;
}
.card-main {
flex: 1;
min-width: 0;
}
.card-title {
font-size: 30rpx;
font-weight: 700;
line-height: 1.5;
color: #0f172a;
}
.card-school {
margin-top: 8rpx;
font-size: 24rpx;
color: #64748b;
}
.tag-row {
display: flex;
flex-wrap: wrap;
gap: 10rpx;
margin-top: 14rpx;
}
.tag-chip {
padding: 8rpx 14rpx;
border-radius: 999rpx;
background: #fff4e6;
color: #c76b11;
font-size: 20rpx;
}
.tag-chip--blue {
background: #eef5ff;
color: #2563eb;
}
.tag-chip--soft {
background: #f3f4f6;
color: #6b7280;
}
.info-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14rpx;
margin-top: 18rpx;
}
.info-item {
padding: 18rpx;
border-radius: 22rpx;
background: #f8fbff;
}
.info-label {
font-size: 20rpx;
color: #8b94a3;
}
.info-value {
margin-top: 8rpx;
font-size: 23rpx;
line-height: 1.6;
color: #1f2937;
word-break: break-all;
}
.card-footer {
display: flex;
justify-content: flex-end;
margin-top: 18rpx;
}
.footer-link {
font-size: 24rpx;
font-weight: 600;
color: #2563eb;
}
.load-more {
padding: 28rpx 0 12rpx;
text-align: center;
}
.load-more-text {
font-size: 22rpx;
color: #64748b;
}
.load-more-text--end {
color: #94a3b8;
}
</style>

View File

@@ -0,0 +1,680 @@
<template>
<view class="page">
<scroll-view
scroll-y
class="page-scroll"
refresher-enabled
:refresher-triggered="refreshing"
@refresherrefresh="handleRefresh"
@scrolltolower="loadMore"
>
<common-hero title="挑战杯人才库" :use-image-bg="true"></common-hero>
<view class="hero-card hero-card--green">
<view class="hero-title">创新竞赛历史人才数据库</view>
<view class="hero-desc">
按姓名学校人才类型参赛项目和比赛级别查看历届挑战杯人才线索支持复制详情链接用于进一步跟踪
</view>
<view class="hero-meta">
<view class="meta-item">
<view class="meta-value">{{ list.length }}</view>
<view class="meta-label">当前已加载</view>
</view>
<view class="meta-item">
<view class="meta-value">{{ levelOptions.length }}</view>
<view class="meta-label">级别筛选</view>
</view>
<view class="meta-item">
<view class="meta-value">{{ talentTypeCount }}</view>
<view class="meta-label">人才类型</view>
</view>
</view>
</view>
<view class="filter-card">
<view class="search-box">
<uv-input
v-model.trim="filters.keywords"
class="search-input"
placeholder="姓名/项目/学校/奖项"
confirm-type="search"
@confirm="applyFilters"
:maxlength="-1"
border="none"
/>
<view class="search-btn" @tap="applyFilters">查询</view>
</view>
<view class="field-row">
<uv-input
v-model.trim="filters.schoolName"
class="field-input"
placeholder="参赛学校"
confirm-type="search"
@confirm="applyFilters"
:maxlength="-1"
border="none"
/>
</view>
<view class="field-row">
<uv-input
v-model.trim="filters.talentType"
class="field-input"
placeholder="人才类型"
confirm-type="search"
@confirm="applyFilters"
:maxlength="-1"
border="none"
/>
</view>
<view class="picker-row picker-row--single">
<picker class="picker-item" mode="selector" :range="levelLabels" :value="levelIndex"
@change="handleLevelChange">
<view class="picker-trigger">{{ filters.matchLevel || '比赛级别' }}</view>
</picker>
</view>
<view class="action-row">
<view class="action-btn action-btn--primary action-btn--green" @tap="applyFilters">查询</view>
<view class="action-btn" @tap="resetFilters">重置</view>
</view>
</view>
<view v-if="loading && !list.length" class="state-card">
<view class="state-title">正在加载人才库...</view>
<view class="state-desc">请稍候正在同步挑战杯历史人才数据</view>
</view>
<view v-else-if="loadError && !list.length" class="state-card state-card--error">
<view class="state-title">加载失败</view>
<view class="state-desc">{{ loadError }}</view>
<view class="state-action state-action--green" @tap="loadData(true)">重新加载</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 || item.sourceId" class="data-card">
<view class="card-top">
<view class="avatar-cover" :style="getCoverStyle(item.coverImage)">
<view class="avatar-text">{{ getAvatarText(item.personName) }}</view>
</view>
<view class="card-main">
<view class="card-title-row">
<view class="card-title">{{ item.personName || '未命名人才' }}</view>
<view class="tag-chip tag-chip--green">{{ item.talentType || '未分类' }}</view>
</view>
<view class="card-school">{{ item.schoolName || '未知学校' }}</view>
<view class="card-project">{{ item.projectName || '未录入项目名称' }}</view>
<view class="tag-row">
<view class="tag-chip">{{ item.awardName || '未录入奖项' }}</view>
<view class="tag-chip tag-chip--blue">{{ item.matchLevel || '未知级别' }}</view>
<view class="tag-chip tag-chip--soft">{{ item.matchTerm || '未知届次' }}</view>
</view>
</view>
</view>
<view class="info-grid">
<view class="info-item">
<view class="info-label">来源ID</view>
<view class="info-value">{{ item.sourceId || '-' }}</view>
</view>
<view class="info-item">
<view class="info-label">来源页码</view>
<view class="info-value">{{ item.pageNo || '-' }}</view>
</view>
<view class="info-item">
<view class="info-label">同步时间</view>
<view class="info-value">{{ formatDateTime(item.lastSyncTime) }}</view>
</view>
</view>
<view class="card-footer">
<view class="footer-link footer-link--green" @tap="copyLink(item.detailUrl)">复制详情链接</view>
</view>
</view>
</view>
<view v-if="list.length" class="load-more">
<view v-if="loadingMore" class="load-more-text">正在加载更多...</view>
<view v-else-if="hasMore" class="load-more-text" @tap="loadMore">点击加载更多</view>
<view v-else class="load-more-text load-more-text--end">没有更多数据了</view>
</view>
</scroll-view>
</view>
</template>
<script>
import CommonHero from '../../components/common-hero/common-hero.vue'
import {pageTzbTalentList} from './tzb-database-service'
const DEFAULT_FILTERS = () => ({
keywords: '',
schoolName: '',
talentType: '',
matchLevel: ''
})
export default {
components: {
CommonHero
},
data() {
return {
loading: false,
loadingMore: false,
refreshing: false,
loadError: '',
page: 1,
limit: 10,
total: 0,
hasMore: true,
list: [],
filters: DEFAULT_FILTERS(),
levelOptions: ['国赛', '省赛', '校赛']
}
},
computed: {
levelLabels() {
return ['全部级别', ...this.levelOptions]
},
levelIndex() {
if (!this.filters.matchLevel) {
return 0
}
const index = this.levelOptions.findIndex((item) => item === this.filters.matchLevel)
return index >= 0 ? index + 1 : 0
},
talentTypeCount() {
const set = new Set(this.list.map((item) => item.talentType).filter(Boolean))
return set.size || '-'
}
},
onLoad() {
this.loadData(true)
},
methods: {
showToast(title) {
uni.showToast({
title,
icon: 'none'
})
},
formatDateTime(value) {
if (!value) {
return '-'
}
const date = new Date(String(value).replace(/-/g, '/'))
if (Number.isNaN(date.getTime())) {
return value
}
const pad = (item) => String(item).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
},
getCoverStyle(url) {
if (url) {
return `background-image: linear-gradient(rgba(15, 23, 42, 0.12), rgba(15, 23, 42, 0.24)), url(${url});`
}
return 'background: linear-gradient(135deg, #34d399 0%, #10b981 100%);'
},
getAvatarText(name) {
return String(name || '人才').slice(-2)
},
handleLevelChange(event) {
const index = Number(event.detail.value || 0)
this.filters.matchLevel = index === 0 ? '' : this.levelOptions[index - 1]
},
handleRefresh() {
this.refreshing = true
this.loadData(true)
},
applyFilters() {
this.loadData(true)
},
resetFilters() {
this.filters = DEFAULT_FILTERS()
this.loadData(true)
},
async loadData(reset = false) {
if (this.loading || this.loadingMore) {
if (reset) {
this.refreshing = false
}
return
}
const nextPage = reset ? 1 : this.page
if (!reset && !this.hasMore) {
return
}
if (reset) {
this.loading = true
this.loadError = ''
} else {
this.loadingMore = true
}
try {
const params = {
page: nextPage,
limit: this.limit
}
if (this.filters.keywords) params.keywords = this.filters.keywords
if (this.filters.schoolName) params.schoolName = this.filters.schoolName
if (this.filters.talentType) params.talentType = this.filters.talentType
if (this.filters.matchLevel) params.matchLevel = this.filters.matchLevel
const result = await pageTzbTalentList(params)
const nextList = (result && result.list) || []
const count = Number((result && result.count) || 0)
this.total = count
this.page = nextPage + 1
this.list = reset ? nextList : this.list.concat(nextList)
this.hasMore = this.list.length < count
} catch (error) {
this.loadError = (error && error.message) || '人才库加载失败'
this.showToast(this.loadError)
} finally {
this.loading = false
this.loadingMore = false
this.refreshing = false
}
},
loadMore() {
if (!this.loading && !this.loadingMore && this.hasMore) {
this.loadData(false)
}
},
copyLink(url) {
if (!url) {
this.showToast('暂无详情链接')
return
}
uni.setClipboardData({
data: url,
success: () => {
this.showToast('详情链接已复制')
}
})
}
}
}
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
background: radial-gradient(circle at top left, rgba(16, 185, 129, 0.14), transparent 28%),
linear-gradient(180deg, #f5fffb 0%, #fffdf8 100%);
}
.page-scroll {
height: 100vh;
//padding: 28rpx 24rpx 40rpx;
box-sizing: border-box;
}
.hero-card,
.filter-card,
.state-card,
.data-card {
border-radius: 30rpx;
box-shadow: 0 14rpx 34rpx rgba(15, 23, 42, 0.06);
}
.hero-card {
padding: 34rpx 36rpx;
border-radius: 28rpx;
background: rgba(255, 255, 255, 0.5);
box-shadow: 0 4rpx 12rpx 0 rgba(174, 174, 174, 0.4);
backdrop-filter: blur(24rpx);
-webkit-backdrop-filter: blur(24rpx);
border: 1rpx solid rgba(255, 255, 255, 0.5);
}
.hero-card--green .hero-badge {
background: rgba(20,150,242,.1);
}
.hero-badge {
display: inline-flex;
padding: 10rpx 18rpx;
border-radius: 999rpx;
font-size: 22rpx;
color: #1496f2;
}
.hero-title {
margin-top: 8rpx;
padding-left: 10rpx;
font-size: 30rpx;
line-height: 1.35;
font-weight: 700;
color: #18a0f7;
border-left: 5px solid #0f94ef;
}
.hero-desc {
margin-top: 20rpx;
font-size: 24rpx;
line-height: 1.8;
color: #7f7f7f;
}
.hero-meta {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16rpx;
margin-top: 26rpx;
}
.meta-item {
padding: 18rpx 16rpx;
border-radius: 22rpx;
background: rgba(255, 255, 255, 0.12);
}
.meta-value {
font-size: 32rpx;
font-weight: 700;
}
.meta-label {
margin-top: 6rpx;
font-size: 22rpx;
color: rgba(247, 255, 251, 0.84);
}
.filter-card {
margin-top: 22rpx;
padding: 24rpx;
background: rgba(255, 255, 255, 0.94);
}
.search-box,
.field-input,
.picker-trigger {
background: #f7fffb;
border: 1rpx solid rgba(16, 185, 129, 0.12);
}
.search-box {
display: flex;
align-items: center;
gap: 16rpx;
padding: 0 18rpx;
height: 84rpx;
border-radius: 999rpx;
}
:deep(.search-input.uv-input) {
flex: 1;
height: 100%;
padding: 0 !important;
background: transparent !important;
border: 0 !important;
}
:deep(.search-input.uv-input .uv-input__content__field-wrapper__field) {
font-size: 24rpx;
color: #1f2937;
}
.search-btn {
flex-shrink: 0;
font-size: 24rpx;
font-weight: 600;
color: #0f9f71;
}
.field-row,
.picker-row,
.action-row {
margin-top: 16rpx;
}
:deep(.field-input.uv-input) {
width: 100%;
height: 84rpx;
padding: 0 20rpx !important;
border-radius: 22rpx;
box-sizing: border-box;
background: #f7fffb !important;
border: 1rpx solid rgba(16, 185, 129, 0.12) !important;
}
:deep(.field-input.uv-input .uv-input__content__field-wrapper__field) {
font-size: 24rpx;
color: #1f2937;
}
.picker-row--single {
display: block;
}
.picker-item {
display: block;
}
.picker-trigger {
display: flex;
align-items: center;
justify-content: center;
height: 84rpx;
border-radius: 22rpx;
font-size: 24rpx;
color: #1f2937;
}
.action-row {
display: flex;
gap: 14rpx;
}
.action-btn {
flex: 1;
height: 82rpx;
border-radius: 22rpx;
background: #ecfdf5;
color: #0f9f71;
font-size: 24rpx;
font-weight: 600;
line-height: 82rpx;
text-align: center;
}
.action-btn--primary {
color: #fff;
}
.action-btn--green {
background: linear-gradient(135deg, #34d399, #10b981);
}
.state-card {
margin-top: 22rpx;
padding: 32rpx 28rpx;
background: rgba(255, 255, 255, 0.94);
}
.state-card--error {
background: rgba(247, 255, 251, 0.98);
border: 1rpx solid rgba(16, 185, 129, 0.16);
}
.state-title {
font-size: 30rpx;
font-weight: 700;
color: #1f2329;
}
.state-desc {
margin-top: 12rpx;
font-size: 24rpx;
line-height: 1.7;
color: #6c7480;
}
.state-action {
margin-top: 18rpx;
font-size: 24rpx;
font-weight: 600;
color: #2563eb;
}
.state-action--green {
color: #0f9f71;
}
.card-list {
display: flex;
flex-direction: column;
gap: 18rpx;
margin-top: 22rpx;
}
.data-card {
padding: 24rpx;
background: rgba(255, 255, 255, 0.95);
}
.card-top {
display: flex;
gap: 18rpx;
}
.avatar-cover {
flex-shrink: 0;
width: 144rpx;
height: 144rpx;
border-radius: 50%;
background-size: cover;
background-position: center;
display: flex;
align-items: center;
justify-content: center;
}
.avatar-text {
font-size: 34rpx;
font-weight: 700;
color: #fff;
}
.card-main {
flex: 1;
min-width: 0;
}
.card-title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12rpx;
}
.card-title {
font-size: 30rpx;
font-weight: 700;
line-height: 1.5;
color: #0f172a;
}
.card-school,
.card-project {
margin-top: 8rpx;
font-size: 24rpx;
line-height: 1.6;
color: #64748b;
}
.card-project {
color: #1f2937;
}
.tag-row {
display: flex;
flex-wrap: wrap;
gap: 10rpx;
margin-top: 14rpx;
}
.tag-chip {
padding: 8rpx 14rpx;
border-radius: 999rpx;
background: #fff4e6;
color: #c76b11;
font-size: 20rpx;
}
.tag-chip--green {
background: #e9fbf5;
color: #0f9f71;
}
.tag-chip--blue {
background: #eef5ff;
color: #2563eb;
}
.tag-chip--soft {
background: #f3f4f6;
color: #6b7280;
}
.info-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14rpx;
margin-top: 18rpx;
}
.info-item {
padding: 18rpx;
border-radius: 22rpx;
background: #f7fffb;
}
.info-label {
font-size: 20rpx;
color: #8b94a3;
}
.info-value {
margin-top: 8rpx;
font-size: 23rpx;
line-height: 1.6;
color: #1f2937;
word-break: break-all;
}
.card-footer {
display: flex;
justify-content: flex-end;
margin-top: 18rpx;
}
.footer-link {
font-size: 24rpx;
font-weight: 600;
}
.footer-link--green {
color: #0f9f71;
}
.load-more {
padding: 28rpx 0 12rpx;
text-align: center;
}
.load-more-text {
font-size: 22rpx;
color: #64748b;
}
.load-more-text--end {
color: #94a3b8;
}
</style>

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>

192
pages/gxmu/wxxzx.vue Normal file
View File

@@ -0,0 +1,192 @@
<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">GXMU · 未来学术之星</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>
<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.topicName || '未命名课题' }}</view><view class="card-subtitle">{{ item.collegeGradeClass || '-' }}</view></view>
<view class="card-year">{{ item.year || '-' }}</view>
</view>
<view class="meta-line">负责人{{ item.leader || '-' }}</view>
<view class="meta-line">联系电话{{ 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="form-section">
<view class="form-section-title">基本信息</view>
<view class="field-item"><view class="field-label">年份</view><uv-input class="field-input" :modelValue="current.year" border="none" @input="setField('year', $event)" /></view>
<view class="field-item"><view class="field-label">课题名称</view><uv-input class="field-input" :modelValue="current.topicName" border="none" @input="setField('topicName', $event)" /></view>
<view class="field-item"><view class="field-label">课题负责人</view><uv-input class="field-input" :modelValue="current.leader" border="none" @input="setField('leader', $event)" /></view>
<view class="field-item"><view class="field-label">学院年级班别</view><uv-input class="field-input" :modelValue="current.collegeGradeClass" border="none" @input="setField('collegeGradeClass', $event)" /></view>
<view class="field-item"><view class="field-label">联系电话</view><uv-input class="field-input" :modelValue="current.phone" border="none" @input="setField('phone', $event)" /></view>
<view class="field-item"><view class="field-label">指导老师</view><uv-input class="field-input" :modelValue="current.advisor" border="none" @input="setField('advisor', $event)" /></view>
<view class="field-item"><view class="field-label">课题类型</view><uv-input class="field-input" :modelValue="current.topicType" border="none" @input="setField('topicType', $event)" placeholder="自然科学 / 人文科学" /></view>
<view class="field-item"><view class="field-label">立论依据</view><textarea class="field-textarea" :value="current.rationale" @input="setField('rationale', $event)" /></view>
<!-- <view class="field-item"><view class="field-label">指导老师意见</view><textarea class="field-textarea" :value="current.teacherOpinion" @input="setField('teacherOpinion', $event)" /></view>-->
<!-- <view class="field-item"><view class="field-label">指导老师签名</view><uv-input class="field-input" :modelValue="current.teacherSign" border="none" @input="setField('teacherSign', $event)" /></view>-->
<!-- <view class="field-item"><view class="field-label">评审小组意见</view><textarea class="field-textarea" :value="current.reviewOpinion" @input="setField('reviewOpinion', $event)" /></view>-->
<!-- <view class="field-item"><view class="field-label">评审分数</view><uv-input class="field-input" :modelValue="current.reviewScore" border="none" @input="setField('reviewScore', $event)" /></view>-->
<!-- <view class="field-item"><view class="field-label">评审组长签名</view><uv-input class="field-input" :modelValue="current.reviewLeaderSign" border="none" @input="setField('reviewLeaderSign', $event)" /></view>-->
<!-- <view class="field-item"><view class="field-label">学院意见</view><textarea class="field-textarea" :value="current.collegeOpinion" @input="setField('collegeOpinion', $event)" /></view>-->
<!-- <view class="field-item"><view class="field-label">校团委意见</view><textarea class="field-textarea" :value="current.schoolOpinion" @input="setField('schoolOpinion', $event)" /></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>
</view>
</template>
<script>
import CommonHero from '../../components/common-hero/common-hero.vue'
import { addWxxzxForm, getWxxzxForm, updateWxxzxForm, userPageWxxzxForm } from './module-form-service'
const createDefaultForm = () => ({
id: undefined,
year: '',
topicName: '',
leader: '',
collegeGradeClass: '',
phone: '',
advisor: '',
topicType: '',
rationale: '',
teacherOpinion: '',
teacherSign: '',
reviewOpinion: '',
reviewScore: '',
reviewLeaderSign: '',
collegeOpinion: '',
schoolOpinion: '',
promiseSigner: '',
promiseSignature: '',
reviewCollege: '',
reviewReporter: '',
reviewDate: ''
})
export default {
components: {
CommonHero
},
data() {
return {
loading: false,
showEdit: false,
pageTitle: '未来学术之星申报',
fixedYear: '',
list: [],
current: createDefaultForm()
}
},
onLoad(options) {
this.fixedYear = String(options.year || '').trim()
this.pageTitle = decodeURIComponent(options.declareTitle || '未来学术之星申报').trim() || '未来学术之星申报'
this.loadData()
},
methods: {
async loadData() {
this.loading = true
try {
const result = await userPageWxxzxForm({
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.showEdit = true
},
async openEdit(item) {
try {
const detail = item && item.id ? await getWxxzxForm(item.id) : item
this.current = { ...createDefaultForm(), ...(detail || {}), year: (detail && detail.year) || this.fixedYear || '' }
this.showEdit = true
} catch (error) {
uni.showToast({ title: (error && error.message) || '加载详情失败', icon: 'none' })
}
},
closeEdit() { this.showEdit = false },
setField(key, event) {
const value = typeof event === 'string' ? event : (event.detail && event.detail.value) || ''
this.current[key] = value || ''
},
async save() {
try {
const payload = { ...this.current, year: this.fixedYear || this.current.year }
if (payload.id) {
await updateWxxzxForm(payload)
} else {
await addWxxzxForm(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; }
.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; }
.form-section-title { font-size: 28rpx; font-weight: 700; color: #1f2329; margin-bottom: 16rpx; }
.field-item + .field-item { margin-top: 16rpx; }
.field-label { margin-bottom: 10rpx; font-size: 24rpx; color: #5f6b7a; }
.field-textarea { width: 100%; box-sizing: border-box; border-radius: 20rpx; border: 1rpx solid #dce4ec; background: #fff; padding: 20rpx; font-size: 24rpx; color: #1f2329; }
: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; }
.field-textarea { min-height: 180rpx; }
.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>