Files
airz-uniapp/packages/content/help/help.uvue
T
hujun 1723363b7e feat: 增加反馈消息闭环相关功能及文件
新增反馈目标登录白名单、账户切换守卫、待确认反馈存储、缓存生命周期管理,补充相关文档及截图,完善消息跳转逻辑
2026-10-05 20:11:24 +08:00

285 lines
20 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">
<view v-if="targetId.length > 0" class="linked-feedback">
<text class="section-title">通知关联反馈</text>
<text v-if="targetLoading" class="state-text">正在读取回复…</text>
<template v-else-if="targetItem != null">
<text class="feedback-status">{{ statusLabel(targetItem!.status) }}</text>
<text class="feedback-copy" :selectable="true">{{ targetItem!.content }}</text>
<view v-if="targetItem!.reply.length > 0" class="feedback-reply"><text class="reply-label">处理回复</text><text class="reply-copy" :selectable="true">{{ targetItem!.reply }}</text><text class="reply-time">{{ targetItem!.handledAt.length > 0 ? targetItem!.handledAt : targetItem!.updatedAt }}</text></view>
<text v-else class="state-text">当前暂无回复,可稍后刷新查看</text>
</template>
<text v-if="targetError.length > 0" class="feedback-error">{{ targetError }}</text>
<button v-if="!targetLoading" class="retry-button" @click="reloadTarget">{{ targetError.length > 0 ? '重新读取回复' : '刷新回复' }}</button>
</view>
<text class="section-title">提交反馈</text>
<textarea v-model="draft" :disabled="sending || pendingRequest != null || recoveryBlocked" :maxlength="255" class="feedback-input" placeholder="请填写遇到的问题或建议" />
<view class="feedback-count"><text class="count-text">{{ draft.length }}/255</text></view>
<text v-if="pendingRequest != null" class="feedback-notice">上次反馈尚未确认,内容已保留。请先确认后再提交新反馈。</text>
<text v-if="submitError.length > 0" class="feedback-error">{{ submitError }}</text>
<text v-if="submitted" class="feedback-success">反馈已提交</text>
<button v-if="recoveryBlocked" class="retry-button" @click="restorePending">重新读取提交状态</button>
<button class="primary-button feedback-submit" :disabled="sending || recoveryBlocked || draft.trim().length == 0" @click="send">{{ sending ? '处理中…' : (pendingRequest != null ? '确认上次提交' : '提交反馈') }}</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.handledAt.length > 0 ? item.handledAt : 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, PendingFeedback, feedbackStatus, submitFeedback, fetchFeedback, fetchFeedbackDetail, loadPendingFeedback, prepareFeedback, clearPendingFeedback, feedbackRejected } 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 pendingRequest = ref<PendingFeedback | null>(null); const recoveryBlocked = ref(false)
const feedbackLoading = ref(false); const loadingMore = ref(false); const feedbackError = ref(''); const moreError = ref(''); const hasMore = ref(false)
const targetId = ref(''); const targetItem = ref<FeedbackItem | null>(null); const targetLoading = ref(false); const targetError = ref('')
let detailRequest = 0
let visible = false; let generation = 0; let faqRequest = 0; let listRequest = 0; let page = 0
let owner = ''
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 => feedbackStatus(status)
const contact = () => { contactCustomerService() }
const login = () => { uni.redirectTo({ url: '/packages/account/login/login?resumeHelp=1' + (targetId.value.length > 0 ? '&feedbackId=' + targetId.value : '') }) }
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
pendingRequest.value = null; recoveryBlocked.value = false; page = 0; listRequest++
targetItem.value = null; targetError.value = ''; targetLoading.value = false; detailRequest++
}
let reconcileAccount: () => void = () => {}
function restorePending() {
if (!visible || !loggedIn.value) return
try {
const session = getSession()
if (session?.getString('token') != owner) { reconcileAccount(); return }
const pending = loadPendingFeedback(session?.getString('userId') ?? '')
if (pending == null && pendingRequest.value != null) throw new Error('本机提交状态已变化,请联系客服核对')
pendingRequest.value = pending; recoveryBlocked.value = false; submitError.value = ''
if (pending != null) { draft.value = pending.content; submitted.value = false }
} catch (error) {
recoveryBlocked.value = true
submitError.value = error instanceof Error ? error.message : '本机待确认反馈读取失败,请重试'
}
}
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(); targetId.value = ''; owner = sessionToken; loggedIn.value = owner.length > 0
if (loggedIn.value) { restorePending(); reloadAfterAccountChange() }
return false
}
reconcileAccount = () => { current(generation, owner) }
// 单条读取不依赖第一页或分页;离页和账户变化后不能回填通知关联的私有回复。
async function reloadTarget() {
if (!visible || !loggedIn.value || targetLoading.value || targetId.value.length == 0) return
const ticket = generation; const token = owner
if (!current(ticket, token)) return
const id = targetId.value; const request = ++detailRequest
targetLoading.value = true; targetItem.value = null; targetError.value = ''
try {
const item = await fetchFeedbackDetail(id)
if (current(ticket, token) && request == detailRequest && id == targetId.value) targetItem.value = item
} catch (error) {
if (current(ticket, token) && request == detailRequest) targetError.value = error instanceof Error ? error.message : '反馈回复读取失败,请重试'
} finally { if (ticket == generation && request == detailRequest) targetLoading.value = 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 = () => { reloadTarget(); reloadFeedback() }
const loadMore = () => { loadFeedback(true) }
const changeTab = (value: string) => {
tab.value = value
if (value == 'feedback') { reloadTarget(); reloadFeedback() } else if (questions.value.length == 0) load()
}
const send = async () => {
if (!visible || sending.value) return
const session = getSession(); const token = session?.getString('token') ?? ''; const ticket = generation
if (!current(ticket, owner)) return
if (token.length == 0) { login(); return }
const userId = session?.getString('userId') ?? ''
restorePending()
if (recoveryBlocked.value || draft.value.trim().length == 0) return
let pending: PendingFeedback
try {
pending = prepareFeedback(userId, draft.value)
pendingRequest.value = pending; draft.value = pending.content
} catch (error) {
restorePending()
submitError.value = error instanceof Error ? error.message : '本机未能保存提交状态,请重试'
return
}
listRequest++; feedbackLoading.value = false; loadingMore.value = false
sending.value = true; submitError.value = ''; submitted.value = false
try {
const receipt = await submitFeedback(pending.content, pending.requestId)
if (!current(ticket, token)) return
clearPendingFeedback(userId, pending)
// 先用提交回执更新当前列表,再刷新服务端分页,避免网络较慢时成功反馈暂时消失。
let replaced = false
for (let index = 0; index < records.value.length; index++) {
if (records.value[index].id == receipt.id) { records.value[index] = receipt; replaced = true; break }
}
if (!replaced && page <= 1) records.value.unshift(receipt)
draft.value = ''; pendingRequest.value = null; submitted.value = true
} catch (error) {
if (!current(ticket, token)) return
if (feedbackRejected(error)) {
try { clearPendingFeedback(userId, pending); pendingRequest.value = null }
catch (_cleanupError) { restorePending() }
}
if (!recoveryBlocked.value) 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'
const id = options?.['feedbackId'] ?? ''
if (/^[1-9][0-9]{0,9}$/.test(id) && !(id.length == 10 && id > '2147483647')) { targetId.value = id; tab.value = 'feedback' }
})
onShow(() => {
visible = true; generation++; const token = getSession()?.getString('token') ?? ''
if (token != owner) { if (owner.length > 0) targetId.value = ''; clearFeedback() }
owner = token; loggedIn.value = token.length > 0; restorePending(); load(); if (tab.value == 'feedback') { reloadTarget(); reloadFeedback() }
})
onHide(() => { visible = false; generation++; faqRequest++; listRequest++; detailRequest++; targetItem.value = null; targetLoading.value = false; targetError.value = ''; 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; }
.linked-feedback { padding: 24rpx; margin-bottom: 30rpx; border: 1rpx solid #c7dfed; border-radius: 16rpx; background-color: #ffffff; }
.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-notice { color: #66758a; font-size: 25rpx; line-height: 40rpx; padding-bottom: 12rpx; }
.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>