Compare commits
91 Commits
4cf75922da
...
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 | |||
|
|
c0c93fffc4 | ||
|
|
d5890936c3 | ||
| 95c22f6d0f | |||
| 0029215bd9 | |||
| 640231a223 | |||
| 99f8fd7b6a | |||
|
|
4b39add733 | ||
| 8113f98195 | |||
| a28e0ee44c | |||
| a5ea613a7d | |||
|
|
049ce7a92d | ||
| ebea520bca | |||
| 3f0f349adf | |||
|
|
57596cbcb9 | ||
| 5461dc276c | |||
| 212931e969 | |||
| d0ff91a2c4 | |||
| 872d3febe9 | |||
| 75c9ea0c3c | |||
| e514536d1b | |||
|
|
ed53ca187f | ||
| 42dafbf7ef | |||
| 1aa1cc9d4a | |||
| f75d607576 | |||
| e05ccf555a | |||
| 7f720eb2ad | |||
| 8494210a5d | |||
| ffcf34fb10 | |||
| 8f1dbc28f7 | |||
| c9420b28e9 | |||
| 73a6692998 | |||
| 5d0b9c5a3a | |||
| 8dea3294a5 | |||
| 577ec92ac8 | |||
|
|
79fb997640 | ||
|
|
8cce64b005 | ||
|
|
1bacbe4936 | ||
|
|
02fef1700b | ||
|
|
99cb6b4710 | ||
|
|
1c05aa1f5b | ||
| 009b4840ad | |||
| 4ae11e31f4 | |||
| bca0d997c6 | |||
|
|
b43eb98a1c | ||
|
|
44c297aac2 | ||
| f64c9e5dae | |||
| 975835baa5 | |||
| 1ac524e1f1 | |||
| 7e8bef0cb9 | |||
|
|
ce597b182d | ||
|
|
fdd5577c85 | ||
|
|
4dfc7bdfd8 | ||
|
|
4befbb05cc | ||
| 74dc6debcd | |||
| 534dfd8126 | |||
| 4c29882f36 |
50
App.vue
50
App.vue
@@ -1,29 +1,38 @@
|
||||
<script setup>
|
||||
import { reactive, inject, onMounted } from 'vue';
|
||||
import { onLaunch, onShow, onHide } from '@dcloudio/uni-app';
|
||||
import { IncreaseRevie } from '@/common/all-in-one-listen.js';
|
||||
import useUserStore from './stores/useUserStore';
|
||||
import usePageAnimation from './hook/usePageAnimation';
|
||||
import useDictStore from './stores/useDictStore';
|
||||
import { GlobalInactivityManager } from '@/utils/GlobalInactivityManager';
|
||||
const { $api, navTo, appendScriptTagElement, aes_Decrypt, sm2_Decrypt, safeReLaunch } = inject('globalFunction');
|
||||
import { playTextDirectly } from '@/hook/useTTSPlayer-all-in-one';
|
||||
const {
|
||||
$api,
|
||||
navTo,
|
||||
appendScriptTagElement,
|
||||
aes_Decrypt,
|
||||
sm2_Decrypt,
|
||||
safeReLaunch,
|
||||
isY9MachineType,
|
||||
isAsdMachineType,
|
||||
} = inject('globalFunction');
|
||||
import config from '@/config.js';
|
||||
import baseDB from '@/utils/db.js';
|
||||
import { $confirm } from '@/utils/modal.js';
|
||||
import useLocationStore from '@/stores/useLocationStore';
|
||||
usePageAnimation();
|
||||
const appword = 'aKd20dbGdFvmuwrt'; // 固定值
|
||||
let uQRListen = null;
|
||||
let inactivityManager = null;
|
||||
let inactivityModalTimer = null;
|
||||
|
||||
usePageAnimation();
|
||||
onLaunch((options) => {
|
||||
useDictStore().getDictData();
|
||||
if (lightAppJssdk.user) {
|
||||
if (isAsdMachineType()) {
|
||||
console.warn('爱山东环境');
|
||||
getUserInfo();
|
||||
useUserStore().changMiniProgramAppStatus(false);
|
||||
useUserStore().changMachineEnv(false);
|
||||
useLocationStore().getLocationLoop(); //循环获取定位
|
||||
return;
|
||||
}
|
||||
if (isY9MachineType()) {
|
||||
@@ -32,14 +41,27 @@ onLaunch((options) => {
|
||||
useUserStore().logOutApp();
|
||||
useUserStore().changMiniProgramAppStatus(true);
|
||||
useUserStore().changMachineEnv(true);
|
||||
useLocationStore().getLocation();
|
||||
uQRListen = new IncreaseRevie();
|
||||
(function loop() {
|
||||
console.log('一体机尝试获取定位');
|
||||
useLocationStore()
|
||||
.getLocation()
|
||||
.then(({ longitude, latitude }) => {
|
||||
console.log(`一体机获取定位成功:lng:${longitude},lat${latitude}`);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log('一体机获取定位失败,30s后尝试重新获取');
|
||||
setTimeout(() => {
|
||||
loop();
|
||||
}, 3000);
|
||||
});
|
||||
})();
|
||||
inactivityManager = new GlobalInactivityManager(handleInactivity, 60 * 1000);
|
||||
inactivityManager.start();
|
||||
return;
|
||||
}
|
||||
// 正式上线去除此方法
|
||||
console.warn('浏览器环境');
|
||||
useLocationStore().getLocationLoop(); //循环获取定位
|
||||
useUserStore().changMiniProgramAppStatus(true);
|
||||
useUserStore().changMachineEnv(false);
|
||||
useUserStore().initSeesionId(); //更新
|
||||
@@ -66,7 +88,7 @@ onHide(() => {
|
||||
});
|
||||
|
||||
function handleInactivity() {
|
||||
console.log('【全局】60秒无操作,执行安全逻辑');
|
||||
console.warn('60秒无操作,执行安全逻辑');
|
||||
if (inactivityModalTimer) {
|
||||
clearTimeout(inactivityModalTimer);
|
||||
inactivityModalTimer = null;
|
||||
@@ -74,6 +96,7 @@ function handleInactivity() {
|
||||
|
||||
if (useUserStore().hasLogin) {
|
||||
// 1. 正常弹出确认框
|
||||
playTextDirectly('长时间无操作,是否继续使用?');
|
||||
$confirm({
|
||||
title: '会话即将过期',
|
||||
content: '长时间无操作,是否继续使用?',
|
||||
@@ -97,7 +120,7 @@ function handleInactivity() {
|
||||
},
|
||||
});
|
||||
|
||||
// 2. 启动 10 秒倒计时
|
||||
// 2. 启动 20 秒倒计时
|
||||
inactivityModalTimer = setTimeout(() => {
|
||||
inactivityModalTimer = null;
|
||||
console.log('【自动登出】10秒无响应,强制清理状态');
|
||||
@@ -106,7 +129,7 @@ function handleInactivity() {
|
||||
uni.$emit('hide-global-popup');
|
||||
|
||||
performLogout();
|
||||
}, 10000);
|
||||
}, 20000);
|
||||
} else {
|
||||
inactivityManager?.resume();
|
||||
}
|
||||
@@ -119,13 +142,6 @@ function performLogout() {
|
||||
inactivityManager?.resume(); // 恢复监听
|
||||
}
|
||||
|
||||
// 一体机环境判断
|
||||
function isY9MachineType() {
|
||||
const ua = navigator.userAgent;
|
||||
const isY9Machine = /Y9-ZYYH/i.test(ua); // 匹配机器型号
|
||||
return isY9Machine;
|
||||
}
|
||||
|
||||
// 爱山东环境登录
|
||||
function getUserInfo() {
|
||||
lightAppJssdk.user.getUserInfoWithEncryptedParamByAppId({
|
||||
|
||||
@@ -1,35 +1,53 @@
|
||||
import {
|
||||
$api
|
||||
$api,
|
||||
safeReLaunch
|
||||
} from "./globalFunction";
|
||||
import baseDB from '@/utils/db.js';
|
||||
import useUserStore from '@/stores/useUserStore';
|
||||
import {
|
||||
playTextDirectly
|
||||
} from '@/hook/useTTSPlayer-all-in-one'
|
||||
|
||||
|
||||
export class IncreaseRevie {
|
||||
constructor(arg) {
|
||||
this.myEventCallback = this.myCallback.bind(this);
|
||||
this._debounceTimer = null;
|
||||
this.init();
|
||||
|
||||
this._debounceTimer = null; // 防抖计时器
|
||||
this._initTimer = null; // 启动延时计时器
|
||||
}
|
||||
|
||||
init() {
|
||||
|
||||
start() {
|
||||
this.close();
|
||||
setTimeout(() => {
|
||||
|
||||
this._initTimer = setTimeout(() => {
|
||||
if (window.hh?.on) {
|
||||
console.log('开始监听 QR 扫码事件');
|
||||
window.hh.on('initQRListener', this.myEventCallback);
|
||||
}
|
||||
this._initTimer = null;
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
|
||||
close() {
|
||||
if (this._initTimer) {
|
||||
clearTimeout(this._initTimer);
|
||||
this._initTimer = null;
|
||||
}
|
||||
|
||||
if (window.hh?.off) {
|
||||
window.hh.off('initQRListener', this.myEventCallback);
|
||||
}
|
||||
|
||||
if (this._debounceTimer) {
|
||||
clearTimeout(this._debounceTimer);
|
||||
this._debounceTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
myCallback(res) {
|
||||
if (this._debounceTimer) {
|
||||
clearTimeout(this._debounceTimer);
|
||||
@@ -41,25 +59,170 @@ export class IncreaseRevie {
|
||||
}, 300);
|
||||
}
|
||||
|
||||
|
||||
async handleDebouncedCallback(res) {
|
||||
if (res.data) {
|
||||
const code = res.data.qrQode
|
||||
console.log('二维码code', code);
|
||||
// 把code给到后端,后端拿code兑换用户信息,给前端返回token进行登录
|
||||
// 一体机用户需要清空indexDB
|
||||
// useUserStore()
|
||||
// .loginSetToken(resData.token)
|
||||
// .then((resume) => {
|
||||
// if (resume.data.jobTitleId) {
|
||||
// useUserStore().initSeesionId();
|
||||
// safeReLaunch('/pages/index/index');
|
||||
// } else {
|
||||
// safeReLaunch('/pages/login/login');
|
||||
// }
|
||||
// });
|
||||
// baseDB.resetAndReinit(); // 清空indexdb
|
||||
const code = res.data.qrCode;
|
||||
if (/^\d{6}$/.test(String(code))) {
|
||||
$api.createRequest(`/app/qrcodeLogin/${code}`, {}, 'get').then((resData) => {
|
||||
useUserStore()
|
||||
.loginSetToken(resData.token)
|
||||
.then((resume) => {
|
||||
playTextDirectly('登录成功');
|
||||
// 根据是否有一体机岗位ID判断跳转路径
|
||||
if (resume.data.jobTitleId) {
|
||||
useUserStore().initSeesionId();
|
||||
safeReLaunch('/pages/index/index');
|
||||
} else {
|
||||
safeReLaunch('/pages/login/login');
|
||||
}
|
||||
// 关闭监听,避免重复扫码
|
||||
this.close();
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// 格式不对不做处理,或者提示
|
||||
console.log('QR Code format mismatch');
|
||||
}
|
||||
} else {
|
||||
$api.msg('识别失败')
|
||||
$api.msg('识别失败');
|
||||
playTextDirectly('识别失败');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
export class FaceLoginService {
|
||||
constructor() {
|
||||
this.isInitialized = false;
|
||||
this.defaultScope = "auth_user,yingpin"; // 默认聚合授权参数
|
||||
|
||||
this._retryTimer = null;
|
||||
}
|
||||
|
||||
start(scope = null) {
|
||||
// 启动前先清理可能存在的旧状态
|
||||
this.close();
|
||||
console.log("[FaceLogin] 服务启动...");
|
||||
// 开始执行初始化逻辑
|
||||
this.init(scope);
|
||||
}
|
||||
|
||||
close() {
|
||||
// 1. 清除正在等待执行的重试定时器
|
||||
if (this._retryTimer) {
|
||||
clearTimeout(this._retryTimer);
|
||||
this._retryTimer = null;
|
||||
console.log("[FaceLogin] 已取消挂起的初始化重试");
|
||||
}
|
||||
|
||||
// 2. 重置初始化状态
|
||||
this.isInitialized = false;
|
||||
|
||||
console.log("[FaceLogin] 服务已关闭");
|
||||
}
|
||||
|
||||
async init(scope = null, retryCount = 1) {
|
||||
const params = {
|
||||
action: "initFace",
|
||||
event: "open",
|
||||
taskId: this._generateTaskId(),
|
||||
params: {
|
||||
serviceId: "",
|
||||
query: "",
|
||||
scope: scope || this.defaultScope
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
await this._bridgeCall(params);
|
||||
this.isInitialized = true;
|
||||
console.log("[FaceLogin] 初始化成功");
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error(`[FaceLogin] 初始化失败: ${err.message}`);
|
||||
if (retryCount > 0) {
|
||||
console.log("[FaceLogin] 3秒后尝试重新初始化...");
|
||||
this._retryTimer = setTimeout(() => {
|
||||
this._retryTimer = null; // 执行时清空引用
|
||||
this.init(scope, retryCount - 1); // 递归重试
|
||||
}, 3000);
|
||||
} else {
|
||||
// throw err; // 视业务逻辑决定是否阻断
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 2. 唤起 1:N 刷脸并获取 AuthCode
|
||||
*/
|
||||
async startFaceLogin() {
|
||||
if (!this.isInitialized) {
|
||||
console.warn("[FaceLogin] 服务未初始化,尝试自动补救初始化...");
|
||||
try {
|
||||
await this.init(null, 0);
|
||||
if (!this.isInitialized) throw new Error("初始化未完成");
|
||||
} catch (e) {
|
||||
throw new Error("刷脸服务初始化失败,请稍后重试");
|
||||
}
|
||||
}
|
||||
|
||||
const params = {
|
||||
action: "getFaceInfo",
|
||||
event: "open",
|
||||
taskId: this._generateTaskId()
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await this._bridgeCall(params);
|
||||
const {
|
||||
data
|
||||
} = res;
|
||||
// if (!data || !data.extInfo) {
|
||||
// throw new Error("返回数据缺少 extInfo");
|
||||
// }
|
||||
|
||||
// let extInfoObj;
|
||||
// try {
|
||||
// extInfoObj = JSON.parse(data.extInfo);
|
||||
// } catch (e) {
|
||||
// throw new Error("extInfo JSON解析失败");
|
||||
// }
|
||||
|
||||
// if (!extInfoObj.authCode) {
|
||||
// throw new Error("未获取到 authCode");
|
||||
// }
|
||||
|
||||
return data;
|
||||
|
||||
} catch (error) {
|
||||
const errMsg = error.message || error.subMessage || "刷脸失败";
|
||||
throw new Error(errMsg);
|
||||
}
|
||||
}
|
||||
|
||||
_bridgeCall(params) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (typeof hh === 'undefined' || !hh.call) {
|
||||
return reject(new Error("Bridge环境未就绪 (hh未定义)"));
|
||||
}
|
||||
|
||||
hh.call("ampeHHCommunication", params, (res) => {
|
||||
res = JSON.parse(res)
|
||||
if (res && res.success) {
|
||||
resolve(res);
|
||||
} else {
|
||||
reject(res || {
|
||||
message: "未知错误",
|
||||
success: false
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
_generateTaskId() {
|
||||
return 'task_' + Date.now() + '_' + Math.floor(Math.random() * 1000);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -42,3 +42,27 @@ uni-modal .uni-modal__ft{
|
||||
font-size: 36rpx !important;
|
||||
line-height: 80rpx !important;
|
||||
}
|
||||
|
||||
.uni-popup-dialog{
|
||||
width: 420rpx !important;
|
||||
}
|
||||
.uni-dialog-title{
|
||||
padding-top: 40rpx !important;
|
||||
}
|
||||
.uni-dialog-title-text{
|
||||
font-size: 34rpx !important;
|
||||
}
|
||||
.uni-dialog-content{
|
||||
padding: 25rpx !important;
|
||||
}
|
||||
.uni-dialog-content-text{
|
||||
font-size: 30rpx !important;
|
||||
}
|
||||
.uni-dialog-button{
|
||||
height: 80rpx !important;
|
||||
}
|
||||
.uni-dialog-button-text{
|
||||
font-size: 36rpx !important;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
>
|
||||
<!-- 顶部头部区域 -->
|
||||
<view
|
||||
v-if="showHeader || defaultShow"
|
||||
class="container-header"
|
||||
:style="border ? { borderBottom: `2rpx solid ${borderColor}` } : { borderBottom: 'none' }"
|
||||
>
|
||||
@@ -20,6 +21,7 @@
|
||||
<slot name="headerright"></slot>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
|
||||
<!-- 主体不可滚动 headContent -->
|
||||
<view class="container-headContent">
|
||||
@@ -43,6 +45,10 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from "vue"
|
||||
import { storeToRefs } from 'pinia';
|
||||
import useUserStore from '@/stores/useUserStore';
|
||||
const { isMiniProgram } = storeToRefs(useUserStore());
|
||||
import img from '@/static/icon/background2.png';
|
||||
const emit = defineEmits(['onScrollBottom']);
|
||||
defineProps({
|
||||
@@ -82,8 +88,16 @@ defineProps({
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showHeader: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const defaultShow = computed(()=>{
|
||||
return isMiniProgram.value
|
||||
})
|
||||
|
||||
const handleScrollToLower = () => {
|
||||
emit('onScrollBottom');
|
||||
};
|
||||
|
||||
@@ -33,10 +33,14 @@ onMounted(() => {
|
||||
state.resolve = options.resolve;
|
||||
popup.value.open();
|
||||
});
|
||||
uni.$on('hide-global-popup',()=>{
|
||||
popup.value.close()
|
||||
})
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
uni.$off('show-global-popup');
|
||||
uni.$off('hide-global-popup');
|
||||
});
|
||||
|
||||
const onConfirm = () => {
|
||||
|
||||
@@ -66,11 +66,11 @@ const props = defineProps({
|
||||
},
|
||||
longitude: {
|
||||
type: Number,
|
||||
default: 120.382665,
|
||||
default: 120.366085,
|
||||
},
|
||||
latitude: {
|
||||
type: Number,
|
||||
default: 36.066938,
|
||||
default: 36.086656,
|
||||
},
|
||||
seeDate: {
|
||||
type: String,
|
||||
|
||||
@@ -41,11 +41,11 @@ const props = defineProps({
|
||||
},
|
||||
longitude: {
|
||||
type: Number,
|
||||
default: 120.382665,
|
||||
default: 120.366085,
|
||||
},
|
||||
latitude: {
|
||||
type: Number,
|
||||
default: 36.066938,
|
||||
default: 36.086656,
|
||||
},
|
||||
seeDate: {
|
||||
type: String,
|
||||
|
||||
@@ -50,11 +50,11 @@ const props = defineProps({
|
||||
},
|
||||
longitude: {
|
||||
type: Number,
|
||||
default: 120.382665,
|
||||
default: 120.366085,
|
||||
},
|
||||
latitude: {
|
||||
type: Number,
|
||||
default: 36.066938,
|
||||
default: 36.086656,
|
||||
},
|
||||
seeDate: {
|
||||
type: String,
|
||||
|
||||
@@ -93,11 +93,11 @@ const props = defineProps({
|
||||
},
|
||||
longitude: {
|
||||
type: Number,
|
||||
default: 120.382665,
|
||||
default: 120.366085,
|
||||
},
|
||||
latitude: {
|
||||
type: Number,
|
||||
default: 36.066938,
|
||||
default: 36.086656,
|
||||
},
|
||||
seeDate: {
|
||||
type: String,
|
||||
|
||||
@@ -93,11 +93,11 @@ const props = defineProps({
|
||||
},
|
||||
longitude: {
|
||||
type: Number,
|
||||
default: 120.382665,
|
||||
default: 120.366085,
|
||||
},
|
||||
latitude: {
|
||||
type: Number,
|
||||
default: 36.066938,
|
||||
default: 36.086656,
|
||||
},
|
||||
seeDate: {
|
||||
type: String,
|
||||
|
||||
@@ -93,11 +93,11 @@ const props = defineProps({
|
||||
},
|
||||
longitude: {
|
||||
type: Number,
|
||||
default: 120.382665,
|
||||
default: 120.366085,
|
||||
},
|
||||
latitude: {
|
||||
type: Number,
|
||||
default: 36.066938,
|
||||
default: 36.086656,
|
||||
},
|
||||
seeDate: {
|
||||
type: String,
|
||||
|
||||
@@ -56,11 +56,11 @@ const props = defineProps({
|
||||
},
|
||||
longitude: {
|
||||
type: Number,
|
||||
default: 120.382665,
|
||||
default: 120.366085,
|
||||
},
|
||||
latitude: {
|
||||
type: Number,
|
||||
default: 36.066938,
|
||||
default: 36.086656,
|
||||
},
|
||||
seeDate: {
|
||||
type: String,
|
||||
|
||||
@@ -56,11 +56,11 @@ const props = defineProps({
|
||||
},
|
||||
longitude: {
|
||||
type: Number,
|
||||
default: 120.382665,
|
||||
default: 120.366085,
|
||||
},
|
||||
latitude: {
|
||||
type: Number,
|
||||
default: 36.066938,
|
||||
default: 36.086656,
|
||||
},
|
||||
seeDate: {
|
||||
type: String,
|
||||
|
||||
@@ -25,7 +25,7 @@ import { useReadMsg } from '@/stores/useReadMsg';
|
||||
import useScreenStore from '@/stores/useScreenStore'
|
||||
|
||||
import useUserStore from '@/stores/useUserStore';
|
||||
const { isMachineEnv } = storeToRefs(useUserStore());
|
||||
const { isMachineEnv, hasLogin } = storeToRefs(useUserStore());
|
||||
|
||||
const screenStore = useScreenStore()
|
||||
const {isWideScreen} = screenStore
|
||||
@@ -95,7 +95,7 @@ onMounted(() => {
|
||||
});
|
||||
|
||||
const changeItem = (item) => {
|
||||
if(isMachineEnv.value && item.needLogin){
|
||||
if(isMachineEnv.value && item.needLogin && !hasLogin.value){
|
||||
useUserStore().logOut()
|
||||
}else{
|
||||
uni.switchTab({
|
||||
|
||||
@@ -4,12 +4,11 @@ export default {
|
||||
// baseUrl: 'http://192.168.3.29:8081',
|
||||
// baseUrl: 'http://10.213.6.207:19010/api',
|
||||
// 语音转文字
|
||||
vioceBaseURl: 'wss://qd.zhaopinzao8dian.com/api/app/asr/connect', // 自定义
|
||||
// vioceBaseURl: 'wss://fw.rc.qingdao.gov.cn/rgpp-api/api/app/asr/connect', // 内网
|
||||
// vioceBaseURl: 'wss://qd.zhaopinzao8dian.com/api/app/asr/connect', // 测试
|
||||
vioceBaseURl: 'wss://fw.rc.qingdao.gov.cn/rgpp-api/api/app/asr/connect', // 正式
|
||||
// 语音合成
|
||||
// speechSynthesis: 'wss://qd.zhaopinzao8dian.com/api/speech-synthesis',
|
||||
speechSynthesis2: 'wss://resource.zhuoson.com/synthesis/', //直接替换即可
|
||||
// speechSynthesis2: 'http://39.98.44.136:19527', //直接替换即可
|
||||
// speechSynthesis: 'wss://qd.zhaopinzao8dian.com/api/speech-synthesis', // 测试
|
||||
speechSynthesis: 'wss://fw.rc.qingdao.gov.cn/rgpp-api/api/app/tts/connect', // 正式
|
||||
// indexedDB
|
||||
DBversion: 3,
|
||||
// 只使用本地缓寸的数据
|
||||
|
||||
459
hook/piper-bundle.js
Normal file
459
hook/piper-bundle.js
Normal file
@@ -0,0 +1,459 @@
|
||||
/**
|
||||
* PiperTTS Bundle (SDK + Worker + PCMPlayer)
|
||||
* Fix: Smart End Detection that supports Pause/Resume
|
||||
*/
|
||||
class PCMPlayer {
|
||||
constructor(options) {
|
||||
this.init(options);
|
||||
}
|
||||
|
||||
init(options) {
|
||||
this.option = Object.assign({}, {
|
||||
inputCodec: 'Int16',
|
||||
channels: 1,
|
||||
sampleRate: 16000,
|
||||
flushTime: 50,
|
||||
fftSize: 2048,
|
||||
}, options);
|
||||
|
||||
this.samples = new Float32Array();
|
||||
this.interval = setInterval(this.flush.bind(this), this.option.flushTime);
|
||||
this.convertValue = this.getConvertValue();
|
||||
this.typedArray = this.getTypedArray();
|
||||
|
||||
this.initAudioContext();
|
||||
this.bindAudioContextEvent();
|
||||
}
|
||||
|
||||
getConvertValue() {
|
||||
const map = {
|
||||
Int8: 128,
|
||||
Int16: 32768,
|
||||
Int32: 2147483648,
|
||||
Float32: 1
|
||||
};
|
||||
if (!map[this.option.inputCodec]) throw new Error('Codec Error');
|
||||
return map[this.option.inputCodec];
|
||||
}
|
||||
|
||||
getTypedArray() {
|
||||
const map = {
|
||||
Int8: Int8Array,
|
||||
Int16: Int16Array,
|
||||
Int32: Int32Array,
|
||||
Float32: Float32Array
|
||||
};
|
||||
if (!map[this.option.inputCodec]) throw new Error('Codec Error');
|
||||
return map[this.option.inputCodec];
|
||||
}
|
||||
|
||||
initAudioContext() {
|
||||
this.audioCtx = new(window.AudioContext || window.webkitAudioContext)();
|
||||
this.gainNode = this.audioCtx.createGain();
|
||||
this.gainNode.gain.value = 1.0;
|
||||
this.gainNode.connect(this.audioCtx.destination);
|
||||
this.startTime = this.audioCtx.currentTime;
|
||||
this.analyserNode = this.audioCtx.createAnalyser();
|
||||
this.analyserNode.fftSize = this.option.fftSize;
|
||||
}
|
||||
|
||||
static isTypedArray(data) {
|
||||
return (data.byteLength && data.buffer && data.buffer.constructor == ArrayBuffer) || data.constructor ==
|
||||
ArrayBuffer;
|
||||
}
|
||||
|
||||
isSupported(data) {
|
||||
if (!PCMPlayer.isTypedArray(data)) throw new Error('Data must be ArrayBuffer or TypedArray');
|
||||
return true;
|
||||
}
|
||||
|
||||
feed(data) {
|
||||
this.isSupported(data);
|
||||
data = this.getFormattedValue(data);
|
||||
const tmp = new Float32Array(this.samples.length + data.length);
|
||||
tmp.set(this.samples, 0);
|
||||
tmp.set(data, this.samples.length);
|
||||
this.samples = tmp;
|
||||
}
|
||||
|
||||
getFormattedValue(data) {
|
||||
data = data.constructor == ArrayBuffer ? new this.typedArray(data) : new this.typedArray(data.buffer);
|
||||
let float32 = new Float32Array(data.length);
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
float32[i] = data[i] / this.convertValue;
|
||||
}
|
||||
return float32;
|
||||
}
|
||||
|
||||
volume(val) {
|
||||
this.gainNode.gain.value = val;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.interval) clearInterval(this.interval);
|
||||
this.samples = null;
|
||||
if (this.audioCtx) {
|
||||
this.audioCtx.close();
|
||||
this.audioCtx = null;
|
||||
}
|
||||
}
|
||||
|
||||
flush() {
|
||||
if (!this.samples.length) return;
|
||||
|
||||
const bufferSource = this.audioCtx.createBufferSource();
|
||||
if (typeof this.option.onended === 'function') {
|
||||
bufferSource.onended = (e) => this.option.onended(this, e);
|
||||
}
|
||||
|
||||
const length = this.samples.length / this.option.channels;
|
||||
const audioBuffer = this.audioCtx.createBuffer(this.option.channels, length, this.option.sampleRate);
|
||||
|
||||
for (let channel = 0; channel < this.option.channels; channel++) {
|
||||
const audioData = audioBuffer.getChannelData(channel);
|
||||
let offset = channel;
|
||||
let decrement = 50;
|
||||
for (let i = 0; i < length; i++) {
|
||||
audioData[i] = this.samples[offset];
|
||||
if (i < 50) audioData[i] = (audioData[i] * i) / 50;
|
||||
if (i >= length - 51) audioData[i] = (audioData[i] * decrement--) / 50;
|
||||
offset += this.option.channels;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.startTime < this.audioCtx.currentTime) {
|
||||
this.startTime = this.audioCtx.currentTime;
|
||||
}
|
||||
|
||||
bufferSource.buffer = audioBuffer;
|
||||
bufferSource.connect(this.gainNode);
|
||||
bufferSource.connect(this.analyserNode);
|
||||
bufferSource.start(this.startTime);
|
||||
|
||||
this.startTime += audioBuffer.duration;
|
||||
this.samples = new Float32Array();
|
||||
}
|
||||
|
||||
async pause() {
|
||||
await this.audioCtx.suspend();
|
||||
}
|
||||
async continue () {
|
||||
await this.audioCtx.resume();
|
||||
}
|
||||
|
||||
bindAudioContextEvent() {
|
||||
if (typeof this.option.onstatechange === 'function') {
|
||||
this.audioCtx.onstatechange = (e) => {
|
||||
this.option.onstatechange(this, e, this.audioCtx.state);
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Worker 源码
|
||||
// ==========================================
|
||||
const WORKER_SOURCE = `
|
||||
let globalWs = null;
|
||||
|
||||
self.onmessage = function (e) {
|
||||
const { type, data } = e.data;
|
||||
switch (type) {
|
||||
case 'connect': connectWebSocket(data); break;
|
||||
case 'stop': closeWs(); break;
|
||||
}
|
||||
};
|
||||
|
||||
function closeWs() {
|
||||
if (globalWs) {
|
||||
globalWs.onerror = null;
|
||||
globalWs.onclose = null;
|
||||
globalWs.onmessage = null;
|
||||
try { globalWs.close(1000, 'User stopped'); } catch (e) {}
|
||||
globalWs = null;
|
||||
}
|
||||
}
|
||||
|
||||
function connectWebSocket(config) {
|
||||
closeWs();
|
||||
|
||||
const { url, text, options } = config;
|
||||
self.postMessage({ type: 'status', data: 'ws_connecting' });
|
||||
|
||||
try {
|
||||
const currentWs = new WebSocket(url);
|
||||
currentWs.binaryType = 'arraybuffer';
|
||||
globalWs = currentWs;
|
||||
|
||||
currentWs.onopen = () => {
|
||||
if (globalWs !== currentWs) return;
|
||||
self.postMessage({ type: 'status', data: 'ws_connected' });
|
||||
currentWs.send(JSON.stringify({
|
||||
text: text,
|
||||
speaker_id: options.speakerId || 0,
|
||||
length_scale: options.lengthScale || 1.0,
|
||||
noise_scale: options.noiseScale || 0.667,
|
||||
}));
|
||||
self.postMessage({ type: 'status', data: 'generating' });
|
||||
};
|
||||
|
||||
currentWs.onmessage = (event) => {
|
||||
if (globalWs !== currentWs) return;
|
||||
|
||||
if (typeof event.data === 'string' && event.data === 'END') {
|
||||
const wsToClose = currentWs;
|
||||
globalWs = null;
|
||||
|
||||
wsToClose.onmessage = null;
|
||||
wsToClose.onerror = null;
|
||||
wsToClose.onclose = null;
|
||||
|
||||
try { wsToClose.close(1000, 'Done'); } catch(e) {}
|
||||
|
||||
self.postMessage({ type: 'end' });
|
||||
} else {
|
||||
self.postMessage({ type: 'audio-data', buffer: event.data }, [event.data]);
|
||||
}
|
||||
};
|
||||
|
||||
currentWs.onclose = (e) => {
|
||||
if (globalWs === currentWs) {
|
||||
self.postMessage({ type: 'end' });
|
||||
globalWs = null;
|
||||
}
|
||||
};
|
||||
|
||||
currentWs.onerror = () => {
|
||||
if (globalWs === currentWs) {
|
||||
self.postMessage({ type: 'error', data: 'WebSocket error' });
|
||||
}
|
||||
};
|
||||
|
||||
} catch (e) {
|
||||
self.postMessage({ type: 'error', data: e.message });
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// ==========================================
|
||||
// PiperTTS SDK
|
||||
// ==========================================
|
||||
class PiperTTS {
|
||||
constructor(config = {}) {
|
||||
this.baseUrl = config.baseUrl || 'http://localhost:5001';
|
||||
this.onStatus = config.onStatus || console.log;
|
||||
this.onStart = config.onStart || (() => {});
|
||||
this.onEnd = config.onEnd || (() => {});
|
||||
this.sampleRate = config.sampleRate || 16000;
|
||||
|
||||
this.player = null;
|
||||
this.worker = null;
|
||||
this.recordedChunks = [];
|
||||
this.isRecording = false;
|
||||
|
||||
// 新增:检测音频结束的定时器 ID
|
||||
this.endCheckInterval = null;
|
||||
|
||||
this._initWorker();
|
||||
}
|
||||
|
||||
_initWorker() {
|
||||
const blob = new Blob([WORKER_SOURCE], {
|
||||
type: 'application/javascript'
|
||||
});
|
||||
this.worker = new Worker(URL.createObjectURL(blob));
|
||||
|
||||
this.worker.onmessage = (e) => {
|
||||
const {
|
||||
type,
|
||||
data,
|
||||
buffer
|
||||
} = e.data;
|
||||
switch (type) {
|
||||
case 'status':
|
||||
const map = {
|
||||
ws_connecting: '正在连接...',
|
||||
ws_connected: '已连接',
|
||||
generating: '流式接收中...'
|
||||
};
|
||||
this.onStatus(map[data] || data, 'processing');
|
||||
break;
|
||||
case 'error':
|
||||
if (this.recordedChunks.length > 0) {
|
||||
this.onStatus('数据接收完毕', 'success');
|
||||
this._triggerEndWithDelay();
|
||||
} else {
|
||||
this.onStatus(`错误: ${data}`, 'error');
|
||||
this.stop();
|
||||
}
|
||||
break;
|
||||
case 'audio-data':
|
||||
this._handleAudio(buffer);
|
||||
break;
|
||||
case 'end':
|
||||
this.onStatus('数据接收完毕', 'success');
|
||||
this._triggerEndWithDelay();
|
||||
break;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 【核心修改】智能轮询检测
|
||||
* 只有当 AudioContext 处于 running 状态且时间走完时,才触发 onEnd
|
||||
*/
|
||||
_triggerEndWithDelay() {
|
||||
// 先清除可能存在的旧定时器
|
||||
if (this.endCheckInterval) clearInterval(this.endCheckInterval);
|
||||
|
||||
// 每 200ms 检查一次
|
||||
this.endCheckInterval = setInterval(() => {
|
||||
// 1. 如果播放器没了,直接结束
|
||||
if (!this.player || !this.player.audioCtx) {
|
||||
this._finishEndCheck();
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 如果处于暂停状态 (suspended),什么都不做,继续等
|
||||
if (this.player.audioCtx.state === 'suspended') {
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. 计算剩余时间
|
||||
// startTime 是缓冲区结束的绝对时间,currentTime 是当前时间
|
||||
const remainingTime = this.player.startTime - this.player.audioCtx.currentTime;
|
||||
|
||||
// 4. 如果剩余时间小于 0.1秒(留点冗余),说明播完了
|
||||
if (remainingTime <= 0.1) {
|
||||
this._finishEndCheck();
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
|
||||
_finishEndCheck() {
|
||||
if (this.endCheckInterval) {
|
||||
clearInterval(this.endCheckInterval);
|
||||
this.endCheckInterval = null;
|
||||
}
|
||||
this.onEnd();
|
||||
}
|
||||
|
||||
_initPlayer() {
|
||||
if (this.player) {
|
||||
this.player.destroy();
|
||||
}
|
||||
|
||||
this.player = new PCMPlayer({
|
||||
inputCodec: 'Int16',
|
||||
channels: 1,
|
||||
sampleRate: this.sampleRate,
|
||||
flushTime: 50,
|
||||
});
|
||||
}
|
||||
|
||||
async speak(text, options = {}) {
|
||||
if (!text) return;
|
||||
this.stop();
|
||||
|
||||
this._initPlayer();
|
||||
if (this.player) {
|
||||
await this.player.continue();
|
||||
}
|
||||
|
||||
this.recordedChunks = [];
|
||||
this.isRecording = true;
|
||||
this.onStart();
|
||||
|
||||
const wsUrl = this.baseUrl.replace(/^http/, 'ws');
|
||||
this.worker.postMessage({
|
||||
type: 'connect',
|
||||
data: {
|
||||
url: wsUrl,
|
||||
text,
|
||||
options
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
stop() {
|
||||
// 停止时必须清除轮询检测
|
||||
if (this.endCheckInterval) {
|
||||
clearInterval(this.endCheckInterval);
|
||||
this.endCheckInterval = null;
|
||||
}
|
||||
|
||||
this.worker.postMessage({
|
||||
type: 'stop'
|
||||
});
|
||||
if (this.player) {
|
||||
this.player.destroy();
|
||||
this.player = null;
|
||||
}
|
||||
this.onStatus('已停止', 'default');
|
||||
}
|
||||
|
||||
_handleAudio(arrayBuffer) {
|
||||
if (this.isRecording) {
|
||||
this.recordedChunks.push(arrayBuffer);
|
||||
}
|
||||
if (this.player) {
|
||||
this.player.feed(arrayBuffer);
|
||||
}
|
||||
}
|
||||
|
||||
getAnalyserNode() {
|
||||
return this.player ? this.player.analyserNode : null;
|
||||
}
|
||||
|
||||
downloadAudio(filename = 'tts_output.wav') {
|
||||
if (this.recordedChunks.length === 0) return;
|
||||
let totalLen = 0;
|
||||
for (let chunk of this.recordedChunks) totalLen += chunk.byteLength;
|
||||
const tmp = new Uint8Array(totalLen);
|
||||
let offset = 0;
|
||||
for (let chunk of this.recordedChunks) {
|
||||
tmp.set(new Uint8Array(chunk), offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
const wavBuffer = this._encodeWAV(new Int16Array(tmp.buffer), this.sampleRate);
|
||||
const blob = new Blob([wavBuffer], {
|
||||
type: 'audio/wav'
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.style = 'display: none';
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
_encodeWAV(samples, sampleRate) {
|
||||
const buffer = new ArrayBuffer(44 + samples.length * 2);
|
||||
const view = new DataView(buffer);
|
||||
const writeString = (view, offset, string) => {
|
||||
for (let i = 0; i < string.length; i++) view.setUint8(offset + i, string.charCodeAt(i));
|
||||
};
|
||||
writeString(view, 0, 'RIFF');
|
||||
view.setUint32(4, 36 + samples.length * 2, true);
|
||||
writeString(view, 8, 'WAVE');
|
||||
writeString(view, 12, 'fmt ');
|
||||
view.setUint32(16, 16, true);
|
||||
view.setUint16(20, 1, true);
|
||||
view.setUint16(22, 1, true);
|
||||
view.setUint32(24, sampleRate, true);
|
||||
view.setUint32(28, sampleRate * 2, true);
|
||||
view.setUint16(32, 2, true);
|
||||
view.setUint16(34, 16, true);
|
||||
writeString(view, 36, 'data');
|
||||
view.setUint32(40, samples.length * 2, true);
|
||||
let offset = 44;
|
||||
for (let i = 0; i < samples.length; i++) {
|
||||
view.setInt16(offset, samples[i], true);
|
||||
offset += 2;
|
||||
}
|
||||
return view;
|
||||
}
|
||||
}
|
||||
export default PiperTTS;
|
||||
@@ -1,216 +0,0 @@
|
||||
/**
|
||||
* PiperTTS SDK - 兼容移动端的流式语音合成客户端
|
||||
* 特性:
|
||||
* 1. Web Audio API 实时调度,解决移动端不支持 MSE 的问题
|
||||
* 2. 头部注入 (Header Injection) 技术,解决分片解码错误
|
||||
* 3. 自动状态管理与事件回调
|
||||
*/
|
||||
export class PiperTTS {
|
||||
constructor(config = {}) {
|
||||
this.baseUrl = config.baseUrl || 'http://localhost:5001';
|
||||
this.wsUrl = config.wsUrl || '/ws/synthesize'
|
||||
this.audioCtx = config.audioCtx || new(window.AudioContext || window.webkitAudioContext)();
|
||||
this.onStatus = config.onStatus || ((msg, type) => console.log(`[Piper] ${msg}`));
|
||||
this.onStart = config.onStart || (() => {});
|
||||
this.onEnd = config.onEnd || (() => {});
|
||||
|
||||
|
||||
// 内部状态
|
||||
this.ws = null;
|
||||
this.nextTime = 0; // 下一段音频的预定播放时间
|
||||
this.audioHeader = null; // 保存WAV/MP3头部
|
||||
this.chunkQueue = []; // 数据缓冲队列
|
||||
this.queueSize = 0; // 当前缓冲区字节数
|
||||
this.analyser = null; // 可视化分析器节点
|
||||
|
||||
// 配置参数
|
||||
this.flushThreshold = 8 * 1024; // 8KB 阈值
|
||||
}
|
||||
|
||||
/**
|
||||
* [重要] 初始化音频引擎
|
||||
* 必须在用户点击事件(click/touch)中调用一次,否则手机上没声音
|
||||
*/
|
||||
async init() {
|
||||
if (this.audioCtx.state === 'suspended') {
|
||||
await this.audioCtx.resume();
|
||||
this.onStatus('音频引擎已激活', 'success');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定可视化分析器
|
||||
* @param {AnalyserNode} analyserNode - Web Audio Analyser节点
|
||||
*/
|
||||
attachVisualizer(analyserNode) {
|
||||
this.analyser = analyserNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始合成并播放
|
||||
* @param {string} text - 要合成的文本
|
||||
* @param {object} options - 可选参数 {speaker_id, noise_scale, etc.}
|
||||
*/
|
||||
speak(text, options = {}) {
|
||||
if (!text) return;
|
||||
|
||||
this.stop(); // 清理上一次播放
|
||||
this.onStatus('正在建立连接...', 'processing');
|
||||
|
||||
try {
|
||||
const wsUrl = this.baseUrl.replace(/^http/, 'ws') + this.wsUrl;
|
||||
this.ws = new WebSocket(wsUrl);
|
||||
this.ws.binaryType = 'arraybuffer';
|
||||
|
||||
this.ws.onopen = () => {
|
||||
this.onStatus('连接成功,请求生成...', 'processing');
|
||||
// 初始化时间轴:当前时间 + 缓冲延迟
|
||||
this.nextTime = this.audioCtx.currentTime + 0.1;
|
||||
this.onStart();
|
||||
|
||||
this.ws.send(
|
||||
JSON.stringify({
|
||||
text: text,
|
||||
speaker_id: options.speakerId || null,
|
||||
length_scale: options.lengthScale || 1.0,
|
||||
noise_scale: options.noiseScale || 0.667,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => this._handleMessage(event);
|
||||
|
||||
this.ws.onclose = async () => {
|
||||
// 处理剩余残余数据
|
||||
if (this.chunkQueue.length > 0) {
|
||||
await this._processQueue(true);
|
||||
}
|
||||
this.onStatus('播放结束', 'success');
|
||||
this.onEnd();
|
||||
};
|
||||
|
||||
this.ws.onerror = (err) => {
|
||||
console.error(err);
|
||||
this.onStatus('连接发生错误', 'error');
|
||||
};
|
||||
} catch (e) {
|
||||
this.onStatus(`启动失败: ${e.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止播放并重置状态
|
||||
*/
|
||||
stop() {
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
// 重置缓冲
|
||||
this.chunkQueue = [];
|
||||
this.queueSize = 0;
|
||||
this.audioHeader = null;
|
||||
// 注意:Web Audio API 很难"立即停止"已经在 flight 中的 node,
|
||||
// 除非我们追踪所有的 sourceNode 并调用 .stop()。
|
||||
// 简单实现:suspend 再 resume 或者关闭 context (不推荐频繁关闭)。
|
||||
// 这里的 stop 主要停止数据接收。
|
||||
}
|
||||
|
||||
// --- 内部私有方法 ---
|
||||
|
||||
async _handleMessage(event) {
|
||||
if (!(event.data instanceof ArrayBuffer)) return;
|
||||
|
||||
const chunk = event.data;
|
||||
|
||||
// 1. 捕获头部 (Header Injection 核心)
|
||||
if (!this.audioHeader) {
|
||||
// 截取前100字节作为通用头
|
||||
this.audioHeader = chunk.slice(0, 100);
|
||||
}
|
||||
|
||||
// 2. 入队
|
||||
this.chunkQueue.push(chunk);
|
||||
this.queueSize += chunk.byteLength;
|
||||
|
||||
// 3. 达到阈值则解码播放
|
||||
if (this.queueSize >= this.flushThreshold) {
|
||||
await this._processQueue();
|
||||
}
|
||||
}
|
||||
|
||||
async _processQueue(isLast = false) {
|
||||
if (this.chunkQueue.length === 0) return;
|
||||
|
||||
// 1. 合并 Buffer
|
||||
const rawData = new Uint8Array(this.queueSize);
|
||||
let offset = 0;
|
||||
for (const chunk of this.chunkQueue) {
|
||||
rawData.set(new Uint8Array(chunk), offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
|
||||
// 清空队列
|
||||
this.chunkQueue = [];
|
||||
this.queueSize = 0;
|
||||
|
||||
try {
|
||||
// 2. 构造带头部的 Buffer
|
||||
let decodeTarget;
|
||||
// 简单的头部检测逻辑,如果没有头,就拼上去
|
||||
if (this.audioHeader && !this._hasHeader(rawData)) {
|
||||
const newBuffer = new Uint8Array(this.audioHeader.byteLength + rawData.byteLength);
|
||||
newBuffer.set(new Uint8Array(this.audioHeader), 0);
|
||||
newBuffer.set(rawData, this.audioHeader.byteLength);
|
||||
decodeTarget = newBuffer.buffer;
|
||||
} else {
|
||||
decodeTarget = rawData.buffer;
|
||||
}
|
||||
|
||||
// 3. 解码
|
||||
const decodedBuffer = await this.audioCtx.decodeAudioData(decodeTarget);
|
||||
|
||||
// 4. 播放调度
|
||||
this._scheduleBuffer(decodedBuffer);
|
||||
} catch (err) {
|
||||
// 解码失败处理:如果是中间数据,放回队列头部等待拼接
|
||||
if (!isLast) {
|
||||
this.chunkQueue.unshift(rawData);
|
||||
this.queueSize += rawData.byteLength;
|
||||
} else {
|
||||
console.warn('最后一段数据解码失败,丢弃', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_scheduleBuffer(decodedBuffer) {
|
||||
const source = this.audioCtx.createBufferSource();
|
||||
source.buffer = decodedBuffer;
|
||||
|
||||
// 连接可视化
|
||||
if (this.analyser) {
|
||||
source.connect(this.analyser);
|
||||
this.analyser.connect(this.audioCtx.destination);
|
||||
} else {
|
||||
source.connect(this.audioCtx.destination);
|
||||
}
|
||||
|
||||
// 计算播放时间:如果发生卡顿,立即播放;否则无缝衔接
|
||||
const scheduleTime = Math.max(this.audioCtx.currentTime, this.nextTime);
|
||||
source.start(scheduleTime);
|
||||
|
||||
// 更新下一段的开始时间
|
||||
this.nextTime = scheduleTime + decodedBuffer.duration;
|
||||
}
|
||||
|
||||
_hasHeader(uint8Arr) {
|
||||
if (uint8Arr.byteLength < 4) return false;
|
||||
// Check "RIFF" (WAV)
|
||||
if (uint8Arr[0] === 82 && uint8Arr[1] === 73 && uint8Arr[2] === 70) return true;
|
||||
// Check "ID3" (MP3)
|
||||
if (uint8Arr[0] === 73 && uint8Arr[1] === 68 && uint8Arr[2] === 51) return true;
|
||||
// Check MP3 Sync Word (Simplify)
|
||||
if (uint8Arr[0] === 0xff && (uint8Arr[1] & 0xe0) === 0xe0) return true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
710
hook/useAudioSpeak.js
Normal file
710
hook/useAudioSpeak.js
Normal file
@@ -0,0 +1,710 @@
|
||||
// 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元/月" 格式,保持原样
|
||||
}
|
||||
|
||||
// 处理经验要求格式
|
||||
let experienceText = job.experience;
|
||||
if (experienceText) {
|
||||
// 匹配 "X-X年" 格式,如 "1-3年"、"5-10年"
|
||||
const expRangeMatch = experienceText.match(/(\d+)-(\d+)年/);
|
||||
if (expRangeMatch) {
|
||||
const minExp = parseInt(expRangeMatch[1], 10);
|
||||
const maxExp = parseInt(expRangeMatch[2], 10);
|
||||
experienceText = `${minExp}到${maxExp}年`;
|
||||
}
|
||||
|
||||
// 还可以处理其他常见格式
|
||||
else if (experienceText.includes('年以下')) {
|
||||
// 如 "3年以下"
|
||||
experienceText = experienceText.replace('年以下', '年以下');
|
||||
} else if (experienceText.includes('年以上')) {
|
||||
// 如 "5年以上"
|
||||
experienceText = experienceText.replace('年以上', '年以上');
|
||||
} else if (experienceText === '不限' || experienceText === '无经验要求') {
|
||||
experienceText = '经验不限';
|
||||
}
|
||||
// 其他格式保持原样
|
||||
}
|
||||
|
||||
// 同样可以处理学历要求(如果需要的话)
|
||||
let educationText = job.education;
|
||||
// 这里可以添加学历格式的转换逻辑
|
||||
|
||||
return `第 ${index + 1} 个岗位,岗位名称是:${job.jobTitle},公司是:${job.companyName},薪资:${salaryText},地点:${job.location},学历要求:${educationText},经验要求:${experienceText}。`;
|
||||
});
|
||||
|
||||
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();
|
||||
if (!cleanText) return [];
|
||||
|
||||
const segments = [];
|
||||
|
||||
// 1. 按完整标点分割成独立的句子(包括中英文标点)
|
||||
// 正则解释:匹配非标点字符 + 标点符号(或者匹配到结尾)
|
||||
const sentenceRegex = /([^。?!;,、\n\r\.\?!;,]+[。?!;,、\n\r\.\?!;,]+|.+$)/g;
|
||||
|
||||
let currentIndex = 0;
|
||||
let match;
|
||||
const rawSentences = [];
|
||||
|
||||
while ((match = sentenceRegex.exec(cleanText)) !== null) {
|
||||
const sentence = match[0].trim();
|
||||
if (sentence) {
|
||||
rawSentences.push(sentence);
|
||||
}
|
||||
currentIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
// 处理最后剩余的部分
|
||||
if (currentIndex < cleanText.length) {
|
||||
const remaining = cleanText.substring(currentIndex).trim();
|
||||
if (remaining) {
|
||||
rawSentences.push(remaining);
|
||||
}
|
||||
}
|
||||
|
||||
// 如果正则没有匹配到,整个文本作为一句话
|
||||
if (rawSentences.length === 0) {
|
||||
rawSentences.push(cleanText);
|
||||
}
|
||||
|
||||
// 2. 处理每个句子
|
||||
for (const sentence of rawSentences) {
|
||||
if (sentence.length <= maxSegmentLength) {
|
||||
// 句子长度正常,直接作为一个片段
|
||||
segments.push(sentence);
|
||||
} else {
|
||||
// 句子超长,需要分割
|
||||
// console.log('检测到超长句子,需要分割:', sentence);
|
||||
|
||||
let currentPos = 0;
|
||||
const sentenceLength = sentence.length;
|
||||
|
||||
while (currentPos < sentenceLength) {
|
||||
// 优先在标点处分割
|
||||
let splitPos = -1;
|
||||
const searchStart = currentPos;
|
||||
const searchEnd = Math.min(currentPos + maxSegmentLength, sentenceLength);
|
||||
|
||||
// 在搜索范围内找标点
|
||||
for (let i = searchEnd - 1; i > searchStart; i--) {
|
||||
if (/[。?!;,、\n\r\.\?!;,]/u.test(sentence[i])) {
|
||||
splitPos = i + 1; // 包含标点
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没找到标点,在最大限制处分割
|
||||
if (splitPos === -1) {
|
||||
splitPos = searchEnd;
|
||||
}
|
||||
|
||||
// 确保至少分割出一个字符
|
||||
if (splitPos <= currentPos) {
|
||||
splitPos = currentPos + 1;
|
||||
}
|
||||
|
||||
const segment = sentence.substring(currentPos, splitPos).trim();
|
||||
if (segment) {
|
||||
segments.push(segment);
|
||||
}
|
||||
|
||||
currentPos = splitPos;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 特殊情况:合并以冒号开头的短片段到上一句
|
||||
const finalSegments = [];
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const currentSegment = segments[i];
|
||||
|
||||
// 检查是否以冒号开头且很短(可能是被错误分割的部分)
|
||||
if (i > 0 &&
|
||||
(currentSegment.startsWith(':') || currentSegment.startsWith(':')) &&
|
||||
currentSegment.length < 15 &&
|
||||
!currentSegment.endsWith('。') &&
|
||||
!currentSegment.endsWith('!') &&
|
||||
!currentSegment.endsWith('?')) {
|
||||
|
||||
// 尝试合并到上一句
|
||||
const previousSegment = finalSegments[finalSegments.length - 1];
|
||||
if (previousSegment && (previousSegment.length + currentSegment.length) <= maxSegmentLength) {
|
||||
finalSegments[finalSegments.length - 1] = previousSegment + currentSegment;
|
||||
} else {
|
||||
finalSegments.push(currentSegment);
|
||||
}
|
||||
} else {
|
||||
finalSegments.push(currentSegment);
|
||||
}
|
||||
}
|
||||
|
||||
// 清理:移除空白和空字符串
|
||||
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}"`);
|
||||
|
||||
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}`);
|
||||
|
||||
// 如果音频上下文被暂停,先恢复
|
||||
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('开始新的语音播报');
|
||||
|
||||
|
||||
cleanup();
|
||||
// 等待一小段时间确保资源清理完成
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -56,12 +56,16 @@ export function useColumnCount(onChange = () => {}) {
|
||||
// if (process.client) {
|
||||
window.addEventListener('resize', calcColumn)
|
||||
// }
|
||||
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
// if (process.client) {
|
||||
|
||||
// if (process.client) {
|
||||
window.removeEventListener('resize', calcColumn)
|
||||
// }
|
||||
|
||||
|
||||
})
|
||||
|
||||
// 列数变化时执行回调
|
||||
|
||||
@@ -3,255 +3,343 @@ import {
|
||||
onUnmounted
|
||||
} from 'vue'
|
||||
import {
|
||||
$api,
|
||||
|
||||
$api
|
||||
} from '../common/globalFunction';
|
||||
|
||||
import config from '@/config'
|
||||
|
||||
// Alibaba Cloud
|
||||
export function useAudioRecorder() {
|
||||
// --- 状态定义 ---
|
||||
const isRecording = ref(false)
|
||||
const isStopping = ref(false)
|
||||
const isSocketConnected = ref(false)
|
||||
const recordingDuration = ref(0)
|
||||
|
||||
const audioDataForDisplay = ref(new Array(16).fill(0))
|
||||
const volumeLevel = ref(0)
|
||||
|
||||
const volumeLevel = ref(0) // 0-100
|
||||
const recognizedText = ref('')
|
||||
const lastFinalText = ref('')
|
||||
|
||||
let audioStream = null
|
||||
let audioContext = null
|
||||
let audioInput = null
|
||||
let scriptProcessor = null
|
||||
let websocket = null
|
||||
// --- 内部变量 ---
|
||||
let socketTask = null
|
||||
let durationTimer = null
|
||||
|
||||
const generateUUID = () => {
|
||||
return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11)
|
||||
.replace(/[018]/g, c =>
|
||||
(c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16)
|
||||
).replace(/-/g, '')
|
||||
// --- APP/小程序 变量 ---
|
||||
let recorderManager = null;
|
||||
|
||||
// --- H5 变量 ---
|
||||
let audioContext = null;
|
||||
let scriptProcessor = null;
|
||||
let mediaStreamSource = null;
|
||||
let h5Stream = null;
|
||||
|
||||
// --- 配置项 ---
|
||||
const RECORD_CONFIG = {
|
||||
duration: 600000,
|
||||
sampleRate: 16000,
|
||||
numberOfChannels: 1,
|
||||
format: 'pcm',
|
||||
frameSize: 4096
|
||||
}
|
||||
|
||||
const fetchWsUrl = async () => {
|
||||
const res = await $api.createRequest('/app/speech/getToken')
|
||||
if (res.code !== 200) throw new Error('无法获取语音识别 wsUrl')
|
||||
const wsUrl = res.msg
|
||||
return wsUrl
|
||||
/**
|
||||
* 获取 WebSocket 地址 (含 Token)
|
||||
*/
|
||||
const getWsUrl = async () => {
|
||||
let wsUrl = config.vioceBaseURl
|
||||
|
||||
// 拼接 Token
|
||||
const token = uni.getStorageSync('token') || '';
|
||||
if (token) {
|
||||
const separator = wsUrl.includes('?') ? '&' : '?';
|
||||
wsUrl = `${wsUrl}${separator}token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
return wsUrl;
|
||||
}
|
||||
|
||||
function extractWsParams(wsUrl) {
|
||||
const url = new URL(wsUrl)
|
||||
const appkey = url.searchParams.get('appkey')
|
||||
const token = url.searchParams.get('token')
|
||||
return {
|
||||
appkey,
|
||||
token
|
||||
/**
|
||||
* 开始录音 (入口)
|
||||
*/
|
||||
const startRecording = async () => {
|
||||
if (isRecording.value) return
|
||||
|
||||
try {
|
||||
recognizedText.value = ''
|
||||
volumeLevel.value = 0
|
||||
|
||||
// #ifdef H5
|
||||
if (location.protocol !== 'https:' && location.hostname !== 'localhost') {
|
||||
uni.showToast({
|
||||
title: 'H5录音需要HTTPS环境',
|
||||
icon: 'none'
|
||||
});
|
||||
return;
|
||||
}
|
||||
// #endif
|
||||
|
||||
const url = await getWsUrl()
|
||||
console.log('正在连接 ASR:', url)
|
||||
|
||||
await connectSocket(url);
|
||||
|
||||
} catch (err) {
|
||||
console.error('启动失败:', err);
|
||||
uni.showToast({
|
||||
title: '启动失败: ' + (err.message || ''),
|
||||
icon: 'none'
|
||||
});
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const connectWebSocket = async () => {
|
||||
const wsUrl = await fetchWsUrl()
|
||||
const {
|
||||
appkey,
|
||||
token
|
||||
} = extractWsParams(wsUrl)
|
||||
/**
|
||||
* 连接 WebSocket
|
||||
*/
|
||||
const connectSocket = (url) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
websocket = new WebSocket(wsUrl)
|
||||
websocket.binaryType = 'arraybuffer'
|
||||
socketTask = uni.connectSocket({
|
||||
url: url,
|
||||
success: () => console.log('Socket 连接请求发送'),
|
||||
fail: (err) => reject(err)
|
||||
});
|
||||
|
||||
websocket.onopen = () => {
|
||||
isSocketConnected.value = true
|
||||
socketTask.onOpen((res) => {
|
||||
console.log('WebSocket 已连接');
|
||||
isSocketConnected.value = true;
|
||||
|
||||
// 发送 StartTranscription 消息(参考 demo.html)
|
||||
const startTranscriptionMessage = {
|
||||
header: {
|
||||
appkey: appkey, // 不影响使用,可留空或由 wsUrl 带入
|
||||
namespace: 'SpeechTranscriber',
|
||||
name: 'StartTranscription',
|
||||
task_id: generateUUID(),
|
||||
message_id: generateUUID()
|
||||
},
|
||||
payload: {
|
||||
format: 'pcm',
|
||||
sample_rate: 16000,
|
||||
enable_intermediate_result: true,
|
||||
enable_punctuation_prediction: true,
|
||||
enable_inverse_text_normalization: true
|
||||
}
|
||||
// #ifdef H5
|
||||
startH5Recording().then(() => resolve()).catch(err => {
|
||||
socketTask.close();
|
||||
reject(err);
|
||||
});
|
||||
// #endif
|
||||
|
||||
// #ifndef H5
|
||||
startAppRecording();
|
||||
resolve();
|
||||
// #endif
|
||||
});
|
||||
|
||||
socketTask.onMessage((res) => {
|
||||
// 接收文本结果
|
||||
if (res.data) {
|
||||
recognizedText.value = res.data;
|
||||
}
|
||||
websocket.send(JSON.stringify(startTranscriptionMessage))
|
||||
resolve()
|
||||
}
|
||||
});
|
||||
|
||||
websocket.onerror = (e) => {
|
||||
isSocketConnected.value = false
|
||||
reject(e)
|
||||
}
|
||||
socketTask.onError((err) => {
|
||||
console.error('Socket 错误:', err);
|
||||
isSocketConnected.value = false;
|
||||
stopRecording();
|
||||
});
|
||||
|
||||
websocket.onclose = () => {
|
||||
isSocketConnected.value = false
|
||||
}
|
||||
|
||||
websocket.onmessage = (e) => {
|
||||
const msg = JSON.parse(e.data)
|
||||
const name = msg?.header?.name
|
||||
const payload = msg?.payload
|
||||
|
||||
switch (name) {
|
||||
case 'TranscriptionResultChanged': {
|
||||
// 中间识别文本(可选:使用 stash_result.unfixedText 更精确)
|
||||
const text = payload?.unfixed_result || payload?.result || ''
|
||||
lastFinalText.value = text
|
||||
break
|
||||
}
|
||||
case 'SentenceBegin': {
|
||||
// 可选:开始新的一句,重置状态
|
||||
// console.log('开始新的句子识别')
|
||||
break
|
||||
}
|
||||
case 'SentenceEnd': {
|
||||
const text = payload?.result || ''
|
||||
const confidence = payload?.confidence || 0
|
||||
if (text && confidence > 0.5) {
|
||||
recognizedText.value += text
|
||||
lastFinalText.value = ''
|
||||
// console.log('识别完成:', {
|
||||
// text,
|
||||
// confidence
|
||||
// })
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'TranscriptionStarted': {
|
||||
// console.log('识别任务已开始')
|
||||
break
|
||||
}
|
||||
case 'TranscriptionCompleted': {
|
||||
lastFinalText.value = ''
|
||||
// console.log('识别全部完成')
|
||||
break
|
||||
}
|
||||
case 'TaskFailed': {
|
||||
console.error('识别失败:', msg?.header?.status_text)
|
||||
break
|
||||
}
|
||||
default:
|
||||
console.log('未知消息类型:', name, msg)
|
||||
break
|
||||
}
|
||||
}
|
||||
socketTask.onClose(() => {
|
||||
isSocketConnected.value = false;
|
||||
console.log('Socket 已关闭');
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
const startRecording = async () => {
|
||||
if (isRecording.value) return
|
||||
const startH5Recording = async () => {
|
||||
try {
|
||||
recognizedText.value = ''
|
||||
lastFinalText.value = ''
|
||||
await connectWebSocket()
|
||||
|
||||
audioStream = await navigator.mediaDevices.getUserMedia({
|
||||
// 1. 获取麦克风流
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: true
|
||||
})
|
||||
audioContext = new(window.AudioContext || window.webkitAudioContext)({
|
||||
});
|
||||
h5Stream = stream;
|
||||
|
||||
// 2. 创建 AudioContext
|
||||
const AudioContext = window.AudioContext || window.webkitAudioContext;
|
||||
audioContext = new AudioContext({
|
||||
sampleRate: 16000
|
||||
})
|
||||
audioInput = audioContext.createMediaStreamSource(audioStream)
|
||||
scriptProcessor = audioContext.createScriptProcessor(2048, 1, 1)
|
||||
});
|
||||
|
||||
mediaStreamSource = audioContext.createMediaStreamSource(stream);
|
||||
scriptProcessor = audioContext.createScriptProcessor(4096, 1, 1);
|
||||
|
||||
scriptProcessor.onaudioprocess = (event) => {
|
||||
const input = event.inputBuffer.getChannelData(0)
|
||||
const pcm = new Int16Array(input.length)
|
||||
let sum = 0
|
||||
for (let i = 0; i < input.length; ++i) {
|
||||
const s = Math.max(-1, Math.min(1, input[i]))
|
||||
pcm[i] = s * 0x7FFF
|
||||
sum += s * s
|
||||
if (!isSocketConnected.value || !socketTask) return;
|
||||
|
||||
const inputData = event.inputBuffer.getChannelData(0);
|
||||
|
||||
calculateVolume(inputData, true);
|
||||
|
||||
const buffer = new ArrayBuffer(inputData.length * 2);
|
||||
const view = new DataView(buffer);
|
||||
for (let i = 0; i < inputData.length; i++) {
|
||||
let s = Math.max(-1, Math.min(1, inputData[i]));
|
||||
view.setInt16(i * 2, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
|
||||
}
|
||||
|
||||
volumeLevel.value = Math.sqrt(sum / input.length)
|
||||
audioDataForDisplay.value = Array(16).fill(volumeLevel.value)
|
||||
socketTask.send({
|
||||
data: buffer,
|
||||
fail: (e) => console.error('发送音频失败', e)
|
||||
});
|
||||
};
|
||||
|
||||
if (websocket?.readyState === WebSocket.OPEN) {
|
||||
websocket.send(pcm.buffer)
|
||||
}
|
||||
}
|
||||
mediaStreamSource.connect(scriptProcessor);
|
||||
scriptProcessor.connect(audioContext.destination);
|
||||
|
||||
audioInput.connect(scriptProcessor)
|
||||
scriptProcessor.connect(audioContext.destination)
|
||||
isRecording.value = true;
|
||||
recordingDuration.value = 0;
|
||||
durationTimer = setInterval(() => recordingDuration.value++, 1000);
|
||||
|
||||
console.log('H5 录音已启动');
|
||||
|
||||
isRecording.value = true
|
||||
recordingDuration.value = 0
|
||||
durationTimer = setInterval(() => recordingDuration.value++, 1000)
|
||||
} catch (err) {
|
||||
console.error('启动失败:', err)
|
||||
cleanup()
|
||||
console.error('H5 录音启动失败:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const stopRecording = () => {
|
||||
if (!isRecording.value || isStopping.value) return
|
||||
isStopping.value = true
|
||||
const stopH5Resources = () => {
|
||||
if (scriptProcessor) scriptProcessor.disconnect();
|
||||
if (mediaStreamSource) mediaStreamSource.disconnect();
|
||||
if (audioContext) audioContext.close();
|
||||
if (h5Stream) h5Stream.getTracks().forEach(track => track.stop());
|
||||
|
||||
if (websocket?.readyState === WebSocket.OPEN) {
|
||||
websocket.send(JSON.stringify({
|
||||
header: {
|
||||
namespace: 'SpeechTranscriber',
|
||||
name: 'StopTranscription',
|
||||
message_id: generateUUID()
|
||||
}
|
||||
}))
|
||||
websocket.close()
|
||||
scriptProcessor = null;
|
||||
mediaStreamSource = null;
|
||||
audioContext = null;
|
||||
h5Stream = null;
|
||||
}
|
||||
|
||||
const startAppRecording = () => {
|
||||
recorderManager = uni.getRecorderManager();
|
||||
|
||||
recorderManager.onFrameRecorded((res) => {
|
||||
const {
|
||||
frameBuffer
|
||||
} = res;
|
||||
|
||||
calculateVolume(frameBuffer, false);
|
||||
|
||||
if (isSocketConnected.value && socketTask) {
|
||||
socketTask.send({
|
||||
data: frameBuffer
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
recorderManager.onStart(() => {
|
||||
console.log('APP 录音已开始');
|
||||
isRecording.value = true;
|
||||
recordingDuration.value = 0;
|
||||
durationTimer = setInterval(() => recordingDuration.value++, 1000);
|
||||
});
|
||||
|
||||
recorderManager.onError((err) => {
|
||||
console.error('APP 录音报错:', err);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
recorderManager.start(RECORD_CONFIG);
|
||||
}
|
||||
const stopHardwareResource = () => {
|
||||
// APP/小程序停止
|
||||
if (recorderManager) {
|
||||
recorderManager.stop();
|
||||
}
|
||||
|
||||
cleanup()
|
||||
isStopping.value = false
|
||||
// H5停止
|
||||
// #ifdef H5
|
||||
if (scriptProcessor) scriptProcessor.disconnect();
|
||||
if (mediaStreamSource) mediaStreamSource.disconnect();
|
||||
if (audioContext) audioContext.close();
|
||||
if (h5Stream) h5Stream.getTracks().forEach(track => track.stop());
|
||||
|
||||
scriptProcessor = null;
|
||||
mediaStreamSource = null;
|
||||
audioContext = null;
|
||||
h5Stream = null;
|
||||
// #endif
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止录音 (通用)
|
||||
*/
|
||||
const stopRecording = () => {
|
||||
// 停止 APP 录音
|
||||
if (recorderManager) {
|
||||
recorderManager.stop();
|
||||
}
|
||||
|
||||
// 停止 H5 录音资源
|
||||
// #ifdef H5
|
||||
stopH5Resources();
|
||||
// #endif
|
||||
|
||||
// 关闭 Socket
|
||||
if (socketTask) {
|
||||
socketTask.close();
|
||||
}
|
||||
|
||||
cleanup();
|
||||
}
|
||||
|
||||
const cancelRecording = () => {
|
||||
if (!isRecording.value || isStopping.value) return
|
||||
isStopping.value = true
|
||||
websocket?.close()
|
||||
cleanup()
|
||||
isStopping.value = false
|
||||
if (!isRecording.value) return;
|
||||
|
||||
console.log('取消录音 - 丢弃结果');
|
||||
|
||||
// 1. 停止硬件录音
|
||||
stopHardwareResource();
|
||||
|
||||
// 2. 强制关闭 Socket
|
||||
if (socketTask) {
|
||||
socketTask.close();
|
||||
}
|
||||
|
||||
// 3. 关键:清空已识别的文本
|
||||
recognizedText.value = '';
|
||||
|
||||
// 4. 清理资源
|
||||
cleanup();
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理状态
|
||||
*/
|
||||
const cleanup = () => {
|
||||
clearInterval(durationTimer)
|
||||
clearInterval(durationTimer);
|
||||
isRecording.value = false;
|
||||
isSocketConnected.value = false;
|
||||
socketTask = null;
|
||||
recorderManager = null;
|
||||
volumeLevel.value = 0;
|
||||
}
|
||||
|
||||
scriptProcessor?.disconnect()
|
||||
audioInput?.disconnect()
|
||||
audioStream?.getTracks().forEach(track => track.stop())
|
||||
audioContext?.close()
|
||||
/**
|
||||
* 计算音量 (兼容 Float32 和 Int16/ArrayBuffer)
|
||||
*/
|
||||
const calculateVolume = (data, isFloat32) => {
|
||||
let sum = 0;
|
||||
let length = 0;
|
||||
|
||||
audioStream = null
|
||||
audioContext = null
|
||||
audioInput = null
|
||||
scriptProcessor = null
|
||||
websocket = null
|
||||
|
||||
isRecording.value = false
|
||||
isSocketConnected.value = false
|
||||
if (isFloat32) {
|
||||
length = data.length;
|
||||
for (let i = 0; i < length; i += 10) {
|
||||
sum += Math.abs(data[i]);
|
||||
}
|
||||
volumeLevel.value = Math.min(100, Math.floor((sum / (length / 10)) * 100 * 3));
|
||||
} else {
|
||||
const int16Data = new Int16Array(data);
|
||||
length = int16Data.length;
|
||||
for (let i = 0; i < length; i += 10) {
|
||||
sum += Math.abs(int16Data[i]);
|
||||
}
|
||||
const avg = sum / (length / 10);
|
||||
volumeLevel.value = Math.min(100, Math.floor((avg / 10000) * 100));
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (isRecording.value) stopRecording()
|
||||
if (isRecording.value) {
|
||||
stopRecording();
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
isRecording,
|
||||
isStopping,
|
||||
isSocketConnected,
|
||||
recordingDuration,
|
||||
audioDataForDisplay,
|
||||
volumeLevel,
|
||||
recognizedText,
|
||||
lastFinalText,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
cancelRecording
|
||||
|
||||
@@ -1,348 +0,0 @@
|
||||
import {
|
||||
ref,
|
||||
onUnmounted
|
||||
} from 'vue'
|
||||
import {
|
||||
$api
|
||||
} from '../common/globalFunction'; // 你的请求封装
|
||||
import config from '@/config'
|
||||
|
||||
// 开源
|
||||
export function useAudioRecorder() {
|
||||
// --- 状态定义 ---
|
||||
const isRecording = ref(false)
|
||||
const isSocketConnected = ref(false)
|
||||
const recordingDuration = ref(0)
|
||||
const volumeLevel = ref(0) // 0-100
|
||||
const recognizedText = ref('')
|
||||
|
||||
// --- 内部变量 ---
|
||||
let socketTask = null
|
||||
let durationTimer = null
|
||||
|
||||
// --- APP/小程序 变量 ---
|
||||
let recorderManager = null;
|
||||
|
||||
// --- H5 变量 ---
|
||||
let audioContext = null;
|
||||
let scriptProcessor = null;
|
||||
let mediaStreamSource = null;
|
||||
let h5Stream = null;
|
||||
|
||||
// --- 配置项 ---
|
||||
const RECORD_CONFIG = {
|
||||
duration: 600000,
|
||||
sampleRate: 16000,
|
||||
numberOfChannels: 1,
|
||||
format: 'pcm',
|
||||
frameSize: 4096
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 WebSocket 地址 (含 Token)
|
||||
*/
|
||||
const getWsUrl = async () => {
|
||||
let wsUrl = config.vioceBaseURl
|
||||
|
||||
// 拼接 Token
|
||||
const token = uni.getStorageSync('token') || '';
|
||||
if (token) {
|
||||
const separator = wsUrl.includes('?') ? '&' : '?';
|
||||
wsUrl = `${wsUrl}${separator}token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
return wsUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始录音 (入口)
|
||||
*/
|
||||
const startRecording = async () => {
|
||||
if (isRecording.value) return
|
||||
|
||||
try {
|
||||
recognizedText.value = ''
|
||||
volumeLevel.value = 0
|
||||
|
||||
// #ifdef H5
|
||||
if (location.protocol !== 'https:' && location.hostname !== 'localhost') {
|
||||
uni.showToast({
|
||||
title: 'H5录音需要HTTPS环境',
|
||||
icon: 'none'
|
||||
});
|
||||
return;
|
||||
}
|
||||
// #endif
|
||||
|
||||
const url = await getWsUrl()
|
||||
console.log('正在连接 ASR:', url)
|
||||
|
||||
await connectSocket(url);
|
||||
|
||||
} catch (err) {
|
||||
console.error('启动失败:', err);
|
||||
uni.showToast({
|
||||
title: '启动失败: ' + (err.message || ''),
|
||||
icon: 'none'
|
||||
});
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接 WebSocket
|
||||
*/
|
||||
const connectSocket = (url) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
socketTask = uni.connectSocket({
|
||||
url: url,
|
||||
success: () => console.log('Socket 连接请求发送'),
|
||||
fail: (err) => reject(err)
|
||||
});
|
||||
|
||||
socketTask.onOpen((res) => {
|
||||
console.log('WebSocket 已连接');
|
||||
isSocketConnected.value = true;
|
||||
|
||||
// #ifdef H5
|
||||
startH5Recording().then(() => resolve()).catch(err => {
|
||||
socketTask.close();
|
||||
reject(err);
|
||||
});
|
||||
// #endif
|
||||
|
||||
// #ifndef H5
|
||||
startAppRecording();
|
||||
resolve();
|
||||
// #endif
|
||||
});
|
||||
|
||||
socketTask.onMessage((res) => {
|
||||
// 接收文本结果
|
||||
if (res.data) {
|
||||
recognizedText.value = res.data;
|
||||
}
|
||||
});
|
||||
|
||||
socketTask.onError((err) => {
|
||||
console.error('Socket 错误:', err);
|
||||
isSocketConnected.value = false;
|
||||
stopRecording();
|
||||
});
|
||||
|
||||
socketTask.onClose(() => {
|
||||
isSocketConnected.value = false;
|
||||
console.log('Socket 已关闭');
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
const startH5Recording = async () => {
|
||||
try {
|
||||
// 1. 获取麦克风流
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: true
|
||||
});
|
||||
h5Stream = stream;
|
||||
|
||||
// 2. 创建 AudioContext
|
||||
const AudioContext = window.AudioContext || window.webkitAudioContext;
|
||||
audioContext = new AudioContext({
|
||||
sampleRate: 16000
|
||||
});
|
||||
|
||||
mediaStreamSource = audioContext.createMediaStreamSource(stream);
|
||||
scriptProcessor = audioContext.createScriptProcessor(4096, 1, 1);
|
||||
|
||||
scriptProcessor.onaudioprocess = (event) => {
|
||||
if (!isSocketConnected.value || !socketTask) return;
|
||||
|
||||
const inputData = event.inputBuffer.getChannelData(0);
|
||||
|
||||
calculateVolume(inputData, true);
|
||||
|
||||
const buffer = new ArrayBuffer(inputData.length * 2);
|
||||
const view = new DataView(buffer);
|
||||
for (let i = 0; i < inputData.length; i++) {
|
||||
let s = Math.max(-1, Math.min(1, inputData[i]));
|
||||
view.setInt16(i * 2, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
|
||||
}
|
||||
|
||||
socketTask.send({
|
||||
data: buffer,
|
||||
fail: (e) => console.error('发送音频失败', e)
|
||||
});
|
||||
};
|
||||
|
||||
mediaStreamSource.connect(scriptProcessor);
|
||||
scriptProcessor.connect(audioContext.destination);
|
||||
|
||||
isRecording.value = true;
|
||||
recordingDuration.value = 0;
|
||||
durationTimer = setInterval(() => recordingDuration.value++, 1000);
|
||||
|
||||
console.log('H5 录音已启动');
|
||||
|
||||
} catch (err) {
|
||||
console.error('H5 录音启动失败:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const stopH5Resources = () => {
|
||||
if (scriptProcessor) scriptProcessor.disconnect();
|
||||
if (mediaStreamSource) mediaStreamSource.disconnect();
|
||||
if (audioContext) audioContext.close();
|
||||
if (h5Stream) h5Stream.getTracks().forEach(track => track.stop());
|
||||
|
||||
scriptProcessor = null;
|
||||
mediaStreamSource = null;
|
||||
audioContext = null;
|
||||
h5Stream = null;
|
||||
}
|
||||
|
||||
const startAppRecording = () => {
|
||||
recorderManager = uni.getRecorderManager();
|
||||
|
||||
recorderManager.onFrameRecorded((res) => {
|
||||
const {
|
||||
frameBuffer
|
||||
} = res;
|
||||
|
||||
calculateVolume(frameBuffer, false);
|
||||
|
||||
if (isSocketConnected.value && socketTask) {
|
||||
socketTask.send({
|
||||
data: frameBuffer
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
recorderManager.onStart(() => {
|
||||
console.log('APP 录音已开始');
|
||||
isRecording.value = true;
|
||||
recordingDuration.value = 0;
|
||||
durationTimer = setInterval(() => recordingDuration.value++, 1000);
|
||||
});
|
||||
|
||||
recorderManager.onError((err) => {
|
||||
console.error('APP 录音报错:', err);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
recorderManager.start(RECORD_CONFIG);
|
||||
}
|
||||
const stopHardwareResource = () => {
|
||||
// APP/小程序停止
|
||||
if (recorderManager) {
|
||||
recorderManager.stop();
|
||||
}
|
||||
|
||||
// H5停止
|
||||
// #ifdef H5
|
||||
if (scriptProcessor) scriptProcessor.disconnect();
|
||||
if (mediaStreamSource) mediaStreamSource.disconnect();
|
||||
if (audioContext) audioContext.close();
|
||||
if (h5Stream) h5Stream.getTracks().forEach(track => track.stop());
|
||||
|
||||
scriptProcessor = null;
|
||||
mediaStreamSource = null;
|
||||
audioContext = null;
|
||||
h5Stream = null;
|
||||
// #endif
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止录音 (通用)
|
||||
*/
|
||||
const stopRecording = () => {
|
||||
// 停止 APP 录音
|
||||
if (recorderManager) {
|
||||
recorderManager.stop();
|
||||
}
|
||||
|
||||
// 停止 H5 录音资源
|
||||
// #ifdef H5
|
||||
stopH5Resources();
|
||||
// #endif
|
||||
|
||||
// 关闭 Socket
|
||||
if (socketTask) {
|
||||
socketTask.close();
|
||||
}
|
||||
|
||||
cleanup();
|
||||
}
|
||||
|
||||
const cancelRecording = () => {
|
||||
if (!isRecording.value) return;
|
||||
|
||||
console.log('取消录音 - 丢弃结果');
|
||||
|
||||
// 1. 停止硬件录音
|
||||
stopHardwareResource();
|
||||
|
||||
// 2. 强制关闭 Socket
|
||||
if (socketTask) {
|
||||
socketTask.close();
|
||||
}
|
||||
|
||||
// 3. 关键:清空已识别的文本
|
||||
recognizedText.value = '';
|
||||
|
||||
// 4. 清理资源
|
||||
cleanup();
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理状态
|
||||
*/
|
||||
const cleanup = () => {
|
||||
clearInterval(durationTimer);
|
||||
isRecording.value = false;
|
||||
isSocketConnected.value = false;
|
||||
socketTask = null;
|
||||
recorderManager = null;
|
||||
volumeLevel.value = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算音量 (兼容 Float32 和 Int16/ArrayBuffer)
|
||||
*/
|
||||
const calculateVolume = (data, isFloat32) => {
|
||||
let sum = 0;
|
||||
let length = 0;
|
||||
|
||||
if (isFloat32) {
|
||||
length = data.length;
|
||||
for (let i = 0; i < length; i += 10) {
|
||||
sum += Math.abs(data[i]);
|
||||
}
|
||||
volumeLevel.value = Math.min(100, Math.floor((sum / (length / 10)) * 100 * 3));
|
||||
} else {
|
||||
const int16Data = new Int16Array(data);
|
||||
length = int16Data.length;
|
||||
for (let i = 0; i < length; i += 10) {
|
||||
sum += Math.abs(int16Data[i]);
|
||||
}
|
||||
const avg = sum / (length / 10);
|
||||
volumeLevel.value = Math.min(100, Math.floor((avg / 10000) * 100));
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (isRecording.value) {
|
||||
stopRecording();
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
isRecording,
|
||||
isSocketConnected,
|
||||
recordingDuration,
|
||||
volumeLevel,
|
||||
recognizedText,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
cancelRecording
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
import {
|
||||
ref,
|
||||
onUnmounted,
|
||||
readonly
|
||||
} from 'vue';
|
||||
|
||||
const defaultExtractSpeechText = (text) => text;
|
||||
|
||||
|
||||
export function useTTSPlayer() {
|
||||
const synth = window.speechSynthesis;
|
||||
const isSpeaking = ref(false);
|
||||
const isPaused = ref(false);
|
||||
const utteranceRef = ref(null);
|
||||
|
||||
const cleanup = () => {
|
||||
isSpeaking.value = false;
|
||||
isPaused.value = false;
|
||||
utteranceRef.value = null;
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {string} text - The text to be spoken.
|
||||
* @param {object} [options] - Optional settings for the speech.
|
||||
* @param {string} [options.lang] - Language (e.g., 'en-US', 'es-ES').
|
||||
* @param {number} [options.rate] - Speed (0.1 to 10, default 1).
|
||||
* @param {number} [options.pitch] - Pitch (0 to 2, default 1).
|
||||
* @param {SpeechSynthesisVoice} [options.voice] - A specific voice object.
|
||||
* @param {function(string): string} [options.extractSpeechText] - A function to filter/clean the text before speaking.
|
||||
*/
|
||||
const speak = (text, options = {}) => {
|
||||
if (!synth) {
|
||||
console.error('SpeechSynthesis API is not supported in this browser.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isSpeaking.value) {
|
||||
synth.cancel();
|
||||
}
|
||||
|
||||
const filteredText = extractSpeechText(text);
|
||||
|
||||
if (!filteredText || typeof filteredText !== 'string' || filteredText.trim() === '') {
|
||||
console.warn('Text to speak is empty after filtering.');
|
||||
cleanup(); // Ensure state is clean
|
||||
return;
|
||||
}
|
||||
|
||||
const newUtterance = new SpeechSynthesisUtterance(filteredText); // Use filtered text
|
||||
utteranceRef.value = newUtterance;
|
||||
|
||||
newUtterance.lang = 'zh-CN';
|
||||
newUtterance.rate = options.rate || 1;
|
||||
newUtterance.pitch = options.pitch || 1;
|
||||
if (options.voice) {
|
||||
newUtterance.voice = options.voice;
|
||||
}
|
||||
|
||||
newUtterance.onstart = () => {
|
||||
isSpeaking.value = true;
|
||||
isPaused.value = false;
|
||||
};
|
||||
|
||||
newUtterance.onpause = () => {
|
||||
isPaused.value = true;
|
||||
};
|
||||
newUtterance.onresume = () => {
|
||||
isPaused.value = false;
|
||||
};
|
||||
newUtterance.onend = () => {
|
||||
cleanup();
|
||||
};
|
||||
newUtterance.onerror = (event) => {
|
||||
console.error('SpeechSynthesis Error:', event.error);
|
||||
cleanup();
|
||||
};
|
||||
|
||||
synth.speak(newUtterance);
|
||||
};
|
||||
|
||||
const pause = () => {
|
||||
if (synth && isSpeaking.value && !isPaused.value) {
|
||||
synth.pause();
|
||||
}
|
||||
};
|
||||
|
||||
const resume = () => {
|
||||
if (synth && isPaused.value) {
|
||||
synth.resume();
|
||||
}
|
||||
};
|
||||
|
||||
const cancelAudio = () => {
|
||||
if (synth) {
|
||||
synth.cancel();
|
||||
}
|
||||
cleanup();
|
||||
};
|
||||
|
||||
onUnmounted(() => {
|
||||
cancelAudio();
|
||||
});
|
||||
|
||||
return {
|
||||
speak,
|
||||
pause,
|
||||
resume,
|
||||
cancelAudio,
|
||||
isSpeaking: readonly(isSpeaking),
|
||||
isPaused: readonly(isPaused),
|
||||
};
|
||||
}
|
||||
|
||||
function extractSpeechText(markdown) {
|
||||
const jobRegex = /``` job-json\s*({[\s\S]*?})\s*```/g;
|
||||
const jobs = [];
|
||||
let match;
|
||||
let lastJobEndIndex = 0;
|
||||
let firstJobStartIndex = -1;
|
||||
|
||||
// 提取岗位 json 数据及前后位置
|
||||
while ((match = jobRegex.exec(markdown)) !== null) {
|
||||
const jobStr = match[1];
|
||||
try {
|
||||
const job = JSON.parse(jobStr);
|
||||
jobs.push(job);
|
||||
if (firstJobStartIndex === -1) {
|
||||
firstJobStartIndex = match.index;
|
||||
}
|
||||
lastJobEndIndex = jobRegex.lastIndex;
|
||||
} catch (e) {
|
||||
console.warn('JSON 解析失败', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 提取引导语(第一个 job-json 之前的文字)
|
||||
const guideText = firstJobStartIndex > 0 ?
|
||||
markdown.slice(0, firstJobStartIndex).trim() :
|
||||
'';
|
||||
|
||||
// 提取结束语(最后一个 job-json 之后的文字)
|
||||
const endingText = lastJobEndIndex < markdown.length ?
|
||||
markdown.slice(lastJobEndIndex).trim() :
|
||||
'';
|
||||
|
||||
// 岗位信息格式化为语音文本
|
||||
const jobTexts = jobs.map((job, index) => {
|
||||
return `第 ${index + 1} 个岗位,岗位名称是:${job.jobTitle},公司是:${job.companyName},薪资:${job.salary},地点:${job.location},学历要求:${job.education},经验要求:${job.experience}。`;
|
||||
});
|
||||
|
||||
// 拼接总语音内容
|
||||
const finalTextParts = [];
|
||||
if (guideText) finalTextParts.push(guideText);
|
||||
finalTextParts.push(...jobTexts);
|
||||
if (endingText) finalTextParts.push(endingText);
|
||||
|
||||
return finalTextParts.join('\n');
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
import {
|
||||
ref,
|
||||
readonly,
|
||||
onUnmounted
|
||||
} from 'vue';
|
||||
|
||||
// 检查 API 兼容性
|
||||
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||
const isApiSupported = !!SpeechRecognition && !!navigator.mediaDevices && !!window.AudioContext;
|
||||
|
||||
/**
|
||||
* @param {object} [options]
|
||||
* @param {string} [options.lang] - Language code (e.g., 'zh-CN', 'en-US')
|
||||
* @returns {object}
|
||||
*/
|
||||
export function useAudioRecorder(options = {}) {
|
||||
const lang = options.lang || 'zh-CN'; // 默认使用中文
|
||||
|
||||
const isRecording = ref(false);
|
||||
const recognizedText = ref(''); // 完整的识别文本(包含临时的)
|
||||
const lastFinalText = ref(''); // 最后一段已确定的文本
|
||||
const volumeLevel = ref(0); // 音量 (0-100)
|
||||
const audioDataForDisplay = ref(new Uint8Array()); // 波形数据
|
||||
|
||||
let recognition = null;
|
||||
let audioContext = null;
|
||||
let analyser = null;
|
||||
let mediaStreamSource = null;
|
||||
let mediaStream = null;
|
||||
let dataArray = null; // 用于音量和波形
|
||||
let animationFrameId = null;
|
||||
|
||||
if (!isApiSupported) {
|
||||
console.warn(
|
||||
'此浏览器不支持Web语音API或Web音频API。钩子无法正常工作。'
|
||||
);
|
||||
return {
|
||||
isRecording: readonly(isRecording),
|
||||
startRecording: () => console.error('Audio recording not supported.'),
|
||||
stopRecording: () => {},
|
||||
cancelRecording: () => {},
|
||||
audioDataForDisplay: readonly(audioDataForDisplay),
|
||||
volumeLevel: readonly(volumeLevel),
|
||||
recognizedText: readonly(recognizedText),
|
||||
lastFinalText: readonly(lastFinalText),
|
||||
};
|
||||
}
|
||||
|
||||
const setupRecognition = () => {
|
||||
recognition = new SpeechRecognition();
|
||||
recognition.lang = lang;
|
||||
recognition.continuous = true; // 持续识别
|
||||
recognition.interimResults = true; // 返回临时结果
|
||||
|
||||
recognition.onstart = () => {
|
||||
isRecording.value = true;
|
||||
};
|
||||
|
||||
recognition.onend = () => {
|
||||
isRecording.value = false;
|
||||
stopAudioAnalysis(); // 语音识别停止时,也停止音频分析
|
||||
};
|
||||
|
||||
recognition.onerror = (event) => {
|
||||
console.error('SpeechRecognition Error:', event.error);
|
||||
isRecording.value = false;
|
||||
stopAudioAnalysis();
|
||||
};
|
||||
|
||||
recognition.onresult = (event) => {
|
||||
let interim = '';
|
||||
let final = '';
|
||||
|
||||
for (let i = 0; i < event.results.length; i++) {
|
||||
const transcript = event.results[i][0].transcript;
|
||||
if (event.results[i].isFinal) {
|
||||
final += transcript;
|
||||
lastFinalText.value = transcript; // 存储最后一段确定的文本
|
||||
} else {
|
||||
interim += transcript;
|
||||
}
|
||||
}
|
||||
recognizedText.value = final + interim; // 组合为完整文本
|
||||
};
|
||||
};
|
||||
|
||||
const startAudioAnalysis = async () => {
|
||||
try {
|
||||
mediaStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: true
|
||||
});
|
||||
audioContext = new AudioContext();
|
||||
analyser = audioContext.createAnalyser();
|
||||
mediaStreamSource = audioContext.createMediaStreamSource(mediaStream);
|
||||
|
||||
// 设置 Analyser
|
||||
analyser.fftSize = 512; // 必须是 2 的幂
|
||||
const bufferLength = analyser.frequencyBinCount;
|
||||
dataArray = new Uint8Array(bufferLength); // 用于波形
|
||||
|
||||
// 连接节点
|
||||
mediaStreamSource.connect(analyser);
|
||||
|
||||
// 开始循环分析
|
||||
updateAudioData();
|
||||
} catch (err) {
|
||||
console.error('Failed to get media stream or setup AudioContext:', err);
|
||||
if (err.name === 'NotAllowedError' || err.name === 'PermissionDeniedError') {
|
||||
alert('麦克风权限被拒绝。请在浏览器设置中允许访问麦克风。');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const updateAudioData = () => {
|
||||
if (!isRecording.value) return; // 如果停止了就退出循环
|
||||
|
||||
// 获取时域数据 (波形)
|
||||
analyser.getByteTimeDomainData(dataArray);
|
||||
audioDataForDisplay.value = new Uint8Array(dataArray); // 复制数组以触发响应式
|
||||
|
||||
// 计算音量 (RMS)
|
||||
let sumSquares = 0.0;
|
||||
for (const amplitude of dataArray) {
|
||||
const normalized = (amplitude / 128.0) - 1.0; // 转换为 -1.0 到 1.0
|
||||
sumSquares += normalized * normalized;
|
||||
}
|
||||
const rms = Math.sqrt(sumSquares / dataArray.length);
|
||||
volumeLevel.value = Math.min(100, Math.floor(rms * 250)); // 放大 RMS 值到 0-100 范围
|
||||
|
||||
animationFrameId = requestAnimationFrame(updateAudioData);
|
||||
};
|
||||
|
||||
const stopAudioAnalysis = () => {
|
||||
if (animationFrameId) {
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
animationFrameId = null;
|
||||
}
|
||||
// 停止麦克风轨道
|
||||
mediaStream?.getTracks().forEach((track) => track.stop());
|
||||
// 关闭 AudioContext
|
||||
audioContext?.close().catch((e) => console.error('Error closing AudioContext', e));
|
||||
|
||||
mediaStream = null;
|
||||
audioContext = null;
|
||||
analyser = null;
|
||||
mediaStreamSource = null;
|
||||
volumeLevel.value = 0;
|
||||
audioDataForDisplay.value = new Uint8Array();
|
||||
};
|
||||
|
||||
const startRecording = async () => {
|
||||
if (isRecording.value) return;
|
||||
|
||||
// 重置状态
|
||||
recognizedText.value = '';
|
||||
lastFinalText.value = '';
|
||||
|
||||
try {
|
||||
// 必须先启动音频分析以获取麦克风权限
|
||||
await startAudioAnalysis();
|
||||
|
||||
// 如果音频启动成功 (mediaStream 存在),则启动语音识别
|
||||
if (mediaStream) {
|
||||
setupRecognition();
|
||||
recognition.start();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error starting recording:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const stopRecording = () => {
|
||||
if (!isRecording.value || !recognition) return;
|
||||
recognition.stop(); // 这将触发 onend 事件,自动停止音频分析
|
||||
};
|
||||
|
||||
const cancelRecording = () => {
|
||||
if (!recognition) return;
|
||||
isRecording.value = false; // 立即设置状态
|
||||
recognition.abort(); // 这也会触发 onend
|
||||
recognizedText.value = '';
|
||||
lastFinalText.value = '';
|
||||
};
|
||||
|
||||
onUnmounted(() => {
|
||||
if (recognition) {
|
||||
recognition.abort();
|
||||
}
|
||||
stopAudioAnalysis();
|
||||
});
|
||||
|
||||
return {
|
||||
isRecording: readonly(isRecording),
|
||||
startRecording,
|
||||
stopRecording,
|
||||
cancelRecording,
|
||||
audioDataForDisplay: readonly(audioDataForDisplay),
|
||||
volumeLevel: readonly(volumeLevel),
|
||||
recognizedText: readonly(recognizedText),
|
||||
lastFinalText: readonly(lastFinalText),
|
||||
isApiSupported, // 导出支持状态
|
||||
};
|
||||
}
|
||||
21
hook/useTTS.js
Normal file
21
hook/useTTS.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import {
|
||||
useTTSPlayer as useWebTTS
|
||||
} from '@/hook/useTTSPlayer-web.js'
|
||||
import {
|
||||
useTTSPlayer as useHardwareTTS
|
||||
} from '@/hook/useTTSPlayer-all-in-one.js'
|
||||
import {
|
||||
isY9MachineType
|
||||
} from '../common/globalFunction';
|
||||
|
||||
/**
|
||||
* 智能 TTS 适配器 Hook
|
||||
* 自动判断环境并返回对应的播放器实现
|
||||
*/
|
||||
export function useTTSPlayer() {
|
||||
if (isY9MachineType()) {
|
||||
return useHardwareTTS()
|
||||
} else {
|
||||
return useWebTTS()
|
||||
}
|
||||
}
|
||||
296
hook/useTTSPlayer-all-in-one.js
Normal file
296
hook/useTTSPlayer-all-in-one.js
Normal file
@@ -0,0 +1,296 @@
|
||||
import {
|
||||
ref,
|
||||
onUnmounted
|
||||
} from 'vue'
|
||||
import {
|
||||
onHide,
|
||||
onUnload
|
||||
} from '@dcloudio/uni-app'
|
||||
import {
|
||||
isY9MachineType
|
||||
} from '../common/globalFunction';
|
||||
|
||||
/**
|
||||
* 封装 hh.call 通用调用
|
||||
*/
|
||||
const callBridge = (params) => {
|
||||
return new Promise((resolve) => {
|
||||
if (typeof window !== 'undefined' && window.hh && window.hh.call) {
|
||||
// 打印日志方便调试
|
||||
console.log('[TTS Bridge Send]:', params)
|
||||
window.hh.call("ampeHHCommunication", params, (res) => {
|
||||
console.log('[TTS Bridge Res]:', res)
|
||||
resolve(res)
|
||||
})
|
||||
} else {
|
||||
console.warn('当前环境不支持 hh.call,模拟成功')
|
||||
resolve({
|
||||
success: true
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成随机 TaskId
|
||||
*/
|
||||
const generateTaskId = () => {
|
||||
return 'task_' + Date.now() + '_' + Math.floor(Math.random() * 1000)
|
||||
}
|
||||
|
||||
/**
|
||||
* 直接播放文本 (静态方法,无需实例化 Hook)
|
||||
* @param {string} text - 需要朗读的文本
|
||||
*/
|
||||
export async function playTextDirectly(text) {
|
||||
if (!text) return
|
||||
|
||||
if (!isY9MachineType()) return
|
||||
|
||||
const processedText = extractSpeechText(text)
|
||||
if (!processedText) return
|
||||
|
||||
try {
|
||||
// 构造播放参数
|
||||
const params = {
|
||||
"action": "speech",
|
||||
"event": "open",
|
||||
"taskId": generateTaskId(),
|
||||
"params": {
|
||||
"text": processedText,
|
||||
"pitch": 1.0,
|
||||
"rate": 1.0,
|
||||
"speechQueue": 1 // 1表示打断当前播放,立即播放新的
|
||||
}
|
||||
}
|
||||
// 直接调用
|
||||
await callBridge(params)
|
||||
} catch (e) {
|
||||
console.error('Direct Play Error:', e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 一体机 TTS 播放器 Hook (修复版)
|
||||
*/
|
||||
export function useTTSPlayer() {
|
||||
// UI 状态
|
||||
const isSpeaking = ref(false) // 是否正在交互
|
||||
const isPaused = ref(false) // 是否处于暂停状态
|
||||
const isLoading = ref(false) // 是否正在加载
|
||||
|
||||
// 记录最后一次播放的文本
|
||||
const lastText = ref('')
|
||||
|
||||
/**
|
||||
* 停止 (中断) - 内部使用
|
||||
*/
|
||||
const executeStop = async () => {
|
||||
const params = {
|
||||
"action": "speechStop",
|
||||
"event": "open",
|
||||
"taskId": generateTaskId()
|
||||
}
|
||||
await callBridge(params)
|
||||
}
|
||||
|
||||
/**
|
||||
* 核心朗读方法
|
||||
*/
|
||||
const speak = async (text) => {
|
||||
if (!text) return
|
||||
|
||||
const processedText = extractSpeechText(text)
|
||||
if (!processedText) return
|
||||
|
||||
// 1. 立即更新记忆文本 (确保即使后续失败,resume也能读到新的)
|
||||
lastText.value = processedText
|
||||
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
// 2.【关键修复】先强制停止上一次播报
|
||||
// 虽然 speechQueue:1 理论上可以打断,但显式停止更稳健
|
||||
await executeStop()
|
||||
|
||||
// 3. 构造播放参数
|
||||
const params = {
|
||||
"action": "speech",
|
||||
"event": "open",
|
||||
"taskId": generateTaskId(),
|
||||
"params": {
|
||||
"text": processedText,
|
||||
"pitch": 1.0,
|
||||
"rate": 1.0,
|
||||
"speechQueue": 1 // 1表示打断
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 发送播放指令
|
||||
await callBridge(params)
|
||||
|
||||
// 5. 更新状态
|
||||
isSpeaking.value = true
|
||||
isPaused.value = false
|
||||
} catch (e) {
|
||||
console.error('TTS Speak Error:', e)
|
||||
resetState()
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 暂停
|
||||
*/
|
||||
const pause = async () => {
|
||||
if (!isSpeaking.value || isPaused.value) return
|
||||
try {
|
||||
await executeStop()
|
||||
isPaused.value = true
|
||||
// 注意:不要设置 isSpeaking = false,因为逻辑上只是暂停
|
||||
} catch (e) {
|
||||
console.error("Pause failed:", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 恢复 (由于硬件限制,实际上是从头播放当前文本)
|
||||
*/
|
||||
const resume = async () => {
|
||||
if (!isPaused.value) return
|
||||
|
||||
// 如果有缓存的文本,重新触发 speak
|
||||
if (lastText.value) {
|
||||
console.log('[TTS Resume] Re-speaking text:', lastText.value.substring(0, 20) + '...')
|
||||
// 调用 speak 会自动处理 stop 和状态更新
|
||||
await speak(lastText.value)
|
||||
} else {
|
||||
isPaused.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换 播放/暂停
|
||||
* 注意:如果是切换了新文章,请直接调用 speak(newText),不要调用 togglePlay
|
||||
*/
|
||||
const togglePlay = () => {
|
||||
// 如果当前是暂停状态,则恢复
|
||||
if (isPaused.value) {
|
||||
resume()
|
||||
}
|
||||
// 如果当前正在播放,则暂停
|
||||
else if (isSpeaking.value) {
|
||||
pause()
|
||||
}
|
||||
// 如果既没播放也没暂停(比如刚进页面),需要业务层调用 speak 启动,这里无法自动推断文本
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止并重置
|
||||
*/
|
||||
const stop = async () => {
|
||||
await executeStop()
|
||||
resetState()
|
||||
}
|
||||
|
||||
const resetState = () => {
|
||||
isSpeaking.value = false
|
||||
isPaused.value = false
|
||||
isLoading.value = false
|
||||
}
|
||||
|
||||
// === 生命周期管理 ===
|
||||
onUnmounted(() => stop())
|
||||
if (typeof onHide === 'function') onHide(() => stop())
|
||||
if (typeof onUnload === 'function') onUnload(() => stop())
|
||||
|
||||
return {
|
||||
speak,
|
||||
pause,
|
||||
resume,
|
||||
togglePlay,
|
||||
stop,
|
||||
cancelAudio: stop,
|
||||
isSpeaking,
|
||||
isPaused,
|
||||
isLoading
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 文本提取工具函数 (保持不变)
|
||||
*/
|
||||
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');
|
||||
}
|
||||
|
||||
|
||||
// 使用方法 1
|
||||
// import { useTTSPlayer } from '@/hook/useTTSPlayer-all-in-one'
|
||||
|
||||
// const { speak, stop, isSpeaking } = useTTSPlayer()
|
||||
|
||||
// // 调用
|
||||
// speak('你好,这是一段测试文本')
|
||||
|
||||
// 使用方法 2
|
||||
// import { playTextDirectly } from '@/hook/useTTSPlayer-all-in-one'
|
||||
|
||||
// // 直接调用即可,无需实例化
|
||||
// playTextDirectly('直接朗读这段话,不需要处理暂停和UI状态')
|
||||
267
hook/useTTSPlayer-web.js
Normal file
267
hook/useTTSPlayer-web.js
Normal file
@@ -0,0 +1,267 @@
|
||||
import {
|
||||
ref,
|
||||
onUnmounted
|
||||
} from 'vue'
|
||||
import {
|
||||
onHide,
|
||||
onUnload
|
||||
} from '@dcloudio/uni-app'
|
||||
import config from '@/config'
|
||||
import PiperTTS from './piper-bundle.js'
|
||||
|
||||
export function useTTSPlayer() {
|
||||
// UI 状态
|
||||
const isSpeaking = ref(false) // 是否正在交互(含播放、暂停、加载)
|
||||
const isPaused = ref(false) // 是否处于暂停状态
|
||||
const isLoading = ref(false) // 是否正在加载/连接
|
||||
|
||||
// 单例 Piper 实例
|
||||
let piper = null
|
||||
|
||||
/**
|
||||
* 获取 WebSocket 地址 (含 Token)
|
||||
*/
|
||||
const getWsUrl = async () => {
|
||||
let wsUrl = config.speechSynthesis
|
||||
|
||||
// 拼接 Token
|
||||
const token = uni.getStorageSync('token') || '';
|
||||
if (token) {
|
||||
const separator = wsUrl.includes('?') ? '&' : '?';
|
||||
wsUrl = `${wsUrl}${separator}token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
return wsUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取或创建 SDK 实例
|
||||
*/
|
||||
const getPiperInstance = async () => {
|
||||
if (!piper) {
|
||||
|
||||
let baseUrl = await getWsUrl()
|
||||
baseUrl = baseUrl.replace(/\/$/, '')
|
||||
|
||||
piper = new PiperTTS({
|
||||
baseUrl: baseUrl,
|
||||
sampleRate: 16000,
|
||||
onStatus: (msg, type) => {
|
||||
if (type === 'error') {
|
||||
console.error('[TTS Error]', msg)
|
||||
resetState()
|
||||
}
|
||||
},
|
||||
onStart: () => {
|
||||
isLoading.value = false
|
||||
isSpeaking.value = true
|
||||
isPaused.value = false
|
||||
},
|
||||
onEnd: () => {
|
||||
// 只有非暂停状态下的结束,才重置所有状态
|
||||
// 如果是用户手动暂停导致的中断,不应视为自然播放结束
|
||||
isSpeaking.value = false
|
||||
isLoading.value = false
|
||||
isPaused.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
return piper
|
||||
}
|
||||
|
||||
/**
|
||||
* 核心朗读方法
|
||||
*/
|
||||
const speak = async (text) => {
|
||||
if (!text) return
|
||||
|
||||
const processedText = extractSpeechText(text)
|
||||
if (!processedText) return
|
||||
|
||||
const instance = await getPiperInstance()
|
||||
|
||||
// 重置状态
|
||||
isLoading.value = true
|
||||
isPaused.value = false
|
||||
isSpeaking.value = true
|
||||
|
||||
try {
|
||||
// 直接调用 speak,SDK 内部会自动处理 init 和 stop
|
||||
await instance.speak(processedText, {
|
||||
speakerId: 0,
|
||||
noiseScale: 0.667,
|
||||
lengthScale: 1.0
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('TTS Speak Error:', e)
|
||||
resetState()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 暂停
|
||||
*/
|
||||
const pause = async () => {
|
||||
// 1. 只有正在播放且未暂停时,才执行暂停
|
||||
if (!isSpeaking.value || isPaused.value) return
|
||||
|
||||
// 2. 检查播放器实例是否存在
|
||||
if (piper && piper.player) {
|
||||
try {
|
||||
// 执行音频挂起
|
||||
await piper.player.pause()
|
||||
// 3. 成功后更新 UI
|
||||
isPaused.value = true
|
||||
} catch (e) {
|
||||
console.error("Pause failed:", e)
|
||||
// 即使报错,如果不是致命错误,也可以尝试强制更新 UI
|
||||
// isPaused.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 恢复 (继续播放)
|
||||
*/
|
||||
const resume = async () => {
|
||||
// 1. 只有处于暂停状态时,才执行恢复
|
||||
if (!isPaused.value) return
|
||||
|
||||
if (piper && piper.player) {
|
||||
try {
|
||||
await piper.player.continue()
|
||||
// 2. 成功后更新 UI
|
||||
isPaused.value = false
|
||||
isSpeaking.value = true
|
||||
} catch (e) {
|
||||
console.error("Resume failed:", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换 播放/暂停 (方便按钮绑定)
|
||||
*/
|
||||
const togglePlay = () => {
|
||||
if (isPaused.value) {
|
||||
resume()
|
||||
} else {
|
||||
pause()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止 (中断)
|
||||
*/
|
||||
const stop = () => {
|
||||
if (piper) {
|
||||
piper.stop()
|
||||
}
|
||||
resetState()
|
||||
}
|
||||
|
||||
/**
|
||||
* 彻底销毁
|
||||
*/
|
||||
const destroy = () => {
|
||||
if (piper) {
|
||||
piper.stop()
|
||||
piper = null
|
||||
}
|
||||
resetState()
|
||||
}
|
||||
|
||||
const resetState = () => {
|
||||
isSpeaking.value = false
|
||||
isPaused.value = false
|
||||
isLoading.value = false
|
||||
}
|
||||
|
||||
// === 生命周期管理 ===
|
||||
|
||||
onUnmounted(destroy)
|
||||
|
||||
if (typeof onHide === 'function') {
|
||||
onHide(() => {
|
||||
togglePlay()
|
||||
// stop()
|
||||
})
|
||||
}
|
||||
|
||||
if (typeof onUnload === 'function') {
|
||||
onUnload(destroy)
|
||||
}
|
||||
|
||||
return {
|
||||
speak,
|
||||
pause,
|
||||
resume,
|
||||
togglePlay, // 新增:单按钮切换功能
|
||||
stop,
|
||||
cancelAudio: stop,
|
||||
isSpeaking,
|
||||
isPaused,
|
||||
isLoading
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 文本提取工具函数 (保持原样)
|
||||
*/
|
||||
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');
|
||||
}
|
||||
@@ -1,251 +0,0 @@
|
||||
import {
|
||||
ref,
|
||||
onUnmounted,
|
||||
onBeforeUnmount,
|
||||
onMounted
|
||||
} from 'vue'
|
||||
import {
|
||||
onHide,
|
||||
onUnload
|
||||
} from '@dcloudio/uni-app'
|
||||
import WavDecoder from '@/lib/wav-decoder@1.3.0.js'
|
||||
import config from '@/config'
|
||||
|
||||
export function useTTSPlayer() {
|
||||
const isSpeaking = ref(false)
|
||||
const isPaused = ref(false)
|
||||
const isComplete = ref(false)
|
||||
|
||||
const audioContext = new(window.AudioContext || window.webkitAudioContext)()
|
||||
let playTime = audioContext.currentTime
|
||||
let sourceNodes = []
|
||||
let socket = null
|
||||
let sampleRate = 16000
|
||||
let numChannels = 1
|
||||
let isHeaderDecoded = false
|
||||
let pendingText = null
|
||||
|
||||
let currentPlayId = 0
|
||||
let activePlayId = 0
|
||||
|
||||
const speak = (text) => {
|
||||
currentPlayId++
|
||||
const myPlayId = currentPlayId
|
||||
reset()
|
||||
pendingText = text
|
||||
activePlayId = myPlayId
|
||||
}
|
||||
|
||||
const pause = () => {
|
||||
if (audioContext.state === 'running') {
|
||||
audioContext.suspend()
|
||||
isPaused.value = true
|
||||
isSpeaking.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const resume = () => {
|
||||
if (audioContext.state === 'suspended') {
|
||||
audioContext.resume()
|
||||
isPaused.value = false
|
||||
isSpeaking.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const cancelAudio = () => {
|
||||
stop()
|
||||
}
|
||||
|
||||
const stop = () => {
|
||||
isSpeaking.value = false
|
||||
isPaused.value = false
|
||||
isComplete.value = false
|
||||
playTime = audioContext.currentTime
|
||||
|
||||
sourceNodes.forEach(node => {
|
||||
try {
|
||||
node.stop()
|
||||
node.disconnect()
|
||||
} catch (e) {}
|
||||
})
|
||||
sourceNodes = []
|
||||
|
||||
if (socket) {
|
||||
socket.close()
|
||||
socket = null
|
||||
}
|
||||
|
||||
isHeaderDecoded = false
|
||||
pendingText = null
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
stop()
|
||||
isSpeaking.value = false
|
||||
isPaused.value = false
|
||||
isComplete.value = false
|
||||
playTime = audioContext.currentTime
|
||||
initWebSocket()
|
||||
}
|
||||
|
||||
const initWebSocket = () => {
|
||||
const thisPlayId = currentPlayId
|
||||
socket = new WebSocket(config.speechSynthesis)
|
||||
socket.binaryType = 'arraybuffer'
|
||||
|
||||
socket.onopen = () => {
|
||||
if (pendingText && thisPlayId === activePlayId) {
|
||||
const seepdText = extractSpeechText(pendingText)
|
||||
console.log(seepdText)
|
||||
socket.send(seepdText)
|
||||
pendingText = null
|
||||
}
|
||||
}
|
||||
|
||||
socket.onmessage = async (e) => {
|
||||
if (thisPlayId !== activePlayId) return // 忽略旧播放的消息
|
||||
|
||||
if (typeof e.data === 'string') {
|
||||
try {
|
||||
const msg = JSON.parse(e.data)
|
||||
if (msg.status === 'complete') {
|
||||
isComplete.value = true
|
||||
setTimeout(() => {
|
||||
if (thisPlayId === activePlayId) {
|
||||
isSpeaking.value = false
|
||||
}
|
||||
}, (playTime - audioContext.currentTime) * 1000)
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('[TTSPlayer] 文本消息:', e.data)
|
||||
}
|
||||
} else if (e.data instanceof ArrayBuffer) {
|
||||
if (!isHeaderDecoded) {
|
||||
try {
|
||||
const decoded = await WavDecoder.decode(e.data)
|
||||
sampleRate = decoded.sampleRate
|
||||
numChannels = decoded.channelData.length
|
||||
decoded.channelData.forEach((channel, i) => {
|
||||
const audioBuffer = audioContext.createBuffer(1, channel.length,
|
||||
sampleRate)
|
||||
audioBuffer.copyToChannel(channel, 0)
|
||||
playBuffer(audioBuffer)
|
||||
})
|
||||
isHeaderDecoded = true
|
||||
} catch (err) {
|
||||
console.error('WAV 解码失败:', err)
|
||||
}
|
||||
} else {
|
||||
const pcm = new Int16Array(e.data)
|
||||
const audioBuffer = pcmToAudioBuffer(pcm, sampleRate, numChannels)
|
||||
playBuffer(audioBuffer)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const pcmToAudioBuffer = (pcm, sampleRate, numChannels) => {
|
||||
const length = pcm.length / numChannels
|
||||
const audioBuffer = audioContext.createBuffer(numChannels, length, sampleRate)
|
||||
for (let ch = 0; ch < numChannels; ch++) {
|
||||
const channelData = audioBuffer.getChannelData(ch)
|
||||
for (let i = 0; i < length; i++) {
|
||||
const sample = pcm[i * numChannels + ch]
|
||||
channelData[i] = sample / 32768
|
||||
}
|
||||
}
|
||||
return audioBuffer
|
||||
}
|
||||
|
||||
const playBuffer = (audioBuffer) => {
|
||||
if (!isSpeaking.value) {
|
||||
playTime = audioContext.currentTime
|
||||
}
|
||||
const source = audioContext.createBufferSource()
|
||||
source.buffer = audioBuffer
|
||||
source.connect(audioContext.destination)
|
||||
source.start(playTime)
|
||||
sourceNodes.push(source)
|
||||
playTime += audioBuffer.duration
|
||||
isSpeaking.value = true
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
stop()
|
||||
})
|
||||
|
||||
// 页面刷新/关闭时
|
||||
onMounted(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('beforeunload', cancelAudio)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cancelAudio()
|
||||
if (typeof window !== 'undefined') {
|
||||
window.removeEventListener('beforeunload', cancelAudio)
|
||||
}
|
||||
})
|
||||
|
||||
onHide(cancelAudio)
|
||||
onUnload(cancelAudio)
|
||||
|
||||
initWebSocket()
|
||||
|
||||
return {
|
||||
speak,
|
||||
pause,
|
||||
resume,
|
||||
cancelAudio,
|
||||
isSpeaking,
|
||||
isPaused,
|
||||
isComplete
|
||||
}
|
||||
}
|
||||
|
||||
function extractSpeechText(markdown) {
|
||||
const jobRegex = /``` job-json\s*({[\s\S]*?})\s*```/g;
|
||||
const jobs = [];
|
||||
let match;
|
||||
let lastJobEndIndex = 0;
|
||||
let firstJobStartIndex = -1;
|
||||
|
||||
// 提取岗位 json 数据及前后位置
|
||||
while ((match = jobRegex.exec(markdown)) !== null) {
|
||||
const jobStr = match[1];
|
||||
try {
|
||||
const job = JSON.parse(jobStr);
|
||||
jobs.push(job);
|
||||
if (firstJobStartIndex === -1) {
|
||||
firstJobStartIndex = match.index;
|
||||
}
|
||||
lastJobEndIndex = jobRegex.lastIndex;
|
||||
} catch (e) {
|
||||
console.warn('JSON 解析失败', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 提取引导语(第一个 job-json 之前的文字)
|
||||
const guideText = firstJobStartIndex > 0 ?
|
||||
markdown.slice(0, firstJobStartIndex).trim() :
|
||||
'';
|
||||
|
||||
// 提取结束语(最后一个 job-json 之后的文字)
|
||||
const endingText = lastJobEndIndex < markdown.length ?
|
||||
markdown.slice(lastJobEndIndex).trim() :
|
||||
'';
|
||||
|
||||
// 岗位信息格式化为语音文本
|
||||
const jobTexts = jobs.map((job, index) => {
|
||||
return `第 ${index + 1} 个岗位,岗位名称是:${job.jobTitle},公司是:${job.companyName},薪资:${job.salary},地点:${job.location},学历要求:${job.education},经验要求:${job.experience}。`;
|
||||
});
|
||||
|
||||
// 拼接总语音内容
|
||||
const finalTextParts = [];
|
||||
if (guideText) finalTextParts.push(guideText);
|
||||
finalTextParts.push(...jobTexts);
|
||||
if (endingText) finalTextParts.push(endingText);
|
||||
|
||||
return finalTextParts.join('\n');
|
||||
}
|
||||
@@ -1,216 +0,0 @@
|
||||
import {
|
||||
ref,
|
||||
onUnmounted,
|
||||
onMounted,
|
||||
watch
|
||||
} from 'vue'
|
||||
import {
|
||||
onHide,
|
||||
onUnload
|
||||
} from '@dcloudio/uni-app'
|
||||
import config from '@/config'
|
||||
// 请确保 piper-sdk.js 已经正确 export class PiperTTS
|
||||
import {
|
||||
PiperTTS
|
||||
} from './piper-sdk.js'
|
||||
|
||||
export function useTTSPlayer() {
|
||||
// UI 状态
|
||||
const isSpeaking = ref(false)
|
||||
const isPaused = ref(false)
|
||||
const isLoading = ref(false)
|
||||
|
||||
// SDK 实例
|
||||
let piper = null
|
||||
|
||||
/**
|
||||
* 初始化 SDK 实例
|
||||
* 每次 stop 后 piper 会被置空,这里会重新创建
|
||||
*/
|
||||
const initPiper = () => {
|
||||
if (piper) return
|
||||
|
||||
let baseUrl = config.speechSynthesis2 || ''
|
||||
baseUrl = baseUrl.replace(/\/$/, '')
|
||||
|
||||
piper = new PiperTTS({
|
||||
baseUrl: baseUrl,
|
||||
onStatus: (msg, type) => {
|
||||
if (type === 'error') {
|
||||
console.error('[TTS Error]', msg)
|
||||
// 出错时不重置状态,交给用户手动处理或结束事件处理
|
||||
resetState()
|
||||
}
|
||||
},
|
||||
onStart: () => {
|
||||
isLoading.value = false
|
||||
isSpeaking.value = true
|
||||
isPaused.value = false
|
||||
},
|
||||
onEnd: () => {
|
||||
resetState()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 核心朗读方法
|
||||
*/
|
||||
const speak = async (text) => {
|
||||
if (!text) return
|
||||
|
||||
const processedText = extractSpeechText(text)
|
||||
if (!processedText) return
|
||||
|
||||
// 1. 【关键修改】先彻底停止并销毁旧实例
|
||||
// 这会断开 socket 并且 close AudioContext,确保上一个声音立即消失
|
||||
await stop()
|
||||
|
||||
// 2. 初始化新实例 (因为 stop() 把 piper 设为了 null)
|
||||
initPiper()
|
||||
|
||||
// 3. 更新 UI 为加载中
|
||||
isLoading.value = true
|
||||
isPaused.value = false
|
||||
isSpeaking.value = true // 预先设为 true,防止按钮闪烁
|
||||
|
||||
try {
|
||||
// 4. 激活音频引擎 (移动端防静音关键)
|
||||
await piper.init()
|
||||
|
||||
// 5. 发送请求
|
||||
piper.speak(processedText, {
|
||||
speakerId: 0,
|
||||
noiseScale: 0.667,
|
||||
lengthScale: 1.0
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('TTS Speak Error:', e)
|
||||
resetState()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 暂停
|
||||
*/
|
||||
const pause = async () => {
|
||||
if (piper && piper.audioCtx && piper.audioCtx.state === 'running') {
|
||||
await piper.audioCtx.suspend()
|
||||
isPaused.value = true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 恢复
|
||||
*/
|
||||
const resume = async () => {
|
||||
if (piper && piper.audioCtx && piper.audioCtx.state === 'suspended') {
|
||||
await piper.audioCtx.resume()
|
||||
isPaused.value = false
|
||||
isSpeaking.value = true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止并重置 (核打击模式)
|
||||
*/
|
||||
const stop = async () => {
|
||||
if (piper) {
|
||||
// 1. 断开 WebSocket
|
||||
piper.stop()
|
||||
|
||||
// 2. 【关键】关闭 AudioContext
|
||||
// Web Audio API 中,已经 schedule 的 buffer 很难单独取消
|
||||
// 最直接的方法是关闭整个 Context
|
||||
if (piper.audioCtx && piper.audioCtx.state !== 'closed') {
|
||||
try {
|
||||
await piper.audioCtx.close()
|
||||
} catch (e) {
|
||||
console.warn('AudioContext close failed', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 销毁实例引用
|
||||
piper = null
|
||||
}
|
||||
resetState()
|
||||
}
|
||||
|
||||
// UI 状态重置
|
||||
const resetState = () => {
|
||||
isSpeaking.value = false
|
||||
isPaused.value = false
|
||||
isLoading.value = false
|
||||
}
|
||||
|
||||
// === 生命周期 ===
|
||||
onMounted(() => {
|
||||
// 预初始化可以不做,等到点击时再做,避免空闲占用 AudioContext 资源
|
||||
// initPiper()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stop()
|
||||
})
|
||||
|
||||
// Uniapp 生命周期
|
||||
if (typeof onHide === 'function') onHide(stop)
|
||||
if (typeof onUnload === 'function') onUnload(stop)
|
||||
|
||||
return {
|
||||
speak,
|
||||
pause,
|
||||
resume,
|
||||
stop,
|
||||
cancelAudio: stop,
|
||||
isSpeaking,
|
||||
isPaused,
|
||||
isLoading
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取文本逻辑 (保持不变)
|
||||
*/
|
||||
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) => {
|
||||
return `第 ${index + 1} 个岗位,岗位名称是:${job.jobTitle},公司是:${job.companyName},薪资:${job.salary},地点:${job.location},学历要求:${job.education},经验要求:${job.experience}。`;
|
||||
});
|
||||
|
||||
const finalTextParts = [];
|
||||
if (guideText) finalTextParts.push(guideText);
|
||||
finalTextParts.push(...jobTexts);
|
||||
if (endingText) finalTextParts.push(endingText);
|
||||
|
||||
return finalTextParts.join('\n');
|
||||
}
|
||||
5
main.js
5
main.js
@@ -26,9 +26,12 @@ import renderCompanyCollectionRecord from '@/components/renderCompanyCollectionR
|
||||
import renderJobViewRecord from '@/components/renderJobViewRecord/renderJobViewRecord.vue';
|
||||
import MyIcons from '@/components/My-icons/my-icons.vue';
|
||||
import GlobalPopup from '@/components/GlobalPopup/GlobalPopup.vue'
|
||||
import FileIcon from '@/components/FileIcon/fileIcon.vue'
|
||||
import FileText from '@/components/FileText/fileText.vue'
|
||||
// import Tabbar from '@/components/tabbar/midell-box.vue'
|
||||
// 自动导入 directives 目录下所有指令
|
||||
console.log(lightAppJssdk)
|
||||
|
||||
const directives = import.meta.glob('./directives/*.js', {
|
||||
eager: true
|
||||
});
|
||||
@@ -58,6 +61,8 @@ export function createApp() {
|
||||
app.component('renderJobViewRecord', renderJobViewRecord) //渲染岗位浏览记录
|
||||
app.component('MyIcons', MyIcons)
|
||||
app.component('global-popup', GlobalPopup)
|
||||
app.component('FileIcon', FileIcon)
|
||||
app.component('FileText', FileText)
|
||||
// app.component('tabbar-custom', Tabbar)
|
||||
|
||||
for (const path in directives) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name" : "qingdao-employment-service",
|
||||
"appid" : "__UNI__2496162",
|
||||
"appid" : "__UNI__C939371",
|
||||
"description" : "招聘",
|
||||
"versionName" : "1.0.0",
|
||||
"versionCode" : "100",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<AppLayout title="" :use-scroll-view="false">
|
||||
<AppLayout title="" :use-scroll-view="false" showHeader>
|
||||
<template #headerleft v-if="isMiniProgram">
|
||||
<view class="btnback">
|
||||
<image src="@/static/icon/back.png" @click="navBack"></image>
|
||||
@@ -75,11 +75,13 @@ import { reactive, inject, watch, ref, onMounted, computed } from 'vue';
|
||||
import { onLoad, onShow } from '@dcloudio/uni-app';
|
||||
import dictLabel from '@/components/dict-Label/dict-Label.vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
const { isMiniProgram } = storeToRefs(useUserStore());
|
||||
import useUserStore from '@/stores/useUserStore';
|
||||
const { isMiniProgram } = storeToRefs(useUserStore());
|
||||
const { checkAuth } = useUserStore();
|
||||
import useLocationStore from '@/stores/useLocationStore';
|
||||
const { longitudeVal, latitudeVal } = storeToRefs(useLocationStore());
|
||||
const { $api, navTo, vacanciesTo, navBack } = inject('globalFunction');
|
||||
import { playTextDirectly } from '@/hook/useTTSPlayer-all-in-one';
|
||||
|
||||
const isExpanded = ref(false);
|
||||
const pageState = reactive({
|
||||
@@ -110,6 +112,9 @@ onLoad((options) => {
|
||||
});
|
||||
|
||||
function companyCollection() {
|
||||
if (!checkAuth()) {
|
||||
return
|
||||
}
|
||||
if (dataType.value === 2) {
|
||||
// 第三方数据收藏逻辑
|
||||
const id = companyInfo.value.id;
|
||||
@@ -119,11 +124,13 @@ function companyCollection() {
|
||||
$api.createRequest(`/app/company/collection/${id}/2`, {}, 'DELETE').then((resData) => {
|
||||
getCompanyInfo(companyId, zphId);
|
||||
$api.msg('取消收藏成功');
|
||||
playTextDirectly('取消收藏成功');
|
||||
});
|
||||
} else {
|
||||
$api.createRequest(`/app/company/collection/${id}/2`, {}, 'POST').then((resData) => {
|
||||
getCompanyInfo(companyId, zphId);
|
||||
$api.msg('收藏成功');
|
||||
playTextDirectly('收藏成功');
|
||||
});
|
||||
}
|
||||
} else {
|
||||
@@ -133,11 +140,13 @@ function companyCollection() {
|
||||
$api.createRequest(`/app/company/collection/${companyId}/1`, {}, 'DELETE').then((resData) => {
|
||||
getCompanyInfo(companyId);
|
||||
$api.msg('取消收藏成功');
|
||||
playTextDirectly('取消收藏成功');
|
||||
});
|
||||
} else {
|
||||
$api.createRequest(`/app/company/collection/${companyId}/1`, {}, 'POST').then((resData) => {
|
||||
getCompanyInfo(companyId);
|
||||
$api.msg('收藏成功');
|
||||
playTextDirectly('收藏成功');
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -260,8 +269,7 @@ image {
|
||||
display: flex;
|
||||
flex-direction: column
|
||||
.content-top{
|
||||
padding: 28rpx
|
||||
padding-top: 50rpx
|
||||
padding:28rpx;
|
||||
display: flex
|
||||
flex-direction: row
|
||||
flex-wrap: nowrap
|
||||
|
||||
@@ -76,6 +76,9 @@ import { storeToRefs } from 'pinia';
|
||||
import useUserStore from '@/stores/useUserStore';
|
||||
const { getUserResume } = useUserStore();
|
||||
const { userInfo, isMiniProgram ,hasLogin,isMachineEnv} = storeToRefs(useUserStore());
|
||||
import { playTextDirectly } from '@/hook/useTTSPlayer-all-in-one';
|
||||
|
||||
|
||||
const popup = ref(null);
|
||||
const selectJobsModel = ref(null);
|
||||
|
||||
@@ -112,12 +115,17 @@ function handleFocus() {
|
||||
}
|
||||
|
||||
function confirm() {
|
||||
if(!hasLogin.value){
|
||||
useUserStore().logOut()
|
||||
return
|
||||
}
|
||||
const { id } = dataItem.value;
|
||||
let ids = userInfo.value.jobTitleId + `,${id}`;
|
||||
const result = dedupeAndCheck(ids);
|
||||
if (result.hasDuplicate) {
|
||||
popup.value.close();
|
||||
$api.msg('期望岗位已重复');
|
||||
playTextDirectly('期望岗位已重复')
|
||||
return;
|
||||
}
|
||||
complete({ jobTitleId: result.deduplicated });
|
||||
@@ -171,14 +179,17 @@ function handelChangeInpute(e) {
|
||||
function handelClickItem(item) {
|
||||
dataItem.value = item;
|
||||
popup.value.open();
|
||||
playTextDirectly('确添加该期望岗位吗?')
|
||||
}
|
||||
|
||||
function complete(values) {
|
||||
if (!values.jobTitleId.length) {
|
||||
playTextDirectly('至少添加一份期望岗位?')
|
||||
return $api.msg('至少添加一份期望岗位');
|
||||
}
|
||||
$api.createRequest('/app/user/resume', values, 'post').then((resData) => {
|
||||
$api.msg('完成');
|
||||
$api.msg('操作成功');
|
||||
playTextDirectly('操作成功')
|
||||
getUserResume();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -41,6 +41,9 @@ import useLocationStore from '@/stores/useLocationStore';
|
||||
const { longitudeVal, latitudeVal } = storeToRefs(useLocationStore());
|
||||
import useUserStore from '@/stores/useUserStore';
|
||||
const { isMiniProgram } = storeToRefs(useUserStore());
|
||||
import { playTextDirectly } from '@/hook/useTTSPlayer-all-in-one';
|
||||
|
||||
|
||||
// state
|
||||
const title = ref('事业单位');
|
||||
const cardInfo = ref({});
|
||||
@@ -66,6 +69,7 @@ onLoad(() => {
|
||||
|
||||
// search
|
||||
function searchCollection(e) {
|
||||
playTextDirectly('正在为您查找…')
|
||||
const value = e.detail.value;
|
||||
pageState.search.companyName = value;
|
||||
getDataList('refresh');
|
||||
|
||||
@@ -112,6 +112,8 @@ import { storeToRefs } from 'pinia';
|
||||
const { longitudeVal, latitudeVal } = storeToRefs(useLocationStore());
|
||||
import useUserStore from '@/stores/useUserStore';
|
||||
const { isMiniProgram } = storeToRefs(useUserStore());
|
||||
const { checkAuth } = useUserStore();
|
||||
import { playTextDirectly } from '@/hook/useTTSPlayer-all-in-one';
|
||||
|
||||
const isExpanded = ref(false);
|
||||
const fairInfo = ref({});
|
||||
@@ -201,19 +203,24 @@ function expand() {
|
||||
isExpanded.value = !isExpanded.value;
|
||||
}
|
||||
|
||||
// 取消/收藏岗位
|
||||
// 取消/预约招聘会
|
||||
function applyExhibitors() {
|
||||
if (!checkAuth()) {
|
||||
return
|
||||
}
|
||||
|
||||
const fairId = fairInfo.value.zphID;
|
||||
if (fairInfo.value.isCollection) {
|
||||
$api.createRequest(`/app/fair/collection/${fairId}`, {}, 'DELETE').then((resData) => {
|
||||
getJobFairInfo(fairId);
|
||||
$api.msg('取消预约成功');
|
||||
playTextDirectly('取消预约成功')
|
||||
});
|
||||
$api.msg('已预约成功');
|
||||
} else {
|
||||
$api.createRequest(`/app/fair/collection/${fairId}`, {}, 'POST').then((resData) => {
|
||||
getJobFairInfo(fairId);
|
||||
$api.msg('预约成功');
|
||||
playTextDirectly('预约成功')
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -293,8 +300,7 @@ image {
|
||||
display: flex;
|
||||
flex-direction: column
|
||||
.content-top{
|
||||
padding: 28rpx
|
||||
padding-top: 50rpx
|
||||
padding:28rpx;
|
||||
display: flex
|
||||
flex-direction: row
|
||||
flex-wrap: nowrap
|
||||
|
||||
564
packageA/pages/myResume/components/uploadQrcode.vue
Normal file
564
packageA/pages/myResume/components/uploadQrcode.vue
Normal file
@@ -0,0 +1,564 @@
|
||||
<template>
|
||||
<uni-popup ref="popup" type="center" borderRadius="12px 12px 0 0" @change="changePopup">
|
||||
<view class="popup-inner">
|
||||
<view v-show="!fileCount" class="title">请扫码上传附件</view>
|
||||
<view v-show="!fileCount" class="img-box">
|
||||
<!-- 加载状态 -->
|
||||
<view v-if="loading" class="loading-container">
|
||||
<uni-load-more status="loading" :icon-size="24" :content-text="loadingText" />
|
||||
</view>
|
||||
<canvas canvas-id="qrcode" id="qrcode" />
|
||||
</view>
|
||||
<view class="tips" v-if="!loading">
|
||||
已上传
|
||||
<span class="num">{{ fileCount }}</span>
|
||||
个文件
|
||||
</view>
|
||||
<view v-show="!fileCount" class="tips" v-if="!loading">请使用手机扫描二维码上传文件</view>
|
||||
<view v-show="fileCount" class="file-list">
|
||||
<view v-for="(file, index) in fileList" class="file-item">
|
||||
<image
|
||||
class="file-icon"
|
||||
@click="preViewImage(file)"
|
||||
v-if="isImage(file.fileSuffix)"
|
||||
:src="file.fileUrl"
|
||||
mode="scaleToFill"
|
||||
></image>
|
||||
<FileIcon v-else class="file-icon" :type="file.fileSuffix"></FileIcon>
|
||||
<view class="right">
|
||||
<view class="file-name">{{ file.originalName }}</view>
|
||||
<FileText :type="file.fileSuffix"></FileText>
|
||||
</view>
|
||||
<view class="remove-btn" @click="delFile(file, index)">×</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-show="fileCount" class="confirm-btn btn-feel" @click="handleConfirm">确认</view>
|
||||
<view class="close-btn" @click="close"></view>
|
||||
</view>
|
||||
</uni-popup>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, inject, onUnmounted, watch, computed } from 'vue';
|
||||
import uQRCode from '@/static/js/qrcode';
|
||||
import config from '@/config';
|
||||
import { onShow, onHide } from '@dcloudio/uni-app';
|
||||
import { UUID } from '../../../../lib/uuid-min';
|
||||
const props = defineProps({
|
||||
sessionId: {
|
||||
type: [Number, String],
|
||||
default: '',
|
||||
},
|
||||
leaveFileCount: {
|
||||
type: [Number, String],
|
||||
default: 2,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['onSend', 'onClose']);
|
||||
const { $api } = inject('globalFunction');
|
||||
|
||||
const popup = ref(null);
|
||||
const loading = ref(false);
|
||||
const pollingTimer = ref(null);
|
||||
const isPolling = ref(false);
|
||||
const isVisible = ref(false);
|
||||
const uuid = ref(null);
|
||||
const fileList = ref([]);
|
||||
const deleting = ref(false);
|
||||
|
||||
const fileCount = computed(() => {
|
||||
return fileList.value.length ?? 0;
|
||||
});
|
||||
|
||||
// 计算加载文本
|
||||
const loadingText = computed(() => ({
|
||||
contentdown: '二维码生成中',
|
||||
contentrefresh: '二维码生成中',
|
||||
}));
|
||||
|
||||
onUnmounted(() => {
|
||||
stopPolling();
|
||||
clearResources();
|
||||
});
|
||||
|
||||
function isImage(type) {
|
||||
if (!type || typeof type !== 'string') return false;
|
||||
const imageTypes = [
|
||||
'jpg',
|
||||
'jpeg',
|
||||
'png',
|
||||
'gif',
|
||||
'bmp',
|
||||
'webp',
|
||||
'svg',
|
||||
'tiff',
|
||||
'tif',
|
||||
'ico',
|
||||
'apng',
|
||||
'avif',
|
||||
'heic',
|
||||
'heif',
|
||||
'jfif',
|
||||
];
|
||||
const lowerType = type.toLowerCase();
|
||||
|
||||
return imageTypes.some((item) => lowerType.includes(item));
|
||||
}
|
||||
function preViewImage(file) {
|
||||
if (file.fileUrl) {
|
||||
uni.previewImage({
|
||||
urls: [file.fileUrl],
|
||||
});
|
||||
} else {
|
||||
$api.msg('文件地址丢失');
|
||||
}
|
||||
}
|
||||
async function delFile(file, idx) {
|
||||
deleting.value = true;
|
||||
try {
|
||||
await $api.createRequest(`/app/kiosk/remove?sessionId=${uuid.value}&ids=${file.id}`, {}, 'post', true);
|
||||
} catch (error) {
|
||||
$api.msg(error);
|
||||
} finally {
|
||||
deleting.value = false;
|
||||
}
|
||||
fileList.value.splice(idx, 1);
|
||||
if(fileList.value.length == 0){
|
||||
open()
|
||||
}
|
||||
}
|
||||
|
||||
function open() {
|
||||
uuid.value = UUID.generate();
|
||||
resetState();
|
||||
isVisible.value = true;
|
||||
popup.value.open();
|
||||
initQrCode();
|
||||
}
|
||||
|
||||
function close() {
|
||||
isVisible.value = false;
|
||||
stopPolling();
|
||||
popup.value.close();
|
||||
resetState();
|
||||
}
|
||||
|
||||
function changePopup(e) {
|
||||
if (e.show) {
|
||||
} else {
|
||||
stopPolling();
|
||||
emit('onClose');
|
||||
}
|
||||
}
|
||||
|
||||
function handleConfirm() {
|
||||
emit('onSend', fileList.value);
|
||||
close();
|
||||
}
|
||||
|
||||
// 重置所有状态
|
||||
function resetState() {
|
||||
fileList.value = [];
|
||||
loading.value = false;
|
||||
stopPolling();
|
||||
}
|
||||
|
||||
async function initQrCode() {
|
||||
try {
|
||||
loading.value = true;
|
||||
// 清除之前的二维码
|
||||
clearCanvas();
|
||||
await makeQrcode();
|
||||
// 二维码生成成功后开始轮询
|
||||
if (isVisible.value) {
|
||||
startPolling();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('生成二维码失败:', error);
|
||||
uni.showToast({
|
||||
title: '生成二维码失败,请重试',
|
||||
icon: 'none',
|
||||
});
|
||||
close();
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function makeQrcode() {
|
||||
const protocol = window.location.protocol;
|
||||
const host = window.location.host;
|
||||
const isLocal = host.includes('localhost') || host.includes('127.0.0.1');
|
||||
// const pathPrefix = isLocal ? '' : '/rgpp-api/all-in-one';
|
||||
let pathPrefix = '';
|
||||
if (host.includes('localhost') || host.includes('127.0.0.1')) {
|
||||
pathPrefix = '';
|
||||
} else if (host.includes('qd.zhaopinzao8dian.com')) {
|
||||
// 外网测试环境
|
||||
pathPrefix = '/app';
|
||||
} else if (host.includes('fw.rc.qingdao.gov.cn')) {
|
||||
// 青岛政务网环境
|
||||
pathPrefix = '/rgpp-api/all-in-one';
|
||||
} else {
|
||||
pathPrefix = '';
|
||||
}
|
||||
const htmlPath = `${protocol}//${host}${pathPrefix}/static/upload.html?sessionId=${uuid.value}&uploadApi=${config.baseUrl}/app/kiosk/upload&fileCount=${props.leaveFileCount}&accept='.pdf,.png,.jpg'`;
|
||||
|
||||
// const htmlPath = `${window.location.host}/static/upload.html?sessionId=${uuid.value}&uploadApi=${
|
||||
// config.baseUrl + '/app/kiosk/upload'
|
||||
// }`;
|
||||
// const htmlPath = `${window.location.host}/static/upload.html?sessionId=${props.sessionId}&uploadApi=${
|
||||
// config.baseUrl + '/app/kiosk/upload'
|
||||
// }`;
|
||||
console.log(htmlPath);
|
||||
console.log('剩余可上传文件数量:', props.leaveFileCount);
|
||||
return new Promise((resolve, reject) => {
|
||||
setTimeout(() => {
|
||||
uQRCode.make({
|
||||
canvasId: 'qrcode',
|
||||
text: htmlPath,
|
||||
size: uni.upx2px(300),
|
||||
margin: 0,
|
||||
backgroundColor: '#ffffff',
|
||||
foregroundColor: '#1677ff',
|
||||
fileType: 'png',
|
||||
correctLevel: uQRCode.defaults.correctLevel,
|
||||
});
|
||||
resolve();
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
|
||||
// 清除画布
|
||||
function clearCanvas() {
|
||||
const ctx = uni.createCanvasContext('qrcode');
|
||||
ctx.clearRect(0, 0, uni.upx2px(300), uni.upx2px(300));
|
||||
ctx.draw();
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
if (isPolling.value) return;
|
||||
|
||||
isPolling.value = true;
|
||||
console.log('开始轮询');
|
||||
|
||||
// 轮询检查上传状态
|
||||
const poll = async () => {
|
||||
if (!isPolling.value || !isVisible.value || deleting.value) return;
|
||||
const { data } = await $api.createRequest('/app/kiosk/list', { sessionId: uuid.value });
|
||||
// const { data } = await $api.createRequest('/app/kiosk/list',{sessionId:props.sessionId});
|
||||
if (data && data.length) {
|
||||
fileList.value = data;
|
||||
}
|
||||
if (isPolling.value && isVisible.value) {
|
||||
pollingTimer.value = setTimeout(poll, 2000); // 每2秒轮询一次
|
||||
}
|
||||
};
|
||||
poll();
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
isPolling.value = false;
|
||||
if (pollingTimer.value) {
|
||||
clearTimeout(pollingTimer.value);
|
||||
pollingTimer.value = null;
|
||||
}
|
||||
console.log('停止轮询');
|
||||
}
|
||||
|
||||
function clearResources() {
|
||||
stopPolling();
|
||||
clearCanvas();
|
||||
}
|
||||
|
||||
defineExpose({ open, close });
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.popup-inner {
|
||||
padding: 40rpx 30rpx 50rpx;
|
||||
width: 520rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
border-radius: 20rpx;
|
||||
background: linear-gradient(135deg, #fafafa 0%, #f0f7ff 100%);
|
||||
box-shadow: 0 20rpx 60rpx rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 32rpx;
|
||||
color: #1a1a1a;
|
||||
margin-bottom: 30rpx;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
padding-bottom: 16rpx;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 60rpx;
|
||||
height: 4rpx;
|
||||
background: linear-gradient(90deg, #4191fe 0%, #256bfa 100%);
|
||||
border-radius: 2rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.img-box {
|
||||
margin: 0 auto 30rpx;
|
||||
width: 300rpx;
|
||||
height: 300rpx;
|
||||
background: linear-gradient(145deg, #ffffff 0%, #f8faff 100%);
|
||||
border-radius: 16rpx;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
box-shadow: 0 8rpx 32rpx rgba(65, 145, 254, 0.2);
|
||||
padding: 20rpx;
|
||||
border: 2rpx solid rgba(65, 145, 254, 0.1);
|
||||
|
||||
canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 8rpx;
|
||||
transition: all 0.3s ease;
|
||||
animation: canvasFadeIn 0.5s ease;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes canvasFadeIn {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(20rpx);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.file-list {
|
||||
margin-top: 30rpx;
|
||||
width: 470rpx;
|
||||
max-height: 60vh;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
padding-right: 10rpx;
|
||||
|
||||
.file-item {
|
||||
width: 100%;
|
||||
padding: 25rpx;
|
||||
padding-right: 15rpx;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #bbc5d1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
animation: fadeIn 0.5s ease;
|
||||
background: linear-gradient(135deg, #ffffff 0%, #f0f8ff 100%);
|
||||
border-radius: 16rpx;
|
||||
margin-bottom: 20rpx;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 6rpx;
|
||||
height: 100%;
|
||||
background: linear-gradient(180deg, #4191fe 0%, #256bfa 100%);
|
||||
border-radius: 3rpx 0 0 3rpx;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2rpx);
|
||||
box-shadow: 0 8rpx 24rpx rgba(65, 145, 254, 0.15);
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
margin-right: 20rpx;
|
||||
border-radius: 12rpx;
|
||||
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.08);
|
||||
transition: transform 0.3s ease;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
}
|
||||
|
||||
.right {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
|
||||
.file-name {
|
||||
font-size: 30rpx;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-weight: 500;
|
||||
color: #1a1a1a;
|
||||
margin-bottom: 8rpx;
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
.file-size {
|
||||
font-size: 24rpx;
|
||||
color: #838383;
|
||||
}
|
||||
}
|
||||
|
||||
.remove-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #ff6b6b;
|
||||
font-size: 52rpx;
|
||||
cursor: pointer;
|
||||
margin-left: 20rpx;
|
||||
flex-shrink: 0;
|
||||
width: 60rpx;
|
||||
height: 60rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
transition: all 0.3s ease;
|
||||
margin-top: -15rpx;
|
||||
|
||||
&:active {
|
||||
background-color: rgba(255, 107, 107, 0.1);
|
||||
transform: scale(0.9);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.confirm-btn {
|
||||
font-weight: 500;
|
||||
font-size: 32rpx;
|
||||
color: #ffffff;
|
||||
text-align: center;
|
||||
width: 350rpx;
|
||||
height: 80rpx;
|
||||
line-height: 80rpx;
|
||||
background: linear-gradient(135deg, #4191fe 0%, #256bfa 100%);
|
||||
border-radius: 16rpx;
|
||||
margin-top: 30rpx;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 8rpx 24rpx rgba(37, 107, 250, 0.3);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
|
||||
transition: left 0.6s ease;
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.98);
|
||||
box-shadow: 0 4rpx 16rpx rgba(37, 107, 250, 0.4);
|
||||
|
||||
&::after {
|
||||
left: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(145deg, #ffffff 0%, #f8faff 100%);
|
||||
border-radius: 16rpx;
|
||||
}
|
||||
|
||||
.tips {
|
||||
font-size: 26rpx;
|
||||
color: #666;
|
||||
text-align: center;
|
||||
line-height: 1.6;
|
||||
padding: 0 20rpx;
|
||||
margin-top: 10rpx;
|
||||
animation: fadeIn 0.5s ease;
|
||||
|
||||
.num {
|
||||
color: #4191fe;
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
margin: 0 8rpx;
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
position: absolute;
|
||||
right: 20rpx;
|
||||
top: 20rpx;
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #ffffff 0%, #f8faff 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.1);
|
||||
z-index: 10;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.9) rotate(90deg);
|
||||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 24rpx;
|
||||
height: 2rpx;
|
||||
background: #5a5a68;
|
||||
border-radius: 1rpx;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
&::before {
|
||||
transform: translate(-50%, -50%) rotate(45deg);
|
||||
}
|
||||
|
||||
&::after {
|
||||
transform: translate(-50%, -50%) rotate(-45deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -36,7 +36,7 @@
|
||||
<image
|
||||
class="button-click"
|
||||
src="@/static/icon/edit1.png"
|
||||
@click="navTo('/packageA/pages/personalInfo/personalInfo')"
|
||||
@click="handleNavTo('/packageA/pages/personalInfo/personalInfo')"
|
||||
></image>
|
||||
</view>
|
||||
</view>
|
||||
@@ -74,7 +74,7 @@
|
||||
<image
|
||||
class="icon"
|
||||
src="@/static/icon/edit1.png"
|
||||
@click="navTo('/packageA/pages/jobExpect/jobExpect')"
|
||||
@click="handleNavTo('/packageA/pages/jobExpect/jobExpect')"
|
||||
></image>
|
||||
</view>
|
||||
<view class="mys-text">
|
||||
@@ -97,7 +97,7 @@
|
||||
<view class="mys-info" style="padding: 0">
|
||||
<view class="mys-h4">
|
||||
<text>工作经历</text>
|
||||
<view class="mys-edit-icon btn-tada" @click="navTo('/packageA/pages/workExp/workExp')">
|
||||
<view class="mys-edit-icon btn-tada" @click="handleNavTo('/packageA/pages/workExp/workExp')">
|
||||
<image class="icon button-click btn-feel" src="@/static/icon/plus.png"></image>
|
||||
<view class="txt">添加</view>
|
||||
</view>
|
||||
@@ -108,7 +108,7 @@
|
||||
<image
|
||||
class="icon btn-feel"
|
||||
src="@/static/icon/edit1.png"
|
||||
@click="navTo(`/packageA/pages/workExp/workExp?id=${item.id}`)"
|
||||
@click="handleNavTo(`/packageA/pages/workExp/workExp?id=${item.id}`)"
|
||||
></image>
|
||||
</view>
|
||||
<view class="mys-text fl_box fl_justbet">
|
||||
@@ -127,6 +127,7 @@
|
||||
<view class="footer-button btn-feel" @click="chooseResume">上传简历</view>
|
||||
</view>
|
||||
</template>
|
||||
<UploadQrcode ref="qrcodeRef" @onSend="handleFileSend" :leaveFileCount="1" ></UploadQrcode>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
@@ -137,21 +138,35 @@ import { onLoad, onShow } from '@dcloudio/uni-app';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import useUserStore from '@/stores/useUserStore';
|
||||
import useDictStore from '@/stores/useDictStore';
|
||||
const { userInfo, isMiniProgram } = storeToRefs(useUserStore());
|
||||
const { getUserResume } = useUserStore();
|
||||
const { userInfo, isMiniProgram,isMachineEnv } = storeToRefs(useUserStore());
|
||||
const { getUserResume, checkAuth } = useUserStore();
|
||||
const { getDictData, oneDictData } = useDictStore();
|
||||
import { playTextDirectly } from '@/hook/useTTSPlayer-all-in-one';
|
||||
import config from '@/config.js';
|
||||
import UploadQrcode from './components/uploadQrcode.vue';
|
||||
|
||||
const qrcodeRef = ref(null);
|
||||
const showNotice = ref(true);
|
||||
onLoad(() => {
|
||||
getUserResume();
|
||||
});
|
||||
|
||||
function handleNavTo(url) {
|
||||
if(!checkAuth()) {
|
||||
return
|
||||
}
|
||||
navTo(url)
|
||||
}
|
||||
|
||||
function closeNotice() {
|
||||
showNotice.value = false;
|
||||
}
|
||||
|
||||
function chooseResume() {
|
||||
if(isMachineEnv.value){
|
||||
qrcodeRef.value?.open()
|
||||
return
|
||||
}
|
||||
uni.chooseImage({
|
||||
sizeType: ['original', 'compressed'],
|
||||
sourceType: ['album', 'camera'],
|
||||
@@ -162,9 +177,11 @@ function chooseResume() {
|
||||
res = JSON.parse(res);
|
||||
getUserResume();
|
||||
$api.msg('上传成功');
|
||||
playTextDirectly('上传成功')
|
||||
})
|
||||
.catch((err) => {
|
||||
$api.msg('上传失败');
|
||||
playTextDirectly('上传失败')
|
||||
});
|
||||
},
|
||||
fail: (error) => {},
|
||||
@@ -209,6 +226,22 @@ function uploadResume(tempFilePath, loading) {
|
||||
});
|
||||
});
|
||||
}
|
||||
const handleFileSend = (rows)=>{
|
||||
const file = {
|
||||
url: rows[0].fileUrl,
|
||||
type: rows[0].fileSuffix,
|
||||
name: rows[0].originalName,
|
||||
}
|
||||
$api.createRequest(`/app/oss/uploadToObs?userId=${userInfo.value.userId}&filePath=${encodeURI(file.url)}`, { }, 'POST',true,).then((res) => {
|
||||
getUserResume();
|
||||
$api.msg('上传成功');
|
||||
playTextDirectly('上传失败')
|
||||
}).catch(()=>{
|
||||
$api.msg('上传失败');
|
||||
playTextDirectly('上传失败')
|
||||
})
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="stylus" scoped>
|
||||
|
||||
@@ -82,6 +82,7 @@ const { userInfo } = storeToRefs(useUserStore());
|
||||
const { getUserResume } = useUserStore();
|
||||
const { dictLabel, oneDictData } = useDictStore();
|
||||
const openSelectPopup = inject('openSelectPopup');
|
||||
import { FileValidator } from '@/utils/fileValidator.js'; //文件校验
|
||||
|
||||
const percent = ref('0%');
|
||||
const state = reactive({
|
||||
@@ -278,15 +279,26 @@ function selectAvatar() {
|
||||
sizeType: ['original', 'compressed'],
|
||||
sourceType: ['album', 'camera'],
|
||||
count: 1,
|
||||
success: ({ tempFilePaths, tempFiles }) => {
|
||||
$api.uploadFile(tempFilePaths[0], true)
|
||||
.then((res) => {
|
||||
res = JSON.parse(res);
|
||||
if (res.msg) fromValue.avatar = res.msg;
|
||||
})
|
||||
.catch((err) => {
|
||||
$api.msg('上传失败');
|
||||
});
|
||||
success: async (res) => {
|
||||
const tempFilePaths = res.tempFilePaths;
|
||||
const file = res.tempFiles[0];
|
||||
|
||||
const imageValidator = new FileValidator();
|
||||
|
||||
try {
|
||||
await imageValidator.validate(file);
|
||||
|
||||
$api.uploadFile(tempFilePaths[0], true)
|
||||
.then((res) => {
|
||||
res = JSON.parse(res);
|
||||
if (res.msg) fromValue.avatar = res.msg;
|
||||
})
|
||||
.catch((err) => {
|
||||
$api.msg('上传失败');
|
||||
});
|
||||
} catch (error) {
|
||||
$api.msg(error);
|
||||
}
|
||||
},
|
||||
fail: (error) => {},
|
||||
});
|
||||
@@ -392,4 +404,4 @@ function selectAvatar() {
|
||||
color: #FFFFFF;
|
||||
text-align: center;
|
||||
line-height: 90rpx
|
||||
</style>
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<AppLayout title="" backGorundColor="#F4F4F4">
|
||||
<AppLayout title="" backGorundColor="#F4F4F4" showHeader>
|
||||
<template #headerleft v-if="isMiniProgram">
|
||||
<view class="btnback">
|
||||
<image src="@/static/icon/back.png" @click="navBack"></image>
|
||||
@@ -185,7 +185,9 @@ import dictLabel from '@/components/dict-Label/dict-Label.vue';
|
||||
import RadarMap from './component/radarMap.vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import useUserStore from '@/stores/useUserStore';
|
||||
import { playTextDirectly } from '@/hook/useTTSPlayer-all-in-one';
|
||||
const { isMiniProgram, hasLogin } = storeToRefs(useUserStore());
|
||||
const { checkAuth } = useUserStore();
|
||||
|
||||
const { $api, navTo, getLenPx, parseQueryParams, navBack, isEmptyObject } = inject('globalFunction');
|
||||
import config from '@/config.js';
|
||||
@@ -321,20 +323,26 @@ function getCompetivetuveness(jobId) {
|
||||
}
|
||||
}
|
||||
|
||||
// 申请岗位
|
||||
// 申请岗位 第三方需要认证
|
||||
function jobApply() {
|
||||
if (dataType.value === 2) {
|
||||
// 第三方数据申请逻辑
|
||||
|
||||
if (!checkAuth()) {
|
||||
return
|
||||
}
|
||||
const params = {
|
||||
jobid: jobInfo.value.id,
|
||||
jobname: jobInfo.value.gwmc,
|
||||
};
|
||||
if (jobInfo.value.isApply) {
|
||||
$api.msg('已经投递过该岗位了~');
|
||||
playTextDirectly('已经投递过该岗位了');
|
||||
return;
|
||||
} else {
|
||||
$api.createRequest(`/app/internal/sendResume`, params, 'POST').then((resData) => {
|
||||
$api.msg('投递成功');
|
||||
playTextDirectly('投递成功');
|
||||
getDetail(jobIdRef.value);
|
||||
});
|
||||
}
|
||||
@@ -348,6 +356,7 @@ function jobApply() {
|
||||
$api.createRequest(`/app/job/apply/${jobId}`, {}, 'GET').then((resData) => {
|
||||
getDetail(jobId);
|
||||
$api.msg('申请成功');
|
||||
playTextDirectly('申请成功');
|
||||
const jobUrl = jobInfo.value.jobUrl;
|
||||
return window.open(jobUrl);
|
||||
});
|
||||
@@ -355,8 +364,11 @@ function jobApply() {
|
||||
}
|
||||
}
|
||||
|
||||
// 取消/收藏岗位
|
||||
// 取消/收藏岗位 都需要认证
|
||||
function jobCollection() {
|
||||
if (!checkAuth()) {
|
||||
return
|
||||
}
|
||||
if (dataType.value === 2) {
|
||||
// 第三方数据收藏逻辑
|
||||
const id = jobInfo.value.id;
|
||||
@@ -364,11 +376,13 @@ function jobCollection() {
|
||||
$api.createRequest(`/app/job/collection/${id}/2`, {}, 'DELETE').then((resData) => {
|
||||
getDetail(jobIdRef.value);
|
||||
$api.msg('取消收藏成功');
|
||||
playTextDirectly('取消收藏成功');
|
||||
});
|
||||
} else {
|
||||
$api.createRequest(`/app/job/collection/${id}/2`, {}, 'POST').then((resData) => {
|
||||
getDetail(jobIdRef.value);
|
||||
$api.msg('收藏成功');
|
||||
playTextDirectly('收藏成功');
|
||||
});
|
||||
}
|
||||
} else {
|
||||
@@ -378,11 +392,13 @@ function jobCollection() {
|
||||
$api.createRequest(`/app/job/collection/${jobId}/1`, {}, 'DELETE').then((resData) => {
|
||||
getDetail(jobId);
|
||||
$api.msg('取消收藏成功');
|
||||
playTextDirectly('取消收藏成功');
|
||||
});
|
||||
} else {
|
||||
$api.createRequest(`/app/job/collection/${jobId}/1`, {}, 'POST').then((resData) => {
|
||||
getDetail(jobId);
|
||||
$api.msg('收藏成功');
|
||||
playTextDirectly('收藏成功');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,7 +63,14 @@
|
||||
{
|
||||
"path": "pages/search/search",
|
||||
"style": {
|
||||
"navigationBarTitleText": "",
|
||||
"navigationBarTitleText": "搜索",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/auth/auth",
|
||||
"style": {
|
||||
"navigationBarTitleText": "实名认证",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
}
|
||||
|
||||
643
pages/auth/auth.vue
Normal file
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 }}
|
||||
</view>
|
||||
<view class="chat-item self" v-if="isRecording">
|
||||
<view class="message">{{ recognizedText }} {{ lastFinalText }}</view>
|
||||
<view class="chat-item self" v-if="isRecording || isProcessing">
|
||||
<view class="message">{{ recognizedText || (isProcessing ? '正在识别语音...' : '正在录音 '+recordingDuration+'s') }}</view>
|
||||
</view>
|
||||
</view>
|
||||
<scroll-view class="chat-list scrollView" :scroll-top="scrollTop" :scroll-y="true" scroll-with-animation>
|
||||
@@ -78,6 +78,12 @@
|
||||
src="/static/icon/stop.png"
|
||||
@click="stopMarkdown(msg.displayText, index)"
|
||||
></image>
|
||||
<image
|
||||
v-else-if="isLoading && speechIndex === index"
|
||||
class="controll-icon mar_le10 btn-light"
|
||||
src="/static/icon/audio-fetching.png"
|
||||
@click="stopMarkdown(msg.displayText, index)"
|
||||
></image>
|
||||
<image
|
||||
class="controll-icon mar_le10 btn-light"
|
||||
src="/static/icon/broadcast.png"
|
||||
@@ -112,9 +118,8 @@
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="chat-item self" v-if="isRecording">
|
||||
<!-- <view class="message">{{ recognizedText }} {{ lastFinalText }}</view> -->
|
||||
<view class="message">{{ recognizedText }}</view>
|
||||
<view class="chat-item self" v-if="isRecording || isProcessing">
|
||||
<view class="message">{{ recognizedText || (isProcessing ? '正在识别语音...' : '正在录音 '+recordingDuration+'s') }}</view>
|
||||
</view>
|
||||
<view v-if="isTyping" class="self">
|
||||
<text class="message msg-loading">
|
||||
@@ -210,7 +215,6 @@
|
||||
<view class="uploadfiles-list">
|
||||
<view
|
||||
class="file-uploadsend"
|
||||
:class="{ 'file-border': isImage(file.type) }"
|
||||
v-for="(file, index) in filesList"
|
||||
:key="index"
|
||||
>
|
||||
@@ -219,7 +223,7 @@
|
||||
@click="preViewImage(file)"
|
||||
v-if="isImage(file.type)"
|
||||
:src="file.url"
|
||||
mode="scaleToFill"
|
||||
mode="heightFix"
|
||||
></image>
|
||||
<view class="file-doc" @click="jumpUrl(file)" v-else>
|
||||
<FileIcon class="doc-icon" :type="file.type"></FileIcon>
|
||||
@@ -268,15 +272,10 @@ import PopupFeeBack from './popupbadFeeback.vue';
|
||||
import UploadQrcode from './uploadQrcode.vue';
|
||||
import AudioWave from './AudioWave.vue';
|
||||
import WaveDisplay from './WaveDisplay.vue';
|
||||
import FileIcon from './fileIcon.vue';
|
||||
import FileText from './fileText.vue';
|
||||
import useScreenStore from '@/stores/useScreenStore'
|
||||
const screenStore = useScreenStore();
|
||||
// 系统功能hook和阿里云hook
|
||||
import { useAudioRecorder } from '@/hook/useRealtimeRecorder2.js';
|
||||
// import { useAudioRecorder } from '@/hook/useSystemSpeechReader.js';
|
||||
import { useTTSPlayer } from '@/hook/useTTSPlayer2.js';
|
||||
// import { useTTSPlayer } from '@/hook/useSystemPlayer.js';
|
||||
import { useRealtimeRecorderOnce } from '@/hook/useRealtimeRecorderOnce.js';
|
||||
import { useAudioSpeak } from '@/hook/useAudioSpeak.js';
|
||||
// 全局
|
||||
const { $api, navTo, throttle } = inject('globalFunction');
|
||||
const emit = defineEmits(['onConfirm']);
|
||||
@@ -284,20 +283,33 @@ const { messages, isTyping, textInput, chatSessionID } = storeToRefs(useChatGrou
|
||||
import successIcon from '@/static/icon/success.png';
|
||||
import useUserStore from '@/stores/useUserStore';
|
||||
const { isMachineEnv } = storeToRefs(useUserStore());
|
||||
|
||||
import { FileValidator } from '@/utils/fileValidator.js'; //文件校验
|
||||
// hook
|
||||
// 语音识别
|
||||
const {
|
||||
isRecording,
|
||||
isProcessing,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
cancelRecording,
|
||||
audioDataForDisplay,
|
||||
volumeLevel,
|
||||
recognizedText,
|
||||
lastFinalText,
|
||||
} = useAudioRecorder();
|
||||
recordingDuration
|
||||
} = useRealtimeRecorderOnce();
|
||||
|
||||
watch(recognizedText, (newText) => {
|
||||
console.log(newText,'++++++++')
|
||||
if (newText && newText.trim() && !isProcessing.value) {
|
||||
setTimeout(() => {
|
||||
sendMessage(newText);
|
||||
}, 300);
|
||||
}
|
||||
});
|
||||
|
||||
// 语音合成
|
||||
const { speak, pause, resume, isSpeaking, isPaused, cancelAudio } = useTTSPlayer(config.speechSynthesis);
|
||||
const { speak, pause, resume, isSpeaking, isPaused, isLoading, cancelAudio,cleanup } = useAudioSpeak();
|
||||
|
||||
// state
|
||||
const queries = ref([]);
|
||||
@@ -349,8 +361,14 @@ const leaveFileCount = computed(()=>{ //还剩多少文件可以上传 给扫码
|
||||
onMounted(async () => {
|
||||
changeQueries();
|
||||
scrollToBottom();
|
||||
isAudioPermission.value = await requestMicPermission();
|
||||
});
|
||||
isAudioPermission.value = await requestMicPermission();
|
||||
})
|
||||
|
||||
onUnmounted(()=>{
|
||||
// console.log('清理TTS资源')
|
||||
cleanup()
|
||||
})
|
||||
|
||||
|
||||
const requestMicPermission = async () => {
|
||||
try {
|
||||
@@ -374,6 +392,8 @@ function showControll(index) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
|
||||
const sendMessage = (text) => {
|
||||
const values = textInput.value || text;
|
||||
showfile.value = false;
|
||||
@@ -539,22 +559,29 @@ function uploadCamera(type = 'camera') {
|
||||
count: 1, //默认9
|
||||
sizeType: ['original', 'compressed'], //可以指定是原图还是压缩图,默认二者都有
|
||||
sourceType: [type], //从相册选择
|
||||
success: function (res) {
|
||||
success: async (res)=> {
|
||||
const tempFilePaths = res.tempFilePaths;
|
||||
const file = res.tempFiles[0];
|
||||
// 继续上传
|
||||
$api.uploadFile(tempFilePaths[0], true).then((resData) => {
|
||||
resData = JSON.parse(resData);
|
||||
console.log(file.type,'++')
|
||||
if (isImage(file.type)) {
|
||||
filesList.value.push({
|
||||
url: resData.msg,
|
||||
type: file.type,
|
||||
name: file.name,
|
||||
});
|
||||
textInput.value = state.uploadFileTips;
|
||||
}
|
||||
});
|
||||
|
||||
const imageValidator = new FileValidator()
|
||||
try {
|
||||
await imageValidator.validate(file)
|
||||
|
||||
$api.uploadFile(tempFilePaths[0], true).then((resData) => {
|
||||
resData = JSON.parse(resData);
|
||||
console.log(file.type,'++')
|
||||
if (isImage(file.type)) {
|
||||
filesList.value.push({
|
||||
url: resData.msg,
|
||||
type: file.type,
|
||||
name: file.name,
|
||||
});
|
||||
textInput.value = state.uploadFileTips;
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
$api.msg(error)
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -563,25 +590,30 @@ function getUploadFile(type = 'camera') {
|
||||
if (VerifyNumberFiles()) return;
|
||||
uni.chooseFile({
|
||||
count: 1,
|
||||
success: (res) => {
|
||||
success: async(res) => {
|
||||
const tempFilePaths = res.tempFilePaths;
|
||||
const file = res.tempFiles[0];
|
||||
const allowedTypes = config.allowedFileTypes || [];
|
||||
const size = $api.formatFileSize(file.size);
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
return $api.msg('仅支持 txt md word pdf ppt csv excel 格式类型');
|
||||
}
|
||||
// 继续上传
|
||||
$api.uploadFile(tempFilePaths[0], true).then((resData) => {
|
||||
resData = JSON.parse(resData);
|
||||
filesList.value.push({
|
||||
url: resData.msg,
|
||||
type: file.type,
|
||||
name: file.name,
|
||||
size: size,
|
||||
|
||||
const imageValidator = new FileValidator({allowedExtensions:config.allowedFileTypes})
|
||||
|
||||
try{
|
||||
await imageValidator.validate(file)
|
||||
|
||||
$api.uploadFile(tempFilePaths[0], true).then((resData) => {
|
||||
resData = JSON.parse(resData);
|
||||
filesList.value.push({
|
||||
url: resData.msg,
|
||||
type: file.type,
|
||||
name: file.name,
|
||||
size: size,
|
||||
});
|
||||
textInput.value = state.uploadFileTips;
|
||||
});
|
||||
textInput.value = state.uploadFileTips;
|
||||
});
|
||||
}catch(error){
|
||||
$api.msg(error)
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -640,19 +672,12 @@ const handleTouchMove = (e) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
const handleTouchEnd = async () => {
|
||||
if (status.value === 'cancel') {
|
||||
console.log('取消发送');
|
||||
cancelRecording();
|
||||
} else {
|
||||
stopRecording();
|
||||
if (isAudioPermission.value) {
|
||||
if (recognizedText.value) {
|
||||
sendMessage(recognizedText.value);
|
||||
} else {
|
||||
$api.msg('说话时长太短');
|
||||
}
|
||||
}
|
||||
await stopRecording();
|
||||
}
|
||||
status.value = 'idle';
|
||||
};
|
||||
@@ -712,20 +737,20 @@ function colseFeeBack() {
|
||||
}
|
||||
|
||||
function readMarkdown(value, index) {
|
||||
speechIndex.value = index;
|
||||
if (speechIndex.value !== index) {
|
||||
speechIndex.value = index;
|
||||
speak(value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPaused.value) {
|
||||
resume();
|
||||
resume();
|
||||
} else {
|
||||
// console.log(value, speechIndex.value, index, isPaused.value)
|
||||
speak(value);
|
||||
pause();
|
||||
}
|
||||
}
|
||||
function stopMarkdown(value, index) {
|
||||
pause(value);
|
||||
pause()
|
||||
speechIndex.value = index;
|
||||
}
|
||||
function refreshMarkdown(index) {
|
||||
@@ -1107,22 +1132,23 @@ image-margin-top = 40rpx
|
||||
padding: 16rpx 20rpx 18rpx 20rpx
|
||||
height: calc(100% - 40rpx)
|
||||
.doc-icon
|
||||
width: 60rpx
|
||||
height: 76rpx
|
||||
margin-right: 20rpx
|
||||
width: 60rpx;
|
||||
height: 76rpx;
|
||||
margin-right: 20rpx;
|
||||
.doc-con
|
||||
flex: 1
|
||||
width: 0
|
||||
max-width:320rpx;
|
||||
overflow :hidden;
|
||||
padding-right:40rpx;
|
||||
box-sizing:border-box;
|
||||
.file-uploadsend
|
||||
margin: 10rpx 18rpx 0 0;
|
||||
height: 100%
|
||||
font-size: 24rpx
|
||||
position: relative
|
||||
min-width: 460rpx;
|
||||
height: 160rpx;
|
||||
border-radius: 12rpx 12rpx 12rpx 12rpx;
|
||||
border: 2rpx solid #E2E2E2;
|
||||
overflow: hidden
|
||||
flex-shrink: 0;
|
||||
.file-del
|
||||
position: absolute
|
||||
right: 25rpx
|
||||
@@ -1155,8 +1181,8 @@ image-margin-top = 40rpx
|
||||
color: #7B7B7B;
|
||||
max-width: 100%
|
||||
.file-iconImg
|
||||
// height: 100%
|
||||
width: 100%
|
||||
height: 100%
|
||||
// width: 100%
|
||||
.filerow
|
||||
display: flex
|
||||
align-items: center
|
||||
@@ -1166,8 +1192,6 @@ image-margin-top = 40rpx
|
||||
height: 20rpx
|
||||
width: 2rpx
|
||||
background: rgba(226, 226, 226, .9)
|
||||
.file-border
|
||||
width: 160rpx !important;
|
||||
|
||||
@keyframes ai-circle {
|
||||
0% {
|
||||
|
||||
@@ -40,8 +40,6 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, inject, onUnmounted, watch, computed } from 'vue';
|
||||
import FileIcon from './fileIcon.vue';
|
||||
import FileText from './fileText.vue';
|
||||
import uQRCode from '@/static/js/qrcode';
|
||||
import config from '@/config';
|
||||
import { onShow, onHide } from '@dcloudio/uni-app';
|
||||
@@ -66,9 +64,12 @@ const pollingTimer = ref(null);
|
||||
const isPolling = ref(false);
|
||||
const isVisible = ref(false);
|
||||
const uuid = ref(null);
|
||||
const fileCount = ref(0);
|
||||
const fileList = ref([]);
|
||||
const delFiles = ref([]); //本地记录删除的文件
|
||||
const deleting = ref(false);
|
||||
|
||||
const fileCount = computed(() => {
|
||||
return fileList.value.length ?? 0;
|
||||
});
|
||||
|
||||
// 计算加载文本
|
||||
const loadingText = computed(() => ({
|
||||
@@ -113,10 +114,19 @@ function preViewImage(file) {
|
||||
$api.msg('文件地址丢失');
|
||||
}
|
||||
}
|
||||
function delFile(file, idx) {
|
||||
async function delFile(file, idx) {
|
||||
deleting.value = true;
|
||||
try {
|
||||
await $api.createRequest(`/app/kiosk/remove?sessionId=${uuid.value}&ids=${file.id}`, {}, 'post', true);
|
||||
} catch (error) {
|
||||
$api.msg(error);
|
||||
} finally {
|
||||
deleting.value = false;
|
||||
}
|
||||
fileList.value.splice(idx, 1);
|
||||
fileCount.value = fileList.value.length
|
||||
delFiles.value.push(file.fileUrl);
|
||||
if(fileList.value.length == 0){
|
||||
open()
|
||||
}
|
||||
}
|
||||
|
||||
function open() {
|
||||
@@ -149,9 +159,7 @@ function handleConfirm() {
|
||||
|
||||
// 重置所有状态
|
||||
function resetState() {
|
||||
delFiles.value = []
|
||||
fileList.value = [];
|
||||
fileCount.value = 0;
|
||||
loading.value = false;
|
||||
stopPolling();
|
||||
}
|
||||
@@ -179,23 +187,23 @@ async function initQrCode() {
|
||||
}
|
||||
|
||||
function makeQrcode() {
|
||||
const protocol = window.location.protocol;
|
||||
const host = window.location.host;
|
||||
const isLocal = host.includes('localhost') || host.includes('127.0.0.1');
|
||||
// const pathPrefix = isLocal ? '' : '/rgpp-api/all-in-one';
|
||||
let pathPrefix = '';
|
||||
if (host.includes('localhost') || host.includes('127.0.0.1')) {
|
||||
pathPrefix = '';
|
||||
} else if (host.includes('qd.zhaopinzao8dian.com')) {
|
||||
// 外网测试环境
|
||||
pathPrefix = '/app';
|
||||
} else if (host.includes('fw.rc.qingdao.gov.cn')) {
|
||||
// 青岛政务网环境
|
||||
pathPrefix = '/rgpp-api/all-in-one';
|
||||
} else {
|
||||
pathPrefix = '';
|
||||
}
|
||||
const htmlPath = `${protocol}//${host}${pathPrefix}/static/upload.html?sessionId=${uuid.value}&uploadApi=${config.baseUrl}/app/kiosk/upload`;
|
||||
const protocol = window.location.protocol;
|
||||
const host = window.location.host;
|
||||
const isLocal = host.includes('localhost') || host.includes('127.0.0.1');
|
||||
// const pathPrefix = isLocal ? '' : '/rgpp-api/all-in-one';
|
||||
let pathPrefix = '';
|
||||
if (host.includes('localhost') || host.includes('127.0.0.1')) {
|
||||
pathPrefix = '';
|
||||
} else if (host.includes('qd.zhaopinzao8dian.com')) {
|
||||
// 外网测试环境
|
||||
pathPrefix = '/app';
|
||||
} else if (host.includes('fw.rc.qingdao.gov.cn')) {
|
||||
// 青岛政务网环境
|
||||
pathPrefix = '/rgpp-api/all-in-one';
|
||||
} else {
|
||||
pathPrefix = '';
|
||||
}
|
||||
const htmlPath = `${protocol}//${host}${pathPrefix}/static/upload.html?sessionId=${uuid.value}&uploadApi=${config.baseUrl}/app/kiosk/upload&fileCount=${props.leaveFileCount}`;
|
||||
|
||||
// const htmlPath = `${window.location.host}/static/upload.html?sessionId=${uuid.value}&uploadApi=${
|
||||
// config.baseUrl + '/app/kiosk/upload'
|
||||
@@ -204,7 +212,7 @@ function makeQrcode() {
|
||||
// config.baseUrl + '/app/kiosk/upload'
|
||||
// }`;
|
||||
console.log(htmlPath);
|
||||
console.log('剩余可上传文件数量:',props.leaveFileCount)
|
||||
console.log('剩余可上传文件数量:', props.leaveFileCount);
|
||||
return new Promise((resolve, reject) => {
|
||||
setTimeout(() => {
|
||||
uQRCode.make({
|
||||
@@ -237,20 +245,16 @@ function startPolling() {
|
||||
|
||||
// 轮询检查上传状态
|
||||
const poll = async () => {
|
||||
if (!isPolling.value || !isVisible.value) return;
|
||||
if (!isPolling.value || !isVisible.value || deleting.value) return;
|
||||
const { data } = await $api.createRequest('/app/kiosk/list', { sessionId: uuid.value });
|
||||
// const { data } = await $api.createRequest('/app/kiosk/list',{sessionId:props.sessionId});
|
||||
if (data && data.length) {
|
||||
// 上传完成,触发事件
|
||||
fileList.value = data.filter((item) => !delFiles.value.includes(item.fileUrl))
|
||||
fileCount.value = fileList.value.length;
|
||||
// emit('onSend', data);
|
||||
fileList.value = data;
|
||||
}
|
||||
if (isPolling.value && isVisible.value) {
|
||||
pollingTimer.value = setTimeout(poll, 2000); // 每2秒轮询一次
|
||||
}
|
||||
};
|
||||
|
||||
poll();
|
||||
}
|
||||
|
||||
|
||||
@@ -219,7 +219,7 @@ const renderScene = (sw, sh) => {
|
||||
radius: scaledRadius,
|
||||
floatOffset: Math.random() * Math.PI * 2,
|
||||
floatSpeed: 0.01 + Math.random() * 0.02,
|
||||
floatRange: 2 + Math.random() * 2,
|
||||
floatRange: 2 * ratio + Math.random() * 2,
|
||||
safeH: safeH,
|
||||
};
|
||||
|
||||
|
||||
@@ -11,7 +11,12 @@
|
||||
<image class="bg-text" mode="widthFix" src="@/static/icon/index-text-bg.png"></image>
|
||||
<view class="search-inner">
|
||||
<view class="inner-left">
|
||||
<image class="bg-text2" mode="widthFix" src="@/static/icon/index-text-bg2.png"></image>
|
||||
<image
|
||||
class="bg-text2"
|
||||
mode="widthFix"
|
||||
@click="reloadBrowser()"
|
||||
src="@/static/icon/index-text-bg2.png"
|
||||
></image>
|
||||
<view class="search-input button-click" @click="navTo('/pages/search/search')">
|
||||
<image class="icon" src="@/static/icon/index-search.png"></image>
|
||||
<text class="inpute">请告诉我想找什么工作</text>
|
||||
@@ -20,7 +25,7 @@
|
||||
<image class="bg-robot button-click" mode="widthFix" src="@/static/icon/index-robot.png"></image>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="!isMachineEnv" class="ai-card-out" >
|
||||
<view v-if="!isMachineEnv" class="ai-card-out">
|
||||
<view class="ai-card">
|
||||
<image class="ai-card-bg" src="@/static/icon/ai-card-bg.png" />
|
||||
<view class="ai-card-inner">
|
||||
@@ -56,16 +61,16 @@
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="hasLogin" :class="{'match-move-top':isMachineEnv}" class="match-card-out">
|
||||
<view v-if="hasLogin" :class="{ 'match-move-top': isMachineEnv }" class="match-card-out">
|
||||
<view class="match-card">
|
||||
<image class="match-card-bg" src="@/static/icon/match-card-bg.png" />
|
||||
<view class="title">简历匹配职位</view>
|
||||
<view class="title">为您匹配的职位</view>
|
||||
<view class="match-item-container">
|
||||
<AIMatch :tags="matchTags" :loading="matchLoading" @tag-click="handleTagClick"></AIMatch>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view :class="{'cards-move-top':isMachineEnv && !hasLogin}" class="cards">
|
||||
<view :class="{ 'cards-move-top': isMachineEnv && !hasLogin }" class="cards">
|
||||
<view class="card card1 press-button" @click="navTo('/pages/nearby/nearby')">
|
||||
<view class="card-title">附近工作</view>
|
||||
<view class="card-text">好岗职等你来</view>
|
||||
@@ -254,11 +259,11 @@
|
||||
import { reactive, inject, watch, ref, onMounted, watchEffect, nextTick, getCurrentInstance } from 'vue';
|
||||
import img from '@/static/icon/filter.png';
|
||||
import dictLabel from '@/components/dict-Label/dict-Label.vue';
|
||||
const { $api, navTo, vacanciesTo, formatTotal, throttle } = inject('globalFunction');
|
||||
const { $api, navTo, vacanciesTo, formatTotal, throttle, reloadBrowser } = inject('globalFunction');
|
||||
import { onLoad, onShow } from '@dcloudio/uni-app';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import useUserStore from '@/stores/useUserStore';
|
||||
const { userInfo, hasLogin ,isMachineEnv} = storeToRefs(useUserStore());
|
||||
const { userInfo, hasLogin, isMachineEnv } = storeToRefs(useUserStore());
|
||||
import useDictStore from '@/stores/useDictStore';
|
||||
const { getTransformChildren, oneDictData } = useDictStore();
|
||||
import useLocationStore from '@/stores/useLocationStore';
|
||||
@@ -271,7 +276,6 @@ const recommedIndexDb = useRecommedIndexedDBStore();
|
||||
import config from '@/config';
|
||||
import AIMatch from './AIMatch.vue';
|
||||
|
||||
|
||||
const { proxy } = getCurrentInstance();
|
||||
|
||||
const maskFirstEntry = ref(true);
|
||||
@@ -363,7 +367,7 @@ onMounted(() => {
|
||||
let firstEntry = uni.getStorageSync('firstEntry') === false ? false : true; // 默认未读
|
||||
maskFirstEntry.value = firstEntry;
|
||||
getMatchTags();
|
||||
console.log(isMachineEnv.value,'+++++++++')
|
||||
// console.log(isMachineEnv.value, '+++++++++');
|
||||
});
|
||||
|
||||
async function getMatchTags() {
|
||||
@@ -466,7 +470,6 @@ const { columnCount, columnSpace } = useColumnCount(() => {
|
||||
getJobRecommend('refresh');
|
||||
nextTick(() => {
|
||||
waterfallsFlowRef.value?.refresh?.();
|
||||
useLocationStore().getLocation();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -531,6 +534,75 @@ function findJob(job) {
|
||||
conditionSearch.value[nameAttr] = val;
|
||||
}
|
||||
}
|
||||
if (state.tabIndex === 'all') {
|
||||
// 调用推荐接口
|
||||
getJobRecommendWithNewSearch(job.index);
|
||||
} else {
|
||||
// 调用普通列表接口
|
||||
getJobListWithNewSearch(job.index);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 立即查询新的数据替换现有数据
|
||||
function getJobRecommendWithNewSearch(index) {
|
||||
const params = {
|
||||
pageSize: pageState.pageSize,
|
||||
sessionId: useUserStore().seesionId,
|
||||
...pageState.search,
|
||||
...conditionSearch.value,
|
||||
};
|
||||
|
||||
$api.createRequest('/app/job/recommend', params).then((resData) => {
|
||||
const currentHeight = waterfallsFlowRef.value.$el.offsetHeight; // 获取当前高度
|
||||
waterfallsFlowRef.value.$el.style.minHeight = `${currentHeight}px`;
|
||||
const { data } = resData;
|
||||
const newData = dataToImg(data);
|
||||
const sliceIndex = index + 1;
|
||||
list.value.splice(sliceIndex, list.value.length - sliceIndex, ...newData);
|
||||
nextTick(() => {
|
||||
waterfallsFlowRef.value.refresh();
|
||||
setTimeout(() => {
|
||||
waterfallsFlowRef.value.$el.style.minHeight = '';
|
||||
}, 500);
|
||||
});
|
||||
// 更新加载状态
|
||||
updateLoadmoreStatus(newData);
|
||||
});
|
||||
}
|
||||
|
||||
// 立即查询新的数据替换现有数据
|
||||
function getJobListWithNewSearch(index) {
|
||||
// 重置分页参数
|
||||
pageState.page = 1;
|
||||
pageState.maxPage = 2;
|
||||
|
||||
const params = {
|
||||
current: pageState.page,
|
||||
pageSize: pageState.pageSize,
|
||||
...pageState.search,
|
||||
...conditionSearch.value,
|
||||
};
|
||||
|
||||
$api.createRequest('/app/job/list', params).then((resData) => {
|
||||
const { rows, total } = resData;
|
||||
// 将新数据转换为图片格式
|
||||
const newData = dataToImg(rows);
|
||||
//替换点击项以后的数据
|
||||
list.value.splice(index, list.value.length - index, ...newData);
|
||||
// 更新分页和加载状态
|
||||
pageState.total = total;
|
||||
pageState.maxPage = Math.ceil(total / pageState.pageSize);
|
||||
updateLoadmoreStatus(newData);
|
||||
});
|
||||
}
|
||||
|
||||
function updateLoadmoreStatus(newData) {
|
||||
if (loadmoreRef.value && typeof loadmoreRef.value.change === 'function') {
|
||||
if (newData.length < pageState.pageSize) {
|
||||
loadmoreRef.value.change('noMore');
|
||||
} else {
|
||||
loadmoreRef.value.change('more');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -129,7 +129,6 @@ const { columnCount, columnSpace } = useColumnCount(() => {
|
||||
pageSize.value = 10 * (columnCount.value - 1);
|
||||
nextTick(() => {
|
||||
waterfallsFlowRef.value?.refresh?.();
|
||||
useLocationStore().getLocation();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
v-for="(_, index) in 2"
|
||||
:key="index"
|
||||
>
|
||||
<component
|
||||
<component
|
||||
:is="components[index]"
|
||||
@onShowTabbar="changeShowTabbar"
|
||||
:ref="(el) => handelComponentsRef(el, index)"
|
||||
@@ -87,12 +87,20 @@ onLoad(() => {
|
||||
}
|
||||
// 预加载较重页面
|
||||
setTimeout(() => {
|
||||
uni.preloadPage({ url: '/packageA/pages/post/post' });
|
||||
uni.preloadPage({ url: '/pages/nearby/nearby' });
|
||||
uni.preloadPage({ url: '/pages/chat/chat' });
|
||||
uni.preloadPage({ url: '/pages/careerfair/careerfair' });
|
||||
uni.preloadPage({ url: '/pages/msglog/msglog' });
|
||||
uni.preloadPage({ url: '/pages/mine/mine' });
|
||||
uni.preloadPage({ url: '/pages/auth/auth' });
|
||||
uni.preloadPage({ url: '/pages/login/login' });
|
||||
uni.preloadPage({ url: '/pages/search/search' });
|
||||
uni.preloadPage({ url: '/packageA/pages/choiceness/choiceness' });
|
||||
uni.preloadPage({ url: '/packageA/pages/reservation/reservation' });
|
||||
uni.preloadPage({ url: '/packageA/pages/Intendedposition/Intendedposition' });
|
||||
uni.preloadPage({ url: '/packageA/pages/post/post' });
|
||||
uni.preloadPage({ url: '/packageA/pages/exhibitors/exhibitors' });
|
||||
uni.preloadPage({ url: '/packageA/pages/UnitDetails/UnitDetails' });
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
<template>
|
||||
<AppLayout title="就业服务程序">
|
||||
<view v-if="isMachineEnv" class="alipay-login-container">
|
||||
<AppLayout :title="pageTitle">
|
||||
<view v-if="isMachineEnv && !hasLogin" class="alipay-login-container">
|
||||
<!-- 切换 -->
|
||||
<view class="login-method-switch">
|
||||
<view
|
||||
class="method-item"
|
||||
:class="{ active: loginMethod === 'face' }"
|
||||
@click="switchLoginMethod('face')"
|
||||
>
|
||||
扫脸登录
|
||||
</view>
|
||||
<view
|
||||
class="method-item"
|
||||
:class="{ active: loginMethod === 'qrcode' }"
|
||||
@@ -17,6 +10,13 @@
|
||||
>
|
||||
扫码登录
|
||||
</view>
|
||||
<view
|
||||
class="method-item"
|
||||
:class="{ active: loginMethod === 'face' }"
|
||||
@click="switchLoginMethod('face')"
|
||||
>
|
||||
扫脸登录
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="login-scan-area">
|
||||
@@ -64,7 +64,8 @@
|
||||
</view>
|
||||
|
||||
<view class="countdown-container">
|
||||
<view class="countdown-wrapper">
|
||||
<!-- 刷脸不限时间 -->
|
||||
<view class="countdown-wrapper" v-if="loginMethod === 'qrcode'">
|
||||
<text class="countdown-number">{{ countdown }}</text>
|
||||
<text class="countdown-text">秒后自动返回</text>
|
||||
</view>
|
||||
@@ -106,7 +107,7 @@
|
||||
/>
|
||||
</view>
|
||||
<view class="content-sex">
|
||||
<view class="sex-titile">求职区域</view>
|
||||
<view class="sex-titile">求职者性别</view>
|
||||
<view class="sext-ri">
|
||||
<view
|
||||
class="sext-box"
|
||||
@@ -193,14 +194,18 @@ import { storeToRefs } from 'pinia';
|
||||
import tabcontrolVue from './components/tabcontrol.vue';
|
||||
import SelectJobs from '@/components/selectJobs/selectJobs.vue';
|
||||
import { reactive, inject, watch, ref, onMounted, onUnmounted } from 'vue';
|
||||
import { onLoad, onShow } from '@dcloudio/uni-app';
|
||||
import { onLoad, onShow, onHide } from '@dcloudio/uni-app';
|
||||
import useUserStore from '@/stores/useUserStore';
|
||||
import useDictStore from '@/stores/useDictStore';
|
||||
import { playTextDirectly } from '@/hook/useTTSPlayer-all-in-one';
|
||||
import { IncreaseRevie, FaceLoginService } from '@/common/all-in-one-listen.js';
|
||||
const { $api, navTo } = inject('globalFunction');
|
||||
const { loginSetToken, getUserResume } = useUserStore();
|
||||
const { isMachineEnv } = storeToRefs(useUserStore());
|
||||
const { isMachineEnv, hasLogin } = storeToRefs(useUserStore());
|
||||
const { getDictSelectOption, oneDictData } = useDictStore();
|
||||
const openSelectPopup = inject('openSelectPopup');
|
||||
const qrHandler = new IncreaseRevie();
|
||||
const faceService = new FaceLoginService();
|
||||
// status
|
||||
const selectJobsModel = ref();
|
||||
const tabCurrent = ref(1);
|
||||
@@ -231,19 +236,36 @@ const scanLineTop = ref(0);
|
||||
let scanInterval = null;
|
||||
const countdown = ref(60);
|
||||
let countdownTimer = null;
|
||||
const loginMethod = ref('face'); // 'qrcode' / 'face'
|
||||
|
||||
const loginMethod = ref('qrcode'); // 'qrcode' / 'face'
|
||||
const pageTitle = ref('就享家服务程序');
|
||||
onLoad((parmas) => {
|
||||
getTreeselect();
|
||||
if (!isMachineEnv.value) $api.msg('请完善微简历');
|
||||
if (!isMachineEnv.value) {
|
||||
$api.msg('请完善微简历');
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (isMachineEnv) {
|
||||
if (isMachineEnv.value) {
|
||||
startCountdown();
|
||||
startScanAnimation();
|
||||
faceService.start(); // 自动开始初始化流程
|
||||
if (loginMethod.value === 'face') {
|
||||
playTextDirectly('开始刷脸登录');
|
||||
} else {
|
||||
playTextDirectly('请进行登录');
|
||||
}
|
||||
setTimeout(() => {
|
||||
if (loginMethod.value === 'face') {
|
||||
handleFaceLogin();
|
||||
}
|
||||
if (loginMethod.value === 'qrcode') {
|
||||
qrHandler.start();
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
stopScanAnimation();
|
||||
stopCountdown();
|
||||
@@ -254,7 +276,7 @@ const startCountdown = () => {
|
||||
countdown.value = 60;
|
||||
countdownTimer = setInterval(() => {
|
||||
countdown.value--;
|
||||
if (countdown.value <= 0) {
|
||||
if (countdown.value <= 0 && loginMethod.value === 'qrcode') {
|
||||
returnToHome();
|
||||
}
|
||||
}, 1000);
|
||||
@@ -286,12 +308,63 @@ const cancelLogin = () => {
|
||||
|
||||
// 切换登录方式
|
||||
const switchLoginMethod = (method) => {
|
||||
if (!isMachineEnv.value) {
|
||||
return;
|
||||
}
|
||||
if (loginMethod.value !== method) {
|
||||
loginMethod.value = method;
|
||||
resetCountdown();
|
||||
switch (method) {
|
||||
case 'qrcode':
|
||||
faceService.close();
|
||||
qrHandler.start();
|
||||
playTextDirectly('扫码登录');
|
||||
resetCountdown();
|
||||
break;
|
||||
case 'face':
|
||||
qrHandler.close();
|
||||
handleFaceLogin();
|
||||
playTextDirectly('扫脸登录');
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
async function handleFaceLogin() {
|
||||
try {
|
||||
const authCode = await faceService.startFaceLogin();
|
||||
console.log('authCode获取:', authCode);
|
||||
if (authCode.name) {
|
||||
pageTitle.value = `就享家服务程序(${authCode.name})`;
|
||||
}
|
||||
$api.createRequest('/app/alipay/scanLogin', authCode, 'POST').then((resData) => {
|
||||
loginSetToken(resData.token).then((resume) => {
|
||||
if (resume.data.jobTitleId) {
|
||||
useUserStore().initSeesionId();
|
||||
uni.reLaunch({
|
||||
url: '/pages/index/index',
|
||||
});
|
||||
} else {
|
||||
if (resume.data.sex) {
|
||||
pageTitle.value = `就享家服务程序(${name})`;
|
||||
fromValue.sex = resume.data.sex === '男' ? 0 : 1;
|
||||
}
|
||||
playTextDirectly('登录成功,请完善简历信息');
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
this.$api.msg(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
qrHandler.close();
|
||||
});
|
||||
|
||||
onHide(() => {
|
||||
qrHandler.close();
|
||||
});
|
||||
|
||||
// 开始动画
|
||||
const startScanAnimation = () => {
|
||||
clearInterval(scanInterval);
|
||||
@@ -331,8 +404,9 @@ function changeEducation() {
|
||||
maskClick: true,
|
||||
data: [oneDictData('education')],
|
||||
success: (_, [value]) => {
|
||||
fromValue.area = value.value;
|
||||
fromValue.education = value.value;
|
||||
state.educationText = value.label;
|
||||
console.log();
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -385,6 +459,7 @@ function changeJobs() {
|
||||
}
|
||||
|
||||
function nextStep() {
|
||||
if (!state.experienceText) return $api.msg('请选择工作经验');
|
||||
tabCurrent.value += 1;
|
||||
}
|
||||
|
||||
@@ -453,6 +528,9 @@ function loginTest() {
|
||||
}
|
||||
|
||||
function complete() {
|
||||
if (!state.areaText) return $api.msg('请选择求职区域');
|
||||
if (!state.jobsText.length) return $api.msg('请选择求职岗位');
|
||||
if (!state.salayText) return $api.msg('请选择期望薪资');
|
||||
$api.createRequest('/app/user/resume', fromValue, 'post').then((resData) => {
|
||||
$api.msg('完成');
|
||||
getUserResume();
|
||||
@@ -928,6 +1006,7 @@ function complete() {
|
||||
margin-bottom: 52rpx
|
||||
.sex-titile
|
||||
line-height: 80rpx;
|
||||
color: #6A6A6A;
|
||||
.sext-ri
|
||||
display: flex
|
||||
align-items: center;
|
||||
|
||||
@@ -49,12 +49,12 @@
|
||||
</view>
|
||||
<view class="card-main">
|
||||
<view class="main-title">服务专区</view>
|
||||
<view class="main-row btn-feel" @click="selectFile">
|
||||
<view class="main-row btn-feel" @click="goAuth">
|
||||
<view class="row-left">
|
||||
<image class="left-img" src="@/static/icon/server1.png"></image>
|
||||
<text class="left-text">实名认证</text>
|
||||
</view>
|
||||
<view class="row-right">已认证</view>
|
||||
<view class="row-right">{{isAuth ? '已认证' : '未认证'}}</view>
|
||||
</view>
|
||||
<view v-if="!isMachineEnv" class="main-row btn-feel" @click="handleItemClick('素质测评')">
|
||||
<view class="row-left">
|
||||
@@ -112,7 +112,7 @@ const { $api, navTo } = inject('globalFunction');
|
||||
import useUserStore from '@/stores/useUserStore';
|
||||
|
||||
const popup = ref(null);
|
||||
const { userInfo, Completion, counts, isMachineEnv } = storeToRefs(useUserStore());
|
||||
const { userInfo, Completion, counts, isMachineEnv, isAuth } = storeToRefs(useUserStore());
|
||||
|
||||
import useScreenStore from '@/stores/useScreenStore';
|
||||
const screenStore = useScreenStore();
|
||||
@@ -144,12 +144,10 @@ function confirm() {
|
||||
|
||||
const isAbove90 = (percent) => parseFloat(percent) < 90;
|
||||
|
||||
function selectFile() {
|
||||
// FileUploader.showMenuAndUpload({
|
||||
// success: function (res) {
|
||||
// alert('上传成功: ' + JSON.stringify(res));
|
||||
// },
|
||||
// });
|
||||
function goAuth() {
|
||||
if(!isAuth.value){
|
||||
navTo('/pages/auth/auth')
|
||||
}
|
||||
}
|
||||
function chooseFileUploadTest(pam) {}
|
||||
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
<scroll-view :scroll-y="true" class="nearby-scroll" @scrolltolower="scrollBottom">
|
||||
<view class="nearby-map" @touchmove.stop.prevent>
|
||||
<map
|
||||
style="width: 100%; height: 410rpx"
|
||||
style="width: 100%; height: 690rpx"
|
||||
:latitude="latitudeVal"
|
||||
:longitude="longitudeVal"
|
||||
:markers="mapCovers"
|
||||
:circles="mapCircles"
|
||||
:controls="mapControls"
|
||||
@controltap="handleControl"
|
||||
:scale="mapScale"
|
||||
></map>
|
||||
<view class="nearby-select">
|
||||
<view class="select-view" @click="changeRangeShow">
|
||||
@@ -106,16 +107,18 @@ const tMap = ref();
|
||||
const progress = ref();
|
||||
const mapCovers = ref([]);
|
||||
const mapCircles = ref([]);
|
||||
const mapScale = ref(14.5)
|
||||
const mapControls = ref([
|
||||
{
|
||||
id: 1,
|
||||
position: {
|
||||
// 控件位置
|
||||
left: customSystem.systemInfo.screenWidth - 48 - 14,
|
||||
top: 320,
|
||||
width: 48,
|
||||
height: 48,
|
||||
left: customSystem.systemInfo.screenWidth - uni.upx2px(75 + 30),
|
||||
top: uni.upx2px(655 - 75 - 30),
|
||||
width: uni.upx2px(75),
|
||||
height: uni.upx2px(75),
|
||||
},
|
||||
width:100,
|
||||
iconPath: LocationPng, // 控件图标
|
||||
},
|
||||
]);
|
||||
@@ -148,6 +151,8 @@ function changeRangeShow() {
|
||||
|
||||
function changeRadius(item) {
|
||||
console.log(item);
|
||||
if(item > 1) mapScale.value = 14.5 - item * 0.3
|
||||
else mapScale.value = 14.5
|
||||
pageState.search.radius = item;
|
||||
rangeShow.value = false;
|
||||
progressChange(item);
|
||||
@@ -221,27 +226,23 @@ onMounted(() => {
|
||||
});
|
||||
|
||||
function getInit() {
|
||||
useLocationStore()
|
||||
.getLocation()
|
||||
.then((res) => {
|
||||
mapCovers.value = [
|
||||
{
|
||||
latitude: res.latitude,
|
||||
longitude: res.longitude,
|
||||
iconPath: point2,
|
||||
},
|
||||
];
|
||||
mapCircles.value = [
|
||||
{
|
||||
latitude: res.latitude,
|
||||
longitude: res.longitude,
|
||||
radius: 1000,
|
||||
fillColor: '#1c52fa25',
|
||||
color: '#256BFA',
|
||||
},
|
||||
];
|
||||
getJobList('refresh');
|
||||
});
|
||||
mapCovers.value = [
|
||||
{
|
||||
latitude: latitudeVal.value,
|
||||
longitude: longitudeVal.value,
|
||||
iconPath: point2,
|
||||
},
|
||||
];
|
||||
mapCircles.value = [
|
||||
{
|
||||
latitude: latitudeVal.value,
|
||||
longitude:longitudeVal.value,
|
||||
radius: 1000,
|
||||
fillColor: '#1c52fa25',
|
||||
color: '#256BFA',
|
||||
},
|
||||
];
|
||||
getJobList('refresh');
|
||||
}
|
||||
|
||||
function progressChange(value) {
|
||||
@@ -363,7 +364,7 @@ defineExpose({ loadData, handleFilterConfirm });
|
||||
height: 100%;
|
||||
background: #f4f4f4;
|
||||
.nearby-map
|
||||
height: 400rpx;
|
||||
height: 655rpx;
|
||||
background: #e8e8e8;
|
||||
overflow: hidden
|
||||
.nearby-list
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<view class="container">
|
||||
<view v-show="searchFocus" class="search-mask"></view>
|
||||
<view>
|
||||
<view class="top">
|
||||
<image
|
||||
@@ -9,22 +10,50 @@
|
||||
@click="navBack"
|
||||
></image>
|
||||
<view class="search-box">
|
||||
<my-icons
|
||||
class="iconsearch"
|
||||
color="#666666"
|
||||
type="search"
|
||||
size="36"
|
||||
@confirm="searchCollection"
|
||||
></my-icons>
|
||||
<!-- 修改为左右布局的切换按钮 -->
|
||||
<view class="search-type-tabs">
|
||||
<view
|
||||
class="type-tab button-click"
|
||||
:class="{ active: searchType === 'job' }"
|
||||
@click="setSearchType('job')"
|
||||
>
|
||||
职位
|
||||
</view>
|
||||
<view
|
||||
class="type-tab button-click"
|
||||
:class="{ active: searchType === 'major' }"
|
||||
@click="setSearchType('major')"
|
||||
>
|
||||
专业
|
||||
</view>
|
||||
</view>
|
||||
<input
|
||||
class="inputed"
|
||||
type="text"
|
||||
focus
|
||||
:focus="autoFocus"
|
||||
v-model="searchValue"
|
||||
placeholder="搜索职位名称"
|
||||
:placeholder="searchType === 'job' ? '搜索职位名称' : '搜索专业名称'"
|
||||
placeholder-class="placeholder"
|
||||
@input="handleInputChange"
|
||||
@blur="handleInputBlur"
|
||||
@focus="handleInputFocus"
|
||||
@confirm="searchBtn"
|
||||
/>
|
||||
<!-- 联想搜索下拉列表 -->
|
||||
<scroll-view
|
||||
scroll-y
|
||||
class="search-suggestions"
|
||||
v-show="showSuggestions && filteredSuggestions.length"
|
||||
>
|
||||
<view
|
||||
class="suggestion-item"
|
||||
v-for="(item, index) in filteredSuggestions"
|
||||
:key="index"
|
||||
@click="selectSuggestion(item)"
|
||||
>
|
||||
<view class="item-txt line_1">{{ item }}</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
<view class="search-btn button-click" @click="searchBtn">搜索</view>
|
||||
</view>
|
||||
@@ -86,7 +115,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { inject, ref, reactive, nextTick } from 'vue';
|
||||
import { inject, ref, reactive, nextTick, computed } from 'vue';
|
||||
import { onLoad, onShow } from '@dcloudio/uni-app';
|
||||
import SelectJobs from '@/components/selectJobs/selectJobs.vue';
|
||||
const { $api, navBack, navTo } = inject('globalFunction');
|
||||
@@ -98,9 +127,34 @@ import img from '@/static/icon/filter.png';
|
||||
const { longitudeVal, latitudeVal } = storeToRefs(useLocationStore());
|
||||
import useUserStore from '@/stores/useUserStore';
|
||||
const { isMiniProgram } = storeToRefs(useUserStore());
|
||||
import { playTextDirectly } from '@/hook/useTTSPlayer-all-in-one';
|
||||
|
||||
// 搜索类型:job-职位,major-专业
|
||||
const searchType = ref('job');
|
||||
const searchValue = ref('');
|
||||
const historyList = ref([]);
|
||||
const listCom = ref([]);
|
||||
const showSuggestions = ref(false);
|
||||
const searchFocus = ref(false);
|
||||
const autoFocus = ref(false)
|
||||
|
||||
// 专业数据源(示例数据,可以替换为实际数据)
|
||||
const majorDataSource = ref([]);
|
||||
|
||||
// 计算属性:过滤后的联想列表
|
||||
const filteredSuggestions = computed(() => {
|
||||
if (!searchValue.value || searchType.value !== 'major') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const searchText = searchValue.value.toLowerCase();
|
||||
try {
|
||||
return majorDataSource.value.filter((item) => item.toLowerCase().includes(searchText));
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
const pageState = reactive({
|
||||
page: 0,
|
||||
total: 0,
|
||||
@@ -114,7 +168,7 @@ const isLoaded = ref(false);
|
||||
const waterfallsFlowRef = ref(null);
|
||||
const loadmoreRef = ref(null);
|
||||
const currentTab = ref(0);
|
||||
// 响应式搜索条件(可以被修改)
|
||||
|
||||
const searchParams = ref({});
|
||||
const pageSize = ref(10);
|
||||
|
||||
@@ -151,21 +205,80 @@ const { columnCount, columnSpace } = useColumnCount(() => {
|
||||
pageSize.value = 10 * (columnCount.value - 1);
|
||||
nextTick(() => {
|
||||
waterfallsFlowRef.value?.refresh?.();
|
||||
useLocationStore().getLocation();
|
||||
});
|
||||
});
|
||||
|
||||
onLoad((options) => {
|
||||
if(options.keyWord){
|
||||
searchValue.value = decodeURIComponent(options.keyWord)
|
||||
searchBtn()
|
||||
if (options.keyWord) {
|
||||
searchValue.value = decodeURIComponent(options.keyWord);
|
||||
searchBtn();
|
||||
}else{
|
||||
//从标签点进来不自动聚焦
|
||||
autoFocus.value = true
|
||||
}
|
||||
let arr = uni.getStorageSync('searchList');
|
||||
if (arr) {
|
||||
historyList.value = uni.getStorageSync('searchList');
|
||||
}
|
||||
|
||||
getMajorDataSource();
|
||||
});
|
||||
|
||||
function getMajorDataSource() {
|
||||
const LoadCache = (resData) => {
|
||||
if (resData.code === 200) {
|
||||
majorDataSource.value = resData.data;
|
||||
console.log(majorDataSource.value);
|
||||
}
|
||||
};
|
||||
$api.createRequestWithCache('/app/common/majorManagement/getMajorList', {}, 'GET', false, LoadCache).then(
|
||||
LoadCache
|
||||
);
|
||||
}
|
||||
|
||||
// 设置搜索类型并触发搜索
|
||||
function setSearchType(type) {
|
||||
if (searchType.value === type) return;
|
||||
searchType.value = type;
|
||||
|
||||
// 如果输入框有值且当前是专业搜索,显示联想列表
|
||||
if (searchValue.value && searchType.value === 'major' && searchFocus.value) {
|
||||
showSuggestions.value = true;
|
||||
} else {
|
||||
showSuggestions.value = false;
|
||||
}
|
||||
|
||||
// 如果有搜索值,触发搜索
|
||||
if (searchValue.value) {
|
||||
// 重置页码
|
||||
pageState.page = 0;
|
||||
|
||||
// 触发搜索
|
||||
playTextDirectly('正在为您查找岗位');
|
||||
|
||||
// 根据搜索类型设置不同的参数
|
||||
if (searchType.value === 'job') {
|
||||
searchParams.value = {
|
||||
jobTitle: searchValue.value,
|
||||
};
|
||||
} else {
|
||||
searchParams.value = {
|
||||
majorTitle: searchValue.value,
|
||||
};
|
||||
}
|
||||
|
||||
if (currentTab.value === 0) {
|
||||
getJobList('refresh');
|
||||
} else {
|
||||
refresh();
|
||||
waterfallsFlowRef.value?.refresh?.();
|
||||
}
|
||||
|
||||
// 隐藏联想列表
|
||||
showSuggestions.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function changeType(type) {
|
||||
if (currentTab.value === type) return;
|
||||
switch (type) {
|
||||
@@ -180,27 +293,84 @@ function changeType(type) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function searchFn(item) {
|
||||
searchValue.value = item;
|
||||
searchBtn();
|
||||
}
|
||||
|
||||
function handleInputChange(e) {
|
||||
const val = e.detail.value;
|
||||
searchValue.value = val;
|
||||
|
||||
// 如果是专业搜索且输入框有值,显示联想列表
|
||||
if (searchType.value === 'major' && val && searchFocus.value) {
|
||||
showSuggestions.value = true;
|
||||
} else {
|
||||
showSuggestions.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleInputFocus() {
|
||||
searchFocus.value = true;
|
||||
// 如果是专业搜索且输入框有值,显示联想列表
|
||||
if (searchType.value === 'major' && searchValue.value) {
|
||||
showSuggestions.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
function handleInputBlur() {
|
||||
searchFocus.value = false;
|
||||
// 延迟隐藏联想列表,以便点击项能触发
|
||||
setTimeout(() => {
|
||||
showSuggestions.value = false;
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// 选择联想项
|
||||
function selectSuggestion(item) {
|
||||
searchValue.value = item;
|
||||
showSuggestions.value = false;
|
||||
// 直接触发搜索
|
||||
searchBtn();
|
||||
}
|
||||
|
||||
function searchBtn() {
|
||||
if (!searchValue.value) {
|
||||
return;
|
||||
}
|
||||
playTextDirectly('正在为您查找岗位');
|
||||
|
||||
// 保存到历史记录(仅当搜索成功时保存)
|
||||
historyList.value.unshift(searchValue.value);
|
||||
historyList.value = unique(historyList.value);
|
||||
uni.setStorageSync('searchList', historyList.value);
|
||||
searchParams.value = {
|
||||
jobTitle: searchValue,
|
||||
};
|
||||
|
||||
// 根据搜索类型设置不同的参数
|
||||
if (searchType.value === 'job') {
|
||||
// 职位搜索
|
||||
searchParams.value = {
|
||||
jobTitle: searchValue.value,
|
||||
};
|
||||
} else {
|
||||
// 专业搜索
|
||||
searchParams.value = {
|
||||
majorTitle: searchValue.value,
|
||||
};
|
||||
}
|
||||
|
||||
// 重置页码
|
||||
pageState.page = 0;
|
||||
|
||||
if (currentTab.value === 0) {
|
||||
getJobList('refresh');
|
||||
} else {
|
||||
refresh();
|
||||
waterfallsFlowRef.value?.refresh?.();
|
||||
}
|
||||
|
||||
// 隐藏联想列表
|
||||
showSuggestions.value = false;
|
||||
}
|
||||
|
||||
function searchCollection(e) {
|
||||
@@ -251,13 +421,25 @@ function getJobList(type = 'add') {
|
||||
pageState.page = 1;
|
||||
pageState.maxPage = 2;
|
||||
}
|
||||
|
||||
// 根据搜索类型构建参数
|
||||
let params = {
|
||||
current: pageState.page,
|
||||
pageSize: pageState.pageSize,
|
||||
...pageState.search,
|
||||
jobTitle: searchValue.value,
|
||||
};
|
||||
|
||||
// 移除之前的搜索参数,根据当前搜索类型添加
|
||||
if (searchType.value === 'job') {
|
||||
params.jobTitle = searchValue.value;
|
||||
// 确保没有majorTitle参数
|
||||
delete params.majorTitle;
|
||||
} else {
|
||||
params.majorTitle = searchValue.value;
|
||||
// 确保没有jobTitle参数
|
||||
delete params.jobTitle;
|
||||
}
|
||||
|
||||
$api.createRequest('/app/job/list', params, 'GET', true).then((resData) => {
|
||||
const { rows, total } = resData;
|
||||
if (type === 'add') {
|
||||
@@ -268,6 +450,16 @@ function getJobList(type = 'add') {
|
||||
listCom.value = [...listCom.value, ...reslist];
|
||||
} else {
|
||||
listCom.value = [...rows];
|
||||
// 一体机语音提示
|
||||
if (searchType.value === 'major') {
|
||||
if (rows.length) {
|
||||
$api.msg('为您找到相关专业的职位');
|
||||
playTextDirectly(`为您找到相关专业的职位`);
|
||||
} else {
|
||||
$api.msg('未找到相关专业的职位,请尝试其他关键词');
|
||||
playTextDirectly(`未找到相关专业的职位,请尝试其他关键词`);
|
||||
}
|
||||
}
|
||||
}
|
||||
pageState.total = resData.total;
|
||||
pageState.maxPage = Math.ceil(pageState.total / pageState.pageSize);
|
||||
@@ -296,7 +488,6 @@ function dataToImg(data) {
|
||||
.Detailscroll-view{
|
||||
flex: 1
|
||||
overflow: hidden
|
||||
|
||||
}
|
||||
.container{
|
||||
display: flex
|
||||
@@ -364,8 +555,6 @@ function dataToImg(data) {
|
||||
justify-content: space-between
|
||||
background-color: #fff;
|
||||
padding: 20rpx 20rpx;
|
||||
position: sticky;
|
||||
top: 0
|
||||
.btnback{
|
||||
width: 60rpx;
|
||||
height: 60rpx;
|
||||
@@ -373,27 +562,90 @@ function dataToImg(data) {
|
||||
.search-box{
|
||||
flex: 1;
|
||||
padding: 0 24rpx 0 6rpx;
|
||||
position: relative
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
.search-type-tabs {
|
||||
background: #FFFFFF;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 21rpx;
|
||||
transform: translate(0, -50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: 8rpx;
|
||||
padding: 4rpx;
|
||||
z-index: 2;
|
||||
border: 1rpx solid #E0E0E0;
|
||||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
|
||||
border-radius: 60rpx
|
||||
|
||||
.type-tab {
|
||||
padding: 10rpx 24rpx;
|
||||
font-size: 24rpx;
|
||||
color: #666666;
|
||||
border-radius: 30rpx;
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
|
||||
&.active {
|
||||
background: #256BFA;
|
||||
color: #FFFFFF;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.type-tab:first-child {
|
||||
margin-right: 1rpx;
|
||||
}
|
||||
}
|
||||
.inputed {
|
||||
padding-left: 30rpx
|
||||
width: 100%;
|
||||
background: #F8F8F8;
|
||||
font-size: 28rpx;
|
||||
font-family: PingFang SC;
|
||||
font-weight: 400;
|
||||
line-height: 36rpx;
|
||||
color: #666666;
|
||||
padding: 0 30rpx 0 80rpx;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
background: #F8F8F8;
|
||||
font-size: 28rpx;
|
||||
font-family: PingFang SC;
|
||||
font-weight: 400;
|
||||
line-height: 36rpx;
|
||||
color: #666666;
|
||||
padding: 0 30rpx 0 230rpx;
|
||||
box-sizing: border-box;
|
||||
height: 80rpx;
|
||||
background: #F5F5F5;
|
||||
border-radius: 75rpx 75rpx 75rpx 75rpx;
|
||||
border-radius: 73rpx;
|
||||
}
|
||||
.iconsearch{
|
||||
position: absolute
|
||||
top: 50%
|
||||
left: 36rpx
|
||||
transform: translate(0, -50%)
|
||||
|
||||
.search-suggestions {
|
||||
position: absolute;
|
||||
top: 105%;
|
||||
width:calc(100% - 24rpx - 6rpx);
|
||||
background: #FFFFFF;
|
||||
border: 2rpx solid #ECECEC;
|
||||
border-top: 0;
|
||||
z-index: 10;
|
||||
border-radius: 30rpx
|
||||
max-height: 40vh;
|
||||
overflow hidden;
|
||||
.suggestion-item {
|
||||
height: 80rpx;
|
||||
padding: 0rpx 24rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-items: flex-start;
|
||||
border-top: 2rpx dashed #e3e3e3;
|
||||
|
||||
.item-txt {
|
||||
font-size: 28rpx;
|
||||
color: #333333;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.suggestion-item:hover {
|
||||
background: #e5e5e5;
|
||||
}
|
||||
|
||||
.suggestion-item:first-child {
|
||||
border-top: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
.search-btn {
|
||||
@@ -470,4 +722,13 @@ function dataToImg(data) {
|
||||
font-size: 24rpx;
|
||||
color: #6C7282;
|
||||
margin-top: 6rpx;
|
||||
</style>
|
||||
.search-mask{
|
||||
position fixed;
|
||||
width 100vw;
|
||||
height:100vh;
|
||||
top:0;
|
||||
left:0
|
||||
z-index:9;
|
||||
background: #33333355;
|
||||
}
|
||||
</style>
|
||||
BIN
static/icon/audio-fetching.png
Normal file
BIN
static/icon/audio-fetching.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
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 |
170
static/js/fileValidator.js
Normal file
170
static/js/fileValidator.js
Normal file
@@ -0,0 +1,170 @@
|
||||
const KNOWN_SIGNATURES = {
|
||||
png: '89504E470D0A1A0A',
|
||||
jpg: 'FFD8FF',
|
||||
jpeg: 'FFD8FF',
|
||||
gif: '47494638',
|
||||
webp: '52494646',
|
||||
docx: '504B0304',
|
||||
xlsx: '504B0304',
|
||||
pptx: '504B0304',
|
||||
doc: 'D0CF11E0',
|
||||
xls: 'D0CF11E0',
|
||||
ppt: 'D0CF11E0',
|
||||
pdf: '25504446',
|
||||
txt: 'TYPE_TEXT',
|
||||
csv: 'TYPE_TEXT',
|
||||
md: 'TYPE_TEXT',
|
||||
json: 'TYPE_TEXT',
|
||||
};
|
||||
export class FileValidator {
|
||||
version = '1.0.0';
|
||||
signs = Object.keys(KNOWN_SIGNATURES);
|
||||
constructor(options = {}) {
|
||||
this.maxSizeMB = options.maxSizeMB || 10;
|
||||
if (options.allowedExtensions && Array.isArray(options.allowedExtensions)) {
|
||||
this.allowedConfig = {};
|
||||
options.allowedExtensions.forEach((ext) => {
|
||||
const key = ext.toLowerCase();
|
||||
if (KNOWN_SIGNATURES[key]) {
|
||||
this.allowedConfig[key] = KNOWN_SIGNATURES[key];
|
||||
} else {
|
||||
console.warn(`[FileValidator] 未知的文件类型: .${key},已忽略`);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this.allowedConfig = {
|
||||
...KNOWN_SIGNATURES,
|
||||
};
|
||||
}
|
||||
}
|
||||
_isValidUTF8(buffer) {
|
||||
try {
|
||||
const decoder = new TextDecoder('utf-8', {
|
||||
fatal: true,
|
||||
});
|
||||
decoder.decode(buffer);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
_bufferToHex(buffer) {
|
||||
return Array.prototype.map
|
||||
.call(new Uint8Array(buffer), (x) => ('00' + x.toString(16)).slice(-2))
|
||||
.join('')
|
||||
.toUpperCase();
|
||||
}
|
||||
_countCSVRows(buffer) {
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
const text = decoder.decode(buffer);
|
||||
let rowCount = 0;
|
||||
let inQuote = false;
|
||||
let len = text.length;
|
||||
for (let i = 0; i < len; i++) {
|
||||
const char = text[i];
|
||||
if (char === '"') {
|
||||
inQuote = !inQuote;
|
||||
} else if (char === '\n' && !inQuote) {
|
||||
rowCount++;
|
||||
}
|
||||
}
|
||||
if (len > 0 && text[len - 1] !== '\n') {
|
||||
rowCount++;
|
||||
}
|
||||
return rowCount;
|
||||
}
|
||||
_validateTextContent(buffer, extension) {
|
||||
let contentStr = '';
|
||||
try {
|
||||
const decoder = new TextDecoder('utf-8', {
|
||||
fatal: true,
|
||||
});
|
||||
contentStr = decoder.decode(buffer);
|
||||
} catch (e) {
|
||||
console.warn('UTF-8 解码失败', e);
|
||||
return false;
|
||||
}
|
||||
if (contentStr.includes('\0')) {
|
||||
return false;
|
||||
}
|
||||
if (extension === 'json') {
|
||||
try {
|
||||
JSON.parse(contentStr);
|
||||
} catch (e) {
|
||||
console.warn('无效的 JSON 格式');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
validate(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!file || !file.name) return reject('无效的文件对象');
|
||||
if (file.size > this.maxSizeMB * 1024 * 1024) {
|
||||
return reject(`文件大小超出限制 (最大 ${this.maxSizeMB}MB)`);
|
||||
}
|
||||
const fileName = file.name.toLowerCase();
|
||||
const extension = fileName.substring(fileName.lastIndexOf('.') + 1);
|
||||
const expectedMagic = this.allowedConfig[extension];
|
||||
if (!expectedMagic) {
|
||||
return reject(`不支持的文件格式: .${extension}`);
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const buffer = e.target.result;
|
||||
let isSafe = false;
|
||||
if (expectedMagic === 'TYPE_TEXT') {
|
||||
if (this._validateTextContent(buffer, extension)) {
|
||||
isSafe = true;
|
||||
} else {
|
||||
if (extension === 'json') {
|
||||
return reject(`文件异常:不是有效的 JSON 文件`);
|
||||
}
|
||||
return reject(`文件异常:.${extension} 包含非法二进制内容或编码错误`);
|
||||
}
|
||||
if (extension === 'csv' && this.csvMaxRows > 0) {
|
||||
const rows = this._countCSVRows(buffer);
|
||||
if (rows > this.csvMaxRows) {
|
||||
return reject(`CSV 行数超出限制 (当前 ${rows} 行,最大允许 ${this.csvMaxRows} 行)`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const fileHeader = this._bufferToHex(buffer.slice(0, 8));
|
||||
if (fileHeader.startsWith(expectedMagic)) {
|
||||
isSafe = true;
|
||||
} else {
|
||||
return reject(`文件可能已被篡改 (真实类型与 .${extension} 不符)`);
|
||||
}
|
||||
}
|
||||
if (isSafe) resolve(true);
|
||||
};
|
||||
reader.onerror = () => reject('文件读取失败,无法校验');
|
||||
if (expectedMagic === 'TYPE_TEXT' && extension === 'json') {
|
||||
reader.readAsArrayBuffer(file);
|
||||
} else {
|
||||
reader.readAsArrayBuffer(file.slice(0, 2048));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 【demo】
|
||||
// 如果传入了 allowedExtensions,则只使用传入的;否则使用全部 KNOWN_SIGNATURES
|
||||
// const imageValidator = new FileValidator({
|
||||
// maxSizeMB: 5,
|
||||
// allowedExtensions: ['png', 'jpg', 'jpeg'],
|
||||
// });
|
||||
|
||||
// imageValidator
|
||||
// .validate(file)
|
||||
// .then(() => {
|
||||
// statusDiv.textContent = `检测通过: ${file.name}`;
|
||||
// statusDiv.style.color = 'green';
|
||||
// console.log('图片校验通过,开始上传...');
|
||||
// // upload(file)...
|
||||
// })
|
||||
// .catch((err) => {
|
||||
// statusDiv.textContent = `检测失败: ${err}`;
|
||||
// statusDiv.style.color = 'red';
|
||||
// });
|
||||
2642
static/upload.html
2642
static/upload.html
File diff suppressed because it is too large
Load Diff
@@ -9,12 +9,22 @@ import {
|
||||
} from '@/common/globalFunction.js'
|
||||
import config from '../config';
|
||||
|
||||
const defalutLongLat = {
|
||||
longitude: 120.366085,
|
||||
latitude: 36.086656,
|
||||
}
|
||||
|
||||
const useLocationStore = defineStore("location", () => {
|
||||
// 定义状态
|
||||
const longitudeVal = ref(null) // 经度
|
||||
const latitudeVal = ref(null) //纬度
|
||||
const longitudeVal = ref(120.366085) // 经度
|
||||
const latitudeVal = ref(36.086656) //纬度
|
||||
const timer = ref(null)
|
||||
const count = ref(0)
|
||||
|
||||
function getLocation() { // 获取经纬度两个平台
|
||||
// #ifndef H5
|
||||
const lightAppJssdk = {}
|
||||
// #endif
|
||||
return new Promise((resole, reject) => {
|
||||
try {
|
||||
if (lightAppJssdk.map) {
|
||||
@@ -25,49 +35,57 @@ const useLocationStore = defineStore("location", () => {
|
||||
resole(data)
|
||||
},
|
||||
fail: function(data) {
|
||||
longitudeVal.value = 120.382665
|
||||
latitudeVal.value = 36.066938
|
||||
resole({
|
||||
longitude: 120.382665,
|
||||
latitude: 36.066938
|
||||
})
|
||||
longitudeVal.value = defalutLongLat.longitude
|
||||
latitudeVal.value = defalutLongLat.latitude
|
||||
reject()
|
||||
msg('用户位置获取失败')
|
||||
console.log('失败3', data)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
uni.getLocation({
|
||||
type: 'gcj02',
|
||||
highAccuracyExpireTime: 3000,
|
||||
isHighAccuracy: true,
|
||||
timeout: 2000,
|
||||
// highAccuracyExpireTime: 3000,
|
||||
// isHighAccuracy: true,
|
||||
// timeout: 2000,
|
||||
success: function(data) {
|
||||
longitudeVal.value = Number(data.longitude)
|
||||
latitudeVal.value = Number(data.latitude)
|
||||
resole(data)
|
||||
},
|
||||
fail: function(data) {
|
||||
longitudeVal.value = 120.382665
|
||||
latitudeVal.value = 36.066938
|
||||
resole({
|
||||
longitude: 120.382665,
|
||||
latitude: 36.066938
|
||||
})
|
||||
longitudeVal.value = defalutLongLat.longitude
|
||||
latitudeVal.value = defalutLongLat.latitude
|
||||
reject()
|
||||
msg('用户位置获取失败')
|
||||
console.log('失败2', data)
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
longitudeVal.value = 120.382665
|
||||
latitudeVal.value = 36.066938
|
||||
resole({
|
||||
longitude: 120.382665,
|
||||
latitude: 36.066938
|
||||
})
|
||||
longitudeVal.value = defalutLongLat.longitude
|
||||
latitudeVal.value = defalutLongLat.latitude
|
||||
reject()
|
||||
msg('测试环境,使用模拟定位')
|
||||
console.log('失败', data)
|
||||
console.log('失败1', e)
|
||||
}
|
||||
})
|
||||
}
|
||||
function getLocationLoop(gap = 1000 * 60 * 2) {
|
||||
console.log(`🔄开始循环获取定位,间隔:${Math.floor(gap/1000)}秒`)
|
||||
const run = () => {
|
||||
count.value++
|
||||
console.log(`📍第${count.value}次获取定位`)
|
||||
getLocation()
|
||||
}
|
||||
run()
|
||||
timer.value = setInterval(run,gap);
|
||||
}
|
||||
|
||||
function clearGetLocationLoop(params) {
|
||||
clearInterval(timer.value)
|
||||
timer.value = null
|
||||
}
|
||||
|
||||
function longitude() {
|
||||
return longitudeVal.value
|
||||
@@ -80,11 +98,12 @@ const useLocationStore = defineStore("location", () => {
|
||||
// 导入
|
||||
return {
|
||||
getLocation,
|
||||
getLocationLoop,
|
||||
clearGetLocationLoop,
|
||||
longitudeVal,
|
||||
latitudeVal
|
||||
|
||||
latitudeVal,
|
||||
}
|
||||
},{
|
||||
}, {
|
||||
unistorage: true,
|
||||
})
|
||||
|
||||
|
||||
@@ -97,7 +97,7 @@ export const useRecommedIndexedDBStore = defineStore("indexedDB", () => {
|
||||
async function addRecord(payload) {
|
||||
const totalRecords = await baseDB.db.getRecordCount(tableName.value);
|
||||
if (totalRecords >= total.value) {
|
||||
console.log(`⚠数据超过 ${total.value} 条,删除最早的一条...`);
|
||||
console.log(`数据超过 ${total.value} 条,删除最早的一条...`);
|
||||
await baseDB.db.deleteOldestRecord(tableName.value);
|
||||
}
|
||||
if (!baseDB.isDBReady) await baseDB.initDB();
|
||||
|
||||
@@ -5,8 +5,11 @@ import {
|
||||
ref,
|
||||
computed
|
||||
} from 'vue';
|
||||
|
||||
|
||||
import wideScreenStyles from '../common/wide-screen.css?inline';
|
||||
|
||||
|
||||
// 屏幕检测管理器类
|
||||
class ScreenDetectionManager {
|
||||
constructor() {
|
||||
@@ -49,17 +52,25 @@ class ScreenDetectionManager {
|
||||
|
||||
// 检测折叠屏
|
||||
checkVisualViewport() {
|
||||
if (window.visualViewport?.segments?.length > 1) {
|
||||
return {
|
||||
foldable: true,
|
||||
count: window.visualViewport.segments.length - 1
|
||||
try {
|
||||
if (window.visualViewport?.segments?.length > 1) {
|
||||
return {
|
||||
foldable: true,
|
||||
count: window.visualViewport.segments.length - 1
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
foldable: false,
|
||||
count: 1
|
||||
}
|
||||
}
|
||||
} else {
|
||||
} catch (error) {
|
||||
return {
|
||||
foldable: false,
|
||||
count: 1
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// 动态加载 CSS
|
||||
@@ -74,11 +85,11 @@ class ScreenDetectionManager {
|
||||
|
||||
// 添加加载成功/失败监听
|
||||
this.cssLink.onload = () => {
|
||||
console.log('🎨 宽屏 CSS 文件加载成功');
|
||||
console.log('宽屏 CSS 文件加载成功');
|
||||
};
|
||||
|
||||
this.cssLink.onerror = () => {
|
||||
console.error('❌ 宽屏 CSS 文件加载失败');
|
||||
console.error('宽屏 CSS 文件加载失败');
|
||||
this.cssLink = null;
|
||||
};
|
||||
|
||||
@@ -97,7 +108,7 @@ class ScreenDetectionManager {
|
||||
try {
|
||||
this.cssLink.parentNode.removeChild(this.cssLink);
|
||||
this.cssLink = null;
|
||||
console.log('🗑️ 宽屏 CSS 文件已移除');
|
||||
console.log('宽屏 CSS 文件已移除');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('移除 CSS 失败:', error);
|
||||
|
||||
@@ -2,7 +2,8 @@ import {
|
||||
defineStore
|
||||
} from 'pinia';
|
||||
import {
|
||||
ref
|
||||
ref,
|
||||
inject
|
||||
} from 'vue'
|
||||
import {
|
||||
createRequest
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
import {
|
||||
msg,
|
||||
$api,
|
||||
navTo
|
||||
} from '../common/globalFunction';
|
||||
import baseDB from '@/utils/db.js';
|
||||
|
||||
@@ -61,6 +63,7 @@ const useUserStore = defineStore("user", () => {
|
||||
const counts = ref({})
|
||||
const isMiniProgram = ref(false)
|
||||
const isMachineEnv = ref(false)
|
||||
const isAuth = ref(false) //是否认证(身份证+手机号)
|
||||
|
||||
const login = (value) => {
|
||||
hasLogin.value = true;
|
||||
@@ -135,6 +138,19 @@ const useUserStore = defineStore("user", () => {
|
||||
userInfo.value = values.data;
|
||||
// role.value = values.role;
|
||||
hasLogin.value = true;
|
||||
isAuth.value = values.data?.isCert == 0 ? true : false //是否认证 0已认证
|
||||
}
|
||||
|
||||
const checkAuth = () => {
|
||||
if (!hasLogin.value) {
|
||||
logOut()
|
||||
return false //验证失败
|
||||
}
|
||||
if (hasLogin.value && !isAuth.value) {
|
||||
navTo('/pages/auth/auth')
|
||||
return false //验证失败
|
||||
}
|
||||
return true // 验证通过
|
||||
}
|
||||
|
||||
|
||||
@@ -186,7 +202,9 @@ const useUserStore = defineStore("user", () => {
|
||||
isMiniProgram,
|
||||
changMiniProgramAppStatus,
|
||||
changMachineEnv,
|
||||
isMachineEnv
|
||||
isMachineEnv,
|
||||
isAuth,
|
||||
checkAuth
|
||||
}
|
||||
}, {
|
||||
unistorage: true,
|
||||
|
||||
@@ -39,7 +39,7 @@ const useChatGroupDBStore = defineStore("messageGroup", () => {
|
||||
setTimeout(async () => {
|
||||
if (!baseDB.isDBReady) await baseDB.initDB();
|
||||
const result = await baseDB.db.getAll(tableName.value);
|
||||
console.log('result', result)
|
||||
// console.log('result', result)
|
||||
// 1、判断是否有数据,没数据请求服务器
|
||||
if (result.length) {
|
||||
console.warn('本地数据库存在数据')
|
||||
|
||||
227
utils/IDCardParser.js
Normal file
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;
|
||||
@@ -66,7 +66,14 @@ class BaseStore {
|
||||
}
|
||||
|
||||
initDB() {
|
||||
// #ifdef H5
|
||||
this.db = new IndexedDBHelper(this.dbName, config.DBversion);
|
||||
// #endif
|
||||
|
||||
// #ifndef H5
|
||||
this.db = new UniStorageHelper(this.dbName, config.DBversion);
|
||||
// #endif
|
||||
|
||||
return this.db.openDB([{
|
||||
name: 'record',
|
||||
keyPath: "id",
|
||||
|
||||
170
utils/fileValidator.js
Normal file
170
utils/fileValidator.js
Normal file
@@ -0,0 +1,170 @@
|
||||
const KNOWN_SIGNATURES = {
|
||||
png: '89504E470D0A1A0A',
|
||||
jpg: 'FFD8FF',
|
||||
jpeg: 'FFD8FF',
|
||||
gif: '47494638',
|
||||
webp: '52494646',
|
||||
docx: '504B0304',
|
||||
xlsx: '504B0304',
|
||||
pptx: '504B0304',
|
||||
doc: 'D0CF11E0',
|
||||
xls: 'D0CF11E0',
|
||||
ppt: 'D0CF11E0',
|
||||
pdf: '25504446',
|
||||
txt: 'TYPE_TEXT',
|
||||
csv: 'TYPE_TEXT',
|
||||
md: 'TYPE_TEXT',
|
||||
json: 'TYPE_TEXT',
|
||||
};
|
||||
export class FileValidator {
|
||||
version = '1.0.0';
|
||||
signs = Object.keys(KNOWN_SIGNATURES);
|
||||
constructor(options = {}) {
|
||||
this.maxSizeMB = options.maxSizeMB || 10;
|
||||
if (options.allowedExtensions && Array.isArray(options.allowedExtensions)) {
|
||||
this.allowedConfig = {};
|
||||
options.allowedExtensions.forEach((ext) => {
|
||||
const key = ext.toLowerCase();
|
||||
if (KNOWN_SIGNATURES[key]) {
|
||||
this.allowedConfig[key] = KNOWN_SIGNATURES[key];
|
||||
} else {
|
||||
console.warn(`[FileValidator] 未知的文件类型: .${key},已忽略`);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this.allowedConfig = {
|
||||
...KNOWN_SIGNATURES,
|
||||
};
|
||||
}
|
||||
}
|
||||
_isValidUTF8(buffer) {
|
||||
try {
|
||||
const decoder = new TextDecoder('utf-8', {
|
||||
fatal: true,
|
||||
});
|
||||
decoder.decode(buffer);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
_bufferToHex(buffer) {
|
||||
return Array.prototype.map
|
||||
.call(new Uint8Array(buffer), (x) => ('00' + x.toString(16)).slice(-2))
|
||||
.join('')
|
||||
.toUpperCase();
|
||||
}
|
||||
_countCSVRows(buffer) {
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
const text = decoder.decode(buffer);
|
||||
let rowCount = 0;
|
||||
let inQuote = false;
|
||||
let len = text.length;
|
||||
for (let i = 0; i < len; i++) {
|
||||
const char = text[i];
|
||||
if (char === '"') {
|
||||
inQuote = !inQuote;
|
||||
} else if (char === '\n' && !inQuote) {
|
||||
rowCount++;
|
||||
}
|
||||
}
|
||||
if (len > 0 && text[len - 1] !== '\n') {
|
||||
rowCount++;
|
||||
}
|
||||
return rowCount;
|
||||
}
|
||||
_validateTextContent(buffer, extension) {
|
||||
let contentStr = '';
|
||||
try {
|
||||
const decoder = new TextDecoder('utf-8', {
|
||||
fatal: true,
|
||||
});
|
||||
contentStr = decoder.decode(buffer);
|
||||
} catch (e) {
|
||||
console.warn('UTF-8 解码失败', e);
|
||||
return false;
|
||||
}
|
||||
if (contentStr.includes('\0')) {
|
||||
return false;
|
||||
}
|
||||
if (extension === 'json') {
|
||||
try {
|
||||
JSON.parse(contentStr);
|
||||
} catch (e) {
|
||||
console.warn('无效的 JSON 格式');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
validate(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!file || !file.name) return reject('无效的文件对象');
|
||||
if (file.size > this.maxSizeMB * 1024 * 1024) {
|
||||
return reject(`文件大小超出限制 (最大 ${this.maxSizeMB}MB)`);
|
||||
}
|
||||
const fileName = file.name.toLowerCase();
|
||||
const extension = fileName.substring(fileName.lastIndexOf('.') + 1);
|
||||
const expectedMagic = this.allowedConfig[extension];
|
||||
if (!expectedMagic) {
|
||||
return reject(`不支持的文件格式: .${extension}`);
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const buffer = e.target.result;
|
||||
let isSafe = false;
|
||||
if (expectedMagic === 'TYPE_TEXT') {
|
||||
if (this._validateTextContent(buffer, extension)) {
|
||||
isSafe = true;
|
||||
} else {
|
||||
if (extension === 'json') {
|
||||
return reject(`文件异常:不是有效的 JSON 文件`);
|
||||
}
|
||||
return reject(`文件异常:.${extension} 包含非法二进制内容或编码错误`);
|
||||
}
|
||||
if (extension === 'csv' && this.csvMaxRows > 0) {
|
||||
const rows = this._countCSVRows(buffer);
|
||||
if (rows > this.csvMaxRows) {
|
||||
return reject(`CSV 行数超出限制 (当前 ${rows} 行,最大允许 ${this.csvMaxRows} 行)`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const fileHeader = this._bufferToHex(buffer.slice(0, 8));
|
||||
if (fileHeader.startsWith(expectedMagic)) {
|
||||
isSafe = true;
|
||||
} else {
|
||||
return reject(`文件可能已被篡改 (真实类型与 .${extension} 不符)`);
|
||||
}
|
||||
}
|
||||
if (isSafe) resolve(true);
|
||||
};
|
||||
reader.onerror = () => reject('文件读取失败,无法校验');
|
||||
if (expectedMagic === 'TYPE_TEXT' && extension === 'json') {
|
||||
reader.readAsArrayBuffer(file);
|
||||
} else {
|
||||
reader.readAsArrayBuffer(file.slice(0, 2048));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 【demo】
|
||||
// 如果传入了 allowedExtensions,则只使用传入的;否则使用全部 KNOWN_SIGNATURES
|
||||
// const imageValidator = new FileValidator({
|
||||
// maxSizeMB: 5,
|
||||
// allowedExtensions: ['png', 'jpg', 'jpeg'],
|
||||
// });
|
||||
|
||||
// imageValidator
|
||||
// .validate(file)
|
||||
// .then(() => {
|
||||
// statusDiv.textContent = `检测通过: ${file.name}`;
|
||||
// statusDiv.style.color = 'green';
|
||||
// console.log('图片校验通过,开始上传...');
|
||||
// // upload(file)...
|
||||
// })
|
||||
// .catch((err) => {
|
||||
// statusDiv.textContent = `检测失败: ${err}`;
|
||||
// statusDiv.style.color = 'red';
|
||||
// });
|
||||
@@ -18,7 +18,9 @@ const needToEncrypt = [
|
||||
["post", "/app/user/experience/edit"],
|
||||
["post", "/app/user/experience/delete"],
|
||||
["get", "/app/user/experience/getSingle/{value}"],
|
||||
["get", "/app/user/experience/list"]
|
||||
["get", "/app/user/experience/list"],
|
||||
["post", "/app/alipay/scanLogin"],
|
||||
["post", "/app/user/cert"],
|
||||
]
|
||||
|
||||
/**
|
||||
@@ -137,6 +139,7 @@ export function createRequest(url, data = {}, method = 'GET', loading = false, h
|
||||
} = resData.data
|
||||
if (code === 200) {
|
||||
resolve(resData.data)
|
||||
// console.log(resData.data.data,'接口解密')
|
||||
return
|
||||
}
|
||||
if (msg) {
|
||||
|
||||
Reference in New Issue
Block a user