feat: 增加反馈消息闭环相关功能及文件
新增反馈目标登录白名单、账户切换守卫、待确认反馈存储、缓存生命周期管理,补充相关文档及截图,完善消息跳转逻辑
This commit is contained in:
@@ -1,11 +1,14 @@
|
||||
export const FEEDBACK_PENDING_KEY = 'content.feedback.pending'
|
||||
|
||||
// 只清理指定账户命名空间,不触及其他账户或旧匿名草稿。
|
||||
export function clearAccountCache(userId: string): void {
|
||||
if (userId.length == 0) return
|
||||
const prefix = 'airz:v1:user:' + userId + ':'
|
||||
const pendingCheckIn = prefix + 'finance.points.checkin.pending'
|
||||
const pendingFeedback = prefix + FEEDBACK_PENDING_KEY
|
||||
uni.getStorageInfoSync().keys.forEach((key: string) => {
|
||||
// 未确认的签到是恢复用命令上下文,仅保留原键/业务日/规则,重新登录后仍须服务端鉴权。
|
||||
if (key.startsWith(prefix) && key != pendingCheckIn) uni.removeStorageSync(key)
|
||||
// 只保留未确认命令:签到三字段、反馈原键/原文;恢复须重新登录原账户,不保留Token或列表。
|
||||
if (key.startsWith(prefix) && key != pendingCheckIn && key != pendingFeedback) uni.removeStorageSync(key)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -18,26 +18,26 @@ function imageUrl(value: string): string {
|
||||
function sections(value: UTSJSONObject[]): ContentSection[] {
|
||||
return value.map((item: UTSJSONObject): ContentSection => ({ id: item.getString('id') ?? '', title: item.getString('title') ?? '', body: item.getString('body') ?? '' }))
|
||||
}
|
||||
function articleFrom(data: UTSJSONObject): NewsArticle {
|
||||
export function contentArticleFrom(data: UTSJSONObject): NewsArticle {
|
||||
return { id: data.getString('id') ?? '', title: data.getString('title') ?? '', summary: data.getString('summary') ?? '', category: data.getString('category') ?? '',
|
||||
date: data.getString('date') ?? '', source: data.getString('source') ?? '', readingTime: data.getString('readingTime') ?? '', image: imageUrl(data.getString('image') ?? ''),
|
||||
sections: sections(data['sections'] as UTSJSONObject[]) }
|
||||
}
|
||||
function lessonFrom(data: UTSJSONObject): FinanceLesson {
|
||||
export function contentLessonFrom(data: UTSJSONObject): FinanceLesson {
|
||||
return { id: data.getString('id') ?? '', title: data.getString('title') ?? '', desc: data.getString('desc') ?? '', duration: data.getString('duration') ?? '', tag: data.getString('tag') ?? '',
|
||||
image: imageUrl(data.getString('image') ?? ''), intro: data.getString('intro') ?? '', topics: data['topics'] as string[], chapters: sections(data['chapters'] as UTSJSONObject[]),
|
||||
chapterCount: data.getNumber('chapterCount') ?? 0, lessonVersion: data.getString('lessonVersion') ?? '' }
|
||||
}
|
||||
export async function fetchArticles(category: string, page: number = 1): Promise<ArticlePage> {
|
||||
const data = await requestJson('/content/articles', 'GET', { category, page, pageSize: 20 })
|
||||
return { items: (data['items'] as UTSJSONObject[]).map((item: UTSJSONObject): NewsArticle => articleFrom(item)), total: data.getNumber('total') ?? 0, hasMore: data.getBoolean('hasMore') ?? false }
|
||||
return { items: (data['items'] as UTSJSONObject[]).map((item: UTSJSONObject): NewsArticle => contentArticleFrom(item)), total: data.getNumber('total') ?? 0, hasMore: data.getBoolean('hasMore') ?? false }
|
||||
}
|
||||
export async function fetchArticle(id: string): Promise<NewsArticle> { return articleFrom(await requestJson('/content/articles/' + encodeURIComponent(id), 'GET', {})) }
|
||||
export async function fetchArticle(id: string): Promise<NewsArticle> { return contentArticleFrom(await requestJson('/content/articles/' + encodeURIComponent(id), 'GET', {})) }
|
||||
export async function fetchLessons(topic: string, page: number = 1): Promise<LessonPage> {
|
||||
const data = await requestJson('/content/lessons', 'GET', { topic, page, pageSize: 20 })
|
||||
return { items: (data['items'] as UTSJSONObject[]).map((item: UTSJSONObject): FinanceLesson => lessonFrom(item)), total: data.getNumber('total') ?? 0, hasMore: data.getBoolean('hasMore') ?? false }
|
||||
return { items: (data['items'] as UTSJSONObject[]).map((item: UTSJSONObject): FinanceLesson => contentLessonFrom(item)), total: data.getNumber('total') ?? 0, hasMore: data.getBoolean('hasMore') ?? false }
|
||||
}
|
||||
export async function fetchLesson(id: string): Promise<FinanceLesson> { return lessonFrom(await requestJson('/content/lessons/' + encodeURIComponent(id), 'GET', {})) }
|
||||
export async function fetchLesson(id: string): Promise<FinanceLesson> { return contentLessonFrom(await requestJson('/content/lessons/' + encodeURIComponent(id), 'GET', {})) }
|
||||
function stateFrom(data: UTSJSONObject): LessonState { return { lessonVersion: data.getString('lessonVersion') ?? '', completedChapterIds: data['completedChapterIds'] as string[] } }
|
||||
export async function fetchLessonProgress(id: string): Promise<LessonState> { return stateFrom(await requestJson('/content/lessons/' + id + '/progress', 'GET', {})) }
|
||||
export async function completeLessonChapter(id: string, chapterId: string, lessonVersion: string): Promise<LessonState> {
|
||||
@@ -45,7 +45,7 @@ export async function completeLessonChapter(id: string, chapterId: string, lesso
|
||||
}
|
||||
export async function fetchContentHome(): Promise<ContentHome> {
|
||||
const data = await requestJson('/content/home', 'GET', {})
|
||||
return { lessons: (data['lessons'] as UTSJSONObject[]).map((item: UTSJSONObject): FinanceLesson => lessonFrom(item)), articles: (data['articles'] as UTSJSONObject[]).map((item: UTSJSONObject): NewsArticle => articleFrom(item)) }
|
||||
return { lessons: (data['lessons'] as UTSJSONObject[]).map((item: UTSJSONObject): FinanceLesson => contentLessonFrom(item)), articles: (data['articles'] as UTSJSONObject[]).map((item: UTSJSONObject): NewsArticle => contentArticleFrom(item)) }
|
||||
}
|
||||
export async function searchContent(keyword: string, type: string, page: number = 1): Promise<SearchPage> {
|
||||
const data = await requestJson('/content/search', 'GET', { keyword, type, page, pageSize: 20 })
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { requestJson } from '@/services/api.uts'
|
||||
import { FinanceLesson, NewsArticle, contentLessonFrom, contentArticleFrom } from '@/services/content.uts'
|
||||
|
||||
/** 首页运营横幅;当前后端没有可发布横幅时返回空数组。 */
|
||||
export type HomeBanner = { id: string, title: string, subtitle: string, image: string, actionUrl: string }
|
||||
export type HomeAssessment = { assessmentId: string, type: string, status: string, statusLabel: string, stage: string, revisionId: string | null, hasReport: boolean, updatedAt: string | null }
|
||||
export type HomeAssessmentSummary = { total: number, latest: HomeAssessment | null }
|
||||
export type HomeRecommendations = { lessons: FinanceLesson[], articles: NewsArticle[] }
|
||||
export type HomeCatalog = { banners: HomeBanner[], recommendations: HomeRecommendations, assessmentSummary: HomeAssessmentSummary | null, unreadCount: number }
|
||||
|
||||
function requiredString(data: UTSJSONObject, key: string, message: string): string {
|
||||
const value = data.getString(key)
|
||||
if (value == null || value.length == 0) throw new Error(message)
|
||||
return value
|
||||
}
|
||||
|
||||
function nullableString(data: UTSJSONObject, key: string, message: string): string | null {
|
||||
const value = data[key]
|
||||
if (value == null) return null
|
||||
if (typeof value != 'string') throw new Error(message)
|
||||
return value as string
|
||||
}
|
||||
|
||||
function recordId(value: string, message: string): string {
|
||||
if (!/^[1-9][0-9]{0,9}$/.test(value)) throw new Error(message)
|
||||
if (value.length == 10 && value > '2147483647') throw new Error(message)
|
||||
return value
|
||||
}
|
||||
|
||||
function bannersFrom(value: any | null): HomeBanner[] {
|
||||
if (value == null || !Array.isArray(value)) throw new Error('首页横幅格式无效')
|
||||
return (value as UTSJSONObject[]).map((item: UTSJSONObject): HomeBanner => {
|
||||
if (item == null || typeof item != 'object' || Array.isArray(item)) throw new Error('首页横幅格式无效')
|
||||
return {
|
||||
id: recordId(requiredString(item, 'id', '首页横幅缺少编号'), '首页横幅编号无效'),
|
||||
title: requiredString(item, 'title', '首页横幅标题无效'),
|
||||
subtitle: requiredString(item, 'subtitle', '首页横幅副标题无效'),
|
||||
image: requiredString(item, 'image', '首页横幅图片无效'),
|
||||
actionUrl: requiredString(item, 'actionUrl', '首页横幅跳转地址无效')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function assessmentFrom(data: UTSJSONObject): HomeAssessment {
|
||||
if (data == null || typeof data != 'object' || Array.isArray(data)) throw new Error('首页评估摘要格式无效')
|
||||
const type = requiredString(data, 'type', '首页评估类型无效')
|
||||
const status = requiredString(data, 'status', '首页评估状态无效')
|
||||
if (!['personal', 'enterprise'].includes(type) || !['draft', 'processing', 'succeeded', 'failed'].includes(status)) throw new Error('首页评估摘要格式无效')
|
||||
const hasReport = data.getBoolean('hasReport')
|
||||
if (hasReport == null) throw new Error('首页评估报告标记无效')
|
||||
const revisionId = nullableString(data, 'revisionId', '首页评估版本编号无效')
|
||||
if (revisionId != null) recordId(revisionId, '首页评估版本编号无效')
|
||||
return {
|
||||
assessmentId: recordId(requiredString(data, 'assessmentId', '首页评估编号无效'), '首页评估编号无效'),
|
||||
type, status, statusLabel: requiredString(data, 'statusLabel', '首页评估状态文案无效'),
|
||||
stage: requiredString(data, 'stage', '首页评估阶段无效'), revisionId, hasReport,
|
||||
updatedAt: nullableString(data, 'updatedAt', '首页评估更新时间无效')
|
||||
}
|
||||
}
|
||||
|
||||
function summaryFrom(value: any | null): HomeAssessmentSummary | null {
|
||||
if (value == null) return null
|
||||
if (typeof value != 'object' || Array.isArray(value)) throw new Error('首页评估摘要格式无效')
|
||||
const data = value as UTSJSONObject
|
||||
const total = data.getNumber('total')
|
||||
if (total == null || !Number.isInteger(total) || total < 0) throw new Error('首页评估数量无效')
|
||||
const latest = data['latest']
|
||||
if (latest == null) return { total, latest: null }
|
||||
if (typeof latest != 'object' || Array.isArray(latest)) throw new Error('首页最新评估格式无效')
|
||||
return { total, latest: assessmentFrom(latest as UTSJSONObject) }
|
||||
}
|
||||
|
||||
function recommendationsFrom(value: any | null): HomeRecommendations {
|
||||
if (value == null || typeof value != 'object' || Array.isArray(value)) throw new Error('首页推荐格式无效')
|
||||
const data = value as UTSJSONObject
|
||||
const lessons = data['lessons']; const articles = data['articles']
|
||||
if (lessons == null || !Array.isArray(lessons) || articles == null || !Array.isArray(articles)) throw new Error('首页推荐格式无效')
|
||||
return {
|
||||
lessons: (lessons as UTSJSONObject[]).map((item: UTSJSONObject): FinanceLesson => contentLessonFrom(item)),
|
||||
articles: (articles as UTSJSONObject[]).map((item: UTSJSONObject): NewsArticle => contentArticleFrom(item))
|
||||
}
|
||||
}
|
||||
|
||||
function homeFrom(data: UTSJSONObject): HomeCatalog {
|
||||
const unreadCount = data.getNumber('unreadCount')
|
||||
if (unreadCount == null || !Number.isInteger(unreadCount) || unreadCount < 0) throw new Error('首页未读数量无效')
|
||||
return {
|
||||
banners: bannersFrom(data['banners']),
|
||||
recommendations: recommendationsFrom(data['recommendations']),
|
||||
assessmentSummary: summaryFrom(data['assessmentSummary']),
|
||||
unreadCount
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchHome(): Promise<HomeCatalog> { return homeFrom(await requestJson('/home', 'GET', {})) }
|
||||
+75
-19
@@ -4,7 +4,29 @@ export type UserMessage = { id: string, type: string, category: string, icon: st
|
||||
export type MessagePage = { items: UserMessage[], snapshotId: string, nextBeforeId: string, unreadCount: number }
|
||||
export type MessageDetail = { item: UserMessage, targetUrl: string, targetLabel: string, targetUnavailable: boolean }
|
||||
|
||||
const validId = (value: string): boolean => /^[1-9][0-9]{0,9}$/.test(value)
|
||||
const validId = (value: string, allowZero: boolean): boolean => {
|
||||
if (!(allowZero ? /^(?:0|[1-9][0-9]{0,9})$/.test(value) : /^[1-9][0-9]{0,9}$/.test(value))) return false
|
||||
return !(value.length == 10 && value > '2147483647')
|
||||
}
|
||||
const requiredId = (value: string, message: string): string => {
|
||||
if (!validId(value, false)) throw new Error(message)
|
||||
return value
|
||||
}
|
||||
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 parseSnapshotId = (value: string): string => {
|
||||
if (!validId(value, true)) throw new Error('消息快照格式无效')
|
||||
return value
|
||||
}
|
||||
const optionalId = (value: any | null): string => {
|
||||
if (value == null) return ''
|
||||
if (typeof value != 'string') throw new Error('消息分页游标格式无效')
|
||||
if (value.length == 0) return ''
|
||||
return requiredId(value as string, '消息分页游标格式无效')
|
||||
}
|
||||
const count = (data: UTSJSONObject): number => {
|
||||
const value = data.getNumber('unreadCount')
|
||||
if (value == null || !Number.isInteger(value) || value < 0) throw new Error('未读数量格式无效')
|
||||
@@ -12,44 +34,78 @@ const count = (data: UTSJSONObject): number => {
|
||||
}
|
||||
function messageFrom(data: UTSJSONObject): UserMessage {
|
||||
const id = data.getString('id') ?? ''; const type = data.getString('type') ?? ''
|
||||
if (!validId(id) || !['system', 'service', 'activity'].includes(type)) throw new Error('消息格式无效')
|
||||
if (!validId(id, false) || !['system', 'service', 'activity'].includes(type)) throw new Error('消息格式无效')
|
||||
const title = requiredText(data, 'title', '消息标题格式无效')
|
||||
const content = requiredText(data, 'content', '消息内容格式无效')
|
||||
const createdAt = requiredText(data, 'createdAt', '消息时间格式无效')
|
||||
const isRead = data.getBoolean('isRead')
|
||||
if (isRead == null) throw new Error('消息已读标记无效')
|
||||
const categories: UTSJSONObject = { system: '系统通知', service: '服务通知', activity: '活动消息' }
|
||||
const icons: UTSJSONObject = { system: 'system-bell', service: 'approval', activity: 'gift' }
|
||||
const date = new Date((data.getString('createdAt') ?? '').replace(' ', 'T'))
|
||||
const date = new Date(createdAt.replace(' ', 'T'))
|
||||
const now = new Date(); const pad = (n: number): string => n < 10 ? '0' + n.toString() : n.toString()
|
||||
let time = '未记录'
|
||||
if (!isNaN(date.getTime())) time = date.getFullYear() == now.getFullYear() && date.getMonth() == now.getMonth() && date.getDate() == now.getDate()
|
||||
? pad(date.getHours()) + ':' + pad(date.getMinutes()) : date.getFullYear().toString() + '-' + pad(date.getMonth() + 1) + '-' + pad(date.getDate())
|
||||
return { id, type, category: categories.getString(type) ?? '', icon: icons.getString(type) ?? '', title: data.getString('title') ?? '',
|
||||
content: data.getString('content') ?? '', time, isRead: data.getBoolean('isRead') ?? false }
|
||||
return { id, type, category: categories.getString(type) ?? '', icon: icons.getString(type) ?? '', title,
|
||||
content, time, isRead }
|
||||
}
|
||||
export async function fetchUnreadMessages(): Promise<number> {
|
||||
if (getSession() == null) return 0
|
||||
return count(await requestJson('/messages/unread-count', 'GET', {}))
|
||||
}
|
||||
export async function fetchMessages(type: string, beforeId: string = '', snapshotId: string = ''): Promise<MessagePage> {
|
||||
if (!['all', 'system', 'service', 'activity'].includes(type)) throw new Error('消息分类无效')
|
||||
if (beforeId.length > 0 && !validId(beforeId, false)) throw new Error('消息分页游标格式无效')
|
||||
if (beforeId.length > 0 && snapshotId.length == 0) throw new Error('消息分页快照缺失')
|
||||
if (snapshotId.length > 0 && !validId(snapshotId, true)) throw new Error('消息快照格式无效')
|
||||
const params: UTSJSONObject = { type, limit: 20 }
|
||||
if (beforeId.length > 0) { params['beforeId'] = beforeId; params['snapshotId'] = snapshotId }
|
||||
const data = await requestJson('/messages', 'GET', params)
|
||||
const items = data['items'] as UTSJSONObject[]
|
||||
return { items: items.map((item: UTSJSONObject): UserMessage => messageFrom(item)), snapshotId: data.getString('snapshotId') ?? '',
|
||||
nextBeforeId: data.getString('nextBeforeId') ?? '', unreadCount: count(data) }
|
||||
const rows = data['items']
|
||||
if (rows == null || !Array.isArray(rows)) throw new Error('消息列表格式无效')
|
||||
;(rows as any[]).forEach((item: any) => {
|
||||
if (item == null || typeof item != 'object' || Array.isArray(item)) throw new Error('消息列表格式无效')
|
||||
})
|
||||
const returnedSnapshot = parseSnapshotId(data.getString('snapshotId') ?? '')
|
||||
return { items: (rows as UTSJSONObject[]).map((item: UTSJSONObject): UserMessage => messageFrom(item)), snapshotId: returnedSnapshot,
|
||||
nextBeforeId: optionalId(data['nextBeforeId']), unreadCount: count(data) }
|
||||
}
|
||||
export async function fetchMessage(id: string): Promise<MessageDetail> {
|
||||
if (!validId(id)) throw new Error('消息编号无效')
|
||||
if (!validId(id, false)) throw new Error('消息编号无效')
|
||||
const data = await requestJson('/messages/' + id, 'GET', {})
|
||||
const target = data['target'] as UTSJSONObject | null
|
||||
const item = data['item']
|
||||
if (item == null || typeof item != 'object' || Array.isArray(item)) throw new Error('消息详情格式无效')
|
||||
const targetValue = data['target']
|
||||
if (targetValue != null && (typeof targetValue != 'object' || Array.isArray(targetValue))) throw new Error('消息关联格式无效')
|
||||
const target = targetValue as UTSJSONObject | null
|
||||
let targetUrl = ''; let targetLabel = ''
|
||||
if (target != null) {
|
||||
const assessmentId = target.getString('assessmentId') ?? ''; const revisionId = target.getString('revisionId') ?? ''
|
||||
const type = target.getString('type') ?? ''; const kind = target.getString('kind') ?? ''
|
||||
if (!validId(assessmentId) || !validId(revisionId) || !['personal', 'enterprise'].includes(type)) throw new Error('关联评估格式无效')
|
||||
// 只构造白名单路由,通知不能下发任意链接;历史失败也只打开原提交版本。
|
||||
if (kind == 'report') { targetUrl = '/packages/finance/report/report?assessmentId=' + assessmentId + '&revisionId=' + revisionId; targetLabel = '查看报告' }
|
||||
else if (kind == 'revision') { targetUrl = '/packages/finance/archive/archive?assessmentId=' + assessmentId + '&revisionId=' + revisionId + '&type=' + type; targetLabel = '查看评估' }
|
||||
else throw new Error('关联评估格式无效')
|
||||
const kind = target.getString('kind') ?? ''
|
||||
if (kind == 'feedback') {
|
||||
const feedbackId = requiredId(target.getString('feedbackId') ?? '', '关联反馈格式无效')
|
||||
targetUrl = '/packages/content/help/help?feedback=1&feedbackId=' + feedbackId; targetLabel = '查看回复'
|
||||
} else {
|
||||
const assessmentId = target.getString('assessmentId') ?? ''; const revisionId = target.getString('revisionId') ?? ''
|
||||
const type = target.getString('type') ?? ''
|
||||
if (!validId(assessmentId, false) || !validId(revisionId, false) || !['personal', 'enterprise'].includes(type)) throw new Error('关联评估格式无效')
|
||||
// 只构造白名单路由,通知不能下发任意链接;历史失败也只打开原提交版本。
|
||||
if (kind == 'report') { targetUrl = '/packages/finance/report/report?assessmentId=' + assessmentId + '&revisionId=' + revisionId; targetLabel = '查看报告' }
|
||||
else if (kind == 'revision') { targetUrl = '/packages/finance/archive/archive?assessmentId=' + assessmentId + '&revisionId=' + revisionId + '&type=' + type; targetLabel = '查看评估' }
|
||||
else throw new Error('关联评估格式无效')
|
||||
}
|
||||
}
|
||||
return { item: messageFrom(data['item'] as UTSJSONObject), targetUrl, targetLabel, targetUnavailable: data.getBoolean('targetUnavailable') ?? false }
|
||||
const targetUnavailable = data.getBoolean('targetUnavailable')
|
||||
if (targetUnavailable == null) throw new Error('消息关联状态无效')
|
||||
return { item: messageFrom(item as UTSJSONObject), targetUrl, targetLabel, targetUnavailable }
|
||||
}
|
||||
export async function readMessage(id: string): Promise<number> { return count(await requestJson('/messages/' + id + '/read', 'POST', {})) }
|
||||
export async function readAllMessages(snapshotId: string): Promise<number> { return count(await requestJson('/messages/read-all', 'POST', { snapshotId })) }
|
||||
export async function readAllMessages(snapshotId: string): Promise<number> {
|
||||
if (!validId(snapshotId, true)) throw new Error('消息快照格式无效')
|
||||
const data = await requestJson('/messages/read-all', 'POST', { snapshotId })
|
||||
const updatedCount = data.getNumber('updatedCount')
|
||||
if (updatedCount == null || !Number.isInteger(updatedCount) || updatedCount < 0) throw new Error('消息已读回执格式无效')
|
||||
const returnedSnapshot = parseSnapshotId(data.getString('snapshotId') ?? '')
|
||||
if (returnedSnapshot != snapshotId) throw new Error('消息已读回执快照不一致')
|
||||
return count(data)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user