Compare commits

...

10 Commits

Author SHA1 Message Date
4ae11e31f4 地图优化 2025-12-19 16:45:21 +08:00
bca0d997c6 style 2025-12-19 16:33:15 +08:00
Apcallover
b43eb98a1c flat: ok 2025-12-19 16:13:09 +08:00
Apcallover
44c297aac2 flat: ok 2025-12-19 16:10:39 +08:00
f64c9e5dae bug fix 2025-12-19 16:05:16 +08:00
975835baa5 修改上传html 加入文件校验 2025-12-19 15:48:24 +08:00
1ac524e1f1 fix 2025-12-19 14:47:08 +08:00
7e8bef0cb9 feat : 一体机定位直到成功,其他环境循环2分钟定位, 上传加入文件校验 2025-12-19 14:46:37 +08:00
Apcallover
ce597b182d flat 文件校验 2025-12-19 12:06:35 +08:00
Apcallover
fdd5577c85 flat: 合并代码 2025-12-19 11:35:00 +08:00
14 changed files with 1797 additions and 1598 deletions

16
App.vue
View File

@@ -24,6 +24,7 @@ onLaunch((options) => {
getUserInfo();
useUserStore().changMiniProgramAppStatus(false);
useUserStore().changMachineEnv(false);
useLocationStore().getLocationLoop()//循环获取定位
return;
}
if (isY9MachineType()) {
@@ -32,7 +33,18 @@ onLaunch((options) => {
useUserStore().logOutApp();
useUserStore().changMiniProgramAppStatus(true);
useUserStore().changMachineEnv(true);
useLocationStore().getLocation();
(function loop() {
console.log('📍一体机尝试获取定位')
useLocationStore().getLocation().then(({longitude,latitude})=>{
console.log(`✅一体机获取定位成功:lng:${longitude},lat${latitude}`)
})
.catch(err=>{
console.log('❌一体机获取定位失败,30s后尝试重新获取')
setTimeout(() => {
loop()
}, 3000);
})
})()
uQRListen = new IncreaseRevie();
inactivityManager = new GlobalInactivityManager(handleInactivity, 60 * 1000);
inactivityManager.start();
@@ -40,6 +52,7 @@ onLaunch((options) => {
}
// 正式上线去除此方法
console.warn('浏览器环境');
useLocationStore().getLocationLoop()//循环获取定位
useUserStore().changMiniProgramAppStatus(true);
useUserStore().changMachineEnv(false);
useUserStore().initSeesionId(); //更新
@@ -57,6 +70,7 @@ onLaunch((options) => {
onMounted(() => {});
onShow(() => {
console.log('App Show');
});

View File

@@ -630,6 +630,10 @@ export function sm4Encrypt(key, value, mode = "hex") {
}
}
export function reloadBrowser() {
window.location.reload()
}
export const $api = {
msg,
@@ -679,5 +683,6 @@ export default {
aes_Decrypt,
sm2_Decrypt,
sm2_Encrypt,
safeReLaunch
safeReLaunch,
reloadBrowser
}

View File

@@ -1,158 +0,0 @@
import {
ref,
onUnmounted,
readonly
} from 'vue';
const defaultExtractSpeechText = (text) => text;
export function useTTSPlayer() {
const synth = window.speechSynthesis;
const isSpeaking = ref(false);
const isPaused = ref(false);
const utteranceRef = ref(null);
const cleanup = () => {
isSpeaking.value = false;
isPaused.value = false;
utteranceRef.value = null;
};
/**
* @param {string} text - The text to be spoken.
* @param {object} [options] - Optional settings for the speech.
* @param {string} [options.lang] - Language (e.g., 'en-US', 'es-ES').
* @param {number} [options.rate] - Speed (0.1 to 10, default 1).
* @param {number} [options.pitch] - Pitch (0 to 2, default 1).
* @param {SpeechSynthesisVoice} [options.voice] - A specific voice object.
* @param {function(string): string} [options.extractSpeechText] - A function to filter/clean the text before speaking.
*/
const speak = (text, options = {}) => {
if (!synth) {
console.error('SpeechSynthesis API is not supported in this browser.');
return;
}
if (isSpeaking.value) {
synth.cancel();
}
const filteredText = extractSpeechText(text);
if (!filteredText || typeof filteredText !== 'string' || filteredText.trim() === '') {
console.warn('Text to speak is empty after filtering.');
cleanup(); // Ensure state is clean
return;
}
const newUtterance = new SpeechSynthesisUtterance(filteredText); // Use filtered text
utteranceRef.value = newUtterance;
newUtterance.lang = 'zh-CN';
newUtterance.rate = options.rate || 1;
newUtterance.pitch = options.pitch || 1;
if (options.voice) {
newUtterance.voice = options.voice;
}
newUtterance.onstart = () => {
isSpeaking.value = true;
isPaused.value = false;
};
newUtterance.onpause = () => {
isPaused.value = true;
};
newUtterance.onresume = () => {
isPaused.value = false;
};
newUtterance.onend = () => {
cleanup();
};
newUtterance.onerror = (event) => {
console.error('SpeechSynthesis Error:', event.error);
cleanup();
};
synth.speak(newUtterance);
};
const pause = () => {
if (synth && isSpeaking.value && !isPaused.value) {
synth.pause();
}
};
const resume = () => {
if (synth && isPaused.value) {
synth.resume();
}
};
const cancelAudio = () => {
if (synth) {
synth.cancel();
}
cleanup();
};
onUnmounted(() => {
cancelAudio();
});
return {
speak,
pause,
resume,
cancelAudio,
isSpeaking: readonly(isSpeaking),
isPaused: readonly(isPaused),
};
}
function extractSpeechText(markdown) {
const jobRegex = /``` job-json\s*({[\s\S]*?})\s*```/g;
const jobs = [];
let match;
let lastJobEndIndex = 0;
let firstJobStartIndex = -1;
// 提取岗位 json 数据及前后位置
while ((match = jobRegex.exec(markdown)) !== null) {
const jobStr = match[1];
try {
const job = JSON.parse(jobStr);
jobs.push(job);
if (firstJobStartIndex === -1) {
firstJobStartIndex = match.index;
}
lastJobEndIndex = jobRegex.lastIndex;
} catch (e) {
console.warn('JSON 解析失败', e);
}
}
// 提取引导语(第一个 job-json 之前的文字)
const guideText = firstJobStartIndex > 0 ?
markdown.slice(0, firstJobStartIndex).trim() :
'';
// 提取结束语(最后一个 job-json 之后的文字)
const endingText = lastJobEndIndex < markdown.length ?
markdown.slice(lastJobEndIndex).trim() :
'';
// 岗位信息格式化为语音文本
const jobTexts = jobs.map((job, index) => {
return `${index + 1} 个岗位,岗位名称是:${job.jobTitle},公司是:${job.companyName},薪资:${job.salary},地点:${job.location},学历要求:${job.education},经验要求:${job.experience}`;
});
// 拼接总语音内容
const finalTextParts = [];
if (guideText) finalTextParts.push(guideText);
finalTextParts.push(...jobTexts);
if (endingText) finalTextParts.push(endingText);
return finalTextParts.join('\n');
}

View File

@@ -1,6 +1,6 @@
{
"name" : "qingdao-employment-service",
"appid" : "__UNI__2496162",
"appid" : "__UNI__C939371",
"description" : "招聘",
"versionName" : "1.0.0",
"versionCode" : "100",

View File

@@ -82,6 +82,7 @@ const { userInfo } = storeToRefs(useUserStore());
const { getUserResume } = useUserStore();
const { dictLabel, oneDictData } = useDictStore();
const openSelectPopup = inject('openSelectPopup');
import { FileValidator } from '@/utils/fileValidator.js'; //文件校验
const percent = ref('0%');
const state = reactive({
@@ -278,15 +279,26 @@ function selectAvatar() {
sizeType: ['original', 'compressed'],
sourceType: ['album', 'camera'],
count: 1,
success: ({ tempFilePaths, tempFiles }) => {
$api.uploadFile(tempFilePaths[0], true)
.then((res) => {
res = JSON.parse(res);
if (res.msg) fromValue.avatar = res.msg;
})
.catch((err) => {
$api.msg('上传失败');
});
success: async (res) => {
const tempFilePaths = res.tempFilePaths;
const file = res.tempFiles[0];
const imageValidator = new FileValidator();
try {
await imageValidator.validate(file);
$api.uploadFile(tempFilePaths[0], true)
.then((res) => {
res = JSON.parse(res);
if (res.msg) fromValue.avatar = res.msg;
})
.catch((err) => {
$api.msg('上传失败');
});
} catch (error) {
$api.msg(error);
}
},
fail: (error) => {},
});
@@ -392,4 +404,4 @@ function selectAvatar() {
color: #FFFFFF;
text-align: center;
line-height: 90rpx
</style>
</style>

View File

@@ -273,9 +273,7 @@ import useScreenStore from '@/stores/useScreenStore'
const screenStore = useScreenStore();
// 系统功能hook和阿里云hook
import { useAudioRecorder } from '@/hook/useRealtimeRecorder.js';
// import { useAudioRecorder } from '@/hook/useSystemSpeechReader.js';
import { useTTSPlayer } from '@/hook/useTTSPlayer.js';
// import { useTTSPlayer } from '@/hook/useSystemPlayer.js';
// 全局
const { $api, navTo, throttle } = inject('globalFunction');
const emit = defineEmits(['onConfirm']);
@@ -283,6 +281,8 @@ const { messages, isTyping, textInput, chatSessionID } = storeToRefs(useChatGrou
import successIcon from '@/static/icon/success.png';
import useUserStore from '@/stores/useUserStore';
const { isMachineEnv } = storeToRefs(useUserStore());
import { FileValidator } from '@/utils/fileValidator.js'; //文件校验
// hook
// 语音识别
const {
@@ -538,22 +538,29 @@ function uploadCamera(type = 'camera') {
count: 1, //默认9
sizeType: ['original', 'compressed'], //可以指定是原图还是压缩图,默认二者都有
sourceType: [type], //从相册选择
success: function (res) {
success: async (res)=> {
const tempFilePaths = res.tempFilePaths;
const file = res.tempFiles[0];
// 继续上传
$api.uploadFile(tempFilePaths[0], true).then((resData) => {
resData = JSON.parse(resData);
console.log(file.type,'++')
if (isImage(file.type)) {
filesList.value.push({
url: resData.msg,
type: file.type,
name: file.name,
});
textInput.value = state.uploadFileTips;
}
});
const imageValidator = new FileValidator()
try {
await imageValidator.validate(file)
$api.uploadFile(tempFilePaths[0], true).then((resData) => {
resData = JSON.parse(resData);
console.log(file.type,'++')
if (isImage(file.type)) {
filesList.value.push({
url: resData.msg,
type: file.type,
name: file.name,
});
textInput.value = state.uploadFileTips;
}
});
} catch (error) {
$api.msg(error)
}
},
});
}
@@ -562,25 +569,30 @@ function getUploadFile(type = 'camera') {
if (VerifyNumberFiles()) return;
uni.chooseFile({
count: 1,
success: (res) => {
success: async(res) => {
const tempFilePaths = res.tempFilePaths;
const file = res.tempFiles[0];
const allowedTypes = config.allowedFileTypes || [];
const size = $api.formatFileSize(file.size);
if (!allowedTypes.includes(file.type)) {
return $api.msg('仅支持 txt md word pdf ppt csv excel 格式类型');
}
// 继续上传
$api.uploadFile(tempFilePaths[0], true).then((resData) => {
resData = JSON.parse(resData);
filesList.value.push({
url: resData.msg,
type: file.type,
name: file.name,
size: size,
const imageValidator = new FileValidator({allowedExtensions:config.allowedFileTypes})
try{
await imageValidator.validate(file)
$api.uploadFile(tempFilePaths[0], true).then((resData) => {
resData = JSON.parse(resData);
filesList.value.push({
url: resData.msg,
type: file.type,
name: file.name,
size: size,
});
textInput.value = state.uploadFileTips;
});
textInput.value = state.uploadFileTips;
});
}catch(error){
$api.msg(error)
}
},
});
}

View File

@@ -11,7 +11,12 @@
<image class="bg-text" mode="widthFix" src="@/static/icon/index-text-bg.png"></image>
<view class="search-inner">
<view class="inner-left">
<image class="bg-text2" mode="widthFix" src="@/static/icon/index-text-bg2.png"></image>
<image
class="bg-text2"
mode="widthFix"
@click="reloadBrowser()"
src="@/static/icon/index-text-bg2.png"
></image>
<view class="search-input button-click" @click="navTo('/pages/search/search')">
<image class="icon" src="@/static/icon/index-search.png"></image>
<text class="inpute">请告诉我想找什么工作</text>
@@ -20,7 +25,7 @@
<image class="bg-robot button-click" mode="widthFix" src="@/static/icon/index-robot.png"></image>
</view>
</view>
<view v-if="!isMachineEnv" class="ai-card-out" >
<view v-if="!isMachineEnv" class="ai-card-out">
<view class="ai-card">
<image class="ai-card-bg" src="@/static/icon/ai-card-bg.png" />
<view class="ai-card-inner">
@@ -56,7 +61,7 @@
</view>
</view>
</view>
<view v-if="hasLogin" :class="{'match-move-top':isMachineEnv}" class="match-card-out">
<view v-if="hasLogin" :class="{ 'match-move-top': isMachineEnv }" class="match-card-out">
<view class="match-card">
<image class="match-card-bg" src="@/static/icon/match-card-bg.png" />
<view class="title">简历匹配职位</view>
@@ -65,7 +70,7 @@
</view>
</view>
</view>
<view :class="{'cards-move-top':isMachineEnv && !hasLogin}" class="cards">
<view :class="{ 'cards-move-top': isMachineEnv && !hasLogin }" class="cards">
<view class="card card1 press-button" @click="navTo('/pages/nearby/nearby')">
<view class="card-title">附近工作</view>
<view class="card-text">好岗职等你来</view>
@@ -254,11 +259,11 @@
import { reactive, inject, watch, ref, onMounted, watchEffect, nextTick, getCurrentInstance } from 'vue';
import img from '@/static/icon/filter.png';
import dictLabel from '@/components/dict-Label/dict-Label.vue';
const { $api, navTo, vacanciesTo, formatTotal, throttle } = inject('globalFunction');
const { $api, navTo, vacanciesTo, formatTotal, throttle, reloadBrowser } = inject('globalFunction');
import { onLoad, onShow } from '@dcloudio/uni-app';
import { storeToRefs } from 'pinia';
import useUserStore from '@/stores/useUserStore';
const { userInfo, hasLogin ,isMachineEnv} = storeToRefs(useUserStore());
const { userInfo, hasLogin, isMachineEnv } = storeToRefs(useUserStore());
import useDictStore from '@/stores/useDictStore';
const { getTransformChildren, oneDictData } = useDictStore();
import useLocationStore from '@/stores/useLocationStore';
@@ -271,7 +276,6 @@ const recommedIndexDb = useRecommedIndexedDBStore();
import config from '@/config';
import AIMatch from './AIMatch.vue';
const { proxy } = getCurrentInstance();
const maskFirstEntry = ref(true);
@@ -363,7 +367,7 @@ onMounted(() => {
let firstEntry = uni.getStorageSync('firstEntry') === false ? false : true; // 默认未读
maskFirstEntry.value = firstEntry;
getMatchTags();
console.log(isMachineEnv.value,'+++++++++')
// console.log(isMachineEnv.value, '+++++++++');
});
async function getMatchTags() {
@@ -466,7 +470,6 @@ const { columnCount, columnSpace } = useColumnCount(() => {
getJobRecommend('refresh');
nextTick(() => {
waterfallsFlowRef.value?.refresh?.();
useLocationStore().getLocation();
});
});

View File

@@ -129,7 +129,6 @@ const { columnCount, columnSpace } = useColumnCount(() => {
pageSize.value = 10 * (columnCount.value - 1);
nextTick(() => {
waterfallsFlowRef.value?.refresh?.();
useLocationStore().getLocation();
});
});

View File

@@ -2,13 +2,14 @@
<scroll-view :scroll-y="true" class="nearby-scroll" @scrolltolower="scrollBottom">
<view class="nearby-map" @touchmove.stop.prevent>
<map
style="width: 100%; height: 410rpx"
style="width: 100%; height: 690rpx"
:latitude="latitudeVal"
:longitude="longitudeVal"
:markers="mapCovers"
:circles="mapCircles"
:controls="mapControls"
@controltap="handleControl"
:scale="mapScale"
></map>
<view class="nearby-select">
<view class="select-view" @click="changeRangeShow">
@@ -106,16 +107,18 @@ const tMap = ref();
const progress = ref();
const mapCovers = ref([]);
const mapCircles = ref([]);
const mapScale = ref(14.5)
const mapControls = ref([
{
id: 1,
position: {
// 控件位置
left: customSystem.systemInfo.screenWidth - 48 - 14,
top: 320,
width: 48,
height: 48,
left: customSystem.systemInfo.screenWidth - uni.upx2px(75 + 30),
top: uni.upx2px(655 - 75 - 30),
width: uni.upx2px(75),
height: uni.upx2px(75),
},
width:100,
iconPath: LocationPng, // 控件图标
},
]);
@@ -148,6 +151,8 @@ function changeRangeShow() {
function changeRadius(item) {
console.log(item);
if(item > 1) mapScale.value = 14.5 - item * 0.3
else mapScale.value = 14.5
pageState.search.radius = item;
rangeShow.value = false;
progressChange(item);
@@ -221,27 +226,23 @@ onMounted(() => {
});
function getInit() {
useLocationStore()
.getLocation()
.then((res) => {
mapCovers.value = [
{
latitude: res.latitude,
longitude: res.longitude,
iconPath: point2,
},
];
mapCircles.value = [
{
latitude: res.latitude,
longitude: res.longitude,
radius: 1000,
fillColor: '#1c52fa25',
color: '#256BFA',
},
];
getJobList('refresh');
});
mapCovers.value = [
{
latitude: latitudeVal.value,
longitude: longitudeVal.value,
iconPath: point2,
},
];
mapCircles.value = [
{
latitude: latitudeVal.value,
longitude:longitudeVal.value,
radius: 1000,
fillColor: '#1c52fa25',
color: '#256BFA',
},
];
getJobList('refresh');
}
function progressChange(value) {
@@ -363,7 +364,7 @@ defineExpose({ loadData, handleFilterConfirm });
height: 100%;
background: #f4f4f4;
.nearby-map
height: 400rpx;
height: 655rpx;
background: #e8e8e8;
overflow: hidden
.nearby-list

View File

@@ -151,7 +151,6 @@ const { columnCount, columnSpace } = useColumnCount(() => {
pageSize.value = 10 * (columnCount.value - 1);
nextTick(() => {
waterfallsFlowRef.value?.refresh?.();
useLocationStore().getLocation();
});
});

170
static/js/fileValidator.js Normal file
View File

@@ -0,0 +1,170 @@
const KNOWN_SIGNATURES = {
png: '89504E470D0A1A0A',
jpg: 'FFD8FF',
jpeg: 'FFD8FF',
gif: '47494638',
webp: '52494646',
docx: '504B0304',
xlsx: '504B0304',
pptx: '504B0304',
doc: 'D0CF11E0',
xls: 'D0CF11E0',
ppt: 'D0CF11E0',
pdf: '25504446',
txt: 'TYPE_TEXT',
csv: 'TYPE_TEXT',
md: 'TYPE_TEXT',
json: 'TYPE_TEXT',
};
export class FileValidator {
version = '1.0.0';
signs = Object.keys(KNOWN_SIGNATURES);
constructor(options = {}) {
this.maxSizeMB = options.maxSizeMB || 10;
if (options.allowedExtensions && Array.isArray(options.allowedExtensions)) {
this.allowedConfig = {};
options.allowedExtensions.forEach((ext) => {
const key = ext.toLowerCase();
if (KNOWN_SIGNATURES[key]) {
this.allowedConfig[key] = KNOWN_SIGNATURES[key];
} else {
console.warn(`[FileValidator] 未知的文件类型: .${key},已忽略`);
}
});
} else {
this.allowedConfig = {
...KNOWN_SIGNATURES,
};
}
}
_isValidUTF8(buffer) {
try {
const decoder = new TextDecoder('utf-8', {
fatal: true,
});
decoder.decode(buffer);
return true;
} catch (e) {
return false;
}
}
_bufferToHex(buffer) {
return Array.prototype.map
.call(new Uint8Array(buffer), (x) => ('00' + x.toString(16)).slice(-2))
.join('')
.toUpperCase();
}
_countCSVRows(buffer) {
const decoder = new TextDecoder('utf-8');
const text = decoder.decode(buffer);
let rowCount = 0;
let inQuote = false;
let len = text.length;
for (let i = 0; i < len; i++) {
const char = text[i];
if (char === '"') {
inQuote = !inQuote;
} else if (char === '\n' && !inQuote) {
rowCount++;
}
}
if (len > 0 && text[len - 1] !== '\n') {
rowCount++;
}
return rowCount;
}
_validateTextContent(buffer, extension) {
let contentStr = '';
try {
const decoder = new TextDecoder('utf-8', {
fatal: true,
});
contentStr = decoder.decode(buffer);
} catch (e) {
console.warn('UTF-8 解码失败', e);
return false;
}
if (contentStr.includes('\0')) {
return false;
}
if (extension === 'json') {
try {
JSON.parse(contentStr);
} catch (e) {
console.warn('无效的 JSON 格式');
return false;
}
}
return true;
}
validate(file) {
return new Promise((resolve, reject) => {
if (!file || !file.name) return reject('无效的文件对象');
if (file.size > this.maxSizeMB * 1024 * 1024) {
return reject(`文件大小超出限制 (最大 ${this.maxSizeMB}MB)`);
}
const fileName = file.name.toLowerCase();
const extension = fileName.substring(fileName.lastIndexOf('.') + 1);
const expectedMagic = this.allowedConfig[extension];
if (!expectedMagic) {
return reject(`不支持的文件格式: .${extension}`);
}
const reader = new FileReader();
reader.onload = (e) => {
const buffer = e.target.result;
let isSafe = false;
if (expectedMagic === 'TYPE_TEXT') {
if (this._validateTextContent(buffer, extension)) {
isSafe = true;
} else {
if (extension === 'json') {
return reject(`文件异常:不是有效的 JSON 文件`);
}
return reject(`文件异常:.${extension} 包含非法二进制内容或编码错误`);
}
if (extension === 'csv' && this.csvMaxRows > 0) {
const rows = this._countCSVRows(buffer);
if (rows > this.csvMaxRows) {
return reject(`CSV 行数超出限制 (当前 ${rows} 行,最大允许 ${this.csvMaxRows} 行)`);
}
}
} else {
const fileHeader = this._bufferToHex(buffer.slice(0, 8));
if (fileHeader.startsWith(expectedMagic)) {
isSafe = true;
} else {
return reject(`文件可能已被篡改 (真实类型与 .${extension} 不符)`);
}
}
if (isSafe) resolve(true);
};
reader.onerror = () => reject('文件读取失败,无法校验');
if (expectedMagic === 'TYPE_TEXT' && extension === 'json') {
reader.readAsArrayBuffer(file);
} else {
reader.readAsArrayBuffer(file.slice(0, 2048));
}
});
}
}
// 【demo】
// 如果传入了 allowedExtensions则只使用传入的否则使用全部 KNOWN_SIGNATURES
// const imageValidator = new FileValidator({
// maxSizeMB: 5,
// allowedExtensions: ['png', 'jpg', 'jpeg'],
// });
// imageValidator
// .validate(file)
// .then(() => {
// statusDiv.textContent = `检测通过: ${file.name}`;
// statusDiv.style.color = 'green';
// console.log('图片校验通过,开始上传...');
// // upload(file)...
// })
// .catch((err) => {
// statusDiv.textContent = `检测失败: ${err}`;
// statusDiv.style.color = 'red';
// });

File diff suppressed because it is too large Load Diff

View File

@@ -9,10 +9,17 @@ import {
} from '@/common/globalFunction.js'
import config from '../config';
const defalutLongLat = {
longitude: 120.382665,
latitude: 36.066938,
}
const useLocationStore = defineStore("location", () => {
// 定义状态
const longitudeVal = ref(null) // 经度
const latitudeVal = ref(null) //纬度
const timer = ref(null)
const count = ref(0)
function getLocation() { // 获取经纬度两个平台
return new Promise((resole, reject) => {
@@ -25,49 +32,57 @@ const useLocationStore = defineStore("location", () => {
resole(data)
},
fail: function(data) {
longitudeVal.value = 120.382665
latitudeVal.value = 36.066938
resole({
longitude: 120.382665,
latitude: 36.066938
})
longitudeVal.value = defalutLongLat.longitude
latitudeVal.value = defalutLongLat.latitude
resole(defalutLongLat)
msg('用户位置获取失败')
console.log('失败3', data)
}
})
} else {
uni.getLocation({
type: 'gcj02',
highAccuracyExpireTime: 3000,
isHighAccuracy: true,
timeout: 2000,
// highAccuracyExpireTime: 3000,
// isHighAccuracy: true,
// timeout: 2000,
success: function(data) {
longitudeVal.value = Number(data.longitude)
latitudeVal.value = Number(data.latitude)
resole(data)
},
fail: function(data) {
longitudeVal.value = 120.382665
latitudeVal.value = 36.066938
resole({
longitude: 120.382665,
latitude: 36.066938
})
longitudeVal.value = defalutLongLat.longitude
latitudeVal.value = defalutLongLat.latitude
resole(defalutLongLat)
msg('用户位置获取失败')
console.log('失败2', data)
}
});
}
} catch (e) {
longitudeVal.value = 120.382665
latitudeVal.value = 36.066938
resole({
longitude: 120.382665,
latitude: 36.066938
})
longitudeVal.value = defalutLongLat.longitude
latitudeVal.value = defalutLongLat.latitude
resole(defalutLongLat)
msg('测试环境,使用模拟定位')
console.log('失败', data)
console.log('失败1', e)
}
})
}
function getLocationLoop(gap = 1000 * 60 * 2) {
console.log(`🔄开始循环获取定位,间隔:${Math.floor(gap/1000)}`)
const run = () => {
count.value++
console.log(`📍第${count.value}次获取定位`)
getLocation()
}
run()
timer.value = setInterval(run,gap);
}
function clearGetLocationLoop(params) {
clearInterval(timer.value)
timer.value = null
}
function longitude() {
return longitudeVal.value
@@ -80,11 +95,12 @@ const useLocationStore = defineStore("location", () => {
// 导入
return {
getLocation,
getLocationLoop,
clearGetLocationLoop,
longitudeVal,
latitudeVal
latitudeVal,
}
},{
}, {
unistorage: true,
})

View File

@@ -1,56 +1,26 @@
/**
* FileValidator.js
* 封装好的文件安全校验类
*/
// ==========================================
// 1. 预定义:已知文件类型的魔数 (Signature Database)
// ==========================================
const KNOWN_SIGNATURES = {
// === 图片 ===
png: '89504E470D0A1A0A',
jpg: 'FFD8FF',
jpeg: 'FFD8FF',
gif: '47494638',
webp: '52494646', // RIFF Header
// === 文档 (Office 新版 - ZIP 格式) ===
webp: '52494646',
docx: '504B0304',
xlsx: '504B0304',
pptx: '504B0304',
// === 文档 (Office 旧版 - OLECF 格式) ===
doc: 'D0CF11E0',
xls: 'D0CF11E0',
ppt: 'D0CF11E0',
// === 其他 ===
pdf: '25504446',
// === 纯文本 (无固定魔数,需特殊算法检测) ===
txt: 'TYPE_TEXT',
csv: 'TYPE_TEXT',
md: 'TYPE_TEXT',
json: 'TYPE_TEXT',
};
// ==========================================
// 2. 核心类定义
// ==========================================
export class FileValidator {
/**
* 构造函数
* @param {Object} options 配置项
* @param {number} [options.maxSizeMB=10] 最大文件大小 (MB)
* @param {string[]} [options.allowedExtensions] 允许的扩展名列表 (如 ['jpg', 'png']),默认允许全部已知类型
*/
version = '1.0.0';
signs = Object.keys(KNOWN_SIGNATURES);
constructor(options = {}) {
// 配置大小 (默认 10MB)
this.maxSizeMB = options.maxSizeMB || 10;
// 配置允许的类型
// 如果传入了 allowedExtensions则只使用传入的否则使用全部 KNOWN_SIGNATURES
if (options.allowedExtensions && Array.isArray(options.allowedExtensions)) {
this.allowedConfig = {};
options.allowedExtensions.forEach((ext) => {
@@ -63,103 +33,122 @@ export class FileValidator {
});
} else {
this.allowedConfig = {
...KNOWN_SIGNATURES
...KNOWN_SIGNATURES,
};
}
}
/**
* 辅助ArrayBuffer 转 Hex 字符串
*/
_isValidUTF8(buffer) {
try {
const decoder = new TextDecoder('utf-8', {
fatal: true,
});
decoder.decode(buffer);
return true;
} catch (e) {
return false;
}
}
_bufferToHex(buffer) {
return Array.prototype.map
.call(new Uint8Array(buffer), (x) => ('00' + x.toString(16)).slice(-2))
.join('')
.toUpperCase();
}
/**
* 辅助:纯文本抽样检测
*/
_isCleanText(buffer) {
const bytes = new Uint8Array(buffer);
const checkLen = Math.min(bytes.length, 1000);
let suspiciousCount = 0;
for (let i = 0; i < checkLen; i++) {
const byte = bytes[i];
// 允许常见控制符: 9(Tab), 10(LF), 13(CR)
// 0-31 范围内其他的通常是二进制控制符
if (byte < 32 && ![9, 10, 13].includes(byte)) {
suspiciousCount++;
_countCSVRows(buffer) {
const decoder = new TextDecoder('utf-8');
const text = decoder.decode(buffer);
let rowCount = 0;
let inQuote = false;
let len = text.length;
for (let i = 0; i < len; i++) {
const char = text[i];
if (char === '"') {
inQuote = !inQuote;
} else if (char === '\n' && !inQuote) {
rowCount++;
}
}
// 如果可疑字符占比 < 5%,认为是纯文本
return suspiciousCount / checkLen < 0.05;
if (len > 0 && text[len - 1] !== '\n') {
rowCount++;
}
return rowCount;
}
_validateTextContent(buffer, extension) {
let contentStr = '';
try {
const decoder = new TextDecoder('utf-8', {
fatal: true,
});
contentStr = decoder.decode(buffer);
} catch (e) {
console.warn('UTF-8 解码失败', e);
return false;
}
if (contentStr.includes('\0')) {
return false;
}
if (extension === 'json') {
try {
JSON.parse(contentStr);
} catch (e) {
console.warn('无效的 JSON 格式');
return false;
}
}
return true;
}
/**
* 执行校验
* @param {File} file 文件对象
* @returns {Promise<boolean>}
*/
validate(file) {
return new Promise((resolve, reject) => {
// 1. 基础对象检查
if (!file || !file.name) return reject('无效的文件对象');
// 2. 大小检查
if (file.size > this.maxSizeMB * 1024 * 1024) {
return reject(`文件大小超出限制 (最大 ${this.maxSizeMB}MB)`);
}
// 3. 后缀名检查
const fileName = file.name.toLowerCase();
const extension = fileName.substring(fileName.lastIndexOf('.') + 1);
// 检查是否在配置的白名单中
const expectedMagic = this.allowedConfig[extension];
if (!expectedMagic) {
return reject(`不支持的文件格式: .${extension}`);
}
// 4. 读取二进制头进行魔数校验
const reader = new FileReader();
reader.onload = (e) => {
const buffer = e.target.result;
let isSafe = false;
// 分支处理:纯文本 vs 二进制
if (expectedMagic === 'TYPE_TEXT') {
if (this._isCleanText(buffer)) {
if (this._validateTextContent(buffer, extension)) {
isSafe = true;
} else {
return reject(`文件异常:.${extension} 文件包含非法二进制内容`);
if (extension === 'json') {
return reject(`文件异常:不是有效的 JSON 文件`);
}
return reject(`文件异常:.${extension} 包含非法二进制内容或编码错误`);
}
if (extension === 'csv' && this.csvMaxRows > 0) {
const rows = this._countCSVRows(buffer);
if (rows > this.csvMaxRows) {
return reject(`CSV 行数超出限制 (当前 ${rows} 行,最大允许 ${this.csvMaxRows} 行)`);
}
}
} else {
// 获取文件头 Hex (读取足够长的字节以覆盖最长的魔数PNG需8字节)
const fileHeader = this._bufferToHex(buffer.slice(0, 8));
// 使用 startsWith 匹配
if (fileHeader.startsWith(expectedMagic)) {
isSafe = true;
} else {
return reject(`文件可能已被篡改 (真实类型与 .${extension} 不符)`);
}
}
if (isSafe) resolve(true);
};
reader.onerror = () => reject('文件读取失败,无法校验');
// 读取前 1KB 进行判断
reader.readAsArrayBuffer(file.slice(0, 1024));
if (expectedMagic === 'TYPE_TEXT' && extension === 'json') {
reader.readAsArrayBuffer(file);
} else {
reader.readAsArrayBuffer(file.slice(0, 2048));
}
});
}
}
// 【demo】
// 如果传入了 allowedExtensions则只使用传入的否则使用全部 KNOWN_SIGNATURES
// const imageValidator = new FileValidator({