Compare commits

41 Commits

Author SHA1 Message Date
Apcallover
c0c93fffc4 flat: 合并 2025-12-24 18:40:23 +08:00
Apcallover
d5890936c3 flat: 扫码登录 2025-12-24 18:39:44 +08:00
95c22f6d0f feat : 一体机简历上传 2025-12-24 18:18:13 +08:00
0029215bd9 feat : 对接专业搜索联想数据源接口,upload.html新增文件类型限制参数(带默认) 2025-12-24 17:26:59 +08:00
640231a223 Merge branch 'main' of http://124.243.245.42:3000/sdz/qingdao-employment-service 2025-12-24 17:04:08 +08:00
99f8fd7b6a fix 2025-12-24 17:04:07 +08:00
Apcallover
4b39add733 flat: 暂存 2025-12-24 17:00:57 +08:00
8113f98195 1 2025-12-24 16:57:17 +08:00
a28e0ee44c fix 2025-12-24 16:38:44 +08:00
a5ea613a7d feat : 小程序下隐藏applayout头部 2025-12-24 16:33:59 +08:00
Apcallover
049ce7a92d flat: 暂存 2025-12-24 16:33:58 +08:00
ebea520bca style : 适配两个页面 2025-12-24 16:04:26 +08:00
3f0f349adf 回退 2025-12-24 15:59:42 +08:00
Apcallover
57596cbcb9 flat: 一体机登录修改 2025-12-24 15:22:50 +08:00
5461dc276c feat : 搜索新增专业/职位切换, 专业搜索时新增联想功能(接口待调) 2025-12-24 15:03:18 +08:00
212931e969 feat: fileicon, filetext 组件改为全局,新增一体机上传简历(接口待调) 2025-12-24 13:53:19 +08:00
d0ff91a2c4 暂存 2025-12-24 11:48:40 +08:00
872d3febe9 暂存 2025-12-24 11:46:44 +08:00
75c9ea0c3c 暂存 2025-12-24 11:10:54 +08:00
e514536d1b 暂存 2025-12-24 10:53:45 +08:00
Apcallover
ed53ca187f flat: 暂存 2025-12-23 17:46:30 +08:00
42dafbf7ef fix 2025-12-23 17:20:35 +08:00
1aa1cc9d4a fix 2025-12-23 17:13:03 +08:00
f75d607576 feat : 新增 目标岗位确认后,立即执行查询和替换 2025-12-23 17:08:20 +08:00
e05ccf555a 增加填写求职名片下一步限制 2025-12-23 16:44:30 +08:00
7f720eb2ad 添加部分语音提示 2025-12-23 10:01:57 +08:00
8494210a5d feat : 增加部分语音提示 2025-12-23 09:51:14 +08:00
ffcf34fb10 fix : 切换播放,暂停播放bug 2025-12-22 18:03:10 +08:00
8f1dbc28f7 feat:文字转语音改为片段请求 2025-12-22 17:46:40 +08:00
c9420b28e9 fix : 长时间无响应弹窗出现后,10秒微操作,关闭弹窗 2025-12-22 14:52:25 +08:00
73a6692998 feat : useLocationStore 定义经纬度时就给默认,有时还在定位中,地图功能无法使用 2025-12-22 14:40:59 +08:00
5d0b9c5a3a fix 2025-12-22 14:20:19 +08:00
8dea3294a5 feat : 默认经纬度替换,bugfix,添加一体机样式 2025-12-22 14:19:10 +08:00
577ec92ac8 feat : 扫码上传的本地删除改为接口删除 2025-12-22 11:39:50 +08:00
Apcallover
79fb997640 flat: 语音对接2 2025-12-22 09:28:46 +08:00
Apcallover
8cce64b005 flat: 语音对接 2025-12-22 09:28:24 +08:00
Apcallover
1bacbe4936 flat: 添加语音播放提示 2025-12-20 15:10:44 +08:00
Apcallover
02fef1700b flat: 对接一体机语音播放 2025-12-20 14:51:03 +08:00
Apcallover
99cb6b4710 flat: 合并 2025-12-20 14:28:28 +08:00
Apcallover
1c05aa1f5b flat:暂存 2025-12-20 14:27:38 +08:00
009b4840ad fix 宽屏匹配标签动画幅度过小 2025-12-19 17:09:00 +08:00
47 changed files with 3196 additions and 1094 deletions

56
App.vue
View File

