260720
This commit is contained in:
881
pages/activity-outline/index.vue
Normal file
881
pages/activity-outline/index.vue
Normal 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>
|
||||
17
pages/activity-outline/service.js
Normal file
17
pages/activity-outline/service.js
Normal 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
|
||||
})
|
||||
}
|
||||
447
pages/assistant/chat-service.js
Normal file
447
pages/assistant/chat-service.js
Normal 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
1725
pages/assistant/index.vue
Normal file
File diff suppressed because it is too large
Load Diff
1038
pages/creative-assistant/index.vue
Normal file
1038
pages/creative-assistant/index.vue
Normal file
File diff suppressed because it is too large
Load Diff
45
pages/creative-assistant/service.js
Normal file
45
pages/creative-assistant/service.js
Normal 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
395
pages/gxmu/config.js
Normal 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
|
||||
}, {})
|
||||
}
|
||||
886
pages/gxmu/cross-school-board.vue
Normal file
886
pages/gxmu/cross-school-board.vue
Normal 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>
|
||||
10
pages/gxmu/declare-service.js
Normal file
10
pages/gxmu/declare-service.js
Normal 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
892
pages/gxmu/form.vue
Normal 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
697
pages/gxmu/index.vue
Normal 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>
|
||||
212
pages/gxmu/module-form-service.js
Normal file
212
pages/gxmu/module-form-service.js
Normal 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
432
pages/gxmu/qmgc.vue
Normal 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
1297
pages/gxmu/review-list.vue
Normal file
File diff suppressed because it is too large
Load Diff
10
pages/gxmu/stats-service.js
Normal file
10
pages/gxmu/stats-service.js
Normal 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
735
pages/gxmu/stats.vue
Normal 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>
|
||||
19
pages/gxmu/tzb-database-service.js
Normal file
19
pages/gxmu/tzb-database-service.js
Normal 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
205
pages/gxmu/tzb-database.vue
Normal 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--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>
|
||||
661
pages/gxmu/tzb-project-list.vue
Normal file
661
pages/gxmu/tzb-project-list.vue
Normal 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>
|
||||
680
pages/gxmu/tzb-talent-list.vue
Normal file
680
pages/gxmu/tzb-talent-list.vue
Normal 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
787
pages/gxmu/tzbcy.vue
Normal 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
192
pages/gxmu/wxxzx.vue
Normal 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
568
pages/index/index.vue
Normal 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
298
pages/login/index.vue
Normal 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
21
pages/login/service.js
Normal 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
1112
pages/manuscript/index.vue
Normal file
File diff suppressed because it is too large
Load Diff
52
pages/manuscript/service.js
Normal file
52
pages/manuscript/service.js
Normal 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
355
pages/mine/declare.vue
Normal 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
501
pages/mine/index.vue
Normal 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
422
pages/mine/manuscripts.vue
Normal 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
566
pages/mine/profile.vue
Normal 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--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
344
pages/mine/qingma.vue
Normal 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
187
pages/mine/service.js
Normal 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
1260
pages/tygl/index.vue
Normal file
File diff suppressed because it is too large
Load Diff
47
pages/tygl/service.js
Normal file
47
pages/tygl/service.js
Normal 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
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user