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

214 lines
15 KiB
Plaintext

<template>
<view class="page" :style="pageStyle()">
<AppHeader title="帮助与反馈" />
<view class="help-body" :style="contentStyle(96, false)">
<view class="help-tabs"><view :class="tab == 'faq' ? 'help-tab active-tab' : 'help-tab'" @click="changeTab('faq')"><text :class="tab == 'faq' ? 'tab-text active-text' : 'tab-text'">常见问题</text></view><view :class="tab == 'feedback' ? 'help-tab active-tab' : 'help-tab'" @click="changeTab('feedback')"><text :class="tab == 'feedback' ? 'tab-text active-text' : 'tab-text'">意见反馈</text></view></view>
<scroll-view scroll-y class="help-scroll">
<view v-if="tab == 'faq'" class="help-content">
<text class="help-title">常见问题</text><text class="help-description">关于账户和融资使用,您可能想了解这些</text>
<view v-if="loading" class="help-state"><text class="state-text">正在加载…</text></view>
<view v-else-if="errorMessage.length > 0" class="help-state"><text class="state-text">{{ errorMessage }}</text><button class="retry-button" @click="load">重新加载</button></view>
<view v-else-if="questions.length == 0" class="help-state"><text class="state-text">常见问题正在整理中</text></view>
<template v-else>
<view class="faq-group" v-for="group in groups" :key="group.category">
<text class="group-title">{{ group.category }}</text>
<view class="faq-list"><view v-for="(item, index) in group.items" :key="item.id" :class="index == group.items.length - 1 ? 'faq-item last-item' : 'faq-item'">
<view class="question-row" @click="toggle(item.id)"><text class="question-text">{{ item.question }}</text><AppIcon name="chevron-right" :size="28" :class="expanded == item.id ? 'question-arrow expanded-arrow' : 'question-arrow'" /></view>
<view v-if="expanded == item.id" class="answer-panel"><text class="answer-text">{{ item.answer }}</text></view>
</view></view>
</view>
</template>
</view>
<view v-else class="feedback-content">
<template v-if="loggedIn">
<text class="section-title">提交反馈</text>
<textarea v-model="draft" :disabled="sending" :maxlength="255" class="feedback-input" placeholder="请填写遇到的问题或建议" />
<view class="feedback-count"><text class="count-text">{{ draft.length }}/255</text></view>
<text v-if="submitError.length > 0" class="feedback-error">{{ submitError }}</text>
<text v-if="submitted" class="feedback-success">反馈已提交</text>
<button class="primary-button feedback-submit" :disabled="sending || draft.trim().length == 0" @click="send">{{ sending ? '提交中…' : (submitError.length > 0 ? '重试提交' : '提交反馈') }}</button>
<view class="feedback-list-head"><text class="section-title">我的反馈</text><text class="refresh-link" @click="reloadFeedback">刷新</text></view>
<view v-if="feedbackLoading" class="feedback-state"><text class="state-text">正在加载…</text></view>
<view v-else-if="feedbackError.length > 0" class="feedback-state"><text class="state-text">{{ feedbackError }}</text><button class="retry-button" @click="reloadFeedback">重新加载</button></view>
<view v-else-if="records.length == 0" class="feedback-state"><text class="state-text">暂无反馈记录</text></view>
<template v-else>
<view v-for="item in records" :key="item.id" class="feedback-item">
<view class="feedback-meta"><text :class="item.status == 'resolved' ? 'feedback-status replied-status' : 'feedback-status'">{{ statusLabel(item.status) }}</text><text class="feedback-time">{{ item.createdAt }}</text></view>
<text class="feedback-copy" :selectable="true">{{ item.content }}</text>
<view v-if="item.reply.length > 0" class="feedback-reply"><text class="reply-label">处理回复</text><text class="reply-copy" :selectable="true">{{ item.reply }}</text><text class="reply-time">{{ item.updatedAt }}</text></view>
</view>
<text v-if="moreError.length > 0" class="feedback-error">{{ moreError }}</text>
<text v-if="hasMore" class="feedback-more" @click="loadMore">{{ loadingMore ? '正在加载…' : (moreError.length > 0 ? '重试加载' : '加载更多') }}</text>
</template>
</template>
<view v-else class="help-state"><text class="state-text">登录后可提交和查看反馈</text><button class="retry-button" @click="login">去登录</button></view>
</view>
</scroll-view>
<view class="contact-bar" :style="bottomStyle(20)"><button class="primary-button contact-button" @click="contact">联系客服</button></view>
</view>
</view>
</template>
<script setup lang="uts">
import AppHeader from '@/components/AppHeader.uvue'
import AppIcon from '@/components/AppIcon.uvue'
import { usePageLayout } from '@/services/layout.uts'
import { getSession } from '@/services/api.uts'
import { helpCategories, HelpQuestion, fetchHelpQuestions, contactCustomerService } from '@/packages/content/services/help.uts'
import { FeedbackItem, feedbackKey, submitFeedback, fetchFeedback } from '@/packages/content/services/feedback.uts'
const { pageStyle, contentStyle, bottomStyle } = usePageLayout()
type HelpGroup = { category: string, items: HelpQuestion[] }
const questions = ref<HelpQuestion[]>([])
const expanded = ref('')
const loading = ref(false)
const errorMessage = ref('')
const tab = ref('faq'); const loggedIn = ref(false); const draft = ref(''); const sending = ref(false)
const submitError = ref(''); const submitted = ref(false); const records = ref<FeedbackItem[]>([])
const feedbackLoading = ref(false); const loadingMore = ref(false); const feedbackError = ref(''); const moreError = ref(''); const hasMore = ref(false)
let visible = false; let generation = 0; let faqRequest = 0; let listRequest = 0; let page = 0
let owner = ''; let requestKey = ''; let requestText = ''
const groups = computed((): HelpGroup[] => {
const results: HelpGroup[] = []
helpCategories.forEach((category: string) => {
const items = questions.value.filter((item: HelpQuestion): boolean => item.category == category)
if (items.length > 0) { const group: HelpGroup = { category, items }; results.push(group) }
})
return results
})
const toggle = (id: string) => { expanded.value = expanded.value == id ? '' : id }
const statusLabel = (status: string): string => {
if (status == 'pending') return '待处理'
if (status == 'processing') return '处理中'
if (status == 'resolved') return '已回复'
if (status == 'closed') return '已关闭'
return '待核对'
}
const contact = () => { contactCustomerService() }
const login = () => { uni.redirectTo({ url: '/packages/account/login/login?resumeHelp=1' }) }
const clearFeedback = () => {
draft.value = ''; records.value = []; submitError.value = ''; submitted.value = false; sending.value = false
feedbackLoading.value = false; loadingMore.value = false; feedbackError.value = ''; moreError.value = ''; hasMore.value = false
requestKey = ''; requestText = ''; page = 0; listRequest++
}
let reloadAfterAccountChange: () => void = () => {}
// 离页或切换账户后,旧提交和列表响应不得回填私有反馈。
function current(ticket: number, token: string): boolean {
if (!visible || ticket != generation) return false
const sessionToken = getSession()?.getString('token') ?? ''
if (sessionToken == token) return true
generation++; clearFeedback(); owner = sessionToken; loggedIn.value = owner.length > 0
if (loggedIn.value) reloadAfterAccountChange()
return false
}
const load = async () => {
if (!visible || loading.value) return
const ticket = ++faqRequest
loading.value = true; errorMessage.value = ''
try { const items = await fetchHelpQuestions(); if (visible && ticket == faqRequest) questions.value = items }
catch (_error) { if (visible && ticket == faqRequest) errorMessage.value = '暂时无法加载帮助内容,请稍后重试' }
finally { if (ticket == faqRequest) loading.value = false }
}
async function loadFeedback(append: boolean) {
if (!visible || !loggedIn.value || feedbackLoading.value || loadingMore.value || sending.value) return
const token = getSession()?.getString('token') ?? ''; const ticket = generation
if (!current(ticket, owner) || token.length == 0 || (append && !hasMore.value)) return
const request = ++listRequest; const nextPage = append ? page + 1 : 1
if (append) { loadingMore.value = true; moreError.value = '' }
else { feedbackLoading.value = true; feedbackError.value = ''; moreError.value = ''; records.value = []; hasMore.value = false }
try {
const result = await fetchFeedback(nextPage)
if (!current(ticket, token) || request != listRequest) return
if (append) { result.items.forEach((item: FeedbackItem) => { if (!records.value.some((old: FeedbackItem): boolean => old.id == item.id)) records.value.push(item) }) }
else records.value = result.items
page = result.page; hasMore.value = result.hasMore
} catch (error) {
if (current(ticket, token) && request == listRequest) {
const message = error instanceof Error ? error.message : '反馈记录加载失败,请重试'
if (append) moreError.value = message; else feedbackError.value = message
}
} finally { if (ticket == generation && request == listRequest) { feedbackLoading.value = false; loadingMore.value = false } }
}
function reloadFeedback() { loadFeedback(false) }
reloadAfterAccountChange = reloadFeedback
const loadMore = () => { loadFeedback(true) }
const changeTab = (value: string) => {
tab.value = value
if (value == 'feedback') reloadFeedback(); else if (questions.value.length == 0) load()
}
const send = async () => {
if (sending.value || draft.value.trim().length == 0) return
const token = getSession()?.getString('token') ?? ''; const ticket = generation
if (!current(ticket, owner)) return
if (token.length == 0) { login(); return }
const content = draft.value.trim()
// 同一段内容的网络失败重试复用请求键;成功后才清空,避免生成重复反馈。
if (requestKey.length == 0 || requestText != content) { requestKey = feedbackKey(); requestText = content }
listRequest++; feedbackLoading.value = false; loadingMore.value = false
sending.value = true; submitError.value = ''; submitted.value = false
try {
await submitFeedback(requestText, requestKey)
if (!current(ticket, token)) return
draft.value = ''; requestKey = ''; requestText = ''; submitted.value = true
} catch (error) { if (current(ticket, token)) submitError.value = error instanceof Error ? error.message : '提交失败,请重试' }
finally { if (ticket == generation) { sending.value = false; if (submitted.value) reloadFeedback() } }
}
watch(draft, () => { if (draft.value.length > 0) submitted.value = false })
onLoad((options) => { if (options?.['feedback'] == '1') tab.value = 'feedback' })
onShow(() => {
visible = true; generation++; const token = getSession()?.getString('token') ?? ''
if (token != owner) clearFeedback()
owner = token; loggedIn.value = token.length > 0; load(); if (tab.value == 'feedback') reloadFeedback()
})
onHide(() => { visible = false; generation++; faqRequest++; listRequest++; loading.value = false; feedbackLoading.value = false; loadingMore.value = false; sending.value = false; records.value = [] })
onUnload(() => { visible = false; generation++; faqRequest++; clearFeedback() })
</script>
<style>
.help-body { overflow: hidden; }
.help-scroll { flex: 1; min-height: 0; }
.help-tabs { flex-shrink: 0; flex-direction: row; height: 88rpx; padding: 0 30rpx; background-color: #ffffff; border-bottom: 1rpx solid #e8eef6; }
.help-tab { flex: 1; align-items: center; justify-content: center; border-bottom: 4rpx solid transparent; }
.active-tab { border-bottom-color: #0868f7; }
.tab-text { color: #66758a; font-size: 28rpx; }
.active-text { color: #0868f7; font-weight: 600; }
.help-content { padding: 32rpx 30rpx 40rpx; }
.help-title { color: #23344e; font-size: 38rpx; line-height: 54rpx; font-weight: 700; }
.help-description { color: #738198; font-size: 25rpx; line-height: 40rpx; margin-top: 12rpx; }
.faq-group { margin-top: 30rpx; }
.group-title { color: #66758a; font-size: 25rpx; line-height: 38rpx; font-weight: 600; margin-bottom: 16rpx; }
.faq-list { padding: 0 24rpx; border-radius: 18rpx; background-color: #ffffff; }
.faq-item { border-bottom: 1rpx solid #edf1f6; }
.last-item { border-bottom-width: 0; }
.question-row { min-height: 102rpx; padding: 26rpx 0; flex-direction: row; align-items: center; }
.question-text { flex: 1; min-width: 0; color: #29394f; font-size: 28rpx; line-height: 42rpx; font-weight: 600; }
.question-arrow { margin-left: 18rpx; flex-shrink: 0; }
.expanded-arrow { transform: rotate(90deg); }
.answer-panel { padding: 0 0 26rpx; }
.answer-text { color: #66758a; font-size: 25rpx; line-height: 44rpx; }
.help-state { padding: 90rpx 20rpx; align-items: center; }
.state-text { font-size: 26rpx; line-height: 42rpx; color: #738198; text-align: center; }
.retry-button { color: #0868f7; background-color: #e8f2ff; font-size: 26rpx; margin-top: 26rpx; padding: 0 30rpx; height: 76rpx; line-height: 76rpx; border-radius: 12rpx; }
.contact-bar { flex-shrink: 0; padding: 20rpx 30rpx; border-top: 1rpx solid #e8eef6; background-color: #ffffff; }
.contact-button { margin: 0; }
.feedback-content { padding: 30rpx 30rpx 48rpx; }
.section-title { font-size: 30rpx; line-height: 44rpx; color: #29394f; font-weight: 600; }
.feedback-input { width: 100%; height: 220rpx; margin-top: 22rpx; padding: 22rpx; background-color: #ffffff; border: 1rpx solid #dbe3ee; border-radius: 12rpx; color: #29394f; font-size: 27rpx; line-height: 42rpx; }
.feedback-count { align-items: flex-end; padding: 12rpx 0; }
.count-text { color: #738198; font-size: 23rpx; line-height: 34rpx; }
.feedback-submit { margin: 18rpx 0 0; }
.feedback-error { color: #b64d35; font-size: 25rpx; line-height: 40rpx; padding: 12rpx 0; }
.feedback-success { color: #258064; font-size: 25rpx; line-height: 40rpx; }
.feedback-list-head { flex-direction: row; align-items: center; justify-content: space-between; margin-top: 42rpx; padding-top: 26rpx; border-top: 1rpx solid #dbe3ee; }
.refresh-link { font-size: 25rpx; color: #0868f7; padding: 10rpx 0 10rpx 24rpx; }
.feedback-state { padding: 52rpx 16rpx; align-items: center; }
.feedback-item { padding: 26rpx 0; border-bottom: 1rpx solid #dbe3ee; }
.feedback-meta { flex-direction: row; align-items: center; justify-content: space-between; flex-wrap: wrap; }
.feedback-status { color: #9b6c23; font-size: 24rpx; line-height: 38rpx; }
.replied-status { color: #258064; }
.feedback-time { color: #738198; font-size: 22rpx; line-height: 38rpx; }
.feedback-copy { color: #29394f; font-size: 27rpx; line-height: 42rpx; margin-top: 14rpx; }
.feedback-reply { border-left: 4rpx solid #8ebba9; padding-left: 20rpx; margin-top: 22rpx; }
.reply-label { color: #258064; font-size: 24rpx; line-height: 36rpx; font-weight: 600; }
.reply-copy { color: #52657e; font-size: 25rpx; line-height: 42rpx; margin-top: 8rpx; }
.reply-time { color: #738198; font-size: 22rpx; line-height: 36rpx; margin-top: 10rpx; }
.feedback-more { text-align: center; color: #0868f7; font-size: 25rpx; line-height: 40rpx; padding: 26rpx; }
</style>