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

View File

@@ -0,0 +1,881 @@
<template>
<view class="page">
<scroll-view scroll-y class="page-scroll">
<view class="hero-card">
<view class="hero-kicker">AI EVENT PLANNING OUTLINE</view>
<view class="hero-title">活动策划大纲生成</view>
<view class="hero-desc">
输入活动主题受众时间和资金等信息系统会调用 `/ai/activityOutlineGen`
生成结构化活动策划大纲适合方案初稿汇报材料和执行拆解场景
</view>
<view class="hero-actions">
<view class="hero-action" @tap="fillExample('volunteer')">志愿服务</view>
<view class="hero-action" @tap="fillExample('salon')">主题沙龙</view>
<view class="hero-action" @tap="fillExample('sports')">校园赛事</view>
</view>
</view>
<view class="panel-card">
<view class="panel-title">活动信息</view>
<view class="field-list">
<view class="field-item">
<view class="field-label">活动主题</view>
<uv-input
v-model="form.event_theme"
class="field-input"
placeholder="例如:青春志愿行 绿美校园环保行动"
placeholder-style="color: #c0c4cc;"
:maxlength="-1"
/>
</view>
<view class="field-item">
<view class="field-label">面向受众</view>
<uv-input
v-model="form.people"
class="field-input"
placeholder="例如:全校团员青年、学生骨干、青年志愿者"
placeholder-style="color: #c0c4cc;"
:maxlength="-1"
/>
</view>
<view class="field-item">
<view class="field-label">活动时间</view>
<uv-input
v-model="form.event_time"
class="field-input"
placeholder="例如2026年4月18日 14:30-17:30"
placeholder-style="color: #c0c4cc;"
:maxlength="-1"
/>
</view>
<view class="field-item">
<view class="field-label">活动资金</view>
<uv-input
v-model="form.event_funding"
class="field-input"
placeholder="例如:预算 5000 元,来源为校团委专项经费"
placeholder-style="color: #c0c4cc;"
:maxlength="-1"
/>
</view>
<view class="field-item">
<view class="field-label">活动形式</view>
<uv-input
v-model="form.event_type"
class="field-input"
placeholder="例如:专题讲座 + 分组互动 + 现场展示"
placeholder-style="color: #c0c4cc;"
:maxlength="-1"
/>
</view>
<view class="field-item">
<view class="field-label">活动地点</view>
<uv-input
v-model="form.event_location"
class="field-input"
placeholder="例如:大学生活动中心一楼报告厅"
placeholder-style="color: #c0c4cc;"
:maxlength="-1"
/>
</view>
<view class="field-item">
<view class="field-label">备注</view>
<textarea
v-model="form.event_meme"
class="field-textarea"
placeholder="补充活动背景、目标、关键限制、预期成果或希望体现的亮点"
placeholder-style="color: #c0c4cc;"
maxlength="-1"
/>
</view>
</view>
<view class="submit-bar">
<view class="ghost-btn" @tap="resetForm">重置</view>
<view class="primary-btn" :class="{ 'primary-btn--disabled': !canSubmit || loading }" @tap="handleGenerate">
{{ loading ? '生成中' : '生成大纲' }}
</view>
</view>
<view class="hint-block">
<view class="hint-title">提交说明</view>
<view class="hint-text">
必填字段会直接作为 `inputs` 透传给 AI后端固定触发活动策划大纲生成指令并返回整理后的正文结果
</view>
</view>
</view>
<view class="panel-card result-card">
<view class="result-head">
<view class="panel-title">生成结果</view>
<view class="result-status" :class="loading ? 'result-status--loading' : hasOutlineContent ? 'result-status--done' : ''">
{{ loading ? '生成中' : hasOutlineContent ? '已生成' : '待生成' }}
</view>
</view>
<view v-if="loading" class="loading-panel">
<view class="loading-kicker">OUTLINE GENERATING</view>
<view class="loading-title">AI 正在整理活动策划结构</view>
<view class="loading-desc">
正在综合活动主题受众时间与经费信息生成可直接二次编辑的策划大纲
</view>
</view>
<view v-if="showThinkingIndicator" class="outline-thinking">
<view class="thinking-dot"></view>
<text>AI 正在生成正文内容请稍候...</text>
</view>
<view v-if="resultSummary" class="result-alert">
{{ resultSummary }}
</view>
<view v-if="hasOutlineContent || loading" class="field-item">
<view class="field-label">策划大纲</view>
<textarea
v-model="editableOutlineMarkdown"
class="result-textarea"
placeholder="填写左侧信息后开始生成活动策划大纲"
placeholder-style="color: #c0c4cc;"
maxlength="-1"
:disabled="loading"
/>
</view>
<view v-else class="result-empty">
填写左侧信息后开始生成活动策划大纲
</view>
<view class="result-actions">
<view class="result-btn" @tap="copyOutline">复制内容</view>
<view class="result-btn" @tap="clearResult">清空结果</view>
</view>
</view>
</scroll-view>
</view>
</template>
<script>
import {
activityOutlineGen,
buildWebSocketUrl,
getCurrentUser
} from './service'
const STREAM_END_TEXT = '__END__'
const SOCKET_CONNECTED_TEXT = '连接成功'
const THINK_OPEN_TAG = '<think>'
const THINK_CLOSE_TAG = '</think>'
const createDefaultForm = () => ({
event_theme: '',
people: '',
event_time: '',
event_funding: '',
event_type: '',
event_location: '',
event_meme: ''
})
export default {
data() {
return {
form: createDefaultForm(),
loading: false,
result: null,
outlineMarkdown: '',
editableOutlineMarkdown: '',
resultSummary: '',
activeConversationId: '',
activeMessageId: '',
socketTask: null,
socketConnectPromise: null,
streamStarted: false,
streamResolver: null,
currentUser: {
userId: null
}
}
},
computed: {
canSubmit() {
return !!(
String(this.form.event_theme || '').trim() &&
String(this.form.people || '').trim() &&
String(this.form.event_time || '').trim() &&
String(this.form.event_funding || '').trim()
)
},
parsedOutlineContent() {
return this.parseAssistantContent(this.outlineMarkdown)
},
visibleOutlineMarkdown() {
return this.parsedOutlineContent.content
},
hasOutlineContent() {
return !!String(this.editableOutlineMarkdown || '').trim()
},
showThinkingIndicator() {
return this.loading && this.parsedOutlineContent.thinking
}
},
onUnload() {
this.clearStreamResolver()
this.disconnectWebSocket()
},
methods: {
showToast(title) {
uni.showToast({
title,
icon: 'none'
})
},
parseAssistantContent(value) {
if (!value) {
return {
content: '',
thinking: false
}
}
let cursor = 0
let content = ''
let thinking = false
while (cursor < value.length) {
if (!thinking) {
const openIndex = value.indexOf(THINK_OPEN_TAG, cursor)
const closeIndex = value.indexOf(THINK_CLOSE_TAG, cursor)
if (closeIndex !== -1 && (openIndex === -1 || closeIndex < openIndex)) {
cursor = closeIndex + THINK_CLOSE_TAG.length
continue
}
if (openIndex === -1) {
content += value.slice(cursor)
break
}
content += value.slice(cursor, openIndex)
cursor = openIndex + THINK_OPEN_TAG.length
thinking = true
continue
}
const closeIndex = value.indexOf(THINK_CLOSE_TAG, cursor)
if (closeIndex === -1) {
break
}
cursor = closeIndex + THINK_CLOSE_TAG.length
thinking = false
}
return {
content,
thinking
}
},
syncEditableOutline() {
this.editableOutlineMarkdown = this.visibleOutlineMarkdown
},
buildPayload() {
return {
event_theme: String(this.form.event_theme || '').trim(),
people: String(this.form.people || '').trim(),
event_time: String(this.form.event_time || '').trim(),
event_funding: String(this.form.event_funding || '').trim(),
event_type: String(this.form.event_type || '').trim(),
event_location: String(this.form.event_location || '').trim(),
event_meme: String(this.form.event_meme || '').trim()
}
},
pickNonEmptyString() {
for (let index = 0; index < arguments.length; index += 1) {
const value = arguments[index]
if (typeof value === 'string' && value.length > 0) {
return value
}
}
return ''
},
pickRawString() {
for (let index = 0; index < arguments.length; index += 1) {
const value = arguments[index]
if (typeof value === 'string') {
return value
}
}
return undefined
},
stringifyUnknown(value) {
try {
return JSON.stringify(value, null, 2)
} catch (error) {
return String(value || '')
}
},
parseOutline(payload) {
if (!payload) {
return ''
}
const answer = this.pickNonEmptyString(
payload.answer,
payload.raw && payload.raw.answer,
payload.raw && payload.raw.data && payload.raw.data.answer,
payload.raw && payload.raw.output,
payload.raw && payload.raw.result,
payload.raw && payload.raw.content,
payload.raw && payload.raw.message
)
if (answer) {
return answer
}
return this.stringifyUnknown((payload && payload.raw) || payload)
},
buildSummary() {
const parts = []
if (this.activeConversationId) {
parts.push(`conversationId: ${this.activeConversationId}`)
}
if (this.activeMessageId) {
parts.push(`messageId: ${this.activeMessageId}`)
}
return parts.join(' | ')
},
clearStreamResolver() {
this.streamResolver = null
},
resolveStream() {
if (this.streamResolver && this.streamResolver.resolve) {
this.streamResolver.resolve()
}
this.clearStreamResolver()
},
rejectStream(error) {
if (this.streamResolver && this.streamResolver.reject) {
this.streamResolver.reject(error)
}
this.clearStreamResolver()
},
createStreamWaiter() {
this.streamStarted = false
return new Promise((resolve, reject) => {
this.streamResolver = {
resolve,
reject
}
})
},
disconnectWebSocket() {
this.socketConnectPromise = null
const socketTask = this.socketTask
this.socketTask = null
if (socketTask && typeof socketTask.close === 'function') {
try {
socketTask.close({})
} catch (error) {
console.warn('socket close failed', error)
}
}
},
appendOutlineChunk(chunk, conversationId, messageId) {
if (!chunk) {
return
}
if (conversationId && conversationId !== '0') {
this.activeConversationId = conversationId
}
if (messageId && messageId !== '0') {
this.activeMessageId = messageId
}
if (!this.streamStarted) {
this.streamStarted = true
this.outlineMarkdown = ''
}
this.outlineMarkdown += chunk
this.syncEditableOutline()
this.resultSummary = this.buildSummary() || '正在通过 WebSocket 接收流式内容'
},
finishStream(conversationId, messageId) {
if (conversationId && conversationId !== '0') {
this.activeConversationId = conversationId
}
if (messageId && messageId !== '0') {
this.activeMessageId = messageId
}
this.resultSummary = this.buildSummary() || '已通过 WebSocket 接收生成结果'
this.loading = false
this.resolveStream()
this.disconnectWebSocket()
},
handleSocketMessage(raw) {
if (!raw) {
return
}
try {
const data = JSON.parse(raw)
if (data && data.answer === STREAM_END_TEXT) {
this.finishStream(data.conversationId, data.messageId)
return
}
if (data && (data.loading === 'true' || data.loading === true)) {
this.resultSummary = 'AI 正在组织活动策划大纲结构...'
return
}
const chunk = this.pickRawString(data && data.notice, data && data.answer)
if (typeof chunk === 'string') {
this.appendOutlineChunk(chunk, data && data.conversationId, data && data.messageId)
}
} catch (error) {
this.appendOutlineChunk(raw)
}
},
refreshUser() {
this.currentUser = getCurrentUser()
return this.currentUser
},
connectWebSocket() {
const user = this.refreshUser()
if (!user.userId) {
return Promise.reject(new Error('未获取到当前登录用户'))
}
const currentSocket = this.socketTask
if (currentSocket && this.socketConnectPromise) {
return this.socketConnectPromise
}
if (currentSocket) {
this.disconnectWebSocket()
}
let cleanupPending = () => undefined
const connectPromise = new Promise((resolve, reject) => {
let settled = false
const socketTask = uni.connectSocket({
url: buildWebSocketUrl(user.userId),
complete: () => {}
})
cleanupPending = () => {
if (this.socketConnectPromise === connectPromise) {
this.socketConnectPromise = null
}
}
socketTask.onMessage((event) => {
const raw = String((event && event.data) || '')
if (raw === SOCKET_CONNECTED_TEXT) {
if (!settled) {
settled = true
cleanupPending()
resolve()
}
return
}
this.handleSocketMessage(raw)
})
socketTask.onClose(() => {
if (this.socketTask === socketTask) {
this.socketTask = null
}
if (!settled) {
settled = true
cleanupPending()
reject(new Error('活动策划大纲连接已关闭'))
return
}
if (this.loading) {
this.rejectStream(new Error('活动策划大纲连接已关闭'))
this.loading = false
}
cleanupPending()
})
socketTask.onError(() => {
if (!settled) {
settled = true
cleanupPending()
reject(new Error('活动策划大纲连接失败'))
return
}
if (this.loading) {
this.rejectStream(new Error('活动策划大纲连接失败'))
this.loading = false
}
})
this.socketTask = socketTask
})
this.socketConnectPromise = connectPromise
return connectPromise
},
clearResult() {
this.result = null
this.outlineMarkdown = ''
this.editableOutlineMarkdown = ''
this.resultSummary = ''
this.activeConversationId = ''
this.activeMessageId = ''
},
resetForm() {
this.form = createDefaultForm()
this.clearResult()
},
async handleGenerate() {
if (this.loading) {
return
}
if (!this.canSubmit) {
this.showToast('请先填写全部必填项')
return
}
this.loading = true
this.clearResult()
this.resultSummary = '正在建立 WebSocket 连接...'
try {
await this.connectWebSocket()
this.resultSummary = '连接成功,已提交生成任务,等待流式返回...'
const waitStream = this.createStreamWaiter()
const data = await activityOutlineGen(this.buildPayload())
this.result = data || null
if (!this.streamStarted && data && this.parseOutline(data)) {
this.outlineMarkdown = this.parseOutline(data)
this.syncEditableOutline()
this.resultSummary = this.buildSummary() || '接口直接返回生成结果'
this.loading = false
this.resolveStream()
this.disconnectWebSocket()
} else {
await waitStream
}
if (!this.visibleOutlineMarkdown) {
this.showToast('接口已返回,但未解析到正文内容')
return
}
this.showToast('活动策划大纲生成完成')
} catch (error) {
this.disconnectWebSocket()
this.clearStreamResolver()
this.loading = false
this.showToast((error && error.message) || '生成失败')
}
},
copyOutline() {
if (!this.editableOutlineMarkdown) {
this.showToast('暂无可复制内容')
return
}
uni.setClipboardData({
data: this.editableOutlineMarkdown,
success: () => {
this.showToast('内容已复制')
}
})
},
fillExample(type) {
if (type === 'volunteer') {
this.form.event_theme = '青春志愿行 绿美校园环保行动'
this.form.people = '全校团员青年、学生骨干、青年志愿者'
this.form.event_time = '2026年4月18日 14:30-17:30'
this.form.event_funding = '预算 5000 元,来源为校团委专项经费'
this.form.event_type = '志愿服务 + 分组清洁 + 环保倡议'
this.form.event_location = '校园主干道、教学楼周边和青年林'
this.form.event_meme = '突出劳动教育、志愿服务精神和绿色校园建设,生成含活动背景、流程、分工、宣传和风险预案的大纲。'
return
}
if (type === 'salon') {
this.form.event_theme = '青春思享汇 新时代青年成长主题沙龙'
this.form.people = '学生干部、团支部书记、青年学生代表'
this.form.event_time = '2026年5月10日 19:00-21:00'
this.form.event_funding = '预算 3000 元,包含物料、海报和场地布置'
this.form.event_type = '主题分享 + 圆桌讨论 + 互动提问'
this.form.event_location = '大学生活动中心多功能厅'
this.form.event_meme = '希望突出思想引领、青年交流和成果展示,生成适合汇报审批的结构化活动方案。'
return
}
this.form.event_theme = '青春杯校园三人篮球赛'
this.form.people = '各学院学生代表队、体育类社团、观赛学生'
this.form.event_time = '2026年4月下旬周末全天'
this.form.event_funding = '预算 8000 元,含裁判、奖品、宣传和后勤保障'
this.form.event_type = '校园赛事 + 开幕仪式 + 分组淘汰赛'
this.form.event_location = '学校室外篮球场'
this.form.event_meme = '需要体现赛事组织、赛程安排、安全保障、医疗预案和宣传报道计划。'
}
}
}
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
background:
radial-gradient(circle at top right, rgba(51, 97, 164, 0.14), transparent 26%),
linear-gradient(180deg, #f7f9fc 0%, #f2f4f8 100%);
}
.page-scroll {
height: 100vh;
padding: 28rpx 24rpx 40rpx;
}
.hero-card {
padding: 34rpx 30rpx;
border-radius: 32rpx;
background: linear-gradient(135deg, #16315f 0%, #26508e 52%, #4b79bb 100%);
box-shadow: 0 18rpx 44rpx rgba(33, 67, 121, 0.18);
color: #eef5ff;
}
.hero-kicker {
font-size: 22rpx;
letter-spacing: 4rpx;
color: rgba(238, 245, 255, 0.72);
}
.hero-title {
margin-top: 18rpx;
font-size: 42rpx;
line-height: 1.35;
font-weight: 700;
}
.hero-desc {
margin-top: 16rpx;
font-size: 24rpx;
line-height: 1.8;
color: rgba(238, 245, 255, 0.88);
}
.hero-actions {
display: flex;
flex-wrap: wrap;
gap: 14rpx;
margin-top: 26rpx;
}
.hero-action {
padding: 12rpx 20rpx;
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.12);
font-size: 22rpx;
color: #f4f8ff;
}
.panel-card {
margin-top: 24rpx;
padding: 26rpx;
border-radius: 30rpx;
background: rgba(255, 255, 255, 0.96);
border: 1rpx solid rgba(51, 97, 164, 0.08);
box-shadow: 0 10rpx 28rpx rgba(61, 73, 95, 0.08);
}
.panel-title {
font-size: 30rpx;
font-weight: 700;
color: #1f2329;
}
.field-list {
display: flex;
flex-direction: column;
gap: 22rpx;
margin-top: 20rpx;
}
.field-item {
width: 100%;
}
.field-label {
margin-bottom: 12rpx;
font-size: 25rpx;
font-weight: 600;
color: #2b3037;
}
.field-textarea {
width: 100%;
min-height: 220rpx;
padding: 22rpx 24rpx;
border-radius: 22rpx;
background: #f6f8fb;
border: 1rpx solid #dde4ef;
font-size: 24rpx;
line-height: 1.8;
color: #2a2e35;
box-sizing: border-box;
}
: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;
}
:deep(.field-input.uv-input .uv-input__content__field-wrapper__field) {
height: 88rpx;
min-height: 88rpx;
font-size: 28rpx;
line-height: 88rpx;
color: #303133;
}
.submit-bar,
.result-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18rpx;
margin-top: 24rpx;
}
.ghost-btn,
.primary-btn,
.result-btn {
height: 88rpx;
line-height: 88rpx;
border-radius: 22rpx;
font-size: 28rpx;
font-weight: 700;
text-align: center;
}
.ghost-btn {
background: #f6f7fa;
color: #50617a;
border: 1rpx solid #d8dfeb;
}
.primary-btn {
background: linear-gradient(135deg, #1f4f8f 0%, #3f70b5 100%);
color: #fff;
}
.primary-btn--disabled {
opacity: 0.6;
}
.result-btn {
background: #eef4fb;
color: #24508f;
}
.hint-block,
.result-alert {
margin-top: 22rpx;
padding: 22rpx 24rpx;
border-radius: 24rpx;
background: #f5f8fd;
border: 1rpx solid rgba(51, 97, 164, 0.08);
font-size: 22rpx;
line-height: 1.75;
color: #6d7d93;
}
.hint-title {
font-size: 24rpx;
font-weight: 700;
color: #35527d;
}
.hint-text {
margin-top: 10rpx;
}
.result-card {
margin-bottom: 20rpx;
}
.result-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
}
.result-status {
padding: 10rpx 16rpx;
border-radius: 999rpx;
background: #eef1f5;
color: #7e8792;
font-size: 20rpx;
font-weight: 600;
}
.result-status--loading {
background: #edf5ff;
color: #1554ad;
}
.result-status--done {
background: #eef9f1;
color: #1f8f49;
}
.loading-panel {
margin-top: 22rpx;
padding: 26rpx;
border-radius: 26rpx;
background: linear-gradient(135deg, #f5f9ff 0%, #eef4fb 100%);
}
.loading-kicker {
font-size: 20rpx;
letter-spacing: 3rpx;
color: #6682aa;
}
.loading-title {
margin-top: 10rpx;
font-size: 30rpx;
font-weight: 700;
color: #23446f;
}
.loading-desc {
margin-top: 12rpx;
font-size: 22rpx;
line-height: 1.75;
color: #6d7d93;
}
.outline-thinking {
display: flex;
align-items: center;
gap: 12rpx;
margin-top: 20rpx;
font-size: 22rpx;
color: #35527d;
}
.thinking-dot {
width: 16rpx;
height: 16rpx;
border-radius: 50%;
background: #4b79bb;
}
.result-textarea {
width: 100%;
min-height: 560rpx;
padding: 24rpx;
border-radius: 24rpx;
background: #f8fafc;
border: 1rpx solid #e1e7f0;
font-size: 24rpx;
line-height: 1.85;
color: #2b3037;
box-sizing: border-box;
}
.result-empty {
margin-top: 22rpx;
padding: 32rpx 24rpx;
border-radius: 24rpx;
background: #f8fafc;
font-size: 24rpx;
line-height: 1.75;
color: #8a94a0;
text-align: center;
}
</style>

View File

@@ -0,0 +1,17 @@
import {
API_BASE_URL,
apiRequest,
buildWebSocketUrl,
getCurrentUser
} from '../assistant/chat-service'
export { buildWebSocketUrl, getCurrentUser }
export function activityOutlineGen(data) {
return apiRequest({
url: `${API_BASE_URL}/ai/activityOutlineGen`,
method: 'POST',
data,
withTenant: true
})
}

View File

@@ -0,0 +1,447 @@
import {
API_BASE_URL,
DEFAULT_TENANT_ID,
TOKEN_HEADER_NAME,
clearAuthStorage,
getStoredUserInfo,
getToken,
redirectToLogin
} from '../../utils/request'
export { API_BASE_URL, getToken }
const MODULES_API_URL = API_BASE_URL
const APP_SECRET = 'ffd6eee985af45e4a75098422d1decbb'
const AI_REQUEST_TIMEOUT = 10 * 60 * 1000
let realAtob
const b64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='
const b64re = /^(?:[A-Za-z\d+/]{4})*?(?:[A-Za-z\d+/]{2}(?:==)?|[A-Za-z\d+/]{3}=?)?$/
if (typeof atob !== 'function') {
realAtob = function(str) {
let input = String(str || '').replace(/[\t\n\f\r ]+/g, '')
if (!b64re.test(input)) {
throw new Error("Failed to execute 'atob': The string to be decoded is not correctly encoded.")
}
input += '=='.slice(2 - (input.length & 3))
let bitmap
let result = ''
let r1
let r2
let index = 0
for (; index < input.length;) {
bitmap =
(b64.indexOf(input.charAt(index++)) << 18) |
(b64.indexOf(input.charAt(index++)) << 12) |
((r1 = b64.indexOf(input.charAt(index++))) << 6) |
(r2 = b64.indexOf(input.charAt(index++)))
result +=
r1 === 64
? String.fromCharCode((bitmap >> 16) & 255)
: r2 === 64
? String.fromCharCode((bitmap >> 16) & 255, (bitmap >> 8) & 255)
: String.fromCharCode((bitmap >> 16) & 255, (bitmap >> 8) & 255, bitmap & 255)
}
return result
}
} else {
realAtob = atob
}
function b64DecodeUnicode(str) {
return decodeURIComponent(
realAtob(str)
.split('')
.map((char) => `%${`00${char.charCodeAt(0).toString(16)}`.slice(-2)}`)
.join('')
)
}
function parseTokenPayload(token) {
const rawToken = String(token || '').replace(/^Bearer\s+/i, '')
const parts = rawToken.split('.')
if (!rawToken || parts.length !== 3) {
return null
}
try {
const normalized = parts[1].replace(/-/g, '+').replace(/_/g, '/')
return JSON.parse(b64DecodeUnicode(normalized))
} catch (error) {
return null
}
}
function safeAdd(x, y) {
const lsw = (x & 0xffff) + (y & 0xffff)
const msw = (x >> 16) + (y >> 16) + (lsw >> 16)
return (msw << 16) | (lsw & 0xffff)
}
function bitRotateLeft(num, cnt) {
return (num << cnt) | (num >>> (32 - cnt))
}
function md5cmn(q, a, b, x, s, t) {
return safeAdd(bitRotateLeft(safeAdd(safeAdd(a, q), safeAdd(x, t)), s), b)
}
function md5ff(a, b, c, d, x, s, t) {
return md5cmn((b & c) | (~b & d), a, b, x, s, t)
}
function md5gg(a, b, c, d, x, s, t) {
return md5cmn((b & d) | (c & ~d), a, b, x, s, t)
}
function md5hh(a, b, c, d, x, s, t) {
return md5cmn(b ^ c ^ d, a, b, x, s, t)
}
function md5ii(a, b, c, d, x, s, t) {
return md5cmn(c ^ (b | ~d), a, b, x, s, t)
}
function binlMD5(x, len) {
x[len >> 5] |= 0x80 << len % 32
x[(((len + 64) >>> 9) << 4) + 14] = len
let i
let olda
let oldb
let oldc
let oldd
let a = 1732584193
let b = -271733879
let c = -1732584194
let d = 271733878
for (i = 0; i < x.length; i += 16) {
olda = a
oldb = b
oldc = c
oldd = d
a = md5ff(a, b, c, d, x[i], 7, -680876936)
d = md5ff(d, a, b, c, x[i + 1], 12, -389564586)
c = md5ff(c, d, a, b, x[i + 2], 17, 606105819)
b = md5ff(b, c, d, a, x[i + 3], 22, -1044525330)
a = md5ff(a, b, c, d, x[i + 4], 7, -176418897)
d = md5ff(d, a, b, c, x[i + 5], 12, 1200080426)
c = md5ff(c, d, a, b, x[i + 6], 17, -1473231341)
b = md5ff(b, c, d, a, x[i + 7], 22, -45705983)
a = md5ff(a, b, c, d, x[i + 8], 7, 1770035416)
d = md5ff(d, a, b, c, x[i + 9], 12, -1958414417)
c = md5ff(c, d, a, b, x[i + 10], 17, -42063)
b = md5ff(b, c, d, a, x[i + 11], 22, -1990404162)
a = md5ff(a, b, c, d, x[i + 12], 7, 1804603682)
d = md5ff(d, a, b, c, x[i + 13], 12, -40341101)
c = md5ff(c, d, a, b, x[i + 14], 17, -1502002290)
b = md5ff(b, c, d, a, x[i + 15], 22, 1236535329)
a = md5gg(a, b, c, d, x[i + 1], 5, -165796510)
d = md5gg(d, a, b, c, x[i + 6], 9, -1069501632)
c = md5gg(c, d, a, b, x[i + 11], 14, 643717713)
b = md5gg(b, c, d, a, x[i], 20, -373897302)
a = md5gg(a, b, c, d, x[i + 5], 5, -701558691)
d = md5gg(d, a, b, c, x[i + 10], 9, 38016083)
c = md5gg(c, d, a, b, x[i + 15], 14, -660478335)
b = md5gg(b, c, d, a, x[i + 4], 20, -405537848)
a = md5gg(a, b, c, d, x[i + 9], 5, 568446438)
d = md5gg(d, a, b, c, x[i + 14], 9, -1019803690)
c = md5gg(c, d, a, b, x[i + 3], 14, -187363961)
b = md5gg(b, c, d, a, x[i + 8], 20, 1163531501)
a = md5gg(a, b, c, d, x[i + 13], 5, -1444681467)
d = md5gg(d, a, b, c, x[i + 2], 9, -51403784)
c = md5gg(c, d, a, b, x[i + 7], 14, 1735328473)
b = md5gg(b, c, d, a, x[i + 12], 20, -1926607734)
a = md5hh(a, b, c, d, x[i + 5], 4, -378558)
d = md5hh(d, a, b, c, x[i + 8], 11, -2022574463)
c = md5hh(c, d, a, b, x[i + 11], 16, 1839030562)
b = md5hh(b, c, d, a, x[i + 14], 23, -35309556)
a = md5hh(a, b, c, d, x[i + 1], 4, -1530992060)
d = md5hh(d, a, b, c, x[i + 4], 11, 1272893353)
c = md5hh(c, d, a, b, x[i + 7], 16, -155497632)
b = md5hh(b, c, d, a, x[i + 10], 23, -1094730640)
a = md5hh(a, b, c, d, x[i + 13], 4, 681279174)
d = md5hh(d, a, b, c, x[i], 11, -358537222)
c = md5hh(c, d, a, b, x[i + 3], 16, -722521979)
b = md5hh(b, c, d, a, x[i + 6], 23, 76029189)
a = md5hh(a, b, c, d, x[i + 9], 4, -640364487)
d = md5hh(d, a, b, c, x[i + 12], 11, -421815835)
c = md5hh(c, d, a, b, x[i + 15], 16, 530742520)
b = md5hh(b, c, d, a, x[i + 2], 23, -995338651)
a = md5ii(a, b, c, d, x[i], 6, -198630844)
d = md5ii(d, a, b, c, x[i + 7], 10, 1126891415)
c = md5ii(c, d, a, b, x[i + 14], 15, -1416354905)
b = md5ii(b, c, d, a, x[i + 5], 21, -57434055)
a = md5ii(a, b, c, d, x[i + 12], 6, 1700485571)
d = md5ii(d, a, b, c, x[i + 3], 10, -1894986606)
c = md5ii(c, d, a, b, x[i + 10], 15, -1051523)
b = md5ii(b, c, d, a, x[i + 1], 21, -2054922799)
a = md5ii(a, b, c, d, x[i + 8], 6, 1873313359)
d = md5ii(d, a, b, c, x[i + 15], 10, -30611744)
c = md5ii(c, d, a, b, x[i + 6], 15, -1560198380)
b = md5ii(b, c, d, a, x[i + 13], 21, 1309151649)
a = md5ii(a, b, c, d, x[i + 4], 6, -145523070)
d = md5ii(d, a, b, c, x[i + 11], 10, -1120210379)
c = md5ii(c, d, a, b, x[i + 2], 15, 718787259)
b = md5ii(b, c, d, a, x[i + 9], 21, -343485551)
a = safeAdd(a, olda)
b = safeAdd(b, oldb)
c = safeAdd(c, oldc)
d = safeAdd(d, oldd)
}
return [a, b, c, d]
}
function binl2rstr(input) {
let index
let output = ''
const length32 = input.length * 32
for (index = 0; index < length32; index += 8) {
output += String.fromCharCode((input[index >> 5] >>> index % 32) & 0xff)
}
return output
}
function rstr2binl(input) {
const output = Array(input.length >> 2)
let index
for (index = 0; index < output.length; index += 1) {
output[index] = 0
}
const length8 = input.length * 8
for (index = 0; index < length8; index += 8) {
output[index >> 5] |= (input.charCodeAt(index / 8) & 0xff) << index % 32
}
return output
}
function rstrMD5(value) {
return binl2rstr(binlMD5(rstr2binl(value), value.length * 8))
}
function rstr2hex(input) {
const hexTab = '0123456789abcdef'
let output = ''
let index
let value
for (index = 0; index < input.length; index += 1) {
value = input.charCodeAt(index)
output += hexTab.charAt((value >>> 4) & 0x0f) + hexTab.charAt(value & 0x0f)
}
return output
}
function str2rstrUTF8(input) {
return unescape(encodeURIComponent(input))
}
function md5(value) {
return rstr2hex(rstrMD5(str2rstrUTF8(String(value || ''))))
}
function objKeySort(obj) {
const next = {}
Object.keys(obj || {})
.sort()
.forEach((key) => {
next[key] = obj[key]
})
return next
}
function addSignature(payload) {
const form = payload || {}
form.timestamp = Date.now()
form.version = 'v3'
let sign = ''
const sorted = objKeySort(form)
Object.keys(sorted).forEach((key) => {
const value = form[key]
if (value !== null && value !== undefined && value !== '') {
sign = `${sign}${value}-`
}
})
form.sign = md5(`${sign}${APP_SECRET}`)
return form
}
function getStorageValue(keys) {
for (let index = 0; index < keys.length; index += 1) {
const value = uni.getStorageSync(keys[index])
if (value) {
return value
}
}
return ''
}
export function getCurrentUser() {
const primaryToken = getToken()
const storedUser = getStoredUserInfo()
const tokenList = [
primaryToken,
uni.getStorageSync('uni_id_token'),
uni.getStorageSync('access_token')
].filter(Boolean)
let payload = null
for (let index = 0; index < tokenList.length; index += 1) {
payload = parseTokenPayload(tokenList[index])
if (payload) {
break
}
}
if (!payload) {
return {
...storedUser,
token: primaryToken || ''
}
}
return {
...storedUser,
userId: Number(payload.userId || payload.uid || payload.id || 0) || null,
nickname: payload.nickname || payload.username || payload.nickName || '',
realName: payload.realName || payload.realname || payload.name || '',
token: primaryToken || ''
}
}
export function apiRequest({
url,
method = 'GET',
data,
withSignature = true,
withTenant = false,
timeout
}) {
const token = getToken()
const requestData = data ? { ...data } : undefined
const headers = {}
if (token) {
headers[TOKEN_HEADER_NAME] = token
}
if (requestData && withTenant) {
requestData.tenantId = DEFAULT_TENANT_ID
}
if (requestData && withSignature) {
addSignature(requestData)
}
return uni.request({
url,
method,
data: requestData,
header: headers,
...(timeout !== undefined && { timeout })
}).then((response) => {
const responseHeader = (response && response.header) || {}
const nextToken =
responseHeader[TOKEN_HEADER_NAME] ||
responseHeader[TOKEN_HEADER_NAME.toLowerCase()]
if (nextToken) {
uni.setStorageSync('access_token', nextToken)
}
const { statusCode } = response || {}
const body = (response && response.data) || {}
if (statusCode && statusCode >= 400) {
return Promise.reject(new Error(body.message || '请求失败'))
}
if (Number(body.code) === 401) {
clearAuthStorage()
redirectToLogin()
return Promise.reject(new Error(body.message || '登录已失效'))
}
if (body.code === 0) {
return body.data !== undefined ? body.data : body.message
}
return Promise.reject(new Error(body.message || '请求失败'))
})
}
export function listAiChatList(params) {
return apiRequest({
url: `${MODULES_API_URL}/ai/ai-chat-list`,
method: 'GET',
data: params
})
}
export function listAiChatHistory(params) {
return apiRequest({
url: `${MODULES_API_URL}/ai/ai-chat-history`,
method: 'GET',
data: params
})
}
export function removeAiChatList(id) {
return apiRequest({
url: `${MODULES_API_URL}/ai/ai-chat-list/${id}`,
method: 'DELETE',
withSignature: false
})
}
export function sendMessage(data) {
return apiRequest({
url: `${API_BASE_URL}/ai/chat/message`,
method: 'POST',
data,
withTenant: true,
timeout: AI_REQUEST_TIMEOUT
})
}
export function manuscriptGen(data) {
return apiRequest({
url: `${API_BASE_URL}/ai/manuscriptGen`,
method: 'POST',
data,
withTenant: true,
timeout: AI_REQUEST_TIMEOUT
})
}
export function creativeAssistant(data) {
return apiRequest({
url: `${API_BASE_URL}/ai/creativeAssistant`,
method: 'POST',
data,
withTenant: true,
timeout: AI_REQUEST_TIMEOUT
})
}
export function activityOutlineGen(data) {
return apiRequest({
url: `${API_BASE_URL}/ai/activityOutlineGen`,
method: 'POST',
data,
withTenant: true,
timeout: AI_REQUEST_TIMEOUT
})
}
export function buildWebSocketUrl(userId) {
const url = String(API_BASE_URL || '')
if (/^https?:\/\//.test(url)) {
return `${url
.replace(/^https:/, 'wss:')
.replace(/^http:/, 'ws:')
.replace(/\/api\/?$/, '')
.replace(/\/$/, '')}/chat/${userId}`
}
return `${url.replace(/\/api\/?$/, '')}/chat/${userId}`
}

1725
pages/assistant/index.vue Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,45 @@
import { API_BASE_URL, apiRequest, getToken } from '../assistant/chat-service'
export function creativeAssistant(data) {
return apiRequest({
url: `${API_BASE_URL}/ai/creativeAssistant`,
method: 'POST',
data,
withTenant: true,
timeout: 600000
})
}
export function uploadAiFile(file) {
const token = getToken()
return new Promise((resolve, reject) => {
uni.uploadFile({
url: `${API_BASE_URL}/ai/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 || '上传失败'))
}
})
})
}

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>

568
pages/index/index.vue Normal file
View File

@@ -0,0 +1,568 @@
<template>
<view class="page">
<scroll-view scroll-y class="page-scroll">
<common-hero title="首页" :use-image-bg="true">
<view class="search-bar" @click="handleSearch">
<view class="search-left">
<view class="search-icon"></view>
<text class="search-placeholder">关键字搜索</text>
</view>
<text class="search-action">搜索</text>
</view>
<swiper
class="hero-swiper"
circular
autoplay
indicator-dots
indicator-color="rgba(72, 88, 104, 0.28)"
indicator-active-color="#5f6570"
interval="3600"
duration="500"
>
<swiper-item v-for="item in banners" :key="item.image">
<image class="banner-image" :src="item.image" mode="aspectFill"></image>
</swiper-item>
</swiper>
<view class="notice-card" @click="handleInfoClick">
<view class="notice-text">
<text class="notice-label">资讯:</text>
<text class="notice-content">{{ notice.title }}</text>
</view>
<view class="notice-arrow"></view>
</view>
</common-hero>
<view class="section">
<view class="section-title">核心业务</view>
<view class="business-list">
<view
v-for="item in featuredModules"
:key="item.title"
class="business-card"
@click="handleFeatureClick(item)"
>
<view class="business-card-top">
<view class="business-icon">{{ item.icon }}</view>
<view class="business-tag">{{ item.tag }}</view>
</view>
<view class="business-title">{{ item.title }}</view>
<view class="business-desc">{{ item.desc }}</view>
</view>
</view>
</view>
<view class="section">
<view class="section-headline">
<view>
<view class="section-title">申报系统</view>
<view class="section-subtitle">当前开放的申报项目入口</view>
</view>
<view class="section-link" @click="goDeclareSystem">查看全部</view>
</view>
<view class="declare-entry-card" @click="goDeclareSystem">
<view class="declare-entry-top">
<view class="declare-entry-action">进入系统</view>
</view>
<view v-if="declareLoading" class="declare-state">正在加载申报项目...</view>
<view v-else-if="declareError" class="declare-state">{{ declareError }}</view>
<view v-else-if="!declarePreviewList.length" class="declare-state">当前暂无可申报项目</view>
<view v-else class="declare-preview-list">
<view
v-for="item in declarePreviewList"
:key="item.id || `${item.module}-${item.year}`"
class="declare-preview-item"
@click.stop="goDeclareSystem"
>
<view class="declare-preview-main">
<view class="declare-preview-name">{{ getDeclareTitle(item) }}</view>
<view class="declare-preview-meta">
{{ getDeclareGroup(item.module) }} · {{ item.year ? `${item.year}` : '未设置年度' }}
</view>
</view>
<view class="declare-preview-tag">可申报</view>
</view>
</view>
</view>
</view>
</scroll-view>
</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 {
banners: [
{
image: '/static/swiper.jpg'
}
],
notice: {
title: '文稿、长图、排版和素材能力统一沉淀'
},
declareLoading: false,
declareError: '',
declareList: [],
featuredModules: [
{
title: '团员档案管理',
desc: '成员信息、组织关系、成长记录',
icon: '档',
tag: '组织',
route: '/pages/tygl/index'
},
{
title: '数据统计与报表中心',
desc: '查看挑战杯项目的年度趋势、结构分布与学校排行。',
icon: '数',
tag: '报表',
route: '/pages/gxmu/stats'
},
{
title: '跨校活动情报看板',
desc: '聚合兄弟高校案例线索,快速对标活动策划与传播打法。',
icon: '情',
tag: '案例',
route: '/pages/gxmu/cross-school-board'
},
{
title: '创新竞赛历史项目数据库',
desc: '进入挑战杯项目库与人才库,检索历史案例、人才与详情链接。',
icon: '库',
tag: '数据',
route: '/pages/gxmu/tzb-database'
}
]
}
},
computed: {
declarePreviewList() {
return this.declareList.slice(0, 2)
}
},
onLoad() {
this.loadDeclareList()
},
methods: {
getModuleMetaSafe(code) {
return getModuleMeta(code) || {
title: code || '未命名模块',
group: '申报项目'
}
},
getDeclareTitle(item) {
const title = String((item && item.title) || '').trim()
if (title) {
return title
}
return this.getModuleMetaSafe(item && item.module).title
},
getDeclareGroup(module) {
return this.getModuleMetaSafe(module).group
},
getTimestamp(value) {
if (!value) {
return 0
}
const time = new Date(String(value).replace(/-/g, '/')).getTime()
return Number.isNaN(time) ? 0 : time
},
async loadDeclareList() {
if (this.declareLoading) {
return
}
this.declareLoading = true
this.declareError = ''
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)
})
} catch (error) {
this.declareError = (error && error.message) || '申报项目加载失败'
} finally {
this.declareLoading = false
}
},
handleSearch() {
uni.showToast({
title: '搜索功能开发中',
icon: 'none'
})
},
handleInfoClick() {
uni.showToast({
title: this.notice.title,
icon: 'none'
})
},
handleFeatureClick(item) {
if (item.route) {
uni.navigateTo({
url: item.route
})
return
}
uni.showToast({
title: `${item.title}待接入`,
icon: 'none'
})
},
goDeclareSystem() {
uni.navigateTo({
url: '/pages/gxmu/index'
})
}
}
}
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
background: linear-gradient(180deg, #f8fbff 0%, #edf3f8 32%, #f7f7f8 100%);
}
.page-scroll {
height: 100vh;
}
.search-bar {
position: absolute;
top: 170rpx;
z-index: 9999;
width: calc(100vw - 56rpx);
left: 28rpx;
padding: 0 26rpx 0 30rpx;
height: 70rpx;
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.97);
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: 0 16rpx 40rpx rgba(97, 151, 193, 0.12);
}
.search-left {
display: flex;
align-items: center;
}
.search-icon {
width: 26rpx;
height: 26rpx;
border: 4rpx solid #c7c7c7;
border-radius: 50%;
position: relative;
}
.search-icon::after {
content: '';
position: absolute;
right: -10rpx;
bottom: -8rpx;
width: 14rpx;
height: 4rpx;
background: #c7c7c7;
border-radius: 999rpx;
transform: rotate(45deg);
transform-origin: center;
}
.search-placeholder {
margin-left: 20rpx;
font-size: 32rpx;
color: #cbcbcb;
}
.search-action {
font-size: 28rpx;
font-weight: 700;
color: #111;
}
.hero-swiper {
position: relative;
z-index: 2;
height: 338rpx;
margin-top: 40rpx;
}
.banner-image {
display: block;
width: 100%;
height: 338rpx;
}
.notice-card {
margin: 18rpx 28rpx 0;
padding: 0 20rpx 0 28rpx;
height: 86rpx;
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.98);
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: 0 12rpx 32rpx rgba(55, 95, 130, 0.08);
}
.notice-text {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
font-size: 28rpx;
color: #111;
}
.notice-label {
flex-shrink: 0;
margin-right: 12rpx;
font-weight: 700;
color: #1197ff;
}
.notice-content {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.notice-arrow {
width: 42rpx;
height: 42rpx;
border-radius: 50%;
border: 2rpx solid #4ca7ff;
display: flex;
align-items: center;
justify-content: center;
font-size: 30rpx;
color: #4ca7ff;
}
.section {
padding: 20rpx 28rpx 20rpx;
}
.section-headline {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16rpx;
}
.section-title {
font-size: 46rpx;
font-weight: 700;
color: #15a0ff;
}
.section-subtitle {
margin-top: 8rpx;
font-size: 24rpx;
line-height: 1.5;
color: #7c8a96;
}
.section-link {
flex-shrink: 0;
font-size: 24rpx;
font-weight: 700;
color: #1496f2;
}
.business-list {
margin-top: 24rpx;
display: flex;
flex-direction: column;
}
.business-card + .business-card {
margin-top: 26rpx;
}
.business-card {
padding: 24rpx 22rpx 26rpx;
border-radius: 28rpx;
background: rgba(255, 255, 255, 0.98);
box-shadow: 0 20rpx 48rpx rgba(79, 98, 117, 0.08);
}
.business-card-top {
display: flex;
align-items: center;
justify-content: space-between;
}
.business-icon {
min-width: 88rpx;
height: 58rpx;
padding: 0 20rpx;
border-radius: 999rpx;
background: linear-gradient(135deg, #16b2ff 0%, #1496f2 100%);
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 26rpx;
font-weight: 700;
color: #fff;
}
.business-tag {
min-width: 90rpx;
height: 42rpx;
padding: 0 20rpx;
border-radius: 999rpx;
border: 2rpx solid #22a4ff;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 24rpx;
font-weight: 500;
color: #22a4ff;
}
.business-title {
margin-top: 22rpx;
font-size: 26rpx;
font-weight: 700;
line-height: 1.35;
color: #111;
}
.business-desc {
margin-top: 10rpx;
font-size: 24rpx;
line-height: 1.5;
color: #7c7c7c;
}
.declare-entry-card {
margin-top: 24rpx;
padding: 28rpx 24rpx;
border-radius: 28rpx;
background:
linear-gradient(180deg, rgba(240, 247, 255, 0.96) 0%, rgba(255, 255, 255, 0.98) 44%),
#ffffff;
box-shadow: 0 20rpx 48rpx rgba(79, 98, 117, 0.08);
}
.declare-entry-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
}
.declare-entry-badge {
display: inline-flex;
align-items: center;
height: 46rpx;
padding: 0 18rpx;
border-radius: 999rpx;
background: #eaf4ff;
color: #1554ad;
font-size: 20rpx;
font-weight: 700;
letter-spacing: 1rpx;
}
.declare-entry-action {
font-size: 24rpx;
font-weight: 700;
color: #1496f2;
}
.declare-entry-title {
margin-top: 18rpx;
font-size: 30rpx;
font-weight: 700;
line-height: 1.45;
color: #1f2329;
}
.declare-entry-desc {
margin-top: 12rpx;
font-size: 24rpx;
line-height: 1.7;
color: #607080;
}
.declare-state {
margin-top: 22rpx;
padding: 20rpx 22rpx;
border-radius: 20rpx;
background: rgba(246, 248, 250, 0.9);
font-size: 24rpx;
line-height: 1.6;
color: #7d8a97;
}
.declare-preview-list {
margin-top: 22rpx;
display: flex;
flex-direction: column;
gap: 16rpx;
}
.declare-preview-item {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16rpx;
padding: 20rpx;
border-radius: 22rpx;
background: rgba(255, 255, 255, 0.96);
border: 1rpx solid rgba(20, 150, 242, 0.08);
}
.declare-preview-main {
flex: 1;
min-width: 0;
}
.declare-preview-name {
font-size: 26rpx;
font-weight: 700;
line-height: 1.45;
color: #1f2329;
}
.declare-preview-meta {
margin-top: 8rpx;
font-size: 22rpx;
line-height: 1.5;
color: #7c8a96;
}
.declare-preview-tag {
flex-shrink: 0;
height: 42rpx;
padding: 0 18rpx;
border-radius: 999rpx;
background: #eef5ff;
color: #1554ad;
font-size: 20rpx;
font-weight: 700;
line-height: 42rpx;
}
</style>

298
pages/login/index.vue Normal file
View File

@@ -0,0 +1,298 @@
<template>
<view class="page">
<view class="hero-card">
<view class="hero-title">进入智慧团委小程序</view>
<view class="hero-desc">支持手机号密码登录也支持微信手机号授权登录</view>
</view>
<view class="login-card">
<view class="tab-row">
<view
class="tab-item"
:class="{ 'tab-item--active': loginMode === 'password' }"
@tap="loginMode = 'password'"
>
手机号密码登录
</view>
<view
class="tab-item"
:class="{ 'tab-item--active': loginMode === 'wechat' }"
@tap="loginMode = 'wechat'"
>
微信授权登录
</view>
</view>
<view v-if="loginMode === 'password'" class="form-section">
<uv-input
v-model.trim="form.username"
class="field-input"
type="text"
placeholder="请输入手机号或账号"
:maxlength="-1"
border="none"
/>
<uv-input
v-model="form.password"
class="field-input"
type="password"
placeholder="请输入密码"
:maxlength="-1"
border="none"
password
/>
<view class="submit-btn" :class="{ 'submit-btn--loading': loading }" @tap="submitPasswordLogin">
{{ loading ? '登录中...' : '立即登录' }}
</view>
</view>
<view v-else class="form-section">
<view class="wechat-intro">授权后将使用微信手机号完成登录</view>
<button
class="submit-btn submit-btn--wechat"
:disabled="loading"
open-type="getPhoneNumber"
@getphonenumber="handleWxPhoneLogin"
>
{{ loading ? '登录中...' : '微信授权登录' }}
</button>
</view>
</view>
</view>
</template>
<script>
import { loginByMpWxPhone, loginByPassword } from './service'
import { hasToken, setAuthInfo } from '../../utils/request'
export default {
data() {
return {
loginMode: 'password',
loading: false,
redirect: '',
wxCode: '',
form: {
username: '',
password: ''
}
}
},
onLoad(options) {
this.redirect = decodeURIComponent((options && options.redirect) || '')
},
onShow() {
if (hasToken()) {
this.navigateAfterLogin()
return
}
this.refreshWxCode()
},
methods: {
showToast(title) {
uni.showToast({
title,
icon: 'none'
})
},
refreshWxCode() {
uni.login({
success: (res) => {
this.wxCode = (res && res.code) || ''
}
})
},
navigateAfterLogin() {
const url = this.redirect || '/pages/index/index'
uni.reLaunch({
url
})
},
async submitPasswordLogin() {
if (this.loading) {
return
}
if (!this.form.username) {
this.showToast('请输入手机号或账号')
return
}
if (!this.form.password) {
this.showToast('请输入密码')
return
}
this.loading = true
try {
const result = await loginByPassword({
username: this.form.username,
password: this.form.password
})
setAuthInfo(result)
this.showToast('登录成功')
setTimeout(() => {
this.navigateAfterLogin()
}, 300)
} catch (error) {
this.showToast((error && error.message) || '登录失败')
} finally {
this.loading = false
}
},
async handleWxPhoneLogin(event) {
if (this.loading) {
return
}
const detail = (event && event.detail) || {}
if (!detail.code) {
this.showToast(detail.errMsg || '未获取到微信手机号凭证')
return
}
this.loading = true
try {
if (!this.wxCode) {
await new Promise((resolve) => {
uni.login({
success: (res) => {
this.wxCode = (res && res.code) || ''
resolve()
},
fail: () => resolve()
})
})
}
const result = await loginByMpWxPhone({
phoneCode: detail.code,
code: this.wxCode || undefined
})
setAuthInfo(result)
this.showToast('登录成功')
setTimeout(() => {
this.navigateAfterLogin()
}, 300)
} catch (error) {
this.showToast((error && error.message) || '微信登录失败')
this.refreshWxCode()
} finally {
this.loading = false
}
}
}
}
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
padding: 28rpx 24rpx 40rpx;
background:
radial-gradient(circle at top left, rgba(20, 150, 242, 0.14), transparent 30%),
linear-gradient(180deg, #f5fbff 0%, #eef7ff 100%);
}
.hero-card,
.login-card {
border-radius: 32rpx;
box-shadow: 0 18rpx 40rpx rgba(20, 118, 194, 0.12);
}
.hero-card {
padding: 34rpx 30rpx;
background: linear-gradient(145deg, #0d6fba 0%, #1496f2 58%, #5bc2ff 100%);
color: #fff;
}
.hero-badge {
display: inline-flex;
padding: 10rpx 18rpx;
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.16);
font-size: 22rpx;
}
.hero-title {
margin-top: 22rpx;
font-size: 42rpx;
font-weight: 700;
}
.hero-desc {
margin-top: 14rpx;
font-size: 24rpx;
line-height: 1.7;
color: rgba(255, 255, 255, 0.86);
}
.login-card {
margin-top: 24rpx;
padding: 28rpx 24rpx;
background: rgba(255, 255, 255, 0.94);
}
.tab-row {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14rpx;
}
.tab-item {
height: 82rpx;
border-radius: 22rpx;
background: #edf5ff;
color: #1496f2;
font-size: 24rpx;
font-weight: 600;
line-height: 82rpx;
text-align: center;
}
.tab-item--active {
background: linear-gradient(135deg, #1496f2, #0d6fba);
color: #fff;
}
.form-section {
margin-top: 22rpx;
}
:deep(.field-input.uv-input) {
width: 100%;
height: 88rpx;
margin-top: 16rpx;
padding: 0 24rpx !important;
border-radius: 24rpx;
background: #f8fbff !important;
border: 1rpx solid rgba(20, 150, 242, 0.12) !important;
}
:deep(.field-input.uv-input .uv-input__content__field-wrapper__field) {
font-size: 24rpx;
color: #1f2329;
}
.wechat-intro {
font-size: 24rpx;
line-height: 1.7;
color: #667085;
}
.submit-btn {
width: 100%;
height: 88rpx;
margin-top: 22rpx;
border: none;
border-radius: 24rpx;
background: linear-gradient(135deg, #1496f2, #0d6fba);
color: #fff;
font-size: 26rpx;
font-weight: 700;
line-height: 88rpx;
text-align: center;
}
.submit-btn--wechat {
background: linear-gradient(135deg, #1dbf73, #12a150);
}
.submit-btn--loading {
opacity: 0.72;
}
</style>

21
pages/login/service.js Normal file
View File

@@ -0,0 +1,21 @@
import { DEFAULT_TENANT_ID, post } from '../../utils/request'
export function loginByPassword(data) {
return post(
'/login',
{
tenantId: DEFAULT_TENANT_ID,
isMiniApp: 1,
...data
},
{
auth: false
}
)
}
export function loginByMpWxPhone(data) {
return post('/wx-login/loginByMpWxPhone', data, {
auth: false
})
}

1112
pages/manuscript/index.vue Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,52 @@
import {
API_BASE_URL,
apiRequest,
buildWebSocketUrl,
getCurrentUser,
getToken
} from '../assistant/chat-service'
export { buildWebSocketUrl, getCurrentUser }
export function manuscriptGen(data) {
return apiRequest({
url: `${API_BASE_URL}/ai/manuscriptGen`,
method: 'POST',
data,
withTenant: true
})
}
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 || '上传失败'))
}
})
})
}

355
pages/mine/declare.vue Normal file
View File

@@ -0,0 +1,355 @@
<template>
<view class="page">
<scroll-view scroll-y class="page-scroll">
<view class="hero-card">
<view class="hero-title">我的申报</view>
<view class="hero-desc">汇总本地草稿已提交记录和当前开放的申报项目</view>
<view class="hero-stats">
<view class="stat-item">
<view class="stat-value">{{ localRecords.length }}</view>
<view class="stat-label">本地记录</view>
</view>
<view class="stat-item">
<view class="stat-value">{{ submittedCount }}</view>
<view class="stat-label">已提交</view>
</view>
<view class="stat-item">
<view class="stat-value">{{ openDeclareList.length }}</view>
<view class="stat-label">开放项目</view>
</view>
</view>
</view>
<view class="section-card">
<view class="section-head">
<view class="section-title">本地申报记录</view>
<view class="section-action" @tap="loadData()">刷新</view>
</view>
<view v-if="!localRecords.length" class="empty-block">
当前还没有本地草稿或提交记录可先前往申报页填写
</view>
<view v-else class="card-list">
<view v-for="item in localRecords" :key="item.key" class="data-card">
<view class="card-top">
<view class="card-main">
<view class="card-title">{{ item.title }}</view>
<view class="card-subtitle">{{ item.year || '未设置年度' }} · {{ item.group }}</view>
</view>
<view class="status-tag" :class="item.status === '已提交' ? 'status-tag--success' : 'status-tag--warning'">
{{ item.status }}
</view>
</view>
<view class="meta-row">保存时间{{ item.savedAt || '-' }}</view>
<view class="card-actions">
<view class="card-action" @tap="continueEdit(item)">继续编辑</view>
<view class="card-action" @tap="copyText(item.title)">复制标题</view>
</view>
</view>
</view>
</view>
<view class="section-card">
<view class="section-head">
<view class="section-title">当前开放项目</view>
<view class="section-action" @tap="goDeclareList">全部查看</view>
</view>
<view v-if="loading" class="empty-block">正在加载开放项目...</view>
<view v-else-if="errorText" class="empty-block empty-block--error">{{ errorText }}</view>
<view v-else-if="!openDeclareList.length" class="empty-block">当前暂无开放的申报项目</view>
<view v-else class="card-list">
<view v-for="item in openDeclareList" :key="item.id || `${item.module}-${item.year}`" class="data-card">
<view class="card-top">
<view class="card-main">
<view class="card-title">{{ getDeclareTitle(item) }}</view>
<view class="card-subtitle">{{ getDeclareGroup(item.module) }} · {{ item.year || '未设置年度' }}</view>
</view>
<view class="status-tag status-tag--default">可申报</view>
</view>
<view class="meta-row">时间范围{{ formatDateTime(item.startTime) }} {{ formatDateTime(item.endTime) }}</view>
<view class="card-actions">
<view class="card-action" @tap="goApply(item)">进入申报</view>
</view>
</view>
</view>
</view>
</scroll-view>
</view>
</template>
<script>
import {
formatDateTime,
getLocalDeclareRecords,
listAvailableDeclare
} from './service'
import { getFormConfig, getModuleMeta } from '../../utils/gxmu/config'
export default {
data() {
return {
loading: false,
errorText: '',
localRecords: [],
openDeclareList: []
}
},
computed: {
submittedCount() {
return this.localRecords.filter((item) => item.status === '已提交').length
}
},
onShow() {
this.loadData()
},
onPullDownRefresh() {
this.loadData(true)
},
methods: {
formatDateTime,
getDeclareTitle(item) {
return String(item.title || '').trim() || this.getDeclareGroup(item.module)
},
getDeclareGroup(module) {
const meta = getModuleMeta(module)
return (meta && meta.title) || module || '申报项目'
},
async loadData(fromPullDown = false) {
this.localRecords = getLocalDeclareRecords()
this.loading = true
this.errorText = ''
try {
const result = await listAvailableDeclare({ usable: true })
const list = Array.isArray(result) ? result : []
this.openDeclareList = list.sort((left, right) => Number(right.year || 0) - Number(left.year || 0))
} catch (error) {
this.errorText = (error && error.message) || '开放项目加载失败'
} finally {
this.loading = false
if (fromPullDown) {
uni.stopPullDownRefresh()
}
}
},
continueEdit(item) {
uni.navigateTo({
url: item.route
})
},
copyText(value) {
uni.setClipboardData({
data: String(value || ''),
success: () => {
uni.showToast({
title: '已复制',
icon: 'none'
})
}
})
},
goDeclareList() {
uni.navigateTo({
url: '/pages/gxmu/index'
})
},
goApply(item) {
if (!item.module || !getFormConfig(item.module)) {
uni.showToast({
title: '当前申报项目在小程序端暂未配置',
icon: 'none'
})
return
}
const moduleMeta = getModuleMeta(item.module)
const title = String(item.title || '').trim() || ((moduleMeta && moduleMeta.title) || '申报表')
uni.navigateTo({
url: `/pages/gxmu/form?code=${encodeURIComponent(item.module)}&year=${encodeURIComponent(item.year || '')}&declareTitle=${encodeURIComponent(title)}`
})
}
}
}
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
background:
radial-gradient(circle at top center, rgba(20, 150, 242, 0.1), transparent 30%),
linear-gradient(180deg, #f5fbff 0%, #eef7ff 100%);
}
.page-scroll {
height: 100vh;
padding: 24rpx 24rpx 40rpx;
}
.hero-card,
.section-card,
.data-card {
border-radius: 30rpx;
background: rgba(255, 255, 255, 0.94);
border: 1rpx solid rgba(20, 150, 242, 0.08);
box-shadow: 0 10rpx 28rpx rgba(34, 94, 142, 0.08);
}
.hero-card {
padding: 32rpx 28rpx;
background: linear-gradient(145deg, #0d6fba 0%, #1496f2 58%, #5bc2ff 100%);
color: #fff;
}
.hero-title {
font-size: 40rpx;
font-weight: 700;
}
.hero-desc {
margin-top: 14rpx;
font-size: 24rpx;
line-height: 1.7;
color: rgba(255, 255, 255, 0.86);
}
.hero-stats {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16rpx;
margin-top: 24rpx;
}
.stat-item {
padding: 18rpx 16rpx;
border-radius: 22rpx;
background: rgba(255, 255, 255, 0.12);
text-align: center;
}
.stat-value {
font-size: 32rpx;
font-weight: 700;
}
.stat-label {
margin-top: 6rpx;
font-size: 22rpx;
color: rgba(255, 255, 255, 0.8);
}
.section-card {
margin-top: 24rpx;
padding: 26rpx;
}
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
}
.section-title {
font-size: 30rpx;
font-weight: 700;
color: #1f2329;
}
.section-action {
font-size: 22rpx;
color: #1496f2;
font-weight: 700;
}
.empty-block {
margin-top: 18rpx;
padding: 26rpx 22rpx;
border-radius: 24rpx;
background: #faf5f2;
font-size: 24rpx;
line-height: 1.75;
color: #7b726d;
}
.empty-block--error {
color: #0f7dd1;
}
.card-list {
display: flex;
flex-direction: column;
gap: 18rpx;
margin-top: 18rpx;
}
.data-card {
padding: 24rpx;
}
.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: 30rpx;
font-weight: 700;
color: #22272f;
}
.card-subtitle {
margin-top: 8rpx;
font-size: 22rpx;
color: #8b817c;
}
.meta-row {
margin-top: 18rpx;
padding: 18rpx;
border-radius: 22rpx;
background: #faf7f2;
font-size: 24rpx;
line-height: 1.75;
color: #5f564f;
}
.status-tag {
padding: 10rpx 16rpx;
border-radius: 999rpx;
font-size: 20rpx;
font-weight: 700;
}
.status-tag--success {
background: #eef9f1;
color: #1f8f49;
}
.status-tag--warning {
background: #fff8e6;
color: #ad7b00;
}
.status-tag--default {
background: #f5f5f5;
color: #8b817c;
}
.card-actions {
display: flex;
gap: 14rpx;
margin-top: 18rpx;
}
.card-action {
padding: 10rpx 18rpx;
border-radius: 999rpx;
background: rgba(20, 150, 242, 0.08);
color: #1496f2;
font-size: 22rpx;
}
</style>

501
pages/mine/index.vue Normal file
View File

@@ -0,0 +1,501 @@
<template>
<view class="page">
<scroll-view scroll-y class="page-scroll">
<common-hero title="我的">
<view class="hero-content">
<view class="profile-card">
<view class="profile-top">
<image v-if="profile.avatar" class="avatar-image" :src="profile.avatar" mode="aspectFill" />
<view v-else class="avatar">{{ avatarText }}</view>
<view class="profile-main">
<view class="profile-name">{{ displayName }}</view>
<view class="profile-role">{{ profileRole }}</view>
</view>
</view>
<view class="profile-stats">
<view v-for="item in stats" :key="item.label" class="profile-stat">
<view class="profile-stat-value">{{ item.value }}</view>
<view class="profile-stat-label">{{ item.label }}</view>
</view>
</view>
<view class="logout-btn" @click="handleLogout">退出登录</view>
</view>
</view>
</common-hero>
<view class="section">
<view class="section-title">常用入口</view>
<view class="entry-grid">
<view
v-for="item in visibleEntries"
:key="item.title"
class="entry-card"
@click="openEntry(item)"
>
<view class="entry-icon">{{ item.icon }}</view>
<view class="entry-title">{{ item.title }}</view>
<view class="entry-desc">{{ item.desc }}</view>
</view>
</view>
</view>
<view class="section">
<view class="section-title">最近状态</view>
<view class="status-list">
<view v-for="item in statusList" :key="item.title" class="status-card" @click="openStatus(item)">
<view class="status-main">
<view class="status-name">{{ item.title }}</view>
<view class="status-desc">{{ item.desc }}</view>
</view>
<view class="status-tag">{{ item.tag }}</view>
</view>
</view>
</view>
</scroll-view>
</view>
</template>
<script>
import CommonHero from '../../components/common-hero/common-hero.vue'
import {
ensureLoggedIn,
getCurrentUser,
getLocalProfileOverride,
getLocalDeclareRecords,
getManuscriptStatusMeta,
getReviewStatusMeta,
getUserProfile,
listManuscript,
logout,
userPageQmgcForm
} from './service'
import { hasReviewListAuthority } from '../../utils/gxmu/review-list-service'
export default {
components: {
CommonHero
},
data() {
return {
profile: {
nickname: '',
realName: '',
username: '',
avatar: '',
organizationName: '',
tenantName: '',
merchantName: '',
roles: []
},
manuscriptList: [],
qingmaList: [],
declareList: [],
hasReviewListPermission: false,
entries: [
{
title: '用户资料',
desc: '查看账号信息、组织身份和联系方式。',
icon: '资',
route: '/packageMine/profile'
},
{
title: '我的“青马”',
desc: '管理学员培养计划、考核与成长档案。',
icon: '青',
route: '/packageMine/qingma'
},
{
title: '我的稿件',
desc: '汇总我创建或参与编辑的全部稿件。',
icon: '稿',
route: '/packageMine/manuscripts'
},
{
title: '我的申报',
desc: '查看项目进度、待补材料与审核状态。',
icon: '报',
route: '/packageMine/declare'
},
{
title: '审核列表',
desc: '聚合查看业务数据、审核流并处理待审事项。',
icon: '审',
route: '/pages/gxmu/review-list',
authority: 'gxmu:reviewList:list'
}
]
}
},
computed: {
displayName() {
return this.profile.nickname || this.profile.realName || this.profile.username || '未登录用户'
},
avatarText() {
return (this.displayName || '我').slice(0, 2)
},
profileRole() {
const roles = Array.isArray(this.profile.roles) ? this.profile.roles : []
if (roles.length) {
return roles.map((item) => item.roleName).filter(Boolean).join(' / ')
}
return '校级团委数字工作台'
},
profileOrg() {
return this.profile.organizationName || this.profile.merchantName || this.profile.tenantName || '暂无组织信息'
},
stats() {
return [
{ value: `${this.manuscriptList.length}`, label: '我的稿件' },
{ value: `${this.declareList.length}`, label: '我的申报' },
{ value: `${this.qingmaList.length}`, label: '我的“青马”' }
]
},
visibleEntries() {
return this.entries.filter((item) => {
if (item.authority === 'gxmu:reviewList:list') {
return this.hasReviewListPermission
}
return true
})
},
statusList() {
const manuscript = this.manuscriptList[0]
const qingma = this.qingmaList[0]
const declare = this.declareList[0]
return [
{
title: '我的申报',
desc: declare ? `${declare.title}${declare.year ? ` · ${declare.year}` : ''},上次保存于 ${declare.savedAt || '刚刚'}` : '当前暂无本地申报记录,可前往五四评优页开始填写。',
tag: declare ? declare.status : '待创建',
route: '/packageMine/declare'
},
{
title: '我的“青马”',
desc: qingma ? `${qingma.name || '当前学员'} · ${qingma.schoolInfo || '暂无院系信息'}` : '当前暂无青马记录,可前往申报页查看开放项目。',
tag: qingma ? getReviewStatusMeta(qingma.reviewList).text : '待处理',
route: '/packageMine/qingma'
},
{
title: '我的稿件',
desc: manuscript ? `${manuscript.title || '未命名稿件'} · 最近更新时间 ${manuscript.updateTime || manuscript.createTime || '-'}` : '当前暂无稿件记录,可前往文稿工作台开始创作。',
tag: manuscript ? getManuscriptStatusMeta(manuscript).text : '待更新',
route: '/packageMine/manuscripts'
}
]
}
},
onShow() {
if (!ensureLoggedIn({ redirectUrl: '/pages/mine/index' })) {
return
}
this.loadOverview()
},
methods: {
showToast(title) {
uni.showToast({
title,
icon: 'none'
})
},
async loadOverview() {
const currentUser = getCurrentUser()
this.hasReviewListPermission = false
this.profile = {
...this.profile,
...currentUser
}
const localOverride = getLocalProfileOverride(currentUser.userId)
if (localOverride.nickname !== undefined) {
this.profile.nickname = String(localOverride.nickname || '').trim()
}
if (localOverride.realName !== undefined) {
this.profile.realName = String(localOverride.realName || '').trim()
}
if (localOverride.sex !== undefined || localOverride.sexName !== undefined) {
const nextGender = String(localOverride.sexName || localOverride.sex || '').trim()
this.profile.sex = nextGender
this.profile.sexName = nextGender
}
this.declareList = getLocalDeclareRecords()
try {
const [profile, manuscripts, qingmaResult] = await Promise.all([
getUserProfile().catch(() => null),
listManuscript().catch(() => []),
userPageQmgcForm({ page: 1, limit: 5 }).catch(() => ({ list: [] }))
])
if (profile) {
this.profile = {
...this.profile,
...profile
}
this.hasReviewListPermission = hasReviewListAuthority(profile)
const latestOverride = getLocalProfileOverride(this.profile.userId || currentUser.userId)
if (latestOverride.nickname !== undefined) {
this.profile.nickname = String(latestOverride.nickname || '').trim()
}
if (latestOverride.realName !== undefined) {
this.profile.realName = String(latestOverride.realName || '').trim()
}
if (latestOverride.sex !== undefined || latestOverride.sexName !== undefined) {
const nextGender = String(latestOverride.sexName || latestOverride.sex || '').trim()
this.profile.sex = nextGender
this.profile.sexName = nextGender
}
}
const userId = Number(currentUser.userId || this.profile.userId || 0)
const list = Array.isArray(manuscripts) ? manuscripts : []
this.manuscriptList = userId ? list.filter((item) => Number(item.userId) === userId) : list
this.qingmaList = (qingmaResult && qingmaResult.list) || []
} catch (error) {
console.warn('mine overview load failed', error)
}
},
handleLogout() {
uni.showModal({
title: '提示',
content: '确认退出当前登录账号?',
success: ({ confirm }) => {
if (!confirm) {
return
}
logout()
this.showToast('已退出登录')
setTimeout(() => {
uni.reLaunch({
url: '/pages/login/index?redirect=%2Fpages%2Fmine%2Findex'
})
}, 300)
}
})
},
openEntry(item) {
if (item.route) {
uni.navigateTo({
url: item.route
})
return
}
uni.showToast({
title: `${item.title}待接入`,
icon: 'none'
})
},
openStatus(item) {
if (item.route) {
uni.navigateTo({
url: item.route
})
return
}
}
}
}
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
background: linear-gradient(180deg, #f8fbff 0%, #edf3f8 32%, #f7f7f8 100%);
}
.page-scroll {
height: 100vh;
padding-bottom: 40rpx;
}
.hero-content {
position: relative;
z-index: 2;
padding: 20rpx 24rpx 0;
}
.profile-card {
padding: 34rpx 30rpx;
border-radius: 32rpx;
background: linear-gradient(145deg, #0d6fba 0%, #1496f2 58%, #5bc2ff 100%);
box-shadow: 0 18rpx 40rpx rgba(20, 118, 194, 0.18);
color: #fff;
}
.profile-top {
display: flex;
align-items: center;
}
.avatar,
.avatar-image {
width: 110rpx;
height: 110rpx;
border-radius: 32rpx;
background: rgba(255, 255, 255, 0.16);
}
.avatar {
display: flex;
align-items: center;
justify-content: center;
font-size: 30rpx;
font-weight: 700;
}
.profile-main {
margin-left: 22rpx;
flex: 1;
min-width: 0;
}
.profile-name {
font-size: 38rpx;
font-weight: 700;
}
.profile-role {
margin-top: 10rpx;
font-size: 24rpx;
color: rgba(255, 255, 255, 0.84);
}
.profile-org {
margin-top: 8rpx;
font-size: 22rpx;
line-height: 1.6;
color: rgba(255, 255, 255, 0.8);
}
.profile-stats {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16rpx;
margin-top: 28rpx;
}
.logout-btn {
margin-top: 22rpx;
height: 76rpx;
border-radius: 22rpx;
background: rgba(11, 34, 58, 0.18);
font-size: 24rpx;
font-weight: 600;
line-height: 76rpx;
text-align: center;
color: #fff;
}
.profile-stat {
padding: 20rpx 16rpx;
border-radius: 24rpx;
background: rgba(255, 255, 255, 0.12);
text-align: center;
}
.profile-stat-value {
font-size: 34rpx;
font-weight: 700;
}
.profile-stat-label {
margin-top: 8rpx;
font-size: 22rpx;
color: rgba(255, 255, 255, 0.82);
}
.section {
margin-top: 30rpx;
padding: 0 24rpx;
}
.section-title {
padding: 0 6rpx;
margin-bottom: 18rpx;
font-size: 34rpx;
font-weight: 700;
color: #1f2329;
}
.entry-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18rpx;
}
.entry-card,
.status-card {
background: rgba(255, 255, 255, 0.92);
border: 1rpx solid rgba(20, 150, 242, 0.08);
box-shadow: 0 10rpx 28rpx rgba(34, 94, 142, 0.08);
}
.entry-card {
padding: 24rpx;
border-radius: 28rpx;
}
.entry-icon {
display: flex;
align-items: center;
justify-content: center;
width: 72rpx;
height: 72rpx;
border-radius: 22rpx;
background: rgba(20, 150, 242, 0.1);
color: #1496f2;
font-size: 28rpx;
font-weight: 700;
}
.entry-title {
margin-top: 18rpx;
font-size: 28rpx;
font-weight: 700;
color: #262a31;
}
.entry-desc {
margin-top: 10rpx;
font-size: 22rpx;
line-height: 1.6;
color: #7a706b;
}
.status-list {
display: flex;
flex-direction: column;
gap: 18rpx;
}
.status-card {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24rpx;
border-radius: 28rpx;
}
.status-main {
flex: 1;
padding-right: 20rpx;
}
.status-name {
font-size: 28rpx;
font-weight: 700;
color: #23272f;
}
.status-desc {
margin-top: 10rpx;
font-size: 22rpx;
line-height: 1.6;
color: #7c736e;
}
.status-tag {
padding: 12rpx 18rpx;
border-radius: 999rpx;
background: #eaf5ff;
color: #1496f2;
font-size: 22rpx;
white-space: nowrap;
}
</style>

422
pages/mine/manuscripts.vue Normal file
View File

@@ -0,0 +1,422 @@
<template>
<view class="page">
<scroll-view scroll-y class="page-scroll">
<view class="hero-card">
<view class="hero-title">我的稿件</view>
<view class="hero-desc">汇总当前账号创建或参与的稿件记录可查看状态和内容摘要</view>
<view class="hero-stats">
<view class="stat-item">
<view class="stat-value">{{ manuscripts.length }}</view>
<view class="stat-label">稿件总数</view>
</view>
<view class="stat-item">
<view class="stat-value">{{ publishedCount }}</view>
<view class="stat-label">已通过/发布</view>
</view>
<view class="stat-item">
<view class="stat-value">{{ pendingCount }}</view>
<view class="stat-label">待处理</view>
</view>
</view>
</view>
<view class="toolbar-card">
<uv-input
v-model="keyword"
class="search-input"
placeholder="搜索稿件标题或内容"
placeholder-style="color: #c0c4cc;"
:maxlength="-1"
/>
<view class="toolbar-actions">
<view class="toolbar-btn" @tap="loadData()">刷新</view>
<view class="toolbar-btn toolbar-btn--primary" @tap="goCreate">去创作</view>
</view>
</view>
<view v-if="loading" class="state-card">
<view class="state-title">正在加载稿件...</view>
<view class="state-desc">请稍候正在同步当前账号的稿件记录</view>
</view>
<view v-else-if="errorText" class="state-card state-card--error">
<view class="state-title">加载失败</view>
<view class="state-desc">{{ errorText }}</view>
</view>
<view v-else-if="!filteredList.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 filteredList" :key="item.id || item.title" class="data-card">
<view class="card-top">
<view class="card-main">
<view class="card-title">{{ item.title || '未命名稿件' }}</view>
<view class="card-subtitle">
创建时间{{ formatDateTime(item.createTime) }} · 更新时间{{ formatDateTime(item.updateTime) }}
</view>
</view>
<view class="status-tag" :class="`status-tag--${getStatusMeta(item).tone}`">
{{ getStatusMeta(item).text }}
</view>
</view>
<view v-if="item.cover" class="cover-wrap">
<image class="cover-image" :src="item.cover" mode="aspectFill" />
</view>
<view class="content-preview">{{ getContentPreview(item.content) }}</view>
<view class="card-actions">
<view class="card-action" @tap="toggleExpand(item)">{{ expandedId === item.id ? '收起内容' : '查看内容' }}</view>
<view class="card-action" @tap="copyTitle(item.title)">复制标题</view>
</view>
<view v-if="expandedId === item.id" class="content-detail">{{ item.content || '暂无正文内容' }}</view>
</view>
</view>
</scroll-view>
</view>
</template>
<script>
import {
formatDateTime,
getCurrentUser,
getManuscriptStatusMeta,
listManuscript
} from './service'
export default {
data() {
return {
loading: false,
errorText: '',
keyword: '',
expandedId: null,
manuscripts: []
}
},
computed: {
filteredList() {
const keyword = String(this.keyword || '').trim().toLowerCase()
if (!keyword) {
return this.manuscripts
}
return this.manuscripts.filter((item) => {
return (
String(item.title || '').toLowerCase().includes(keyword) ||
String(item.content || '').toLowerCase().includes(keyword)
)
})
},
publishedCount() {
return this.manuscripts.filter((item) => {
const text = this.getStatusMeta(item).text
return text === '通过' || text === '已发布'
}).length
},
pendingCount() {
return this.manuscripts.length - this.publishedCount
}
},
onShow() {
this.loadData()
},
onPullDownRefresh() {
this.loadData(true)
},
methods: {
formatDateTime,
getStatusMeta(item) {
return getManuscriptStatusMeta(item)
},
getContentPreview(content) {
const text = String(content || '').replace(/\s+/g, ' ').trim()
return text ? `${text.slice(0, 90)}${text.length > 90 ? '...' : ''}` : '暂无内容摘要'
},
async loadData(fromPullDown = false) {
this.loading = true
this.errorText = ''
try {
const user = getCurrentUser()
const result = await listManuscript()
const list = Array.isArray(result) ? result : []
this.manuscripts = list
.filter((item) => !user.userId || Number(item.userId) === Number(user.userId))
.sort((left, right) => {
const leftTime = new Date(String((left.updateTime || left.createTime || '')).replace(/-/g, '/')).getTime()
const rightTime = new Date(String((right.updateTime || right.createTime || '')).replace(/-/g, '/')).getTime()
return (Number.isNaN(rightTime) ? 0 : rightTime) - (Number.isNaN(leftTime) ? 0 : leftTime)
})
} catch (error) {
this.errorText = (error && error.message) || '稿件加载失败'
} finally {
this.loading = false
if (fromPullDown) {
uni.stopPullDownRefresh()
}
}
},
toggleExpand(item) {
this.expandedId = this.expandedId === item.id ? null : item.id
},
copyTitle(title) {
if (!title) {
uni.showToast({
title: '暂无标题可复制',
icon: 'none'
})
return
}
uni.setClipboardData({
data: title,
success: () => {
uni.showToast({
title: '标题已复制',
icon: 'none'
})
}
})
},
goCreate() {
uni.navigateTo({
url: '/pages/manuscript/index'
})
}
}
}
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
background:
radial-gradient(circle at top center, rgba(240, 122, 61, 0.1), transparent 30%),
linear-gradient(180deg, #fff8f2 0%, #f8f2ec 100%);
}
.page-scroll {
height: 100vh;
padding: 24rpx 24rpx 40rpx;
}
.hero-card,
.toolbar-card,
.state-card,
.data-card {
border-radius: 30rpx;
background: rgba(255, 255, 255, 0.94);
border: 1rpx solid rgba(240, 122, 61, 0.08);
box-shadow: 0 10rpx 28rpx rgba(76, 49, 35, 0.08);
}
.hero-card {
padding: 32rpx 28rpx;
background: linear-gradient(145deg, #7c2f04 0%, #c8641f 56%, #f29a4b 100%);
color: #fffaf5;
}
.hero-title {
font-size: 40rpx;
font-weight: 700;
}
.hero-desc {
margin-top: 14rpx;
font-size: 24rpx;
line-height: 1.7;
color: rgba(255, 250, 245, 0.86);
}
.hero-stats {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16rpx;
margin-top: 24rpx;
}
.stat-item {
padding: 18rpx 16rpx;
border-radius: 22rpx;
background: rgba(255, 255, 255, 0.12);
text-align: center;
}
.stat-value {
font-size: 32rpx;
font-weight: 700;
}
.stat-label {
margin-top: 6rpx;
font-size: 22rpx;
color: rgba(255, 250, 245, 0.8);
}
.toolbar-card {
margin-top: 24rpx;
padding: 22rpx;
}
:deep(.search-input.uv-input) {
width: 100%;
min-height: 84rpx;
padding: 0 22rpx;
border-radius: 12rpx;
border: 1px solid #dcdfe6;
background: #fff;
box-sizing: border-box;
}
:deep(.search-input.uv-input .uv-input__content__field-wrapper__field) {
height: 84rpx;
min-height: 84rpx;
font-size: 28rpx;
line-height: 84rpx;
color: #303133;
}
.toolbar-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16rpx;
margin-top: 18rpx;
}
.toolbar-btn {
height: 84rpx;
line-height: 84rpx;
border-radius: 22rpx;
background: #edf6ff;
color: #0f7dd1;
font-size: 26rpx;
font-weight: 700;
text-align: center;
}
.toolbar-btn--primary {
background: linear-gradient(135deg, #0f7dd1 0%, #1496f2 100%);
color: #fff;
}
.state-card {
margin-top: 24rpx;
padding: 28rpx 24rpx;
}
.state-card--error {
border-color: rgba(20, 150, 242, 0.16);
}
.state-title {
font-size: 30rpx;
font-weight: 700;
color: #20252c;
}
.state-desc {
margin-top: 12rpx;
font-size: 24rpx;
line-height: 1.75;
color: #786e69;
}
.card-list {
display: flex;
flex-direction: column;
gap: 18rpx;
margin-top: 24rpx;
}
.data-card {
padding: 24rpx;
}
.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: 30rpx;
font-weight: 700;
color: #22272f;
}
.card-subtitle {
margin-top: 8rpx;
font-size: 22rpx;
line-height: 1.6;
color: #8b817c;
}
.status-tag {
padding: 10rpx 16rpx;
border-radius: 999rpx;
font-size: 20rpx;
font-weight: 700;
}
.status-tag--success {
background: #eef9f1;
color: #1f8f49;
}
.status-tag--danger {
background: #fff1f0;
color: #d4380d;
}
.status-tag--warning {
background: #fff8e6;
color: #ad7b00;
}
.status-tag--default {
background: #f5f5f5;
color: #8b817c;
}
.cover-wrap {
margin-top: 18rpx;
}
.cover-image {
width: 100%;
height: 260rpx;
border-radius: 24rpx;
}
.content-preview,
.content-detail {
margin-top: 18rpx;
font-size: 24rpx;
line-height: 1.8;
color: #5d544f;
word-break: break-word;
white-space: pre-wrap;
}
.content-detail {
padding: 20rpx;
border-radius: 22rpx;
background: #fbf7f3;
}
.card-actions {
display: flex;
gap: 14rpx;
margin-top: 18rpx;
}
.card-action {
padding: 10rpx 18rpx;
border-radius: 999rpx;
background: rgba(240, 122, 61, 0.08);
color: #c8641f;
font-size: 22rpx;
}
</style>

566
pages/mine/profile.vue Normal file
View File

@@ -0,0 +1,566 @@
<template>
<view class="page">
<scroll-view scroll-y class="page-scroll">
<view class="section-card">
<view class="section-title">基础信息</view>
<view class="info-list">
<!-- <view class="info-item info-item&#45;&#45;form">-->
<!-- <view class="info-main">-->
<!-- <view class="info-label">登录账号</view>-->
<!-- <view class="info-value">{{ profile.username || '-' }}</view>-->
<!-- </view>-->
<!-- </view>-->
<view class="info-item info-item--form">
<view class="info-main">
<view class="info-label">姓名</view>
<uv-input
class="info-input"
:value="editingRealName"
maxlength="20"
placeholder="请输入姓名"
placeholder-style="color: #b2a7a1;"
border="none"
@input="handleRealNameInput"
/>
</view>
</view>
<view class="info-item info-item--form">
<view class="info-main">
<view class="info-label">昵称</view>
<uv-input
class="info-input"
:value="editingNickname"
maxlength="20"
placeholder="请输入昵称"
placeholder-style="color: #b2a7a1;"
border="none"
@input="handleNicknameInput"
/>
</view>
<view class="info-action" @tap="fillWechatNickname">读取微信昵称</view>
</view>
<view class="info-item info-item--form">
<view class="info-main">
<view class="info-label">性别</view>
<picker
mode="selector"
:range="genderOptions"
:value="genderIndex"
@change="handleGenderChange"
>
<view class="info-input info-input--picker">
{{ editingGender || '请选择性别' }}
</view>
</picker>
</view>
</view>
<view class="info-item info-item--form">
<view class="info-main">
<view class="info-label">手机号码</view>
<view class="info-value">{{ profile.phone || '-' }}</view>
</view>
<view v-if="profile.phone" class="info-action" @tap="copyText(profile.phone)">复制</view>
</view>
</view>
<view class="info-actions">
<view class="nickname-btn nickname-btn--primary" @tap="saveProfileBasic">保存基础信息</view>
</view>
</view>
</scroll-view>
</view>
</template>
<script>
import {
getCurrentUser,
getLocalProfileOverride,
getUserProfile,
saveLocalProfileOverride,
updateUserProfileData
} from './service'
export default {
data() {
return {
editingNickname: '',
editingRealName: '',
editingGender: '',
genderOptions: ['男', '女', '未知'],
profile: {
userId: '',
nickname: '',
realName: '',
username: '',
avatar: '',
phone: '',
email: '',
organizationName: '',
tenantName: '',
merchantName: '',
sexName: '',
sex: '',
address: '',
province: '',
city: '',
region: '',
introduction: '',
roles: []
}
}
},
computed: {
displayName() {
return this.profile.nickname || this.profile.realName || this.profile.username || '未命名用户'
},
roleText() {
const roles = Array.isArray(this.profile.roles) ? this.profile.roles : []
if (roles.length) {
return roles.map((item) => item.roleName).filter(Boolean).join(' / ')
}
return '暂无角色信息'
},
organizationText() {
return this.profile.organizationName || this.profile.merchantName || this.profile.tenantName || '暂无组织信息'
},
avatarText() {
return (this.displayName || '我').slice(0, 2)
},
genderIndex() {
const index = this.genderOptions.indexOf(this.editingGender)
return index > -1 ? index : 0
}
},
onShow() {
this.loadProfile()
},
onPullDownRefresh() {
this.loadProfile(true)
},
methods: {
getProfileUserId() {
return Number(this.profile.userId || getCurrentUser().userId || 0) || ''
},
applyLocalProfileOverride() {
const override = getLocalProfileOverride(this.getProfileUserId())
if (override.nickname !== undefined) {
this.profile = {
...this.profile,
nickname: String(override.nickname || '').trim()
}
}
if (override.realName !== undefined) {
this.profile = {
...this.profile,
realName: String(override.realName || '').trim()
}
}
if (override.sex !== undefined || override.sexName !== undefined) {
const nextGender = String(override.sexName || override.sex || '').trim()
this.profile = {
...this.profile,
sex: nextGender,
sexName: nextGender
}
}
this.editingNickname = this.profile.nickname || ''
this.editingRealName = this.profile.realName || ''
this.editingGender = this.profile.sexName || this.profile.sex || ''
},
async loadProfile(fromPullDown = false) {
try {
const tokenUser = getCurrentUser()
this.profile = {
...this.profile,
...tokenUser
}
const result = await getUserProfile()
this.profile = {
...this.profile,
...(result || {})
}
this.applyLocalProfileOverride()
} catch (error) {
this.applyLocalProfileOverride()
if (fromPullDown) {
uni.showToast({
title: (error && error.message) || '用户资料加载失败',
icon: 'none'
})
}
} finally {
if (fromPullDown) {
uni.stopPullDownRefresh()
}
}
},
handleNicknameInput(event) {
const value = typeof event === 'string' ? event : (event.detail && event.detail.value) || ''
this.editingNickname = String(value || '')
},
handleRealNameInput(event) {
const value = typeof event === 'string' ? event : (event.detail && event.detail.value) || ''
this.editingRealName = String(value || '')
},
handleGenderChange(event) {
const index = Number((event.detail && event.detail.value) || 0)
this.editingGender = this.genderOptions[index] || ''
},
async saveProfileBasic() {
const nickname = String(this.editingNickname || '').trim()
const realName = String(this.editingRealName || '').trim()
const gender = String(this.editingGender || '').trim()
try {
await updateUserProfileData({
userId: this.getProfileUserId() || undefined,
nickname,
realName,
sex: gender,
sexName: gender
})
this.profile = {
...this.profile,
nickname,
realName,
sex: gender,
sexName: gender
}
this.editingNickname = nickname
this.editingRealName = realName
this.editingGender = gender
saveLocalProfileOverride(this.getProfileUserId(), {
nickname,
realName,
sex: gender,
sexName: gender
})
uni.showToast({
title: '资料已保存',
icon: 'none'
})
} catch (error) {
uni.showToast({
title: (error && error.message) || '保存失败',
icon: 'none'
})
}
},
fillWechatNickname() {
// #ifdef MP-WEIXIN
const assignNickname = (userInfo) => {
const nickname = String((userInfo && (userInfo.nickName || userInfo.nickname)) || '').trim()
if (!nickname) {
uni.showToast({
title: '未读取到微信昵称',
icon: 'none'
})
return
}
this.editingNickname = nickname
uni.showToast({
title: '已读取微信昵称',
icon: 'none'
})
}
if (typeof uni.getUserProfile === 'function') {
uni.getUserProfile({
desc: '用于完善用户昵称',
success: (res) => {
assignNickname(res && res.userInfo)
},
fail: () => {
uni.showToast({
title: '未授权读取微信昵称',
icon: 'none'
})
}
})
return
}
if (typeof uni.getUserInfo === 'function') {
uni.getUserInfo({
success: (res) => {
assignNickname(res && res.userInfo)
},
fail: () => {
uni.showToast({
title: '读取微信昵称失败',
icon: 'none'
})
}
})
return
}
uni.showToast({
title: '当前环境不支持读取微信昵称',
icon: 'none'
})
return
// #endif
// #ifndef MP-WEIXIN
uni.showToast({
title: '仅微信小程序支持读取微信昵称',
icon: 'none'
})
// #endif
},
copyText(value) {
uni.setClipboardData({
data: String(value || ''),
success: () => {
uni.showToast({
title: '已复制',
icon: 'none'
})
}
})
},
goPage(url) {
uni.navigateTo({
url
})
}
}
}
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
background:
radial-gradient(circle at top center, rgba(20, 150, 242, 0.1), transparent 28%),
linear-gradient(180deg, #f5fbff 0%, #eef7ff 100%);
}
.page-scroll {
height: 100vh;
padding: 24rpx 24rpx 40rpx;
}
.hero-card,
.section-card {
border-radius: 32rpx;
background: rgba(255, 255, 255, 0.94);
border: 1rpx solid rgba(20, 150, 242, 0.08);
box-shadow: 0 10rpx 28rpx rgba(34, 94, 142, 0.08);
}
.hero-card {
padding: 34rpx 30rpx;
background: linear-gradient(145deg, #0d6fba 0%, #1496f2 58%, #5bc2ff 100%);
color: #fff;
}
.hero-top {
display: flex;
align-items: center;
}
.avatar-image,
.avatar-text {
width: 112rpx;
height: 112rpx;
border-radius: 32rpx;
background: rgba(255, 255, 255, 0.16);
}
.avatar-text {
display: flex;
align-items: center;
justify-content: center;
font-size: 30rpx;
font-weight: 700;
}
.hero-main {
flex: 1;
min-width: 0;
margin-left: 22rpx;
}
.hero-name {
font-size: 38rpx;
font-weight: 700;
}
.hero-role,
.hero-org {
margin-top: 10rpx;
font-size: 24rpx;
line-height: 1.6;
color: rgba(255, 255, 255, 0.84);
}
.hero-stats {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16rpx;
margin-top: 28rpx;
}
.stat-item {
padding: 20rpx 16rpx;
border-radius: 24rpx;
background: rgba(255, 255, 255, 0.12);
text-align: center;
}
.stat-value {
font-size: 28rpx;
font-weight: 700;
}
.stat-label {
margin-top: 8rpx;
font-size: 22rpx;
color: rgba(255, 255, 255, 0.82);
}
.section-card {
margin-top: 24rpx;
padding: 26rpx;
}
.section-title {
font-size: 30rpx;
font-weight: 700;
color: #1f2329;
}
.info-list {
display: flex;
flex-direction: column;
gap: 16rpx;
margin-top: 18rpx;
}
.info-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
padding: 20rpx 18rpx;
border-radius: 24rpx;
background: #faf5f2;
}
.info-main {
flex: 1;
min-width: 0;
}
.info-label {
font-size: 22rpx;
color: #8b817c;
}
.info-value {
margin-top: 8rpx;
font-size: 25rpx;
line-height: 1.6;
color: #2b3037;
word-break: break-all;
}
.info-action {
flex-shrink: 0;
font-size: 22rpx;
color: #1496f2;
}
.info-item--form {
align-items: flex-start;
}
:deep(.info-input.uv-input) {
width: 100%;
height: 92rpx;
margin-top: 12rpx;
padding: 0 24rpx !important;
border-radius: 24rpx;
background: #ffffff !important;
border: 1rpx solid #eadfd7 !important;
box-sizing: border-box;
}
:deep(.info-input.uv-input .uv-input__content__field-wrapper__field) {
font-size: 26rpx;
color: #2b3037;
}
.info-input--picker {
display: flex;
align-items: center;
}
.info-actions {
margin-top: 18rpx;
}
.nickname-btn {
height: 80rpx;
line-height: 80rpx;
border-radius: 22rpx;
font-size: 24rpx;
font-weight: 700;
text-align: center;
}
.nickname-btn--ghost {
background: #eef6ff;
color: #1496f2;
}
.nickname-btn--primary {
background: linear-gradient(135deg, #0d6fba 0%, #1496f2 100%);
color: #fff;
}
.nickname-tip {
margin-top: 14rpx;
font-size: 22rpx;
line-height: 1.6;
color: #8b817c;
}
.bio-text {
margin-top: 18rpx;
font-size: 24rpx;
line-height: 1.8;
color: #746a65;
}
.quick-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16rpx;
margin-top: 18rpx;
}
.quick-card {
padding: 22rpx 18rpx;
border-radius: 24rpx;
background: #fbf6f1;
text-align: center;
}
.quick-icon {
display: flex;
align-items: center;
justify-content: center;
width: 72rpx;
height: 72rpx;
margin: 0 auto;
border-radius: 22rpx;
background: rgba(20, 150, 242, 0.1);
color: #1496f2;
font-size: 28rpx;
font-weight: 700;
}
.quick-title {
margin-top: 14rpx;
font-size: 24rpx;
font-weight: 700;
color: #2b3037;
}
</style>

344
pages/mine/qingma.vue Normal file
View File

@@ -0,0 +1,344 @@
<template>
<view class="page">
<scroll-view scroll-y class="page-scroll">
<view class="hero-card">
<view class="hero-title">我的青马</view>
<view class="hero-desc">查看青马工程报名记录培养信息与当前审核状态</view>
<view class="hero-stats">
<view class="stat-item">
<view class="stat-value">{{ records.length }}</view>
<view class="stat-label">记录总数</view>
</view>
<view class="stat-item">
<view class="stat-value">{{ approvedCount }}</view>
<view class="stat-label">已通过</view>
</view>
<view class="stat-item">
<view class="stat-value">{{ pendingCount }}</view>
<view class="stat-label">待跟进</view>
</view>
</view>
</view>
<view v-if="loading" class="state-card">
<view class="state-title">正在加载青马数据...</view>
<view class="state-desc">请稍候正在同步你的青马工程记录</view>
</view>
<view v-else-if="errorText" class="state-card state-card--error">
<view class="state-title">加载失败</view>
<view class="state-desc">{{ errorText }}</view>
</view>
<view v-else-if="!records.length" class="state-card">
<view class="state-title">暂未查询到青马记录</view>
<view class="state-desc">当前账号还没有青马工程报名或培养数据可前往申报页查看开放项目</view>
<view class="state-action" @tap="goApplyList">查看申报列表</view>
</view>
<view v-else class="card-list">
<view v-for="item in records" :key="item.id || `${item.name}-${item.year}`" class="data-card">
<view class="card-top">
<view class="card-main">
<view class="card-title">{{ item.name || '未命名学员' }}</view>
<view class="card-subtitle">{{ item.year || '未设置年度' }} · 青马工程</view>
</view>
<view class="status-tag" :class="`status-tag--${getStatusMeta(item).tone}`">
{{ getStatusMeta(item).text }}
</view>
</view>
<view class="meta-list">
<view class="meta-item">
<view class="meta-label">学校院系</view>
<view class="meta-value">{{ item.schoolInfo || '-' }}</view>
</view>
<view class="meta-item">
<view class="meta-label">手机号码</view>
<view class="meta-value">{{ item.phone || '-' }}</view>
</view>
<view class="meta-item">
<view class="meta-label">团学职务</view>
<view class="meta-value">{{ item.leaguePosition || '-' }}</view>
</view>
<view class="meta-item">
<view class="meta-label">综合成绩</view>
<view class="meta-value">{{ item.academicPerformance || '-' }}</view>
</view>
</view>
<view class="card-actions">
<view class="card-action" @tap="copyValue(item.phone)">复制电话</view>
<view class="card-action" @tap="copyValue(item.schoolInfo)">复制院系</view>
</view>
</view>
</view>
</scroll-view>
</view>
</template>
<script>
import { getReviewStatusMeta, userPageQmgcForm } from './service'
export default {
data() {
return {
loading: false,
errorText: '',
records: []
}
},
computed: {
approvedCount() {
return this.records.filter((item) => this.getStatusMeta(item).text === '通过').length
},
pendingCount() {
return this.records.filter((item) => this.getStatusMeta(item).text !== '通过').length
}
},
onShow() {
this.loadData()
},
onPullDownRefresh() {
this.loadData(true)
},
methods: {
getStatusMeta(item) {
return getReviewStatusMeta(item && item.reviewList)
},
async loadData(fromPullDown = false) {
this.loading = true
this.errorText = ''
try {
const result = await userPageQmgcForm({
page: 1,
limit: 20
})
this.records = (result && result.list) || []
} catch (error) {
this.errorText = (error && error.message) || '青马数据加载失败'
} finally {
this.loading = false
if (fromPullDown) {
uni.stopPullDownRefresh()
}
}
},
copyValue(value) {
if (!value) {
uni.showToast({
title: '暂无可复制内容',
icon: 'none'
})
return
}
uni.setClipboardData({
data: String(value),
success: () => {
uni.showToast({
title: '已复制',
icon: 'none'
})
}
})
},
goApplyList() {
uni.navigateTo({
url: '/pages/gxmu/index'
})
}
}
}
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
background:
radial-gradient(circle at top center, rgba(184, 146, 33, 0.1), transparent 30%),
linear-gradient(180deg, #f8f4eb 0%, #f4efe5 100%);
}
.page-scroll {
height: 100vh;
padding: 24rpx 24rpx 40rpx;
}
.hero-card,
.state-card,
.data-card {
border-radius: 30rpx;
background: rgba(255, 255, 255, 0.94);
border: 1rpx solid rgba(184, 146, 33, 0.08);
box-shadow: 0 10rpx 28rpx rgba(76, 49, 35, 0.08);
}
.hero-card {
padding: 32rpx 28rpx;
background: linear-gradient(145deg, #6f4f05 0%, #a37a11 56%, #d4b24a 100%);
color: #fffdf4;
}
.hero-title {
font-size: 40rpx;
font-weight: 700;
}
.hero-desc {
margin-top: 14rpx;
font-size: 24rpx;
line-height: 1.7;
color: rgba(255, 253, 244, 0.86);
}
.hero-stats {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16rpx;
margin-top: 24rpx;
}
.stat-item {
padding: 18rpx 16rpx;
border-radius: 22rpx;
background: rgba(255, 255, 255, 0.12);
text-align: center;
}
.stat-value {
font-size: 32rpx;
font-weight: 700;
}
.stat-label {
margin-top: 6rpx;
font-size: 22rpx;
color: rgba(255, 253, 244, 0.8);
}
.state-card {
margin-top: 24rpx;
padding: 28rpx 24rpx;
}
.state-card--error {
border-color: rgba(195, 49, 30, 0.16);
}
.state-title {
font-size: 30rpx;
font-weight: 700;
color: #20252c;
}
.state-desc {
margin-top: 12rpx;
font-size: 24rpx;
line-height: 1.75;
color: #786e69;
}
.state-action {
margin-top: 18rpx;
font-size: 24rpx;
color: #a37a11;
font-weight: 700;
}
.card-list {
display: flex;
flex-direction: column;
gap: 18rpx;
margin-top: 24rpx;
}
.data-card {
padding: 24rpx;
}
.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: 30rpx;
font-weight: 700;
color: #22272f;
}
.card-subtitle {
margin-top: 8rpx;
font-size: 22rpx;
color: #8b817c;
}
.status-tag {
padding: 10rpx 16rpx;
border-radius: 999rpx;
font-size: 20rpx;
font-weight: 700;
}
.status-tag--success {
background: #eef9f1;
color: #1f8f49;
}
.status-tag--danger {
background: #fff1f0;
color: #d4380d;
}
.status-tag--warning {
background: #fff8e6;
color: #ad7b00;
}
.status-tag--default {
background: #f5f5f5;
color: #8b817c;
}
.meta-list {
display: flex;
flex-direction: column;
gap: 14rpx;
margin-top: 18rpx;
}
.meta-item {
padding: 18rpx;
border-radius: 22rpx;
background: #faf7f0;
}
.meta-label {
font-size: 20rpx;
color: #8b817c;
}
.meta-value {
margin-top: 8rpx;
font-size: 24rpx;
line-height: 1.7;
color: #2d333b;
word-break: break-all;
}
.card-actions {
display: flex;
gap: 14rpx;
margin-top: 18rpx;
}
.card-action {
padding: 10rpx 18rpx;
border-radius: 999rpx;
background: rgba(163, 122, 17, 0.08);
color: #a37a11;
font-size: 22rpx;
}
</style>

187
pages/mine/service.js Normal file
View File

@@ -0,0 +1,187 @@
import {
API_BASE_URL,
apiRequest,
getCurrentUser
} from '../assistant/chat-service'
import { clearAuthStorage, ensureLoggedIn, get, hasToken } from '../../utils/request'
import { getModuleMeta } from '../../utils/gxmu/config'
const LOCAL_DECLARE_PREFIX = 'gxmu_form_draft_'
const LOCAL_PROFILE_OVERRIDE_PREFIX = 'mine_profile_override_'
export { getCurrentUser }
export { hasToken, ensureLoggedIn }
export function logout() {
clearAuthStorage()
}
function getProfileOverrideStorageKey(userId) {
const normalizedUserId = String(userId || '').trim()
return `${LOCAL_PROFILE_OVERRIDE_PREFIX}${normalizedUserId || 'default'}`
}
export function getLocalProfileOverride(userId) {
try {
return uni.getStorageSync(getProfileOverrideStorageKey(userId)) || {}
} catch (error) {
return {}
}
}
export function saveLocalProfileOverride(userId, payload) {
const value = payload && typeof payload === 'object' ? payload : {}
uni.setStorageSync(getProfileOverrideStorageKey(userId), value)
return value
}
export function getUserProfile() {
return apiRequest({
url: `${API_BASE_URL}/auth/user`,
method: 'GET'
})
}
export function updateUserProfileData(data) {
return apiRequest({
url: `${API_BASE_URL}/system/user/update-data`,
method: 'POST',
data
})
}
export function listOrganizations(params) {
return get('/system/organization', params)
}
export function listManuscript(params) {
return apiRequest({
url: `${API_BASE_URL}/cms/manuscript`,
method: 'GET',
data: params
})
}
export function userPageQmgcForm(params) {
return apiRequest({
url: `${API_BASE_URL}/gxmu/qmgc-form/userPage`,
method: 'GET',
data: params
})
}
export function listAvailableDeclare(params) {
return apiRequest({
url: `${API_BASE_URL}/gxmu/declare`,
method: 'GET',
data: params
})
}
export function 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())}`
}
export function getLatestReview(reviewList) {
if (!Array.isArray(reviewList) || !reviewList.length) {
return null
}
return reviewList[reviewList.length - 1] || null
}
export function getReviewStatusMeta(reviewList) {
const latest = getLatestReview(reviewList)
if (!latest) {
return {
text: '未审核',
tone: 'default'
}
}
if (latest.status === 1) {
return {
text: '通过',
tone: 'success'
}
}
if (latest.status === 0 || latest.status === 2) {
return {
text: '不通过',
tone: 'danger'
}
}
return {
text: '待审核',
tone: 'warning'
}
}
export function getManuscriptStatusMeta(record) {
const reviewMeta = getReviewStatusMeta(record && record.reviewList)
if (reviewMeta.text !== '未审核') {
return reviewMeta
}
if (record && Number(record.status) === 1) {
return {
text: '已发布',
tone: 'success'
}
}
if (record && Number(record.status) === 0) {
return {
text: '待审核',
tone: 'warning'
}
}
return {
text: '未审核',
tone: 'default'
}
}
export function getLocalDeclareRecords() {
try {
const storageInfo = uni.getStorageInfoSync()
const keys = (storageInfo.keys || []).filter((key) => key.indexOf(LOCAL_DECLARE_PREFIX) === 0)
return keys
.map((key) => {
const draft = uni.getStorageSync(key)
if (!draft || !draft.formData) {
return null
}
const moduleCode = draft.moduleCode || ''
if (!moduleCode) {
return null
}
const moduleMeta = getModuleMeta(moduleCode)
const year = String((draft.formData && draft.formData.year) || '').trim()
return {
key,
moduleCode,
title: (moduleMeta && moduleMeta.title) || '未命名申报',
group: (moduleMeta && moduleMeta.group) || '申报项目',
icon: (moduleMeta && moduleMeta.icon) || '申',
year,
savedAt: draft.savedAt || '',
status: draft.status === 'submitted' ? '已提交' : '草稿',
formData: draft.formData,
route: `/pages/gxmu/form?code=${encodeURIComponent(moduleCode)}&year=${encodeURIComponent(year)}&declareTitle=${encodeURIComponent((moduleMeta && moduleMeta.title) || '申报表')}`
}
})
.filter(Boolean)
.sort((left, right) => {
const leftTime = new Date(String((left && left.savedAt) || '').replace(/-/g, '/')).getTime()
const rightTime = new Date(String((right && right.savedAt) || '').replace(/-/g, '/')).getTime()
return (Number.isNaN(rightTime) ? 0 : rightTime) - (Number.isNaN(leftTime) ? 0 : leftTime)
})
} catch (error) {
return []
}
}

1260
pages/tygl/index.vue Normal file

File diff suppressed because it is too large Load Diff

47
pages/tygl/service.js Normal file
View File

@@ -0,0 +1,47 @@
import { get } from '../../utils/request'
import { API_BASE_URL, apiRequest, getCurrentUser } from '../assistant/chat-service'
export { getCurrentUser }
export function listDictData(params) {
return get('/system/dict-data', params)
}
export function listOrganizations(params) {
return get('/system/organization', params)
}
export function listColleges(params) {
return get('/gxmu/college', params)
}
export function listClasses(params) {
return get('/gxmu/class', params)
}
export function getUserProfile() {
return apiRequest({
url: `${API_BASE_URL}/auth/user`,
method: 'GET'
})
}
export function getCurrentTyglForm() {
return get('/gxmu/tygl-form/current')
}
export function saveTyglForm(data) {
return apiRequest({
url: `${API_BASE_URL}/gxmu/tygl-form`,
method: 'POST',
data
})
}
export function updateTyglForm(data) {
return apiRequest({
url: `${API_BASE_URL}/gxmu/tygl-form`,
method: 'PUT',
data
})
}