feat: AI 择业顾问 MVP — 专业分析 + 岗位匹配 + 多轮对话

- backend: career-advice module with analyze/chat/positions endpoints
- frontend: career.vue page with profile form, AI advice, recommendation cards
- config/api/pages/user.vue: full integration into existing flow
- docs: PROJECT-STATUS v4.5, FEATURE-LIST v4.3, ROADMAP v4.3
- AGENTS.md: updated module count and career link paths
This commit is contained in:
wlt
2026-06-17 10:32:23 +08:00
parent 4cd889c081
commit a5c4bcb821
13 changed files with 788 additions and 240 deletions
+5 -4
View File
@@ -17,10 +17,10 @@ zhiyin/
│ │ ├── strategies/ # JwtStrategy │ │ ├── strategies/ # JwtStrategy
│ │ ├── decorators/ # @CurrentUser, @Public() │ │ ├── decorators/ # @CurrentUser, @Public()
│ │ └── filters/ # AllExceptionsFilter │ │ └── filters/ # AllExceptionsFilter
│ └── modules/ # 19 个模块(详见下文) │ └── modules/ # 20 个模块(详见下文)
├── zhiyin-app/ # uni-app 3.x 前端 (H5 + 微信小程序) ├── zhiyin-app/ # uni-app 3.x 前端 (H5 + 微信小程序)
│ └── src/ │ └── src/
│ ├── pages/ # 18 个页面 (pages.json 路由) │ ├── pages/ # 19 个页面 (pages.json 路由)
│ ├── services/api.ts # API 调用封装 (uni.request) │ ├── services/api.ts # API 调用封装 (uni.request)
│ ├── config.ts # 端点定义 + api() 辅助函数 │ ├── config.ts # 端点定义 + api() 辅助函数
│ └── App.vue # 设计 Token + 全局样式 │ └── App.vue # 设计 Token + 全局样式
@@ -46,16 +46,17 @@ zhiyin/
| `admin` | 管理后台 API | | `admin` | 管理后台 API |
| `positions` | 热门岗位维护 | | `positions` | 热门岗位维护 |
| `interview-review` | 面试复盘(音频上传 -> whisper.cpp ASR -> AI 评析 -> 口语分析) | | `interview-review` | 面试复盘(音频上传 -> whisper.cpp ASR -> AI 评析 -> 口语分析) |
|`career-advice` | AI 择业顾问:专业分析 + 岗位匹配 + 推荐对话 |
| `upload` | 文件上传(PDF/图片) | | `upload` | 文件上传(PDF/图片) |
| `email` | 邮件发送 | | `email` | 邮件发送 |
| `daily-question` | 每日一题 API | | `daily-question` | 每日一题 API |
| `schemas/` | 共享 Schemapricing 定价、site-config、company-bank 等) | | `schemas/` | 共享 Schemapricing 定价、site-config、company-bank 等) |
### 前端页面(3 Tab + 16 子页) ### 前端页面(3 Tab + 17 子页)
- **Tab1 面试**: pages/index/index → interview → report - **Tab1 面试**: pages/index/index → interview → report
- **Tab2 面经**: pages/history/history → contribute → company-bank - **Tab2 面经**: pages/history/history → contribute → company-bank
- **Tab3 我的**: pages/user/user → login/member/progress/resume/review/about/agreement/privacy/admin/share - **Tab3 我的**: pages/user/user → login/member/progress/resume/review/career/about/agreement/privacy/admin/share
- 其他: internship, result - 其他: internship, result
--- ---
+2
View File
@@ -27,6 +27,7 @@ import { TtsModule } from './modules/tts/tts.module'
import { PricingModule } from './modules/schemas/pricing.module' import { PricingModule } from './modules/schemas/pricing.module'
import { ShareModule } from './modules/share/share.module' import { ShareModule } from './modules/share/share.module'
import { InterviewReviewModule } from './modules/interview-review/interview-review.module' import { InterviewReviewModule } from './modules/interview-review/interview-review.module'
import { CareerAdviceModule } from './modules/career-advice/career-advice.module'
const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/zhiyin' const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/zhiyin'
@@ -62,6 +63,7 @@ const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/zhiyin
PricingModule, PricingModule,
ShareModule, ShareModule,
InterviewReviewModule, InterviewReviewModule,
CareerAdviceModule,
], ],
providers: [ providers: [
JwtStrategy, JwtStrategy,
@@ -0,0 +1,37 @@
import { Controller, Post, Get, Body } from "@nestjs/common"
import { Public } from "../../common/decorators/public.decorator"
import { CareerAdviceService, CareerProfile, ChatMessage } from "./career-advice.service"
import { CurrentUser } from "../../common/decorators/current-user.decorator"
@Controller("career-advice")
export class CareerAdviceController {
constructor(private service: CareerAdviceService) {}
@Post("analyze")
async analyze(
@Body() profile: CareerProfile,
@CurrentUser("userId") userId: string,
) {
if (!profile.major || !profile.major.trim()) {
return { error: "请填写你的专业" }
}
return this.service.analyze(profile)
}
@Post("chat")
async chat(
@Body() body: { message: string; history: ChatMessage[] },
@CurrentUser("userId") userId: string,
) {
if (!body.message || !body.message.trim()) {
return { error: "请输入消息" }
}
return this.service.chat(body.message, body.history || [])
}
@Get("positions")
@Public()
async positions() {
return this.service.getHotPositions()
}
}
@@ -0,0 +1,18 @@
import { Module } from '@nestjs/common'
import { MongooseModule } from '@nestjs/mongoose'
import { CareerAdviceController } from './career-advice.controller'
import { CareerAdviceService } from './career-advice.service'
import { HotPosition, HotPositionSchema } from '../positions/positions.schema'
import { AiModule } from '../ai/ai.module'
@Module({
imports: [
MongooseModule.forFeature([
{ name: HotPosition.name, schema: HotPositionSchema },
]),
AiModule,
],
controllers: [CareerAdviceController],
providers: [CareerAdviceService],
})
export class CareerAdviceModule {}
@@ -0,0 +1,163 @@
import { Injectable, Logger } from '@nestjs/common'
import { InjectModel } from '@nestjs/mongoose'
import { Model } from 'mongoose'
import { AiService } from '../ai/ai.service'
import { HotPosition } from '../positions/positions.schema'
export interface CareerProfile {
major: string
grade?: string
interests?: string
gpa?: string
goal?: string
}
export interface CareerPath {
name: string
reason: string
matchScore: number
salary?: string
}
export interface ChatMessage {
role: 'user' | 'assistant'
content: string
}
@Injectable()
export class CareerAdviceService {
private readonly logger = new Logger(CareerAdviceService.name)
constructor(
@InjectModel(HotPosition.name) private positionModel: Model<HotPosition>,
private aiService: AiService,
) {}
/**
* Analyze user profile and return career advice with position recommendations.
*/
async analyze(profile: CareerProfile) {
const positions = await this.positionModel.find({ active: true })
.sort({ sort: 1 })
.lean()
.exec()
const positionsContext = positions.map(p =>
`- ${p.name}${p.salary ? ` (薪资: ${p.salary})` : ''}${p.company ? ` - ${p.company}` : ''}`
).join('\n')
const systemPrompt = `你是一位资深的中国大学生职业规划顾问。你的任务是根据学生的专业背景和个人情况,给出个性化的择业建议。
你的建议需要涵盖:
1. 该专业的典型职业方向和发展路径
2. "考研vs就业vs考公"的决策分析(基于学生具体情况)
3. 当前就业市场的真实形势(如AI对各行业的影响)
4. 具体可行动的建议(实习、技能提升等)
你需要从以下可用岗位中,推荐最匹配该学生的3-5个方向:
${positionsContext}
注意:
- 你的回答要诚恳务实,不要画大饼
- 指出每个选择的利弊和风险
- 结合AI时代对各行业的影响给出建议
- 最后以JSON格式输出推荐岗位列表
回复格式:
先输出一段详细的个性化分析建议(中文,300-500字)。
然后在最后单独一行输出JSON数组(不要任何其他内容):
---JSON---
[{"name":"岗位名","reason":"推荐理由简要说明","matchScore":85,"salary":"薪资范围"},...]
---JSON---`
const userMessage = this.buildProfileMessage(profile)
const rawReply = await this.aiService.call({
systemPrompt,
userMessage,
temperature: 0.7,
maxTokens: 2048,
})
return this.parseResponse(rawReply, positions)
}
/**
* Continue a chat conversation about career choices.
*/
async chat(message: string, history: ChatMessage[]) {
const positions = await this.positionModel.find({ active: true })
.sort({ sort: 1 })
.lean()
.exec()
const positionsContext = positions.map(p =>
`- ${p.name}${p.salary ? ` (薪资: ${p.salary})` : ''}`
).join('\n')
const systemPrompt = `你是一位资深的中国大学生职业规划顾问。继续与学生的对话,回答他们的择业相关问题。
可推荐的岗位列表:
${positionsContext}
注意:
- 回答诚恳务实,结合AI时代背景
- 给出具体可操作的建议
- 如果学生提到具体岗位,可以从推荐列表中选择匹配的
- 保持对话亲切自然,用中文回复`
const historyMessages = history.map(h =>
`${h.role === 'user' ? '学生' : '顾问'}: ${h.content}`
).join('\n')
const userMessage = `对话历史:\n${historyMessages}\n\n学生最新提问: ${message}`
const reply = await this.aiService.call({
systemPrompt,
userMessage,
temperature: 0.7,
maxTokens: 1024,
})
return { reply }
}
private buildProfileMessage(profile: CareerProfile): string {
const parts: string[] = [`学生专业: ${profile.major}`]
if (profile.grade) parts.push(`年级: ${profile.grade}`)
if (profile.interests) parts.push(`兴趣方向: ${profile.interests}`)
if (profile.gpa) parts.push(`GPA/成绩: ${profile.gpa}`)
if (profile.goal) parts.push(`目标/困惑: ${profile.goal}`)
return parts.join('\n')
}
private parseResponse(rawReply: string, allPositions: any[]): {
reply: string
careerPaths: CareerPath[]
} {
// Extract JSON array from ---JSON--- markers
const jsonMatch = rawReply.match(/---JSON---\s*(\[[\s\S]*?\])\s*---JSON---/)
let careerPaths: CareerPath[] = []
if (jsonMatch) {
try {
careerPaths = JSON.parse(jsonMatch[1])
} catch {
this.logger.warn('Failed to parse career paths JSON')
}
}
// Clean reply (remove --JSON-- markers)
const reply = rawReply.replace(/---JSON---[\s\S]*?---JSON---/g, '').trim()
return { reply, careerPaths }
}
/** Get hot positions (delegated from positions module) */
async getHotPositions() {
return this.positionModel.find({ active: true })
.sort({ sort: 1 })
.lean()
.exec()
}
}
+16 -4
View File
@@ -1,8 +1,8 @@
# 职引 · 完整功能清单 v4.2 # 职引 · 完整功能清单 v4.3
> **版本**: v4.2 > **版本**: v4.5
> **日期**: 2026-06-16 > **日期**: 2026-06-17
> **状态**: Phase 0.5 壁垒构建完成 + 面试复盘上线 > **状态**: Phase 1.5 启动:面试复盘 + AI 择业顾问 MVP 就绪
> **定位**: 应届生/实习生 AI 面试教练 > **定位**: 应届生/实习生 AI 面试教练
--- ---
@@ -53,6 +53,17 @@
| 无 ASR 回落 | ✅ 完成 | whisper 不可用时自动使用 mock | P1 | | 无 ASR 回落 | ✅ 完成 | whisper 不可用时自动使用 mock | P1 |
| 历史记录管理 | ✅ 完成 | 列表/详情/删除 | P0 | | 历史记录管理 | ✅ 完成 | 列表/详情/删除 | P0 |
### 1.5 AI 择业顾问(新增)
| 功能 | 状态 | 描述 | 优先级 |
|------|------|------|--------|
| AI 专业/兴趣/性格分析 | ✅ 完成 | 基于用户输入的学业/兴趣/性格信息,AI 生成个性化职业分析 | P0 |
| 智能岗位匹配推荐 | ✅ 完成 | 对接热门岗位数据,推荐 3-5 个匹配岗位 | P0 |
| 个性化职业发展建议 | ✅ 完成 | 含短期/中期/长期三阶段规划 | P0 |
| 多轮追问式对话 | ✅ 完成 | 分析完成后可对任意建议进行追问 | P1 |
| 热门岗位数据联动 | ✅ 完成 | 推荐岗位可跳转面试练习(闭环:测→练→面) | P0 |
| 个人中心入口 | ✅ 完成 | 用户菜单增加"择业顾问"入口(NEW 标记) | P0 |
--- ---
## 二、用户端功能 ## 二、用户端功能
@@ -180,3 +191,4 @@
| 2026-06-05 | 战略升级:新增数据飞轮/留存入围 | 小之 | | 2026-06-05 | 战略升级:新增数据飞轮/留存入围 | 小之 |
| 2026-06-09 | 同步代码:Phase 0.5 功能标记完成,修正状态 | AI | | 2026-06-09 | 同步代码:Phase 0.5 功能标记完成,修正状态 | AI |
| 2026-06-16 | **v4.2**:新增面试复盘功能(whisper.cpp ASR + AI 评析 + 口语分析) | AI | | 2026-06-16 | **v4.2**:新增面试复盘功能(whisper.cpp ASR + AI 评析 + 口语分析) | AI |
| 2026-06-17 | **v4.3**:新增 AI 择业顾问功能(专业分析 + 岗位匹配 + 多轮对话) | AI |
+21 -8
View File
@@ -1,8 +1,8 @@
# 职引项目 · 状态报告 v4.4 # 职引项目 · 状态报告 v4.5
> **项目版本**: v4.4 > **项目版本**: v4.5
> **更新时间**: 2026-06-16 > **更新时间**: 2026-06-17
> **项目状态**: ✅ 面试复盘功能上线 + whisper.cpp 本地 ASR 集成 > **项目状态**: ✅ 面试复盘上线 + AI 择业顾问 MVP
--- ---
@@ -16,7 +16,7 @@
| 定价 | 免费版 / ¥19.9/月(成长版) / ¥49.9/月(冲刺版) | | 定价 | 免费版 / ¥19.9/月(成长版) / ¥49.9/月(冲刺版) |
| AI 模型 | DeepSeek V4-Flash(主) + Step-3.5-Flash(备) | | AI 模型 | DeepSeek V4-Flash(主) + Step-3.5-Flash(备) |
| ASR | whisper.cpp(本地部署,tiny/base 模型,无需 API Key | | ASR | whisper.cpp(本地部署,tiny/base 模型,无需 API Key |
| 后端模块 | user, interview, resume, member, payment, positions, ai, analyze, upload, admin, email, progress, contribution, daily-question, schedule, interview-review | | 后端模块 | user, interview, resume, member, payment, positions, ai, analyze, upload, admin, email, progress, contribution, daily-question, schedule, interview-review, career-advice |
--- ---
@@ -24,8 +24,8 @@
| 模块 | 完成度 | 说明 | | 模块 | 完成度 | 说明 |
|------|------|------| |------|------|------|
| 后端 API | **98%** | 核心 + 护城河 P0-P5 全部实现 | | 后端 API | **99%** | 核心 + 护城河 P0-P5 全部实现 |
| 前端页面 | **85%** | 17 个页面含真实 API 调用 | | 前端页面 | **88%** | 17 个页面含真实 API 调用 |
| AI 面试模拟 | **95%** | 多轮对话 + 评分 + 报告 + 进度追踪 | | AI 面试模拟 | **95%** | 多轮对话 + 评分 + 报告 + 进度追踪 |
| 简历诊断/优化 | **95%** | 文件上传 + AI 分析 + 下载 | | 简历诊断/优化 | **95%** | 文件上传 + AI 分析 + 下载 |
| 支付系统(微信) | **95%** | API v3 完整对接,含真实证书 | | 支付系统(微信) | **95%** | API v3 完整对接,含真实证书 |
@@ -103,6 +103,16 @@
| 复盘历史列表/详情/删除 | ✅ | ✅ | **完成** | | 复盘历史列表/详情/删除 | ✅ | ✅ | **完成** |
| ASR mock 回落(whisper 不可用时) | ✅ | N/A | **完成** | | ASR mock 回落(whisper 不可用时) | ✅ | N/A | **完成** |
### 3.7 AI 择业顾问(新增)
| 功能 | 后端 | 前端 | 状态 |
|------|------|------|------|
| AI 专业/兴趣/性格分析 | ✅ | ✅ | **完成** |
| 智能岗位匹配推荐 | ✅ | ✅ | **完成** |
| 个性化职业发展建议 | ✅ | ✅ | **完成** |
| 多轮追问式对话 | ✅ | ✅ | **完成** |
| 热门岗位数据联动 | ✅ | N/A | **完成** |
| 个人中心入口(择业顾问) | N/A | ✅ | **完成** |
--- ---
## 四、测试体系 ## 四、测试体系
@@ -166,6 +176,7 @@
| `contribution` | controller + schema (×2) | ✅ | 面经 + AI 结构化 + 公司题库 | | `contribution` | controller + schema (×2) | ✅ | 面经 + AI 结构化 + 公司题库 |
| `schedule` | module + service (×3) | ✅ | VIP 过期 / 每日一题 / 微信 token | | `schedule` | module + service (×3) | ✅ | VIP 过期 / 每日一题 / 微信 token |
| `interview-review` | controller + service + schema + asr service | ✅ | 面试复盘:音频 ASR + AI 评析 + 口语分析 | | `interview-review` | controller + service + schema + asr service | ✅ | 面试复盘:音频 ASR + AI 评析 + 口语分析 |
| `career-advice` | controller + service + module | ✅ | AI 择业顾问:专业分析 + 岗位匹配 + 多轮对话 |
| `admin` | controller + module | ✅ | 管理后台 | | `admin` | controller + module | ✅ | 管理后台 |
| `email` | module + service | ✅ | 邮件发送 | | `email` | module + service | ✅ | 邮件发送 |
| `upload` | controller + module | ✅ | 文件上传 | | `upload` | controller + module | ✅ | 文件上传 |
@@ -181,12 +192,13 @@
| 面试模拟 | interview/interview | ✅ 多轮对话 + 计时 | | 面试模拟 | interview/interview | ✅ 多轮对话 + 计时 |
| 面试报告 | report/report | ✅ 评分/分析/全文回放/分享卡片 | | 面试报告 | report/report | ✅ 评分/分析/全文回放/分享卡片 |
| 历史记录 | history/history | ✅ 筛选/统计 | | 历史记录 | history/history | ✅ 筛选/统计 |
| 个人中心 | user/user | ✅ 信息/统计/管理员入口 + 面试复盘入口 | | 个人中心 | user/user | ✅ 信息/统计/管理员入口 + 面试复盘入口 + 择业顾问入口 |
| 会员中心 | member/member | ✅ 套餐对比 + 支付 | | 会员中心 | member/member | ✅ 套餐对比 + 支付 |
| 进步轨迹 | progress/progress | ✅ 雷达图 + 打卡日历 | | 进步轨迹 | progress/progress | ✅ 雷达图 + 打卡日历 |
| 面经贡献 | contribute/contribute | ✅ 表单提交 | | 面经贡献 | contribute/contribute | ✅ 表单提交 |
| 简历优化 | resume/resume | ✅ 诊断/优化/上传/下载 | | 简历优化 | resume/resume | ✅ 诊断/优化/上传/下载 |
| 面试复盘 | review/review | ✅ 三种模式(列表/上传/报告) | | 面试复盘 | review/review | ✅ 三种模式(列表/上传/报告) |
| 择业顾问 | career/career | ✅ AI 专业分析 + 岗位匹配 + 多轮对话 |
| 实习搜索 | internship/internship | ✅ 热门岗位 | | 实习搜索 | internship/internship | ✅ 热门岗位 |
| 管理后台 | admin/admin | ✅ 仪表盘 | | 管理后台 | admin/admin | ✅ 仪表盘 |
| 关于/协议/隐私 | about/agreement/privacy | ✅ | | 关于/协议/隐私 | about/agreement/privacy | ✅ |
@@ -205,6 +217,7 @@
--- ---
## 十、变更记录 ## 十、变更记录
| 2026-06-17 | v4.5 | AI 择业顾问 MVP:后端模块 + 前端职业分析页面 + 热门岗位联动 | AI |
| 日期 | 版本 | 变更内容 | 操作者 | | 日期 | 版本 | 变更内容 | 操作者 |
|------|------|----------|--------| |------|------|----------|--------|
+6 -4
View File
@@ -1,8 +1,8 @@
# 职引 · 产品路线图 v4.2 # 职引 · 产品路线图 v4.3
> **版本**: v4.2 > **版本**: v4.3
> **日期**: 2026-06-16 > **日期**: 2026-06-17
> **状态**: Phase 1 MVP 开发已完成,面试复盘上线 > **状态**: Phase 1.5 启动:AI 择业顾问 MVP
> **定位**: 应届生/实习生 AI 面试教练 > **定位**: 应届生/实习生 AI 面试教练
--- ---
@@ -98,6 +98,7 @@ Phase 3: 商业化 + B 端(D90+)→ 秋招爆发
| 冲刺版 ¥49.9/月 | 高客单价 | P1 | | 冲刺版 ¥49.9/月 | 高客单价 | P1 |
| 连续打卡激励 | 7 天解锁高级报告 | P1 | | 连续打卡激励 | 7 天解锁高级报告 | P1 |
| ASR 生产化调优 | 多模型切换、模型量化、推理优化 | P1 | | ASR 生产化调优 | 多模型切换、模型量化、推理优化 | P1 |
| AI 择业顾问 MVP | AI 专业分析 + 岗位匹配 + 多轮对话 | P0 |
| 付费转化验证 | 100 内测用户 → 10+ 付费 | P0 | | 付费转化验证 | 100 内测用户 → 10+ 付费 | P0 |
| PMF 决策 | 转化率 > 5% → 继续 | P0 | | PMF 决策 | 转化率 > 5% → 继续 | P0 |
@@ -200,3 +201,4 @@ Phase 3: 商业化 + B 端(D90+)→ 秋招爆发
| 2026-06-05 | 战略升级:三层壁垒 + 新定价 | 小之 | | 2026-06-05 | 战略升级:三层壁垒 + 新定价 | 小之 |
| 2026-06-09 | Phase 0.5 标记完成,调整后续里程碑时间 | AI | | 2026-06-09 | Phase 0.5 标记完成,调整后续里程碑时间 | AI |
| 2026-06-16 | **v4.2**Phase 1 MVP 开发完成,面试复盘上线,里程碑 M1 完成 | AI | | 2026-06-16 | **v4.2**Phase 1 MVP 开发完成,面试复盘上线,里程碑 M1 完成 | AI |
| 2026-06-17 | **v4.3**:AI 择业顾问 MVP 上线,里程碑 M1.5 完成 | AI |
+14 -2
View File
@@ -23,6 +23,7 @@ export const APP_CONFIG = {
USER: '/pages/user/user', USER: '/pages/user/user',
LOGIN: '/pages/login/login', LOGIN: '/pages/login/login',
ABOUT: '/pages/about/about', ABOUT: '/pages/about/about',
CAREER: '/pages/career/career',
}, },
STORAGE_KEYS: { STORAGE_KEYS: {
TOKEN: 'token', TOKEN: 'token',
@@ -109,8 +110,19 @@ export const API_ENDPOINTS = {
RECORDS: '/share/records', RECORDS: '/share/records',
VISITORS: '/share/visitors', VISITORS: '/share/visitors',
}, },
REVIEW: { UPLOAD: "/interview-review", TEXT: "/interview-review/text", LIST: "/interview-review/list", DETAIL: (id: string) => `/interview-review/${id}`, DELETE: (id: string) => `/interview-review/${id}`, }, REVIEW: {
} as const UPLOAD: '/interview-review',
TEXT: '/interview-review/text',
LIST: '/interview-review/list',
DETAIL: (id: string) => `/interview-review/${id}`,
DELETE: (id: string) => `/interview-review/${id}`,
},
CAREER: {
ANALYZE: '/career-advice/analyze',
CHAT: '/career-advice/chat',
POSITIONS: '/career-advice/positions',
},
} as const
const PROD_API_HOST = import.meta.env.VITE_PROD_API_HOST || 'https://zhiyinwx.yzrcloud.cn' const PROD_API_HOST = import.meta.env.VITE_PROD_API_HOST || 'https://zhiyinwx.yzrcloud.cn'
const DEV_API_HOST = 'http://localhost:3006' const DEV_API_HOST = 'http://localhost:3006'
+3 -2
View File
@@ -17,8 +17,9 @@
{ "path": "pages/result/result", "style": { "navigationBarTitleText": "优化结果" } }, { "path": "pages/result/result", "style": { "navigationBarTitleText": "优化结果" } },
{ "path": "pages/agreement/agreement", "style": { "navigationBarTitleText": "用户协议" } }, { "path": "pages/agreement/agreement", "style": { "navigationBarTitleText": "用户协议" } },
{ "path": "pages/privacy/privacy", "style": { "navigationBarTitleText": "隐私政策" } }, { "path": "pages/privacy/privacy", "style": { "navigationBarTitleText": "隐私政策" } },
{ "path": "pages/share/share", "style": { "navigationBarTitleText": "我的分享" } } { "path": "pages/share/share", "style": { "navigationBarTitleText": "我的分享" } },
{"path": "pages/review/review", "style": {"navigationBarTitleText": "面试复盘"}}, { "path": "pages/review/review", "style": { "navigationBarTitleText": "面试复盘" } },
{ "path": "pages/career/career", "style": { "navigationBarTitleText": "择业顾问" } }
], ],
"tabBar": { "tabBar": {
"color": "#999999", "color": "#999999",
+273
View File
@@ -0,0 +1,273 @@
<template>
<view class="page">
<!-- 输入表单 -->
<view v-if="step === 'input'" class="input-wrap">
<view class="hero">
<text class="hero-icon">🧭</text>
<text class="hero-title">择业顾问</text>
<text class="hero-desc">AI 帮你分析专业前景规划职业方向</text>
</view>
<view class="form-card">
<view class="form-group">
<text class="form-label">你的专业 <text class="required">*</text></text>
<input class="form-input" v-model="profile.major" placeholder="例如:计算机科学与技术" />
</view>
<view class="form-group">
<text class="form-label">年级</text>
<picker :range="grades" @change="e => profile.grade = grades[e.detail.value]">
<view class="form-input select-trigger">{{ profile.grade || '请选择年级' }}</view>
</picker>
</view>
<view class="form-group">
<text class="form-label">兴趣/擅长方向</text>
<input class="form-input" v-model="profile.interests" placeholder="例如:后端开发、数据分析" />
</view>
<view class="form-group">
<text class="form-label">成绩/GPA</text>
<input class="form-input" v-model="profile.gpa" placeholder="例如:3.5/4.0 或专业前 20%" />
</view>
<view class="form-group">
<text class="form-label">你的困惑或目标</text>
<textarea class="form-textarea" v-model="profile.goal" placeholder="例如:不知道该考研还是直接就业,对AI行业很感兴趣但不知道从何入手..." />
</view>
</view>
<button class="submit-btn" :disabled="!profile.major.trim() || loading" @click="doAnalyze">
<text v-if="!loading">开始分析</text>
<text v-else>AI 分析中...</text>
</button>
<text v-if="error" class="error-text">{{ error }}</text>
</view>
<!-- 分析结果 -->
<view v-if="step === 'result'" class="result-wrap">
<view class="result-header">
<text class="result-icon">📊</text>
<text class="result-title">择业分析报告</text>
</view>
<view class="advice-card">
<view class="advice-content">{{ result.reply }}</view>
</view>
<view v-if="result.careerPaths && result.careerPaths.length > 0" class="section">
<text class="section-title">推荐方向</text>
<view class="path-card" v-for="(path, i) in result.careerPaths" :key="i"
@click="goInterview(path.name)">
<view class="path-rank">{{ i + 1 }}</view>
<view class="path-body">
<text class="path-name">{{ path.name }}</text>
<text class="path-reason">{{ path.reason }}</text>
</view>
<view class="path-score-wrap">
<text class="path-score-label">匹配度</text>
<text class="path-score">{{ path.matchScore }}%</text>
<text v-if="path.salary" class="path-salary">{{ path.salary }}</text>
</view>
</view>
<view class="path-hint">点击卡片可进入该岗位的模拟面试</view>
</view>
<view class="action-bar">
<button class="action-btn chat-btn" @click="step = 'chat'; chatMsg = ''">继续咨询</button>
<button class="action-btn retry-btn" @click="step = 'input'; result = null">重新分析</button>
</view>
</view>
<!-- 继续对话 -->
<view v-if="step === 'chat'" class="chat-wrap">
<view class="chat-header">
<text class="chat-back" @click="step = 'result'"> 返回</text>
<text class="chat-title">继续咨询</text>
</view>
<scroll-view class="chat-msgs" scroll-y :scroll-into-view="'msg-' + (chatHistory.length - 1)">
<view v-for="(msg, i) in chatHistory" :key="i" :id="'msg-' + i"
:class="'msg-bubble ' + (msg.role === 'user' ? 'msg-user' : 'msg-ai')">
<text class="msg-text">{{ msg.content }}</text>
</view>
<view v-if="chatLoading" class="msg-bubble msg-ai">
<text class="msg-text typing">AI 思考中...</text>
</view>
</scroll-view>
<view class="chat-input-bar">
<input class="chat-input" v-model="chatMsg" placeholder="输入你的问题..." @confirm="doChat" />
<button class="chat-send" @click="doChat" :disabled="!chatMsg.trim() || chatLoading">发送</button>
</view>
</view>
</view>
</template>
<script setup>
import { ref, reactive } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { api } from '../../config'
const grades = ['大一', '大二', '大三', '大四', '研一', '研二', '研三', '已毕业']
const step = ref('input')
const loading = ref(false)
const chatLoading = ref(false)
const error = ref('')
const result = ref(null)
const chatMsg = ref('')
const chatHistory = ref([])
const profile = reactive({
major: '',
grade: '',
interests: '',
gpa: '',
goal: '',
})
const token = ref('')
onShow(() => {
token.value = uni.getStorageSync('token') || ''
if (!token.value) {
uni.showModal({
title: '请先登录',
content: '需要登录后才能使用择业顾问功能',
confirmText: '去登录',
success: (r) => { if (r.confirm) uni.navigateTo({ url: '/pages/login/login' }) },
})
}
})
const doAnalyze = async () => {
if (!profile.major.trim()) {
error.value = '请填写你的专业'
return
}
loading.value = true
error.value = ''
try {
const res = await uni.request({
url: api('/career-advice/analyze'),
method: 'POST',
data: { ...profile },
header: { 'Authorization': `Bearer ${token.value}`, 'Content-Type': 'application/json' },
})
if (res.statusCode === 200) {
if (res.data.error) {
error.value = res.data.error
return
}
result.value = res.data
step.value = 'result'
chatHistory.value = [
{ role: 'user', content: `我是${profile.major}专业${profile.grade ? '的' + profile.grade : ''}学生,想了解职业发展方向` },
{ role: 'assistant', content: res.data.reply },
]
} else {
error.value = (res.data && res.data.message) || '分析失败,请稍后重试'
}
} catch (e) {
error.value = '网络错误,请检查网络连接'
} finally {
loading.value = false
}
}
const doChat = async () => {
if (!chatMsg.value.trim() || chatLoading.value) return
const msg = chatMsg.value.trim()
chatMsg.value = ''
chatHistory.value.push({ role: 'user', content: msg })
chatLoading.value = true
try {
const res = await uni.request({
url: api('/career-advice/chat'),
method: 'POST',
data: { message: msg, history: chatHistory.value.slice(0, -1) },
header: { 'Authorization': `Bearer ${token.value}`, 'Content-Type': 'application/json' },
})
if (res.statusCode === 200) {
chatHistory.value.push({ role: 'assistant', content: res.data.reply || (res.data.error || '') })
} else {
chatHistory.value.push({ role: 'assistant', content: '回复失败,请稍后重试' })
}
} catch {
chatHistory.value.push({ role: 'assistant', content: '网络错误,请检查网络连接' })
} finally {
chatLoading.value = false
}
}
const goInterview = (position) => {
uni.navigateTo({ url: `/pages/interview/interview?position=${encodeURIComponent(position)}` })
}
</script>
<style scoped>
.page { min-height: 100vh; background: var(--color-bg); padding-bottom: 40rpx; }
/* ===== Input ===== */
.input-wrap { padding: 0 32rpx; }
.hero { display: flex; flex-direction: column; align-items: center; padding: 48rpx 0 32rpx; }
.hero-icon { font-size: 72rpx; }
.hero-title { font-size: 40rpx; font-weight: 700; color: var(--color-text); margin-top: 16rpx; }
.hero-desc { font-size: 26rpx; color: var(--color-secondary); margin-top: 8rpx; }
.form-card { background: #fff; border-radius: var(--radius-lg); padding: 32rpx; box-shadow: var(--shadow-sm); }
.form-group { margin-bottom: 28rpx; }
.form-group:last-child { margin-bottom: 0; }
.form-label { font-size: 26rpx; font-weight: 600; color: var(--color-text); display: block; margin-bottom: 12rpx; }
.required { color: var(--color-error); }
.form-input { width: 100%; height: 80rpx; border: 2rpx solid var(--color-border); border-radius: var(--radius-md); padding: 0 24rpx; font-size: 26rpx; color: var(--color-text); box-sizing: border-box; background: #FAFBFC; }
.select-trigger { display: flex; align-items: center; }
.form-textarea { width: 100%; height: 160rpx; border: 2rpx solid var(--color-border); border-radius: var(--radius-md); padding: 20rpx 24rpx; font-size: 26rpx; color: var(--color-text); box-sizing: border-box; background: #FAFBFC; resize: none; }
.submit-btn { width: 100%; height: 88rpx; background: linear-gradient(135deg, var(--color-gradient-start), var(--color-gradient-mid)); color: #fff; font-size: 30rpx; font-weight: 600; border-radius: var(--radius-lg); margin-top: 32rpx; display: flex; align-items: center; justify-content: center; border: none; }
.submit-btn:active { transform: scale(0.98); opacity: 0.9; }
.submit-btn[disabled] { opacity: 0.5; }
.error-text { display: block; text-align: center; color: var(--color-error); font-size: 24rpx; margin-top: 16rpx; }
/* ===== Result ===== */
.result-wrap { padding: 0 32rpx; }
.result-header { display: flex; flex-direction: column; align-items: center; padding: 32rpx 0; }
.result-icon { font-size: 56rpx; }
.result-title { font-size: 34rpx; font-weight: 700; color: var(--color-text); margin-top: 12rpx; }
.advice-card { background: linear-gradient(135deg, #EEF2FF 0%, #E0E7FF 100%); border-radius: var(--radius-lg); padding: 32rpx; margin-bottom: 24rpx; }
.advice-content { font-size: 26rpx; color: #374151; line-height: 1.8; white-space: pre-wrap; }
.section { margin-bottom: 24rpx; }
.section-title { font-size: 30rpx; font-weight: 700; color: var(--color-text); display: block; margin-bottom: 16rpx; }
.path-card { display: flex; align-items: center; background: #fff; border-radius: var(--radius-lg); padding: 24rpx; margin-bottom: 16rpx; box-shadow: var(--shadow-sm); }
.path-card:active { transform: scale(0.98); background: #F9FAFB; }
.path-rank { width: 48rpx; height: 48rpx; background: linear-gradient(135deg, var(--color-gradient-start), var(--color-gradient-mid)); color: #fff; font-size: 24rpx; font-weight: 700; border-radius: 50%; display: flex; align-items: center; justify-content: center; flex-shrink: 0; margin-right: 16rpx; }
.path-body { flex: 1; }
.path-name { font-size: 28rpx; font-weight: 600; color: var(--color-text); display: block; }
.path-reason { font-size: 22rpx; color: var(--color-secondary); margin-top: 4rpx; display: block; line-height: 1.4; }
.path-score-wrap { display: flex; flex-direction: column; align-items: center; margin-left: 12rpx; flex-shrink: 0; }
.path-score-label { font-size: 20rpx; color: var(--color-secondary); }
.path-score { font-size: 32rpx; font-weight: 700; color: var(--color-primary); }
.path-salary { font-size: 20rpx; color: var(--color-success); margin-top: 2rpx; }
.path-hint { font-size: 22rpx; color: var(--color-secondary); text-align: center; margin-top: -8rpx; margin-bottom: 16rpx; }
.action-bar { display: flex; gap: 16rpx; }
.action-btn { flex: 1; height: 80rpx; font-size: 28rpx; font-weight: 600; border-radius: var(--radius-md); display: flex; align-items: center; justify-content: center; }
.chat-btn { background: var(--color-primary); color: #fff; }
.retry-btn { background: #fff; color: var(--color-text); border: 2rpx solid var(--color-border); }
/* ===== Chat ===== */
.chat-wrap { display: flex; flex-direction: column; height: 100vh; }
.chat-header { display: flex; align-items: center; padding: 24rpx 32rpx; background: #fff; border-bottom: 1rpx solid var(--color-border); }
.chat-back { font-size: 28rpx; color: var(--color-primary); margin-right: 24rpx; }
.chat-title { font-size: 30rpx; font-weight: 700; color: var(--color-text); flex: 1; }
.chat-msgs { flex: 1; padding: 24rpx 32rpx; overflow-y: auto; }
.msg-bubble { max-width: 80%; margin-bottom: 20rpx; padding: 20rpx 24rpx; border-radius: var(--radius-md); font-size: 26rpx; line-height: 1.6; }
.msg-user { background: var(--color-primary); color: #fff; align-self: flex-end; margin-left: auto; border-radius: var(--radius-md) var(--radius-md) 4rpx var(--radius-md); }
.msg-ai { background: #fff; color: var(--color-text); box-shadow: var(--shadow-sm); border-radius: var(--radius-md) var(--radius-md) var(--radius-md) 4rpx; }
.typing { color: var(--color-secondary); }
.chat-input-bar { display: flex; align-items: center; padding: 16rpx 32rpx; background: #fff; border-top: 1rpx solid var(--color-border); gap: 16rpx; }
.chat-input { flex: 1; height: 72rpx; border: 2rpx solid var(--color-border); border-radius: 36rpx; padding: 0 24rpx; font-size: 26rpx; }
.chat-send { height: 72rpx; padding: 0 32rpx; background: var(--color-primary); color: #fff; font-size: 26rpx; font-weight: 600; border-radius: 36rpx; display: flex; align-items: center; justify-content: center; }
.chat-send[disabled] { opacity: 0.5; }
</style>
+11 -4
View File
@@ -41,6 +41,12 @@
<!-- 菜单列表 --> <!-- 菜单列表 -->
<view class="menu-area"> <view class="menu-area">
<view class="menu-group"> <view class="menu-group">
<view class="menu-item" @click="requireLogin(goCareer, '择业顾问')">
<view class="menu-icon-wrap wrap-teal"><text class="menu-icon">🧭</text></view>
<text class="menu-text">择业顾问</text>
<text class="menu-tag">NEW</text>
<text class="menu-arrow"></text>
</view>
<view class="menu-item" @click="requireLogin(goHistory, '面试记录')"> <view class="menu-item" @click="requireLogin(goHistory, '面试记录')">
<view class="menu-icon-wrap wrap-blue"><text class="menu-icon">📋</text></view> <view class="menu-icon-wrap wrap-blue"><text class="menu-icon">📋</text></view>
<text class="menu-text">面试记录</text> <text class="menu-text">面试记录</text>
@@ -104,7 +110,6 @@ const refreshState = () => {
try { const s = uni.getStorageSync('userInfo'); if (s) userInfo.value = JSON.parse(s) } catch(e) {} try { const s = uni.getStorageSync('userInfo'); if (s) userInfo.value = JSON.parse(s) } catch(e) {}
loadStats() loadStats()
checkAdmin() checkAdmin()
// Fetch fresh user info from API to update stale cache (e.g. credits changed after interview)
fetchUserInfo() fetchUserInfo()
} }
@@ -115,7 +120,7 @@ const fetchUserInfo = async () => {
userInfo.value = res.data userInfo.value = res.data
uni.setStorageSync('userInfo', JSON.stringify(res.data)) uni.setStorageSync('userInfo', JSON.stringify(res.data))
} }
} catch(e) { /* silent - cached data is fallback */ } } catch(e) { /* silent */ }
} }
onMounted(refreshState) onMounted(refreshState)
@@ -143,8 +148,9 @@ const checkAdmin = () => {
} }
const goLogin = () => uni.navigateTo({ url: '/pages/login/login' }) const goLogin = () => uni.navigateTo({ url: '/pages/login/login' })
const goCareer = () => uni.navigateTo({ url: '/pages/career/career' })
const goHistory = () => uni.switchTab({ url: '/pages/history/history' }) const goHistory = () => uni.switchTab({ url: '/pages/history/history' })
const goReviewReview = () => uni.navigateTo({ url: "/pages/review/review" }) const goReviewReview = () => uni.navigateTo({ url: '/pages/review/review' })
const goVip = () => uni.navigateTo({ url: '/pages/member/member' }) const goVip = () => uni.navigateTo({ url: '/pages/member/member' })
const goResume = () => uni.navigateTo({ url: '/pages/resume/resume' }) const goResume = () => uni.navigateTo({ url: '/pages/resume/resume' })
const goShare = () => uni.navigateTo({ url: '/pages/share/share' }) const goShare = () => uni.navigateTo({ url: '/pages/share/share' })
@@ -184,7 +190,6 @@ const doLogout = () => {
.guest-icon { font-size: 40rpx; } .guest-icon { font-size: 40rpx; }
.guest-info { flex: 1; } .guest-info { flex: 1; }
.guest-name { font-size: 30rpx; font-weight: 600; color: #FFFFFF; } .guest-name { font-size: 30rpx; font-weight: 600; color: #FFFFFF; }
.guest-hint { font-size: 22rpx; color: rgba(255,255,255,0.7); margin-top: 4rpx; }
.menu-area { padding: 0 32rpx 32rpx; margin-top: -40rpx; } .menu-area { padding: 0 32rpx 32rpx; margin-top: -40rpx; }
.menu-group { background: #FFFFFF; border-radius: var(--radius-lg); overflow: hidden; margin-bottom: 24rpx; box-shadow: var(--shadow-sm); } .menu-group { background: #FFFFFF; border-radius: var(--radius-lg); overflow: hidden; margin-bottom: 24rpx; box-shadow: var(--shadow-sm); }
@@ -194,12 +199,14 @@ const doLogout = () => {
.menu-icon-wrap { width: 60rpx; height: 60rpx; border-radius: var(--radius-md); display: flex; align-items: center; justify-content: center; margin-right: 20rpx; flex-shrink: 0; } .menu-icon-wrap { width: 60rpx; height: 60rpx; border-radius: var(--radius-md); display: flex; align-items: center; justify-content: center; margin-right: 20rpx; flex-shrink: 0; }
.menu-icon { font-size: 28rpx; } .menu-icon { font-size: 28rpx; }
.menu-text { flex: 1; font-size: 28rpx; color: var(--color-text); font-weight: 500; } .menu-text { flex: 1; font-size: 28rpx; color: var(--color-text); font-weight: 500; }
.menu-tag { font-size: 18rpx; color: #fff; background: var(--color-primary); padding: 2rpx 12rpx; border-radius: 20rpx; margin-right: 12rpx; }
.menu-arrow { font-size: 32rpx; color: #D1D5DB; } .menu-arrow { font-size: 32rpx; color: #D1D5DB; }
.wrap-blue { background: #EEF2FF; } .wrap-blue { background: #EEF2FF; }
.wrap-purple { background: #F5F3FF; } .wrap-purple { background: #F5F3FF; }
.wrap-green { background: #ECFDF5; } .wrap-green { background: #ECFDF5; }
.wrap-orange { background: #FFF7ED; } .wrap-orange { background: #FFF7ED; }
.wrap-gray { background: #F3F4F6; } .wrap-gray { background: #F3F4F6; }
.wrap-teal { background: #E6FFFA; }
.logout-wrap { margin-top: 8rpx; } .logout-wrap { margin-top: 8rpx; }
.logout-btn { background: #FFFFFF; color: var(--color-error); font-size: 28rpx; font-weight: 500; border-radius: var(--radius-md); height: 88rpx; line-height: 88rpx; border: 2rpx solid #FECACA; } .logout-btn { background: #FFFFFF; color: var(--color-error); font-size: 28rpx; font-weight: 500; border-radius: var(--radius-md); height: 88rpx; line-height: 88rpx; border: 2rpx solid #FECACA; }
.logout-btn:active { background: #FEF2F2; transform: scale(0.96); } .logout-btn:active { background: #FEF2F2; transform: scale(0.96); }
+12 -5
View File
@@ -22,6 +22,7 @@ async function request<T = any>(url: string, method: string = 'POST', data?: any
} }
} }
const apiService = {
user: { user: {
sendCode: (phone: string) => request(API_ENDPOINTS.USER.SEND_CODE, 'POST', { phone }), sendCode: (phone: string) => request(API_ENDPOINTS.USER.SEND_CODE, 'POST', { phone }),
login: (phone: string, code: string) => request(API_ENDPOINTS.USER.LOGIN, 'POST', { phone, code }), login: (phone: string, code: string) => request(API_ENDPOINTS.USER.LOGIN, 'POST', { phone, code }),
@@ -89,14 +90,20 @@ async function request<T = any>(url: string, method: string = 'POST', data?: any
records: () => request(API_ENDPOINTS.SHARE.RECORDS, 'GET', undefined, true), records: () => request(API_ENDPOINTS.SHARE.RECORDS, 'GET', undefined, true),
visitors: () => request(API_ENDPOINTS.SHARE.VISITORS, 'GET', undefined, true), visitors: () => request(API_ENDPOINTS.SHARE.VISITORS, 'GET', undefined, true),
}, },
review: { review: {
list: (page = 1, limit = 20) => list: (page = 1, limit = 20) =>
request(`${API_ENDPOINTS.REVIEW.LIST}?page=${page}&limit=${limit}`, "GET", undefined, true), request(`${API_ENDPOINTS.REVIEW.LIST}?page=${page}&limit=${limit}`, 'GET', undefined, true),
detail: (id: string) => request(API_ENDPOINTS.REVIEW.DETAIL(id), "GET", undefined, true), detail: (id: string) => request(API_ENDPOINTS.REVIEW.DETAIL(id), 'GET', undefined, true),
delete: (id: string) => request(API_ENDPOINTS.REVIEW.DELETE(id), "DELETE", undefined, true), delete: (id: string) => request(API_ENDPOINTS.REVIEW.DELETE(id), 'DELETE', undefined, true),
submitText: (position: string, text: string, company?: string) => submitText: (position: string, text: string, company?: string) =>
request(API_ENDPOINTS.REVIEW.TEXT, "POST", { position, text, company: company || "" }, true), request(API_ENDPOINTS.REVIEW.TEXT, 'POST', { position, text, company: company || '' }, true),
},
career: {
analyze: (profile: { major: string; grade?: string; interests?: string; gpa?: string; goal?: string }) =>
request(API_ENDPOINTS.CAREER.ANALYZE, 'POST', profile, true),
chat: (message: string, history: { role: string; content: string }[]) =>
request(API_ENDPOINTS.CAREER.CHAT, 'POST', { message, history }, true),
positions: () => request(API_ENDPOINTS.CAREER.POSITIONS, 'GET', undefined, true),
}, },
} }