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
+115 -12
View File
@@ -1,16 +1,96 @@
import { requestJson } from '@/services/api.uts'
import { ApiRequestError, getSession, requestJson } from '@/services/api.uts'
import { FEEDBACK_PENDING_KEY } from '@/services/cache-lifecycle.uts'
export type FeedbackItem = { id: string, content: string, status: string, reply: string, createdAt: string, updatedAt: string }
export type FeedbackItem = { id: string, content: string, status: string, reply: string, createdAt: string, handledAt: string, updatedAt: string }
export type FeedbackPage = { items: FeedbackItem[], page: number, total: number, hasMore: boolean }
export type PendingFeedback = { requestId: string, content: string }
const validId = (value: string): boolean => {
if (!/^[1-9][0-9]{0,9}$/.test(value)) return false
return !(value.length == 10 && value > '2147483647')
}
const validRequestId = (value: string): boolean => /^[A-Za-z0-9_-]{8,64}$/.test(value)
const validContent = (value: string): boolean => value.trim().length > 0 && value.length <= 255
const validStatuses = ['pending', 'processing', 'resolved', 'closed']
const requiredText = (data: UTSJSONObject, key: string, message: string): string => {
const value = data.getString(key)
if (value == null || value.length == 0) throw new Error(message)
return value
}
const optionalText = (data: UTSJSONObject, key: string, message: string): string => {
const value = data[key]
if (value == null) return ''
if (typeof value != 'string') throw new Error(message)
return value as string
}
function feedbackView(data: UTSJSONObject): FeedbackItem {
const id = data.getString('id') ?? ''; const content = data.getString('content') ?? ''
if (id.length == 0) throw new Error('反馈响应不完整')
return { id, content, status: data.getString('status') ?? 'pending', reply: data.getString('reply') ?? '',
createdAt: data.getString('createdAt') ?? '', updatedAt: data.getString('updatedAt') ?? '' }
const status = data.getString('status') ?? ''
if (!validId(id) || content.length == 0 || !validStatuses.includes(status)) throw new Error('反馈响应不完整')
const createdAt = requiredText(data, 'createdAt', '反馈时间格式无效')
// 后端对没有回复/处理时间的字段返回 null,统一转为空字符串供页面渲染。
const reply = optionalText(data, 'reply', '反馈回复格式无效')
const handledAt = optionalText(data, 'handledAt', '反馈处理时间格式无效')
const updatedAt = requiredText(data, 'updatedAt', '反馈更新时间格式无效')
return { id, content, status, reply,
createdAt, handledAt, updatedAt }
}
export function feedbackKey(): string { return 'feedback_' + Date.now().toString() + '_' + Math.floor(Math.random() * 1000000000).toString() }
function pendingStorageKey(userId: string): string {
if (!validId(userId) || getSession()?.getString('userId') != userId) throw new Error('请重新登录原账户后确认反馈')
return 'airz:v1:user:' + userId + ':' + FEEDBACK_PENDING_KEY
}
/** 只恢复已发送命令,不缓存普通草稿、Token、反馈列表或回复。坏存储不能当作空记录覆盖。 */
export function loadPendingFeedback(userId: string): PendingFeedback | null {
const key = pendingStorageKey(userId)
const exists = uni.getStorageInfoSync().keys.includes(key)
const value = uni.getStorageSync(key)
// H5同步读取失败也可能返回空值,已有键不能按“没有提交”处理。
if (value == null || value === '') {
if (exists || uni.getStorageInfoSync().keys.includes(key)) throw new Error('本机待确认反馈读取失败,请重新读取提交状态')
return null
}
if (typeof value != 'object' || Array.isArray(value)) throw new Error('本机待确认反馈读取失败,请重新读取提交状态')
const data = value as UTSJSONObject
const requestId = data.getString('requestId') ?? ''; const content = data.getString('content') ?? ''
if (UTSJSONObject.keys(data).length != 2 || !validRequestId(requestId) || !validContent(content) || content != content.trim())
throw new Error('本机待确认反馈不完整,请联系客服核对')
return { requestId, content }
}
/** 发请求前保存并回读;已有未知命令始终沿用,不因重新输入而生成新键。 */
export function prepareFeedback(userId: string, content: string): PendingFeedback {
const previous = loadPendingFeedback(userId)
if (previous != null) return previous
const value = content.trim()
if (!validContent(value)) throw new Error('请填写1至255字的反馈内容')
const pending: PendingFeedback = { requestId: feedbackKey(), content: value }
uni.setStorageSync(pendingStorageKey(userId), pending)
const saved = loadPendingFeedback(userId)
if (saved == null || saved.requestId != pending.requestId || saved.content != pending.content)
throw new Error('本机未能保存提交状态,请稍后重试')
return saved
}
/** 比较原命令后才移除,迟到回执不能清除另一条待确认提交。 */
export function clearPendingFeedback(userId: string, pending: PendingFeedback): void {
const saved = loadPendingFeedback(userId)
if (saved == null || saved.requestId != pending.requestId || saved.content != pending.content)
throw new Error('本机提交状态已变化,请重新读取提交状态')
const key = pendingStorageKey(userId)
uni.removeStorageSync(key)
if (uni.getStorageInfoSync().keys.includes(key)) throw new Error('反馈已确认,但本机状态未清理,请再次确认')
}
export function feedbackRejected(error: any | null): boolean {
// 参数在写入前确定被拒绝;冲突代表已有记录,401/网络/服务故障与坏回执仍保留原键。
return error instanceof ApiRequestError && (error.status == 400 || error.status == 413 || error.status == 422)
&& ['VALIDATION_ERROR', 'FEEDBACK_ATTACHMENTS_UNAVAILABLE'].includes(error.errorCode)
}
export function feedbackStatus(status: string): string {
if (status == 'pending') return '待处理'
if (status == 'processing') return '处理中'
@@ -19,13 +99,36 @@ export function feedbackStatus(status: string): string {
return '待核对'
}
export async function submitFeedback(content: string, requestId: string): Promise<FeedbackItem> {
const value = content.trim()
if (value.length == 0) throw new Error('请输入反馈内容')
if (value.length > 255) throw new Error('反馈内容不能超过255字')
if (!validRequestId(requestId)) throw new Error('反馈请求标识无效')
const attachmentIds: string[] = []
return feedbackView(await requestJson('/feedback', 'POST', { content, requestId, attachmentIds }))
const receipt = await requestJson('/feedback', 'POST', { content: value, requestId, attachmentIds })
if (receipt.getString('requestId') != requestId) throw new Error('提交回执未能核对,请确认上次提交')
return feedbackView(receipt)
}
export async function fetchFeedback(page: number): Promise<FeedbackPage> {
const data = await requestJson('/feedback', 'GET', { page: page.toString(), pageSize: '20' })
const rows = data['items'] as UTSJSONObject[] | null
if (rows == null) throw new Error('反馈记录加载失败')
return { items: rows.map((item: UTSJSONObject): FeedbackItem => feedbackView(item)), page: data.getNumber('page') ?? page,
total: data.getNumber('total') ?? 0, hasMore: data.getBoolean('hasMore') ?? false }
export async function fetchFeedback(page: number, pageSize: number = 20): Promise<FeedbackPage> {
if (!Number.isInteger(page) || page < 1 || page > 1000 || !Number.isInteger(pageSize) || pageSize < 1 || pageSize > 50) throw new Error('反馈分页参数无效')
const data = await requestJson('/feedback', 'GET', { page: page.toString(), pageSize: pageSize.toString() })
const rowsValue = data['items']
if (rowsValue == null || !Array.isArray(rowsValue)) throw new Error('反馈记录加载失败')
const rows = rowsValue as UTSJSONObject[]
rows.forEach((item: UTSJSONObject) => {
if (item == null || typeof item != 'object' || Array.isArray(item)) throw new Error('反馈记录格式无效')
})
const returnedPage = data.getNumber('page') ?? page; const total = data.getNumber('total') ?? 0
if (!Number.isInteger(returnedPage) || returnedPage < 1 || !Number.isInteger(total) || total < 0) throw new Error('反馈分页响应无效')
const hasMore = data.getBoolean('hasMore')
if (hasMore == null) throw new Error('反馈分页标记无效')
return { items: rows.map((item: UTSJSONObject): FeedbackItem => feedbackView(item)), page: returnedPage,
total, hasMore }
}
/** 读取单条反馈的最新处理状态,严格由后端按当前会员校验归属。 */
export async function fetchFeedbackDetail(id: string): Promise<FeedbackItem> {
if (!validId(id)) throw new Error('反馈编号无效')
const item = feedbackView(await requestJson('/feedback/' + encodeURIComponent(id), 'GET', {}))
if (item.id != id) throw new Error('反馈详情编号不一致')
return item
}