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

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
node_modules/
unpackage/
.hbuilderx/
.DS_Store
/.claude/
/.idea/

33
App.vue Normal file
View File

@@ -0,0 +1,33 @@
<script>
export default {
onLaunch: function() {
console.log('App Launch')
},
onShow: function() {
console.log('App Show')
},
onHide: function() {
console.log('App Hide')
}
}
</script>
<style lang="scss">
@import "@/uni_modules/uv-ui-tools/index.scss";
page {
background: #f4faff;
color: #1f2329;
font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
}
view,
scroll-view,
swiper,
swiper-item,
button,
input,
textarea {
box-sizing: border-box;
}
</style>

View File

@@ -0,0 +1,195 @@
<template>
<view class="hero-section" :style="heroStyle">
<!-- <view class="hero-bg hero-bg-left"></view>-->
<!-- <view class="hero-bg hero-bg-right"></view>-->
<view class="navbar" :style="navbarStyle">
<view class="navbar-inner">
<view class="navbar-left" v-if="showBack">
<view class="nav-back-btn" @tap="handleBack">
<uv-icon name="arrow-left" color="#101214" size="22"></uv-icon>
</view>
</view>
<view class="navbar-title">{{ title }}</view>
<view class="navbar-right-placeholder"></view>
</view>
</view>
<view class="navbar-spacer" :style="{ height: navbarHeight + 'px' }"></view>
<slot></slot>
</view>
</template>
<script>
const TAB_BAR_PATHS = ['/pages/index/index', '/pages/assistant/index', '/pages/mine/index']
export default {
name: 'CommonHero',
props: {
title: {
type: String,
default: ''
},
useImageBg: {
type: Boolean,
default: false
},
backgroundImage: {
type: String,
default: '/static/indexBg.jpg'
},
reduceTop: {
type: Number,
default: 0
}
},
data() {
return {
statusBarHeight: 0,
currentRoute: ''
}
},
computed: {
showBack() {
return TAB_BAR_PATHS.indexOf(this.currentRoute) === -1
},
navbarHeight() {
return this.statusBarHeight + uni.upx2px(88) - uni.upx2px(this.reduceTop)
},
navbarStyle() {
return {
paddingTop: `${this.statusBarHeight}px`
}
},
heroStyle() {
if (this.useImageBg) {
return {
backgroundImage: `url('${this.backgroundImage}')`,
backgroundSize: 'cover',
backgroundPosition: 'center top',
backgroundRepeat: 'no-repeat'
}
}
return {
background: 'linear-gradient(180deg, #f7fbff 0%, #d9f0ff 44%, #f4fbff 100%)'
}
}
},
mounted() {
const { statusBarHeight = 0 } = uni.getSystemInfoSync()
this.statusBarHeight = statusBarHeight
const pages = getCurrentPages()
const currentPage = pages[pages.length - 1]
const route = currentPage && currentPage.route ? currentPage.route : ''
this.currentRoute = route ? `/${route}` : ''
},
methods: {
handleBack() {
const pages = getCurrentPages()
if (pages.length > 1) {
uni.navigateBack({
delta: 1
})
return
}
uni.switchTab({
url: '/pages/index/index'
})
}
}
}
</script>
<style lang="scss" scoped>
.hero-section {
position: relative;
//padding: 0 0 30rpx;
overflow: hidden;
backgroud: url('@/static/indexBg.jpg');
}
.hero-bg {
position: absolute;
border-radius: 50%;
filter: blur(8rpx);
opacity: 0.9;
backgroud: url('@/static/indexBg.jpg');
}
.hero-bg-left {
top: 84rpx;
left: -30rpx;
width: 300rpx;
height: 200rpx;
background: radial-gradient(circle, rgba(197, 237, 255, 0.95) 0%, rgba(197, 237, 255, 0) 72%);
}
.hero-bg-right {
top: 46rpx;
right: -40rpx;
width: 360rpx;
height: 260rpx;
background: radial-gradient(circle, rgba(184, 231, 255, 0.92) 0%, rgba(184, 231, 255, 0) 72%);
}
.navbar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
padding-left: 28rpx;
padding-right: 20rpx;
background: rgba(255, 255, 255, 0.72);
backdrop-filter: blur(18rpx);
-webkit-backdrop-filter: blur(18rpx);
box-shadow: 0 10rpx 28rpx rgba(65, 105, 135, 0.08);
}
.navbar-inner {
height: 88rpx;
display: flex;
align-items: center;
justify-content: space-between;
}
.navbar-left {
width: 72rpx;
height: 72rpx;
display: flex;
align-items: center;
justify-content: center;
}
.nav-back-btn {
width: 72rpx;
height: 72rpx;
border-radius: 24rpx;
background: rgba(255, 255, 255, 0.72);
box-shadow: 0 8rpx 18rpx rgba(115, 164, 196, 0.14);
display: flex;
align-items: center;
justify-content: center;
}
.navbar-title {
flex: 1;
font-size: 28rpx;
font-weight: 700;
color: #101214;
letter-spacing: 2rpx;
text-align: center;
}
.navbar-spacer {
width: 100%;
}
.navbar-right-placeholder {
width: 72rpx;
height: 72rpx;
flex-shrink: 0;
opacity: 0;
}
</style>

View File

@@ -0,0 +1,112 @@
<template>
<view class="u-input" :style="mergedStyle">
<input
class="u-input__inner"
:type="type"
:placeholder="placeholder"
:placeholder-style="placeholderStyle"
:value="inputValue"
:maxlength="maxlength"
:disabled="disabled"
:confirm-type="confirmType"
@input="handleInput"
@confirm="handleConfirm"
@focus="handleFocus"
@blur="handleBlur"
/>
</view>
</template>
<script>
export default {
name: 'UInput',
props: {
modelValue: {
type: [String, Number],
default: ''
},
value: {
type: [String, Number],
default: ''
},
type: {
type: String,
default: 'text'
},
placeholder: {
type: String,
default: ''
},
placeholderStyle: {
type: String,
default: 'color: #c0c4cc;'
},
maxlength: {
type: [String, Number],
default: 140
},
disabled: {
type: Boolean,
default: false
},
confirmType: {
type: String,
default: 'done'
},
customStyle: {
type: [String, Object],
default: ''
}
},
computed: {
inputValue() {
return this.modelValue !== '' && this.modelValue !== undefined
? this.modelValue
: this.value
},
mergedStyle() {
if (typeof this.customStyle === 'string') {
return this.customStyle
}
return this.customStyle || {}
}
},
methods: {
handleInput(event) {
const value = event.detail.value
this.$emit('update:modelValue', value)
this.$emit('input', value)
this.$emit('change', value)
},
handleConfirm(event) {
this.$emit('confirm', event)
},
handleFocus(event) {
this.$emit('focus', event)
},
handleBlur(event) {
this.$emit('blur', event)
}
}
}
</script>
<style lang="scss" scoped>
.u-input {
display: flex;
align-items: center;
width: 100%;
box-sizing: border-box;
}
.u-input__inner {
width: 100%;
height: 100%;
min-height: inherit;
font-size: inherit;
color: inherit;
background: transparent;
border: 0;
outline: none;
}
</style>

20
index.html Normal file
View File

@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<script>
var coverSupport = 'CSS' in window && typeof CSS.supports === 'function' && (CSS.supports('top: env(a)') ||
CSS.supports('top: constant(a)'))
document.write(
'<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0' +
(coverSupport ? ', viewport-fit=cover' : '') + '" />')
</script>
<title></title>
<!--preload-links-->
<!--app-context-->
</head>
<body>
<div id="app"><!--app-html--></div>
<script type="module" src="/main.js"></script>
</body>
</html>

27
main.js Normal file
View File

@@ -0,0 +1,27 @@
import App from './App'
import uvUI from '@/uni_modules/uv-ui-tools'
// #ifndef VUE3
import Vue from 'vue'
import './uni.promisify.adaptor'
Vue.config.productionTip = false
Vue.use(uvUI)
App.mpType = 'app'
const app = new Vue({
...App
})
app.$mount()
// #endif
// #ifdef VUE3
import { createSSRApp } from 'vue'
export function createApp() {
const app = createSSRApp(App)
app.use(uvUI)
return {
app
}
}
// #endif

72
manifest.json Normal file
View File

@@ -0,0 +1,72 @@
{
"name" : "tuanwei",
"appid" : "__UNI__F254BAB",
"description" : "",
"versionName" : "1.0.0",
"versionCode" : "100",
"transformPx" : false,
/* 5+App */
"app-plus" : {
"usingComponents" : true,
"nvueStyleCompiler" : "uni-app",
"compilerVersion" : 3,
"splashscreen" : {
"alwaysShowBeforeRender" : true,
"waiting" : true,
"autoclose" : true,
"delay" : 0
},
/* */
"modules" : {},
/* */
"distribute" : {
/* android */
"android" : {
"permissions" : [
"<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>",
"<uses-permission android:name=\"android.permission.MOUNT_UNMOUNT_FILESYSTEMS\"/>",
"<uses-permission android:name=\"android.permission.VIBRATE\"/>",
"<uses-permission android:name=\"android.permission.READ_LOGS\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>",
"<uses-feature android:name=\"android.hardware.camera.autofocus\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
"<uses-permission android:name=\"android.permission.CAMERA\"/>",
"<uses-permission android:name=\"android.permission.GET_ACCOUNTS\"/>",
"<uses-permission android:name=\"android.permission.READ_PHONE_STATE\"/>",
"<uses-permission android:name=\"android.permission.CHANGE_WIFI_STATE\"/>",
"<uses-permission android:name=\"android.permission.WAKE_LOCK\"/>",
"<uses-permission android:name=\"android.permission.FLASHLIGHT\"/>",
"<uses-feature android:name=\"android.hardware.camera\"/>",
"<uses-permission android:name=\"android.permission.WRITE_SETTINGS\"/>"
]
},
/* ios */
"ios" : {},
/* SDK */
"sdkConfigs" : {}
}
},
/* */
"quickapp" : {},
/* */
"mp-weixin" : {
"appid" : "wxf7ee9cd5801bf7a6",
"setting" : {
"urlCheck" : false
},
"usingComponents" : true
},
"mp-alipay" : {
"usingComponents" : true
},
"mp-baidu" : {
"usingComponents" : true
},
"mp-toutiao" : {
"usingComponents" : true
},
"uniStatistics" : {
"enable" : false
},
"vueVersion" : "3"
}

373
packageMine/declare.vue Normal file
View File

@@ -0,0 +1,373 @@
<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-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 CommonHero from '../components/common-hero/common-hero.vue'
import {
formatDateTime,
getLocalDeclareRecords,
listAvailableDeclare
} from '../pages/mine/service'
import { getFormConfig, getModuleMeta } from '../utils/gxmu/config'
export default {
components: {
CommonHero
},
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;
}
.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-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;
}
.section-card,
.data-card {
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-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>

441
packageMine/manuscripts.vue Normal file
View File

@@ -0,0 +1,441 @@
<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-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 CommonHero from '../components/common-hero/common-hero.vue'
import {
formatDateTime,
getCurrentUser,
getManuscriptStatusMeta,
listManuscript
} from '../pages/mine/service'
export default {
components: {
CommonHero
},
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;
}
.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-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;
}
.toolbar-card,
.state-card,
.data-card {
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-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>

932
packageMine/profile.vue Normal file
View File

@@ -0,0 +1,932 @@
<template>
<view class="page">
<scroll-view scroll-y class="page-scroll">
<common-hero title="用户资料" :use-image-bg="true" :reduce-top="24"></common-hero>
<view class="section-card">
<view class="section-title">基础信息</view>
<view class="info-list">
<!-- <view class="info-item info-item&#45;&#45;form">-->
<!-- <view class="info-main">-->
<!-- <view class="info-label">登录账号</view>-->
<!-- <view class="info-value">{{ profile.username || '-' }}</view>-->
<!-- </view>-->
<!-- </view>-->
<view class="info-item info-item--form">
<view class="info-main">
<view class="info-label">姓名</view>
<uv-input
class="info-input"
:value="editingRealName"
maxlength="20"
placeholder="请输入姓名"
placeholder-style="color: #b2a7a1;"
border="none"
@input="handleRealNameInput"
/>
</view>
</view>
<view class="info-item info-item--form">
<view class="info-main">
<view class="info-label">昵称</view>
<uv-input
class="info-input"
type="nickname"
:value="editingNickname"
maxlength="20"
placeholder="请输入昵称"
placeholder-style="color: #b2a7a1;"
border="none"
@input="handleNicknameInput"
/>
</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-input info-input--picker" @tap="openOrganizationPicker">
{{ editingOrganizationName || '请选择所属组织机构' }}
</view>
</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 v-if="organizationPickerVisible" class="organization-mask" @tap="closeOrganizationPicker">
<view class="organization-panel" @tap.stop>
<view class="organization-head">
<view class="organization-title">选择所属组织机构</view>
<view class="organization-close" @tap="closeOrganizationPicker">关闭</view>
</view>
<input
v-model="organizationKeyword"
class="organization-search"
type="text"
placeholder="搜索组织机构"
placeholder-style="color: #b2a7a1;"
/>
<scroll-view scroll-y class="organization-scroll">
<view v-if="filteredOrganizationTree.length" class="organization-tree">
<view
v-for="item in filteredOrganizationTree"
:key="item.value"
class="organization-tree-node"
>
<view
class="organization-item"
:class="{ 'organization-item--active': String(item.value) === String(editingOrganizationId) }"
:style="{ paddingLeft: `${20 + item.level * 28}rpx` }"
>
<view class="organization-item-main" @tap="selectOrganization(item)">
<view class="organization-item-name">{{ item.rawLabel || item.text }}</view>
</view>
<view
v-if="item.children && item.children.length"
class="organization-item-toggle"
@tap.stop="toggleOrganizationNode(item.value)"
>
{{ isOrganizationExpanded(item.value) ? '收起' : '展开' }}
</view>
</view>
</view>
</view>
<view v-else class="organization-empty">没有匹配的组织机构</view>
</scroll-view>
</view>
</view>
</view>
</template>
<script>
import CommonHero from '../components/common-hero/common-hero.vue'
import {
getCurrentUser,
getLocalProfileOverride,
getUserProfile,
listOrganizations,
saveLocalProfileOverride,
updateUserProfileData
} from '../pages/mine/service'
export default {
components: {
CommonHero
},
data() {
return {
editingNickname: '',
editingRealName: '',
editingGender: '',
editingOrganizationId: '',
editingOrganizationName: '',
genderOptions: ['男', '女', '未知'],
organizationTree: [],
organizationPickerVisible: false,
organizationKeyword: '',
organizationExpandedMap: {},
profile: {
userId: '',
nickname: '',
realName: '',
username: '',
avatar: '',
phone: '',
email: '',
organizationId: '',
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
},
filteredOrganizationTree() {
const keyword = String(this.organizationKeyword || '').trim().toLowerCase()
const source = Array.isArray(this.organizationTree) ? this.organizationTree : []
if (!keyword) {
return this.flattenVisibleOrganizationTree(source)
}
const filteredTree = this.filterOrganizationTree(source, keyword)
return this.flattenVisibleOrganizationTree(filteredTree, true)
}
},
onShow() {
this.loadProfile()
this.loadOrganizationOptions()
},
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
}
}
if (override.organizationId !== undefined || override.organizationName !== undefined) {
this.profile = {
...this.profile,
organizationId: override.organizationId || '',
organizationName: String(override.organizationName || '').trim()
}
}
this.editingNickname = this.profile.nickname || ''
this.editingRealName = this.profile.realName || ''
this.editingGender = this.profile.sexName || this.profile.sex || ''
this.editingOrganizationId = this.profile.organizationId || ''
this.editingOrganizationName = this.profile.organizationName || ''
},
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()
}
}
},
async loadOrganizationOptions() {
try {
const organizationList = await listOrganizations()
if (!Array.isArray(organizationList) || !organizationList.length) {
this.organizationTree = []
this.organizationExpandedMap = {}
return
}
this.organizationTree = this.buildOrganizationTree(organizationList)
this.organizationExpandedMap = this.buildOrganizationExpandedMap(this.organizationTree, this.editingOrganizationId)
if (this.editingOrganizationId && !this.editingOrganizationName) {
const matched = this.findOrganizationNodeByValue(this.organizationTree, this.editingOrganizationId)
if (matched) {
this.editingOrganizationName = this.buildOrganizationLabel(this.organizationTree, this.editingOrganizationId)
}
}
} catch (error) {
this.organizationTree = []
this.organizationExpandedMap = {}
}
},
normalizeOrganizationNode(item) {
return {
text: item.organizationName || item.name || item.label || '',
value: Number(item.organizationId || item.id || item.value || 0) || String(item.organizationId || item.id || item.value || ''),
rawLabel: item.organizationName || item.name || item.label || '',
parentId: Number(item.parentId || item.parentID || item.pid || 0) || 0,
children: []
}
},
buildOrganizationTree(list) {
const source = Array.isArray(list) ? list : []
const nodeMap = {}
source.forEach((item) => {
const normalized = this.normalizeOrganizationNode(item)
if (normalized.text && normalized.value !== '') {
nodeMap[String(normalized.value)] = normalized
}
})
const roots = []
Object.keys(nodeMap).forEach((key) => {
const current = nodeMap[key]
const parentKey = String(current.parentId || 0)
if (current.parentId && nodeMap[parentKey]) {
nodeMap[parentKey].children.push(current)
} else {
roots.push(current)
}
})
return roots
},
findOrganizationNodeByValue(list, targetValue) {
const source = Array.isArray(list) ? list : []
const normalizedTarget = String(targetValue || '')
for (let index = 0; index < source.length; index += 1) {
const item = source[index]
if (String(item.value) === normalizedTarget) {
return item
}
if (Array.isArray(item.children) && item.children.length) {
const matched = this.findOrganizationNodeByValue(item.children, targetValue)
if (matched) {
return matched
}
}
}
return null
},
findOrganizationPathNodes(list, targetValue, path = []) {
const source = Array.isArray(list) ? list : []
const normalizedTarget = String(targetValue || '')
for (let index = 0; index < source.length; index += 1) {
const item = source[index]
const nextPath = path.concat(item)
if (String(item.value) === normalizedTarget) {
return nextPath
}
if (Array.isArray(item.children) && item.children.length) {
const matched = this.findOrganizationPathNodes(item.children, targetValue, nextPath)
if (matched) {
return matched
}
}
}
return null
},
buildOrganizationLabel(list, targetValue) {
const pathNodes = this.findOrganizationPathNodes(list, targetValue) || []
return pathNodes.map((item) => item.rawLabel || item.text).filter(Boolean).join(' / ')
},
buildOrganizationExpandedMap(list, targetValue) {
const expandedMap = {}
const pathNodes = this.findOrganizationPathNodes(list, targetValue) || []
pathNodes.forEach((item) => {
expandedMap[String(item.value)] = true
})
return expandedMap
},
filterOrganizationTree(list, keyword) {
return (Array.isArray(list) ? list : [])
.map((item) => {
const children = this.filterOrganizationTree(item.children || [], keyword)
const text = String(item.rawLabel || item.text || '').toLowerCase()
if (text.includes(keyword) || children.length) {
return {
...item,
children
}
}
return null
})
.filter(Boolean)
},
flattenVisibleOrganizationTree(list, forceExpand = false, level = 0) {
const result = []
;(Array.isArray(list) ? list : []).forEach((item) => {
result.push({
...item,
level
})
const shouldExpand = forceExpand || this.isOrganizationExpanded(item.value)
if (shouldExpand && Array.isArray(item.children) && item.children.length) {
result.push(...this.flattenVisibleOrganizationTree(item.children, forceExpand, level + 1))
}
})
return result
},
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] || ''
},
openOrganizationPicker() {
if (!this.organizationTree.length) {
uni.showToast({
title: '暂无组织机构数据',
icon: 'none'
})
return
}
this.organizationKeyword = ''
this.organizationPickerVisible = true
},
closeOrganizationPicker() {
this.organizationPickerVisible = false
},
isOrganizationExpanded(value) {
return !!this.organizationExpandedMap[String(value)]
},
toggleOrganizationNode(value) {
const key = String(value)
this.organizationExpandedMap = {
...this.organizationExpandedMap,
[key]: !this.organizationExpandedMap[key]
}
},
selectOrganization(item) {
this.editingOrganizationId = item.value
this.editingOrganizationName = this.buildOrganizationLabel(this.organizationTree, item.value)
this.closeOrganizationPicker()
},
async saveProfileBasic() {
const nickname = String(this.editingNickname || '').trim()
const realName = String(this.editingRealName || '').trim()
const gender = String(this.editingGender || '').trim()
const organizationId = this.editingOrganizationId
const organizationName = String(this.editingOrganizationName || '').trim()
try {
await updateUserProfileData({
userId: this.getProfileUserId() || undefined,
nickname,
realName,
sex: gender,
sexName: gender,
organizationId,
organizationName
})
this.profile = {
...this.profile,
nickname,
realName,
sex: gender,
sexName: gender,
organizationId,
organizationName
}
this.editingNickname = nickname
this.editingRealName = realName
this.editingGender = gender
this.editingOrganizationId = organizationId
this.editingOrganizationName = organizationName
saveLocalProfileOverride(this.getProfileUserId(), {
nickname,
realName,
sex: gender,
sexName: gender,
organizationId,
organizationName
})
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;
min-height: 92rpx;
margin-top: 12rpx;
padding: 0 24rpx;
border-radius: 24rpx;
background: #ffffff;
border: 1rpx solid #eadfd7;
box-sizing: border-box;
font-size: 26rpx;
color: #2b3037;
}
.organization-mask {
position: fixed;
inset: 0;
z-index: 99;
background: rgba(44, 28, 23, 0.28);
display: flex;
align-items: flex-end;
}
.organization-panel {
width: 100%;
max-height: 76vh;
padding: 28rpx 24rpx calc(24rpx + env(safe-area-inset-bottom));
border-radius: 32rpx 32rpx 0 0;
background: #fff;
box-sizing: border-box;
display: flex;
flex-direction: column;
}
.organization-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
}
.organization-title {
font-size: 30rpx;
font-weight: 700;
color: #1f2329;
}
.organization-close {
padding: 10rpx 18rpx;
border-radius: 18rpx;
background: rgba(20, 150, 242, 0.08);
color: #1496f2;
font-size: 24rpx;
}
.organization-search {
width: 100%;
height: 88rpx;
margin-top: 20rpx;
padding: 0 24rpx;
border-radius: 24rpx;
background: #ffffff;
border: 1rpx solid #eadfd7;
box-sizing: border-box;
font-size: 26rpx;
color: #2b3037;
}
.organization-scroll {
flex: 1;
min-height: 0;
margin-top: 20rpx;
}
.organization-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
padding: 10rpx 20rpx;
border-radius: 22rpx;
//background: #faf5f2;
}
.organization-item + .organization-item {
margin-top: 14rpx;
}
.organization-item--active {
background: rgba(20, 150, 242, 0.08);
border: 1rpx solid rgba(20, 150, 242, 0.22);
}
.organization-item-name {
font-size: 25rpx;
line-height: 1.6;
color: #2b3037;
word-break: break-all;
}
.organization-item-main {
flex: 1;
min-width: 0;
}
.organization-item-toggle {
flex-shrink: 0;
padding: 8rpx 16rpx;
border-radius: 999rpx;
background: rgba(20, 150, 242, 0.08);
font-size: 22rpx;
color: #1496f2;
}
.organization-empty {
padding: 56rpx 0 32rpx;
text-align: center;
font-size: 24rpx;
color: #8b817c;
}
.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>