@@ -1,30 +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()//循环获取定位
useLocationStore().getLocationLoop(); //循环获取定位
return;
}
if (isY9MachineType()) {
@@ -34,25 +42,26 @@ onLaunch((options) => {
useUserStore().changMiniProgramAppStatus(true);
useUserStore().changMachineEnv(true);
(function loop() {
console.log('📍一体机尝试获取定位')
useLocationStore().getLocation().then(({longitude,latitude})=>{
console.log(`✅一体机获取定位成功:lng:${longitude},lat${latitude}`)
})
.catch(err=>{
console.log('❌一体机获取定位失败,30s后尝试重新获取')
setTimeout(() => {
loop()
}, 3000);
})
})()
uQRListen = new IncreaseRevie();
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()//循环获取定位
useLocationStore().getLocationLoop(); //循环获取定位
useUserStore().changMiniProgramAppStatus(true);
useUserStore().changMachineEnv(false);
useUserStore().initSeesionId(); //更新
@@ -70,7 +79,6 @@ onLaunch((options) => {
onMounted(() => {});
onShow(() => {
console.log('App Show');
});
@@ -88,6 +96,7 @@ function handleInactivity() {
if (useUserStore().hasLogin) {
// 1. 正常弹出确认框
playTextDirectly('长时间无操作,是否继续使用?');
$confirm({
title: '会话即将过期',
content: '长时间无操作,是否继续使用?',
@@ -133,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({

View File

@@ -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,189 @@ 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;
}
/**
* 【新增】生命周期 - 开始
* 统一入口,通常在页面加载 (onLoad/onMounted) 时调用
*/
start(scope = null) {
// 启动前先清理可能存在的旧状态
this.close();
console.log("[FaceLogin] 服务启动...");
// 开始执行初始化逻辑
this.init(scope);
}
/**
* 【新增】生命周期 - 关闭
* 统一出口,通常在页面卸载 (onUnload/onUnmounted) 时调用
*/
close() {
// 1. 清除正在等待执行的重试定时器
if (this._retryTimer) {
clearTimeout(this._retryTimer);
this._retryTimer = null;
console.log("[FaceLogin] 已取消挂起的初始化重试");
}
// 2. 重置初始化状态
this.isInitialized = false;
console.log("[FaceLogin] 服务已关闭");
}
/**
* 1. 初始化刷脸服务
* (已修改:增加了对定时器的管理)
*/
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}`);
// 如果还有重试次数,且服务没有被手动 close 关闭
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);
}
}
/**
* 基础 Bridge 调用封装
*/
_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

View File

@@ -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;
}

View File

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

View File

@@ -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 = () => {

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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({

View File

@@ -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,
// 只使用本地缓寸的数据

View File

@@ -364,7 +364,7 @@ class PiperTTS {
this.isRecording = true;
this.onStart();
const wsUrl = this.baseUrl.replace(/^http/, 'ws') + '/ws/synthesize';
const wsUrl = this.baseUrl.replace(/^http/, 'ws');
this.worker.postMessage({
type: 'connect',
data: {

View File

@@ -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;
}
}

726
hook/useAudioSpeak.js Normal file
View File

@@ -0,0 +1,726 @@
// useAudioSpeak.js
import { ref } from 'vue'
/**
* TTS语音合成Hook
* @param {Object} config - TTS配置
* @param {string} config.apiUrl - 语音合成API地址
* @param {number} config.maxSegmentLength - 最大分段长度
* @returns {Object} TTS相关方法和状态
*/
export const useAudioSpeak = (config = {}) => {
const {
apiUrl = 'http://39.98.44.136:19527/synthesize',
maxSegmentLength = 30
} = 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 audioContext = null
let audioSource = null
let audioQueue = [] // 播放队列 [{blob, segmentIndex, text, isMerged?}, ...]
let isPlayingQueue = false
let isCancelled = false
let segments = []
let allRequestsCompleted = false
let pendingMergeSegments = [] // 存储所有片段的原始数据 [{arrayBuffer, segmentIndex}]
let firstSegmentHeader = null
let lastPlayedIndex = -1
let currentPlayingIndex = -1 // 当前正在播放的片段索引
let isInterrupted = false // 是否被中断
// 按标点分割文本
const splitTextByPunctuation = (text) => {
const segments = []
const punctuation = /[,。!?;!?;\n]/g
let lastIndex = 0
while (true) {
const match = punctuation.exec(text)
if (!match) break
const isChinesePunctuation = /[,。!?;]/.test(match[0])
const endIndex = isChinesePunctuation ? match.index + 1 : match.index
const segment = text.substring(lastIndex, endIndex)
if (segment.trim()) {
segments.push(segment.trim())
}
lastIndex = endIndex
}
const lastSegment = text.substring(lastIndex)
if (lastSegment.trim()) {
segments.push(lastSegment.trim())
}
return segments
}
// 预处理文本
const preprocessText = (text) => {
if (!text || typeof text !== 'string') return []
const cleanText = text.replace(/\s+/g, ' ').trim()
let segments = splitTextByPunctuation(cleanText)
const finalSegments = []
segments.forEach(segment => {
if (segment.length <= maxSegmentLength) {
finalSegments.push(segment)
} else {
for (let i = 0; i < segment.length; i += maxSegmentLength) {
finalSegments.push(segment.substring(i, i + maxSegmentLength))
}
}
})
return finalSegments.filter(seg => seg && seg.trim())
}
// 检测WAV头部大小
const detectWavHeaderSize = (arrayBuffer) => {
try {
const header = new Uint8Array(arrayBuffer.slice(0, 100))
if (header[0] === 0x52 && header[1] === 0x49 && header[2] === 0x46 && header[3] === 0x46) {
for (let i = 36; i < 60; i++) {
if (header[i] === 0x64 && header[i+1] === 0x61 && header[i+2] === 0x74 && header[i+3] === 0x61) {
return i + 8
}
}
}
return 44
} catch (error) {
console.error('检测WAV头部大小失败:', error)
return 44
}
}
// 请求音频片段
const fetchAudioSegment = async (text, index) => {
try {
console.log(`📶正在请求第${index + 1}段音频: "${text}"`)
const response = await fetch(apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
text: text,
speed: 1.0,
volume: 1.0,
pitch: 1.0,
voice_type: 1
})
})
if (!response.ok) {
throw new Error(`HTTP错误! 状态码: ${response.status}`)
}
const audioBlob = await response.blob()
if (!audioBlob || audioBlob.size < 100) {
throw new Error('音频数据太小或无效')
}
console.log(`${index + 1}段音频获取成功,大小: ${audioBlob.size} 字节`)
const arrayBuffer = await audioBlob.arrayBuffer()
// 保存原始数据用于可能的合并
pendingMergeSegments.push({
arrayBuffer: arrayBuffer,
segmentIndex: index
})
// 如果是第一个片段保存其WAV头部用于合并
if (index === 0) {
const headerSize = detectWavHeaderSize(arrayBuffer)
firstSegmentHeader = new Uint8Array(arrayBuffer.slice(0, headerSize))
}
return {
blob: audioBlob,
segmentIndex: index,
text: text,
arrayBuffer: arrayBuffer
}
} catch (error) {
console.error(`获取第${index + 1}段音频失败:`, error)
throw error
}
}
// 初始化音频上下文
const initAudioContext = () => {
if (!audioContext || audioContext.state === 'closed') {
audioContext = new (window.AudioContext || window.webkitAudioContext)()
console.log('音频上下文已初始化')
}
return audioContext
}
// 解码并播放音频Blob
const decodeAndPlayBlob = async (audioBlob, segmentIndex) => {
return new Promise((resolve, reject) => {
if (isCancelled || !audioContext) {
console.log('播放已取消或音频上下文不存在,跳过播放')
resolve()
return
}
// 设置当前正在播放的片段索引
currentPlayingIndex = segmentIndex
console.log(`设置当前播放索引为: ${currentPlayingIndex}`)
const fileReader = new FileReader()
fileReader.onload = async (e) => {
try {
const arrayBuffer = e.target.result
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer)
// 如果在此期间被取消,直接返回
if (isCancelled) {
console.log('播放过程中被取消')
resolve()
return
}
audioSource = audioContext.createBufferSource()
audioSource.buffer = audioBuffer
audioSource.connect(audioContext.destination)
audioSource.onended = () => {
console.log(`${segmentIndex + 1}个片段播放完成`)
audioSource = null
lastPlayedIndex = segmentIndex
currentPlayingIndex = -1
resolve()
}
audioSource.onerror = (error) => {
console.error('音频播放错误:', error)
currentPlayingIndex = -1
reject(error)
}
console.log(`▶️开始播放第${segmentIndex + 1}个片段`)
// 如果音频上下文被暂停,先恢复
if (audioContext.state === 'suspended') {
await audioContext.resume()
}
audioSource.start(0)
} catch (error) {
console.error('解码或播放音频失败:', error)
currentPlayingIndex = -1
reject(error)
}
}
fileReader.onerror = (error) => {
console.error('读取音频文件失败:', error)
currentPlayingIndex = -1
reject(error)
}
fileReader.readAsArrayBuffer(audioBlob)
})
}
// 合并剩余音频片段
const mergeRemainingSegments = (segmentsToMerge) => {
if (segmentsToMerge.length === 0 || !firstSegmentHeader) {
console.log('没有待合并的片段或缺少头部信息')
return null
}
try {
// 按segmentIndex排序
segmentsToMerge.sort((a, b) => a.segmentIndex - b.segmentIndex)
console.log(`开始合并${segmentsToMerge.length}个剩余片段`)
// 计算总数据大小
let totalAudioDataSize = 0
for (const segment of segmentsToMerge) {
const headerSize = detectWavHeaderSize(segment.arrayBuffer)
totalAudioDataSize += segment.arrayBuffer.byteLength - headerSize
}
console.log(`剩余音频数据总大小: ${totalAudioDataSize}字节`)
// 创建合并后的数组
const headerSize = firstSegmentHeader.length
const totalSize = headerSize + totalAudioDataSize
const mergedArray = new Uint8Array(totalSize)
// 设置头部
mergedArray.set(firstSegmentHeader, 0)
// 更新头部中的data大小
const view = new DataView(mergedArray.buffer)
view.setUint32(40, totalAudioDataSize, true)
view.setUint32(4, 36 + totalAudioDataSize, true)
// 合并所有音频数据
let offset = headerSize
for (const segment of segmentsToMerge) {
const segmentHeaderSize = detectWavHeaderSize(segment.arrayBuffer)
const segmentData = new Uint8Array(segment.arrayBuffer.slice(segmentHeaderSize))
mergedArray.set(segmentData, offset)
offset += segmentData.length
}
console.log(`音频合并完成,总大小: ${mergedArray.length}字节`)
// 创建Blob
return new Blob([mergedArray], { type: 'audio/wav' })
} catch (error) {
console.error('合并音频片段失败:', error)
return null
}
}
// 从队列播放音频
const playFromQueue = async () => {
if (isPlayingQueue || audioQueue.length === 0) {
return
}
isPlayingQueue = true
try {
while (audioQueue.length > 0 && !isCancelled && !isInterrupted) {
const audioItem = audioQueue[0]
currentSegmentIndex.value = audioItem.segmentIndex
// 播放第一个音频
console.log(`准备播放第${audioItem.segmentIndex + 1}个片段: "${audioItem.text}"`)
await decodeAndPlayBlob(audioItem.blob, audioItem.segmentIndex)
if (isCancelled || isInterrupted) {
console.log('播放被中断,退出播放队列')
break
}
// 播放完成后移除
audioQueue.shift()
console.log(`片段${audioItem.segmentIndex + 1}播放完成,队列剩余: ${audioQueue.length}`)
// 更新进度
progress.value = Math.floor(((audioItem.segmentIndex + 1) / totalSegments.value) * 100)
// 短暂延迟
await new Promise(resolve => setTimeout(resolve, 50))
}
// 检查是否所有片段都播放完成
if (audioQueue.length === 0 && lastPlayedIndex === totalSegments.value - 1) {
console.log('所有音频片段播放完成')
progress.value = 100
}
} catch (error) {
console.error('播放队列出错:', error)
} finally {
isPlayingQueue = false
}
}
// 向队列添加音频
const addToQueue = (audioItem) => {
// 如果被取消或中断,不添加到队列
if (isCancelled || isInterrupted) {
console.log('播放已中断,不添加到队列')
return
}
// 按segmentIndex插入到正确位置
let insertIndex = 0
for (let i = audioQueue.length - 1; i >= 0; i--) {
if (audioQueue[i].segmentIndex < audioItem.segmentIndex) {
insertIndex = i + 1
break
}
}
audioQueue.splice(insertIndex, 0, audioItem)
console.log(`音频片段${audioItem.segmentIndex + 1}已添加到队列,队列长度: ${audioQueue.length}`)
// 如果队列中有音频且当前没有在播放,开始播放
if (!isPlayingQueue && audioQueue.length === 1) {
playFromQueue()
}
}
// 尝试合并剩余片段
const tryMergeRemainingSegments = () => {
if (!allRequestsCompleted) {
console.log('合并检查: 请求未完成,跳过')
return
}
// 获取真正未播放的片段(不包括已经播放的和正在播放的)
const trulyUnplayedSegments = audioQueue.filter(item => {
// 排除已经播放完成的
if (item.segmentIndex <= lastPlayedIndex) {
return false
}
// 排除当前正在播放的
if (currentPlayingIndex !== -1 && item.segmentIndex === currentPlayingIndex) {
return false
}
return true
})
const shouldMerge = trulyUnplayedSegments.length > 1
console.log(`🔀合并检查: 已播放到=${lastPlayedIndex}, 正在播放=${currentPlayingIndex}, 真正未播放片段=${trulyUnplayedSegments.length}, 应该合并=${shouldMerge}`)
if (!shouldMerge) {
console.log('不符合合并条件(真正未播放片段数量 <= 1')
return
}
console.log('✔️符合合并条件,开始合并剩余片段')
// 获取这些片段的原始数据
const segmentsToMergeData = []
for (const item of trulyUnplayedSegments) {
const segmentData = pendingMergeSegments.find(s => s.segmentIndex === item.segmentIndex)
if (segmentData) {
segmentsToMergeData.push(segmentData)
}
}
if (segmentsToMergeData.length === 0) {
console.log('没有找到待合并的原始数据')
return
}
// 合并这些片段
const mergedBlob = mergeRemainingSegments(segmentsToMergeData)
if (mergedBlob) {
// 从audioQueue中移除这些将被合并的片段
const segmentIndicesToRemove = trulyUnplayedSegments.map(s => s.segmentIndex)
for (let i = audioQueue.length - 1; i >= 0; i--) {
if (segmentIndicesToRemove.includes(audioQueue[i].segmentIndex)) {
audioQueue.splice(i, 1)
}
}
// 将合并后的音频添加到队列的合适位置
const firstSegmentIndex = Math.min(...segmentIndicesToRemove)
const mergedText = trulyUnplayedSegments.map(s => s.text).join(' ')
const mergedAudioItem = {
blob: mergedBlob,
segmentIndex: firstSegmentIndex,
text: mergedText,
isMerged: true
}
// 插入到正确位置按segmentIndex
let insertIndex = 0
for (let i = audioQueue.length - 1; i >= 0; i--) {
if (audioQueue[i].segmentIndex < firstSegmentIndex) {
insertIndex = i + 1
break
}
}
audioQueue.splice(insertIndex, 0, mergedAudioItem)
console.log(`合并后的音频已添加到队列位置${insertIndex},包含${trulyUnplayedSegments.length}个原始片段,队列长度: ${audioQueue.length}`)
} else {
console.log('合并失败,保持原始片段')
}
}
// 文本提取工具函数
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');
}
// 清理资源
const cleanup = () => {
console.log('开始清理资源')
isCancelled = true
isInterrupted = true
if (audioSource) {
try {
audioSource.stop()
console.log('音频源已停止')
} catch (e) {
console.warn('停止音频源失败:', e)
}
audioSource = null
}
if (audioContext && audioContext.state !== 'closed') {
audioContext.close()
console.log('音频上下文已关闭')
}
audioContext = null
audioQueue = []
segments = []
isPlayingQueue = false
allRequestsCompleted = false
pendingMergeSegments = []
firstSegmentHeader = null
lastPlayedIndex = -1
currentPlayingIndex = -1
isSpeaking.value = false
isPaused.value = false
isLoading.value = false
progress.value = 0
console.log('资源清理完成')
}
// 停止播放
const stopAudio = () => {
console.log('停止音频播放')
isCancelled = true
isInterrupted = true
if (audioSource) {
try {
audioSource.stop()
console.log('音频源已停止')
} catch (e) {
console.warn('停止音频源失败:', e)
}
audioSource = null
}
audioQueue = []
isPlayingQueue = false
currentPlayingIndex = -1
isSpeaking.value = false
isPaused.value = false
isLoading.value = false
// 恢复中断标志,为下一次播放准备
setTimeout(() => {
isCancelled = false
isInterrupted = false
}, 100)
}
// 主speak方法
const speak = async (text) => {
console.log('开始新的语音播报')
// 先停止当前播放
if (isSpeaking.value || audioQueue.length > 0) {
console.log('检测到正在播放,先停止')
stopAudio()
// 等待一小段时间确保资源清理完成
await new Promise(resolve => setTimeout(resolve, 200))
}
text = extractSpeechText(text)
console.log('开始语音播报:', text)
// 重置状态
isCancelled = false
isInterrupted = false
currentText.value = text
isLoading.value = true
isSpeaking.value = true
progress.value = 0
audioQueue = []
allRequestsCompleted = false
pendingMergeSegments = []
firstSegmentHeader = null
lastPlayedIndex = -1
currentPlayingIndex = -1
// 预处理文本
segments = preprocessText(text)
console.log('文本分段结果:', segments)
if (segments.length === 0) {
console.warn('没有有效的文本可以播报')
isLoading.value = false
isSpeaking.value = false
return
}
totalSegments.value = segments.length
try {
// 初始化音频上下文
initAudioContext()
// 1. 串行请求所有音频片段
for (let i = 0; i < segments.length; i++) {
if (isCancelled || isInterrupted) {
console.log('播放被取消或中断,停止请求')
break
}
console.log(`串行请求第${i + 1}/${segments.length}个片段`)
// 更新进度(请求进度)
progress.value = Math.floor(((i + 1) / segments.length) * 50)
// 请求音频片段
const audioItem = await fetchAudioSegment(segments[i], i)
if (isCancelled || isInterrupted) {
console.log('播放被取消或中断,停止添加队列')
break
}
// 添加到播放队列
addToQueue({
blob: audioItem.blob,
segmentIndex: i,
text: segments[i]
})
// 如果是第一个片段取消loading状态
if (i === 0 && isLoading.value) {
console.log('第一个音频片段已就绪,开始播放')
isLoading.value = false
}
}
if (isCancelled || isInterrupted) {
console.log('播放被取消或中断,退出播放')
return
}
// 2. 所有请求完成
console.log('所有音频片段请求完成')
allRequestsCompleted = true
// 3. 立即检查是否可以合并剩余片段
tryMergeRemainingSegments()
// 4. 等待所有音频播放完成
while (audioQueue.length > 0 && !isCancelled && !isInterrupted) {
await new Promise(resolve => setTimeout(resolve, 100))
}
console.log('音频播放完成')
} catch (error) {
console.error('语音播报失败:', error)
} finally {
// 最终清理
if (isCancelled || isInterrupted) {
console.log('播放被取消或中断,进行清理')
cleanup()
} else {
console.log('播放正常完成')
isSpeaking.value = false
isPaused.value = false
isLoading.value = false
progress.value = 100
}
}
}
// 暂停播放
const pause = () => {
if (audioContext && isSpeaking.value && !isPaused.value) {
audioContext.suspend().then(() => {
isPaused.value = true
console.log('播放已暂停')
})
}
}
// 恢复播放
const resume = () => {
if (audioContext && isSpeaking.value && isPaused.value) {
audioContext.resume().then(() => {
isPaused.value = false
console.log('播放已恢复')
})
}
}
// 取消音频
const cancelAudio = () => {
console.log('取消音频播放')
stopAudio()
cleanup()
}
return {
// 状态
isSpeaking,
isPaused,
isLoading,
currentText,
currentSegmentIndex,
totalSegments,
progress,
// 方法
speak,
pause,
resume,
cancelAudio,
cleanup
}
}

View File

@@ -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)
// }
})
// 列数变化时执行回调

View File

@@ -4,10 +4,9 @@ import {
} from 'vue'
import {
$api
} from '../common/globalFunction'; // 你的请求封装
} from '../common/globalFunction';
import config from '@/config'
// 开源
export function useAudioRecorder() {
// --- 状态定义 ---
const isRecording = ref(false)

21
hook/useTTS.js Normal file
View 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()
}
}

View File

@@ -0,0 +1,274 @@
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 || typeof markdown !== 'string') return ''; // 增加类型安全检查
if (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');
}
// 使用方法 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状态')

View File

@@ -18,12 +18,28 @@ export function useTTSPlayer() {
// 单例 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 = () => {
const getPiperInstance = async () => {
if (!piper) {
let baseUrl = config.speechSynthesis2 || ''
let baseUrl = await getWsUrl()
baseUrl = baseUrl.replace(/\/$/, '')
piper = new PiperTTS({
@@ -61,7 +77,7 @@ export function useTTSPlayer() {
const processedText = extractSpeechText(text)
if (!processedText) return
const instance = getPiperInstance()
const instance = await getPiperInstance()
// 重置状态
isLoading.value = true

View File

@@ -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) {

View File

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

View File

@@ -1,5 +1,5 @@
<template>
<AppLayout title="" :use-scroll-view="false">
<AppLayout title="" :use-scroll-view="false" showHeader>
<template #headerleft v-if="isMiniProgram">
<view class="btnback">
<image src="@/static/icon/back.png" @click="navBack"></image>
@@ -80,6 +80,7 @@ import useUserStore from '@/stores/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({
@@ -119,11 +120,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 +136,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 +265,7 @@ image {
display: flex;
flex-direction: column
.content-top{
padding: 28rpx
padding-top: 50rpx
padding:28rpx;
display: flex
flex-direction: row
flex-wrap: nowrap

View File

@@ -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();
});
}

View File

@@ -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');

View File

@@ -112,6 +112,7 @@ import { storeToRefs } from 'pinia';
const { longitudeVal, latitudeVal } = storeToRefs(useLocationStore());
import useUserStore from '@/stores/useUserStore';
const { isMiniProgram } = storeToRefs(useUserStore());
import { playTextDirectly } from '@/hook/useTTSPlayer-all-in-one';
const isExpanded = ref(false);
const fairInfo = ref({});
@@ -201,19 +202,20 @@ function expand() {
isExpanded.value = !isExpanded.value;
}
// 取消/收藏岗位
// 取消/预约招聘会
function applyExhibitors() {
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 +295,7 @@ image {
display: flex;
flex-direction: column
.content-top{
padding: 28rpx
padding-top: 50rpx
padding:28rpx;
display: flex
flex-direction: row
flex-wrap: nowrap

View File

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

View File

@@ -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,11 +138,14 @@ 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 { userInfo, isMiniProgram,isMachineEnv } = storeToRefs(useUserStore());
const { getUserResume } = 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();
@@ -152,6 +156,10 @@ function closeNotice() {
}
function chooseResume() {
if(isMachineEnv.value){
qrcodeRef.value?.open()
return
}
uni.chooseImage({
sizeType: ['original', 'compressed'],
sourceType: ['album', 'camera'],
@@ -162,9 +170,11 @@ function chooseResume() {
res = JSON.parse(res);
getUserResume();
$api.msg('上传成功');
playTextDirectly('上传成功')
})
.catch((err) => {
$api.msg('上传失败');
playTextDirectly('上传失败')
});
},
fail: (error) => {},
@@ -209,6 +219,22 @@ function uploadResume(tempFilePath, loading) {
});
});
}
const handleFileSend = (rows)=>{
const file = {
url: rows[0].fileUrl,
type: rows[0].fileSuffix,
name: rows[0].originalName,
}
$api.createRequest(`/app/oss/uploadToObs?userId=${userInfo.value.userId}&filePath=${encodeURI(file.url)}`, { }, 'POST',true,).then((res) => {
getUserResume();
$api.msg('上传成功');
playTextDirectly('上传失败')
}).catch(()=>{
$api.msg('上传失败');
playTextDirectly('上传失败')
})
}
</script>
<style lang="stylus" scoped>

View File

@@ -1,5 +1,5 @@
<template>
<AppLayout title="" backGorundColor="#F4F4F4">
<AppLayout title="" backGorundColor="#F4F4F4" showHeader>
<template #headerleft v-if="isMiniProgram">
<view class="btnback">
<image src="@/static/icon/back.png" @click="navBack"></image>
@@ -185,6 +185,7 @@ 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 { $api, navTo, getLenPx, parseQueryParams, navBack, isEmptyObject } = inject('globalFunction');
@@ -331,10 +332,12 @@ function jobApply() {
};
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 +351,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);
});
@@ -364,11 +368,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 +384,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('收藏成功');
});
}
}

View File

@@ -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"
@@ -267,13 +273,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/useRealtimeRecorder.js';
import { useTTSPlayer } from '@/hook/useTTSPlayer.js';
import { useAudioSpeak } from '@/hook/useAudioSpeak.js';
// 全局
const { $api, navTo, throttle } = inject('globalFunction');
const emit = defineEmits(['onConfirm']);
@@ -296,7 +299,7 @@ const {
lastFinalText,
} = useAudioRecorder();
// 语音合成
const { speak, pause, resume, isSpeaking, isPaused, cancelAudio } = useTTSPlayer(config.speechSynthesis);
const { speak, pause, resume, isSpeaking, isPaused, isLoading, cancelAudio,cleanup } = useAudioSpeak();
// state
const queries = ref([]);
@@ -348,8 +351,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 {
@@ -723,8 +732,8 @@ function colseFeeBack() {
}
function readMarkdown(value, index) {
speechIndex.value = index;
if (speechIndex.value !== index) {
speechIndex.value = index;
speak(value);
return;
}

View File

@@ -40,8 +40,6 @@
<script setup>
import { ref, inject, onUnmounted, watch, computed } from 'vue';
import FileIcon from './fileIcon.vue';
import FileText from './fileText.vue';
import uQRCode from '@/static/js/qrcode';
import config from '@/config';
import { onShow, onHide } from '@dcloudio/uni-app';
@@ -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&fileCount=${props.leaveFileCount}`;
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();
}

View File

@@ -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,
};

View File

@@ -534,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');
}
}
}

View File

@@ -18,7 +18,7 @@
v-for="(_, index) in 2"
:key="index"
>
<component
<component
:is="components[index]"
@onShowTabbar="changeShowTabbar"
:ref="(el) => handelComponentsRef(el, index)"

View File

@@ -1,6 +1,6 @@
<template>
<AppLayout title="就业服务程序">
<view v-if="isMachineEnv" class="alipay-login-container">
<view v-if="isMachineEnv && !hasLogin" class="alipay-login-container">
<!-- 切换 -->
<view class="login-method-switch">
<view
@@ -106,7 +106,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 +193,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,17 +235,33 @@ 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'
onLoad((parmas) => {
getTreeselect();
if (!isMachineEnv.value) $api.msg('请完善微简历');
if (!isMachineEnv.value) {
$api.msg('请完善微简历');
}
});
onMounted(() => {
if (isMachineEnv) {
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(() => {
@@ -286,12 +306,45 @@ const cancelLogin = () => {
// 切换登录方式
const switchLoginMethod = (method) => {
if (!isMachineEnv) {
return;
}
if (loginMethod.value !== method) {
loginMethod.value = method;
switch (method) {
case 'qrcode':
faceService.close();
qrHandler.start();
playTextDirectly('扫码登录');
break;
case 'face':
qrHandler.close();
handleFaceLogin();
playTextDirectly('扫脸登录');
break;
}
resetCountdown();
}
};
async function handleFaceLogin() {
try {
const authCode = await faceService.startFaceLogin();
console.log('拿到 AuthCode:', authCode);
// 调用后端登录接口...
} catch (err) {
this.$api.msg(err.message);
}
}
onUnmounted(() => {
qrHandler.close();
});
onHide(() => {
qrHandler.close();
});
// 开始动画
const startScanAnimation = () => {
clearInterval(scanInterval);
@@ -331,8 +384,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 +439,7 @@ function changeJobs() {
}
function nextStep() {
if (!state.experienceText) return $api.msg('请选择工作经验');
tabCurrent.value += 1;
}
@@ -453,6 +508,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 +986,7 @@ function complete() {
margin-bottom: 52rpx
.sex-titile
line-height: 80rpx;
color: #6A6A6A;
.sext-ri
display: flex
align-items: center;

View File

@@ -1,5 +1,6 @@
<template>
<view class="container">
<view v-show="searchFocus" class="search-mask" ></view>
<view>
<view class="top">
<image
@@ -9,22 +10,46 @@
@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
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 +111,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 +123,36 @@ 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 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 +166,7 @@ const isLoaded = ref(false);
const waterfallsFlowRef = ref(null);
const loadmoreRef = ref(null);
const currentTab = ref(0);
// 响应式搜索条件(可以被修改)
const searchParams = ref({});
const pageSize = ref(10);
@@ -163,8 +215,63 @@ onLoad((options) => {
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) {
@@ -179,27 +286,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) {
@@ -250,12 +414,24 @@ 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;
@@ -295,7 +471,6 @@ function dataToImg(data) {
.Detailscroll-view{
flex: 1
overflow: hidden
}
.container{
display: flex
@@ -363,8 +538,6 @@ function dataToImg(data) {
justify-content: space-between
background-color: #fff;
padding: 20rpx 20rpx;
position: sticky;
top: 0
.btnback{
width: 60rpx;
height: 60rpx;
@@ -372,27 +545,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 {
@@ -469,4 +705,13 @@ function dataToImg(data) {
font-size: 24rpx;
color: #6C7282;
margin-top: 6rpx;
</style>
.search-mask{
position fixed;
width 100vw;
height:100vh;
top:0;
left:0
z-index:9;
background: #33333355;
}
</style>

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

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

View File

@@ -10,18 +10,21 @@ import {
import config from '../config';
const defalutLongLat = {
longitude: 120.382665,
latitude: 36.066938,
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) {
@@ -34,7 +37,7 @@ const useLocationStore = defineStore("location", () => {
fail: function(data) {
longitudeVal.value = defalutLongLat.longitude
latitudeVal.value = defalutLongLat.latitude
resole(defalutLongLat)
reject()
msg('用户位置获取失败')
console.log('失败3', data)
}
@@ -53,7 +56,7 @@ const useLocationStore = defineStore("location", () => {
fail: function(data) {
longitudeVal.value = defalutLongLat.longitude
latitudeVal.value = defalutLongLat.latitude
resole(defalutLongLat)
reject()
msg('用户位置获取失败')
console.log('失败2', data)
}
@@ -62,7 +65,7 @@ const useLocationStore = defineStore("location", () => {
} catch (e) {
longitudeVal.value = defalutLongLat.longitude
latitudeVal.value = defalutLongLat.latitude
resole(defalutLongLat)
reject()
msg('测试环境,使用模拟定位')
console.log('失败1', e)
}

View File

@@ -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

View File

@@ -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",