Merge remote-tracking branch 'origin/master'

This commit is contained in:
zk
2026-05-22 14:29:46 +08:00
35 changed files with 1771 additions and 240 deletions
+3
View File
@@ -47,14 +47,17 @@ declare module 'vue' {
MemberDialog: typeof import('./src/components/MemberDialog.vue')['default']
ProfileEditDrawer: typeof import('./src/components/ProfileEditDrawer.vue')['default']
ProfilePageContent: typeof import('./src/components/ProfilePageContent.vue')['default']
ProfileWelcomeDialog: typeof import('./src/components/ProfileWelcomeDialog.vue')['default']
RegionSelector: typeof import('./src/components/tools/RegionSelector.vue')['default']
ResumeAnalysisReportDrawer: typeof import('./src/components/ResumeAnalysisReportDrawer.vue')['default']
ResumeEditNameDialog: typeof import('./src/components/ResumeEditNameDialog.vue')['default']
ResumeExportDialog: typeof import('./src/components/ResumeExportDialog.vue')['default']
ResumeIssueFixDrawer: typeof import('./src/components/ResumeIssueFixDrawer.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SettingsDeleteAccountDialog: typeof import('./src/components/SettingsDeleteAccountDialog.vue')['default']
SettingsDialog: typeof import('./src/components/SettingsDialog.vue')['default']
SettingsInviteDialog: typeof import('./src/components/SettingsInviteDialog.vue')['default']
SideNav: typeof import('./src/components/SideNav.vue')['default']
}
export interface GlobalDirectives {
+42
View File
@@ -57,3 +57,45 @@ export function checkLogin() {
export function logout() {
return request.post<any, ApiResult>('/public/logout')
}
/**
* 注销账号
* POST /user/manage/cancel
* Cookie 自动携带 Token
*/
export function cancelAccount() {
return request.post<any, ApiResult>('/user/manage/cancel')
}
/** 用户个人信息 DTO */
export interface UserInfo {
/** 用户ID */
id?: number
/** 手机号 */
mobileNumber?: string
/** 邮箱 */
email?: string
/** 昵称 */
nick?: string
/** 真实姓名 */
realName?: string
/** 头像 */
picture?: string
/** 生日 */
birthday?: { seconds?: number; nanos?: number }
/** 性别 1男 2女 */
sex?: number
/** 邀请码 */
inviteCode?: string
/** 注册时间 */
createTime?: { seconds?: number; nanos?: number }
}
/**
* 查询个人信息
* GET /user/manage/info
* Cookie 自动携带 Token
*/
export function fetchUserInfo() {
return request.get<any, ApiResult<UserInfo>>('/user/manage/info')
}
+117
View File
@@ -0,0 +1,117 @@
import request from '@/utils/request'
import type { ApiResult } from '@/api/auth'
/** 会员商品项类型(后端返回) */
export interface MemberProduct {
id: number
/** 商品名称 */
productName: string
/** 标签,如"限时优惠"、"最划算" */
tag: string
/** 主推标识 0=否 1=是 */
isFeatured: number
/** 购买按钮文字 */
buyButtonText: string
/** 实付价格(分) */
price: number
/** 划线价(分) */
originalPrice: number
/** 折算月价(分) */
monthlyPrice: number
/** 有效天数 */
durationDays: number
/** 排序,越小越靠前 */
sortOrder: number
/** 状态 0=下架 1=上架 */
status: number
/** 创建时间 */
createTime: { seconds: number; nanos: number }
/** 更新时间 */
updateTime: { seconds: number; nanos: number }
/** 逻辑删除 0=正常 非0=已删除 */
isDelete: number
}
/** 会员状态返回类型 */
export interface MemberStatus {
/** 是否是会员 */
isMember: boolean
/** 到期时间(毫秒时间戳) */
expireTime?: number
/** 首次开通时间(毫秒时间戳) */
createTime?: number
/** 最近续费时间(毫秒时间戳) */
updateTime?: number
}
/**
* 查询会员状态
* GET /member/status
*/
export function fetchMemberStatus() {
return request.get<any, ApiResult<MemberStatus>>('/member/status')
}
/**
* 查询会员商品列表
* GET /member/product/list
*/
export function fetchMemberProductList() {
return request.get<any, ApiResult<MemberProduct[]>>('/member/product/list')
}
/** 创建订单请求参数 */
export interface CreateOrderParams {
/** 商品ID */
productId: string
/** 支付渠道 1=微信 2=支付宝 */
payChannel: number
}
/** 创建订单返回数据 */
export interface CreateOrderResult {
/** 订单ID,用于轮询状态 */
orderId: string
/** 支付渠道 */
payChannel: number
/** 支付数据(支付宝表单HTML / 微信二维码链接) */
payData: string
}
/**
* 创建会员订单
* POST /member/product/createOrder
*/
export function createMemberOrder(data: CreateOrderParams) {
return request.post<any, ApiResult<CreateOrderResult>>('/member/product/createOrder', data)
}
/** 订单详情返回数据 */
export interface OrderDetailResult {
/** 订单ID */
orderId: string
/** 订单编号 */
orderNo: string
/** 商品名称 */
productName: string
/** 实付金额(分) */
payAmount: number
/** 订单状态 0=待支付 1=已支付 2=已退款 3=已关闭 */
status: number
/** 支付渠道 1=微信 2=支付宝 */
payChannel: number
/** 支付时间 */
payTime: { seconds: number; nanos: number }
/** 下单时间 */
createTime: { seconds: number; nanos: number }
}
/**
* 查询订单详情
* GET /member/product/orderDetail
*/
export function fetchOrderDetail(orderId: string) {
return request.get<any, ApiResult<OrderDetailResult>>('/member/product/orderDetail', {
params: { orderId },
})
}
+11
View File
@@ -75,6 +75,17 @@ export function fetchProfile() {
return request.get<any, ApiResult<ProfileData>>('/user/profile')
}
/**
* 根据简历ID同步更新个人资料
* POST /user/profile/syncFromResume?resumeId=xxx
* Cookie 自动携带 Token
*/
export function syncProfileFromResume(resumeId: string) {
return request.post<any, ApiResult>('/user/profile/syncFromResume', null, {
params: { resumeId },
})
}
// ==================== 教育经历相关 ====================
/** 描述段落 */
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

@@ -200,7 +200,7 @@
// 双击提示文字
&__hint {
font-size: 0.11rem;
color: $text-light;
color: #666;
margin-bottom: 0.06rem;
line-height: 1;
}
@@ -206,7 +206,7 @@
// 双击提示文字
&__hint {
font-size: 0.11rem;
color: $text-light;
color: #666;
margin-bottom: 0.06rem;
line-height: 1;
}
@@ -10,18 +10,20 @@
display: flex;
align-items: center;
justify-content: center;
}
.member-dialog {
background: $bg-white;
border-radius: 0.2rem;
width: 8.4rem;
width: 8.8rem;
height: 80vh;
position: relative;
box-shadow: 0 0.1rem 0.4rem rgba(0, 0, 0, 0.15);
display: flex;
flex-direction: column;
overflow: hidden;
font-size: 0.14rem;
// 关闭按钮
&__close {
@@ -520,12 +522,11 @@
&__order-body {
flex: 1;
display: flex;
overflow: hidden;
overflow-y: auto;
}
&__order-left {
flex: 1;
overflow-y: auto;
padding: 0.3rem;
}
@@ -533,7 +534,6 @@
width: 2.8rem;
padding: 0.3rem 0.24rem;
background: $bg-main;
overflow-y: auto;
flex-shrink: 0;
}
@@ -1116,7 +1116,9 @@
padding: 0.3rem 0.4rem;
text-align: center;
position: relative;
min-width: 3.2rem;
width: 5rem;
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 0.1rem 0.4rem rgba(0, 0, 0, 0.15);
}
@@ -1163,21 +1165,27 @@
}
&__qrcode-image {
width: 1.8rem;
height: 1.8rem;
width: 2.1rem;
height: 2.1rem;
margin: 0 auto 0.16rem;
display: flex;
align-items: center;
justify-content: center;
}
&__payment-iframe {
width: 100%;
height: 100%;
border: none;
border-radius: 0.08rem;
}
&__qrcode-placeholder {
width: 100%;
height: 100%;
background: $bg-main;
border: 1px dashed $border-color;
border-radius: 0.08rem;
// TODO: 替换为真实二维码
}
&__qrcode-amount {
@@ -0,0 +1,130 @@
@use '../variables' as *;
// ==================== 欢迎使用弹窗 ====================
.profile-welcome-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: $overlay-bg;
z-index: 2200;
display: flex;
align-items: center;
justify-content: center;
}
.profile-welcome-dialog {
width: 5.6rem;
background: $bg-white;
border-radius: 0.12rem;
padding: 0.36rem 0.4rem;
box-shadow: 0 0.04rem 0.2rem rgba(0, 0, 0, 0.15);
// 标题
&__title {
font-size: 0.22rem;
font-weight: 700;
color: $text-dark;
margin: 0 0 0.24rem 0;
}
// 表单区域
&__form {
margin-bottom: 0.2rem;
}
// 标签
&__label {
font-size: 0.14rem;
font-weight: 600;
color: $text-dark;
margin-bottom: 0.1rem;
}
// 上传区域
&__upload-area {
background: $bg-main;
border-radius: 0.08rem;
padding: 0.32rem 0.2rem;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background 0.2s;
margin-bottom: 0.14rem;
&:hover {
background: darken(#F3F4F5, 3%);
}
}
// 上传图标
&__upload-icon {
width: 0.44rem;
height: 0.44rem;
background: $text-dark;
border-radius: 0.06rem;
display: flex;
align-items: center;
justify-content: center;
color: $bg-white;
margin-bottom: 0.1rem;
&--done {
background: $accent;
font-size: 0.2rem;
font-weight: 700;
}
}
// 上传提示文字
&__upload-tip {
font-size: 0.12rem;
color: $text-middle;
margin: 0;
text-align: center;
}
// 隐私说明
&__privacy {
font-size: 0.11rem;
color: $text-middle;
line-height: 1.7;
margin: 0;
}
// 开始匹配按钮
&__start-btn {
width: 100%;
height: 0.46rem;
border: none;
border-radius: 0.06rem;
background: $text-dark;
color: $bg-white;
font-size: 0.15rem;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s;
&:hover:not(:disabled) {
opacity: 0.85;
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
}
}
// 全屏加载遮罩 — z-index 需高于弹窗 overlay2200
.profile-welcome-loading {
z-index: 2300 !important;
}
// 上传中弹窗层级降低,让 loading 遮罩盖在上面
.profile-welcome-overlay--behind {
z-index: 2000 !important;
}
@@ -317,7 +317,82 @@
line-height: 1.6;
}
// ===== 步骤2安全验证 =====
// ===== 步骤2二次确认 =====
&__confirm-card {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: 0.5rem 0.8rem;
}
&__confirm-icon {
width: 0.56rem;
height: 0.56rem;
border-radius: 50%;
background: rgba($danger, 0.1);
color: $danger;
font-size: 0.24rem;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 0.2rem;
}
&__confirm-title {
font-size: 0.22rem;
font-weight: 700;
color: $text-dark;
margin: 0 0 0.12rem 0;
}
&__confirm-desc {
font-size: 0.13rem;
color: $text-middle;
line-height: 1.6;
margin: 0 0 0.28rem 0;
max-width: 3.6rem;
}
&__confirm-actions {
display: flex;
gap: 0.2rem;
width: 100%;
max-width: 4.0rem;
}
&__confirm-btn {
flex: 1;
height: 0.44rem;
border-radius: 0.06rem;
font-size: 0.15rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
&--cancel {
background: #EDF1F7;
border: none;
color: $text-dark;
&:hover {
border-color: $text-middle;
}
}
&--danger {
background: #DC2626;
border: none;
color: $bg-white;
&:hover {
background: darken(#DC2626, 8%);
}
}
}
// ===== 步骤2(备用):安全验证 =====
&__verify {
display: flex;
align-items: center;
@@ -424,25 +499,60 @@
}
// ===== 步骤3:完成 =====
&__done {
&__done-card {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0.6rem 0;
background: $bg-white;
border: 1px solid $border-color;
border-radius: 0.12rem;
padding: 0.6rem 0.8rem;
margin-top: 0.4rem;
text-align: center;
}
&__done-icon {
width: 0.6rem;
height: 0.6rem;
width: 0.56rem;
height: 0.56rem;
border-radius: 50%;
background: $accent;
color: $bg-white;
font-size: 0.28rem;
background: rgba($accent, 0.15);
color: $accent;
font-size: 0.26rem;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 0.2rem;
}
&__done-title {
font-size: 0.22rem;
font-weight: 700;
color: $text-dark;
margin: 0 0 0.12rem 0;
}
&__done-desc {
font-size: 0.13rem;
color: $text-middle;
margin: 0 0 0.28rem 0;
line-height: 1.6;
}
&__done-btn {
width: 2.6rem;
height: 0.44rem;
border: none;
border-radius: 0.12rem;
background: $accent;
color: $bg-white;
font-size: 0.15rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
&:hover {
background: $accent-hover;
}
}
}
@@ -229,6 +229,11 @@
font-size: 0.11rem;
padding: 0.02rem 0.08rem;
border-radius: 0.1rem;
&--inactive {
background: #ccc;
color: $bg-white;
}
}
&__member-terms {
@@ -251,13 +256,27 @@
&__member-price {
font-size: 0.13rem;
color: #555;
display: flex;
flex-direction: column;
gap: 0.06rem;
span {
margin-left: 0.12rem;
margin-left: 0;
color: $text-light;
}
}
&__member-expire-line {
font-size: 0.13rem;
color: #555;
}
&__member-remain-line {
font-size: 0.13rem;
color: $accent;
font-weight: 600;
}
&__member-manage-btn {
background: $bg-main;
border: 1px solid $border-color;
@@ -0,0 +1,195 @@
@use '../variables' as *;
// ==================== 邀请注册送会员弹窗 ====================
.settings-invite-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: $overlay-bg;
z-index: 2200;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.14rem;
}
.settings-invite-dialog {
position: relative;
width: 4.2rem;
max-height: 85vh;
background: $bg-white;
border-radius: 0.12rem;
padding: 0.32rem 0.28rem;
box-shadow: 0 0.04rem 0.2rem rgba(0, 0, 0, 0.15);
overflow-y: auto;
// 关闭按钮
&__close {
position: absolute;
top: 0.14rem;
right: 0.18rem;
font-size: 0.18rem;
color: $text-light;
cursor: pointer;
transition: color 0.2s;
&:hover {
color: $text-dark;
}
}
// 标题
&__title {
font-size: 0.18rem;
font-weight: 700;
color: $text-dark;
text-align: center;
margin: 0 0 0.2rem 0;
}
// 邀请链接区域
&__link-box {
background: $bg-main;
border-radius: 0.08rem;
padding: 0.14rem 0.16rem;
margin-bottom: 0.14rem;
}
&__link-text {
font-size: 0.12rem;
color: $text-dark;
line-height: 1.7;
margin: 0;
word-break: break-all;
}
// 复制链接按钮
&__copy-btn {
width: 100%;
height: 0.4rem;
border: none;
border-radius: 0.2rem;
background: $text-dark;
color: $bg-white;
font-size: 0.14rem;
font-weight: 600;
cursor: pointer;
margin-bottom: 0.18rem;
transition: opacity 0.2s;
&:hover {
opacity: 0.85;
}
}
// 步骤提示
&__steps-tip {
font-size: 0.12rem;
color: $text-middle;
text-align: center;
margin: 0 0 0.14rem 0;
}
// 步骤卡片
&__step {
border: 1px solid $border-color;
border-radius: 0.08rem;
padding: 0.12rem 0.16rem;
margin-bottom: 0.1rem;
background: #F2F4F7;
}
&__step-title {
font-size: 0.13rem;
font-weight: 700;
color: $text-dark;
margin-bottom: 0.04rem;
}
&__step-desc {
font-size: 0.11rem;
color: $text-middle;
margin: 0;
line-height: 1.5;
}
// 统计数据
&__stats {
display: flex;
gap: 0.12rem;
margin-top: 0.16rem;
margin-bottom: 0.16rem;
}
&__stat-item {
flex: 1;
border: 1px solid $border-color;
border-radius: 0.08rem;
padding: 0.12rem;
}
&__stat-label {
font-size: 0.11rem;
color: $text-middle;
margin-bottom: 0.06rem;
}
&__stat-value {
font-size: 0.16rem;
font-weight: 700;
color: $text-dark;
}
// 活动规则
&__rules {
display: flex;
align-items: center;
justify-content: space-between;
border: 1px solid $border-color;
border-radius: 0.08rem;
padding: 0.12rem 0.16rem;
cursor: pointer;
transition: background 0.2s;
&:hover {
background: $bg-main;
}
}
&__rules-label {
font-size: 0.13rem;
font-weight: 600;
color: $text-dark;
}
&__rules-arrow {
width: 0.14rem;
height: 0.14rem;
color: $text-middle;
transition: transform 0.2s;
&--open {
transform: rotate(180deg);
}
}
&__rules-content {
margin-top: 0.1rem;
padding: 0.12rem 0.16rem;
background: $bg-main;
border-radius: 0.06rem;
p {
font-size: 0.11rem;
color: $text-middle;
line-height: 1.8;
margin: 0 0 0.04rem 0;
&:last-child {
margin-bottom: 0;
}
}
}
}
+2
View File
@@ -33,6 +33,8 @@
@use './components/agent-apply-progress.scss';
@use './components/ai-thinking-indicator.scss';
@use './components/settings-delete-account-dialog.scss';
@use './components/profile-welcome-dialog.scss';
@use './components/settings-invite-dialog.scss';
// 全局样式(优先级最高)
@use './auto.scss';
+1
View File
@@ -13,6 +13,7 @@
background: $bg-main;
display: flex;
flex-direction: column;
font-size: 0.14rem;
// 手动包裹的两层 div,需要撑满剩余高度并允许内部滚动
> .bg-white {
+1
View File
@@ -14,6 +14,7 @@
background: $bg-main;
display: flex;
flex-direction: column;
font-size: 0.14rem;
}
// 页面标题
+1
View File
@@ -12,6 +12,7 @@
background: $bg-main;
display: flex;
flex-direction: column;
font-size: 0.14rem;
}
// 页面标题区
+9
View File
@@ -12,6 +12,7 @@
background: $bg-main;
display: flex;
flex-direction: column;
font-size: 0.14rem;
}
&__header {
@@ -160,6 +161,14 @@
height: 0.16rem;
}
// 空状态提示
&__empty {
padding: 0.6rem 0.2rem;
text-align: center;
color: $text-light;
font-size: 0.14rem;
}
// 弹出菜单
&__popup {
position: absolute;
+2 -2
View File
@@ -48,10 +48,10 @@ $btn-dark: #4FC2C9;
$btn-dark-hover: #42A8B3;
// 次要深色按钮背景(除非特意指定使用否则不用这个按钮色)
$btn-dark: #1A1A2E;
//$btn-dark: #1A1A2E;
// 次要深色按钮悬停态(除非特意指定使用否则不用这个按钮色)
$btn-dark-hover: #2E3142;
//$btn-dark-hover: #2E3142;
// 渐变色背景
$gradient-bg: linear-gradient(to right, #4FC2C9, #42A8B3);
+5 -1
View File
@@ -20,7 +20,7 @@
<!-- AI 欢迎消息 -->
<div class="ai-chat__msg ai-chat__msg--ai">
<div class="ai-chat__msg-bubble">
<div class="ai-chat__msg-title">欢迎回来李华!</div>
<div class="ai-chat__msg-title">欢迎回来{{nickName}}!</div>
<div class="ai-chat__msg-text">很高兴再次见到你让我们继续您通往理想工作的旅程吧</div>
</div>
</div>
@@ -92,6 +92,10 @@ const props = defineProps<{
const currentRoute = useRoute()
const store = useStore()
/** 用户昵称 — 从全局 store 读取 */
const nickName = computed(() => store.state.userInfo?.nick || '')
// ==================== 状态 ====================
/** 会员购买弹窗的显示状态 */
+195 -41
View File
@@ -108,14 +108,14 @@
<!-- 顶部导航 -->
<div class="member-dialog__order-header">
<span class="member-dialog__order-back" @click="currentView = 'intro2'"> 返回会员介绍</span>
<!-- 步骤条 -->
<!-- 步骤条 二维码弹窗打开时显示第2步 -->
<div class="member-dialog__steps">
<div class="member-dialog__step member-dialog__step--active">
<span class="member-dialog__step-num">1</span>
<div class="member-dialog__step" :class="showQrCode ? 'member-dialog__step--done' : 'member-dialog__step--active'">
<span class="member-dialog__step-num">{{ showQrCode ? '✓' : '1' }}</span>
<span class="member-dialog__step-label">选择套餐</span>
</div>
<div class="member-dialog__step-line"></div>
<div class="member-dialog__step">
<div class="member-dialog__step-line" :class="{ 'member-dialog__step-line--done': showQrCode }"></div>
<div class="member-dialog__step" :class="{ 'member-dialog__step--active': showQrCode }">
<span class="member-dialog__step-num">2</span>
<span class="member-dialog__step-label">支付方式</span>
</div>
@@ -144,12 +144,15 @@
@click="selectedPlan = plan.key"
>
<div v-if="plan.recommend" class="member-dialog__order-plan-badge"> 推荐</div>
<div class="member-dialog__order-plan-info">
<div class="dflex">
<div class="member-dialog__order-plan-name">{{ plan.name }}</div>
<!-- 选中圆圈 -->
<div class="member-dialog__order-plan-radio">
<div v-if="selectedPlan === plan.key" class="member-dialog__order-plan-radio-inner"></div>
</div>
<div class="member-dialog__order-plan-info">
<div class="member-dialog__order-plan-name">{{ plan.name }}</div>
</div>
<div class="member-dialog__order-plan-price">
<span class="member-dialog__order-plan-price-symbol">¥</span>
<span class="member-dialog__order-plan-price-num">{{ plan.price }}</span>
@@ -157,6 +160,7 @@
</div>
<div class="member-dialog__order-plan-desc">{{ plan.orderDesc }}</div>
</div>
</div>
</div>
@@ -317,33 +321,36 @@
<h2 class="member-dialog__order-success-title">支付成功求职加速已开启</h2>
<p class="member-dialog__order-success-desc">你已成功解锁 AI 求职加速权益现在可以开始优化简历匹配岗位并准备面试</p>
<button class="member-dialog__order-success-btn" @click="handleGoResume">开始优化我的简历</button>
<button class="member-dialog__order-success-btn-secondary" @click="$emit('update:modelValue', false)">查看会员权益</button>
<button class="member-dialog__order-success-btn-secondary" @click="handleViewMemberBenefits">查看会员权益</button>
</div>
</div>
</div>
<!-- ==================== 付款二维码遮罩层 ==================== -->
<div v-if="showQrCode" class="member-dialog__qrcode-overlay" @click="showQrCode = false">
<!-- ==================== 付款弹窗 ==================== -->
<div v-if="showQrCode" class="member-dialog__qrcode-overlay" @click="handleCloseQrCode">
<div class="member-dialog__qrcode-modal" @click.stop>
<!-- 关闭按钮 -->
<span class="member-dialog__qrcode-close" @click="showQrCode = false"></span>
<span class="member-dialog__qrcode-close" @click="handleCloseQrCode"></span>
<!-- 支付方式标题 -->
<div class="member-dialog__qrcode-title">
<span class="member-dialog__qrcode-title-icon">{{ selectedPayment === 'wechat' ? '💬' : '🔷' }}</span>
<span>{{ selectedPayment === 'wechat' ? '微信支付' : '支付宝' }}</span>
</div>
<!-- 扫码提示 -->
<h3 class="member-dialog__qrcode-subtitle">扫码完成支付</h3>
<p class="member-dialog__qrcode-desc">请使用{{ selectedPayment === 'wechat' ? '微信' : '支付宝' }} App 扫描二维码完成支付完成后此窗口会自动关闭</p>
<!-- 二维码占位 -->
<h3 class="member-dialog__qrcode-subtitle">{{ selectedPayment === 'alipay' ? '扫码完成支付' : '扫码完成支付' }}</h3>
<p class="member-dialog__qrcode-desc">{{ selectedPayment === 'alipay' ? '请在支付宝页面完成支付,完成后点击下方按钮确认。' : '请使用微信 App 扫描二维码完成支付完成后此窗口会自动关闭。' }}</p>
<!-- 支付宝iframe 渲染支付表单 -->
<div class="member-dialog__qrcode-image">
<!-- TODO: 替换为真实二维码图片 -->
<div class="member-dialog__qrcode-placeholder"></div>
<iframe
v-if="paymentFormHtml"
:srcdoc="paymentFormHtml"
frameborder="0"
class="member-dialog__payment-iframe"
scrolling="auto"
></iframe>
</div>
<!-- 金额 -->
<div class="member-dialog__qrcode-amount">¥{{ currentPlan.price }}</div>
<!-- 我已完成支付按钮 -->
<button class="member-dialog__qrcode-confirm-btn" @click="handlePaymentDone">我已完成支付</button>
</div>
</div>
</div>
@@ -352,20 +359,23 @@
</template>
<script setup lang="ts">
import { ref, computed, watch, reactive } from 'vue'
import { ref, computed, watch, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useStore } from 'vuex'
import { fetchMemberProductList, createMemberOrder, fetchOrderDetail, type MemberProduct } from '@/api/member'
/** 组件 Props — 控制弹窗显示/隐藏 */
const props = defineProps<{ modelValue: boolean }>()
/** 组件 Emits — 通知父组件更新 modelValue */
defineEmits<{ (e: 'update:modelValue', value: boolean): void }>()
const emit = defineEmits<{ (e: 'update:modelValue', value: boolean): void }>()
const router = useRouter()
const store = useStore()
// ==================== 类型定义 ====================
/** 套餐项类型 */
/** 套餐项类型(包含后端完整数据 + 前端展示字段) */
interface PlanItem {
key: string
name: string
@@ -376,6 +386,8 @@ interface PlanItem {
perMonth?: string
btnText: string
orderDesc: string
/** 后端原始数据,下单时使用 */
raw: MemberProduct
}
/** 支付方式类型 */
@@ -409,6 +421,15 @@ const agreeProtocol = ref(false)
/** 是否显示二维码弹窗 */
const showQrCode = ref(false)
/** 支付宝表单 HTML(用于 iframe 渲染) */
const paymentFormHtml = ref('')
/** 当前订单ID(用于后续轮询支付状态) */
const orderId = ref('')
/** 是否正在创建订单 */
const creatingOrder = ref(false)
/** 监听弹窗开关 — 打开时锁定背景滚动,关闭时恢复并重置状态 */
watch(() => props.modelValue, (val) => {
document.body.style.overflow = val ? 'hidden' : ''
@@ -416,6 +437,21 @@ watch(() => props.modelValue, (val) => {
currentView.value = 'intro2'
showQrCode.value = false
agreeProtocol.value = false
paymentFormHtml.value = ''
orderId.value = ''
// 清除轮询计时器
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
}
})
/** 监听二维码弹窗关闭 — 无论何种方式关闭都停止轮询 */
watch(showQrCode, (val) => {
if (!val && pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
})
@@ -423,17 +459,62 @@ watch(() => props.modelValue, (val) => {
/** 当前选中的套餐对象 */
const currentPlan = computed(() => {
return plans.find(p => p.key === selectedPlan.value) || plans[1]
return plans.value.find(p => p.key === selectedPlan.value) || plans.value[0]
})
// ==================== 常量数据 ====================
/** 套餐列表 */
const plans: PlanItem[] = [
{ key: 'weekly', name: '周会员', price: '17.99', unit: '周', tag: '临时体验 / 急用一次', btnText: '立即体验', orderDesc: '适合临时体验,急用一次的求职者' },
{ key: 'quarterly', name: '季度会员', price: '49.99', unit: '3个月', recommend: true, perMonth: '16.66', btnText: '开启完整求职冲刺', orderDesc: '推荐正在集中投递、准备面试的求职者,性价比最高' },
{ key: 'monthly', name: '月度会员', price: '19.99', unit: '月', tag: '标准求职', btnText: '开始求职加速', orderDesc: '标准求职周期,持续使用 AI 求职加速能力' },
]
/** 套餐列表(从接口获取) */
const plans = ref<PlanItem[]>([])
/** 根据有效天数生成单位描述 */
function getDurationUnit(days: number): string {
if (days <= 7) return '周'
if (days <= 31) return '月'
if (days <= 93) return '3个月'
if (days <= 186) return '半年'
return '年'
}
/** 将后端商品数据转换为前端套餐项 */
function mapProductToPlan(product: MemberProduct): PlanItem {
const priceYuan = (product.price / 100).toFixed(2)
const monthlyPriceYuan = product.monthlyPrice ? (product.monthlyPrice / 100).toFixed(2) : undefined
return {
key: String(product.id),
name: product.productName,
price: priceYuan,
unit: getDurationUnit(product.durationDays),
tag: product.tag || undefined,
recommend: product.isFeatured === 1,
perMonth: monthlyPriceYuan,
btnText: product.buyButtonText || '立即开通',
orderDesc: product.tag || product.productName,
raw: product,
}
}
/** 加载会员商品列表 */
async function loadMemberProducts() {
try {
const res = await fetchMemberProductList()
if (res.data && res.data.length) {
// 按 sortOrder 排序
const sorted = [...res.data].sort((a, b) => a.sortOrder - b.sortOrder)
plans.value = sorted.map(mapProductToPlan)
// 默认选中主推套餐,若无则选第一个
const featured = plans.value.find(p => p.recommend)
selectedPlan.value = featured ? featured.key : plans.value[0].key
}
} catch (e) {
console.error('加载会员商品列表失败', e)
}
}
/** 组件挂载时加载商品列表 */
onMounted(() => {
loadMemberProducts()
})
/** 求职加速能力列表 */
const abilities = [
@@ -485,29 +566,102 @@ function handleUpgrade(plan: PlanItem) {
currentView.value = 'order1'
}
/** 点击立即开启求职加速 — 显示二维码弹窗 */
function handleShowQrCode() {
if (!agreeProtocol.value) return
showQrCode.value = true
}
/** 点击立即开启求职加速 — 调用创建订单接口并显示支付弹窗 */
async function handleShowQrCode() {
if (!agreeProtocol.value || creatingOrder.value) return
const plan = currentPlan.value
if (!plan) return
/** 点击我已完成支付 — 进入确认支付结果步骤 */
function handlePaymentDone() {
showQrCode.value = false
currentView.value = 'order2'
// 模拟接口确认支付结果,成功后进入步骤三
creatingOrder.value = true
try {
// 支付渠道:1=微信 2=支付宝
const payChannel = selectedPayment.value === 'alipay' ? 2 : 1
const res = await createMemberOrder({
productId: plan.key, // key 即为商品 id(字符串,避免大数精度丢失)
payChannel,
})
if (res.data) {
orderId.value = String(res.data.orderId)
paymentFormHtml.value = res.data.payData
showQrCode.value = true
// 创建订单成功后立即开始轮询支付状态
confirmPayment()
}
} catch (e) {
console.error('创建订单失败', e)
} finally {
creatingOrder.value = false
}
}
/** 模拟确认支付结果接口 */
/** 轮询计时器 */
let pollTimer: ReturnType<typeof setInterval> | null = null
/** 轮询查询订单支付状态 */
async function confirmPayment() {
// TODO: 替换为真实支付确认接口
await new Promise(resolve => setTimeout(resolve, 2000))
const startTime = Date.now()
/** 超时时间:5 分钟 */
const timeout = 5 * 60 * 1000
/** 轮询间隔:500ms */
const interval = 500
// 清除可能存在的旧计时器
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
pollTimer = setInterval(async () => {
try {
const res = await fetchOrderDetail(orderId.value)
const status = res.data?.status
if (status === 1) {
// 支付成功 — 关闭二维码弹窗,进入完成步骤
clearInterval(pollTimer!)
pollTimer = null
showQrCode.value = false
currentView.value = 'order3'
} else if (status === 2 || status === 3) {
// 已退款或已关闭 — 提示并回到选择套餐步骤
clearInterval(pollTimer!)
pollTimer = null
showQrCode.value = false
ElMessage.error('支付未成功,请重新下单')
currentView.value = 'order1'
} else if (Date.now() - startTime >= timeout) {
// 超时 — 提示并回到选择套餐步骤
clearInterval(pollTimer!)
pollTimer = null
showQrCode.value = false
ElMessage.error('支付未成功,请重新下单')
currentView.value = 'order1'
}
// status === 0 继续等待
} catch (e) {
console.error('查询订单状态失败', e)
}
}, interval)
}
/** 支付成功后跳转简历页 */
function handleGoResume() {
router.push('/resume')
}
/** 关闭二维码弹窗 — 停止轮询 */
function handleCloseQrCode() {
showQrCode.value = false
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
}
/** 查看会员权益 — 关闭会员弹窗,打开设置弹窗的会员 Tab */
function handleViewMemberBenefits() {
emit('update:modelValue', false)
store.commit('SET_SETTINGS_TAB', 'member')
store.commit('SET_SHOW_SETTINGS', true)
}
</script>
+156
View File
@@ -0,0 +1,156 @@
<template>
<!-- 欢迎使用弹窗 首次登录无个人资料时弹出引导上传简历 -->
<Teleport to="body">
<div v-if="dialogVisible" class="profile-welcome-overlay" :class="{ 'profile-welcome-overlay--behind': uploading }" @click.stop>
<div class="profile-welcome-dialog" @click.stop>
<!-- 标题 -->
<h2 class="profile-welcome-dialog__title">欢迎使用Offer派</h2>
<!-- 简历上传区域 -->
<div class="profile-welcome-dialog__form">
<div class="profile-welcome-dialog__label">*简历</div>
<div class="profile-welcome-dialog__upload-area" @click="handleUploadClick">
<!-- 未上传状态 -->
<template v-if="!uploadedFileName">
<div class="profile-welcome-dialog__upload-icon">
<svg viewBox="0 0 24 24" fill="none" width="28" height="28">
<path d="M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<path d="M12 14V4m0 0l-4 4m4-4l4 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<p class="profile-welcome-dialog__upload-tip">支持上传PDFWORD格式文件大小不超过10M</p>
</template>
<!-- 已上传状态 -->
<template v-else>
<div class="profile-welcome-dialog__upload-icon profile-welcome-dialog__upload-icon--done"></div>
<p class="profile-welcome-dialog__upload-tip">{{ uploadedFileName }}</p>
</template>
</div>
<!-- 隐私说明 -->
<p class="profile-welcome-dialog__privacy">
您的个人资料仅用于职位匹配简历优化及网申投递未经您的明确许可我们绝不会将信息泄露给第三方或招聘人员您可以随时更新或删除您的相关信息
</p>
</div>
<!-- 开始匹配按钮 -->
<button
class="profile-welcome-dialog__start-btn"
:disabled="!uploadedFileName"
@click="handleStart"
>
开始匹配
</button>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { uploadResume } from '@/utils/aiRequest'
import { syncProfileFromResume } from '@/api/profile'
import { ElLoading, ElMessage } from 'element-plus'
import 'element-plus/es/components/loading/style/css'
/** 组件 Props */
const props = defineProps<{ modelValue: boolean }>()
/** 组件 Emits */
const emit = defineEmits<{ (e: 'update:modelValue', value: boolean): void }>()
const router = useRouter()
const route = useRoute()
/** 已上传的文件名 */
const uploadedFileName = ref('')
/** 内部控制弹窗显隐(用于上传时临时隐藏) */
const dialogVisible = ref(false)
/** 上传中状态 — 降低弹窗层级 */
const uploading = ref(false)
/** 上传后获得的简历 ID */
const resumeId = ref('')
/** 弹窗打开时重置状态 */
watch(() => props.modelValue, (val) => {
if (val) {
uploadedFileName.value = ''
resumeId.value = ''
dialogVisible.value = true
} else {
dialogVisible.value = false
}
document.body.style.overflow = val ? 'hidden' : ''
})
/** 点击上传区域 — 弹出文件选择 */
function handleUploadClick() {
const input = document.createElement('input')
input.type = 'file'
input.accept = '.pdf,.doc,.docx,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document'
input.onchange = async () => {
const file = input.files?.[0]
if (!file) return
// 文件大小校验(10MB
if (file.size > 10 * 1024 * 1024) {
ElMessage.error('文件大小不能超过10M')
return
}
// 标记上传中,降低弹窗层级让 loading 显示在上面
uploading.value = true
// 全屏加载
const loading = ElLoading.service({
lock: true,
text: '简历解析中,请耐心等待…',
background: 'rgba(0, 0, 0, 0.5)',
customClass: 'profile-welcome-loading',
})
try {
// 上传简历文件
const res = await uploadResume(file)
if (res.code === 0 && res.data?.resumeId) {
resumeId.value = String(res.data.resumeId)
uploadedFileName.value = file.name
// 继续调用同步个人资料接口,加载特效延续
loading.setText('正在同步个人资料…')
await syncProfileFromResume(resumeId.value)
loading.close()
uploading.value = false
ElMessage.success('简历上传并同步成功')
} else {
loading.close()
uploading.value = false
ElMessage.error(res.msg || '上传失败')
}
} catch {
loading.close()
uploading.value = false
ElMessage.error('上传失败,请稍后重试')
}
}
input.click()
}
/** 点击开始匹配按钮 */
function handleStart() {
emit('update:modelValue', false)
// 如果当前在 profile 页面就跳转 profile,其余跳转 jobs
if (route.path === '/profile') {
// 已经在 profile 页面,刷新页面数据
router.go(0)
} else {
router.push('/jobs')
}
}
</script>
+114
View File
@@ -0,0 +1,114 @@
<template>
<!-- 导出简历格式选择弹窗 -->
<el-dialog
v-model="visible"
title="导出简历"
width="3.6rem"
:close-on-click-modal="false"
class="resume-export-dialog"
>
<!-- 格式选择 -->
<el-radio-group v-model="exportFormat" class="resume-export-dialog__radio-group">
<el-radio value="pdf">PDF 简历</el-radio>
<el-radio value="word">Word 简历</el-radio>
</el-radio-group>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="exporting" @click="doExport">下载</el-button>
</template>
</el-dialog>
<!-- 隐藏的简历模板用于导出时渲染DOM -->
<div v-if="exportTemplateData" style="position:absolute;left:-9999px;top:0;">
<JobResumeTemplate ref="exportTemplateRef" :resume-data="exportTemplateData" />
</div>
</template>
<script setup lang="ts">
import { ref, computed, nextTick } from 'vue'
import JobResumeTemplate from '@/components/JobResumeTemplate.vue'
import type { ResumeTemplateData } from '@/components/JobResumeTemplate.vue'
import { exportResumePdf, exportResumeWord, loadResumeTemplateData } from '@/utils/resumeExport'
import { ElMessage } from 'element-plus'
// ==================== Props & Emits ====================
const props = defineProps<{
/** 控制弹窗显示/隐藏(v-model */
modelValue: boolean
/** 要导出的简历 ID */
resumeId: string
/** 导出文件名(不含扩展名) */
resumeName?: string
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
}>()
// ==================== 弹窗显隐双向绑定 ====================
/** 弹窗可见状态 */
const visible = computed({
get: () => props.modelValue,
set: (val: boolean) => emit('update:modelValue', val),
})
// ==================== 导出相关状态 ====================
/** 导出格式:pdf 或 word */
const exportFormat = ref<'pdf' | 'word'>('pdf')
/** 导出中状态 */
const exporting = ref(false)
/** 导出用的简历模板数据 */
const exportTemplateData = ref<ResumeTemplateData | null>(null)
/** 导出用的简历模板组件引用 */
const exportTemplateRef = ref<InstanceType<typeof JobResumeTemplate> | null>(null)
// ==================== 导出逻辑 ====================
/** 执行导出下载 */
async function doExport() {
exporting.value = true
try {
// 1. 加载简历完整数据
const data = await loadResumeTemplateData(props.resumeId)
if (!data) {
ElMessage.error('获取简历数据失败')
return
}
// 2. 设置模板数据,等待DOM渲染
exportTemplateData.value = data
await nextTick()
// 3. 获取渲染后的DOM
const element = exportTemplateRef.value?.resumeRef
if (!element) {
ElMessage.error('简历模板渲染失败')
return
}
const fileName = props.resumeName || '简历'
if (exportFormat.value === 'pdf') {
await exportResumePdf(element, fileName)
} else {
exportResumeWord(element, fileName)
}
ElMessage.success('导出成功')
visible.value = false
} catch (err) {
console.error('[导出简历] 失败', err)
ElMessage.error('导出失败,请稍后重试')
} finally {
exporting.value = false
// 清理隐藏模板数据
exportTemplateData.value = null
}
}
</script>
+32 -9
View File
@@ -98,8 +98,21 @@
</div>
</template>
<!-- 步骤2安全验证 -->
<!-- 步骤2二次确认 -->
<template v-if="currentStep === 2">
<div class="delete-account-dialog__confirm-card">
<div class="delete-account-dialog__confirm-icon">!</div>
<h2 class="delete-account-dialog__confirm-title">确认注销账号</h2>
<p class="delete-account-dialog__confirm-desc">注销完成后不能撤回账号权益将清零已开通会员权益不退款</p>
<div class="delete-account-dialog__confirm-actions">
<button class="delete-account-dialog__confirm-btn delete-account-dialog__confirm-btn--cancel" @click="currentStep = 1">再想想</button>
<button class="delete-account-dialog__confirm-btn delete-account-dialog__confirm-btn--danger" @click="handleConfirmDelete">确认注销</button>
</div>
</div>
</template>
<!-- 步骤2备用安全验证 手机验证码暂时注释 -->
<!-- <template v-if="currentStep === 2">
<h2 class="delete-account-dialog__title">安全验证</h2>
<p class="delete-account-dialog__subtitle">为保护账号安全请完成身份验证后继续注销流程</p>
<div class="delete-account-dialog__verify">
@@ -116,15 +129,15 @@
<p class="delete-account-dialog__verify-tip">完成验证后进入最终确认</p>
</div>
</div>
</template>
</template> -->
<!-- 步骤3完成 -->
<template v-if="currentStep === 3">
<div class="delete-account-dialog__done">
<div class="delete-account-dialog__done-card">
<div class="delete-account-dialog__done-icon"></div>
<h2 class="delete-account-dialog__title">账号注销申请已提交</h2>
<p class="delete-account-dialog__subtitle">你的账号将在 7 个工作日内完成注销处理在此期间如需撤回请联系客服</p>
<button class="delete-account-dialog__btn delete-account-dialog__btn--primary" @click="handleFinish">我知道了</button>
<h2 class="delete-account-dialog__done-title">注销申请已提交</h2>
<p class="delete-account-dialog__done-desc">我们将按注销流程处理你的账号处理期间请勿再次购买会员权益</p>
<button class="delete-account-dialog__done-btn" @click="handleFinish">我知道了</button>
</div>
</template>
</div>
@@ -136,6 +149,7 @@
import { ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useStore } from 'vuex'
import { cancelAccount } from '@/api/auth'
/** 组件 Props */
const props = defineProps<{ modelValue: boolean }>()
@@ -196,10 +210,19 @@ const sendCode = () => {
ElMessage.success('验证码已发送')
}
/** 确认注销 */
const handleConfirmDelete = () => {
// TODO: 调用注销接口
/** 确认注销 — 调用后端注销接口 */
const handleConfirmDelete = async () => {
try {
await cancelAccount()
// 注销成功,清空登录状态
store.commit('SET_AUTHENTICATED', false)
// 清空浏览器缓存数据(聊天记录、session 等)
localStorage.clear()
sessionStorage.clear()
currentStep.value = 3
} catch {
ElMessage.error('注销请求失败,请稍后重试')
}
}
/** 完成 — 关闭弹窗并退出登录 */
+107 -47
View File
@@ -48,7 +48,7 @@
<h2 class="settings-dialog__content-title">账号与安全</h2>
<div class="settings-dialog__section">
<div class="settings-dialog__section-label">手机号</div>
<p class="settings-dialog__section-value">130****2222</p>
<p class="settings-dialog__section-value">{{ userPhone }}</p>
</div>
<div class="settings-dialog__danger-section">
<div class="settings-dialog__danger-title">注销我的账号</div>
@@ -66,28 +66,39 @@
<div class="settings-dialog__member-card">
<div class="settings-dialog__member-header">
<div class="settings-dialog__member-title-row">
<span class="settings-dialog__member-name">会员</span>
<span class="settings-dialog__member-badge">查看详情</span>
<span class="settings-dialog__member-name">正式会员</span>
<span
class="settings-dialog__member-badge"
:class="{ 'settings-dialog__member-badge--inactive': !memberStatus.isMember }"
>
{{ memberStatus.isMember ? '已开通' : '未开通' }}
</span>
</div>
<span class="settings-dialog__member-terms" @click="handleMemberTerms">会员条款</span>
</div>
<div class="settings-dialog__member-info-row">
<span class="settings-dialog__member-price">
¥19.99/
<!-- <span>将于2026年3月27日续费</span>-->
<!-- 已开通显示到期时间和剩余天数 -->
<span v-if="memberStatus.isMember" class="settings-dialog__member-price">
<span class="settings-dialog__member-expire-line">到期时间{{ memberExpireDateTime }}</span>
<span class="settings-dialog__member-remain-line">剩余 {{ memberRemainDays }} </span>
</span>
<!-- 未开通显示价格 -->
<span v-else class="settings-dialog__member-price">
¥19.99/
</span>
<!-- <button class="settings-dialog__member-manage-btn" @click="handleManageSubscription">管理我的订阅</button>-->
</div>
</div>
<div class="settings-dialog__member-issue">
<div class="settings-dialog__member-issue-title">订阅状态异常</div>
<p class="settings-dialog__member-issue-desc">
如果你已经和完成了付款或更改了订阅但是没有看到最新状态你可以尝试更新状态或联系我们获取帮助
</p>
<div class="settings-dialog__member-issue-actions">
<button class="settings-dialog__member-issue-btn" @click="handleRefreshStatus">更新状态</button>
<button class="settings-dialog__member-issue-btn" @click="handleContactUs">联系我们</button>
</div>
<!-- <div class="settings-dialog__member-issue-title">订阅状态异常</div>-->
<!-- <p class="settings-dialog__member-issue-desc">-->
<!-- 如果你已经和完成了付款或更改了订阅但是没有看到最新状态你可以尝试更新状态或联系我们获取帮助-->
<!-- </p>-->
<!-- <div class="settings-dialog__member-issue-actions">-->
<!-- <button class="settings-dialog__member-issue-btn" @click="handleRefreshStatus">更新状态</button>-->
<!-- <button class="settings-dialog__member-issue-btn" @click="handleContactUs">联系我们</button>-->
<!-- </div>-->
</div>
</template>
@@ -127,34 +138,34 @@
</div>
</div>
</div>
<div class="settings-dialog__reminder-block">
<div class="settings-dialog__reminder-block-title">即时岗位提醒</div>
<div class="settings-dialog__reminder-row">
<div class="settings-dialog__reminder-info">
<div class="settings-dialog__reminder-label">开启即时岗位更新提醒</div>
<div class="settings-dialog__reminder-desc">
抢先申请 在岗位发布后一小时内即可收到为你量身定制的最新职位提醒
</div>
</div>
<el-switch v-model="reminders.instant" active-color="#4FC2C9" />
</div>
</div>
<div class="settings-dialog__reminder-block">
<div class="settings-dialog__reminder-block-title">岗位更新提醒频率</div>
<div class="settings-dialog__reminder-row">
<div class="settings-dialog__reminder-info">
<div class="settings-dialog__reminder-desc">
会员用户每天可接收无限次岗位更新提醒免费用户每天最多接收 1
</div>
</div>
<el-select v-model="reminders.frequency" style="width: 1.2rem;">
<el-option label="1次/天" value="1" />
<el-option label="2次/天" value="2" />
<el-option label="5次/天" value="5" />
<el-option label="无限次" value="unlimited" />
</el-select>
</div>
</div>
<!-- <div class="settings-dialog__reminder-block">-->
<!-- <div class="settings-dialog__reminder-block-title">即时岗位提醒</div>-->
<!-- <div class="settings-dialog__reminder-row">-->
<!-- <div class="settings-dialog__reminder-info">-->
<!-- <div class="settings-dialog__reminder-label">开启即时岗位更新提醒</div>-->
<!-- <div class="settings-dialog__reminder-desc">-->
<!-- 抢先申请 在岗位发布后一小时内即可收到为你量身定制的最新职位提醒-->
<!-- </div>-->
<!-- </div>-->
<!-- <el-switch v-model="reminders.instant" active-color="#4FC2C9" />-->
<!-- </div>-->
<!-- </div>-->
<!-- <div class="settings-dialog__reminder-block">-->
<!-- <div class="settings-dialog__reminder-block-title">岗位更新提醒频率</div>-->
<!-- <div class="settings-dialog__reminder-row">-->
<!-- <div class="settings-dialog__reminder-info">-->
<!-- <div class="settings-dialog__reminder-desc">-->
<!-- 会员用户每天可接收无限次岗位更新提醒免费用户每天最多接收 1 -->
<!-- </div>-->
<!-- </div>-->
<!-- <el-select v-model="reminders.frequency" style="width: 1.2rem;">-->
<!-- <el-option label="1次/天" value="1" />-->
<!-- <el-option label="2次/天" value="2" />-->
<!-- <el-option label="5次/天" value="5" />-->
<!-- <el-option label="无限次" value="unlimited" />-->
<!-- </el-select>-->
<!-- </div>-->
<!-- </div>-->
</template>
<!-- Tab: 用户隐私协议 长文本可滚动查看 -->
<template v-if="activeTab === 'privacy'">
@@ -230,6 +241,9 @@
<!-- 注销账号弹窗 -->
<SettingsDeleteAccountDialog v-model="showDeleteAccount" />
<!-- 邀请注册送会员弹窗 -->
<SettingsInviteDialog v-model="showInviteDialog" />
</Teleport>
</template>
@@ -238,14 +252,17 @@ import { ref, reactive, watch, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useStore } from 'vuex'
import { logout } from '@/api/auth'
import { fetchMemberStatus, type MemberStatus } from '@/api/member'
import { timestampToLocalDateTime, timestampDiffDays } from '@/utils/time'
import JobGoalDialog from './JobGoalDialog.vue'
import { resolveRegionName } from '@/utils/region'
import { resolveIndustryName } from '@/utils/industry'
import { resolveJobCategoryName } from '@/utils/jobCategory'
import SettingsDeleteAccountDialog from './SettingsDeleteAccountDialog.vue'
import SettingsInviteDialog from './SettingsInviteDialog.vue'
/** 组件 Props — 控制弹窗显示/隐藏 */
const props = defineProps<{ modelValue: boolean }>()
/** 组件 Props — 控制弹窗显示/隐藏,可指定初始 Tab */
const props = defineProps<{ modelValue: boolean; initialTab?: string }>()
/** 组件 Emits — 通知父组件更新 modelValue */
const emit = defineEmits<{ (e: 'update:modelValue', value: boolean): void }>()
@@ -258,18 +275,24 @@ const store = useStore()
const tabs = [
{ key: 'account', label: '账号与安全', icon: '👤' },
{ key: 'member', label: '会员', icon: '🏅' },
{ key: 'reminder', label: '岗位更新提醒', icon: '🔔' },
{ key: 'reminder', label: '目标岗位设置', icon: '🔔' },
]
/** 当前选中的 Tab */
const activeTab = ref('account')
/** 用户手机号 — 从全局 store 读取 */
const userPhone = computed(() => store.state.userInfo?.mobileNumber || '')
/** 退出登录确认弹窗的显示状态 */
const showLogout = ref(false)
/** 监听弹窗开关 — 打开时锁定背景页面滚动,关闭时恢复 */
watch(() => props.modelValue, (val) => {
document.body.style.overflow = val ? 'hidden' : ''
if (val && props.initialTab) {
activeTab.value = props.initialTab
}
})
/** 岗位更新提醒的配置项 */
@@ -284,6 +307,42 @@ const showGoalDialog = ref(false)
/** 注销账号弹窗显示状态 */
const showDeleteAccount = ref(false)
/** 邀请注册弹窗显示状态 */
const showInviteDialog = ref(false)
/** 会员状态数据 */
const memberStatus = reactive<MemberStatus>({
isMember: false,
expireTime: undefined,
createTime: undefined,
updateTime: undefined,
})
/** 会员到期时间(格式化显示) */
const memberExpireDateTime = computed(() => {
return timestampToLocalDateTime(memberStatus.expireTime, 'returnMinute')
})
/** 会员剩余天数 */
const memberRemainDays = computed(() => {
return timestampDiffDays(memberStatus.expireTime)
})
/** 查询会员状态 */
const loadMemberStatus = async () => {
try {
const res = await fetchMemberStatus()
if (res.data) {
memberStatus.isMember = res.data.isMember ?? false
memberStatus.expireTime = res.data.expireTime
memberStatus.createTime = res.data.createTime
memberStatus.updateTime = res.data.updateTime
}
} catch {
// 查询失败保持默认未开通状态
}
}
/** 岗位名称列表 */
const intentionCategoryNames = computed(() => {
const ids = store.state.jobIntention.categoryIds || []
@@ -312,11 +371,12 @@ const handleEditTarget = () => {
showGoalDialog.value = true
}
/** 弹窗打开时加载求职意向数据 */
/** 弹窗打开时加载求职意向数据和会员状态 */
watch(() => props.modelValue, (val) => {
if (val && store.state.isAuthenticated) {
store.dispatch('loadCommonData')
store.dispatch('loadJobIntention')
loadMemberStatus()
}
})
+107
View File
@@ -0,0 +1,107 @@
<template>
<!-- 邀请注册送会员弹窗 -->
<Teleport to="body">
<div v-if="modelValue" class="settings-invite-overlay" @click="$emit('update:modelValue', false)">
<div class="settings-invite-dialog" @click.stop>
<!-- 关闭按钮 -->
<span class="settings-invite-dialog__close" @click="$emit('update:modelValue', false)"></span>
<!-- 标题 -->
<h2 class="settings-invite-dialog__title">邀请好友免费获取7天会员权益</h2>
<!-- 邀请链接区域 -->
<div class="settings-invite-dialog__link-box">
<p class="settings-invite-dialog__link-text">来一起领取AI求职助手会员{{ inviteText }}点击链接进入活动</p>
</div>
<!-- 复制链接按钮 -->
<button class="settings-invite-dialog__copy-btn" @click="handleCopy">复制链接</button>
<!-- 步骤说明 -->
<p class="settings-invite-dialog__steps-tip">仅需 3 步可免费获取会员权益</p>
<div class="settings-invite-dialog__step">
<div class="settings-invite-dialog__step-title">第1步分享专属邀请链接</div>
<p class="settings-invite-dialog__step-desc">点击"复制链接"并发送给好友</p>
</div>
<div class="settings-invite-dialog__step">
<div class="settings-invite-dialog__step-title">第2步好友完成新用户首次体验</div>
<p class="settings-invite-dialog__step-desc">链接 24 小时内有效好友在时效内体验AI求职助手</p>
</div>
<div class="settings-invite-dialog__step">
<div class="settings-invite-dialog__step-title">第3步分享人获得7天会员权益</div>
<p class="settings-invite-dialog__step-desc">免费会员权益一年内有效AI助力加速上岸</p>
</div>
<!-- 统计数据 -->
<div class="settings-invite-dialog__stats">
<div class="settings-invite-dialog__stat-item">
<div class="settings-invite-dialog__stat-label">累计获得会员天数</div>
<div class="settings-invite-dialog__stat-value">{{ totalDays }}</div>
</div>
<div class="settings-invite-dialog__stat-item">
<div class="settings-invite-dialog__stat-label">累计邀请好友</div>
<div class="settings-invite-dialog__stat-value">{{ totalFriends }}</div>
</div>
</div>
<!-- 活动规则折叠 -->
<div class="settings-invite-dialog__rules" @click="showRules = !showRules">
<span class="settings-invite-dialog__rules-label">活动规则</span>
<svg class="settings-invite-dialog__rules-arrow" :class="{ 'settings-invite-dialog__rules-arrow--open': showRules }" viewBox="0 0 12 12" fill="none">
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<div v-if="showRules" class="settings-invite-dialog__rules-content">
<p>1. 每成功邀请一位新用户注册并完成首次体验邀请人可获得 7 天免费会员权益</p>
<p>2. 邀请链接有效期为 24 小时过期需重新生成</p>
<p>3. 累计获得的会员天数一年内有效超过一年未使用的天数将自动失效</p>
<p>4. 同一被邀请人仅计算一次重复邀请不累计奖励</p>
<p>5. 本活动最终解释权归平台所有</p>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useStore } from 'vuex'
import { ElMessage } from 'element-plus'
/** 组件 Props */
defineProps<{ modelValue: boolean }>()
/** 组件 Emits */
defineEmits<{ (e: 'update:modelValue', value: boolean): void }>()
const store = useStore()
/** 活动规则展开状态 */
const showRules = ref(false)
/** 累计获得会员天数(暂用模拟数据) */
const totalDays = ref(1)
/** 累计邀请好友数(暂用模拟数据) */
const totalFriends = ref(1)
/** 用户邀请码 — 从全局 store 读取 */
const inviteCode = computed(() => store.state.userInfo?.inviteCode || '')
/** 邀请链接文案 */
const inviteText = computed(() => {
const code = inviteCode.value
return `https://www.offerpai.com.cn/invite_code=${code}`
})
/** 复制链接到剪贴板 */
async function handleCopy() {
try {
await navigator.clipboard.writeText(inviteText.value)
ElMessage.success('链接已复制')
} catch {
ElMessage.error('复制失败,请手动复制')
}
}
</script>
+12 -2
View File
@@ -112,7 +112,9 @@
</div>
</Teleport>
<!-- 设置弹窗 -->
<SettingsDialog v-model="showSettingsDialog" />
<SettingsDialog v-model="showSettingsDialog" :initial-tab="store.state.settingsTab" />
<!-- 邀请注册送会员弹窗 -->
<SettingsInviteDialog v-model="showShareDialog" />
</div>
</template>
@@ -132,6 +134,7 @@ import navAgentIcon from '@/assets/images/nav/nav-agent-icon.png'
import navMessageIcon from '@/assets/images/nav/nav-message-icon.png'
import navSettingIcon from '@/assets/images/nav/nav-setting-icon.png'
import navFeedbackIcon from '@/assets/images/nav/nav-feedback-icon.png'
import navShareIcon from '@/assets/images/nav/nav-share-icon.png'
const route = useRoute()
const router = useRouter()
@@ -190,13 +193,18 @@ const mainMenus = computed<MenuItem[]>(() => {
return [...staticMenus, ...dynamicMenuItems.value]
})
const showShareDialog = ref(false)
const showMessageDialog = ref(false)
const showFeedbackDialog = ref(false)
const showSettingsDialog = ref(false)
const showSettingsDialog = computed({
get: () => store.state.showSettings,
set: (val: boolean) => store.commit('SET_SHOW_SETTINGS', val),
})
const feedbackType = ref('')
const feedbackTypeIndex = ref(0)
const feedbackDetail = ref('')
import { ElMessage } from 'element-plus'
import SettingsInviteDialog from "@/components/SettingsInviteDialog.vue";
// ==================== 站内信相关 ====================
/** 未读消息数量 */
@@ -374,6 +382,7 @@ const settingsMenu = computed(() => {
})
const footerMenus = computed(() => [
{ iconImg: navShareIcon, label: '分享送会员', action: () => { showShareDialog.value = true } },
{ iconImg: navMessageIcon, label: '消息通知', badge: unreadCount.value > 0 ? 'NEW' : '', action: () => { showMessageDialog.value = true } },
{ iconImg: settingsMenu.value.iconImg, label: settingsMenu.value.label, action: () => { handleSettingsNav() } },
{ iconImg: navFeedbackIcon, label: '反馈', action: () => { showFeedbackDialog.value = true } },
@@ -387,6 +396,7 @@ async function handleSettingsNav() {
const res = await checkLogin()
if (res.code === '0' && res.data === true) {
store.commit('SET_AUTHENTICATED', true)
store.commit('SET_SETTINGS_TAB', 'account')
showSettingsDialog.value = true
} else {
store.commit('SET_AUTHENTICATED', false)
+1 -1
View File
@@ -69,7 +69,7 @@
</div>
<!-- 提示双击选中一级 allowParentSelect 开启时显示 -->
<div v-if="searchText.length < 2 && allowParentSelect" class="industry-selector__hint">双击可选中一级行业分类</div>
<div v-if="searchText.length < 2 && allowParentSelect" class="industry-selector__hint">双击可选中一级行业分类</div>
<!-- 分栏联动选择区搜索关键词不足 2 字符时显示 -->
<div v-if="searchText.length < 2" class="industry-selector__columns">
+1 -1
View File
@@ -71,7 +71,7 @@
</div>
<!-- 提示双击选中一二级 allowParentSelect 开启时显示 -->
<div v-if="searchText.length < 2 && allowParentSelect" class="job-category-selector__hint">双击可选中一级/二级岗位分类</div>
<div v-if="searchText.length < 2 && allowParentSelect" class="job-category-selector__hint">双击可选中一级/二级岗位分类</div>
<!-- 三栏联动选择区搜索关键词不足 2 字符时显示 -->
<div v-if="searchText.length < 2" class="job-category-selector__columns">
+42
View File
@@ -7,6 +7,8 @@ import { fetchIndustryTree, fetchJobCategoryTree, fetchRegionTree } from '@/api/
import type { IndustryItem, JobCategoryItem, RegionItem } from '@/api/common'
import { fetchJobIntention, saveJobIntention } from '@/api/jobs'
import type { JobIntention } from '@/api/jobs'
import { fetchUserInfo } from '@/api/auth'
import type { UserInfo } from '@/api/auth'
/** 职位列表页缓存数据(从详情页返回时恢复用) */
export interface JobListCache {
@@ -79,6 +81,18 @@ export interface RootState {
* 由 loadJobIntention action 从接口加载,saveJobIntention action 保存后更新
*/
jobIntention: JobIntention
/**
* 用户个人信息 — 登录后从 /user/manage/info 接口获取
* 多个页面可直接从 store 读取
*/
userInfo: UserInfo | null
/**
* 设置弹窗 — 控制显示/隐藏及初始 Tab
*/
showSettings: boolean
settingsTab: string
}
export default createStore<RootState>({
@@ -100,6 +114,9 @@ export default createStore<RootState>({
industryIds: [],
employmentType: 0,
},
userInfo: null,
showSettings: false,
settingsTab: 'account',
},
getters: {
getAppName: (state) => state.appName,
@@ -151,6 +168,15 @@ export default createStore<RootState>({
employmentType: data.employmentType ?? 0,
}
},
SET_USER_INFO(state, data: UserInfo | null) {
state.userInfo = data
},
SET_SHOW_SETTINGS(state, show: boolean) {
state.showSettings = show
},
SET_SETTINGS_TAB(state, tab: string) {
state.settingsTab = tab
},
},
actions: {
updateAppName({ commit }, name: string) {
@@ -201,6 +227,7 @@ export default createStore<RootState>({
commit('SET_AUTHENTICATED', false)
commit('SET_SHOW_LOGIN', false)
commit('SET_LOGIN_REDIRECT', '')
commit('SET_USER_INFO', null)
// 清除 Jobs 页面缓存数据
commit('SET_JOB_LIST_CACHE', null)
commit('SET_JOB_INTENTION', {
@@ -263,6 +290,21 @@ export default createStore<RootState>({
}
},
/**
* 加载用户个人信息到 store
* 登录状态下调用,多个页面可直接读取 store.state.userInfo
*/
async loadUserInfo({ commit }) {
try {
const res = await fetchUserInfo()
if (res.code === '0' && res.data) {
commit('SET_USER_INFO', res.data)
}
} catch (err) {
console.error('[store] 加载用户信息失败', err)
}
},
/**
* 保存求职意向:已登录时调接口保存并更新 store,未登录时仅更新 store
* @param data 求职意向数据
+168
View File
@@ -0,0 +1,168 @@
/**
* 时间处理工具
* 提供毫秒时间戳转换、时间间隔计算方法、浏览器本地时间事件缓存记录工具
*/
/**
* 时间精度级别
* returnYear — 只返回年
* returnMonth — 返回到月
* returnDay — 返回到天
* returnHour — 返回到小时
* returnMinute — 返回到分钟
* returnSecond — 返回到秒
*/
export type TimePrecision = 'returnYear' | 'returnMonth' | 'returnDay' | 'returnHour' | 'returnMinute' | 'returnSecond'
/**
* 毫秒时间戳转 LocalDateTime 字符串
* @param timestamp 毫秒时间戳
* @param precision 返回精度,默认 returnSecond
* @returns 格式化的本地时间字符串
*/
export function timestampToLocalDateTime(timestamp: number | null | undefined, precision: TimePrecision = 'returnSecond'): string {
if (timestamp == null) return '--'
const date = new Date(timestamp)
const y = date.getFullYear()
if (precision === 'returnYear') return `${y}`
const m = String(date.getMonth() + 1).padStart(2, '0')
if (precision === 'returnMonth') return `${y}-${m}`
const d = String(date.getDate()).padStart(2, '0')
if (precision === 'returnDay') return `${y}-${m}-${d}`
const h = String(date.getHours()).padStart(2, '0')
if (precision === 'returnHour') return `${y}-${m}-${d} ${h}`
const min = String(date.getMinutes()).padStart(2, '0')
if (precision === 'returnMinute') return `${y}-${m}-${d} ${h}:${min}`
const s = String(date.getSeconds()).padStart(2, '0')
return `${y}-${m}-${d} ${h}:${min}:${s}`
}
/**
* 计算毫秒时间戳与当前时间的间隔天数
* 如果目标时间在当前时间之前,返回负数
* @param timestamp 毫秒时间戳
* @returns 间隔天数(正数表示未来,负数表示过去)
*/
export function timestampDiffDays(timestamp: number | null | undefined): number {
if (timestamp == null) return 0
const diffMs = timestamp - Date.now()
return Math.floor(diffMs / (1000 * 60 * 60 * 24))
}
/**
* 计算毫秒时间戳与当前时间的详细间隔
* 返回如 "200天1小时5分30秒" 或 "-3天2小时10分15秒" 的格式
* @param timestamp 毫秒时间戳
* @returns 格式化的时间间隔字符串
*/
export function timestampDiffDetailed(timestamp: number | null | undefined): string {
if (timestamp == null) return '--'
let diffMs = timestamp - Date.now()
// 判断是否为过去时间
const prefix = diffMs < 0 ? '-' : ''
diffMs = Math.abs(diffMs)
const days = Math.floor(diffMs / (1000 * 60 * 60 * 24))
diffMs %= 1000 * 60 * 60 * 24
const hours = Math.floor(diffMs / (1000 * 60 * 60))
diffMs %= 1000 * 60 * 60
const minutes = Math.floor(diffMs / (1000 * 60))
diffMs %= 1000 * 60
const seconds = Math.floor(diffMs / 1000)
// 拼接结果,只显示有值的部分
let result = ''
if (days > 0) result += `${days}`
if (hours > 0) result += `${hours}小时`
if (minutes > 0) result += `${minutes}`
if (seconds > 0 || result === '') result += `${seconds}`
return `${prefix}${result}`
}
// ==================== 浏览器本地时间事件缓存记录工具 ====================
/** localStorage 存储的 key */
const TIME_EVENT_CACHE_KEY = 'local_time_event_cache'
/**
* 自定义事件名称 ID 注册表
* 每次新增事件 ID 必须在此处登记,格式:事件ID — 事件描述说明
*
* ┌──────────────────────────────────────────────────────────────┐
* │ 事件名称ID │ 事件描述说明 │
* ├──────────────────────────────────────────────────────────────┤
* │ member_status_query │ 会员状态查询时间记录 │
* │ │ │
* │ │ │
* │ │ │
* └──────────────────────────────────────────────────────────────┘
*/
/** 本地时间事件缓存数据项 */
export interface TimeEventRecord {
/** 用户ID(对应 vuex store 中 userInfo.id */
userId: number | string
/** 事件名称ID(需在上方注册表中登记) */
eventId: string
/** 时间点(LocalDateTime 格式字符串,如 "2026-05-21 14:30:00" */
time: string
}
/**
* 从 localStorage 读取时间事件缓存数据
* @returns 缓存的时间事件数组
*/
function getTimeEventCache(): TimeEventRecord[] {
try {
const raw = localStorage.getItem(TIME_EVENT_CACHE_KEY)
if (!raw) return []
return JSON.parse(raw) as TimeEventRecord[]
} catch {
return []
}
}
/**
* 将时间事件缓存数据保存到 localStorage
* @param data 时间事件数组
*/
function setTimeEventCache(data: TimeEventRecord[]): void {
localStorage.setItem(TIME_EVENT_CACHE_KEY, JSON.stringify(data))
}
/**
* 查询本地时间事件缓存
* 根据用户ID和事件名称ID查找对应的时间记录
* @param eventId 事件名称ID
* @param userId 用户ID(从 vuex store.state.userInfo.id 获取)
* @returns 匹配的时间点字符串,未找到返回 null
*/
export function getTimeEvent(eventId: string, userId: number | string): string | null {
const cache = getTimeEventCache()
const record = cache.find(item => item.userId == userId && item.eventId === eventId)
return record ? record.time : null
}
/**
* 保存/更新本地时间事件缓存
* 同一个用户ID + 事件名称ID 只允许存在唯一一条记录
* @param eventId 事件名称ID
* @param userId 用户ID(从 vuex store.state.userInfo.id 获取)
* @param time 要保存的时间点(LocalDateTime 格式字符串)
*/
export function setTimeEvent(eventId: string, userId: number | string, time: string): void {
const cache = getTimeEventCache()
const index = cache.findIndex(item => item.userId == userId && item.eventId === eventId)
if (index >= 0) {
// 已存在则更新时间点
cache[index].time = time
} else {
// 不存在则新增一条记录
cache.push({ userId, eventId, time })
}
setTimeEventCache(cache)
}
+63 -2
View File
@@ -2,6 +2,18 @@
<div class="jobs-page dflex">
<SideNav />
<div class="jobs-page__content">
<div v-if="false" class="fs18 p20 bgeee">
支付
<iframe
v-if="paymentFormHtml"
:srcdoc="paymentFormHtml"
frameborder="0"
style="width: 100%; height: 600px;"
scrolling="auto"
></iframe>
</div>
<!-- 页面标题 + Tab 切换 -->
<JobPageHeader v-model:activeTab="activeTab" :favoriteCount="favoriteTotal" :applyCount="applyTotal" />
<!-- 收藏统计栏 -->
@@ -43,7 +55,7 @@
:categoryIds="selectedCategoryIds"
:maxSelect="3"
:level="3"
:allowParentSelect="false"
:allowParentSelect="true"
@update:categoryIds="onCategoryChange"
/>
<!-- 行业筛选使用行业选择组件 -->
@@ -52,7 +64,7 @@
:industryIds="selectedIndustryIds"
:maxSelect="3"
:level="2"
:allowParentSelect="false"
:allowParentSelect="true"
@update:industryIds="onIndustryChange"
/>
<!-- 其他筛选条件 -->
@@ -246,6 +258,9 @@
<!-- 职位问题反馈弹窗 -->
<JobFeedbackDialog ref="feedbackDialogRef" v-model="showFeedbackDialog" :job-id="feedbackJobId" />
<!-- 欢迎上传简历弹窗 -->
<ProfileWelcomeDialog v-model="showWelcomeDialog" />
</div>
</template>
@@ -261,9 +276,21 @@ import JobFeedbackDialog from '@/components/JobFeedbackDialog.vue'
import IndustrySelector from '@/components/tools/IndustrySelector.vue'
import JobCategorySelector from '@/components/tools/JobCategorySelector.vue'
import RegionSelector from '@/components/tools/RegionSelector.vue'
import ProfileWelcomeDialog from '@/components/ProfileWelcomeDialog.vue'
import { fetchProfile } from '@/api/profile'
import { fetchJobList, fetchFavoriteList, toggleJobFavorite, removeJobFavorite, fetchFavoriteCount, fetchApplyList, fetchApplyCount, removeJobFromList } from '@/api/jobs'
import type { JobListItem, JobListParams, FavoriteListParams, ApplyListParams, ApplyCountData } from '@/api/jobs'
// 2. 注意:这里的字符串不需要再手动转义双引号了
const paymentFormHtml = ref(`
<form name="punchout_form" method="post" action="https://openapi-sandbox.dl.alipaydev.com/gateway.do?charset=UTF-8&method=alipay.trade.page.pay&sign=o8MQ7cEAh4yrf6ri5iT9gXbtR%2BTncX36p9JNZf8nmXxEp1WKO6dF1HDC6DPF8nxJbus6OG9E5UPSoiHgqrEiu3aeOs4LMcYzXXZP0UQzG%2F6pXyZrvlL70G8uwpnV6MgQojzhKheG0vYIJgSPaGsHKjwcp%2BT8yOZg3eEDIgFYEBf2a3c7kANc7%2FuOOGCSD9Ip9zqwX9LQ3b433NPH3AbGQkFSLsdGOTY62I7yI8hEF7EM7nNm%2BYryieqju21yN76JrV4k%2BgIGHn83I5oT3y7zurzdTqHLD4UawpPWdJ5B7p6wnJ4cN4uzO8JgRpNqogYqqKBPcHIlB9o4uax3bcRqhg%3D%3D&notify_url=http%3A%2F%2F39.108.134.218%3A10106%2Fapi%2F%2Fpublic%2FalipayNotify%2Fpay&version=1.0&app_id=2018062160374941&sign_type=RSA2&timestamp=2026-05-20+15%3A29%3A21&alipay_sdk=alipay-sdk-java-4.40.791.ALL&format=json">
<input type="hidden" name="biz_content" value='{"out_trade_no":"OP2057000709638877186","product_code":"FAST_INSTANT_TRADE_PAY","qr_pay_mode":"4","qrcode_width":200,"subject":"周会员","total_amount":"0.10"}'>
<input type="submit" value="立即支付" style="display:none">
</form>
<script>document.forms[0].submit();<\/script>
`)
// ==================== 路由相关 ====================
const router = useRouter()
@@ -273,6 +300,9 @@ const store = useStore()
/** 登录状态 — 从 store 读取 */
const isAuthenticated = computed(() => store.state.isAuthenticated)
/** 用户昵称 — 从全局 store 读取 */
const nickName = computed(() => store.state.userInfo?.nick || '')
// ==================== 页面状态 ====================
/** 当前激活的 Tab,从 URL query 参数读取,默认"推荐" */
@@ -302,6 +332,9 @@ const feedbackJobId = ref<string | null>(null)
/** 当前问助手的岗位 ID(传给 AiChat 组件) */
const currentAskJobId = ref<string>('')
/** 欢迎弹窗的显示状态 */
const showWelcomeDialog = ref(false)
/** 点击"问助手"按钮,传入岗位 ID 给 AiChat */
function askAssistant(job: JobItem) {
currentAskJobId.value = job.id
@@ -516,6 +549,19 @@ onMounted(async () => {
// 仅登录状态下才加载需要鉴权的数据
if (isAuthenticated.value) {
// 加载用户个人信息到全局 store
store.dispatch('loadUserInfo')
// 检查个人资料是否存在,不存在则弹出欢迎弹窗
// try {
// const profileRes = await fetchProfile()
// if (profileRes.code === '0' && (!profileRes.data || profileRes.data === null)) {
// showWelcomeDialog.value = true
// }
// } catch {
// // 接口异常不阻塞页面加载
// }
// 加载收藏统计(用于 Tab 标签显示)
loadFavoriteCount()
@@ -861,6 +907,21 @@ watch(
{ deep: true },
)
// 监听登录状态变化 — 登录成功后检查个人资料是否存在
watch(isAuthenticated, async (newVal, oldVal) => {
if (newVal && !oldVal) {
// 从未登录变为已登录,检查个人资料
try {
const profileRes = await fetchProfile()
if (profileRes.code === '0' && (!profileRes.data || profileRes.data === null)) {
showWelcomeDialog.value = true
}
} catch {
// 接口异常不阻塞
}
}
})
// 监听 Tab 切换,重置列表状态并加载对应数据
watch(activeTab, (newTab, oldTab) => {
if (initializing) return
+45 -24
View File
@@ -10,6 +10,9 @@
@save="handleSaveEdit"
/>
<!-- 欢迎上传简历弹窗 -->
<ProfileWelcomeDialog v-model="showWelcomeDialog" />
<!-- 页面标题 -->
<div class="profile-page__header">
<h2 class="profile-page__title">个人资料 <span class="profile-page__title-tip"></span></h2>
@@ -51,12 +54,13 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, onMounted, watch, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useStore } from 'vuex'
import SideNav from '@/components/SideNav.vue'
import ProfileEditDrawer from '@/components/ProfileEditDrawer.vue'
import ProfilePageContent from '@/components/ProfilePageContent.vue'
import ProfileWelcomeDialog from '@/components/ProfileWelcomeDialog.vue'
import { saveProfile, fetchProfile, fetchEducation, saveEducation, fetchWork, saveWork, fetchInternship, saveInternship, fetchProject, saveProject, fetchCompetition, saveCompetition } from '@/api/profile'
import type { SaveEducationItem, SaveWorkItem, SaveProjectItem, SaveCompetitionItem } from '@/api/profile'
@@ -68,8 +72,26 @@ onMounted(async () => {
if (!store.state.regions.length) {
store.dispatch('loadCommonData')
}
// 请求个人资料主表数据,填充非数组字段
await loadProfile()
// 请求个人资料主表数据,判断是否需要弹出欢迎弹窗
const profileRes = await fetchProfile()
if (profileRes.code === '0' && (!profileRes.data || profileRes.data === null)) {
// 无个人资料数据,弹出欢迎上传弹窗
showWelcomeDialog.value = true
return
}
// 有数据,正常填充
if (profileRes.code === '0' && profileRes.data) {
const d = profileRes.data
profile.value.name = d.name || ''
profile.value.phone = d.mobileNumber || ''
profile.value.email = d.email || ''
profile.value.idNumber = d.idCard || ''
profile.value.regionCode = d.regionCode || ''
profile.value.wechat = d.wechatNumber || ''
profile.value.skills = d.skills || []
profile.value.certificates = d.certificates || []
profile.value.portfolioUrl = d.portfolioUrl || ''
}
// 请求教育经历数据
await loadEducation()
// 请求工作经历数据
@@ -82,27 +104,6 @@ onMounted(async () => {
await loadCompetition()
})
/** 加载个人资料主表数据 */
async function loadProfile() {
try {
const res = await fetchProfile()
if (res.code === '0' && res.data) {
const d = res.data
profile.value.name = d.name || ''
profile.value.phone = d.mobileNumber || ''
profile.value.email = d.email || ''
profile.value.idNumber = d.idCard || ''
profile.value.regionCode = d.regionCode || ''
profile.value.wechat = d.wechatNumber || ''
profile.value.skills = d.skills || []
profile.value.certificates = d.certificates || []
profile.value.portfolioUrl = d.portfolioUrl || ''
}
} catch {
console.error('[Profile] 加载个人资料失败')
}
}
/** 加载教育经历数据 */
async function loadEducation() {
try {
@@ -213,6 +214,26 @@ async function loadCompetition() {
/** 编辑抽屉的显示状态 */
const showEditDrawer = ref(false)
/** 欢迎弹窗的显示状态 */
const showWelcomeDialog = ref(false)
/** 登录状态 */
const isAuthenticated = computed(() => store.state.isAuthenticated)
/** 监听登录状态变化 — 登录成功后检查个人资料是否存在 */
watch(isAuthenticated, async (newVal, oldVal) => {
if (newVal && !oldVal) {
try {
const profileRes = await fetchProfile()
if (profileRes.code === '0' && (!profileRes.data || profileRes.data === null)) {
showWelcomeDialog.value = true
}
} catch {
// 接口异常不阻塞
}
}
})
/** 当前编辑的模块名称 */
const editModule = ref('info')
+12 -77
View File
@@ -15,7 +15,11 @@
<!-- 简历表格 -->
<div class="resume-page__table-wrap">
<table class="resume-page__table">
<!-- 空状态提示 -->
<div v-if="resumeList.length === 0" class="resume-page__empty">
<p>暂无简历请点击上方按钮上传简历</p>
</div>
<table v-else class="resume-page__table">
<thead>
<tr>
<th class="resume-page__th">简历</th>
@@ -82,40 +86,21 @@
@saved="loadResumeList"
/>
<!-- 导出简历格式选择弹窗 -->
<el-dialog
<!-- 导出简历弹窗组件 -->
<ResumeExportDialog
v-model="exportDialogVisible"
title="导出简历"
width="3.6rem"
:close-on-click-modal="false"
class="resume-export-dialog"
>
<!-- 格式选择 -->
<el-radio-group v-model="exportFormat" class="resume-export-dialog__radio-group">
<el-radio value="pdf">PDF 简历</el-radio>
<el-radio value="word">Word 简历</el-radio>
</el-radio-group>
<template #footer>
<el-button @click="exportDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="exporting" @click="doExport">下载</el-button>
</template>
</el-dialog>
<!-- 隐藏的简历模板用于导出时渲染DOM -->
<div v-if="exportTemplateData" style="position:absolute;left:-9999px;top:0;">
<JobResumeTemplate ref="exportTemplateRef" :resume-data="exportTemplateData" />
</div>
:resume-id="exportResumeId"
:resume-name="exportResumeName"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, nextTick } from 'vue'
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import SideNav from '@/components/SideNav.vue'
import ResumeEditNameDialog from '@/components/ResumeEditNameDialog.vue'
import JobResumeTemplate from '@/components/JobResumeTemplate.vue'
import type { ResumeTemplateData } from '@/components/JobResumeTemplate.vue'
import { exportResumePdf, exportResumeWord, loadResumeTemplateData } from '@/utils/resumeExport'
import ResumeExportDialog from '@/components/ResumeExportDialog.vue'
import { uploadResume } from '@/utils/aiRequest'
import {
fetchResumeList, deleteResume, type ResumeListItem,
@@ -228,60 +213,10 @@ const editTargetPosition = ref('')
/** 导出弹窗是否可见 */
const exportDialogVisible = ref(false)
/** 导出格式:pdf 或 word */
const exportFormat = ref<'pdf' | 'word'>('pdf')
/** 导出中状态 */
const exporting = ref(false)
/** 当前导出的简历 ID */
const exportResumeId = ref('')
/** 当前导出的简历名称(用于文件名) */
const exportResumeName = ref('')
/** 导出用的简历模板数据 */
const exportTemplateData = ref<ResumeTemplateData | null>(null)
/** 导出用的简历模板组件引用 */
const exportTemplateRef = ref<InstanceType<typeof JobResumeTemplate> | null>(null)
/** 执行导出下载 */
async function doExport() {
exporting.value = true
try {
// 1. 加载简历完整数据
const data = await loadResumeTemplateData(exportResumeId.value)
if (!data) {
ElMessage.error('获取简历数据失败')
return
}
// 2. 设置模板数据,等待DOM渲染
exportTemplateData.value = data
await nextTick()
// 3. 获取渲染后的DOM
const element = exportTemplateRef.value?.resumeRef
if (!element) {
ElMessage.error('简历模板渲染失败')
return
}
const fileName = exportResumeName.value || '简历'
if (exportFormat.value === 'pdf') {
await exportResumePdf(element, fileName)
} else {
exportResumeWord(element, fileName)
}
ElMessage.success('导出成功')
exportDialogVisible.value = false
} catch (err) {
console.error('[导出简历] 失败', err)
ElMessage.error('导出失败,请稍后重试')
} finally {
exporting.value = false
// 清理隐藏模板数据
exportTemplateData.value = null
}
}
/** 弹出菜单操作项 */
const popupActions = ['设为默认简历', '编辑名称岗位', '导出简历', '删除']
+33 -7
View File
@@ -29,6 +29,22 @@
@submit="handleFixSubmit"
/>
<!-- 导出简历弹窗组件 -->
<ResumeExportDialog
v-model="exportDialogVisible"
:resume-id="resumeId"
:resume-name="resumeMain.resumeName || '简历'"
/>
<!-- 编辑简历名称弹窗 -->
<ResumeEditNameDialog
v-model="editNameVisible"
:resume-id="resumeId"
:resume-name="resumeMain.resumeName || ''"
:target-position="resumeMain.targetPosition || ''"
@saved="loadResumeDetail"
/>
<!-- 顶部标题 -->
<h2 class="resume-detail__page-title">我的简历</h2>
@@ -43,7 +59,6 @@
<span class="resume-detail__tab-name">{{ resumeMain.resumeName || '未命名简历' }}</span>
</div>
<div class="resume-detail__toolbar-right">
<button class="resume-detail__tool-btn" @click="handleFeedback">问题反馈</button>
<button class="resume-detail__tool-btn" @click="handleEdit">
<svg viewBox="0 0 16 16" fill="none" class="resume-detail__tool-icon">
<path d="M11.5 2.5l2 2L5 13H3v-2l8.5-8.5z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/>
@@ -407,6 +422,8 @@ import SideNav from '@/components/SideNav.vue'
import ProfileEditDrawer from '@/components/ProfileEditDrawer.vue'
import ResumeAnalysisReportDrawer from '@/components/ResumeAnalysisReportDrawer.vue'
import ResumeIssueFixDrawer from '@/components/ResumeIssueFixDrawer.vue'
import ResumeExportDialog from '@/components/ResumeExportDialog.vue'
import ResumeEditNameDialog from '@/components/ResumeEditNameDialog.vue'
import {
fetchResumeMain,
fetchResumeEducation,
@@ -675,14 +692,23 @@ function goBack() {
router.push('/resume')
}
/** 问题反馈 */
function handleFeedback() { console.log('问题反馈') }
/** 编辑简历信息 */
function handleEdit() { console.log('编辑简历信息') }
/** 导出简历 */
function handleExport() { console.log('导出') }
/** 编辑简历名称弹窗显示状态 */
const editNameVisible = ref(false)
/** 编辑简历信息 — 打开编辑名称弹窗 */
function handleEdit() {
editNameVisible.value = true
}
/** 导出简历弹窗显示状态 */
const exportDialogVisible = ref(false)
/** 导出简历 — 打开导出弹窗 */
function handleExport() {
exportDialogVisible.value = true
}
/** 删除简历 — 二次确认后调用删除接口,成功后返回简历列表 */
async function handleDelete() {
+1
View File
@@ -37,6 +37,7 @@ export default defineConfig({
host: '0.0.0.0',
proxy: {
'/api': {
// target: 'http://192.168.31.133:8080',
target: 'http://127.0.0.1:8080',
changeOrigin: true,
},