2026-09-21 15:04:59 +08:00
|
|
|
<template>
|
|
|
|
|
<view class="page" :style="pageStyle()">
|
|
|
|
|
<AppHeader title="帮助与反馈" />
|
|
|
|
|
<view class="help-body" :style="contentStyle(96, false)">
|
2026-10-01 20:26:41 +08:00
|
|
|
<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>
|
2026-09-21 15:04:59 +08:00
|
|
|
<scroll-view scroll-y class="help-scroll">
|
2026-10-01 20:26:41 +08:00
|
|
|
<view v-if="tab == 'faq'" class="help-content">
|
2026-09-21 15:04:59 +08:00
|
|
|
<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>
|
2026-10-01 20:26:41 +08:00
|
|
|
<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>
|
2026-09-21 15:04:59 +08:00
|
|
|
</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'
|
2026-10-01 20:26:41 +08:00
|
|
|
import { getSession } from '@/services/api.uts'
|
2026-09-21 15:04:59 +08:00
|
|
|
import { helpCategories, HelpQuestion, fetchHelpQuestions, contactCustomerService } from '@/packages/content/services/help.uts'
|
2026-10-01 20:26:41 +08:00
|
|
|
import { FeedbackItem, feedbackKey, submitFeedback, fetchFeedback } from '@/packages/content/services/feedback.uts'
|
2026-09-21 15:04:59 +08:00
|
|
|
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('')
|
2026-10-01 20:26:41 +08:00
|
|
|
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 = ''
|
2026-09-21 15:04:59 +08:00
|
|
|
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 }
|
2026-10-01 20:26:41 +08:00
|
|
|
const statusLabel = (status: string): string => {
|
|
|
|
|
if (status == 'pending') return '待处理'
|
|
|
|
|
if (status == 'processing') return '处理中'
|
|
|
|
|
if (status == 'resolved') return '已回复'
|
|
|
|
|
if (status == 'closed') return '已关闭'
|
|
|
|
|
return '待核对'
|
2026-09-21 15:04:59 +08:00
|
|
|
}
|
2026-10-01 20:26:41 +08:00
|
|
|
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() })
|
2026-09-21 15:04:59 +08:00
|
|
|
</script>
|
|
|
|
|
<style>
|
|
|
|
|
.help-body { overflow: hidden; }
|
|
|
|
|
.help-scroll { flex: 1; min-height: 0; }
|
2026-10-01 20:26:41 +08:00
|
|
|
.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; }
|
2026-09-21 15:04:59 +08:00
|
|
|
.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; }
|
2026-10-01 20:26:41 +08:00
|
|
|
.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; }
|
2026-09-21 15:04:59 +08:00
|
|
|
</style>
|