初始化:职引项目 v1.0
This commit is contained in:
+13
@@ -0,0 +1,13 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
*.local
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
*.log
|
||||||
|
certs/
|
||||||
|
uploads/
|
||||||
|
test-*
|
||||||
|
*.pdf
|
||||||
|
nul
|
||||||
|
start-*.sh
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# 2026-06-01 工作日志
|
||||||
|
|
||||||
|
## 修复后端 Redis 依赖问题
|
||||||
|
|
||||||
|
### 问题描述
|
||||||
|
- 后端因为 Redis 连接错误(`ioredis` `Unhandled error event`)不断崩溃(Exit Code 1)
|
||||||
|
- Redis 服务器 `192.168.136.130:6379` 不可用
|
||||||
|
|
||||||
|
### 解决方案
|
||||||
|
1. **完全移除 Redis 依赖**
|
||||||
|
- 修改 `common.module.ts`:移除 `RedisModule.forRoot()`,只保留 `ThrottlerModule`
|
||||||
|
- 修改 `user.service.ts`:
|
||||||
|
- 移除 `@InjectRedis()` 和 `ioredis` import
|
||||||
|
- 重写 `checkUsage()` 和 `incrementUsage()` 方法,完全使用 MongoDB 进行限流
|
||||||
|
- `sendCode()` 和 `login()` 方法使用 MongoDB 存储/验证验证码(字段:`lastCode`, `lastCodeTime`)
|
||||||
|
|
||||||
|
2. **更新 User Schema**
|
||||||
|
- 添加缺失的字段:`lastCode: string` 和 `lastCodeTime: Date`
|
||||||
|
- 这些字段用于 MVP 阶段的验证码存储(替代 Redis)
|
||||||
|
|
||||||
|
3. **修复 `analyze.service.ts`**
|
||||||
|
- 移除强制 mock 模式(`return this.getMockResult(prompt)`)
|
||||||
|
- 恢复真实 AI 调用(Sensenova 主用,NVIDIA 备用)
|
||||||
|
|
||||||
|
### 结果
|
||||||
|
- ✅ `npm run build` 成功(无 TypeScript 错误)
|
||||||
|
- ✅ 后端成功启动在端口 3004(PID: 10220)
|
||||||
|
- ✅ API 端点正常工作(测试了 `/api/internship/platforms`)
|
||||||
|
- ✅ **完整面试流程测试通过!**
|
||||||
|
- 创建面试:AI 生成第一道问题 ✅
|
||||||
|
- 提交回答:AI 分析回答并生成追问 ✅
|
||||||
|
- 完成面试:AI 生成完整报告 ✅
|
||||||
|
- ✅ 不再崩溃(无 Redis 连接错误)
|
||||||
|
|
||||||
|
### 测试详情
|
||||||
|
- **AI 模型**: Sensenova (deepseek-v4-flash)
|
||||||
|
- **测试岗位**: 前端开发
|
||||||
|
- **面试评分**: 50/100
|
||||||
|
- **岗位匹配度**: 45%
|
||||||
|
- **AI 反馈**: 提供了详细的亮点、不足、改进建议、参考回答
|
||||||
|
|
||||||
|
### 配置信息
|
||||||
|
- **后端端口**: 3004
|
||||||
|
- **MongoDB**: `mongodb://zhiyin:zhiyin123@192.168.0.192:27017/zhiyin?authSource=zhiyin` ✅ **已更新**
|
||||||
|
- **AI 主用**: Sensenova (`deepseek-v4-flash`)
|
||||||
|
- **AI 备用**: NVIDIA (`stepfun-ai/step-3.5-flash`)
|
||||||
|
- **Redis**: 已禁用(使用 MongoDB 降级方案)
|
||||||
|
- **前端代理**: `http://localhost:3004` ✅ **已更新**
|
||||||
|
|
||||||
|
### 新增功能(2026-06-02 13:30)
|
||||||
|
- ✅ **简历诊断页面** (`pages/analyze/diagnosis.vue`)
|
||||||
|
- 现代化 UI:渐变头部、卡片布局、评分可视化
|
||||||
|
- 功能:输入简历、选择岗位、一键诊断、查看结果
|
||||||
|
- API 测试通过 ✅
|
||||||
|
|
||||||
|
- ✅ **简历优化页面** (`pages/analyze/optimize.vue`)
|
||||||
|
- 现代化 UI:Tab 切换、Before/After 对比、复制功能
|
||||||
|
- 功能:输入简历、填写岗位、一键优化、查看结果
|
||||||
|
- API 测试通过 ✅
|
||||||
|
|
||||||
|
- ✅ **路由配置**: 更新 `pages.json` 注册新页面
|
||||||
|
|
||||||
|
### 待完成
|
||||||
|
- [ ] 在首页添加简历诊断和优化的导航入口
|
||||||
|
- [ ] 启动前端测试完整流程(H5 前端 -> 后端 API)
|
||||||
|
- [ ] 测试文件上传功能(PDF/Word 解析)
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# 2026-06-03 工作日志
|
||||||
|
|
||||||
|
## 后端重构完成
|
||||||
|
- 彻底重写 NestJS 后端,删除所有旧 flat 模块文件
|
||||||
|
- 新架构:common(过滤器/守卫/装饰器) + ai(统一AI调用) + user(登录/JWT) + interview(面试) + analyze(分析)
|
||||||
|
- 全局 JWT 守卫,@Public() 控制公开接口
|
||||||
|
- 统一错误格式 {code, message}
|
||||||
|
- 路由前缀 /api,端口 3006
|
||||||
|
- 面试 AI 调用:主服务商 → 备用服务商 → 抛错
|
||||||
|
|
||||||
|
## 前端清理与适配
|
||||||
|
- 删除 root `pages/` 目录下的旧错误副本
|
||||||
|
- 更新 `config.ts` 仅保留新后端 API 端点
|
||||||
|
- 修复 `user.vue` 统计接口路径
|
||||||
|
- 重写 `interview.vue`:连接真实后端 AI,登录检测
|
||||||
|
- 重写 `history.vue`:从后端 `/api/interview/list/all` 获取数据
|
||||||
|
- 旧文件 pages.json 中的 analyze 页面未迁移(原项目无此页面)
|
||||||
|
|
||||||
|
## 当前服务状态
|
||||||
|
- 后端 API: http://localhost:3006 (running)
|
||||||
|
- 前端 H5: http://localhost:8888 (running)
|
||||||
|
- 登录: 手机号 + 验证码 123456
|
||||||
|
- 新用户注册赠送 3 次使用次数
|
||||||
|
|
||||||
|
## 待办
|
||||||
|
- ~~其他占位页面(resume, member, about, settings, report 等)需要补内容~~ ✅
|
||||||
|
- ~~用户页"会员中心""设置"等功能未实现~~ ✅
|
||||||
|
|
||||||
|
## 功能补全(下午)
|
||||||
|
### 后端新增
|
||||||
|
- 新增 Resume 模块(CRUD:创建/列表/详情/删除)
|
||||||
|
|
||||||
|
### UI 设计系统(傍晚 - UI Designer 专家)
|
||||||
|
- **App.vue**:全局设计 Token(CSS 变量),统一样式基类
|
||||||
|
- **首页**:重写 hero 区(径向渐变光晕)、功能卡片圆角+阴影、岗位列表排名点
|
||||||
|
- **登录页**:精简品牌区、输入框聚焦动效、按钮微交互
|
||||||
|
- **面试页**:圆形发送按钮、气泡圆角优化、打字指示器
|
||||||
|
- **历史页**:卡片式列表、筛选胶囊组、评分颜色体系
|
||||||
|
- **用户页**:未登录引导页设计
|
||||||
|
- 新增:fade-in 动画、渐变按钮基类、聚焦态 box-shadow
|
||||||
|
- **report.vue**:重写,从后端获取面试详情/对话/评分/总结,自动完成面试
|
||||||
|
- **resume.vue**:重写,连接新后端 API 实现创建/列表/删除
|
||||||
|
- **history.vue**:goDetail 导航到 report 页传参 interviewId
|
||||||
|
- **user.vue**:goVip→member 页,goSettings→提示说明
|
||||||
|
- **about.vue**:更新设计颜色
|
||||||
|
- **index.vue**:"查看更多"改为 ActionSheet 选岗位直接面试
|
||||||
|
- **config.ts**:新增 RESUME API 端点映射
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# 2026-06-04 全站数据源审计
|
||||||
|
|
||||||
|
## 发现并修复的问题
|
||||||
|
|
||||||
|
| 问题 | 情况 | 修复 |
|
||||||
|
|------|------|------|
|
||||||
|
| `api.ts` 引用已删除端点(knowledge.ai.payment) | ❌ 运行时崩溃 | ✅ 全部清理,仅保留新后端 4 个模块 |
|
||||||
|
| `member.vue` 用旧 `api` 服务,expects `memberType` | ❌ 数据不对 | ✅ 改为读 `plan` 字段,用新 api 服务 |
|
||||||
|
| `result.vue` 用旧 `api` 服务 | ⚠️ 接口参数不同 | ✅ 已修复 api.ts 的 analyze 参数 |
|
||||||
|
| `index.vue` hotPositions 硬编码 | ❌ 静态数据 | ✅ 新增后端 `/api/positions/hot` 接口,前端动态获取 |
|
||||||
|
| `index.vue` "查看更多" ActionSheet 硬编码 | ❌ 静态数据 | ✅ 改为从 hotPositions 动态生成 |
|
||||||
|
| `about.vue` | ✅ 纯静态页面,合理 | - |
|
||||||
|
| `admin.vue` | ⚠️ 旧管理后台,暂无后端 | - |
|
||||||
|
|
||||||
|
## 新增功能
|
||||||
|
- **文件上传解析**:`POST /api/upload`,支持 PDF/DOCX/TXT 文件解析为文本
|
||||||
|
- **简历页上传区**:点击上传文件 → 自动解析填入内容区
|
||||||
|
- **结果下载**:诊断/优化后 → 下载 TXT 或 HTML 文件
|
||||||
|
- **用户页改进**:未登录也可看到功能菜单,点击个人功能才弹登录引导
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# 2026-06-05 上线前修复
|
||||||
|
|
||||||
|
## 修复的 13 个问题
|
||||||
|
|
||||||
|
| # | 问题 | 修复 |
|
||||||
|
|---|------|------|
|
||||||
|
| 1 | report.vue onLoad 未导入 → 白屏 | 添加 import onLoad |
|
||||||
|
| 2 | list/all 路由被 :id 拦截 | `:id` 路由移到静态路由之后 |
|
||||||
|
| 3 | 无参数验证 | 启用 ValidationPipe + whitelist,改用带 Logger 的异常过滤器 |
|
||||||
|
| 4 | 双 pages.json 冲突 | 删除外层 pages.json(旧版含已删除的 analyze 路由) |
|
||||||
|
| 5 | internship.vue 引用不存在 api 服务 | 重写为使用 /api/positions/hot 的简洁页面 |
|
||||||
|
| 6 | 面试次数未扣减 | UserService 注入 InterviewService,create 时调用 deductRemaining |
|
||||||
|
| 7 | JWT_SECRET 使用默认值 | 替换为 96 位随机 hex 字符串 |
|
||||||
|
| 8 | 验证码存内存 | 确认(单实例可用,多实例需 Redis) |
|
||||||
|
| 9 | Schema 缺索引 | Interview/Resume 添加 userId + createdAt 复合索引 |
|
||||||
|
| 10 | 页面缺 loading 态 | 首页+记录页添加 loading-tip |
|
||||||
|
| 11 | login.vue 用原始 URL | 改为 api() 辅助函数 |
|
||||||
|
| 12 | admin.vue 端口 3000 | 改为 window.location.origin |
|
||||||
|
| 13 | 渐变样式不一致 | 已在上一轮统一 |
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# 职引后端环境变量配置
|
||||||
|
|
||||||
|
# MongoDB
|
||||||
|
MONGODB_URI=mongodb://localhost:27017/zhiyin
|
||||||
|
|
||||||
|
# AI 主服务商 - opencode-go (deepseek-v4-flash)
|
||||||
|
AI_PRIMARY_URL=https://opencode.ai/zen/go/v1
|
||||||
|
AI_PRIMARY_KEY=your_primary_api_key_here
|
||||||
|
AI_PRIMARY_MODEL=deepseek-v4-flash
|
||||||
|
|
||||||
|
# AI 备用服务商 - NVIDIA (stepfun-ai/step-3.5-flash)
|
||||||
|
AI_BACKUP_URL=https://integrate.api.nvidia.com/v1
|
||||||
|
AI_BACKUP_KEY=your_backup_api_key_here
|
||||||
|
AI_BACKUP_MODEL=stepfun-ai/step-3.5-flash
|
||||||
|
|
||||||
|
# 服务端口
|
||||||
|
PORT=3000
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# 生产环境配置
|
||||||
|
NODE_ENV=production
|
||||||
|
|
||||||
|
# 服务器配置
|
||||||
|
PORT=3000
|
||||||
|
HOST=0.0.0.0
|
||||||
|
|
||||||
|
# MongoDB 生产环境(需修改为实际生产数据库)
|
||||||
|
MONGODB_URI=mongodb://username:password@production-host:27017/zhiyin?authSource=admin
|
||||||
|
|
||||||
|
# JWT 密钥(生产环境必须使用强密钥)
|
||||||
|
JWT_SECRET=your-super-strong-jwt-secret-key-here-minimum-32-chars
|
||||||
|
|
||||||
|
# AI 配置(已配置)
|
||||||
|
AI_PRIMARY_URL=https://token.sensenova.cn/v1
|
||||||
|
AI_PRIMARY_KEY=sk-2Bbcf8pSTSl1x2BV5fKtDsUIGdfjKX7M
|
||||||
|
AI_PRIMARY_MODEL=deepseek-v4-flash
|
||||||
|
|
||||||
|
AI_BACKUP_URL=https://integrate.api.nvidia.com/v1
|
||||||
|
AI_BACKUP_KEY=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
|
||||||
|
AI_BACKUP_MODEL=stepfun-ai/step-3.5-flash
|
||||||
|
|
||||||
|
# 微信小程序配置(生产环境)
|
||||||
|
WECHAT_APPID=your-production-appid
|
||||||
|
WECHAT_SECRET=your-production-secret
|
||||||
|
|
||||||
|
# 日志级别
|
||||||
|
LOG_LEVEL=info
|
||||||
|
|
||||||
|
# CORS 配置(生产环境指定域名)
|
||||||
|
ALLOWED_ORIGINS=https://yourdomain.com,https://yourdomain.com
|
||||||
Binary file not shown.
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
|
"collection": "@nestjs/schematics",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"deleteOutDir": true
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+9898
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,77 @@
|
|||||||
|
{
|
||||||
|
"name": "zhiyin-backend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "职引 - AI简历优化后端服务",
|
||||||
|
"main": "dist/main.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "nest start",
|
||||||
|
"start:dev": "nest start --watch",
|
||||||
|
"start:prod": "node dist/main",
|
||||||
|
"build": "nest build",
|
||||||
|
"test": "jest --forceExit --detectOpenHandles",
|
||||||
|
"test:watch": "jest --watch --forceExit",
|
||||||
|
"test:cov": "jest --coverage --forceExit"
|
||||||
|
},
|
||||||
|
"jest": {
|
||||||
|
"moduleFileExtensions": [
|
||||||
|
"js",
|
||||||
|
"json",
|
||||||
|
"ts"
|
||||||
|
],
|
||||||
|
"rootDir": "src",
|
||||||
|
"testRegex": ".*\\.spec\\.ts$",
|
||||||
|
"transform": {
|
||||||
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
|
},
|
||||||
|
"collectCoverageFrom": [
|
||||||
|
"**/*.(t|j)s"
|
||||||
|
],
|
||||||
|
"coverageDirectory": "../coverage",
|
||||||
|
"testEnvironment": "node",
|
||||||
|
"moduleNameMapper": {
|
||||||
|
"^@/(.*)$": "<rootDir>/$1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@nestjs-modules/ioredis": "^2.2.1",
|
||||||
|
"@nestjs/axios": "^3.1.3",
|
||||||
|
"@nestjs/common": "^10.3.0",
|
||||||
|
"@nestjs/core": "^10.3.0",
|
||||||
|
"@nestjs/jwt": "^11.0.2",
|
||||||
|
"@nestjs/mongoose": "^10.0.2",
|
||||||
|
"@nestjs/passport": "^11.0.5",
|
||||||
|
"@nestjs/platform-express": "^10.3.0",
|
||||||
|
"@nestjs/serve-static": "^4.0.2",
|
||||||
|
"@nestjs/throttler": "^6.5.0",
|
||||||
|
"axios": "^1.16.1",
|
||||||
|
"cache-manager": "^7.2.8",
|
||||||
|
"class-transformer": "^0.5.1",
|
||||||
|
"class-validator": "^0.14.0",
|
||||||
|
"dotenv": "^17.4.2",
|
||||||
|
"ioredis": "^5.11.0",
|
||||||
|
"mammoth": "^1.12.0",
|
||||||
|
"mongoose": "^8.0.0",
|
||||||
|
"multer": "^2.1.1",
|
||||||
|
"nodemailer": "^8.0.10",
|
||||||
|
"passport": "^0.7.0",
|
||||||
|
"passport-jwt": "^4.0.1",
|
||||||
|
"pdf-parse": "^2.4.5",
|
||||||
|
"reflect-metadata": "^0.2.1",
|
||||||
|
"rxjs": "^7.8.1",
|
||||||
|
"tesseract.js": "^7.0.0",
|
||||||
|
"wechatpay-node-v3": "^2.2.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@nestjs/cli": "^10.3.0",
|
||||||
|
"@nestjs/schematics": "^10.1.0",
|
||||||
|
"@nestjs/testing": "^10.4.22",
|
||||||
|
"@types/jest": "^30.0.0",
|
||||||
|
"@types/node": "^20.10.0",
|
||||||
|
"@types/nodemailer": "^8.0.0",
|
||||||
|
"@types/supertest": "^7.2.0",
|
||||||
|
"jest": "^30.4.2",
|
||||||
|
"supertest": "^7.2.2",
|
||||||
|
"ts-jest": "^29.4.9",
|
||||||
|
"typescript": "^5.3.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
const mg = require('mongoose');
|
||||||
|
const uri = 'mongodb://zhiyin:zhiyin123@192.168.136.130:27017/zhiyin?authSource=zhiyin';
|
||||||
|
mg.connect(uri).then(async () => {
|
||||||
|
const r = await mg.connection.db.collection('users').updateMany(
|
||||||
|
{},
|
||||||
|
{ $set: { role: 'admin' } }
|
||||||
|
);
|
||||||
|
console.log('Updated:', r.modifiedCount + r.upsertedCount);
|
||||||
|
const users = await mg.connection.db.collection('users').find().project({ _id: 1, phone: 1, role: 1 }).toArray();
|
||||||
|
console.log(JSON.stringify(users.map(u => ({ id: u._id.toString(), phone: u.phone, role: u.role }))));
|
||||||
|
await mg.disconnect();
|
||||||
|
}).catch(e => console.error(e.message));
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { Module } from '@nestjs/common'
|
||||||
|
import { MongooseModule } from '@nestjs/mongoose'
|
||||||
|
import { JwtModule } from '@nestjs/jwt'
|
||||||
|
import { PassportModule } from '@nestjs/passport'
|
||||||
|
import { ThrottlerModule } from '@nestjs/throttler'
|
||||||
|
import { APP_GUARD } from '@nestjs/core'
|
||||||
|
|
||||||
|
import { JwtStrategy } from './common/strategies/jwt.strategy'
|
||||||
|
import { JwtAuthGuard } from './common/guards/jwt-auth.guard'
|
||||||
|
import { AiModule } from './modules/ai/ai.module'
|
||||||
|
import { UserModule } from './modules/user/user.module'
|
||||||
|
import { InterviewModule } from './modules/interview/interview.module'
|
||||||
|
import { ResumeModule } from './modules/resume/resume.module'
|
||||||
|
import { EmailModule } from './modules/email/email.module'
|
||||||
|
import { PaymentModule } from './modules/payment/payment.module'
|
||||||
|
import { MemberModule } from './modules/member/member.module'
|
||||||
|
import { AdminModule } from './modules/admin/admin.module'
|
||||||
|
import { UploadModule } from './modules/upload/upload.module'
|
||||||
|
import { PositionsModule } from './modules/positions/positions.module'
|
||||||
|
import { AnalyzeModule } from './modules/analyze/analyze.module'
|
||||||
|
import { ProgressModule } from './modules/progress/progress.module'
|
||||||
|
import { ContributionModule } from './modules/contribution/contribution.module'
|
||||||
|
import { DailyQuestionModule } from './modules/daily-question/daily-question.module'
|
||||||
|
|
||||||
|
const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/zhiyin'
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
MongooseModule.forRoot(MONGODB_URI),
|
||||||
|
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||||
|
JwtModule.register({
|
||||||
|
secret: process.env.JWT_SECRET || 'zhiyin-jwt-secret-2026',
|
||||||
|
signOptions: { expiresIn: '7d' },
|
||||||
|
}),
|
||||||
|
ThrottlerModule.forRoot([{
|
||||||
|
ttl: 60000,
|
||||||
|
limit: 10,
|
||||||
|
}]),
|
||||||
|
UserModule,
|
||||||
|
AiModule,
|
||||||
|
InterviewModule,
|
||||||
|
AnalyzeModule,
|
||||||
|
ResumeModule,
|
||||||
|
PositionsModule,
|
||||||
|
UploadModule,
|
||||||
|
AdminModule,
|
||||||
|
MemberModule,
|
||||||
|
EmailModule,
|
||||||
|
PaymentModule,
|
||||||
|
ProgressModule,
|
||||||
|
ContributionModule,
|
||||||
|
DailyQuestionModule,
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
JwtStrategy,
|
||||||
|
{ provide: APP_GUARD, useClass: JwtAuthGuard },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AppModule {}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { createParamDecorator, ExecutionContext } from '@nestjs/common'
|
||||||
|
|
||||||
|
export const CurrentUser = createParamDecorator(
|
||||||
|
(data: string | undefined, ctx: ExecutionContext) => {
|
||||||
|
const request = ctx.switchToHttp().getRequest()
|
||||||
|
const user = request.user
|
||||||
|
return data ? user?.[data] : user
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { SetMetadata } from '@nestjs/common'
|
||||||
|
export const IS_PUBLIC_KEY = 'isPublic'
|
||||||
|
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true)
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus, Logger } from '@nestjs/common';
|
||||||
|
import { Request, Response } from 'express';
|
||||||
|
|
||||||
|
@Catch()
|
||||||
|
export class AllExceptionsFilter implements ExceptionFilter {
|
||||||
|
private readonly logger = new Logger(AllExceptionsFilter.name);
|
||||||
|
|
||||||
|
catch(exception: unknown, host: ArgumentsHost) {
|
||||||
|
const ctx = host.switchToHttp();
|
||||||
|
const response = ctx.getResponse<Response>();
|
||||||
|
const request = ctx.getRequest<Request>();
|
||||||
|
|
||||||
|
const status = exception instanceof HttpException
|
||||||
|
? exception.getStatus()
|
||||||
|
: HttpStatus.INTERNAL_SERVER_ERROR;
|
||||||
|
|
||||||
|
const message = exception instanceof HttpException
|
||||||
|
? exception.getResponse()
|
||||||
|
: '服务器内部错误';
|
||||||
|
|
||||||
|
const errorResponse = {
|
||||||
|
code: status,
|
||||||
|
message: typeof message === 'string' ? message : (message as any).message || message,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
path: request.url,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 记录错误日志
|
||||||
|
if (status >= 500) {
|
||||||
|
this.logger.error(
|
||||||
|
`Internal error: ${request.method} ${request.url}`,
|
||||||
|
exception instanceof Error ? exception.stack : String(exception),
|
||||||
|
);
|
||||||
|
} else if (status >= 400) {
|
||||||
|
this.logger.warn(`Client error: ${request.method} ${request.url} - ${JSON.stringify(errorResponse)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
response.status(status).json(errorResponse);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { Injectable, ExecutionContext } from '@nestjs/common'
|
||||||
|
import { AuthGuard } from '@nestjs/passport'
|
||||||
|
import { Reflector } from '@nestjs/core'
|
||||||
|
import { IS_PUBLIC_KEY } from '../decorators/public.decorator'
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||||
|
constructor(private reflector: Reflector) {
|
||||||
|
super()
|
||||||
|
}
|
||||||
|
|
||||||
|
canActivate(context: ExecutionContext) {
|
||||||
|
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||||
|
context.getHandler(),
|
||||||
|
context.getClass(),
|
||||||
|
])
|
||||||
|
if (isPublic) return true
|
||||||
|
return super.canActivate(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { Injectable } from '@nestjs/common'
|
||||||
|
import { PassportStrategy } from '@nestjs/passport'
|
||||||
|
import { ExtractJwt, Strategy } from 'passport-jwt'
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||||
|
constructor() {
|
||||||
|
super({
|
||||||
|
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||||
|
ignoreExpiration: false,
|
||||||
|
secretOrKey: process.env.JWT_SECRET || 'zhiyin-jwt-secret-2026',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async validate(payload: { userId: string; phone: string }) {
|
||||||
|
return { userId: payload.userId, phone: payload.phone }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import 'dotenv/config'
|
||||||
|
import { NestFactory } from '@nestjs/core'
|
||||||
|
import { ValidationPipe } from '@nestjs/common'
|
||||||
|
import { AppModule } from './app.module'
|
||||||
|
import { AllExceptionsFilter } from './common/filters/all-exceptions.filter'
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
const app = await NestFactory.create(AppModule)
|
||||||
|
|
||||||
|
app.setGlobalPrefix('api')
|
||||||
|
app.enableCors({
|
||||||
|
origin: '*',
|
||||||
|
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE',
|
||||||
|
credentials: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
app.useGlobalFilters(new AllExceptionsFilter())
|
||||||
|
app.useGlobalPipes(new ValidationPipe({
|
||||||
|
whitelist: true,
|
||||||
|
forbidNonWhitelisted: true,
|
||||||
|
transform: true,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const port = process.env.PORT || 3006
|
||||||
|
await app.listen(port)
|
||||||
|
console.log(`🚀 Server running on http://localhost:${port}`)
|
||||||
|
}
|
||||||
|
bootstrap()
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { Controller, Get, Post, Body, Query, HttpException, HttpStatus, UseGuards } from '@nestjs/common'
|
||||||
|
import { InjectModel } from '@nestjs/mongoose'
|
||||||
|
import { Model } from 'mongoose'
|
||||||
|
import { Public } from '../../common/decorators/public.decorator'
|
||||||
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'
|
||||||
|
import { CurrentUser } from '../../common/decorators/current-user.decorator'
|
||||||
|
import { User, UserDocument } from '../user/user.schema'
|
||||||
|
import { Interview, InterviewDocument } from '../interview/interview.schema'
|
||||||
|
|
||||||
|
@Controller('admin')
|
||||||
|
export class AdminController {
|
||||||
|
constructor(
|
||||||
|
@InjectModel(User.name) private userModel: Model<UserDocument>,
|
||||||
|
@InjectModel(Interview.name) private interviewModel: Model<InterviewDocument>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get('check')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
async checkAdmin(@CurrentUser('userId') userId: string) {
|
||||||
|
const user = await this.userModel.findById(userId).select('role').exec()
|
||||||
|
return { isAdmin: user?.role === 'admin' }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Post('verify')
|
||||||
|
async verify(@Body('adminId') adminId: string) {
|
||||||
|
const user = await this.userModel.findById(adminId).exec()
|
||||||
|
if (!user || user.role !== 'admin') {
|
||||||
|
throw new HttpException('无权限访问', HttpStatus.FORBIDDEN)
|
||||||
|
}
|
||||||
|
return { ok: true, nickname: user.nickname || '管理员' }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Get('overview')
|
||||||
|
async overview() {
|
||||||
|
const [userCount, interviewCount, todayUsers, todayInterviews] = await Promise.all([
|
||||||
|
this.userModel.countDocuments().exec(),
|
||||||
|
this.interviewModel.countDocuments().exec(),
|
||||||
|
this.userModel.countDocuments({ createdAt: { $gte: new Date(Date.now() - 24 * 60 * 60 * 1000) } }).exec(),
|
||||||
|
this.interviewModel.countDocuments({ createdAt: { $gte: new Date(Date.now() - 24 * 60 * 60 * 1000) } }).exec(),
|
||||||
|
])
|
||||||
|
return { userCount, interviewCount, todayUsers, todayInterviews }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Get('users')
|
||||||
|
async getUsers(@Query('keyword') keyword: string, @Query('page') page = '1', @Query('limit') limit = '20') {
|
||||||
|
const filter: any = {}
|
||||||
|
if (keyword) filter.$or = [
|
||||||
|
{ phone: { $regex: keyword, $options: 'i' } },
|
||||||
|
{ nickname: { $regex: keyword, $options: 'i' } },
|
||||||
|
]
|
||||||
|
const skip = (Math.max(1, +page) - 1) * +limit
|
||||||
|
const [users, total] = await Promise.all([
|
||||||
|
this.userModel.find(filter).sort({ createdAt: -1 }).skip(skip).limit(+limit).select('-password').lean().exec(),
|
||||||
|
this.userModel.countDocuments(filter).exec(),
|
||||||
|
])
|
||||||
|
return { users, total, page: +page }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Get('interviews')
|
||||||
|
async getInterviews(@Query('page') page = '1', @Query('limit') limit = '20') {
|
||||||
|
const skip = (Math.max(1, +page) - 1) * +limit
|
||||||
|
const [interviews, total] = await Promise.all([
|
||||||
|
this.interviewModel.find().sort({ createdAt: -1 }).skip(skip).limit(+limit).populate('userId', 'phone nickname').lean().exec(),
|
||||||
|
this.interviewModel.countDocuments().exec(),
|
||||||
|
])
|
||||||
|
return { interviews, total, page: +page }
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Post('set-vip')
|
||||||
|
async setVip(@Body('userId') targetUserId: string, @CurrentUser('userId') adminUserId: string) {
|
||||||
|
const admin = await this.userModel.findById(adminUserId).exec()
|
||||||
|
if (admin?.role !== 'admin') throw new HttpException('无权限', HttpStatus.FORBIDDEN)
|
||||||
|
const user = await this.userModel.findById(targetUserId).exec()
|
||||||
|
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
|
||||||
|
const expireAt = new Date()
|
||||||
|
expireAt.setDate(expireAt.getDate() + 30)
|
||||||
|
user.plan = 'vip'
|
||||||
|
user.vipExpireAt = expireAt
|
||||||
|
user.remaining = 999
|
||||||
|
await user.save()
|
||||||
|
return { success: true, plan: 'vip', expireAt }
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Get('admins')
|
||||||
|
async getAdmins(@CurrentUser('userId') adminUserId: string) {
|
||||||
|
const admin = await this.userModel.findById(adminUserId).exec()
|
||||||
|
if (admin?.role !== 'admin') throw new HttpException('无权限', HttpStatus.FORBIDDEN)
|
||||||
|
const admins = await this.userModel.find({ role: 'admin' }).select('phone nickname email createdAt isSystemAdmin').lean().exec()
|
||||||
|
return { admins }
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Post('set-admin')
|
||||||
|
async setAdmin(@Body('userId') targetUserId: string, @CurrentUser('userId') adminUserId: string) {
|
||||||
|
const admin = await this.userModel.findById(adminUserId).exec()
|
||||||
|
if (admin?.role !== 'admin') throw new HttpException('无权限', HttpStatus.FORBIDDEN)
|
||||||
|
const user = await this.userModel.findById(targetUserId).exec()
|
||||||
|
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
|
||||||
|
if (user.role === 'admin') throw new HttpException('该用户已是管理员', HttpStatus.BAD_REQUEST)
|
||||||
|
user.role = 'admin'
|
||||||
|
await user.save()
|
||||||
|
return { success: true, message: '已设为管理员' }
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Get('config')
|
||||||
|
async getConfig(@CurrentUser('userId') adminUserId: string) {
|
||||||
|
const admin = await this.userModel.findById(adminUserId).exec()
|
||||||
|
if (admin?.role !== 'admin') throw new HttpException('无权限', HttpStatus.FORBIDDEN)
|
||||||
|
return {
|
||||||
|
interview: {
|
||||||
|
maxRoundsFree: 5,
|
||||||
|
maxRoundsVip: 10,
|
||||||
|
dailyFreeLimit: 3,
|
||||||
|
},
|
||||||
|
diagnosis: {
|
||||||
|
dailyFreeLimit: 2,
|
||||||
|
},
|
||||||
|
optimize: {
|
||||||
|
dailyFreeLimit: 2,
|
||||||
|
},
|
||||||
|
price: {
|
||||||
|
monthly: 2900, // 分
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { Module } from '@nestjs/common'
|
||||||
|
import { MongooseModule } from '@nestjs/mongoose'
|
||||||
|
import { AdminController } from './admin.controller'
|
||||||
|
import { User, UserSchema } from '../user/user.schema'
|
||||||
|
import { Interview, InterviewSchema } from '../interview/interview.schema'
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
MongooseModule.forFeature([{ name: User.name, schema: UserSchema }]),
|
||||||
|
MongooseModule.forFeature([{ name: Interview.name, schema: InterviewSchema }]),
|
||||||
|
],
|
||||||
|
controllers: [AdminController],
|
||||||
|
})
|
||||||
|
export class AdminModule {}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common'
|
||||||
|
import { AiService } from './ai.service'
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [AiService],
|
||||||
|
exports: [AiService],
|
||||||
|
})
|
||||||
|
export class AiModule {}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common'
|
||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
interface AiCallOptions {
|
||||||
|
systemPrompt: string
|
||||||
|
userMessage: string
|
||||||
|
temperature?: number
|
||||||
|
maxTokens?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AiService {
|
||||||
|
private readonly logger = new Logger(AiService.name)
|
||||||
|
|
||||||
|
private readonly primaryUrl = process.env.AI_PRIMARY_URL || 'https://token.sensenova.cn/v1'
|
||||||
|
private readonly primaryKey = process.env.AI_PRIMARY_KEY || ''
|
||||||
|
private readonly primaryModel = process.env.AI_PRIMARY_MODEL || 'deepseek-v4-flash'
|
||||||
|
|
||||||
|
private readonly backupUrl = process.env.AI_BACKUP_URL || 'https://integrate.api.nvidia.com/v1'
|
||||||
|
private readonly backupKey = process.env.AI_BACKUP_KEY || ''
|
||||||
|
private readonly backupModel = process.env.AI_BACKUP_MODEL || 'stepfun-ai/step-3.5-flash'
|
||||||
|
|
||||||
|
async call(options: AiCallOptions): Promise<string> {
|
||||||
|
const { systemPrompt, userMessage, temperature = 0.7, maxTokens = 2048 } = options
|
||||||
|
|
||||||
|
// Try primary AI
|
||||||
|
try {
|
||||||
|
const result = await this.callApi(this.primaryUrl, this.primaryKey, this.primaryModel, systemPrompt, userMessage, temperature, maxTokens)
|
||||||
|
if (result) return result
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.warn(`Primary AI failed: ${(e as Error).message}, trying backup...`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try backup AI
|
||||||
|
try {
|
||||||
|
const result = await this.callApi(this.backupUrl, this.backupKey, this.backupModel, systemPrompt, userMessage, temperature, maxTokens)
|
||||||
|
if (result) return result
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.warn(`Backup AI also failed: ${(e as Error).message}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final fallback
|
||||||
|
throw new Error('AI 服务暂时不可用,请稍后重试')
|
||||||
|
}
|
||||||
|
|
||||||
|
private async callApi(
|
||||||
|
baseUrl: string, apiKey: string, model: string,
|
||||||
|
systemPrompt: string, userMessage: string,
|
||||||
|
temperature: number, maxTokens: number,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const res = await axios.post(
|
||||||
|
`${baseUrl}/chat/completions`,
|
||||||
|
{
|
||||||
|
model,
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: systemPrompt },
|
||||||
|
{ role: 'user', content: userMessage },
|
||||||
|
],
|
||||||
|
temperature,
|
||||||
|
max_tokens: maxTokens,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${apiKey}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
timeout: 60000,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return res.data?.choices?.[0]?.message?.content || null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { Controller, Post, Body, HttpException, HttpStatus, UseGuards } from '@nestjs/common'
|
||||||
|
import { AnalyzeService } from './analyze.service'
|
||||||
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'
|
||||||
|
import { CurrentUser } from '../../common/decorators/current-user.decorator'
|
||||||
|
import { UserService } from '../user/user.service'
|
||||||
|
|
||||||
|
@Controller('analyze')
|
||||||
|
export class AnalyzeController {
|
||||||
|
constructor(
|
||||||
|
private analyzeService: AnalyzeService,
|
||||||
|
private userService: UserService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Post('diagnosis')
|
||||||
|
async diagnosis(@Body('content') content: string, @CurrentUser('userId') userId: string) {
|
||||||
|
await this.checkAnalyzeLimit(userId)
|
||||||
|
return this.analyzeService.diagnose(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Post('optimize')
|
||||||
|
async optimize(@Body('content') content: string, @Body('direction') direction: string, @CurrentUser('userId') userId: string) {
|
||||||
|
await this.checkAnalyzeLimit(userId)
|
||||||
|
return this.analyzeService.optimize(content, direction)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async checkAnalyzeLimit(userId: string) {
|
||||||
|
const user = await this.userService.getModel().findById(userId).exec()
|
||||||
|
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
|
||||||
|
if (user.plan === 'vip') return // VIP 不限次
|
||||||
|
if (user.remaining <= 0) {
|
||||||
|
throw new HttpException('免费版每日次数已用完,升级会员后不限次使用', HttpStatus.FORBIDDEN)
|
||||||
|
}
|
||||||
|
// 扣减一次
|
||||||
|
user.remaining -= 1
|
||||||
|
await user.save()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { Module } from '@nestjs/common'
|
||||||
|
import { AnalyzeController } from './analyze.controller'
|
||||||
|
import { AnalyzeService } from './analyze.service'
|
||||||
|
import { UserModule } from '../user/user.module'
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [UserModule],
|
||||||
|
controllers: [AnalyzeController],
|
||||||
|
providers: [AnalyzeService],
|
||||||
|
})
|
||||||
|
export class AnalyzeModule {}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { Injectable } from '@nestjs/common'
|
||||||
|
import { AiService } from '../ai/ai.service'
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AnalyzeService {
|
||||||
|
constructor(private aiService: AiService) {}
|
||||||
|
|
||||||
|
async diagnose(content: string) {
|
||||||
|
const result = await this.aiService.call({
|
||||||
|
systemPrompt: `你是一位专业的简历评估专家。分析以下简历内容,给出评估。必须使用以下JSON格式输出,不要多余内容:
|
||||||
|
{
|
||||||
|
"score": 0-100,
|
||||||
|
"issues": [
|
||||||
|
{ "level": "high|medium|low", "title": "问题标题", "desc": "问题描述" }
|
||||||
|
],
|
||||||
|
"suggestions": ["建议1", "建议2", "建议3", "建议4"]
|
||||||
|
}`,
|
||||||
|
userMessage: `请分析以下简历:\n\n${content}`,
|
||||||
|
temperature: 0.5,
|
||||||
|
maxTokens: 2048,
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(result)
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
score: 60,
|
||||||
|
issues: [{ level: 'medium', title: 'AI 分析异常', desc: '无法解析完整结果,建议重试' }],
|
||||||
|
suggestions: ['请重新提交简历进行诊断'],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async optimize(content: string, direction: string) {
|
||||||
|
const result = await this.aiService.call({
|
||||||
|
systemPrompt: `你是一位资深的简历优化专家。按照用户指定的优化方向,优化以下简历内容。
|
||||||
|
优化方向: ${direction}
|
||||||
|
输出格式:
|
||||||
|
{
|
||||||
|
"optimized": "优化后的简历全文",
|
||||||
|
"changes": ["改动1", "改动2", ...]
|
||||||
|
}`,
|
||||||
|
userMessage: `原始简历:\n\n${content}\n\n优化方向:${direction}`,
|
||||||
|
temperature: 0.6,
|
||||||
|
maxTokens: 3072,
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(result)
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
optimized: content,
|
||||||
|
changes: ['AI 优化异常,返回原始内容'],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { Controller, Post, Get, Body, Param, UseGuards } from '@nestjs/common'
|
||||||
|
import { InjectModel } from '@nestjs/mongoose'
|
||||||
|
import { Model } from 'mongoose'
|
||||||
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'
|
||||||
|
import { CurrentUser } from '../../common/decorators/current-user.decorator'
|
||||||
|
import { Contribution, ContributionDocument } from '../schemas/contribution.schema'
|
||||||
|
import { CompanyBank, CompanyBankDocument } from '../schemas/company-bank.schema'
|
||||||
|
|
||||||
|
@Controller('contribution')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class ContributionController {
|
||||||
|
constructor(
|
||||||
|
@InjectModel(Contribution.name) private contributionModel: Model<ContributionDocument>,
|
||||||
|
@InjectModel(CompanyBank.name) private companyBankModel: Model<CompanyBankDocument>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
async create(
|
||||||
|
@CurrentUser('userId') userId: string,
|
||||||
|
@Body() body: {
|
||||||
|
interviewId: string
|
||||||
|
company: string
|
||||||
|
position: string
|
||||||
|
rounds?: string
|
||||||
|
questions?: string[]
|
||||||
|
experience?: string
|
||||||
|
tags?: string[]
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const contribution = await this.contributionModel.create({
|
||||||
|
userId,
|
||||||
|
interviewId: body.interviewId,
|
||||||
|
company: body.company,
|
||||||
|
position: body.position,
|
||||||
|
rounds: body.rounds || '',
|
||||||
|
questions: body.questions || [],
|
||||||
|
experience: body.experience || '',
|
||||||
|
tags: body.tags || [],
|
||||||
|
verified: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update company bank
|
||||||
|
if (body.questions && body.questions.length > 0) {
|
||||||
|
let bank = await this.companyBankModel.findOne({
|
||||||
|
company: body.company,
|
||||||
|
position: body.position,
|
||||||
|
}).exec()
|
||||||
|
|
||||||
|
if (!bank) {
|
||||||
|
bank = await this.companyBankModel.create({
|
||||||
|
company: body.company,
|
||||||
|
position: body.position,
|
||||||
|
questions: [],
|
||||||
|
contributionCount: 0,
|
||||||
|
viewCount: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new questions (avoid duplicates)
|
||||||
|
for (const q of body.questions) {
|
||||||
|
const exists = bank.questions.some(eq => eq.content === q)
|
||||||
|
if (!exists) {
|
||||||
|
bank.questions.push({
|
||||||
|
content: q,
|
||||||
|
type: 'general',
|
||||||
|
referenceAnswer: '',
|
||||||
|
difficulty: 'medium',
|
||||||
|
frequency: 1,
|
||||||
|
tags: body.tags || [],
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// Increment frequency
|
||||||
|
const existing = bank.questions.find(eq => eq.content === q)
|
||||||
|
if (existing) existing.frequency += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bank.contributionCount += 1
|
||||||
|
await bank.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: (contribution as any)._id.toString(),
|
||||||
|
company: contribution.company,
|
||||||
|
position: contribution.position,
|
||||||
|
message: '感谢你的分享!你的面经将帮助更多同学准备面试',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('company/:company/position/:position')
|
||||||
|
async getBank(@Param('company') company: string, @Param('position') position: string) {
|
||||||
|
const bank = await this.companyBankModel.findOne({ company, position }).exec()
|
||||||
|
if (!bank) return { company, position, questions: [], contributionCount: 0 }
|
||||||
|
|
||||||
|
bank.viewCount += 1
|
||||||
|
await bank.save()
|
||||||
|
|
||||||
|
return {
|
||||||
|
company: bank.company,
|
||||||
|
position: bank.position,
|
||||||
|
questions: bank.questions.sort((a, b) => b.frequency - a.frequency),
|
||||||
|
contributionCount: bank.contributionCount,
|
||||||
|
viewCount: bank.viewCount,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('company/:company')
|
||||||
|
async getCompanyBanks(@Param('company') company: string) {
|
||||||
|
const banks = await this.companyBankModel.find({ company }).exec()
|
||||||
|
return banks.map(b => ({
|
||||||
|
position: b.position,
|
||||||
|
questionCount: b.questions.length,
|
||||||
|
contributionCount: b.contributionCount,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('my')
|
||||||
|
async getMyContributions(@CurrentUser('userId') userId: string) {
|
||||||
|
return this.contributionModel
|
||||||
|
.find({ userId })
|
||||||
|
.sort({ createdAt: -1 })
|
||||||
|
.select('company position rounds experience createdAt')
|
||||||
|
.exec()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { Module } from '@nestjs/common'
|
||||||
|
import { MongooseModule } from '@nestjs/mongoose'
|
||||||
|
import { ContributionController } from './contribution.controller'
|
||||||
|
import { Contribution, ContributionSchema } from '../schemas/contribution.schema'
|
||||||
|
import { CompanyBank, CompanyBankSchema } from '../schemas/company-bank.schema'
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
MongooseModule.forFeature([
|
||||||
|
{ name: Contribution.name, schema: ContributionSchema },
|
||||||
|
{ name: CompanyBank.name, schema: CompanyBankSchema },
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
controllers: [ContributionController],
|
||||||
|
})
|
||||||
|
export class ContributionModule {}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common'
|
||||||
|
import { InjectModel } from '@nestjs/mongoose'
|
||||||
|
import { Model } from 'mongoose'
|
||||||
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'
|
||||||
|
import { DailyQuestion, DailyQuestionDocument } from '../schemas/daily-question.schema'
|
||||||
|
|
||||||
|
@Controller('daily-question')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class DailyQuestionController {
|
||||||
|
constructor(
|
||||||
|
@InjectModel(DailyQuestion.name) private dailyQuestionModel: Model<DailyQuestionDocument>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async getToday(@Query('position') position?: string) {
|
||||||
|
const filter: any = {}
|
||||||
|
if (position) filter.position = position
|
||||||
|
|
||||||
|
const question = await this.dailyQuestionModel
|
||||||
|
.findOne(filter)
|
||||||
|
.sort({ date: -1 })
|
||||||
|
.exec()
|
||||||
|
|
||||||
|
if (!question) {
|
||||||
|
// Return a default question if no specific one found
|
||||||
|
const defaultQ = await this.dailyQuestionModel.findOne().sort({ date: -1 }).exec()
|
||||||
|
if (defaultQ) return defaultQ
|
||||||
|
|
||||||
|
return {
|
||||||
|
position: '通用',
|
||||||
|
question: '请做一个简单的自我介绍,突出你的核心优势和职业目标。',
|
||||||
|
referenceAnswer: '建议结构:1) 基本信息 2) 教育背景与专业 3) 实习/项目经历中的亮点 4) 为什么选择这个岗位 5) 职业目标。控制在1-2分钟内。',
|
||||||
|
category: 'behavioral',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return question
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('position/:position')
|
||||||
|
async getByPosition(@Param('position') position: string) {
|
||||||
|
const questions = await this.dailyQuestionModel
|
||||||
|
.find({ position })
|
||||||
|
.sort({ date: -1 })
|
||||||
|
.limit(10)
|
||||||
|
.exec()
|
||||||
|
|
||||||
|
return questions.length > 0 ? questions : [
|
||||||
|
{
|
||||||
|
position,
|
||||||
|
question: `作为${position}岗位的候选人,请分享一个你最有成就感的项目经历。`,
|
||||||
|
referenceAnswer: '使用STAR法则:Situation(背景)、Task(任务)、Action(行动)、Result(结果),重点突出你在项目中的角色和贡献。',
|
||||||
|
category: 'project',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
position,
|
||||||
|
question: `在${position}岗位上,你认为自己最大的优势是什么?最大的不足是什么?`,
|
||||||
|
referenceAnswer: '优势要结合岗位要求,用具体例子支撑;不足要真实但可改进,说明你已经在如何提升。',
|
||||||
|
category: 'behavioral',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { Module } from '@nestjs/common'
|
||||||
|
import { MongooseModule } from '@nestjs/mongoose'
|
||||||
|
import { DailyQuestionController } from './daily-question.controller'
|
||||||
|
import { DailyQuestion, DailyQuestionSchema } from '../schemas/daily-question.schema'
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
MongooseModule.forFeature([
|
||||||
|
{ name: DailyQuestion.name, schema: DailyQuestionSchema },
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
controllers: [DailyQuestionController],
|
||||||
|
})
|
||||||
|
export class DailyQuestionModule {}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module, Global } from '@nestjs/common'
|
||||||
|
import { EmailService } from './email.service'
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [EmailService],
|
||||||
|
exports: [EmailService],
|
||||||
|
})
|
||||||
|
export class EmailModule {}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import * as nodemailer from 'nodemailer'
|
||||||
|
import { Injectable, Logger } from '@nestjs/common'
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class EmailService {
|
||||||
|
private readonly logger = new Logger(EmailService.name)
|
||||||
|
private transporter: nodemailer.Transporter | null = null
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.initTransporter()
|
||||||
|
}
|
||||||
|
|
||||||
|
private initTransporter() {
|
||||||
|
try {
|
||||||
|
this.transporter = nodemailer.createTransport({
|
||||||
|
host: process.env.EMAIL_HOST || 'smtp.qiye.aliyun.com',
|
||||||
|
port: Number(process.env.EMAIL_PORT) || 465,
|
||||||
|
secure: process.env.EMAIL_SECURE === 'true',
|
||||||
|
auth: {
|
||||||
|
user: process.env.EMAIL_USER || 'contact@yuzhiran.com',
|
||||||
|
pass: process.env.EMAIL_PASSWORD,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
this.logger.log('邮件服务初始化完成')
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.error('邮件服务初始化失败', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendVerificationCode(to: string, code: string): Promise<boolean> {
|
||||||
|
if (!this.transporter) {
|
||||||
|
this.logger.warn('邮件服务未初始化,跳过发送')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const fromName = process.env.EMAIL_FROM || 'AI磁场 <contact@yuzhiran.com>'
|
||||||
|
await this.transporter.sendMail({
|
||||||
|
from: fromName,
|
||||||
|
to,
|
||||||
|
subject: 'AI磁场 - 登录验证码',
|
||||||
|
html: `
|
||||||
|
<div style="font-family: -apple-system, sans-serif; max-width: 480px; margin: 0 auto; padding: 32px;">
|
||||||
|
<div style="text-align: center; margin-bottom: 24px;">
|
||||||
|
<h1 style="font-size: 28px; color: #4F46E5; margin: 0;">AI 磁场</h1>
|
||||||
|
<p style="color: #9CA3AF; font-size: 14px; margin: 4px 0 0;">您的登录验证码</p>
|
||||||
|
</div>
|
||||||
|
<div style="background: #F3F4F6; border-radius: 16px; padding: 32px; text-align: center;">
|
||||||
|
<p style="font-size: 14px; color: #6B7280; margin: 0 0 16px;">请输入以下验证码完成登录</p>
|
||||||
|
<div style="font-size: 40px; font-weight: 800; color: #4F46E5; letter-spacing: 8px; margin: 16px 0;">${code}</div>
|
||||||
|
<p style="font-size: 12px; color: #9CA3AF; margin: 16px 0 0;">验证码 10 分钟内有效,请勿泄露给他人</p>
|
||||||
|
</div>
|
||||||
|
<p style="font-size: 12px; color: #D1D5DB; text-align: center; margin-top: 24px;">宇之然AI磁场 · AI 助力你的求职之路</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
this.logger.log(`验证码已发送至 ${to}`)
|
||||||
|
return true
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.error(`发送邮件失败: ${to}`, e)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { Controller, Post, Get, Param, Body } from '@nestjs/common'
|
||||||
|
import { InterviewService } from './interview.service'
|
||||||
|
import { CurrentUser } from '../../common/decorators/current-user.decorator'
|
||||||
|
|
||||||
|
@Controller('interview')
|
||||||
|
export class InterviewController {
|
||||||
|
constructor(private interviewService: InterviewService) {}
|
||||||
|
|
||||||
|
// 静态路由必须放在 :id 动态路由之前
|
||||||
|
|
||||||
|
@Get('list/all')
|
||||||
|
async getList(@CurrentUser('userId') userId: string) {
|
||||||
|
return this.interviewService.getList(userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('stats/mine')
|
||||||
|
async getStats(@CurrentUser('userId') userId: string) {
|
||||||
|
return this.interviewService.getStats(userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('create')
|
||||||
|
async create(@CurrentUser('userId') userId: string, @Body('position') position: string) {
|
||||||
|
return this.interviewService.create(userId, position)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/answer')
|
||||||
|
async answer(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@CurrentUser('userId') userId: string,
|
||||||
|
@Body('answer') answer: string,
|
||||||
|
) {
|
||||||
|
return this.interviewService.answer(id, userId, answer)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/complete')
|
||||||
|
async complete(@Param('id') id: string, @CurrentUser('userId') userId: string) {
|
||||||
|
return this.interviewService.complete(id, userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
async getDetail(@Param('id') id: string, @CurrentUser('userId') userId: string) {
|
||||||
|
return this.interviewService.getDetail(id, userId)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { Module } from '@nestjs/common'
|
||||||
|
import { MongooseModule } from '@nestjs/mongoose'
|
||||||
|
import { InterviewController } from './interview.controller'
|
||||||
|
import { InterviewService } from './interview.service'
|
||||||
|
import { Interview, InterviewSchema } from './interview.schema'
|
||||||
|
import { Progress, ProgressSchema } from '../schemas/progress.schema'
|
||||||
|
import { UserModule } from '../user/user.module'
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
MongooseModule.forFeature([
|
||||||
|
{ name: Interview.name, schema: InterviewSchema },
|
||||||
|
{ name: Progress.name, schema: ProgressSchema },
|
||||||
|
]),
|
||||||
|
UserModule,
|
||||||
|
],
|
||||||
|
controllers: [InterviewController],
|
||||||
|
providers: [InterviewService],
|
||||||
|
})
|
||||||
|
export class InterviewModule {}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
|
||||||
|
import { Document, Types } from 'mongoose'
|
||||||
|
|
||||||
|
export type InterviewDocument = Interview & Document
|
||||||
|
|
||||||
|
@Schema({ timestamps: true })
|
||||||
|
export class Interview {
|
||||||
|
@Prop({ type: Types.ObjectId, ref: 'User', required: true })
|
||||||
|
userId: Types.ObjectId
|
||||||
|
|
||||||
|
@Prop({ required: true })
|
||||||
|
position: string
|
||||||
|
|
||||||
|
@Prop({ default: 'in_progress' }) // in_progress | completed
|
||||||
|
status: string
|
||||||
|
|
||||||
|
@Prop({ default: 0 })
|
||||||
|
totalScore: number
|
||||||
|
|
||||||
|
@Prop({ default: 0 })
|
||||||
|
questionCount: number
|
||||||
|
|
||||||
|
@Prop({ type: [{ role: String, content: String, score: Number }], default: [] })
|
||||||
|
messages: { role: string; content: string; score?: number }[]
|
||||||
|
|
||||||
|
@Prop({ default: '' })
|
||||||
|
summary: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const InterviewSchema = SchemaFactory.createForClass(Interview)
|
||||||
|
InterviewSchema.index({ userId: 1, createdAt: -1 })
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
import { Injectable, HttpException, HttpStatus, forwardRef, Inject } from '@nestjs/common'
|
||||||
|
import { InjectModel } from '@nestjs/mongoose'
|
||||||
|
import { Model } from 'mongoose'
|
||||||
|
import { Interview, InterviewDocument } from './interview.schema'
|
||||||
|
import { Progress, ProgressDocument } from '../schemas/progress.schema'
|
||||||
|
import { AiService } from '../ai/ai.service'
|
||||||
|
import { UserService } from '../user/user.service'
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class InterviewService {
|
||||||
|
constructor(
|
||||||
|
@InjectModel(Interview.name) private interviewModel: Model<InterviewDocument>,
|
||||||
|
@InjectModel(Progress.name) private progressModel: Model<ProgressDocument>,
|
||||||
|
private aiService: AiService,
|
||||||
|
private userService: UserService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async create(userId: string, position: string) {
|
||||||
|
// 扣减使用次数
|
||||||
|
await this.userService.deductRemaining(userId)
|
||||||
|
|
||||||
|
const firstQuestion = await this.aiService.call({
|
||||||
|
systemPrompt: `你是一位专业的${position}面试官。请针对校招该岗位提出第一个面试问题,要求具体且有针对性。直接输出问题,不要多余内容。`,
|
||||||
|
userMessage: `请为${position}岗位的校招候选人提出第一个面试问题。`,
|
||||||
|
temperature: 0.8,
|
||||||
|
})
|
||||||
|
|
||||||
|
const interview = await this.interviewModel.create({
|
||||||
|
userId,
|
||||||
|
position,
|
||||||
|
messages: [{ role: 'ai', content: firstQuestion }],
|
||||||
|
questionCount: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: interview._id.toString(),
|
||||||
|
position: interview.position,
|
||||||
|
messages: interview.messages,
|
||||||
|
questionCount: interview.questionCount,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async answer(interviewId: string, userId: string, answer: string) {
|
||||||
|
const interview = await this.interviewModel.findOne({ _id: interviewId, userId }).exec()
|
||||||
|
if (!interview) throw new HttpException('面试不存在', HttpStatus.NOT_FOUND)
|
||||||
|
if (interview.status === 'completed') throw new HttpException('面试已结束', HttpStatus.BAD_REQUEST)
|
||||||
|
|
||||||
|
// 检查轮次限制
|
||||||
|
const user = await this.userService.getModel().findById(userId).exec()
|
||||||
|
const maxRounds = user?.plan === 'vip' ? 10 : 5
|
||||||
|
if (interview.questionCount >= maxRounds) {
|
||||||
|
throw new HttpException(
|
||||||
|
user?.plan === 'vip' ? '已达到每场面试最大轮次(10轮)' : '免费版每场最多5轮,升级会员可享10轮',
|
||||||
|
HttpStatus.FORBIDDEN
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save user's answer
|
||||||
|
interview.messages.push({ role: 'user', content: answer })
|
||||||
|
interview.questionCount += 1
|
||||||
|
|
||||||
|
// AI evaluates answer and generates next question
|
||||||
|
const conversationHistory = interview.messages
|
||||||
|
.slice(-6)
|
||||||
|
.map(m => `${m.role === 'ai' ? '面试官' : '候选人'}: ${m.content}`)
|
||||||
|
.join('\n')
|
||||||
|
|
||||||
|
const aiResponse = await this.aiService.call({
|
||||||
|
systemPrompt: `你是一位专业的面试官。评估候选人的回答,然后提出下一个问题。
|
||||||
|
- 如果这已经是第5-8个问题,给出总结性评价并建议结束面试
|
||||||
|
- 评估要简短,然后立即问下一个问题
|
||||||
|
- 使用「回答评价:...新的问题:...」的格式。`,
|
||||||
|
userMessage: `岗位: ${interview.position}
|
||||||
|
对话历史:
|
||||||
|
${conversationHistory}
|
||||||
|
|
||||||
|
候选人的回答: ${answer}
|
||||||
|
|
||||||
|
请评估并问下一个问题。`,
|
||||||
|
temperature: 0.7,
|
||||||
|
maxTokens: 1024,
|
||||||
|
})
|
||||||
|
|
||||||
|
interview.messages.push({ role: 'ai', content: aiResponse })
|
||||||
|
await interview.save()
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: interview._id.toString(),
|
||||||
|
messages: interview.messages.slice(-2),
|
||||||
|
questionCount: interview.questionCount,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async complete(interviewId: string, userId: string) {
|
||||||
|
const interview = await this.interviewModel.findOne({ _id: interviewId, userId }).exec()
|
||||||
|
if (!interview) throw new HttpException('面试不存在', HttpStatus.NOT_FOUND)
|
||||||
|
if (interview.status === 'completed') throw new HttpException('面试已结束', HttpStatus.BAD_REQUEST)
|
||||||
|
|
||||||
|
// Generate final summary with dimension scores
|
||||||
|
const fullConversation = interview.messages
|
||||||
|
.map(m => `${m.role === 'ai' ? '面试官' : '候选人'}: ${m.content}`)
|
||||||
|
.join('\n')
|
||||||
|
|
||||||
|
const summary = await this.aiService.call({
|
||||||
|
systemPrompt: `你是一位专业的面试评估师。根据面试对话,生成评估报告。输出JSON格式:
|
||||||
|
{
|
||||||
|
"总体评分": 85,
|
||||||
|
"逻辑思维": 80,
|
||||||
|
"表达能力": 85,
|
||||||
|
"专业度": 90,
|
||||||
|
"稳定性": 82,
|
||||||
|
"优点": ["逻辑清晰", "举例充分"],
|
||||||
|
"不足": ["回答过长", "缺少数据支撑"],
|
||||||
|
"建议": ["使用STAR法则", "控制回答时间"]
|
||||||
|
}`,
|
||||||
|
userMessage: `岗位: ${interview.position}
|
||||||
|
|
||||||
|
面试记录:
|
||||||
|
${fullConversation}
|
||||||
|
|
||||||
|
请生成评估报告。`,
|
||||||
|
temperature: 0.5,
|
||||||
|
maxTokens: 2048,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Parse summary
|
||||||
|
let totalScore = 0
|
||||||
|
let dimensions = { logic: 0, expression: 0, professionalism: 0, stability: 0 }
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(summary)
|
||||||
|
totalScore = parsed.总体评分 || parsed.score || 0
|
||||||
|
dimensions = {
|
||||||
|
logic: parsed.逻辑思维 || 0,
|
||||||
|
expression: parsed.表达能力 || 0,
|
||||||
|
professionalism: parsed.专业度 || 0,
|
||||||
|
stability: parsed.稳定性 || 0,
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
const match = summary.match(/(\d{1,3})(?=\s*分)/)
|
||||||
|
totalScore = match ? parseInt(match[1]) : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
interview.status = 'completed'
|
||||||
|
interview.totalScore = totalScore
|
||||||
|
interview.summary = summary
|
||||||
|
await interview.save()
|
||||||
|
|
||||||
|
// === PROGRESS TRACKING ===
|
||||||
|
await this.trackProgress(userId, interview._id.toString(), interview.position, totalScore, dimensions)
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: interview._id.toString(),
|
||||||
|
totalScore,
|
||||||
|
summary,
|
||||||
|
dimensions,
|
||||||
|
questionCount: interview.questionCount,
|
||||||
|
position: interview.position,
|
||||||
|
contributionPrompt: '分享你的面试经验,帮助更多同学!是否愿意贡献面经?(选填公司名称 + 遇到的面试题)',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async trackProgress(
|
||||||
|
userId: string,
|
||||||
|
interviewId: string,
|
||||||
|
position: string,
|
||||||
|
totalScore: number,
|
||||||
|
dimensions: { logic: number; expression: number; professionalism: number; stability: number },
|
||||||
|
) {
|
||||||
|
let progress = await this.progressModel.findOne({ userId }).exec()
|
||||||
|
if (!progress) {
|
||||||
|
progress = await this.progressModel.create({
|
||||||
|
userId,
|
||||||
|
totalInterviews: 0,
|
||||||
|
completedInterviews: 0,
|
||||||
|
recentScores: [],
|
||||||
|
streakHistory: [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
progress.totalInterviews += 1
|
||||||
|
progress.completedInterviews += 1
|
||||||
|
|
||||||
|
// Update rolling averages
|
||||||
|
const n = progress.completedInterviews
|
||||||
|
progress.avgLogic = Math.round(((progress.avgLogic * (n - 1)) + dimensions.logic) / n)
|
||||||
|
progress.avgExpression = Math.round(((progress.avgExpression * (n - 1)) + dimensions.expression) / n)
|
||||||
|
progress.avgProfessionalism = Math.round(((progress.avgProfessionalism * (n - 1)) + dimensions.professionalism) / n)
|
||||||
|
progress.avgStability = Math.round(((progress.avgStability * (n - 1)) + dimensions.stability) / n)
|
||||||
|
|
||||||
|
// Add to recent scores
|
||||||
|
progress.recentScores.push({
|
||||||
|
interviewId,
|
||||||
|
date: new Date(),
|
||||||
|
position,
|
||||||
|
totalScore,
|
||||||
|
dimensions,
|
||||||
|
})
|
||||||
|
// Keep only last 20
|
||||||
|
if (progress.recentScores.length > 20) {
|
||||||
|
progress.recentScores = progress.recentScores.slice(-20)
|
||||||
|
}
|
||||||
|
|
||||||
|
// === STREAK TRACKING ===
|
||||||
|
const today = new Date()
|
||||||
|
today.setHours(0, 0, 0, 0)
|
||||||
|
|
||||||
|
if (progress.lastInterviewDate) {
|
||||||
|
const lastDate = new Date(progress.lastInterviewDate)
|
||||||
|
lastDate.setHours(0, 0, 0, 0)
|
||||||
|
const diffDays = Math.floor((today.getTime() - lastDate.getTime()) / (1000 * 60 * 60 * 24))
|
||||||
|
|
||||||
|
if (diffDays === 1) {
|
||||||
|
// Consecutive day
|
||||||
|
progress.streak += 1
|
||||||
|
} else if (diffDays > 1) {
|
||||||
|
// Streak broken
|
||||||
|
progress.streak = 1
|
||||||
|
}
|
||||||
|
// same day = no change
|
||||||
|
} else {
|
||||||
|
progress.streak = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
progress.lastInterviewDate = today
|
||||||
|
progress.streakHistory.push(today)
|
||||||
|
|
||||||
|
await progress.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDetail(interviewId: string, userId: string) {
|
||||||
|
const interview = await this.interviewModel.findOne({ _id: interviewId, userId }).exec()
|
||||||
|
if (!interview) throw new HttpException('面试不存在', HttpStatus.NOT_FOUND)
|
||||||
|
return interview
|
||||||
|
}
|
||||||
|
|
||||||
|
async getList(userId: string) {
|
||||||
|
const interviews = await this.interviewModel
|
||||||
|
.find({ userId })
|
||||||
|
.sort({ createdAt: -1 })
|
||||||
|
.select('position status totalScore questionCount createdAt')
|
||||||
|
.exec()
|
||||||
|
|
||||||
|
return interviews.map(i => ({
|
||||||
|
id: i._id.toString(),
|
||||||
|
position: i.position,
|
||||||
|
status: i.status,
|
||||||
|
totalScore: i.totalScore,
|
||||||
|
questionCount: i.questionCount,
|
||||||
|
time: (i as any).createdAt,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async getStats(userId: string) {
|
||||||
|
const interviews = await this.interviewModel.find({ userId }).exec()
|
||||||
|
const completed = interviews.filter(i => i.status === 'completed')
|
||||||
|
const totalScore = completed.reduce((s, i) => s + i.totalScore, 0)
|
||||||
|
|
||||||
|
return {
|
||||||
|
interviewCount: interviews.length,
|
||||||
|
completedCount: completed.length,
|
||||||
|
avgScore: completed.length > 0 ? Math.round(totalScore / completed.length) : 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { Controller, Post, Get, Body, HttpException, HttpStatus, UseGuards } from '@nestjs/common'
|
||||||
|
import { InjectModel } from '@nestjs/mongoose'
|
||||||
|
import { Model } from 'mongoose'
|
||||||
|
import { User, UserDocument } from '../user/user.schema'
|
||||||
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'
|
||||||
|
import { CurrentUser } from '../../common/decorators/current-user.decorator'
|
||||||
|
import { Public } from '../../common/decorators/public.decorator'
|
||||||
|
|
||||||
|
const GROWTH_PRICE = 1990
|
||||||
|
const DURATION_DAYS = 30
|
||||||
|
const FREE_DAILY_LIMIT = 2
|
||||||
|
|
||||||
|
interface PlanConfig {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
price: number
|
||||||
|
dailyLimit: number
|
||||||
|
features: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const PLANS: Record<string, PlanConfig> = {
|
||||||
|
free: {
|
||||||
|
id: 'free',
|
||||||
|
name: '免费版',
|
||||||
|
price: 0,
|
||||||
|
dailyLimit: FREE_DAILY_LIMIT,
|
||||||
|
features: [
|
||||||
|
'每日 2 次 AI 模拟面试',
|
||||||
|
'基础面试报告',
|
||||||
|
'通用题库随机出题',
|
||||||
|
'简历诊断(限 3 次)',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
growth: {
|
||||||
|
id: 'growth',
|
||||||
|
name: '成长版',
|
||||||
|
price: GROWTH_PRICE,
|
||||||
|
dailyLimit: 999,
|
||||||
|
features: [
|
||||||
|
'免费版全部权益',
|
||||||
|
'无限面试次数',
|
||||||
|
'详细面试报告(四维评分)',
|
||||||
|
'进步轨迹雷达图 + 打卡',
|
||||||
|
'每日一题推送',
|
||||||
|
'参考回答思路',
|
||||||
|
'公司真题库',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller('member')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class MemberController {
|
||||||
|
constructor(
|
||||||
|
@InjectModel(User.name) private userModel: Model<UserDocument>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// 公开的套餐配置(给前端会员页和限制拦截用)
|
||||||
|
@Public()
|
||||||
|
@Get('plans')
|
||||||
|
getPlans() {
|
||||||
|
return {
|
||||||
|
interview: {
|
||||||
|
dailyFreeLimit: FREE_DAILY_LIMIT,
|
||||||
|
maxRoundsFree: 5,
|
||||||
|
maxRoundsVip: 10,
|
||||||
|
},
|
||||||
|
diagnosis: { dailyFreeLimit: 2 },
|
||||||
|
optimize: { dailyFreeLimit: 2 },
|
||||||
|
price: { monthly: GROWTH_PRICE },
|
||||||
|
plans: Object.values(PLANS).map(p => ({
|
||||||
|
id: p.id,
|
||||||
|
name: p.name,
|
||||||
|
price: p.price,
|
||||||
|
priceDisplay: p.price === 0 ? '免费' : `¥${(p.price / 100).toFixed(1)}/月`,
|
||||||
|
dailyLimit: p.dailyLimit,
|
||||||
|
features: p.features,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('status')
|
||||||
|
async getStatus(@CurrentUser('userId') userId: string) {
|
||||||
|
const user = await this.userModel.findById(userId).exec()
|
||||||
|
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
|
||||||
|
const planConfig = PLANS[user.plan] || PLANS.free
|
||||||
|
return {
|
||||||
|
plan: user.plan,
|
||||||
|
planName: planConfig.name,
|
||||||
|
remaining: user.remaining,
|
||||||
|
dailyLimit: planConfig.dailyLimit,
|
||||||
|
vipExpireAt: user.vipExpireAt,
|
||||||
|
isVip: user.plan !== 'free',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('create-order')
|
||||||
|
async createOrder(@CurrentUser('userId') userId: string) {
|
||||||
|
const user = await this.userModel.findById(userId).exec()
|
||||||
|
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
|
||||||
|
const orderId = `ZHI${Date.now()}${userId.slice(-4)}`
|
||||||
|
return {
|
||||||
|
orderId,
|
||||||
|
planId: 'growth',
|
||||||
|
planName: '成长版',
|
||||||
|
amount: GROWTH_PRICE,
|
||||||
|
amountDisplay: `¥${(GROWTH_PRICE / 100).toFixed(1)}`,
|
||||||
|
duration: `${DURATION_DAYS} 天`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('pay')
|
||||||
|
async pay(@CurrentUser('userId') userId: string, @Body('orderId') orderId: string) {
|
||||||
|
const user = await this.userModel.findById(userId).exec()
|
||||||
|
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
|
||||||
|
const expireAt = new Date()
|
||||||
|
expireAt.setDate(expireAt.getDate() + DURATION_DAYS)
|
||||||
|
user.plan = 'growth'
|
||||||
|
user.vipExpireAt = expireAt
|
||||||
|
user.remaining = 999
|
||||||
|
await user.save()
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
plan: 'growth',
|
||||||
|
planName: '成长版',
|
||||||
|
expireAt,
|
||||||
|
message: '支付成功!欢迎开通成长版',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common'
|
||||||
|
import { MongooseModule } from '@nestjs/mongoose'
|
||||||
|
import { MemberController } from './member.controller'
|
||||||
|
import { User, UserSchema } from '../user/user.schema'
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [MongooseModule.forFeature([{ name: User.name, schema: UserSchema }])],
|
||||||
|
controllers: [MemberController],
|
||||||
|
})
|
||||||
|
export class MemberModule {}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { Controller, Post, Body, UseGuards, HttpException, HttpStatus } from '@nestjs/common'
|
||||||
|
import { InjectModel } from '@nestjs/mongoose'
|
||||||
|
import { Model } from 'mongoose'
|
||||||
|
import { User, UserDocument } from '../user/user.schema'
|
||||||
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'
|
||||||
|
import { CurrentUser } from '../../common/decorators/current-user.decorator'
|
||||||
|
import { WechatPayService } from './wechat-pay.service'
|
||||||
|
import { Public } from '../../common/decorators/public.decorator'
|
||||||
|
|
||||||
|
const VIP_AMOUNT = 2900 // 29 元(分)
|
||||||
|
const VIP_DURATION_DAYS = 30
|
||||||
|
|
||||||
|
@Controller('payment')
|
||||||
|
export class PaymentController {
|
||||||
|
constructor(
|
||||||
|
@InjectModel(User.name) private userModel: Model<UserDocument>,
|
||||||
|
private wechatPay: WechatPayService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** 创建订单(H5:Native 扫码支付) */
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Post('create')
|
||||||
|
async create(@CurrentUser('userId') userId: string) {
|
||||||
|
const user = await this.userModel.findById(userId).exec()
|
||||||
|
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
|
||||||
|
if (user.plan === 'vip') throw new HttpException('已是会员', HttpStatus.BAD_REQUEST)
|
||||||
|
|
||||||
|
const outTradeNo = `VIP${Date.now()}${userId.slice(-6)}`
|
||||||
|
const result = await this.wechatPay.nativePay(
|
||||||
|
'职引月度会员',
|
||||||
|
outTradeNo,
|
||||||
|
VIP_AMOUNT,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
outTradeNo: result.outTradeNo,
|
||||||
|
codeUrl: result.codeUrl, // 二维码链接
|
||||||
|
amount: VIP_AMOUNT,
|
||||||
|
title: '职引月度会员',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** JSAPI 支付(微信小程序/公众号内使用) */
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Post('jsapi')
|
||||||
|
async jsapi(@CurrentUser('userId') userId: string, @Body('openid') openid: string) {
|
||||||
|
const user = await this.userModel.findById(userId).exec()
|
||||||
|
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
|
||||||
|
if (!openid) throw new HttpException('缺少 openid', HttpStatus.BAD_REQUEST)
|
||||||
|
if (user.plan === 'vip') throw new HttpException('已是会员', HttpStatus.BAD_REQUEST)
|
||||||
|
|
||||||
|
const outTradeNo = `VIP${Date.now()}${userId.slice(-6)}`
|
||||||
|
const result = await this.wechatPay.jsapiPay('职引月度会员', outTradeNo, VIP_AMOUNT, openid)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 支付回调通知 */
|
||||||
|
@Public()
|
||||||
|
@Post('notify')
|
||||||
|
async notify(@Body() body: any, @Body('headers') headers: any) {
|
||||||
|
// 实际运行时从 request 读取 header
|
||||||
|
try {
|
||||||
|
const decrypted = this.wechatPay.verifyAndDecrypt(body, '', '', '')
|
||||||
|
if (!decrypted) return { code: 'FAIL', message: '验签失败' }
|
||||||
|
// 处理成功支付
|
||||||
|
const outTradeNo = decrypted.out_trade_no
|
||||||
|
const userId = outTradeNo.slice(-6) // 从订单号取 userId
|
||||||
|
const user = await this.userModel.findOne({ _id: { $regex: userId + '$' } }).exec()
|
||||||
|
if (user && user.plan !== 'vip') {
|
||||||
|
const expireAt = new Date()
|
||||||
|
expireAt.setDate(expireAt.getDate() + VIP_DURATION_DAYS)
|
||||||
|
user.plan = 'vip'
|
||||||
|
user.vipExpireAt = expireAt
|
||||||
|
user.remaining = 999
|
||||||
|
await user.save()
|
||||||
|
}
|
||||||
|
return { code: 'SUCCESS', message: '成功' }
|
||||||
|
} catch {
|
||||||
|
return { code: 'FAIL', message: '处理失败' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 查询订单 */
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Post('query')
|
||||||
|
async query(@Body('outTradeNo') outTradeNo: string) {
|
||||||
|
return this.wechatPay.queryOrder(outTradeNo)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Module } from '@nestjs/common'
|
||||||
|
import { MongooseModule } from '@nestjs/mongoose'
|
||||||
|
import { PaymentController } from './payment.controller'
|
||||||
|
import { WechatPayService } from './wechat-pay.service'
|
||||||
|
import { User, UserSchema } from '../user/user.schema'
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [MongooseModule.forFeature([{ name: User.name, schema: UserSchema }])],
|
||||||
|
controllers: [PaymentController],
|
||||||
|
providers: [WechatPayService],
|
||||||
|
exports: [WechatPayService],
|
||||||
|
})
|
||||||
|
export class PaymentModule {}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import * as fs from 'fs'
|
||||||
|
import * as path from 'path'
|
||||||
|
import * as crypto from 'crypto'
|
||||||
|
import axios from 'axios'
|
||||||
|
import { Injectable, Logger } from '@nestjs/common'
|
||||||
|
|
||||||
|
const MCHID = process.env.WX_MCHID
|
||||||
|
const API_V3_KEY = process.env.WX_API_V3_KEY
|
||||||
|
const NOTIFY_URL = process.env.WX_NOTIFY_URL
|
||||||
|
const APPID = process.env.WX_APPID
|
||||||
|
const WX_API_BASE = 'https://api.mch.weixin.qq.com'
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WechatPayService {
|
||||||
|
private readonly logger = new Logger(WechatPayService.name)
|
||||||
|
private readonly privateKey: string
|
||||||
|
private readonly mchSerialNo: string
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
if (!MCHID || !API_V3_KEY || !APPID) {
|
||||||
|
this.logger.warn('微信支付配置不完整,支付功能不可用')
|
||||||
|
}
|
||||||
|
const certDir = path.resolve(__dirname, '../../certs')
|
||||||
|
this.privateKey = fs.readFileSync(path.join(certDir, 'apiclient_key.pem'), 'utf8')
|
||||||
|
// 从证书中提取序列号
|
||||||
|
const cert = fs.readFileSync(path.join(certDir, 'apiclient_cert.pem'), 'utf8')
|
||||||
|
const certObj = new crypto.X509Certificate(cert)
|
||||||
|
this.mchSerialNo = certObj.serialNumber
|
||||||
|
this.logger.log(`微信支付初始化完成,商户号: ${MCHID}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 生成请求签名(API v3) */
|
||||||
|
private sign(method: string, url: string, body: string, nonce: string, timestamp: string): string {
|
||||||
|
const message = `${method}\n${url}\n${timestamp}\n${nonce}\n${body}\n`
|
||||||
|
return crypto.createSign('RSA-SHA256').update(message).sign(this.privateKey, 'base64')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取 Authorization header */
|
||||||
|
private getAuth(method: string, path: string, body: any) {
|
||||||
|
const nonce = crypto.randomBytes(16).toString('hex')
|
||||||
|
const timestamp = Math.floor(Date.now() / 1000).toString()
|
||||||
|
const bodyStr = body ? JSON.stringify(body) : ''
|
||||||
|
const signature = this.sign(method, path, bodyStr, nonce, timestamp)
|
||||||
|
const auth = `WECHATPAY2-SHA256-RSA2048 mchid="${MCHID}",nonce_str="${nonce}",timestamp="${timestamp}",serial_no="${this.mchSerialNo}",signature="${signature}"`
|
||||||
|
return auth
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 发起 API v3 请求 */
|
||||||
|
private async request(method: string, apiPath: string, body?: any) {
|
||||||
|
const url = `${WX_API_BASE}${apiPath}`
|
||||||
|
try {
|
||||||
|
const res = await axios({
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
headers: {
|
||||||
|
'Authorization': this.getAuth(method, apiPath, body || ''),
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'User-Agent': 'zhiyin-backend/1.0',
|
||||||
|
},
|
||||||
|
data: body,
|
||||||
|
})
|
||||||
|
return res.data
|
||||||
|
} catch (e: any) {
|
||||||
|
this.logger.error(`微信支付请求失败: ${method} ${apiPath}`, e.response?.data || e.message)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Native 支付:获取二维码链接 */
|
||||||
|
async nativePay(description: string, outTradeNo: string, amount: number, openid?: string) {
|
||||||
|
// amount 单位:分
|
||||||
|
const body: any = {
|
||||||
|
appid: APPID,
|
||||||
|
mchid: MCHID,
|
||||||
|
description,
|
||||||
|
out_trade_no: outTradeNo,
|
||||||
|
notify_url: NOTIFY_URL,
|
||||||
|
amount: { total: amount, currency: 'CNY' },
|
||||||
|
}
|
||||||
|
// JSAPI 时需要 payer.openid
|
||||||
|
if (openid) body.payer = { openid }
|
||||||
|
const apiPath = '/v3/pay/transactions/native'
|
||||||
|
const result = await this.request('POST', apiPath, body)
|
||||||
|
return { codeUrl: result.code_url, outTradeNo }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** JSAPI 支付:获取调起支付的参数 */
|
||||||
|
async jsapiPay(description: string, outTradeNo: string, amount: number, openid: string) {
|
||||||
|
const body = {
|
||||||
|
appid: APPID,
|
||||||
|
mchid: MCHID,
|
||||||
|
description,
|
||||||
|
out_trade_no: outTradeNo,
|
||||||
|
notify_url: NOTIFY_URL,
|
||||||
|
amount: { total: amount, currency: 'CNY' },
|
||||||
|
payer: { openid },
|
||||||
|
}
|
||||||
|
const result = await this.request('POST', '/v3/pay/transactions/jsapi', body)
|
||||||
|
const prepayId = result.prepay_id
|
||||||
|
// 生成小程序/JSAPI 调起支付参数
|
||||||
|
const nonce = crypto.randomBytes(16).toString('hex')
|
||||||
|
const timestamp = Math.floor(Date.now() / 1000).toString()
|
||||||
|
const packageStr = `prepay_id=${prepayId}`
|
||||||
|
const signStr = `${APPID}\n${timestamp}\n${nonce}\n${packageStr}\n`
|
||||||
|
const paySign = crypto.createSign('RSA-SHA256').update(signStr).sign(this.privateKey, 'base64')
|
||||||
|
return {
|
||||||
|
prepayId,
|
||||||
|
payParams: {
|
||||||
|
appId: APPID,
|
||||||
|
timeStamp: timestamp,
|
||||||
|
nonceStr: nonce,
|
||||||
|
package: packageStr,
|
||||||
|
signType: 'RSA',
|
||||||
|
paySign,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 验证并解密回调通知 */
|
||||||
|
verifyAndDecrypt(body: any, wechatSignature: string, wechatTimestamp: string, wechatNonce: string) {
|
||||||
|
// 1. 验签
|
||||||
|
const message = `${wechatTimestamp}\n${wechatNonce}\n${JSON.stringify(body)}\n`
|
||||||
|
const certDir = path.resolve(__dirname, '../../certs')
|
||||||
|
const platformCert = fs.readFileSync(path.join(certDir, 'pub_key.pem'), 'utf8')
|
||||||
|
const verify = crypto.createVerify('RSA-SHA256').update(message)
|
||||||
|
const isValid = verify.verify(platformCert, wechatSignature, 'base64')
|
||||||
|
if (!isValid) {
|
||||||
|
this.logger.warn('微信支付回调验签失败')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
// 2. 解密 resource
|
||||||
|
const resource = body.resource
|
||||||
|
const ciphertext = Buffer.from(resource.ciphertext, 'base64')
|
||||||
|
const associatedData = resource.associated_data || ''
|
||||||
|
const nonce = resource.nonce
|
||||||
|
const key = API_V3_KEY
|
||||||
|
// AES-256-GCM 解密
|
||||||
|
const authTag = ciphertext.subarray(ciphertext.length - 16)
|
||||||
|
const data = ciphertext.subarray(0, ciphertext.length - 16)
|
||||||
|
const decipher = crypto.createDecipheriv('aes-256-gcm', key, nonce)
|
||||||
|
decipher.setAAD(Buffer.from(associatedData))
|
||||||
|
decipher.setAuthTag(authTag)
|
||||||
|
const decrypted = decipher.update(data) + decipher.final('utf8')
|
||||||
|
return JSON.parse(decrypted)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 查询订单 */
|
||||||
|
async queryOrder(outTradeNo: string) {
|
||||||
|
return this.request('GET', `/v3/pay/transactions/out-trade-no/${outTradeNo}?mchid=${MCHID}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { Controller, Get } from '@nestjs/common'
|
||||||
|
import { Public } from '../../common/decorators/public.decorator'
|
||||||
|
|
||||||
|
@Controller('positions')
|
||||||
|
export class PositionsController {
|
||||||
|
@Public()
|
||||||
|
@Get('hot')
|
||||||
|
hot() {
|
||||||
|
return [
|
||||||
|
{ name: '前端工程师', salary: '15-25K', company: '腾讯' },
|
||||||
|
{ name: '后端工程师', salary: '18-30K', company: '阿里巴巴' },
|
||||||
|
{ name: 'AI 算法工程师', salary: '20-35K', company: '字节跳动' },
|
||||||
|
{ name: '产品经理', salary: '12-20K', company: '美团' },
|
||||||
|
{ name: 'UI 设计师', salary: '10-18K', company: '网易' },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { Module } from '@nestjs/common'
|
||||||
|
import { PositionsController } from './positions.controller'
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [PositionsController],
|
||||||
|
})
|
||||||
|
export class PositionsModule {}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { Controller, Get, UseGuards } from '@nestjs/common'
|
||||||
|
import { InjectModel } from '@nestjs/mongoose'
|
||||||
|
import { Model } from 'mongoose'
|
||||||
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'
|
||||||
|
import { CurrentUser } from '../../common/decorators/current-user.decorator'
|
||||||
|
import { Progress, ProgressDocument } from '../schemas/progress.schema'
|
||||||
|
import { Interview, InterviewDocument } from '../interview/interview.schema'
|
||||||
|
|
||||||
|
@Controller('progress')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class ProgressController {
|
||||||
|
constructor(
|
||||||
|
@InjectModel(Progress.name) private progressModel: Model<ProgressDocument>,
|
||||||
|
@InjectModel(Interview.name) private interviewModel: Model<InterviewDocument>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async getProgress(@CurrentUser('userId') userId: string) {
|
||||||
|
let progress = await this.progressModel.findOne({ userId }).exec()
|
||||||
|
if (!progress) {
|
||||||
|
progress = await this.progressModel.create({
|
||||||
|
userId,
|
||||||
|
totalInterviews: 0,
|
||||||
|
completedInterviews: 0,
|
||||||
|
streak: 0,
|
||||||
|
recentScores: [],
|
||||||
|
streakHistory: [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const recentInterviews = await this.interviewModel
|
||||||
|
.find({ userId, status: 'completed' })
|
||||||
|
.sort({ createdAt: -1 })
|
||||||
|
.limit(10)
|
||||||
|
.select('position totalScore questionCount createdAt')
|
||||||
|
.exec()
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalInterviews: progress.totalInterviews,
|
||||||
|
completedInterviews: progress.completedInterviews,
|
||||||
|
dimensions: {
|
||||||
|
logic: progress.avgLogic || Math.round(60 + Math.random() * 20),
|
||||||
|
expression: progress.avgExpression || Math.round(60 + Math.random() * 20),
|
||||||
|
professionalism: progress.avgProfessionalism || Math.round(60 + Math.random() * 20),
|
||||||
|
stability: progress.avgStability || Math.round(60 + Math.random() * 20),
|
||||||
|
},
|
||||||
|
streak: progress.streak,
|
||||||
|
lastInterviewDate: progress.lastInterviewDate,
|
||||||
|
recentScores: progress.recentScores.slice(-5),
|
||||||
|
interviews: recentInterviews.map(i => ({
|
||||||
|
id: (i as any)._id.toString(),
|
||||||
|
position: i.position,
|
||||||
|
totalScore: i.totalScore,
|
||||||
|
questionCount: i.questionCount,
|
||||||
|
date: (i as any).createdAt,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('stats')
|
||||||
|
async getStats(@CurrentUser('userId') userId: string) {
|
||||||
|
const progress = await this.progressModel.findOne({ userId }).exec()
|
||||||
|
if (!progress) {
|
||||||
|
return { totalInterviews: 0, completedInterviews: 0, avgScore: 0, streak: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
const completedInterviews = await this.interviewModel
|
||||||
|
.find({ userId, status: 'completed' })
|
||||||
|
.select('totalScore')
|
||||||
|
.exec()
|
||||||
|
|
||||||
|
const avgScore = completedInterviews.length > 0
|
||||||
|
? Math.round(completedInterviews.reduce((s, i) => s + i.totalScore, 0) / completedInterviews.length)
|
||||||
|
: 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalInterviews: progress.totalInterviews,
|
||||||
|
completedInterviews: progress.completedInterviews,
|
||||||
|
avgScore,
|
||||||
|
streak: progress.streak,
|
||||||
|
dimensions: {
|
||||||
|
logic: progress.avgLogic || 0,
|
||||||
|
expression: progress.avgExpression || 0,
|
||||||
|
professionalism: progress.avgProfessionalism || 0,
|
||||||
|
stability: progress.avgStability || 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { Module } from '@nestjs/common'
|
||||||
|
import { MongooseModule } from '@nestjs/mongoose'
|
||||||
|
import { ProgressController } from './progress.controller'
|
||||||
|
import { Progress, ProgressSchema } from '../schemas/progress.schema'
|
||||||
|
import { Interview, InterviewSchema } from '../interview/interview.schema'
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
MongooseModule.forFeature([
|
||||||
|
{ name: Progress.name, schema: ProgressSchema },
|
||||||
|
{ name: Interview.name, schema: InterviewSchema },
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
controllers: [ProgressController],
|
||||||
|
exports: [],
|
||||||
|
})
|
||||||
|
export class ProgressModule {}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { Controller, Post, Get, Delete, Param, Body } from '@nestjs/common'
|
||||||
|
import { ResumeService } from './resume.service'
|
||||||
|
import { CurrentUser } from '../../common/decorators/current-user.decorator'
|
||||||
|
|
||||||
|
@Controller('resume')
|
||||||
|
export class ResumeController {
|
||||||
|
constructor(private resumeService: ResumeService) {}
|
||||||
|
|
||||||
|
@Post('create')
|
||||||
|
async create(
|
||||||
|
@CurrentUser('userId') userId: string,
|
||||||
|
@Body('title') title: string,
|
||||||
|
@Body('content') content: string,
|
||||||
|
@Body('targetPosition') targetPosition?: string,
|
||||||
|
) {
|
||||||
|
return this.resumeService.create(userId, title, content, targetPosition)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('list')
|
||||||
|
async list(@CurrentUser('userId') userId: string) {
|
||||||
|
return this.resumeService.list(userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
async getDetail(@Param('id') id: string, @CurrentUser('userId') userId: string) {
|
||||||
|
return this.resumeService.getDetail(id, userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
async delete(@Param('id') id: string, @CurrentUser('userId') userId: string) {
|
||||||
|
return this.resumeService.delete(id, userId)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from '@nestjs/common'
|
||||||
|
import { MongooseModule } from '@nestjs/mongoose'
|
||||||
|
import { ResumeController } from './resume.controller'
|
||||||
|
import { ResumeService } from './resume.service'
|
||||||
|
import { Resume, ResumeSchema } from './resume.schema'
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [MongooseModule.forFeature([{ name: Resume.name, schema: ResumeSchema }])],
|
||||||
|
controllers: [ResumeController],
|
||||||
|
providers: [ResumeService],
|
||||||
|
})
|
||||||
|
export class ResumeModule {}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
|
||||||
|
import { Document, Types } from 'mongoose'
|
||||||
|
|
||||||
|
export type ResumeDocument = Resume & Document
|
||||||
|
|
||||||
|
@Schema({ timestamps: true })
|
||||||
|
export class Resume {
|
||||||
|
@Prop({ type: Types.ObjectId, ref: 'User', required: true })
|
||||||
|
userId: Types.ObjectId
|
||||||
|
|
||||||
|
@Prop({ required: true })
|
||||||
|
title: string
|
||||||
|
|
||||||
|
@Prop({ default: '' })
|
||||||
|
content: string
|
||||||
|
|
||||||
|
@Prop({ default: '' })
|
||||||
|
targetPosition: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ResumeSchema = SchemaFactory.createForClass(Resume)
|
||||||
|
ResumeSchema.index({ userId: 1, createdAt: -1 })
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { Injectable, HttpException, HttpStatus } from '@nestjs/common'
|
||||||
|
import { InjectModel } from '@nestjs/mongoose'
|
||||||
|
import { Model } from 'mongoose'
|
||||||
|
import { Resume, ResumeDocument } from './resume.schema'
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ResumeService {
|
||||||
|
constructor(@InjectModel(Resume.name) private resumeModel: Model<ResumeDocument>) {}
|
||||||
|
|
||||||
|
async create(userId: string, title: string, content: string, targetPosition?: string) {
|
||||||
|
const resume = await this.resumeModel.create({ userId, title, content, targetPosition })
|
||||||
|
return resume.toObject()
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(userId: string) {
|
||||||
|
const list = await this.resumeModel.find({ userId }).sort({ createdAt: -1 }).exec()
|
||||||
|
return list.map(r => ({
|
||||||
|
id: r._id.toString(),
|
||||||
|
title: r.title,
|
||||||
|
targetPosition: r.targetPosition,
|
||||||
|
createdAt: (r as any).createdAt,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDetail(resumeId: string, userId: string) {
|
||||||
|
const resume = await this.resumeModel.findOne({ _id: resumeId, userId }).exec()
|
||||||
|
if (!resume) throw new HttpException('简历不存在', HttpStatus.NOT_FOUND)
|
||||||
|
return resume.toObject()
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(resumeId: string, userId: string) {
|
||||||
|
const res = await this.resumeModel.deleteOne({ _id: resumeId, userId }).exec()
|
||||||
|
if (res.deletedCount === 0) throw new HttpException('简历不存在', HttpStatus.NOT_FOUND)
|
||||||
|
return { message: '删除成功' }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
|
||||||
|
import { Document } from 'mongoose'
|
||||||
|
|
||||||
|
export type CompanyBankDocument = CompanyBank & Document
|
||||||
|
|
||||||
|
@Schema({ timestamps: true })
|
||||||
|
export class CompanyBank {
|
||||||
|
@Prop({ required: true })
|
||||||
|
company: string // 公司名称
|
||||||
|
|
||||||
|
@Prop({ required: true })
|
||||||
|
position: string // 岗位
|
||||||
|
|
||||||
|
@Prop({ type: [Object], default: [] })
|
||||||
|
questions: Array<{
|
||||||
|
content: string // 问题内容
|
||||||
|
type: string // basic/algorithm/project/behavioral
|
||||||
|
referenceAnswer: string // 参考回答
|
||||||
|
difficulty: string // easy/medium/hard
|
||||||
|
frequency: number // 出现频率统计
|
||||||
|
tags: string[] // 标签
|
||||||
|
}>
|
||||||
|
|
||||||
|
@Prop({ default: 0 })
|
||||||
|
contributionCount: number // 用户贡献次数
|
||||||
|
|
||||||
|
@Prop({ default: 0 })
|
||||||
|
viewCount: number // 查看次数
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CompanyBankSchema = SchemaFactory.createForClass(CompanyBank)
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
|
||||||
|
import { Document } from 'mongoose'
|
||||||
|
|
||||||
|
export type ContributionDocument = Contribution & Document
|
||||||
|
|
||||||
|
@Schema({ timestamps: true })
|
||||||
|
export class Contribution {
|
||||||
|
@Prop({ required: true, index: true })
|
||||||
|
userId: string
|
||||||
|
|
||||||
|
@Prop({ required: true })
|
||||||
|
interviewId: string
|
||||||
|
|
||||||
|
@Prop({ required: true })
|
||||||
|
company: string // 公司名称
|
||||||
|
|
||||||
|
@Prop({ required: true })
|
||||||
|
position: string // 岗位
|
||||||
|
|
||||||
|
@Prop({ default: '' })
|
||||||
|
rounds: string // 面试轮次描述
|
||||||
|
|
||||||
|
@Prop({ type: [String], default: [] })
|
||||||
|
questions: string[] // 遇到的面试题(脱敏)
|
||||||
|
|
||||||
|
@Prop({ default: '' })
|
||||||
|
experience: string // 面试经验/感受
|
||||||
|
|
||||||
|
@Prop({ type: [String], default: [] })
|
||||||
|
tags: string[] // 标签(如"算法题多"、"重视项目经历")
|
||||||
|
|
||||||
|
@Prop({ default: false })
|
||||||
|
verified: boolean // 是否经过审核
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ContributionSchema = SchemaFactory.createForClass(Contribution)
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
|
||||||
|
import { Document } from 'mongoose'
|
||||||
|
|
||||||
|
export type DailyQuestionDocument = DailyQuestion & Document
|
||||||
|
|
||||||
|
@Schema({ timestamps: true })
|
||||||
|
export class DailyQuestion {
|
||||||
|
@Prop({ required: true })
|
||||||
|
position: string // 适用岗位
|
||||||
|
|
||||||
|
@Prop({ required: true })
|
||||||
|
question: string // 面试题
|
||||||
|
|
||||||
|
@Prop({ required: true })
|
||||||
|
referenceAnswer: string // 参考思路
|
||||||
|
|
||||||
|
@Prop({ default: 'general' })
|
||||||
|
category: string // basic/algorithm/project/behavioral
|
||||||
|
|
||||||
|
@Prop()
|
||||||
|
date?: Date // 推送日期
|
||||||
|
|
||||||
|
@Prop({ default: false })
|
||||||
|
pushed: boolean // 是否已推送
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DailyQuestionSchema = SchemaFactory.createForClass(DailyQuestion)
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
|
||||||
|
import { Document } from 'mongoose'
|
||||||
|
|
||||||
|
export type ProgressDocument = Progress & Document
|
||||||
|
|
||||||
|
@Schema({ timestamps: true })
|
||||||
|
export class Progress {
|
||||||
|
@Prop({ required: true, index: true })
|
||||||
|
userId: string
|
||||||
|
|
||||||
|
@Prop({ default: 0 })
|
||||||
|
totalInterviews: number
|
||||||
|
|
||||||
|
@Prop({ default: 0 })
|
||||||
|
completedInterviews: number
|
||||||
|
|
||||||
|
// 四维能力平均分
|
||||||
|
@Prop({ default: 0 })
|
||||||
|
avgLogic: number // 逻辑思维
|
||||||
|
|
||||||
|
@Prop({ default: 0 })
|
||||||
|
avgExpression: number // 表达能力
|
||||||
|
|
||||||
|
@Prop({ default: 0 })
|
||||||
|
avgProfessionalism: number // 专业度
|
||||||
|
|
||||||
|
@Prop({ default: 0 })
|
||||||
|
avgStability: number // 稳定性
|
||||||
|
|
||||||
|
// 最近面试记录(用于进步对比)
|
||||||
|
@Prop({ type: [Object], default: [] })
|
||||||
|
recentScores: Array<{
|
||||||
|
interviewId: string
|
||||||
|
date: Date
|
||||||
|
position: string
|
||||||
|
totalScore: number
|
||||||
|
dimensions: {
|
||||||
|
logic: number
|
||||||
|
expression: number
|
||||||
|
professionalism: number
|
||||||
|
stability: number
|
||||||
|
}
|
||||||
|
}>
|
||||||
|
|
||||||
|
// 打卡记录
|
||||||
|
@Prop({ default: 0 })
|
||||||
|
streak: number // 连续打卡天数
|
||||||
|
|
||||||
|
@Prop()
|
||||||
|
lastInterviewDate?: Date
|
||||||
|
|
||||||
|
@Prop({ type: [Date], default: [] })
|
||||||
|
streakHistory: Date[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProgressSchema = SchemaFactory.createForClass(Progress)
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { Controller, Post, UseInterceptors, UploadedFile, HttpException, HttpStatus } from '@nestjs/common'
|
||||||
|
import { FileInterceptor } from '@nestjs/platform-express'
|
||||||
|
import * as mammoth from 'mammoth'
|
||||||
|
import { memoryStorage } from 'multer'
|
||||||
|
import { Public } from '../../common/decorators/public.decorator'
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
|
const pdfParse = require('pdf-parse')
|
||||||
|
|
||||||
|
@Controller('upload')
|
||||||
|
export class UploadController {
|
||||||
|
@Public()
|
||||||
|
@Post()
|
||||||
|
@UseInterceptors(FileInterceptor('file', {
|
||||||
|
storage: memoryStorage(),
|
||||||
|
limits: { fileSize: 10 * 1024 * 1024 },
|
||||||
|
}))
|
||||||
|
async uploadFile(@UploadedFile() file: any) {
|
||||||
|
if (!file) {
|
||||||
|
throw new HttpException('请选择文件', HttpStatus.BAD_REQUEST)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = file.originalname.toLowerCase().split('.').pop() || ''
|
||||||
|
let text = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (ext) {
|
||||||
|
case 'pdf':
|
||||||
|
const pdfData = await pdfParse(file.buffer)
|
||||||
|
text = pdfData.text
|
||||||
|
break
|
||||||
|
case 'docx':
|
||||||
|
const docxResult = await mammoth.extractRawText({ buffer: file.buffer })
|
||||||
|
text = docxResult.value
|
||||||
|
break
|
||||||
|
case 'doc':
|
||||||
|
text = '[旧版 Word 格式 (.doc) 暂不支持解析,请转换为 .docx 或粘贴文本]'
|
||||||
|
break
|
||||||
|
case 'txt':
|
||||||
|
text = file.buffer.toString('utf-8')
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
throw new HttpException('不支持的文件格式,请上传 PDF、DOCX 或 TXT', HttpStatus.BAD_REQUEST)
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e instanceof HttpException) throw e
|
||||||
|
throw new HttpException('文件解析失败:' + (e.message || '未知错误'), HttpStatus.INTERNAL_SERVER_ERROR)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!text.trim()) {
|
||||||
|
throw new HttpException('未能从文件中提取到有效文本', HttpStatus.BAD_REQUEST)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: text.trim(),
|
||||||
|
fileName: file.originalname,
|
||||||
|
fileSize: file.size,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from '@nestjs/common'
|
||||||
|
import { MulterModule } from '@nestjs/platform-express'
|
||||||
|
import { memoryStorage } from 'multer'
|
||||||
|
import { UploadController } from './upload.controller'
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
MulterModule.register({ storage: memoryStorage() }),
|
||||||
|
],
|
||||||
|
controllers: [UploadController],
|
||||||
|
})
|
||||||
|
export class UploadModule {}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { Controller, Post, Get, Put, Body, Req } from '@nestjs/common'
|
||||||
|
import { UserService } from './user.service'
|
||||||
|
import { Public } from '../../common/decorators/public.decorator'
|
||||||
|
import { CurrentUser } from '../../common/decorators/current-user.decorator'
|
||||||
|
|
||||||
|
@Controller('user')
|
||||||
|
export class UserController {
|
||||||
|
constructor(private userService: UserService) {}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Post('send-code')
|
||||||
|
async sendCode(@Body('phone') phone: string) {
|
||||||
|
return this.userService.sendCode(phone)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Post('login')
|
||||||
|
async login(@Body('phone') phone: string, @Body('code') code: string) {
|
||||||
|
return this.userService.loginByPhone(phone, code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 📧 邮箱验证码登录(H5 用)
|
||||||
|
@Public()
|
||||||
|
@Post('send-email-code')
|
||||||
|
async sendEmailCode(@Body('email') email: string) {
|
||||||
|
return this.userService.sendEmailCode(email)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Post('email-login')
|
||||||
|
async emailLogin(@Body('email') email: string, @Body('code') code: string) {
|
||||||
|
return this.userService.loginByEmail(email, code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 微信静默登录
|
||||||
|
@Public()
|
||||||
|
@Post('wx-login')
|
||||||
|
async wxLogin(@Body('code') code: string) {
|
||||||
|
return this.userService.loginByWx(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('info')
|
||||||
|
async getInfo(@CurrentUser('userId') userId: string) {
|
||||||
|
return this.userService.getInfo(userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('update')
|
||||||
|
async update(@CurrentUser('userId') userId: string, @Body() data: { nickname?: string; avatar?: string }) {
|
||||||
|
return this.userService.update(userId, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('usage')
|
||||||
|
async getUsage(@CurrentUser('userId') userId: string) {
|
||||||
|
return this.userService.getUsage(userId)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { Module } from '@nestjs/common'
|
||||||
|
import { MongooseModule } from '@nestjs/mongoose'
|
||||||
|
import { JwtModule } from '@nestjs/jwt'
|
||||||
|
import { UserController } from './user.controller'
|
||||||
|
import { UserService } from './user.service'
|
||||||
|
import { User, UserSchema } from './user.schema'
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
MongooseModule.forFeature([{ name: User.name, schema: UserSchema }]),
|
||||||
|
JwtModule.register({
|
||||||
|
secret: process.env.JWT_SECRET || 'zhiyin-jwt-secret-2026',
|
||||||
|
signOptions: { expiresIn: '7d' },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
controllers: [UserController],
|
||||||
|
providers: [UserService],
|
||||||
|
exports: [UserService],
|
||||||
|
})
|
||||||
|
export class UserModule {}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
|
||||||
|
import { Document } from 'mongoose'
|
||||||
|
|
||||||
|
export type UserDocument = User & Document
|
||||||
|
|
||||||
|
@Schema({ timestamps: true })
|
||||||
|
export class User {
|
||||||
|
@Prop({ unique: true, sparse: true })
|
||||||
|
phone?: string
|
||||||
|
|
||||||
|
@Prop({ unique: true, sparse: true })
|
||||||
|
wxOpenid?: string
|
||||||
|
|
||||||
|
@Prop({ default: '' })
|
||||||
|
nickname?: string
|
||||||
|
|
||||||
|
@Prop({ default: '' })
|
||||||
|
avatar?: string
|
||||||
|
|
||||||
|
@Prop({ default: 0 })
|
||||||
|
interviewCount: number
|
||||||
|
|
||||||
|
@Prop({ default: 3 })
|
||||||
|
remaining: number
|
||||||
|
|
||||||
|
@Prop({ default: 'free' })
|
||||||
|
plan: string
|
||||||
|
|
||||||
|
@Prop()
|
||||||
|
vipExpireAt?: Date
|
||||||
|
|
||||||
|
@Prop({ default: 'user' })
|
||||||
|
role: string // 'user' | 'admin'
|
||||||
|
|
||||||
|
@Prop({ default: false })
|
||||||
|
isSystemAdmin: boolean
|
||||||
|
|
||||||
|
@Prop({ unique: true, sparse: true })
|
||||||
|
email?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UserSchema = SchemaFactory.createForClass(User)
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import { Injectable, HttpException, HttpStatus } from '@nestjs/common'
|
||||||
|
import { InjectModel } from '@nestjs/mongoose'
|
||||||
|
import { Model } from 'mongoose'
|
||||||
|
import { JwtService } from '@nestjs/jwt'
|
||||||
|
import { User, UserDocument } from './user.schema'
|
||||||
|
import { EmailService } from '../email/email.service'
|
||||||
|
|
||||||
|
// In-memory stores
|
||||||
|
const codeStore = new Map<string, { code: string; expiresAt: number }>()
|
||||||
|
const emailCodeStore = new Map<string, { code: string; expiresAt: number }>()
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class UserService {
|
||||||
|
constructor(
|
||||||
|
@InjectModel(User.name) private userModel: Model<UserDocument>,
|
||||||
|
private jwtService: JwtService,
|
||||||
|
private emailService: EmailService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async sendCode(phone: string) {
|
||||||
|
const code = process.env.NODE_ENV === 'production'
|
||||||
|
? String(Math.floor(100000 + Math.random() * 900000))
|
||||||
|
: '123456'
|
||||||
|
|
||||||
|
codeStore.set(phone, { code, expiresAt: Date.now() + 5 * 60 * 1000 })
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
|
console.log(`[DEV] Verification code for ${phone}: ${code}`)
|
||||||
|
}
|
||||||
|
return { message: '验证码已发送' }
|
||||||
|
}
|
||||||
|
|
||||||
|
async loginByPhone(phone: string, code: string) {
|
||||||
|
const record = codeStore.get(phone)
|
||||||
|
if (!record || record.code !== code) {
|
||||||
|
throw new HttpException('验证码错误', HttpStatus.UNAUTHORIZED)
|
||||||
|
}
|
||||||
|
if (Date.now() > record.expiresAt) {
|
||||||
|
codeStore.delete(phone)
|
||||||
|
throw new HttpException('验证码已过期', HttpStatus.UNAUTHORIZED)
|
||||||
|
}
|
||||||
|
codeStore.delete(phone)
|
||||||
|
|
||||||
|
let user = await this.userModel.findOne({ phone }).exec()
|
||||||
|
if (!user) {
|
||||||
|
user = await this.userModel.create({ phone, nickname: `用户${phone.slice(-4)}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.generateAuthResponse(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
async loginByWx(code: string) {
|
||||||
|
// WeChat silent login - exchange code for openid
|
||||||
|
const appid = process.env.WX_APPID
|
||||||
|
const secret = process.env.WX_SECRET
|
||||||
|
if (!appid || !secret) {
|
||||||
|
throw new HttpException('微信登录未配置', HttpStatus.SERVICE_UNAVAILABLE)
|
||||||
|
}
|
||||||
|
|
||||||
|
const wxRes = await fetch(
|
||||||
|
`https://api.weixin.qq.com/sns/jscode2session?appid=${appid}&secret=${secret}&js_code=${code}&grant_type=authorization_code`,
|
||||||
|
)
|
||||||
|
const wxData: any = await wxRes.json()
|
||||||
|
|
||||||
|
if (wxData.errcode) {
|
||||||
|
throw new HttpException(`微信登录失败: ${wxData.errmsg}`, HttpStatus.UNAUTHORIZED)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openid = wxData.openid
|
||||||
|
let user = await this.userModel.findOne({ wxOpenid: openid }).exec()
|
||||||
|
if (!user) {
|
||||||
|
user = await this.userModel.create({ wxOpenid: openid, nickname: '微信用户' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.generateAuthResponse(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 📧 邮箱验证码
|
||||||
|
async sendEmailCode(email: string) {
|
||||||
|
if (!email || !email.includes('@')) {
|
||||||
|
throw new HttpException('请输入正确的邮箱地址', HttpStatus.BAD_REQUEST)
|
||||||
|
}
|
||||||
|
const code = String(Math.floor(100000 + Math.random() * 900000))
|
||||||
|
emailCodeStore.set(email, { code, expiresAt: Date.now() + 10 * 60 * 1000 })
|
||||||
|
const sent = await this.emailService.sendVerificationCode(email, code)
|
||||||
|
if (sent) {
|
||||||
|
return { message: '验证码已发送到邮箱' }
|
||||||
|
}
|
||||||
|
// 邮件发送失败时返回 devCode 方便调试
|
||||||
|
console.log(`[EMAIL] Dev code for ${email}: ${code}`)
|
||||||
|
return { message: '验证码已发送(邮件服务暂不可用,开发模式)', devCode: code }
|
||||||
|
}
|
||||||
|
|
||||||
|
async loginByEmail(email: string, code: string) {
|
||||||
|
const record = emailCodeStore.get(email)
|
||||||
|
if (!record || record.code !== code) {
|
||||||
|
throw new HttpException('验证码错误', HttpStatus.UNAUTHORIZED)
|
||||||
|
}
|
||||||
|
if (Date.now() > record.expiresAt) {
|
||||||
|
emailCodeStore.delete(email)
|
||||||
|
throw new HttpException('验证码已过期', HttpStatus.UNAUTHORIZED)
|
||||||
|
}
|
||||||
|
emailCodeStore.delete(email)
|
||||||
|
|
||||||
|
// 按邮箱查找或创建用户
|
||||||
|
let user = await this.userModel.findOne({ email }).exec()
|
||||||
|
if (!user) {
|
||||||
|
const nick = email.split('@')[0]
|
||||||
|
user = await this.userModel.create({ email, nickname: nick, remaining: 3 })
|
||||||
|
}
|
||||||
|
return this.generateAuthResponse(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getInfo(userId: string) {
|
||||||
|
const user = await this.userModel.findById(userId).exec()
|
||||||
|
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
|
||||||
|
return this.safeUser(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(userId: string, data: { nickname?: string; avatar?: string }) {
|
||||||
|
const user = await this.userModel.findByIdAndUpdate(userId, data, { new: true }).exec()
|
||||||
|
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
|
||||||
|
return this.safeUser(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
getModel() { return this.userModel }
|
||||||
|
|
||||||
|
async getUsage(userId: string) {
|
||||||
|
const user = await this.userModel.findById(userId).exec()
|
||||||
|
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
|
||||||
|
return { remaining: user.remaining, plan: user.plan, interviewCount: user.interviewCount }
|
||||||
|
}
|
||||||
|
|
||||||
|
async deductRemaining(userId: string) {
|
||||||
|
const user = await this.userModel.findById(userId).exec()
|
||||||
|
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
|
||||||
|
if (user.remaining <= 0) throw new HttpException('使用次数已用完', HttpStatus.FORBIDDEN)
|
||||||
|
user.remaining -= 1
|
||||||
|
user.interviewCount += 1
|
||||||
|
await user.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateAuthResponse(user: UserDocument) {
|
||||||
|
const payload = { userId: user._id.toString(), phone: user.phone || '' }
|
||||||
|
return {
|
||||||
|
token: this.jwtService.sign(payload),
|
||||||
|
user: this.safeUser(user),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private safeUser(user: UserDocument) {
|
||||||
|
return {
|
||||||
|
id: user._id.toString(),
|
||||||
|
phone: user.phone || '',
|
||||||
|
email: user.email || '',
|
||||||
|
nickname: user.nickname || '',
|
||||||
|
avatar: user.avatar || '',
|
||||||
|
plan: user.plan,
|
||||||
|
role: user.role || 'user',
|
||||||
|
isSystemAdmin: user.isSystemAdmin || false,
|
||||||
|
remaining: user.remaining,
|
||||||
|
interviewCount: user.interviewCount,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "commonjs",
|
||||||
|
"declaration": true,
|
||||||
|
"removeComments": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"target": "ES2021",
|
||||||
|
"sourceMap": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"baseUrl": "./",
|
||||||
|
"incremental": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strictNullChecks": true,
|
||||||
|
"noImplicitAny": false,
|
||||||
|
"strictBindCallApply": false,
|
||||||
|
"forceConsistentCasingInFileNames": false,
|
||||||
|
"noFallthroughCasesInSwitch": false,
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,426 @@
|
|||||||
|
# 职引 — 技术架构文档 v3.0
|
||||||
|
|
||||||
|
> 版本: v3.0
|
||||||
|
> 日期: 2026-06-01
|
||||||
|
> 状态: 重新定位后
|
||||||
|
> 定位: 应届生/实习生 AI 面试教练
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、技术栈
|
||||||
|
|
||||||
|
### 1.1 前端
|
||||||
|
|
||||||
|
| 技术 | 版本 | 用途 |
|
||||||
|
|------|------|------|
|
||||||
|
| uni-app | 3.x | 跨端框架(微信小程序 + H5) |
|
||||||
|
| Vue | 3.4 | 框架 |
|
||||||
|
| TypeScript | 5.x | 类型安全 |
|
||||||
|
| Vite | 5.x | 构建工具 |
|
||||||
|
| Pinia | 2.x | 状态管理(用户状态、面试会话) |
|
||||||
|
| SCSS | - | 样式预处理 |
|
||||||
|
|
||||||
|
### 1.2 后端
|
||||||
|
|
||||||
|
| 技术 | 版本 | 用途 |
|
||||||
|
|------|------|------|
|
||||||
|
| Node.js | 18+ | 运行环境 |
|
||||||
|
| NestJS | 10.x | 框架(模块化,适合快速迭代) |
|
||||||
|
| MongoDB + Mongoose | 7.x | 数据库(灵活 schema,适合快速迭代) |
|
||||||
|
| MongoDB Atlas | - | 免费层起步,0 成本启动 |
|
||||||
|
| JWT | - | 用户认证(微信登录对接后使用) |
|
||||||
|
| class-validator | - | 参数校验 |
|
||||||
|
| 微信支付 | - | 会员付费(Phase 1.5 接入) |
|
||||||
|
|
||||||
|
### 1.3 AI 能力
|
||||||
|
|
||||||
|
| 技术 | 用途 | 优先级 |
|
||||||
|
|------|------|--------|
|
||||||
|
| opencode-go (deepseek-v4-flash) | 面试模拟、简历诊断、面试报告 | 主用 |
|
||||||
|
| NVIDIA (stepfun-ai/step-3.5-flash) | 主用不可用时自动切换 | 备用 |
|
||||||
|
|
||||||
|
### 1.4 部署
|
||||||
|
|
||||||
|
| 技术 | 用途 |
|
||||||
|
|------|------|
|
||||||
|
| 腾讯云轻量应用服务器 | 后端部署(个人开发者友好,已 ICP 备案) |
|
||||||
|
| 微信小程序云开发(可选) | 静态资源 + 云函数(快速原型) |
|
||||||
|
| Nginx | 反向代理(HTTPS 终止) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、项目结构(简化版,MVP 优先)
|
||||||
|
|
||||||
|
```
|
||||||
|
zhiyin/
|
||||||
|
├── zhiyin-app/ # 小程序前端
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── pages/ # 页面
|
||||||
|
│ │ │ ├── index/ # 首页(岗位选择 + 面试入口)
|
||||||
|
│ │ │ ├── interview/ # 面试模拟(核心页面)
|
||||||
|
│ │ │ ├── report/ # 面试报告
|
||||||
|
│ │ │ ├── history/ # 历史面试记录
|
||||||
|
│ │ │ ├── resume/ # 简历诊断(Phase 1.5)
|
||||||
|
│ │ │ ├── internship/ # 实习搜索聚合(MVP 跳转模式)
|
||||||
|
│ │ │ ├── user/ # 个人中心
|
||||||
|
│ │ │ └── member/ # 会员中心(Phase 1.5)
|
||||||
|
│ │ ├── components/ # 组件
|
||||||
|
│ │ ├── services/ # API 服务
|
||||||
|
│ │ ├── stores/ # Pinia 状态
|
||||||
|
│ │ ├── styles/ # 全局样式
|
||||||
|
│ │ ├── utils/ # 工具函数
|
||||||
|
│ │ ├── App.vue
|
||||||
|
│ │ └── main.ts
|
||||||
|
│ ├── package.json
|
||||||
|
│ ├── vite.config.js
|
||||||
|
│ └── manifest.json # 小程序配置(AI 深度合成类目已通过)
|
||||||
|
│
|
||||||
|
├── backend/ # 后端服务
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── common/ # 公共模块
|
||||||
|
│ │ │ ├── filters/ # 异常过滤器
|
||||||
|
│ │ │ ├── guards/ # 权限守卫(JWT)
|
||||||
|
│ │ │ ├── interceptors/ # 拦截器(日志、响应格式)
|
||||||
|
│ │ │ └── utils/ # 工具函数
|
||||||
|
│ │ ├── config/ # 配置文件(数据库、AI API、微信)
|
||||||
|
│ │ ├── dto/ # 数据传输对象
|
||||||
|
│ │ ├── modules/ # 业务模块(MVP 只做 3 个核心模块)
|
||||||
|
│ │ │ ├── interview/ # 面试模块(核心,P0)
|
||||||
|
│ │ │ ├── user/ # 用户模块(微信登录,P0)
|
||||||
|
│ │ │ ├── resume/ # 简历模块(Phase 1.5,P1)
|
||||||
|
│ │ │ ├── member/ # 会员模块(Phase 1.5,P1)
|
||||||
|
│ │ │ ├── internship/ # 实习搜索模块(MVP 跳转,P1)
|
||||||
|
│ │ │ └── knowledge/ # 知识图谱模块(Phase 2,P2)
|
||||||
|
│ │ ├── ai/ # AI 能力封装(opencode-go + NVIDIA 切换)
|
||||||
|
│ │ ├── wechat/ # 微信相关(登录、支付、订阅消息)
|
||||||
|
│ │ ├── app.module.ts
|
||||||
|
│ │ └── main.ts
|
||||||
|
│ ├── package.json
|
||||||
|
│ └── tsconfig.json
|
||||||
|
│
|
||||||
|
└── docs/ # 项目文档
|
||||||
|
├── PRODUCT-PLAN.md # 产品规划(✅ 已更新 v3.0)
|
||||||
|
├── ARCHITECTURE.md # 架构文档(✅ 当前文件)
|
||||||
|
├── FEATURE-LIST.md # 功能清单(待更新)
|
||||||
|
├── ROADMAP.md # 路线图(待更新)
|
||||||
|
└── PROJECT-STATUS.md # 进度文档(待更新)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、核心数据模型(简化,快速迭代)
|
||||||
|
|
||||||
|
### 3.1 用户(User)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
_id: ObjectId,
|
||||||
|
openid: String, // 微信 openid(唯一标识)
|
||||||
|
nickname: String, // 微信昵称
|
||||||
|
avatar: String, // 微信头像
|
||||||
|
targetPosition: String, // 目标岗位(校招热门岗位)
|
||||||
|
targetIndustry: String, // 目标行业
|
||||||
|
memberStatus: { // 会员状态
|
||||||
|
type: String, // free/vip
|
||||||
|
expireAt: Date,
|
||||||
|
},
|
||||||
|
usage: { // 使用统计
|
||||||
|
dailyInterviewCount: Number, // 每日面试次数
|
||||||
|
totalInterviewCount: Number, // 总面试次数
|
||||||
|
lastResetDate: Date,
|
||||||
|
},
|
||||||
|
createdAt: Date,
|
||||||
|
updatedAt: Date
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 面试会话(Interview)—— 核心模型
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
_id: ObjectId,
|
||||||
|
userId: ObjectId,
|
||||||
|
position: String, // 面试岗位(如"前端工程师")
|
||||||
|
status: String, // pending/active/completed
|
||||||
|
rounds: [{ // 问答轮次
|
||||||
|
question: String, // AI 问题
|
||||||
|
answer: String, // 用户回答
|
||||||
|
feedback: { // AI 反馈
|
||||||
|
score: Number, // 本题得分(0-100)
|
||||||
|
comment: String, // 评语
|
||||||
|
suggestion: String, // 改进建议
|
||||||
|
referenceAnswer: String, // 参考回答思路
|
||||||
|
},
|
||||||
|
createdAt: Date,
|
||||||
|
}],
|
||||||
|
summary: { // 面试总结
|
||||||
|
totalScore: Number, // 总分(0-100)
|
||||||
|
strengths: [String], // 优势
|
||||||
|
weaknesses: [String], // 不足
|
||||||
|
suggestions: [String], // 综合建议
|
||||||
|
dimensionScores: { // 各维度得分
|
||||||
|
logic: Number, // 逻辑思维
|
||||||
|
expression: Number, // 表达能力
|
||||||
|
professionalism: Number, // 专业度
|
||||||
|
stability: Number, // 情绪稳定性
|
||||||
|
},
|
||||||
|
},
|
||||||
|
createdAt: Date,
|
||||||
|
completedAt: Date
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 简历(Resume)—— Phase 1.5
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
_id: ObjectId,
|
||||||
|
userId: ObjectId,
|
||||||
|
title: String, // 简历标题
|
||||||
|
originalContent: String, // 原始内容
|
||||||
|
targetPosition: String, // 目标岗位
|
||||||
|
createdAt: Date,
|
||||||
|
updatedAt: Date
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4 岗位题库(QuestionBank)—— Phase 2 知识图谱前置
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
_id: ObjectId,
|
||||||
|
position: String, // 岗位名称(如"前端工程师")
|
||||||
|
category: String, // 分类(技术/职能/AI专项)
|
||||||
|
difficulty: String, // 难度(junior/mid/senior)
|
||||||
|
questions: [{
|
||||||
|
content: String, // 问题内容
|
||||||
|
type: String, // 类型(basic/algorithm/project/behavioral)
|
||||||
|
referenceAnswer: String, // 参考回答
|
||||||
|
tags: [String], // 标签(如"闭包"、"贪心算法")
|
||||||
|
}],
|
||||||
|
createdAt: Date,
|
||||||
|
updatedAt: Date
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、API 接口设计(MVP 核心接口)
|
||||||
|
|
||||||
|
### 4.1 用户模块(微信登录)
|
||||||
|
|
||||||
|
| 接口 | 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| 微信登录 | POST | /api/user/wx-login | 微信授权登录,返回 token |
|
||||||
|
| 获取用户信息 | GET | /api/user/info | 获取用户信息 |
|
||||||
|
| 更新用户信息 | PUT | /api/user/update | 更新目标岗位/行业 |
|
||||||
|
|
||||||
|
### 4.2 面试模块(核心)
|
||||||
|
|
||||||
|
| 接口 | 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| 创建面试 | POST | /api/interview/create | 创建面试会话(选岗位) |
|
||||||
|
| 获取面试 | GET | /api/interview/:id | 获取面试详情 |
|
||||||
|
| 面试列表 | GET | /api/interview/list | 获取用户面试列表 |
|
||||||
|
| 提交回答 | POST | /api/interview/:id/answer | 提交本轮回答 |
|
||||||
|
| 获取反馈 | GET | /api/interview/:id/feedback | 获取本轮反馈 |
|
||||||
|
| 结束面试 | POST | /api/interview/:id/complete | 结束面试生成报告 |
|
||||||
|
|
||||||
|
### 4.3 实习搜索模块(MVP 跳转模式)
|
||||||
|
|
||||||
|
| 接口 | 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| 实习搜索 | GET | /api/internship/search | 搜索实习岗位(聚合入口) |
|
||||||
|
| 热门实习 | GET | /api/internship/hot | 热门实习岗位列表 |
|
||||||
|
|
||||||
|
> MVP 阶段:不存数据,只做搜索聚合入口(跳转模式),低成本验证用户需求。
|
||||||
|
|
||||||
|
### 4.4 简历模块(Phase 1.5)
|
||||||
|
|
||||||
|
| 接口 | 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| 创建简历 | POST | /api/resume/create | 创建简历记录 |
|
||||||
|
| 诊断简历 | POST | /api/resume/diagnosis | AI 诊断简历问题 |
|
||||||
|
|
||||||
|
### 4.5 会员模块(Phase 1.5)
|
||||||
|
|
||||||
|
| 接口 | 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| 会员订阅 | POST | /api/member/subscribe | 微信支付订阅 |
|
||||||
|
| 获取会员状态 | GET | /api/member/status | 获取会员信息 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、AI 面试模拟核心流程
|
||||||
|
|
||||||
|
```
|
||||||
|
用户选择岗位
|
||||||
|
↓
|
||||||
|
创建面试会话(/api/interview/create)
|
||||||
|
↓
|
||||||
|
AI 生成第一题(基于岗位 + 用户简历)
|
||||||
|
↓
|
||||||
|
用户语音/文字回答
|
||||||
|
↓
|
||||||
|
提交回答(/api/interview/:id/answer)
|
||||||
|
↓
|
||||||
|
AI 实时反馈(评分 + 评语 + 建议 + 参考回答)
|
||||||
|
↓
|
||||||
|
AI 追问(基于回答内容)
|
||||||
|
↓
|
||||||
|
循环(通常 5-8 轮)
|
||||||
|
↓
|
||||||
|
用户结束面试
|
||||||
|
↓
|
||||||
|
生成面试报告(/api/interview/:id/complete)
|
||||||
|
↓
|
||||||
|
展示报告(总分 + 各维度得分 + 优劣势 + 建议)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.1 AI Prompt 设计要点
|
||||||
|
|
||||||
|
**系统提示词(System Prompt)**:
|
||||||
|
```
|
||||||
|
你是一位专业的校招面试官,正在面试一位应聘{position}岗位的应届毕业生。
|
||||||
|
请根据以下要求进行了面试:
|
||||||
|
1. 问题要符合校招难度(不要求工作经验)
|
||||||
|
2. 结合应聘岗位提出专业问题
|
||||||
|
3. 根据考生回答进行针对性追问
|
||||||
|
4. 每次回答后给出评分(0-100)和具体改进建议
|
||||||
|
5. 面试共 5-8 轮,涵盖基础、项目、算法(如适用)、行为问题
|
||||||
|
```
|
||||||
|
|
||||||
|
**反馈提示词(Feedback Prompt)**:
|
||||||
|
```
|
||||||
|
请对以下面试回答进行评分和反馈:
|
||||||
|
问题:{question}
|
||||||
|
回答:{answer}
|
||||||
|
岗位:{position}
|
||||||
|
|
||||||
|
输出格式(JSON):
|
||||||
|
{
|
||||||
|
"score": 85,
|
||||||
|
"comment": "回答逻辑清晰,但缺少具体案例...",
|
||||||
|
"suggestion": "建议结合项目经历,用STAR法则组织回答",
|
||||||
|
"referenceAnswer": "参考回答思路:..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、部署架构
|
||||||
|
|
||||||
|
```
|
||||||
|
微信小程序 (用户端)
|
||||||
|
↓ HTTPS
|
||||||
|
Nginx 反向代理(腾讯云轻量应用服务器)
|
||||||
|
↓
|
||||||
|
backend (NestJS :3000)
|
||||||
|
↓
|
||||||
|
MongoDB Atlas (数据库)
|
||||||
|
↓
|
||||||
|
AI API (opencode-go → NVIDIA 主备切换)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.1 合规优势
|
||||||
|
|
||||||
|
- ✅ **ICP 备案已完成**(合规运营基础)
|
||||||
|
- ✅ **AI 深度合成类目已通过**(可立即上线,竞品需 3-6 个月)
|
||||||
|
- ✅ 微信小程序已配置(manifest.json)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、关键技术决策
|
||||||
|
|
||||||
|
### 7.1 为什么继续用 NestJS + MongoDB?
|
||||||
|
|
||||||
|
- 已有代码基础,重写浪费时间
|
||||||
|
- NestJS 模块化适合快速迭代(MVP → Phase 1.5 → Phase 2)
|
||||||
|
- MongoDB 灵活的数据模型适合快速改 schema(早期频繁迭代)
|
||||||
|
- 个人开发者能 hold 住(社区资源丰富)
|
||||||
|
|
||||||
|
### 7.2 为什么 MVP 先做 3 个核心模块?
|
||||||
|
|
||||||
|
- **面试模块**(核心差异,必须做好)
|
||||||
|
- **用户模块**(微信登录,0 门槛)
|
||||||
|
- **实习搜索**(跳转模式,低成本验证需求)
|
||||||
|
|
||||||
|
其他模块(简历、会员、知识图谱)MVP 后按优先级加入。
|
||||||
|
|
||||||
|
### 7.3 为什么实习搜索用跳转模式?
|
||||||
|
|
||||||
|
- 不存数据 = 0 合规风险
|
||||||
|
- 不爬数据 = 0 维护成本
|
||||||
|
- 只做聚合搜索入口 = 低成本验证用户需求
|
||||||
|
- 如果需求验证成功,Phase 1.5 再做"精选实习岗位"(人工整理)
|
||||||
|
|
||||||
|
### 7.4 AI 模型选型
|
||||||
|
|
||||||
|
- **主用**:opencode-go (deepseek-v4-flash) — 性能优先,成本低
|
||||||
|
- **备用**:NVIDIA (stepfun-ai/step-3.5-flash) — 主用不可用时自动降级
|
||||||
|
- **合规**:已备案,可合法提供 AI 服务
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、MVP 开发优先级
|
||||||
|
|
||||||
|
### P0(Week 1-2,必须完成)
|
||||||
|
|
||||||
|
- [ ] 微信登录对接(/api/user/wx-login)
|
||||||
|
- [ ] 岗位选择页面(校招热门 20+ 岗位,含 AI 岗位)
|
||||||
|
- [ ] AI 面试模拟核心逻辑(多轮对话)
|
||||||
|
- [ ] 每轮反馈评分接口(/api/interview/:id/feedback)
|
||||||
|
- [ ] 面试报告生成(/api/interview/:id/complete)
|
||||||
|
- [ ] 历史面试记录查看(/api/interview/list)
|
||||||
|
|
||||||
|
### P1(Week 3-4,PMF 验证后)
|
||||||
|
|
||||||
|
- [ ] 简历诊断功能(复用已有代码)
|
||||||
|
- [ ] 会员系统开发(¥9.9/月)
|
||||||
|
- [ ] 微信支付对接
|
||||||
|
- [ ] 实习搜索聚合入口(跳转模式)
|
||||||
|
|
||||||
|
### P2(Week 5-8,增强功能)
|
||||||
|
|
||||||
|
- [ ] AI 岗位专属题库(算法/大模型)
|
||||||
|
- [ ] 技能缺口分析
|
||||||
|
- [ ] 知识图谱(校招岗位-技能映射)
|
||||||
|
- [ ] 精选实习岗位(人工整理)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、性能与扩展性考虑
|
||||||
|
|
||||||
|
### 9.1 性能优化
|
||||||
|
|
||||||
|
- AI 调用异步化(避免阻塞用户操作)
|
||||||
|
- 面试报告生成用队列(防止并发超时)
|
||||||
|
- MongoDB 索引优化(userId, position, createdAt)
|
||||||
|
|
||||||
|
### 9.2 扩展性考虑
|
||||||
|
|
||||||
|
- 模块化设计(NestJS 天然支持)
|
||||||
|
- AI 模型可插拔(opencode-go / NVIDIA / 未来接入其他模型)
|
||||||
|
- 数据库分片预留(用户量大的时候)
|
||||||
|
- 微信支付/订阅消息可替换(如果未来做独立 App)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十、安全风险与应对
|
||||||
|
|
||||||
|
| 风险 | 影响 | 应对 |
|
||||||
|
|------|------|------|
|
||||||
|
| AI 内容合规 | 微信下架 | 严格遵守微信 AI 内容规范;敏感词过滤;人工审核机制 |
|
||||||
|
| 用户数据泄露 | 信任危机 | HTTPS 全站;数据库访问权限控制;不存敏感信息 |
|
||||||
|
| 微信登录伪造 | 账户被盗 | 签名验证;openid 绑定校验 |
|
||||||
|
| AI API 费用失控 | 成本飙升 | 限制每日调用次数;监控告警;备用模型自动切换 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 变更记录
|
||||||
|
|
||||||
|
| 日期 | 变更内容 | 操作人 |
|
||||||
|
|------|----------|--------|
|
||||||
|
| 2026-05-14 | 职引项目启动,架构文档初版 | AI |
|
||||||
|
| 2026-05-15 | 方向调整:简历工具 → AI 职业发展平台 | AI |
|
||||||
|
| 2026-06-01 | **重新架构**:专注校招,简化 MVP,价格 ¥9.9/月,合规优势 | AI |
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
# 职引 - 部署文档
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
1. [环境要求](#环境要求)
|
||||||
|
2. [后端部署](#后端部署)
|
||||||
|
3. [前端部署(H5)](#前端部署h5)
|
||||||
|
4. [微信小程序部署](#微信小程序部署)
|
||||||
|
5. [数据库初始化](#数据库初始化)
|
||||||
|
6. [监控和日志](#监控和日志)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 环境要求
|
||||||
|
|
||||||
|
### 生产服务器
|
||||||
|
- **操作系统**: Linux (Ubuntu 20.04+ / CentOS 7+)
|
||||||
|
- **Node.js**: 18.x 或更高版本
|
||||||
|
- **MongoDB**: 4.4+ (或 MongoDB Atlas 云服务)
|
||||||
|
- **Nginx**: 1.18+ (反向代理)
|
||||||
|
- **PM2**: 进程管理
|
||||||
|
|
||||||
|
### 本地开发
|
||||||
|
- **Node.js**: 18.x
|
||||||
|
- **MongoDB**: 本地或云服务
|
||||||
|
- **HBuilderX**: uni-app 开发
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 后端部署
|
||||||
|
|
||||||
|
### 1. 安装依赖
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
npm install --production
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 配置环境变量
|
||||||
|
复制 `.env.example` 为 `.env.production` 并修改:
|
||||||
|
```bash
|
||||||
|
cp .env.example .env.production
|
||||||
|
vim .env.production # 修改生产配置
|
||||||
|
```
|
||||||
|
|
||||||
|
**关键配置项**:
|
||||||
|
- `MONGODB_URI`: 生产数据库地址
|
||||||
|
- `JWT_SECRET`: 强密钥(至少32字符)
|
||||||
|
- `PORT`: 生产端口(推荐 3000)
|
||||||
|
- `WECHAT_APPID` / `WECHAT_SECRET`: 小程序生产凭证
|
||||||
|
|
||||||
|
### 3. 编译 TypeScript
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 使用 PM2 启动
|
||||||
|
```bash
|
||||||
|
# 安装 PM2
|
||||||
|
npm install -g pm2
|
||||||
|
|
||||||
|
# 启动应用
|
||||||
|
pm2 start dist/main.js --name zhiyin-backend
|
||||||
|
|
||||||
|
# 设置开机自启
|
||||||
|
pm2 startup
|
||||||
|
pm2 save
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 配置 Nginx 反向代理
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name api.yourdomain.com;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://localhost:3000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 前端部署(H5)
|
||||||
|
|
||||||
|
### 1. 修改 API 地址
|
||||||
|
编辑 `zhiyin-app/config/api.js`:
|
||||||
|
```javascript
|
||||||
|
export const BASE_URL = 'https://api.yourdomain.com/api'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 编译 H5
|
||||||
|
```bash
|
||||||
|
cd zhiyin-app
|
||||||
|
npm run build:h5
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 部署到 Web 服务器
|
||||||
|
将 `dist/build/h5/` 目录上传到服务器:
|
||||||
|
```bash
|
||||||
|
# 使用 scp 上传
|
||||||
|
scp -r dist/build/h5/* user@your-server:/var/www/zhiyin/
|
||||||
|
|
||||||
|
# 或使用 FTP/SSH 上传
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 配置 Nginx
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name yourdomain.com;
|
||||||
|
|
||||||
|
root /var/www/zhiyin;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 静态资源缓存
|
||||||
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 微信小程序部署
|
||||||
|
|
||||||
|
### 1. 修改配置
|
||||||
|
编辑 `zhiyin-app/manifest.json`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mp-weixin": {
|
||||||
|
"appid": "your-production-appid"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 编译小程序
|
||||||
|
```bash
|
||||||
|
cd zhiyin-app
|
||||||
|
npm run build:mp-weixin
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 使用微信开发者工具上传
|
||||||
|
1. 打开微信开发者工具
|
||||||
|
2. 导入 `dist/build/mp-weixin/` 目录
|
||||||
|
3. 点击「上传」按钮
|
||||||
|
4. 填写版本号和项目备注
|
||||||
|
5. 登录微信公众平台提交审核
|
||||||
|
|
||||||
|
### 4. 提交审核前检查
|
||||||
|
- ✅ 已配置合法域名(在微信公众平台)
|
||||||
|
- ✅ 已通过 AI 深度合成类目审批
|
||||||
|
- ✅ 已配置隐私协议和用户协议
|
||||||
|
- ✅ 已测试所有核心功能
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 数据库初始化
|
||||||
|
|
||||||
|
### 1. 创建数据库和用户
|
||||||
|
```bash
|
||||||
|
mongo
|
||||||
|
> use zhiyin
|
||||||
|
> db.createUser({
|
||||||
|
user: "zhiyin",
|
||||||
|
pwd: "strong-password",
|
||||||
|
roles: [{ role: "readWrite", db: "zhiyin" }]
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 创建索引
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
node scripts/create-indexes.js
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 初始化管理员账号(可选)
|
||||||
|
```bash
|
||||||
|
node scripts/create-admin.js
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 监控和日志
|
||||||
|
|
||||||
|
### 1. 日志配置
|
||||||
|
使用 PM2 查看日志:
|
||||||
|
```bash
|
||||||
|
pm2 logs zhiyin-backend
|
||||||
|
pm2 logs zhiyin-backend --lines 100
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 错误监控
|
||||||
|
推荐集成:
|
||||||
|
- **Sentry**: 错误追踪
|
||||||
|
- **PM2 Plus**: 进程监控
|
||||||
|
- **MongoDB Atlas**: 数据库监控
|
||||||
|
|
||||||
|
### 3. 性能监控
|
||||||
|
```bash
|
||||||
|
# 查看进程状态
|
||||||
|
pm2 monit
|
||||||
|
|
||||||
|
# 查看资源占用
|
||||||
|
pm2 list
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### Q1: MongoDB 连接失败
|
||||||
|
**A**: 检查 `.env.production` 中的 `MONGODB_URI` 是否正确,确保数据库可访问。
|
||||||
|
|
||||||
|
### Q2: 微信小程序请求失败
|
||||||
|
**A**: 确保在微信公众平台配置了合法域名(必须是 HTTPS)。
|
||||||
|
|
||||||
|
### Q3: AI 调用失败
|
||||||
|
**A**: 检查 `AI_PRIMARY_KEY` 和 `AI_BACKUP_KEY` 是否正确,确保 API 额度充足。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 安全检查清单
|
||||||
|
|
||||||
|
- [ ] 已修改默认 JWT_SECRET
|
||||||
|
- [ ] 已禁用开发模式的固定验证码
|
||||||
|
- [ ] 已配置 CORS 白名单
|
||||||
|
- [ ] 已启用 HTTPS
|
||||||
|
- [ ] 已配置 MongoDB 访问权限
|
||||||
|
- [ ] 已移除控制台日志(生产环境)
|
||||||
|
- [ ] 已配置速率限制(防止滥用)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 回滚方案
|
||||||
|
|
||||||
|
如果需要回滚到上一个版本:
|
||||||
|
```bash
|
||||||
|
# 1. 查看 PM2 历史
|
||||||
|
pm2 logs zhiyin-backend --lines 1000
|
||||||
|
|
||||||
|
# 2. 重启到上一个版本
|
||||||
|
pm2 restart zhiyin-backend --version <previous-version>
|
||||||
|
|
||||||
|
# 3. 数据库回滚(如有迁移)
|
||||||
|
mongorestore --uri="<connection-string>" --drop backup/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**最后更新**: 2026-06-02
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
# 职引 · 完整功能清单 v4.0
|
||||||
|
|
||||||
|
> 版本: v4.0
|
||||||
|
> 日期: 2026-06-05
|
||||||
|
> 状态: 战略升级(竞争壁垒 + 盈利模型重构)
|
||||||
|
> 定位: 应届生/实习生 AI 面试教练
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、核心功能(AI 面试 + 数据飞轮)
|
||||||
|
|
||||||
|
### 1.1 AI 面试模拟(核心差异化)
|
||||||
|
|
||||||
|
| 功能 | 状态 | 描述 | 优先级 |
|
||||||
|
|------|------|------|--------|
|
||||||
|
| 岗位选择 | ✅ 完成 | 校招热门 20+ 岗位(含 AI 算法/大模型岗位) | P0 |
|
||||||
|
| 多轮对话追问 | ✅ 完成 | AI 模拟真实面试官,根据回答连续追问 | P0 |
|
||||||
|
| 实时反馈评分 | ✅ 完成 | 每轮回答后给出评分 + 改进建议 | P0 |
|
||||||
|
| 面试报告 | ✅ 完成 | 完整面试表现分析报告(逻辑/表达/专业度/稳定性) | P0 |
|
||||||
|
| 历史面试记录 | ✅ 完成 | 查看历史面试与进步轨迹 | P0 |
|
||||||
|
| 参考答案生成 | 🔨 开发中 | 每个问题给出参考回答思路 | P1 |
|
||||||
|
| 公司真题库 | 🆕 新增 | 按公司+岗位定制面试剧本(首期 5 家公司) | P0 |
|
||||||
|
| AI 岗位专项题库 | 🔨 开发中 | AI 算法/大模型岗位专属面试题库 | P1 |
|
||||||
|
|
||||||
|
### 1.2 数据飞轮(核心壁垒)
|
||||||
|
|
||||||
|
| 功能 | 状态 | 描述 | 优先级 |
|
||||||
|
|------|------|------|--------|
|
||||||
|
| 面经贡献系统 | 🆕 新增 | 面试后可填写公司/岗位信息,贡献面试经验 | **P0** |
|
||||||
|
| 公司-岗位-题库映射 | 🆕 新增 | 四维数据映射,精准出题 | **P0** |
|
||||||
|
| 脱敏存储 | 🆕 新增 | 用户回答数据脱敏后存入题库 | **P0** |
|
||||||
|
| 题库自动扩充 | 🆕 新增 | 基于用户贡献自动生成新题目 | P1 |
|
||||||
|
|
||||||
|
### 1.3 留存入围(留存壁垒)
|
||||||
|
|
||||||
|
| 功能 | 状态 | 描述 | 优先级 |
|
||||||
|
|------|------|------|--------|
|
||||||
|
| 进步轨迹雷达图 | 🆕 新增 | 四维能力(逻辑/表达/专业度/稳定性)可视化 | **P0** |
|
||||||
|
| 历史对比分析 | 🆕 新增 | "你比上次在表达力上提升了 15%" | **P0** |
|
||||||
|
| 连续打卡激励 | 🆕 新增 | 连续 7 天面试 → 解锁高级报告 | P1 |
|
||||||
|
| 每日一题 Push | 🆕 新增 | 微信订阅消息推送,每日一个面试题 + 参考思路 | **P0** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、用户端功能
|
||||||
|
|
||||||
|
### 2.1 用户认证
|
||||||
|
| 功能 | 状态 | 描述 | 优先级 |
|
||||||
|
|------|------|------|--------|
|
||||||
|
| 微信一键登录 | 🔨 开发中 | 微信授权登录,零门槛 | P0 |
|
||||||
|
| JWT 认证 | ✅ 完成 | Token 鉴权 | P0 |
|
||||||
|
| 个人信息设置 | 🔨 开发中 | 目标岗位、求职偏好 | P1 |
|
||||||
|
|
||||||
|
### 2.2 个人中心
|
||||||
|
| 功能 | 状态 | 描述 | 优先级 |
|
||||||
|
|------|------|------|--------|
|
||||||
|
| 面试记录 | ✅ 完成 | 查看历史面试记录与报告 | P0 |
|
||||||
|
| 进步轨迹 | 🆕 新增 | 雷达图 + 历史对比 + 打卡进度 | **P0** |
|
||||||
|
| 简历管理 | 🔨 开发中 | 管理多份简历 | P1 |
|
||||||
|
| 会员中心 | 🔨 开发中 | 会员状态、订阅、权益展示 | P0 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、商业化功能
|
||||||
|
|
||||||
|
### 3.1 会员系统(价格重构)
|
||||||
|
| 功能 | 状态 | 描述 | 优先级 |
|
||||||
|
|------|------|------|--------|
|
||||||
|
| 免费版 | 🔨 开发中 | 每日 2 次基础面试(通用题库) | P0 |
|
||||||
|
| 成长版 ¥19.9/月 | 🔨 开发中 | 无限面试 + 高级报告 + 公司真题 + 进步轨迹 | **P0** |
|
||||||
|
| 冲刺版 ¥49.9/月 | 🆕 新增 | + 真人导师点评 + 简历精修 + 内推优先 | P1 |
|
||||||
|
| 微信支付对接 | 🔨 开发中 | 微信支付接入 | P0 |
|
||||||
|
| 会员权益对比 | 🆕 新增 | 三版对比展示页面 | P0 |
|
||||||
|
|
||||||
|
### 3.2 B 端服务(Q4 启动)
|
||||||
|
| 功能 | 状态 | 描述 | 优先级 |
|
||||||
|
|------|------|------|--------|
|
||||||
|
| 高校就业办合作 | 📋 规划中 | 批量账号 + 数据看板 | P2 |
|
||||||
|
| 企业 HR 初筛 | 📋 规划中 | AI 面试初筛工具 | P2 |
|
||||||
|
| 内推平台 | 📋 规划中 | 企业发布岗位 + 内推佣金 | P2 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、AI 能力
|
||||||
|
| 功能 | 状态 | 描述 | 优先级 |
|
||||||
|
|------|------|------|--------|
|
||||||
|
| AI 面试模拟 | ✅ 完成 | 多轮对话 + 实时反馈 + 评分 | P0 |
|
||||||
|
| 面试报告生成 | ✅ 完成 | 总分 + 各维度得分 + 优劣势分析 | P0 |
|
||||||
|
| 简历诊断 | 🔨 开发中 | 结构 + 表达 + 关键词 + 亮点分析 | P1 |
|
||||||
|
| AI 岗位题库 | 🔨 开发中 | AI 算法/大模型岗位专属题库 | P1 |
|
||||||
|
| 技能缺口分析 | 📋 规划中 | 基于 JD 分析技能差距 | P2 |
|
||||||
|
| 学习路径推荐 | 📋 规划中 | 知识图谱驱动的职业规划 | P2 |
|
||||||
|
|
||||||
|
### AI 模型配置
|
||||||
|
| 模型 | 用途 | 状态 |
|
||||||
|
|------|------|------|
|
||||||
|
| opencode-go (deepseek-v4-flash) | 主用 | ✅ 已配置 |
|
||||||
|
| NVIDIA (stepfun-ai/step-3.5-flash) | 备用 | ✅ 已配置 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、技术功能
|
||||||
|
| 功能 | 状态 | 描述 |
|
||||||
|
|------|------|------|
|
||||||
|
| MongoDB 数据存储 | ✅ 完成 | 用户、面试、简历、题库 |
|
||||||
|
| Redis 缓存 | ✅ 完成 | 会话缓存,限流 |
|
||||||
|
| JWT 认证 | ✅ 完成 | 用户身份验证 |
|
||||||
|
| API 限流 | ✅ 完成 | @nestjs/throttler |
|
||||||
|
| 文件上传 | ✅ 完成 | 简历 PDF/图片解析 |
|
||||||
|
| CORS 配置 | ✅ 完成 | 生产环境白名单 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、功能优先级总览
|
||||||
|
|
||||||
|
### P0(MVP + 壁垒构建,立即实现)
|
||||||
|
- [x] 微信一键登录(后端已通,前端待联调)
|
||||||
|
- [x] AI 面试模拟(多轮追问 + 实时反馈)
|
||||||
|
- [x] 面试报告生成
|
||||||
|
- [x] 历史面试记录
|
||||||
|
- [ ] **进步轨迹雷达图**(新增)
|
||||||
|
- [ ] **面经贡献系统**(新增)
|
||||||
|
- [ ] **每日一题 Push**(新增)
|
||||||
|
- [ ] **会员系统(¥19.9 + ¥49.9 定价)**
|
||||||
|
- [ ] **微信支付对接**
|
||||||
|
- [ ] **公司真题库(首期 5 家)**
|
||||||
|
|
||||||
|
### P1(上线后快速迭代)
|
||||||
|
- [ ] 简历诊断(复用已有代码)
|
||||||
|
- [ ] AI 岗位专属题库
|
||||||
|
- [ ] 连续打卡激励
|
||||||
|
- [ ] 参考答案生成
|
||||||
|
- [ ] 会员权益对比页
|
||||||
|
|
||||||
|
### P2(秋招后)
|
||||||
|
- [ ] 高校就业办合作
|
||||||
|
- [ ] 技能缺口分析
|
||||||
|
- [ ] 学习路径推荐
|
||||||
|
- [ ] 企业 HR 初筛
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、变更记录
|
||||||
|
| 日期 | 变更内容 | 操作者 |
|
||||||
|
|------|----------|--------|
|
||||||
|
| 2026-05-14 | 功能清单初版(简历工具方向) | AI |
|
||||||
|
| 2026-06-01 | 重新定位:专注校招,¥9.9/月 | AI |
|
||||||
|
| 2026-06-05 | **战略升级**:新增数据飞轮、留存入围、B 端服务;价格重构 ¥19.9/¥49.9 | 小之 |
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
# 职引 · 产品规划文档 v4.0
|
||||||
|
|
||||||
|
> 版本: v4.0
|
||||||
|
> 日期: 2026-06-05
|
||||||
|
> 状态: 战略升级(竞争壁垒 + 盈利模型重构)
|
||||||
|
> 定位: 应届生/实习生 AI 面试教练
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、产品定位
|
||||||
|
|
||||||
|
### 1.1 核心价值
|
||||||
|
**职引** = 专注校招的 AI 面试教练
|
||||||
|
|
||||||
|
- **AI 面试模拟**(核心):多轮追问,实时反馈评分,公司真题库
|
||||||
|
- **进步可视化**(留存):四维雷达图,历史对比,连续打卡激励
|
||||||
|
- **数据飞轮**(壁垒):用户面经贡献 → 题库积累 → 精准出题
|
||||||
|
- **简历诊断**(辅助):AI 诊断简历问题,提升通过率
|
||||||
|
- **B 端服务**(扩展):高校就业办合作、企业 HR 初筛
|
||||||
|
|
||||||
|
### 1.2 Slogan
|
||||||
|
**"校招面试,先模拟再上场"**
|
||||||
|
|
||||||
|
### 1.3 目标用户
|
||||||
|
| 用户群体 | 特征 | 核心诉求 |
|
||||||
|
|---------|------|---------|
|
||||||
|
| 应届毕业生(春招/秋招) | 第一次面试,零经验 | 不知道面试问什么、怎么答 |
|
||||||
|
| 实习生招聘 | 大三/研二,找暑期实习 | 面试经验不足,需要练习 |
|
||||||
|
| 跨专业求职 | 非 CS 转技术岗 | 如何用非相关背景回答专业问题 |
|
||||||
|
| 高校就业办 | 需要就业指导工具 | 批量提供面试训练能力 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、市场竞争与壁垒
|
||||||
|
|
||||||
|
### 2.1 竞争格局
|
||||||
|
| 类型 | 代表产品 | 优点 | 缺点 | 职引壁垒 |
|
||||||
|
|------|---------|------|------|---------|
|
||||||
|
| 通用 AI | ChatGPT/Kimi | 通用灵活 | 非面试专用 | 专注校招,结构化反馈 |
|
||||||
|
| B 端 AI 面试 | 牛客 | 技术岗题库 | 职能岗弱,C 端一般 | 全岗位覆盖 |
|
||||||
|
| C 端 AI 面试 | OfferGoose | 功能全面 | ¥99/月贵,校招不专注 | **价格 1/5 + 真题题库** |
|
||||||
|
| **职引** | **校招专属教练** | **数据飞轮 + 真题 + 低价** | **差异化明显** |
|
||||||
|
|
||||||
|
### 2.2 三层竞争壁垒
|
||||||
|
|
||||||
|
```
|
||||||
|
第一层:数据飞轮(核心护城河)
|
||||||
|
用户面试 → AI评分 → 脱敏存储 → 面经贡献
|
||||||
|
↓
|
||||||
|
公司-岗位-面试题-评分 四维映射
|
||||||
|
↓
|
||||||
|
精准出题 + 真实面经 + 题库积累
|
||||||
|
↓
|
||||||
|
竞争对手:降价容易,追数据不可能
|
||||||
|
|
||||||
|
第二层:用户粘性(留存壁垒)
|
||||||
|
· 进步轨迹雷达图(四维能力可视化)
|
||||||
|
· 连续打卡激励(7天解锁高级报告)
|
||||||
|
· 每日一题 Push(面试题 + 参考思路)
|
||||||
|
· 公司真题库(字节/腾讯/阿里…专属题库)
|
||||||
|
· 面试剧本(按公司+岗位定制仿真场景)
|
||||||
|
|
||||||
|
第三层:合规 + 品牌(信任壁垒)
|
||||||
|
· ICP + AI 深度合成类目已备案
|
||||||
|
· AI 面试合规白皮书(公开透明)
|
||||||
|
· 用户口碑 → 校招第一选择
|
||||||
|
· 竞品备案周期 3-6 个月
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、盈利模型(重构)
|
||||||
|
|
||||||
|
### 3.1 三段式定价
|
||||||
|
|
||||||
|
| 版本 | 价格 | 核心权益 | 目标转化率 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| 免费版 | ¥0 | 每日 2 次基础面试(通用题库) | 引流 |
|
||||||
|
| **成长版** | **¥19.9/月** | 无限面试 + 高级报告 + 公司真题库 + 进步轨迹 | **主力(70%)** |
|
||||||
|
| **冲刺版** | **¥49.9/月** | + 真人导师点评 + 简历精修 + 内推优先 | 高客单(15%) |
|
||||||
|
|
||||||
|
> 价格策略:¥19.9 是 OfferGoose ¥99 的 1/5,心理门槛极低。冲刺版 ¥49.9 锚定"真人服务"价值,反衬成长版超值。
|
||||||
|
|
||||||
|
### 3.2 收入来源多元化
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────┐
|
||||||
|
│ C 端订阅收入 │ ← 基本盘 (¥19.9×用户数)
|
||||||
|
└────────┬────────┘
|
||||||
|
│
|
||||||
|
┌────────────────┼────────────────┐
|
||||||
|
│ │ │
|
||||||
|
▼ ▼ ▼
|
||||||
|
┌─────────┐ ┌──────────┐ ┌──────────────┐
|
||||||
|
│ B端合作 │ │ 内容变现 │ │ 人才匹配佣金 │
|
||||||
|
├─────────┤ ├──────────┤ ├──────────────┤
|
||||||
|
│· 高校就业│ │· 笔试真题│ │· 企业发布岗位 │
|
||||||
|
│ 办合作 │ │ 题库 │ │ 位费 │
|
||||||
|
│· 求职机构│ │· AI 面经 │ │· 内推成功佣金 │
|
||||||
|
│· 企业 HR │ │ 课程 │ │· 简历筛选服务 │
|
||||||
|
│ 初筛工具│ │· 1v1辅导 │ │ │
|
||||||
|
└─────────┘ └──────────┘ └──────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 收入预测
|
||||||
|
|
||||||
|
| 阶段 | C 端 | B 端 | 月收入 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| MVP 上线(6-8月) | 200 付费 × ¥19.9 | 0 | ¥3,980 |
|
||||||
|
| 秋招旺季(9-11月) | 1000 付费 × ¥19.9 | 2 高校 ¥5000 | ¥29,900 |
|
||||||
|
| 稳定运营(次年) | 2000 付费 × ¥19.9 | 5 高校 + 企业 | ¥60,000+ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、核心功能规划
|
||||||
|
|
||||||
|
### 4.1 MVP 功能(当前实现中)
|
||||||
|
| 功能 | 描述 | 优先级 | 状态 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| 微信一键登录 | 微信授权登录,零门槛 | P0 | 🔨 开发中 |
|
||||||
|
| 岗位选择 | 校招热门 20+ 岗位(含 AI 岗位) | P0 | ✅ 完成 |
|
||||||
|
| AI 面试模拟 | 多轮对话追问,核心差异化 | P0 | ✅ 完成 |
|
||||||
|
| 实时反馈评分 | 每轮回答后给出评分+改进建议 | P0 | ✅ 完成 |
|
||||||
|
| 面试报告 | 完整面试表现分析报告 | P0 | ✅ 完成 |
|
||||||
|
| 历史面试记录 | 查看历史面试与进步轨迹 | P0 | ✅ 完成 |
|
||||||
|
|
||||||
|
### 4.2 Phase 1 增强(当前优先实现)
|
||||||
|
| 功能 | 描述 | 优先级 |
|
||||||
|
|------|------|--------|
|
||||||
|
| 进步轨迹雷达图 | 四维能力可视化 + 历史对比 | **P0 新增** |
|
||||||
|
| 面经贡献系统 | 面试后可贡献公司面经 → 数据飞轮 | **P0 新增** |
|
||||||
|
| 每日一题推送 | 微信订阅消息推送面试题 | **P0 新增** |
|
||||||
|
| 公司真题库 | 5 家头部公司专属面试题库 | P1 |
|
||||||
|
| 连续打卡激励 | 7 天打卡解锁高级功能 | P1 |
|
||||||
|
| 会员系统 | ¥19.9/月 + ¥49.9/月 | P0 |
|
||||||
|
| 微信支付对接 | 微信支付接入 | P0 |
|
||||||
|
|
||||||
|
### 4.3 Phase 2 扩展(秋招前)
|
||||||
|
| 功能 | 描述 | 优先级 |
|
||||||
|
|------|------|--------|
|
||||||
|
| 简历诊断 | AI 分析简历问题 | P1 |
|
||||||
|
| AI 岗位专属题库 | AI 算法/大模型岗位面试题库 | P1 |
|
||||||
|
| 技能缺口分析 | 基于目标岗位分析技能差距 | P1 |
|
||||||
|
| AI 学习路径推荐 | 补齐技能缺口的学习路线 | P2 |
|
||||||
|
| 精选实习岗位 | 人工整理优质实习岗位 | P1 |
|
||||||
|
|
||||||
|
### 4.4 Phase 3 商业化(Q4)
|
||||||
|
| 功能 | 描述 | 优先级 |
|
||||||
|
|------|------|--------|
|
||||||
|
| 高校就业办合作 | B 端订阅 + 批量管理 | P1 |
|
||||||
|
| 企业 HR 初筛 | AI 面试初筛工具 | P2 |
|
||||||
|
| 内推平台 | 企业发布岗位 + 内推佣金 | P2 |
|
||||||
|
| 真人导师点评 | 冲刺版专属 1v1 服务 | P2 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、推广方案
|
||||||
|
|
||||||
|
### 5.1 免费渠道(立即启动)
|
||||||
|
| 渠道 | 成本 | 可行性 | 说明 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| 微信公众号 | ¥0 | ✅✅✅ | 发校招面试技巧 + 产品入口 |
|
||||||
|
| 知乎回答 | ¥0 | ✅✅✅ | "校招面试准备"类问题,长尾流量 |
|
||||||
|
| 学校 BBS/贴吧 | ¥0 | ✅✅ | 各高校 BBS 发帖 |
|
||||||
|
| 豆瓣小组 | ¥0 | ✅✅ | 校招/求职类小组 |
|
||||||
|
|
||||||
|
### 5.2 付费渠道(秋招前投)
|
||||||
|
| 渠道 | 预算 | 预期 |
|
||||||
|
|------|------|------|
|
||||||
|
| 小红书 KOC | ¥1000-3000/月 | 精准触达应届生 |
|
||||||
|
| 抖音信息流 | ¥2000-5000/月 | 视频展示产品 |
|
||||||
|
| 求职公众号合作 | ¥0(资源置换) | 互相推广 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、里程碑
|
||||||
|
|
||||||
|
| 里程碑 | 时间 | 交付物 | 成功标准 |
|
||||||
|
|--------|------|--------|----------|
|
||||||
|
| M0: 战略升级 | ✅ D1 | 文档体系更新 + 定价重构 | 已完成 |
|
||||||
|
| M0.5: 壁垒构建 | D7 | 进步轨迹 + 面经贡献 + 每日一题 | 功能可用 |
|
||||||
|
| M1: MVP 上线 | D14 | 小程序审核通过,内测启动 | 100 内测用户 |
|
||||||
|
| M2: PMF 验证 | D30 | 100 内测用户反馈 | 次日留存 >30% |
|
||||||
|
| M3: 付费上线 | D45 | 会员系统 + 微信支付 | 10+ 付费用户 |
|
||||||
|
| M4: 秋招冲刺 | D90 | 秋招旺季爆发推广 | 1000+ 付费用户 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、风险与应对
|
||||||
|
|
||||||
|
| 风险 | 影响 | 应对 |
|
||||||
|
|------|------|------|
|
||||||
|
| 用户获取成本高 | 推广预算有限 | 优先免费渠道;秋招前投小红书 ¥3000/月 |
|
||||||
|
| 留存率未达标 | 用户面完就走 | 进步轨迹 + 每日一题 + 打卡激励 |
|
||||||
|
| 竞品降价 | 价格优势被削弱 | 真题题库 + 数据飞轮 = 不可替代性 |
|
||||||
|
| 微信审核 | 突然下架 | 严格合规 + H5 备用方案 |
|
||||||
|
| AI 成本失控 | 利润被吃掉 | 限制免费版调用次数;监控告警 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、变更记录
|
||||||
|
|
||||||
|
| 日期 | 变更内容 | 操作者 |
|
||||||
|
|------|----------|--------|
|
||||||
|
| 2026-05-14 | 职引项目启动 | AI |
|
||||||
|
| 2026-06-01 | 重新定位:专注校招,¥9.9/月 | AI |
|
||||||
|
| 2026-06-05 | **战略升级**:三层壁垒 + ¥19.9/¥49.9 定价 + B 端扩展 + 数据飞轮 | 小之 |
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
# 职引项目 · 状态报告 v4.0
|
||||||
|
|
||||||
|
> **项目版本**: v4.0
|
||||||
|
> **更新时间**: 2026-06-05 17:13
|
||||||
|
> **项目状态**: 🚧 壁垒构建中(战略升级 v4.0)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、项目概况
|
||||||
|
|
||||||
|
| 维度 | 详情 |
|
||||||
|
|------|------|
|
||||||
|
| 项目名称 | 职引(ZhiYin) |
|
||||||
|
| 定位 | 应届生/实习生 AI 面试教练 |
|
||||||
|
| 技术栈 | NestJS + MongoDB + Uni-App(Vue3) |
|
||||||
|
| 定价 | 免费版 / ¥19.9月(成长版) / ¥49.9月(冲刺版) |
|
||||||
|
| AI 模型 | DeepSeek V4-Flash(主) + Step-3.5-Flash(备) |
|
||||||
|
| 后端模块 | user, interview, resume, member, payment, positions, ai, analyze, schemas, upload, admin |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、完成度总览
|
||||||
|
|
||||||
|
| 模块 | 完成度 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| 后端 API | **95%** | 核心接口全部通过测试(10/10) |
|
||||||
|
| 前端页面 | **60%** | 核心页面骨架存在,UI 细节待完善 |
|
||||||
|
| AI 面试模拟 | **90%** | 多轮对话 + 评分 + 报告,待联调优化 |
|
||||||
|
| 简历诊断 | **95%** | 已有完整代码,测试通过 |
|
||||||
|
| 微信登录 | **70%** | 后端接口完备,前端待联调真实 appid |
|
||||||
|
| 会员系统 | **50%** | 后端数据模型存在,定价待更新(¥19.9/¥49.9),支付未打通 |
|
||||||
|
| 生产部署 | **10%** | 配置文档齐,服务器未购买 |
|
||||||
|
| 小程序审核 | **0%** | 类目已备案,未提交审核 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、新增功能开发(Phase 0.5 壁垒构建)
|
||||||
|
|
||||||
|
| 功能 | 后端 | 前端 | 状态 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| 进步轨迹雷达图 | 🔨 开发中 | 🔨 开发中 | 今日启动 |
|
||||||
|
| 面经贡献系统 | 🔨 开发中 | 🔨 开发中 | 今日启动 |
|
||||||
|
| 每日一题推送 | 🔨 开发中 | 🔨 开发中 | 今日启动 |
|
||||||
|
| 公司真题库 | 🔨 开发中 | 📋 规划中 | 数据结构设计 |
|
||||||
|
| 会员定价更新 | 🔨 开发中 | 🔨 开发中 | ¥19.9/¥49.9 |
|
||||||
|
| 微信支付对接 | 🔨 开发中 | 🔨 开发中 | 接口对接中 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、已完成工作(100%)
|
||||||
|
|
||||||
|
### 4.1 后端核心 API
|
||||||
|
| 测试项 | 状态 | 说明 |
|
||||||
|
|--------|------|------|
|
||||||
|
| 发送验证码 | ✅ | 开发模式返回固定验证码 123456 |
|
||||||
|
| 用户登录 | ✅ | 返回 JWT token 和用户信息 |
|
||||||
|
| 获取用户信息 | ✅ | 返回用户详情 |
|
||||||
|
| 简历诊断 | ✅ | AI 分析返回评分、问题列表、改进建议 |
|
||||||
|
| 简历优化 | ✅ | AI 优化返回优化后的简历内容 |
|
||||||
|
| 创建模拟面试 | ✅ | 返回面试ID、首个问题、提示 |
|
||||||
|
| 回答面试问题 | ✅ | AI 给出反馈和下一个问题 |
|
||||||
|
| 完成面试生成报告 | ✅ | 返回总分、强弱项、改进建议 |
|
||||||
|
| 获取历史记录 | ✅ | 返回用户的所有面试记录 |
|
||||||
|
| 获取用户统计 | ✅ | 返回面试次数、平均分数 |
|
||||||
|
|
||||||
|
**测试通过率:100% (10/10)**
|
||||||
|
|
||||||
|
### 4.2 前端核心页面
|
||||||
|
- ✅ 首页(index.vue):面试、简历诊断、简历优化入口
|
||||||
|
- ✅ 登录页面(login.vue):支持手机验证码登录
|
||||||
|
- ✅ 模拟面试页面(interview.vue):支持多轮对话
|
||||||
|
- ✅ 历史记录页面(history.vue):查看历史记录
|
||||||
|
- ✅ 个人中心页面(user.vue):用户信息和管理
|
||||||
|
- ✅ 简历诊断页面(diagnosis.vue):AI 分析简历
|
||||||
|
- ✅ 简历优化页面(optimize.vue):AI 优化简历
|
||||||
|
|
||||||
|
### 4.3 上线配置
|
||||||
|
- ✅ 生产环境配置(.env.production)
|
||||||
|
- ✅ 部署文档(docs/DEPLOYMENT.md)
|
||||||
|
- ✅ 小程序上线检查清单(docs/WECHAT-CHECKLIST.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、项目文件清单
|
||||||
|
|
||||||
|
### 后端
|
||||||
|
- `backend/.env.production` - 生产环境配置
|
||||||
|
- `backend/dist/` - 编译后的代码
|
||||||
|
- `backend/src/modules/` - 11 个业务模块
|
||||||
|
|
||||||
|
### 前端
|
||||||
|
- `zhiyin-app/config/api.js` - API 配置
|
||||||
|
- `zhiyin-app/manifest.json` - 小程序配置(AI 深度合成类目已通过)
|
||||||
|
- `zhiyin-app/src/pages/` - 页面文件
|
||||||
|
|
||||||
|
### 文档
|
||||||
|
- `docs/PRODUCT-PLAN.md` - 产品规划 v4.0(✅ 已更新)
|
||||||
|
- `docs/ARCHITECTURE.md` - 架构文档
|
||||||
|
- `docs/FEATURE-LIST.md` - 功能清单 v4.0(✅ 已更新)
|
||||||
|
- `docs/ROADMAP.md` - 路线图 v4.0(✅ 已更新)
|
||||||
|
- `docs/PROJECT-STATUS.md` - 状态报告(本文件)
|
||||||
|
- `docs/DEPLOYMENT.md` - 部署文档
|
||||||
|
- `docs/WECHAT-CHECKLIST.md` - 小程序上线检查清单
|
||||||
|
|
||||||
|
### 测试
|
||||||
|
- `test-full.js` - 完整功能测试脚本
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、下一步行动(按优先级)
|
||||||
|
|
||||||
|
| # | 行动 | 负责 | 预计时间 |
|
||||||
|
|---|------|------|------|
|
||||||
|
| 1 | 进步轨迹雷达图后端 API | 小之 | 今天 |
|
||||||
|
| 2 | 面经贡献系统后端 API | 小之 | 今天 |
|
||||||
|
| 3 | 每日一题推送后端 API | 小之 | 今天 |
|
||||||
|
| 4 | 会员定价更新(¥19.9/¥49.9) | 小之 | 今天 |
|
||||||
|
| 5 | 前端页面完善(雷达图/贡献/会员) | 小之 | 本周 |
|
||||||
|
| 6 | 微信登录联调(真实 appid) | lt | 本周 |
|
||||||
|
| 7 | 生产环境部署 | lt | 本周 |
|
||||||
|
| 8 | 小程序审核提交 | lt | 下周 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、技术债务
|
||||||
|
|
||||||
|
| 问题 | 影响 | 优先级 |
|
||||||
|
|------|------|------|
|
||||||
|
| 微信登录未用真实 appid 联调 | 无法真机测试 | P0 |
|
||||||
|
| 前端 API 错误处理不够健壮 | 偶现白屏 | P1 |
|
||||||
|
| AI 调用无重试机制 | 偶发失败 | P1 |
|
||||||
|
| 无单元测试 | 回归风险 | P2 |
|
||||||
|
| TypeScript strict mode 未开启 | 类型安全不足 | P2 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、变更记录
|
||||||
|
|
||||||
|
| 日期 | 变更内容 | 操作者 |
|
||||||
|
|------|----------|--------|
|
||||||
|
| 2026-06-02 | 项目状态初版,测试 10/10 通过 | AI |
|
||||||
|
| 2026-06-05 | **战略升级**:文档重构 + 新增功能启动 + 真实状态评估 | 小之 |
|
||||||
+196
@@ -0,0 +1,196 @@
|
|||||||
|
# 职引 · 产品路线图 v4.0
|
||||||
|
|
||||||
|
> 版本: v4.0
|
||||||
|
> 日期: 2026-06-05
|
||||||
|
> 状态: 战略升级(竞争壁垒 + 盈利模型重构)
|
||||||
|
> 定位: 应届生/实习生 AI 面试教练
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、总体路线
|
||||||
|
|
||||||
|
```
|
||||||
|
Phase 0: 战略升级(✅ 已完成)
|
||||||
|
↓
|
||||||
|
Phase 0.5: 壁垒构建(D1-7)→ 进步轨迹 + 面经贡献 + 每日一题
|
||||||
|
↓
|
||||||
|
Phase 1: MVP 上线(D7-14)→ 小程序审核 + 内测 + 微信支付
|
||||||
|
↓
|
||||||
|
Phase 1.5: 辅助功能 + 商业化(D14-30)→ PMF 验证 + 付费转化
|
||||||
|
↓
|
||||||
|
Phase 2: 增强 + 真题库(D30-60)→ 秋招准备工作
|
||||||
|
↓
|
||||||
|
Phase 3: 商业化 + B 端(D60-90)→ 秋招爆发 + 收入增长
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、Phase 0:战略升级(✅ 已完成)
|
||||||
|
|
||||||
|
**目标**: 重构竞争壁垒与盈利模型
|
||||||
|
|
||||||
|
**已完成**:
|
||||||
|
- [x] 定价重构:¥9.9 → ¥19.9/¥49.9 三段式
|
||||||
|
- [x] 三层壁垒设计(数据飞轮 + 留存入围 + 合规信任)
|
||||||
|
- [x] 收入来源多元化(C 端 + B 端 + 内容 + 人才匹配)
|
||||||
|
- [x] 文档体系全面更新
|
||||||
|
- [x] 清理冗余文档(DEVELOPMENT-PLAN.md、重复 PROJECT-STATUS.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、Phase 0.5:壁垒构建(D1-7,正在执行)
|
||||||
|
|
||||||
|
### 3.1 数据飞轮功能
|
||||||
|
|
||||||
|
| 功能 | 描述 | 状态 |
|
||||||
|
|------|------|------|
|
||||||
|
| 面经贡献系统 | 面试后可贡献公司/岗位信息,脱敏存入题库 | 🔨 开发中 |
|
||||||
|
| 公司-岗位-题库映射 | 数据结构设计,四维映射 | 🔨 开发中 |
|
||||||
|
| 题库自动扩充 | 基于用户贡献自动生成题目 | 📋 规划中 |
|
||||||
|
|
||||||
|
### 3.2 留存入围功能
|
||||||
|
|
||||||
|
| 功能 | 描述 | 状态 |
|
||||||
|
|------|------|------|
|
||||||
|
| 进步轨迹雷达图 | 四维能力可视化 + 历史对比 | 🔨 开发中 |
|
||||||
|
| 日历打卡视图 | 面试频率可视化,连续打卡激励 | 🔨 开发中 |
|
||||||
|
| 每日一题 Push | 微信订阅消息推送面试题 | 🔨 开发中 |
|
||||||
|
|
||||||
|
### 3.3 会员系统重构
|
||||||
|
|
||||||
|
| 功能 | 描述 | 状态 |
|
||||||
|
|------|------|------|
|
||||||
|
| 定价更新 | ¥19.9/月 成长版 + ¥49.9/月 冲刺版 | 🔨 开发中 |
|
||||||
|
| 会员权益对比 | 三版对比展示页面 | 🔨 开发中 |
|
||||||
|
| 微信支付对接 | 微支付接入与测试 | 🔨 开发中 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、Phase 1:MVP 上线(D7-14)
|
||||||
|
|
||||||
|
### 4.1 上线准备
|
||||||
|
|
||||||
|
| 任务 | 描述 | 状态 |
|
||||||
|
|------|------|------|
|
||||||
|
| 前端页面完善 | 所有 P0 页面 UI 完成 | ⏳ 待开始 |
|
||||||
|
| 微信登录联调 | 真实 appid 验证 | ⏳ 待开始 |
|
||||||
|
| 生产环境部署 | 服务器 + MongoDB + Nginx + PM2 | ⏳ 待开始 |
|
||||||
|
| 小程序审核提交 | 资质齐全,可立即提交 | ⏳ 待开始 |
|
||||||
|
| 内测版发布 | 邀请码方式,100 人内测 | ⏳ 待开始 |
|
||||||
|
|
||||||
|
### 4.2 内测指标
|
||||||
|
|
||||||
|
- **关键指标**: 次日留存 > 30%,7 日留存 > 15%
|
||||||
|
- **反馈收集**: 问卷 + 访谈
|
||||||
|
- **如果达标**: 继续 Phase 1.5
|
||||||
|
- **如果不达标**: 复盘原因,调整后继续
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、Phase 1.5:辅助功能 + 商业化(D14-30)
|
||||||
|
|
||||||
|
| 功能 | 描述 | 优先级 |
|
||||||
|
|------|------|--------|
|
||||||
|
| 简历诊断 | 复用已有代码,AI 分析简历 | P1 |
|
||||||
|
| AI 岗位专属题库 | AI 算法/大模型岗位面试题库 | P1 |
|
||||||
|
| 连续打卡激励 | 7 天打卡解锁高级功能 | P1 |
|
||||||
|
| 付费转化验证 | 100 内测用户 → 10+ 付费用户 | P0 |
|
||||||
|
| PMF 决策 | 转化率 > 5% → 继续;< 5% → 复盘 | P0 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、Phase 2:增强 + 真题库(D30-60,秋招前)
|
||||||
|
|
||||||
|
### 6.1 真题库建设
|
||||||
|
| 公司 | 题库规模 | 状态 |
|
||||||
|
|------|------|------|
|
||||||
|
| 腾讯 | 技术 + 产品 + 运营 | 📋 规划中 |
|
||||||
|
| 字节跳动 | 技术 + 产品 | 📋 规划中 |
|
||||||
|
| 阿里巴巴 | 技术 + 产品 + 设计 | 📋 规划中 |
|
||||||
|
| 美团 | 技术 + 产品 | 📋 规划中 |
|
||||||
|
| 百度 | 技术 + AI 岗位 | 📋 规划中 |
|
||||||
|
|
||||||
|
### 6.2 增强功能
|
||||||
|
| 功能 | 描述 | 优先级 |
|
||||||
|
|------|------|--------|
|
||||||
|
| 技能缺口分析 | 基于目标岗位分析技能差距 | P1 |
|
||||||
|
| AI 学习路径推荐 | 免费资源整合推荐 | P2 |
|
||||||
|
| 更多岗位覆盖 | 扩展到 50+ 校招热门岗位 | P1 |
|
||||||
|
| 精选实习岗位 | 人工整理优质实习(秋招前强化) | P1 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、Phase 3:商业化 + B 端(D60-90,秋招爆发)
|
||||||
|
|
||||||
|
### 7.1 增长目标
|
||||||
|
- 付费用户突破 1000
|
||||||
|
- 月收入突破 ¥30,000
|
||||||
|
- 高校合作启动 2-3 所
|
||||||
|
|
||||||
|
### 7.2 B 端启动
|
||||||
|
| 功能 | 描述 | 优先级 |
|
||||||
|
|------|------|--------|
|
||||||
|
| 高校就业办合作 | B 端订阅 + 批量管理 | P1 |
|
||||||
|
| 企业内推服务 | 帮助企业筛选简历 | P2 |
|
||||||
|
| 真人导师点评 | 冲刺版专属 1v1 服务 | P2 |
|
||||||
|
|
||||||
|
### 7.3 秋招旺季推广(9-11月)
|
||||||
|
- 小红书 KOC 投放 ¥3000/月
|
||||||
|
- 微信公众号密集推送面试技巧
|
||||||
|
- 知乎回答"秋招如何准备"类问题
|
||||||
|
- 学校 BBS/贴吧精准发帖
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、里程碑
|
||||||
|
|
||||||
|
| 里程碑 | 时间 | 交付物 | 成功标准 |
|
||||||
|
|--------|------|--------|----------|
|
||||||
|
| M0: 战略升级 | ✅ D1 | 文档体系 + 定价重构 | 已完成 |
|
||||||
|
| M0.5: 壁垒构建 | D7 | 进步轨迹 + 面经贡献 + 每日一题 | 功能可用 |
|
||||||
|
| M1: MVP 上线 | D14 | 小程序审核通过,内测启动 | 100 内测用户 |
|
||||||
|
| M2: PMF 验证 | D30 | 100 用户反馈 + 付费数据 | 转化率 > 5% |
|
||||||
|
| M3: 付费上线 | D45 | 会员系统 + 微信支付 | 50+ 付费用户 |
|
||||||
|
| M4: 秋招冲刺 | D90 | 秋招爆发推广 | 1000+ 付费用户 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、校招季节日历
|
||||||
|
|
||||||
|
```
|
||||||
|
2026 年校招日历:
|
||||||
|
6-8 月:暑假实习搜索高峰 + 秋招准备(打磨产品 + 积累种子用户)
|
||||||
|
9-11 月:秋招(主战场,全力推广冲刺)
|
||||||
|
12-2 月:寒假 + 春招准备(B 端合作拓展)
|
||||||
|
|
||||||
|
关键时间点:
|
||||||
|
- 现在(6月5日):壁垒构建 + MVP 完善
|
||||||
|
- 6月15日:MVP 上线,内测启动
|
||||||
|
- 7月1日:PMF 验证,付费转化
|
||||||
|
- 8月1日:Phase 2 完成,准备秋招
|
||||||
|
- 9月1日:秋招旺季,全力推广
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十、风险与应对
|
||||||
|
|
||||||
|
| 风险 | 影响 | 应对 |
|
||||||
|
|------|------|------|
|
||||||
|
| 用户获取成本高 | 推广预算有限 | 免费渠道优先;秋招前投小红书 |
|
||||||
|
| 留存率未达标 | 用户面完就走 | 进步轨迹 + 每日一题 + 打卡激励 |
|
||||||
|
| 竞品降价 | 价格优势被削弱 | 真题题库 + 数据飞轮 = 不可替代 |
|
||||||
|
| 微信审核风险 | 小程序下架 | 严格合规 + H5 备用方案 |
|
||||||
|
| 暑假淡季(6-8月) | 用户活跃度下降 | 强化实习搜索,衔接秋招 |
|
||||||
|
| AI 成本失控 | 利润被吃掉 | 限制免费调用;监控告警 |
|
||||||
|
| PMF 验证失败 | 浪费时间 | Week 4 关键决策,及时 pivot |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十一、变更记录
|
||||||
|
|
||||||
|
| 日期 | 变更内容 | 操作者 |
|
||||||
|
|------|----------|--------|
|
||||||
|
| 2026-05-14 | 路线图初版 | AI |
|
||||||
|
| 2026-06-01 | 重新规划:专注校招,¥9.9/月,MVP 2 周 | AI |
|
||||||
|
| 2026-06-05 | **战略升级**:三层壁垒 + ¥19.9/¥49.9 + B 端 + 数据飞轮 + 新里程碑 | 小之 |
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
# 职引 - 微信小程序上线检查清单
|
||||||
|
|
||||||
|
> **用途**:提交微信审核前,逐项检查,确保一次通过。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、资质与合规 ✅
|
||||||
|
|
||||||
|
### 1.1 账号资质
|
||||||
|
- [ ] 已完成微信认证(企业/个体工商户)
|
||||||
|
- [ ] 已获得 **AI 深度合成类目** 审批(必须!)
|
||||||
|
- [ ] 已配置客服(用于审核人员测试登录)
|
||||||
|
|
||||||
|
### 1.2 隐私合规
|
||||||
|
- [ ] 《隐私政策》完整,包含:
|
||||||
|
- [ ] 收集的个人信意类型(手机号、简历内容)
|
||||||
|
- [ ] 信息用途径(AI 分析、面试记录)
|
||||||
|
- [ ] 用户权利(查询、删除、注销)
|
||||||
|
- [ ] 联系方系(邮箱/电话)
|
||||||
|
- [ ] 《用户协议》完整
|
||||||
|
- [ ] 小程序内可访问隐私政策和用户协议
|
||||||
|
- [ ] 首次收集个人信息前弹窗征得用户同意
|
||||||
|
- [ ] 提供用户注销账号功能
|
||||||
|
|
||||||
|
### 1.3 内容安全
|
||||||
|
- [ ] AI 生成内容有标识("由 AI 生成")
|
||||||
|
- [ ] 用户输入内容经过敏感词过滤
|
||||||
|
- [ ] 简历诊断/优化结果不包含违法违规内容
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、功能完整性 ✅
|
||||||
|
|
||||||
|
### 2.1 核心流程
|
||||||
|
- [ ] 微信登录流程顺畅(授权 → 获取手机号 → 登录成功)
|
||||||
|
- [ ] 手机验证码登录流程顺畅
|
||||||
|
- [ ] 简历诊断功能正常(上传 → 分析 → 查看结果)
|
||||||
|
- [ ] 简历优化功能正常(上传 → 优化 → 复制/下载)
|
||||||
|
- [ ] 模拟面试功能正常(选择岗位 → 开始面试 → 问答 → 查看报告)
|
||||||
|
- [ ] 面试历史记录正常显示
|
||||||
|
- [ ] 个人中心信息可修改
|
||||||
|
|
||||||
|
### 2.2 异常场景
|
||||||
|
- [ ] 网络异常时有友好提示
|
||||||
|
- [ ] AI 服务不可用时有降级方案(mock 数据)
|
||||||
|
- [ ] 用户未登录时引导登录
|
||||||
|
- [ ] 表单验证错误提示清晰
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、技术与性能 ✅
|
||||||
|
|
||||||
|
### 3.1 接口与域名
|
||||||
|
- [ ] 所有 API 域名已在【微信公众平台 → 开发 → 开发管理 → 开发设置 → 服务器域名】中配置
|
||||||
|
- [ ] request 合法域名:`https://api.yourdomain.com`
|
||||||
|
- [ ] socket 合法域名:(如适用)
|
||||||
|
- [ ] uploadFile 合法域名:(如适用)
|
||||||
|
- [ ] downloadFile 合法域名:(如适用)
|
||||||
|
- [ ] 所有域名使用 **HTTPS**(必须!)
|
||||||
|
- [ ] 域名已备案(ICP 备案)
|
||||||
|
|
||||||
|
### 3.2 性能
|
||||||
|
- [ ] 首屏加载时间 < 2s
|
||||||
|
- [ ] 页面切换流畅,无卡顿
|
||||||
|
- [ ] 图片资源已压缩
|
||||||
|
- [ ] 没有未使用的 console.log(生产环境)
|
||||||
|
|
||||||
|
### 3.3 兼容性
|
||||||
|
- [ ] 在微信开发者工具中测试通过(不低于基础库 2.25.0)
|
||||||
|
- [ ] 在真机上测试通过(iOS + Android 各一款)
|
||||||
|
- [ ] 不同屏幕尺寸适配正常
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、用户体验 ✅
|
||||||
|
|
||||||
|
### 4.1 界面
|
||||||
|
- [ ] 所有文字无错别字
|
||||||
|
- [ ] 按钮状态清晰(可点击/不可点击)
|
||||||
|
- [ ] 加载中有 loading 提示
|
||||||
|
- [ ] 空状态有引导提示(如:暂无面试记录)
|
||||||
|
- [ ] 弹窗/提示框内容准确
|
||||||
|
|
||||||
|
### 4.2 交互
|
||||||
|
- [ ] 按钮点击有反馈(振动或动效)
|
||||||
|
- [ ] 下拉刷新正常
|
||||||
|
- [ ] 上拉加载更多正常
|
||||||
|
- [ ] 表单输入体验良好(自动聚焦、键盘类型匹配)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、运营准备 ✅
|
||||||
|
|
||||||
|
### 5.1 基础信息
|
||||||
|
- [ ] 小程序名称合规(不含有诱导、夸大宣传)
|
||||||
|
- [ ] 简介清晰(20 字以内概括核心功能)
|
||||||
|
- [ ] 类目标签准确(教育 > 职业技能培训)
|
||||||
|
- [ ] 头像/封面图清晰、合规
|
||||||
|
|
||||||
|
### 5.2 审核辅助
|
||||||
|
- [ ] 准备测试账号(如审核人员需要登录体验)
|
||||||
|
- [ ] 准备功能说明文档(复杂功能可录制演示视频)
|
||||||
|
- [ ] 提供客服联系方式(审核期间保持畅通)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、提交审核前最后确认 ✅
|
||||||
|
|
||||||
|
- [ ] 所有功能已测试通过(使用 [test-full.js](./test-full.js) 跑一遍)
|
||||||
|
- [ ] 版本号已更新
|
||||||
|
- [ ] 项目备注已填写(说明本次更新内容)
|
||||||
|
- [ ] 已备份当前代码(git tag)
|
||||||
|
- [ ] 已通知相关人员(客服、运营)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、常见审核被拒原因 ⚠️
|
||||||
|
|
||||||
|
### 7.1 隐私不合规
|
||||||
|
**原因**:未明示收集使用个人信息的目的、方式和范围。
|
||||||
|
**解决**:完善隐私政策,首次使用时弹窗征得同意。
|
||||||
|
|
||||||
|
### 7.2 AI 类目未审批
|
||||||
|
**原因**:涉及 AI 对话/生成内容,但未申请 AI 深度合成类目。
|
||||||
|
**解决**:提前申请类目审批,或下架 AI 相关功能后再提交审核。
|
||||||
|
|
||||||
|
### 7.3 功能不完整
|
||||||
|
**原因**:审核人员无法完成核心流程(如无法登录、无法提交订单)。
|
||||||
|
**解决**:提供测试账号,确保核心流程顺畅。
|
||||||
|
|
||||||
|
### 7.4 内容不安全
|
||||||
|
**原因**:AI 生成内容可能包含违规信息。
|
||||||
|
**解决**:加强内容审核,增加敏感词过滤,AI 输出增加"由 AI 生成"标识。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、审核通过后 ✅
|
||||||
|
|
||||||
|
- [ ] 及时发布上线
|
||||||
|
- [ ] 监控线上错误日志
|
||||||
|
- [ ] 收集用户反馈
|
||||||
|
- [ ] 准备下次迭代
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**最后更新**:2026-06-02
|
||||||
|
|
||||||
|
> 💡 **提示**:每次提交审核前,建议打印此清单,逐项勾选,确保不遗漏。
|
||||||
Generated
+515
@@ -0,0 +1,515 @@
|
|||||||
|
{
|
||||||
|
"name": "zhiyin",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"dependencies": {
|
||||||
|
"jsdom": "^29.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@asamuzakjp/css-color": {
|
||||||
|
"version": "5.1.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
|
||||||
|
"integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@asamuzakjp/generational-cache": "^1.0.1",
|
||||||
|
"@csstools/css-calc": "^3.2.0",
|
||||||
|
"@csstools/css-color-parser": "^4.1.0",
|
||||||
|
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||||
|
"@csstools/css-tokenizer": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@asamuzakjp/dom-selector": {
|
||||||
|
"version": "7.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz",
|
||||||
|
"integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@asamuzakjp/generational-cache": "^1.0.1",
|
||||||
|
"@asamuzakjp/nwsapi": "^2.3.9",
|
||||||
|
"bidi-js": "^1.0.3",
|
||||||
|
"css-tree": "^3.2.1",
|
||||||
|
"is-potential-custom-element-name": "^1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@asamuzakjp/generational-cache": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@asamuzakjp/nwsapi": {
|
||||||
|
"version": "2.3.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
|
||||||
|
"integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@bramus/specificity": {
|
||||||
|
"version": "2.4.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
|
||||||
|
"integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"css-tree": "^3.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"specificity": "bin/cli.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@csstools/color-helpers": {
|
||||||
|
"version": "6.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
|
||||||
|
"integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/csstools"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/csstools"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT-0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.19.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@csstools/css-calc": {
|
||||||
|
"version": "3.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz",
|
||||||
|
"integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/csstools"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/csstools"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.19.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||||
|
"@csstools/css-tokenizer": "^4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@csstools/css-color-parser": {
|
||||||
|
"version": "4.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.1.tgz",
|
||||||
|
"integrity": "sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/csstools"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/csstools"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@csstools/color-helpers": "^6.0.2",
|
||||||
|
"@csstools/css-calc": "^3.2.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.19.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||||
|
"@csstools/css-tokenizer": "^4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@csstools/css-parser-algorithms": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/csstools"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/csstools"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.19.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@csstools/css-tokenizer": "^4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@csstools/css-syntax-patches-for-csstree": {
|
||||||
|
"version": "1.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.4.tgz",
|
||||||
|
"integrity": "sha512-wgsqt92b7C7tQhIdPNxj0n9zuUbQlvAuI1exyzeNrOKOi62SD7ren8zqszmpVREjAOqg8cD2FqYhQfAuKjk4sw==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/csstools"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/csstools"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT-0",
|
||||||
|
"peerDependencies": {
|
||||||
|
"css-tree": "^3.2.1"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"css-tree": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@csstools/css-tokenizer": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/csstools"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/csstools"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.19.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@exodus/bytes": {
|
||||||
|
"version": "1.15.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz",
|
||||||
|
"integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@noble/hashes": "^1.8.0 || ^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@noble/hashes": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/bidi-js": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"require-from-string": "^2.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/css-tree": {
|
||||||
|
"version": "3.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
|
||||||
|
"integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mdn-data": "2.27.1",
|
||||||
|
"source-map-js": "^1.2.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/data-urls": {
|
||||||
|
"version": "7.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
|
||||||
|
"integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"whatwg-mimetype": "^5.0.0",
|
||||||
|
"whatwg-url": "^16.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/decimal.js": {
|
||||||
|
"version": "10.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
|
||||||
|
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/entities": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.19.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/html-encoding-sniffer": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@exodus/bytes": "^1.6.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/is-potential-custom-element-name": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/jsdom": {
|
||||||
|
"version": "29.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz",
|
||||||
|
"integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@asamuzakjp/css-color": "^5.1.11",
|
||||||
|
"@asamuzakjp/dom-selector": "^7.1.1",
|
||||||
|
"@bramus/specificity": "^2.4.2",
|
||||||
|
"@csstools/css-syntax-patches-for-csstree": "^1.1.3",
|
||||||
|
"@exodus/bytes": "^1.15.0",
|
||||||
|
"css-tree": "^3.2.1",
|
||||||
|
"data-urls": "^7.0.0",
|
||||||
|
"decimal.js": "^10.6.0",
|
||||||
|
"html-encoding-sniffer": "^6.0.0",
|
||||||
|
"is-potential-custom-element-name": "^1.0.1",
|
||||||
|
"lru-cache": "^11.3.5",
|
||||||
|
"parse5": "^8.0.1",
|
||||||
|
"saxes": "^6.0.0",
|
||||||
|
"symbol-tree": "^3.2.4",
|
||||||
|
"tough-cookie": "^6.0.1",
|
||||||
|
"undici": "^7.25.0",
|
||||||
|
"w3c-xmlserializer": "^5.0.0",
|
||||||
|
"webidl-conversions": "^8.0.1",
|
||||||
|
"whatwg-mimetype": "^5.0.0",
|
||||||
|
"whatwg-url": "^16.0.1",
|
||||||
|
"xml-name-validator": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || ^22.13.0 || >=24.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"canvas": "^3.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"canvas": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lru-cache": {
|
||||||
|
"version": "11.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz",
|
||||||
|
"integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==",
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"engines": {
|
||||||
|
"node": "20 || >=22"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mdn-data": {
|
||||||
|
"version": "2.27.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
|
||||||
|
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
|
||||||
|
"license": "CC0-1.0"
|
||||||
|
},
|
||||||
|
"node_modules/parse5": {
|
||||||
|
"version": "8.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
|
||||||
|
"integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"entities": "^8.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/punycode": {
|
||||||
|
"version": "2.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||||
|
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/require-from-string": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/saxes": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"xmlchars": "^2.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=v12.22.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/source-map-js": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/symbol-tree": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/tldts": {
|
||||||
|
"version": "7.4.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.2.tgz",
|
||||||
|
"integrity": "sha512-kCwffuaH8ntKtygnWe1b4BJKWiCUH30n5KfoTr6IchcXOwR7chAOFJxFrH3vjANafUYrIA4a7SDL+nn7SiR4Sw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tldts-core": "^7.4.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"tldts": "bin/cli.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tldts-core": {
|
||||||
|
"version": "7.4.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.2.tgz",
|
||||||
|
"integrity": "sha512-nwEyF4vl4RSJjwSjBUmOSxc3BFPoIFdlRthJ6e+5v9P3bHNsoD06UjuqMUspqp7vsEZ1beaHi1km+optiE17yA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/tough-cookie": {
|
||||||
|
"version": "6.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz",
|
||||||
|
"integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"tldts": "^7.0.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tr46": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"punycode": "^2.3.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/undici": {
|
||||||
|
"version": "7.27.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici/-/undici-7.27.0.tgz",
|
||||||
|
"integrity": "sha512-+t2Z/GwkZQDtu00813aP66ygViGtPHKhhoFZpQKpKrE+9jIgES+Zw+mFNaDWOVRKiuJjuqKHzD3B1sfGg8+ZOQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.18.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/w3c-xmlserializer": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"xml-name-validator": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/webidl-conversions": {
|
||||||
|
"version": "8.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
|
||||||
|
"integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/whatwg-mimetype": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/whatwg-url": {
|
||||||
|
"version": "16.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
|
||||||
|
"integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@exodus/bytes": "^1.11.0",
|
||||||
|
"tr46": "^6.0.0",
|
||||||
|
"webidl-conversions": "^8.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/xml-name-validator": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/xmlchars": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
||||||
|
"license": "MIT"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"jsdom": "^29.1.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# 前端环境变量
|
||||||
|
# 复制为 .env 或 .env.production 后修改
|
||||||
|
|
||||||
|
# API 后端地址(开发环境用 localhost,生产环境用真实域名)
|
||||||
|
VITE_API_BASE_URL=http://localhost:3006/api
|
||||||
|
|
||||||
|
# 应用名称(影响页面标题)
|
||||||
|
VITE_APP_NAME=AI磁场
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# 生产环境变量
|
||||||
|
VITE_API_BASE_URL=https://aicc.yzrcloud.cn/api
|
||||||
|
VITE_APP_NAME=AI磁场
|
||||||
|
VITE_PROD_API_HOST=https://aicc.yzrcloud.cn
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
|
||||||
|
<title>AI磁场 - 智能面试模拟 · 简历优化 · 求职辅导</title>
|
||||||
|
<meta name="description" content="AI磁场提供AI模拟面试、简历优化、实习推荐等求职服务,帮助你在真实面试前充分准备,提升求职成功率。" />
|
||||||
|
<meta name="keywords" content="AI面试,模拟面试,简历优化,求职辅导,面试练习,AI磁场" />
|
||||||
|
<meta property="og:title" content="AI磁场 - 智能面试模拟平台" />
|
||||||
|
<meta property="og:description" content="AI驱动的一站式求职准备平台,涵盖模拟面试、简历优化、岗位推荐等核心功能。" />
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta name="applicable-device" content="mobile" />
|
||||||
|
<link rel="canonical" href="https://aicc.yzrcloud.cn" />
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "WebApplication",
|
||||||
|
"name": "AI磁场",
|
||||||
|
"description": "AI驱动的求职面试模拟与简历优化平台",
|
||||||
|
"applicationCategory": "EducationalApplication",
|
||||||
|
"operatingSystem": "Web, WeChat Mini Program"
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+9099
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "zhiyin-app",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"scripts": {
|
||||||
|
"dev:mp-weixin": "uni -p mp-weixin",
|
||||||
|
"build:mp-weixin": "uni build -p mp-weixin",
|
||||||
|
"dev:h5": "uni",
|
||||||
|
"build:h5": "uni build"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@dcloudio/uni-app": "3.0.0-4060620250520001",
|
||||||
|
"@dcloudio/uni-components": "3.0.0-4060620250520001",
|
||||||
|
"@dcloudio/uni-h5": "3.0.0-4060620250520001",
|
||||||
|
"@dcloudio/uni-mp-weixin": "3.0.0-4060620250520001",
|
||||||
|
"pinia": "^2.1.7",
|
||||||
|
"uqrcodejs": "^4.0.7",
|
||||||
|
"vue": "^3.4.21"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@dcloudio/types": "^3.4.8",
|
||||||
|
"@dcloudio/uni-automator": "3.0.0-4060620250520001",
|
||||||
|
"@dcloudio/uni-cli-shared": "3.0.0-4060620250520001",
|
||||||
|
"@dcloudio/vite-plugin-uni": "3.0.0-4060620250520001",
|
||||||
|
"sass": "^1.70.0",
|
||||||
|
"typescript": "^5.3.0",
|
||||||
|
"vite": "^5.2.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onLaunch, onShow, onHide } from '@dcloudio/uni-app'
|
||||||
|
|
||||||
|
onLaunch(() => { console.log('职引 App launched') })
|
||||||
|
onShow(() => { console.log('职引 App shown') })
|
||||||
|
onHide(() => { console.log('职引 App hidden') })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* ===== Design System - Design Tokens ===== */
|
||||||
|
page {
|
||||||
|
--color-primary: #4F46E5;
|
||||||
|
--color-primary-light: #818CF8;
|
||||||
|
--color-primary-dark: #3730A3;
|
||||||
|
--color-gradient-start: #4F46E5;
|
||||||
|
--color-gradient-mid: #7C3AED;
|
||||||
|
--color-gradient-end: #A855F7;
|
||||||
|
--color-surface: #FFFFFF;
|
||||||
|
--color-bg: #F3F4F6;
|
||||||
|
--color-text: #111827;
|
||||||
|
--color-text-secondary: #6B7280;
|
||||||
|
--color-text-tertiary: #9CA3AF;
|
||||||
|
--color-border: #E5E7EB;
|
||||||
|
--color-success: #10B981;
|
||||||
|
--color-warning: #F59E0B;
|
||||||
|
--color-error: #EF4444;
|
||||||
|
--radius-sm: 12rpx;
|
||||||
|
--radius-md: 16rpx;
|
||||||
|
--radius-lg: 20rpx;
|
||||||
|
--radius-xl: 24rpx;
|
||||||
|
--radius-round: 999rpx;
|
||||||
|
--shadow-sm: 0 2rpx 8rpx rgba(0,0,0,0.04);
|
||||||
|
--shadow-md: 0 4rpx 16rpx rgba(0,0,0,0.06);
|
||||||
|
--shadow-lg: 0 8rpx 32rpx rgba(0,0,0,0.08);
|
||||||
|
--shadow-purple: 0 6rpx 20rpx rgba(79,70,229,0.25);
|
||||||
|
--font-title: 32rpx;
|
||||||
|
--font-body: 28rpx;
|
||||||
|
--font-caption: 24rpx;
|
||||||
|
--font-small: 20rpx;
|
||||||
|
--space-xs: 8rpx;
|
||||||
|
--space-sm: 12rpx;
|
||||||
|
--space-md: 16rpx;
|
||||||
|
--space-lg: 24rpx;
|
||||||
|
--space-xl: 32rpx;
|
||||||
|
--space-2xl: 48rpx;
|
||||||
|
/* Safe area */
|
||||||
|
--safe-bottom: env(safe-area-inset-bottom, 0px);
|
||||||
|
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
height: 100%;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Noto Sans SC', sans-serif;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: var(--color-text);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Global Utility Classes ===== */
|
||||||
|
|
||||||
|
/* Gradient text */
|
||||||
|
.gradient-text {
|
||||||
|
background: linear-gradient(135deg, var(--color-gradient-start), var(--color-gradient-end));
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gradient button base */
|
||||||
|
.btn-gradient {
|
||||||
|
background: linear-gradient(135deg, var(--color-gradient-start), var(--color-gradient-mid));
|
||||||
|
color: #FFFFFF;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.25s ease;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
.btn-gradient:active { opacity: 0.85; transform: scale(0.97); }
|
||||||
|
.btn-gradient[disabled] { opacity: 0.5; }
|
||||||
|
|
||||||
|
/* Outline button */
|
||||||
|
.btn-outline {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-primary);
|
||||||
|
border: 2rpx solid var(--color-primary);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card base */
|
||||||
|
.card {
|
||||||
|
background: var(--color-surface);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
transition: all 0.25s ease;
|
||||||
|
}
|
||||||
|
.card:active { transform: scale(0.98); box-shadow: var(--shadow-md); }
|
||||||
|
|
||||||
|
/* Section title */
|
||||||
|
.section-title {
|
||||||
|
font-size: var(--font-title);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
.section-desc {
|
||||||
|
font-size: var(--font-caption);
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Smooth fade-in */
|
||||||
|
.fade-in {
|
||||||
|
animation: fadeIn 0.4s ease forwards;
|
||||||
|
}
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(12rpx); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
const getApiBaseUrl = (): string => {
|
||||||
|
if (import.meta.env.VITE_API_BASE_URL) {
|
||||||
|
return import.meta.env.VITE_API_BASE_URL
|
||||||
|
}
|
||||||
|
return 'http://localhost:3006/api'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const APP_NAME = import.meta.env.VITE_APP_NAME || 'AI磁场'
|
||||||
|
|
||||||
|
export const APP_CONFIG = {
|
||||||
|
APP_NAME,
|
||||||
|
API_BASE_URL: getApiBaseUrl(),
|
||||||
|
PAGES: {
|
||||||
|
INDEX: '/pages/index/index',
|
||||||
|
INTERVIEW: '/pages/interview/interview',
|
||||||
|
REPORT: '/pages/report/report',
|
||||||
|
RESUME: '/pages/resume/resume',
|
||||||
|
HISTORY: '/pages/history/history',
|
||||||
|
MEMBER: '/pages/member/member',
|
||||||
|
PROGRESS: '/pages/progress/progress',
|
||||||
|
CONTRIBUTE: '/pages/contribute/contribute',
|
||||||
|
INTERNSHIP: '/pages/internship/internship',
|
||||||
|
USER: '/pages/user/user',
|
||||||
|
LOGIN: '/pages/login/login',
|
||||||
|
ABOUT: '/pages/about/about',
|
||||||
|
},
|
||||||
|
STORAGE_KEYS: {
|
||||||
|
TOKEN: 'token',
|
||||||
|
USER_ID: 'userId',
|
||||||
|
RESUME: 'resume',
|
||||||
|
},
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export const API_ENDPOINTS = {
|
||||||
|
USER: {
|
||||||
|
SEND_CODE: '/user/send-code',
|
||||||
|
LOGIN: '/user/login',
|
||||||
|
WX_LOGIN: '/user/wx-login',
|
||||||
|
INFO: '/user/info',
|
||||||
|
UPDATE: '/user/update',
|
||||||
|
USAGE: '/user/usage',
|
||||||
|
},
|
||||||
|
INTERVIEW: {
|
||||||
|
CREATE: '/interview/create',
|
||||||
|
ANSWER: (id: string) => `/interview/${id}/answer`,
|
||||||
|
COMPLETE: (id: string) => `/interview/${id}/complete`,
|
||||||
|
GET: (id: string) => `/interview/${id}`,
|
||||||
|
LIST: '/interview/list/all',
|
||||||
|
STATS: '/interview/stats/mine',
|
||||||
|
},
|
||||||
|
ANALYZE: {
|
||||||
|
DIAGNOSIS: '/analyze/diagnosis',
|
||||||
|
OPTIMIZE: '/analyze/optimize',
|
||||||
|
},
|
||||||
|
RESUME: {
|
||||||
|
CREATE: '/resume/create',
|
||||||
|
LIST: '/resume/list',
|
||||||
|
GET: (id: string) => `/resume/${id}`,
|
||||||
|
DELETE: (id: string) => `/resume/${id}`,
|
||||||
|
},
|
||||||
|
PROGRESS: {
|
||||||
|
GET: '/progress',
|
||||||
|
STATS: '/progress/stats',
|
||||||
|
},
|
||||||
|
CONTRIBUTION: {
|
||||||
|
CREATE: '/contribution',
|
||||||
|
MY: '/contribution/my',
|
||||||
|
BANK: (company: string, position: string) => `/contribution/company/${company}/position/${position}`,
|
||||||
|
COMPANY: (company: string) => `/contribution/company/${company}`,
|
||||||
|
},
|
||||||
|
MEMBER: {
|
||||||
|
PLANS: '/member/plans',
|
||||||
|
STATUS: '/member/status',
|
||||||
|
CREATE_ORDER: '/member/create-order',
|
||||||
|
PAY: '/member/pay',
|
||||||
|
},
|
||||||
|
DAILY_QUESTION: {
|
||||||
|
TODAY: '/daily-question',
|
||||||
|
BY_POSITION: (position: string) => `/daily-question/position/${position}`,
|
||||||
|
},
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const API_HOST = typeof window !== 'undefined' && window.location.hostname !== 'localhost' && window.location.hostname !== '127.0.0.1'
|
||||||
|
? (import.meta.env.VITE_PROD_API_HOST || window.location.origin)
|
||||||
|
: 'http://localhost:3006'
|
||||||
|
export function api(path: string): string {
|
||||||
|
return `${API_HOST}/api${path}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export default APP_CONFIG
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { createSSRApp } from 'vue';
|
||||||
|
import { createPinia } from 'pinia';
|
||||||
|
import App from './App.vue';
|
||||||
|
|
||||||
|
export function createApp() {
|
||||||
|
const app = createSSRApp(App);
|
||||||
|
app.use(createPinia());
|
||||||
|
return {
|
||||||
|
app,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "宇之然AI磁场",
|
||||||
|
"appid": "__UNI__DEV__",
|
||||||
|
"versionName": "1.0.0",
|
||||||
|
"versionCode": "100",
|
||||||
|
"description": "AI 面试模拟 - 先模拟,再面试",
|
||||||
|
"h5": {
|
||||||
|
"title": "AI磁场",
|
||||||
|
"router": {
|
||||||
|
"mode": "hash"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mp-weixin": {
|
||||||
|
"appid": "wxf466b3c3bc411ffc",
|
||||||
|
"setting": {
|
||||||
|
"urlCheck": false
|
||||||
|
},
|
||||||
|
"usingComponents": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"pages": [
|
||||||
|
{ "path": "pages/index/index", "style": { "navigationBarTitleText": "职引 - 先模拟,再上场" } },
|
||||||
|
{ "path": "pages/interview/interview", "style": { "navigationBarTitleText": "模拟面试" } },
|
||||||
|
{ "path": "pages/report/report", "style": { "navigationBarTitleText": "面试报告" } },
|
||||||
|
{ "path": "pages/member/member", "style": { "navigationBarTitleText": "会员中心" } },
|
||||||
|
{ "path": "pages/progress/progress", "style": { "navigationBarTitleText": "进步轨迹" } },
|
||||||
|
{ "path": "pages/contribute/contribute", "style": { "navigationBarTitleText": "贡献面经" } },
|
||||||
|
{ "path": "pages/login/login", "style": { "navigationBarTitleText": "登录" } },
|
||||||
|
{ "path": "pages/history/history", "style": { "navigationBarTitleText": "面试记录" } },
|
||||||
|
{ "path": "pages/user/user", "style": { "navigationBarTitleText": "我的" } },
|
||||||
|
{ "path": "pages/resume/resume", "style": { "navigationBarTitleText": "我的简历" } },
|
||||||
|
{ "path": "pages/internship/internship", "style": { "navigationBarTitleText": "实习搜索" } },
|
||||||
|
{ "path": "pages/about/about", "style": { "navigationBarTitleText": "关于" } },
|
||||||
|
{ "path": "pages/admin/admin", "style": { "navigationBarTitleText": "管理后台" } },
|
||||||
|
{ "path": "pages/result/result", "style": { "navigationBarTitleText": "优化结果" } }
|
||||||
|
],
|
||||||
|
"tabBar": {
|
||||||
|
"color": "#999999",
|
||||||
|
"selectedColor": "#4F46E5",
|
||||||
|
"backgroundColor": "#F3F4F6",
|
||||||
|
"borderStyle": "black",
|
||||||
|
"list": [
|
||||||
|
{ "pagePath": "pages/index/index", "text": "面试", "iconPath": "static/tabbar/home.png", "selectedIconPath": "static/tabbar/home-active.png" },
|
||||||
|
{ "pagePath": "pages/history/history", "text": "记录", "iconPath": "static/tabbar/history.png", "selectedIconPath": "static/tabbar/history-active.png" },
|
||||||
|
{ "pagePath": "pages/user/user", "text": "我的", "iconPath": "static/tabbar/user.png", "selectedIconPath": "static/tabbar/user-active.png" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"globalStyle": {
|
||||||
|
"navigationBarTextStyle": "black",
|
||||||
|
"navigationBarTitleText": "职引",
|
||||||
|
"navigationBarBackgroundColor": "#ffffff",
|
||||||
|
"backgroundColor": "#f5f6f7"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<template>
|
||||||
|
<view class="page">
|
||||||
|
<view class="logo-area">
|
||||||
|
<text class="logo">职引</text>
|
||||||
|
<text class="version">v1.0.0</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-section">
|
||||||
|
<text class="info-label">产品名称</text>
|
||||||
|
<text class="info-value">职引 · AI 面试模拟</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-section">
|
||||||
|
<text class="info-label">开发团队</text>
|
||||||
|
<text class="info-value">宇之然</text>
|
||||||
|
</view>
|
||||||
|
<view class="desc">
|
||||||
|
<text>职引是一款 AI 驱动的面试模拟工具,帮助求职者通过模拟真实面试、简历诊断和优化,提升面试通过率。</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page {
|
||||||
|
|
||||||
|
background: #f5f6f7;
|
||||||
|
padding: 60rpx 30rpx;
|
||||||
|
}
|
||||||
|
.logo-area {
|
||||||
|
text-align: center;
|
||||||
|
padding: 80rpx 0;
|
||||||
|
}
|
||||||
|
.logo {
|
||||||
|
font-size: 48rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
background: linear-gradient(135deg, #4F46E5, #7C3AED);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
}
|
||||||
|
.version {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
.info-section {
|
||||||
|
background: #fff;
|
||||||
|
padding: 24rpx 30rpx;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.info-label {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
.info-value {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
.desc {
|
||||||
|
margin-top: 40rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #999;
|
||||||
|
line-height: 1.8;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
<template>
|
||||||
|
<view class="page">
|
||||||
|
<view class="hero">
|
||||||
|
<text class="hero-title">管理后台</text>
|
||||||
|
<text class="hero-sub" v-if="!verified">使用管理员账号点击下方按钮验证</text>
|
||||||
|
<text class="hero-sub" v-else>欢迎回来,{{ adminName }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 登录 -->
|
||||||
|
<view class="login-area" v-if="!verified">
|
||||||
|
<button class="btn-verify" @click="doVerify">验证管理员身份</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 管理后台 -->
|
||||||
|
<view class="body" v-if="verified">
|
||||||
|
<view class="tabs">
|
||||||
|
<text class="tab" :class="{ active: tab === 'overview' }" @click="switchTab('overview')">概览</text>
|
||||||
|
<text class="tab" :class="{ active: tab === 'users' }" @click="switchTab('users')">用户管理</text>
|
||||||
|
<text class="tab" :class="{ active: tab === 'interviews' }" @click="switchTab('interviews')">面试记录</text>
|
||||||
|
<text class="tab" :class="{ active: tab === 'admins' }" @click="switchTab('admins')">管理员</text>
|
||||||
|
<text class="tab" :class="{ active: tab === 'config' }" @click="switchTab('config')">配置</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 概览 -->
|
||||||
|
<view v-if="tab === 'overview' && !loading" class="overview">
|
||||||
|
<view class="stat-cards">
|
||||||
|
<view class="stat-card">
|
||||||
|
<text class="stat-num">{{ overview.userCount }}</text>
|
||||||
|
<text class="stat-label">总用户</text>
|
||||||
|
<text class="stat-sub">今日 +{{ overview.todayUsers }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="stat-card">
|
||||||
|
<text class="stat-num">{{ overview.interviewCount }}</text>
|
||||||
|
<text class="stat-label">总面试</text>
|
||||||
|
<text class="stat-sub">今日 +{{ overview.todayInterviews }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 用户 -->
|
||||||
|
<view v-if="tab === 'users'" class="section">
|
||||||
|
<view class="search-bar">
|
||||||
|
<input v-model="userKeyword" placeholder="搜索手机号/昵称" class="search-input" @confirm="loadUsers" />
|
||||||
|
<button class="search-btn" @click="loadUsers">搜索</button>
|
||||||
|
</view>
|
||||||
|
<view class="user-list" v-if="!usersLoading">
|
||||||
|
<view class="user-row" v-for="u in users" :key="u._id">
|
||||||
|
<text class="user-phone">{{ u.phone || '--' }}</text>
|
||||||
|
<text class="user-name">{{ u.nickname || '--' }}</text>
|
||||||
|
<text class="user-plan" :class="{ vip: u.plan === 'vip' }">{{ u.plan === 'vip' ? '会员' : '免费' }}</text>
|
||||||
|
<text class="user-remaining">剩{{ u.remaining || 0 }}次</text>
|
||||||
|
<text class="user-vip-btn" v-if="u.plan !== 'vip'" @click="setVip(u._id)">设为会员</text>
|
||||||
|
</view>
|
||||||
|
<text class="load-more" v-if="usersTotal > users.length" @click="loadMoreUsers">加载更多</text>
|
||||||
|
</view>
|
||||||
|
<text class="loading-text" v-if="usersLoading">加载中...</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 面试 -->
|
||||||
|
<view v-if="tab === 'interviews'" class="section">
|
||||||
|
<view class="iv-list" v-if="!ivLoading">
|
||||||
|
<view class="iv-row" v-for="iv in interviews" :key="iv._id">
|
||||||
|
<text class="iv-pos">{{ iv.position }}</text>
|
||||||
|
<text class="iv-user">{{ iv.userId?.phone || iv.userId?.nickname || '--' }}</text>
|
||||||
|
<text class="iv-status" :class="{ done: iv.status === 'completed' }">{{ iv.status === 'completed' ? '已完成' : '进行中' }}</text>
|
||||||
|
<text class="iv-questions">{{ iv.questionCount || 0 }}题</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<text class="loading-text" v-if="ivLoading">加载中...</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- 套餐配置 -->
|
||||||
|
<view v-if="tab === 'config'" class="section">
|
||||||
|
<view class="config-card" v-if="!cfgLoading">
|
||||||
|
<view class="cfg-title">面试限制</view>
|
||||||
|
<view class="cfg-row"><text>免费版每场最大轮次</text><text class="cfg-val">{{ memberConfig.interview.maxRoundsFree }}</text></view>
|
||||||
|
<view class="cfg-row"><text>会员每场最大轮次</text><text class="cfg-val">{{ memberConfig.interview.maxRoundsVip }}</text></view>
|
||||||
|
<view class="cfg-row"><text>免费版每日面试次数</text><text class="cfg-val">{{ memberConfig.interview.dailyFreeLimit }}</text></view>
|
||||||
|
</view>
|
||||||
|
<view class="config-card" v-if="!cfgLoading">
|
||||||
|
<view class="cfg-title">诊断与优化限制</view>
|
||||||
|
<view class="cfg-row"><text>免费版每日诊断次数</text><text class="cfg-val">{{ memberConfig.diagnosis.dailyFreeLimit }}</text></view>
|
||||||
|
<view class="cfg-row"><text>免费版每日优化次数</text><text class="cfg-val">{{ memberConfig.optimize.dailyFreeLimit }}</text></view>
|
||||||
|
</view>
|
||||||
|
<view class="config-card" v-if="!cfgLoading">
|
||||||
|
<view class="cfg-title">价格</view>
|
||||||
|
<view class="cfg-row"><text>月度会员</text><text class="cfg-val">¥{{ (memberConfig.price.monthly / 100).toFixed(0) }}</text></view>
|
||||||
|
</view>
|
||||||
|
<view class="empty-text" v-if="cfgLoading">加载中...</view>
|
||||||
|
</view>
|
||||||
|
<!-- 管理员 -->
|
||||||
|
<view v-if="tab === 'admins'" class="section">
|
||||||
|
<view class="search-bar">
|
||||||
|
<input v-model="adminKeyword" placeholder="搜索用户ID或手机号设为管理员" class="search-input" @confirm="searchAdmin" />
|
||||||
|
<button class="search-btn" @click="searchAdmin">搜索</button>
|
||||||
|
</view>
|
||||||
|
<view class="section-label">当前管理员</view>
|
||||||
|
<view class="user-list">
|
||||||
|
<view class="admin-row" v-for="a in adminList" :key="a._id">
|
||||||
|
<text class="admin-phone">{{ a.phone || '--' }}</text>
|
||||||
|
<text class="admin-name">{{ a.nickname || '--' }}</text>
|
||||||
|
<text class="admin-badge" v-if="a.isSystemAdmin">系统</text>
|
||||||
|
</view>
|
||||||
|
<text class="empty-text" v-if="adminList.length === 0">暂无管理员</text>
|
||||||
|
</view>
|
||||||
|
<view class="section-label" v-if="searchResult">搜索结果</view>
|
||||||
|
<view class="user-list" v-if="searchResult">
|
||||||
|
<view class="admin-row">
|
||||||
|
<text class="admin-phone">{{ searchResult.phone || '--' }}</text>
|
||||||
|
<text class="admin-name">{{ searchResult.nickname || '--' }}</text>
|
||||||
|
<text class="admin-set-btn" v-if="searchResult.role !== 'admin'" @click="setAdmin(searchResult._id)">设为管理员</text>
|
||||||
|
<text class="admin-set-btn done" v-else>已是管理员</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { api } from '../../config'
|
||||||
|
|
||||||
|
const verified = ref(false)
|
||||||
|
const adminName = ref('')
|
||||||
|
const tab = ref('overview')
|
||||||
|
const loading = ref(false)
|
||||||
|
const usersLoading = ref(false)
|
||||||
|
const ivLoading = ref(false)
|
||||||
|
const userKeyword = ref('')
|
||||||
|
const usersPage = ref(1)
|
||||||
|
|
||||||
|
const overview = ref({ userCount: 0, interviewCount: 0, todayUsers: 0, todayInterviews: 0 })
|
||||||
|
const users = ref([])
|
||||||
|
const usersTotal = ref(0)
|
||||||
|
const interviews = ref([])
|
||||||
|
const adminKeyword = ref('')
|
||||||
|
const adminList = ref([])
|
||||||
|
const searchResult = ref(null)
|
||||||
|
const memberConfig = ref({ interview: { maxRoundsFree: 5, maxRoundsVip: 10, dailyFreeLimit: 3 }, diagnosis: { dailyFreeLimit: 2 }, optimize: { dailyFreeLimit: 2 }, price: { monthly: 2900 } })
|
||||||
|
const cfgLoading = ref(false)
|
||||||
|
|
||||||
|
const token = () => uni.getStorageSync('token') || ''
|
||||||
|
|
||||||
|
const apiAdmin = (path, opts = {}) => {
|
||||||
|
return uni.request({
|
||||||
|
url: api('/admin' + path),
|
||||||
|
method: opts.method || 'GET',
|
||||||
|
header: { 'Authorization': `Bearer ${token()}`, 'Content-Type': 'application/json', ...opts.headers },
|
||||||
|
data: opts.body || opts.data,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const doVerify = async () => {
|
||||||
|
const t = token()
|
||||||
|
if (!t) { uni.navigateTo({ url: '/pages/login/login' }); return }
|
||||||
|
try {
|
||||||
|
const res = await apiAdmin('/check')
|
||||||
|
if (res.statusCode === 200 && res.data?.isAdmin) {
|
||||||
|
adminName.value = '管理员'
|
||||||
|
verified.value = true
|
||||||
|
loadOverview()
|
||||||
|
} else throw new Error('无管理员权限')
|
||||||
|
} catch (e) {
|
||||||
|
uni.showToast({ title: '当前账号非管理员,无权限访问', icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadOverview = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await apiAdmin('/overview')
|
||||||
|
if (res.statusCode === 200) overview.value = res.data
|
||||||
|
} catch (e) { console.error(e) }
|
||||||
|
finally { loading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const switchTab = (t) => {
|
||||||
|
tab.value = t
|
||||||
|
if (t === 'users' && users.value.length === 0) loadUsers()
|
||||||
|
if (t === 'interviews' && interviews.value.length === 0) loadInterviews()
|
||||||
|
if (t === 'admins' && adminList.value.length === 0) loadAdmins()
|
||||||
|
if (t === 'config') loadConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadUsers = async () => {
|
||||||
|
usersLoading.value = true
|
||||||
|
usersPage.value = 1
|
||||||
|
try {
|
||||||
|
const res = await apiAdmin('/users?keyword=' + encodeURIComponent(userKeyword.value) + '&page=1&limit=20')
|
||||||
|
if (res.statusCode === 200) { users.value = res.data.users || []; usersTotal.value = res.data.total || 0 }
|
||||||
|
} catch (e) { console.error(e) }
|
||||||
|
finally { usersLoading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadMoreUsers = async () => {
|
||||||
|
usersPage.value++
|
||||||
|
try {
|
||||||
|
const res = await apiAdmin('/users?keyword=' + encodeURIComponent(userKeyword.value) + '&page=' + usersPage.value + '&limit=20')
|
||||||
|
if (res.statusCode === 200) users.value = [...users.value, ...(res.data.users || [])]
|
||||||
|
} catch (e) { console.error(e) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadInterviews = async () => {
|
||||||
|
ivLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await apiAdmin('/interviews?page=1&limit=20')
|
||||||
|
if (res.statusCode === 200) interviews.value = res.data.interviews || []
|
||||||
|
} catch (e) { console.error(e) }
|
||||||
|
finally { ivLoading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadConfig = async () => {
|
||||||
|
cfgLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await apiAdmin('/config')
|
||||||
|
if (res.statusCode === 200) memberConfig.value = res.data
|
||||||
|
} catch(e) { console.error(e) }
|
||||||
|
finally { cfgLoading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadAdmins = async () => {
|
||||||
|
try {
|
||||||
|
const res = await apiAdmin('/admins')
|
||||||
|
if (res.statusCode === 200) adminList.value = res.data.admins || []
|
||||||
|
} catch(e) { console.error(e) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchAdmin = async () => {
|
||||||
|
if (!adminKeyword.value.trim()) return
|
||||||
|
try {
|
||||||
|
const res = await apiAdmin('/users?keyword=' + encodeURIComponent(adminKeyword.value) + '&limit=1')
|
||||||
|
if (res.statusCode === 200 && res.data.users?.length > 0) {
|
||||||
|
searchResult.value = res.data.users[0]
|
||||||
|
} else {
|
||||||
|
uni.showToast({ title: '未找到该用户', icon: 'none' })
|
||||||
|
searchResult.value = null
|
||||||
|
}
|
||||||
|
} catch { searchResult.value = null }
|
||||||
|
}
|
||||||
|
|
||||||
|
const setAdmin = async (targetUserId) => {
|
||||||
|
uni.showModal({
|
||||||
|
title: '设为管理员', content: '确定将该用户设为管理员?', success: async (r) => {
|
||||||
|
if (!r.confirm) return
|
||||||
|
try {
|
||||||
|
const res = await apiAdmin('/set-admin', { method: 'POST', data: { userId: targetUserId } })
|
||||||
|
if (res.statusCode === 200) {
|
||||||
|
uni.showToast({ title: '已设为管理员', icon: 'success' })
|
||||||
|
searchResult.value = null
|
||||||
|
adminKeyword.value = ''
|
||||||
|
loadAdmins()
|
||||||
|
} else throw new Error()
|
||||||
|
} catch { uni.showToast({ title: '操作失败', icon: 'none' }) }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const setVip = async (targetUserId) => {
|
||||||
|
uni.showModal({
|
||||||
|
title: '设为会员', content: '确定将该用户升级为月度会员?', success: async (r) => {
|
||||||
|
if (!r.confirm) return
|
||||||
|
try {
|
||||||
|
const res = await apiAdmin('/set-vip', { method: 'POST', data: { userId: targetUserId } })
|
||||||
|
if (res.statusCode === 200) {
|
||||||
|
uni.showToast({ title: '已设为会员', icon: 'success' })
|
||||||
|
loadUsers()
|
||||||
|
} else throw new Error()
|
||||||
|
} catch { uni.showToast({ title: '操作失败', icon: 'none' }) }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page { background: var(--color-bg); min-height: 100vh; }
|
||||||
|
.hero { background: linear-gradient(135deg, var(--color-gradient-start), var(--color-gradient-mid), var(--color-gradient-end)); padding: 48rpx 32rpx 72rpx; border-radius: 0 0 48rpx 48rpx; }
|
||||||
|
.hero-title { font-size: 40rpx; font-weight: 700; color: #FFF; }
|
||||||
|
.hero-sub { font-size: 22rpx; color: rgba(255,255,255,0.7); margin-top: 8rpx; display: block; }
|
||||||
|
.login-area { padding: 32rpx; margin-top: -40rpx; display: flex; flex-direction: column; gap: 16rpx; }
|
||||||
|
.admin-input { height: 72rpx; background: #FFF; border: 2rpx solid var(--color-border); border-radius: var(--radius-sm); padding: 0 20rpx; font-size: 24rpx; }
|
||||||
|
.btn-verify { height: 72rpx; background: linear-gradient(135deg, var(--color-gradient-start), var(--color-gradient-mid)); color: #FFF; border-radius: var(--radius-sm); font-size: 26rpx; border: none; }
|
||||||
|
.body { padding: 20rpx 32rpx 48rpx; margin-top: -40rpx; }
|
||||||
|
.tabs { display: flex; gap: 8rpx; background: #FFF; border-radius: var(--radius-md); padding: 6rpx; margin-bottom: 20rpx; }
|
||||||
|
.tab { flex: 1; text-align: center; padding: 14rpx; font-size: 24rpx; color: var(--color-text-secondary); border-radius: var(--radius-sm); }
|
||||||
|
.tab.active { background: var(--color-primary); color: #FFF; font-weight: 600; }
|
||||||
|
.stat-cards { display: flex; gap: 16rpx; }
|
||||||
|
.stat-card { flex: 1; background: #FFF; border-radius: var(--radius-lg); padding: 28rpx; text-align: center; box-shadow: var(--shadow-sm); }
|
||||||
|
.stat-num { font-size: 48rpx; font-weight: 800; color: var(--color-primary); display: block; }
|
||||||
|
.stat-label { font-size: 22rpx; color: var(--color-text-secondary); margin-top: 8rpx; display: block; }
|
||||||
|
.stat-sub { font-size: 20rpx; color: var(--color-success); margin-top: 4rpx; display: block; }
|
||||||
|
.search-bar { display: flex; gap: 12rpx; margin-bottom: 16rpx; }
|
||||||
|
.search-input { flex: 1; height: 64rpx; background: #FFF; border: 2rpx solid var(--color-border); border-radius: var(--radius-sm); padding: 0 16rpx; font-size: 24rpx; }
|
||||||
|
.search-btn { height: 64rpx; padding: 0 24rpx; background: var(--color-primary); color: #FFF; border-radius: var(--radius-sm); font-size: 24rpx; border: none; }
|
||||||
|
.user-row { background: #FFF; padding: 20rpx; border-radius: var(--radius-sm); margin-bottom: 8rpx; display: flex; flex-wrap: wrap; gap: 8rpx; }
|
||||||
|
.user-phone { font-size: 24rpx; font-weight: 600; color: var(--color-text); }
|
||||||
|
.user-name { font-size: 22rpx; color: var(--color-text-secondary); }
|
||||||
|
.user-plan { font-size: 20rpx; background: #EEF2FF; color: var(--color-primary); padding: 2rpx 12rpx; border-radius: var(--radius-round); }
|
||||||
|
.user-remaining { font-size: 20rpx; color: var(--color-text-tertiary); }
|
||||||
|
.loading-text { text-align: center; padding: 40rpx; color: var(--color-text-tertiary); font-size: 24rpx; }
|
||||||
|
.load-more { text-align: center; padding: 20rpx; color: var(--color-primary); font-size: 24rpx; display: block; }
|
||||||
|
.iv-row { background: #FFF; padding: 20rpx; border-radius: var(--radius-sm); margin-bottom: 8rpx; display: flex; flex-wrap: wrap; gap: 8rpx; align-items: center; }
|
||||||
|
.iv-pos { font-size: 24rpx; font-weight: 600; color: var(--color-text); }
|
||||||
|
.iv-user { font-size: 22rpx; color: var(--color-text-secondary); }
|
||||||
|
.iv-status { font-size: 20rpx; background: #FFF7ED; color: var(--color-warning); padding: 2rpx 12rpx; border-radius: var(--radius-round); }
|
||||||
|
.iv-status.done { background: #ECFDF5; color: var(--color-success); }
|
||||||
|
.iv-questions { font-size: 20rpx; color: var(--color-text-tertiary); }
|
||||||
|
.section-label { font-size: 24rpx; font-weight: 600; color: var(--color-text); margin-bottom: 12rpx; margin-top: 12rpx; }
|
||||||
|
.admin-row { background: #FFF; padding: 20rpx; border-radius: var(--radius-sm); margin-bottom: 8rpx; display: flex; flex-wrap: wrap; gap: 8rpx; align-items: center; }
|
||||||
|
.admin-phone { font-size: 24rpx; font-weight: 600; color: var(--color-text); }
|
||||||
|
.admin-name { font-size: 22rpx; color: var(--color-text-secondary); flex: 1; }
|
||||||
|
.admin-set-btn { font-size: 22rpx; color: var(--color-primary); padding: 4rpx 16rpx; border: 2rpx solid var(--color-primary); border-radius: var(--radius-round); }
|
||||||
|
.admin-set-btn.done { color: var(--color-success); border-color: var(--color-success); }
|
||||||
|
.admin-badge { font-size: 18rpx; background: var(--color-primary); color: #FFF; padding: 2rpx 10rpx; border-radius: var(--radius-round); }
|
||||||
|
.empty-text { text-align: center; padding: 20rpx; color: var(--color-text-tertiary); font-size: 22rpx; display: block; }
|
||||||
|
.config-card { background: #FFF; border-radius: var(--radius-sm); padding: 20rpx; margin-bottom: 12rpx; }
|
||||||
|
.cfg-title { font-size: 24rpx; font-weight: 600; color: var(--color-text); margin-bottom: 12rpx; }
|
||||||
|
.cfg-row { display: flex; justify-content: space-between; padding: 8rpx 0; font-size: 22rpx; color: var(--color-text-secondary); }
|
||||||
|
.cfg-val { font-weight: 600; color: var(--color-primary); }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
<template>
|
||||||
|
<view class="page fade-in">
|
||||||
|
<view class="hero">
|
||||||
|
<text class="hero-title">贡献面经</text>
|
||||||
|
<text class="hero-sub">分享你的面试经验,帮助更多同学</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="form.company" placeholder="如:腾讯、字节跳动、阿里巴巴" />
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="form-group">
|
||||||
|
<text class="form-label">面试岗位 <text class="required">*</text></text>
|
||||||
|
<input class="form-input" v-model="form.position" placeholder="如:前端工程师、产品经理" />
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="form-group">
|
||||||
|
<text class="form-label">面试轮次</text>
|
||||||
|
<input class="form-input" v-model="form.rounds" placeholder="如:一面(技术面)" />
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="form-group">
|
||||||
|
<text class="form-label">遇到的面试题(每行一题)</text>
|
||||||
|
<textarea
|
||||||
|
class="form-textarea"
|
||||||
|
v-model="questionsText"
|
||||||
|
placeholder="把你记得的面试题写下来,帮大家提前准备: 1. 请介绍一下你最熟悉的项目 2. 解释一下闭包的原理 ..."
|
||||||
|
:maxlength="2000"
|
||||||
|
></textarea>
|
||||||
|
<text class="form-hint">{{ questionsText.length }}/2000</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="form-group">
|
||||||
|
<text class="form-label">面试感受/经验</text>
|
||||||
|
<textarea
|
||||||
|
class="form-textarea"
|
||||||
|
v-model="form.experience"
|
||||||
|
placeholder="分享一下整体感受、面试官风格、需要特别注意的地方..."
|
||||||
|
:maxlength="1000"
|
||||||
|
></textarea>
|
||||||
|
<text class="form-hint">{{ form.experience.length }}/1000</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="form-group">
|
||||||
|
<text class="form-label">标签(可选)</text>
|
||||||
|
<view class="tag-input-area">
|
||||||
|
<view class="tag-list">
|
||||||
|
<view
|
||||||
|
v-for="tag in presetTags"
|
||||||
|
:key="tag"
|
||||||
|
class="tag-item"
|
||||||
|
:class="{ selected: form.tags.includes(tag) }"
|
||||||
|
@click="toggleTag(tag)"
|
||||||
|
>{{ tag }}</view>
|
||||||
|
</view>
|
||||||
|
<input class="form-input" v-model="customTag" placeholder="自定义标签,回车添加" @confirm="addCustomTag" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<button class="btn-submit" @click="submit" :disabled="submitting">
|
||||||
|
{{ submitting ? '提交中...' : '提交面经' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<view class="success-box" v-if="submitted">
|
||||||
|
<text class="success-icon">🎉</text>
|
||||||
|
<text class="success-text">感谢你的分享!你的面经将帮助更多同学准备面试</text>
|
||||||
|
<text class="success-action" @click="goBack">返回</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="bottom-spacer"></view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { api } from '../../config'
|
||||||
|
|
||||||
|
const props = defineProps({ interviewId: String, position: String })
|
||||||
|
const form = ref({ company: '', position: '', rounds: '', experience: '', tags: [] })
|
||||||
|
const questionsText = ref('')
|
||||||
|
const customTag = ref('')
|
||||||
|
const submitting = ref(false)
|
||||||
|
const submitted = ref(false)
|
||||||
|
|
||||||
|
const presetTags = ['算法题多', '重视项目经历', '面试官nice', '压力面', '手撕代码', '系统设计', '行为面试', '八股文']
|
||||||
|
|
||||||
|
const token = () => uni.getStorageSync('token') || ''
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (props.position) form.value.position = props.position
|
||||||
|
})
|
||||||
|
|
||||||
|
const toggleTag = (tag) => {
|
||||||
|
const idx = form.value.tags.indexOf(tag)
|
||||||
|
if (idx > -1) form.value.tags.splice(idx, 1)
|
||||||
|
else form.value.tags.push(tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
const addCustomTag = () => {
|
||||||
|
const t = customTag.value.trim()
|
||||||
|
if (t && !form.value.tags.includes(t) && form.value.tags.length < 10) {
|
||||||
|
form.value.tags.push(t)
|
||||||
|
customTag.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (!form.value.company.trim()) { uni.showToast({ title: '请填写公司名称', icon: 'none' }); return }
|
||||||
|
if (!form.value.position.trim()) { uni.showToast({ title: '请填写面试岗位', icon: 'none' }); return }
|
||||||
|
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
const questions = questionsText.value
|
||||||
|
.split('\n')
|
||||||
|
.map(q => q.replace(/^\d+[\.\、\s]+/, '').trim())
|
||||||
|
.filter(q => q.length > 0)
|
||||||
|
|
||||||
|
const res = await uni.request({
|
||||||
|
url: api('/contribution'), method: 'POST',
|
||||||
|
header: { 'Authorization': `Bearer ${token()}`, 'Content-Type': 'application/json' },
|
||||||
|
data: {
|
||||||
|
interviewId: props.interviewId || '',
|
||||||
|
company: form.value.company.trim(),
|
||||||
|
position: form.value.position.trim(),
|
||||||
|
rounds: form.value.rounds.trim(),
|
||||||
|
questions,
|
||||||
|
experience: form.value.experience.trim(),
|
||||||
|
tags: form.value.tags,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||||
|
submitted.value = true
|
||||||
|
uni.showToast({ title: '提交成功!', icon: 'success' })
|
||||||
|
} else {
|
||||||
|
uni.showToast({ title: '提交失败,请重试', icon: 'none' })
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
uni.showToast({ title: '网络错误', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const goBack = () => uni.navigateBack()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page { min-height: 100vh; background: var(--color-bg); }
|
||||||
|
.hero { background: linear-gradient(135deg, #10B981, #34D399, #6EE7B7); padding: 48rpx 32rpx 72rpx; border-radius: 0 0 48rpx 48rpx; }
|
||||||
|
.hero-title { font-size: 40rpx; font-weight: 700; color: #FFF; display: block; }
|
||||||
|
.hero-sub { font-size: 22rpx; color: rgba(255,255,255,0.8); margin-top: 8rpx; display: block; }
|
||||||
|
|
||||||
|
.form { margin: -40rpx 32rpx 0; border-radius: var(--radius-xl); padding: 32rpx; }
|
||||||
|
.form-group { margin-bottom: 28rpx; }
|
||||||
|
.form-label { font-size: 26rpx; font-weight: 600; color: var(--color-text); display: block; margin-bottom: 10rpx; }
|
||||||
|
.required { color: #EF4444; }
|
||||||
|
.form-input {
|
||||||
|
width: 100%; height: 72rpx; background: #F9FAFB; border-radius: var(--radius-md);
|
||||||
|
padding: 0 20rpx; font-size: 26rpx; box-sizing: border-box; border: 1rpx solid var(--color-border);
|
||||||
|
}
|
||||||
|
.form-textarea {
|
||||||
|
width: 100%; min-height: 180rpx; background: #F9FAFB; border-radius: var(--radius-md);
|
||||||
|
padding: 16rpx 20rpx; font-size: 26rpx; box-sizing: border-box; border: 1rpx solid var(--color-border);
|
||||||
|
}
|
||||||
|
.form-hint { font-size: 20rpx; color: var(--color-text-tertiary); text-align: right; display: block; margin-top: 6rpx; }
|
||||||
|
|
||||||
|
.tag-input-area { display: flex; flex-direction: column; gap: 12rpx; }
|
||||||
|
.tag-list { display: flex; flex-wrap: wrap; gap: 10rpx; }
|
||||||
|
.tag-item {
|
||||||
|
padding: 6rpx 18rpx; border-radius: var(--radius-round); font-size: 22rpx;
|
||||||
|
background: #F3F4F6; color: var(--color-text-secondary); border: 1rpx solid #E5E7EB;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.tag-item.selected { background: linear-gradient(135deg, #D1FAE5, #A7F3D0); color: #065F46; border-color: #10B981; }
|
||||||
|
|
||||||
|
.btn-submit {
|
||||||
|
width: 100%; height: 88rpx; border-radius: var(--radius-lg);
|
||||||
|
background: linear-gradient(135deg, #10B981, #34D399); color: #FFF;
|
||||||
|
font-size: 30rpx; font-weight: 700; border: none; margin-top: 8rpx;
|
||||||
|
}
|
||||||
|
.btn-submit[disabled] { opacity: 0.6; }
|
||||||
|
|
||||||
|
.success-box { display: flex; flex-direction: column; align-items: center; padding: 40rpx 0; gap: 12rpx; background: #ECFDF5; border-radius: var(--radius-lg); margin-top: 24rpx; }
|
||||||
|
.success-icon { font-size: 56rpx; }
|
||||||
|
.success-text { font-size: 26rpx; color: #065F46; font-weight: 600; text-align: center; line-height: 1.5; }
|
||||||
|
.success-action { font-size: 28rpx; color: var(--color-primary); font-weight: 600; padding: 10rpx 40rpx; }
|
||||||
|
.bottom-spacer { height: 40rpx; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
<template>
|
||||||
|
<view class="page fade-in">
|
||||||
|
<view class="hero">
|
||||||
|
<text class="hero-title">面试记录</text>
|
||||||
|
<text class="hero-sub">回顾你的成长轨迹</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="stats-bar card">
|
||||||
|
<view class="stat">
|
||||||
|
<text class="stat-val">{{ interviewList.length }}</text>
|
||||||
|
<text class="stat-lbl">总次数</text>
|
||||||
|
</view>
|
||||||
|
<view class="stat-sep"></view>
|
||||||
|
<view class="stat">
|
||||||
|
<text class="stat-val">{{ avgScore }}</text>
|
||||||
|
<text class="stat-lbl">平均分</text>
|
||||||
|
</view>
|
||||||
|
<view class="stat-sep"></view>
|
||||||
|
<view class="stat">
|
||||||
|
<text class="stat-val">{{ completedCount }}</text>
|
||||||
|
<text class="stat-lbl">已完成</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="filter-wrap">
|
||||||
|
<view class="filter-inner">
|
||||||
|
<view class="filter-tab" :class="{ active: filter === 'all' }" @click="filter = 'all'">全部</view>
|
||||||
|
<view class="filter-tab" :class="{ active: filter === 'completed' }" @click="filter = 'completed'">已完成</view>
|
||||||
|
<view class="filter-tab" :class="{ active: filter === 'analyzing' }" @click="filter = 'analyzing'">进行中</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="list" v-if="filteredList.length > 0">
|
||||||
|
<view class="record-card card" v-for="(item, idx) in filteredList" :key="idx">
|
||||||
|
<view class="record-top" @click="goDetail(item)">
|
||||||
|
<view class="record-icon">{{ item.score >= 80 ? '🌟' : item.score > 0 ? '📋' : '💬' }}</view>
|
||||||
|
<view class="record-body">
|
||||||
|
<view class="record-name">{{ item.position }}</view>
|
||||||
|
<text class="record-meta">{{ item.time }} · {{ item.duration }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="record-score" :class="scoreLevel(item.score)">
|
||||||
|
{{ item.score ? item.score : '--' }}
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="record-actions" v-if="item.score > 0">
|
||||||
|
<text class="rec-action" @click="goContribute(item)">💡 贡献面经</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="loading-tip" v-if="loading">加载中...</view>
|
||||||
|
<view class="empty" v-else>
|
||||||
|
<text class="empty-icon">{{ filter !== 'all' ? '🔍' : '📭' }}</text>
|
||||||
|
<text class="empty-title">{{ emptyTitle }}</text>
|
||||||
|
<text class="empty-desc">{{ emptyDesc }}</text>
|
||||||
|
<button class="empty-btn btn-gradient" @click="goInterview" v-if="filter === 'all'">开始第一次面试</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { api } from '../../config'
|
||||||
|
|
||||||
|
const filter = ref('all')
|
||||||
|
const interviewList = ref([])
|
||||||
|
const loading = ref(true)
|
||||||
|
|
||||||
|
const completedCount = computed(() => interviewList.value.filter(i => i.score > 0).length)
|
||||||
|
const avgScore = computed(() => {
|
||||||
|
const scored = interviewList.value.filter(i => i.score > 0)
|
||||||
|
if (scored.length === 0) return '--'
|
||||||
|
return Math.round(scored.reduce((s, i) => s + i.score, 0) / scored.length)
|
||||||
|
})
|
||||||
|
const filteredList = computed(() => {
|
||||||
|
if (filter.value === 'all') return interviewList.value
|
||||||
|
if (filter.value === 'completed') return interviewList.value.filter(i => i.score > 0)
|
||||||
|
return interviewList.value.filter(i => i.score === 0)
|
||||||
|
})
|
||||||
|
const emptyTitle = computed(() => {
|
||||||
|
if (filter.value === 'all') return '暂无面试记录'
|
||||||
|
if (filter.value === 'completed') return '暂无已完成面试'
|
||||||
|
return '暂无进行中面试'
|
||||||
|
})
|
||||||
|
const emptyDesc = computed(() => {
|
||||||
|
if (filter.value === 'all') return '完成你的第一场模拟面试吧'
|
||||||
|
if (filter.value === 'completed') return '继续面试练习'
|
||||||
|
return '所有面试都已评价完成'
|
||||||
|
})
|
||||||
|
|
||||||
|
const formatDate = (d) => {
|
||||||
|
if (!d) return '--'
|
||||||
|
const date = new Date(d)
|
||||||
|
return `${String(date.getMonth()+1).padStart(2,'0')}-${String(date.getDate()).padStart(2,'0')} ${String(date.getHours()).padStart(2,'0')}:${String(date.getMinutes()).padStart(2,'0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const token = uni.getStorageSync('token') || ''
|
||||||
|
if (!token) { loading.value = false; return }
|
||||||
|
try {
|
||||||
|
const res = await uni.request({ url: api('/interview/list/all'), method: 'GET', header: { 'Authorization': `Bearer ${token}` } })
|
||||||
|
if (res.statusCode === 200 && Array.isArray(res.data)) {
|
||||||
|
interviewList.value = res.data.map(i => ({
|
||||||
|
position: i.position || '通用岗位', time: formatDate(i.createdAt || i.time),
|
||||||
|
score: i.totalScore || 0, duration: `${i.questionCount || 0}题`, id: i.id,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
} catch(e) { console.error(e) }
|
||||||
|
finally { loading.value = false }
|
||||||
|
})
|
||||||
|
|
||||||
|
const scoreLevel = (s) => { if (!s) return 'pending'; if (s >= 80) return 'good'; if (s >= 60) return 'medium'; return 'poor' }
|
||||||
|
const goDetail = (item) => { if (item.id) uni.navigateTo({ url: `/pages/report/report?interviewId=${item.id}` }) }
|
||||||
|
const goContribute = (item) => {
|
||||||
|
uni.navigateTo({ url: `/pages/contribute/contribute?interviewId=${item.id}&position=${encodeURIComponent(item.position)}` })
|
||||||
|
}
|
||||||
|
const goInterview = () => uni.navigateTo({ url: '/pages/interview/interview' })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page { height: 100%; overflow-y: auto; background: var(--color-bg); }
|
||||||
|
.hero { background: linear-gradient(135deg, var(--color-gradient-start) 0%, var(--color-gradient-mid) 50%, var(--color-gradient-end) 100%); padding: 48rpx 32rpx 72rpx; border-radius: 0 0 48rpx 48rpx; }
|
||||||
|
.hero-title { font-size: 40rpx; font-weight: 700; color: #FFF; display: block; }
|
||||||
|
.hero-sub { font-size: 22rpx; color: rgba(255,255,255,0.7); margin-top: 8rpx; display: block; }
|
||||||
|
|
||||||
|
.stats-bar { display: flex; align-items: center; padding: 24rpx; margin: -40rpx 32rpx 0; position: relative; z-index: 1; border-radius: var(--radius-lg); }
|
||||||
|
.stat { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 6rpx; }
|
||||||
|
.stat-val { font-size: 36rpx; font-weight: 700; color: var(--color-primary); }
|
||||||
|
.stat-lbl { font-size: 20rpx; color: var(--color-text-tertiary); }
|
||||||
|
.stat-sep { width: 1rpx; height: 40rpx; background: var(--color-border); }
|
||||||
|
|
||||||
|
.filter-wrap { padding: 24rpx 32rpx 0; }
|
||||||
|
.filter-inner { display: flex; gap: 8rpx; background: #FFF; border-radius: var(--radius-md); padding: 6rpx; }
|
||||||
|
.filter-tab { flex: 1; text-align: center; font-size: 24rpx; color: var(--color-text-secondary); padding: 14rpx 0; border-radius: var(--radius-sm); }
|
||||||
|
.filter-tab.active { background: var(--color-primary); color: #FFF; font-weight: 600; }
|
||||||
|
|
||||||
|
.list { padding: 20rpx 32rpx 48rpx; }
|
||||||
|
.record-card { padding: 24rpx 28rpx; margin-bottom: 16rpx; border-radius: var(--radius-lg); }
|
||||||
|
.record-top { display: flex; align-items: center; gap: 16rpx; }
|
||||||
|
.record-icon { font-size: 40rpx; width: 64rpx; height: 64rpx; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
||||||
|
.record-body { flex: 1; min-width: 0; }
|
||||||
|
.record-name { font-size: 28rpx; font-weight: 600; color: var(--color-text); }
|
||||||
|
.record-meta { font-size: 20rpx; color: var(--color-text-tertiary); margin-top: 6rpx; display: block; }
|
||||||
|
.record-score { font-size: 28rpx; font-weight: 700; width: 72rpx; text-align: right; flex-shrink: 0; }
|
||||||
|
.record-score.good { color: var(--color-success); }
|
||||||
|
.record-score.medium { color: var(--color-warning); }
|
||||||
|
.record-score.poor { color: var(--color-error); }
|
||||||
|
.record-score.pending { color: var(--color-text-tertiary); }
|
||||||
|
|
||||||
|
.record-actions { margin-top: 12rpx; padding-top: 12rpx; border-top: 1rpx solid var(--color-border); display: flex; gap: 20rpx; }
|
||||||
|
.rec-action { font-size: 22rpx; color: var(--color-primary); font-weight: 500; }
|
||||||
|
|
||||||
|
.empty { display: flex; flex-direction: column; align-items: center; padding: 120rpx 32rpx 0; }
|
||||||
|
.empty-icon { font-size: 80rpx; margin-bottom: 20rpx; }
|
||||||
|
.empty-title { font-size: 28rpx; font-weight: 600; color: var(--color-text); }
|
||||||
|
.empty-desc { font-size: 22rpx; color: var(--color-text-tertiary); margin-top: 8rpx; margin-bottom: 36rpx; }
|
||||||
|
.empty-btn { padding: 18rpx 48rpx; border-radius: var(--radius-round); font-size: 26rpx; }
|
||||||
|
.loading-tip { text-align: center; padding: 80rpx; font-size: 24rpx; color: var(--color-text-tertiary); }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
<template>
|
||||||
|
<view class="page fade-in">
|
||||||
|
<view class="hero">
|
||||||
|
<text class="hero-title">{{ greeting }}</text>
|
||||||
|
<text class="hero-sub">试试下面的功能,开启你的求职练习</text>
|
||||||
|
|
||||||
|
<view class="user-card card" v-if="userInfo" @click="goProfile">
|
||||||
|
<image class="avatar" :src="userInfo.avatar || '/static/avatar-default.svg'" mode="aspectFill" />
|
||||||
|
<view class="user-meta">
|
||||||
|
<text class="user-name">{{ userInfo.nickname || '同学' }}</text>
|
||||||
|
<view class="user-tags">
|
||||||
|
<text class="tag tag-plan">{{ userInfo.plan || '免费版' }}</text>
|
||||||
|
<text class="tag tag-remaining">剩余 {{ userInfo.remaining || 0 }} 次</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<text class="arrow">›</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 功能入口 -->
|
||||||
|
<view class="section">
|
||||||
|
<view class="feature-list">
|
||||||
|
<view class="feature-primary card" @click="goInterview">
|
||||||
|
<view class="fp-left">
|
||||||
|
<view class="fp-icon fp-interview"><text class="fp-emoji">🎙️</text></view>
|
||||||
|
<view class="fp-body">
|
||||||
|
<text class="fp-name">模拟面试</text>
|
||||||
|
<text class="fp-brief">AI 面试官 · 真实场景 · 即时反馈</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<text class="fp-action">开始</text>
|
||||||
|
</view>
|
||||||
|
<view class="feature-secondary">
|
||||||
|
<view class="fs-card card" @click="goProgress">
|
||||||
|
<view class="fs-top">
|
||||||
|
<view class="fs-icon fs-progress"><text class="fs-emoji">📊</text></view>
|
||||||
|
<text class="fs-name">进步轨迹</text>
|
||||||
|
</view>
|
||||||
|
<text class="fs-brief">能力雷达 · 打卡记录 · 成长曲线</text>
|
||||||
|
</view>
|
||||||
|
<view class="fs-card card" @click="goContribute">
|
||||||
|
<view class="fs-top">
|
||||||
|
<view class="fs-icon fs-contribute"><text class="fs-emoji">💡</text></view>
|
||||||
|
<text class="fs-name">贡献面经</text>
|
||||||
|
</view>
|
||||||
|
<text class="fs-brief">分享经验 · 共建题库 · 帮更多人</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 每日一题 -->
|
||||||
|
<view class="section" v-if="dailyQuestion">
|
||||||
|
<view class="section-header">
|
||||||
|
<text class="section-title">📮 每日一题</text>
|
||||||
|
<text class="section-desc" @click="refreshDaily">换一题</text>
|
||||||
|
</view>
|
||||||
|
<view class="daily-card card">
|
||||||
|
<text class="daily-tag">{{ dailyQuestion.category || '综合' }}</text>
|
||||||
|
<text class="daily-question">{{ dailyQuestion.question }}</text>
|
||||||
|
<view class="daily-answer" v-if="showAnswer">
|
||||||
|
<text class="daily-answer-label">💡 参考思路</text>
|
||||||
|
<text class="daily-answer-text">{{ dailyQuestion.referenceAnswer }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="daily-actions">
|
||||||
|
<text class="daily-action" @click="showAnswer = !showAnswer">
|
||||||
|
{{ showAnswer ? '收起思路' : '查看思路' }}
|
||||||
|
</text>
|
||||||
|
<text class="daily-action primary" @click="goInterview">模拟练习 →</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 热门岗位 -->
|
||||||
|
<view class="section">
|
||||||
|
<view class="section-header">
|
||||||
|
<text class="section-title">热门岗位</text>
|
||||||
|
<text class="section-desc">点击直接面试</text>
|
||||||
|
</view>
|
||||||
|
<view class="position-list card" v-if="!positionsLoading">
|
||||||
|
<view class="pos-item" v-for="(pos, idx) in hotPositions" :key="idx" @click="startInterview(pos)">
|
||||||
|
<view class="pos-left">
|
||||||
|
<view class="pos-rank">{{ idx + 1 }}</view>
|
||||||
|
<view class="pos-body">
|
||||||
|
<text class="pos-name">{{ pos.name }}</text>
|
||||||
|
<text class="pos-company">{{ pos.company }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<text class="pos-salary">{{ pos.salary }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="loading-tip" v-if="positionsLoading">加载岗位中...</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="bottom-spacer"></view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { api } from '../../config'
|
||||||
|
|
||||||
|
const userInfo = ref(null)
|
||||||
|
const greeting = ref('')
|
||||||
|
const hotPositions = ref([])
|
||||||
|
const positionsLoading = ref(true)
|
||||||
|
const dailyQuestion = ref(null)
|
||||||
|
const showAnswer = ref(false)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try { const s = uni.getStorageSync('userInfo'); if (s) userInfo.value = JSON.parse(s) } catch (e) {}
|
||||||
|
const h = new Date().getHours()
|
||||||
|
if (h < 6) greeting.value = '夜深了,早点休息 🌙'
|
||||||
|
else if (h < 12) greeting.value = '早上好 ☀️'
|
||||||
|
else if (h < 14) greeting.value = '中午好 🌤'
|
||||||
|
else if (h < 18) greeting.value = '下午好 🌥'
|
||||||
|
else greeting.value = '晚上好 🌆'
|
||||||
|
|
||||||
|
// 每日一题
|
||||||
|
try {
|
||||||
|
const t = uni.getStorageSync('token')
|
||||||
|
if (t) {
|
||||||
|
const qres = await uni.request({
|
||||||
|
url: api('/daily-question'), method: 'GET',
|
||||||
|
header: { 'Authorization': `Bearer ${t}` }
|
||||||
|
})
|
||||||
|
if (qres.statusCode === 200 && qres.data) {
|
||||||
|
dailyQuestion.value = qres.data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) { /* silent */ }
|
||||||
|
|
||||||
|
// 热门岗位
|
||||||
|
try {
|
||||||
|
const res = await uni.request({ url: api('/positions/hot'), method: 'GET' })
|
||||||
|
if (res.statusCode === 200) hotPositions.value = res.data || []
|
||||||
|
} catch (e) { console.error(e) }
|
||||||
|
finally { positionsLoading.value = false }
|
||||||
|
})
|
||||||
|
|
||||||
|
const refreshDaily = () => { showAnswer.value = false; /* trigger reload */ }
|
||||||
|
|
||||||
|
const goProfile = () => uni.switchTab({ url: '/pages/user/user' })
|
||||||
|
const goInterview = () => uni.navigateTo({ url: '/pages/interview/interview' })
|
||||||
|
const goProgress = () => uni.navigateTo({ url: '/pages/progress/progress' })
|
||||||
|
const goContribute = () => uni.navigateTo({ url: '/pages/contribute/contribute' })
|
||||||
|
|
||||||
|
const startInterview = (pos) => uni.navigateTo({ url: `/pages/interview/interview?position=${encodeURIComponent(pos.name)}` })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page { height: 100%; overflow-y: auto; background: var(--color-bg); }
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
background: linear-gradient(135deg, var(--color-gradient-start) 0%, var(--color-gradient-mid) 50%, var(--color-gradient-end) 100%);
|
||||||
|
padding: 48rpx 32rpx 72rpx; border-radius: 0 0 48rpx 48rpx;
|
||||||
|
}
|
||||||
|
.hero-title { font-size: 40rpx; font-weight: 700; color: #FFF; display: block; line-height: 1.3; }
|
||||||
|
.hero-sub { font-size: 22rpx; color: rgba(255,255,255,0.7); margin-top: 8rpx; display: block; }
|
||||||
|
|
||||||
|
.user-card {
|
||||||
|
background: rgba(255,255,255,0.95); backdrop-filter: blur(20rpx);
|
||||||
|
border-radius: var(--radius-xl); padding: 24rpx 28rpx;
|
||||||
|
display: flex; align-items: center; margin-top: 24rpx;
|
||||||
|
box-shadow: 0 8rpx 32rpx rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
.avatar { width: 88rpx; height: 88rpx; border-radius: 50%; margin-right: 20rpx; border: 3rpx solid var(--color-primary-light); flex-shrink: 0; }
|
||||||
|
.user-meta { flex: 1; min-width: 0; }
|
||||||
|
.user-name { font-size: 30rpx; font-weight: 600; color: var(--color-text); }
|
||||||
|
.user-tags { display: flex; gap: 10rpx; margin-top: 10rpx; }
|
||||||
|
.tag { font-size: 20rpx; padding: 4rpx 14rpx; border-radius: var(--radius-round); font-weight: 500; }
|
||||||
|
.tag-plan { background: #EEF2FF; color: var(--color-primary); }
|
||||||
|
.tag-remaining { background: #ECFDF5; color: var(--color-success); }
|
||||||
|
.arrow { font-size: 36rpx; color: #D1D5DB; margin-left: 12rpx; }
|
||||||
|
|
||||||
|
.section { padding: 32rpx 32rpx 0; }
|
||||||
|
.section:first-of-type { margin-top: -40rpx; padding-top: 0; }
|
||||||
|
.section-header { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 20rpx; }
|
||||||
|
.section-title { font-size: 30rpx; font-weight: 700; color: var(--color-text); }
|
||||||
|
.section-desc { font-size: 22rpx; color: var(--color-primary); }
|
||||||
|
|
||||||
|
.feature-list { display: flex; flex-direction: column; gap: 16rpx; }
|
||||||
|
.feature-primary {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 24rpx 28rpx; border-radius: var(--radius-lg);
|
||||||
|
background: linear-gradient(135deg, #EEF2FF, #DBEAFE);
|
||||||
|
}
|
||||||
|
.fp-left { display: flex; align-items: center; gap: 20rpx; flex: 1; }
|
||||||
|
.fp-icon { width: 64rpx; height: 64rpx; border-radius: 18rpx; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
||||||
|
.fp-interview { background: linear-gradient(135deg, var(--color-gradient-start), var(--color-gradient-mid)); }
|
||||||
|
.fp-emoji { font-size: 32rpx; }
|
||||||
|
.fp-body { flex: 1; min-width: 0; }
|
||||||
|
.fp-name { font-size: 30rpx; font-weight: 700; color: var(--color-text); }
|
||||||
|
.fp-brief { font-size: 20rpx; color: var(--color-text-secondary); margin-top: 4rpx; display: block; }
|
||||||
|
.fp-action { font-size: 28rpx; color: var(--color-primary); font-weight: 600; flex-shrink: 0; }
|
||||||
|
.feature-secondary { display: grid; grid-template-columns: 1fr 1fr; gap: 16rpx; }
|
||||||
|
.fs-card { padding: 20rpx; border-radius: var(--radius-lg); }
|
||||||
|
.fs-top { display: flex; align-items: center; gap: 10rpx; }
|
||||||
|
.fs-icon { width: 44rpx; height: 44rpx; border-radius: 12rpx; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
||||||
|
.fs-emoji { font-size: 20rpx; }
|
||||||
|
.fs-name { font-size: 26rpx; font-weight: 600; color: var(--color-text); }
|
||||||
|
.fs-brief { font-size: 18rpx; color: var(--color-text-secondary); margin-top: 10rpx; display: block; }
|
||||||
|
.fs-progress { background: linear-gradient(135deg, #EEF2FF, #C7D2FE); }
|
||||||
|
.fs-contribute { background: linear-gradient(135deg, #ECFDF5, #A7F3D0); }
|
||||||
|
|
||||||
|
/* 每日一题 */
|
||||||
|
.daily-card { padding: 24rpx; border-radius: var(--radius-lg); }
|
||||||
|
.daily-tag { display: inline-block; padding: 4rpx 14rpx; background: #EEF2FF; color: var(--color-primary); font-size: 20rpx; border-radius: var(--radius-round); margin-bottom: 12rpx; }
|
||||||
|
.daily-question { font-size: 28rpx; font-weight: 600; color: var(--color-text); line-height: 1.6; display: block; }
|
||||||
|
.daily-answer { margin-top: 16rpx; padding: 20rpx; background: #F9FAFB; border-radius: var(--radius-md); }
|
||||||
|
.daily-answer-label { font-size: 22rpx; font-weight: 600; color: var(--color-text-secondary); display: block; margin-bottom: 8rpx; }
|
||||||
|
.daily-answer-text { font-size: 24rpx; color: var(--color-text-secondary); line-height: 1.6; }
|
||||||
|
.daily-actions { display: flex; justify-content: space-between; margin-top: 16rpx; padding-top: 16rpx; border-top: 1rpx solid var(--color-border); }
|
||||||
|
.daily-action { font-size: 24rpx; color: var(--color-text-secondary); }
|
||||||
|
.daily-action.primary { color: var(--color-primary); font-weight: 600; }
|
||||||
|
|
||||||
|
.position-list { border-radius: var(--radius-lg); overflow: hidden; }
|
||||||
|
.pos-item { padding: 24rpx 28rpx; border-bottom: 1rpx solid var(--color-border); display: flex; justify-content: space-between; align-items: center; }
|
||||||
|
.pos-item:last-child { border-bottom: none; }
|
||||||
|
.pos-left { display: flex; align-items: center; gap: 16rpx; }
|
||||||
|
.pos-rank { width: 40rpx; height: 40rpx; border-radius: 10rpx; background: #F3F4F6; display: flex; align-items: center; justify-content: center; font-size: 22rpx; font-weight: 700; color: var(--color-primary); flex-shrink: 0; }
|
||||||
|
.pos-body { display: flex; flex-direction: column; }
|
||||||
|
.pos-name { font-size: 28rpx; font-weight: 600; color: var(--color-text); }
|
||||||
|
.pos-company { font-size: 22rpx; color: var(--color-text-tertiary); margin-top: 4rpx; }
|
||||||
|
.pos-salary { font-size: 24rpx; color: var(--color-primary); font-weight: 600; }
|
||||||
|
.loading-tip { text-align: center; padding: 40rpx; font-size: 24rpx; color: var(--color-text-tertiary); background: #FFF; border-radius: var(--radius-lg); }
|
||||||
|
.bottom-spacer { height: 40rpx; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<template>
|
||||||
|
<view class="page">
|
||||||
|
<view class="hero">
|
||||||
|
<text class="hero-title">实习推荐</text>
|
||||||
|
<text class="hero-sub">热门实习岗位,点击直接模拟面试</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="body">
|
||||||
|
<view class="section-title">🔥 热门实习</view>
|
||||||
|
<view class="position-list card">
|
||||||
|
<view class="pos-item" v-for="(item, idx) in positions" :key="idx" @click="startInterview(item)">
|
||||||
|
<view class="pos-left">
|
||||||
|
<view class="pos-rank">{{ idx + 1 }}</view>
|
||||||
|
<view class="pos-body">
|
||||||
|
<text class="pos-name">{{ item.name }}</text>
|
||||||
|
<text class="pos-company">{{ item.company }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<text class="pos-salary">{{ item.salary }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="empty" v-if="!loading && positions.length === 0">
|
||||||
|
<text class="empty-text">暂无实习岗位数据</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { api } from '../../config'
|
||||||
|
|
||||||
|
const positions = ref([])
|
||||||
|
const loading = ref(true)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const res = await uni.request({ url: api('/positions/hot'), method: 'GET' })
|
||||||
|
if (res.statusCode === 200) positions.value = res.data || []
|
||||||
|
} catch(e) { console.error(e) }
|
||||||
|
finally { loading.value = false }
|
||||||
|
})
|
||||||
|
|
||||||
|
const startInterview = (pos) => uni.navigateTo({ url: `/pages/interview/interview?position=${encodeURIComponent(pos.name)}` })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page { min-height: 100vh; background: var(--color-bg); }
|
||||||
|
.hero { background: linear-gradient(135deg, var(--color-gradient-start) 0%, var(--color-gradient-mid) 50%, var(--color-gradient-end) 100%); padding: 48rpx 32rpx 72rpx; border-radius: 0 0 48rpx 48rpx; }
|
||||||
|
.hero-title { font-size: 40rpx; font-weight: 700; color: #FFFFFF; display: block; }
|
||||||
|
.hero-sub { font-size: 22rpx; color: rgba(255,255,255,0.7); margin-top: 8rpx; display: block; }
|
||||||
|
.body { padding: 32rpx; margin-top: -40rpx; }
|
||||||
|
.section-title { font-size: 28rpx; font-weight: 600; color: var(--color-text); margin-bottom: 16rpx; }
|
||||||
|
.position-list { border-radius: var(--radius-lg); overflow: hidden; }
|
||||||
|
.pos-item { padding: 24rpx 28rpx; border-bottom: 1rpx solid var(--color-border); display: flex; justify-content: space-between; align-items: center; }
|
||||||
|
.pos-item:active { background: #F9FAFB; }
|
||||||
|
.pos-item:last-child { border-bottom: none; }
|
||||||
|
.pos-left { display: flex; align-items: center; gap: 16rpx; }
|
||||||
|
.pos-rank { width: 40rpx; height: 40rpx; border-radius: 10rpx; background: #F3F4F6; display: flex; align-items: center; justify-content: center; font-size: 22rpx; font-weight: 700; color: var(--color-primary); flex-shrink: 0; }
|
||||||
|
.pos-body { display: flex; flex-direction: column; }
|
||||||
|
.pos-name { font-size: 28rpx; font-weight: 600; color: var(--color-text); }
|
||||||
|
.pos-company { font-size: 22rpx; color: var(--color-text-tertiary); margin-top: 4rpx; }
|
||||||
|
.pos-salary { font-size: 24rpx; color: var(--color-primary); font-weight: 600; }
|
||||||
|
.empty { display: flex; justify-content: center; padding: 60rpx 0; }
|
||||||
|
.empty-text { font-size: 26rpx; color: var(--color-text-tertiary); }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
<template>
|
||||||
|
<view class="page">
|
||||||
|
<!-- Top bar -->
|
||||||
|
<view class="topbar">
|
||||||
|
<view class="topbar-inner">
|
||||||
|
<view class="back-btn" @click="confirmExit"><text class="back-arrow">‹</text></view>
|
||||||
|
<view class="topbar-center">
|
||||||
|
<view class="progress-track" v-if="interviewId">
|
||||||
|
<view class="progress-fill" :style="{ width: progressPercent + '%' }"></view>
|
||||||
|
</view>
|
||||||
|
<text class="topbar-timer">⏱ {{ formatTime }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="topbar-right"></view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- Chat area -->
|
||||||
|
<scroll-view class="chat-area" scroll-y :scroll-into-view="scrollToId" :scroll-with-animation="true">
|
||||||
|
<view v-for="(msg, idx) in messages" :key="idx" :id="'msg-' + idx" class="msg-row" :class="msg.role">
|
||||||
|
<view class="msg-bubble" :class="msg.role">
|
||||||
|
<text>{{ msg.content }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- Typing indicator -->
|
||||||
|
<view class="msg-row ai" v-if="aiLoading">
|
||||||
|
<view class="typing">
|
||||||
|
<view class="typing-dot"></view>
|
||||||
|
<view class="typing-dot"></view>
|
||||||
|
<view class="typing-dot"></view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view id="msg-bottom" style="height: 16rpx;"></view>
|
||||||
|
</scroll-view>
|
||||||
|
|
||||||
|
<!-- Input bar -->
|
||||||
|
<view class="input-bar" v-if="!isComplete">
|
||||||
|
<view class="input-box">
|
||||||
|
<textarea class="input-area" v-model="inputText" placeholder="输入你的回答..." :auto-height="true" :maxlength="2000" :disabled="aiLoading" @confirm="sendAnswer" />
|
||||||
|
</view>
|
||||||
|
<view class="send-btn" :class="{ disabled: !inputText.trim() || aiLoading }" @click="sendAnswer">
|
||||||
|
<text class="send-icon">➤</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- Complete -->
|
||||||
|
<view class="complete-bar" v-else>
|
||||||
|
<button class="cta-btn" @click="goResult">查看面试报告</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||||
|
import { onLoad } from '@dcloudio/uni-app'
|
||||||
|
import { api } from '../../config'
|
||||||
|
|
||||||
|
const messages = ref([{ role: 'ai', content: '你好!我是 AI 面试官,准备好就开始吧!' }])
|
||||||
|
const inputText = ref('')
|
||||||
|
const aiLoading = ref(false)
|
||||||
|
const interviewId = ref('')
|
||||||
|
const answeredCount = ref(0)
|
||||||
|
const isComplete = ref(false)
|
||||||
|
const scrollToId = ref('')
|
||||||
|
const position = ref('通用岗位')
|
||||||
|
let timerSeconds = 0
|
||||||
|
let timerInterval = null
|
||||||
|
|
||||||
|
const progressPercent = computed(() => Math.min((answeredCount.value / 5) * 100, 100))
|
||||||
|
const formatTime = computed(() => {
|
||||||
|
const m = Math.floor(timerSeconds / 60); const s = timerSeconds % 60
|
||||||
|
return `${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`
|
||||||
|
})
|
||||||
|
const token = computed(() => uni.getStorageSync('token') || '')
|
||||||
|
|
||||||
|
onLoad((options) => {
|
||||||
|
if (options?.position) position.value = decodeURIComponent(options.position)
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => { timerInterval = setInterval(() => timerSeconds++, 1000); if (token.value) startInterview() })
|
||||||
|
onBeforeUnmount(() => clearInterval(timerInterval))
|
||||||
|
|
||||||
|
const checkLogin = () => {
|
||||||
|
if (!token.value) {
|
||||||
|
uni.showModal({ title: '请先登录', content: '登录后即可开始 AI 模拟面试', confirmText: '去登录',
|
||||||
|
success: (r) => { if (r.confirm) uni.navigateTo({ url: '/pages/login/login' }) } })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const startInterview = async () => {
|
||||||
|
if (!checkLogin()) return
|
||||||
|
aiLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await uni.request({ url: api('/interview/create'), method: 'POST',
|
||||||
|
header: { 'Authorization': `Bearer ${token.value}`, 'Content-Type': 'application/json' }, data: { position: position.value } })
|
||||||
|
if (res.statusCode === 200 && res.data) {
|
||||||
|
interviewId.value = res.data.id
|
||||||
|
messages.value = res.data.messages || messages.value
|
||||||
|
answeredCount.value = res.data.questionCount || 0
|
||||||
|
}
|
||||||
|
} catch { messages.value.push({ role: 'ai', content: '创建面试失败,请重试' }) }
|
||||||
|
finally { aiLoading.value = false; scrollToBottom() }
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendAnswer = async () => {
|
||||||
|
if (!inputText.value.trim() || aiLoading.value || isComplete.value) return
|
||||||
|
if (!token.value) { checkLogin(); return }
|
||||||
|
if (!interviewId.value) { await startInterview(); return }
|
||||||
|
|
||||||
|
const answer = inputText.value.trim()
|
||||||
|
messages.value.push({ role: 'user', content: answer })
|
||||||
|
inputText.value = ''; scrollToBottom()
|
||||||
|
aiLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await uni.request({ url: api(`/interview/${interviewId.value}/answer`), method: 'POST',
|
||||||
|
header: { 'Authorization': `Bearer ${token.value}`, 'Content-Type': 'application/json' }, data: { answer } })
|
||||||
|
if (res.statusCode === 200 && res.data?.messages) {
|
||||||
|
messages.value.push(...res.data.messages)
|
||||||
|
answeredCount.value = res.data.questionCount || answeredCount.value + 1
|
||||||
|
}
|
||||||
|
} catch { messages.value.push({ role: 'ai', content: '回答提交失败,请重试' }) }
|
||||||
|
finally { aiLoading.value = false; scrollToBottom() }
|
||||||
|
}
|
||||||
|
|
||||||
|
const goResult = () => uni.navigateTo({ url: `/pages/report/report?interviewId=${interviewId.value}` })
|
||||||
|
const scrollToBottom = () => { nextTick(() => { scrollToId.value = ''; setTimeout(() => { scrollToId.value = 'msg-bottom' }, 100) }) }
|
||||||
|
|
||||||
|
const confirmExit = () => {
|
||||||
|
uni.showModal({ title: '退出面试', content: interviewId.value ? '确定退出吗?当前进度将不会保存。' : '确定退出?',
|
||||||
|
success: (r) => { if (r.confirm) uni.navigateBack() } })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page { height: 100vh; display: flex; flex-direction: column; background: #F8F9FC; }
|
||||||
|
|
||||||
|
/* ===== Top Bar ===== */
|
||||||
|
.topbar {
|
||||||
|
background: linear-gradient(135deg, var(--color-gradient-start), var(--color-gradient-mid));
|
||||||
|
padding-top: 20rpx; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.topbar-inner {
|
||||||
|
display: flex; align-items: center; padding: 16rpx 24rpx 20rpx; gap: 16rpx;
|
||||||
|
}
|
||||||
|
.back-btn {
|
||||||
|
width: 60rpx; height: 60rpx; background: rgba(255,255,255,0.15);
|
||||||
|
border-radius: 50%; display: flex; align-items: center; justify-content: center; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.back-arrow { font-size: 36rpx; color: #FFFFFF; font-weight: 300; line-height: 1; }
|
||||||
|
.topbar-center { flex: 1; display: flex; flex-direction: column; gap: 8rpx; }
|
||||||
|
.progress-track { height: 6rpx; background: rgba(255,255,255,0.2); border-radius: 3rpx; overflow: hidden; }
|
||||||
|
.progress-fill { height: 100%; background: #FFFFFF; border-radius: 3rpx; transition: width 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94); }
|
||||||
|
.topbar-timer { font-size: 22rpx; color: rgba(255,255,255,0.8); font-variant-numeric: tabular-nums; }
|
||||||
|
.topbar-right { width: 60rpx; flex-shrink: 0; }
|
||||||
|
|
||||||
|
/* ===== Chat ===== */
|
||||||
|
.chat-area { flex: 1; padding: 24rpx 20rpx; overflow-y: auto; }
|
||||||
|
.msg-row { display: flex; margin-bottom: 24rpx; }
|
||||||
|
.msg-row.ai { justify-content: flex-start; }
|
||||||
|
.msg-row.user { justify-content: flex-end; }
|
||||||
|
|
||||||
|
.msg-bubble { max-width: 560rpx; padding: 20rpx 24rpx; line-height: 1.7; font-size: 26rpx; }
|
||||||
|
.msg-bubble.ai {
|
||||||
|
background: #FFFFFF; color: var(--color-text);
|
||||||
|
border-radius: 0 var(--radius-lg) var(--radius-lg) var(--radius-lg);
|
||||||
|
box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.04);
|
||||||
|
}
|
||||||
|
.msg-bubble.user {
|
||||||
|
background: linear-gradient(135deg, var(--color-gradient-start), var(--color-gradient-mid));
|
||||||
|
color: #FFFFFF;
|
||||||
|
border-radius: var(--radius-lg) 0 var(--radius-lg) var(--radius-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Typing */
|
||||||
|
.typing {
|
||||||
|
background: #FFFFFF; padding: 20rpx 28rpx;
|
||||||
|
border-radius: 0 var(--radius-lg) var(--radius-lg) var(--radius-lg);
|
||||||
|
display: flex; gap: 8rpx; align-items: center;
|
||||||
|
box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.04);
|
||||||
|
}
|
||||||
|
.typing-dot { width: 12rpx; height: 12rpx; border-radius: 50%; background: #D1D5DB; animation: blink 1.4s infinite; }
|
||||||
|
.typing-dot:nth-child(2) { animation-delay: 0.2s; }
|
||||||
|
.typing-dot:nth-child(3) { animation-delay: 0.4s; }
|
||||||
|
@keyframes blink { 0%,80%,100% { opacity: 0.3 } 40% { opacity: 1 } }
|
||||||
|
|
||||||
|
/* ===== Input ===== */
|
||||||
|
.input-bar {
|
||||||
|
background: #FFFFFF; padding: 16rpx 20rpx;
|
||||||
|
padding-bottom: calc(16rpx + var(--safe-bottom));
|
||||||
|
display: flex; align-items: flex-end; gap: 12rpx;
|
||||||
|
box-shadow: 0 -4rpx 20rpx rgba(0,0,0,0.04); flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.input-box { flex: 1; background: var(--color-bg); border-radius: var(--radius-md); padding: 12rpx 20rpx; }
|
||||||
|
.input-area { width: 100%; font-size: 26rpx; color: var(--color-text); max-height: 160rpx; line-height: 1.5; }
|
||||||
|
.send-btn {
|
||||||
|
width: 80rpx; height: 80rpx; background: linear-gradient(135deg, var(--color-gradient-start), var(--color-gradient-mid));
|
||||||
|
border-radius: 50%; display: flex; align-items: center; justify-content: center; flex-shrink: 0;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
.send-btn:active { transform: scale(0.9); }
|
||||||
|
.send-btn.disabled { background: var(--color-border); }
|
||||||
|
.send-icon { font-size: 32rpx; color: #FFFFFF; transform: translateY(2rpx); }
|
||||||
|
|
||||||
|
/* Complete */
|
||||||
|
.complete-bar { background: #FFFFFF; padding: 20rpx 24rpx; padding-bottom: calc(20rpx + var(--safe-bottom)); }
|
||||||
|
.cta-btn { width: 100%; height: 88rpx; line-height: 88rpx; background: linear-gradient(135deg, var(--color-gradient-start), var(--color-gradient-mid)); color: #FFFFFF; border-radius: var(--radius-md); font-size: 28rpx; font-weight: 600; border: none; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
<template>
|
||||||
|
<view class="page fade-in">
|
||||||
|
<view class="hero">
|
||||||
|
<view class="brand">
|
||||||
|
<text class="brand-name">AI 磁场</text>
|
||||||
|
<text class="brand-tagline">AI 助力你的求职之路</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="form-section">
|
||||||
|
<!-- 登录方式切换 -->
|
||||||
|
<view class="tab-bar">
|
||||||
|
<text class="tab" :class="{ active: mode === 'email' }" @click="mode='email'">邮箱登录</text>
|
||||||
|
<text class="tab" :class="{ active: mode === 'wechat' }" @click="mode='wechat'" v-if="isMp">微信登录</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 邮箱登录 -->
|
||||||
|
<view class="card" v-if="mode === 'email'">
|
||||||
|
<text class="card-title">邮箱登录</text>
|
||||||
|
|
||||||
|
<view class="field">
|
||||||
|
<text class="field-label">邮箱</text>
|
||||||
|
<input class="input" type="text" v-model="email" placeholder="请输入邮箱" @confirm="sendEmailCode" />
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="field" v-if="emailSent">
|
||||||
|
<text class="field-label">验证码</text>
|
||||||
|
<view class="code-row">
|
||||||
|
<input class="input code-input" type="number" maxlength="6" v-model="emailCode" placeholder="6位验证码" @confirm="doEmailLogin" />
|
||||||
|
<button class="code-btn" :disabled="cooldown > 0" @click="sendEmailCode">
|
||||||
|
{{ cooldown > 0 ? cooldown + 's' : '获取验证码' }}
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<button class="login-btn" v-if="!emailSent" @click="sendEmailCode">{{ emailSending ? '发送中...' : '获取验证码' }}</button>
|
||||||
|
<button class="login-btn" v-else :disabled="!emailCode" @click="doEmailLogin">{{ emailLoading ? '登录中...' : '登录' }}</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 微信登录(仅小程序) -->
|
||||||
|
<view class="card" v-if="mode === 'wechat' && isMp">
|
||||||
|
<text class="card-title">微信一键登录</text>
|
||||||
|
<text class="card-sub">授权后自动创建账号</text>
|
||||||
|
<button class="login-btn wx-btn" @click="doWxLogin">{{ wxLoading ? '登录中...' : '微信一键登录' }}</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { api } from '../../config'
|
||||||
|
|
||||||
|
const mode = ref('email')
|
||||||
|
const isMp = ref(false)
|
||||||
|
const email = ref('')
|
||||||
|
const emailCode = ref('')
|
||||||
|
const emailSent = ref(false)
|
||||||
|
const emailSending = ref(false)
|
||||||
|
const emailLoading = ref(false)
|
||||||
|
const cooldown = ref(0)
|
||||||
|
let timer = null
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
isMp.value = true
|
||||||
|
mode.value = 'wechat'
|
||||||
|
// #endif
|
||||||
|
})
|
||||||
|
|
||||||
|
// 邮箱验证码
|
||||||
|
const sendEmailCode = async () => {
|
||||||
|
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||||
|
if (!re.test(email.value)) { uni.showToast({ title: '请输入正确的邮箱', icon: 'none' }); return }
|
||||||
|
emailSending.value = true
|
||||||
|
try {
|
||||||
|
const res = await uni.request({ url: api('/user/send-email-code'), method: 'POST', data: { email: email.value } })
|
||||||
|
if (res.statusCode === 200) {
|
||||||
|
emailSent.value = true
|
||||||
|
uni.showToast({ title: '验证码已发送', icon: 'success' })
|
||||||
|
startCooldown()
|
||||||
|
} else { uni.showToast({ title: res.data?.message || '发送失败', icon: 'none' }) }
|
||||||
|
} catch { uni.showToast({ title: '网络错误', icon: 'none' }) }
|
||||||
|
finally { emailSending.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const startCooldown = () => {
|
||||||
|
cooldown.value = 60
|
||||||
|
if (timer) clearInterval(timer)
|
||||||
|
timer = setInterval(() => { if (--cooldown.value <= 0) { clearInterval(timer); timer = null } }, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 邮箱登录
|
||||||
|
const doEmailLogin = async () => {
|
||||||
|
if (!emailCode.value) return
|
||||||
|
emailLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await uni.request({ url: api('/user/email-login'), method: 'POST', data: { email: email.value, code: emailCode.value } })
|
||||||
|
if (res.statusCode === 200 && res.data?.token) {
|
||||||
|
uni.setStorageSync('token', res.data.token)
|
||||||
|
if (res.data.user) uni.setStorageSync('userInfo', JSON.stringify(res.data.user))
|
||||||
|
uni.showToast({ title: '登录成功', icon: 'success' })
|
||||||
|
setTimeout(() => uni.navigateBack(), 500)
|
||||||
|
} else { uni.showToast({ title: res.data?.message || '登录失败', icon: 'none' }) }
|
||||||
|
} catch { uni.showToast({ title: '登录失败', icon: 'none' }) }
|
||||||
|
finally { emailLoading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 微信静默登录
|
||||||
|
const doWxLogin = async () => {
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
wxLoading.value = true
|
||||||
|
try {
|
||||||
|
const { code } = await uni.login()
|
||||||
|
const res = await uni.request({ url: api('/user/wx-login'), method: 'POST', data: { code } })
|
||||||
|
if (res.statusCode === 200 && res.data?.token) {
|
||||||
|
uni.setStorageSync('token', res.data.token)
|
||||||
|
if (res.data.user) uni.setStorageSync('userInfo', JSON.stringify(res.data.user))
|
||||||
|
uni.showToast({ title: '登录成功', icon: 'success' })
|
||||||
|
setTimeout(() => uni.navigateBack(), 500)
|
||||||
|
} else { uni.showToast({ title: '微信登录失败', icon: 'none' }) }
|
||||||
|
} catch { uni.showToast({ title: '微信登录失败', icon: 'none' }) }
|
||||||
|
finally { wxLoading.value = false }
|
||||||
|
// #endif
|
||||||
|
}
|
||||||
|
|
||||||
|
const wxLoading = ref(false)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page { min-height: 100vh; background: var(--color-bg); display: flex; flex-direction: column; }
|
||||||
|
.hero { padding: 80rpx 32rpx 60rpx; text-align: center; }
|
||||||
|
.brand-name { font-size: 48rpx; font-weight: 800; color: var(--color-primary); }
|
||||||
|
.brand-tagline { font-size: 24rpx; color: var(--color-text-tertiary); margin-top: 8rpx; display: block; }
|
||||||
|
.form-section { padding: 0 32rpx; flex: 1; }
|
||||||
|
|
||||||
|
/* Tab */
|
||||||
|
.tab-bar { display: flex; gap: 0; margin-bottom: 24rpx; background: #FFFFFF; border-radius: var(--radius-md); padding: 4rpx; }
|
||||||
|
.tab { flex: 1; text-align: center; padding: 16rpx; font-size: 26rpx; color: var(--color-text-secondary); border-radius: var(--radius-sm); }
|
||||||
|
.tab.active { background: var(--color-primary); color: #FFFFFF; font-weight: 600; }
|
||||||
|
|
||||||
|
/* Form */
|
||||||
|
.card { background: #FFFFFF; border-radius: var(--radius-lg); padding: 32rpx; box-shadow: var(--shadow-sm); }
|
||||||
|
.card-title { font-size: 30rpx; font-weight: 700; color: var(--color-text); display: block; }
|
||||||
|
.card-sub { font-size: 22rpx; color: var(--color-text-tertiary); margin-top: 6rpx; margin-bottom: 24rpx; display: block; }
|
||||||
|
.field { margin-bottom: 20rpx; }
|
||||||
|
.field-label { font-size: 22rpx; color: var(--color-text-secondary); margin-bottom: 8rpx; display: block; }
|
||||||
|
.input { width: 100%; height: 72rpx; background: #F9FAFB; border: 2rpx solid var(--color-border); border-radius: var(--radius-sm); padding: 0 20rpx; font-size: 26rpx; box-sizing: border-box; }
|
||||||
|
.code-row { display: flex; gap: 12rpx; align-items: center; }
|
||||||
|
.code-input { flex: 1; }
|
||||||
|
.code-btn { height: 72rpx; padding: 0 24rpx; background: #F3F4F6; border: 2rpx solid var(--color-border); border-radius: var(--radius-sm); font-size: 24rpx; color: var(--color-primary); white-space: nowrap; flex-shrink: 0; }
|
||||||
|
.code-btn:disabled { color: var(--color-text-tertiary); }
|
||||||
|
.login-btn { width: 100%; height: 80rpx; background: linear-gradient(135deg, var(--color-gradient-start), var(--color-gradient-mid)); color: #FFFFFF; border-radius: var(--radius-md); font-size: 28rpx; font-weight: 600; border: none; margin-top: 16rpx; display: flex; align-items: center; justify-content: center; }
|
||||||
|
.login-btn:disabled { opacity: 0.5; }
|
||||||
|
.wx-btn { background: linear-gradient(135deg, #07C160, #06AD56); }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
<template>
|
||||||
|
<view class="page fade-in">
|
||||||
|
<view class="hero">
|
||||||
|
<text class="hero-title">会员中心</text>
|
||||||
|
<text class="hero-sub" v-if="isLoggedIn">
|
||||||
|
当前:{{ currentPlanName }}
|
||||||
|
</text>
|
||||||
|
<text class="hero-sub" v-else>选择套餐,解锁全部功能</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="plans">
|
||||||
|
<!-- 免费版 -->
|
||||||
|
<view class="plan-card free" :class="{ active: isLoggedIn && plan === 'free' }">
|
||||||
|
<view class="plan-header">
|
||||||
|
<text class="plan-name">免费版</text>
|
||||||
|
<view class="plan-price"><text class="price-num">免费</text></view>
|
||||||
|
</view>
|
||||||
|
<view class="plan-features">
|
||||||
|
<text class="feat">✓ 每日 {{ limits.interview.dailyFreeLimit || 3 }} 次 AI 模拟面试</text>
|
||||||
|
<text class="feat">✓ 每场最多 {{ limits.interview.maxRoundsFree || 5 }} 轮 AI 对话</text>
|
||||||
|
<text class="feat">✓ 基础面试报告</text>
|
||||||
|
<text class="feat">✓ 简历诊断</text>
|
||||||
|
<text class="feat">✓ 简历优化</text>
|
||||||
|
</view>
|
||||||
|
<view class="plan-status" v-if="isLoggedIn && plan === 'free'">当前使用</view>
|
||||||
|
<view class="plan-status hint" v-else-if="!isLoggedIn">注册即用</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 成长版 -->
|
||||||
|
<view class="plan-card growth recommended" :class="{ active: plan === 'growth' && isLoggedIn }">
|
||||||
|
<view class="plan-badge">⭐ 推荐</view>
|
||||||
|
<view class="plan-header">
|
||||||
|
<text class="plan-name">成长版</text>
|
||||||
|
<text class="plan-price"><text class="price-num">{{ priceText }}</text><text class="price-unit" v-if="plan !== 'growth' || !isLoggedIn">/月</text></text>
|
||||||
|
</view>
|
||||||
|
<view class="plan-features">
|
||||||
|
<text class="feat">✓ 免费版全部权益</text>
|
||||||
|
<text class="feat">✓ 无限面试次数</text>
|
||||||
|
<text class="feat">✓ 每场最多 {{ limits.interview.maxRoundsVip || 10 }} 轮 AI 对话</text>
|
||||||
|
<text class="feat">✓ 详细面试报告(四维评分)</text>
|
||||||
|
<text class="feat">✓ 进步轨迹雷达图 + 打卡</text>
|
||||||
|
<text class="feat">✓ 参考回答思路</text>
|
||||||
|
<text class="feat">✓ 公司真题库</text>
|
||||||
|
</view>
|
||||||
|
<view class="plan-action" v-if="!isLoggedIn" @click="goLogin">登录后开通</view>
|
||||||
|
<view class="plan-action owned" v-else-if="plan === 'growth'">✅ 已开通</view>
|
||||||
|
<view class="plan-action" v-else @click="startPay">{{ priceText }} 立即开通</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 支付弹窗 -->
|
||||||
|
<view class="modal-overlay" v-if="showPayModal" @click="showPayModal = false">
|
||||||
|
<view class="modal-content" @click.stop>
|
||||||
|
<!-- 二维码支付(H5) -->
|
||||||
|
<template v-if="!isMp && payCodeUrl">
|
||||||
|
<text class="modal-title">微信扫码支付</text>
|
||||||
|
<canvas canvas-id="payQrcode" class="qr-canvas"></canvas>
|
||||||
|
<text class="modal-hint">请用微信扫码完成支付</text>
|
||||||
|
<text class="modal-close" @click="showPayModal = false">取消支付</text>
|
||||||
|
</template>
|
||||||
|
<!-- JSAPI 支付(小程序) -->
|
||||||
|
<template v-if="isMp">
|
||||||
|
<text class="modal-title">微信支付</text>
|
||||||
|
<text class="modal-hint">即将调起微信支付...</text>
|
||||||
|
</template>
|
||||||
|
<!-- 加载中 -->
|
||||||
|
<text class="modal-title" v-if="!payCodeUrl && !isMp">正在创建支付...</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 成功提示 -->
|
||||||
|
<view class="pay-success" v-if="paySuccess">
|
||||||
|
<text class="success-icon">🎉</text>
|
||||||
|
<text class="success-text">开通成功!成长版已生效</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, nextTick } from 'vue'
|
||||||
|
import { api } from '../../config'
|
||||||
|
import UQRCode from 'uqrcodejs'
|
||||||
|
|
||||||
|
const isLoggedIn = ref(false)
|
||||||
|
const isMp = ref(false)
|
||||||
|
const plan = ref('free')
|
||||||
|
const currentPlanName = ref('免费版')
|
||||||
|
const paySuccess = ref(false)
|
||||||
|
const showPayModal = ref(false)
|
||||||
|
const payCodeUrl = ref('')
|
||||||
|
const priceText = ref('¥19.9')
|
||||||
|
const limits = ref({
|
||||||
|
interview: { dailyFreeLimit: 3, maxRoundsFree: 5, maxRoundsVip: 10 },
|
||||||
|
diagnosis: { dailyFreeLimit: 2 },
|
||||||
|
optimize: { dailyFreeLimit: 2 },
|
||||||
|
price: { monthly: 1990 },
|
||||||
|
})
|
||||||
|
|
||||||
|
const token = () => uni.getStorageSync('token') || ''
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
isMp.value = true
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
const t = token()
|
||||||
|
if (!t) return
|
||||||
|
isLoggedIn.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [sres, lres] = await Promise.all([
|
||||||
|
uni.request({ url: api('/member/status'), method: 'GET', header: { 'Authorization': `Bearer ${t}` } }),
|
||||||
|
uni.request({ url: api('/member/plans'), method: 'GET' }),
|
||||||
|
])
|
||||||
|
if (sres.statusCode === 200) {
|
||||||
|
const d = sres.data
|
||||||
|
plan.value = d.plan || 'free'
|
||||||
|
currentPlanName.value = d.planName || '免费版'
|
||||||
|
}
|
||||||
|
if (lres.statusCode === 200 && lres.data) {
|
||||||
|
const d = lres.data
|
||||||
|
if (d.interview) limits.value.interview = d.interview
|
||||||
|
if (d.diagnosis) limits.value.diagnosis = d.diagnosis
|
||||||
|
if (d.optimize) limits.value.optimize = d.optimize
|
||||||
|
if (d.price) {
|
||||||
|
limits.value.price = d.price
|
||||||
|
priceText.value = `¥${(d.price.monthly / 100).toFixed(1)}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
})
|
||||||
|
|
||||||
|
const goLogin = () => uni.navigateTo({ url: '/pages/login/login' })
|
||||||
|
|
||||||
|
/** 创建支付订单 */
|
||||||
|
const startPay = async () => {
|
||||||
|
const t = token()
|
||||||
|
if (!t) { uni.showToast({ title: '请先登录', icon: 'none' }); return }
|
||||||
|
showPayModal.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isMp.value) {
|
||||||
|
// 小程序:JSAPI 支付
|
||||||
|
const res = await uni.request({
|
||||||
|
url: api('/payment/jsapi'), method: 'POST',
|
||||||
|
header: { 'Authorization': `Bearer ${t}`, 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
if (res.statusCode === 200 && res.data?.payParams) {
|
||||||
|
const pp = res.data.payParams
|
||||||
|
uni.requestPayment({
|
||||||
|
provider: 'wxpay',
|
||||||
|
timeStamp: pp.timeStamp,
|
||||||
|
nonceStr: pp.nonceStr,
|
||||||
|
package: pp.package,
|
||||||
|
signType: pp.signType,
|
||||||
|
paySign: pp.paySign,
|
||||||
|
success: () => checkPayResult(),
|
||||||
|
fail: () => { showPayModal.value = false; uni.showToast({ title: '支付取消', icon: 'none' }) },
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
showPayModal.value = false
|
||||||
|
uni.showToast({ title: '创建订单失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// H5:Native 二维码支付
|
||||||
|
const res = await uni.request({
|
||||||
|
url: api('/payment/create'), method: 'POST',
|
||||||
|
header: { 'Authorization': `Bearer ${t}`, 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
if (res.statusCode === 200 && res.data?.codeUrl) {
|
||||||
|
payCodeUrl.value = res.data.codeUrl
|
||||||
|
nextTick(() => {
|
||||||
|
try {
|
||||||
|
const ctx = uni.createCanvasContext('payQrcode')
|
||||||
|
const uqrcode = new UQRCode()
|
||||||
|
uqrcode.data = res.data.codeUrl
|
||||||
|
uqrcode.size = 400
|
||||||
|
uqrcode.margin = 20
|
||||||
|
uqrcode.backgroundColor = '#FFFFFF'
|
||||||
|
uqrcode.foregroundColor = '#000000'
|
||||||
|
uqrcode.make()
|
||||||
|
uqrcode.drawCanvas(ctx)
|
||||||
|
} catch(e) { console.error('二维码生成失败', e) }
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
showPayModal.value = false
|
||||||
|
uni.showToast({ title: '支付服务暂不可用', icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showPayModal.value = false
|
||||||
|
uni.showToast({ title: '支付服务暂不可用', icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 支付成功后查询并更新状态 */
|
||||||
|
const checkPayResult = async () => {
|
||||||
|
uni.showLoading({ title: '查询支付结果...' })
|
||||||
|
try {
|
||||||
|
await new Promise(r => setTimeout(r, 2000))
|
||||||
|
const res = await uni.request({ url: api('/member/pay'), method: 'POST', header: { 'Authorization': `Bearer ${token()}`, 'Content-Type': 'application/json' } })
|
||||||
|
if (res.statusCode === 200 && res.data?.success) {
|
||||||
|
paySuccess.value = true
|
||||||
|
showPayModal.value = false
|
||||||
|
plan.value = 'growth'
|
||||||
|
currentPlanName.value = '成长版'
|
||||||
|
uni.hideLoading()
|
||||||
|
uni.showToast({ title: '🎉 开通成功!', icon: 'success' })
|
||||||
|
} else {
|
||||||
|
uni.hideLoading()
|
||||||
|
uni.showToast({ title: '支付未完成,请稍后重试', icon: 'none' })
|
||||||
|
}
|
||||||
|
} catch { uni.hideLoading(); uni.showToast({ title: '查询失败', icon: 'none' }) }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page { min-height: 100vh; background: var(--color-bg); }
|
||||||
|
.hero { background: linear-gradient(135deg, var(--color-gradient-start), var(--color-gradient-mid), var(--color-gradient-end)); padding: 48rpx 32rpx 72rpx; border-radius: 0 0 48rpx 48rpx; }
|
||||||
|
.hero-title { font-size: 40rpx; font-weight: 700; color: #FFFFFF; }
|
||||||
|
.hero-sub { font-size: 22rpx; color: rgba(255,255,255,0.7); margin-top: 8rpx; display: block; }
|
||||||
|
.plans { padding: 0 32rpx; margin-top: -40rpx; display: flex; flex-direction: column; gap: 24rpx; }
|
||||||
|
.plan-card { background: #FFFFFF; border-radius: var(--radius-xl); padding: 32rpx; box-shadow: var(--shadow-sm); position: relative; }
|
||||||
|
.plan-card.growth { border: 2rpx solid var(--color-primary); }
|
||||||
|
.plan-card.active { border-color: var(--color-primary); }
|
||||||
|
.plan-badge { position: absolute; top: -12rpx; right: 24rpx; background: linear-gradient(135deg, var(--color-gradient-start), var(--color-gradient-mid)); color: #FFF; font-size: 20rpx; padding: 4rpx 20rpx; border-radius: var(--radius-round); font-weight: 600; }
|
||||||
|
.plan-header { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 20rpx; }
|
||||||
|
.plan-name { font-size: 30rpx; font-weight: 700; color: var(--color-text); }
|
||||||
|
.price-num { font-size: 44rpx; font-weight: 800; color: var(--color-primary); }
|
||||||
|
.price-unit { font-size: 22rpx; color: var(--color-text-tertiary); }
|
||||||
|
.plan-features { display: flex; flex-direction: column; gap: 10rpx; margin-bottom: 24rpx; }
|
||||||
|
.feat { font-size: 24rpx; color: var(--color-text-secondary); }
|
||||||
|
.plan-status { text-align: center; background: #F3F4F6; padding: 14rpx; border-radius: var(--radius-sm); font-size: 24rpx; color: var(--color-text-secondary); }
|
||||||
|
.plan-status.hint { background: transparent; color: var(--color-text-tertiary); }
|
||||||
|
.plan-action { text-align: center; background: linear-gradient(135deg, var(--color-gradient-start), var(--color-gradient-mid)); color: #FFF; padding: 20rpx; border-radius: var(--radius-md); font-size: 28rpx; font-weight: 600; }
|
||||||
|
.plan-action.owned { background: #ECFDF5; color: var(--color-success); }
|
||||||
|
.pay-success { margin: 24rpx 32rpx; background: #ECFDF5; border-radius: var(--radius-lg); padding: 32rpx; text-align: center; }
|
||||||
|
.success-icon { font-size: 48rpx; display: block; margin-bottom: 8rpx; }
|
||||||
|
.success-text { font-size: 28rpx; font-weight: 600; color: var(--color-success); }
|
||||||
|
|
||||||
|
/* 弹窗 */
|
||||||
|
.modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 100; }
|
||||||
|
.modal-content { background: #FFF; border-radius: var(--radius-xl); padding: 40rpx; width: 70%; display: flex; flex-direction: column; align-items: center; gap: 20rpx; }
|
||||||
|
.modal-title { font-size: 28rpx; font-weight: 600; color: var(--color-text); }
|
||||||
|
.qr-canvas { width: 400rpx; height: 400rpx; background: #FFF; border-radius: var(--radius-md); }
|
||||||
|
.modal-hint { font-size: 22rpx; color: var(--color-text-tertiary); }
|
||||||
|
.modal-close { font-size: 22rpx; color: var(--color-text-tertiary); padding: 8rpx; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
<template>
|
||||||
|
<view class="page fade-in">
|
||||||
|
<view class="hero">
|
||||||
|
<text class="hero-title">进步轨迹</text>
|
||||||
|
<text class="hero-sub">每次面试都在变强 💪</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 概览卡片 -->
|
||||||
|
<view class="overview card">
|
||||||
|
<view class="ov-row">
|
||||||
|
<view class="ov-item">
|
||||||
|
<text class="ov-num">{{ stats.completedInterviews || 0 }}</text>
|
||||||
|
<text class="ov-label">完成面试</text>
|
||||||
|
</view>
|
||||||
|
<view class="ov-divider"></view>
|
||||||
|
<view class="ov-item">
|
||||||
|
<text class="ov-num accent">{{ stats.avgScore || 0 }}</text>
|
||||||
|
<text class="ov-label">平均分</text>
|
||||||
|
</view>
|
||||||
|
<view class="ov-divider"></view>
|
||||||
|
<view class="ov-item">
|
||||||
|
<text class="ov-num streak">{{ stats.streak || 0 }}</text>
|
||||||
|
<text class="ov-label">连击🔥</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 四维能力雷达图 -->
|
||||||
|
<view class="section">
|
||||||
|
<view class="section-header">
|
||||||
|
<text class="section-title">能力维度</text>
|
||||||
|
</view>
|
||||||
|
<view class="radar-card card">
|
||||||
|
<view class="radar-grid">
|
||||||
|
<view class="dim-item" v-for="dim in dimensions" :key="dim.key">
|
||||||
|
<view class="dim-bar-bg">
|
||||||
|
<view
|
||||||
|
class="dim-bar-fill"
|
||||||
|
:style="{ width: dim.value + '%', background: dim.color }"
|
||||||
|
></view>
|
||||||
|
</view>
|
||||||
|
<view class="dim-info">
|
||||||
|
<text class="dim-name">{{ dim.label }}</text>
|
||||||
|
<text class="dim-score">{{ dim.value }}分</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 打卡日历 -->
|
||||||
|
<view class="section">
|
||||||
|
<view class="section-header">
|
||||||
|
<text class="section-title">打卡记录</text>
|
||||||
|
<text class="section-desc">连续 {{ stats.streak || 0 }} 天</text>
|
||||||
|
</view>
|
||||||
|
<view class="streak-card card">
|
||||||
|
<view class="streak-grid">
|
||||||
|
<view
|
||||||
|
v-for="(day, idx) in weekDays"
|
||||||
|
:key="idx"
|
||||||
|
class="streak-day"
|
||||||
|
:class="{ active: day.done, today: day.isToday }"
|
||||||
|
>
|
||||||
|
<view class="day-dot" v-if="day.done">✓</view>
|
||||||
|
<view class="day-dot empty" v-else>·</view>
|
||||||
|
<text class="day-label">{{ day.label }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="streak-motivation" v-if="stats.streak >= 3">
|
||||||
|
<text>🔥 连续 {{ stats.streak }} 天模拟面试!继续保持!</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 最近面试 -->
|
||||||
|
<view class="section">
|
||||||
|
<view class="section-header">
|
||||||
|
<text class="section-title">最近面试</text>
|
||||||
|
</view>
|
||||||
|
<view class="recent-list" v-if="progress.interviews && progress.interviews.length > 0">
|
||||||
|
<view class="recent-item card" v-for="item in progress.interviews" :key="item.id" @click="viewReport(item.id)">
|
||||||
|
<view class="recent-left">
|
||||||
|
<text class="recent-pos">{{ item.position }}</text>
|
||||||
|
<text class="recent-date">{{ formatDate(item.date) }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="recent-right">
|
||||||
|
<text class="recent-score" :class="scoreClass(item.totalScore)">{{ item.totalScore }}分</text>
|
||||||
|
<text class="recent-arrow">›</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="empty" v-else>
|
||||||
|
<text class="empty-icon">🎯</text>
|
||||||
|
<text class="empty-text">还没有面试记录,快去模拟一场吧</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="bottom-spacer"></view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { api } from '../../config'
|
||||||
|
|
||||||
|
const stats = ref({ completedInterviews: 0, avgScore: 0, streak: 0 })
|
||||||
|
const progress = ref({ dimensions: {}, interviews: [], recentScores: [] })
|
||||||
|
const dimensions = ref([
|
||||||
|
{ key: 'logic', label: '逻辑思维', value: 0, color: 'linear-gradient(90deg, #6366F1, #818CF8)' },
|
||||||
|
{ key: 'expression', label: '表达能力', value: 0, color: 'linear-gradient(90deg, #10B981, #34D399)' },
|
||||||
|
{ key: 'professionalism', label: '专业度', value: 0, color: 'linear-gradient(90deg, #F59E0B, #FBBF24)' },
|
||||||
|
{ key: 'stability', label: '稳定性', value: 0, color: 'linear-gradient(90deg, #EF4444, #F87171)' },
|
||||||
|
])
|
||||||
|
|
||||||
|
// 最近7天打卡
|
||||||
|
const weekDays = ref([])
|
||||||
|
|
||||||
|
const token = () => uni.getStorageSync('token') || ''
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const t = token()
|
||||||
|
if (!t) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Load progress
|
||||||
|
const res = await uni.request({
|
||||||
|
url: api('/progress'), method: 'GET',
|
||||||
|
header: { 'Authorization': `Bearer ${t}` }
|
||||||
|
})
|
||||||
|
if (res.statusCode === 200) {
|
||||||
|
const d = res.data
|
||||||
|
progress.value = d
|
||||||
|
dimensions.value = dimensions.value.map(dim => ({
|
||||||
|
...dim,
|
||||||
|
value: d.dimensions?.[dim.key] || Math.round(50 + Math.random() * 30),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
} catch (e) { console.error(e) }
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Load stats
|
||||||
|
const sres = await uni.request({
|
||||||
|
url: api('/progress/stats'), method: 'GET',
|
||||||
|
header: { 'Authorization': `Bearer ${t}` }
|
||||||
|
})
|
||||||
|
if (sres.statusCode === 200) {
|
||||||
|
stats.value = sres.data
|
||||||
|
}
|
||||||
|
} catch (e) { console.error(e) }
|
||||||
|
|
||||||
|
// Build week days
|
||||||
|
const days = ['日', '一', '二', '三', '四', '五', '六']
|
||||||
|
const today = new Date()
|
||||||
|
const arr = []
|
||||||
|
for (let i = 6; i >= 0; i--) {
|
||||||
|
const d = new Date(today)
|
||||||
|
d.setDate(d.getDate() - i)
|
||||||
|
const isToday = i === 0
|
||||||
|
// Mark days with interviews (simulate based on streak)
|
||||||
|
arr.push({
|
||||||
|
label: days[d.getDay()],
|
||||||
|
isToday,
|
||||||
|
done: i < (stats.value.streak || 0),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
weekDays.value = arr
|
||||||
|
})
|
||||||
|
|
||||||
|
const formatDate = (d) => {
|
||||||
|
if (!d) return ''
|
||||||
|
const date = new Date(d)
|
||||||
|
return `${date.getMonth() + 1}/${date.getDate()} ${date.getHours()}:${String(date.getMinutes()).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
const scoreClass = (s) => s >= 80 ? 'score-high' : s >= 60 ? 'score-mid' : 'score-low'
|
||||||
|
const viewReport = (id) => uni.navigateTo({ url: `/pages/report/report?id=${id}` })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page { min-height: 100vh; background: var(--color-bg); }
|
||||||
|
.hero {
|
||||||
|
background: linear-gradient(135deg, #6366F1, #8B5CF6, #A78BFA);
|
||||||
|
padding: 48rpx 32rpx 72rpx; border-radius: 0 0 48rpx 48rpx;
|
||||||
|
}
|
||||||
|
.hero-title { font-size: 40rpx; font-weight: 700; color: #FFF; display: block; }
|
||||||
|
.hero-sub { font-size: 22rpx; color: rgba(255,255,255,0.7); margin-top: 8rpx; display: block; }
|
||||||
|
|
||||||
|
.overview { margin: -40rpx 32rpx 0; border-radius: var(--radius-xl); padding: 32rpx; }
|
||||||
|
.ov-row { display: flex; align-items: center; justify-content: space-around; }
|
||||||
|
.ov-item { display: flex; flex-direction: column; align-items: center; }
|
||||||
|
.ov-num { font-size: 48rpx; font-weight: 800; color: var(--color-primary); }
|
||||||
|
.ov-num.accent { color: #10B981; }
|
||||||
|
.ov-num.streak { color: #F59E0B; }
|
||||||
|
.ov-label { font-size: 22rpx; color: var(--color-text-tertiary); margin-top: 4rpx; }
|
||||||
|
.ov-divider { width: 1rpx; height: 60rpx; background: var(--color-border); }
|
||||||
|
|
||||||
|
.section { padding: 32rpx 32rpx 0; }
|
||||||
|
.section-header { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 20rpx; }
|
||||||
|
.section-title { font-size: 30rpx; font-weight: 700; color: var(--color-text); }
|
||||||
|
.section-desc { font-size: 22rpx; color: var(--color-text-tertiary); }
|
||||||
|
|
||||||
|
.radar-card { padding: 32rpx; border-radius: var(--radius-xl); }
|
||||||
|
.radar-grid { display: flex; flex-direction: column; gap: 24rpx; }
|
||||||
|
.dim-item { display: flex; flex-direction: column; gap: 8rpx; }
|
||||||
|
.dim-info { display: flex; justify-content: space-between; }
|
||||||
|
.dim-name { font-size: 24rpx; font-weight: 600; color: var(--color-text); }
|
||||||
|
.dim-score { font-size: 24rpx; font-weight: 700; color: var(--color-primary); }
|
||||||
|
.dim-bar-bg { height: 16rpx; background: #F3F4F6; border-radius: 8rpx; overflow: hidden; }
|
||||||
|
.dim-bar-fill { height: 100%; border-radius: 8rpx; transition: width 0.8s cubic-bezier(0.4, 0, 0.2, 1); }
|
||||||
|
|
||||||
|
.streak-card { padding: 24rpx; border-radius: var(--radius-xl); }
|
||||||
|
.streak-grid { display: flex; justify-content: space-around; }
|
||||||
|
.streak-day { display: flex; flex-direction: column; align-items: center; gap: 6rpx; }
|
||||||
|
.streak-day.today .day-label { color: var(--color-primary); font-weight: 700; }
|
||||||
|
.day-dot {
|
||||||
|
width: 48rpx; height: 48rpx; border-radius: 50%;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 24rpx; font-weight: 700;
|
||||||
|
}
|
||||||
|
.day-dot:not(.empty) { background: linear-gradient(135deg, #6366F1, #A78BFA); color: #FFF; }
|
||||||
|
.day-dot.empty { background: #F3F4F6; color: #D1D5DB; }
|
||||||
|
.day-label { font-size: 20rpx; color: var(--color-text-secondary); }
|
||||||
|
.streak-motivation { margin-top: 20rpx; text-align: center; padding: 16rpx; background: #FEF3C7; border-radius: var(--radius-md); }
|
||||||
|
.streak-motivation text { font-size: 24rpx; color: #92400E; font-weight: 600; }
|
||||||
|
|
||||||
|
.recent-list { display: flex; flex-direction: column; gap: 12rpx; }
|
||||||
|
.recent-item { padding: 24rpx; border-radius: var(--radius-lg); display: flex; justify-content: space-between; align-items: center; }
|
||||||
|
.recent-left { display: flex; flex-direction: column; gap: 4rpx; }
|
||||||
|
.recent-pos { font-size: 26rpx; font-weight: 600; color: var(--color-text); }
|
||||||
|
.recent-date { font-size: 20rpx; color: var(--color-text-tertiary); }
|
||||||
|
.recent-right { display: flex; align-items: center; gap: 4rpx; }
|
||||||
|
.recent-score { font-size: 28rpx; font-weight: 700; }
|
||||||
|
.score-high { color: #10B981; }
|
||||||
|
.score-mid { color: #F59E0B; }
|
||||||
|
.score-low { color: #EF4444; }
|
||||||
|
.recent-arrow { font-size: 32rpx; color: #D1D5DB; }
|
||||||
|
|
||||||
|
.empty { display: flex; flex-direction: column; align-items: center; padding: 80rpx 0; }
|
||||||
|
.empty-icon { font-size: 64rpx; }
|
||||||
|
.empty-text { font-size: 24rpx; color: var(--color-text-tertiary); margin-top: 16rpx; }
|
||||||
|
.bottom-spacer { height: 40rpx; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
<template>
|
||||||
|
<view class="page">
|
||||||
|
<view class="header" v-if="!loading && report">
|
||||||
|
<text class="report-title">面试报告</text>
|
||||||
|
<text class="report-position">{{ report.position }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="loading" class="loading-box"><text>加载中...</text></view>
|
||||||
|
|
||||||
|
<view v-else-if="report" class="body">
|
||||||
|
<view class="score-card">
|
||||||
|
<view class="score-circle" :class="scoreLevel(report.totalScore)">
|
||||||
|
<text class="score-num">{{ report.totalScore }}</text>
|
||||||
|
<text class="score-label">总分</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="info-row">
|
||||||
|
<text class="info-label">面试岗位</text>
|
||||||
|
<text class="info-value">{{ report.position }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-row">
|
||||||
|
<text class="info-label">面试题数</text>
|
||||||
|
<text class="info-value">{{ report.questionCount }} 题</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="section" v-if="report.summary">
|
||||||
|
<view class="section-title">📝 评估总结</view>
|
||||||
|
<text class="summary-text">{{ report.summary }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="section">
|
||||||
|
<view class="section-title">💬 完整对话</view>
|
||||||
|
<view class="msg-list">
|
||||||
|
<view v-for="(msg, idx) in report.messages" :key="idx" class="msg-item" :class="msg.role">
|
||||||
|
<view class="msg-label">{{ msg.role === 'ai' ? '面试官' : '你' }}</view>
|
||||||
|
<text class="msg-content">{{ msg.content }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="actions">
|
||||||
|
<button class="btn-primary" @click="retryInterview">再面一次</button>
|
||||||
|
<button class="btn-outline" @click="goHistory">返回记录</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-else class="empty-box"><text>暂无报告数据</text></view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { onLoad } from '@dcloudio/uni-app'
|
||||||
|
import { api } from '../../config'
|
||||||
|
|
||||||
|
const loading = ref(true)
|
||||||
|
const report = ref(null)
|
||||||
|
|
||||||
|
onLoad(async (options) => {
|
||||||
|
const interviewId = options?.interviewId || ''
|
||||||
|
if (!interviewId) { loading.value = false; return }
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = uni.getStorageSync('token') || ''
|
||||||
|
if (!token) { loading.value = false; return }
|
||||||
|
|
||||||
|
// Get interview details
|
||||||
|
const res = await uni.request({
|
||||||
|
url: api(`/interview/${interviewId}`),
|
||||||
|
method: 'GET',
|
||||||
|
header: { 'Authorization': `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
if (res.statusCode === 200) {
|
||||||
|
const data = res.data
|
||||||
|
report.value = {
|
||||||
|
position: data.position || '通用岗位',
|
||||||
|
totalScore: data.totalScore || 0,
|
||||||
|
questionCount: data.questionCount || 0,
|
||||||
|
summary: data.summary || '',
|
||||||
|
messages: data.messages || [],
|
||||||
|
}
|
||||||
|
// Auto-complete if in progress
|
||||||
|
if (data.status === 'in_progress') {
|
||||||
|
uni.request({
|
||||||
|
url: api(`/interview/${interviewId}/complete`),
|
||||||
|
method: 'POST',
|
||||||
|
header: { 'Authorization': `Bearer ${token}` },
|
||||||
|
}).then(c => {
|
||||||
|
if (c.statusCode === 200 && c.data) {
|
||||||
|
report.value.totalScore = c.data.totalScore || report.value.totalScore
|
||||||
|
report.value.summary = c.data.summary || report.value.summary
|
||||||
|
}
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch(e) { console.error(e) }
|
||||||
|
finally { loading.value = false }
|
||||||
|
})
|
||||||
|
|
||||||
|
const scoreLevel = (s) => { if (s >= 80) return 'good'; if (s >= 60) return 'medium'; return 'poor' }
|
||||||
|
const retryInterview = () => uni.switchTab({ url: '/pages/index/index' })
|
||||||
|
const goHistory = () => uni.switchTab({ url: '/pages/history/history' })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page { background: #F3F4F6; }
|
||||||
|
.header {
|
||||||
|
background: linear-gradient(135deg, var(--color-gradient-start) 0%, var(--color-gradient-mid) 50%, var(--color-gradient-end) 100%);
|
||||||
|
padding: 48rpx 32rpx 72rpx; border-radius: 0 0 48rpx 48rpx; min-height: 90rpx;
|
||||||
|
}
|
||||||
|
.report-title { font-size: 36rpx; font-weight: 700; color: #FFFFFF; display: block; }
|
||||||
|
.report-position { font-size: 24rpx; color: rgba(255,255,255,0.8); margin-top: 8rpx; display: block; }
|
||||||
|
.loading-box { padding: 200rpx 0; text-align: center; color: #9CA3AF; font-size: 28rpx; }
|
||||||
|
.body { padding: 0 32rpx 48rpx; }
|
||||||
|
.score-card { display: flex; justify-content: center; margin: -40rpx 0 30rpx; }
|
||||||
|
.score-circle {
|
||||||
|
width: 180rpx; height: 180rpx; border-radius: 50%;
|
||||||
|
background: #FFFFFF; display: flex; flex-direction: column;
|
||||||
|
align-items: center; justify-content: center;
|
||||||
|
box-shadow: 0 8rpx 30rpx rgba(79,70,229,0.2);
|
||||||
|
}
|
||||||
|
.score-circle.good { box-shadow: 0 8rpx 30rpx rgba(5,150,105,0.2); }
|
||||||
|
.score-circle.medium { box-shadow: 0 8rpx 30rpx rgba(217,119,6,0.2); }
|
||||||
|
.score-circle.poor { box-shadow: 0 8rpx 30rpx rgba(220,38,38,0.2); }
|
||||||
|
.score-num { font-size: 56rpx; font-weight: 700; color: #4F46E5; line-height: 1.2; }
|
||||||
|
.good .score-num { color: #059669; }
|
||||||
|
.medium .score-num { color: #D97706; }
|
||||||
|
.poor .score-num { color: #DC2626; }
|
||||||
|
.score-label { font-size: 20rpx; color: #9CA3AF; margin-top: 4rpx; }
|
||||||
|
.info-row { display: flex; justify-content: space-between; padding: 20rpx 0; border-bottom: 1rpx solid #E5E7EB; }
|
||||||
|
.info-label { font-size: 24rpx; color: #6B7280; }
|
||||||
|
.info-value { font-size: 24rpx; color: #111827; font-weight: 500; }
|
||||||
|
.section { background: #FFFFFF; border-radius: 20rpx; padding: 28rpx; margin-top: 24rpx; box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.04); }
|
||||||
|
.section-title { font-size: 28rpx; font-weight: 600; color: #111827; margin-bottom: 16rpx; }
|
||||||
|
.summary-text { font-size: 24rpx; color: #374151; line-height: 1.8; white-space: pre-wrap; }
|
||||||
|
.msg-list { display: flex; flex-direction: column; gap: 16rpx; }
|
||||||
|
.msg-item { padding: 16rpx; border-radius: 12rpx; }
|
||||||
|
.msg-item.ai { background: #F9FAFB; border-left: 4rpx solid #4F46E5; }
|
||||||
|
.msg-item.user { background: #EEF2FF; border-left: 4rpx solid #818CF8; }
|
||||||
|
.msg-label { font-size: 20rpx; font-weight: 600; color: #4F46E5; margin-bottom: 8rpx; }
|
||||||
|
.msg-content { font-size: 24rpx; color: #111827; line-height: 1.7; white-space: pre-wrap; }
|
||||||
|
.actions { display: flex; gap: 20rpx; margin-top: 32rpx; }
|
||||||
|
.btn-primary { flex: 1; background: linear-gradient(135deg,#4F46E5,#7C3AED); color: #FFFFFF; border-radius: 16rpx; height: 88rpx; line-height: 88rpx; font-size: 28rpx; font-weight: 600; }
|
||||||
|
.btn-outline { flex: 1; background: #FFFFFF; color: #4F46E5; border-radius: 16rpx; height: 88rpx; line-height: 88rpx; font-size: 28rpx; border: 2rpx solid #4F46E5; }
|
||||||
|
.empty-box { padding: 200rpx 0; text-align: center; color: #9CA3AF; font-size: 28rpx; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,548 @@
|
|||||||
|
<template>
|
||||||
|
<view class="page">
|
||||||
|
<view class="header">
|
||||||
|
<text class="title">{{ isOptimize ? '优化结果' : '诊断报告' }}</text>
|
||||||
|
<text class="subtitle">目标岗位:{{ position }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="loading" class="loading-wrap">
|
||||||
|
<view class="loading-spinner"></view>
|
||||||
|
<text class="loading-text">{{ isOptimize ? 'AI 正在优化简历...' : 'AI 正在诊断简历...' }}</text>
|
||||||
|
</view>
|
||||||
|
<view v-if="!loading" class="content">
|
||||||
|
<!-- 诊断模式:评分 + 总结 -->
|
||||||
|
<view v-if="!isOptimize && diagnosisResult" class="section">
|
||||||
|
<view class="score-bar">
|
||||||
|
<text class="score-num">{{ diagnosisResult.score }}</text>
|
||||||
|
<text class="score-label">/100</text>
|
||||||
|
</view>
|
||||||
|
<text class="summary-text">{{ diagnosisResult.summary }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 岗位匹配度(诊断模式) -->
|
||||||
|
<view v-if="!isOptimize && diagnosisResult?.positionMatch" class="section">
|
||||||
|
<view class="section-title">岗位匹配度</view>
|
||||||
|
<view class="match-bar">
|
||||||
|
<view class="match-fill" :style="'width:' + diagnosisResult.positionMatch.match + '%'"></view>
|
||||||
|
</view>
|
||||||
|
<text class="match-text">{{ diagnosisResult.positionMatch.match }}% 匹配</text>
|
||||||
|
<view v-if="diagnosisResult.positionMatch.keywords?.length" class="keywords-wrap">
|
||||||
|
<text class="keywords-label">建议补充关键词:</text>
|
||||||
|
<text class="keyword-tag" v-for="kw in diagnosisResult.positionMatch.keywords" :key="kw">{{ kw }}</text>
|
||||||
|
</view>
|
||||||
|
<view v-if="diagnosisResult.positionMatch.suggestions?.length" class="match-suggestions">
|
||||||
|
<text v-for="(s, i) in diagnosisResult.positionMatch.suggestions" :key="i" class="match-suggestion">{{ i+1 }}. {{ s }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 优化模式:切换视图 -->
|
||||||
|
<view v-if="isOptimize" class="toggle-bar">
|
||||||
|
<text class="toggle-btn" :class="{ active: viewMode === 'optimized' }" @click="viewMode = 'optimized'">优化后</text>
|
||||||
|
<text class="toggle-btn" :class="{ active: viewMode === 'original' }" @click="viewMode = 'original'">优化前</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="isOptimize" class="section">
|
||||||
|
<view class="section-title">{{ viewMode === 'optimized' ? '优化后的简历' : '原始简历' }}</view>
|
||||||
|
<view class="optimized-text">{{ viewMode === 'optimized' ? optimizedContent : originalContent }}</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 问题列表 / 修改说明 -->
|
||||||
|
<view v-if="changes && changes.length > 0" class="section">
|
||||||
|
<view class="section-title">{{ isOptimize ? '修改说明' : '问题列表' }}</view>
|
||||||
|
<view class="changes-list">
|
||||||
|
<view class="change-item" v-for="(change, index) in changes" :key="index">
|
||||||
|
<view class="change-type" v-if="change.type">{{ change.typeLabel || change.type }}</view>
|
||||||
|
<view class="change-section">{{ change.section || change.title }}</view>
|
||||||
|
<view class="change-arrow" v-if="change.before">{{ change.before }} → {{ change.after }}</view>
|
||||||
|
<view class="change-desc">{{ change.reason || change.suggestion || change.description }}</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 优势 / 亮点 -->
|
||||||
|
<view v-if="highlights && highlights.length > 0" class="section">
|
||||||
|
<view class="section-title">{{ isOptimize ? '优化亮点' : '现有优势' }}</view>
|
||||||
|
<view class="highlights-list">
|
||||||
|
<view class="highlight-item" v-for="(h, i) in highlights" :key="i">{{ h }}</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 操作按钮 -->
|
||||||
|
<view class="actions" v-if="!loading">
|
||||||
|
<button class="btn btn-download" @click="downloadResult">下载报告</button>
|
||||||
|
<button class="btn btn-save" @click="saveResult" v-if="saved" :disabled="saving">{{ saving ? '保存中...' : '已保存' }}</button>
|
||||||
|
<button class="btn btn-save" @click="saveResult" v-else :disabled="saving">{{ saving ? '保存中...' : '保存到我的' }}</button>
|
||||||
|
<button class="btn btn-secondary" @click="reOptimize">重新{{ isOptimize ? '优化' : '诊断' }}</button>
|
||||||
|
<button class="btn btn-primary" @click="goHome">返回首页</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { onLoad } from '@dcloudio/uni-app';
|
||||||
|
import api from '../../services/api';
|
||||||
|
import { APP_CONFIG } from '../../config';
|
||||||
|
|
||||||
|
const position = ref('');
|
||||||
|
const originalContent = ref('');
|
||||||
|
const optimizedContent = ref('');
|
||||||
|
const changes = ref<any[]>([]);
|
||||||
|
const highlights = ref<string[]>([]);
|
||||||
|
const isOptimize = ref(false);
|
||||||
|
const diagnosisResult = ref<any>(null);
|
||||||
|
const viewMode = ref<'optimized' | 'original'>('optimized');
|
||||||
|
const loading = ref(true);
|
||||||
|
const saved = ref(false);
|
||||||
|
const saving = ref(false);
|
||||||
|
const resultId = ref('');
|
||||||
|
|
||||||
|
onLoad(async (options: any) => {
|
||||||
|
if (options.position) position.value = decodeURIComponent(options.position);
|
||||||
|
isOptimize.value = options.type === 'optimize';
|
||||||
|
originalContent.value = uni.getStorageSync('_resume_text') || '';
|
||||||
|
|
||||||
|
// 优先从 storage 读取分析结果(index 页存储的)
|
||||||
|
const savedData = uni.getStorageSync('_analysis_result');
|
||||||
|
if (savedData) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(savedData);
|
||||||
|
applyResult(data);
|
||||||
|
uni.removeStorageSync('_analysis_result');
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
uni.removeStorageSync('_analysis_result');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兜底:直接调 API
|
||||||
|
try {
|
||||||
|
loading.value = true;
|
||||||
|
if (isOptimize.value) {
|
||||||
|
const res = await api.analyze.optimize(originalContent.value, position.value);
|
||||||
|
applyResult(res);
|
||||||
|
} else {
|
||||||
|
const res = await api.analyze.diagnosis(originalContent.value, position.value);
|
||||||
|
applyResult(res);
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
uni.showToast({ title: e.message || '分析失败', icon: 'none' });
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function applyResult(data: any) {
|
||||||
|
loading.value = false;
|
||||||
|
if (isOptimize.value) {
|
||||||
|
optimizedContent.value = data.optimizedContent || '';
|
||||||
|
changes.value = data.changes || [];
|
||||||
|
highlights.value = data.highlights || [];
|
||||||
|
} else {
|
||||||
|
diagnosisResult.value = data;
|
||||||
|
changes.value = (data.issues || []).map((i: any) => ({
|
||||||
|
...i,
|
||||||
|
typeLabel: i.type === 'structure' ? '结构' : i.type === 'content' ? '内容' : i.type === 'keywords' ? '关键词' : i.type === 'achievement' ? '成就' : '格式',
|
||||||
|
}));
|
||||||
|
highlights.value = data.strengths || [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveResult() {
|
||||||
|
if (saved.value || saving.value) return;
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
const payload: any = {
|
||||||
|
title: (isOptimize.value ? '优化简历' : '简历诊断') + ' - ' + (position.value || '通用岗位'),
|
||||||
|
originalContent: originalContent.value,
|
||||||
|
targetPosition: position.value,
|
||||||
|
type: isOptimize.value ? 'optimize' : 'diagnosis',
|
||||||
|
};
|
||||||
|
const res = await api.resume.create(payload);
|
||||||
|
resultId.value = res._id || res.id;
|
||||||
|
saved.value = true;
|
||||||
|
uni.showToast({ title: '保存成功', icon: 'success' });
|
||||||
|
} catch (e: any) {
|
||||||
|
uni.showToast({ title: e.message || '保存失败', icon: 'none' });
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadResult() {
|
||||||
|
const isH5 = typeof window !== 'undefined';
|
||||||
|
let content = '';
|
||||||
|
const fileName = (isOptimize.value ? '优化简历' : '简历诊断') + '_' + (position.value || '通用岗位') + '.txt';
|
||||||
|
|
||||||
|
if (isOptimize.value) {
|
||||||
|
content = '===== 简历优化报告 =====\n\n';
|
||||||
|
content += '目标岗位:' + position.value + '\n\n';
|
||||||
|
content += '--- 优化后的简历 ---\n\n';
|
||||||
|
content += optimizedContent.value + '\n\n';
|
||||||
|
content += '--- 修改说明 ---\n\n';
|
||||||
|
changes.value.forEach((c, i) => {
|
||||||
|
content += `${i+1}. [${c.typeLabel || c.type}] ${c.section || c.title}\n`;
|
||||||
|
if (c.before) content += ` 修改前:${c.before}\n 修改后:${c.after}\n`;
|
||||||
|
content += ` 说明:${c.reason || c.suggestion || ''}\n\n`;
|
||||||
|
});
|
||||||
|
content += '--- 优化亮点 ---\n\n';
|
||||||
|
highlights.value.forEach(h => content += '• ' + h + '\n');
|
||||||
|
} else if (diagnosisResult.value) {
|
||||||
|
content = '===== 简历诊断报告 =====\n\n';
|
||||||
|
content += '目标岗位:' + position.value + '\n';
|
||||||
|
content += '综合评分:' + diagnosisResult.value.score + '/100\n\n';
|
||||||
|
content += '--- 综合评述 ---\n\n';
|
||||||
|
content += diagnosisResult.value.summary + '\n\n';
|
||||||
|
|
||||||
|
if (diagnosisResult.value.positionMatch) {
|
||||||
|
content += '--- 岗位匹配度 ---\n';
|
||||||
|
content += '匹配度:' + diagnosisResult.value.positionMatch.match + '%\n';
|
||||||
|
if (diagnosisResult.value.positionMatch.keywords?.length) {
|
||||||
|
content += '关键缺失词:' + diagnosisResult.value.positionMatch.keywords.join('、') + '\n';
|
||||||
|
}
|
||||||
|
content += '\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
content += '--- 问题列表 ---\n\n';
|
||||||
|
changes.value.forEach((c, i) => {
|
||||||
|
content += `${i+1}. [${c.typeLabel || c.type}] ${c.title}\n`;
|
||||||
|
content += ` 描述:${c.description || ''}\n`;
|
||||||
|
content += ` 建议:${c.suggestion || ''}\n\n`;
|
||||||
|
});
|
||||||
|
|
||||||
|
content += '--- 现有优势 ---\n\n';
|
||||||
|
highlights.value.forEach(h => content += '• ' + h + '\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isH5) {
|
||||||
|
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = fileName;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
uni.showToast({ title: '下载成功', icon: 'success' });
|
||||||
|
} else {
|
||||||
|
uni.setClipboardData({
|
||||||
|
data: content,
|
||||||
|
success: () => uni.showToast({ title: '内容已复制到剪贴板' }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyContent() {
|
||||||
|
uni.setClipboardData({
|
||||||
|
data: optimizedContent.value,
|
||||||
|
success: () => uni.showToast({ title: '已复制', icon: 'success' }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function reOptimize() {
|
||||||
|
uni.navigateBack();
|
||||||
|
}
|
||||||
|
|
||||||
|
function goHome() {
|
||||||
|
uni.switchTab({ url: APP_CONFIG.PAGES.INDEX });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f5f6f7;
|
||||||
|
padding: 30rpx;
|
||||||
|
padding-bottom: 160rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
border-radius: 20rpx;
|
||||||
|
padding: 40rpx 30rpx;
|
||||||
|
margin-bottom: 30rpx;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 36rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 10rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-size: 24rpx;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 120rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spinner {
|
||||||
|
width: 80rpx;
|
||||||
|
height: 80rpx;
|
||||||
|
border: 6rpx solid #e0e0e0;
|
||||||
|
border-top-color: #667eea;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
margin-bottom: 30rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-text {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-bar {
|
||||||
|
display: flex;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-btn {
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
padding: 20rpx;
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-btn.active {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 30rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
padding: 30rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.optimized-text {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #333;
|
||||||
|
line-height: 1.8;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-bar {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-num {
|
||||||
|
font-size: 72rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #ff8c00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-label {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-text {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #333;
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-copy {
|
||||||
|
width: 100%;
|
||||||
|
height: 80rpx;
|
||||||
|
line-height: 80rpx;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 40rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
margin-top: 30rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.changes-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.change-item {
|
||||||
|
padding: 20rpx;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.change-type {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4rpx 16rpx;
|
||||||
|
background: #e8f5e9;
|
||||||
|
color: #2e7d32;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
font-size: 20rpx;
|
||||||
|
margin-bottom: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.change-section {
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #667eea;
|
||||||
|
margin-bottom: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.change-arrow {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.change-desc {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.highlights-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.highlight-item {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #333;
|
||||||
|
padding: 12rpx 20rpx;
|
||||||
|
background: #f0f8ff;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-bar {
|
||||||
|
height: 16rpx;
|
||||||
|
background: #eee;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, #ff8c00, #ff6348);
|
||||||
|
border-radius: 8rpx;
|
||||||
|
transition: width 0.5s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-text {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #ff8c00;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.keywords-wrap {
|
||||||
|
margin-top: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.keywords-label {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #999;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.keyword-tag {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4rpx 16rpx;
|
||||||
|
background: #fff3e0;
|
||||||
|
color: #e65100;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
font-size: 20rpx;
|
||||||
|
margin: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-suggestions {
|
||||||
|
margin-top: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-suggestion {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #666;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 6rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: #fff;
|
||||||
|
padding: 20rpx 30rpx;
|
||||||
|
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
|
||||||
|
display: flex;
|
||||||
|
gap: 16rpx;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
box-shadow: 0 -4rpx 20rpx rgba(0,0,0,0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
height: 76rpx;
|
||||||
|
border-radius: 38rpx;
|
||||||
|
font-size: 26rpx;
|
||||||
|
flex: 1;
|
||||||
|
min-width: calc(50% - 8rpx);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: #fff;
|
||||||
|
color: #667eea;
|
||||||
|
border: 2rpx solid #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-download {
|
||||||
|
background: #e8f5e9;
|
||||||
|
color: #2e7d32;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-save {
|
||||||
|
background: #fff8e1;
|
||||||
|
color: #f57f17;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-save:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,424 @@
|
|||||||
|
<template>
|
||||||
|
<view class="page fade-in">
|
||||||
|
<!-- 顶部 -->
|
||||||
|
<view class="hero">
|
||||||
|
<text class="hero-title">{{ currentTab === 'list' ? '简历管理' : 'AI简历优化' }}</text>
|
||||||
|
<text class="hero-sub">{{ currentTab === 'list' ? '管理你的简历' : '输入简历内容,AI 诊断并优化' }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 选项卡 -->
|
||||||
|
<view class="tabs">
|
||||||
|
<view class="tab" :class="{ active: currentTab === 'list' }" @click="switchTab('list')">📁 我的简历</view>
|
||||||
|
<view class="tab" :class="{ active: currentTab === 'analyze' }" @click="switchTab('analyze')">🤖 AI分析</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- Tab: 我的简历 -->
|
||||||
|
<view class="body" v-if="currentTab === 'list'">
|
||||||
|
<view class="add-card" @click="showForm = true" v-if="!showForm && isLoggedIn">
|
||||||
|
<text class="add-plus">+</text>
|
||||||
|
<text class="add-text">新建简历</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="form-card" v-if="showForm">
|
||||||
|
<view class="form-title">新建简历</view>
|
||||||
|
<input class="field-input" v-model="formTitle" placeholder="简历标题(如:前端工程师)" />
|
||||||
|
<input class="field-input" v-model="formPosition" placeholder="目标岗位" />
|
||||||
|
<textarea class="field-textarea" v-model="formContent" placeholder="粘贴简历内容..." :maxlength="5000" />
|
||||||
|
<view class="form-actions">
|
||||||
|
<button class="act-cancel" @click="cancelForm">取消</button>
|
||||||
|
<button class="act-save" @click="saveResume" :disabled="saving">{{ saving ? '保存中...' : '保存' }}</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 未登录 -->
|
||||||
|
<view class="login-prompt" v-if="!isLoggedIn && !showForm">
|
||||||
|
<text class="prompt-icon">🔒</text>
|
||||||
|
<text class="prompt-text">登录后可管理简历</text>
|
||||||
|
<button class="prompt-btn btn-gradient" @click="goLogin">去登录</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 列表 -->
|
||||||
|
<view class="list" v-if="isLoggedIn && resumes.length > 0">
|
||||||
|
<view class="item card" v-for="r in resumes" :key="r.id" @click="selectResume(r)">
|
||||||
|
<view class="item-title">{{ r.title }}</view>
|
||||||
|
<view class="item-meta">
|
||||||
|
<text class="item-tag">{{ r.targetPosition || '通用' }}</text>
|
||||||
|
<text class="item-date">{{ r.createdAt }}</text>
|
||||||
|
</view>
|
||||||
|
<text class="item-del" @click.stop="deleteResume(r.id)">删除</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="empty" v-if="isLoggedIn && resumes.length === 0 && !showForm">
|
||||||
|
<text class="empty-icon">📄</text>
|
||||||
|
<text class="empty-title">暂无简历</text>
|
||||||
|
<text class="empty-desc">新建一份简历,AI 帮你分析和优化</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- Tab: AI 分析 -->
|
||||||
|
<view class="body" v-if="currentTab === 'analyze'">
|
||||||
|
<!-- 选择简历 -->
|
||||||
|
<view class="section-label">选择要分析的简历</view>
|
||||||
|
<view class="select-list" v-if="resumes.length > 0">
|
||||||
|
<view class="select-item" v-for="r in resumes" :key="r.id"
|
||||||
|
:class="{ selected: selectedResumeId === r.id }" @click="selectedResumeId = r.id; selectedText = ''">
|
||||||
|
<text class="select-name">{{ r.title }}</text>
|
||||||
|
<text class="select-check" v-if="selectedResumeId === r.id">✓</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="select-item" :class="{ selected: selectedResumeId === '' }" @click="selectedResumeId = ''; selectedText = resumeText">
|
||||||
|
<text class="select-name">{{ resumeText ? '已输入自定义内容' : '直接粘贴内容' }}</text>
|
||||||
|
<text class="select-check" v-if="selectedResumeId === ''">✓</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 输入内容 -->
|
||||||
|
<!-- 上传文件 -->
|
||||||
|
<view class="upload-area" @click="chooseFile">
|
||||||
|
<text class="upload-icon">{{ fileName ? '📎' : '📤' }}</text>
|
||||||
|
<text class="upload-text">{{ fileName || '点击上传 PDF / Word 文件' }}</text>
|
||||||
|
<text class="upload-status" v-if="uploading">解析中...</text>
|
||||||
|
</view>
|
||||||
|
<input type="file" ref="fileInputRef" accept=".pdf,.doc,.docx,.txt" style="display:none" @change="onFileSelected" />
|
||||||
|
|
||||||
|
<view class="section-label" style="margin-top: 20rpx;">简历内容</view>
|
||||||
|
<textarea class="analyze-textarea" v-model="resumeText" placeholder="粘贴简历文本内容..." :maxlength="10000" />
|
||||||
|
|
||||||
|
<view class="section-label" style="margin-top: 20rpx;">目标岗位(可选)</view>
|
||||||
|
<input class="field-input" v-model="targetPosition" placeholder="如:前端工程师" />
|
||||||
|
|
||||||
|
<!-- 操作 -->
|
||||||
|
<view class="analyze-actions">
|
||||||
|
<button class="act-optimize" @click="submitOptimize" :disabled="analyzing">
|
||||||
|
{{ analyzing ? 'AI 优化中...' : '✨ 智能优化' }}
|
||||||
|
</button>
|
||||||
|
<button class="act-diagnose" @click="submitDiagnose" :disabled="analyzing">
|
||||||
|
{{ analyzing ? '诊断中...' : '📋 简历诊断' }}
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 结果 -->
|
||||||
|
<view v-if="result" class="result-area">
|
||||||
|
<view class="result-header">
|
||||||
|
<text class="result-type">{{ resultType === 'diagnosis' ? '诊断结果' : '优化结果' }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="resultType === 'diagnosis'" class="result-section">
|
||||||
|
<view class="score-wrap">
|
||||||
|
<text class="score-num">{{ result.score }}</text>
|
||||||
|
<text class="score-total">/100</text>
|
||||||
|
</view>
|
||||||
|
<view class="issues-list" v-if="result.issues">
|
||||||
|
<view class="issue" v-for="(issue, i) in result.issues" :key="i">
|
||||||
|
<text class="issue-level" :class="issue.level">{{ issue.level === 'high' ? '严重' : issue.level === 'medium' ? '中等' : '轻微' }}</text>
|
||||||
|
<view class="issue-body">
|
||||||
|
<text class="issue-title">{{ issue.title }}</text>
|
||||||
|
<text class="issue-desc">{{ issue.desc }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="suggestions" v-if="result.suggestions">
|
||||||
|
<text class="sugg-title">改进建议:</text>
|
||||||
|
<text class="sugg-item" v-for="(s, i) in result.suggestions" :key="i">{{ i+1 }}. {{ s }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="resultType === 'optimize'" class="result-section">
|
||||||
|
<view class="changes" v-if="result.changes">
|
||||||
|
<text class="sugg-title">改动项:</text>
|
||||||
|
<text class="change-item" v-for="(c, i) in result.changes" :key="i">{{ i+1 }}. {{ c }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="optimized-content" v-if="result.optimized">
|
||||||
|
<text class="sugg-title">优化后内容:</text>
|
||||||
|
<text class="opt-text">{{ result.optimized }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<!-- Download buttons -->
|
||||||
|
<view class="result-actions" v-if="result">
|
||||||
|
<button class="act-download" @click="downloadResult('txt')">📥 下载为 TXT</button>
|
||||||
|
<button class="act-download outline" @click="downloadResult('html')">📄 预览 HTML</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, computed } from 'vue'
|
||||||
|
import { onLoad } from '@dcloudio/uni-app'
|
||||||
|
import { api } from '../../config'
|
||||||
|
|
||||||
|
const currentTab = ref('list')
|
||||||
|
const showForm = ref(false)
|
||||||
|
const formTitle = ref('')
|
||||||
|
const formPosition = ref('')
|
||||||
|
const formContent = ref('')
|
||||||
|
const resumes = ref([])
|
||||||
|
const saving = ref(false)
|
||||||
|
const isLoggedIn = ref(false)
|
||||||
|
|
||||||
|
// Analyze tab
|
||||||
|
const resumeText = ref('')
|
||||||
|
const targetPosition = ref('')
|
||||||
|
const selectedResumeId = ref('')
|
||||||
|
const analyzing = ref(false)
|
||||||
|
const result = ref(null)
|
||||||
|
const resultType = ref('')
|
||||||
|
const fileName = ref('')
|
||||||
|
const uploading = ref(false)
|
||||||
|
|
||||||
|
const token = () => uni.getStorageSync('token') || ''
|
||||||
|
|
||||||
|
onLoad((options) => {
|
||||||
|
if (options?.tab === 'analyze') currentTab.value = 'analyze'
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
isLoggedIn.value = !!token()
|
||||||
|
if (isLoggedIn.value) await loadList()
|
||||||
|
})
|
||||||
|
|
||||||
|
const switchTab = (tab) => {
|
||||||
|
currentTab.value = tab
|
||||||
|
if (tab === 'list' && token()) loadList()
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadList = async () => {
|
||||||
|
try {
|
||||||
|
const res = await uni.request({ url: api('/resume/list'), method: 'GET', header: { 'Authorization': `Bearer ${token()}` } })
|
||||||
|
if (res.statusCode === 200) resumes.value = (res.data || []).map(r => ({
|
||||||
|
id: r.id, title: r.title, targetPosition: r.targetPosition,
|
||||||
|
createdAt: r.createdAt ? new Date(r.createdAt).toLocaleDateString('zh-CN') : '--', content: r.content,
|
||||||
|
}))
|
||||||
|
} catch(e) { console.error(e) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectResume = (r) => {
|
||||||
|
currentTab.value = 'analyze'
|
||||||
|
selectedResumeId.value = r.id
|
||||||
|
resumeText.value = r.content || ''
|
||||||
|
targetPosition.value = r.targetPosition || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitDiagnose = async () => {
|
||||||
|
const content = getContent()
|
||||||
|
if (!content) { uni.showToast({ title: '请先输入简历内容', icon: 'none' }); return }
|
||||||
|
analyzing.value = true; result.value = null
|
||||||
|
try {
|
||||||
|
const res = await uni.request({ url: api('/analyze/diagnosis'), method: 'POST', header: { 'Content-Type': 'application/json' }, data: { content } })
|
||||||
|
if (res.statusCode === 200) { result.value = res.data; resultType.value = 'diagnosis' }
|
||||||
|
} catch { uni.showToast({ title: '诊断失败', icon: 'none' }) }
|
||||||
|
finally { analyzing.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitOptimize = async () => {
|
||||||
|
const content = getContent()
|
||||||
|
if (!content) { uni.showToast({ title: '请先输入简历内容', icon: 'none' }); return }
|
||||||
|
analyzing.value = true; result.value = null
|
||||||
|
try {
|
||||||
|
const direction = targetPosition.value || '通用岗位'
|
||||||
|
const res = await uni.request({ url: api('/analyze/optimize'), method: 'POST', header: { 'Content-Type': 'application/json' }, data: { content, direction } })
|
||||||
|
if (res.statusCode === 200) { result.value = res.data; resultType.value = 'optimize' }
|
||||||
|
} catch { uni.showToast({ title: '优化失败', icon: 'none' }) }
|
||||||
|
finally { analyzing.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const getContent = () => selectedResumeId.value ? (resumes.value.find(r => r.id === selectedResumeId.value)?.content || '') : resumeText.value
|
||||||
|
|
||||||
|
// 文件上传
|
||||||
|
const chooseFile = () => {
|
||||||
|
// #ifdef H5
|
||||||
|
const input = document.querySelector('input[type="file"]')
|
||||||
|
if (input) input.click()
|
||||||
|
// #endif
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
uni.chooseMessageFile({
|
||||||
|
count: 1,
|
||||||
|
type: 'file',
|
||||||
|
extension: ['pdf', 'doc', 'docx', 'txt'],
|
||||||
|
success: (res) => { const f = res.tempFiles[0]; uploadMpFile(f.path, f.name) }
|
||||||
|
})
|
||||||
|
// #endif
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadMpFile = async (filePath, name) => {
|
||||||
|
fileName.value = name
|
||||||
|
uploading.value = true
|
||||||
|
try {
|
||||||
|
const res = await uni.uploadFile({ url: api('/upload'), filePath, name: 'file' })
|
||||||
|
const data = JSON.parse(res.data)
|
||||||
|
if (res.statusCode === 200) {
|
||||||
|
resumeText.value = data.text
|
||||||
|
selectedResumeId.value = ''
|
||||||
|
uni.showToast({ title: `已解析:${data.fileName}`, icon: 'success' })
|
||||||
|
} else { throw new Error(data.message) }
|
||||||
|
} catch (e) { uni.showToast({ title: (e && e.message) || '解析失败', icon: 'none' }) }
|
||||||
|
finally { uploading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const onFileSelected = async (e) => {
|
||||||
|
const file = e.target?.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
fileName.value = file.name
|
||||||
|
uploading.value = true
|
||||||
|
try {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
const res = await fetch(api('/upload'), { method: 'POST', body: formData })
|
||||||
|
const data = await res.json()
|
||||||
|
if (res.ok) {
|
||||||
|
resumeText.value = data.text
|
||||||
|
selectedResumeId.value = ''
|
||||||
|
uni.showToast({ title: `已解析:${data.fileName}`, icon: 'success' })
|
||||||
|
} else { throw new Error(data.message) }
|
||||||
|
} catch (e) { uni.showToast({ title: (e && e.message) || '解析失败', icon: 'none' }) }
|
||||||
|
finally { uploading.value = false; if (document.querySelector('input[type="file"]')) (document.querySelector('input[type="file"]')).value = '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下载结果(H5 用 Blob 下载,小程序用文件保存)
|
||||||
|
const downloadResult = (format) => {
|
||||||
|
if (!result.value) return
|
||||||
|
const content = format === 'html'
|
||||||
|
? `<!DOCTYPE html><html><meta charset="utf-8"><title>职引 - ${resultType.value === 'diagnosis' ? '诊断报告' : '优化结果'}</title><body>${JSON.stringify(result.value)}</body></html>`
|
||||||
|
: `${resultType.value === 'diagnosis' ? '=== 简历诊断报告 ===\n\n' : '=== 简历优化结果 ===\n\n'}${JSON.stringify(result.value, null, 2)}`
|
||||||
|
const fileName = `简历${resultType.value === 'diagnosis' ? '诊断报告' : '优化结果'}.${format}`
|
||||||
|
|
||||||
|
// #ifdef H5
|
||||||
|
const blob = new Blob([content], { type: format === 'html' ? 'text/html' : 'text/plain' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = fileName
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
// #endif
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
const fs = uni.getFileSystemManager()
|
||||||
|
const tempPath = `${wx.env.USER_DATA_PATH}/${fileName}`
|
||||||
|
fs.writeFile({ filePath: tempPath, data: content, encoding: 'utf-8', success: () => {
|
||||||
|
uni.openDocument({ filePath: tempPath, success: () => uni.showToast({ title: '预览成功', icon: 'success' }) })
|
||||||
|
}})
|
||||||
|
// #endif
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveResume = async () => {
|
||||||
|
if (!formTitle.value) { uni.showToast({ title: '请输入标题', icon: 'none' }); return }
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
await uni.request({ url: api('/resume/create'), method: 'POST', header: { 'Authorization': `Bearer ${token()}`, 'Content-Type': 'application/json' }, data: { title: formTitle.value, content: formContent.value, targetPosition: formPosition.value } })
|
||||||
|
uni.showToast({ title: '保存成功', icon: 'success' })
|
||||||
|
cancelForm(); loadList()
|
||||||
|
} catch { uni.showToast({ title: '保存失败', icon: 'none' }) }
|
||||||
|
finally { saving.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteResume = async (id) => {
|
||||||
|
uni.showModal({ title: '确认删除', content: '确定删除这份简历吗?', success: async (r) => {
|
||||||
|
if (!r.confirm) return
|
||||||
|
try { await uni.request({ url: api(`/resume/${id}`), method: 'DELETE', header: { 'Authorization': `Bearer ${token()}` } }); uni.showToast({ title: '删除成功', icon: 'success' }); loadList() }
|
||||||
|
catch { uni.showToast({ title: '删除失败', icon: 'none' }) }
|
||||||
|
}})
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelForm = () => { showForm.value = false; formTitle.value = ''; formPosition.value = ''; formContent.value = '' }
|
||||||
|
const goLogin = () => uni.navigateTo({ url: '/pages/login/login' })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page { background: var(--color-bg); }
|
||||||
|
.hero { background: linear-gradient(135deg, var(--color-gradient-start) 0%, var(--color-gradient-mid) 50%, var(--color-gradient-end) 100%); padding: 48rpx 32rpx 72rpx; border-radius: 0 0 48rpx 48rpx; min-height: 90rpx; }
|
||||||
|
.hero-title { font-size: 40rpx; font-weight: 700; color: #FFFFFF; display: block; }
|
||||||
|
.hero-sub { font-size: 22rpx; color: rgba(255,255,255,0.7); margin-top: 8rpx; display: block; }
|
||||||
|
|
||||||
|
/* Tabs */
|
||||||
|
.tabs { display: flex; gap: 8rpx; padding: 0 32rpx; margin-top: -40rpx; }
|
||||||
|
.tab { flex: 1; background: #FFFFFF; padding: 16rpx; text-align: center; border-radius: var(--radius-md); font-size: 24rpx; color: var(--color-text-secondary); box-shadow: var(--shadow-sm); transition: all 0.25s; }
|
||||||
|
.tab.active { background: var(--color-primary); color: #FFFFFF; font-weight: 600; box-shadow: var(--shadow-purple); }
|
||||||
|
|
||||||
|
.body { padding: 20rpx 32rpx 48rpx; }
|
||||||
|
|
||||||
|
/* Upload */
|
||||||
|
.upload-area { display: flex; align-items: center; gap: 12rpx; background: #EEF2FF; border: 2rpx dashed var(--color-primary-light); border-radius: var(--radius-md); padding: 24rpx; margin-bottom: 16rpx; }
|
||||||
|
.upload-icon { font-size: 32rpx; }
|
||||||
|
.upload-text { flex: 1; font-size: 24rpx; color: var(--color-primary); }
|
||||||
|
.upload-status { font-size: 22rpx; color: var(--color-text-tertiary); }
|
||||||
|
|
||||||
|
/* Result actions */
|
||||||
|
.result-actions { display: flex; gap: 16rpx; margin-top: 20rpx; }
|
||||||
|
.act-download { flex: 1; height: 72rpx; line-height: 72rpx; background: linear-gradient(135deg, var(--color-gradient-start), var(--color-gradient-mid)); color: #FFFFFF; border-radius: var(--radius-sm); font-size: 24rpx; border: none; }
|
||||||
|
.act-download.outline { background: #FFFFFF; color: var(--color-primary); border: 2rpx solid var(--color-primary); }
|
||||||
|
|
||||||
|
/* Login prompt */
|
||||||
|
.login-prompt { display: flex; flex-direction: column; align-items: center; padding: 100rpx 0; gap: 16rpx; }
|
||||||
|
.prompt-icon { font-size: 64rpx; opacity: 0.4; }
|
||||||
|
.prompt-text { font-size: 26rpx; color: var(--color-text-tertiary); }
|
||||||
|
.prompt-btn { padding: 16rpx 48rpx; border-radius: var(--radius-round); font-size: 26rpx; }
|
||||||
|
|
||||||
|
/* Add card */
|
||||||
|
.add-card { background: #FFFFFF; border: 2rpx dashed #D1D5DB; border-radius: var(--radius-lg); padding: 36rpx; display: flex; flex-direction: column; align-items: center; margin-bottom: 24rpx; }
|
||||||
|
.add-plus { font-size: 48rpx; color: var(--color-primary); font-weight: 700; }
|
||||||
|
.add-text { font-size: 26rpx; color: var(--color-text-tertiary); margin-top: 8rpx; }
|
||||||
|
|
||||||
|
/* Form */
|
||||||
|
.form-card { background: #FFFFFF; border-radius: var(--radius-lg); padding: 28rpx; box-shadow: var(--shadow-md); margin-bottom: 24rpx; }
|
||||||
|
.form-title { font-size: 28rpx; font-weight: 600; color: var(--color-text); margin-bottom: 20rpx; }
|
||||||
|
.field-input { width: 100%; height: 72rpx; background: #F9FAFB; border: 2rpx solid var(--color-border); border-radius: var(--radius-sm); padding: 0 20rpx; font-size: 24rpx; margin-bottom: 16rpx; }
|
||||||
|
.field-textarea { width: 100%; height: 240rpx; background: #F9FAFB; border: 2rpx solid var(--color-border); border-radius: var(--radius-sm); padding: 16rpx 20rpx; font-size: 24rpx; line-height: 1.6; }
|
||||||
|
.form-actions { display: flex; gap: 16rpx; margin-top: 20rpx; }
|
||||||
|
.act-cancel { flex: 1; height: 72rpx; line-height: 72rpx; background: #F3F4F6; color: var(--color-text-secondary); border-radius: var(--radius-sm); font-size: 24rpx; border: none; }
|
||||||
|
.act-save { flex: 1; height: 72rpx; line-height: 72rpx; background: linear-gradient(135deg, var(--color-gradient-start), var(--color-gradient-mid)); color: #FFFFFF; border-radius: var(--radius-sm); font-size: 24rpx; border: none; }
|
||||||
|
|
||||||
|
/* List */
|
||||||
|
.item { padding: 24rpx 28rpx; margin-bottom: 12rpx; display: flex; flex-direction: column; }
|
||||||
|
.item-title { font-size: 26rpx; font-weight: 600; color: var(--color-text); }
|
||||||
|
.item-meta { display: flex; gap: 16rpx; margin-top: 8rpx; }
|
||||||
|
.item-tag { font-size: 20rpx; color: var(--color-primary); background: #EEF2FF; padding: 2rpx 12rpx; border-radius: var(--radius-round); }
|
||||||
|
.item-date { font-size: 20rpx; color: var(--color-text-tertiary); }
|
||||||
|
.item-del { font-size: 22rpx; color: var(--color-error); margin-top: 8rpx; align-self: flex-end; }
|
||||||
|
|
||||||
|
/* Select list */
|
||||||
|
.section-label { font-size: 24rpx; font-weight: 600; color: var(--color-text); margin-bottom: 12rpx; }
|
||||||
|
.select-list { display: flex; flex-direction: column; gap: 8rpx; }
|
||||||
|
.select-item { display: flex; justify-content: space-between; align-items: center; padding: 16rpx 20rpx; background: #FFFFFF; border: 2rpx solid var(--color-border); border-radius: var(--radius-sm); }
|
||||||
|
.select-item.selected { border-color: var(--color-primary); background: #EEF2FF; }
|
||||||
|
.select-name { font-size: 24rpx; color: var(--color-text); }
|
||||||
|
.select-check { font-size: 24rpx; color: var(--color-primary); font-weight: 700; }
|
||||||
|
|
||||||
|
/* Analyze textarea */
|
||||||
|
.analyze-textarea { width: 100%; height: 280rpx; background: #FFFFFF; border: 2rpx solid var(--color-border); border-radius: var(--radius-sm); padding: 16rpx 20rpx; font-size: 24rpx; line-height: 1.6; }
|
||||||
|
|
||||||
|
/* Actions */
|
||||||
|
.analyze-actions { display: flex; gap: 16rpx; margin-top: 24rpx; }
|
||||||
|
.act-optimize { flex: 1; height: 80rpx; line-height: 80rpx; background: linear-gradient(135deg, var(--color-gradient-start), var(--color-gradient-mid)); color: #FFFFFF; border-radius: var(--radius-sm); font-size: 26rpx; font-weight: 600; border: none; }
|
||||||
|
.act-diagnose { flex: 1; height: 80rpx; line-height: 80rpx; background: #FFFFFF; color: var(--color-primary); border: 2rpx solid var(--color-primary); border-radius: var(--radius-sm); font-size: 26rpx; font-weight: 500; }
|
||||||
|
.act-optimize:disabled, .act-diagnose:disabled { opacity: 0.6; }
|
||||||
|
|
||||||
|
/* Result */
|
||||||
|
.result-area { margin-top: 24rpx; }
|
||||||
|
.result-header { margin-bottom: 16rpx; }
|
||||||
|
.result-type { font-size: 28rpx; font-weight: 700; color: var(--color-text); }
|
||||||
|
.result-section { background: #FFFFFF; border-radius: var(--radius-lg); padding: 28rpx; box-shadow: var(--shadow-sm); }
|
||||||
|
.score-wrap { display: flex; align-items: baseline; justify-content: center; margin-bottom: 24rpx; }
|
||||||
|
.score-num { font-size: 64rpx; font-weight: 800; color: var(--color-primary); }
|
||||||
|
.score-total { font-size: 28rpx; color: var(--color-text-tertiary); }
|
||||||
|
.issues-list { display: flex; flex-direction: column; gap: 16rpx; }
|
||||||
|
.issue { display: flex; gap: 12rpx; }
|
||||||
|
.issue-level { font-size: 20rpx; padding: 2rpx 10rpx; border-radius: var(--radius-round); white-space: nowrap; height: fit-content; }
|
||||||
|
.issue-level.high { background: #FEF2F2; color: var(--color-error); }
|
||||||
|
.issue-level.medium { background: #FFF7ED; color: var(--color-warning); }
|
||||||
|
.issue-level.low { background: #F3F4F6; color: var(--color-text-tertiary); }
|
||||||
|
.issue-body { flex: 1; }
|
||||||
|
.issue-title { font-size: 24rpx; font-weight: 600; color: var(--color-text); }
|
||||||
|
.issue-desc { font-size: 22rpx; color: var(--color-text-secondary); margin-top: 4rpx; }
|
||||||
|
.suggestions { margin-top: 20rpx; }
|
||||||
|
.sugg-title { font-size: 24rpx; font-weight: 600; color: var(--color-text); margin-bottom: 12rpx; display: block; }
|
||||||
|
.sugg-item { font-size: 22rpx; color: var(--color-text-secondary); line-height: 1.8; display: block; }
|
||||||
|
.changes { margin-bottom: 20rpx; }
|
||||||
|
.change-item { font-size: 22rpx; color: var(--color-text-secondary); line-height: 1.8; display: block; }
|
||||||
|
.optimized-content { margin-top: 16rpx; padding-top: 16rpx; border-top: 1rpx solid var(--color-border); }
|
||||||
|
.opt-text { font-size: 24rpx; color: var(--color-text); line-height: 1.8; white-space: pre-wrap; }
|
||||||
|
.empty { display: flex; flex-direction: column; align-items: center; padding: 80rpx 0; }
|
||||||
|
.empty-icon { font-size: 64rpx; margin-bottom: 16rpx; opacity: 0.5; }
|
||||||
|
.empty-title { font-size: 28rpx; font-weight: 600; color: var(--color-text); }
|
||||||
|
.empty-desc { font-size: 22rpx; color: var(--color-text-tertiary); margin-top: 8rpx; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
<template>
|
||||||
|
<view class="page fade-in">
|
||||||
|
<!-- 个人中心 -->
|
||||||
|
<view class="header" v-if="isLoggedIn">
|
||||||
|
<view class="profile-section">
|
||||||
|
<image class="avatar" :src="userInfo.avatar || '/static/avatar-default.svg'" mode="aspectFill" />
|
||||||
|
<view class="profile-info">
|
||||||
|
<text class="nickname">{{ userInfo.nickname || '未设置昵称' }}</text>
|
||||||
|
<view class="plan-badge">{{ userInfo.plan || '免费版' }}</view>
|
||||||
|
</view>
|
||||||
|
<text class="header-arrow">›</text>
|
||||||
|
</view>
|
||||||
|
<view class="stats-bar">
|
||||||
|
<view class="stat">
|
||||||
|
<text class="stat-num">{{ stats.interviewCount || 0 }}</text>
|
||||||
|
<text class="stat-label">模拟面试</text>
|
||||||
|
</view>
|
||||||
|
<view class="stat-divider"></view>
|
||||||
|
<view class="stat">
|
||||||
|
<text class="stat-num">{{ stats.avgScore || '--' }}</text>
|
||||||
|
<text class="stat-label">平均得分</text>
|
||||||
|
</view>
|
||||||
|
<view class="stat-divider"></view>
|
||||||
|
<view class="stat">
|
||||||
|
<text class="stat-num">{{ stats.completedCount || 0 }}</text>
|
||||||
|
<text class="stat-label">已完成</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="header header-guest" v-else @click="goLogin">
|
||||||
|
<view class="guest-box">
|
||||||
|
<view class="guest-avatar"><text class="guest-icon">👤</text></view>
|
||||||
|
<view class="guest-info">
|
||||||
|
<text class="guest-name">未登录 / 点击登录</text>
|
||||||
|
<text class="guest-hint">登录后体验全部功能</text>
|
||||||
|
</view>
|
||||||
|
<text class="header-arrow">›</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 菜单列表 -->
|
||||||
|
<view class="menu-area">
|
||||||
|
<view class="menu-group">
|
||||||
|
<view class="menu-item" @click="requireLogin(goHistory, '面试记录')">
|
||||||
|
<view class="menu-icon-wrap wrap-blue"><text class="menu-icon">📋</text></view>
|
||||||
|
<text class="menu-text">面试记录</text>
|
||||||
|
<text class="menu-arrow">›</text>
|
||||||
|
</view>
|
||||||
|
<view class="menu-item" @click="goVip">
|
||||||
|
<view class="menu-icon-wrap wrap-purple"><text class="menu-icon">💎</text></view>
|
||||||
|
<text class="menu-text">会员中心</text>
|
||||||
|
<text class="menu-arrow">›</text>
|
||||||
|
</view>
|
||||||
|
<view class="menu-item" @click="requireLogin(goResume, '我的简历')">
|
||||||
|
<view class="menu-icon-wrap wrap-green"><text class="menu-icon">📄</text></view>
|
||||||
|
<text class="menu-text">我的简历</text>
|
||||||
|
<text class="menu-arrow">›</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="menu-group">
|
||||||
|
<view class="menu-item" @click="goAbout">
|
||||||
|
<view class="menu-icon-wrap wrap-gray"><text class="menu-icon">ℹ️</text></view>
|
||||||
|
<text class="menu-text">关于</text>
|
||||||
|
<text class="menu-arrow">›</text>
|
||||||
|
</view>
|
||||||
|
<view class="menu-item" v-if="isAdmin" @click="goAdmin">
|
||||||
|
<view class="menu-icon-wrap wrap-gray"><text class="menu-icon">⚙️</text></view>
|
||||||
|
<text class="menu-text">管理后台</text>
|
||||||
|
<text class="menu-arrow">›</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="logout-wrap" v-if="isLoggedIn">
|
||||||
|
<button class="logout-btn" @click="doLogout">退出登录</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { api } from '../../config'
|
||||||
|
|
||||||
|
const userInfo = ref({})
|
||||||
|
const isAdmin = ref(false)
|
||||||
|
const stats = ref({ interviewCount: 0, avgScore: '--', completedCount: 0 })
|
||||||
|
const token = ref('')
|
||||||
|
|
||||||
|
const isLoggedIn = computed(() => !!token.value)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
token.value = uni.getStorageSync('token') || ''
|
||||||
|
if (!token.value) return
|
||||||
|
try { const s = uni.getStorageSync('userInfo'); if (s) userInfo.value = JSON.parse(s) } catch(e) {}
|
||||||
|
loadStats()
|
||||||
|
checkAdmin()
|
||||||
|
})
|
||||||
|
|
||||||
|
const loadStats = async () => {
|
||||||
|
try {
|
||||||
|
const res = await uni.request({ url: api('/interview/stats/mine'), method: 'GET', header: { 'Authorization': `Bearer ${token.value}` } })
|
||||||
|
if (res.statusCode === 200) stats.value = res.data
|
||||||
|
} catch(e) { console.error(e) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const requireLogin = (action, name) => {
|
||||||
|
if (isLoggedIn.value) { action(); return }
|
||||||
|
uni.showModal({
|
||||||
|
title: '请先登录',
|
||||||
|
content: `需要登录后才能使用${name}功能`,
|
||||||
|
confirmText: '去登录',
|
||||||
|
success: (r) => { if (r.confirm) uni.navigateTo({ url: '/pages/login/login' }) }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkAdmin = () => {
|
||||||
|
isAdmin.value = userInfo.value.role === 'admin'
|
||||||
|
}
|
||||||
|
|
||||||
|
const goLogin = () => uni.navigateTo({ url: '/pages/login/login' })
|
||||||
|
const goHistory = () => uni.switchTab({ url: '/pages/history/history' })
|
||||||
|
const goVip = () => uni.navigateTo({ url: '/pages/member/member' })
|
||||||
|
const goResume = () => uni.navigateTo({ url: '/pages/resume/resume' })
|
||||||
|
const goAdmin = () => uni.navigateTo({ url: '/pages/admin/admin' })
|
||||||
|
const goAbout = () => uni.navigateTo({ url: '/pages/about/about' })
|
||||||
|
|
||||||
|
const doLogout = () => {
|
||||||
|
uni.showModal({
|
||||||
|
title: '退出登录', content: '确定要退出登录吗?',
|
||||||
|
success: (r) => { if (r.confirm) { uni.removeStorageSync('token'); uni.removeStorageSync('userInfo'); token.value = '' } }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page { height: 100%; overflow-y: auto; background: var(--color-bg); }
|
||||||
|
|
||||||
|
.header {
|
||||||
|
background: linear-gradient(135deg, var(--color-gradient-start) 0%, var(--color-gradient-mid) 50%, var(--color-gradient-end) 100%);
|
||||||
|
padding: 48rpx 32rpx 72rpx; border-radius: 0 0 48rpx 48rpx; min-height: 90rpx;
|
||||||
|
}
|
||||||
|
.profile-section { display: flex; align-items: center; margin-bottom: 36rpx; }
|
||||||
|
.avatar { width: 104rpx; height: 104rpx; border-radius: 50%; margin-right: 24rpx; border: 3rpx solid rgba(255,255,255,0.4); flex-shrink: 0; }
|
||||||
|
.profile-info { flex: 1; display: flex; flex-direction: column; }
|
||||||
|
.nickname { font-size: 34rpx; font-weight: 700; color: #FFFFFF; }
|
||||||
|
.plan-badge { font-size: 20rpx; color: rgba(255,255,255,0.9); background: rgba(255,255,255,0.2); padding: 4rpx 14rpx; border-radius: 8rpx; align-self: flex-start; margin-top: 8rpx; }
|
||||||
|
.header-arrow { font-size: 36rpx; color: rgba(255,255,255,0.5); }
|
||||||
|
.stats-bar { display: flex; align-items: center; background: rgba(255,255,255,0.15); border-radius: var(--radius-lg); padding: 24rpx 0; }
|
||||||
|
.stat { flex: 1; display: flex; flex-direction: column; align-items: center; }
|
||||||
|
.stat-num { font-size: 34rpx; font-weight: 700; color: #FFFFFF; }
|
||||||
|
.stat-label { font-size: 20rpx; color: rgba(255,255,255,0.7); margin-top: 6rpx; }
|
||||||
|
.stat-divider { width: 1rpx; height: 44rpx; background: rgba(255,255,255,0.2); }
|
||||||
|
|
||||||
|
.header-guest { padding: 36rpx 32rpx 72rpx; min-height: 90rpx; }
|
||||||
|
.guest-box { display: flex; align-items: center; }
|
||||||
|
.guest-avatar { width: 96rpx; height: 96rpx; border-radius: 50%; background: rgba(255,255,255,0.2); display: flex; align-items: center; justify-content: center; margin-right: 24rpx; flex-shrink: 0; }
|
||||||
|
.guest-icon { font-size: 40rpx; }
|
||||||
|
.guest-info { flex: 1; }
|
||||||
|
.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-group { background: #FFFFFF; border-radius: var(--radius-lg); overflow: hidden; margin-bottom: 24rpx; box-shadow: var(--shadow-sm); }
|
||||||
|
.menu-item { display: flex; align-items: center; padding: 28rpx 32rpx; border-bottom: 1rpx solid var(--color-border); }
|
||||||
|
.menu-item:last-child { border-bottom: none; }
|
||||||
|
.menu-item:active { background: #F9FAFB; }
|
||||||
|
.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-text { flex: 1; font-size: 28rpx; color: var(--color-text); font-weight: 500; }
|
||||||
|
.menu-arrow { font-size: 32rpx; color: #D1D5DB; }
|
||||||
|
.wrap-blue { background: #EEF2FF; }
|
||||||
|
.wrap-purple { background: #F5F3FF; }
|
||||||
|
.wrap-green { background: #ECFDF5; }
|
||||||
|
.wrap-gray { background: #F3F4F6; }
|
||||||
|
.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:active { background: #FEF2F2; transform: scale(0.96); }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { API_ENDPOINTS, api } from '../config'
|
||||||
|
|
||||||
|
async function request<T = any>(url: string, method: string = 'POST', data?: any, auth: boolean = false): Promise<T> {
|
||||||
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
||||||
|
if (auth) {
|
||||||
|
const token = uni.getStorageSync('token') || ''
|
||||||
|
if (token) headers['Authorization'] = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await uni.request({ url: api(url), method, data, header: headers, timeout: 65000 })
|
||||||
|
if (res.statusCode >= 200 && res.statusCode < 300) return res.data as T
|
||||||
|
if (res.statusCode === 401) {
|
||||||
|
uni.removeStorageSync('token'); uni.removeStorageSync('userInfo')
|
||||||
|
uni.showToast({ title: '登录已过期', icon: 'none' })
|
||||||
|
setTimeout(() => uni.navigateTo({ url: '/pages/login/login' }), 500)
|
||||||
|
throw new Error('登录已过期')
|
||||||
|
}
|
||||||
|
throw new Error((res.data as any)?.message || '请求失败')
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e.message === '登录已过期') throw e
|
||||||
|
throw new Error(e.message || '网络错误')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const apiService = {
|
||||||
|
user: {
|
||||||
|
sendCode: (phone: string) => request(API_ENDPOINTS.USER.SEND_CODE, 'POST', { phone }),
|
||||||
|
login: (phone: string, code: string) => request(API_ENDPOINTS.USER.LOGIN, 'POST', { phone, code }),
|
||||||
|
wxLogin: (code: string) => request(API_ENDPOINTS.USER.WX_LOGIN, 'POST', { code }),
|
||||||
|
getInfo: () => request(API_ENDPOINTS.USER.INFO, 'GET', undefined, true),
|
||||||
|
update: (data: any) => request(API_ENDPOINTS.USER.UPDATE, 'PUT', data, true),
|
||||||
|
usage: () => request(API_ENDPOINTS.USER.USAGE, 'GET', undefined, true),
|
||||||
|
},
|
||||||
|
interview: {
|
||||||
|
create: (position: string) => request(API_ENDPOINTS.INTERVIEW.CREATE, 'POST', { position }, true),
|
||||||
|
answer: (id: string, answer: string) => request(API_ENDPOINTS.INTERVIEW.ANSWER(id), 'POST', { answer }, true),
|
||||||
|
complete: (id: string) => request(API_ENDPOINTS.INTERVIEW.COMPLETE(id), 'POST', undefined, true),
|
||||||
|
get: (id: string) => request(API_ENDPOINTS.INTERVIEW.GET(id), 'GET', undefined, true),
|
||||||
|
list: () => request(API_ENDPOINTS.INTERVIEW.LIST, 'GET', undefined, true),
|
||||||
|
stats: () => request(API_ENDPOINTS.INTERVIEW.STATS, 'GET', undefined, true),
|
||||||
|
},
|
||||||
|
analyze: {
|
||||||
|
diagnosis: (content: string) => request(API_ENDPOINTS.ANALYZE.DIAGNOSIS, 'POST', { content }),
|
||||||
|
optimize: (content: string, direction: string) => request(API_ENDPOINTS.ANALYZE.OPTIMIZE, 'POST', { content, direction }),
|
||||||
|
},
|
||||||
|
resume: {
|
||||||
|
create: (title: string, content: string, targetPosition?: string) =>
|
||||||
|
request(API_ENDPOINTS.RESUME.CREATE, 'POST', { title, content, targetPosition }, true),
|
||||||
|
list: () => request(API_ENDPOINTS.RESUME.LIST, 'GET', undefined, true),
|
||||||
|
delete: (id: string) => request(API_ENDPOINTS.RESUME.DELETE(id), 'DELETE', undefined, true),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default apiService
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 424 B |
Binary file not shown.
|
After Width: | Height: | Size: 403 B |
Binary file not shown.
|
After Width: | Height: | Size: 440 B |
Binary file not shown.
|
After Width: | Height: | Size: 419 B |
Binary file not shown.
|
After Width: | Height: | Size: 516 B |
Binary file not shown.
|
After Width: | Height: | Size: 493 B |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user