feat: 增加反馈消息闭环相关功能及文件
新增反馈目标登录白名单、账户切换守卫、待确认反馈存储、缓存生命周期管理,补充相关文档及截图,完善消息跳转逻辑
This commit is contained in:
+101
-30
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user