import { API_BASE_URL, requestJson } from '@/services/api.uts' export type ContentSection = { id: string, title: string, body: string } export type NewsArticle = { id: string, title: string, summary: string, category: string, date: string, source: string, readingTime: string, image: string, sections: ContentSection[] } export type FinanceLesson = { id: string, title: string, desc: string, duration: string, tag: string, image: string, intro: string, topics: string[], chapters: ContentSection[], chapterCount: number, lessonVersion: string } export type ArticlePage = { items: NewsArticle[], total: number, hasMore: boolean } export type LessonPage = { items: FinanceLesson[], total: number, hasMore: boolean } export type LessonState = { lessonId: string, lessonVersion: string, completedChapterIds: string[] } export type ContentHome = { lessons: FinanceLesson[], articles: NewsArticle[] } export type ContentSearchResult = { id: string, type: string, title: string, summary: string, category: string, meta: string, image: string } export type SearchPage = { items: ContentSearchResult[], total: number, hasMore: boolean } function imageUrl(value: string): string { // 随包图片保留本地路径,后台上传的封面从接口站点读取。 if (value.startsWith('/uploads/')) return API_BASE_URL.replace('/api/v1', '') + value return value } function contentObject(value: any | null): UTSJSONObject { if (value == null || typeof value != 'object' || Array.isArray(value)) throw new Error('内容响应格式不完整,请重试') return value as UTSJSONObject } function contentText(data: UTSJSONObject, key: string, empty: boolean = false): string { const value = data.getString(key) if (value == null || (!empty && value.trim().length == 0)) throw new Error('内容响应格式不完整,请重试') return value } function validContentId(id: string): boolean { return /^[1-9][0-9]{0,9}$/.test(id) && parseFloat(id) <= 2147483647 } function requireContentId(id: string): void { if (!validContentId(id)) throw new Error('内容编号无效,请重新选择') } function uniqueStrings(value: any | null): string[] { if (value == null || !Array.isArray(value)) throw new Error('内容响应格式不完整,请重试') const result: string[] = [] ;(value as any[]).forEach((item: any) => { if (typeof item != 'string' || result.includes(item as string)) throw new Error('内容响应格式不完整,请重试') result.push(item as string) }) return result } function sections(value: any | null, lesson: boolean): ContentSection[] { if (value == null || !Array.isArray(value) || (value as any[]).length > 30) throw new Error('内容章节格式不完整,请重试') const ids: string[] = [] return (value as any[]).map((value: any): ContentSection => { const item = contentObject(value); const id = lesson ? contentText(item, 'id') : '' if (lesson && (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(id) || ids.includes(id))) throw new Error('课程章节编号无效,请重试') ids.push(id) return { id, title: contentText(item, 'title'), body: contentText(item, 'body') } }) } export function contentArticleFrom(data: UTSJSONObject): NewsArticle { contentObject(data); const id = contentText(data, 'id'); requireContentId(id) return { id, title: contentText(data, 'title'), summary: contentText(data, 'summary'), category: contentText(data, 'category'), date: contentText(data, 'date'), source: contentText(data, 'source'), readingTime: contentText(data, 'readingTime'), image: imageUrl(contentText(data, 'image', true)), sections: sections(data['sections'], false) } } export function contentLessonFrom(data: UTSJSONObject): FinanceLesson { contentObject(data); const id = contentText(data, 'id'); requireContentId(id) const chapterCount = data.getNumber('chapterCount'); const lessonVersion = contentText(data, 'lessonVersion') const topics = uniqueStrings(data['topics']); const chapters = sections(data['chapters'], true) if (chapterCount == null || !Number.isInteger(chapterCount) || chapterCount < 1 || chapterCount > 30 || !/^[a-f0-9]{64}$/.test(lessonVersion) || topics.length == 0 || topics.some((topic: string): boolean => !['credit','business','documents'].includes(topic)) || (chapters.length > 0 && chapters.length != chapterCount)) throw new Error('课程响应格式不完整,请重试') return { id, title: contentText(data, 'title'), desc: contentText(data, 'desc'), duration: contentText(data, 'duration'), tag: contentText(data, 'tag'), image: imageUrl(contentText(data, 'image', true)), intro: contentText(data, 'intro'), topics, chapters, chapterCount, lessonVersion } } function pageItems(data: UTSJSONObject, page: number): UTSJSONObject[] { const total = data.getNumber('total'); const more = data.getBoolean('hasMore'); const items = data['items'] if (data.getNumber('page') != page || data.getNumber('pageSize') != 20 || total == null || !Number.isInteger(total) || total < 0 || more == null || more != (page * 20 < total) || items == null || !Array.isArray(items) || (items as any[]).length > 20 || (items as any[]).length > total) throw new Error('内容分页响应不完整,请重试') return (items as any[]).map((item: any): UTSJSONObject => contentObject(item)) } export function validateContentAppend(previous: string[], incoming: string[]): void { const seen = previous.slice() incoming.forEach((key: string) => { if (seen.includes(key)) throw new Error('内容列表已变化,请刷新后重试') seen.push(key) }) } export async function fetchArticles(category: string, page: number = 1): Promise { const data = await requestJson('/content/articles', 'GET', { category, page, pageSize: 20 }) const items = pageItems(data, page).map((item: UTSJSONObject): NewsArticle => contentArticleFrom(item)) validateContentAppend([], items.map((item: NewsArticle): string => item.id)) if (category.length > 0 && items.some((item: NewsArticle): boolean => item.category != category)) throw new Error('资讯分类响应不符,请重试') return { items, total: data.getNumber('total')!, hasMore: data.getBoolean('hasMore')! } } export async function fetchArticle(id: string): Promise { requireContentId(id) const result = contentArticleFrom(await requestJson('/content/articles/' + id, 'GET', {})) if (result.id != id || result.sections.length == 0) throw new Error('资讯详情响应不符,请重试') return result } export async function fetchLessons(topic: string, page: number = 1): Promise { const data = await requestJson('/content/lessons', 'GET', { topic, page, pageSize: 20 }) const items = pageItems(data, page).map((item: UTSJSONObject): FinanceLesson => contentLessonFrom(item)) validateContentAppend([], items.map((item: FinanceLesson): string => item.id)) if (topic.length > 0 && items.some((item: FinanceLesson): boolean => !item.topics.includes(topic))) throw new Error('课程场景响应不符,请重试') return { items, total: data.getNumber('total')!, hasMore: data.getBoolean('hasMore')! } } export async function fetchLesson(id: string): Promise { requireContentId(id) const result = contentLessonFrom(await requestJson('/content/lessons/' + id, 'GET', {})) if (result.id != id || result.chapters.length != result.chapterCount) throw new Error('课程详情响应不符,请重试') return result } function stateFrom(data: UTSJSONObject, id: string): LessonState { const lessonId = contentText(data, 'lessonId'); const lessonVersion = contentText(data, 'lessonVersion') const completedChapterIds = uniqueStrings(data['completedChapterIds']) if (lessonId != id || !/^[a-f0-9]{64}$/.test(lessonVersion) || completedChapterIds.length > 30 || completedChapterIds.some((chapter: string): boolean => !/^[a-z0-9][a-z0-9_-]{0,63}$/.test(chapter))) throw new Error('课程进度响应不符,请刷新后重试') return { lessonId, lessonVersion, completedChapterIds } } export function validateLessonState(lesson: FinanceLesson, state: LessonState): void { if (state.lessonId != lesson.id || state.lessonVersion != lesson.lessonVersion || state.completedChapterIds.some((id: string): boolean => !lesson.chapters.some((chapter: ContentSection): boolean => chapter.id == id))) throw new Error('课程或进度已更新,请刷新后继续学习') } export async function fetchLessonProgress(id: string): Promise { requireContentId(id) return stateFrom(await requestJson('/content/lessons/' + id + '/progress', 'GET', {}), id) } export async function completeLessonChapter(id: string, chapterId: string, lessonVersion: string): Promise { requireContentId(id) if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(chapterId) || !/^[a-f0-9]{64}$/.test(lessonVersion)) throw new Error('课程章节或版本无效,请刷新') const state = stateFrom(await requestJson('/content/lessons/' + id + '/progress', 'PUT', { chapterId, lessonVersion, completed: true }), id) if (state.lessonVersion != lessonVersion || !state.completedChapterIds.includes(chapterId)) throw new Error('课程进度保存响应不符,请刷新后重试') return state } export async function fetchContentHome(): Promise { const data = await requestJson('/content/home', 'GET', {}) 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 any[]).map((item: any): FinanceLesson => contentLessonFrom(contentObject(item))), articles: (articles as any[]).map((item: any): NewsArticle => contentArticleFrom(contentObject(item))) } } export async function searchContent(keyword: string, type: string, page: number = 1): Promise { const data = await requestJson('/content/search', 'GET', { keyword, type, page, pageSize: 20 }) const items = pageItems(data, page).map((item: UTSJSONObject): ContentSearchResult => { const id = contentText(item, 'id'); const kind = contentText(item, 'type'); requireContentId(id) if (!['news','lesson'].includes(kind) || (type != 'all' && kind != type)) throw new Error('搜索分类响应不符,请重试') return { id, type: kind, title: contentText(item, 'title'), summary: contentText(item, 'summary'), category: contentText(item, 'category'), meta: contentText(item, 'meta'), image: imageUrl(contentText(item, 'image', true)) } }) validateContentAppend([], items.map((item: ContentSearchResult): string => item.type + ':' + item.id)) return { items, total: data.getNumber('total')!, hasMore: data.getBoolean('hasMore')! } } export function emptyArticle(): NewsArticle { return { id: '', title: '', summary: '', category: '', date: '', source: '', readingTime: '', image: '', sections: [] } } export function emptyLesson(): FinanceLesson { return { id: '', title: '', desc: '', duration: '', tag: '', image: '', intro: '', topics: [], chapters: [], chapterCount: 0, lessonVersion: '' } }