362
packageMine/qingma.vue Normal file
View File

@@ -0,0 +1,362 @@
<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-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 CommonHero from '../components/common-hero/common-hero.vue'
import { getReviewStatusMeta, userPageQmgcForm } from '../pages/mine/service'
export default {
components: {
CommonHero
},
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;
}
.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-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;
}
.state-card,
.data-card {
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-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>

237
pages.json Normal file
View File

@@ -0,0 +1,237 @@
{
"easycom": {
"autoscan": true,
"custom": {
"^zero-markdown-view$": "@/uni_modules/zero-markdown-view/components/zero-markdown-view/zero-markdown-view.vue"
}
},
"pages": [
{
"path": "pages/index/index",
"style": {
"navigationBarTitleText": "首页",
"navigationStyle": "custom"
}
},
{
"path": "pages/assistant/index",
"style": {
"navigationBarTitleText": "助手",
"navigationStyle": "custom"
}
},
{
"path": "pages/mine/index",
"style": {
"navigationBarTitleText": "我的",
"navigationStyle": "custom"
}
}
],
"subPackages": [
{
"root": "pages/gxmu",
"pages": [
{
"path": "index",
"style": {
"navigationBarTitleText": "五四评优",
"navigationStyle": "custom"
}
},
{
"path": "form",
"style": {
"navigationBarTitleText": "申报表",
"navigationStyle": "custom"
}
},
{
"path": "tzbcy",
"style": {
"navigationBarTitleText": "挑战杯申报",
"navigationStyle": "custom"
}
},
{
"path": "qmgc",
"style": {
"navigationBarTitleText": "青马工程申报",
"navigationStyle": "custom"
}
},
{
"path": "wxxzx",
"style": {
"navigationBarTitleText": "未来学术之星申报",
"navigationStyle": "custom"
}
},
{
"path": "stats",
"style": {
"navigationBarTitleText": "数据统计与报表中心",
"navigationStyle": "custom"
}
},
{
"path": "cross-school-board",
"style": {
"navigationBarTitleText": "跨校活动情报看板",
"navigationStyle": "custom"
}
},
{
"path": "tzb-database",
"style": {
"navigationBarTitleText": "创新竞赛历史项目数据库",
"navigationStyle": "custom"
}
},
{
"path": "tzb-project-list",
"style": {
"navigationBarTitleText": "挑战杯项目库",
"navigationStyle": "custom"
}
},
{
"path": "tzb-talent-list",
"style": {
"navigationBarTitleText": "挑战杯人才库",
"navigationStyle": "custom"
}
},
{
"path": "review-list",
"style": {
"navigationBarTitleText": "审核列表",
"navigationStyle": "custom"
}
}
]
},
{
"root": "packageMine",
"pages": [
{
"path": "profile",
"style": {
"navigationBarTitleText": "用户资料",
"navigationStyle": "custom"
}
},
{
"path": "qingma",
"style": {
"navigationBarTitleText": "我的青马",
"navigationStyle": "custom"
}
},
{
"path": "manuscripts",
"style": {
"navigationBarTitleText": "我的稿件",
"navigationStyle": "custom"
}
},
{
"path": "declare",
"style": {
"navigationBarTitleText": "我的申报",
"navigationStyle": "custom"
}
}
]
},
{
"root": "pages/tygl",
"pages": [
{
"path": "index",
"style": {
"navigationBarTitleText": "团员档案管理",
"navigationStyle": "custom"
}
}
]
},
{
"root": "pages/manuscript",
"pages": [
{
"path": "index",
"style": {
"navigationBarTitleText": "文稿工作台"
}
}
]
},
{
"root": "pages/activity-outline",
"pages": [
{
"path": "index",
"style": {
"navigationBarTitleText": "活动策划大纲"
}
}
]
},
{
"root": "pages/creative-assistant",
"pages": [
{
"path": "index",
"style": {
"navigationBarTitleText": "AI创意助手"
}
}
]
},
{
"root": "pages/login",
"pages": [
{
"path": "index",
"style": {
"navigationBarTitleText": "登录"
}
}
]
}
],
"globalStyle": {
"navigationBarTextStyle": "white",
"navigationBarTitleText": "智慧团委",
"navigationBarBackgroundColor": "#1496F2",
"backgroundColor": "#F4FAFF"
},
"tabBar": {
"color": "#7D7672",
"selectedColor": "#1496F2",
"backgroundColor": "#FFFFFF",
"borderStyle": "black",
"list": [
{
"pagePath": "pages/index/index",
"iconPath": "static/tabbar/home.png",
"selectedIconPath": "static/tabbar/home-active.png",
"text": "首页"
},
{
"pagePath": "pages/assistant/index",
"iconPath": "static/tabbar/chat.png",
"selectedIconPath": "static/tabbar/chat-active.png",
"text": "助手"
},
{
"pagePath": "pages/mine/index",
"iconPath": "static/tabbar/user.png",
"selectedIconPath": "static/tabbar/user-active.png",
"text": "我的"
}
]
},
"uniIdRouter": {}
}

View File

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

View File

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

View File

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

1725
pages/assistant/index.vue Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,45 @@
import { API_BASE_URL, apiRequest, getToken } from '../assistant/chat-service'
export function creativeAssistant(data) {
return apiRequest({
url: `${API_BASE_URL}/ai/creativeAssistant`,
method: 'POST',
data,
withTenant: true,
timeout: 600000
})
}
export function uploadAiFile(file) {
const token = getToken()
return new Promise((resolve, reject) => {
uni.uploadFile({
url: `${API_BASE_URL}/ai/file/upload`,
filePath: file.path,
name: 'file',
header: token
? {
Authorization: token
}
: {},
formData: {
tenantId: '10049'
},
success: (response) => {
try {
const result = JSON.parse(response.data || '{}')
if (result.code === 0 && result.data) {
resolve(result.data)
return
}
reject(new Error(result.message || '上传失败'))
} catch (error) {
reject(new Error('上传响应解析失败'))
}
},
fail: (error) => {
reject(new Error(error.errMsg || '上传失败'))
}
})
})
}

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

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

View File

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

View File

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

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

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

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

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

View File

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

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

@@ -0,0 +1,432 @@
<template>
<view class="page">
<scroll-view scroll-y class="page-scroll">
<common-hero :title="pageTitle" :use-image-bg="true"></common-hero>
<view class="hero-card">
<!-- <view class="hero-badge">GXMU · 青马工程</view>-->
<view class="hero-title">{{ pageTitle }}</view>
<!-- <view class="hero-desc">按照后台青马工程登记表字段组织包含基础信息奖惩情况培养意向等内容</view>-->
<view class="hero-actions">
<view class="hero-action" @tap="openCreate">立即申报</view>
<view class="hero-action hero-action--secondary" @tap="loadData">刷新列表</view>
</view>
</view>
<view v-if="loading" class="state-card"><view class="state-title">正在加载申报记录...</view></view>
<view v-else-if="!list.length" class="state-card"><view class="state-title">暂无申报记录</view></view>
<view v-else class="card-list">
<view v-for="item in list" :key="item.id" class="data-card" @tap="openEdit(item)">
<view class="card-top">
<view><view class="card-title">{{ item.name || '未命名学员' }}</view><view class="card-subtitle">{{ item.schoolInfo || '-' }}</view></view>
<view class="card-year">{{ item.year || '-' }}</view>
</view>
<view class="meta-line">手机号{{ item.phone || '-' }}</view>
<view class="meta-line">团学职务{{ item.leaguePosition || '-' }}</view>
</view>
</view>
</scroll-view>
<view v-if="showEdit" class="modal-mask" @tap="closeEdit">
<view class="modal-panel" @tap.stop>
<view class="modal-head"><view class="modal-title">{{ current.id ? '编辑青马工程申报' : '新增青马工程申报' }}</view><view class="modal-close" @tap="closeEdit">关闭</view></view>
<scroll-view scroll-y class="modal-body">
<view class="form-section">
<view class="form-section-title">登记表</view>
<view class="field-item"><view class="field-label">年份</view><uv-input class="field-input" :modelValue="current.year" border="none" @input="setField('year', $event)" /></view>
<view class="field-item"><view class="field-label">姓名</view><uv-input class="field-input" :modelValue="current.name" border="none" @input="setField('name', $event)" /></view>
<view class="field-item">
<view class="field-label">性别</view>
<view class="field-picker" @tap="openGenderPicker">
{{ current.gender || '请选择性别' }}
</view>
</view>
<view class="field-item">
<view class="field-label">民族</view>
<picker mode="selector" :range="nationLabels" :value="nationIndex" :disabled="!nationOptions.length" @change="handleNationChange">
<view class="field-picker" :class="{ 'field-picker--disabled': !nationOptions.length }">
{{ current.nation || (nationOptions.length ? '请选择民族' : '民族加载中') }}
</view>
</picker>
</view>
<view class="field-item">
<view class="field-label">出生年月</view>
<view class="field-picker" @tap="openBirthMonthPicker">
{{ current.birthMonth || '请选择出生年月' }}
</view>
</view>
<view class="field-item">
<view class="field-label">政治面貌</view>
<view class="field-picker" :class="{ 'field-picker--disabled': !politicsOptions.length }" @tap="openPoliticsPicker">
{{ current.politics || (politicsOptions.length ? '请选择政治面貌' : '政治面貌加载中') }}
</view>
</view>
<view class="field-item"><view class="field-label">籍贯</view><uv-input class="field-input" :modelValue="current.nativePlace" border="none" @input="setField('nativePlace', $event)" /></view>
<view class="field-item"><view class="field-label">手机号码</view><uv-input class="field-input" :modelValue="current.phone" border="none" @input="setField('phone', $event)" /></view>
<view class="field-item"><view class="field-label">微信号</view><uv-input class="field-input" :modelValue="current.wechat" border="none" @input="setField('wechat', $event)" /></view>
<view class="field-item"><view class="field-label">电子邮箱</view><uv-input class="field-input" :modelValue="current.email" border="none" @input="setField('email', $event)" /></view>
<view class="field-item"><view class="field-label">QQ号</view><uv-input class="field-input" :modelValue="current.qq" border="none" @input="setField('qq', $event)" /></view>
<view class="field-item"><view class="field-label">身份证号</view><uv-input class="field-input" :modelValue="current.idCardNo" border="none" @input="setField('idCardNo', $event)" /></view>
<view class="field-item"><view class="field-label">爱好特长</view><uv-input class="field-input" :modelValue="current.hobby" border="none" @input="setField('hobby', $event)" /></view>
<view class="field-item">
<view class="field-label">是否有志到广西基层工作</view>
<view class="field-picker" @tap="openWillingPicker">
{{ current.willingToWorkInGuangxi || '请选择' }}
</view>
</view>
<view class="field-item"><view class="field-label">学校院系年级专业</view><uv-input class="field-input" :modelValue="current.schoolInfo" border="none" @input="setField('schoolInfo', $event)" /></view>
<view class="field-item"><view class="field-label">担任团学职务情况</view><uv-input class="field-input" :modelValue="current.leaguePosition" border="none" @input="setField('leaguePosition', $event)" /></view>
<view class="field-item"><view class="field-label">个人简历</view><textarea class="field-textarea" :value="current.resume" @input="setField('resume', $event)" /></view>
<view class="field-item"><view class="field-label">奖惩情况</view><textarea class="field-textarea" :value="current.awards" @input="setField('awards', $event)" /></view>
<view class="field-item"><view class="field-label">综合成绩情况</view><textarea class="field-textarea" :value="current.academicPerformance" @input="setField('academicPerformance', $event)" /></view>
</view>
</scroll-view>
<view class="modal-actions"><view class="ghost-btn" @tap="closeEdit">取消</view><view class="primary-btn" @tap="save">保存</view></view>
</view>
</view>
<uv-picker
ref="willingPicker"
keyName="text"
:columns="willingPickerColumns"
:defaultIndex="[willingPickerIndex]"
closeOnClickOverlay
@confirm="confirmWillingPicker"
></uv-picker>
<uv-picker
ref="genderPicker"
title="选择性别"
keyName="text"
:columns="genderPickerColumns"
:defaultIndex="[genderPickerIndex]"
closeOnClickOverlay
@confirm="confirmGenderPicker"
></uv-picker>
<uv-picker
ref="politicsPicker"
title="选择政治面貌"
keyName="text"
:columns="politicsPickerColumns"
:defaultIndex="[politicsPickerIndex]"
closeOnClickOverlay
@confirm="confirmPoliticsPicker"
></uv-picker>
<uv-picker
ref="birthMonthPicker"
title="选择出生年月"
keyName="text"
:columns="birthMonthPickerColumns"
:defaultIndex="birthMonthPickerIndexs"
closeOnClickOverlay
@change="handleBirthMonthPickerChange"
@confirm="confirmBirthMonthPicker"
></uv-picker>
</view>
</template>
<script>
import CommonHero from '../../components/common-hero/common-hero.vue'
import { addQmgcForm, getQmgcForm, listDictData, updateQmgcForm, userPageQmgcForm } from './module-form-service'
const POLITICS_DICT_ID = 172
const NATION_DICT_ID = 173
const createDefaultForm = () => ({
id: undefined,
year: '',
name: '',
gender: '',
nation: '',
photo: '',
birthMonth: '',
politics: '',
nativePlace: '',
phone: '',
wechat: '',
email: '',
qq: '',
idCardNo: '',
hobby: '',
willingToWorkInGuangxi: '',
schoolInfo: '',
leaguePosition: '',
resume: '',
awards: '',
academicPerformance: '',
secondaryLeagueOpinion: '',
secondaryLeagueOpinionDate: '',
secondaryPartyOpinion: '',
secondaryPartyOpinionDate: '',
schoolLeagueOpinion: '',
schoolLeagueOpinionDate: ''
})
const normalizeDictOptions = (list) =>
(Array.isArray(list) ? list : [])
.map((item) => {
const label = String((item && (item.dictDataName || item.label || item.name || item.value)) || '').trim()
return {
label,
value: label
}
})
.filter((item) => item.label)
export default {
components: {
CommonHero
},
data() {
return {
loading: false,
showEdit: false,
pageTitle: '青马工程申报',
fixedYear: '',
list: [],
current: createDefaultForm(),
nationOptions: [],
politicsOptions: [],
willingPickerColumns: [[
{ text: '是', value: '是' },
{ text: '否', value: '否' }
]],
genderPickerColumns: [[
{ text: '男', value: '男' },
{ text: '女', value: '女' }
]],
birthMonthPickerColumns: [[]],
birthMonthPickerIndexs: [0, 0]
}
},
computed: {
nationLabels() {
return this.nationOptions.map((item) => item.label)
},
politicsLabels() {
return this.politicsOptions.map((item) => item.label)
},
nationIndex() {
const index = this.nationOptions.findIndex((item) => item.value === this.current.nation)
return index > -1 ? index : 0
},
politicsIndex() {
const index = this.politicsOptions.findIndex((item) => item.value === this.current.politics)
return index > -1 ? index : 0
},
genderPickerIndex() {
const options = (this.genderPickerColumns[0] || []).map((item) => item.value)
const index = options.indexOf(this.current.gender)
return index > -1 ? index : 0
},
politicsPickerColumns() {
return [
this.politicsOptions.map((item) => ({
text: item.label,
value: item.value
}))
]
},
politicsPickerIndex() {
const options = (this.politicsPickerColumns[0] || []).map((item) => item.value)
const index = options.indexOf(this.current.politics)
return index > -1 ? index : 0
},
willingPickerIndex() {
const options = (this.willingPickerColumns[0] || []).map((item) => item.value)
const index = options.indexOf(this.current.willingToWorkInGuangxi)
return index > -1 ? index : 0
}
},
onLoad(options) {
this.fixedYear = String(options.year || '').trim()
this.pageTitle = decodeURIComponent(options.declareTitle || '青马工程申报').trim() || '青马工程申报'
this.loadDictOptions()
this.loadData()
},
methods: {
async loadDictOptions() {
try {
const [nationList, politicsList] = await Promise.all([
listDictData({ dictId: NATION_DICT_ID }),
listDictData({ dictId: POLITICS_DICT_ID })
])
this.nationOptions = normalizeDictOptions(nationList)
this.politicsOptions = normalizeDictOptions(politicsList)
} catch (error) {
uni.showToast({ title: (error && error.message) || '字典加载失败', icon: 'none' })
}
},
async loadData() {
this.loading = true
try {
const result = await userPageQmgcForm({
page: 1,
limit: 50,
year: this.fixedYear || undefined
})
this.list = Array.isArray(result && result.list) ? result.list : []
} catch (error) {
uni.showToast({ title: (error && error.message) || '加载失败', icon: 'none' })
} finally {
this.loading = false
}
},
openCreate() {
this.current = { ...createDefaultForm(), year: this.fixedYear || '' }
this.showEdit = true
},
async openEdit(item) {
try {
const detail = item && item.id ? await getQmgcForm(item.id) : item
this.current = { ...createDefaultForm(), ...(detail || {}), year: (detail && detail.year) || this.fixedYear || '' }
this.showEdit = true
} catch (error) {
uni.showToast({ title: (error && error.message) || '加载详情失败', icon: 'none' })
}
},
closeEdit() { this.showEdit = false },
setField(key, event) {
const value = typeof event === 'string' ? event : (event.detail && event.detail.value) || ''
this.current[key] = value || ''
},
handleNationChange(event) {
const index = Number((event.detail && event.detail.value) || 0)
const option = this.nationOptions[index]
this.current.nation = (option && option.value) || ''
},
openGenderPicker() {
this.$nextTick(() => {
this.$refs.genderPicker && this.$refs.genderPicker.open()
})
},
confirmGenderPicker(event) {
const value = (((event || {}).value || [])[0] || {}).value || ''
this.current.gender = value
},
openPoliticsPicker() {
if (!this.politicsOptions.length) {
return
}
this.$nextTick(() => {
this.$refs.politicsPicker && this.$refs.politicsPicker.open()
})
},
confirmPoliticsPicker(event) {
const value = (((event || {}).value || [])[0] || {}).value || ''
this.current.politics = value
},
buildBirthMonthPickerState(value, nextIndexs = []) {
const nowYear = new Date().getFullYear()
const years = []
for (let year = nowYear; year >= 1950; year -= 1) {
years.push({ text: `${year}`, value: `${year}` })
}
const months = Array.from({ length: 12 }, (_, index) => {
const month = String(index + 1).padStart(2, '0')
return { text: month, value: month }
})
let selectedYear = String(this.current.birthMonth || '').slice(0, 4) || `${nowYear}`
let selectedMonth = String(this.current.birthMonth || '').slice(5, 7) || '01'
if (typeof nextIndexs[0] === 'number' && years[nextIndexs[0]]) {
selectedYear = years[nextIndexs[0]].value
}
if (typeof nextIndexs[1] === 'number' && months[nextIndexs[1]]) {
selectedMonth = months[nextIndexs[1]].value
}
const yearIndex = Math.max(0, years.findIndex((item) => item.value === selectedYear))
const monthIndex = Math.max(0, months.findIndex((item) => item.value === selectedMonth))
return {
columns: [years, months],
indexs: [yearIndex, monthIndex]
}
},
openBirthMonthPicker() {
const pickerState = this.buildBirthMonthPickerState(this.current.birthMonth)
this.birthMonthPickerColumns = pickerState.columns
this.birthMonthPickerIndexs = pickerState.indexs
this.$nextTick(() => {
this.$refs.birthMonthPicker && this.$refs.birthMonthPicker.open()
})
},
handleBirthMonthPickerChange(event) {
const pickerState = this.buildBirthMonthPickerState(this.current.birthMonth, (event && event.indexs) || [])
this.birthMonthPickerColumns = pickerState.columns
this.birthMonthPickerIndexs = pickerState.indexs
},
confirmBirthMonthPicker(event) {
const values = (event && event.value) || []
const year = (values[0] && values[0].value) || ''
const month = (values[1] && values[1].value) || ''
if (year && month) {
this.current.birthMonth = `${year}-${month}`
}
this.birthMonthPickerIndexs = (event && event.indexs) || this.birthMonthPickerIndexs
},
openWillingPicker() {
this.$nextTick(() => {
this.$refs.willingPicker && this.$refs.willingPicker.open()
})
},
confirmWillingPicker(event) {
const values = (event && event.value) || []
const option = values[0] || null
this.current.willingToWorkInGuangxi = (option && option.value) || ''
},
async save() {
try {
const payload = { ...this.current, year: this.fixedYear || this.current.year }
if (payload.id) {
await updateQmgcForm(payload)
} else {
await addQmgcForm(payload)
}
uni.showToast({ title: '保存成功', icon: 'none' })
this.closeEdit()
this.loadData()
} catch (error) {
uni.showToast({ title: (error && error.message) || '保存失败', icon: 'none' })
}
}
}
}
</script>
<style lang="scss" scoped>
.page { min-height: 100vh; background: linear-gradient(180deg, #f5fbff 0%, #eef7ff 100%); }
.page-scroll { height: 100vh; padding: 24rpx; box-sizing: border-box; }
.hero-card,.state-card,.data-card { border-radius: 28rpx; }
.hero-card { padding: 34rpx 36rpx; background: rgba(255,255,255,.5); box-shadow: 0 4rpx 12rpx 0 rgba(174,174,174,.4); backdrop-filter: blur(24rpx); -webkit-backdrop-filter: blur(24rpx); border: 1rpx solid rgba(255,255,255,.5); }
.hero-badge { display: inline-flex; padding: 10rpx 18rpx; border-radius: 999rpx; background: rgba(20,150,242,.1); font-size: 22rpx; color: #1496f2; }
.hero-title { margin-top: 8rpx; padding-left: 10rpx; font-size: 30rpx; font-weight: 700; line-height: 1.35; color: #18a0f7; border-left: 5px solid #0f94ef; }
.hero-desc { margin-top: 20rpx; font-size: 24rpx; line-height: 1.8; color: #7f7f7f; }
.hero-actions { display: flex; gap: 16rpx; margin-top: 24rpx; }
.hero-action { padding: 14rpx 24rpx; border-radius: 999rpx; background: rgba(20,150,242,.12); font-size: 24rpx; color: #1496f2; }
.hero-action--secondary { background: rgba(31,35,41,.08); color: #5f6b76; }
.state-card,.data-card { margin-top: 24rpx; padding: 24rpx; background: rgba(255,255,255,.96); box-shadow: 0 10rpx 28rpx rgba(34,94,142,.08); }
.state-title,.card-title { font-size: 30rpx; font-weight: 700; color: #1f2329; }
.card-subtitle,.meta-line { margin-top: 10rpx; font-size: 24rpx; line-height: 1.6; color: #786e69; }
.card-list { margin-top: 24rpx; display: flex; flex-direction: column; gap: 16rpx; }
.card-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 16rpx; }
.card-year { padding: 10rpx 16rpx; border-radius: 999rpx; background: #eef5ff; color: #1554ad; font-size: 22rpx; }
.modal-mask { position: fixed; inset: 0; background: rgba(15,23,42,.34); display: flex; align-items: flex-end; z-index: 99; }
.modal-panel { width: 100%; max-height: 92vh; border-radius: 32rpx 32rpx 0 0; background: #fff; display: flex; flex-direction: column; }
.modal-head { display: flex; justify-content: space-between; align-items: center; padding: 24rpx; border-bottom: 1rpx solid #eef1f4; }
.modal-title { font-size: 30rpx; font-weight: 700; color: #1f2329; }
.modal-close { font-size: 24rpx; color: #7c8a96; }
.modal-body { max-height: 70vh; padding: 24rpx; box-sizing: border-box; }
.form-section-title { font-size: 28rpx; font-weight: 700; color: #1f2329; margin-bottom: 16rpx; }
.field-item + .field-item { margin-top: 16rpx; }
.field-label { margin-bottom: 10rpx; font-size: 24rpx; color: #5f6b7a; }
.field-picker { width: 100%; box-sizing: border-box; min-height: 84rpx; padding: 20rpx; border-radius: 20rpx; border: 1rpx solid rgba(220,220,220,1); background: #F4FCFF; font-size: 24rpx; color: #1f2329; }
.field-picker--disabled { color: #a8b3bf; background: #f7f9fb; }
.field-textarea { width: 100%; box-sizing: border-box; border-radius: 20rpx; border: 1rpx solid #dce4ec; background: #fff; padding: 20rpx; font-size: 24rpx; color: #1f2329; }
:deep(.field-input.uv-input) { width: 100%; box-sizing: border-box; border-radius: 20rpx; border: 1rpx solid #dce4ec !important; background: #fff !important; padding: 20rpx !important; }
:deep(.field-input.uv-input .uv-input__content__field-wrapper__field) { font-size: 24rpx; color: #1f2329; min-height: 40rpx; }
.field-textarea { min-height: 180rpx; }
.modal-actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16rpx; padding: 24rpx; border-top: 1rpx solid #eef1f4; }
.ghost-btn,.primary-btn { height: 84rpx; line-height: 84rpx; border-radius: 22rpx; text-align: center; font-size: 26rpx; font-weight: 700; }
.ghost-btn { background: #f6f7fa; color: #50617a; }
.primary-btn { background: linear-gradient(135deg, #0f7dd1 0%, #1496f2 100%); color: #fff; }
</style>

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

File diff suppressed because it is too large Load Diff

View File

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

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

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

View File

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

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

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

View File

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

View File

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

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

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

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

@@ -0,0 +1,192 @@
<template>
<view class="page">
<scroll-view scroll-y class="page-scroll">
<common-hero :title="pageTitle" :use-image-bg="true"></common-hero>
<view class="hero-card">
<!-- <view class="hero-badge">GXMU · 未来学术之星</view>-->
<view class="hero-title">{{ pageTitle }}</view>
<!-- <view class="hero-desc">按照后台未来学术之星申报页结构拆分为基础信息申请表立论依据等内容</view>-->
<view class="hero-actions">
<view class="hero-action" @tap="openCreate">立即申报</view>
<view class="hero-action hero-action--secondary" @tap="loadData">刷新列表</view>
</view>
</view>
<view v-if="loading" class="state-card"><view class="state-title">正在加载申报记录...</view></view>
<view v-else-if="!list.length" class="state-card"><view class="state-title">暂无申报记录</view></view>
<view v-else class="card-list">
<view v-for="item in list" :key="item.id" class="data-card" @tap="openEdit(item)">
<view class="card-top">
<view><view class="card-title">{{ item.topicName || '未命名课题' }}</view><view class="card-subtitle">{{ item.collegeGradeClass || '-' }}</view></view>
<view class="card-year">{{ item.year || '-' }}</view>
</view>
<view class="meta-line">负责人{{ item.leader || '-' }}</view>
<view class="meta-line">联系电话{{ item.phone || '-' }}</view>
</view>
</view>
</scroll-view>
<view v-if="showEdit" class="modal-mask" @tap="closeEdit">
<view class="modal-panel" @tap.stop>
<view class="modal-head"><view class="modal-title">{{ current.id ? '编辑未来学术之星申报' : '新增未来学术之星申报' }}</view><view class="modal-close" @tap="closeEdit">关闭</view></view>
<scroll-view scroll-y class="modal-body">
<view class="form-section">
<view class="form-section-title">基本信息</view>
<view class="field-item"><view class="field-label">年份</view><uv-input class="field-input" :modelValue="current.year" border="none" @input="setField('year', $event)" /></view>
<view class="field-item"><view class="field-label">课题名称</view><uv-input class="field-input" :modelValue="current.topicName" border="none" @input="setField('topicName', $event)" /></view>
<view class="field-item"><view class="field-label">课题负责人</view><uv-input class="field-input" :modelValue="current.leader" border="none" @input="setField('leader', $event)" /></view>
<view class="field-item"><view class="field-label">学院年级班别</view><uv-input class="field-input" :modelValue="current.collegeGradeClass" border="none" @input="setField('collegeGradeClass', $event)" /></view>
<view class="field-item"><view class="field-label">联系电话</view><uv-input class="field-input" :modelValue="current.phone" border="none" @input="setField('phone', $event)" /></view>
<view class="field-item"><view class="field-label">指导老师</view><uv-input class="field-input" :modelValue="current.advisor" border="none" @input="setField('advisor', $event)" /></view>
<view class="field-item"><view class="field-label">课题类型</view><uv-input class="field-input" :modelValue="current.topicType" border="none" @input="setField('topicType', $event)" placeholder="自然科学 / 人文科学" /></view>
<view class="field-item"><view class="field-label">立论依据</view><textarea class="field-textarea" :value="current.rationale" @input="setField('rationale', $event)" /></view>
<!-- <view class="field-item"><view class="field-label">指导老师意见</view><textarea class="field-textarea" :value="current.teacherOpinion" @input="setField('teacherOpinion', $event)" /></view>-->
<!-- <view class="field-item"><view class="field-label">指导老师签名</view><uv-input class="field-input" :modelValue="current.teacherSign" border="none" @input="setField('teacherSign', $event)" /></view>-->
<!-- <view class="field-item"><view class="field-label">评审小组意见</view><textarea class="field-textarea" :value="current.reviewOpinion" @input="setField('reviewOpinion', $event)" /></view>-->
<!-- <view class="field-item"><view class="field-label">评审分数</view><uv-input class="field-input" :modelValue="current.reviewScore" border="none" @input="setField('reviewScore', $event)" /></view>-->
<!-- <view class="field-item"><view class="field-label">评审组长签名</view><uv-input class="field-input" :modelValue="current.reviewLeaderSign" border="none" @input="setField('reviewLeaderSign', $event)" /></view>-->
<!-- <view class="field-item"><view class="field-label">学院意见</view><textarea class="field-textarea" :value="current.collegeOpinion" @input="setField('collegeOpinion', $event)" /></view>-->
<!-- <view class="field-item"><view class="field-label">校团委意见</view><textarea class="field-textarea" :value="current.schoolOpinion" @input="setField('schoolOpinion', $event)" /></view>-->
</view>
</scroll-view>
<view class="modal-actions"><view class="ghost-btn" @tap="closeEdit">取消</view><view class="primary-btn" @tap="save">保存</view></view>
</view>
</view>
</view>
</template>
<script>
import CommonHero from '../../components/common-hero/common-hero.vue'
import { addWxxzxForm, getWxxzxForm, updateWxxzxForm, userPageWxxzxForm } from './module-form-service'
const createDefaultForm = () => ({
id: undefined,
year: '',
topicName: '',
leader: '',
collegeGradeClass: '',
phone: '',
advisor: '',
topicType: '',
rationale: '',
teacherOpinion: '',
teacherSign: '',
reviewOpinion: '',
reviewScore: '',
reviewLeaderSign: '',
collegeOpinion: '',
schoolOpinion: '',
promiseSigner: '',
promiseSignature: '',
reviewCollege: '',
reviewReporter: '',
reviewDate: ''
})
export default {
components: {
CommonHero
},
data() {
return {
loading: false,
showEdit: false,
pageTitle: '未来学术之星申报',
fixedYear: '',
list: [],
current: createDefaultForm()
}
},
onLoad(options) {
this.fixedYear = String(options.year || '').trim()
this.pageTitle = decodeURIComponent(options.declareTitle || '未来学术之星申报').trim() || '未来学术之星申报'
this.loadData()
},
methods: {
async loadData() {
this.loading = true
try {
const result = await userPageWxxzxForm({
page: 1,
limit: 50,
year: this.fixedYear || undefined
})
this.list = Array.isArray(result && result.list) ? result.list : []
} catch (error) {
uni.showToast({ title: (error && error.message) || '加载失败', icon: 'none' })
} finally {
this.loading = false
}
},
openCreate() {
this.current = { ...createDefaultForm(), year: this.fixedYear || '' }
this.showEdit = true
},
async openEdit(item) {
try {
const detail = item && item.id ? await getWxxzxForm(item.id) : item
this.current = { ...createDefaultForm(), ...(detail || {}), year: (detail && detail.year) || this.fixedYear || '' }
this.showEdit = true
} catch (error) {
uni.showToast({ title: (error && error.message) || '加载详情失败', icon: 'none' })
}
},
closeEdit() { this.showEdit = false },
setField(key, event) {
const value = typeof event === 'string' ? event : (event.detail && event.detail.value) || ''
this.current[key] = value || ''
},
async save() {
try {
const payload = { ...this.current, year: this.fixedYear || this.current.year }
if (payload.id) {
await updateWxxzxForm(payload)
} else {
await addWxxzxForm(payload)
}
uni.showToast({ title: '保存成功', icon: 'none' })
this.closeEdit()
this.loadData()
} catch (error) {
uni.showToast({ title: (error && error.message) || '保存失败', icon: 'none' })
}
}
}
}
</script>
<style lang="scss" scoped>
.page { min-height: 100vh; background: linear-gradient(180deg, #f5fbff 0%, #eef7ff 100%); }
.page-scroll { height: 100vh; padding: 24rpx; box-sizing: border-box; }
.hero-card,.state-card,.data-card { border-radius: 28rpx; }
.hero-card { padding: 34rpx 36rpx; background: rgba(255,255,255,.5); box-shadow: 0 4rpx 12rpx 0 rgba(174,174,174,.4); backdrop-filter: blur(24rpx); -webkit-backdrop-filter: blur(24rpx); border: 1rpx solid rgba(255,255,255,.5); }
.hero-badge { display: inline-flex; padding: 10rpx 18rpx; border-radius: 999rpx; background: rgba(20,150,242,.1); font-size: 22rpx; color: #1496f2; }
.hero-title { margin-top: 8rpx; padding-left: 10rpx; font-size: 30rpx; font-weight: 700; line-height: 1.35; color: #18a0f7; border-left: 5px solid #0f94ef; }
.hero-desc { margin-top: 20rpx; font-size: 24rpx; line-height: 1.8; color: #7f7f7f; }
.hero-actions { display: flex; gap: 16rpx; margin-top: 24rpx; }
.hero-action { padding: 14rpx 24rpx; border-radius: 999rpx; background: rgba(20,150,242,.12); font-size: 24rpx; color: #1496f2; }
.hero-action--secondary { background: rgba(31,35,41,.08); color: #5f6b76; }
.state-card,.data-card { margin-top: 24rpx; padding: 24rpx; background: rgba(255,255,255,.96); box-shadow: 0 10rpx 28rpx rgba(34,94,142,.08); }
.state-title,.card-title { font-size: 30rpx; font-weight: 700; color: #1f2329; }
.card-subtitle,.meta-line { margin-top: 10rpx; font-size: 24rpx; line-height: 1.6; color: #786e69; }
.card-list { margin-top: 24rpx; display: flex; flex-direction: column; gap: 16rpx; }
.card-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 16rpx; }
.card-year { padding: 10rpx 16rpx; border-radius: 999rpx; background: #eef5ff; color: #1554ad; font-size: 22rpx; }
.modal-mask { position: fixed; inset: 0; background: rgba(15,23,42,.34); display: flex; align-items: flex-end; z-index: 99; }
.modal-panel { width: 100%; max-height: 92vh; border-radius: 32rpx 32rpx 0 0; background: #fff; display: flex; flex-direction: column; }
.modal-head { display: flex; justify-content: space-between; align-items: center; padding: 24rpx; border-bottom: 1rpx solid #eef1f4; }
.modal-title { font-size: 30rpx; font-weight: 700; color: #1f2329; }
.modal-close { font-size: 24rpx; color: #7c8a96; }
.modal-body { max-height: 70vh; padding: 24rpx; box-sizing: border-box; }
.form-section-title { font-size: 28rpx; font-weight: 700; color: #1f2329; margin-bottom: 16rpx; }
.field-item + .field-item { margin-top: 16rpx; }
.field-label { margin-bottom: 10rpx; font-size: 24rpx; color: #5f6b7a; }
.field-textarea { width: 100%; box-sizing: border-box; border-radius: 20rpx; border: 1rpx solid #dce4ec; background: #fff; padding: 20rpx; font-size: 24rpx; color: #1f2329; }
:deep(.field-input.uv-input) { width: 100%; box-sizing: border-box; border-radius: 20rpx; border: 1rpx solid #dce4ec !important; background: #fff !important; padding: 20rpx !important; }
:deep(.field-input.uv-input .uv-input__content__field-wrapper__field) { font-size: 24rpx; color: #1f2329; min-height: 40rpx; }
.field-textarea { min-height: 180rpx; }
.modal-actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16rpx; padding: 24rpx; border-top: 1rpx solid #eef1f4; }
.ghost-btn,.primary-btn { height: 84rpx; line-height: 84rpx; border-radius: 22rpx; text-align: center; font-size: 26rpx; font-weight: 700; }
.ghost-btn { background: #f6f7fa; color: #50617a; }
.primary-btn { background: linear-gradient(135deg, #0f7dd1 0%, #1496f2 100%); color: #fff; }
</style>

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

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

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

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

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

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

1112
pages/manuscript/index.vue Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,52 @@
import {
API_BASE_URL,
apiRequest,
buildWebSocketUrl,
getCurrentUser,
getToken
} from '../assistant/chat-service'
export { buildWebSocketUrl, getCurrentUser }
export function manuscriptGen(data) {
return apiRequest({
url: `${API_BASE_URL}/ai/manuscriptGen`,
method: 'POST',
data,
withTenant: true
})
}
export function uploadTempFile(file) {
const token = getToken()
return new Promise((resolve, reject) => {
uni.uploadFile({
url: `${API_BASE_URL}/file/upload`,
filePath: file.path,
name: 'file',
header: token
? {
Authorization: token
}
: {},
formData: {
tenantId: '10049'
},
success: (response) => {
try {
const result = JSON.parse(response.data || '{}')
if (result.code === 0 && result.data) {
resolve(result.data)
return
}
reject(new Error(result.message || '上传失败'))
} catch (error) {
reject(new Error('上传响应解析失败'))
}
},
fail: (error) => {
reject(new Error(error.errMsg || '上传失败'))
}
})
})
}

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

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

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

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

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

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

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

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

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

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

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

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

1260
pages/tygl/index.vue Normal file

File diff suppressed because it is too large Load Diff

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

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

BIN
static/indexBg.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

BIN
static/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

BIN
static/swiper.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 354 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
static/tabbar/chat.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

BIN
static/tabbar/home.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
static/tabbar/user.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

13
uni.promisify.adaptor.js Normal file
View File

@@ -0,0 +1,13 @@
uni.addInterceptor({
returnValue (res) {
if (!(!!res && (typeof res === "object" || typeof res === "function") && typeof res.then === "function")) {
return res;
}
return new Promise((resolve, reject) => {
res.then((res) => {
if (!res) return resolve(res)
return res[0] ? reject(res[0]) : resolve(res[1])
});
});
},
});

78
uni.scss Normal file
View File

@@ -0,0 +1,78 @@
/**
* 这里是uni-app内置的常用样式变量
*
* uni-app 官方扩展插件及插件市场https://ext.dcloud.net.cn上很多三方插件均使用了这些样式变量
* 如果你是插件开发者建议你使用scss预处理并在插件代码中直接使用这些变量无需 import 这个文件方便用户通过搭积木的方式开发整体风格一致的App
*
*/
@import "@/uni_modules/uv-ui-tools/theme.scss";
/**
* 如果你是App开发者插件使用者你可以通过修改这些变量来定制自己的插件主题实现自定义主题功能
*
* 如果你的项目同样使用了scss预处理你也可以直接在你的 scss 代码中使用如下变量,同时无需 import 这个文件
*/
/* 颜色变量 */
/* 行为相关颜色 */
$uni-color-primary: #1496f2;
$uni-color-success: #4cd964;
$uni-color-warning: #f0ad4e;
$uni-color-error: #dd524d;
/* 文字基本颜色 */
$uni-text-color:#333;//基本色
$uni-text-color-inverse:#fff;//反色
$uni-text-color-grey:#999;//辅助灰色,如加载更多的提示信息
$uni-text-color-placeholder: #808080;
$uni-text-color-disable:#c0c0c0;
/* 背景颜色 */
$uni-bg-color:#ffffff;
$uni-bg-color-grey:#f8f8f8;
$uni-bg-color-hover:#f1f1f1;//点击状态颜色
$uni-bg-color-mask:rgba(0, 0, 0, 0.4);//遮罩颜色
/* 边框颜色 */
$uni-border-color:#c8c7cc;
/* 尺寸变量 */
/* 文字尺寸 */
$uni-font-size-sm:12px;
$uni-font-size-base:14px;
$uni-font-size-lg:16px;
/* 图片尺寸 */
$uni-img-size-sm:20px;
$uni-img-size-base:26px;
$uni-img-size-lg:40px;
/* Border Radius */
$uni-border-radius-sm: 2px;
$uni-border-radius-base: 3px;
$uni-border-radius-lg: 6px;
$uni-border-radius-circle: 50%;
/* 水平间距 */
$uni-spacing-row-sm: 5px;
$uni-spacing-row-base: 10px;
$uni-spacing-row-lg: 15px;
/* 垂直间距 */
$uni-spacing-col-sm: 4px;
$uni-spacing-col-base: 8px;
$uni-spacing-col-lg: 12px;
/* 透明度 */
$uni-opacity-disabled: 0.3; // 组件禁用态的透明度
/* 文章场景相关 */
$uni-color-title: #2C405A; // 文章标题颜色
$uni-font-size-title:20px;
$uni-color-subtitle: #555555; // 二级标题颜色
$uni-font-size-subtitle:26px;
$uni-color-paragraph: #3F536E; // 文章段落颜色
$uni-font-size-paragraph:15px;

View File

@@ -0,0 +1,31 @@
## 1.0.132023-12-06
1. 优化
## 1.0.122023-12-06
1. 阻止事件冒泡处理
## 1.0.112023-10-29
1. imgMode默认值改成aspectFit
## 1.0.102023-08-13
1. 优化nvue方便自定义图标
## 1.0.92023-07-28
1. 修改几个对应错误图标的BUG
## 1.0.82023-07-24
1. 优化 支持base64图片
## 1.0.72023-07-17
1. 修复 uv-icon 恢复uv-empty相关的图标
## 1.0.62023-07-13
1. 修复icon设置name属性对应图标错误的BUG
## 1.0.52023-07-04
1. 更新图标,删除一些不常用的图标
2. 删除base64修改成ttf文件引入读取图标
3. 自定义图标文档说明https://www.uvui.cn/guide/customIcon.html
## 1.0.42023-07-03
1. 修复主题颜色在APP不生效的BUG
## 1.0.32023-05-24
1. 将线上ttf字体包替换成base64避免加载时或者网络差时候显示白色方块
## 1.0.22023-05-16
1. 优化组件依赖,修改后无需全局引入,组件导入即可使用
2. 优化部分功能
## 1.0.12023-05-10
1. 修复小程序中异常显示
## 1.0.02023-05-04
新发版

View File

@@ -0,0 +1,160 @@
export default {
'uvicon-level': 'e68f',
'uvicon-checkbox-mark': 'e659',
'uvicon-folder': 'e694',
'uvicon-movie': 'e67c',
'uvicon-star-fill': 'e61e',
'uvicon-star': 'e618',
'uvicon-phone-fill': 'e6ac',
'uvicon-phone': 'e6ba',
'uvicon-apple-fill': 'e635',
'uvicon-backspace': 'e64d',
'uvicon-attach': 'e640',
'uvicon-empty-data': 'e671',
'uvicon-empty-address': 'e68a',
'uvicon-empty-favor': 'e662',
'uvicon-empty-car': 'e657',
'uvicon-empty-order': 'e66b',
'uvicon-empty-list': 'e672',
'uvicon-empty-search': 'e677',
'uvicon-empty-permission': 'e67d',
'uvicon-empty-news': 'e67e',
'uvicon-empty-history': 'e685',
'uvicon-empty-coupon': 'e69b',
'uvicon-empty-page': 'e60e',
'uvicon-empty-wifi-off': 'e6cc',
'uvicon-reload': 'e627',
'uvicon-order': 'e695',
'uvicon-server-man': 'e601',
'uvicon-search': 'e632',
'uvicon-more-dot-fill': 'e66f',
'uvicon-scan': 'e631',
'uvicon-map': 'e665',
'uvicon-map-fill': 'e6a8',
'uvicon-tags': 'e621',
'uvicon-tags-fill': 'e613',
'uvicon-eye': 'e664',
'uvicon-eye-fill': 'e697',
'uvicon-eye-off': 'e69c',
'uvicon-eye-off-outline': 'e688',
'uvicon-mic': 'e66d',
'uvicon-mic-off': 'e691',
'uvicon-calendar': 'e65c',
'uvicon-trash': 'e623',
'uvicon-trash-fill': 'e6ce',
'uvicon-play-left': 'e6bf',
'uvicon-play-right': 'e6b3',
'uvicon-minus': 'e614',
'uvicon-plus': 'e625',
'uvicon-info-circle': 'e69f',
'uvicon-info-circle-fill': 'e6a7',
'uvicon-question-circle': 'e622',
'uvicon-question-circle-fill': 'e6bc',
'uvicon-close': 'e65a',
'uvicon-checkmark': 'e64a',
'uvicon-checkmark-circle': 'e643',
'uvicon-checkmark-circle-fill': 'e668',
'uvicon-setting': 'e602',
'uvicon-setting-fill': 'e6d0',
'uvicon-heart': 'e6a2',
'uvicon-heart-fill': 'e68b',
'uvicon-camera': 'e642',
'uvicon-camera-fill': 'e650',
'uvicon-more-circle': 'e69e',
'uvicon-more-circle-fill': 'e684',
'uvicon-chat': 'e656',
'uvicon-chat-fill': 'e63f',
'uvicon-bag': 'e647',
'uvicon-error-circle': 'e66e',
'uvicon-error-circle-fill': 'e655',
'uvicon-close-circle': 'e64e',
'uvicon-close-circle-fill': 'e666',
'uvicon-share': 'e629',
'uvicon-share-fill': 'e6bb',
'uvicon-share-square': 'e6c4',
'uvicon-shopping-cart': 'e6cb',
'uvicon-shopping-cart-fill': 'e630',
'uvicon-bell': 'e651',
'uvicon-bell-fill': 'e604',
'uvicon-list': 'e690',
'uvicon-list-dot': 'e6a9',
'uvicon-zhifubao-circle-fill': 'e617',
'uvicon-weixin-circle-fill': 'e6cd',
'uvicon-weixin-fill': 'e620',
'uvicon-qq-fill': 'e608',
'uvicon-qq-circle-fill': 'e6b9',
'uvicon-moments-circel-fill': 'e6c2',
'uvicon-moments': 'e6a0',
'uvicon-car': 'e64f',
'uvicon-car-fill': 'e648',
'uvicon-warning-fill': 'e6c7',
'uvicon-warning': 'e6c1',
'uvicon-clock-fill': 'e64b',
'uvicon-clock': 'e66c',
'uvicon-edit-pen': 'e65d',
'uvicon-edit-pen-fill': 'e679',
'uvicon-email': 'e673',
'uvicon-email-fill': 'e683',
'uvicon-minus-circle': 'e6a5',
'uvicon-plus-circle': 'e603',
'uvicon-plus-circle-fill': 'e611',
'uvicon-file-text': 'e687',
'uvicon-file-text-fill': 'e67f',
'uvicon-pushpin': 'e6d1',
'uvicon-pushpin-fill': 'e6b6',
'uvicon-grid': 'e68c',
'uvicon-grid-fill': 'e698',
'uvicon-play-circle': 'e6af',
'uvicon-play-circle-fill': 'e62a',
'uvicon-pause-circle-fill': 'e60c',
'uvicon-pause': 'e61c',
'uvicon-pause-circle': 'e696',
'uvicon-gift-fill': 'e6b0',
'uvicon-gift': 'e680',
'uvicon-kefu-ermai': 'e660',
'uvicon-server-fill': 'e610',
'uvicon-coupon-fill': 'e64c',
'uvicon-coupon': 'e65f',
'uvicon-integral': 'e693',
'uvicon-integral-fill': 'e6b1',
'uvicon-home-fill': 'e68e',
'uvicon-home': 'e67b',
'uvicon-account': 'e63a',
'uvicon-account-fill': 'e653',
'uvicon-thumb-down-fill': 'e628',
'uvicon-thumb-down': 'e60a',
'uvicon-thumb-up': 'e612',
'uvicon-thumb-up-fill': 'e62c',
'uvicon-lock-fill': 'e6a6',
'uvicon-lock-open': 'e68d',
'uvicon-lock-opened-fill': 'e6a1',
'uvicon-lock': 'e69d',
'uvicon-red-packet': 'e6c3',
'uvicon-photo-fill': 'e6b4',
'uvicon-photo': 'e60d',
'uvicon-volume-off-fill': 'e6c8',
'uvicon-volume-off': 'e6bd',
'uvicon-volume-fill': 'e624',
'uvicon-volume': 'e605',
'uvicon-download': 'e670',
'uvicon-arrow-up-fill': 'e636',
'uvicon-arrow-down-fill': 'e638',
'uvicon-play-left-fill': 'e6ae',
'uvicon-play-right-fill': 'e6ad',
'uvicon-arrow-downward': 'e634',
'uvicon-arrow-leftward': 'e63b',
'uvicon-arrow-rightward': 'e644',
'uvicon-arrow-upward': 'e641',
'uvicon-arrow-down': 'e63e',
'uvicon-arrow-right': 'e63c',
'uvicon-arrow-left': 'e646',
'uvicon-arrow-up': 'e633',
'uvicon-skip-back-left': 'e6c5',
'uvicon-skip-forward-right': 'e61f',
'uvicon-arrow-left-double': 'e637',
'uvicon-man': 'e675',
'uvicon-woman': 'e626',
'uvicon-en': 'e6b8',
'uvicon-twitte': 'e607',
'uvicon-twitter-circle-fill': 'e6cf'
}

View File

@@ -0,0 +1,90 @@
export default {
props: {
// 图标类名
name: {
type: String,
default: ''
},
// 图标颜色,可接受主题色
color: {
type: String,
default: '#606266'
},
// 字体大小单位px
size: {
type: [String, Number],
default: '16px'
},
// 是否显示粗体
bold: {
type: Boolean,
default: false
},
// 点击图标的时候传递事件出去的index用于区分点击了哪一个
index: {
type: [String, Number],
default: null
},
// 触摸图标时的类名
hoverClass: {
type: String,
default: ''
},
// 自定义扩展前缀,方便用户扩展自己的图标库
customPrefix: {
type: String,
default: 'uvicon'
},
// 图标右边或者下面的文字
label: {
type: [String, Number],
default: ''
},
// label的位置只能右边或者下边
labelPos: {
type: String,
default: 'right'
},
// label的大小
labelSize: {
type: [String, Number],
default: '15px'
},
// label的颜色
labelColor: {
type: String,
default: '#606266'
},
// label与图标的距离
space: {
type: [String, Number],
default: '3px'
},
// 图片的mode
imgMode: {
type: String,
default: 'aspectFit'
},
// 用于显示图片小图标时,图片的宽度
width: {
type: [String, Number],
default: ''
},
// 用于显示图片小图标时,图片的高度
height: {
type: [String, Number],
default: ''
},
// 用于解决某些情况下,让图标垂直居中的用途
top: {
type: [String, Number],
default: 0
},
// 是否阻止事件传播
stop: {
type: Boolean,
default: false
},
...uni.$uv?.props?.icon
}
}

View File

@@ -0,0 +1,226 @@
<template>
<view
class="uv-icon"
@tap="clickHandler"
:class="['uv-icon--' + labelPos]"
>
<image
class="uv-icon__img"
v-if="isImg"
:src="name"
:mode="imgMode"
:style="[imgStyle, $uv.addStyle(customStyle)]"
></image>
<text
v-else
class="uv-icon__icon"
:class="uClasses"
:style="[iconStyle, $uv.addStyle(customStyle)]"
:hover-class="hoverClass"
>{{icon}}</text>
<!-- 这里进行空字符串判断如果仅仅是v-if="label"可能会出现传递0的时候结果也无法显示 -->
<text
v-if="label !== ''"
class="uv-icon__label"
:style="{
color: labelColor,
fontSize: $uv.addUnit(labelSize),
marginLeft: labelPos == 'right' ? $uv.addUnit(space) : 0,
marginTop: labelPos == 'bottom' ? $uv.addUnit(space) : 0,
marginRight: labelPos == 'left' ? $uv.addUnit(space) : 0,
marginBottom: labelPos == 'top' ? $uv.addUnit(space) : 0
}"
>{{ label }}</text>
</view>
</template>
<script>
import mpMixin from '@/uni_modules/uv-ui-tools/libs/mixin/mpMixin.js'
import mixin from '@/uni_modules/uv-ui-tools/libs/mixin/mixin.js'
// #ifdef APP-NVUE
// nvue通过weex的dom模块引入字体相关文档地址如下
// https://weex.apache.org/zh/docs/modules/dom.html#addrule
import iconUrl from './uvicons.ttf';
const domModule = weex.requireModule('dom')
domModule.addRule('fontFace', {
'fontFamily': "uvicon-iconfont",
'src': "url('" + iconUrl + "')"
})
// #endif
// 引入图标名称已经对应的unicode
import icons from './icons';
import props from './props.js';
/**
* icon 图标
* @description 基于字体的图标集,包含了大多数常见场景的图标。
* @tutorial https://www.uvui.cn/components/icon.html
* @property {String} name 图标名称,见示例图标集
* @property {String} color 图标颜色,可接受主题色 (默认 color['uv-content-color']
* @property {String | Number} size 图标字体大小单位px (默认 '16px'
* @property {Boolean} bold 是否显示粗体 (默认 false
* @property {String | Number} index 点击图标的时候传递事件出去的index用于区分点击了哪一个
* @property {String} hoverClass 图标按下去的样式类用法同uni的view组件的hoverClass参数详情见官网
* @property {String} customPrefix 自定义扩展前缀,方便用户扩展自己的图标库 (默认 'uicon'
* @property {String | Number} label 图标右侧的label文字
* @property {String} labelPos label相对于图标的位置只能right或bottom (默认 'right'
* @property {String | Number} labelSize label字体大小单位px (默认 '15px'
* @property {String} labelColor 图标右侧的label文字颜色 默认 color['uv-content-color']
* @property {String | Number} space label与图标的距离单位px (默认 '3px'
* @property {String} imgMode 图片的mode
* @property {String | Number} width 显示图片小图标时的宽度
* @property {String | Number} height 显示图片小图标时的高度
* @property {String | Number} top 图标在垂直方向上的定位 用于解决某些情况下,让图标垂直居中的用途 (默认 0
* @property {Boolean} stop 是否阻止事件传播 (默认 false
* @property {Object} customStyle icon的样式对象形式
* @event {Function} click 点击图标时触发
* @event {Function} touchstart 事件触摸时触发
* @example <uv-icon name="photo" color="#2979ff" size="28"></uv-icon>
*/
export default {
name: 'uv-icon',
emits: ['click'],
mixins: [mpMixin, mixin, props],
data() {
return {
colorType: [
'primary',
'success',
'info',
'error',
'warning'
]
}
},
computed: {
uClasses() {
let classes = []
classes.push(this.customPrefix)
classes.push(this.customPrefix + '-' + this.name)
// 主题色,通过类配置
if (this.color && this.colorType.includes(this.color)) classes.push('uv-icon__icon--' + this.color)
// 阿里,头条,百度小程序通过数组绑定类名时,无法直接使用[a, b, c]的形式,否则无法识别
// 故需将其拆成一个字符串的形式,通过空格隔开各个类名
//#ifdef MP-ALIPAY || MP-TOUTIAO || MP-BAIDU
classes = classes.join(' ')
//#endif
return classes
},
iconStyle() {
let style = {}
style = {
fontSize: this.$uv.addUnit(this.size),
lineHeight: this.$uv.addUnit(this.size),
fontWeight: this.bold ? 'bold' : 'normal',
// 某些特殊情况需要设置一个到顶部的距离,才能更好的垂直居中
top: this.$uv.addUnit(this.top)
}
// 非主题色值时,才当作颜色值
if (this.color && !this.colorType.includes(this.color)) style.color = this.color
return style
},
// 判断传入的name属性是否图片路径只要带有"/"均认为是图片形式
isImg() {
const isBase64 = this.name.indexOf('data:') > -1 && this.name.indexOf('base64') > -1;
return this.name.indexOf('/') !== -1 || isBase64;
},
imgStyle() {
let style = {}
// 如果设置width和height属性则优先使用否则使用size属性
style.width = this.width ? this.$uv.addUnit(this.width) : this.$uv.addUnit(this.size)
style.height = this.height ? this.$uv.addUnit(this.height) : this.$uv.addUnit(this.size)
return style
},
// 通过图标名,查找对应的图标
icon() {
// 如果内置的图标中找不到对应的图标就直接返回name值因为用户可能传入的是unicode代码
const code = icons['uvicon-' + this.name];
// #ifdef APP-NVUE
if(!code) {
return code ? unescape(`%u${code}`) : ['uvicon'].indexOf(this.customPrefix) > -1 ? unescape(`%u${this.name}`) : '';
}
// #endif
return code ? unescape(`%u${code}`) : ['uvicon'].indexOf(this.customPrefix) > -1 ? this.name : '';
}
},
methods: {
clickHandler(e) {
this.$emit('click', this.index)
// 是否阻止事件冒泡
this.stop && this.preventEvent(e)
}
}
}
</script>
<style lang="scss" scoped>
@import '@/uni_modules/uv-ui-tools/libs/css/components.scss';
@import '@/uni_modules/uv-ui-tools/libs/css/color.scss';
// 变量定义
$uv-icon-primary: $uv-primary !default;
$uv-icon-success: $uv-success !default;
$uv-icon-info: $uv-info !default;
$uv-icon-warning: $uv-warning !default;
$uv-icon-error: $uv-error !default;
$uv-icon-label-line-height: 1 !default;
/* #ifndef APP-NVUE */
// 非nvue下加载字体
@font-face {
font-family: 'uvicon-iconfont';
src: url('./uvicons.ttf') format('truetype');
}
/* #endif */
.uv-icon {
/* #ifndef APP-NVUE */
display: flex;
/* #endif */
align-items: center;
&--left {
flex-direction: row-reverse;
align-items: center;
}
&--right {
flex-direction: row;
align-items: center;
}
&--top {
flex-direction: column-reverse;
justify-content: center;
}
&--bottom {
flex-direction: column;
justify-content: center;
}
&__icon {
font-family: uvicon-iconfont;
position: relative;
@include flex;
align-items: center;
&--primary {
color: $uv-icon-primary;
}
&--success {
color: $uv-icon-success;
}
&--error {
color: $uv-icon-error;
}
&--warning {
color: $uv-icon-warning;
}
&--info {
color: $uv-icon-info;
}
}
&__img {
/* #ifndef APP-NVUE */
height: auto;
will-change: transform;
/* #endif */
}
&__label {
/* #ifndef APP-NVUE */
line-height: $uv-icon-label-line-height;
/* #endif */
}
}
</style>

Binary file not shown.

View File

@@ -0,0 +1,83 @@
{
"id": "uv-icon",
"displayName": "uv-icon 图标 全面兼容vue3+2、app、h5、小程序等多端",
"version": "1.0.13",
"description": "基于字体的图标集,包含了大多数常见场景的图标,支持自定义,支持自定义图片图标等。可自定义颜色、大小。",
"keywords": [
"uv-ui,uvui,uv-icon,icon,图标,字体图标"
],
"repository": "",
"engines": {
"HBuilderX": "^3.1.0"
},
"dcloudext": {
"type": "component-vue",
"sale": {
"regular": {
"price": "0.00"
},
"sourcecode": {
"price": "0.00"
}
},
"contact": {
"qq": ""
},
"declaration": {
"ads": "无",
"data": "插件不采集任何数据",
"permissions": "无"
},
"npmurl": ""
},
"uni_modules": {
"dependencies": [
"uv-ui-tools"
],
"encrypt": [],
"platforms": {
"cloud": {
"tcb": "y",
"aliyun": "y"
},
"client": {
"Vue": {
"vue2": "y",
"vue3": "y"
},
"App": {
"app-vue": "y",
"app-nvue": "y"
},
"H5-mobile": {
"Safari": "y",
"Android Browser": "y",
"微信浏览器(Android)": "y",
"QQ浏览器(Android)": "y"
},
"H5-pc": {
"Chrome": "y",
"IE": "y",
"Edge": "y",
"Firefox": "y",
"Safari": "y"
},
"小程序": {
"微信": "y",
"阿里": "y",
"百度": "y",
"字节跳动": "y",
"QQ": "y",
"钉钉": "u",
"快手": "u",
"飞书": "u",
"京东": "u"
},
"快应用": {
"华为": "u",
"联盟": "u"
}
}
}
}
}

View File

@@ -0,0 +1,15 @@
## uv-icon 图标库
> **组件名uv-icon**
基于字体的图标集,包含了大多数常见场景的图标,支持自定义,支持自定义图片图标等。
# <a href="https://www.uvui.cn/components/icon.html" target="_blank">查看文档</a>
## [下载完整示例项目](https://ext.dcloud.net.cn/plugin?name=uv-ui)
### [更多插件请关注uv-ui组件库](https://ext.dcloud.net.cn/plugin?name=uv-ui)
![image](https://mp-a667b617-c5f1-4a2d-9a54-683a67cff588.cdn.bspapp.com/uv-ui/banner.png)
#### 如使用过程中有任何问题反馈或者您对uv-ui有一些好的建议欢迎加入uv-ui官方交流群<a href="https://www.uvui.cn/components/addQQGroup.html" target="_blank">官方QQ群</a>

View File

@@ -0,0 +1,29 @@
## 1.0.132023-12-06
1. 优化
## 1.0.122023-12-06
1. 阻止事件冒泡问题
## 1.0.112023-11-10
1. 调整清除按钮样式的marginLeft避免微信上多数情况触发不了的BUG
## 1.0.102023-10-07
1. 修复搜狗输入法下存在不可清空的情况
## 1.0.92023-09-14
1. 修复H5等情况设置禁用或可读情况下点击事件无效的问题
## 1.0.82023-08-22
1. 修复无法@keyboardheightchange无法获取键盘高度的BUG
## 1.0.72023-08-18
1. 修复ios端不能输入的BUG
## 1.0.62023-08-05
1. 修复在vue2模式下v-model设置为0时不生效的BUG
## 1.0.52023-07-18
1. 修复在微信小程序端清除内容存在不能清除的BUG
## 1.0.42023-07-13
1. 修复value/v-model更改不生效的BUG
## 1.0.32023-07-03
去除插槽判断避免某些平台不显示的BUG
## 1.0.22023-05-16
1. 优化组件依赖,修改后无需全局引入,组件导入即可使用
2. 优化部分功能
## 1.0.12023-05-12
1. 修复vue3双向绑定的BUG
## 1.0.02023-05-10
uv-input 输入框

View File

@@ -0,0 +1,175 @@
export default {
props: {
value: {
type: [String, Number],
default: ''
},
modelValue: {
type: [String, Number],
default: ''
},
// 输入框类型
// number-数字输入键盘app-vue下可以输入浮点数app-nvue和小程序平台下只能输入整数
// idcard-身份证输入键盘微信、支付宝、百度、QQ小程序
// digit-带小数点的数字键盘App的nvue页面、微信、支付宝、百度、头条、QQ小程序
// text-文本输入键盘
type: {
type: String,
default: 'text'
},
// 是否禁用输入框
disabled: {
type: Boolean,
default: false
},
// 禁用状态时的背景色
disabledColor: {
type: String,
default: '#f5f7fa'
},
// 是否显示清除控件
clearable: {
type: Boolean,
default: false
},
// 是否密码类型
password: {
type: Boolean,
default: false
},
// 最大输入长度,设置为 -1 的时候不限制最大长度
maxlength: {
type: [String, Number],
default: -1
},
// 输入框为空时的占位符
placeholder: {
type: String,
default: null
},
// 指定placeholder的样式类注意页面或组件的style中写了scoped时需要在类名前写/deep/
placeholderClass: {
type: String,
default: 'input-placeholder'
},
// 指定placeholder的样式
placeholderStyle: {
type: [String, Object],
default: 'color: #c0c4cc'
},
// 设置右下角按钮的文字有效值send|search|next|go|done兼容性详见uni-app文档
// https://uniapp.dcloud.io/component/input
// https://uniapp.dcloud.io/component/textarea
confirmType: {
type: String,
default: 'done'
},
// 点击键盘右下角按钮时是否保持键盘不收起H5无效
confirmHold: {
type: Boolean,
default: false
},
// focus时点击页面的时候不收起键盘微信小程序有效
holdKeyboard: {
type: Boolean,
default: false
},
// 自动获取焦点
// 在 H5 平台能否聚焦以及软键盘是否跟随弹出取决于当前浏览器本身的实现。nvue 页面不支持,需使用组件的 focus()、blur() 方法控制焦点
focus: {
type: Boolean,
default: false
},
// 键盘收起时是否自动失去焦点目前仅App3.0.0+有效
autoBlur: {
type: Boolean,
default: false
},
// 指定focus时光标的位置
cursor: {
type: [String, Number],
default: -1
},
// 输入框聚焦时底部与键盘的距离
cursorSpacing: {
type: [String, Number],
default: 30
},
// 光标起始位置自动聚集时有效需与selection-end搭配使用
selectionStart: {
type: [String, Number],
default: -1
},
// 光标结束位置自动聚集时有效需与selection-start搭配使用
selectionEnd: {
type: [String, Number],
default: -1
},
// 键盘弹起时,是否自动上推页面
adjustPosition: {
type: Boolean,
default: true
},
// 输入框内容对齐方式可选值为left|center|right
inputAlign: {
type: String,
default: 'left'
},
// 输入框字体的大小
fontSize: {
type: [String, Number],
default: '14px'
},
// 输入框字体颜色
color: {
type: String,
default: '#303133'
},
// 输入框前置图标
prefixIcon: {
type: String,
default: ''
},
// 前置图标样式,对象或字符串
prefixIconStyle: {
type: [String, Object],
default: ''
},
// 输入框后置图标
suffixIcon: {
type: String,
default: ''
},
// 后置图标样式,对象或字符串
suffixIconStyle: {
type: [String, Object],
default: ''
},
// 边框类型surround-四周边框bottom-底部边框none-无边框
border: {
type: String,
default: 'surround'
},
// 是否只读与disabled不同之处在于disabled会置灰组件而readonly则不会
readonly: {
type: Boolean,
default: false
},
// 输入框形状circle-圆形square-方形
shape: {
type: String,
default: 'square'
},
// 用于处理或者过滤输入框内容的方法
formatter: {
type: [Function, null],
default: null
},
// 是否忽略组件内对文本合成系统事件的处理
ignoreCompositionEvent: {
type: Boolean,
default: true
},
...uni.$uv?.props?.input
}
}

View File

@@ -0,0 +1,348 @@
<template>
<view class="uv-input" :class="inputClass" :style="[wrapperStyle]">
<view class="uv-input__content">
<view class="uv-input__content__prefix-icon">
<slot name="prefix">
<uv-icon
v-if="prefixIcon"
:name="prefixIcon"
size="18"
:customStyle="prefixIconStyle"
></uv-icon>
</slot>
</view>
<view class="uv-input__content__field-wrapper" @click="clickHandler">
<!-- 根据uni-app的input组件文档H5和APP中只要声明了password参数(无论true还是false)type均失效此时
为了防止type=number时又存在password属性type无效此时需要设置password为undefined
-->
<input
class="uv-input__content__field-wrapper__field"
:style="[inputStyle]"
:type="type"
:focus="focus"
:cursor="cursor"
:value="innerValue"
:auto-blur="autoBlur"
:disabled="disabled || readonly"
:maxlength="maxlength"
:placeholder="placeholder"
:placeholder-style="placeholderStyle"
:placeholder-class="placeholderClass"
:confirm-type="confirmType"
:confirm-hold="confirmHold"
:hold-keyboard="holdKeyboard"
:cursor-spacing="cursorSpacing"
:adjust-position="adjustPosition"
:selection-end="selectionEnd"
:selection-start="selectionStart"
:password="password || type === 'password' || undefined"
:ignoreCompositionEvent="ignoreCompositionEvent"
@input="onInput"
@blur="onBlur"
@focus="onFocus"
@confirm="onConfirm"
@keyboardheightchange="onkeyboardheightchange"
/>
</view>
<view
class="uv-input__content__clear"
v-if="isShowClear"
@tap="onClear"
>
<uv-icon
name="close"
size="11"
color="#ffffff"
customStyle="line-height: 12px"
></uv-icon>
</view>
<view class="uv-input__content__subfix-icon">
<slot name="suffix">
<uv-icon
v-if="suffixIcon"
:name="suffixIcon"
size="18"
:customStyle="suffixIconStyle"
></uv-icon>
</slot>
</view>
</view>
</view>
</template>
<script>
import mpMixin from '@/uni_modules/uv-ui-tools/libs/mixin/mpMixin.js'
import mixin from '@/uni_modules/uv-ui-tools/libs/mixin/mixin.js'
import props from "./props.js";
/**
* Input 输入框
* @description 此组件为一个输入框默认没有边框和样式是专门为配合表单组件uv-form而设计的利用它可以快速实现表单验证输入内容下拉选择等功能。
* @tutorial https://www.uvui.cn/components/input.html
* @property {String | Number} value 输入的值
* @property {String} type 输入框类型,见上方说明 默认 'text'
* @property {Boolean} fixed 如果 textarea 是在一个 position:fixed 的区域,需要显示指定属性 fixed 为 true兼容性微信小程序、百度小程序、字节跳动小程序、QQ小程序 默认 false
* @property {Boolean} disabled 是否禁用输入框 默认 false
* @property {String} disabledColor 禁用状态时的背景色( 默认 '#f5f7fa'
* @property {Boolean} clearable 是否显示清除控件 默认 false
* @property {Boolean} password 是否密码类型 默认 false
* @property {String | Number} maxlength 最大输入长度,设置为 -1 的时候不限制最大长度 默认 -1
* @property {String} placeholder 输入框为空时的占位符
* @property {String} placeholderClass 指定placeholder的样式类注意页面或组件的style中写了scoped时需要在类名前写/deep/ 默认 'input-placeholder'
* @property {String | Object} placeholderStyle 指定placeholder的样式字符串/对象形式,如"color: red;"
* @property {Boolean} showWordLimit 是否显示输入字数统计,只在 type ="text"或type ="textarea"时有效 默认 false
* @property {String} confirmType 设置右下角按钮的文字兼容性详见uni-app文档 默认 'done'
* @property {Boolean} confirmHold 点击键盘右下角按钮时是否保持键盘不收起H5无效 默认 false
* @property {Boolean} holdKeyboard focus时点击页面的时候不收起键盘微信小程序有效 默认 false
* @property {Boolean} focus 自动获取焦点,在 H5 平台能否聚焦以及软键盘是否跟随弹出取决于当前浏览器本身的实现。nvue 页面不支持,需使用组件的 focus()、blur() 方法控制焦点 默认 false
* @property {Boolean} autoBlur 键盘收起时是否自动失去焦点目前仅App3.0.0+有效 默认 false
* @property {Boolean} disableDefaultPadding 是否去掉 iOS 下的默认内边距仅微信小程序且type=textarea时有效 默认 false
* @property {String Number} cursor 指定focus时光标的位置 默认 -1
* @property {String Number} cursorSpacing 输入框聚焦时底部与键盘的距离 默认 30
* @property {String Number} selectionStart 光标起始位置自动聚集时有效需与selection-end搭配使用 默认 -1
* @property {String Number} selectionEnd 光标结束位置自动聚集时有效需与selection-start搭配使用 默认 -1
* @property {Boolean} adjustPosition 键盘弹起时,是否自动上推页面 默认 true
* @property {String} inputAlign 输入框内容对齐方式( 默认 'left'
* @property {String | Number} fontSize 输入框字体的大小 默认 '15px'
* @property {String} color 输入框字体颜色 默认 '#303133'
* @property {Function} formatter 内容式化函数
* @property {String} prefixIcon 输入框前置图标
* @property {String | Object} prefixIconStyle 前置图标样式,对象或字符串
* @property {String} suffixIcon 输入框后置图标
* @property {String | Object} suffixIconStyle 后置图标样式,对象或字符串
* @property {String} border 边框类型surround-四周边框bottom-底部边框none-无边框 默认 'surround'
* @property {Boolean} readonly 是否只读与disabled不同之处在于disabled会置灰组件而readonly则不会 默认 false
* @property {String} shape 输入框形状circle-圆形square-方形 默认 'square'
* @property {Object} customStyle 定义需要用到的外部样式
* @property {Boolean} ignoreCompositionEvent 是否忽略组件内对文本合成系统事件的处理。
* @example <uv-input v-model="value" :password="true" suffix-icon="lock-fill" />
*/
export default {
name: "uv-input",
mixins: [mpMixin, mixin, props],
data() {
return {
// 输入框的值
innerValue: "",
// 是否处于获得焦点状态
focused: false,
// 过滤处理方法
innerFormatter: value => value
}
},
created() {
// #ifdef VUE2
this.innerValue = this.value;
// #endif
// #ifdef VUE3
this.innerValue = this.modelValue;
// #endif
},
watch: {
value(newVal){
this.innerValue = newVal;
},
modelValue(newVal){
this.innerValue = newVal;
}
},
computed: {
// 是否显示清除控件
isShowClear() {
const { clearable, readonly, focused, innerValue } = this;
return !!clearable && !readonly && !!focused && innerValue !== "";
},
// 组件的类名
inputClass() {
let classes = [],
{ border, disabled, shape } = this;
border === "surround" &&
(classes = classes.concat(["uv-border", "uv-input--radius"]));
classes.push(`uv-input--${shape}`);
border === "bottom" &&
(classes = classes.concat([
"uv-border-bottom",
"uv-input--no-radius",
]));
return classes.join(" ");
},
// 组件的样式
wrapperStyle() {
const style = {};
// 禁用状态下,被背景色加上对应的样式
if (this.disabled) {
style.backgroundColor = this.disabledColor;
}
// 无边框时,去除内边距
if (this.border === "none") {
style.padding = "0";
} else {
// 由于uni-app的iOS开发者能力有限导致需要分开写才有效
style.paddingTop = "6px";
style.paddingBottom = "6px";
style.paddingLeft = "9px";
style.paddingRight = "9px";
}
return this.$uv.deepMerge(style, this.$uv.addStyle(this.customStyle));
},
// 输入框的样式
inputStyle() {
const style = {
color: this.color,
fontSize: this.$uv.addUnit(this.fontSize),
textAlign: this.inputAlign
};
// #ifndef APP-NVUE
if(this.disabled || this.readonly) {
style['pointer-events'] = 'none';
}
// #endif
return style;
}
},
methods: {
// 在微信小程序中不支持将函数当做props参数故只能通过ref形式调用
setFormatter(e) {
this.innerFormatter = e
},
// 当键盘输入时触发input事件
onInput(e) {
let { value = "" } = e.detail || {};
// 格式化过滤方法
const formatter = this.formatter || this.innerFormatter
const formatValue = formatter(value)
// 为了避免props的单向数据流特性需要先将innerValue值设置为当前值再在$nextTick中重新赋予设置后的值才有效
this.innerValue = value
this.$nextTick(() => {
this.innerValue = formatValue;
this.valueChange();
})
},
// 输入框失去焦点时触发
onBlur(event) {
this.$emit("blur", event.detail.value);
// H5端的blur会先于点击清除控件的点击click事件触发导致focused
// 瞬间为false从而隐藏了清除控件而无法被点击到
this.$uv.sleep(100).then(() => {
this.focused = false;
});
// 尝试调用uv-form的验证方法
this.$uv.formValidate(this, "blur");
},
// 输入框聚焦时触发
onFocus(event) {
this.focused = true;
this.$emit("focus");
},
// 点击完成按钮时触发
onConfirm(event) {
this.$emit("confirm", this.innerValue);
},
// 键盘高度发生变化的时候触发此事件
// 兼容性微信小程序2.7.0+、App 3.1.0+
onkeyboardheightchange(e) {
this.$emit("keyboardheightchange",e);
},
// 内容发生变化,进行处理
valueChange() {
if(this.isClear) this.innerValue = '';
const value = this.innerValue;
this.$nextTick(() => {
this.$emit("input", value);
this.$emit("update:modelValue", value);
this.$emit("change", value);
// 尝试调用uv-form的验证方法
this.$uv.formValidate(this, "change");
});
},
// 点击清除控件
onClear() {
this.innerValue = "";
this.isClear = true;
this.$uv.sleep(200).then(res=>{
this.isClear = false;
})
this.$nextTick(() => {
this.$emit("clear");
this.valueChange();
});
},
/**
* 在安卓nvue上事件无法冒泡
* 在某些时间我们希望监听uv-from-item的点击事件此时会导致点击uv-form-item内的uv-input后
* 无法触发uv-form-item的点击事件这里通过手动调用uv-form-item的方法进行触发
*/
clickHandler() {
// #ifdef APP-NVUE
if (this.$uv.os() === "android") {
const formItem = this.$uv.$parent.call(this, "uv-form-item");
if (formItem) {
formItem.clickHandler();
}
}
// #endif
}
}
}
</script>
<style lang="scss" scoped>
$show-border: 1;
$show-border-surround: 1;
$show-border-bottom: 1;
@import '@/uni_modules/uv-ui-tools/libs/css/variable.scss';
@import '@/uni_modules/uv-ui-tools/libs/css/components.scss';
@import '@/uni_modules/uv-ui-tools/libs/css/color.scss';
.uv-input {
@include flex(row);
align-items: center;
justify-content: space-between;
flex: 1;
&--radius,
&--square {
border-radius: 4px;
}
&--no-radius {
border-radius: 0;
}
&--circle {
border-radius: 100px;
}
&__content {
flex: 1;
@include flex(row);
align-items: center;
justify-content: space-between;
&__field-wrapper {
position: relative;
@include flex(row);
margin: 0;
flex: 1;
&__field {
line-height: 26px;
text-align: left;
color: $uv-main-color;
height: 24px;
font-size: 15px;
flex: 1;
}
}
&__clear {
width: 20px;
height: 20px;
border-radius: 100px;
background-color: #c6c7cb;
@include flex(row);
align-items: center;
justify-content: center;
transform: scale(0.82);
margin-left: 15px;
}
&__subfix-icon {
margin-left: 4px;
}
&__prefix-icon {
margin-right: 4px;
}
}
}
</style>

View File

@@ -0,0 +1,88 @@
{
"id": "uv-input",
"displayName": "uv-input 输入框 全面兼容vue3+2、app、h5、小程序等多端",
"version": "1.0.13",
"description": "uv-input 该组件为一个输入框默认没有边框和样式是专门为配合表单组件uv-form而设计的利用它可以快速实现表单验证输入内容下拉选择等功能。",
"keywords": [
"uv-input",
"uvui",
"uv-ui",
"input",
"输入框"
],
"repository": "",
"engines": {
"HBuilderX": "^3.1.0"
},
"dcloudext": {
"type": "component-vue",
"sale": {
"regular": {
"price": "0.00"
},
"sourcecode": {
"price": "0.00"
}
},
"contact": {
"qq": ""
},
"declaration": {
"ads": "无",
"data": "插件不采集任何数据",
"permissions": "无"
},
"npmurl": ""
},
"uni_modules": {
"dependencies": [
"uv-ui-tools",
"uv-icon"
],
"encrypt": [],
"platforms": {
"cloud": {
"tcb": "y",
"aliyun": "y"
},
"client": {
"Vue": {
"vue2": "y",
"vue3": "y"
},
"App": {
"app-vue": "y",
"app-nvue": "y"
},
"H5-mobile": {
"Safari": "y",
"Android Browser": "y",
"微信浏览器(Android)": "y",
"QQ浏览器(Android)": "y"
},
"H5-pc": {
"Chrome": "y",
"IE": "y",
"Edge": "y",
"Firefox": "y",
"Safari": "y"
},
"小程序": {
"微信": "y",
"阿里": "y",
"百度": "y",
"字节跳动": "y",
"QQ": "y",
"钉钉": "u",
"快手": "u",
"飞书": "u",
"京东": "u"
},
"快应用": {
"华为": "u",
"联盟": "u"
}
}
}
}
}

View File

@@ -0,0 +1,19 @@
## Input 输入框
> **组件名uv-input**
此组件为一个输入框默认没有边框和样式是专门为配合表单组件uv-form而设计的利用它可以快速实现表单验证输入内容下拉选择等功能。
# <a href="https://www.uvui.cn/components/input.html" target="_blank">查看文档</a>
## [下载完整示例项目](https://ext.dcloud.net.cn/plugin?name=uv-ui) <small>(请不要 下载插件ZIP</small>
### [更多插件请关注uv-ui组件库](https://ext.dcloud.net.cn/plugin?name=uv-ui)
<a href="https://ext.dcloud.net.cn/plugin?name=uv-ui" target="_blank">
![image](https://mp-a667b617-c5f1-4a2d-9a54-683a67cff588.cdn.bspapp.com/uv-ui/banner.png)
</a>
#### 如使用过程中有任何问题反馈或者您对uv-ui有一些好的建议欢迎加入uv-ui官方交流群<a href="https://www.uvui.cn/components/addQQGroup.html" target="_blank">官方QQ群</a>

View File

@@ -0,0 +1,9 @@
## 1.0.32023-08-14
1. 新增参数textStyle自定义文本样式
## 1.0.22023-06-27
优化
## 1.0.12023-05-16
1. 优化组件依赖,修改后无需全局引入,组件导入即可使用
2. 优化部分功能
## 1.0.02023-05-10
1. 新增uv-loading-icon组件

View File

@@ -0,0 +1,67 @@
export default {
props: {
// 是否显示组件
show: {
type: Boolean,
default: true
},
// 颜色
color: {
type: String,
default: '#909193'
},
// 提示文字颜色
textColor: {
type: String,
default: '#909193'
},
// 文字和图标是否垂直排列
vertical: {
type: Boolean,
default: false
},
// 模式选择circle-圆形spinner-花朵形semicircle-半圆形
mode: {
type: String,
default: 'spinner'
},
// 图标大小单位默认px
size: {
type: [String, Number],
default: 24
},
// 文字大小
textSize: {
type: [String, Number],
default: 15
},
// 文字样式
textStyle: {
type: Object,
default () {
return {}
}
},
// 文字内容
text: {
type: [String, Number],
default: ''
},
// 动画模式 https://www.runoob.com/cssref/css3-pr-animation-timing-function.html
timingFunction: {
type: String,
default: 'linear'
},
// 动画执行周期时间
duration: {
type: [String, Number],
default: 1200
},
// mode=circle时的暗边颜色
inactiveColor: {
type: String,
default: ''
},
...uni.$uv?.props?.loadingIcon
}
}

View File

@@ -0,0 +1,347 @@
<template>
<view
class="uv-loading-icon"
:style="[$uv.addStyle(customStyle)]"
:class="[vertical && 'uv-loading-icon--vertical']"
v-if="show"
>
<view
v-if="!webviewHide"
class="uv-loading-icon__spinner"
:class="[`uv-loading-icon__spinner--${mode}`]"
ref="ani"
:style="{
color: color,
width: $uv.addUnit(size),
height: $uv.addUnit(size),
borderTopColor: color,
borderBottomColor: otherBorderColor,
borderLeftColor: otherBorderColor,
borderRightColor: otherBorderColor,
'animation-duration': `${duration}ms`,
'animation-timing-function': mode === 'semicircle' || mode === 'circle' ? timingFunction : ''
}"
>
<block v-if="mode === 'spinner'">
<!-- #ifndef APP-NVUE -->
<view
v-for="(item, index) in array12"
:key="index"
class="uv-loading-icon__dot"
>
</view>
<!-- #endif -->
<!-- #ifdef APP-NVUE -->
<!-- 此组件内部图标部分无法设置宽高即使通过width和height配置了也无效 -->
<loading-indicator
v-if="!webviewHide"
class="uv-loading-indicator"
:animating="true"
:style="{
color: color,
width: $uv.addUnit(size),
height: $uv.addUnit(size)
}"
/>
<!-- #endif -->
</block>
</view>
<text
v-if="text"
class="uv-loading-icon__text"
:style="[{
fontSize: $uv.addUnit(textSize),
color: textColor,
},$uv.addStyle(textStyle)]"
>{{text}}</text>
</view>
</template>
<script>
import { colorGradient } from '@/uni_modules/uv-ui-tools/libs/function/colorGradient.js'
import mpMixin from '@/uni_modules/uv-ui-tools/libs/mixin/mpMixin.js'
import mixin from '@/uni_modules/uv-ui-tools/libs/mixin/mixin.js'
import props from './props.js';
// #ifdef APP-NVUE
const animation = weex.requireModule('animation');
// #endif
/**
* loading 加载动画
* @description 警此组件为一个小动画目前用在uvui的loadmore加载更多和switch开关等组件的正在加载状态场景。
* @tutorial https://www.uvui.cn/components/loading.html
* @property {Boolean} show 是否显示组件 (默认 true)
* @property {String} color 动画活动区域的颜色,只对 mode = flower 模式有效(默认#909193
* @property {String} textColor 提示文本的颜色(默认#909193
* @property {Boolean} vertical 文字和图标是否垂直排列 (默认 false )
* @property {String} mode 模式选择,见官网说明(默认 'circle'
* @property {String | Number} size 加载图标的大小单位px (默认 24
* @property {String | Number} textSize 文字大小(默认 15
* @property {String | Number} text 文字内容
* @property {Object} textStyle 文字样式
* @property {String} timingFunction 动画模式 (默认 'ease-in-out'
* @property {String | Number} duration 动画执行周期时间(默认 1200
* @property {String} inactiveColor mode=circle时的暗边颜色
* @property {Object} customStyle 定义需要用到的外部样式
* @example <uv-loading mode="circle"></uv-loading>
*/
export default {
name: 'uv-loading-icon',
mixins: [mpMixin, mixin, props],
data() {
return {
// Array.form可以通过一个伪数组对象创建指定长度的数组
// https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Array/from
array12: Array.from({
length: 12
}),
// 这里需要设置默认值为360否则在安卓nvue上会延迟一个duration周期后才执行
// 在iOS nvue上则会一开始默认执行两个周期的动画
aniAngel: 360, // 动画旋转角度
webviewHide: false, // 监听webview的状态如果隐藏了页面则停止动画以免性能消耗
loading: false, // 是否运行中针对nvue使用
}
},
computed: {
// 当为circle类型时给其另外三边设置一个更轻一些的颜色
// 之所以需要这么做的原因是比如父组件传了color为红色那么需要另外的三个边为浅红色
// 而不能是固定的某一个其他颜色(因为这个固定的颜色可能浅蓝,导致效果没有那么细腻良好)
otherBorderColor() {
const lightColor = colorGradient(this.color, '#ffffff', 100)[80]
if (this.mode === 'circle') {
return this.inactiveColor ? this.inactiveColor : lightColor
} else {
return 'transparent'
}
}
},
watch: {
show(n) {
// nvue中show为true且为非loading状态就重新执行动画模块
// #ifdef APP-NVUE
if (n && !this.loading) {
setTimeout(() => {
this.startAnimate()
}, 30)
}
// #endif
}
},
mounted() {
this.init()
},
methods: {
init() {
setTimeout(() => {
// #ifdef APP-NVUE
this.show && this.nvueAnimate()
// #endif
// #ifdef APP-PLUS
this.show && this.addEventListenerToWebview()
// #endif
}, 20)
},
// 监听webview的显示与隐藏
addEventListenerToWebview() {
// webview的堆栈
const pages = getCurrentPages()
// 当前页面
const page = pages[pages.length - 1]
// 当前页面的webview实例
const currentWebview = page.$getAppWebview()
// 监听webview的显示与隐藏从而停止或者开始动画(为了性能)
currentWebview.addEventListener('hide', () => {
this.webviewHide = true
})
currentWebview.addEventListener('show', () => {
this.webviewHide = false
})
},
// #ifdef APP-NVUE
nvueAnimate() {
// nvue下非spinner类型时才需要旋转因为nvue的spinner类型使用了weex的
// loading-indicator组件自带旋转功能
this.mode !== 'spinner' && this.startAnimate()
},
// 执行nvue的animate模块动画
startAnimate() {
this.loading = true
const ani = this.$refs.ani
if (!ani) return
animation.transition(ani, {
// 进行角度旋转
styles: {
transform: `rotate(${this.aniAngel}deg)`,
transformOrigin: 'center center'
},
duration: this.duration,
timingFunction: this.timingFunction,
// delay: 10
}, () => {
// 每次增加360deg为了让其重新旋转一周
this.aniAngel += 360
// 动画结束后继续循环执行动画需要同时判断webviewHide变量
// nvue安卓页面隐藏后依然会继续执行startAnimate方法
this.show && !this.webviewHide ? this.startAnimate() : this.loading = false
})
}
// #endif
}
}
</script>
<style lang="scss" scoped>
@import '@/uni_modules/uv-ui-tools/libs/css/components.scss';
@import '@/uni_modules/uv-ui-tools/libs/css/color.scss';
$uv-loading-icon-color: #c8c9cc !default;
$uv-loading-icon-text-margin-left:4px !default;
$uv-loading-icon-text-color:$uv-content-color !default;
$uv-loading-icon-text-font-size:14px !default;
$uv-loading-icon-text-line-height:20px !default;
$uv-loading-width:30px !default;
$uv-loading-height:30px !default;
$uv-loading-max-width:100% !default;
$uv-loading-max-height:100% !default;
$uv-loading-semicircle-border-width: 2px !default;
$uv-loading-semicircle-border-color:transparent !default;
$uv-loading-semicircle-border-top-right-radius: 100px !default;
$uv-loading-semicircle-border-top-left-radius: 100px !default;
$uv-loading-semicircle-border-bottom-left-radius: 100px !default;
$uv-loading-semicircle-border-bottom-right-radiu: 100px !default;
$uv-loading-semicircle-border-style: solid !default;
$uv-loading-circle-border-top-right-radius: 100px !default;
$uv-loading-circle-border-top-left-radius: 100px !default;
$uv-loading-circle-border-bottom-left-radius: 100px !default;
$uv-loading-circle-border-bottom-right-radiu: 100px !default;
$uv-loading-circle-border-width:2px !default;
$uv-loading-circle-border-top-color:#e5e5e5 !default;
$uv-loading-circle-border-right-color:$uv-loading-circle-border-top-color !default;
$uv-loading-circle-border-bottom-color:$uv-loading-circle-border-top-color !default;
$uv-loading-circle-border-left-color:$uv-loading-circle-border-top-color !default;
$uv-loading-circle-border-style:solid !default;
$uv-loading-icon-host-font-size:0px !default;
$uv-loading-icon-host-line-height:1 !default;
$uv-loading-icon-vertical-margin:6px 0 0 !default;
$uv-loading-icon-dot-top:0 !default;
$uv-loading-icon-dot-left:0 !default;
$uv-loading-icon-dot-width:100% !default;
$uv-loading-icon-dot-height:100% !default;
$uv-loading-icon-dot-before-width:2px !default;
$uv-loading-icon-dot-before-height:25% !default;
$uv-loading-icon-dot-before-margin:0 auto !default;
$uv-loading-icon-dot-before-background-color:currentColor !default;
$uv-loading-icon-dot-before-border-radius:40% !default;
.uv-loading-icon {
/* #ifndef APP-NVUE */
// display: inline-flex;
/* #endif */
flex-direction: row;
align-items: center;
justify-content: center;
color: $uv-loading-icon-color;
&__text {
margin-left: $uv-loading-icon-text-margin-left;
color: $uv-loading-icon-text-color;
font-size: $uv-loading-icon-text-font-size;
line-height: $uv-loading-icon-text-line-height;
}
&__spinner {
width: $uv-loading-width;
height: $uv-loading-height;
position: relative;
/* #ifndef APP-NVUE */
box-sizing: border-box;
max-width: $uv-loading-max-width;
max-height: $uv-loading-max-height;
animation: uv-rotate 1s linear infinite;
/* #endif */
}
&__spinner--semicircle {
border-width: $uv-loading-semicircle-border-width;
border-color: $uv-loading-semicircle-border-color;
border-top-right-radius: $uv-loading-semicircle-border-top-right-radius;
border-top-left-radius: $uv-loading-semicircle-border-top-left-radius;
border-bottom-left-radius: $uv-loading-semicircle-border-bottom-left-radius;
border-bottom-right-radius: $uv-loading-semicircle-border-bottom-right-radiu;
border-style: $uv-loading-semicircle-border-style;
}
&__spinner--circle {
border-top-right-radius: $uv-loading-circle-border-top-right-radius;
border-top-left-radius: $uv-loading-circle-border-top-left-radius;
border-bottom-left-radius: $uv-loading-circle-border-bottom-left-radius;
border-bottom-right-radius: $uv-loading-circle-border-bottom-right-radiu;
border-width: $uv-loading-circle-border-width;
border-top-color: $uv-loading-circle-border-top-color;
border-right-color: $uv-loading-circle-border-right-color;
border-bottom-color: $uv-loading-circle-border-bottom-color;
border-left-color: $uv-loading-circle-border-left-color;
border-style: $uv-loading-circle-border-style;
}
&--vertical {
flex-direction: column
}
}
/* #ifndef APP-NVUE */
:host {
font-size: $uv-loading-icon-host-font-size;
line-height: $uv-loading-icon-host-line-height;
}
.uv-loading-icon {
&__spinner--spinner {
animation-timing-function: steps(12)
}
&__text:empty {
display: none
}
&--vertical &__text {
margin: $uv-loading-icon-vertical-margin;
color: $uv-content-color;
}
&__dot {
position: absolute;
top: $uv-loading-icon-dot-top;
left: $uv-loading-icon-dot-left;
width: $uv-loading-icon-dot-width;
height: $uv-loading-icon-dot-height;
&:before {
display: block;
width: $uv-loading-icon-dot-before-width;
height: $uv-loading-icon-dot-before-height;
margin: $uv-loading-icon-dot-before-margin;
background-color: $uv-loading-icon-dot-before-background-color;
border-radius: $uv-loading-icon-dot-before-border-radius;
content: " "
}
}
}
@for $i from 1 through 12 {
.uv-loading-icon__dot:nth-of-type(#{$i}) {
transform: rotate($i * 30deg);
opacity: 1 - 0.0625 * ($i - 1);
}
}
@keyframes uv-rotate {
0% {
transform: rotate(0deg)
}
to {
transform: rotate(1turn)
}
}
/* #endif */
</style>

View File

@@ -0,0 +1,87 @@
{
"id": "uv-loading-icon",
"displayName": "uv-loading-icon 加载动画 全面兼容vue3+2、app、h5、小程序等多端",
"version": "1.0.3",
"description": "此组件为一个小动画目前用在uv-ui的uv-load-more加载更多等组件还可以运用在项目中正在加载状态场景。",
"keywords": [
"uv-loading-icon",
"uvui",
"uv-ui",
"loading",
"加载动画"
],
"repository": "",
"engines": {
"HBuilderX": "^3.1.0"
},
"dcloudext": {
"type": "component-vue",
"sale": {
"regular": {
"price": "0.00"
},
"sourcecode": {
"price": "0.00"
}
},
"contact": {
"qq": ""
},
"declaration": {
"ads": "无",
"data": "插件不采集任何数据",
"permissions": "无"
},
"npmurl": ""
},
"uni_modules": {
"dependencies": [
"uv-ui-tools"
],
"encrypt": [],
"platforms": {
"cloud": {
"tcb": "y",
"aliyun": "y"
},
"client": {
"Vue": {
"vue2": "y",
"vue3": "y"
},
"App": {
"app-vue": "y",
"app-nvue": "y"
},
"H5-mobile": {
"Safari": "y",
"Android Browser": "y",
"微信浏览器(Android)": "y",
"QQ浏览器(Android)": "y"
},
"H5-pc": {
"Chrome": "y",
"IE": "y",
"Edge": "y",
"Firefox": "y",
"Safari": "y"
},
"小程序": {
"微信": "y",
"阿里": "y",
"百度": "y",
"字节跳动": "y",
"QQ": "y",
"钉钉": "u",
"快手": "u",
"飞书": "u",
"京东": "u"
},
"快应用": {
"华为": "u",
"联盟": "u"
}
}
}
}
}

View File

@@ -0,0 +1,19 @@
## LoadingIcon 加载动画
> **组件名uv-loading-icon**
此组件为一个小动画,目前用在 `uv-ui``uv-load-more` 加载更多等组件,还可以运用在项目中正在加载状态场景。
# <a href="https://www.uvui.cn/components/loadingIcon.html" target="_blank">查看文档</a>
## [下载完整示例项目](https://ext.dcloud.net.cn/plugin?name=uv-ui) <small>(请不要 下载插件ZIP</small>
### [更多插件请关注uv-ui组件库](https://ext.dcloud.net.cn/plugin?name=uv-ui)
<a href="https://ext.dcloud.net.cn/plugin?name=uv-ui" target="_blank">
![image](https://mp-a667b617-c5f1-4a2d-9a54-683a67cff588.cdn.bspapp.com/uv-ui/banner.png)
</a>
#### 如使用过程中有任何问题反馈或者您对uv-ui有一些好的建议欢迎加入uv-ui官方交流群<a href="https://www.uvui.cn/components/addQQGroup.html" target="_blank">官方QQ群</a>

View File

@@ -0,0 +1,9 @@
## 1.0.32023-07-02
uv-overlay 由于弹出层uv-transition的修改组件内部做了相应的修改参数不变。
## 1.0.22023-06-29
1. 优化H5端禁止穿透滚动
## 1.0.12023-05-16
1. 优化组件依赖,修改后无需全局引入,组件导入即可使用
2. 优化部分功能
## 1.0.02023-05-10
1. 新增uv-overlay组件

View File

@@ -0,0 +1,25 @@
export default {
props: {
// 是否显示遮罩
show: {
type: Boolean,
default: false
},
// 层级z-index
zIndex: {
type: [String, Number],
default: 10070
},
// 遮罩的过渡时间单位为ms
duration: {
type: [String, Number],
default: 300
},
// 不透明度值当做rgba的第四个参数
opacity: {
type: [String, Number],
default: 0.5
},
...uni.$uv?.props?.overlay
}
}

View File

@@ -0,0 +1,85 @@
<template>
<uv-transition
:show="show"
mode="fade"
custom-class="uv-overlay"
:duration="duration"
:custom-style="overlayStyle"
@click="clickHandler"
@touchmove.stop.prevent="clear"
>
<slot />
</uv-transition>
</template>
<script>
import mpMixin from '@/uni_modules/uv-ui-tools/libs/mixin/mpMixin.js'
import mixin from '@/uni_modules/uv-ui-tools/libs/mixin/mixin.js'
import props from './props.js';
/**
* overlay 遮罩
* @description 创建一个遮罩层,用于强调特定的页面元素,并阻止用户对遮罩下层的内容进行操作,一般用于弹窗场景
* @tutorial https://www.uvui.cn/components/overlay.html
* @property {Boolean} show 是否显示遮罩(默认 false
* @property {String | Number} zIndex zIndex 层级(默认 10070
* @property {String | Number} duration 动画时长,单位毫秒(默认 300
* @property {String | Number} opacity 不透明度值当做rgba的第四个参数 (默认 0.5
* @property {Object} customStyle 定义需要用到的外部样式
* @event {Function} click 点击遮罩发送事件
* @example <uv-overlay :show="show" @click="show = false"></uv-overlay>
*/
export default {
name: "uv-overlay",
emits: ['click'],
mixins: [mpMixin, mixin, props],
watch: {
show(newVal){
// #ifdef H5
if(newVal){
document.querySelector('body').style.overflow = 'hidden';
}else{
document.querySelector('body').style.overflow = '';
}
// #endif
}
},
computed: {
overlayStyle() {
const style = {
position: 'fixed',
top: 0,
left: 0,
right: 0,
zIndex: this.zIndex,
bottom: 0,
'background-color': `rgba(0, 0, 0, ${this.opacity})`
}
return this.$uv.deepMerge(style, this.$uv.addStyle(this.customStyle))
}
},
methods: {
clickHandler() {
this.$emit('click')
},
clear() {}
}
}
</script>
<style lang="scss" scoped>
/* #ifndef APP-NVUE */
$uv-overlay-top:0 !default;
$uv-overlay-left:0 !default;
$uv-overlay-width:100% !default;
$uv-overlay-height:100% !default;
$uv-overlay-background-color:rgba(0, 0, 0, .7) !default;
.uv-overlay {
position: fixed;
top:$uv-overlay-top;
left:$uv-overlay-left;
width: $uv-overlay-width;
height:$uv-overlay-height;
background-color:$uv-overlay-background-color;
}
/* #endif */
</style>

View File

@@ -0,0 +1,88 @@
{
"id": "uv-overlay",
"displayName": "uv-overlay 遮罩层 全面兼容小程序、nvue、vue2、vue3等多端",
"version": "1.0.3",
"description": "uv-overlay 创建一个遮罩层用于强调特定的页面元素并阻止用户对遮罩下层的内容进行操作一般用于弹窗场景uv-popup、uv-toast、uv-tooltip等组件就是用了该组件。",
"keywords": [
"uv-overlay",
"uvui",
"uv-ui",
"overlay",
"遮罩层"
],
"repository": "",
"engines": {
"HBuilderX": "^3.1.0"
},
"dcloudext": {
"type": "component-vue",
"sale": {
"regular": {
"price": "0.00"
},
"sourcecode": {
"price": "0.00"
}
},
"contact": {
"qq": ""
},
"declaration": {
"ads": "无",
"data": "插件不采集任何数据",
"permissions": "无"
},
"npmurl": ""
},
"uni_modules": {
"dependencies": [
"uv-ui-tools",
"uv-transition"
],
"encrypt": [],
"platforms": {
"cloud": {
"tcb": "y",
"aliyun": "y"
},
"client": {
"Vue": {
"vue2": "y",
"vue3": "y"
},
"App": {
"app-vue": "y",
"app-nvue": "y"
},
"H5-mobile": {
"Safari": "y",
"Android Browser": "y",
"微信浏览器(Android)": "y",
"QQ浏览器(Android)": "y"
},
"H5-pc": {
"Chrome": "y",
"IE": "y",
"Edge": "y",
"Firefox": "y",
"Safari": "y"
},
"小程序": {
"微信": "y",
"阿里": "y",
"百度": "y",
"字节跳动": "y",
"QQ": "y",
"钉钉": "u",
"快手": "u",
"飞书": "u",
"京东": "u"
},
"快应用": {
"华为": "u",
"联盟": "u"
}
}
}
}
}

View File

@@ -0,0 +1,11 @@
## Overlay 遮罩层
> **组件名uv-overlay**
创建一个遮罩层用于强调特定的页面元素并阻止用户对遮罩下层的内容进行操作一般用于弹窗场景uv-popup、uv-toast、uv-tooltip等组件就是用了该组件。
### <a href="https://www.uvui.cn/components/overlay.html" target="_blank">查看文档</a>
### [完整示例项目下载 | 关注更多组件](https://ext.dcloud.net.cn/plugin?name=uv-ui)
#### 如使用过程中有任何问题或者您对uv-ui有一些好的建议欢迎加入 uv-ui 交流群:<a href="https://ext.dcloud.net.cn/plugin?id=12287" target="_blank">uv-ui</a>、<a href="https://www.uvui.cn/components/addQQGroup.html" target="_blank">官方QQ群</a>

View File

@@ -0,0 +1,33 @@
## 1.0.142023-12-29
1. 修复上个版本引出的BUG
## 1.0.132023-12-26
1. 修复抖音小程序滚到底不触发change的BUG
## 1.0.122023-11-20
1. 修复issues反馈的问题uv-picker在组合式API的自定义组件中columns动态赋值无法显示选项https://gitee.com/climblee/uv-ui/issues/I8H0GQ
## 1.0.112023-10-11
1. 将immediate-change默认值改为true该值在于change回调的及时性微信小程序生效
## 1.0.102023-08-25
1. 增加round属性设置弹窗圆角默认为0
## 1.0.92023-08-24
1. 修复cli项目不返回值的问题
## 1.0.82023-08-04
1. 优化
## 1.0.72023-08-02
1. 改组件中删除uv-toolbar组件请单独下载uv-toolbar组件
## 1.0.62023-07-02
uv-picker 由于弹出层uv-popup的修改打开和关闭方法更改详情参考文档https://www.uvui.cn/components/picker.html
## 1.0.52023-06-26
1. 增加color参数
2. 增加activeColor参数
## 1.0.42023-06-15
1. 修改支付宝报错的BUG
## 1.0.32023-06-12
1. setColumnValues的使用统一化避免某些平台报错
2. 取消change回调回传的组件实例直接统一通过ref的方式调取setColumnValues方法
## 1.0.22023-05-23
1. uv-toolbar组件新增下边框属性
## 1.0.12023-05-16
1. 优化组件依赖,修改后无需全局引入,组件导入即可使用
2. 优化部分功能
## 1.0.02023-05-10
uv-picker 选择器

View File

@@ -0,0 +1,95 @@
export default {
props: {
// 是否展示顶部的操作栏
showToolbar: {
type: Boolean,
default: true
},
// 顶部标题
title: {
type: String,
default: ''
},
// 弹窗圆角
round: {
type: [String, Number],
default: 0
},
// 对象数组,设置每一列的数据
columns: {
type: Array,
default: () => []
},
// 是否显示加载中状态
loading: {
type: Boolean,
default: false
},
// 各列中,单个选项的高度
itemHeight: {
type: [String, Number],
default: 44
},
// 取消按钮的文字
cancelText: {
type: String,
default: '取消'
},
// 确认按钮的文字
confirmText: {
type: String,
default: '确定'
},
// 取消按钮的颜色
cancelColor: {
type: String,
default: '#909193'
},
// 确认按钮的颜色
confirmColor: {
type: String,
default: '#3c9cff'
},
// 文字颜色
color: {
type: String,
default: ''
},
// 选中文字的颜色
activeColor: {
type: String,
default: ''
},
// 每列中可见选项的数量
visibleItemCount: {
type: [String, Number],
default: 5
},
// 选项对象中,需要展示的属性键名
keyName: {
type: String,
default: 'text'
},
// 是否允许点击遮罩关闭选择器
closeOnClickOverlay: {
type: Boolean,
default: true
},
// 是否允许点击确认关闭选择器
closeOnClickConfirm: {
type: Boolean,
default: true
},
// 各列的默认索引
defaultIndex: {
type: Array,
default: () => [],
},
// 是否在手指松开时立即触发 change 事件。若不开启则会在滚动动画结束后触发 change 事件只在微信2.21.1及以上有效
immediateChange: {
type: Boolean,
default: true
},
...uni.$uv?.props?.picker
}
}

View File

@@ -0,0 +1,330 @@
<template>
<uv-popup
ref="pickerPopup"
mode="bottom"
:round="round"
:close-on-click-overlay="closeOnClickOverlay"
@change="popupChange"
>
<view class="uv-picker">
<uv-toolbar
v-if="showToolbar"
:cancelColor="cancelColor"
:confirmColor="confirmColor"
:cancelText="cancelText"
:confirmText="confirmText"
:title="title"
@cancel="cancel"
@confirm="confirm"
></uv-toolbar>
<!-- #ifdef MP-TOUTIAO -->
<picker-view
class="uv-picker__view"
:indicatorStyle="`height: ${$uv.addUnit(itemHeight)}`"
:value="innerIndex"
:immediateChange="immediateChange"
:style="{
height: `${$uv.addUnit(visibleItemCount * itemHeight)}`
}"
@pickend="changeHandler"
>
<!-- #endif -->
<!-- #ifndef MP-TOUTIAO -->
<picker-view
class="uv-picker__view"
:indicatorStyle="`height: ${$uv.addUnit(itemHeight)}`"
:value="innerIndex"
:immediateChange="immediateChange"
:style="{
height: `${$uv.addUnit(visibleItemCount * itemHeight)}`
}"
@change="changeHandler"
>
<!-- #endif -->
<!-- @pickend在这里为了解决抖音等滚到底不触发change兼容性问题 -->
<picker-view-column
v-for="(item, index) in innerColumns"
:key="index"
class="uv-picker__view__column"
>
<text
v-if="$uv.test.array(item)"
class="uv-picker__view__column__item uv-line-1"
v-for="(item1, index1) in item"
:key="index1"
:style="[{
height: $uv.addUnit(itemHeight),
lineHeight: $uv.addUnit(itemHeight),
fontWeight: index1 === innerIndex[index] ? 'bold' : 'normal'
},textStyle(index,index1)]"
>{{ getItemText(item1) }}</text>
</picker-view-column>
</picker-view>
<view
v-if="loading"
class="uv-picker--loading"
>
<uv-loading-icon mode="circle"></uv-loading-icon>
</view>
</view>
</uv-popup>
</template>
<script>
/**
* uv-picker
* @description 选择器
* @property {Boolean} showToolbar 是否显示顶部的操作栏(默认 true
* @property {String} title 顶部标题
* @property {Array} columns 对象数组,设置每一列的数据
* @property {Boolean} loading 是否显示加载中状态(默认 false
* @property {String | Number} itemHeight 各列中,单个选项的高度(默认 44
* @property {String} cancelText 取消按钮的文字(默认 '取消'
* @property {String} confirmText 确认按钮的文字(默认 '确定'
* @property {String} cancelColor 取消按钮的颜色(默认 '#909193'
* @property {String} confirmColor 确认按钮的颜色(默认 '#3c9cff'
* @property {String} color 文字颜色(默认 ''
* @property {String} activeColor 选中文字的颜色(默认 ''
* @property {String | Number} visibleItemCount 每列中可见选项的数量(默认 5
* @property {String} keyName 选项对象中,需要展示的属性键名(默认 'text'
* @property {Boolean} closeOnClickOverlay 是否允许点击遮罩关闭选择器(默认 false
* @property {Array} defaultIndex 各列的默认索引
* @property {Boolean} immediateChange 是否在手指松开时立即触发change事件默认 false
* @event {Function} close 关闭选择器时触发
* @event {Function} cancel 点击取消按钮触发
* @event {Function} change 当选择值变化时触发
* @event {Function} confirm 点击确定按钮,返回当前选择的值
*/
import mpMixin from '@/uni_modules/uv-ui-tools/libs/mixin/mpMixin.js'
import mixin from '@/uni_modules/uv-ui-tools/libs/mixin/mixin.js'
import props from './props.js';
export default {
name: 'uv-picker',
emits: ['confirm','cancel','close','change'],
mixins: [mpMixin, mixin, props],
computed: {
// 为了解决支付宝不生效
textStyle(){
return (index,index1) => {
const style = {};
// #ifndef APP-NVUE
style.display = 'block';
// #endif
if(this.color) {
style.color = this.color;
}
if(this.activeColor && index1 === this.innerIndex[index]) {
style.color = this.activeColor;
}
return style;
}
}
},
data() {
return {
// 上一次选择的列索引
lastIndex: [],
// 索引值 对应picker-view的value
innerIndex: [],
// 各列的值
innerColumns: [],
// 上一次的变化列索引
columnIndex: 0,
}
},
watch: {
// 监听默认索引的变化,重新设置对应的值
defaultIndex: {
immediate: true,
handler(n) {
this.setIndexs(n, true)
}
},
// 监听columns参数的变化
columns: {
deep: true,
immediate: true,
handler(n) {
this.setColumns(n)
}
},
},
methods: {
open() {
this.$refs.pickerPopup.open();
},
close() {
this.$refs.pickerPopup.close();
},
popupChange(e) {
if(!e.show) this.$emit('close');
},
// 获取item需要显示的文字判别为对象还是文本
getItemText(item) {
if (this.$uv.test.object(item)) {
return item[this.keyName]
} else {
return item
}
},
// 点击工具栏的取消按钮
cancel() {
this.$emit('cancel');
this.close();
},
// 点击工具栏的确定按钮
confirm() {
// 在这里使用deepClone拷贝后vue3会自动转换成原始对象这样处理是因为cli项目可能出现不返回值的情况
this.$emit('confirm', this.$uv.deepClone({
indexs: this.innerIndex,
value: this.innerColumns.map((item, index) => item[this.innerIndex[index]]),
values: this.innerColumns
}));
if(this.closeOnClickConfirm) {
this.close();
}
},
// 选择器某一列的数据发生变化时触发
changeHandler(e) {
const {
value
} = e.detail
let index = 0,
columnIndex = 0
// 通过对比前后两次的列索引,得出当前变化的是哪一列
for (let i = 0; i < value.length; i++) {
let item = value[i]
if (item !== (this.lastIndex[i] || 0)) { // 把undefined转为合法假值0
// 设置columnIndex为当前变化列的索引
columnIndex = i
// index则为变化列中的变化项的索引
index = item
break // 终止循环,即使少一次循环,也是性能的提升
}
}
this.columnIndex = columnIndex
const values = this.innerColumns
// 将当前的各项变化索引,设置为"上一次"的索引变化值
this.setLastIndex(value)
this.setIndexs(value)
this.$emit('change', {
value: this.innerColumns.map((item, index) => item[value[index]]),
index,
indexs: value,
// values为当前变化列的数组内容
values,
columnIndex
})
},
// 设置index索引此方法可被外部调用设置
setIndexs(index, setLastIndex) {
this.innerIndex = this.$uv.deepClone(index)
if (setLastIndex) {
this.setLastIndex(index)
}
},
// 记录上一次的各列索引位置
setLastIndex(index) {
// 当能进入此方法意味着当前设置的各列默认索引即为“上一次”的选中值需要记录是因为changeHandler中
// 需要拿前后的变化值进行对比,得出当前发生改变的是哪一列
this.lastIndex = this.$uv.deepClone(index)
},
// 设置对应列选项的所有值
setColumnValues(columnIndex, values) {
// 替换innerColumns数组中columnIndex索引的值为values使用的是数组的splice方法
this.innerColumns.splice(columnIndex, 1, values)
// 拷贝一份原有的innerIndex做临时变量将大于当前变化列的所有的列的默认索引设置为0
let tmpIndex = this.$uv.deepClone(this.innerIndex)
for (let i = 0; i < this.innerColumns.length; i++) {
if (i > this.columnIndex) {
tmpIndex[i] = 0
}
}
// 一次性赋值,不能单个修改,否则无效
this.setIndexs(tmpIndex)
},
// 获取对应列的所有选项
getColumnValues(columnIndex) {
// 进行同步阻塞因为外部得到change事件之后可能需要执行setColumnValues更新列的值
// 索引如果在外部change的回调中调用getColumnValues的话可能无法得到变更后的列值这里进行一定延时保证值的准确性
(async () => {
await this.$uv.sleep()
})()
return this.innerColumns[columnIndex]
},
// 设置整体各列的columns的值
setColumns(columns) {
this.innerColumns = this.$uv.deepClone(columns)
// 如果在设置各列数据时没有被设置默认的各列索引defaultIndex那么用0去填充它数组长度为列的数量
if (this.innerIndex.length === 0) {
this.innerIndex = new Array(columns.length).fill(0)
}
},
// 获取各列选中值对应的索引
getIndexs() {
return this.innerIndex
},
// 获取各列选中的值
getValues() {
// 进行同步阻塞因为外部得到change事件之后可能需要执行setColumnValues更新列的值
// 索引如果在外部change的回调中调用getValues的话可能无法得到变更后的列值这里进行一定延时保证值的准确性
(async () => {
await this.$uv.sleep()
})()
return this.innerColumns.map((item, index) => item[this.innerIndex[index]])
}
},
}
</script>
<style lang="scss" scoped>
$show-lines: 1;
@import '@/uni_modules/uv-ui-tools/libs/css/variable.scss';
@import '@/uni_modules/uv-ui-tools/libs/css/components.scss';
@import '@/uni_modules/uv-ui-tools/libs/css/color.scss';
.uv-picker {
position: relative;
&__view {
&__column {
@include flex;
flex: 1;
justify-content: center;
&__item {
@include flex;
justify-content: center;
align-items: center;
font-size: 16px;
text-align: center;
/* #ifndef APP-NVUE */
display: block;
/* #endif */
color: $uv-main-color;
&--disabled {
/* #ifndef APP-NVUE */
cursor: not-allowed;
/* #endif */
opacity: 0.35;
}
}
}
}
&--loading {
position: absolute;
top: 0;
right: 0;
left: 0;
bottom: 0;
@include flex;
justify-content: center;
align-items: center;
background-color: rgba(255, 255, 255, 0.87);
z-index: 1000;
}
}
</style>

View File

@@ -0,0 +1,90 @@
{
"id": "uv-picker",
"displayName": "uv-picker 选择器 全面兼容vue3+2、app、h5、小程序等多端",
"version": "1.0.14",
"description": "uv-picker 此选择器用于单列,多列,多列联动的选择场景...",
"keywords": [
"uv-picker",
"uvui",
"uv-ui",
"picker",
"联动选择"
],
"repository": "",
"engines": {
"HBuilderX": "^3.1.0"
},
"dcloudext": {
"type": "component-vue",
"sale": {
"regular": {
"price": "0.00"
},
"sourcecode": {
"price": "0.00"
}
},
"contact": {
"qq": ""
},
"declaration": {
"ads": "无",
"data": "插件不采集任何数据",
"permissions": "无"
},
"npmurl": ""
},
"uni_modules": {
"dependencies": [
"uv-ui-tools",
"uv-popup",
"uv-loading-icon",
"uv-toolbar"
],
"encrypt": [],
"platforms": {
"cloud": {
"tcb": "y",
"aliyun": "y"
},
"client": {
"Vue": {
"vue2": "y",
"vue3": "y"
},
"App": {
"app-vue": "y",
"app-nvue": "y"
},
"H5-mobile": {
"Safari": "y",
"Android Browser": "y",
"微信浏览器(Android)": "y",
"QQ浏览器(Android)": "y"
},
"H5-pc": {
"Chrome": "y",
"IE": "y",
"Edge": "y",
"Firefox": "y",
"Safari": "y"
},
"小程序": {
"微信": "y",
"阿里": "y",
"百度": "y",
"字节跳动": "y",
"QQ": "y",
"钉钉": "u",
"快手": "u",
"飞书": "u",
"京东": "u"
},
"快应用": {
"华为": "u",
"联盟": "u"
}
}
}
}
}

View File

@@ -0,0 +1,21 @@
## Picker 选择器
> **组件名uv-picker**
此选择器用于单列,多列,多列联动的选择场景。
`uv-datetime-picker`等组件也用到了该组件,功能完善,需要特别注意的是`columns`参数的形式是数组嵌套。
# <a href="https://www.uvui.cn/components/picker.html" target="_blank">查看文档</a>
## [下载完整示例项目](https://ext.dcloud.net.cn/plugin?name=uv-ui) <small>(请不要 下载插件ZIP</small>
### [更多插件请关注uv-ui组件库](https://ext.dcloud.net.cn/plugin?name=uv-ui)
<a href="https://ext.dcloud.net.cn/plugin?name=uv-ui" target="_blank">
![image](https://mp-a667b617-c5f1-4a2d-9a54-683a67cff588.cdn.bspapp.com/uv-ui/banner.png)
</a>
#### 如使用过程中有任何问题反馈或者您对uv-ui有一些好的建议欢迎加入uv-ui官方交流群<a href="https://www.uvui.cn/components/addQQGroup.html" target="_blank">官方QQ群</a>

View File

@@ -0,0 +1,18 @@
## 1.0.72023-11-20
修复issues问题https://gitee.com/climblee/uv-ui/issues/I8HDLO
## 1.0.62023-10-13
1. 优化vue内容有背景色设置圆角被遮挡的情况
## 1.0.52023-09-10
1. 修复H5默认层级过高的问题
2. 修复全局设置prop无效的问题
## 1.0.42023-08-08
1. 修复修改zIndex不生效的BUG
## 1.0.32023-07-02
uv-popup 弹出层代码重构优化性能翻倍小程序体验性能更加避免卡顿。打开和关闭方法更改详情参考文档https://www.uvui.cn/components/popup.html
## 1.0.22023-06-11
1. 修复zIndex层级问题
## 1.0.12023-05-16
1. 优化组件依赖,修改后无需全局引入,组件导入即可使用
2. 优化部分功能
## 1.0.02023-05-10
1. 新增uv-popup组件

View File

@@ -0,0 +1,45 @@
// #ifdef H5
export default {
name: 'Keypress',
props: {
disable: {
type: Boolean,
default: false
}
},
mounted () {
const keyNames = {
esc: ['Esc', 'Escape'],
tab: 'Tab',
enter: 'Enter',
space: [' ', 'Spacebar'],
up: ['Up', 'ArrowUp'],
left: ['Left', 'ArrowLeft'],
right: ['Right', 'ArrowRight'],
down: ['Down', 'ArrowDown'],
delete: ['Backspace', 'Delete', 'Del']
}
const listener = ($event) => {
if (this.disable) {
return
}
const keyName = Object.keys(keyNames).find(key => {
const keyName = $event.key
const value = keyNames[key]
return value === keyName || (Array.isArray(value) && value.includes(keyName))
})
if (keyName) {
// 避免和其他按键事件冲突
setTimeout(() => {
this.$emit(keyName, {})
}, 0)
}
}
document.addEventListener('keyup', listener)
// this.$once('hook:beforeDestroy', () => {
// document.removeEventListener('keyup', listener)
// })
},
render: () => {}
}
// #endif

View File

@@ -0,0 +1,539 @@
<template>
<view
v-if="showPopup"
class="uv-popup"
:class="[popupClass, isDesktop ? 'fixforpc-z-index' : '']"
:style="[{zIndex: zIndex}]"
>
<view @touchstart="touchstart">
<!-- 遮罩层 -->
<uv-overlay
key="1"
v-if="maskShow && overlay"
:show="showTrans"
:duration="duration"
:custom-style="overlayStyle"
:opacity="overlayOpacity"
:zIndex="zIndex"
@click="onTap"
></uv-overlay>
<uv-transition
key="2"
:mode="ani"
name="content"
:custom-style="transitionStyle"
:duration="duration"
:show="showTrans"
@click="onTap"
>
<view
class="uv-popup__content"
:style="[contentStyle]"
:class="[popupClass]"
@click="clear"
>
<uv-status-bar v-if="safeAreaInsetTop"></uv-status-bar>
<slot />
<uv-safe-bottom v-if="safeAreaInsetBottom"></uv-safe-bottom>
<view
v-if="closeable"
@tap.stop="close"
class="uv-popup__content__close"
:class="['uv-popup__content__close--' + closeIconPos]"
hover-class="uv-popup__content__close--hover"
hover-stay-time="150"
>
<uv-icon
name="close"
color="#909399"
size="18"
bold
></uv-icon>
</view>
</view>
</uv-transition>
</view>
<!-- #ifdef H5 -->
<keypress v-if="maskShow" @esc="onTap" />
<!-- #endif -->
</view>
</template>
<script>
// #ifdef H5
import keypress from './keypress.js'
// #endif
import mpMixin from '@/uni_modules/uv-ui-tools/libs/mixin/mpMixin.js'
import mixin from '@/uni_modules/uv-ui-tools/libs/mixin/mixin.js'
/**
* PopUp 弹出层
* @description 弹出层组件,为了解决遮罩弹层的问题
* @tutorial https://www.uvui.cn/components/popup.html
* @property {String} mode = [top|center|bottom|left|right] 弹出方式
* @value top 顶部弹出
* @value center 中间弹出
* @value bottom 底部弹出
* @value left 左侧弹出
* @value right 右侧弹出
* @property {Number} duration 动画时长默认300
* @property {Boolean} overlay 是否显示遮罩默认true
* @property {Boolean} overlayOpacity 遮罩透明度默认0.5
* @property {Object} overlayStyle 遮罩自定义样式
* @property {Boolean} closeOnClickOverlay = [true|false] 蒙版点击是否关闭弹窗默认true
* @property {Number | String} zIndex 弹出层的层级
* @property {Boolean} safeAreaInsetTop 是否留出顶部安全区状态栏高度默认false
* @property {Boolean} safeAreaInsetBottom 是否为留出底部安全区适配默认true
* @property {Boolean} closeable 是否显示关闭图标默认false
* @property {Boolean} closeIconPos 自定义关闭图标位置,`top-left`-左上角,`top-right`-右上角,`bottom-left`-左下角,`bottom-right`-右下角默认top-right
* @property {String} bgColor 主窗口背景色
* @property {String} maskBackgroundColor 蒙版颜色
* @property {Boolean} customStyle 自定义样式
* @event {Function} change 打开关闭弹窗触发e={show: false}
* @event {Function} maskClick 点击遮罩触发
*/
export default {
name: 'uv-popup',
components: {
// #ifdef H5
keypress
// #endif
},
mixins: [mpMixin, mixin],
emits: ['change', 'maskClick'],
props: {
// 弹出层类型可选值top: 顶部弹出层bottom底部弹出层center全屏弹出层
// message: 消息提示 ; dialog : 对话框
mode: {
type: String,
default: 'center'
},
// 动画时长单位ms
duration: {
type: [String, Number],
default: 300
},
// 层级
zIndex: {
type: [String, Number],
// #ifdef H5
default: 997
// #endif
// #ifndef H5
default: 10075
// #endif
},
bgColor: {
type: String,
default: '#ffffff'
},
safeArea: {
type: Boolean,
default: true
},
// 是否显示遮罩
overlay: {
type: Boolean,
default: true
},
// 点击遮罩是否关闭弹窗
closeOnClickOverlay: {
type: Boolean,
default: true
},
// 遮罩的透明度0-1之间
overlayOpacity: {
type: [Number, String],
default: 0.4
},
// 自定义遮罩的样式
overlayStyle: {
type: [Object, String],
default: ''
},
// 是否为iPhoneX留出底部安全距离
safeAreaInsetBottom: {
type: Boolean,
default: true
},
// 是否留出顶部安全距离(状态栏高度)
safeAreaInsetTop: {
type: Boolean,
default: false
},
// 是否显示关闭图标
closeable: {
type: Boolean,
default: false
},
// 自定义关闭图标位置top-left为左上角top-right为右上角bottom-left为左下角bottom-right为右下角
closeIconPos: {
type: String,
default: 'top-right'
},
// mode=center也即中部弹出时是否使用缩放模式
zoom: {
type: Boolean,
default: true
},
round: {
type: [Number, String],
default: 0
},
...uni.$uv?.props?.popup
},
watch: {
/**
* 监听type类型
*/
type: {
handler: function(type) {
if (!this.config[type]) return
this[this.config[type]](true)
},
immediate: true
},
isDesktop: {
handler: function(newVal) {
if (!this.config[newVal]) return
this[this.config[this.mode]](true)
},
immediate: true
},
// H5 下禁止底部滚动
showPopup(show) {
// #ifdef H5
// fix by mehaotian 处理 h5 滚动穿透的问题
document.getElementsByTagName('body')[0].style.overflow = show ? 'hidden' : 'visible'
// #endif
}
},
data() {
return {
ani: [],
showPopup: false,
showTrans: false,
popupWidth: 0,
popupHeight: 0,
config: {
top: 'top',
bottom: 'bottom',
center: 'center',
left: 'left',
right: 'right',
message: 'top',
dialog: 'center',
share: 'bottom'
},
transitionStyle: {
position: 'fixed',
left: 0,
right: 0
},
maskShow: true,
mkclick: true,
popupClass: this.isDesktop ? 'fixforpc-top' : 'top',
direction: ''
}
},
computed: {
isDesktop() {
return this.popupWidth >= 500 && this.popupHeight >= 500
},
bg() {
if (this.bgColor === '' || this.bgColor === 'none' || this.$uv.getPx(this.round)>0) {
return 'transparent'
}
return this.bgColor
},
contentStyle() {
const style = {};
if (this.bgColor) {
style.backgroundColor = this.bg
}
if(this.round) {
const value = this.$uv.addUnit(this.round)
const mode = this.direction?this.direction:this.mode
style.backgroundColor = this.bgColor
if(mode === 'top') {
style.borderBottomLeftRadius = value
style.borderBottomRightRadius = value
} else if(mode === 'bottom') {
style.borderTopLeftRadius = value
style.borderTopRightRadius = value
} else if(mode === 'center') {
style.borderRadius = value
}
}
return this.$uv.deepMerge(style, this.$uv.addStyle(this.customStyle))
}
},
// #ifndef VUE3
// TODO vue2
destroyed() {
this.setH5Visible()
},
// #endif
// #ifdef VUE3
// TODO vue3
unmounted() {
this.setH5Visible()
},
// #endif
created() {
// TODO 处理 message 组件生命周期异常的问题
this.messageChild = null
// TODO 解决头条冒泡的问题
this.clearPropagation = false
},
methods: {
setH5Visible() {
// #ifdef H5
// fix by mehaotian 处理 h5 滚动穿透的问题
document.getElementsByTagName('body')[0].style.overflow = 'visible'
// #endif
},
/**
* 公用方法,不显示遮罩层
*/
closeMask() {
this.maskShow = false
},
// TODO nvue 取消冒泡
clear(e) {
// #ifndef APP-NVUE
e.stopPropagation()
// #endif
this.clearPropagation = true
},
open(direction) {
// fix by mehaotian 处理快速打开关闭的情况
if (this.showPopup) {
return
}
let innerType = ['top', 'center', 'bottom', 'left', 'right', 'message', 'dialog', 'share']
if (!(direction && innerType.indexOf(direction) !== -1)) {
direction = this.mode
}else {
this.direction = direction;
}
if (!this.config[direction]) {
return this.$uv.error(`缺少类型:${direction}`);
}
this[this.config[direction]]()
this.$emit('change', {
show: true,
type: direction
})
},
close(type) {
this.showTrans = false
this.$emit('change', {
show: false,
type: this.mode
})
clearTimeout(this.timer)
// // 自定义关闭事件
this.timer = setTimeout(() => {
this.showPopup = false
}, 300)
},
// TODO 处理冒泡事件,头条的冒泡事件有问题 ,先这样兼容
touchstart() {
this.clearPropagation = false
},
onTap() {
if (this.clearPropagation) {
// fix by mehaotian 兼容 nvue
this.clearPropagation = false
return
}
this.$emit('maskClick')
if (!this.closeOnClickOverlay) return
this.close()
},
/**
* 顶部弹出样式处理
*/
top(type) {
this.popupClass = this.isDesktop ? 'fixforpc-top' : 'top'
this.ani = ['slide-top']
this.transitionStyle = {
position: 'fixed',
zIndex: this.zIndex,
left: 0,
right: 0,
backgroundColor: this.bg
}
// TODO 兼容 type 属性 ,后续会废弃
if (type) return
this.showPopup = true
this.showTrans = true
this.$nextTick(() => {
if (this.messageChild && this.mode === 'message') {
this.messageChild.timerClose()
}
})
},
/**
* 底部弹出样式处理
*/
bottom(type) {
this.popupClass = 'bottom'
this.ani = ['slide-bottom']
this.transitionStyle = {
position: 'fixed',
zIndex: this.zIndex,
left: 0,
right: 0,
bottom: 0,
backgroundColor: this.bg
}
// TODO 兼容 type 属性 ,后续会废弃
if (type) return
this.showPopup = true
this.showTrans = true
},
/**
* 中间弹出样式处理
*/
center(type) {
this.popupClass = 'center'
this.ani = this.zoom?['zoom-in', 'fade']:['fade'];
this.transitionStyle = {
position: 'fixed',
zIndex: this.zIndex,
/* #ifndef APP-NVUE */
display: 'flex',
flexDirection: 'column',
/* #endif */
bottom: 0,
left: 0,
right: 0,
top: 0,
justifyContent: 'center',
alignItems: 'center'
}
// TODO 兼容 type 属性 ,后续会废弃
if (type) return
this.showPopup = true
this.showTrans = true
},
left(type) {
this.popupClass = 'left'
this.ani = ['slide-left']
this.transitionStyle = {
position: 'fixed',
zIndex: this.zIndex,
left: 0,
bottom: 0,
top: 0,
backgroundColor: this.bg,
/* #ifndef APP-NVUE */
display: 'flex',
flexDirection: 'column'
/* #endif */
}
// TODO 兼容 type 属性 ,后续会废弃
if (type) return
this.showPopup = true
this.showTrans = true
},
right(type) {
this.popupClass = 'right'
this.ani = ['slide-right']
this.transitionStyle = {
position: 'fixed',
zIndex: this.zIndex,
bottom: 0,
right: 0,
top: 0,
backgroundColor: this.bg,
/* #ifndef APP-NVUE */
display: 'flex',
flexDirection: 'column'
/* #endif */
}
// TODO 兼容 type 属性 ,后续会废弃
if (type) return
this.showPopup = true
this.showTrans = true
}
}
}
</script>
<style lang="scss" scoped>
.uv-popup {
position: fixed;
/* #ifndef APP-NVUE */
z-index: 99;
/* #endif */
&.top,
&.left,
&.right {
/* #ifdef H5 */
top: var(--window-top);
/* #endif */
/* #ifndef H5 */
top: 0;
/* #endif */
}
.uv-popup__content {
/* #ifndef APP-NVUE */
display: block;
overflow: hidden;
/* #endif */
position: relative;
&.left,
&.right {
/* #ifdef H5 */
padding-top: var(--window-top);
/* #endif */
/* #ifndef H5 */
padding-top: 0;
/* #endif */
flex: 1;
}
&__close {
position: absolute;
&--hover {
opacity: 0.4;
}
}
&__close--top-left {
top: 15px;
left: 15px;
}
&__close--top-right {
top: 15px;
right: 15px;
}
&__close--bottom-left {
bottom: 15px;
left: 15px;
}
&__close--bottom-right {
right: 15px;
bottom: 15px;
}
}
}
.fixforpc-z-index {
/* #ifndef APP-NVUE */
z-index: 999;
/* #endif */
}
.fixforpc-top {
top: 0;
}
</style>

View File

@@ -0,0 +1,92 @@
{
"id": "uv-popup",
"displayName": "uv-popup 弹出层 全面兼容vue3+2、app、h5、小程序等多端",
"version": "1.0.7",
"description": "uv-popup 弹出层容器,用于展示弹窗、信息提示等内容,支持上、下、左、右和中部弹出。组件只提供容器,内部内容由用户自定义。",
"keywords": [
"uv-popup",
"uvui",
"uv-ui",
"popup",
"弹出层"
],
"repository": "",
"engines": {
"HBuilderX": "^3.1.0"
},
"dcloudext": {
"type": "component-vue",
"sale": {
"regular": {
"price": "0.00"
},
"sourcecode": {
"price": "0.00"
}
},
"contact": {
"qq": ""
},
"declaration": {
"ads": "无",
"data": "插件不采集任何数据",
"permissions": "无"
},
"npmurl": ""
},
"uni_modules": {
"dependencies": [
"uv-ui-tools",
"uv-overlay",
"uv-transition",
"uv-icon",
"uv-status-bar",
"uv-safe-bottom"
],
"encrypt": [],
"platforms": {
"cloud": {
"tcb": "y",
"aliyun": "y"
},
"client": {
"Vue": {
"vue2": "y",
"vue3": "y"
},
"App": {
"app-vue": "y",
"app-nvue": "y"
},
"H5-mobile": {
"Safari": "y",
"Android Browser": "y",
"微信浏览器(Android)": "y",
"QQ浏览器(Android)": "y"
},
"H5-pc": {
"Chrome": "y",
"IE": "y",
"Edge": "y",
"Firefox": "y",
"Safari": "y"
},
"小程序": {
"微信": "y",
"阿里": "y",
"百度": "y",
"字节跳动": "y",
"QQ": "y",
"钉钉": "u",
"快手": "u",
"飞书": "u",
"京东": "u"
},
"快应用": {
"华为": "u",
"联盟": "u"
}
}
}
}
}

View File

@@ -0,0 +1,21 @@
## Popup 弹出层
> **组件名uv-popup**
弹出层容器,用于展示弹窗、信息提示等内容,支持上、下、左、右和中部弹出。组件只提供容器,内部内容由用户自定义。
该组件已经放弃原来`uview2.x`的写法,参照了官方`uni-popup`的写法进行重构。在小程序端的性能大大提升,打开和关闭避免延迟,调用方法与之前相比也有所差异,具体请查看文档。
# <a href="https://www.uvui.cn/components/popup.html" target="_blank">查看文档</a>
## [下载完整示例项目](https://ext.dcloud.net.cn/plugin?name=uv-ui) <small>(请不要 下载插件ZIP</small>
### [更多插件请关注uv-ui组件库](https://ext.dcloud.net.cn/plugin?name=uv-ui)
<a href="https://ext.dcloud.net.cn/plugin?name=uv-ui" target="_blank">
![image](https://mp-a667b617-c5f1-4a2d-9a54-683a67cff588.cdn.bspapp.com/uv-ui/banner.png)
</a>
#### 如使用过程中有任何问题反馈或者您对uv-ui有一些好的建议欢迎加入uv-ui官方交流群<a href="https://www.uvui.cn/components/addQQGroup.html" target="_blank">官方QQ群</a>

View File

@@ -0,0 +1,11 @@
## 1.0.42023-09-14
1. 飞书小程序支持
## 1.0.32023-08-14
1. 修复百度报错的BUG
## 1.0.22023-07-02
uv-safe-bottom 修复在百度程序抖音小程序不生效的BUG
## 1.0.12023-05-16
1. 优化组件依赖,修改后无需全局引入,组件导入即可使用
2. 优化部分功能
## 1.0.02023-05-10
uv-safe-bottom 底部安全区组件

View File

@@ -0,0 +1,67 @@
<template>
<view
class="uv-safe-bottom"
:style="[style]"
:class="[!isNvue && 'uv-safe-area-inset-bottom']"
>
</view>
</template>
<script>
import mpMixin from '@/uni_modules/uv-ui-tools/libs/mixin/mpMixin.js'
import mixin from '@/uni_modules/uv-ui-tools/libs/mixin/mixin.js'
/**
* SafeBottom 底部安全区
* @description 这个适配主要是针对IPhone X等一些底部带指示条的机型指示条的操作区域与页面底部存在重合容易导致用户误操作因此我们需要针对这些机型进行底部安全区适配。
* @tutorial https://www.uvui.cn/components/safeAreaInset.html
* @property {type} prop_name
* @property {Object} customStyle 定义需要用到的外部样式
*
* @event {Function()}
* @example <uv-status-bar></uv-status-bar>
*/
export default {
name: "uv-safe-bottom",
mixins: [mpMixin, mixin],
data() {
return {
safeAreaBottomHeight: 0,
isNvue: false,
};
},
computed: {
style() {
const style = {};
// #ifdef APP-NVUE || MP-TOUTIAO || MP-LARK
// nvue下高度使用js计算填充
style.height = this.$uv.addUnit(this.$uv.sys()?.safeAreaInsets?.bottom, 'px');
// #endif
return this.$uv.deepMerge(style, this.$uv.addStyle(this.customStyle));
},
},
mounted() {
// #ifdef APP-NVUE
// 标识为是否nvue
this.isNvue = true;
// #endif
},
};
</script>
<style lang="scss" scoped>
.uv-safe-bottom {
/* #ifndef APP-NVUE */
width: 100%;
/* #endif */
}
/* #ifndef APP-NVUE */
// 历遍生成4个方向的底部安全区
@each $d in top, right, bottom, left {
.uv-safe-area-inset-#{$d} {
padding-#{$d}: 0;
padding-#{$d}: constant(safe-area-inset-#{$d});
padding-#{$d}: env(safe-area-inset-#{$d});
}
}
/* #endif */
</style>

View File

@@ -0,0 +1,87 @@
{
"id": "uv-safe-bottom",
"displayName": "uv-safe-bottom 底部安全区 全面兼容小程序、nvue、vue2、vue3等多端",
"version": "1.0.4",
"description": "这个适配主要是针对IPhone X等一些底部带指示条的机型指示条的操作区域与页面底部存在重合容易导致用户误操作因此我们需要针对这些机型进行底部安全区适配。",
"keywords": [
"uv-safe-bottom",
"uvui",
"uv-ui",
"bottom",
"底部安全区"
],
"repository": "",
"engines": {
"HBuilderX": "^3.1.0"
},
"dcloudext": {
"type": "component-vue",
"sale": {
"regular": {
"price": "0.00"
},
"sourcecode": {
"price": "0.00"
}
},
"contact": {
"qq": ""
},
"declaration": {
"ads": "无",
"data": "插件不采集任何数据",
"permissions": "无"
},
"npmurl": ""
},
"uni_modules": {
"dependencies": [
"uv-ui-tools"
],
"encrypt": [],
"platforms": {
"cloud": {
"tcb": "y",
"aliyun": "y"
},
"client": {
"Vue": {
"vue2": "y",
"vue3": "y"
},
"App": {
"app-vue": "y",
"app-nvue": "y"
},
"H5-mobile": {
"Safari": "y",
"Android Browser": "y",
"微信浏览器(Android)": "y",
"QQ浏览器(Android)": "y"
},
"H5-pc": {
"Chrome": "y",
"IE": "y",
"Edge": "y",
"Firefox": "y",
"Safari": "y"
},
"小程序": {
"微信": "y",
"阿里": "y",
"百度": "y",
"字节跳动": "y",
"QQ": "y",
"钉钉": "u",
"快手": "u",
"飞书": "u",
"京东": "u"
},
"快应用": {
"华为": "u",
"联盟": "u"
}
}
}
}
}

View File

@@ -0,0 +1,11 @@
## SafeBottom 底部安全区
> **组件名uv-safe-bottom**
这个适配主要是针对IPhone X等一些底部带指示条的机型指示条的操作区域与页面底部存在重合容易导致用户误操作因此我们需要针对这些机型进行底部安全区适配。
### <a href="https://www.uvui.cn/guide/safeAreaInset.html" target="_blank">查看文档</a>
### [完整示例项目下载 | 关注更多组件](https://ext.dcloud.net.cn/plugin?name=uv-ui)
#### 如使用过程中有任何问题或者您对uv-ui有一些好的建议欢迎加入 uv-ui 交流群:<a href="https://ext.dcloud.net.cn/plugin?id=12287" target="_blank">uv-ui</a>、<a href="https://www.uvui.cn/components/addQQGroup.html" target="_blank">官方QQ群</a>

View File

@@ -0,0 +1,7 @@
## 1.0.22023-06-05
1. 兼容渐变背景色
## 1.0.12023-05-16
1. 优化组件依赖,修改后无需全局引入,组件导入即可使用
2. 优化部分功能
## 1.0.02023-05-10
1. 新增uv-status-bar组件

View File

@@ -0,0 +1,8 @@
export default {
props: {
bgColor: {
type: String,
default: 'transparent'
}
}
}

View File

@@ -0,0 +1,54 @@
<template>
<view
:style="[style]"
class="uv-status-bar"
>
<slot />
</view>
</template>
<script>
import mpMixin from '@/uni_modules/uv-ui-tools/libs/mixin/mpMixin.js'
import mixin from '@/uni_modules/uv-ui-tools/libs/mixin/mixin.js'
import props from './props.js';
/**
* StatbusBar 状态栏占位
* @description 本组件主要用于状态填充,比如在自定导航栏的时候,它会自动适配一个恰当的状态栏高度。
* @tutorial https://www.uvui.cn/components/statusBar.html
* @property {String} bgColor 背景色 (默认 'transparent' )
* @property {String | Object} customStyle 自定义样式
* @example <uv-status-bar></uv-status-bar>
*/
export default {
name: 'uv-status-bar',
mixins: [mpMixin, mixin, props],
data() {
return {
}
},
computed: {
style() {
const style = {}
// 状态栏高度由于某些安卓和微信开发工具无法识别css的顶部状态栏变量所以使用js获取的方式
style.height = this.$uv.addUnit(this.$uv.sys().statusBarHeight, 'px')
if(this.bgColor){
if (this.bgColor.indexOf("gradient") > -1) {// 渐变色
style.backgroundImage = this.bgColor;
}else{
style.background = this.bgColor;
}
}
return this.$uv.deepMerge(style, this.$uv.addStyle(this.customStyle))
}
},
}
</script>
<style lang="scss" scoped>
.uv-status-bar {
// nvue会默认100%如果nvue下显式写100%的话会导致宽度不为100%而异常
/* #ifndef APP-NVUE */
width: 100%;
/* #endif */
}
</style>

View File

@@ -0,0 +1,87 @@
{
"id": "uv-status-bar",
"displayName": "uv-status-bar 状态栏占位",
"version": "1.0.2",
"description": "状态栏占位组件主要用于状态填充,比如在自定导航栏的时候,它会自动适配一个恰当的状态栏高度。",
"keywords": [
"uv-status-bar",
"uvui",
"uv-ui",
"status-bar",
"状态栏"
],
"repository": "",
"engines": {
"HBuilderX": "^3.1.0"
},
"dcloudext": {
"type": "component-vue",
"sale": {
"regular": {
"price": "0.00"
},
"sourcecode": {
"price": "0.00"
}
},
"contact": {
"qq": ""
},
"declaration": {
"ads": "无",
"data": "插件不采集任何数据",
"permissions": "无"
},
"npmurl": ""
},
"uni_modules": {
"dependencies": [
"uv-ui-tools"
],
"encrypt": [],
"platforms": {
"cloud": {
"tcb": "y",
"aliyun": "y"
},
"client": {
"Vue": {
"vue2": "y",
"vue3": "y"
},
"App": {
"app-vue": "y",
"app-nvue": "y"
},
"H5-mobile": {
"Safari": "y",
"Android Browser": "y",
"微信浏览器(Android)": "y",
"QQ浏览器(Android)": "y"
},
"H5-pc": {
"Chrome": "y",
"IE": "y",
"Edge": "y",
"Firefox": "y",
"Safari": "y"
},
"小程序": {
"微信": "y",
"阿里": "y",
"百度": "y",
"字节跳动": "y",
"QQ": "y",
"钉钉": "u",
"快手": "u",
"飞书": "u",
"京东": "u"
},
"快应用": {
"华为": "u",
"联盟": "u"
}
}
}
}
}

View File

@@ -0,0 +1,10 @@
## StatbusBar 状态栏占位
> **组件名uv-status-bar**
本组件主要用于状态填充,比如在自定导航栏的时候,它会自动适配一个恰当的状态栏高度。
### [完整示例项目下载 | 关注更多组件](https://ext.dcloud.net.cn/plugin?name=uv-ui)
#### 如使用过程中有任何问题或者您对uv-ui有一些好的建议欢迎加入 uv-ui 交流群:<a href="https://ext.dcloud.net.cn/plugin?id=12287" target="_blank">uv-ui</a>、<a href="https://www.uvui.cn/components/addQQGroup.html" target="_blank">官方QQ群</a>

View File

@@ -0,0 +1,2 @@
## 1.0.02023-08-02
1. 新增工具条组件

Some files were not shown because too many files have changed in this diff Show More