Merge remote-tracking branch 'origin/main'

This commit is contained in:
2026-02-02 13:22:25 +08:00
24 changed files with 2109 additions and 795 deletions

View File

@@ -59,7 +59,7 @@ const generateTabbarList = () => {
}, },
{ {
id: 2, id: 2,
text: 'AI+', text: '智能客服',
path: '/pages/chat/chat', path: '/pages/chat/chat',
iconPath: '/static/tabbar/logo3.png', iconPath: '/static/tabbar/logo3.png',
selectedIconPath: '/static/tabbar/logo3.png', selectedIconPath: '/static/tabbar/logo3.png',
@@ -175,7 +175,8 @@ const switchTab = (item, index) => {
const loginRequiredPages = [ const loginRequiredPages = [
'/packageA/pages/job/publishJob', '/packageA/pages/job/publishJob',
'/pages/mine/mine', '/pages/mine/mine',
'/pages/mine/company-mine' '/pages/mine/company-mine',
'/pages/msglog/msglog'
]; ];
if (loginRequiredPages.includes(item.path)) { if (loginRequiredPages.includes(item.path)) {
@@ -279,7 +280,7 @@ onMounted(() => {
bottom: 0; bottom: 0;
left: 0; left: 0;
right: 0; right: 0;
height: 88rpx; height: 100rpx;
background-color: #ffffff; background-color: #ffffff;
border-top: 1rpx solid #e5e5e5; border-top: 1rpx solid #e5e5e5;
display: flex; display: flex;
@@ -306,8 +307,8 @@ onMounted(() => {
} }
.tabbar-icon { .tabbar-icon {
width: 44rpx; width: 50rpx;
height: 44rpx; height: 50rpx;
margin-bottom: 4rpx; margin-bottom: 4rpx;
position: relative; position: relative;
} }
@@ -318,7 +319,7 @@ onMounted(() => {
} }
.tabbar-text { .tabbar-text {
font-size: 20rpx; font-size: 22rpx;
line-height: 1; line-height: 1;
transition: color 0.3s ease; transition: color 0.3s ease;
} }
@@ -332,14 +333,14 @@ onMounted(() => {
position: absolute; position: absolute;
top: 4rpx; top: 4rpx;
right: 20rpx; right: 20rpx;
min-width: 30rpx; min-width: 32rpx;
height: 30rpx; height: 32rpx;
background-color: #ff4444; background-color: #ff4444;
color: #fff; color: #fff;
font-size: 18rpx; font-size: 19rpx;
border-radius: 15rpx; border-radius: 16rpx;
text-align: center; text-align: center;
line-height: 30rpx; line-height: 32rpx;
padding: 0 10rpx; padding: 0 10rpx;
transform: scale(0.8); transform: scale(0.8);
} }
@@ -347,8 +348,8 @@ onMounted(() => {
/* 中间按钮特殊样式 */ /* 中间按钮特殊样式 */
.tabbar-item:has(.center-item) { .tabbar-item:has(.center-item) {
.tabbar-icon { .tabbar-icon {
width: 60rpx; width: 68rpx;
height: 60rpx; height: 68rpx;
margin-bottom: 0; margin-bottom: 0;
} }
} }

View File

@@ -2,17 +2,44 @@
<view class="markdown-body"> <view class="markdown-body">
<!-- 根据不同平台使用不同的渲染方式 --> <!-- 根据不同平台使用不同的渲染方式 -->
<!-- #ifdef MP-WEIXIN --> <!-- #ifdef MP-WEIXIN -->
<rich-text class="markdownRich" id="markdown-content" :nodes="renderedHtml" @itemclick="handleItemClick" /> <!-- 微信小程序端使用v-for遍历岗位卡片列表每个卡片单独渲染支持点击事件 -->
<scroll-view class="markdownRich" id="markdown-content">
<!-- 渲染普通markdown内容 -->
<rich-text :nodes="renderedHtml" />
<!-- 单独渲染岗位卡片支持点击事件 -->
<view v-if="localJobCardsList.length > 0" class="job-cards-container">
<view
v-for="(card, index) in localJobCardsList"
:key="index"
class="custom-card"
@tap="navigateToJobDetail(card.jobId)"
>
<view class="card-title">
<text class="title-text">{{ card.jobTitle }}</text>
<view class="card-salary">{{ card.salary }}</view>
</view>
<view class="card-company">{{ card.location }}·{{ card.companyName }}</view>
<view class="card-info">
<view class="info-item">
<view class="card-tag">{{ card.education }}</view>
<view class="card-tag">{{ card.experience }}</view>
</view>
<view class="info-item">查看详情<view class="position-nav"></view></view>
</view>
</view>
</view>
</scroll-view>
<!-- #endif --> <!-- #endif -->
<!-- #ifndef MP-WEIXIN --> <!-- #ifndef MP-WEIXIN -->
<view class="markdown-body" v-html="renderedHtml"></view> <view class="markdown-body" v-html="renderedHtml" @click="handleH5Click"></view>
<!-- #endif --> <!-- #endif -->
</view> </view>
</template> </template>
<script setup> <script setup>
import { computed, onMounted, inject } from 'vue'; import { computed, onMounted, inject, ref, watch, nextTick } from 'vue';
import { parseMarkdown, codeDataList } from '@/utils/markdownParser'; import { parseMarkdown, codeDataList, jobCardsList } from '@/utils/markdownParser';
const { navTo } = inject('globalFunction'); const { navTo } = inject('globalFunction');
const props = defineProps({ const props = defineProps({
content: { content: {
@@ -26,30 +53,191 @@ const props = defineProps({
}); });
const renderedHtml = computed(() => parseMarkdown(props.content)); const renderedHtml = computed(() => parseMarkdown(props.content));
const markdownContainer = ref(null);
// 响应式岗位卡片列表,用于微信小程序端单独渲染
const localJobCardsList = ref([]);
const handleItemClick = (e) => { // 监听content的变化当内容更新时解析岗位卡片
let { attrs } = e.detail.node; watch(() => props.content, (newContent) => {
console.log(attrs); if (newContent) {
let { 'data-copy-index': codeDataIndex, 'data-job-id': jobId, class: className } = attrs; // 直接调用parseMarkdown来触发jobCardsList的更新
switch (className) { parseMarkdown(newContent);
case 'custom-card': // 将解析器生成的岗位卡片列表赋值给响应式数据
return navTo('/packageA/pages/post/post?jobId=' + jobId); localJobCardsList.value = [...jobCardsList];
case 'custom-more': console.log('Content changed, jobCardsList updated:', localJobCardsList.value);
return navTo('/packageA/pages/moreJobs/moreJobs?jobId=' + jobId); }
case 'copy-btn': }, { immediate: true });
uni.setClipboardData({
data: codeDataList[codeDataIndex], // 同时监听renderedHtml的变化作为备份
showToast: false, watch(() => renderedHtml.value, (newVal) => {
success() { console.log('renderedHtml changed, jobCardsList from parser:', jobCardsList);
uni.showToast({ // 将解析器生成的岗位卡片列表赋值给响应式数据
title: '复制成功', localJobCardsList.value = [...jobCardsList];
icon: 'none', });
});
}, // 微信小程序端导航到岗位详情页面
}); const navigateToJobDetail = (jobId) => {
break; console.log('navigateToJobDetail called with jobId:', jobId);
if (jobId && jobId !== 'undefined' && jobId !== 'null') {
// 跳转到岗位详情页面
uni.navigateTo({
url: `/packageA/pages/post/post?jobId=${jobId}`,
success: (res) => {
console.log('navigateTo success:', res);
},
fail: (err) => {
console.error('navigateTo failed:', err);
// 如果navigateTo失败尝试redirectTo
uni.redirectTo({
url: `/packageA/pages/post/post?jobId=${jobId}`,
success: (res2) => {
console.log('redirectTo success:', res2);
},
fail: (err2) => {
console.error('redirectTo also failed:', err2);
// 如果还是失败,显示错误提示
uni.showToast({
title: '跳转失败,请稍后重试',
icon: 'none'
});
}
});
}
});
} else {
console.error('Invalid jobId:', jobId);
uni.showToast({
title: '岗位信息不完整',
icon: 'none'
});
} }
}; };
// 微信小程序端点击事件处理已移除改用v-for遍历岗位卡片列表每个卡片单独渲染支持点击事件
// 微信小程序端和H5端的jobId取值方式一致都是从JSON数据的appJobUrl字段提取
// 不同的是微信小程序端现在使用v-for遍历渲染而H5端使用v-html渲染
// H5平台点击事件处理
// 微信小程序端和H5端的jobId取值方式一致都是从JSON数据的appJobUrl字段提取
// 不同的是H5端可以直接从DOM属性获取而微信小程序端使用v-for遍历渲染
const handleH5Click = (e) => {
console.log('H5 click event triggered:', e.target);
let target = e.target;
// 查找最接近的带有class的元素
while (target && target.tagName !== 'BODY') {
console.log('Checking target:', target, 'className:', target.className, 'tagName:', target.tagName);
// 直接使用closest方法查找不依赖className
const cardElement = target.closest('.custom-card');
const moreElement = target.closest('.custom-more');
console.log('Found elements:', { cardElement, moreElement });
if (cardElement) {
// 尝试多种方式获取jobId
let jobId = cardElement.getAttribute('data-job-id');
console.log('Found custom-card, data-job-id attribute:', jobId);
// 如果data-job-id为空尝试从onclick事件中提取jobId
if (!jobId) {
const onclick = cardElement.getAttribute('onclick');
if (onclick) {
const match = onclick.match(/jobId=(\w+)/);
if (match && match[1]) {
jobId = match[1];
console.log('Extracted jobId from onclick:', jobId);
}
}
}
if (jobId) {
console.log('Final jobId for navigation:', jobId);
try {
// 直接使用uni.navigateTo避免navTo函数的潜在问题
uni.navigateTo({
url: `/packageA/pages/post/post?jobId=${jobId}`,
success: (res) => {
console.log('navigateTo success:', res);
},
fail: (err) => {
console.error('navigateTo failed:', err);
// 如果navigateTo失败尝试redirectTo
uni.redirectTo({
url: `/packageA/pages/post/post?jobId=${jobId}`,
success: (res2) => {
console.log('redirectTo success:', res2);
},
fail: (err2) => {
console.error('redirectTo also failed:', err2);
}
});
}
});
} catch (error) {
console.error('Navigation error:', error);
}
return;
} else {
console.error('No jobId found for custom-card');
}
} else if (moreElement) {
// 尝试多种方式获取jobId
let jobId = moreElement.getAttribute('data-job-id');
console.log('Found custom-more, data-job-id attribute:', jobId);
// 如果data-job-id为空尝试从onclick事件中提取jobId
if (!jobId) {
const onclick = moreElement.getAttribute('onclick');
if (onclick) {
const match = onclick.match(/jobId=(\w+)/);
if (match && match[1]) {
jobId = match[1];
console.log('Extracted jobId from onclick:', jobId);
}
}
}
if (jobId) {
console.log('Final jobId for more jobs:', jobId);
try {
// 直接使用uni.navigateTo避免navTo函数的潜在问题
uni.navigateTo({
url: `/packageA/pages/moreJobs/moreJobs?jobId=${jobId}`,
success: (res) => {
console.log('navigateTo success:', res);
},
fail: (err) => {
console.error('navigateTo failed:', err);
// 如果navigateTo失败尝试redirectTo
uni.redirectTo({
url: `/packageA/pages/moreJobs/moreJobs?jobId=${jobId}`,
success: (res2) => {
console.log('redirectTo success:', res2);
},
fail: (err2) => {
console.error('redirectTo also failed:', err2);
}
});
}
});
} catch (error) {
console.error('Navigation error:', error);
}
return;
} else {
console.error('No jobId found for custom-more');
}
}
target = target.parentElement;
}
};
// 移除旧的事件监听逻辑,改用全局点击处理
onMounted(() => {
console.log('onMounted called');
});
</script> </script>
<style lang="scss"> <style lang="scss">
@@ -322,107 +510,475 @@ ol {
padding: 0 padding: 0
/* #endif */ /* #endif */
/* H5端和小程序端样式优化 */
.custom-card .custom-card
background: #FFFFFF background: #FFFFFF !important
box-shadow: 0rpx 0rpx 8rpx 0rpx rgba(0,0,0,0.04) box-shadow: 0rpx 0rpx 8rpx 0rpx rgba(0,0,0,0.04) !important
border-radius: 20rpx border-radius: 20rpx !important
padding: 28rpx 24rpx padding: 28rpx 24rpx !important
font-weight: 400 font-weight: 400 !important
font-size: 28rpx font-size: 28rpx !important
color: #333333 color: #333333 !important
margin-bottom: 20rpx margin-bottom: 22rpx !important
position: relative position: relative !important
display: flex display: block !important
flex-direction: column flex-direction: column !important
/* 确保在小程序中边距正确应用 */ /* 确保在所有平台中边距正确应用 */
/* #ifdef MP-WEIXIN */ margin-left: auto !important
margin-left: auto margin-right: auto !important
margin-right: auto width: 100% !important
width: 100% box-sizing: border-box !important
box-sizing: border-box text-decoration: none !important
/* #endif */ overflow: hidden !important
.card-title .custom-card .card-title
font-weight: 600 font-weight: 600 !important
display: flex display: flex !important
align-items: center align-items: center !important
justify-content: space-between justify-content: space-between !important
margin-bottom: 16rpx margin-bottom: 16rpx !important
.title-text .custom-card .card-title .title-text
font-family: 'PingFangSC-Medium', 'PingFang SC', 'Helvetica Neue', Helvetica, Arial, 'Microsoft YaHei', sans-serif font-family: 'PingFangSC-Medium', 'PingFang SC', 'Helvetica Neue', Helvetica, Arial, 'Microsoft YaHei', sans-serif !important
max-width: calc(100% - 160rpx) max-width: calc(100% - 160rpx) !important
overflow: hidden overflow: hidden !important
text-overflow: ellipsis text-overflow: ellipsis !important
font-size: 30rpx font-size: 32rpx !important
line-height: 1.4 line-height: 1.4 !important
white-space: nowrap !important
margin-bottom: 0 !important
.card-salary .custom-card .card-title .card-salary
font-family: DIN-Medium font-family: DIN-Medium !important
font-size: 28rpx font-size: 32rpx !important
color: #FF6E1C color: #4C6EFB !important
line-height: 1.4 line-height: 1.4 !important
font-weight: 500 !important
margin-bottom: 0 !important
.card-company .custom-card .card-company
margin-bottom: 22rpx margin-bottom: 18rpx !important
max-width: 100% max-width: 100% !important
overflow: hidden overflow: hidden !important
text-overflow: ellipsis text-overflow: ellipsis !important
color: #6C7282 color: #6C7282 !important
line-height: 1.4 line-height: 1.4 !important
white-space: nowrap !important
font-size: 28rpx !important
margin-top: 0 !important
display: block !important
.card-info .custom-card .card-tags
display: flex display: flex !important
align-items: center flex-wrap: wrap !important
justify-content: space-between margin-bottom: 24rpx !important
padding-right: 40rpx
.info-item .custom-card .card-tag
display: flex font-weight: 400 !important
position: relative font-size: 24rpx !important
align-items: center color: #6C7282 !important
width: fit-content !important
background: #F4F4F4 !important
border-radius: 4rpx !important
padding: 6rpx 20rpx !important
margin-right: 20rpx !important
margin-bottom: 14rpx !important
display: inline-flex !important
align-items: center !important
justify-content: center !important
height: 30rpx !important
line-height: 30rpx !important
&:last-child .custom-card .card-bottom
color: #256BFA display: flex !important
font-size: 28rpx justify-content: space-between !important
padding-right: 10rpx font-size: 24rpx !important
color: #6C7282 !important
margin-top: 0 !important
margin-bottom: 0 !important
.position-nav .custom-card .card-bottom .info-item
position: absolute display: flex !important
right: -10rpx align-items: center !important
top: 50% justify-content: center !important
transform: translateY(-50%) margin-bottom: 0 !important
.position-nav::before .custom-card .card-info
position: absolute display: flex !important
left: 0 align-items: center !important
top: -4rpx justify-content: space-between !important
content: '' padding-right: 40rpx !important
width: 4rpx
height: 16rpx
border-radius: 2rpx
background: #256BFA
transform: translate(0, -50%) rotate(-45deg)
.position-nav::after .custom-card .card-info .info-item
position: absolute display: flex !important
left: 0 position: relative !important
top: -4rpx align-items: center !important
content: ''
width: 4rpx
height: 16rpx
border-radius: 2rpx
background: #256BFA
transform: rotate(45deg)
.card-tag .custom-card .card-info .info-item:last-child
font-weight: 500 color: #256BFA !important
font-size: 24rpx font-size: 28rpx !important
color: #333333 padding-right: 10rpx !important
width: fit-content
background: #F4F4F4 .custom-card .position-nav
border-radius: 4rpx position: absolute !important
padding: 4rpx 20rpx right: -10rpx !important
margin-right: 16rpx top: 50% !important
margin-bottom: 0 transform: translateY(-50%) !important
.custom-card .position-nav::before
position: absolute !important
left: 0 !important
top: -4rpx !important
content: '' !important
width: 4rpx !important
height: 16rpx !important
border-radius: 2rpx !important
background: #256BFA !important
transform: translate(0, -50%) rotate(-45deg) !important
.custom-card .position-nav::after
position: absolute !important
left: 0 !important
top: -4rpx !important
content: '' !important
width: 4rpx !important
height: 16rpx !important
border-radius: 2rpx !important
background: #256BFA !important
transform: rotate(45deg) !important
/* 为微信小程序专门优化的样式选择器 */
/* #ifdef MP-WEIXIN */
.job-cards-container .custom-card
background: #FFFFFF !important
box-shadow: 0rpx 0rpx 8rpx 0rpx rgba(0,0,0,0.04) !important
border-radius: 20rpx !important
padding: 28rpx 24rpx !important
font-weight: 400 !important
font-size: 28rpx !important
color: #333333 !important
margin-bottom: 22rpx !important
position: relative !important
display: block !important
flex-direction: column !important
margin-left: auto !important
margin-right: auto !important
width: 100% !important
box-sizing: border-box !important
text-decoration: none !important
overflow: hidden !important
.job-cards-container .custom-card .card-title
font-weight: 600 !important
display: flex !important
align-items: center !important
justify-content: space-between !important
margin-bottom: 16rpx !important
.job-cards-container .custom-card .card-title .title-text
font-family: 'PingFangSC-Medium', 'PingFang SC', 'Helvetica Neue', Helvetica, Arial, 'Microsoft YaHei', sans-serif !important
max-width: calc(100% - 160rpx) !important
overflow: hidden !important
text-overflow: ellipsis !important
font-size: 32rpx !important
line-height: 1.4 !important
white-space: nowrap !important
margin-bottom: 0 !important
.job-cards-container .custom-card .card-title .card-salary
font-family: DIN-Medium !important
font-size: 32rpx !important
color: #4C6EFB !important
line-height: 1.4 !important
font-weight: 500 !important
margin-bottom: 0 !important
.job-cards-container .custom-card .card-company
margin-bottom: 18rpx !important
max-width: 100% !important
overflow: hidden !important
text-overflow: ellipsis !important
color: #6C7282 !important
line-height: 1.4 !important
white-space: nowrap !important
font-size: 28rpx !important
margin-top: 0 !important
display: block !important
.job-cards-container .custom-card .card-info
display: flex !important
align-items: center !important
justify-content: space-between !important
padding-right: 40rpx !important
.job-cards-container .custom-card .card-info .info-item
display: flex !important
position: relative !important
align-items: center !important
.job-cards-container .custom-card .card-info .info-item:last-child
color: #256BFA !important
font-size: 28rpx !important
padding-right: 10rpx !important
.job-cards-container .custom-card .card-info .info-item .card-tag
font-weight: 400 !important
font-size: 24rpx !important
color: #6C7282 !important
width: fit-content !important
background: #F4F4F4 !important
border-radius: 4rpx !important
padding: 6rpx 20rpx !important
margin-right: 20rpx !important
margin-bottom: 14rpx !important
display: inline-flex !important
align-items: center !important
justify-content: center !important
height: 30rpx !important
line-height: 30rpx !important
.job-cards-container .custom-card .position-nav
position: absolute !important
right: -10rpx !important
top: 50% !important
transform: translateY(-50%) !important
.job-cards-container .custom-card .position-nav::before
position: absolute !important
left: 0 !important
top: -4rpx !important
content: '' !important
width: 4rpx !important
height: 16rpx !important
border-radius: 2rpx !important
background: #256BFA !important
transform: translate(0, -50%) rotate(-45deg) !important
.job-cards-container .custom-card .position-nav::after
position: absolute !important
left: 0 !important
top: -4rpx !important
content: '' !important
width: 4rpx !important
height: 16rpx !important
border-radius: 2rpx !important
background: #256BFA !important
transform: rotate(45deg) !important
/* #endif */
/* 额外的H5端样式优化 */
/* #ifndef MP-WEIXIN */
/* 确保样式能正确应用到v-html生成的内容 */
.markdown-body {
/* 重置v-html容器样式 */
display: block !important;
/* 为v-html生成的a.custom-card标签添加基础样式 */
a.custom-card {
display: flex !important;
flex-direction: column !important;
margin-bottom: 22rpx !important;
background: #FFFFFF !important;
box-shadow: 0rpx 0rpx 8rpx 0rpx rgba(0,0,0,0.04) !important;
border-radius: 20rpx !important;
padding: 28rpx 24rpx !important;
font-weight: 400 !important;
font-size: 28rpx !important;
color: #333333 !important;
text-decoration: none !important;
overflow: hidden !important;
box-sizing: border-box !important;
width: calc(100% - 0rpx) !important;
max-width: 100% !important;
}
/* 卡片标题样式 */
a.custom-card .card-title {
font-weight: 600 !important;
display: flex !important;
align-items: center !important;
justify-content: space-between !important;
margin-bottom: 16rpx !important;
}
a.custom-card .card-title .title-text {
font-family: 'PingFangSC-Medium', 'PingFang SC', 'Helvetica Neue', Helvetica, Arial, 'Microsoft YaHei', sans-serif !important;
font-size: 32rpx !important;
line-height: 1.4 !important;
color: #333333 !important;
max-width: calc(100% - 160rpx) !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
white-space: nowrap !important;
margin-bottom: 0 !important;
}
a.custom-card .card-title .card-salary {
font-family: DIN-Medium !important;
font-size: 32rpx !important;
color: #4C6EFB !important;
line-height: 1.4 !important;
font-weight: 500 !important;
margin-bottom: 0 !important;
}
/* 公司信息样式 */
a.custom-card .card-company {
margin-bottom: 18rpx !important;
font-size: 28rpx !important;
color: #6C7282 !important;
line-height: 1.4 !important;
max-width: 100% !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
white-space: nowrap !important;
margin-top: 0 !important;
display: block !important;
}
/* 标签容器样式 */
a.custom-card .card-tags {
display: flex !important;
flex-wrap: wrap !important;
margin-bottom: 24rpx !important;
}
/* 单个标签样式 */
a.custom-card .card-tag {
font-weight: 400 !important;
font-size: 24rpx !important;
color: #6C7282 !important;
width: fit-content !important;
background: #F4F4F4 !important;
border-radius: 4rpx !important;
padding: 6rpx 20rpx !important;
margin-right: 20rpx !important;
margin-bottom: 14rpx !important;
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
height: 30rpx !important;
line-height: 30rpx !important;
}
/* 卡片底部样式 */
a.custom-card .card-bottom {
display: flex !important;
justify-content: space-between !important;
font-size: 24rpx !important;
color: #6C7282 !important;
margin-top: 0 !important;
margin-bottom: 0 !important;
}
a.custom-card .card-bottom .info-item {
display: flex !important;
align-items: center !important;
justify-content: center !important;
margin-bottom: 0 !important;
}
/* 卡片信息区域样式 */
a.custom-card .card-info {
display: flex !important;
align-items: center !important;
justify-content: space-between !important;
padding-right: 40rpx !important;
}
a.custom-card .card-info .info-item {
display: flex !important;
align-items: center !important;
}
a.custom-card .card-info .info-item:last-child {
color: #256BFA !important;
font-size: 28rpx !important;
padding-right: 10rpx !important;
position: relative !important;
}
/* 查看详情箭头样式 */
a.custom-card .position-nav {
position: absolute !important;
right: -10rpx !important;
top: 50% !important;
transform: translateY(-50%) !important;
}
a.custom-card .position-nav::before {
position: absolute !important;
left: 0 !important;
top: -4rpx !important;
content: '' !important;
width: 4rpx !important;
height: 16rpx !important;
border-radius: 2rpx !important;
background: #256BFA !important;
transform: translate(0, -50%) rotate(-45deg) !important;
}
a.custom-card .position-nav::after {
position: absolute !important;
left: 0 !important;
top: -4rpx !important;
content: '' !important;
width: 4rpx !important;
height: 16rpx !important;
border-radius: 2rpx !important;
background: #256BFA !important;
transform: rotate(45deg) !important;
}
/* 查看更多按钮样式 */
a.custom-more {
display: flex !important;
justify-content: center !important;
align-items: center !important;
color: #FFFFFF !important;
background: linear-gradient(135deg, #256BFA 0%, #9E74FD 100%) !important;
border-radius: 50rpx !important;
padding: 20rpx 32rpx !important;
margin: 20rpx 0 !important;
font-size: 28rpx !important;
font-weight: 600 !important;
box-shadow: 0rpx 8rpx 24rpx rgba(37, 107, 250, 0.3) !important;
transition: all 0.3s ease !important;
position: relative !important;
overflow: hidden !important;
text-decoration: none !important;
box-sizing: border-box !important;
width: 100% !important;
}
a.custom-more .more-icon {
width: 32rpx !important;
height: 32rpx !important;
background: url('@/static/svg/seemore.svg') center center no-repeat !important;
background-size: 100% 100% !important;
margin-left: 12rpx !important;
filter: brightness(0) invert(1) !important;
}
a.custom-more::before {
content: '' !important;
position: absolute !important;
top: 0 !important;
left: -100% !important;
width: 100% !important;
height: 100% !important;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent) !important;
transition: left 0.5s ease !important;
}
a.custom-more:active {
transform: translateY(2rpx) !important;
box-shadow: 0rpx 4rpx 16rpx rgba(37, 107, 250, 0.4) !important;
}
a.custom-more:active::before {
left: 100% !important;
}
}
/* #endif */
</style> </style>

View File

@@ -40,6 +40,22 @@
</view> </view>
</view> </view>
<!-- 机构类型选择仅单位角色显示 -->
<view v-if="userType === 0" class="org-type-select">
<view class="org-type-title">请选择机构类型</view>
<view class="org-type-options">
<view
v-for="option in orgTypeOptions"
:key="option.value"
class="org-type-item"
:class="{ active: orgType === option.value }"
@click="selectOrgType(option.value)"
>
<view class="org-type-text">{{ option.label }}</view>
</view>
</view>
</view>
<!-- 授权说明 --> <!-- 授权说明 -->
<view class="auth-tips"> <view class="auth-tips">
<view class="tip-item"> <view class="tip-item">
@@ -99,21 +115,50 @@
</template> </template>
<script setup> <script setup>
import { ref, inject } from 'vue'; import { ref, inject, onMounted } from 'vue';
import useUserStore from '@/stores/useUserStore'; import useUserStore from '@/stores/useUserStore';
import useDictStore from '@/stores/useDictStore';
import { tabbarManager } from '@/utils/tabbarManager'; import { tabbarManager } from '@/utils/tabbarManager';
const { $api } = inject('globalFunction'); const { $api } = inject('globalFunction');
const { loginSetToken } = useUserStore(); const { loginSetToken } = useUserStore();
const dictStore = useDictStore();
const popup = ref(null); const popup = ref(null);
const userType = ref(null); // 用户角色1-求职者0-企业 const userType = ref(null); // 用户角色1-求职者0-企业
const orgType = ref(null); // 机构类型
const orgTypeOptions = ref([]); // 机构类型选项
const emit = defineEmits(['success', 'cancel']); const emit = defineEmits(['success', 'cancel']);
// 获取机构类型字典
const getOrgTypeDict = async () => {
try {
const options = await dictStore.getDictSelectOption('org_type');
orgTypeOptions.value = options;
} catch (error) {
console.error('获取机构类型字典失败:', error);
// 使用备用数据
orgTypeOptions.value = [
{ label: '有限责任公司', value: '1' },
{ label: '股份有限公司', value: '2' },
{ label: '个人独资企业', value: '3' },
{ label: '合伙企业', value: '4' },
{ label: '外商投资企业', value: '5' },
{ label: '其他', value: '6' }
];
}
};
// 组件挂载时获取字典数据
onMounted(() => {
getOrgTypeDict();
});
// 打开弹窗 // 打开弹窗
const open = () => { const open = () => {
popup.value?.open(); popup.value?.open();
userType.value = null; // 重置角色选择 userType.value = null; // 重置角色选择
orgType.value = null; // 重置机构类型选择
}; };
// 关闭弹窗 // 关闭弹窗
@@ -125,6 +170,12 @@ const close = () => {
// 选择角色 // 选择角色
const selectRole = (type) => { const selectRole = (type) => {
userType.value = type; userType.value = type;
orgType.value = null; // 切换角色时重置机构类型选择
};
// 选择机构类型
const selectOrgType = (type) => {
orgType.value = type;
}; };
// 验证角色是否已选择 // 验证角色是否已选择
@@ -133,6 +184,13 @@ const validateRole = () => {
$api.msg('请先选择您的角色'); $api.msg('请先选择您的角色');
return false; return false;
} }
// 验证机构类型是否已选择(仅单位角色)
if (userType.value === 0 && orgType.value === null) {
$api.msg('请选择机构类型');
return false;
}
return true; return true;
}; };
@@ -147,6 +205,12 @@ const getPhoneNumber = (e) => {
$api.msg('请先选择您的角色'); $api.msg('请先选择您的角色');
return true; return true;
} }
// 验证机构类型是否已选择(仅单位角色)
if (userType.value === 0 && orgType.value === null) {
$api.msg('请选择机构类型');
return true;
}
uni.login({ uni.login({
provider: 'weixin', provider: 'weixin',
success: (loginRes) => { success: (loginRes) => {
@@ -161,7 +225,8 @@ const getPhoneNumber = (e) => {
code, code,
encryptedData, encryptedData,
iv, iv,
userType: userType.value userType: userType.value,
orgType: orgType.value
}, 'post').then((resData) => { }, 'post').then((resData) => {
uni.hideLoading(); uni.hideLoading();
console.log(resData, 'resume.idCard'); console.log(resData, 'resume.idCard');
@@ -250,7 +315,8 @@ const wxLogin = () => {
// 调用后端接口进行登录 // 调用后端接口进行登录
$api.createRequest('/app/appLogin', { $api.createRequest('/app/appLogin', {
code: loginRes.code, code: loginRes.code,
userType: userType.value userType: userType.value,
orgType: orgType.value
}, 'post').then((resData) => { }, 'post').then((resData) => {
if (resData.token) { if (resData.token) {
loginSetToken(resData.token).then((resume) => { loginSetToken(resData.token).then((resume) => {
@@ -372,7 +438,7 @@ defineExpose({
overflow: hidden overflow: hidden
.modal-content .modal-content
padding: 60rpx 40rpx 40rpx padding: 40rpx 40rpx 40rpx
position: relative position: relative
.close-btn .close-btn
@@ -388,11 +454,11 @@ defineExpose({
.auth-header .auth-header
text-align: center text-align: center
margin-bottom: 40rpx margin-bottom: 20rpx
.auth-logo .auth-logo
width: 120rpx width: 90rpx
height: 120rpx height: 90rpx
margin: 0 auto 24rpx margin: 0 auto 24rpx
.auth-title .auth-title
@@ -407,7 +473,6 @@ defineExpose({
.role-select .role-select
margin-bottom: 32rpx margin-bottom: 32rpx
.role-title .role-title
font-size: 28rpx font-size: 28rpx
font-weight: 500 font-weight: 500
@@ -446,6 +511,41 @@ defineExpose({
color: #333333 color: #333333
font-weight: 500 font-weight: 500
.org-type-select
margin-bottom: 22rpx
.org-type-title
font-size: 28rpx
font-weight: 500
color: #333333
margin-bottom: 20rpx
text-align: center
.org-type-options
display: flex
flex-wrap: wrap
gap: 16rpx
.org-type-item
display:inline-block
background: #F7F8FA
border: 2rpx solid #E5E5E5
border-radius: 10rpx
padding: 10rpx 10rpx
transition: all 0.3s ease
cursor: pointer
text-align: center
&.active
background: #F0F5FF
border-color: #256BFA
box-shadow: 0 4rpx 12rpx rgba(37, 107, 250, 0.15)
.org-type-text
font-size: 22rpx
color: #333333
font-weight: 500
.auth-tips .auth-tips
background: #F7F8FA background: #F7F8FA
border-radius: 16rpx border-radius: 16rpx

View File

@@ -20,6 +20,7 @@ export function useAudioRecorder() {
const recognizedText = ref('') const recognizedText = ref('')
const lastFinalText = ref('') const lastFinalText = ref('')
const isRecognizing = ref(false) // 识别状态,暴露给外部
let audioStream = null let audioStream = null
let audioContext = null let audioContext = null
@@ -132,11 +133,15 @@ export function useAudioRecorder() {
} }
case 'TranscriptionCompleted': { case 'TranscriptionCompleted': {
lastFinalText.value = '' lastFinalText.value = ''
isRecognizing.value = false // 识别完成,重置状态
// console.log('识别全部完成') // console.log('识别全部完成')
cleanup()
break break
} }
case 'TaskFailed': { case 'TaskFailed': {
console.error('识别失败:', msg?.header?.status_text) console.error('识别失败:', msg?.header?.status_text)
isRecognizing.value = false // 识别失败,重置状态
cleanup()
break break
} }
default: default:
@@ -151,7 +156,104 @@ export function useAudioRecorder() {
if (isRecording.value) return if (isRecording.value) return
// #ifdef MP-WEIXIN // #ifdef MP-WEIXIN
$api.msg('小程序暂不支持语音识别功能'); try {
recognizedText.value = ''
lastFinalText.value = ''
// 开始录音时不设置isRecognizing为true只有在停止录音后才保持isRecognizing为true
const recorderManager = uni.getRecorderManager()
// 监听录音完成事件
recorderManager.onStop(async (res) => {
console.log('小程序录音完成:', res)
try {
// 停止录音后设置isRecognizing为true显示loading
isRecognizing.value = true
// 打印请求配置,便于调试
console.log('准备上传语音识别请求配置:', {
url: config.vioceBaseURl,
name: 'file',
method: 'POST',
fileType: 'audio',
filePath: res.tempFilePath
})
// 上传录音文件到服务器进行语音识别
const uploadResult = await uni.uploadFile({
url: config.vioceBaseURl,
filePath: res.tempFilePath,
name: 'file',
fileType: 'audio',
method: 'POST' // 显式设置为POST请求
})
console.log('语音识别上传结果:', uploadResult)
if (uploadResult.statusCode === 200) {
try {
const result = JSON.parse(uploadResult.data)
console.log('语音识别结果:', result)
if (result.code === 200 && result.data) {
recognizedText.value = result.data
console.log('语音识别成功,识别结果:', recognizedText.value)
// 语音识别成功后,自动发送消息
// 这里需要触发一个事件,让父组件知道识别成功
// 或者直接调用发送消息的方法
isRecognizing.value = false // 识别成功,重置状态
} else {
console.error('语音识别返回错误:', result.message || '未知错误')
$api.msg('语音识别失败,请重试')
isRecognizing.value = false // 识别失败,重置状态
}
} catch (parseErr) {
console.error('语音识别结果解析失败:', parseErr)
$api.msg('语音识别失败,请重试')
isRecognizing.value = false // 解析失败,重置状态
}
} else {
console.error('语音识别请求失败,状态码:', uploadResult.statusCode)
$api.msg('语音识别失败,请重试')
isRecognizing.value = false // 请求失败,重置状态
}
} catch (err) {
console.error('语音识别上传失败:', err)
$api.msg('语音识别失败,请重试')
isRecognizing.value = false // 上传失败,重置状态
}
})
// 监听录音错误事件
recorderManager.onError((err) => {
console.error('小程序录音错误:', err)
$api.msg('录音失败,请重试');
cleanup()
})
// 微信小程序录音API
await recorderManager.start({
duration: 60000, // 最长录音60秒
sampleRate: 16000,
numberOfChannels: 1,
encodeBitRate: 96000,
format: 'mp3'
})
isRecording.value = true
recordingDuration.value = 0
durationTimer = setInterval(() => recordingDuration.value++, 1000)
// 监听录音事件(可选)
recorderManager.onFrameRecorded((res) => {
// 更新音量显示
volumeLevel.value = res.volume || 0
audioDataForDisplay.value = Array(16).fill(volumeLevel.value)
})
} catch (err) {
console.error('小程序录音启动失败:', err)
$api.msg('录音启动失败,请重试');
cleanup()
}
return; return;
// #endif // #endif
@@ -164,6 +266,7 @@ export function useAudioRecorder() {
recognizedText.value = '' recognizedText.value = ''
lastFinalText.value = '' lastFinalText.value = ''
// 开始录音时不设置isRecognizing为true只有在停止录音后才保持isRecognizing为true
await connectWebSocket() await connectWebSocket()
audioStream = await navigator.mediaDevices.getUserMedia({ audioStream = await navigator.mediaDevices.getUserMedia({
@@ -201,6 +304,7 @@ export function useAudioRecorder() {
durationTimer = setInterval(() => recordingDuration.value++, 1000) durationTimer = setInterval(() => recordingDuration.value++, 1000)
} catch (err) { } catch (err) {
console.error('启动失败:', err) console.error('启动失败:', err)
isRecognizing.value = false // 启动失败,重置状态
cleanup() cleanup()
} }
// #endif // #endif
@@ -210,6 +314,12 @@ export function useAudioRecorder() {
if (!isRecording.value || isStopping.value) return if (!isRecording.value || isStopping.value) return
isStopping.value = true isStopping.value = true
// #ifdef MP-WEIXIN
uni.getRecorderManager().stop()
// 小程序中录音停止后会触发onStop事件在onStop事件中处理识别结果和状态重置
// #endif
// #ifdef H5
if (websocket?.readyState === WebSocket.OPEN) { if (websocket?.readyState === WebSocket.OPEN) {
websocket.send(JSON.stringify({ websocket.send(JSON.stringify({
header: { header: {
@@ -218,18 +328,39 @@ export function useAudioRecorder() {
message_id: generateUUID() message_id: generateUUID()
} }
})) }))
websocket.close() // H5中不立即调用cleanup等待识别完成
} }
// #endif
cleanup() // 只清理录音相关资源,不重置识别状态
clearInterval(durationTimer)
audioStream?.getTracks().forEach(track => track.stop())
audioContext?.close()
audioStream = null
audioContext = null
audioInput = null
scriptProcessor = null
isRecording.value = false
isSocketConnected.value = false
isStopping.value = false isStopping.value = false
} }
const cancelRecording = () => { const cancelRecording = () => {
if (!isRecording.value || isStopping.value) return if (!isRecording.value || isStopping.value) return
isStopping.value = true isStopping.value = true
// #ifdef MP-WEIXIN
uni.getRecorderManager().stop()
// #endif
// #ifdef H5
websocket?.close() websocket?.close()
// #endif
// 取消录音时重置所有状态
cleanup() cleanup()
isRecognizing.value = false
isStopping.value = false isStopping.value = false
} }
@@ -249,16 +380,22 @@ export function useAudioRecorder() {
isRecording.value = false isRecording.value = false
isSocketConnected.value = false isSocketConnected.value = false
isRecognizing.value = false // 停止录音,重置识别状态
} }
onUnmounted(() => { onUnmounted(() => {
if (isRecording.value) stopRecording() if (isRecording.value) stopRecording()
}) })
const reset = () => {
cleanup()
}
return { return {
isRecording, isRecording,
isStopping, isStopping,
isSocketConnected, isSocketConnected,
isRecognizing,
recordingDuration, recordingDuration,
audioDataForDisplay, audioDataForDisplay,
volumeLevel, volumeLevel,
@@ -266,6 +403,7 @@ export function useAudioRecorder() {
lastFinalText, lastFinalText,
startRecording, startRecording,
stopRecording, stopRecording,
cancelRecording cancelRecording,
reset
} }
} }

View File

@@ -9,77 +9,79 @@ import {
onUnload onUnload
} from '@dcloudio/uni-app' } from '@dcloudio/uni-app'
export function useTTSPlayer(httpUrl) { // 创建单例实例
const isSpeaking = ref(false) let ttsInstance = null
const isPaused = ref(false)
const isComplete = ref(false)
// #ifdef H5 // 创建音频播放器类
// H5环境使用 AudioContext class TTSPlayer {
// 初始化时不立即创建,而是在需要时创建,确保在用户交互后创建 constructor(httpUrl) {
let audioContext = null this.httpUrl = httpUrl
// #endif this.isSpeaking = ref(false)
this.isPaused = ref(false)
this.isComplete = ref(false)
// #ifdef MP-WEIXIN // #ifdef H5
const audioContext = null // 微信小程序不支持 AudioContext this.audioContext = null
let innerAudioContext = null // 微信小程序音频上下文 this.htmlAudioElement = null
let backgroundAudioManager = null // 微信小程序背景音频管理器 this.abortController = null
// #endif // #endif
let currentAudioBuffer = null // #ifdef MP-WEIXIN
let currentSource = null this.backgroundAudioManager = null
let playTimeOffset = 0 this.innerAudioContext = null
// #endif
this.currentAudioBuffer = null
this.currentSource = null
this.playTimeOffset = 0
this.isProcessingRequest = false
}
// 初始化微信小程序音频上下文 // 初始化微信小程序音频上下文
// #ifdef MP-WEIXIN initAudioManager() {
const initAudioManager = () => {
try { try {
console.log('📱 微信小程序:创建背景音频管理器') console.log('📱 微信小程序:创建背景音频管理器')
backgroundAudioManager = uni.getBackgroundAudioManager() this.backgroundAudioManager = uni.getBackgroundAudioManager()
// 设置默认配置 // 设置默认配置
backgroundAudioManager.title = 'AI语音播报' this.backgroundAudioManager.title = 'AI语音播报'
backgroundAudioManager.singer = 'KS AI' this.backgroundAudioManager.singer = 'KS AI'
backgroundAudioManager.coverImgUrl = '/static/icon/logo.png' this.backgroundAudioManager.coverImgUrl = '/static/icon/logo.png'
backgroundAudioManager.volume = 1.0 this.backgroundAudioManager.volume = 1.0
backgroundAudioManager.onPlay(() => { this.backgroundAudioManager.onPlay(() => {
console.log('🎵 微信小程序背景音频播放开始') console.log('🎵 微信小程序背景音频播放开始')
isSpeaking.value = true this.isSpeaking.value = true
isPaused.value = false this.isPaused.value = false
}) })
backgroundAudioManager.onPause(() => { this.backgroundAudioManager.onPause(() => {
console.log('⏸️ 微信小程序背景音频播放暂停') console.log('⏸️ 微信小程序背景音频播放暂停')
isPaused.value = true this.isPaused.value = true
}) })
backgroundAudioManager.onStop(() => { this.backgroundAudioManager.onStop(() => {
console.log('⏹️ 微信小程序背景音频播放停止') console.log('⏹️ 微信小程序背景音频播放停止')
isSpeaking.value = false this.isSpeaking.value = false
isComplete.value = true this.isComplete.value = true
}) })
backgroundAudioManager.onEnded(() => { this.backgroundAudioManager.onEnded(() => {
console.log('🎵 微信小程序背景音频播放结束') console.log('🎵 微信小程序背景音频播放结束')
isSpeaking.value = false this.isSpeaking.value = false
isComplete.value = true this.isComplete.value = true
}) })
backgroundAudioManager.onError((res) => { this.backgroundAudioManager.onError((res) => {
console.error('❌ 微信小程序背景音频播放错误:', res.errMsg, '错误码:', res.errCode) console.error('❌ 微信小程序背景音频播放错误:', res.errMsg, '错误码:', res.errCode)
isSpeaking.value = false this.isSpeaking.value = false
isComplete.value = false this.isComplete.value = false
}) })
backgroundAudioManager.onCanplay(() => { this.backgroundAudioManager.onCanplay(() => {
console.log('🎵 微信小程序背景音频可以播放了') console.log('🎵 微信小程序背景音频可以播放了')
}) })
backgroundAudioManager.onWaiting(() => {
console.log('⏳ 微信小程序背景音频加载中...')
})
console.log('✅ 微信小程序背景音频管理器初始化成功') console.log('✅ 微信小程序背景音频管理器初始化成功')
return true return true
} catch (e) { } catch (e) {
@@ -87,159 +89,384 @@ export function useTTSPlayer(httpUrl) {
// 降级使用InnerAudioContext // 降级使用InnerAudioContext
console.log('🔄 微信小程序背景音频不可用降级使用InnerAudioContext') console.log('🔄 微信小程序背景音频不可用降级使用InnerAudioContext')
if (!innerAudioContext) { if (!this.innerAudioContext) {
innerAudioContext = uni.createInnerAudioContext() this.innerAudioContext = uni.createInnerAudioContext()
innerAudioContext.autoplay = false this.innerAudioContext.autoplay = false
innerAudioContext.obeyMuteSwitch = false this.innerAudioContext.obeyMuteSwitch = false
innerAudioContext.onPlay(() => { this.innerAudioContext.onPlay(() => {
console.log('🎵 微信小程序InnerAudioContext播放开始') console.log('🎵 微信小程序InnerAudioContext播放开始')
isSpeaking.value = true this.isSpeaking.value = true
isPaused.value = false this.isPaused.value = false
}) })
innerAudioContext.onPause(() => { this.innerAudioContext.onPause(() => {
console.log('⏸️ 微信小程序InnerAudioContext播放暂停') console.log('⏸️ 微信小程序InnerAudioContext播放暂停')
isPaused.value = true this.isPaused.value = true
}) })
innerAudioContext.onStop(() => { this.innerAudioContext.onStop(() => {
console.log('⏹️ 微信小程序InnerAudioContext播放停止') console.log('⏹️ 微信小程序InnerAudioContext播放停止')
isSpeaking.value = false this.isSpeaking.value = false
isComplete.value = true this.isComplete.value = true
}) })
innerAudioContext.onEnded(() => { this.innerAudioContext.onEnded(() => {
console.log('🎵 微信小程序InnerAudioContext播放结束') console.log('🎵 微信小程序InnerAudioContext播放结束')
isSpeaking.value = false this.isSpeaking.value = false
isComplete.value = true this.isComplete.value = true
}) })
innerAudioContext.onError((res) => { this.innerAudioContext.onError((res) => {
console.error('❌ 微信小程序InnerAudioContext错误:', res.errMsg, '错误码:', res.errCode) console.error('❌ 微信小程序InnerAudioContext错误:', res.errMsg, '错误码:', res.errCode)
isSpeaking.value = false this.isSpeaking.value = false
isComplete.value = false this.isComplete.value = false
}) })
innerAudioContext.onCanplay(() => { this.innerAudioContext.onCanplay(() => {
console.log('🎵 微信小程序InnerAudioContext可以播放了') console.log('🎵 微信小程序InnerAudioContext可以播放了')
if (isSpeaking.value && !isPaused.value) { if (this.isSpeaking.value && !this.isPaused.value) {
innerAudioContext.play() this.innerAudioContext.play()
} }
}) })
} }
return false return false
} }
} }
// #endif
const speak = async (text) => { // H5环境播放解码后的音频
// 停止当前播放 playDecodedAudio(decoded) {
stop() // #ifdef H5
if (!this.audioContext) return;
// 创建音频源
this.currentSource = this.audioContext.createBufferSource()
this.currentSource.buffer = decoded
this.currentSource.connect(this.audioContext.destination)
// 监听播放结束
this.currentSource.onended = () => {
console.log('🎵 Audio playback completed');
this.isSpeaking.value = false
this.isComplete.value = true
}
// 开始播放
this.currentSource.start()
this.isSpeaking.value = true
this.isPaused.value = false
this.isComplete.value = false
console.log('🎵 Audio playback started');
// #endif
}
// 降级处理:创建一个简单的音频缓冲区
createFallbackAudio(arrayBuffer) {
// #ifdef H5
console.log('🔄 使用降级方案创建音频');
// 创建一个简单的音频缓冲区,生成提示音
const sampleRate = 44100
const duration = 1 // 1秒
const frameCount = sampleRate * duration
const audioBuffer = this.audioContext.createBuffer(1, frameCount, sampleRate)
const channelData = audioBuffer.getChannelData(0)
// 生成一个简单的提示音(正弦波)
for (let i = 0; i < frameCount; i++) {
const t = i / sampleRate
channelData[i] = Math.sin(2 * Math.PI * 440 * t) * 0.1 // 440Hz正弦波音量0.1
}
this.playDecodedAudio(audioBuffer)
// #endif
}
// 暂停播放
pause() {
console.log('⏸️ TTS pause called');
// #ifdef MP-WEIXIN
// 优先使用背景音频管理器
if (this.backgroundAudioManager) {
try {
this.backgroundAudioManager.pause()
console.log('⏸️ 微信小程序背景音频暂停');
return
} catch (e) {
console.error('❌ 微信小程序背景音频暂停失败:', e);
}
}
// 降级使用InnerAudioContext
if (this.innerAudioContext && this.isSpeaking.value && !this.isPaused.value) {
try {
this.innerAudioContext.pause()
console.log('⏸️ 微信小程序InnerAudioContext暂停');
return
} catch (e) {
console.error('❌ 微信小程序InnerAudioContext暂停失败:', e);
}
}
// #endif
// #ifdef H5
if (this.audioContext && !this.isSpeaking.value || this.isPaused.value) {
console.warn('⚠️ Cannot pause TTS playback');
return;
}
if (this.audioContext.state === 'running') {
this.audioContext.suspend()
this.isPaused.value = true
// 保存当前播放位置
this.playTimeOffset = this.audioContext.currentTime
console.log('✅ H5 Audio paused successfully');
}
// #endif
}
// 恢复播放
resume() {
console.log('▶️ TTS resume called');
// #ifdef MP-WEIXIN
// 优先使用背景音频管理器
if (this.backgroundAudioManager) {
try {
this.backgroundAudioManager.play()
console.log('▶️ 微信小程序背景音频恢复播放');
return
} catch (e) {
console.error('❌ 微信小程序背景音频恢复失败:', e);
}
}
// 降级使用InnerAudioContext
if (this.innerAudioContext && this.isSpeaking.value && this.isPaused.value) {
try {
this.innerAudioContext.play()
console.log('▶️ 微信小程序InnerAudioContext恢复播放');
return
} catch (e) {
console.error('❌ 微信小程序InnerAudioContext恢复失败:', e);
}
}
// #endif
// #ifdef H5
if (this.audioContext && !this.isSpeaking.value || !this.isPaused.value) {
console.warn('⚠️ Cannot resume TTS playback');
return;
}
if (this.audioContext.state === 'suspended') {
this.audioContext.resume()
this.isPaused.value = false
console.log('✅ H5 Audio resumed successfully');
}
// #endif
}
// 停止播放
stop() {
console.log('⏹️ TTS stop called');
// #ifdef MP-WEIXIN
// 优先使用背景音频管理器
if (this.backgroundAudioManager) {
try {
this.backgroundAudioManager.stop()
console.log('✅ 微信小程序背景音频停止');
} catch (e) {
console.error('❌ 微信小程序背景音频停止失败:', e);
}
}
// 降级使用InnerAudioContext
if (this.innerAudioContext) {
try {
this.innerAudioContext.stop()
console.log('✅ 微信小程序InnerAudioContext停止');
this.innerAudioContext.destroy()
this.innerAudioContext = null
} catch (e) {
console.error('❌ 微信小程序InnerAudioContext停止错误:', e);
}
}
// #endif
// #ifdef H5
// 取消正在进行的fetch请求
if (this.abortController) {
try {
this.abortController.abort();
this.abortController = null;
console.log('✅ H5 fetch request aborted');
} catch (e) {
console.error('❌ Error aborting H5 fetch request:', e);
}
}
if (this.currentSource) {
try {
this.currentSource.stop()
this.currentSource.disconnect()
} catch (e) {
console.error('❌ Error stopping H5 audio source:', e);
}
this.currentSource = null
}
// 停止并清理HTML5 Audio元素
if (this.htmlAudioElement) {
try {
this.htmlAudioElement.pause();
this.htmlAudioElement.src = '';
this.htmlAudioElement = null;
console.log('✅ H5 HTML5 Audio element stopped and cleaned up');
} catch (e) {
console.error('❌ Error stopping H5 HTML5 Audio element:', e);
}
}
if (this.audioContext && this.audioContext.state === 'running') {
try {
this.audioContext.suspend()
} catch (e) {
console.error('❌ Error suspending H5 audio context:', e);
}
}
// #endif
this.isSpeaking.value = false
this.isPaused.value = false
this.isComplete.value = false
this.currentAudioBuffer = null
this.playTimeOffset = 0
this.isProcessingRequest = false
console.log('✅ TTS playback stopped');
}
// 取消音频播放
cancelAudio() {
this.stop()
}
// 合成并播放语音
async speak(text) {
// 停止当前播放和处理中的请求
this.stop()
try { try {
// 标记开始处理请求
this.isProcessingRequest = true
// 提取要合成的文本 // 提取要合成的文本
const speechText = extractSpeechText(text) const speechText = extractSpeechText(text)
console.log('📤 Sending text to TTS server via GET:', speechText.substring(0, 100) + '...'); console.log('📤 Sending text to TTS server via GET:', speechText.substring(0, 100) + '...');
// 构建GET请求URL // 构建GET请求URL
const url = `${httpUrl}?text=${encodeURIComponent(speechText)}` const url = `${this.httpUrl}?text=${encodeURIComponent(speechText)}`
console.log('🔗 Final GET URL:', url); console.log('🔗 Final GET URL:', url);
// #ifdef MP-WEIXIN // #ifdef MP-WEIXIN
// 微信小程序环境,使用背景音频管理器 // 微信小程序环境,使用背景音频管理器
const isBackgroundAudioAvailable = initAudioManager() const isBackgroundAudioAvailable = this.initAudioManager()
// 重置音频状态 // 重置音频状态,但不立即设置为正在播放,等待实际播放时再设置
isSpeaking.value = true this.isPaused.value = false
isPaused.value = false this.isComplete.value = false
isComplete.value = false
if (isBackgroundAudioAvailable && backgroundAudioManager) { if (isBackgroundAudioAvailable && this.backgroundAudioManager) {
console.log('🎵 微信小程序使用背景音频管理器播放URL:', url); console.log('🎵 微信小程序使用背景音频管理器播放URL:', url);
// 设置背景音频参数 // 设置背景音频参数
backgroundAudioManager.title = 'AI语音播报' this.backgroundAudioManager.title = 'AI语音播报'
backgroundAudioManager.singer = 'KS AI' this.backgroundAudioManager.singer = 'KS AI'
backgroundAudioManager.coverImgUrl = '/static/icon/logo.png' this.backgroundAudioManager.coverImgUrl = '/static/icon/logo.png'
// 直接设置src并播放 // 直接设置src并播放
backgroundAudioManager.src = url this.backgroundAudioManager.src = url
console.log('🎵 微信小程序背景音频开始播放'); console.log('🎵 微信小程序背景音频开始加载');
} else { } else {
// 降级方案使用InnerAudioContext // 降级方案使用InnerAudioContext
console.log('🔄 微信小程序背景音频不可用降级使用InnerAudioContext'); console.log('🔄 微信小程序背景音频不可用降级使用InnerAudioContext');
// 如果已有音频上下文,先销毁再重新创建 // 如果已有音频上下文,先销毁再重新创建
if (innerAudioContext) { if (this.innerAudioContext) {
innerAudioContext.destroy() this.innerAudioContext.destroy()
innerAudioContext = null this.innerAudioContext = null
} }
innerAudioContext = uni.createInnerAudioContext() this.innerAudioContext = uni.createInnerAudioContext()
innerAudioContext.autoplay = false this.innerAudioContext.autoplay = true // 设置自动播放,等音频加载完成后自动开始
innerAudioContext.obeyMuteSwitch = false this.innerAudioContext.obeyMuteSwitch = false
innerAudioContext.volume = 1.0 this.innerAudioContext.volume = 1.0
innerAudioContext.onPlay(() => { this.innerAudioContext.onPlay(() => {
console.log('🎵 微信小程序InnerAudioContext播放开始') console.log('🎵 微信小程序InnerAudioContext播放开始')
isSpeaking.value = true this.isSpeaking.value = true
isPaused.value = false this.isPaused.value = false
}) })
innerAudioContext.onPause(() => { this.innerAudioContext.onPause(() => {
console.log('⏸️ 微信小程序InnerAudioContext播放暂停') console.log('⏸️ 微信小程序InnerAudioContext播放暂停')
isPaused.value = true this.isPaused.value = true
}) })
innerAudioContext.onStop(() => { this.innerAudioContext.onStop(() => {
console.log('⏹️ 微信小程序InnerAudioContext播放停止') console.log('⏹️ 微信小程序InnerAudioContext播放停止')
isSpeaking.value = false this.isSpeaking.value = false
isComplete.value = true this.isComplete.value = true
}) })
innerAudioContext.onEnded(() => { this.innerAudioContext.onEnded(() => {
console.log('🎵 微信小程序InnerAudioContext播放结束') console.log('🎵 微信小程序InnerAudioContext播放结束')
isSpeaking.value = false this.isSpeaking.value = false
isComplete.value = true this.isComplete.value = true
}) })
innerAudioContext.onError((res) => { this.innerAudioContext.onError((res) => {
console.error('❌ 微信小程序InnerAudioContext错误:', res.errMsg, '错误码:', res.errCode) console.error('❌ 微信小程序InnerAudioContext错误:', res.errMsg, '错误码:', res.errCode)
isSpeaking.value = false this.isSpeaking.value = false
isComplete.value = false this.isComplete.value = false
}) })
innerAudioContext.onCanplay(() => { this.innerAudioContext.onCanplay(() => {
console.log('🎵 微信小程序InnerAudioContext可以播放了') console.log('🎵 微信小程序InnerAudioContext可以播放了')
if (isSpeaking.value && !isPaused.value) { // 不需要手动调用play因为已经设置了autoplay
innerAudioContext.play()
}
}) })
innerAudioContext.src = url this.innerAudioContext.src = url
console.log('🎵 微信小程序InnerAudioContext开始播放'); console.log('🎵 微信小程序InnerAudioContext开始加载');
} }
// #endif // #endif
// #ifdef H5 // #ifdef H5
// H5环境使用 AudioContext // H5环境使用 AudioContext
try { try {
// 创建新的AbortController用于取消当前请求
this.abortController = new AbortController();
const signal = this.abortController.signal;
// 确保AudioContext已创建 // 确保AudioContext已创建
if (!audioContext) { if (!this.audioContext) {
console.log('🎵 H5: 创建新的AudioContext'); console.log('🎵 H5: 创建新的AudioContext');
audioContext = new (window.AudioContext || window.webkitAudioContext)(); this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
} }
// 检查并恢复AudioContext状态浏览器安全策略要求用户交互后才能播放音频 // 检查并恢复AudioContext状态浏览器安全策略要求用户交互后才能播放音频
if (audioContext.state === 'suspended') { if (this.audioContext.state === 'suspended') {
console.log('🎵 H5: 恢复挂起的AudioContext'); console.log('🎵 H5: 恢复挂起的AudioContext');
await audioContext.resume(); await this.audioContext.resume();
console.log('✅ H5: AudioContext已恢复状态:', audioContext.state); console.log('✅ H5: AudioContext已恢复状态:', this.audioContext.state);
} }
// 发送GET请求获取语音数据 // 发送GET请求获取语音数据添加signal支持取消
const response = await fetch(url) const response = await fetch(url, { signal })
if (!response.ok) { if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`) throw new Error(`HTTP error! status: ${response.status}`)
} }
@@ -250,277 +477,101 @@ export function useTTSPlayer(httpUrl) {
try { try {
// 直接使用 audioContext.decodeAudioData 解码,不依赖外部库 // 直接使用 audioContext.decodeAudioData 解码,不依赖外部库
const decoded = await audioContext.decodeAudioData(arrayBuffer) const decoded = await this.audioContext.decodeAudioData(arrayBuffer)
console.log('✅ H5: Audio decoded, sampleRate:', decoded.sampleRate, 'channels:', decoded.numberOfChannels); console.log('✅ H5: Audio decoded, sampleRate:', decoded.sampleRate, 'channels:', decoded.numberOfChannels);
// 播放音频 // 播放音频
playDecodedAudio(decoded) this.playDecodedAudio(decoded)
} catch (decodeError) { } catch (decodeError) {
console.error('❌ H5: AudioContext decodeAudioData failed:', decodeError); console.error('❌ H5: AudioContext decodeAudioData failed:', decodeError);
// 降级处理:创建一个简单的音频缓冲区 // 降级处理:创建一个简单的音频缓冲区
createFallbackAudio(arrayBuffer) this.createFallbackAudio(arrayBuffer)
} }
} catch (h5Error) { } catch (h5Error) {
// 检查是否是取消请求导致的错误
if (h5Error.name === 'AbortError') {
console.log('✅ H5: Fetch request aborted as requested');
this.isSpeaking.value = false;
this.isComplete.value = false;
return;
}
console.error('❌ H5: Audio playback failed:', h5Error); console.error('❌ H5: Audio playback failed:', h5Error);
// 尝试使用HTML5 Audio元素作为最终降级方案 // 尝试使用HTML5 Audio元素作为最终降级方案
try { try {
console.log('🔄 H5: 尝试使用HTML5 Audio元素播放'); console.log('🔄 H5: 尝试使用HTML5 Audio元素播放');
// 如果已有Audio元素先停止并销毁
if (this.htmlAudioElement) {
this.htmlAudioElement.pause();
this.htmlAudioElement.src = '';
this.htmlAudioElement = null;
}
const audio = new Audio(url); const audio = new Audio(url);
this.htmlAudioElement = audio;
audio.play(); audio.play();
console.log('✅ H5: HTML5 Audio元素开始播放'); console.log('✅ H5: HTML5 Audio元素开始播放');
// 设置音频状态 // 设置音频状态
isSpeaking.value = true; this.isSpeaking.value = true;
isPaused.value = false; this.isPaused.value = false;
isComplete.value = false; this.isComplete.value = false;
// 监听播放结束 // 监听播放结束
audio.onended = () => { audio.onended = () => {
console.log('🎵 H5: HTML5 Audio播放结束'); console.log('🎵 H5: HTML5 Audio播放结束');
isSpeaking.value = false; this.isSpeaking.value = false;
isComplete.value = true; this.isComplete.value = true;
this.htmlAudioElement = null;
}; };
// 监听播放错误 // 监听播放错误
audio.onerror = (error) => { audio.onerror = (error) => {
console.error('❌ H5: HTML5 Audio播放错误:', error); console.error('❌ H5: HTML5 Audio播放错误:', error);
isSpeaking.value = false; this.isSpeaking.value = false;
isComplete.value = false; this.isComplete.value = false;
this.htmlAudioElement = null;
}; };
} catch (audioError) { } catch (audioError) {
console.error('❌ H5: HTML5 Audio播放也失败了:', audioError); console.error('❌ H5: HTML5 Audio播放也失败了:', audioError);
isSpeaking.value = false; this.isSpeaking.value = false;
isComplete.value = false; this.isComplete.value = false;
this.htmlAudioElement = null;
} }
} finally {
// 清除AbortController因为请求已经完成无论成功还是失败
this.abortController = null;
} }
// #endif // #endif
} catch (error) { } catch (error) {
console.error('❌ TTS synthesis failed:', error); console.error('❌ TTS synthesis failed:', error);
isSpeaking.value = false this.isSpeaking.value = false
isComplete.value = false this.isComplete.value = false
} finally {
// 标记请求处理完成
this.isProcessingRequest = false
} }
} }
}
// #ifdef H5 // 导出单例hook
const playDecodedAudio = (decoded) => { export function useTTSPlayer(httpUrl) {
if (!audioContext) return; // 如果已经有实例,直接返回
if (!ttsInstance) {
// 创建音频源 ttsInstance = new TTSPlayer(httpUrl)
currentSource = audioContext.createBufferSource()
currentSource.buffer = decoded
currentSource.connect(audioContext.destination)
// 监听播放结束
currentSource.onended = () => {
console.log('🎵 Audio playback completed');
isSpeaking.value = false
isComplete.value = true
}
// 开始播放
currentSource.start()
isSpeaking.value = true
isPaused.value = false
isComplete.value = false
console.log('🎵 Audio playback started');
} }
// 降级处理:创建一个简单的音频缓冲区 // 返回实例的方法和状态
const createFallbackAudio = (arrayBuffer) => {
console.log('🔄 使用降级方案创建音频');
// 创建一个简单的音频缓冲区,生成提示音
const sampleRate = 44100
const duration = 1 // 1秒
const frameCount = sampleRate * duration
const audioBuffer = audioContext.createBuffer(1, frameCount, sampleRate)
const channelData = audioBuffer.getChannelData(0)
// 生成一个简单的提示音(正弦波)
for (let i = 0; i < frameCount; i++) {
const t = i / sampleRate
channelData[i] = Math.sin(2 * Math.PI * 440 * t) * 0.1 // 440Hz正弦波音量0.1
}
playDecodedAudio(audioBuffer)
}
// #endif
const pause = () => {
console.log('⏸️ TTS pause called');
// #ifdef MP-WEIXIN
// 优先使用背景音频管理器
if (backgroundAudioManager) {
try {
backgroundAudioManager.pause()
console.log('⏸️ 微信小程序背景音频暂停');
return
} catch (e) {
console.error('❌ 微信小程序背景音频暂停失败:', e);
}
}
// 降级使用InnerAudioContext
if (innerAudioContext && isSpeaking.value && !isPaused.value) {
try {
innerAudioContext.pause()
console.log('⏸️ 微信小程序InnerAudioContext暂停');
return
} catch (e) {
console.error('❌ 微信小程序InnerAudioContext暂停失败:', e);
}
}
// #endif
// #ifdef H5
if (audioContext && !isSpeaking.value || isPaused.value) {
console.warn('⚠️ Cannot pause TTS playback');
return;
}
if (audioContext.state === 'running') {
audioContext.suspend()
isPaused.value = true
// 保存当前播放位置
playTimeOffset = audioContext.currentTime
console.log('✅ H5 Audio paused successfully');
}
// #endif
}
const resume = () => {
console.log('▶️ TTS resume called');
// #ifdef MP-WEIXIN
// 优先使用背景音频管理器
if (backgroundAudioManager) {
try {
backgroundAudioManager.play()
console.log('▶️ 微信小程序背景音频恢复播放');
return
} catch (e) {
console.error('❌ 微信小程序背景音频恢复失败:', e);
}
}
// 降级使用InnerAudioContext
if (innerAudioContext && isSpeaking.value && isPaused.value) {
try {
innerAudioContext.play()
console.log('▶️ 微信小程序InnerAudioContext恢复播放');
return
} catch (e) {
console.error('❌ 微信小程序InnerAudioContext恢复失败:', e);
}
}
// #endif
// #ifdef H5
if (audioContext && !isSpeaking.value || !isPaused.value) {
console.warn('⚠️ Cannot resume TTS playback');
return;
}
if (audioContext.state === 'suspended') {
audioContext.resume()
isPaused.value = false
console.log('✅ H5 Audio resumed successfully');
}
// #endif
}
const cancelAudio = () => {
stop()
}
const stop = () => {
console.log('⏹️ TTS stop called');
// #ifdef MP-WEIXIN
// 优先使用背景音频管理器
if (backgroundAudioManager) {
try {
backgroundAudioManager.stop()
console.log('✅ 微信小程序背景音频停止');
} catch (e) {
console.error('❌ 微信小程序背景音频停止失败:', e);
}
}
// 降级使用InnerAudioContext
if (innerAudioContext) {
try {
innerAudioContext.stop()
console.log('✅ 微信小程序InnerAudioContext停止');
innerAudioContext.destroy()
innerAudioContext = null
} catch (e) {
console.error('❌ 微信小程序InnerAudioContext停止错误:', e);
}
}
// #endif
// #ifdef H5
if (currentSource) {
try {
currentSource.stop()
currentSource.disconnect()
} catch (e) {
console.error('❌ Error stopping H5 audio source:', e);
}
currentSource = null
}
if (audioContext && audioContext.state === 'running') {
try {
audioContext.suspend()
} catch (e) {
console.error('❌ Error suspending H5 audio context:', e);
}
}
// #endif
isSpeaking.value = false
isPaused.value = false
isComplete.value = false
currentAudioBuffer = null
playTimeOffset = 0
console.log('✅ TTS playback stopped');
}
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)
return { return {
speak, speak: ttsInstance.speak.bind(ttsInstance),
pause, pause: ttsInstance.pause.bind(ttsInstance),
resume, resume: ttsInstance.resume.bind(ttsInstance),
cancelAudio, cancelAudio: ttsInstance.cancelAudio.bind(ttsInstance),
isSpeaking, isSpeaking: ttsInstance.isSpeaking,
isPaused, isPaused: ttsInstance.isPaused,
isComplete isComplete: ttsInstance.isComplete
} }
} }

View File

@@ -9,6 +9,7 @@ import globalFunction from '@/common/globalFunction'
import '@/lib/string-similarity.min.js' import '@/lib/string-similarity.min.js'
import similarityJobs from '@/utils/similarity_Job.js'; import similarityJobs from '@/utils/similarity_Job.js';
import config from '@/config.js'; import config from '@/config.js';
import shareMixin from './mixins/share.js';
// 组件 // 组件
import AppLayout from './components/AppLayout/AppLayout.vue'; import AppLayout from './components/AppLayout/AppLayout.vue';
import Empty from './components/empty/empty.vue'; import Empty from './components/empty/empty.vue';
@@ -36,6 +37,9 @@ import { getDict } from '@/apiRc/system/dict.js';
export function createApp() { export function createApp() {
const app = createSSRApp(App) const app = createSSRApp(App)
// 注册全局分享混入
app.mixin(shareMixin)
app.component('AppLayout', AppLayout) app.component('AppLayout', AppLayout)
app.component('Empty', Empty) app.component('Empty', Empty)
app.component('NoBouncePage', NoBouncePage) app.component('NoBouncePage', NoBouncePage)

16
mixins/share.js Normal file
View File

@@ -0,0 +1,16 @@
export default {
onShareAppMessage() {
return {
title: '喀什智慧就业平台',
path: '/pages/index/index',
imageUrl: ''
};
},
onShareTimeline() {
return {
title: '喀什智慧就业平台',
path: '/pages/index/index',
imageUrl: ''
};
}
};

View File

@@ -0,0 +1,99 @@
<template>
<AppLayout :show-bg-image="false" :use-scroll-view="false">
<view class="collection-content">
<scroll-view scroll-y class="main-scroll" @scrolltolower="getJobList('add')">
<view class="one-cards">
<view class="mian">
<renderJobs
seeDate="shareTime"
v-if="pageState.list.length"
:list="pageState.list"
:longitude="longitudeVal"
:latitude="latitudeVal"
></renderJobs>
<empty v-else pdTop="200"></empty>
</view>
</view>
</scroll-view>
</view>
</AppLayout>
</template>
<script setup>
import dictLabel from '@/components/dict-Label/dict-Label.vue';
import AppLayout from '@/components/AppLayout/AppLayout.vue';
import { reactive, inject, watch, ref, onMounted } from 'vue';
import { onLoad, onShow, onReachBottom } from '@dcloudio/uni-app';
import useUserStore from '@/stores/useUserStore';
const { $api, navTo, vacanciesTo } = inject('globalFunction');
import { storeToRefs } from 'pinia';
import useLocationStore from '@/stores/useLocationStore';
const { longitudeVal, latitudeVal } = storeToRefs(useLocationStore());
const userStore = useUserStore();
const state = reactive({});
const pageState = reactive({
page: 0,
list: [],
total: 0,
maxPage: 1,
pageSize: 10,
});
onLoad(() => {
console.log('onLoad');
getJobList('refresh');
});
onReachBottom(() => {
getJobList();
});
function navToPost(jobId) {
navTo(`/packageA/pages/post/post?jobId=${encodeURIComponent(jobId)}`);
}
function getJobList(type = 'add') {
if (type === 'refresh') {
pageState.page = 1;
pageState.maxPage = 1;
}
if (type === 'add' && pageState.page < pageState.maxPage) {
pageState.page += 1;
}
let params = {
current: pageState.page,
pageSize: pageState.pageSize,
};
$api.createRequest('/app/job/selectCencalList', params, 'GET').then((resData) => {
const { rows, total } = resData;
if (type === 'add') {
const str = pageState.pageSize * (pageState.page - 1);
const end = pageState.list.length;
const reslist = rows;
pageState.list.splice(str, end, ...reslist);
} else {
pageState.list = rows;
}
pageState.total = resData.total;
pageState.maxPage = Math.ceil(pageState.total / pageState.pageSize);
console.log(pageState.list);
});
}
</script>
<style lang="stylus" scoped>
.collection-content
height: 100%
display: flex
flex-direction: column
.main-scroll{
flex: 1
overflow: hidden
}
.one-cards{
padding: 0 28rpx 20rpx 28rpx;
background: #f4f4f4
}
</style>

View File

@@ -76,6 +76,18 @@
<view class="picker-text" data-placeholder="请选择学历要求">{{ selectedEducation || '请选择学历要求' }}</view> <view class="picker-text" data-placeholder="请选择学历要求">{{ selectedEducation || '请选择学历要求' }}</view>
</picker> </picker>
</view> </view>
<view class="form-group">
<view class="label">人员类型</view>
<picker
mode="selector"
:range="staffTypes"
range-key="label"
@change="onStaffTypeChange"
class="picker"
>
<view class="picker-text" data-placeholder="请选择人员类型">{{ selectedStaffType || '请选择人员类型' }}</view>
</picker>
</view>
<view class="form-group"> <view class="form-group">
<view class="label">工作经验</view> <view class="label">工作经验</view>
<picker <picker
@@ -302,6 +314,7 @@ const formData = reactive({
jobLocationAreaCode: '', // 新增:工作地点区县字典代码 jobLocationAreaCode: '', // 新增:工作地点区县字典代码
education: '', // 新增:学历要求字典值 education: '', // 新增:学历要求字典值
experience: '', // 新增:工作经验字典值 experience: '', // 新增:工作经验字典值
staffType: '', // 新增:人员类型
images: [], // 新增:岗位图片 images: [], // 新增:岗位图片
contacts: [ contacts: [
{ {
@@ -324,6 +337,7 @@ const experienceLevels = ref([]);
const workDistricts = ref([]); const workDistricts = ref([]);
const workLocations = ref([]); const workLocations = ref([]);
const jobCategories = ref([]); // 新增:岗位分类选项 const jobCategories = ref([]); // 新增:岗位分类选项
const staffTypes = ref([]); // 新增:人员类型选项
// 选中的值 // 选中的值
const selectedEducation = ref(''); const selectedEducation = ref('');
@@ -333,6 +347,7 @@ const selectedWorkLocation = ref('');
const selectedJobCategory = ref(''); const selectedJobCategory = ref('');
const selectedJobTypeLabel = ref(''); // 新增:岗位类型显示文本 const selectedJobTypeLabel = ref(''); // 新增:岗位类型显示文本
const selectedJobTypeIds = ref(''); // 新增岗位类型ID多个用逗号分隔 const selectedJobTypeIds = ref(''); // 新增岗位类型ID多个用逗号分隔
const selectedStaffType = ref(''); // 新增:人员类型显示文本
// 滚动视图高度 // 滚动视图高度
const scrollViewHeight = ref('calc(100vh - 200rpx)'); const scrollViewHeight = ref('calc(100vh - 200rpx)');
@@ -451,6 +466,11 @@ const initFormData = async () => {
} }
console.log('岗位分类选项:', jobCategories.value); console.log('岗位分类选项:', jobCategories.value);
// 设置人员类型选项 - 从字典获取 staffType
const staffTypeDict = await dictStore.getDictSelectOption('staff_type');
console.log('从字典获取的人员类型数据:', staffTypeDict);
staffTypes.value = staffTypeDict;
// 设置企业ID从用户信息获取 // 设置企业ID从用户信息获取
if (userStore.userInfo && userStore.userInfo.id) { if (userStore.userInfo && userStore.userInfo.id) {
formData.companyId = userStore.userInfo.id; formData.companyId = userStore.userInfo.id;
@@ -512,6 +532,14 @@ const onJobCategoryChange = (e) => {
formData.type = selectedItem.value; // 岗位分类保存到type字段 formData.type = selectedItem.value; // 岗位分类保存到type字段
}; };
// 新增人员类型选择器change事件
const onStaffTypeChange = (e) => {
const index = e.detail.value;
const selectedItem = staffTypes.value[index];
selectedStaffType.value = selectedItem.label;
formData.staffType = selectedItem.value; // 人员类型保存到staffType字段
};
// 打开岗位类型选择器 // 打开岗位类型选择器
const openJobTypeSelector = () => { const openJobTypeSelector = () => {
if (!jobTypeSelector.value) return; if (!jobTypeSelector.value) return;
@@ -706,6 +734,7 @@ const publishJob = async () => {
jobCategory: formData.jobCategory, // 岗位类型 jobCategory: formData.jobCategory, // 岗位类型
companyId: formData.companyId, companyId: formData.companyId,
companyName: formData.companyName, companyName: formData.companyName,
staffType: formData.staffType, // 新增:人员类型
jobContactList: formData.contacts.filter(contact => contact.name.trim() && contact.phone.trim()).map(contact => ({ jobContactList: formData.contacts.filter(contact => contact.name.trim() && contact.phone.trim()).map(contact => ({
contactPerson: contact.name, contactPerson: contact.name,
contactPersonPhone: contact.phone, contactPersonPhone: contact.phone,
@@ -760,6 +789,7 @@ const validateForm = () => {
{ field: 'minSalary', message: '请输入最小薪资' }, { field: 'minSalary', message: '请输入最小薪资' },
{ field: 'maxSalary', message: '请输入最大薪资' }, { field: 'maxSalary', message: '请输入最大薪资' },
{ field: 'education', message: '请选择学历要求' }, { field: 'education', message: '请选择学历要求' },
{ field: 'staffType', message: '请选择人员类型' },
{ field: 'experience', message: '请选择工作经验' }, { field: 'experience', message: '请选择工作经验' },
{ field: 'jobLocation', message: '请选择工作地点' }, { field: 'jobLocation', message: '请选择工作地点' },
{ field: 'jobLocationAreaCode', message: '请选择工作区县' }, { field: 'jobLocationAreaCode', message: '请选择工作区县' },

View File

@@ -32,9 +32,9 @@
<!-- 控制栏 --> <!-- 控制栏 -->
<view class="controls"> <view class="controls">
<!-- <text @click="togglePlay">{{ isPlaying ? '暂停' : '播放' }}</text> <!-- <view class="control-text" @click="togglePlay">{{ isPlaying ? '暂停' : '播放' }}</view>
<text @click="toggleFullScreen">{{ isFullScreen ? '退出全屏' : '全屏' }}</text> --> <view class="control-text" @click="toggleFullScreen">{{ isFullScreen ? '退出全屏' : '全屏' }}</view> -->
<text @click="close">关闭</text> <view class="control-text" @click="close">关闭</view>
</view> </view>
</view> </view>
</template> </template>
@@ -180,7 +180,7 @@ defineExpose({ open });
justify-content: space-around; justify-content: space-around;
} }
.controls text { .controls .control-text {
color: #fff; color: #fff;
font-size: 24rpx; font-size: 24rpx;
padding: 8rpx 16rpx; padding: 8rpx 16rpx;

View File

@@ -218,10 +218,22 @@
<view style="height: 34px"></view> <view style="height: 34px"></view>
<template #footer> <template #footer>
<view class="footer"> <view class="footer">
<view class="btn-wq button-click" @click="jobApply">投递简历</view> <view class="btn-wq button-click" @click="jobApply">{{ jobInfo.isApply === 1 ? '取消投递简历' : '投递简历' }}</view>
</view> </view>
</template> </template>
<VideoPlayer ref="videoPalyerRef" /> <VideoPlayer ref="videoPalyerRef" />
<!-- 确认弹窗 -->
<view v-if="showConfirmDialog" class="confirm-dialog">
<view class="dialog-content">
<view class="dialog-title">{{ jobInfo.isApply === 1 ? '确认取消投递' : '确认投递' }}</view>
<view class="dialog-message">{{ jobInfo.isApply === 1 ? '确定要取消投递此职位吗?' : '确定要投递此职位吗?' }}</view>
<view class="dialog-buttons">
<view class="btn-cancel button-click" @click="hideDialog">取消</view>
<view class="btn-confirm button-click" @click="confirmAction">确认</view>
</view>
</view>
</view>
</AppLayout> </AppLayout>
</template> </template>
@@ -229,7 +241,7 @@
import point from '@/static/icon/point.png'; import point from '@/static/icon/point.png';
import VideoPlayer from './component/videoPlayer.vue'; import VideoPlayer from './component/videoPlayer.vue';
import { reactive, inject, watch, ref, onMounted, computed } from 'vue'; import { reactive, inject, watch, ref, onMounted, computed } from 'vue';
import { onLoad, onShow, onHide } from '@dcloudio/uni-app'; import { onLoad, onShow, onHide, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app';
import dictLabel from '@/components/dict-Label/dict-Label.vue'; import dictLabel from '@/components/dict-Label/dict-Label.vue';
import RadarMap from './component/radarMap.vue'; import RadarMap from './component/radarMap.vue';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
@@ -266,6 +278,7 @@ const raderData = ref({
}); });
const videoPalyerRef = ref(null); const videoPalyerRef = ref(null);
const explainUrlRef = ref(''); const explainUrlRef = ref('');
const showConfirmDialog = ref(false);
// 申请人列表直接使用接口返回的applyUsers数组 // 申请人列表直接使用接口返回的applyUsers数组
@@ -351,10 +364,10 @@ function getCompanyIsAJobs(companyId) {
} }
function getTextWidth(text, size = 12) { function getTextWidth(text, size = 12) {
const canvas = document.createElement('canvas'); // 在小程序环境中document 对象不存在,使用估算方法
const context = canvas.getContext('2d'); // 简单估算:每个字符大约占 8px 宽度
context.font = `${12}px Arial`; const estimatedWidth = text.length * 8;
return -(context.measureText(text).width / 2) - 20; // 计算文字中心点 return -(estimatedWidth / 2) - 20; // 计算文字中心点
} }
function getCompetivetuveness(jobId) { function getCompetivetuveness(jobId) {
@@ -422,24 +435,53 @@ function jobApply() {
$api.msg('请您先登录'); $api.msg('请您先登录');
return; return;
} }
// 显示确认弹窗
showConfirmDialog.value = true;
}
// 隐藏弹窗
function hideDialog() {
showConfirmDialog.value = false;
}
// 确认操作
function confirmAction() {
const jobId = jobInfo.value.jobId;
if (jobInfo.value.isApply === 1) {
// 取消投递
$api.createRequest(`/app/job/applyJobCencal`, { jobId }, 'DELETE').then((resData) => {
$api.msg('取消投递成功');
getDetail(jobId); // 刷新职位信息
showConfirmDialog.value = false;
});
} else {
// 确认投递
$api.createRequest(`/app/job/apply/${jobId}`, {}, 'GET').then((resData) => {
$api.msg('申请成功');
getDetail(jobId); // 刷新职位信息
showConfirmDialog.value = false;
});
}
}
// 确认投递
function confirmApply() {
const jobId = jobInfo.value.jobId; const jobId = jobInfo.value.jobId;
$api.createRequest(`/app/job/apply/${jobId}`, {}, 'GET').then((resData) => { $api.createRequest(`/app/job/apply/${jobId}`, {}, 'GET').then((resData) => {
getDetail(jobId); $api.msg('申请成功');
$api.msg('申请成功'); const jobUrl = jobInfo.value.jobUrl;
const jobUrl = jobInfo.value.jobUrl; // return window.open(jobUrl);
// return window.open(jobUrl); showConfirmDialog.value = false;
}); });
// if (jobInfo.value.isApply) { }
// const jobUrl = jobInfo.value.jobUrl;
// return window.open(jobUrl); // 取消投递
// } else { function cancelApply() {
// $api.createRequest(`/app/job/apply/${jobId}`, {}, 'GET').then((resData) => { const jobId = jobInfo.value.jobId;
// getDetail(jobId); $api.createRequest(`/app/job/applyJobCencal`, { jobId }, 'DELETE').then((resData) => {
// $api.msg('申请成功'); $api.msg('取消投递成功');
// const jobUrl = jobInfo.value.jobUrl; showConfirmDialog.value = false;
// return window.open(jobUrl); });
// });
// }
} }
// 取消/收藏岗位 // 取消/收藏岗位
@@ -524,6 +566,24 @@ function handleCompanyDetailClick() {
$api.msg('没有企业信息'); $api.msg('没有企业信息');
} }
} }
// 分享给朋友
onShareAppMessage(() => {
return {
title: '喀什智慧就业平台',
path: '/pages/index/index',
imageUrl: ''
};
});
// 分享到朋友圈
onShareTimeline(() => {
return {
title: '喀什智慧就业平台',
path: '/pages/index/index',
imageUrl: ''
};
});
</script> </script>
<style lang="stylus" scoped> <style lang="stylus" scoped>
@@ -966,4 +1026,70 @@ for i in 0..100
font-weight: bold; font-weight: bold;
color: #333; color: #333;
} }
// 确认弹窗样式
.confirm-dialog {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 9999;
}
.dialog-content {
width: 80%;
max-width: 500rpx;
background-color: #fff;
border-radius: 20rpx;
padding: 40rpx;
display: flex;
flex-direction: column;
align-items: center;
}
.dialog-title {
font-size: 32rpx;
font-weight: 600;
color: #333;
margin-bottom: 30rpx;
}
.dialog-message {
font-size: 28rpx;
color: #666;
margin-bottom: 40rpx;
text-align: center;
}
.dialog-buttons {
width: 100%;
display: flex;
justify-content: space-between;
gap: 20rpx;
}
.btn-cancel, .btn-confirm {
flex: 1;
height: 80rpx;
border-radius: 12rpx;
display: flex;
justify-content: center;
align-items: center;
font-size: 28rpx;
font-weight: 500;
}
.btn-cancel {
background-color: #f5f5f5;
color: #666;
}
.btn-confirm {
background-color: #256BFA;
color: #fff;
}
</style> </style>

View File

@@ -1,11 +1,11 @@
<!-- <!--
* @Date: 2025-10-16 15:15:47 * @Date: 2025-10-16 15:15:47
* @LastEditors: lip * @LastEditors: shirlwang
* @LastEditTime: 2026-01-14 21:33:18 * @LastEditTime: 2026-01-27 09:30:18
--> -->
<template> <template>
<!-- @scroll="handleScroll" @scrolltolower="scrollBottom" --> <!-- @scroll="handleScroll" @scrolltolower="scrollBottom" -->
<scroll-view :scroll-y="true" class="container" style="background-image: url('../../../packageRc/static/pageBg.png');"> <scroll-view :scroll-y="true" class="container" style="background-image: url('../../../packageRc/static/pageBgIndex.png');">
<view style="padding: 40rpx 28rpx;"> <view style="padding: 40rpx 28rpx;">
<!-- #ifdef MP-WEIXIN --> <!-- #ifdef MP-WEIXIN -->
<view class="kinggang"> <view class="kinggang">
@@ -31,54 +31,69 @@
</view> </view>
</view> </view>
<!-- #endif --> <!-- #endif -->
<view class="tabs"> <view class="showtab">
<view class="tab" :class="{active: pageState.type == ''}" @click="changeJobType('')">岗位列表</view> <view class="tabItem" @click="changeType(1)">
<view class="tab" :class="{active: pageState.type == 2}" @click="changeJobType(2)">实习实训</view> <image src="/packageRc/static/gw.png"/>
<view class="tab" :class="{active: pageState.type == 3}" @click="changeJobType(3)">社区实践</view> <image v-show="tabType == 1" class="activeImg" src="/packageRc/static/activeTangle.png"/>
</view>
<view class="tabItem" @click="changeType(2)">
<image src="/packageRc/static/zc.png"/>
<image v-show="tabType == 2" class="activeImg" src="/packageRc/static/activeTangle.png"/>
</view>
</view> </view>
<view class="titles"> <template v-if="tabType == 1">
<view class="title-item" :class="{active: activeTitle == 1}" @click="activeTitle = 1,getJobRecommed()"><view>推荐岗位</view></view> <view class="tabs">
<view class="title-item" :class="{active: activeTitle == 2}" @click="activeTitle = 2,getJobList()"><view>热门岗位</view></view> <view class="tab" :class="{active: pageState.type == ''}" @click="changeJobType('')">岗位列表</view>
</view> <view class="tab" :class="{active: pageState.type == 2}" @click="changeJobType(2)">实习实训</view>
<view v-for="(item, index) in jobList" :key="index" @click="nextDetail(item)" class="job-list"> <view class="tab" :class="{active: pageState.type == 3}" @click="changeJobType(3)">社区实践</view>
<view class="top-line">
<view class="salary">{{item.minSalary}}-{{item.maxSalary}}/</view>
<view class="time"><uni-icons color="#A2A2A2" type="info" size="12"></uni-icons>发布日期{{ item.postingDate }}</view>
</view> </view>
<view class="title">{{ item.jobTitle }}</view> <view class="titles">
<view class="infos"> <view class="title-item" :class="{active: activeTitle == 1}" @click="activeTitle = 1,getJobRecommed()"><view>推荐岗位</view></view>
<view> <view class="title-item" :class="{active: activeTitle == 2}" @click="activeTitle = 2,getJobList()"><view>热门岗位</view></view>
<dict-Label dictType="education" :value="item.education"></dict-Label> </view>
<view v-for="(item, index) in jobList" :key="index" @click="nextDetail(item)" class="job-list">
<view class="top-line">
<view class="salary">{{item.minSalary}}-{{item.maxSalary}}/</view>
<view class="time"><uni-icons color="#A2A2A2" type="info" size="12"></uni-icons>发布日期{{ item.postingDate }}</view>
</view> </view>
<view> <view class="title">{{ item.jobTitle }}</view>
<dict-Label dictType="experience" :value="item.experience"></dict-Label> <view class="infos">
<view>
<dict-Label dictType="education" :value="item.education"></dict-Label>
</view>
<view>
<dict-Label dictType="experience" :value="item.experience"></dict-Label>
</view>
<view>{{ item.jobLocation }}</view>
</view>
<view class="bottom-line">
<view><uni-icons color="#A2A2A2" type="person" size="12"></uni-icons>{{item.vacancies}}</view>
<view>{{ item.companyName }}</view>
</view> </view>
<view>{{ item.jobLocation }}</view>
</view> </view>
<view class="bottom-line">
<view><uni-icons color="#A2A2A2" type="person" size="12"></uni-icons>{{item.vacancies}}</view>
<view>{{ item.companyName }}</view>
</view>
</view>
<view class="view-more-btn" @click="viewMore">查看更多内容</view> <view class="view-more-btn" @click="viewMore">查看更多内容</view>
<view class="titles" style="justify-content: space-between;">
<view class="title-item active"><view>政策专区</view></view> </template>
<view @click="toPolicyList">{{'查看更多 >'}}</view> <template v-else>
</view> <view class="titles" style="justify-content: space-between;">
<view v-for="(item, index) in policyList" :key="index" class="job-list" @click="toPolicyDetail(item)"> <view class="title-item active"><view>政策专区</view></view>
<view class="sign">推荐</view> <view @click="toPolicyList">{{'查看更多 >'}}</view>
<view class="title">
<image src="../../../packageRc/static/zcLeft.png"/>
{{item.zcmc}}</view>
<view class="infos">
<view v-if="item.zcLevel">{{item.zcLevel}}</view>
<view v-if="item.sourceUnit">{{item.sourceUnit}}</view>
</view> </view>
<view class="bottom-line"> <view v-for="(item, index) in policyList" :key="index" class="job-list" @click="toPolicyDetail(item)">
<view><uni-icons color="#A2A2A2" type="info" size="12"></uni-icons>发布日期:{{item.createTime}}</view> <view class="sign">推荐</view>
<view class="title">
<image src="../../../packageRc/static/zcLeft.png"/>
{{item.zcmc}}</view>
<view class="infos">
<view v-if="item.zcLevel">{{item.zcLevel}}</view>
<view v-if="item.sourceUnit">{{item.sourceUnit}}</view>
</view>
<view class="bottom-line">
<view><uni-icons color="#A2A2A2" type="info" size="12"></uni-icons>发布日期:{{item.createTime}}</view>
</view>
</view> </view>
</view> </template>
</view> </view>
</scroll-view> </scroll-view>
</template> </template>
@@ -94,7 +109,10 @@ function getPolicy() {
policyList.value = res.rows policyList.value = res.rows
}) })
} }
let tabType = ref(1)
function changeType(type) {
tabType.value = type
}
function toPolicyList() { function toPolicyList() {
navTo(`/packageRc/pages/policy/policyList?zclx=1`) navTo(`/packageRc/pages/policy/policyList?zclx=1`)
} }
@@ -377,4 +395,22 @@ view{box-sizing: border-box;display: block;}
margin: 0 auto; margin: 0 auto;
margin-bottom: 20rpx; margin-bottom: 20rpx;
} }
.showtab{
display: flex;
justify-content: space-between;
margin-bottom: 40rpx;
.tabItem{
position: relative;
width: calc(50% - 8rpx);
height: 144rpx;
}
.activeImg{
position: absolute;
width: 143rpx;
height: 18rpx;
bottom: -24rpx;
right: 50%;
transform: translateX(50%);
}
}
</style> </style>

Binary file not shown.

After

Width:  |  Height:  |  Size: 734 B

BIN
packageRc/static/gw.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

BIN
packageRc/static/zc.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -39,7 +39,7 @@
{ {
"path": "pages/chat/chat", "path": "pages/chat/chat",
"style": { "style": {
"navigationBarTitleText": "AI+", "navigationBarTitleText": "智能客服",
"navigationBarBackgroundColor": "#4778EC", "navigationBarBackgroundColor": "#4778EC",
"navigationBarTextStyle": "white", "navigationBarTextStyle": "white",
"enablePullDownRefresh": false "enablePullDownRefresh": false
@@ -312,6 +312,13 @@
"navigationBarBackgroundColor": "#4778EC", "navigationBarBackgroundColor": "#4778EC",
"navigationBarTextStyle": "white" "navigationBarTextStyle": "white"
} }
},
{
"path": "pages/cancelApplication/cancelApplication",
"style": {
"navigationBarTitleText": "取消投递",
"navigationBarBackgroundColor": "#FFFFFF"
}
} }
] ]
}, },

View File

@@ -61,6 +61,7 @@
<view class="main-header"> <view class="main-header">
<image src="/static/icon/Hamburger-button.png" @click="toggleDrawer"></image> <image src="/static/icon/Hamburger-button.png" @click="toggleDrawer"></image>
<view class="title">{{ config.appInfo.areaName }}岗位推荐</view> <view class="title">{{ config.appInfo.areaName }}岗位推荐</view>
<!-- <view class="title">智能客服</view> -->
<image src="/static/icon/Comment-one.png" @click="addNewDialogue"></image> <image src="/static/icon/Comment-one.png" @click="addNewDialogue"></image>
</view> </view>
</header> </header>

View File

@@ -65,6 +65,26 @@ const centerIndex = ref(0);
// 动画帧ID // 动画帧ID
let animationId = null; let animationId = null;
// 为小程序环境提供requestAnimationFrame兼容
const requestAnimationFramePolyfill = (callback) => {
// #ifdef MP-WEIXIN
return setTimeout(callback, 16); // 约60fps
// #endif
// #ifdef H5
return requestAnimationFrame(callback);
// #endif
};
// 为小程序环境提供cancelAnimationFrame兼容
const cancelAnimationFramePolyfill = (id) => {
// #ifdef MP-WEIXIN
clearTimeout(id);
// #endif
// #ifdef H5
cancelAnimationFrame(id);
// #endif
};
// 格式化显示时间 // 格式化显示时间
const formattedTime = computed(() => { const formattedTime = computed(() => {
const mins = Math.floor(props.recordingTime / 60) const mins = Math.floor(props.recordingTime / 60)
@@ -125,7 +145,7 @@ const updateWaveform = () => {
} }
} }
animationId = requestAnimationFrame(updateWaveform); animationId = requestAnimationFramePolyfill(updateWaveform);
}; };
// 更新单个波形条 // 更新单个波形条
@@ -157,14 +177,14 @@ const updateWaveBar = (index, value) => {
// 开始动画 // 开始动画
const startAnimation = () => { const startAnimation = () => {
if (!animationId) { if (!animationId) {
animationId = requestAnimationFrame(updateWaveform); animationId = requestAnimationFramePolyfill(updateWaveform);
} }
}; };
// 停止动画 // 停止动画
const stopAnimation = () => { const stopAnimation = () => {
if (animationId) { if (animationId) {
cancelAnimationFrame(animationId); cancelAnimationFramePolyfill(animationId);
animationId = null; animationId = null;
} }
}; };

View File

@@ -133,6 +133,20 @@
<view class="chat-item self" v-if="isRecording"> <view class="chat-item self" v-if="isRecording">
<view class="message">{{ recognizedText }} {{ lastFinalText }}</view> <view class="message">{{ recognizedText }} {{ lastFinalText }}</view>
</view> </view>
<!-- 语音正在识别提示 -->
<!-- <view>{{isRecognizing}}</view> -->
<view class="chat-item self" v-if="isRecognizing">
<view class="message msg-loading">
<view class="loading-content">
<view class="ai-loading">
<view></view>
<view></view>
<view></view>
</view>
<text class="loading-text">正在识别语音...</text>
</view>
</view>
</view>
<view v-if="isTyping" class="self"> <view v-if="isTyping" class="self">
<view class="message msg-loading"> <view class="message msg-loading">
<view class="loading-content"> <view class="loading-content">
@@ -175,9 +189,6 @@
@touchmove="handleTouchMove" @touchmove="handleTouchMove"
@touchend="handleTouchEnd" @touchend="handleTouchEnd"
@touchcancel="handleTouchCancel" @touchcancel="handleTouchCancel"
:catchtouchstart="true"
:catchtouchmove="true"
:catchtouchend="true"
v-show="isVoice" v-show="isVoice"
type="default" type="default"
> >
@@ -294,11 +305,11 @@ import FileIcon from './fileIcon.vue';
import FileText from './fileText.vue'; import FileText from './fileText.vue';
import { useAudioRecorder } from '@/hook/useRealtimeRecorder.js'; import { useAudioRecorder } from '@/hook/useRealtimeRecorder.js';
import { useTTSPlayer } from '@/hook/useTTSPlayer.js'; import { useTTSPlayer } from '@/hook/useTTSPlayer.js';
import successIcon from '@/static/icon/success.png';
// 全局 // 全局
const { $api, navTo, throttle, config } = inject('globalFunction'); const { $api, navTo, throttle, config } = inject('globalFunction');
const emit = defineEmits(['onConfirm']); const emit = defineEmits(['onConfirm']);
const { messages, isTyping, textInput, chatSessionID } = storeToRefs(useChatGroupDBStore()); const { messages, isTyping, textInput, chatSessionID } = storeToRefs(useChatGroupDBStore());
import successIcon from '@/static/icon/success.png';
// hook // hook
const { const {
isRecording, isRecording,
@@ -309,8 +320,32 @@ const {
volumeLevel, volumeLevel,
recognizedText, recognizedText,
lastFinalText, lastFinalText,
recordingDuration,
isRecognizing,
reset
} = useAudioRecorder(); } = useAudioRecorder();
// 监听语音识别结果变化,自动发送消息
watch(
() => recognizedText.value,
(newVal) => {
if (newVal && newVal.trim()) {
console.log('监听到语音识别结果变化,自动发送消息:', newVal);
sendMessage(newVal);
}
}
);
// 监听isRecognizing状态显示提示
watch(
() => isRecognizing.value,
(newVal) => {
if (newVal) {
$api.msg('正在识别语音...');
}
}
);
const { speak, pause, resume, isSpeaking, isPaused, cancelAudio } = useTTSPlayer(config.speechSynthesis); const { speak, pause, resume, isSpeaking, isPaused, cancelAudio } = useTTSPlayer(config.speechSynthesis);
// 获取组件实例(用于小程序 SelectorQuery // 获取组件实例(用于小程序 SelectorQuery
@@ -362,6 +397,7 @@ onMounted(async () => {
changeQueries(); changeQueries();
scrollToBottom(); scrollToBottom();
isAudioPermission.value = await requestMicPermission(); isAudioPermission.value = await requestMicPermission();
reset(); // 重置语音识别状态
}); });
const requestMicPermission = async () => { const requestMicPermission = async () => {
@@ -443,11 +479,16 @@ const sendMessage = (text) => {
console.log('📝 Has job info:', hasJobInfo); console.log('📝 Has job info:', hasJobInfo);
// 开始朗读当前消息 // 开始朗读当前消息
speechIndex.value = index; speechIndex.value = index;
readMarkdown(message.displayText, index); readMarkdown(message.displayText, index, { immediate: false });
// 一旦开始朗读就设置speechIndex避免重复调用
speechIndex.value = index;
} else { } else {
console.log('⏳ Waiting for more content before TTS, current length:', message.displayText.length); console.log('⏳ Waiting for more content before TTS, current length:', message.displayText.length);
} }
} else {
// 已经开始朗读这条消息,不再重复调用
console.log('⏭️ Already speaking this message, skipping duplicate TTS call');
} }
} }
}, },
@@ -472,7 +513,7 @@ const sendMessage = (text) => {
// 开始朗读完整的内容 // 开始朗读完整的内容
speechIndex.value = lastMessageIndex; speechIndex.value = lastMessageIndex;
readMarkdown(lastMessage.displayText, lastMessageIndex); readMarkdown(lastMessage.displayText, lastMessageIndex, { immediate: true });
} }
} }
}, },
@@ -684,17 +725,22 @@ const handleTouchEnd = () => {
if (status.value === 'cancel') { if (status.value === 'cancel') {
console.log('取消发送'); console.log('取消发送');
cancelRecording(); cancelRecording();
status.value = 'idle';
} else { } else {
stopRecording(); stopRecording();
if (isAudioPermission.value) { if (isAudioPermission.value) {
if (recognizedText.value) { // 主要根据录音时长判断,而不是完全依赖识别结果
sendMessage(recognizedText.value); // 由于setInterval是异步的这里需要考虑计时延迟
} else { const actualDuration = recordingDuration.value > 0 ? recordingDuration.value : (isRecording.value ? 0.5 : 0);
if (actualDuration < 1) {
$api.msg('说话时长太短'); $api.msg('说话时长太短');
status.value = 'idle';
} else {
// 状态管理由useAudioRecorder hook内部处理
status.value = 'idle';
} }
} }
} }
status.value = 'idle';
}; };
const handleTouchCancel = () => { const handleTouchCancel = () => {
@@ -740,7 +786,10 @@ function confirmFeeBack(value) {
// 防抖定时器 // 防抖定时器
let ttsDebounceTimer = null; let ttsDebounceTimer = null;
function readMarkdown(value, index) { // 保存上一次调用的文本内容避免重复调用TTS
let lastSpeechText = '';
function readMarkdown(value, index, options = {}) {
console.log('🎤 readMarkdown called'); console.log('🎤 readMarkdown called');
console.log('📝 Text to speak:', value ? value.substring(0, 100) + '...' : 'No text'); console.log('📝 Text to speak:', value ? value.substring(0, 100) + '...' : 'No text');
console.log('🔢 Index:', index); console.log('🔢 Index:', index);
@@ -753,40 +802,37 @@ function readMarkdown(value, index) {
clearTimeout(ttsDebounceTimer); clearTimeout(ttsDebounceTimer);
} }
// 如果当前正在播放其他消息,先停止 // 总是先停止当前播放,无论是不是同一消息
if (speechIndex.value !== index && speechIndex.value !== 0) { console.log('🛑 Always stopping current speech before starting new one');
console.log('🛑 Stopping current speech and starting new one');
speechIndex.value = index;
speak(value);
return;
}
speechIndex.value = index; speechIndex.value = index;
// 如果当前正在播放且暂停了,直接恢复 // 立即调用speak不使用防抖延迟
if (isPaused.value && isSpeaking.value) { const speakNow = () => {
console.log('▶️ Resuming paused speech'); // 检查文本内容是否发生变化避免重复调用TTS
resume(); if (value !== lastSpeechText) {
return; console.log('🎵 Starting new speech');
} console.log('🎵 Calling speak function with text length:', value ? value.length : 0);
try {
// 如果当前正在播放且没有暂停,不需要重新开始 speak(value);
if (isSpeaking.value && !isPaused.value) { console.log('✅ Speak function called successfully');
console.log('🔊 Already speaking, no need to restart'); // 更新上一次调用的文本内容
return; lastSpeechText = value;
} } catch (error) {
console.error('❌ Error calling speak function:', error);
// 使用防抖避免频繁调用TTS }
ttsDebounceTimer = setTimeout(() => { } else {
console.log('🎵 Starting new speech'); console.log('🔄 Same text as last speech, skipping duplicate TTS call');
console.log('🎵 Calling speak function with text length:', value ? value.length : 0);
try {
speak(value);
console.log('✅ Speak function called successfully');
} catch (error) {
console.error('❌ Error calling speak function:', error);
} }
}, 300); // 300ms防抖延迟 };
// 改进防抖逻辑,确保在短时间内只调用一次
if (options.immediate) {
// 如果是onComplete回调立即播放
speakNow();
} else {
// 对于流式数据,总是使用防抖,避免频繁调用
ttsDebounceTimer = setTimeout(speakNow, 500); // 延长防抖时间到500ms
}
} }
function stopMarkdown(value, index) { function stopMarkdown(value, index) {
console.log('⏸️ stopMarkdown called for index:', index); console.log('⏸️ stopMarkdown called for index:', index);
@@ -1118,6 +1164,11 @@ image-margin-top = 40rpx
-moz-user-select:none; -moz-user-select:none;
-ms-user-select:none; -ms-user-select:none;
touch-action: none; /* 禁用默认滚动 */ touch-action: none; /* 禁用默认滚动 */
position: fixed;
left: 0;
right: 0;
bottom: 160rpx; /* 为底部导航栏留出空间 */
z-index: 9999; /* 确保高于其他元素 */
.record-tip .record-tip
font-weight: 400; font-weight: 400;
color: #909090; color: #909090;

View File

@@ -1,5 +1,5 @@
<template> <template>
<view class="app-container"> <view class="app-container" @touchstart="handleTouchStart" @touchmove="handleTouchMove">
<!-- #ifdef MP-WEIXIN --> <!-- #ifdef MP-WEIXIN -->
<!-- 小程序背景图片 --> <!-- 小程序背景图片 -->
<image class="mp-background" src="/static/icon/background2.png" mode="aspectFill"></image> <image class="mp-background" src="/static/icon/background2.png" mode="aspectFill"></image>
@@ -110,7 +110,7 @@
<view class="service-icon service-icon-5"> <view class="service-icon service-icon-5">
<IconfontIcon name="jinengpeixun" :size="48" color="#FFFFFF" /> <IconfontIcon name="jinengpeixun" :size="48" color="#FFFFFF" />
</view> </view>
<view class="service-title">技能培训</view> <view class="service-title">技能课堂</view>
</view> </view>
<view class="service-item press-button" @click="handleServiceClick('skill-evaluation')"> <view class="service-item press-button" @click="handleServiceClick('skill-evaluation')">
<view class="service-icon service-icon-6"> <view class="service-icon service-icon-6">
@@ -136,12 +136,7 @@
</view> </view>
<view class="service-title">虚拟面试</view> <view class="service-title">虚拟面试</view>
</view> </view>
<view class="service-item press-button" style="justify-content:normal" @click="goRc()">
<view class="service-icon service-icon-9">
<IconfontIcon name="Graduation-simple-" :size="32" color="#FFFFFF" />
</view>
<view class="service-title" style="overflow:unset">高校毕业生<br/>智慧就业服务</view>
</view>
<view class="service-item press-button" @click="handleServiceClick('career-planning')"> <view class="service-item press-button" @click="handleServiceClick('career-planning')">
<view class="service-icon service-icon-11"> <view class="service-icon service-icon-11">
<image class="service-icon-img" src="/static/icon/antOutline.png" mode="aspectFit"></image> <image class="service-icon-img" src="/static/icon/antOutline.png" mode="aspectFit"></image>
@@ -185,6 +180,12 @@
</view> </view>
<view class="service-title">评价机构信息</view> <view class="service-title">评价机构信息</view>
</view> </view>
<view class="service-item press-button" style="justify-content:normal" @click="goRc()">
<view class="service-icon service-icon-9">
<IconfontIcon name="Graduation-simple-" :size="32" color="#FFFFFF" />
</view>
<view class="service-title" style="overflow:unset">高校毕业生<br/>智慧就业服务</view>
</view>
</view> </view>
<!-- #endif --> <!-- #endif -->
</view> </view>
@@ -229,7 +230,7 @@
<view class="nav-filter" :class="{ 'sticky-filter': shouldStickyFilter }" v-if="shouldShowJobSeekerContent"> <view class="nav-filter" :class="{ 'sticky-filter': shouldStickyFilter }" v-if="shouldShowJobSeekerContent">
<view class="filter-top" @touchmove.stop.prevent> <view class="filter-top">
<scroll-view :scroll-x="true" :show-scrollbar="false" class="tab-scroll"> <scroll-view :scroll-x="true" :show-scrollbar="false" class="tab-scroll">
<view class="jobs-left"> <view class="jobs-left">
<view <view
@@ -286,7 +287,6 @@
@scrolltolower="scrollBottom" @scrolltolower="scrollBottom"
:enable-back-to-top="false" :enable-back-to-top="false"
:scroll-with-animation="false" :scroll-with-animation="false"
@touchmove.stop.prevent
> >
<view class="falls" v-if="list.length"> <view class="falls" v-if="list.length">
<!-- #ifdef MP-WEIXIN --> <!-- #ifdef MP-WEIXIN -->
@@ -567,10 +567,48 @@ const lastScrollTop = ref(0);
const scrollTop = ref(0); const scrollTop = ref(0);
// 当用户与筛选/导航交互时,临时锁定头部显示状态,避免因数据刷新导致回弹显示 // 当用户与筛选/导航交互时,临时锁定头部显示状态,避免因数据刷新导致回弹显示
const isInteractingWithFilter = ref(false); const isInteractingWithFilter = ref(false);
// 触摸事件状态
const touchStartY = ref(0);
const touchMoveY = ref(0);
// 滚动阈值配置 // 滚动阈值配置
const HIDE_THRESHOLD = 50; // 隐藏顶部区域的滚动阈值(降低阈值,更容易触发) const HIDE_THRESHOLD = 50; // 隐藏顶部区域的滚动阈值(降低阈值,更容易触发)
const SHOW_THRESHOLD = 5; // 显示顶部区域的滚动阈值(接近顶部) const SHOW_THRESHOLD = 5; // 显示顶部区域的滚动阈值(接近顶部)
const STICKY_THRESHOLD = 80; // 筛选区域吸顶的滚动阈值 const STICKY_THRESHOLD = 80; // 筛选区域吸顶的滚动阈值
const TOUCH_MOVE_THRESHOLD = 30; // 触摸滑动阈值,用于判断是否为有效滑动
// 处理触摸开始事件
function handleTouchStart(e) {
// 记录触摸起始位置
touchStartY.value = e.touches[0].clientY;
}
// 处理触摸移动事件
function handleTouchMove(e) {
// 记录触摸移动位置
touchMoveY.value = e.touches[0].clientY;
// 计算滑动距离
const diffY = touchStartY.value - touchMoveY.value;
// 当向上滑动超过阈值时,隐藏顶部区域
if (diffY > TOUCH_MOVE_THRESHOLD) {
if (!shouldHideTop.value) {
shouldHideTop.value = true;
}
if (!shouldStickyFilter.value) {
shouldStickyFilter.value = true;
}
}
// 当向下滑动超过阈值且在顶部附近时,显示顶部区域
else if (diffY < -TOUCH_MOVE_THRESHOLD && scrollTop.value <= SHOW_THRESHOLD) {
if (shouldHideTop.value && !isInteractingWithFilter.value) {
shouldHideTop.value = false;
}
if (shouldStickyFilter.value) {
shouldStickyFilter.value = false;
}
}
}
// 简化的滚动处理函数 // 简化的滚动处理函数
function handleScroll(e) { function handleScroll(e) {
@@ -1511,10 +1549,10 @@ defineExpose({ loadData });
color: #256BFA color: #256BFA
// 服务功能网格样式 // 服务功能网格样式
.service-grid .service-grid
padding: 20rpx 28rpx padding: 10rpx 28rpx
display: grid display: grid
grid-template-columns: 1fr 1fr 1fr 1fr grid-template-columns: 1fr 1fr 1fr 1fr
grid-gap: 20rpx grid-gap: 10rpx
.service-item .service-item
display: flex display: flex
flex-direction: column flex-direction: column
@@ -1522,12 +1560,12 @@ defineExpose({ loadData });
justify-content: center justify-content: center
height: 120rpx height: 120rpx
background: transparent background: transparent
padding: 10px 0px padding: 2rpx 0px
.service-icon .service-icon
width: 88rpx width: 62rpx
height: 88rpx height: 62rpx
border-radius: 12rpx border-radius: 10rpx
margin-bottom: 8rpx margin-bottom: 14rpx
flex-shrink: 0 flex-shrink: 0
.service-icon-1 .service-icon-1
background: linear-gradient(180deg, #FF8E8E 0%, #E53E3E 100%) background: linear-gradient(180deg, #FF8E8E 0%, #E53E3E 100%)

View File

@@ -47,6 +47,10 @@
<view class="mini-num">{{ counts.fairCollecitonCount }}</view> <view class="mini-num">{{ counts.fairCollecitonCount }}</view>
<view class="mini-text">预约</view> <view class="mini-text">预约</view>
</view> </view>
<view class="numbe-item button-click" @click="navTo('/packageA/pages/cancelApplication/cancelApplication')">
<view class="mini-num">{{ counts.applyCencalCount || 0 }}</view>
<view class="mini-text">取消投递</view>
</view>
</view> </view>
<view class="mini-cards"> <view class="mini-cards">
<view class="card-top btn-feel"> <view class="card-top btn-feel">

View File

@@ -49,14 +49,12 @@ export function navigateToLoginPage(options = {}) {
return; return;
case 'h5': case 'h5':
if (loginType === 'idCard') { uni.showToast({
// H5端身份证号码登录 title: '请先登录',
loginPage = '/pages/login/id-card-login'; icon: 'none',
} else { duration: 2000
// H5端账号密码登录 });
loginPage = '/pages/login/h5-login'; return;
}
break;
case 'app': case 'app':
// App端使用微信授权登录 // App端使用微信授权登录

View File

@@ -5,6 +5,7 @@ import parseHtml from '@/lib/html-parser.js';
export let codeDataList = [] export let codeDataList = []
export let jobMoreMap = new Map() export let jobMoreMap = new Map()
export let jobCardsList = []
const md = new MarkdownIt({ const md = new MarkdownIt({
html: true, // 允许 HTML 标签 html: true, // 允许 HTML 标签
@@ -18,11 +19,47 @@ const md = new MarkdownIt({
if (lang === 'job-json') { if (lang === 'job-json') {
const result = safeExtractJson(str); const result = safeExtractJson(str);
if (result) { // json解析成功 if (result) { // json解析成功
const jobId = result.appJobUrl.split('jobId=')[1] let jobId = result.appJobUrl;
let domContext = `<a class="custom-card" data-job-id="${jobId}"><div class="card-title"><span class="title-text">${result.jobTitle}</span><div class="card-salary">${result.salary}</div></div><div class="card-company">${result.location}·${result.companyName}</div><div class="card-info"><div class="info-item"><div class="card-tag">${result.education}</div><div class="card-tag">${result.experience}</div></div><div class="info-item">查看详情<div class="position-nav"></div></div></div></a>` // If appJobUrl contains 'jobId=', extract the value after it, otherwise use it directly
if (jobId && jobId.includes('jobId=')) {
jobId = jobId.split('jobId=')[1];
// 如果还有额外的参数只取jobId部分
if (jobId.includes('&')) {
jobId = jobId.split('&')[0];
}
}
console.log('Job JSON result:', result, 'Extracted jobId:', jobId);
// 确保jobId有效
if (!jobId || jobId === 'undefined' || jobId === 'null') {
console.error('Invalid jobId extracted:', jobId, 'from appJobUrl:', result.appJobUrl);
// 尝试从其他字段获取jobId
if (result.jobId) {
jobId = result.jobId;
console.log('Using jobId from result.jobId:', jobId);
}
}
// 添加到岗位卡片列表,供小程序端单独渲染
jobCardsList.push({
jobId,
jobTitle: result.jobTitle,
salary: result.salary,
location: result.location,
companyName: result.companyName,
education: result.education,
experience: result.experience
});
// 生成岗位卡片HTML注意微信小程序rich-text组件只支持部分HTML属性
// 使用普通的href属性微信小程序rich-text会将其转换为可点击链接
// 添加data-job-id属性方便获取jobId
// 为所有平台添加onclick事件微信小程序可能会忽略但H5端会生效
// 内联基础样式确保在所有平台上正确显示使用px单位以确保H5兼容性
let domContext = `<a class="custom-card" href="/packageA/pages/post/post?jobId=${jobId}" data-job-id="${jobId}" data-jobid="${jobId}" onclick="if(typeof uni !== 'undefined'){uni.navigateTo({url: '/packageA/pages/post/post?jobId=${jobId}'});return false;}" style="display: flex; flex-direction: column; margin-bottom: 11px; background: #FFFFFF; box-shadow: 0 0 4px rgba(0,0,0,0.04); border-radius: 10px; padding: 14px 12px; font-weight: 400; font-size: 14px; color: #333333; text-decoration: none; overflow: hidden; box-sizing: border-box; width: 100%; max-width: 100%;"><div class="card-title" style="font-weight: 600; display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px;"><span class="title-text" style="font-family: 'PingFangSC-Medium', 'PingFang SC', 'Helvetica Neue', Helvetica, Arial, 'Microsoft YaHei', sans-serif; font-size: 16px; line-height: 1.4; color: #333333; max-width: calc(100% - 80px); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${result.jobTitle}</span><div class="card-salary" style="font-family: DIN-Medium; font-size: 16px; color: #4C6EFB; line-height: 1.4; font-weight: 500;">${result.salary}</div></div><div class="card-company" style="margin-bottom: 9px; font-size: 14px; color: #6C7282; line-height: 1.4; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${result.location}·${result.companyName}</div><div class="card-info" style="display: flex; align-items: center; justify-content: space-between; padding-right: 20px;"><div class="info-item" style="display: flex; position: relative; align-items: center;"><div class="card-tag" style="font-weight: 400; font-size: 12px; color: #6C7282; width: fit-content; background: #F4F4F4; border-radius: 2px; padding: 3px 10px; margin-right: 10px; display: inline-flex; align-items: center; justify-content: center; height: 15px; line-height: 15px;">${result.education}</div><div class="card-tag" style="font-weight: 400; font-size: 12px; color: #6C7282; width: fit-content; background: #F4F4F4; border-radius: 2px; padding: 3px 10px; margin-right: 10px; display: inline-flex; align-items: center; justify-content: center; height: 15px; line-height: 15px;">${result.experience}</div></div><div class="info-item" style="color: #256BFA; font-size: 14px; padding-right: 5px; position: relative;">查看详情<div class="position-nav" style="position: absolute; right: -5px; top: 50%; transform: translateY(-50%);"></div></div></div></a>`
if (result.data) { if (result.data) {
jobMoreMap.set(jobId, result.data) jobMoreMap.set(jobId, result.data)
domContext += `<a class="custom-more" data-job-id="${jobId}">查看更多岗位<div class="more-icon"></div></a>` domContext += `<a class="custom-more" href="/packageA/pages/moreJobs/moreJobs?jobId=${jobId}" data-job-id="${jobId}" data-jobid="${jobId}" onclick="if(typeof uni !== 'undefined'){uni.navigateTo({url: '/packageA/pages/moreJobs/moreJobs?jobId=${jobId}'});return false;}" style="display: flex; justify-content: center; align-items: center; color: #FFFFFF; background: linear-gradient(135deg, #256BFA 0%, #9E74FD 100%); border-radius: 25px; padding: 10px 16px; margin: 10px 0; font-size: 14px; font-weight: 600; box-shadow: 0 4px 12px rgba(37, 107, 250, 0.3); text-decoration: none; box-sizing: border-box; width: 100%;">查看更多岗位<div class="more-icon" style="width: 16px; height: 16px; background: url('@/static/svg/seemore.svg') center center no-repeat; background-size: 100% 100%; margin-left: 6px;"></div></a>`
} }
return domContext return domContext
} }
@@ -122,6 +159,7 @@ export function parseMarkdown(content) {
content = content.replace(/<\s*\/\s*think\s*&gt;/gi, '') content = content.replace(/<\s*\/\s*think\s*&gt;/gi, '')
codeDataList = [] codeDataList = []
jobCardsList = [] // 清空岗位卡片列表,避免重复
const unsafeHtml = md.render(content || '') const unsafeHtml = md.render(content || '')
// 在markdown渲染后再次过滤确保没有遗漏 // 在markdown渲染后再次过滤确保没有遗漏