feat: 增加反馈消息闭环相关功能及文件

新增反馈目标登录白名单、账户切换守卫、待确认反馈存储、缓存生命周期管理,补充相关文档及截图,完善消息跳转逻辑
This commit is contained in:
hujun
2026-10-05 20:11:24 +08:00
parent 6b16d4fd55
commit 1723363b7e
154 changed files with 16407 additions and 114 deletions
+101 -30
View File
@@ -21,12 +21,26 @@
</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" :maxlength="255" class="feedback-input" placeholder="请填写遇到的问题或建议" />
<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 class="primary-button feedback-submit" :disabled="sending || draft.trim().length == 0" @click="send">{{ sending ? '提交中…' : (submitError.length > 0 ? '重试提交' : '提交反馈') }}</button>
<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>
@@ -35,7 +49,7 @@
<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 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>
@@ -54,7 +68,7 @@
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'
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[]>([])
@@ -63,9 +77,12 @@
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 = ''; let requestKey = ''; let requestText = ''
let owner = ''
const groups = computed((): HelpGroup[] => {
const results: HelpGroup[] = []
helpCategories.forEach((category: string) => {
@@ -75,19 +92,29 @@
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 statusLabel = (status: string): string => feedbackStatus(status)
const contact = () => { contactCustomerService() }
const login = () => { uni.redirectTo({ url: '/packages/account/login/login?resumeHelp=1' }) }
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
requestKey = ''; requestText = ''; page = 0; listRequest++
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 = () => {}
// 离页或切换账户后,旧提交和列表响应不得回填私有反馈。
@@ -95,10 +122,25 @@
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()
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
@@ -128,37 +170,64 @@
} finally { if (ticket == generation && request == listRequest) { feedbackLoading.value = false; loadingMore.value = false } }
}
function reloadFeedback() { loadFeedback(false) }
reloadAfterAccountChange = reloadFeedback
reloadAfterAccountChange = () => { reloadTarget(); reloadFeedback() }
const loadMore = () => { loadFeedback(true) }
const changeTab = (value: string) => {
tab.value = value
if (value == 'feedback') reloadFeedback(); else if (questions.value.length == 0) load()
if (value == 'feedback') { reloadTarget(); 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 (!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 content = draft.value.trim()
// 同一段内容的网络失败重试复用请求键;成功后才清空,避免生成重复反馈。
if (requestKey.length == 0 || requestText != content) { requestKey = feedbackKey(); requestText = content }
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 {
await submitFeedback(requestText, requestKey)
const receipt = await submitFeedback(pending.content, pending.requestId)
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 : '提交失败,请重试' }
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' })
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) clearFeedback()
owner = token; loggedIn.value = token.length > 0; load(); if (tab.value == 'feedback') reloadFeedback()
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++; loading.value = false; feedbackLoading.value = false; loadingMore.value = false; sending.value = false; records.value = [] })
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>
@@ -189,6 +258,7 @@
.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; }
@@ -196,6 +266,7 @@
.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; }