Compare commits

51 Commits

Author SHA1 Message Date
bffd1cba7e fix : 语音输出状态 2025-12-29 14:08:11 +08:00
3d6297231c fix : 录音状态优化 2025-12-29 13:56:22 +08:00
f78a0b84d8 fix : 修正文本提取工具,优化工作经验解析 2025-12-29 10:41:42 +08:00
e0c5ad5cc9 去掉部分console 2025-12-29 10:39:17 +08:00
6393e78ed5 去掉部分console 2025-12-29 10:36:55 +08:00
14af7b8b20 修改TTS断句逻辑 2025-12-29 10:28:49 +08:00
9769f486f0 refactor : 重构TTS的文字分割方式,队列请求/播放方式, fix : asr录制音频的音量波纹显示 2025-12-29 10:08:20 +08:00
f6b7755e32 feat : 语音转文字实现 2025-12-26 18:41:58 +08:00
e114675eba Merge branch 'main' of http://124.243.245.42:3000/sdz/qingdao-employment-service 2025-12-26 18:15:58 +08:00
8b98e476d8 feat : tts正常播放暂存 2025-12-26 18:15:57 +08:00
Apcallover
0d136943a3 flat:修改错误 2025-12-26 17:08:01 +08:00
3f66ceece9 Merge branch 'main' of http://124.243.245.42:3000/sdz/qingdao-employment-service 2025-12-26 16:44:12 +08:00
2c5ff4220a feat : tts 和 asr 重构暂存 2025-12-26 16:44:11 +08:00
Apcallover
ee6813f767 flat:合并 2025-12-25 17:57:47 +08:00
Apcallover
bd2538aea4 flat: 暂存 2025-12-25 17:54:01 +08:00
29615c394a fix 清除图标 2025-12-25 17:49:26 +08:00
369d065008 feat : 对接认证接口 2025-12-25 17:37:59 +08:00
433352c902 feat : 预加载常用页面 2025-12-25 17:06:27 +08:00
9ff9d9db5e feat : 预加载常用页面 2025-12-25 17:05:03 +08:00
5ab136f1c5 perf : 再次优化岗位信息薪资部分解析 2025-12-25 16:21:36 +08:00
0704896694 perf : 优化处理岗位信息中,对薪资区间测处理(之前语音播放会有问题) 2025-12-25 16:14:11 +08:00
3065618610 perf : 从首页标签进入搜索页,不自动聚焦搜索输入框 2025-12-25 15:57:47 +08:00
87fcbfe5cc fix 2025-12-25 15:42:30 +08:00
aa87305301 feat : 编辑我的简历需要认证 2025-12-25 15:32:44 +08:00
9eaca78b19 fix 2025-12-25 15:18:55 +08:00
850d4e5fbc fix 2025-12-25 15:15:02 +08:00
22487c304d Merge branch 'main' of http://124.243.245.42:3000/sdz/qingdao-employment-service 2025-12-25 15:09:11 +08:00
1b332fbad8 feta : 新增实名认证判断 公司/岗位收藏 需要认证
招聘会预约        需要认证
外部岗位申请    需要认证
内部岗位申请    不需要认证
我的页面-认证状态 改为真实数据
2025-12-25 15:09:09 +08:00
Apcallover
1673b63cda flat: 语音提示 2025-12-25 13:43:19 +08:00
Apcallover
8280cc9fae flat: bug修复 2025-12-25 12:52:54 +08:00
Apcallover
ef669f23be Merge branch 'main' of http://124.243.245.42:3000/sdz/qingdao-employment-service 2025-12-25 10:54:51 +08:00
Apcallover
e9674fcb40 flat: 支付宝刷脸登录 2025-12-25 10:52:14 +08:00
ff71c50b9c 回退 2025-12-25 10:35:13 +08:00
c7f42418ba Merge branch 'main' of http://124.243.245.42:3000/sdz/qingdao-employment-service 2025-12-25 09:40:44 +08:00
b0a62b6921 还原AppLayout header 2025-12-25 09:40:42 +08:00
Apcallover
c0c93fffc4 flat: 合并 2025-12-24 18:40:23 +08:00
Apcallover
d5890936c3 flat: 扫码登录 2025-12-24 18:39:44 +08:00
95c22f6d0f feat : 一体机简历上传 2025-12-24 18:18:13 +08:00
0029215bd9 feat : 对接专业搜索联想数据源接口,upload.html新增文件类型限制参数(带默认) 2025-12-24 17:26:59 +08:00
640231a223 Merge branch 'main' of http://124.243.245.42:3000/sdz/qingdao-employment-service 2025-12-24 17:04:08 +08:00
99f8fd7b6a fix 2025-12-24 17:04:07 +08:00
Apcallover
4b39add733 flat: 暂存 2025-12-24 17:00:57 +08:00
8113f98195 1 2025-12-24 16:57:17 +08:00
a28e0ee44c fix 2025-12-24 16:38:44 +08:00
a5ea613a7d feat : 小程序下隐藏applayout头部 2025-12-24 16:33:59 +08:00
Apcallover
049ce7a92d flat: 暂存 2025-12-24 16:33:58 +08:00
ebea520bca style : 适配两个页面 2025-12-24 16:04:26 +08:00
3f0f349adf 回退 2025-12-24 15:59:42 +08:00
Apcallover
57596cbcb9 flat: 一体机登录修改 2025-12-24 15:22:50 +08:00
5461dc276c feat : 搜索新增专业/职位切换, 专业搜索时新增联想功能(接口待调) 2025-12-24 15:03:18 +08:00
212931e969 feat: fileicon, filetext 组件改为全局,新增一体机上传简历(接口待调) 2025-12-24 13:53:19 +08:00
39 changed files with 5339 additions and 1233 deletions

15
App.vue
View File

@@ -1,7 +1,6 @@
<script setup>
import { reactive, inject, onMounted } from 'vue';
import { onLaunch, onShow, onHide } from '@dcloudio/uni-app';
import { IncreaseRevie } from '@/common/all-in-one-listen.js';
import useUserStore from './stores/useUserStore';
import usePageAnimation from './hook/usePageAnimation';
import useDictStore from './stores/useDictStore';
@@ -22,7 +21,6 @@ import baseDB from '@/utils/db.js';
import { $confirm } from '@/utils/modal.js';
import useLocationStore from '@/stores/useLocationStore';
const appword = 'aKd20dbGdFvmuwrt'; // 固定值
let uQRListen = null;
let inactivityManager = null;
let inactivityModalTimer = null;
@@ -44,20 +42,19 @@ onLaunch((options) => {
useUserStore().changMiniProgramAppStatus(true);
useUserStore().changMachineEnv(true);
(function loop() {
console.log('📍一体机尝试获取定位');
console.log('一体机尝试获取定位');
useLocationStore()
.getLocation()
.then(({ longitude, latitude }) => {
console.log(`一体机获取定位成功:lng:${longitude},lat${latitude}`);
console.log(`一体机获取定位成功:lng:${longitude},lat${latitude}`);
})
.catch((err) => {
console.log('一体机获取定位失败,30s后尝试重新获取');
console.log('一体机获取定位失败,30s后尝试重新获取');
setTimeout(() => {
loop();
}, 3000);
});
})();
uQRListen = new IncreaseRevie();
inactivityManager = new GlobalInactivityManager(handleInactivity, 60 * 1000);
inactivityManager.start();
return;
@@ -91,7 +88,7 @@ onHide(() => {
});
function handleInactivity() {
console.log('【全局】60秒无操作执行安全逻辑');
console.warn('60秒无操作执行安全逻辑');
if (inactivityModalTimer) {
clearTimeout(inactivityModalTimer);
inactivityModalTimer = null;
@@ -123,7 +120,7 @@ function handleInactivity() {
},
});
// 2. 启动 10 秒倒计时
// 2. 启动 20 秒倒计时
inactivityModalTimer = setTimeout(() => {
inactivityModalTimer = null;
console.log('【自动登出】10秒无响应强制清理状态');
@@ -132,7 +129,7 @@ function handleInactivity() {
uni.$emit('hide-global-popup');
performLogout();
}, 10000);
}, 20000);
} else {
inactivityManager?.resume();
}

View File

