Compare commits
35 Commits
c0c93fffc4
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| bffd1cba7e | |||
| 3d6297231c | |||
| f78a0b84d8 | |||
| e0c5ad5cc9 | |||
| 6393e78ed5 | |||
| 14af7b8b20 | |||
| 9769f486f0 | |||
| f6b7755e32 | |||
| e114675eba | |||
| 8b98e476d8 | |||
|
|
0d136943a3 | ||
| 3f66ceece9 | |||
| 2c5ff4220a | |||
|
|
ee6813f767 | ||
|
|
bd2538aea4 | ||
| 29615c394a | |||
| 369d065008 | |||
| 433352c902 | |||
| 9ff9d9db5e | |||
| 5ab136f1c5 | |||
| 0704896694 | |||
| 3065618610 | |||
| 87fcbfe5cc | |||
| aa87305301 | |||
| 9eaca78b19 | |||
| 850d4e5fbc | |||
| 22487c304d | |||
| 1b332fbad8 | |||
|
|
1673b63cda | ||
|
|
8280cc9fae | ||
|
|
ef669f23be | ||
|
|
e9674fcb40 | ||
| ff71c50b9c | |||
| c7f42418ba | |||
| b0a62b6921 |
12
App.vue
12
App.vue
@@ -42,14 +42,14 @@ onLaunch((options) => {
|
|||||||
useUserStore().changMiniProgramAppStatus(true);
|
useUserStore().changMiniProgramAppStatus(true);
|
||||||
useUserStore().changMachineEnv(true);
|
useUserStore().changMachineEnv(true);
|
||||||
(function loop() {
|
(function loop() {
|
||||||
console.log('📍一体机尝试获取定位');
|
console.log('一体机尝试获取定位');
|
||||||
useLocationStore()
|
useLocationStore()
|
||||||
.getLocation()
|
.getLocation()
|
||||||
.then(({ longitude, latitude }) => {
|
.then(({ longitude, latitude }) => {
|
||||||
console.log(`✅一体机获取定位成功:lng:${longitude},lat${latitude}`);
|
console.log(`一体机获取定位成功:lng:${longitude},lat${latitude}`);
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.log('❌一体机获取定位失败,30s后尝试重新获取');
|
console.log('一体机获取定位失败,30s后尝试重新获取');
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
loop();
|
loop();
|
||||||
}, 3000);
|
}, 3000);
|
||||||
@@ -88,7 +88,7 @@ onHide(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
function handleInactivity() {
|
function handleInactivity() {
|
||||||
console.log('【全局】60秒无操作,执行安全逻辑');
|
console.warn('60秒无操作,执行安全逻辑');
|
||||||
if (inactivityModalTimer) {
|
if (inactivityModalTimer) {
|
||||||
clearTimeout(inactivityModalTimer);
|
clearTimeout(inactivityModalTimer);
|
||||||
inactivityModalTimer = null;
|
inactivityModalTimer = null;
|
||||||
@@ -120,7 +120,7 @@ function handleInactivity() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2. 启动 10 秒倒计时
|
// 2. 启动 20 秒倒计时
|
||||||
inactivityModalTimer = setTimeout(() => {
|
inactivityModalTimer = setTimeout(() => {
|
||||||
inactivityModalTimer = null;
|
inactivityModalTimer = null;
|
||||||
console.log('【自动登出】10秒无响应,强制清理状态');
|
console.log('【自动登出】10秒无响应,强制清理状态');
|
||||||
@@ -129,7 +129,7 @@ function handleInactivity() {
|
|||||||
uni.$emit('hide-global-popup');
|
uni.$emit('hide-global-popup');
|
||||||
|
|
||||||
performLogout();
|
performLogout();
|
||||||
}, 10000);
|
}, 20000);
|
||||||
} else {
|
} else {
|
||||||
inactivityManager?.resume();
|
inactivityManager?.resume();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,10 +101,6 @@ export class FaceLoginService {
|
|||||||
this._retryTimer = null;
|
this._retryTimer = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 【新增】生命周期 - 开始
|
|
||||||
* 统一入口,通常在页面加载 (onLoad/onMounted) 时调用
|
|
||||||
*/
|
|
||||||
start(scope = null) {
|
start(scope = null) {
|
||||||
// 启动前先清理可能存在的旧状态
|
// 启动前先清理可能存在的旧状态
|
||||||
this.close();
|
this.close();
|
||||||
@@ -113,10 +109,6 @@ export class FaceLoginService {
|
|||||||
this.init(scope);
|
this.init(scope);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 【新增】生命周期 - 关闭
|
|
||||||
* 统一出口,通常在页面卸载 (onUnload/onUnmounted) 时调用
|
|
||||||
*/
|
|
||||||
close() {
|
close() {
|
||||||
// 1. 清除正在等待执行的重试定时器
|
// 1. 清除正在等待执行的重试定时器
|
||||||
if (this._retryTimer) {
|
if (this._retryTimer) {
|
||||||
@@ -131,10 +123,6 @@ export class FaceLoginService {
|
|||||||
console.log("[FaceLogin] 服务已关闭");
|
console.log("[FaceLogin] 服务已关闭");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 1. 初始化刷脸服务
|
|
||||||
* (已修改:增加了对定时器的管理)
|
|
||||||
*/
|
|
||||||
async init(scope = null, retryCount = 1) {
|
async init(scope = null, retryCount = 1) {
|
||||||
const params = {
|
const params = {
|
||||||
action: "initFace",
|
action: "initFace",
|
||||||
@@ -154,7 +142,6 @@ export class FaceLoginService {
|
|||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[FaceLogin] 初始化失败: ${err.message}`);
|
console.error(`[FaceLogin] 初始化失败: ${err.message}`);
|
||||||
// 如果还有重试次数,且服务没有被手动 close 关闭
|
|
||||||
if (retryCount > 0) {
|
if (retryCount > 0) {
|
||||||
console.log("[FaceLogin] 3秒后尝试重新初始化...");
|
console.log("[FaceLogin] 3秒后尝试重新初始化...");
|
||||||
this._retryTimer = setTimeout(() => {
|
this._retryTimer = setTimeout(() => {
|
||||||
@@ -162,8 +149,7 @@ export class FaceLoginService {
|
|||||||
this.init(scope, retryCount - 1); // 递归重试
|
this.init(scope, retryCount - 1); // 递归重试
|
||||||
}, 3000);
|
}, 3000);
|
||||||
} else {
|
} else {
|
||||||
// 重试耗尽,抛出错误
|
// throw err; // 视业务逻辑决定是否阻断
|
||||||
// throw err; // 可选:视业务逻辑决定是否阻断
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -172,11 +158,9 @@ export class FaceLoginService {
|
|||||||
* 2. 唤起 1:N 刷脸并获取 AuthCode
|
* 2. 唤起 1:N 刷脸并获取 AuthCode
|
||||||
*/
|
*/
|
||||||
async startFaceLogin() {
|
async startFaceLogin() {
|
||||||
// 防御性编程:确保已初始化
|
|
||||||
if (!this.isInitialized) {
|
if (!this.isInitialized) {
|
||||||
console.warn("[FaceLogin] 服务未初始化,尝试自动补救初始化...");
|
console.warn("[FaceLogin] 服务未初始化,尝试自动补救初始化...");
|
||||||
try {
|
try {
|
||||||
// 尝试一次即时初始化(不重试)
|
|
||||||
await this.init(null, 0);
|
await this.init(null, 0);
|
||||||
if (!this.isInitialized) throw new Error("初始化未完成");
|
if (!this.isInitialized) throw new Error("初始化未完成");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -218,9 +202,6 @@ export class FaceLoginService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 基础 Bridge 调用封装
|
|
||||||
*/
|
|
||||||
_bridgeCall(params) {
|
_bridgeCall(params) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
if (typeof hh === 'undefined' || !hh.call) {
|
if (typeof hh === 'undefined' || !hh.call) {
|
||||||
|
|||||||
619
hook/useAudioSpeak-copy.js
Normal file
619
hook/useAudioSpeak-copy.js
Normal 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
657
hook/useRealtimeRecorderOnce.js
Normal file
657
hook/useRealtimeRecorderOnce.js
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -222,39 +222,61 @@ export function useTTSPlayer() {
|
|||||||
* 文本提取工具函数 (保持不变)
|
* 文本提取工具函数 (保持不变)
|
||||||
*/
|
*/
|
||||||
function extractSpeechText(markdown) {
|
function extractSpeechText(markdown) {
|
||||||
if (!markdown || typeof markdown !== 'string') return ''; // 增加类型安全检查
|
if (!markdown || markdown.indexOf('job-json') === -1) {
|
||||||
if (markdown.indexOf('job-json') === -1) {
|
return markdown;
|
||||||
return markdown;
|
|
||||||
}
|
}
|
||||||
const jobRegex = /``` job-json\s*({[\s\S]*?})\s*```/g;
|
const jobRegex = /``` job-json\s*({[\s\S]*?})\s*```/g;
|
||||||
const jobs = [];
|
const jobs = [];
|
||||||
let match;
|
let match;
|
||||||
let lastJobEndIndex = 0;
|
let lastJobEndIndex = 0;
|
||||||
let firstJobStartIndex = -1;
|
let firstJobStartIndex = -1;
|
||||||
|
|
||||||
while ((match = jobRegex.exec(markdown)) !== null) {
|
while ((match = jobRegex.exec(markdown)) !== null) {
|
||||||
const jobStr = match[1];
|
const jobStr = match[1];
|
||||||
try {
|
try {
|
||||||
const job = JSON.parse(jobStr);
|
const job = JSON.parse(jobStr);
|
||||||
jobs.push(job);
|
jobs.push(job);
|
||||||
if (firstJobStartIndex === -1) {
|
if (firstJobStartIndex === -1) {
|
||||||
firstJobStartIndex = match.index;
|
firstJobStartIndex = match.index;
|
||||||
}
|
|
||||||
lastJobEndIndex = jobRegex.lastIndex;
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('JSON 解析失败', e);
|
|
||||||
}
|
}
|
||||||
|
lastJobEndIndex = jobRegex.lastIndex;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('JSON 解析失败', e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const guideText = firstJobStartIndex > 0 ?
|
const guideText = firstJobStartIndex > 0 ?
|
||||||
markdown.slice(0, firstJobStartIndex).trim() : '';
|
markdown.slice(0, firstJobStartIndex).trim() : '';
|
||||||
const endingText = lastJobEndIndex < markdown.length ?
|
const endingText = lastJobEndIndex < markdown.length ?
|
||||||
markdown.slice(lastJobEndIndex).trim() : '';
|
markdown.slice(lastJobEndIndex).trim() : '';
|
||||||
|
|
||||||
const jobTexts = jobs.map((job, index) => {
|
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 = [];
|
const finalTextParts = [];
|
||||||
if (guideText) finalTextParts.push(guideText);
|
if (guideText) finalTextParts.push(guideText);
|
||||||
finalTextParts.push(...jobTexts);
|
finalTextParts.push(...jobTexts);
|
||||||
if (endingText) finalTextParts.push(endingText);
|
if (endingText) finalTextParts.push(endingText);
|
||||||
|
|
||||||
return finalTextParts.join('\n');
|
return finalTextParts.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -209,36 +209,59 @@ export function useTTSPlayer() {
|
|||||||
*/
|
*/
|
||||||
function extractSpeechText(markdown) {
|
function extractSpeechText(markdown) {
|
||||||
if (!markdown || markdown.indexOf('job-json') === -1) {
|
if (!markdown || markdown.indexOf('job-json') === -1) {
|
||||||
return markdown;
|
return markdown;
|
||||||
}
|
}
|
||||||
const jobRegex = /``` job-json\s*({[\s\S]*?})\s*```/g;
|
const jobRegex = /``` job-json\s*({[\s\S]*?})\s*```/g;
|
||||||
const jobs = [];
|
const jobs = [];
|
||||||
let match;
|
let match;
|
||||||
let lastJobEndIndex = 0;
|
let lastJobEndIndex = 0;
|
||||||
let firstJobStartIndex = -1;
|
let firstJobStartIndex = -1;
|
||||||
|
|
||||||
while ((match = jobRegex.exec(markdown)) !== null) {
|
while ((match = jobRegex.exec(markdown)) !== null) {
|
||||||
const jobStr = match[1];
|
const jobStr = match[1];
|
||||||
try {
|
try {
|
||||||
const job = JSON.parse(jobStr);
|
const job = JSON.parse(jobStr);
|
||||||
jobs.push(job);
|
jobs.push(job);
|
||||||
if (firstJobStartIndex === -1) {
|
if (firstJobStartIndex === -1) {
|
||||||
firstJobStartIndex = match.index;
|
firstJobStartIndex = match.index;
|
||||||
}
|
|
||||||
lastJobEndIndex = jobRegex.lastIndex;
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('JSON 解析失败', e);
|
|
||||||
}
|
}
|
||||||
|
lastJobEndIndex = jobRegex.lastIndex;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('JSON 解析失败', e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const guideText = firstJobStartIndex > 0 ?
|
const guideText = firstJobStartIndex > 0 ?
|
||||||
markdown.slice(0, firstJobStartIndex).trim() : '';
|
markdown.slice(0, firstJobStartIndex).trim() : '';
|
||||||
const endingText = lastJobEndIndex < markdown.length ?
|
const endingText = lastJobEndIndex < markdown.length ?
|
||||||
markdown.slice(lastJobEndIndex).trim() : '';
|
markdown.slice(lastJobEndIndex).trim() : '';
|
||||||
|
|
||||||
const jobTexts = jobs.map((job, index) => {
|
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 = [];
|
const finalTextParts = [];
|
||||||
if (guideText) finalTextParts.push(guideText);
|
if (guideText) finalTextParts.push(guideText);
|
||||||
finalTextParts.push(...jobTexts);
|
finalTextParts.push(...jobTexts);
|
||||||
if (endingText) finalTextParts.push(endingText);
|
if (endingText) finalTextParts.push(endingText);
|
||||||
|
|
||||||
return finalTextParts.join('\n');
|
return finalTextParts.join('\n');
|
||||||
}
|
}
|
||||||
@@ -50,7 +50,7 @@
|
|||||||
"quickapp" : {},
|
"quickapp" : {},
|
||||||
/* 小程序特有相关 */
|
/* 小程序特有相关 */
|
||||||
"mp-weixin" : {
|
"mp-weixin" : {
|
||||||
"appid" : "wxdbdcc6a10153c99b",
|
"appid" : "",
|
||||||
"setting" : {
|
"setting" : {
|
||||||
"urlCheck" : false,
|
"urlCheck" : false,
|
||||||
"es6" : true,
|
"es6" : true,
|
||||||
|
|||||||
@@ -75,8 +75,9 @@ import { reactive, inject, watch, ref, onMounted, computed } from 'vue';
|
|||||||
import { onLoad, onShow } from '@dcloudio/uni-app';
|
import { onLoad, onShow } from '@dcloudio/uni-app';
|
||||||
import dictLabel from '@/components/dict-Label/dict-Label.vue';
|
import dictLabel from '@/components/dict-Label/dict-Label.vue';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
const { isMiniProgram } = storeToRefs(useUserStore());
|
|
||||||
import useUserStore from '@/stores/useUserStore';
|
import useUserStore from '@/stores/useUserStore';
|
||||||
|
const { isMiniProgram } = storeToRefs(useUserStore());
|
||||||
|
const { checkAuth } = useUserStore();
|
||||||
import useLocationStore from '@/stores/useLocationStore';
|
import useLocationStore from '@/stores/useLocationStore';
|
||||||
const { longitudeVal, latitudeVal } = storeToRefs(useLocationStore());
|
const { longitudeVal, latitudeVal } = storeToRefs(useLocationStore());
|
||||||
const { $api, navTo, vacanciesTo, navBack } = inject('globalFunction');
|
const { $api, navTo, vacanciesTo, navBack } = inject('globalFunction');
|
||||||
@@ -111,6 +112,9 @@ onLoad((options) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
function companyCollection() {
|
function companyCollection() {
|
||||||
|
if (!checkAuth()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
if (dataType.value === 2) {
|
if (dataType.value === 2) {
|
||||||
// 第三方数据收藏逻辑
|
// 第三方数据收藏逻辑
|
||||||
const id = companyInfo.value.id;
|
const id = companyInfo.value.id;
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ import { storeToRefs } from 'pinia';
|
|||||||
const { longitudeVal, latitudeVal } = storeToRefs(useLocationStore());
|
const { longitudeVal, latitudeVal } = storeToRefs(useLocationStore());
|
||||||
import useUserStore from '@/stores/useUserStore';
|
import useUserStore from '@/stores/useUserStore';
|
||||||
const { isMiniProgram } = storeToRefs(useUserStore());
|
const { isMiniProgram } = storeToRefs(useUserStore());
|
||||||
|
const { checkAuth } = useUserStore();
|
||||||
import { playTextDirectly } from '@/hook/useTTSPlayer-all-in-one';
|
import { playTextDirectly } from '@/hook/useTTSPlayer-all-in-one';
|
||||||
|
|
||||||
const isExpanded = ref(false);
|
const isExpanded = ref(false);
|
||||||
@@ -204,6 +205,10 @@ function expand() {
|
|||||||
|
|
||||||
// 取消/预约招聘会
|
// 取消/预约招聘会
|
||||||
function applyExhibitors() {
|
function applyExhibitors() {
|
||||||
|
if (!checkAuth()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const fairId = fairInfo.value.zphID;
|
const fairId = fairInfo.value.zphID;
|
||||||
if (fairInfo.value.isCollection) {
|
if (fairInfo.value.isCollection) {
|
||||||
$api.createRequest(`/app/fair/collection/${fairId}`, {}, 'DELETE').then((resData) => {
|
$api.createRequest(`/app/fair/collection/${fairId}`, {}, 'DELETE').then((resData) => {
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
<image
|
<image
|
||||||
class="button-click"
|
class="button-click"
|
||||||
src="@/static/icon/edit1.png"
|
src="@/static/icon/edit1.png"
|
||||||
@click="navTo('/packageA/pages/personalInfo/personalInfo')"
|
@click="handleNavTo('/packageA/pages/personalInfo/personalInfo')"
|
||||||
></image>
|
></image>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -74,7 +74,7 @@
|
|||||||
<image
|
<image
|
||||||
class="icon"
|
class="icon"
|
||||||
src="@/static/icon/edit1.png"
|
src="@/static/icon/edit1.png"
|
||||||
@click="navTo('/packageA/pages/jobExpect/jobExpect')"
|
@click="handleNavTo('/packageA/pages/jobExpect/jobExpect')"
|
||||||
></image>
|
></image>
|
||||||
</view>
|
</view>
|
||||||
<view class="mys-text">
|
<view class="mys-text">
|
||||||
@@ -97,7 +97,7 @@
|
|||||||
<view class="mys-info" style="padding: 0">
|
<view class="mys-info" style="padding: 0">
|
||||||
<view class="mys-h4">
|
<view class="mys-h4">
|
||||||
<text>工作经历</text>
|
<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>
|
<image class="icon button-click btn-feel" src="@/static/icon/plus.png"></image>
|
||||||
<view class="txt">添加</view>
|
<view class="txt">添加</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -108,7 +108,7 @@
|
|||||||
<image
|
<image
|
||||||
class="icon btn-feel"
|
class="icon btn-feel"
|
||||||
src="@/static/icon/edit1.png"
|
src="@/static/icon/edit1.png"
|
||||||
@click="navTo(`/packageA/pages/workExp/workExp?id=${item.id}`)"
|
@click="handleNavTo(`/packageA/pages/workExp/workExp?id=${item.id}`)"
|
||||||
></image>
|
></image>
|
||||||
</view>
|
</view>
|
||||||
<view class="mys-text fl_box fl_justbet">
|
<view class="mys-text fl_box fl_justbet">
|
||||||
@@ -139,7 +139,7 @@ import { storeToRefs } from 'pinia';
|
|||||||
import useUserStore from '@/stores/useUserStore';
|
import useUserStore from '@/stores/useUserStore';
|
||||||
import useDictStore from '@/stores/useDictStore';
|
import useDictStore from '@/stores/useDictStore';
|
||||||
const { userInfo, isMiniProgram,isMachineEnv } = storeToRefs(useUserStore());
|
const { userInfo, isMiniProgram,isMachineEnv } = storeToRefs(useUserStore());
|
||||||
const { getUserResume } = useUserStore();
|
const { getUserResume, checkAuth } = useUserStore();
|
||||||
const { getDictData, oneDictData } = useDictStore();
|
const { getDictData, oneDictData } = useDictStore();
|
||||||
import { playTextDirectly } from '@/hook/useTTSPlayer-all-in-one';
|
import { playTextDirectly } from '@/hook/useTTSPlayer-all-in-one';
|
||||||
import config from '@/config.js';
|
import config from '@/config.js';
|
||||||
@@ -151,6 +151,13 @@ onLoad(() => {
|
|||||||
getUserResume();
|
getUserResume();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function handleNavTo(url) {
|
||||||
|
if(!checkAuth()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
navTo(url)
|
||||||
|
}
|
||||||
|
|
||||||
function closeNotice() {
|
function closeNotice() {
|
||||||
showNotice.value = false;
|
showNotice.value = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -187,6 +187,7 @@ import { storeToRefs } from 'pinia';
|
|||||||
import useUserStore from '@/stores/useUserStore';
|
import useUserStore from '@/stores/useUserStore';
|
||||||
import { playTextDirectly } from '@/hook/useTTSPlayer-all-in-one';
|
import { playTextDirectly } from '@/hook/useTTSPlayer-all-in-one';
|
||||||
const { isMiniProgram, hasLogin } = storeToRefs(useUserStore());
|
const { isMiniProgram, hasLogin } = storeToRefs(useUserStore());
|
||||||
|
const { checkAuth } = useUserStore();
|
||||||
|
|
||||||
const { $api, navTo, getLenPx, parseQueryParams, navBack, isEmptyObject } = inject('globalFunction');
|
const { $api, navTo, getLenPx, parseQueryParams, navBack, isEmptyObject } = inject('globalFunction');
|
||||||
import config from '@/config.js';
|
import config from '@/config.js';
|
||||||
@@ -322,10 +323,14 @@ function getCompetivetuveness(jobId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 申请岗位
|
// 申请岗位 第三方需要认证
|
||||||
function jobApply() {
|
function jobApply() {
|
||||||
if (dataType.value === 2) {
|
if (dataType.value === 2) {
|
||||||
// 第三方数据申请逻辑
|
// 第三方数据申请逻辑
|
||||||
|
|
||||||
|
if (!checkAuth()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
const params = {
|
const params = {
|
||||||
jobid: jobInfo.value.id,
|
jobid: jobInfo.value.id,
|
||||||
jobname: jobInfo.value.gwmc,
|
jobname: jobInfo.value.gwmc,
|
||||||
@@ -359,8 +364,11 @@ function jobApply() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 取消/收藏岗位
|
// 取消/收藏岗位 都需要认证
|
||||||
function jobCollection() {
|
function jobCollection() {
|
||||||
|
if (!checkAuth()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
if (dataType.value === 2) {
|
if (dataType.value === 2) {
|
||||||
// 第三方数据收藏逻辑
|
// 第三方数据收藏逻辑
|
||||||
const id = jobInfo.value.id;
|
const id = jobInfo.value.id;
|
||||||
|
|||||||
@@ -63,7 +63,14 @@
|
|||||||
{
|
{
|
||||||
"path": "pages/search/search",
|
"path": "pages/search/search",
|
||||||
"style": {
|
"style": {
|
||||||
"navigationBarTitleText": "",
|
"navigationBarTitleText": "搜索",
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/auth/auth",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "实名认证",
|
||||||
"navigationStyle": "custom"
|
"navigationStyle": "custom"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
643
pages/auth/auth.vue
Normal file
643
pages/auth/auth.vue
Normal 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>
|
||||||
1212
pages/chat/components/ai-paging copy.vue
Normal file
1212
pages/chat/components/ai-paging copy.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -20,8 +20,8 @@
|
|||||||
>
|
>
|
||||||
{{ item }}
|
{{ item }}
|
||||||
</view>
|
</view>
|
||||||
<view class="chat-item self" v-if="isRecording">
|
<view class="chat-item self" v-if="isRecording || isProcessing">
|
||||||
<view class="message">{{ recognizedText }} {{ lastFinalText }}</view>
|
<view class="message">{{ recognizedText || (isProcessing ? '正在识别语音...' : '正在录音 '+recordingDuration+'s') }}</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<scroll-view class="chat-list scrollView" :scroll-top="scrollTop" :scroll-y="true" scroll-with-animation>
|
<scroll-view class="chat-list scrollView" :scroll-top="scrollTop" :scroll-y="true" scroll-with-animation>
|
||||||
@@ -118,9 +118,8 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="chat-item self" v-if="isRecording">
|
<view class="chat-item self" v-if="isRecording || isProcessing">
|
||||||
<!-- <view class="message">{{ recognizedText }} {{ lastFinalText }}</view> -->
|
<view class="message">{{ recognizedText || (isProcessing ? '正在识别语音...' : '正在录音 '+recordingDuration+'s') }}</view>
|
||||||
<view class="message">{{ recognizedText }}</view>
|
|
||||||
</view>
|
</view>
|
||||||
<view v-if="isTyping" class="self">
|
<view v-if="isTyping" class="self">
|
||||||
<text class="message msg-loading">
|
<text class="message msg-loading">
|
||||||
@@ -275,7 +274,7 @@ import AudioWave from './AudioWave.vue';
|
|||||||
import WaveDisplay from './WaveDisplay.vue';
|
import WaveDisplay from './WaveDisplay.vue';
|
||||||
import useScreenStore from '@/stores/useScreenStore'
|
import useScreenStore from '@/stores/useScreenStore'
|
||||||
const screenStore = useScreenStore();
|
const screenStore = useScreenStore();
|
||||||
import { useAudioRecorder } from '@/hook/useRealtimeRecorder.js';
|
import { useRealtimeRecorderOnce } from '@/hook/useRealtimeRecorderOnce.js';
|
||||||
import { useAudioSpeak } from '@/hook/useAudioSpeak.js';
|
import { useAudioSpeak } from '@/hook/useAudioSpeak.js';
|
||||||
// 全局
|
// 全局
|
||||||
const { $api, navTo, throttle } = inject('globalFunction');
|
const { $api, navTo, throttle } = inject('globalFunction');
|
||||||
@@ -290,14 +289,25 @@ import { FileValidator } from '@/utils/fileValidator.js'; //文件校验
|
|||||||
// 语音识别
|
// 语音识别
|
||||||
const {
|
const {
|
||||||
isRecording,
|
isRecording,
|
||||||
|
isProcessing,
|
||||||
startRecording,
|
startRecording,
|
||||||
stopRecording,
|
stopRecording,
|
||||||
cancelRecording,
|
cancelRecording,
|
||||||
audioDataForDisplay,
|
audioDataForDisplay,
|
||||||
volumeLevel,
|
volumeLevel,
|
||||||
recognizedText,
|
recognizedText,
|
||||||
lastFinalText,
|
recordingDuration
|
||||||
} = useAudioRecorder();
|
} = 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();
|
const { speak, pause, resume, isSpeaking, isPaused, isLoading, cancelAudio,cleanup } = useAudioSpeak();
|
||||||
|
|
||||||
@@ -355,7 +365,7 @@ onMounted(async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(()=>{
|
onUnmounted(()=>{
|
||||||
console.log('清理TTS资源')
|
// console.log('清理TTS资源')
|
||||||
cleanup()
|
cleanup()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -382,6 +392,8 @@ function showControll(index) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const sendMessage = (text) => {
|
const sendMessage = (text) => {
|
||||||
const values = textInput.value || text;
|
const values = textInput.value || text;
|
||||||
showfile.value = false;
|
showfile.value = false;
|
||||||
@@ -660,19 +672,12 @@ const handleTouchMove = (e) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTouchEnd = () => {
|
const handleTouchEnd = async () => {
|
||||||
if (status.value === 'cancel') {
|
if (status.value === 'cancel') {
|
||||||
console.log('取消发送');
|
console.log('取消发送');
|
||||||
cancelRecording();
|
cancelRecording();
|
||||||
} else {
|
} else {
|
||||||
stopRecording();
|
await stopRecording();
|
||||||
if (isAudioPermission.value) {
|
|
||||||
if (recognizedText.value) {
|
|
||||||
sendMessage(recognizedText.value);
|
|
||||||
} else {
|
|
||||||
$api.msg('说话时长太短');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
status.value = 'idle';
|
status.value = 'idle';
|
||||||
};
|
};
|
||||||
@@ -737,15 +742,15 @@ function readMarkdown(value, index) {
|
|||||||
speak(value);
|
speak(value);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isPaused.value) {
|
if (isPaused.value) {
|
||||||
resume();
|
resume();
|
||||||
} else {
|
} else {
|
||||||
// console.log(value, speechIndex.value, index, isPaused.value)
|
pause();
|
||||||
speak(value);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function stopMarkdown(value, index) {
|
function stopMarkdown(value, index) {
|
||||||
pause(value);
|
pause()
|
||||||
speechIndex.value = index;
|
speechIndex.value = index;
|
||||||
}
|
}
|
||||||
function refreshMarkdown(index) {
|
function refreshMarkdown(index) {
|
||||||
|
|||||||
@@ -64,7 +64,7 @@
|
|||||||
<view v-if="hasLogin" :class="{ 'match-move-top': isMachineEnv }" class="match-card-out">
|
<view v-if="hasLogin" :class="{ 'match-move-top': isMachineEnv }" class="match-card-out">
|
||||||
<view class="match-card">
|
<view class="match-card">
|
||||||
<image class="match-card-bg" src="@/static/icon/match-card-bg.png" />
|
<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">
|
<view class="match-item-container">
|
||||||
<AIMatch :tags="matchTags" :loading="matchLoading" @tag-click="handleTagClick"></AIMatch>
|
<AIMatch :tags="matchTags" :loading="matchLoading" @tag-click="handleTagClick"></AIMatch>
|
||||||
</view>
|
</view>
|
||||||
|
|||||||
@@ -87,12 +87,20 @@ onLoad(() => {
|
|||||||
}
|
}
|
||||||
// 预加载较重页面
|
// 预加载较重页面
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
uni.preloadPage({ url: '/packageA/pages/post/post' });
|
|
||||||
uni.preloadPage({ url: '/pages/nearby/nearby' });
|
uni.preloadPage({ url: '/pages/nearby/nearby' });
|
||||||
uni.preloadPage({ url: '/pages/chat/chat' });
|
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/choiceness/choiceness' });
|
||||||
uni.preloadPage({ url: '/packageA/pages/reservation/reservation' });
|
uni.preloadPage({ url: '/packageA/pages/reservation/reservation' });
|
||||||
uni.preloadPage({ url: '/packageA/pages/Intendedposition/Intendedposition' });
|
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);
|
}, 1000);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<AppLayout title="就业服务程序">
|
<AppLayout :title="pageTitle">
|
||||||
<view v-if="isMachineEnv && !hasLogin" class="alipay-login-container">
|
<view v-if="isMachineEnv && !hasLogin" class="alipay-login-container">
|
||||||
<!-- 切换 -->
|
<!-- 切换 -->
|
||||||
<view class="login-method-switch">
|
<view class="login-method-switch">
|
||||||
<view
|
|
||||||
class="method-item"
|
|
||||||
:class="{ active: loginMethod === 'face' }"
|
|
||||||
@click="switchLoginMethod('face')"
|
|
||||||
>
|
|
||||||
扫脸登录
|
|
||||||
</view>
|
|
||||||
<view
|
<view
|
||||||
class="method-item"
|
class="method-item"
|
||||||
:class="{ active: loginMethod === 'qrcode' }"
|
:class="{ active: loginMethod === 'qrcode' }"
|
||||||
@@ -17,6 +10,13 @@
|
|||||||
>
|
>
|
||||||
扫码登录
|
扫码登录
|
||||||
</view>
|
</view>
|
||||||
|
<view
|
||||||
|
class="method-item"
|
||||||
|
:class="{ active: loginMethod === 'face' }"
|
||||||
|
@click="switchLoginMethod('face')"
|
||||||
|
>
|
||||||
|
扫脸登录
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="login-scan-area">
|
<view class="login-scan-area">
|
||||||
@@ -64,7 +64,8 @@
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="countdown-container">
|
<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-number">{{ countdown }}</text>
|
||||||
<text class="countdown-text">秒后自动返回</text>
|
<text class="countdown-text">秒后自动返回</text>
|
||||||
</view>
|
</view>
|
||||||
@@ -236,7 +237,7 @@ let scanInterval = null;
|
|||||||
const countdown = ref(60);
|
const countdown = ref(60);
|
||||||
let countdownTimer = null;
|
let countdownTimer = null;
|
||||||
const loginMethod = ref('qrcode'); // 'qrcode' / 'face'
|
const loginMethod = ref('qrcode'); // 'qrcode' / 'face'
|
||||||
|
const pageTitle = ref('就享家服务程序');
|
||||||
onLoad((parmas) => {
|
onLoad((parmas) => {
|
||||||
getTreeselect();
|
getTreeselect();
|
||||||
if (!isMachineEnv.value) {
|
if (!isMachineEnv.value) {
|
||||||
@@ -245,7 +246,7 @@ onLoad((parmas) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (isMachineEnv) {
|
if (isMachineEnv.value) {
|
||||||
startCountdown();
|
startCountdown();
|
||||||
startScanAnimation();
|
startScanAnimation();
|
||||||
faceService.start(); // 自动开始初始化流程
|
faceService.start(); // 自动开始初始化流程
|
||||||
@@ -264,6 +265,7 @@ onMounted(() => {
|
|||||||
}, 1000);
|
}, 1000);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
stopScanAnimation();
|
stopScanAnimation();
|
||||||
stopCountdown();
|
stopCountdown();
|
||||||
@@ -274,7 +276,7 @@ const startCountdown = () => {
|
|||||||
countdown.value = 60;
|
countdown.value = 60;
|
||||||
countdownTimer = setInterval(() => {
|
countdownTimer = setInterval(() => {
|
||||||
countdown.value--;
|
countdown.value--;
|
||||||
if (countdown.value <= 0) {
|
if (countdown.value <= 0 && loginMethod.value === 'qrcode') {
|
||||||
returnToHome();
|
returnToHome();
|
||||||
}
|
}
|
||||||
}, 1000);
|
}, 1000);
|
||||||
@@ -306,7 +308,7 @@ const cancelLogin = () => {
|
|||||||
|
|
||||||
// 切换登录方式
|
// 切换登录方式
|
||||||
const switchLoginMethod = (method) => {
|
const switchLoginMethod = (method) => {
|
||||||
if (!isMachineEnv) {
|
if (!isMachineEnv.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (loginMethod.value !== method) {
|
if (loginMethod.value !== method) {
|
||||||
@@ -316,6 +318,7 @@ const switchLoginMethod = (method) => {
|
|||||||
faceService.close();
|
faceService.close();
|
||||||
qrHandler.start();
|
qrHandler.start();
|
||||||
playTextDirectly('扫码登录');
|
playTextDirectly('扫码登录');
|
||||||
|
resetCountdown();
|
||||||
break;
|
break;
|
||||||
case 'face':
|
case 'face':
|
||||||
qrHandler.close();
|
qrHandler.close();
|
||||||
@@ -323,15 +326,32 @@ const switchLoginMethod = (method) => {
|
|||||||
playTextDirectly('扫脸登录');
|
playTextDirectly('扫脸登录');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
resetCountdown();
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
async function handleFaceLogin() {
|
async function handleFaceLogin() {
|
||||||
try {
|
try {
|
||||||
const authCode = await faceService.startFaceLogin();
|
const authCode = await faceService.startFaceLogin();
|
||||||
console.log('拿到 AuthCode:', authCode);
|
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) {
|
} catch (err) {
|
||||||
this.$api.msg(err.message);
|
this.$api.msg(err.message);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,12 +49,12 @@
|
|||||||
</view>
|
</view>
|
||||||
<view class="card-main">
|
<view class="card-main">
|
||||||
<view class="main-title">服务专区</view>
|
<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">
|
<view class="row-left">
|
||||||
<image class="left-img" src="@/static/icon/server1.png"></image>
|
<image class="left-img" src="@/static/icon/server1.png"></image>
|
||||||
<text class="left-text">实名认证</text>
|
<text class="left-text">实名认证</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="row-right">已认证</view>
|
<view class="row-right">{{isAuth ? '已认证' : '未认证'}}</view>
|
||||||
</view>
|
</view>
|
||||||
<view v-if="!isMachineEnv" class="main-row btn-feel" @click="handleItemClick('素质测评')">
|
<view v-if="!isMachineEnv" class="main-row btn-feel" @click="handleItemClick('素质测评')">
|
||||||
<view class="row-left">
|
<view class="row-left">
|
||||||
@@ -112,7 +112,7 @@ const { $api, navTo } = inject('globalFunction');
|
|||||||
import useUserStore from '@/stores/useUserStore';
|
import useUserStore from '@/stores/useUserStore';
|
||||||
|
|
||||||
const popup = ref(null);
|
const popup = ref(null);
|
||||||
const { userInfo, Completion, counts, isMachineEnv } = storeToRefs(useUserStore());
|
const { userInfo, Completion, counts, isMachineEnv, isAuth } = storeToRefs(useUserStore());
|
||||||
|
|
||||||
import useScreenStore from '@/stores/useScreenStore';
|
import useScreenStore from '@/stores/useScreenStore';
|
||||||
const screenStore = useScreenStore();
|
const screenStore = useScreenStore();
|
||||||
@@ -144,12 +144,10 @@ function confirm() {
|
|||||||
|
|
||||||
const isAbove90 = (percent) => parseFloat(percent) < 90;
|
const isAbove90 = (percent) => parseFloat(percent) < 90;
|
||||||
|
|
||||||
function selectFile() {
|
function goAuth() {
|
||||||
// FileUploader.showMenuAndUpload({
|
if(!isAuth.value){
|
||||||
// success: function (res) {
|
navTo('/pages/auth/auth')
|
||||||
// alert('上传成功: ' + JSON.stringify(res));
|
}
|
||||||
// },
|
|
||||||
// });
|
|
||||||
}
|
}
|
||||||
function chooseFileUploadTest(pam) {}
|
function chooseFileUploadTest(pam) {}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="container">
|
<view class="container">
|
||||||
<view v-show="searchFocus" class="search-mask" ></view>
|
<view v-show="searchFocus" class="search-mask"></view>
|
||||||
<view>
|
<view>
|
||||||
<view class="top">
|
<view class="top">
|
||||||
<image
|
<image
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
<input
|
<input
|
||||||
class="inputed"
|
class="inputed"
|
||||||
type="text"
|
type="text"
|
||||||
focus
|
:focus="autoFocus"
|
||||||
v-model="searchValue"
|
v-model="searchValue"
|
||||||
:placeholder="searchType === 'job' ? '搜索职位名称' : '搜索专业名称'"
|
:placeholder="searchType === 'job' ? '搜索职位名称' : '搜索专业名称'"
|
||||||
placeholder-class="placeholder"
|
placeholder-class="placeholder"
|
||||||
@@ -40,8 +40,12 @@
|
|||||||
@confirm="searchBtn"
|
@confirm="searchBtn"
|
||||||
/>
|
/>
|
||||||
<!-- 联想搜索下拉列表 -->
|
<!-- 联想搜索下拉列表 -->
|
||||||
<scroll-view scroll-y class="search-suggestions" v-show="showSuggestions && filteredSuggestions.length">
|
<scroll-view
|
||||||
<view
|
scroll-y
|
||||||
|
class="search-suggestions"
|
||||||
|
v-show="showSuggestions && filteredSuggestions.length"
|
||||||
|
>
|
||||||
|
<view
|
||||||
class="suggestion-item"
|
class="suggestion-item"
|
||||||
v-for="(item, index) in filteredSuggestions"
|
v-for="(item, index) in filteredSuggestions"
|
||||||
:key="index"
|
:key="index"
|
||||||
@@ -132,6 +136,7 @@ const historyList = ref([]);
|
|||||||
const listCom = ref([]);
|
const listCom = ref([]);
|
||||||
const showSuggestions = ref(false);
|
const showSuggestions = ref(false);
|
||||||
const searchFocus = ref(false);
|
const searchFocus = ref(false);
|
||||||
|
const autoFocus = ref(false)
|
||||||
|
|
||||||
// 专业数据源(示例数据,可以替换为实际数据)
|
// 专业数据源(示例数据,可以替换为实际数据)
|
||||||
const majorDataSource = ref([]);
|
const majorDataSource = ref([]);
|
||||||
@@ -144,13 +149,10 @@ const filteredSuggestions = computed(() => {
|
|||||||
|
|
||||||
const searchText = searchValue.value.toLowerCase();
|
const searchText = searchValue.value.toLowerCase();
|
||||||
try {
|
try {
|
||||||
return majorDataSource.value.filter(item =>
|
return majorDataSource.value.filter((item) => item.toLowerCase().includes(searchText));
|
||||||
item.toLowerCase().includes(searchText)
|
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return []
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const pageState = reactive({
|
const pageState = reactive({
|
||||||
@@ -207,26 +209,31 @@ const { columnCount, columnSpace } = useColumnCount(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
onLoad((options) => {
|
onLoad((options) => {
|
||||||
if(options.keyWord){
|
if (options.keyWord) {
|
||||||
searchValue.value = decodeURIComponent(options.keyWord)
|
searchValue.value = decodeURIComponent(options.keyWord);
|
||||||
searchBtn()
|
searchBtn();
|
||||||
|
}else{
|
||||||
|
//从标签点进来不自动聚焦
|
||||||
|
autoFocus.value = true
|
||||||
}
|
}
|
||||||
let arr = uni.getStorageSync('searchList');
|
let arr = uni.getStorageSync('searchList');
|
||||||
if (arr) {
|
if (arr) {
|
||||||
historyList.value = uni.getStorageSync('searchList');
|
historyList.value = uni.getStorageSync('searchList');
|
||||||
}
|
}
|
||||||
|
|
||||||
getMajorDataSource()
|
getMajorDataSource();
|
||||||
});
|
});
|
||||||
|
|
||||||
function getMajorDataSource() {
|
function getMajorDataSource() {
|
||||||
const LoadCache = (resData) => {
|
const LoadCache = (resData) => {
|
||||||
if (resData.code === 200) {
|
if (resData.code === 200) {
|
||||||
majorDataSource.value = resData.data;
|
majorDataSource.value = resData.data;
|
||||||
console.log(majorDataSource.value)
|
console.log(majorDataSource.value);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
$api.createRequestWithCache('/app/common/majorManagement/getMajorList', {}, 'GET', false, LoadCache).then(LoadCache);
|
$api.createRequestWithCache('/app/common/majorManagement/getMajorList', {}, 'GET', false, LoadCache).then(
|
||||||
|
LoadCache
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设置搜索类型并触发搜索
|
// 设置搜索类型并触发搜索
|
||||||
@@ -247,7 +254,7 @@ function setSearchType(type) {
|
|||||||
pageState.page = 0;
|
pageState.page = 0;
|
||||||
|
|
||||||
// 触发搜索
|
// 触发搜索
|
||||||
playTextDirectly('正在为您查找岗位')
|
playTextDirectly('正在为您查找岗位');
|
||||||
|
|
||||||
// 根据搜索类型设置不同的参数
|
// 根据搜索类型设置不同的参数
|
||||||
if (searchType.value === 'job') {
|
if (searchType.value === 'job') {
|
||||||
@@ -332,7 +339,7 @@ function searchBtn() {
|
|||||||
if (!searchValue.value) {
|
if (!searchValue.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
playTextDirectly('正在为您查找岗位')
|
playTextDirectly('正在为您查找岗位');
|
||||||
|
|
||||||
// 保存到历史记录(仅当搜索成功时保存)
|
// 保存到历史记录(仅当搜索成功时保存)
|
||||||
historyList.value.unshift(searchValue.value);
|
historyList.value.unshift(searchValue.value);
|
||||||
@@ -443,6 +450,16 @@ function getJobList(type = 'add') {
|
|||||||
listCom.value = [...listCom.value, ...reslist];
|
listCom.value = [...listCom.value, ...reslist];
|
||||||
} else {
|
} else {
|
||||||
listCom.value = [...rows];
|
listCom.value = [...rows];
|
||||||
|
// 一体机语音提示
|
||||||
|
if (searchType.value === 'major') {
|
||||||
|
if (rows.length) {
|
||||||
|
$api.msg('为您找到相关专业的职位');
|
||||||
|
playTextDirectly(`为您找到相关专业的职位`);
|
||||||
|
} else {
|
||||||
|
$api.msg('未找到相关专业的职位,请尝试其他关键词');
|
||||||
|
playTextDirectly(`未找到相关专业的职位,请尝试其他关键词`);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
pageState.total = resData.total;
|
pageState.total = resData.total;
|
||||||
pageState.maxPage = Math.ceil(pageState.total / pageState.pageSize);
|
pageState.maxPage = Math.ceil(pageState.total / pageState.pageSize);
|
||||||
|
|||||||
BIN
static/icon/input-delete.png
Normal file
BIN
static/icon/input-delete.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.4 KiB |
@@ -97,7 +97,7 @@ export const useRecommedIndexedDBStore = defineStore("indexedDB", () => {
|
|||||||
async function addRecord(payload) {
|
async function addRecord(payload) {
|
||||||
const totalRecords = await baseDB.db.getRecordCount(tableName.value);
|
const totalRecords = await baseDB.db.getRecordCount(tableName.value);
|
||||||
if (totalRecords >= total.value) {
|
if (totalRecords >= total.value) {
|
||||||
console.log(`⚠数据超过 ${total.value} 条,删除最早的一条...`);
|
console.log(`数据超过 ${total.value} 条,删除最早的一条...`);
|
||||||
await baseDB.db.deleteOldestRecord(tableName.value);
|
await baseDB.db.deleteOldestRecord(tableName.value);
|
||||||
}
|
}
|
||||||
if (!baseDB.isDBReady) await baseDB.initDB();
|
if (!baseDB.isDBReady) await baseDB.initDB();
|
||||||
|
|||||||
@@ -85,11 +85,11 @@ class ScreenDetectionManager {
|
|||||||
|
|
||||||
// 添加加载成功/失败监听
|
// 添加加载成功/失败监听
|
||||||
this.cssLink.onload = () => {
|
this.cssLink.onload = () => {
|
||||||
console.log('🎨 宽屏 CSS 文件加载成功');
|
console.log('宽屏 CSS 文件加载成功');
|
||||||
};
|
};
|
||||||
|
|
||||||
this.cssLink.onerror = () => {
|
this.cssLink.onerror = () => {
|
||||||
console.error('❌ 宽屏 CSS 文件加载失败');
|
console.error('宽屏 CSS 文件加载失败');
|
||||||
this.cssLink = null;
|
this.cssLink = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -108,7 +108,7 @@ class ScreenDetectionManager {
|
|||||||
try {
|
try {
|
||||||
this.cssLink.parentNode.removeChild(this.cssLink);
|
this.cssLink.parentNode.removeChild(this.cssLink);
|
||||||
this.cssLink = null;
|
this.cssLink = null;
|
||||||
console.log('🗑️ 宽屏 CSS 文件已移除');
|
console.log('宽屏 CSS 文件已移除');
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('移除 CSS 失败:', error);
|
console.error('移除 CSS 失败:', error);
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import {
|
|||||||
defineStore
|
defineStore
|
||||||
} from 'pinia';
|
} from 'pinia';
|
||||||
import {
|
import {
|
||||||
ref
|
ref,
|
||||||
|
inject
|
||||||
} from 'vue'
|
} from 'vue'
|
||||||
import {
|
import {
|
||||||
createRequest
|
createRequest
|
||||||
@@ -17,6 +18,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
msg,
|
msg,
|
||||||
$api,
|
$api,
|
||||||
|
navTo
|
||||||
} from '../common/globalFunction';
|
} from '../common/globalFunction';
|
||||||
import baseDB from '@/utils/db.js';
|
import baseDB from '@/utils/db.js';
|
||||||
|
|
||||||
@@ -61,6 +63,7 @@ const useUserStore = defineStore("user", () => {
|
|||||||
const counts = ref({})
|
const counts = ref({})
|
||||||
const isMiniProgram = ref(false)
|
const isMiniProgram = ref(false)
|
||||||
const isMachineEnv = ref(false)
|
const isMachineEnv = ref(false)
|
||||||
|
const isAuth = ref(false) //是否认证(身份证+手机号)
|
||||||
|
|
||||||
const login = (value) => {
|
const login = (value) => {
|
||||||
hasLogin.value = true;
|
hasLogin.value = true;
|
||||||
@@ -135,6 +138,19 @@ const useUserStore = defineStore("user", () => {
|
|||||||
userInfo.value = values.data;
|
userInfo.value = values.data;
|
||||||
// role.value = values.role;
|
// role.value = values.role;
|
||||||
hasLogin.value = true;
|
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,
|
isMiniProgram,
|
||||||
changMiniProgramAppStatus,
|
changMiniProgramAppStatus,
|
||||||
changMachineEnv,
|
changMachineEnv,
|
||||||
isMachineEnv
|
isMachineEnv,
|
||||||
|
isAuth,
|
||||||
|
checkAuth
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
unistorage: true,
|
unistorage: true,
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ const useChatGroupDBStore = defineStore("messageGroup", () => {
|
|||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
if (!baseDB.isDBReady) await baseDB.initDB();
|
if (!baseDB.isDBReady) await baseDB.initDB();
|
||||||
const result = await baseDB.db.getAll(tableName.value);
|
const result = await baseDB.db.getAll(tableName.value);
|
||||||
console.log('result', result)
|
// console.log('result', result)
|
||||||
// 1、判断是否有数据,没数据请求服务器
|
// 1、判断是否有数据,没数据请求服务器
|
||||||
if (result.length) {
|
if (result.length) {
|
||||||
console.warn('本地数据库存在数据')
|
console.warn('本地数据库存在数据')
|
||||||
|
|||||||
227
utils/IDCardParser.js
Normal file
227
utils/IDCardParser.js
Normal 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;
|
||||||
@@ -18,7 +18,9 @@ const needToEncrypt = [
|
|||||||
["post", "/app/user/experience/edit"],
|
["post", "/app/user/experience/edit"],
|
||||||
["post", "/app/user/experience/delete"],
|
["post", "/app/user/experience/delete"],
|
||||||
["get", "/app/user/experience/getSingle/{value}"],
|
["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
|
} = resData.data
|
||||||
if (code === 200) {
|
if (code === 200) {
|
||||||
resolve(resData.data)
|
resolve(resData.data)
|
||||||
|
// console.log(resData.data.data,'接口解密')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (msg) {
|
if (msg) {
|
||||||
|
|||||||
Reference in New Issue
Block a user