feat: 对接微信安全检测
This commit is contained in:
parent
80ac4e815e
commit
f6bd6b4684
@ -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,
|
||||
});
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
239
src/utils/mediaAudit.ts
Normal 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),
|
||||
};
|
||||
};
|
||||
@ -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
10
src/utils/session.ts
Normal 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
58
src/utils/wxOpenid.ts
Normal 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);
|
||||
};
|
||||
Loading…
x
Reference in New Issue
Block a user