@@ -8,32 +8,46 @@ import {
playTextDirectly
} from '@/hook/useTTSPlayer-all-in-one'
export class IncreaseRevie {
constructor(arg) {
this.myEventCallback = this.myCallback.bind(this);
this._debounceTimer = null;
this.init();
this._debounceTimer = null; // 防抖计时器
this._initTimer = null; // 启动延时计时器
}
init() {
start() {
this.close();
setTimeout(() => {
this._initTimer = setTimeout(() => {
if (window.hh?.on) {
console.log('开始监听 QR 扫码事件');
window.hh.on('initQRListener', this.myEventCallback);
}
this._initTimer = null;
}, 1000);
}
close() {
if (this._initTimer) {
clearTimeout(this._initTimer);
this._initTimer = null;
}
if (window.hh?.off) {
window.hh.off('initQRListener', this.myEventCallback);
}
if (this._debounceTimer) {
clearTimeout(this._debounceTimer);
this._debounceTimer = null;
}
}
myCallback(res) {
if (this._debounceTimer) {
clearTimeout(this._debounceTimer);
@@ -45,29 +59,170 @@ export class IncreaseRevie {
}, 300);
}
async handleDebouncedCallback(res) {
if (res.data) {
const code = res.data.qrCode
const code = res.data.qrCode;
if (/^\d{6}$/.test(String(code))) {
// 把code给到后端后端拿code兑换用户信息给前端返回token进行登录
// 一体机用户需要清空indexDB
$api.createRequest(`/app/qrcodeLogin/${code}`, {}, 'get').then((resData) => {
useUserStore()
.loginSetToken(resData.token)
.then((resume) => {
playTextDirectly('登录成功')
playTextDirectly('登录成功');
// 根据是否有一体机岗位ID判断跳转路径
if (resume.data.jobTitleId) {
useUserStore().initSeesionId();
safeReLaunch('/pages/index/index');
} else {
safeReLaunch('/pages/login/login');
}
// 关闭监听,避免重复扫码
this.close();
});
});
} else {
// 格式不对不做处理,或者提示
console.log('QR Code format mismatch');
}
} else {
$api.msg('识别失败')
playTextDirectly('识别失败')
$api.msg('识别失败');
playTextDirectly('识别失败');
}
}
}
export class FaceLoginService {
constructor() {
this.isInitialized = false;
this.defaultScope = "auth_user,yingpin"; // 默认聚合授权参数
this._retryTimer = null;
}
start(scope = null) {
// 启动前先清理可能存在的旧状态
this.close();
console.log("[FaceLogin] 服务启动...");
// 开始执行初始化逻辑
this.init(scope);
}
close() {
// 1. 清除正在等待执行的重试定时器
if (this._retryTimer) {
clearTimeout(this._retryTimer);
this._retryTimer = null;
console.log("[FaceLogin] 已取消挂起的初始化重试");
}
// 2. 重置初始化状态
this.isInitialized = false;
console.log("[FaceLogin] 服务已关闭");
}
async init(scope = null, retryCount = 1) {
const params = {
action: "initFace",
event: "open",
taskId: this._generateTaskId(),
params: {
serviceId: "",
query: "",
scope: scope || this.defaultScope
}
};
try {
await this._bridgeCall(params);
this.isInitialized = true;
console.log("[FaceLogin] 初始化成功");
return true;
} catch (err) {
console.error(`[FaceLogin] 初始化失败: ${err.message}`);
if (retryCount > 0) {
console.log("[FaceLogin] 3秒后尝试重新初始化...");
this._retryTimer = setTimeout(() => {
this._retryTimer = null; // 执行时清空引用
this.init(scope, retryCount - 1); // 递归重试
}, 3000);
} else {
// throw err; // 视业务逻辑决定是否阻断
}
}
}
/**
* 2. 唤起 1:N 刷脸并获取 AuthCode
*/
async startFaceLogin() {
if (!this.isInitialized) {
console.warn("[FaceLogin] 服务未初始化,尝试自动补救初始化...");
try {
await this.init(null, 0);
if (!this.isInitialized) throw new Error("初始化未完成");
} catch (e) {
throw new Error("刷脸服务初始化失败,请稍后重试");
}
}
const params = {
action: "getFaceInfo",
event: "open",
taskId: this._generateTaskId()
};
try {
const res = await this._bridgeCall(params);
const {
data
} = res;
// if (!data || !data.extInfo) {
// throw new Error("返回数据缺少 extInfo");
// }
// let extInfoObj;
// try {
// extInfoObj = JSON.parse(data.extInfo);
// } catch (e) {
// throw new Error("extInfo JSON解析失败");
// }
// if (!extInfoObj.authCode) {
// throw new Error("未获取到 authCode");
// }
return data;
} catch (error) {
const errMsg = error.message || error.subMessage || "刷脸失败";
throw new Error(errMsg);
}
}
_bridgeCall(params) {
return new Promise((resolve, reject) => {
if (typeof hh === 'undefined' || !hh.call) {
return reject(new Error("Bridge环境未就绪 (hh未定义)"));
}
hh.call("ampeHHCommunication", params, (res) => {
res = JSON.parse(res)
if (res && res.success) {
resolve(res);
} else {
reject(res || {
message: "未知错误",
success: false
});
}
});
});
}
_generateTaskId() {
return 'task_' + Date.now() + '_' + Math.floor(Math.random() * 1000);
}
}

View File

@@ -1,12 +1,4 @@
// #ifdef H5
import '@/lib/encryption/sm4.min.js'
// #endif
// #ifndef H5
import { sm4 } from 'sm-crypto';
// #endif
import useUserStore from "../stores/useUserStore";
import {
createRequestWithCache,
@@ -609,6 +601,7 @@ export function sm4Decrypt(key, value, mode = "hex") {
cipherType: mode === 'hex' ? 'hex' : 'base64',
padding: 'pkcs#5'
});
return decrypted
} catch (e) {
@@ -643,32 +636,20 @@ export function reloadBrowser() {
// 一体机环境判断
export function isY9MachineType() {
try {
const ua = navigator.userAgent;
const isY9Machine = /Y9-ZYYH/i.test(ua); // 匹配机器型号
return isY9Machine;
} catch (error) {
return false
}
const ua = navigator.userAgent;
const isY9Machine = /Y9-ZYYH/i.test(ua); // 匹配机器型号
return isY9Machine;
}
// 一体机环境判断
// 爱山东环境判断
export function isAsdMachineType() {
// const ua = navigator.userAgent;
// const isY9Machine = /asd_hanweb/i.test(ua); // 匹配机器型号
// return isY9Machine;
try {
return !!lightAppJssdk.user
} catch (error) {
false
}
return !!lightAppJssdk.user
}
export const $api = {
msg,
prePage,

View File

@@ -6,6 +6,7 @@
>
<!-- 顶部头部区域 -->
<view
v-if="showHeader || defaultShow"
class="container-header"
:style="border ? { borderBottom: `2rpx solid ${borderColor}` } : { borderBottom: 'none' }"
>
@@ -20,6 +21,7 @@
<slot name="headerright"></slot>
</view>
</view>
<!-- 主体不可滚动 headContent -->
<view class="container-headContent">
@@ -43,6 +45,10 @@
</template>
<script setup>
import { computed } from "vue"
import { storeToRefs } from 'pinia';
import useUserStore from '@/stores/useUserStore';
const { isMiniProgram } = storeToRefs(useUserStore());
import img from '@/static/icon/background2.png';
const emit = defineEmits(['onScrollBottom']);
defineProps({
@@ -82,8 +88,16 @@ defineProps({
type: Boolean,
default: true,
},
showHeader: {
type: Boolean,
default: false,
},
});
const defaultShow = computed(()=>{
return isMiniProgram.value
})
const handleScrollToLower = () => {
emit('onScrollBottom');
};

619
hook/useAudioSpeak-copy.js Normal file
View File

@@ -0,0 +1,619 @@
// useAudioSpeak.js
import { ref } from 'vue'
import globalConfig from '@/config.js';
import useUserStore from '@/stores/useUserStore';
/**
* TTS语音合成Hook (队列播放版本)
* @param {Object} config - TTS配置
* @param {string} config.apiUrl - 语音合成API地址
* @param {number} config.maxSegmentLength - 最大分段长度
* @param {number} config.minQueueSize - 最小队列缓存数量
* @param {number} config.maxRetry - 最大重试次数
* @returns {Object} TTS相关方法和状态
*/
export const useAudioSpeak = (config = {}) => {
const {
apiUrl = `${globalConfig.baseUrl}/app/speech/tts`,
maxSegmentLength = 30,
minQueueSize = 3, // 最小队列缓存数量
maxRetry = 2, // 最大重试次数
onStatusChange = () => {} // 状态变化回调
} = config
// 状态
const isSpeaking = ref(false)
const isPaused = ref(false)
const isLoading = ref(false)
// 播放状态
const currentText = ref('')
const currentSegmentIndex = ref(0)
const totalSegments = ref(0)
const progress = ref(0)
// 队列容器
let textQueue = [] // 等待转换的文本队列 [{text, index}]
let audioQueue = [] // 已经转换好的音频队列 [{blobUrl, text, index}]
// 控制标志
let isFetching = false // 是否正在请求音频
let isPlaying = false // 是否正在播放(逻辑状态)
let currentPlayingIndex = -1 // 当前正在播放的片段索引
let audioContext = null
let audioSource = null
let currentAudioUrl = '' // 当前播放的音频URL
// 文本提取工具函数
function extractSpeechText(markdown) {
if (!markdown || markdown.indexOf('job-json') === -1) {
return markdown;
}
const jobRegex = /``` job-json\s*({[\s\S]*?})\s*```/g;
const jobs = [];
let match;
let lastJobEndIndex = 0;
let firstJobStartIndex = -1;
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);
}
}
const guideText = firstJobStartIndex > 0 ?
markdown.slice(0, firstJobStartIndex).trim() : '';
const endingText = lastJobEndIndex < markdown.length ?
markdown.slice(lastJobEndIndex).trim() : '';
const jobTexts = jobs.map((job, index) => {
// 处理薪资格式
let salaryText = job.salary;
if (salaryText) {
// 匹配 "XXXXX-XXXXX元/月" 格式
const rangeMatch = salaryText.match(/(\d+)-(\d+)元\/月/);
if (rangeMatch) {
const minSalary = parseInt(rangeMatch[1], 10);
const maxSalary = parseInt(rangeMatch[2], 10);
// 转换为千位单位
const minK = Math.round(minSalary / 1000);
const maxK = Math.round(maxSalary / 1000);
salaryText = `${minK}千到${maxK}千每月`;
}
// 如果不是 "XXXXX-XXXXX元/月" 格式,保持原样
}
return `${index + 1} 个岗位,岗位名称是:${job.jobTitle},公司是:${job.companyName},薪资:${salaryText},地点:${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');
}
/**
* 智能文本切割
* 作用:合并短句减少请求次数,切分长句避免超时
*/
const _smartSplit = (text) => {
if (!text || typeof text !== 'string') return [];
const cleanText = text.replace(/\s+/g, ' ').trim();
// 先按标点粗分
const rawChunks = cleanText.split(/([。?!;\n\r]|……)/).filter((t) => t.trim());
const mergedChunks = [];
let temp = '';
for (let i = 0; i < rawChunks.length; i++) {
const part = rawChunks[i];
// 如果是标点,追加到上一句
if (/^[。?!;\n\r……]$/.test(part)) {
temp += part;
} else {
// 如果当前积累的句子太长超过50字先推入队列
if (temp.length > 50) {
mergedChunks.push(temp);
temp = part;
} else if (temp.length + part.length < 15) {
// 如果当前积累的太短少于15字则合并下一句
temp += part;
} else {
// 正常长度,推入
if (temp) mergedChunks.push(temp);
temp = part;
}
}
}
if (temp) mergedChunks.push(temp);
// 如果还有超过 maxSegmentLength 的,强制分割
const finalSegments = [];
mergedChunks.forEach(segment => {
if (segment.length <= maxSegmentLength) {
finalSegments.push(segment);
} else {
// 按字数强制分割
for (let i = 0; i < segment.length; i += maxSegmentLength) {
finalSegments.push(segment.substring(i, Math.min(i + maxSegmentLength, segment.length)));
}
}
});
return finalSegments.filter(seg => seg && seg.trim());
}
/**
* 统一状态通知
*/
const _updateState = (state = {}) => {
// 合并当前状态供 UI 使用
const payload = {
isPlaying: isPlaying,
isPaused: isPaused.value,
isLoading: state.isLoading || false,
msg: state.msg || '',
currentSegmentIndex: currentSegmentIndex.value,
totalSegments: totalSegments.value,
progress: progress.value
};
// 更新响应式状态
isLoading.value = state.isLoading || false;
// 调用回调
onStatusChange(payload);
}
/**
* 网络请求包装 (带重试机制)
*/
const _fetchAudioWithRetry = async (text, retries = 0) => {
try {
console.log(`📶正在请求音频: "${text.substring(0, 20)}..."`);
let Authorization = '';
if (useUserStore().token) {
Authorization = `${useUserStore().token}`;
}
const response = await fetch(`${apiUrl}?text=${encodeURIComponent(text)}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Authorization': encodeURIComponent(Authorization)
}
});
if (!response.ok) {
throw new Error(`HTTP错误! 状态码: ${response.status}`);
}
const audioBlob = await response.blob();
if (!audioBlob || audioBlob.size < 100) {
throw new Error('音频数据太小或无效');
}
console.log(`音频获取成功,大小: ${audioBlob.size} 字节`);
// 创建Blob URL
return URL.createObjectURL(audioBlob);
} catch (e) {
if (retries < maxRetry) {
console.warn(`重试 ${retries + 1} 次,文本: ${text.substring(0, 10)}...`);
return await _fetchAudioWithRetry(text, retries + 1);
}
throw e;
}
}
/**
* 缓冲维护器 (生产者)
* 始终保持 audioQueue 里有足够的音频
*/
const _maintainBuffer = async () => {
// 如果正在请求,或 文本队列没了,或 音频缓冲已达标,则停止请求
if (isFetching || textQueue.length === 0) {
return;
}
// 缓冲策略:如果音频队列里的数量少于 minQueueSize就继续下载
if (audioQueue.length >= minQueueSize) {
return;
}
isFetching = true;
const textItem = textQueue.shift(); // 从文本队列取出
try {
const blobUrl = await _fetchAudioWithRetry(textItem.text);
audioQueue.push({
blobUrl,
text: textItem.text,
index: textItem.index
});
console.log(`音频已添加到队列,当前队列长度: ${audioQueue.length}`);
// 如果当前因为没音频卡住了Loading状态立即尝试播放下一段
if (!audioSource && !isPaused.value && isPlaying) {
_playNext();
}
} catch (error) {
console.error('缓冲维护失败:', error);
// 即使失败,也要继续处理下一个文本,防止死锁
} finally {
isFetching = false;
// 递归调用:继续检查是否需要填充缓冲
_maintainBuffer();
}
}
/**
* 初始化音频上下文
*/
const initAudioContext = () => {
if (!audioContext || audioContext.state === 'closed') {
audioContext = new (window.AudioContext || window.webkitAudioContext)();
console.log('音频上下文已初始化');
}
return audioContext;
}
/**
* 播放控制器 (消费者)
*/
const _playNext = () => {
if (!isPlaying || isPaused.value) {
return;
}
// 尝试从缓冲队列获取下一个应该播放的片段
const nextIndex = currentPlayingIndex + 1;
const itemIndex = audioQueue.findIndex(item => item.index === nextIndex);
// 1. 缓冲耗尽:进入"加载中"等待状态
if (itemIndex === -1) {
if (textQueue.length === 0 && !isFetching) {
// 彻底播完了
console.log('所有音频播放完成');
stopAudio();
_updateState({ isPlaying: false, msg: '播放结束' });
} else {
// 还有文本没转完,说明网速慢了,正在缓冲
_updateState({ isLoading: true, msg: '缓冲中...' });
// 确保生产线在运行
_maintainBuffer();
}
return;
}
// 2. 正常播放
const item = audioQueue.splice(itemIndex, 1)[0];
// 释放上一段的内存
if (currentAudioUrl) {
URL.revokeObjectURL(currentAudioUrl);
currentAudioUrl = '';
}
currentPlayingIndex = item.index;
currentSegmentIndex.value = item.index;
currentAudioUrl = item.blobUrl;
// 更新进度
if (totalSegments.value > 0) {
progress.value = Math.floor(((item.index + 1) / totalSegments.value) * 100);
}
_updateState({
isLoading: false,
msg: `播放中: ${item.text.substring(0, 15)}...`
});
// 播放音频
_playAudio(item.blobUrl, item.text, item.index);
// 消费了一个,通知生产者补充库存
_maintainBuffer();
}
/**
* 播放音频
*/
const _playAudio = async (blobUrl, text, index) => {
return new Promise((resolve, reject) => {
if (!isPlaying || isPaused.value) {
resolve();
return;
}
initAudioContext();
const fileReader = new FileReader();
fileReader.onload = async (e) => {
try {
const arrayBuffer = e.target.result;
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
// 如果在此期间被暂停或停止,直接返回
if (!isPlaying || isPaused.value) {
resolve();
return;
}
audioSource = audioContext.createBufferSource();
audioSource.buffer = audioBuffer;
audioSource.connect(audioContext.destination);
audioSource.onended = () => {
console.log(`${index + 1}个片段播放完成`);
audioSource = null;
// 播放下一段
_playNext();
resolve();
};
audioSource.onerror = (error) => {
console.error('音频播放错误:', error);
audioSource = null;
// 跳过错误片段,尝试下一段
setTimeout(() => {
_playNext();
}, 100);
reject(error);
};
console.log(`▶️开始播放第${index + 1}个片段: "${text.substring(0, 20)}..."`);
// 如果音频上下文被暂停,先恢复
if (audioContext.state === 'suspended') {
await audioContext.resume();
}
audioSource.start(0);
} catch (error) {
console.error('解码或播放音频失败:', error);
audioSource = null;
// 跳过错误片段,尝试下一段
setTimeout(() => {
_playNext();
}, 100);
reject(error);
}
};
fileReader.onerror = (error) => {
console.error('读取音频文件失败:', error);
audioSource = null;
// 跳过错误片段,尝试下一段
setTimeout(() => {
_playNext();
}, 100);
reject(error);
};
// 先获取Blob再读取
fetch(blobUrl)
.then(response => response.blob())
.then(blob => {
fileReader.readAsArrayBuffer(blob);
})
.catch(error => {
console.error('获取Blob失败:', error);
reject(error);
});
});
}
/**
* 核心入口:开始播放长文本
*/
const speak = async (text) => {
console.log('开始新的语音播报');
// 先停止当前播放
if (isPlaying) {
console.log('检测到正在播放,先停止');
stopAudio();
// 等待一小段时间确保资源清理完成
await new Promise(resolve => setTimeout(resolve, 200));
}
text = extractSpeechText(text);
console.log('开始语音播报:', text);
// 重置状态
isPlaying = true;
isPaused.value = false;
isLoading.value = true;
isSpeaking.value = true;
currentText.value = text;
progress.value = 0;
currentPlayingIndex = -1;
currentSegmentIndex.value = 0;
// 清空队列
textQueue = [];
audioQueue = [];
// 清理之前的Blob URL
if (currentAudioUrl) {
URL.revokeObjectURL(currentAudioUrl);
currentAudioUrl = '';
}
// 1. 智能切割文本
const segments = _smartSplit(text);
console.log('文本分段结果:', segments);
if (segments.length === 0) {
console.warn('没有有效的文本可以播报');
_updateState({ isPlaying: false, msg: '没有有效的文本' });
isSpeaking.value = false;
isLoading.value = false;
return;
}
totalSegments.value = segments.length;
// 2. 填充文本队列
segments.forEach((segment, index) => {
textQueue.push({ text: segment, index });
});
_updateState({ isLoading: true, msg: '初始化播放...' });
// 3. 启动"缓冲流水线"
_maintainBuffer();
// 4. 开始播放
setTimeout(() => {
_playNext();
}, 100);
}
/**
* 暂停播放
*/
const pause = () => {
if (isPlaying && !isPaused.value) {
isPaused.value = true;
if (audioContext) {
audioContext.suspend().then(() => {
_updateState({ isPaused: true, msg: '已暂停' });
});
} else {
_updateState({ isPaused: true, msg: '已暂停' });
}
}
}
/**
* 恢复播放
*/
const resume = () => {
if (isPlaying && isPaused.value) {
isPaused.value = false;
// 恢复播放。如果当前有音频源则直接resume否则调_playNext
if (audioContext && audioContext.state === 'suspended') {
audioContext.resume().then(() => {
_updateState({ isPaused: false, msg: '继续播放' });
});
} else if (audioSource) {
_updateState({ isPaused: false, msg: '继续播放' });
} else {
_playNext();
}
}
}
/**
* 停止播放
*/
const stopAudio = () => {
console.log('停止音频播放');
isPlaying = false;
isPaused.value = false;
isFetching = false;
isSpeaking.value = false;
isLoading.value = false;
// 停止音频源
if (audioSource) {
try {
audioSource.stop();
console.log('音频源已停止');
} catch (e) {
console.warn('停止音频源失败:', e);
}
audioSource = null;
}
// 暂停音频上下文
if (audioContext && audioContext.state !== 'closed') {
audioContext.suspend();
}
// 清理所有 Blob 内存
if (currentAudioUrl) {
URL.revokeObjectURL(currentAudioUrl);
currentAudioUrl = '';
}
// 清理音频队列中的Blob URL
audioQueue.forEach(item => {
if (item.blobUrl) {
URL.revokeObjectURL(item.blobUrl);
}
});
// 清空队列
textQueue = [];
audioQueue = [];
currentPlayingIndex = -1;
progress.value = 0;
_updateState({ isPlaying: false, msg: '已停止' });
}
/**
* 清理资源
*/
const cleanup = () => {
console.log('开始清理资源');
stopAudio();
if (audioContext && audioContext.state !== 'closed') {
audioContext.close();
console.log('音频上下文已关闭');
}
audioContext = null;
console.log('资源清理完成');
}
return {
// 状态
isSpeaking,
isPaused,
isLoading,
currentText,
currentSegmentIndex,
totalSegments,
progress,
// 方法
speak,
pause,
resume,
cancelAudio: stopAudio,
cleanup
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -53,24 +53,18 @@ export function useColumnCount(onChange = () => {}) {
onMounted(() => {
columnCount.value = 2
calcColumn()
try {
// if (process.client) {
window.addEventListener('resize', calcColumn)
// }
} catch (error) {
}
// if (process.client) {
window.addEventListener('resize', calcColumn)
// }
})
onUnmounted(() => {
try {
// if (process.client) {
window.removeEventListener('resize', calcColumn)
// }
} catch (error) {
}
// if (process.client) {
window.removeEventListener('resize', calcColumn)
// }
})

View File

@@ -0,0 +1,657 @@
import {
ref,
onUnmounted
} from 'vue'
import config from '@/config'
export function useRealtimeRecorderOnce() {
// --- 状态定义 ---
const isRecording = ref(false)
const isProcessing = ref(false)
const recordingDuration = ref(0)
const volumeLevel = ref(0) // 0-100
const recognizedText = ref('')
const audioData = ref(null)
const audioDataForDisplay = ref([])
// --- 内部变量 ---
let durationTimer = null
// --- APP/小程序 变量 ---
let recorderManager = null;
let appAudioChunks = [];
// --- H5 变量 ---
let audioContext = null;
let mediaRecorder = null;
let h5Stream = null;
let h5AudioChunks = [];
let analyser = null;
let dataArray = null;
// --- 配置项 ---
const RECORD_CONFIG = {
duration: 600000,
sampleRate: 16000,
numberOfChannels: 1,
format: 'wav',
encodeBitRate: 16000,
frameSize: 4096
}
// --- WAV文件头函数 ---
const encodeWAV = (samples, sampleRate = 16000, numChannels = 1, bitsPerSample = 16) => {
const bytesPerSample = bitsPerSample / 8;
const blockAlign = numChannels * bytesPerSample;
const byteRate = sampleRate * blockAlign;
const dataSize = samples.length * bytesPerSample;
const buffer = new ArrayBuffer(44 + dataSize);
const view = new DataView(buffer);
// RIFF chunk descriptor
writeString(view, 0, 'RIFF');
view.setUint32(4, 36 + dataSize, true);
writeString(view, 8, 'WAVE');
// fmt sub-chunk
writeString(view, 12, 'fmt ');
view.setUint32(16, 16, true); // Subchunk1Size (16 for PCM)
view.setUint16(20, 1, true); // AudioFormat (1 for PCM)
view.setUint16(22, numChannels, true);
view.setUint32(24, sampleRate, true);
view.setUint32(28, byteRate, true);
view.setUint16(32, blockAlign, true);
view.setUint16(34, bitsPerSample, true);
// data sub-chunk
writeString(view, 36, 'data');
view.setUint32(40, dataSize, true);
// Write audio samples
const volume = 1;
let offset = 44;
for (let i = 0; i < samples.length; i++) {
let sample = Math.max(-1, Math.min(1, samples[i]));
sample = sample * volume;
view.setInt16(offset, sample < 0 ? sample * 0x8000 : sample * 0x7FFF, true);
offset += 2;
}
return buffer;
}
const writeString = (view, offset, string) => {
for (let i = 0; i < string.length; i++) {
view.setUint8(offset + i, string.charCodeAt(i));
}
}
const floatTo16BitPCM = (output, offset, input) => {
for (let i = 0; i < input.length; i++, offset += 2) {
const s = Math.max(-1, Math.min(1, input[i]));
output.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
}
}
// --- 音量计算函数 ---
const calculateVolumeFromFloat32 = (float32Array) => {
if (!float32Array || float32Array.length === 0) return 0;
let sum = 0;
const length = float32Array.length;
// 计算RMS (均方根)
for (let i = 0; i < length; i++) {
// 绝对值方法更敏感
const absValue = Math.abs(float32Array[i]);
sum += absValue * absValue;
}
const rms = Math.sqrt(sum / length);
// 调试打印原始RMS值
// console.log('Float32 RMS:', rms);
// 转换为0-100的值
// 使用对数刻度,使小音量变化更明显
let volume = 0;
if (rms > 0) {
// 使用对数转换:-60dB到0dB映射到0-100
const db = 20 * Math.log10(rms);
// 静音阈值约-60dB
if (db > -60) {
volume = Math.min(100, Math.max(0, (db + 60) / 0.6));
}
}
// 如果没有计算到值使用旧方法作为fallback
if (volume === 0 && rms > 0) {
volume = Math.min(100, Math.floor(rms * 500));
}
// 确保最小值为0
return Math.max(0, volume);
}
const calculateVolumeFromInt16 = (int16Array) => {
if (!int16Array || int16Array.length === 0) return 0;
let sum = 0;
const length = int16Array.length;
// 计算RMS
for (let i = 0; i < length; i++) {
const normalized = int16Array[i] / 32768; // 归一化到[-1, 1]
const absValue = Math.abs(normalized);
sum += absValue * absValue;
}
const rms = Math.sqrt(sum / length);
// 调试打印原始RMS值
// console.log('Int16 RMS:', rms);
// 转换为0-100的值
// 使用对数刻度
let volume = 0;
if (rms > 0) {
const db = 20 * Math.log10(rms);
if (db > -60) {
volume = Math.min(100, Math.max(0, (db + 60) / 0.6));
}
}
// 如果没有计算到值使用旧方法作为fallback
if (volume === 0 && rms > 0) {
volume = Math.min(100, Math.floor(rms * 500));
}
// 确保最小值为0
return Math.max(0, volume);
}
/**
* 开始录音 (入口)
*/
const startRecording = async () => {
if (isRecording.value) return
try {
recognizedText.value = ''
volumeLevel.value = 0
audioData.value = null
audioDataForDisplay.value = []
appAudioChunks = []
h5AudioChunks = []
// #ifdef H5
if (location.protocol !== 'https:' && location.hostname !== 'localhost') {
uni.showToast({
title: 'H5录音需要HTTPS环境',
icon: 'none'
});
return;
}
// #endif
// #ifdef H5
await startH5Recording();
// #endif
// #ifndef H5
startAppRecording();
// #endif
isRecording.value = true;
recordingDuration.value = 0;
durationTimer = setInterval(() => recordingDuration.value++, 1000);
// 启动波形显示更新
updateAudioDataForDisplay();
} catch (err) {
console.error('启动失败:', err);
uni.showToast({
title: '启动失败: ' + (err.message || ''),
icon: 'none'
});
cleanup();
}
}
/**
* H5录音实现 - 手动构建WAV文件
*/
const startH5Recording = async () => {
try {
// 1. 获取麦克风流
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
sampleRate: 16000,
channelCount: 1,
echoCancellation: true,
noiseSuppression: true,
autoGainControl: false
}
});
h5Stream = stream;
// 2. 创建 AudioContext 用于处理音频
const AudioContext = window.AudioContext || window.webkitAudioContext;
audioContext = new AudioContext({
sampleRate: 16000,
latencyHint: 'interactive'
});
// 创建音频处理节点
const source = audioContext.createMediaStreamSource(stream);
// 创建分析器用于音量计算
analyser = audioContext.createAnalyser();
analyser.fftSize = 256;
analyser.smoothingTimeConstant = 0.8;
dataArray = new Float32Array(analyser.frequencyBinCount);
source.connect(analyser);
// 创建脚本处理器用于收集音频数据
const processor = audioContext.createScriptProcessor(4096, 1, 1);
// 存储所有音频样本
let audioSamples = [];
processor.onaudioprocess = (e) => {
if (!isRecording.value) return;
// 获取输入数据
const inputData = e.inputBuffer.getChannelData(0);
// 计算音量
analyser.getFloatTimeDomainData(dataArray);
const volume = calculateVolumeFromFloat32(dataArray);
volumeLevel.value = volume;
// 收集音频样本
for (let i = 0; i < inputData.length; i++) {
audioSamples.push(inputData[i]);
}
// 存储当前音频数据块
const buffer = new Float32Array(inputData.length);
buffer.set(inputData);
h5AudioChunks.push(buffer);
};
source.connect(processor);
processor.connect(audioContext.destination);
// console.log('H5 16kHz WAV录音已启动');
} catch (err) {
console.error('H5录音启动失败:', err);
throw err;
}
}
/**
* 停止H5录音资源
*/
const stopH5Resources = () => {
// 断开所有连接
if (audioContext && audioContext.state !== 'closed') {
audioContext.close();
}
// 停止音轨
if (h5Stream) {
h5Stream.getTracks().forEach(track => track.stop());
}
audioContext = null;
analyser = null;
dataArray = null;
h5Stream = null;
}
/**
* APP/小程序录音实现
*/
const startAppRecording = () => {
recorderManager = uni.getRecorderManager();
recorderManager.onFrameRecorded((res) => {
const { frameBuffer } = res;
if (frameBuffer && frameBuffer.byteLength > 0) {
// 计算音量
const int16Data = new Int16Array(frameBuffer);
const volume = calculateVolumeFromInt16(int16Data);
volumeLevel.value = volume;
// 保存音频数据
appAudioChunks.push(frameBuffer);
}
});
recorderManager.onStart(() => {
// console.log('APP 16kHz WAV录音已开始');
});
recorderManager.onError((err) => {
console.error('APP录音报错:', err);
uni.showToast({
title: '录音失败: ' + err.errMsg,
icon: 'none'
});
cleanup();
});
recorderManager.start(RECORD_CONFIG);
}
/**
* 停止录音 (通用)
*/
const stopRecording = async () => {
if (!isRecording.value) return;
isRecording.value = false;
clearInterval(durationTimer);
// 停止硬件录音
stopHardwareResource();
// 处理录音数据
await processAudioData();
}
/**
* 取消录音
*/
const cancelRecording = () => {
if (!isRecording.value) return;
// console.log('取消录音 - 丢弃结果');
// 1. 停止硬件录音
stopHardwareResource();
// 2. 清理状态
recognizedText.value = '';
audioData.value = null;
audioDataForDisplay.value = [];
appAudioChunks = [];
h5AudioChunks = [];
// 3. 清理资源
cleanup();
}
/**
* 停止硬件资源
*/
const stopHardwareResource = () => {
// APP/小程序停止
if (recorderManager) {
recorderManager.stop();
}
// H5停止
// #ifdef H5
stopH5Resources();
// #endif
}
/**
* 更新音频数据显示
*/
const updateAudioDataForDisplay = () => {
const updateInterval = setInterval(() => {
if (!isRecording.value) {
clearInterval(updateInterval);
audioDataForDisplay.value = [];
return;
}
// 获取当前音量值
const currentVolume = volumeLevel.value;
// 调试:打印音量值
// console.log('Current Volume:', currentVolume);
// 生成适合 WaveDisplay 的数据
const data = [];
const center = 15; // 中心索引
const timeFactor = Date.now() / 150; // 更快的动画
// 根据音量动态调整波形强度
const volumeFactor = currentVolume / 100;
// 添加基础噪声,使波形在安静时也有轻微活动
const baseNoise = Math.random() * 0.1;
for (let i = 0; i < 31; i++) {
// 距离中心的位置
const distanceFromCenter = Math.abs(i - center) / center;
// 基础波形模式
const basePattern = 1 - Math.pow(distanceFromCenter, 1.2);
// 动态效果
const dynamicEffect = Math.sin(timeFactor + i * 0.3) * 0.3;
// 计算基础值
let value;
if (volumeFactor > 0.1) {
// 有音量时:音量因子占主导
value = volumeFactor * 0.8 * basePattern +
volumeFactor * 0.4 +
dynamicEffect * volumeFactor * 0.5;
} else {
// 安静时:使用动态效果和基础噪声
value = basePattern * 0.2 +
dynamicEffect * 0.1 +
baseNoise;
}
// 确保值在有效范围内
value = Math.max(0.15, Math.min(1, value));
// 随机微调
const randomVariance = volumeFactor > 0.1 ? 0.15 : 0.05;
value += (Math.random() - 0.5) * randomVariance;
value = Math.max(0.15, Math.min(1, value));
data.push(value);
}
audioDataForDisplay.value = data;
// 调试:检查生成的数据范围
// const min = Math.min(...data);
// const max = Math.max(...data);
// console.log(`Data range: ${min.toFixed(3)} - ${max.toFixed(3)}`);
}, 50);
}
/**
* 处理录音数据并生成WAV文件
*/
const processAudioData = async () => {
if (isProcessing.value) return;
isProcessing.value = true;
try {
let audioBlob = null;
// #ifdef H5
// H5端合并所有音频样本并生成WAV
if (h5AudioChunks.length > 0) {
// 合并所有Float32Array
const totalLength = h5AudioChunks.reduce((sum, chunk) => sum + chunk.length, 0);
const mergedSamples = new Float32Array(totalLength);
let offset = 0;
h5AudioChunks.forEach(chunk => {
mergedSamples.set(chunk, offset);
offset += chunk.length;
});
// 生成WAV文件
const wavBuffer = encodeWAV(mergedSamples, 16000, 1, 16);
audioBlob = new Blob([wavBuffer], { type: 'audio/wav' });
// console.log(`H5生成WAV文件: ${audioBlob.size} bytes, 时长: ${mergedSamples.length / 16000}秒`);
}
// #endif
// #ifndef H5
// APP/小程序端合并Int16数据并生成WAV
if (appAudioChunks.length > 0) {
// 合并所有Int16Array
const totalLength = appAudioChunks.reduce((sum, chunk) => sum + chunk.byteLength / 2, 0);
const mergedInt16 = new Int16Array(totalLength);
let offset = 0;
appAudioChunks.forEach(chunk => {
const int16Data = new Int16Array(chunk);
mergedInt16.set(int16Data, offset);
offset += int16Data.length;
});
// 转换为Float32用于生成WAV
const floatSamples = new Float32Array(mergedInt16.length);
for (let i = 0; i < mergedInt16.length; i++) {
floatSamples[i] = mergedInt16[i] / 32768;
}
// 生成WAV文件
const wavBuffer = encodeWAV(floatSamples, 16000, 1, 16);
audioBlob = new Blob([wavBuffer], { type: 'audio/wav' });
// console.log(`APP生成WAV文件: ${audioBlob.size} bytes, 时长: ${floatSamples.length / 16000}秒`);
}
// #endif
if (audioBlob && audioBlob.size > 44) { // 确保至少包含WAV头部
audioData.value = audioBlob;
// 保存文件用于调试(可选)
// debugSaveWavFile(audioBlob);
// 发送到服务器进行识别
await sendToASR(audioBlob);
} else {
throw new Error('录音数据为空或无效');
}
} catch (error) {
console.error('处理音频数据失败:', error);
uni.showToast({
title: '音频处理失败,请重试',
icon: 'none'
});
} finally {
isProcessing.value = false;
appAudioChunks = [];
h5AudioChunks = [];
}
}
/**
* 调试用保存WAV文件
*/
const debugSaveWavFile = (blob) => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `recording_${Date.now()}.wav`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
// console.log('WAV文件已保存用于调试');
}
/**
* 发送音频到ASR服务器
*/
const sendToASR = async (audioBlob) => {
try {
// 创建FormData
const formData = new FormData();
formData.append('file', audioBlob, 'recording.wav');
// 添加Token
const token = uni.getStorageSync('token') || '';
const asrUrl = `${config.baseUrl}/app/speech/asr`
const response = await fetch(asrUrl, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`
},
body: formData
});
if (response.ok) {
const result = await response.json();
if(result.code == 200){
isProcessing.value = false
recognizedText.value = result.data || ''
}else{
$api.msg(result.msg || '识别失败')
}
} else {
const errorText = await response.text();
throw new Error(`ASR请求失败: ${response.status} - ${errorText}`);
}
} catch (error) {
console.error('ASR识别失败:', error);
}
}
/**
* 清理状态
*/
const cleanup = () => {
clearInterval(durationTimer);
isRecording.value = false;
isProcessing.value = false;
recordingDuration.value = 0;
volumeLevel.value = 0;
audioDataForDisplay.value = [];
if (recorderManager) {
recorderManager = null;
}
}
onUnmounted(() => {
if (isRecording.value) {
stopRecording();
}
cleanup();
})
return {
isRecording,
isProcessing,
recordingDuration,
volumeLevel,
recognizedText,
audioData,
audioDataForDisplay,
startRecording,
stopRecording,
cancelRecording
}
}

View File

@@ -222,39 +222,61 @@ export function useTTSPlayer() {
* 文本提取工具函数 (保持不变)
*/
function extractSpeechText(markdown) {
if (!markdown || typeof markdown !== 'string') return ''; // 增加类型安全检查
if (markdown.indexOf('job-json') === -1) {
return markdown;
if (!markdown || markdown.indexOf('job-json') === -1) {
return markdown;
}
const jobRegex = /``` job-json\s*({[\s\S]*?})\s*```/g;
const jobs = [];
let match;
let lastJobEndIndex = 0;
let firstJobStartIndex = -1;
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);
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);
}
}
const guideText = firstJobStartIndex > 0 ?
markdown.slice(0, firstJobStartIndex).trim() : '';
markdown.slice(0, firstJobStartIndex).trim() : '';
const endingText = lastJobEndIndex < markdown.length ?
markdown.slice(lastJobEndIndex).trim() : '';
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}`;
// 处理薪资格式
let salaryText = job.salary;
if (salaryText) {
// 匹配 "XXXXX-XXXXX元/月" 格式
const rangeMatch = salaryText.match(/(\d+)-(\d+)元\/月/);
if (rangeMatch) {
const minSalary = parseInt(rangeMatch[1], 10);
const maxSalary = parseInt(rangeMatch[2], 10);
// 转换为千位单位
const minK = Math.round(minSalary / 1000);
const maxK = Math.round(maxSalary / 1000);
salaryText = `${minK}千到${maxK}千每月`;
}
// 如果不是 "XXXXX-XXXXX元/月" 格式,保持原样
}
return `${index + 1} 个岗位,岗位名称是:${job.jobTitle},公司是:${job.companyName},薪资:${salaryText},地点:${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

@@ -209,36 +209,59 @@ export function useTTSPlayer() {
*/
function extractSpeechText(markdown) {
if (!markdown || markdown.indexOf('job-json') === -1) {
return markdown;
return markdown;
}
const jobRegex = /``` job-json\s*({[\s\S]*?})\s*```/g;
const jobs = [];
let match;
let lastJobEndIndex = 0;
let firstJobStartIndex = -1;
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);
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);
}
}
const guideText = firstJobStartIndex > 0 ?
markdown.slice(0, firstJobStartIndex).trim() : '';
markdown.slice(0, firstJobStartIndex).trim() : '';
const endingText = lastJobEndIndex < markdown.length ?
markdown.slice(lastJobEndIndex).trim() : '';
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}`;
// 处理薪资格式
let salaryText = job.salary;
if (salaryText) {
// 匹配 "XXXXX-XXXXX元/月" 格式
const rangeMatch = salaryText.match(/(\d+)-(\d+)元\/月/);
if (rangeMatch) {
const minSalary = parseInt(rangeMatch[1], 10);
const maxSalary = parseInt(rangeMatch[2], 10);
// 转换为千位单位
const minK = Math.round(minSalary / 1000);
const maxK = Math.round(maxSalary / 1000);
salaryText = `${minK}千到${maxK}千每月`;
}
// 如果不是 "XXXXX-XXXXX元/月" 格式,保持原样
}
return `${index + 1} 个岗位,岗位名称是:${job.jobTitle},公司是:${job.companyName},薪资:${salaryText},地点:${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

@@ -26,8 +26,11 @@ import renderCompanyCollectionRecord from '@/components/renderCompanyCollectionR
import renderJobViewRecord from '@/components/renderJobViewRecord/renderJobViewRecord.vue';
import MyIcons from '@/components/My-icons/my-icons.vue';
import GlobalPopup from '@/components/GlobalPopup/GlobalPopup.vue'
import FileIcon from '@/components/FileIcon/fileIcon.vue'
import FileText from '@/components/FileText/fileText.vue'
// import Tabbar from '@/components/tabbar/midell-box.vue'
// 自动导入 directives 目录下所有指令
console.log(lightAppJssdk)
const directives = import.meta.glob('./directives/*.js', {
eager: true
@@ -58,6 +61,8 @@ export function createApp() {
app.component('renderJobViewRecord', renderJobViewRecord) //渲染岗位浏览记录
app.component('MyIcons', MyIcons)
app.component('global-popup', GlobalPopup)
app.component('FileIcon', FileIcon)
app.component('FileText', FileText)
// app.component('tabbar-custom', Tabbar)
for (const path in directives) {

View File

@@ -50,7 +50,7 @@
"quickapp" : {},
/* */
"mp-weixin" : {
"appid" : "wxdbdcc6a10153c99b",
"appid" : "",
"setting" : {
"urlCheck" : false,
"es6" : true,

View File

@@ -1,5 +1,5 @@
<template>
<AppLayout title="" :use-scroll-view="false">
<AppLayout title="" :use-scroll-view="false" showHeader>
<template #headerleft v-if="isMiniProgram">
<view class="btnback">
<image src="@/static/icon/back.png" @click="navBack"></image>
@@ -75,8 +75,9 @@ import { reactive, inject, watch, ref, onMounted, computed } from 'vue';
import { onLoad, onShow } from '@dcloudio/uni-app';
import dictLabel from '@/components/dict-Label/dict-Label.vue';
import { storeToRefs } from 'pinia';
const { isMiniProgram } = storeToRefs(useUserStore());
import useUserStore from '@/stores/useUserStore';
const { isMiniProgram } = storeToRefs(useUserStore());
const { checkAuth } = useUserStore();
import useLocationStore from '@/stores/useLocationStore';
const { longitudeVal, latitudeVal } = storeToRefs(useLocationStore());
const { $api, navTo, vacanciesTo, navBack } = inject('globalFunction');
@@ -111,6 +112,9 @@ onLoad((options) => {
});
function companyCollection() {
if (!checkAuth()) {
return
}
if (dataType.value === 2) {
// 第三方数据收藏逻辑
const id = companyInfo.value.id;
@@ -265,8 +269,7 @@ image {
display: flex;
flex-direction: column
.content-top{
padding: 28rpx
padding-top: 50rpx
padding:28rpx;
display: flex
flex-direction: row
flex-wrap: nowrap

View File

@@ -112,6 +112,7 @@ import { storeToRefs } from 'pinia';
const { longitudeVal, latitudeVal } = storeToRefs(useLocationStore());
import useUserStore from '@/stores/useUserStore';
const { isMiniProgram } = storeToRefs(useUserStore());
const { checkAuth } = useUserStore();
import { playTextDirectly } from '@/hook/useTTSPlayer-all-in-one';
const isExpanded = ref(false);
@@ -204,6 +205,10 @@ function expand() {
// 取消/预约招聘会
function applyExhibitors() {
if (!checkAuth()) {
return
}
const fairId = fairInfo.value.zphID;
if (fairInfo.value.isCollection) {
$api.createRequest(`/app/fair/collection/${fairId}`, {}, 'DELETE').then((resData) => {
@@ -295,8 +300,7 @@ image {
display: flex;
flex-direction: column
.content-top{
padding: 28rpx
padding-top: 50rpx
padding:28rpx;
display: flex
flex-direction: row
flex-wrap: nowrap

View File

@@ -0,0 +1,564 @@
<template>
<uni-popup ref="popup" type="center" borderRadius="12px 12px 0 0" @change="changePopup">
<view class="popup-inner">
<view v-show="!fileCount" class="title">请扫码上传附件</view>
<view v-show="!fileCount" class="img-box">
<!-- 加载状态 -->
<view v-if="loading" class="loading-container">
<uni-load-more status="loading" :icon-size="24" :content-text="loadingText" />
</view>
<canvas canvas-id="qrcode" id="qrcode" />
</view>
<view class="tips" v-if="!loading">
已上传
<span class="num">{{ fileCount }}</span>
个文件
</view>
<view v-show="!fileCount" class="tips" v-if="!loading">请使用手机扫描二维码上传文件</view>
<view v-show="fileCount" class="file-list">
<view v-for="(file, index) in fileList" class="file-item">
<image
class="file-icon"
@click="preViewImage(file)"
v-if="isImage(file.fileSuffix)"
:src="file.fileUrl"
mode="scaleToFill"
></image>
<FileIcon v-else class="file-icon" :type="file.fileSuffix"></FileIcon>
<view class="right">
<view class="file-name">{{ file.originalName }}</view>
<FileText :type="file.fileSuffix"></FileText>
</view>
<view class="remove-btn" @click="delFile(file, index)">×</view>
</view>
</view>
<view v-show="fileCount" class="confirm-btn btn-feel" @click="handleConfirm">确认</view>
<view class="close-btn" @click="close"></view>
</view>
</uni-popup>
</template>
<script setup>
import { ref, inject, onUnmounted, watch, computed } from 'vue';
import uQRCode from '@/static/js/qrcode';
import config from '@/config';
import { onShow, onHide } from '@dcloudio/uni-app';
import { UUID } from '../../../../lib/uuid-min';
const props = defineProps({
sessionId: {
type: [Number, String],
default: '',
},
leaveFileCount: {
type: [Number, String],
default: 2,
},
});
const emit = defineEmits(['onSend', 'onClose']);
const { $api } = inject('globalFunction');
const popup = ref(null);
const loading = ref(false);
const pollingTimer = ref(null);
const isPolling = ref(false);
const isVisible = ref(false);
const uuid = ref(null);
const fileList = ref([]);
const deleting = ref(false);
const fileCount = computed(() => {
return fileList.value.length ?? 0;
});
// 计算加载文本
const loadingText = computed(() => ({
contentdown: '二维码生成中',
contentrefresh: '二维码生成中',
}));
onUnmounted(() => {
stopPolling();
clearResources();
});
function isImage(type) {
if (!type || typeof type !== 'string') return false;
const imageTypes = [
'jpg',
'jpeg',
'png',
'gif',
'bmp',
'webp',
'svg',
'tiff',
'tif',
'ico',
'apng',
'avif',
'heic',
'heif',
'jfif',
];
const lowerType = type.toLowerCase();
return imageTypes.some((item) => lowerType.includes(item));
}
function preViewImage(file) {
if (file.fileUrl) {
uni.previewImage({
urls: [file.fileUrl],
});
} else {
$api.msg('文件地址丢失');
}
}
async function delFile(file, idx) {
deleting.value = true;
try {
await $api.createRequest(`/app/kiosk/remove?sessionId=${uuid.value}&ids=${file.id}`, {}, 'post', true);
} catch (error) {
$api.msg(error);
} finally {
deleting.value = false;
}
fileList.value.splice(idx, 1);
if(fileList.value.length == 0){
open()
}
}
function open() {
uuid.value = UUID.generate();
resetState();
isVisible.value = true;
popup.value.open();
initQrCode();
}
function close() {
isVisible.value = false;
stopPolling();
popup.value.close();
resetState();
}
function changePopup(e) {
if (e.show) {
} else {
stopPolling();
emit('onClose');
}
}
function handleConfirm() {
emit('onSend', fileList.value);
close();
}
// 重置所有状态
function resetState() {
fileList.value = [];
loading.value = false;
stopPolling();
}
async function initQrCode() {
try {
loading.value = true;
// 清除之前的二维码
clearCanvas();
await makeQrcode();
// 二维码生成成功后开始轮询
if (isVisible.value) {
startPolling();
}
} catch (error) {
console.error('生成二维码失败:', error);
uni.showToast({
title: '生成二维码失败,请重试',
icon: 'none',
});
close();
} finally {
loading.value = false;
}
}
function makeQrcode() {
const protocol = window.location.protocol;
const host = window.location.host;
const isLocal = host.includes('localhost') || host.includes('127.0.0.1');
// const pathPrefix = isLocal ? '' : '/rgpp-api/all-in-one';
let pathPrefix = '';
if (host.includes('localhost') || host.includes('127.0.0.1')) {
pathPrefix = '';
} else if (host.includes('qd.zhaopinzao8dian.com')) {
// 外网测试环境
pathPrefix = '/app';
} else if (host.includes('fw.rc.qingdao.gov.cn')) {
// 青岛政务网环境
pathPrefix = '/rgpp-api/all-in-one';
} else {
pathPrefix = '';
}
const htmlPath = `${protocol}//${host}${pathPrefix}/static/upload.html?sessionId=${uuid.value}&uploadApi=${config.baseUrl}/app/kiosk/upload&fileCount=${props.leaveFileCount}&accept='.pdf,.png,.jpg'`;
// const htmlPath = `${window.location.host}/static/upload.html?sessionId=${uuid.value}&uploadApi=${
// config.baseUrl + '/app/kiosk/upload'
// }`;
// const htmlPath = `${window.location.host}/static/upload.html?sessionId=${props.sessionId}&uploadApi=${
// config.baseUrl + '/app/kiosk/upload'
// }`;
console.log(htmlPath);
console.log('剩余可上传文件数量:', props.leaveFileCount);
return new Promise((resolve, reject) => {
setTimeout(() => {
uQRCode.make({
canvasId: 'qrcode',
text: htmlPath,
size: uni.upx2px(300),
margin: 0,
backgroundColor: '#ffffff',
foregroundColor: '#1677ff',
fileType: 'png',
correctLevel: uQRCode.defaults.correctLevel,
});
resolve();
}, 100);
});
}
// 清除画布
function clearCanvas() {
const ctx = uni.createCanvasContext('qrcode');
ctx.clearRect(0, 0, uni.upx2px(300), uni.upx2px(300));
ctx.draw();
}
function startPolling() {
if (isPolling.value) return;
isPolling.value = true;
console.log('开始轮询');
// 轮询检查上传状态
const poll = async () => {
if (!isPolling.value || !isVisible.value || deleting.value) return;
const { data } = await $api.createRequest('/app/kiosk/list', { sessionId: uuid.value });
// const { data } = await $api.createRequest('/app/kiosk/list',{sessionId:props.sessionId});
if (data && data.length) {
fileList.value = data;
}
if (isPolling.value && isVisible.value) {
pollingTimer.value = setTimeout(poll, 2000); // 每2秒轮询一次
}
};
poll();
}
function stopPolling() {
isPolling.value = false;
if (pollingTimer.value) {
clearTimeout(pollingTimer.value);
pollingTimer.value = null;
}
console.log('停止轮询');
}
function clearResources() {
stopPolling();
clearCanvas();
}
defineExpose({ open, close });
</script>
<style lang="scss" scoped>
.popup-inner {
padding: 40rpx 30rpx 50rpx;
width: 520rpx;
display: flex;
flex-direction: column;
align-items: center;
position: relative;
border-radius: 20rpx;
background: linear-gradient(135deg, #fafafa 0%, #f0f7ff 100%);
box-shadow: 0 20rpx 60rpx rgba(0, 0, 0, 0.15);
}
.title {
font-size: 32rpx;
color: #1a1a1a;
margin-bottom: 30rpx;
font-weight: 600;
text-align: center;
position: relative;
padding-bottom: 16rpx;
&::after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 60rpx;
height: 4rpx;
background: linear-gradient(90deg, #4191fe 0%, #256bfa 100%);
border-radius: 2rpx;
}
}
.img-box {
margin: 0 auto 30rpx;
width: 300rpx;
height: 300rpx;
background: linear-gradient(145deg, #ffffff 0%, #f8faff 100%);
border-radius: 16rpx;
overflow: hidden;
position: relative;
box-shadow: 0 8rpx 32rpx rgba(65, 145, 254, 0.2);
padding: 20rpx;
border: 2rpx solid rgba(65, 145, 254, 0.1);
canvas {
width: 100%;
height: 100%;
border-radius: 8rpx;
transition: all 0.3s ease;
animation: canvasFadeIn 0.5s ease;
}
}
@keyframes canvasFadeIn {
0% {
opacity: 0;
transform: scale(0.95);
}
100% {
opacity: 1;
transform: scale(1);
}
}
@keyframes fadeIn {
0% {
opacity: 0;
transform: translateY(20rpx);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
.file-list {
margin-top: 30rpx;
width: 470rpx;
max-height: 60vh;
overflow-x: hidden;
overflow-y: auto;
padding-right: 10rpx;
.file-item {
width: 100%;
padding: 25rpx;
padding-right: 15rpx;
box-sizing: border-box;
border: 1px solid #bbc5d1;
display: flex;
align-items: center;
animation: fadeIn 0.5s ease;
background: linear-gradient(135deg, #ffffff 0%, #f0f8ff 100%);
border-radius: 16rpx;
margin-bottom: 20rpx;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
&::before {
content: '';
position: absolute;
left: 0;
top: 0;
width: 6rpx;
height: 100%;
background: linear-gradient(180deg, #4191fe 0%, #256bfa 100%);
border-radius: 3rpx 0 0 3rpx;
}
&:hover {
transform: translateY(-2rpx);
box-shadow: 0 8rpx 24rpx rgba(65, 145, 254, 0.15);
}
.file-icon {
width: 80rpx;
height: 80rpx;
margin-right: 20rpx;
border-radius: 12rpx;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.08);
transition: transform 0.3s ease;
&:active {
transform: scale(0.95);
}
}
.right {
flex: 1;
overflow: hidden;
min-width: 0;
.file-name {
font-size: 30rpx;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-weight: 500;
color: #1a1a1a;
margin-bottom: 8rpx;
transition: color 0.3s ease;
}
.file-size {
font-size: 24rpx;
color: #838383;
}
}
.remove-btn {
background: none;
border: none;
color: #ff6b6b;
font-size: 52rpx;
cursor: pointer;
margin-left: 20rpx;
flex-shrink: 0;
width: 60rpx;
height: 60rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: all 0.3s ease;
margin-top: -15rpx;
&:active {
background-color: rgba(255, 107, 107, 0.1);
transform: scale(0.9);
}
}
}
}
.confirm-btn {
font-weight: 500;
font-size: 32rpx;
color: #ffffff;
text-align: center;
width: 350rpx;
height: 80rpx;
line-height: 80rpx;
background: linear-gradient(135deg, #4191fe 0%, #256bfa 100%);
border-radius: 16rpx;
margin-top: 30rpx;
transition: all 0.3s ease;
box-shadow: 0 8rpx 24rpx rgba(37, 107, 250, 0.3);
position: relative;
overflow: hidden;
&::after {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
transition: left 0.6s ease;
}
&:active {
transform: scale(0.98);
box-shadow: 0 4rpx 16rpx rgba(37, 107, 250, 0.4);
&::after {
left: 100%;
}
}
}
.loading-container {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(145deg, #ffffff 0%, #f8faff 100%);
border-radius: 16rpx;
}
.tips {
font-size: 26rpx;
color: #666;
text-align: center;
line-height: 1.6;
padding: 0 20rpx;
margin-top: 10rpx;
animation: fadeIn 0.5s ease;
.num {
color: #4191fe;
font-size: 28rpx;
font-weight: 600;
margin: 0 8rpx;
position: relative;
}
}
.close-btn {
position: absolute;
right: 20rpx;
top: 20rpx;
width: 56rpx;
height: 56rpx;
border-radius: 50%;
background: linear-gradient(135deg, #ffffff 0%, #f8faff 100%);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.1);
z-index: 10;
&:active {
transform: scale(0.9) rotate(90deg);
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.15);
}
&::before,
&::after {
content: '';
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 24rpx;
height: 2rpx;
background: #5a5a68;
border-radius: 1rpx;
transition: all 0.3s ease;
}
&::before {
transform: translate(-50%, -50%) rotate(45deg);
}
&::after {
transform: translate(-50%, -50%) rotate(-45deg);
}
}
</style>

View File

@@ -36,7 +36,7 @@
<image
class="button-click"
src="@/static/icon/edit1.png"
@click="navTo('/packageA/pages/personalInfo/personalInfo')"
@click="handleNavTo('/packageA/pages/personalInfo/personalInfo')"
></image>
</view>
</view>
@@ -74,7 +74,7 @@
<image
class="icon"
src="@/static/icon/edit1.png"
@click="navTo('/packageA/pages/jobExpect/jobExpect')"
@click="handleNavTo('/packageA/pages/jobExpect/jobExpect')"
></image>
</view>
<view class="mys-text">
@@ -97,7 +97,7 @@
<view class="mys-info" style="padding: 0">
<view class="mys-h4">
<text>工作经历</text>
<view class="mys-edit-icon btn-tada" @click="navTo('/packageA/pages/workExp/workExp')">
<view class="mys-edit-icon btn-tada" @click="handleNavTo('/packageA/pages/workExp/workExp')">
<image class="icon button-click btn-feel" src="@/static/icon/plus.png"></image>
<view class="txt">添加</view>
</view>
@@ -108,7 +108,7 @@
<image
class="icon btn-feel"
src="@/static/icon/edit1.png"
@click="navTo(`/packageA/pages/workExp/workExp?id=${item.id}`)"
@click="handleNavTo(`/packageA/pages/workExp/workExp?id=${item.id}`)"
></image>
</view>
<view class="mys-text fl_box fl_justbet">
@@ -127,6 +127,7 @@
<view class="footer-button btn-feel" @click="chooseResume">上传简历</view>
</view>
</template>
<UploadQrcode ref="qrcodeRef" @onSend="handleFileSend" :leaveFileCount="1" ></UploadQrcode>
</AppLayout>
</template>
@@ -137,21 +138,35 @@ import { onLoad, onShow } from '@dcloudio/uni-app';
import { storeToRefs } from 'pinia';
import useUserStore from '@/stores/useUserStore';
import useDictStore from '@/stores/useDictStore';
const { userInfo, isMiniProgram } = storeToRefs(useUserStore());
const { getUserResume } = useUserStore();
const { userInfo, isMiniProgram,isMachineEnv } = storeToRefs(useUserStore());
const { getUserResume, checkAuth } = useUserStore();
const { getDictData, oneDictData } = useDictStore();
import { playTextDirectly } from '@/hook/useTTSPlayer-all-in-one';
import config from '@/config.js';
import UploadQrcode from './components/uploadQrcode.vue';
const qrcodeRef = ref(null);
const showNotice = ref(true);
onLoad(() => {
getUserResume();
});
function handleNavTo(url) {
if(!checkAuth()) {
return
}
navTo(url)
}
function closeNotice() {
showNotice.value = false;
}
function chooseResume() {
if(isMachineEnv.value){
qrcodeRef.value?.open()
return
}
uni.chooseImage({
sizeType: ['original', 'compressed'],
sourceType: ['album', 'camera'],
@@ -162,9 +177,11 @@ function chooseResume() {
res = JSON.parse(res);
getUserResume();
$api.msg('上传成功');
playTextDirectly('上传成功')
})
.catch((err) => {
$api.msg('上传失败');
playTextDirectly('上传失败')
});
},
fail: (error) => {},
@@ -209,6 +226,22 @@ function uploadResume(tempFilePath, loading) {
});
});
}
const handleFileSend = (rows)=>{
const file = {
url: rows[0].fileUrl,
type: rows[0].fileSuffix,
name: rows[0].originalName,
}
$api.createRequest(`/app/oss/uploadToObs?userId=${userInfo.value.userId}&filePath=${encodeURI(file.url)}`, { }, 'POST',true,).then((res) => {
getUserResume();
$api.msg('上传成功');
playTextDirectly('上传失败')
}).catch(()=>{
$api.msg('上传失败');
playTextDirectly('上传失败')
})
}
</script>
<style lang="stylus" scoped>

View File

@@ -1,5 +1,5 @@
<template>
<AppLayout title="" backGorundColor="#F4F4F4">
<AppLayout title="" backGorundColor="#F4F4F4" showHeader>
<template #headerleft v-if="isMiniProgram">
<view class="btnback">
<image src="@/static/icon/back.png" @click="navBack"></image>
@@ -187,6 +187,7 @@ import { storeToRefs } from 'pinia';
import useUserStore from '@/stores/useUserStore';
import { playTextDirectly } from '@/hook/useTTSPlayer-all-in-one';
const { isMiniProgram, hasLogin } = storeToRefs(useUserStore());
const { checkAuth } = useUserStore();
const { $api, navTo, getLenPx, parseQueryParams, navBack, isEmptyObject } = inject('globalFunction');
import config from '@/config.js';
@@ -322,10 +323,14 @@ function getCompetivetuveness(jobId) {
}
}
// 申请岗位
// 申请岗位 第三方需要认证
function jobApply() {
if (dataType.value === 2) {
// 第三方数据申请逻辑
if (!checkAuth()) {
return
}
const params = {
jobid: jobInfo.value.id,
jobname: jobInfo.value.gwmc,
@@ -359,8 +364,11 @@ function jobApply() {
}
}
// 取消/收藏岗位
// 取消/收藏岗位 都需要认证
function jobCollection() {
if (!checkAuth()) {
return
}
if (dataType.value === 2) {
// 第三方数据收藏逻辑
const id = jobInfo.value.id;

View File

@@ -63,7 +63,14 @@
{
"path": "pages/search/search",
"style": {
"navigationBarTitleText": "",
"navigationBarTitleText": "搜索",
"navigationStyle": "custom"
}
},
{
"path": "pages/auth/auth",
"style": {
"navigationBarTitleText": "实名认证",
"navigationStyle": "custom"
}
}

643
pages/auth/auth.vue Normal file
View File

@@ -0,0 +1,643 @@
<template>
<AppLayout title="">
<template #headerleft v-if="isMiniProgram">
<view >
<image class="btnback" src="@/static/icon/back.png" @click="navBack"></image>
</view>
</template>
<view class="auth-container">
<view class="auth-header">
<view class="auth-title">身份认证</view>
<view class="auth-subtitle">请填写您的身份信息进行验证</view>
</view>
<view class="auth-form">
<view class="form-item" >
<view class="form-label">
身份证号
<text v-if="idCardError" class="error-text">{{ idCardError }}</text>
</view>
<view class="form-input-wrapper" :class="{ 'error': idCardError }">
<input
class="form-input"
type="idcard"
v-model="formData.idCard"
placeholder="请输入18位身份证号码"
maxlength="18"
@input="onIdCardInput"
@blur="validateIdCard"
/>
<view class="input-clear" v-if="formData.idCard" @click="clearField('idCard')">
<my-icons type="close" size="36" color="#ccc"></my-icons>
</view>
</view>
</view>
<!-- 手机号 -->
<view class="form-item" >
<view class="form-label">
手机号
<text v-if="phoneError" class="error-text">{{ phoneError }}</text>
</view>
<view class="form-input-wrapper" :class="{ 'error': phoneError }">
<input
class="form-input"
type="number"
v-model="formData.phone"
placeholder="请输入11位手机号码"
maxlength="11"
@input="onPhoneInput"
@blur="validatePhone"
/>
<view class="input-clear" v-if="formData.phone" @click="clearField('phone')">
<my-icons type="close" size="36" color="#ccc"></my-icons>
</view>
</view>
</view>
<!-- 验证码 -->
<view class="form-item" >
<view class="form-label">
验证码
<text v-if="codeError" class="error-text">{{ codeError }}</text>
</view>
<view class="form-input-wrapper" :class="{ 'error': codeError }">
<input
class="form-input code-input"
v-model="formData.code"
placeholder="请输入验证码"
maxlength="6"
@input="onCodeInput"
@blur="validateCode"
/>
<view class="send-code-btn btn-feel"
:class="{ 'disabled': !canSendCode }"
@click="sendCode">
{{ codeBtnText }}
</view>
</view>
</view>
</view>
<!-- 认证按钮 -->
<view class="auth-btn-container">
<button class="auth-btn btn-feel" :class="{ 'disabled': !canSubmit }" @click="submitAuth">
确认认证
</button>
<view class="auth-tips">
认证信息仅用于身份验证我们将严格保护您的隐私
</view>
</view>
</view>
</AppLayout>
</template>
<script setup>
import { reactive, ref, computed, onMounted ,onUnmounted ,inject} from 'vue';
import { onLoad } from '@dcloudio/uni-app';
import { storeToRefs } from 'pinia';
import useUserStore from '@/stores/useUserStore';
import { playTextDirectly } from '@/hook/useTTSPlayer-all-in-one';
const { $api ,navBack} = inject('globalFunction');
const { isMiniProgram } = storeToRefs(useUserStore());
// 表单数据
const formData = ref({
idCard: '',
phone: '',
code: ''
});
// 错误提示
const idCardError = ref('');
const phoneError = ref('');
const codeError = ref('');
// 验证码倒计时
const codeCountdown = ref(0);
const codeTimer = ref(null);
const codeBtnText = ref('发送验证码');
const canSendCode = computed(() => {
return codeCountdown.value === 0 && formData.value.phone.length === 11 && !phoneError.value;
});
const canSubmit = computed(() => {
return formData.value.idCard && formData.value.phone && formData.value.code &&
!idCardError.value && !phoneError.value && !codeError.value;
});
// 身份证输入处理
const onIdCardInput = (e) => {
formData.value.idCard = e.detail.value.toUpperCase();
idCardError.value = '';
};
// 手机号输入处理
const onPhoneInput = (e) => {
formData.value.phone = e.detail.value.replace(/[^\d]/g, '');
phoneError.value = '';
// 如果手机号变化且验证码已发送,清空验证码
if (formData.value.code) {
formData.value.code = '';
codeError.value = '';
}
};
// 验证码输入处理
const onCodeInput = (e) => {
// formData.value.code = e.detail.value.replace(/[^\d]/g, '');
codeError.value = '';
};
// 清空字段
const clearField = (field) => {
formData.value[field] = '';
switch(field) {
case 'idCard':
idCardError.value = '';
break;
case 'phone':
phoneError.value = '';
break;
case 'code':
codeError.value = '';
break;
}
};
// 验证身份证号
const validateIdCard = () => {
if (!formData.value.idCard) {
idCardError.value = '请输入身份证号码';
return false;
}
const idCard = formData.value.idCard.trim();
if (idCard.length !== 18) {
idCardError.value = '身份证号码必须为18位';
return false;
}
// 身份证号格式校验
const idCardPattern = /^\d{17}[\dXx]$/;
if (!idCardPattern.test(idCard)) {
idCardError.value = '身份证号码格式不正确';
return false;
}
// 校验码验证
if (!validateIdCardCheckCode(idCard)) {
idCardError.value = '身份证号码校验失败';
return false;
}
idCardError.value = '';
return true;
};
// 身份证校验码验证
const validateIdCardCheckCode = (idCard) => {
if (idCard.length !== 18) return false;
const weight = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2];
const checkCodes = ['1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2'];
let sum = 0;
for (let i = 0; i < 17; i++) {
sum += parseInt(idCard[i]) * weight[i];
}
const checkCode = checkCodes[sum % 11];
return checkCode === idCard[17].toUpperCase();
};
// 验证手机号
const validatePhone = () => {
if (!formData.value.phone) {
phoneError.value = '请输入手机号码';
return false;
}
const phone = formData.value.phone.trim();
if (phone.length !== 11) {
phoneError.value = '手机号码必须为11位';
return false;
}
// 手机号格式校验
const phonePattern = /^1[3-9]\d{9}$/;
if (!phonePattern.test(phone)) {
phoneError.value = '手机号码格式不正确';
return false;
}
phoneError.value = '';
return true;
};
// 验证验证码
const validateCode = () => {
if (!formData.value.code) {
codeError.value = '请输入验证码';
return false;
}
if (formData.value.code.length < 4) {
codeError.value = '请输入正确的验证码';
return false;
}
codeError.value = '';
return true;
};
// 发送验证码
const sendCode = async () => {
if (!canSendCode.value) {
if (!formData.value.phone) {
phoneError.value = '请输入手机号码';
} else if (formData.value.phone.length !== 11) {
phoneError.value = '手机号码必须为11位';
} else if (phoneError.value) {
// 已有错误提示
}
return;
}
// 验证手机号格式
if (!validatePhone()) {
return;
}
// 开始倒计时
codeCountdown.value = 60;
updateCodeBtnText();
codeTimer.value = setInterval(() => {
codeCountdown.value--;
updateCodeBtnText();
if (codeCountdown.value <= 0) {
clearInterval(codeTimer.value);
codeTimer.value = null;
}
}, 1000);
try {
// 调用发送短信验证码接口
await $api.createRequest(`/app/sendCaptchaMessage/${formData.value.phone}`, {}, 'get');
$api.msg('验证码已发送')
playTextDirectly('验证码已发送');
} catch (error) {
// 发送失败,重置倒计时
codeCountdown.value = 0;
clearInterval(codeTimer.value);
codeTimer.value = null;
updateCodeBtnText();
}
};
// 更新验证码按钮文字
const updateCodeBtnText = () => {
if (codeCountdown.value > 0) {
codeBtnText.value = `${codeCountdown.value}s后重新发送`;
} else {
codeBtnText.value = '发送验证码';
}
};
// 提交认证
const submitAuth = async () => {
if (!canSubmit.value) {
// 触发表单验证
validateIdCard();
validatePhone();
validateCode();
return;
}
try {
// 调用身份认证接口
const params = {
idNumber: formData.value.idCard.toUpperCase(),
phone: formData.value.phone,
captchaStr: formData.value.code
};
const result = await $api.createRequest('/app/user/cert', params, 'post');
// 认证成功
$api.msg('身份认证成功')
playTextDirectly('身份认证成功');
// 保存认证信息到store
useUserStore().getUserResume().then(()=>{
setTimeout(() => {
navBack()
}, 500);
})
} catch (error) {
}
};
// 组件卸载时清理定时器
onUnmounted(() => {
if (codeTimer.value) {
clearInterval(codeTimer.value);
}
});
</script>
<style lang="scss" scoped>
.auth-container {
width: 100%;
height: 100%;
padding: 20rpx 40rpx 40rpx;
box-sizing: border-box;
display: flex;
flex-direction: column;
}
.auth-header {
text-align: center;
margin-bottom: 60rpx;
}
.auth-title {
font-size: 48rpx;
font-weight: 600;
color: #1677ff;
margin-bottom: 16rpx;
}
.auth-subtitle {
font-size: 28rpx;
color: #666;
}
.auth-form {
flex: 1;
}
.form-item {
margin-bottom: 48rpx;
animation: slideIn 0.3s ease-out;
animation-fill-mode: both;
&:nth-child(1) { animation-delay: 0.1s; }
&:nth-child(2) { animation-delay: 0.2s; }
&:nth-child(3) { animation-delay: 0.3s; }
}
.form-label {
font-size: 28rpx;
color: #333;
margin-bottom: 20rpx;
display: flex;
justify-content: space-between;
align-items: center;
}
.error-text {
font-size: 24rpx;
color: #ff4d4f;
flex: 1;
text-align: right;
margin-left: 20rpx;
}
.form-input-wrapper {
position: relative;
height: 96rpx;
border: 2rpx solid #e8e8e8;
border-radius: 16rpx;
background: #fff;
display: flex;
align-items: center;
padding: 0 30rpx;
transition: all 0.3s;
&:focus-within {
border-color: #1677ff;
box-shadow: 0 0 0 2rpx rgba(22, 119, 255, 0.1);
}
}
.form-input {
flex: 1;
height: 100%;
font-size: 32rpx;
color: #333;
&::placeholder {
color: #999;
font-size: 28rpx;
}
}
.code-input {
padding-right: 220rpx;
}
.input-clear {
width: 48rpx;
height: 48rpx;
display: flex;
align-items: center;
justify-content: center;
margin-left: 20rpx;
}
.send-code-btn {
position: absolute;
right: 20rpx;
top: 50%;
transform: translateY(-50%);
height: 64rpx;
min-width: 120rpx;
padding: 0 20rpx;
background: #1677ff;
border-radius: 12rpx;
font-size: 26rpx;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s;
&:active {
background: #0958d9;
transform: translateY(-50%) scale(0.98);
}
&.disabled {
background: #d9d9d9;
color: #999;
}
}
.auth-btn-container {
margin-top: 40rpx;
}
.auth-btn {
width: 100%;
height: 100rpx;
background: #1677ff;
border-radius: 16rpx;
font-size: 32rpx;
font-weight: 500;
color: #fff;
border: none;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s;
&:active {
background: #0958d9;
transform: scale(0.98);
}
&.disabled {
background: #d9d9d9;
color: #999;
}
}
.auth-tips {
font-size: 24rpx;
color: #999;
text-align: center;
margin-top: 24rpx;
line-height: 1.5;
}
.auth-status {
margin-top: 40rpx;
padding: 32rpx;
border-radius: 16rpx;
display: flex;
align-items: center;
justify-content: center;
gap: 20rpx;
&.success {
background: #f6ffed;
border: 1rpx solid #b7eb8f;
}
&.error {
background: #fff2f0;
border: 1rpx solid #ffccc7;
}
&.loading {
background: #f0f5ff;
border: 1rpx solid #adc6ff;
}
}
.status-text {
font-size: 28rpx;
color: #333;
}
/* 动画效果 */
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(20rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.btnback{
width: 64rpx;
height: 64rpx;
}
</style>
<style lang="stylus" scoped>
.back-button {
position: absolute;
left: 40rpx;
top: 40rpx;
width: 80rpx;
height: 80rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: rgba(0, 0, 0, 0.05);
&:active {
background: rgba(0, 0, 0, 0.1);
}
}
.loading-spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.form-input:focus {
outline: none;
}
.code-input-container {
position: relative;
&:after {
content: '';
position: absolute;
right: 200rpx;
top: 20rpx;
bottom: 20rpx;
width: 1rpx;
background: #e8e8e8;
}
}
.form-input-wrapper.error {
border-color: #ff4d4f;
animation: shake 0.5s;
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
10%, 30%, 50%, 70%, 90% { transform: translateX(-10rpx); }
20%, 40%, 60%, 80% { transform: translateX(10rpx); }
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -20,8 +20,8 @@
>
{{ item }}
</view>
<view class="chat-item self" v-if="isRecording">
<view class="message">{{ recognizedText }} {{ lastFinalText }}</view>
<view class="chat-item self" v-if="isRecording || isProcessing">
<view class="message">{{ recognizedText || (isProcessing ? '正在识别语音...' : '正在录音 '+recordingDuration+'s') }}</view>
</view>
</view>
<scroll-view class="chat-list scrollView" :scroll-top="scrollTop" :scroll-y="true" scroll-with-animation>
@@ -118,9 +118,8 @@
</view>
</view>
</view>
<view class="chat-item self" v-if="isRecording">
<!-- <view class="message">{{ recognizedText }} {{ lastFinalText }}</view> -->
<view class="message">{{ recognizedText }}</view>
<view class="chat-item self" v-if="isRecording || isProcessing">
<view class="message">{{ recognizedText || (isProcessing ? '正在识别语音...' : '正在录音 '+recordingDuration+'s') }}</view>
</view>
<view v-if="isTyping" class="self">
<text class="message msg-loading">
@@ -273,11 +272,9 @@ import PopupFeeBack from './popupbadFeeback.vue';
import UploadQrcode from './uploadQrcode.vue';
import AudioWave from './AudioWave.vue';
import WaveDisplay from './WaveDisplay.vue';
import FileIcon from './fileIcon.vue';
import FileText from './fileText.vue';
import useScreenStore from '@/stores/useScreenStore'
const screenStore = useScreenStore();
import { useAudioRecorder } from '@/hook/useRealtimeRecorder.js';
import { useRealtimeRecorderOnce } from '@/hook/useRealtimeRecorderOnce.js';
import { useAudioSpeak } from '@/hook/useAudioSpeak.js';
// 全局
const { $api, navTo, throttle } = inject('globalFunction');
@@ -292,14 +289,25 @@ import { FileValidator } from '@/utils/fileValidator.js'; //文件校验
// 语音识别
const {
isRecording,
isProcessing,
startRecording,
stopRecording,
cancelRecording,
audioDataForDisplay,
volumeLevel,
recognizedText,
lastFinalText,
} = useAudioRecorder();
recordingDuration
} = useRealtimeRecorderOnce();
watch(recognizedText, (newText) => {
console.log(newText,'++++++++')
if (newText && newText.trim() && !isProcessing.value) {
setTimeout(() => {
sendMessage(newText);
}, 300);
}
});
// 语音合成
const { speak, pause, resume, isSpeaking, isPaused, isLoading, cancelAudio,cleanup } = useAudioSpeak();
@@ -357,7 +365,7 @@ onMounted(async () => {
})
onUnmounted(()=>{
console.log('清理TTS资源')
// console.log('清理TTS资源')
cleanup()
})
@@ -384,6 +392,8 @@ function showControll(index) {
return true;
}
const sendMessage = (text) => {
const values = textInput.value || text;
showfile.value = false;
@@ -662,19 +672,12 @@ const handleTouchMove = (e) => {
}
};
const handleTouchEnd = () => {
const handleTouchEnd = async () => {
if (status.value === 'cancel') {
console.log('取消发送');
cancelRecording();
} else {
stopRecording();
if (isAudioPermission.value) {
if (recognizedText.value) {
sendMessage(recognizedText.value);
} else {
$api.msg('说话时长太短');
}
}
await stopRecording();
}
status.value = 'idle';
};
@@ -739,15 +742,15 @@ function readMarkdown(value, index) {
speak(value);
return;
}
if (isPaused.value) {
resume();
resume();
} else {
// console.log(value, speechIndex.value, index, isPaused.value)
speak(value);
pause();
}
}
function stopMarkdown(value, index) {
pause(value);
pause()
speechIndex.value = index;
}
function refreshMarkdown(index) {

View File

@@ -40,8 +40,6 @@
<script setup>
import { ref, inject, onUnmounted, watch, computed } from 'vue';
import FileIcon from './fileIcon.vue';
import FileText from './fileText.vue';
import uQRCode from '@/static/js/qrcode';
import config from '@/config';
import { onShow, onHide } from '@dcloudio/uni-app';

View File

@@ -1,395 +0,0 @@
<template>
<view class="out">
<view v-if="loading" class="loading">
<view class="semicircle-loader"></view>
</view>
<view v-else class="container" id="pixi-box" ref="pixiContainerRef"></view>
</view>
</template>
<script setup>
import { onMounted, onUnmounted, ref, nextTick, watch } from 'vue';
const props = defineProps({
tags: {
type: Array,
default: [],
},
loading: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['tag-click']);
// DOM Ref
const pixiContainerRef = ref(null);
// PIXI 变量
let app = null;
let tagsContainer = null;
let activeTagInstances = [];
// 配置数据
const tagsConfig = ref([
{ bgColor: 0x0069fe, fontColor: 0xffffff, size: 16, opacity: 1.0, angle: 0, radius: 0 },
{
bgColor: 0x87e2ec,
fontColor: 0xffffff,
size: 14,
opacity: 1,
angle: -Math.PI / 2,
radius: 68,
tailRotation: Math.PI / 2,
},
{
bgColor: 0xffebeb,
tailColor: 0xffe1e1,
fontColor: 0xff6969,
size: 11.5,
opacity: 1,
angle: -Math.PI / 4.2,
radius: 125,
tailRotation: (3 * Math.PI) / 4.5,
},
{
bgColor: 0x21ea85,
fontColor: 0xffffff,
size: 15,
opacity: 1,
angle: -Math.PI / 10,
radius: 130,
tailRotation: (3 * Math.PI) / 4.2,
},
{
bgColor: 0xebf3ff,
tailColor: 0xb9d3ff,
fontColor: 0x1d71ef,
size: 12,
opacity: 1,
angle: Math.PI / 18,
radius: 135,
tailRotation: (3 * Math.PI) / 4.3,
},
{
bgColor: 0xffd4b6,
fontColor: 0xffffff,
size: 14,
opacity: 1,
angle: Math.PI / 4.3,
radius: 100,
tailRotation: -(3 * Math.PI) / 4.5,
},
{
bgColor: 0xff9400,
fontColor: 0xffffff,
size: 14,
opacity: 1,
angle: (2 * Math.PI) / 3,
radius: 92,
tailRotation: -Math.PI / 2.4,
},
{
bgColor: 0xebf3ff,
tailColor: 0xb9d3ff,
fontColor: 0x1d71ef,
size: 10.5,
opacity: 1,
angle: (5.4 * Math.PI) / 6,
radius: 110,
tailRotation: (3 * Math.PI) / 1.79,
},
{
bgColor: 0xff6969,
fontColor: 0xffffff,
size: 14,
opacity: 1,
angle: (6.3 * Math.PI) / 5.8,
radius: 120,
tailRotation: Math.PI / 2.9,
},
{
bgColor: 0xfce9c9,
fontColor: 0xfbc55f,
size: 13,
opacity: 1,
angle: (7.2 * Math.PI) / 5.9,
radius: 120,
tailRotation: Math.PI / 3,
},
]);
watch(
() => props.tags,
(val) => {
if (val && val.length) {
tagsConfig.value.map((tag, index) => {
// console.log(val[index])
tag.name = val[index]?.job_name;
});
setTimeout(() => {
initPixi();
}, 100);
}
},
{ deep: true }
);
onMounted(async () => {
window.addEventListener('resize', handleResize);
});
onUnmounted(() => {
window.removeEventListener('resize', handleResize);
if (app) {
app.destroy(true, { children: true, texture: true, baseTexture: true });
app = null;
}
});
const getContainerDOM = () => {
const refVal = pixiContainerRef.value;
if (!refVal) return document.getElementById('pixi-box');
if (refVal.$el) return refVal.$el;
return refVal;
};
const clamp = (num, min, max) => Math.min(Math.max(num, min), max);
const initPixi = () => {
const container = getContainerDOM();
if (!container) return;
const width = container.clientWidth || 300;
const height = container.clientHeight || 300;
if (app) return;
app = new PIXI.Application({
width: width,
height: height,
backgroundAlpha: 0,
backgroundColor: 0xf5f7fa,
antialias: true,
resolution: window.devicePixelRatio || 1,
autoDensity: true,
});
app.view.style.touchAction = 'auto';
container.appendChild(app.view);
tagsContainer = new PIXI.Container();
app.stage.addChild(tagsContainer);
renderScene(width, height);
};
const renderScene = (sw, sh) => {
let ratio = window.innerWidth / 400;
if (ratio < 1) ratio = 1;
tagsContainer.removeChildren();
activeTagInstances = [];
tagsConfig.value.forEach((data, index) => {
const scaledRadius = data.radius * ratio;
let x = sw / 2 + scaledRadius * Math.cos(data.angle);
let y = sh / 2 + scaledRadius * Math.sin(data.angle);
const tag = createTag(data, index, ratio);
tagsContainer.addChild(tag);
const safeW = tag.width / 2 + 10;
const safeH = tag.height / 2 + 10;
// 强制修正 x 和 y使其不超出屏幕
x = clamp(x, safeW, sw - safeW);
y = clamp(y, safeH, sh - safeH);
tag.x = x;
tag.y = y;
// 4. 保存元数据
tag.userData = {
originalX: x,
originalY: y,
angle: data.angle,
radius: scaledRadius,
floatOffset: Math.random() * Math.PI * 2,
floatSpeed: 0.01 + Math.random() * 0.02,
floatRange: 2 * ratio + Math.random() * 2,
safeH: safeH,
};
if (data.radius > 0) {
const tail = createCometTail(
data.tailColor || data.bgColor,
data.tailRotation,
tag.width,
tag.height,
ratio
);
tag.addChildAt(tail, 0);
tag.updateTail = () => tail.updateAnim();
}
activeTagInstances.push(tag);
});
// 动画循环
app.ticker.add(() => {
const screenH = app.screen.height;
activeTagInstances.forEach((tag) => {
const meta = tag.userData;
if (meta) {
// 计算新的浮动位置
meta.floatOffset += meta.floatSpeed;
let nextY = meta.originalY + Math.sin(meta.floatOffset) * meta.floatRange;
// 再次进行边界检查
if (nextY < meta.safeH) nextY = meta.safeH;
if (nextY > screenH - meta.safeH) nextY = screenH - meta.safeH;
tag.y = nextY;
if (tag.updateTail) tag.updateTail();
}
});
});
};
const createTag = (tagData, index, ratio) => {
if (ratio > 1) ratio = ratio * 0.9;
const tagGroup = new PIXI.Container();
tagGroup.eventMode = 'static';
tagGroup.cursor = 'pointer';
tagGroup.on('pointertap', () => emit('tag-click', tagData));
const text = new PIXI.Text(tagData.name, {
fontFamily: ['PingFang SC', 'Microsoft YaHei', 'Arial'],
fontSize: tagData.size * ratio,
fill: tagData.fontColor,
padding: 4 * ratio,
resolution: 2,
});
text.anchor.set(0.5);
const paddingH = 26 * ratio;
const paddingV = 10 * ratio;
let bgWidth = text.width + paddingH;
let bgHeight = text.height + paddingV;
if (index === 0) bgWidth = Math.max(bgWidth, tagData.size * 4.5);
const bg = new PIXI.Graphics();
bg.beginFill(tagData.bgColor, tagData.opacity ?? 1);
bg.drawRoundedRect(-bgWidth / 2, -bgHeight / 2, bgWidth, bgHeight, bgHeight / 2);
bg.endFill();
tagGroup.addChild(bg);
tagGroup.addChild(text);
return tagGroup;
};
const createCometTail = (bgColor, tailRotation, parentWidth, parentHeight, ratio) => {
if (ratio > 1) ratio = ratio;
const tailGroup = new PIXI.Container();
const graphics = new PIXI.Graphics();
tailGroup.addChild(graphics);
const baseLength = 45 * ratio;
const startWidth = parentHeight + 20 * ratio;
const endWidth = parentHeight * 0.4;
let breathPhase = Math.random() * Math.PI * 2;
const breathSpeed = 0.04;
tailGroup.updateAnim = () => {
breathPhase += breathSpeed;
const breathScale = 0.85 + 0.15 * Math.sin(breathPhase);
graphics.clear();
const currentLength = baseLength * breathScale;
const cos = Math.cos(tailRotation);
const sin = Math.sin(tailRotation);
const perpX = -sin;
const perpY = cos;
const p1 = { x: perpX * (startWidth / 2), y: perpY * (startWidth / 2) };
const p2 = { x: -perpX * (startWidth / 2), y: -perpY * (startWidth / 2) };
const endCX = cos * currentLength;
const endCY = sin * currentLength;
const p3 = { x: endCX - perpX * (endWidth / 2), y: endCY - perpY * (endWidth / 2) };
const p4 = { x: endCX + perpX * (endWidth / 2), y: endCY + perpY * (endWidth / 2) };
const segments = 8;
for (let i = 0; i < segments; i++) {
const t1 = i / segments;
const t2 = (i + 1) / segments;
const alpha = 0.4 * (1 - t1);
const sp1 = { x: p1.x + (p4.x - p1.x) * t1, y: p1.y + (p4.y - p1.y) * t1 };
const sp2 = { x: p2.x + (p3.x - p2.x) * t1, y: p2.y + (p3.y - p2.y) * t1 };
const ep1 = { x: p1.x + (p4.x - p1.x) * t2, y: p1.y + (p4.y - p1.y) * t2 };
const ep2 = { x: p2.x + (p3.x - p2.x) * t2, y: p2.y + (p3.y - p2.y) * t2 };
graphics.beginFill(bgColor, alpha);
graphics.moveTo(sp1.x, sp1.y);
graphics.lineTo(sp2.x, sp2.y);
graphics.lineTo(ep2.x, ep2.y);
graphics.lineTo(ep1.x, ep1.y);
graphics.endFill();
}
};
tailGroup.updateAnim();
return tailGroup;
};
const handleResize = () => {
const container = getContainerDOM();
if (!app || !container) return;
app.destroy(true, { children: true, texture: true, baseTexture: true });
app = null;
initPixi();
};
</script>
<style scoped>
.out {
width: 100%;
height: 100%;
}
.loading {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.semicircle-loader {
width: 150rpx;
height: 150rpx;
border: 12rpx solid #f3f3f3;
border-top: 12rpx solid #3498db;
border-radius: 50%;
animation: spin 1.2s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.container {
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
color: #b9d3ff;
}
</style>

View File

@@ -64,7 +64,7 @@
<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>
<view class="title">为您匹配职位</view>
<view class="match-item-container">
<AIMatch :tags="matchTags" :loading="matchLoading" @tag-click="handleTagClick"></AIMatch>
</view>

View File

@@ -18,13 +18,8 @@
v-for="(_, index) in 2"
:key="index"
>
<IndexRefactor
v-if="index === 0"
@onShowTabbar="changeShowTabbar"
:ref="(el) => handelComponentsRef(el, index)"
/>
<IndexTwo
v-if="index === 1"
<component
:is="components[index]"
@onShowTabbar="changeShowTabbar"
:ref="(el) => handelComponentsRef(el, index)"
/>
@@ -92,12 +87,20 @@ onLoad(() => {
}
// 预加载较重页面
setTimeout(() => {
uni.preloadPage({ url: '/packageA/pages/post/post' });
uni.preloadPage({ url: '/pages/nearby/nearby' });
uni.preloadPage({ url: '/pages/chat/chat' });
uni.preloadPage({ url: '/pages/careerfair/careerfair' });
uni.preloadPage({ url: '/pages/msglog/msglog' });
uni.preloadPage({ url: '/pages/mine/mine' });
uni.preloadPage({ url: '/pages/auth/auth' });
uni.preloadPage({ url: '/pages/login/login' });
uni.preloadPage({ url: '/pages/search/search' });
uni.preloadPage({ url: '/packageA/pages/choiceness/choiceness' });
uni.preloadPage({ url: '/packageA/pages/reservation/reservation' });
uni.preloadPage({ url: '/packageA/pages/Intendedposition/Intendedposition' });
uni.preloadPage({ url: '/packageA/pages/post/post' });
uni.preloadPage({ url: '/packageA/pages/exhibitors/exhibitors' });
uni.preloadPage({ url: '/packageA/pages/UnitDetails/UnitDetails' });
}, 1000);
});

View File

@@ -1,15 +1,8 @@
<template>
<AppLayout title="就业服务程序">
<view v-if="isMachineEnv" class="alipay-login-container">
<AppLayout :title="pageTitle">
<view v-if="isMachineEnv && !hasLogin" class="alipay-login-container">
<!-- 切换 -->
<view class="login-method-switch">
<view
class="method-item"
:class="{ active: loginMethod === 'face' }"
@click="switchLoginMethod('face')"
>
扫脸登录
</view>
<view
class="method-item"
:class="{ active: loginMethod === 'qrcode' }"
@@ -17,6 +10,13 @@
>
扫码登录
</view>
<view
class="method-item"
:class="{ active: loginMethod === 'face' }"
@click="switchLoginMethod('face')"
>
扫脸登录
</view>
</view>
<view class="login-scan-area">
@@ -64,7 +64,8 @@
</view>
<view class="countdown-container">
<view class="countdown-wrapper">
<!-- 刷脸不限时间 -->
<view class="countdown-wrapper" v-if="loginMethod === 'qrcode'">
<text class="countdown-number">{{ countdown }}</text>
<text class="countdown-text">秒后自动返回</text>
</view>
@@ -193,16 +194,18 @@ import { storeToRefs } from 'pinia';
import tabcontrolVue from './components/tabcontrol.vue';
import SelectJobs from '@/components/selectJobs/selectJobs.vue';
import { reactive, inject, watch, ref, onMounted, onUnmounted } from 'vue';
import { onLoad, onShow } from '@dcloudio/uni-app';
import { onLoad, onShow, onHide } from '@dcloudio/uni-app';
import useUserStore from '@/stores/useUserStore';
import useDictStore from '@/stores/useDictStore';
import { playTextDirectly } from '@/hook/useTTSPlayer-all-in-one';
import { IncreaseRevie, FaceLoginService } from '@/common/all-in-one-listen.js';
const { $api, navTo } = inject('globalFunction');
const { loginSetToken, getUserResume } = useUserStore();
const { isMachineEnv } = storeToRefs(useUserStore());
const { isMachineEnv, hasLogin } = storeToRefs(useUserStore());
const { getDictSelectOption, oneDictData } = useDictStore();
const openSelectPopup = inject('openSelectPopup');
const qrHandler = new IncreaseRevie();
const faceService = new FaceLoginService();
// status
const selectJobsModel = ref();
const tabCurrent = ref(1);
@@ -233,20 +236,36 @@ const scanLineTop = ref(0);
let scanInterval = null;
const countdown = ref(60);
let countdownTimer = null;
const loginMethod = ref('face'); // 'qrcode' / 'face'
const loginMethod = ref('qrcode'); // 'qrcode' / 'face'
const pageTitle = ref('就享家服务程序');
onLoad((parmas) => {
getTreeselect();
if (!isMachineEnv.value) $api.msg('请完善微简历');
if (!isMachineEnv.value) {
$api.msg('请完善微简历');
}
});
onMounted(() => {
if (isMachineEnv) {
if (isMachineEnv.value) {
startCountdown();
startScanAnimation();
playTextDirectly('请进行用户登录');
faceService.start(); // 自动开始初始化流程
if (loginMethod.value === 'face') {
playTextDirectly('开始刷脸登录');
} else {
playTextDirectly('请进行登录');
}
setTimeout(() => {
if (loginMethod.value === 'face') {
handleFaceLogin();
}
if (loginMethod.value === 'qrcode') {
qrHandler.start();
}
}, 1000);
}
});
onUnmounted(() => {
stopScanAnimation();
stopCountdown();
@@ -257,7 +276,7 @@ const startCountdown = () => {
countdown.value = 60;
countdownTimer = setInterval(() => {
countdown.value--;
if (countdown.value <= 0) {
if (countdown.value <= 0 && loginMethod.value === 'qrcode') {
returnToHome();
}
}, 1000);
@@ -289,12 +308,63 @@ const cancelLogin = () => {
// 切换登录方式
const switchLoginMethod = (method) => {
if (!isMachineEnv.value) {
return;
}
if (loginMethod.value !== method) {
loginMethod.value = method;
resetCountdown();
switch (method) {
case 'qrcode':
faceService.close();
qrHandler.start();
playTextDirectly('扫码登录');
resetCountdown();
break;
case 'face':
qrHandler.close();
handleFaceLogin();
playTextDirectly('扫脸登录');
break;
}
}
};
async function handleFaceLogin() {
try {
const authCode = await faceService.startFaceLogin();
console.log('authCode获取', authCode);
if (authCode.name) {
pageTitle.value = `就享家服务程序(${authCode.name})`;
}
$api.createRequest('/app/alipay/scanLogin', authCode, 'POST').then((resData) => {
loginSetToken(resData.token).then((resume) => {
if (resume.data.jobTitleId) {
useUserStore().initSeesionId();
uni.reLaunch({
url: '/pages/index/index',
});
} else {
if (resume.data.sex) {
pageTitle.value = `就享家服务程序(${name})`;
fromValue.sex = resume.data.sex === '男' ? 0 : 1;
}
playTextDirectly('登录成功,请完善简历信息');
}
});
});
} catch (err) {
this.$api.msg(err.message);
}
}
onUnmounted(() => {
qrHandler.close();
});
onHide(() => {
qrHandler.close();
});
// 开始动画
const startScanAnimation = () => {
clearInterval(scanInterval);
@@ -336,7 +406,7 @@ function changeEducation() {
success: (_, [value]) => {
fromValue.education = value.value;
state.educationText = value.label;
console.log()
console.log();
},
});
}
@@ -389,7 +459,7 @@ function changeJobs() {
}
function nextStep() {
if(!state.experienceText) return $api.msg('请选择工作经验');
if (!state.experienceText) return $api.msg('请选择工作经验');
tabCurrent.value += 1;
}
@@ -458,9 +528,9 @@ function loginTest() {
}
function complete() {
if(!state.areaText) return $api.msg('请选择求职区域');
if(!state.jobsText.length) return $api.msg('请选择求职岗位');
if(!state.salayText) return $api.msg('请选择期望薪资');
if (!state.areaText) return $api.msg('请选择求职区域');
if (!state.jobsText.length) return $api.msg('请选择求职岗位');
if (!state.salayText) return $api.msg('请选择期望薪资');
$api.createRequest('/app/user/resume', fromValue, 'post').then((resData) => {
$api.msg('完成');
getUserResume();

View File

@@ -49,12 +49,12 @@
</view>
<view class="card-main">
<view class="main-title">服务专区</view>
<view class="main-row btn-feel" @click="selectFile">
<view class="main-row btn-feel" @click="goAuth">
<view class="row-left">
<image class="left-img" src="@/static/icon/server1.png"></image>
<text class="left-text">实名认证</text>
</view>
<view class="row-right">已认证</view>
<view class="row-right">{{isAuth ? '已认证' : '未认证'}}</view>
</view>
<view v-if="!isMachineEnv" class="main-row btn-feel" @click="handleItemClick('素质测评')">
<view class="row-left">
@@ -112,7 +112,7 @@ const { $api, navTo } = inject('globalFunction');
import useUserStore from '@/stores/useUserStore';
const popup = ref(null);
const { userInfo, Completion, counts, isMachineEnv } = storeToRefs(useUserStore());
const { userInfo, Completion, counts, isMachineEnv, isAuth } = storeToRefs(useUserStore());
import useScreenStore from '@/stores/useScreenStore';
const screenStore = useScreenStore();
@@ -144,12 +144,10 @@ function confirm() {
const isAbove90 = (percent) => parseFloat(percent) < 90;
function selectFile() {
// FileUploader.showMenuAndUpload({
// success: function (res) {
// alert('上传成功: ' + JSON.stringify(res));
// },
// });
function goAuth() {
if(!isAuth.value){
navTo('/pages/auth/auth')
}
}
function chooseFileUploadTest(pam) {}

View File

@@ -1,5 +1,6 @@
<template>
<view class="container">
<view v-show="searchFocus" class="search-mask"></view>
<view>
<view class="top">
<image
@@ -9,22 +10,50 @@
@click="navBack"
></image>
<view class="search-box">
<my-icons
class="iconsearch"
color="#666666"
type="search"
size="36"
@confirm="searchCollection"
></my-icons>
<!-- 修改为左右布局的切换按钮 -->
<view class="search-type-tabs">
<view
class="type-tab button-click"
:class="{ active: searchType === 'job' }"
@click="setSearchType('job')"
>
职位
</view>
<view
class="type-tab button-click"
:class="{ active: searchType === 'major' }"
@click="setSearchType('major')"
>
专业
</view>
</view>
<input
class="inputed"
type="text"
focus
:focus="autoFocus"
v-model="searchValue"
placeholder="搜索职位名称"
:placeholder="searchType === 'job' ? '搜索职位名称' : '搜索专业名称'"
placeholder-class="placeholder"
@input="handleInputChange"
@blur="handleInputBlur"
@focus="handleInputFocus"
@confirm="searchBtn"
/>
<!-- 联想搜索下拉列表 -->
<scroll-view
scroll-y
class="search-suggestions"
v-show="showSuggestions && filteredSuggestions.length"
>
<view
class="suggestion-item"
v-for="(item, index) in filteredSuggestions"
:key="index"
@click="selectSuggestion(item)"
>
<view class="item-txt line_1">{{ item }}</view>
</view>
</scroll-view>
</view>
<view class="search-btn button-click" @click="searchBtn">搜索</view>
</view>
@@ -86,7 +115,7 @@
</template>
<script setup>
import { inject, ref, reactive, nextTick } from 'vue';
import { inject, ref, reactive, nextTick, computed } from 'vue';
import { onLoad, onShow } from '@dcloudio/uni-app';
import SelectJobs from '@/components/selectJobs/selectJobs.vue';
const { $api, navBack, navTo } = inject('globalFunction');
@@ -100,10 +129,32 @@ import useUserStore from '@/stores/useUserStore';
const { isMiniProgram } = storeToRefs(useUserStore());
import { playTextDirectly } from '@/hook/useTTSPlayer-all-in-one';
// 搜索类型job-职位major-专业
const searchType = ref('job');
const searchValue = ref('');
const historyList = ref([]);
const listCom = ref([]);
const showSuggestions = ref(false);
const searchFocus = ref(false);
const autoFocus = ref(false)
// 专业数据源(示例数据,可以替换为实际数据)
const majorDataSource = ref([]);
// 计算属性:过滤后的联想列表
const filteredSuggestions = computed(() => {
if (!searchValue.value || searchType.value !== 'major') {
return [];
}
const searchText = searchValue.value.toLowerCase();
try {
return majorDataSource.value.filter((item) => item.toLowerCase().includes(searchText));
} catch (error) {
return [];
}
});
const pageState = reactive({
page: 0,
total: 0,
@@ -117,7 +168,7 @@ const isLoaded = ref(false);
const waterfallsFlowRef = ref(null);
const loadmoreRef = ref(null);
const currentTab = ref(0);
// 响应式搜索条件(可以被修改)
const searchParams = ref({});
const pageSize = ref(10);
@@ -158,16 +209,76 @@ const { columnCount, columnSpace } = useColumnCount(() => {
});
onLoad((options) => {
if(options.keyWord){
searchValue.value = decodeURIComponent(options.keyWord)
searchBtn()
if (options.keyWord) {
searchValue.value = decodeURIComponent(options.keyWord);
searchBtn();
}else{
//从标签点进来不自动聚焦
autoFocus.value = true
}
let arr = uni.getStorageSync('searchList');
if (arr) {
historyList.value = uni.getStorageSync('searchList');
}
getMajorDataSource();
});
function getMajorDataSource() {
const LoadCache = (resData) => {
if (resData.code === 200) {
majorDataSource.value = resData.data;
console.log(majorDataSource.value);
}
};
$api.createRequestWithCache('/app/common/majorManagement/getMajorList', {}, 'GET', false, LoadCache).then(
LoadCache
);
}
// 设置搜索类型并触发搜索
function setSearchType(type) {
if (searchType.value === type) return;
searchType.value = type;
// 如果输入框有值且当前是专业搜索,显示联想列表
if (searchValue.value && searchType.value === 'major' && searchFocus.value) {
showSuggestions.value = true;
} else {
showSuggestions.value = false;
}
// 如果有搜索值,触发搜索
if (searchValue.value) {
// 重置页码
pageState.page = 0;
// 触发搜索
playTextDirectly('正在为您查找岗位');
// 根据搜索类型设置不同的参数
if (searchType.value === 'job') {
searchParams.value = {
jobTitle: searchValue.value,
};
} else {
searchParams.value = {
majorTitle: searchValue.value,
};
}
if (currentTab.value === 0) {
getJobList('refresh');
} else {
refresh();
waterfallsFlowRef.value?.refresh?.();
}
// 隐藏联想列表
showSuggestions.value = false;
}
}
function changeType(type) {
if (currentTab.value === type) return;
switch (type) {
@@ -182,28 +293,84 @@ function changeType(type) {
break;
}
}
function searchFn(item) {
searchValue.value = item;
searchBtn();
}
function handleInputChange(e) {
const val = e.detail.value;
searchValue.value = val;
// 如果是专业搜索且输入框有值,显示联想列表
if (searchType.value === 'major' && val && searchFocus.value) {
showSuggestions.value = true;
} else {
showSuggestions.value = false;
}
}
function handleInputFocus() {
searchFocus.value = true;
// 如果是专业搜索且输入框有值,显示联想列表
if (searchType.value === 'major' && searchValue.value) {
showSuggestions.value = true;
}
}
function handleInputBlur() {
searchFocus.value = false;
// 延迟隐藏联想列表,以便点击项能触发
setTimeout(() => {
showSuggestions.value = false;
}, 100);
}
// 选择联想项
function selectSuggestion(item) {
searchValue.value = item;
showSuggestions.value = false;
// 直接触发搜索
searchBtn();
}
function searchBtn() {
if (!searchValue.value) {
return;
}
playTextDirectly('正在为您查找岗位')
playTextDirectly('正在为您查找岗位');
// 保存到历史记录(仅当搜索成功时保存)
historyList.value.unshift(searchValue.value);
historyList.value = unique(historyList.value);
uni.setStorageSync('searchList', historyList.value);
searchParams.value = {
jobTitle: searchValue,
};
// 根据搜索类型设置不同的参数
if (searchType.value === 'job') {
// 职位搜索
searchParams.value = {
jobTitle: searchValue.value,
};
} else {
// 专业搜索
searchParams.value = {
majorTitle: searchValue.value,
};
}
// 重置页码
pageState.page = 0;
if (currentTab.value === 0) {
getJobList('refresh');
} else {
refresh();
waterfallsFlowRef.value?.refresh?.();
}
// 隐藏联想列表
showSuggestions.value = false;
}
function searchCollection(e) {
@@ -254,13 +421,25 @@ function getJobList(type = 'add') {
pageState.page = 1;
pageState.maxPage = 2;
}
// 根据搜索类型构建参数
let params = {
current: pageState.page,
pageSize: pageState.pageSize,
...pageState.search,
jobTitle: searchValue.value,
};
// 移除之前的搜索参数,根据当前搜索类型添加
if (searchType.value === 'job') {
params.jobTitle = searchValue.value;
// 确保没有majorTitle参数
delete params.majorTitle;
} else {
params.majorTitle = searchValue.value;
// 确保没有jobTitle参数
delete params.jobTitle;
}
$api.createRequest('/app/job/list', params, 'GET', true).then((resData) => {
const { rows, total } = resData;
if (type === 'add') {
@@ -271,6 +450,16 @@ function getJobList(type = 'add') {
listCom.value = [...listCom.value, ...reslist];
} else {
listCom.value = [...rows];
// 一体机语音提示
if (searchType.value === 'major') {
if (rows.length) {
$api.msg('为您找到相关专业的职位');
playTextDirectly(`为您找到相关专业的职位`);
} else {
$api.msg('未找到相关专业的职位,请尝试其他关键词');
playTextDirectly(`未找到相关专业的职位,请尝试其他关键词`);
}
}
}
pageState.total = resData.total;
pageState.maxPage = Math.ceil(pageState.total / pageState.pageSize);
@@ -299,7 +488,6 @@ function dataToImg(data) {
.Detailscroll-view{
flex: 1
overflow: hidden
}
.container{
display: flex
@@ -367,8 +555,6 @@ function dataToImg(data) {
justify-content: space-between
background-color: #fff;
padding: 20rpx 20rpx;
position: sticky;
top: 0
.btnback{
width: 60rpx;
height: 60rpx;
@@ -376,27 +562,90 @@ function dataToImg(data) {
.search-box{
flex: 1;
padding: 0 24rpx 0 6rpx;
position: relative
position: relative;
z-index: 10;
.search-type-tabs {
background: #FFFFFF;
position: absolute;
top: 50%;
left: 21rpx;
transform: translate(0, -50%);
display: flex;
align-items: center;
border-radius: 8rpx;
padding: 4rpx;
z-index: 2;
border: 1rpx solid #E0E0E0;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
border-radius: 60rpx
.type-tab {
padding: 10rpx 24rpx;
font-size: 24rpx;
color: #666666;
border-radius: 30rpx;
transition: all 0.2s ease;
white-space: nowrap;
&.active {
background: #256BFA;
color: #FFFFFF;
font-weight: 500;
}
}
.type-tab:first-child {
margin-right: 1rpx;
}
}
.inputed {
padding-left: 30rpx
width: 100%;
background: #F8F8F8;
font-size: 28rpx;
font-family: PingFang SC;
font-weight: 400;
line-height: 36rpx;
color: #666666;
padding: 0 30rpx 0 80rpx;
box-sizing: border-box;
width: 100%;
background: #F8F8F8;
font-size: 28rpx;
font-family: PingFang SC;
font-weight: 400;
line-height: 36rpx;
color: #666666;
padding: 0 30rpx 0 230rpx;
box-sizing: border-box;
height: 80rpx;
background: #F5F5F5;
border-radius: 75rpx 75rpx 75rpx 75rpx;
border-radius: 73rpx;
}
.iconsearch{
position: absolute
top: 50%
left: 36rpx
transform: translate(0, -50%)
.search-suggestions {
position: absolute;
top: 105%;
width:calc(100% - 24rpx - 6rpx);
background: #FFFFFF;
border: 2rpx solid #ECECEC;
border-top: 0;
z-index: 10;
border-radius: 30rpx
max-height: 40vh;
overflow hidden;
.suggestion-item {
height: 80rpx;
padding: 0rpx 24rpx;
display: flex;
align-items: center;
justify-items: flex-start;
border-top: 2rpx dashed #e3e3e3;
.item-txt {
font-size: 28rpx;
color: #333333;
width: 100%;
}
}
.suggestion-item:hover {
background: #e5e5e5;
}
.suggestion-item:first-child {
border-top: none;
}
}
}
.search-btn {
@@ -473,4 +722,13 @@ function dataToImg(data) {
font-size: 24rpx;
color: #6C7282;
margin-top: 6rpx;
</style>
.search-mask{
position fixed;
width 100vw;
height:100vh;
top:0;
left:0
z-index:9;
background: #33333355;
}
</style>

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

View File

@@ -615,8 +615,7 @@
<div class="upload-icon">📁</div>
<div class="upload-text" id="uploadText">点击选择文件</div>
<div class="upload-hint" id="uploadHint">支持图片、文档、文本等格式</div>
<input type="file" id="fileInput" class="file-input" multiple
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.csv,.ppt,.pptx,.txt,.md" />
<input type="file" id="fileInput" class="file-input" multiple />
</div>
</div>
@@ -663,6 +662,9 @@
const sessionId = urlParams.get('sessionId');
const uploadApi = urlParams.get('uploadApi');
const fileCountParam = urlParams.get('fileCount');
const acceptParam = urlParams.get('accept');
const DEFAULT_ACCEPT = 'image/*,.pdf,.doc,.docx,.xls,.xlsx,.csv,.ppt,.pptx,.txt,.md';
const ACCEPT_TYPES = acceptParam ? acceptParam : DEFAULT_ACCEPT;
// 配置常量
const MAX_FILE_COUNT = fileCountParam ? parseInt(fileCountParam) : 2; // 从URL参数获取默认为2
@@ -671,6 +673,7 @@
console.log('Session ID:', sessionId);
console.log('Upload API:', uploadApi);
console.log('Max file count:', MAX_FILE_COUNT);
console.log('允许上传类型:', ACCEPT_TYPES);
// DOM元素
const uploadArea = document.getElementById('uploadArea');
@@ -692,6 +695,10 @@
const limitWarningInfo = document.getElementById('limitWarningInfo');
const limitWarningText = document.getElementById('limitWarningText');
//设置文件类型
fileInput.accept = ACCEPT_TYPES;
// 状态变量
let selectedFiles = [];
let isUploading = false;
@@ -1079,7 +1086,7 @@
if (MAX_FILE_COUNT <= 0) {
// 如果没有设置文件数量限制,始终可以上传
uploadText.textContent = '点击选择文件';
uploadHint.innerHTML = '支持图片、文档、文本等格式<br />文件大小不超过10MB';
uploadHint.innerHTML = `支持格式: ${ACCEPT_TYPES}<br />文件大小不超过10MB`;
fileInput.disabled = false;
} else if (selectedFiles.length >= MAX_FILE_COUNT) {
uploadText.textContent = `已达到最大文件数量(${MAX_FILE_COUNT}个)`;
@@ -1087,8 +1094,7 @@
fileInput.disabled = true;
} else {
uploadText.textContent = '点击选择文件';
uploadHint.innerHTML =
`支持图片、文档、文本等格式<br />最多${MAX_FILE_COUNT}个文件每个不超过10MB<br />已上传: ${uploadedCount}/${MAX_FILE_COUNT}`;
uploadHint.innerHTML = `支持格式: ${ACCEPT_TYPES}<br />最多${MAX_FILE_COUNT}个文件每个不超过10MB`;
fileInput.disabled = false;
}

View File

@@ -97,7 +97,7 @@ export const useRecommedIndexedDBStore = defineStore("indexedDB", () => {
async function addRecord(payload) {
const totalRecords = await baseDB.db.getRecordCount(tableName.value);
if (totalRecords >= total.value) {
console.log(`数据超过 ${total.value} 条,删除最早的一条...`);
console.log(`数据超过 ${total.value} 条,删除最早的一条...`);
await baseDB.db.deleteOldestRecord(tableName.value);
}
if (!baseDB.isDBReady) await baseDB.initDB();

View File

@@ -6,9 +6,9 @@ import {
computed
} from 'vue';
// #ifndef MP-WEIXIN
import wideScreenStyles from '../common/wide-screen.css?inline';
// #endif
// 屏幕检测管理器类
class ScreenDetectionManager {
@@ -70,7 +70,7 @@ class ScreenDetectionManager {
count: 1
}
}
}
// 动态加载 CSS
@@ -85,11 +85,11 @@ class ScreenDetectionManager {
// 添加加载成功/失败监听
this.cssLink.onload = () => {
console.log('🎨 宽屏 CSS 文件加载成功');
console.log('宽屏 CSS 文件加载成功');
};
this.cssLink.onerror = () => {
console.error('宽屏 CSS 文件加载失败');
console.error('宽屏 CSS 文件加载失败');
this.cssLink = null;
};
@@ -108,7 +108,7 @@ class ScreenDetectionManager {
try {
this.cssLink.parentNode.removeChild(this.cssLink);
this.cssLink = null;
console.log('🗑️ 宽屏 CSS 文件已移除');
console.log('宽屏 CSS 文件已移除');
return true;
} catch (error) {
console.error('移除 CSS 失败:', error);

View File

@@ -2,7 +2,8 @@ import {
defineStore
} from 'pinia';
import {
ref
ref,
inject
} from 'vue'
import {
createRequest
@@ -17,6 +18,7 @@ import {
import {
msg,
$api,
navTo
} from '../common/globalFunction';
import baseDB from '@/utils/db.js';
@@ -61,6 +63,7 @@ const useUserStore = defineStore("user", () => {
const counts = ref({})
const isMiniProgram = ref(false)
const isMachineEnv = ref(false)
const isAuth = ref(false) //是否认证(身份证+手机号)
const login = (value) => {
hasLogin.value = true;
@@ -135,6 +138,19 @@ const useUserStore = defineStore("user", () => {
userInfo.value = values.data;
// role.value = values.role;
hasLogin.value = true;
isAuth.value = values.data?.isCert == 0 ? true : false //是否认证 0已认证
}
const checkAuth = () => {
if (!hasLogin.value) {
logOut()
return false //验证失败
}
if (hasLogin.value && !isAuth.value) {
navTo('/pages/auth/auth')
return false //验证失败
}
return true // 验证通过
}
@@ -186,7 +202,9 @@ const useUserStore = defineStore("user", () => {
isMiniProgram,
changMiniProgramAppStatus,
changMachineEnv,
isMachineEnv
isMachineEnv,
isAuth,
checkAuth
}
}, {
unistorage: true,

View File

@@ -39,7 +39,7 @@ const useChatGroupDBStore = defineStore("messageGroup", () => {
setTimeout(async () => {
if (!baseDB.isDBReady) await baseDB.initDB();
const result = await baseDB.db.getAll(tableName.value);
console.log('result', result)
// console.log('result', result)
// 1、判断是否有数据没数据请求服务器
if (result.length) {
console.warn('本地数据库存在数据')

227
utils/IDCardParser.js Normal file
View File

@@ -0,0 +1,227 @@
// 使用示例:
// import IDCardParser from '@/utils/IDCardParser';
// const handleCheck = (idStr) => {
// const parser = new IDCardParser(idStr);
// const result = parser.getInfo();
// if(result.valid) {
// // 填充表单
// console.log(result.age, result.gender);
// } else {
// alert(result.message);
// }
// }
class IDCardParser {
constructor(idNumber) {
this.idNumber = idNumber ? idNumber.trim().toUpperCase() : "";
this.provinceMap = {
11: "北京市",
12: "天津市",
13: "河北省",
14: "山西省",
15: "内蒙古自治区",
21: "辽宁省",
22: "吉林省",
23: "黑龙江省",
31: "上海市",
32: "江苏省",
33: "浙江省",
34: "安徽省",
35: "福建省",
36: "江西省",
37: "山东省",
41: "河南省",
42: "湖北省",
43: "湖南省",
44: "广东省",
45: "广西壮族自治区",
46: "海南省",
50: "重庆市",
51: "四川省",
52: "贵州省",
53: "云南省",
54: "西藏自治区",
61: "陕西省",
62: "甘肃省",
63: "青海省",
64: "宁夏回族自治区",
65: "新疆维吾尔自治区",
71: "台湾省",
81: "香港特别行政区",
82: "澳门特别行政区"
};
// 初始化验证结果
const validation = this._validate();
this.isValid = validation.isValid;
this.errorMsg = validation.msg;
// 如果合法,预先解析数据
if (this.isValid) {
this._parseData();
}
}
/**
* 核心校验逻辑
*/
_validate() {
// 1. 基础正则 (18位末位数字或X)
if (!/^\d{17}[\d|X]$/.test(this.idNumber)) {
return {
isValid: false,
msg: "格式错误必须是18位末位为数字或X"
};
}
// 2. 省份校验
const provinceCode = parseInt(this.idNumber.substring(0, 2));
if (!this.provinceMap[provinceCode]) {
return {
isValid: false,
msg: "省份代码错误"
};
}
// 3. 日期合法性严格校验
const year = parseInt(this.idNumber.substring(6, 10));
const month = parseInt(this.idNumber.substring(10, 12));
const day = parseInt(this.idNumber.substring(12, 14));
const date = new Date(year, month - 1, day);
// JS Date会自动纠错(例如2月30会变成3月),所以必须反向比对
if (
date.getFullYear() !== year ||
date.getMonth() + 1 !== month ||
date.getDate() !== day
) {
return {
isValid: false,
msg: "出生日期无效"
};
}
// 检查年份范围可选例如限制在1900年以后
if (year < 1900 || date > new Date()) {
return {
isValid: false,
msg: "出生日期不在合理范围内"
};
}
// 4. 校验码计算 (ISO 7064:1983.MOD 11-2)
if (!this._checkSum()) {
return {
isValid: false,
msg: "校验码错误(身份证可能无效)"
};
}
return {
isValid: true,
msg: "验证通过"
};
}
/**
* 校验码算法
*/
_checkSum() {
const factors = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2];
const checkCodes = ['1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2'];
let sum = 0;
for (let i = 0; i < 17; i++) {
sum += parseInt(this.idNumber[i]) * factors[i];
}
const mod = sum % 11;
return checkCodes[mod] === this.idNumber[17];
}
/**
* 解析内部数据
*/
_parseData() {
const year = parseInt(this.idNumber.substring(6, 10));
const month = parseInt(this.idNumber.substring(10, 12));
const day = parseInt(this.idNumber.substring(12, 14));
this.birthDate = new Date(year, month - 1, day);
this.province = this.provinceMap[parseInt(this.idNumber.substring(0, 2))];
// 性别第17位奇数男偶数女
const genderCode = parseInt(this.idNumber.substring(16, 17));
this.gender = genderCode % 2 !== 0 ? "男" : "女";
}
/**
* 获取所有信息
*/
getInfo() {
if (!this.isValid) {
return {
valid: false,
message: this.errorMsg
};
}
return {
valid: true,
idNumber: this.idNumber,
province: this.province,
birthday: this._formatDate(this.birthDate),
gender: this.gender,
age: this._calculateAge(),
zodiac: this._getZodiac(), // 生肖
constellation: this._getConstellation() // 星座
};
}
_formatDate(date) {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
_calculateAge() {
const today = new Date();
let age = today.getFullYear() - this.birthDate.getFullYear();
const m = today.getMonth() - this.birthDate.getMonth();
// 如果没过生日年龄减1
if (m < 0 || (m === 0 && today.getDate() < this.birthDate.getDate())) {
age--;
}
return age;
}
_getZodiac() {
// 猴鸡狗猪鼠牛虎兔龙蛇马羊 (对应余数 0-11)
const zodiacs = "猴鸡狗猪鼠牛虎兔龙蛇马羊";
return zodiacs.charAt(this.birthDate.getFullYear() % 12);
}
_getConstellation() {
const month = this.birthDate.getMonth() + 1;
const day = this.birthDate.getDate();
// 星座分割日
const dates = [20, 19, 21, 20, 21, 22, 23, 23, 23, 24, 23, 22];
const constellations = [
"摩羯座", "水瓶座", "双鱼座", "白羊座", "金牛座", "双子座",
"巨蟹座", "狮子座", "处女座", "天秤座", "天蝎座", "射手座", "摩羯座"
];
if (day < dates[month - 1]) {
return constellations[month - 1];
} else {
return constellations[month];
}
}
}
export default IDCardParser;

View File

@@ -1,5 +1,4 @@
import IndexedDBHelper from '@/common/IndexedDBHelper.js'
import UniStorageHelper from '@/common/UniStorageHelper.js'
import useChatGroupDBStore from '@/stores/userChatGroupStore'
import config from '@/config'
@@ -74,7 +73,7 @@ class BaseStore {
// #ifndef H5
this.db = new UniStorageHelper(this.dbName, config.DBversion);
// #endif
return this.db.openDB([{
name: 'record',
keyPath: "id",
@@ -111,15 +110,8 @@ class BaseStore {
}
async clearDB() {
// #ifdef H5
return new IndexedDBHelper().deleteDB(this.dbName);
// #endif
// #ifndef H5
return new UniStorageHelper().deleteDB(this.dbName);
// #endif
// 修正拼写错误并优化 Promise 写法
return new IndexedDBHelper().deleteDB(this.dbName);
}
/**

View File

@@ -18,7 +18,9 @@ const needToEncrypt = [
["post", "/app/user/experience/edit"],
["post", "/app/user/experience/delete"],
["get", "/app/user/experience/getSingle/{value}"],
["get", "/app/user/experience/list"]
["get", "/app/user/experience/list"],
["post", "/app/alipay/scanLogin"],
["post", "/app/user/cert"],
]
/**
@@ -137,6 +139,7 @@ export function createRequest(url, data = {}, method = 'GET', loading = false, h
} = resData.data
if (code === 200) {
resolve(resData.data)
// console.log(resData.data.data,'接口解密')
return
}
if (msg) {