Files
airz-uniapp/packages/content/search/search.uvue
T
hujun 2a9ff403c4 feat: 接入内容服务端真实接口并优化页面交互
- 迁移内容数据到服务端接口,移除本地模拟数据
- 首页新增推荐课程加载与未读消息检查
- 资讯、课程、搜索页面支持分页加载与错误重试
- 登录页新增恢复课程、消息、帮助页的路由逻辑
- 评估分析页新增处理中状态动效与阶段提示
- API 环境切换到正式域名 airz.hqling.cloud
2026-10-01 20:26:41 +08:00

131 lines
9.7 KiB
Plaintext
Raw 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" :style="pageStyle()">
<AppHeader title="搜索" />
<view class="search-bar">
<view class="search-box">
<image class="search-icon" src="/static/icons/search.svg" mode="aspectFit" />
<input v-model="keyword" class="search-input" placeholder="搜索新闻、融资课堂" maxlength="80" confirm-type="search" @input="resetResults" @confirm="search" />
<view v-if="keyword.length > 0" class="clear-search" @click="clearSearch"><text class="clear-mark">×</text></view>
</view>
<button class="search-button" @click="search">搜索</button>
</view>
<view class="search-tabs">
<view v-for="item in tabs" :key="item.id" :class="activeType == item.id ? 'search-tab selected-tab' : 'search-tab'" @click="selectType(item.id)"><text :class="activeType == item.id ? 'tab-label selected-label' : 'tab-label'">{{ item.label }}</text></view>
</view>
<scroll-view scroll-y :key="listVersion" :style="contentStyle(296, false)">
<view class="search-content" :style="bottomStyle(40)">
<view v-if="!searched" class="search-guide">
<text class="guide-title">想了解什么?</text><text class="guide-copy">查找融资资讯,学习实用知识</text>
<view class="suggestion-list"><view class="suggestion" v-for="word in suggestions" :key="word" @click="searchWord(word)"><text class="suggestion-label">{{ word }}</text></view></view>
</view>
<view v-else-if="loading" class="state-panel"><text class="state-title">正在搜索…</text></view>
<view v-else-if="errorMessage.length > 0" class="state-panel"><text class="state-title">搜索暂时不可用</text><text class="state-copy">{{ errorMessage }}</text><button class="retry-button" @click="search">重新搜索</button></view>
<view v-else-if="results.length == 0" class="state-panel"><view class="empty-symbol"><image class="empty-icon" src="/static/icons/search.svg" mode="aspectFit" /></view><text class="state-title">没有找到相关内容</text><text class="state-copy">试试更简短的关键词,或切换搜索分类</text></view>
<view v-else>
<text class="result-count">找到 {{ total }} 条相关内容</text>
<view class="result-card" v-for="item in results" :key="item.type + ':' + item.id" @click="openResult(item)">
<view class="result-copy"><view class="result-heading"><text :class="item.type == 'news' ? 'result-kind' : 'result-kind lesson-kind'">{{ item.type == 'news' ? '新闻' : '融资课堂' }}</text><text class="result-category">{{ item.category }}</text></view><text class="result-title">{{ item.title }}</text><text class="result-summary">{{ item.summary }}</text><text class="result-meta">{{ item.meta }}</text></view>
<image v-if="item.image.length > 0" class="result-image" :src="item.image" mode="aspectFill" />
</view>
<text v-if="moreError.length > 0" class="state-copy">{{ moreError }}</text>
<button v-if="hasMore" class="retry-button" @click="more">{{ loadingMore ? '正在加载…' : '加载更多' }}</button>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup lang="uts">
import AppHeader from '@/components/AppHeader.uvue'
import { usePageLayout } from '@/services/layout.uts'
import { searchContent, ContentSearchResult } from '@/packages/content/services/search.uts'
const { pageStyle, contentStyle, bottomStyle } = usePageLayout()
type SearchTab = { id: string, label: string }
const tabs: SearchTab[] = [{ id: 'all', label: '全部' }, { id: 'news', label: '新闻' }, { id: 'lesson', label: '融资课堂' }]
const suggestions: string[] = ['个人信用', '企业融资', '利率', '还款', '材料', '政策']
const keyword = ref('')
const activeType = ref('all')
const searched = ref(false)
const loading = ref(false)
const errorMessage = ref('')
const results = ref<ContentSearchResult[]>([])
const listVersion = ref(0)
const total = ref(0); const hasMore = ref(false); const loadingMore = ref(false); const moreError = ref('')
let page = 1
let requestVersion = 0
const resetResults = () => { requestVersion++; results.value = []; total.value = 0; hasMore.value = false; loadingMore.value = false; moreError.value = ''; searched.value = false; loading.value = false; errorMessage.value = ''; listVersion.value++ }
const clearSearch = () => { keyword.value = ''; resetResults() }
async function load(append: boolean) {
if (append && (loading.value || loadingMore.value || !hasMore.value)) return
keyword.value = keyword.value.trim()
if (keyword.value.length == 0) { clearSearch(); return }
uni.hideKeyboard()
const currentRequest = append ? requestVersion : ++requestVersion
if (append) { loadingMore.value = true; moreError.value = '' }
else { searched.value = true; loading.value = true; loadingMore.value = false; errorMessage.value = ''; moreError.value = ''; results.value = []; total.value = 0; hasMore.value = false; page = 1; listVersion.value++ }
try {
const result = await searchContent(keyword.value, activeType.value, append ? page + 1 : 1)
// 输入、切换分类或退出页面后,忽略旧请求,避免结果被覆盖。
if (currentRequest != requestVersion) return
results.value = append ? results.value.concat(result.items) : result.items
total.value = result.total; hasMore.value = result.hasMore
if (append) page++
} catch (err) {
if (currentRequest == requestVersion) {
const message = err instanceof Error ? err.message : '请检查网络连接后重试'
if (append) moreError.value = message; else errorMessage.value = message
}
} finally { if (currentRequest == requestVersion) { loading.value = false; loadingMore.value = false } }
}
const search = () => { load(false) }
const more = () => { load(true) }
const selectType = (type: string) => { if (activeType.value == type) return; activeType.value = type; if (searched.value) search() }
const searchWord = (word: string) => { keyword.value = word; search() }
const openResult = (item: ContentSearchResult) => {
const route = item.type == 'news' ? '/packages/content/article/article' : '/packages/content/lesson/lesson'
uni.navigateTo({ url: route + '?id=' + encodeURIComponent(item.id) })
}
onShow(() => { if (searched.value) search() })
onHide(() => { requestVersion++; loading.value = false; loadingMore.value = false })
onUnload(() => { requestVersion++ })
</script>
<style>
.search-bar { height: 112rpx; padding: 20rpx 28rpx; flex-direction: row; align-items: center; background-color: #ffffff; }
.search-box { flex: 1; min-width: 0; height: 72rpx; border-radius: 16rpx; background-color: #f2f6fc; flex-direction: row; align-items: center; padding-left: 20rpx; }
.search-icon { width: 34rpx; height: 34rpx; margin-right: 14rpx; flex-shrink: 0; }
.search-input { flex: 1; min-width: 0; height: 72rpx; font-size: 27rpx; color: #22324a; }
.clear-search { width: 64rpx; height: 72rpx; align-items: center; justify-content: center; flex-shrink: 0; }
.clear-mark { font-size: 36rpx; line-height: 44rpx; color: #738198; }
.search-button { width: 92rpx; height: 72rpx; line-height: 72rpx; padding: 0; margin: 0 0 0 16rpx; font-size: 28rpx; font-weight: 600; color: #0868f7; background-color: transparent; border-radius: 12rpx; }
.search-tabs { height: 88rpx; padding: 0 28rpx; flex-direction: row; background-color: #ffffff; border-bottom: 1rpx solid #e8eef6; }
.search-tab { flex: 1; height: 88rpx; align-items: center; justify-content: center; border-bottom: 4rpx solid transparent; }
.selected-tab { border-bottom-color: #0868f7; }
.tab-label { font-size: 28rpx; color: #66758a; }
.selected-label { color: #0868f7; font-weight: 700; }
.search-content { padding: 28rpx 28rpx 40rpx; }
.search-guide { padding: 22rpx 4rpx; }
.guide-title { color: #172640; font-size: 34rpx; line-height: 48rpx; font-weight: 700; }
.guide-copy { color: #738198; font-size: 26rpx; line-height: 40rpx; margin-top: 10rpx; }
.suggestion-list { flex-direction: row; flex-wrap: wrap; margin-top: 28rpx; }
.suggestion { padding: 18rpx 28rpx; margin: 0 16rpx 18rpx 0; border: 1rpx solid #e3eaf4; border-radius: 14rpx; background-color: #ffffff; }
.suggestion-label { font-size: 26rpx; color: #405675; }
.result-count { font-size: 24rpx; color: #738198; margin-bottom: 20rpx; }
.result-card { flex-direction: row; align-items: center; background-color: #ffffff; border: 1rpx solid #e6edf7; border-radius: 18rpx; padding: 24rpx; margin-bottom: 18rpx; }
.result-copy { flex: 1; min-width: 0; }
.result-heading { flex-direction: row; align-items: center; flex-wrap: wrap; }
.result-kind { padding: 5rpx 10rpx; border-radius: 6rpx; font-size: 21rpx; color: #0868f7; background-color: #edf5ff; margin-right: 12rpx; }
.lesson-kind { color: #197f70; background-color: #eaf7f3; }
.result-category { color: #738198; font-size: 22rpx; line-height: 34rpx; }
.result-title { color: #172640; font-size: 29rpx; line-height: 42rpx; font-weight: 600; margin-top: 12rpx; }
.result-summary { color: #66758a; font-size: 24rpx; line-height: 36rpx; margin-top: 10rpx; }
.result-meta { color: #738198; font-size: 22rpx; line-height: 32rpx; margin-top: 14rpx; }
.result-image { width: 132rpx; height: 132rpx; border-radius: 12rpx; margin-left: 20rpx; flex-shrink: 0; }
.state-panel { padding: 100rpx 16rpx; align-items: center; }
.empty-symbol { width: 112rpx; height: 112rpx; border-radius: 56rpx; align-items: center; justify-content: center; background-color: #e8f2ff; margin-bottom: 26rpx; }
.empty-icon { width: 50rpx; height: 50rpx; }
.state-title { color: #26374f; font-size: 30rpx; line-height: 44rpx; font-weight: 600; text-align: center; }
.state-copy { color: #738198; font-size: 25rpx; line-height: 40rpx; margin-top: 14rpx; text-align: center; }
.retry-button { color: #0868f7; background-color: #e8f2ff; height: 76rpx; line-height: 76rpx; padding: 0 36rpx; margin-top: 28rpx; border-radius: 12rpx; font-size: 26rpx; }
</style>