From f6bd6b4684bd34bb6ded5a0ceb1d2216a686091d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=98=BF=E6=A2=A6?= Date: Sun, 5 Jul 2026 16:15:33 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AF=B9=E6=8E=A5=E5=BE=AE=E4=BF=A1?= =?UTF-8?q?=E5=AE=89=E5=85=A8=E6=A3=80=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/upload.ts | 64 +++++- src/main.ts | 5 + src/pages/doWork/components/Question.vue | 187 +++++++++++++++--- src/pages/doWork/index.vue | 22 ++- src/pages/homework/index.vue | 21 +- src/pages/login/index.vue | 3 + src/router/Routeinterception.ts | 3 +- src/utils/mediaAudit.ts | 239 +++++++++++++++++++++++ src/utils/request.ts | 72 +++++-- src/utils/session.ts | 10 + src/utils/wxOpenid.ts | 58 ++++++ 11 files changed, 625 insertions(+), 59 deletions(-) create mode 100644 src/utils/mediaAudit.ts create mode 100644 src/utils/session.ts create mode 100644 src/utils/wxOpenid.ts diff --git a/src/api/upload.ts b/src/api/upload.ts index 78e6451..5808e2a 100644 --- a/src/api/upload.ts +++ b/src/api/upload.ts @@ -1,5 +1,5 @@ import { getCache } from '@/utils/cache'; -import { request, HOST, TENANT_ID } from '@/utils/request'; +import { request, HOST, TENANT_ID, handleUnauthorized, HttpError } from '@/utils/request'; /** * 上传图片到后端,返回 { url, ... } @@ -19,10 +19,14 @@ export const uploadFile = (filePath: string): Promise<{ url: string; [key: strin success: (res) => { try { const data = typeof res.data === 'string' ? JSON.parse(res.data) : res.data; - if (data.code === 200) { + if (data.code === 200 || data.code === 0) { resolve(data.data); + } else if (Number(data.code) === 401) { + const msg401 = data.message || data.msg || '登录已失效,请重新登录'; + handleUnauthorized(msg401); + reject(new HttpError(msg401, 401, data)); } else { - reject(new Error(data.message || '上传失败')); + reject(new Error(data.message || data.msg || '上传失败')); } } catch (e) { reject(new Error('上传返回格式异常')); @@ -33,15 +37,37 @@ export const uploadFile = (filePath: string): Promise<{ url: string; [key: strin }); }; +export interface MediaAuditRecord { + id?: string; + recordId?: string; + titleId?: string; + bizId?: string; + imageUrl?: string; + mediaUrl?: string; + url?: string; + /** pass | review | risky */ + suggest?: string; + /** 100 正常;20001 时政;20002 色情;20006 违法犯罪;21000 其他 */ + label?: number; + status?: number | string; + auditStatus?: number | string; + checkStatus?: number | string; + pass?: boolean; + auditPass?: boolean; + auditResult?: string; + resultMsg?: string; + message?: string; + [key: string]: any; +} + /** - * 检查图片是否合规 + * 提交图片内容安全检测(异步,结果需通过查询接口获取) */ export const checkImage = (data: { - bizId: string; - bizType: number; + openid: string; + recordId: string; + titleId: string; imageUrl: string; - scene: number; - recordId?: string; }) => request({ url: '/applet/media/checkImage', @@ -49,3 +75,25 @@ export const checkImage = (data: { data, silent: true, }); + +/** + * 按试卷记录查询全部图片审核结果 + */ +export const listMediaByRecordId = (recordId: string) => + request({ + url: '/applet/media/listByRecordId', + method: 'GET', + params: { recordId }, + silent: true, + }); + +/** + * 按试卷记录 + 题目查询图片审核结果 + */ +export const getMediaByRecordAndTitle = (recordId: string, titleId: string) => + request({ + url: '/applet/media/getByRecordAndTitle', + method: 'GET', + params: { recordId, titleId }, + silent: true, + }); diff --git a/src/main.ts b/src/main.ts index 5d1a3f2..52fa059 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,11 +3,16 @@ import '@vingogo/uni-ui/lib/style.css'; import App from './App.vue'; import pinia from './state/index'; import router from './router'; +import { useUserStore } from '@/state/modules/user'; +import { registerClearSessionHandler } from '@/utils/session'; import './style/index.css'; export function createApp() { const app = createSSRApp(App); app.use(pinia); + registerClearSessionHandler(() => { + void useUserStore().clear(); + }); app.use(router); return { app, diff --git a/src/pages/doWork/components/Question.vue b/src/pages/doWork/components/Question.vue index 238d2af..1341254 100644 --- a/src/pages/doWork/components/Question.vue +++ b/src/pages/doWork/components/Question.vue @@ -269,19 +269,30 @@ 请将答案写在纸上,拍照上传(最多3张) - + - - + + + {{ pic.statusText || '待复核' }} + + + {{ pic.statusText || '未通过审核' }} + + @@ -289,7 +300,10 @@ 添加 - + + {{ mediaAuditIssueTip }} + + 拍照上传答案 - + 未作答 @@ -381,6 +395,12 @@ import { ref, computed, watch, onUnmounted } from 'vue'; import { uploadFile, checkImage } from '@/api/upload'; import ImageCropper from '@/components/ImageCropper/index.vue'; import { formatQuestionHtml } from '@/utils/questionHtml'; +import { + getMediaAuditInfoForImage, + getQuestionTitleId, + type MediaAuditStatus, +} from '@/utils/mediaAudit'; +import { getWxOpenid } from '@/utils/wxOpenid'; const props = defineProps<{ data?: any; @@ -795,6 +815,77 @@ const picList = computed(() => { .filter(Boolean); }); +interface PicDisplayItem { + url: string; + status: MediaAuditStatus; + labelText: string; + statusText: string; +} + +const displayPicList = computed((): PicDisplayItem[] => { + const pics = picList.value; + if (!props.reportFlag) { + return pics.map((url) => ({ + url, + status: 'unknown' as MediaAuditStatus, + labelText: '', + statusText: '', + })); + } + + const auditList = props.data?.mediaAuditList; + return pics.map((url) => { + const info = getMediaAuditInfoForImage(url, auditList); + return { + url, + status: info.status, + labelText: info.labelText, + statusText: info.statusText, + }; + }); +}); + +const mediaAuditIssueTip = computed(() => { + if (!props.reportFlag || !picList.value.length) return ''; + + const issues = displayPicList.value.filter( + (item) => item.status === 'fail' || item.status === 'pending', + ); + if (!issues.length) return ''; + + const failItems = issues.filter((item) => item.status === 'fail'); + const pendingItems = issues.filter((item) => item.status === 'pending'); + + const parts: string[] = []; + if (failItems.length) { + const labels = [...new Set(failItems.map((item) => item.labelText).filter(Boolean))]; + const labelPart = labels.length ? `(${labels.map((t) => `涉${t}`).join('、')})` : ''; + parts.push(`${failItems.length} 张图片未通过内容审核${labelPart}`); + } + if (pendingItems.length) { + const labels = [...new Set(pendingItems.map((item) => item.labelText).filter(Boolean))]; + const labelPart = labels.length ? `(${labels.join('、')})` : ''; + parts.push(`${pendingItems.length} 张图片待人工复核${labelPart}`); + } + + return parts.join(';'); +}); + +const previewDisplayPicList = (index: number) => { + const previewable = displayPicList.value.filter( + (item) => !props.reportFlag || item.status === 'pass' || item.status === 'unknown', + ); + if (!previewable.length) return; + + const current = displayPicList.value[index]; + if (!current || (props.reportFlag && current.status !== 'pass' && current.status !== 'unknown')) return; + + uni.previewImage({ + urls: previewable.map((item) => item.url), + current: current.url, + }); +}; + const showCropper = ref(false); const cropperImageSrc = ref(''); @@ -841,21 +932,24 @@ const uploadImage = async (filePath: string) => { emit('submit', { submitAnswerPic: submitAnswerPic.value }); uni.hideLoading(); - // 上传成功后调用图片合规检查 - const bizId = props.data?.id || props.data?.itemId || ''; - if (bizId && newUrl) { - checkImage({ - bizId, - bizType: 1, - imageUrl: newUrl, - scene: 1, - recordId: props.recordId, - }).catch(() => { - // 合规检查失败不影响上传流程,静默处理 - }); + // 上传成功后提交图片内容安全检测 + const titleId = getQuestionTitleId(props.data); + const recordId = props.recordId || ''; + if (titleId && recordId && newUrl) { + getWxOpenid() + .then((openid) => checkImage({ + openid, + recordId, + titleId, + imageUrl: newUrl, + })) + .catch((err) => { + console.warn('[Question] checkImage failed', err); + }); } } catch (err: any) { uni.hideLoading(); + if (Number(err?.code) === 401) return; uni.showToast({ title: err?.message || '上传失败', icon: 'none' }); } }; @@ -866,10 +960,6 @@ const deletePic = (url: string) => { emit('submit', { submitAnswerPic: submitAnswerPic.value }); }; -const previewPicList = (index: number) => { - uni.previewImage({ urls: picList.value, current: index }); -}; - const previewAnalyzeImage = (url: string) => { uni.previewImage({ urls: props.data.titleAnalyzePicture.split(','), @@ -1779,6 +1869,57 @@ onUnmounted(() => { } } +.pic-audit-pending, +.pic-audit-fail { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + + text { + font-size: 18rpx; + line-height: 1.3; + text-align: center; + padding: 0 6rpx; + } +} + +.pic-audit-pending { + background: #f1f5f9; + + text { + color: #64748b; + } +} + +.pic-audit-fail { + background: #fef2f2; + + text { + color: #b91c1c; + } +} + +.audit-tip { + margin-top: 6rpx; + padding: 6rpx 9rpx; + border-radius: 10rpx; + + text { + font-size: 15rpx; + line-height: 1.5; + } + + &.fail { + background: #fef2f2; + + text { + color: #b91c1c; + } + } +} + .add-btn { width: 90rpx; height: 90rpx; diff --git a/src/pages/doWork/index.vue b/src/pages/doWork/index.vue index b6e46e8..99e84a3 100644 --- a/src/pages/doWork/index.vue +++ b/src/pages/doWork/index.vue @@ -108,6 +108,9 @@ import { submitExam, getPaperRecordDetail, } from '@/api/homework'; +import { listMediaByRecordId } from '@/api/upload'; +import { attachMediaAuditToPaperList } from '@/utils/mediaAudit'; +import { getWxOpenid } from '@/utils/wxOpenid'; import { resource_type_homework, normalizeResourceType, @@ -176,7 +179,8 @@ const getSubmitAnswerPic = (item: any) => { return extractPicLinksFromAnswer(item?.submitAnswer); }; -const getQuestionKey = (item: any) => item?.id || item?.itemId || item?.jobStuTittleId || ''; +// 与 Web saveRecordAnswer 一致:接口 itemId 应传答卷记录题目 ID,而非题库题目 id +const getQuestionKey = (item: any) => item?.itemId || item?.jobStuTittleId || item?.id || ''; const normalizeCurrentMultiAnswer = () => { const cur = paperList.value[activeIdx.value]; @@ -400,7 +404,7 @@ const saveAnswer = async () => { answer: cur.submitAnswer || '', answerPic: cur.submitAnswerPic || '', costTime: (paperInfo.value.costTime || 0) * 1000, - itemId: getQuestionKey(cur), + itemId: cur.itemId, recordId: params.value.recordId, }); } catch (err) { @@ -476,18 +480,28 @@ const clearTimer = () => { }; const initReport = async () => { + const recordId = params.value.recordId || params.value.id; const res = await getPaperRecordDetail({ - recordId: params.value.recordId || params.value.id, + recordId, reportFlag: 1, }); paperInfo.value = res.data; paperList.value = normalizePaperList(res.data?.titleVoList || res.data?.subjectTitleVoList); + + try { + const auditRes = await listMediaByRecordId(recordId); + const auditList = Array.isArray(auditRes.data) ? auditRes.data : []; + attachMediaAuditToPaperList(paperList.value, auditList); + } catch (err) { + console.warn('[doWork] media audit query failed', err); + } }; const initNewTrain = async () => { if (resourceType.value !== resource_type_homework) { throw new Error('暂不支持该练习类型'); } + await getWxOpenid(); const res: any = await startExam(params.value.recordId); paperInfo.value = res.data || res; if (paperInfo.value.costTime) { @@ -537,6 +551,8 @@ const init = async () => { } } catch (err: any) { console.error('[doWork] init err', err); + // 401 已由 request 拦截器统一跳转登录,避免 navigateBack 覆盖 reLaunch + if (Number(err?.code) === 401) return; uni.showToast({ title: err?.message || '加载失败', icon: 'none' }); setTimeout(() => uni.navigateBack(), 1500); } finally { diff --git a/src/pages/homework/index.vue b/src/pages/homework/index.vue index 080e16c..ff6133b 100644 --- a/src/pages/homework/index.vue +++ b/src/pages/homework/index.vue @@ -151,6 +151,7 @@ import Loading from '@/components/Loading/index.vue'; import { getUnfinishedHomework, type PaperRecord } from '@/api/homework'; import { resource_type_homework } from '@/constants/doWork'; import { formatTime } from '@/utils/format'; +import { getWxOpenid } from '@/utils/wxOpenid'; interface SubjectItem { id: string; @@ -159,6 +160,7 @@ interface SubjectItem { } const pageLoading = ref(false); +const startingPaper = ref(false); const subjectList = ref([]); const subjectPaperMap = ref(new Map()); const activeSubjectId = ref(''); @@ -219,10 +221,21 @@ const onSelectSubject = (item: SubjectItem) => { activeSubjectId.value = item.id; }; -const onStartPaper = (paper: PaperRecord) => { - uni.navigateTo({ - url: `/pages/doWork/index?resourceType=${resource_type_homework}&recordId=${paper.id}&reportFlag=0&subjectId=${activeSubjectId.value}`, - }); +const onStartPaper = async (paper: PaperRecord) => { + if (startingPaper.value) return; + startingPaper.value = true; + uni.showLoading({ title: '准备中...', mask: true }); + try { + await getWxOpenid(); + uni.navigateTo({ + url: `/pages/doWork/index?resourceType=${resource_type_homework}&recordId=${paper.id}&reportFlag=0&subjectId=${activeSubjectId.value}`, + }); + } catch (err: any) { + uni.showToast({ title: err?.message || '获取微信授权失败', icon: 'none' }); + } finally { + uni.hideLoading(); + startingPaper.value = false; + } }; const initData = async () => { diff --git a/src/pages/login/index.vue b/src/pages/login/index.vue index 81a7b12..3b6c2ae 100644 --- a/src/pages/login/index.vue +++ b/src/pages/login/index.vue @@ -184,6 +184,7 @@ import { parentMpLogin, sendParentLoginCode } from '@/api/login'; import { useUserStore } from '@/state/modules/user'; import { useTeacherStore } from '@/state/modules/teacher'; import { setCache, getCache } from '@/utils/cache'; +import { cacheWxOpenid } from '@/utils/wxOpenid'; import { redirectToRoleHomeIfNeeded } from '@/utils/roleHome'; const OSS_URL = 'https://xxl-1313840333.cos.ap-guangzhou.myqcloud.com'; @@ -368,6 +369,7 @@ const onParentAuthorize = async () => { const res = await parentMpLogin(code); const data = res?.data || {}; const accessToken = data.accessToken; + cacheWxOpenid(data.wxUser?.openid); if (accessToken) { await userStore.parentMpLoginSuccess(accessToken); @@ -385,6 +387,7 @@ const onParentAuthorize = async () => { return; } parentWxOpenid.value = openid; + cacheWxOpenid(openid); parentStage.value = 'bind'; if (data.phone) { phone.value = data.phone; diff --git a/src/router/Routeinterception.ts b/src/router/Routeinterception.ts index d7e2486..dfd516b 100644 --- a/src/router/Routeinterception.ts +++ b/src/router/Routeinterception.ts @@ -48,7 +48,8 @@ export function userRouternext(router: Router) { router.afterEach((to) => { const userStore = useUserStore(); - if (userStore.isLogin && to?.name === 'login') { + // 401 清缓存后 Pinia 可能仍短暂为已登录,需同时校验本地 token + if (userStore.isLogin && getCache('token') && to?.name === 'login') { const role = getStoredRole(); if (!role) return; const homeName = role === 'teacher' ? 'teacher-home' : role === 'parent' ? 'parent-home' : 'home'; diff --git a/src/utils/mediaAudit.ts b/src/utils/mediaAudit.ts new file mode 100644 index 0000000..accb5f6 --- /dev/null +++ b/src/utils/mediaAudit.ts @@ -0,0 +1,239 @@ +import type { MediaAuditRecord } from '@/api/upload'; + +export type MediaAuditStatus = 'pass' | 'fail' | 'pending' | 'unknown'; + +/** 微信内容安全 label 枚举 */ +export const MEDIA_AUDIT_LABEL_TEXT: Record = { + 100: '正常', + 20001: '时政', + 20002: '色情', + 20006: '违法犯罪', + 21000: '其他', +}; + +export interface MediaAuditDisplayInfo { + status: MediaAuditStatus; + label: number; + labelText: string; + statusText: string; +} + +export const getMediaAuditLabelText = (label?: number | string | null) => { + const num = Number(label); + if (!Number.isFinite(num) || num === 100) return ''; + return MEDIA_AUDIT_LABEL_TEXT[num] || ''; +}; + +export const getMediaAuditStatusText = ( + status: MediaAuditStatus, + label?: number | string | null, +) => { + const labelText = getMediaAuditLabelText(label); + if (status === 'pass' || status === 'unknown') return ''; + + if (status === 'pending') { + return labelText ? `${labelText}·待复核` : '待复核'; + } + + if (status === 'fail') { + return labelText ? `涉${labelText}内容` : '未通过审核'; + } + + return ''; +}; + +const normalizeImageUrl = (url?: string) => + String(url || '') + .replace(/[`]/g, '') + .replace(/http:\/\//gi, 'https://') + .trim(); + +/** 取 URL 文件名作为弱匹配键,避免域名或协议差异导致匹配失败 */ +const getImageUrlFileKey = (url?: string) => { + const normalized = normalizeImageUrl(url); + if (!normalized) return ''; + const withoutQuery = normalized.split('?')[0].split('#')[0]; + const segments = withoutQuery.split('/').filter(Boolean); + return segments[segments.length - 1] || withoutQuery; +}; + +export const getMediaAuditImageUrl = (item: MediaAuditRecord) => + normalizeImageUrl(item.imageUrl || item.mediaUrl || item.url); + +/** 题目侧可用于与审核记录 titleId / bizId 对齐的 ID */ +export const getQuestionMatchKeys = (item: any) => + [ + item?.jobStuTittleId, + item?.id, + item?.titleId, + item?.questionId, + item?.titleInfoId, + item?.itemId, + ] + .filter(Boolean) + .map(String); + +/** 提交图片审核时使用的题目 ID,优先与接口 titleId 字段一致 */ +export const getQuestionTitleId = (item: any) => { + const keys = getQuestionMatchKeys(item); + return keys[0] || keys[1] || ''; +}; + +/** 审核记录侧可用于分组的 ID */ +export const getAuditRecordMatchKeys = (item: MediaAuditRecord) => + [item.titleId, item.bizId].filter(Boolean).map(String); + +export const normalizeMediaAuditStatus = (item: MediaAuditRecord): MediaAuditStatus => { + const suggest = String(item.suggest || '').toLowerCase(); + if (suggest === 'pass') return 'pass'; + if (suggest === 'risky') return 'fail'; + if (suggest === 'review') return 'pending'; + + if (item.pass === true || item.auditPass === true) return 'pass'; + if (item.pass === false || item.auditPass === false) return 'fail'; + + const rawStatus = item.status ?? item.auditStatus ?? item.checkStatus ?? item.result; + const statusText = String(rawStatus ?? '').toLowerCase(); + + if (rawStatus === true || statusText === '1' || statusText === 'pass') return 'pass'; + if (rawStatus === false || statusText === '2' || statusText === 'fail' || statusText === 'risky') { + return 'fail'; + } + if ( + rawStatus === 0 + || statusText === '0' + || statusText === 'pending' + || statusText === 'review' + ) { + return 'pending'; + } + + if (item.label != null && item.label !== 100) { + if (suggest === 'pass') return 'pass'; + if (suggest === 'review') return 'pending'; + if (suggest === 'risky') return 'fail'; + } + + const text = String(item.auditResult || item.resultMsg || item.message || '').toLowerCase(); + if (/pass|success|合规|通过/.test(text)) return 'pass'; + if (/fail|reject|risky|违规|不合规|不通过/.test(text)) return 'fail'; + if (/pending|review|审核中|复核|处理中/.test(text)) return 'pending'; + + return 'unknown'; +}; + +const dedupeAuditRecords = (records: MediaAuditRecord[]) => { + const seen = new Set(); + return records.filter((item) => { + const key = [ + String(item.id || ''), + String(item.titleId || item.bizId || ''), + getMediaAuditImageUrl(item), + String(item.status ?? item.auditStatus ?? item.suggest ?? ''), + ].join('|'); + if (seen.has(key)) return false; + seen.add(key); + return Boolean(getMediaAuditImageUrl(item) || item.titleId || item.bizId); + }); +}; + +export const groupMediaAuditByTitleId = (records: MediaAuditRecord[]) => { + const grouped = new Map(); + records.forEach((item) => { + getAuditRecordMatchKeys(item).forEach((key) => { + const list = grouped.get(key) || []; + list.push(item); + grouped.set(key, list); + }); + }); + return grouped; +}; + +const groupMediaAuditByImageFile = (records: MediaAuditRecord[]) => { + const grouped = new Map(); + records.forEach((item) => { + const fileKey = getImageUrlFileKey(getMediaAuditImageUrl(item)); + if (!fileKey) return; + const list = grouped.get(fileKey) || []; + list.push(item); + grouped.set(fileKey, list); + }); + return grouped; +}; + +const getQuestionImageUrls = (item: any) => + String(item?.submitAnswerPic || '') + .split(',') + .map((url) => normalizeImageUrl(url)) + .filter(Boolean); + +/** + * 将 recordId 下全部审核记录,按题目 ID 与图片 URL 分发到各题 + */ +export const attachMediaAuditToPaperList = ( + paperList: any[], + auditRecords: MediaAuditRecord[] | null | undefined, +) => { + if (!Array.isArray(paperList) || !Array.isArray(auditRecords)) return; + + const groupedByTitle = groupMediaAuditByTitleId(auditRecords); + const groupedByFile = groupMediaAuditByImageFile(auditRecords); + + paperList.forEach((item) => { + const matched: MediaAuditRecord[] = []; + + getQuestionMatchKeys(item).forEach((key) => { + matched.push(...(groupedByTitle.get(key) || [])); + }); + + getQuestionImageUrls(item).forEach((url) => { + const fileKey = getImageUrlFileKey(url); + if (fileKey) { + matched.push(...(groupedByFile.get(fileKey) || [])); + } + }); + + item.mediaAuditList = dedupeAuditRecords(matched); + }); +}; + +export const findMediaAuditForImage = ( + imageUrl: string, + auditList: MediaAuditRecord[] | undefined, +) => { + if (!auditList?.length) return undefined; + + const target = normalizeImageUrl(imageUrl); + const exact = auditList.find((item) => getMediaAuditImageUrl(item) === target); + if (exact) return exact; + + const fileKey = getImageUrlFileKey(target); + if (!fileKey) return undefined; + + return auditList.find((item) => getImageUrlFileKey(getMediaAuditImageUrl(item)) === fileKey); +}; + +export const getMediaAuditStatusForImage = ( + imageUrl: string, + auditList: MediaAuditRecord[] | undefined, +): MediaAuditStatus => { + const matched = findMediaAuditForImage(imageUrl, auditList); + return matched ? normalizeMediaAuditStatus(matched) : 'unknown'; +}; + +export const getMediaAuditInfoForImage = ( + imageUrl: string, + auditList: MediaAuditRecord[] | undefined, +): MediaAuditDisplayInfo => { + const matched = findMediaAuditForImage(imageUrl, auditList); + const status = matched ? normalizeMediaAuditStatus(matched) : 'unknown'; + const label = Number(matched?.label ?? 100); + const labelText = getMediaAuditLabelText(label); + + return { + status, + label, + labelText, + statusText: getMediaAuditStatusText(status, label), + }; +}; diff --git a/src/utils/request.ts b/src/utils/request.ts index 63c6fba..f897e1b 100644 --- a/src/utils/request.ts +++ b/src/utils/request.ts @@ -1,5 +1,6 @@ import Request from 'luch-request'; -import { getCache, removeCache } from '@/utils/cache'; +import { getCache } from '@/utils/cache'; +import { clearSession } from '@/utils/session'; /** * 灵犀学 AI 通用 HTTP 客户端(基于 luch-request) @@ -43,18 +44,47 @@ const http = new Request({ }, }); -const handleUnauthorized = (message = '登录已失效,请重新登录') => { +const LOGIN_URL = '/pages/login/index'; + +const goLoginPage = () => { + uni.reLaunch({ + url: LOGIN_URL, + fail: () => { + uni.redirectTo({ url: LOGIN_URL }); + }, + complete: () => { + isReloginHandling = false; + }, + }); +}; + +export const handleUnauthorized = (message = '登录已失效,请重新登录') => { if (isReloginHandling) return; isReloginHandling = true; - removeCache('token'); - removeCache('userInfo'); - removeCache('teacherInfo'); - removeCache('role'); - uni.showToast({ title: message, icon: 'none', duration: 2500 }); - // 避免并发 401 触发多次跳转与多次弹窗 + + clearSession(); + + // 小程序中 showToast 期间 navigate 可能被拦截,先跳转再提示 + goLoginPage(); setTimeout(() => { - uni.reLaunch({ url: '/pages/login/index' }); - }, 200); + uni.showToast({ title: message, icon: 'none', duration: 2500 }); + }, 100); +}; + +const isUnauthorizedCode = (code: unknown) => Number(code) === 401; + +const normalizeResponseData = (raw: unknown): BackendResp | null => { + if (!raw) return null; + if (typeof raw === 'object') return raw as BackendResp; + if (typeof raw === 'string') { + try { + const parsed = JSON.parse(raw); + return parsed && typeof parsed === 'object' ? (parsed as BackendResp) : null; + } catch { + return null; + } + } + return null; }; // ----------- 请求拦截 ----------- @@ -79,8 +109,8 @@ http.interceptors.request.use( // ----------- 响应拦截 ----------- http.interceptors.response.use( (response: any) => { - const data = response.data as BackendResp; - if (!data || typeof data !== 'object') { + const data = normalizeResponseData(response.data); + if (!data) { return response; } @@ -89,7 +119,7 @@ http.interceptors.response.use( return data; } - if (data.code === 401) { + if (isUnauthorizedCode(data.code)) { const msg401 = data.message || data.msg || '登录已失效,请重新登录'; handleUnauthorized(msg401); return Promise.reject(new HttpError(msg401, 401, data)); @@ -103,19 +133,21 @@ http.interceptors.response.use( return Promise.reject(new HttpError(msg, data.code, data)); }, (err: any) => { + const body = normalizeResponseData(err?.data); const status = err?.statusCode || err?.status; - let msg = err?.errMsg || err?.message || '网络异常'; + let msg = body?.message || body?.msg || err?.errMsg || err?.message || '网络异常'; - if (status === 401) { - msg = '登录已失效,请重新登录'; + if (isUnauthorizedCode(status) || isUnauthorizedCode(body?.code)) { + msg = body?.message || body?.msg || '登录已失效,请重新登录'; handleUnauthorized(msg); - } else if (status === 500) { + return Promise.reject(new HttpError(msg, 401, body || err)); + } + + if (status === 500) { msg = '服务异常,请稍后重试'; } - if (status !== 401) { - uni.showToast({ title: msg, icon: 'none', duration: 2500 }); - } + uni.showToast({ title: msg, icon: 'none', duration: 2500 }); return Promise.reject(new HttpError(msg, status || -1, err)); }, ); diff --git a/src/utils/session.ts b/src/utils/session.ts new file mode 100644 index 0000000..0bfe4ea --- /dev/null +++ b/src/utils/session.ts @@ -0,0 +1,10 @@ +/** 401 等场景统一清空登录态(由 main.ts 注册 Pinia 同步逻辑) */ +let clearSessionHandler: (() => void) | null = null; + +export const registerClearSessionHandler = (handler: () => void) => { + clearSessionHandler = handler; +}; + +export const clearSession = () => { + clearSessionHandler?.(); +}; diff --git a/src/utils/wxOpenid.ts b/src/utils/wxOpenid.ts new file mode 100644 index 0000000..835f5f0 --- /dev/null +++ b/src/utils/wxOpenid.ts @@ -0,0 +1,58 @@ +import { parentMpLogin } from '@/api/login'; +import { getCache, setCache } from '@/utils/cache'; + +let openidPromise: Promise | null = null; + +const readCachedOpenid = () => { + const cachedOpenid = getCache('wxOpenid'); + if (cachedOpenid) return String(cachedOpenid); + + const userInfo = getCache('userInfo'); + if (userInfo && typeof userInfo === 'object') { + const openid = userInfo.openid || userInfo.openId || userInfo.wxOpenid; + if (openid) return String(openid); + } + + return ''; +}; + +/** 获取当前微信 openid,优先读缓存,否则通过 uni.login + mpLogin 换取并持久化 */ +export const getWxOpenid = async (): Promise => { + const cached = readCachedOpenid(); + if (cached) return cached; + + if (!openidPromise) { + openidPromise = (async () => { + const loginRes = await new Promise((resolve, reject) => { + uni.login({ + provider: 'weixin', + success: resolve, + fail: reject, + }); + }); + + const code = loginRes?.code || ''; + if (!code) { + throw new Error('获取微信 code 失败'); + } + + const res = await parentMpLogin(code); + const openid = res?.data?.wxUser?.openid || ''; + if (!openid) { + throw new Error('获取微信 openid 失败'); + } + + setCache('wxOpenid', openid); + return openid; + })().finally(() => { + openidPromise = null; + }); + } + + return openidPromise; +}; + +export const cacheWxOpenid = (openid?: string) => { + if (!openid) return; + setCache('wxOpenid', openid); +};