Files
tuanwei-uniapp/pages/gxmu/tzb-project-list.vue
2026-07-20 11:56:02 +08:00

662 lines
15 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>