feat: 对接微信安全检测

This commit is contained in:
阿梦 2026-07-05 16:15:33 +09:00
parent 80ac4e815e
commit f6bd6b4684
11 changed files with 625 additions and 59 deletions

View File

@ -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<MediaAuditRecord[]>({
url: '/applet/media/listByRecordId',
method: 'GET',
params: { recordId },
silent: true,
});
/**
* +
*/
export const getMediaByRecordAndTitle = (recordId: string, titleId: string) =>
request<MediaAuditRecord | MediaAuditRecord[]>({
url: '/applet/media/getByRecordAndTitle',
method: 'GET',
params: { recordId, titleId },
silent: true,
});

View File

@ -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,

View File

@ -269,19 +269,30 @@
<text>请将答案写在纸上拍照上传最多3张</text>
</view>
<view v-if="picList.length" class="pic-list">
<view v-if="displayPicList.length" class="pic-list">
<view
v-for="(pic, pIdx) in picList"
v-for="(pic, pIdx) in displayPicList"
:key="pIdx"
class="pic-item"
>
<image :src="pic" mode="aspectFill" @click="previewPicList(pIdx)" />
<view v-if="!reportFlag" class="del-btn" @click.stop="deletePic(pic)">
<image
v-if="!reportFlag || pic.status === 'pass' || pic.status === 'unknown'"
:src="pic.url"
mode="aspectFill"
@click="previewDisplayPicList(pIdx)"
/>
<view v-else-if="pic.status === 'pending'" class="pic-audit-pending">
<text>{{ pic.statusText || '待复核' }}</text>
</view>
<view v-else-if="pic.status === 'fail'" class="pic-audit-fail">
<text>{{ pic.statusText || '未通过审核' }}</text>
</view>
<view v-if="!reportFlag" class="del-btn" @click.stop="deletePic(pic.url)">
<image class="del-icon" src="/static/image/svg/del.svg" mode="aspectFit" />
</view>
</view>
<view
v-if="!reportFlag && picList.length < 3"
v-if="!reportFlag && displayPicList.length < 3"
class="add-btn"
@click="chooseImage"
>
@ -289,7 +300,10 @@
<text>添加</text>
</view>
</view>
<view v-else-if="!reportFlag" class="upload-btn" @click="chooseImage">
<view v-if="reportFlag && mediaAuditIssueTip" class="audit-tip fail">
<text>{{ mediaAuditIssueTip }}</text>
</view>
<view v-else-if="!reportFlag && !displayPicList.length" class="upload-btn" @click="chooseImage">
<view class="upload-camera">
<svg viewBox="0 0 24 24" fill="none">
<path d="M3 8a2 2 0 0 1 2-2h2l2-2h6l2 2h2a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8z"
@ -299,7 +313,7 @@
</view>
<text>拍照上传答案</text>
</view>
<view v-else class="empty-tip">
<view v-else-if="reportFlag && !picList.length" class="empty-tip">
<text>未作答</text>
</view>
</view>
@ -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;

View File

@ -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 {

View File

@ -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<SubjectItem[]>([]);
const subjectPaperMap = ref(new Map<string, PaperRecord[]>());
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 () => {

View File

@ -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;

View File

@ -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';

239
src/utils/mediaAudit.ts Normal file
View File

@ -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<number, string> = {
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<string>();
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<string, MediaAuditRecord[]>();
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<string, MediaAuditRecord[]>();
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),
};
};

View File

@ -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));
},
);

10
src/utils/session.ts Normal file
View File

@ -0,0 +1,10 @@
/** 401 等场景统一清空登录态(由 main.ts 注册 Pinia 同步逻辑) */
let clearSessionHandler: (() => void) | null = null;
export const registerClearSessionHandler = (handler: () => void) => {
clearSessionHandler = handler;
};
export const clearSession = () => {
clearSessionHandler?.();
};

58
src/utils/wxOpenid.ts Normal file
View File

@ -0,0 +1,58 @@
import { parentMpLogin } from '@/api/login';
import { getCache, setCache } from '@/utils/cache';
let openidPromise: Promise<string> | 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<string> => {
const cached = readCachedOpenid();
if (cached) return cached;
if (!openidPromise) {
openidPromise = (async () => {
const loginRes = await new Promise<UniApp.LoginRes>((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);
};