初始化:职引项目 v1.0

This commit is contained in:
yuzhiran
2026-06-08 16:28:00 +08:00
commit 511f60d0db
111 changed files with 27295 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
node_modules/
dist/
.env
*.local
.DS_Store
Thumbs.db
*.log
certs/
uploads/
test-*
*.pdf
nul
start-*.sh
+66
View File
@@ -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 错误)
- ✅ 后端成功启动在端口 3004PID: 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`)
- 现代化 UITab 切换、Before/After 对比、复制功能
- 功能:输入简历、填写岗位、一键优化、查看结果
- API 测试通过 ✅
-**路由配置**: 更新 `pages.json` 注册新页面
### 待完成
- [ ] 在首页添加简历诊断和优化的导航入口
- [ ] 启动前端测试完整流程(H5 前端 -> 后端 API
- [ ] 测试文件上传功能(PDF/Word 解析)
+47
View File
@@ -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**:全局设计 TokenCSS 变量),统一样式基类
- **首页**:重写 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 端点映射
+19
View File
@@ -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 文件
- **用户页改进**:未登录也可看到功能菜单,点击个人功能才弹登录引导
+19
View File
@@ -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 注入 InterviewServicecreate 时调用 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 | 渐变样式不一致 | 已在上一轮统一 |
+17
View File
@@ -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
+31
View File
@@ -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.
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}
+9898
View File
File diff suppressed because it is too large Load Diff
+77
View File
@@ -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"
}
}
+12
View File
@@ -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));
+59
View File
@@ -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 }
}
}
+28
View File
@@ -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, // 分
},
}
}
}
+14
View File
@@ -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 {}
+9
View File
@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common'
import { AiService } from './ai.service'
@Global()
@Module({
providers: [AiService],
exports: [AiService],
})
export class AiModule {}
+72
View File
@@ -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,
) {}
/** 创建订单(H5Native 扫码支付) */
@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)
}
}
+20
View File
@@ -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 {}
+42
View File
@@ -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)
+165
View File
@@ -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,
}
}
}
+25
View File
@@ -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/*"]
}
}
}
+426
View File
@@ -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.5P1
│ │ │ ├── member/ # 会员模块(Phase 1.5P1
│ │ │ ├── internship/ # 实习搜索模块(MVP 跳转,P1)
│ │ │ └── knowledge/ # 知识图谱模块(Phase 2P2
│ │ ├── 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 开发优先级
### P0Week 1-2,必须完成)
- [ ] 微信登录对接(/api/user/wx-login
- [ ] 岗位选择页面(校招热门 20+ 岗位,含 AI 岗位)
- [ ] AI 面试模拟核心逻辑(多轮对话)
- [ ] 每轮反馈评分接口(/api/interview/:id/feedback
- [ ] 面试报告生成(/api/interview/:id/complete
- [ ] 历史面试记录查看(/api/interview/list
### P1Week 3-4PMF 验证后)
- [ ] 简历诊断功能(复用已有代码)
- [ ] 会员系统开发(¥9.9/月)
- [ ] 微信支付对接
- [ ] 实习搜索聚合入口(跳转模式)
### P2Week 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 |
+259
View File
@@ -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
+148
View File
@@ -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 | 小之 |
+207
View File
@@ -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 端扩展 + 数据飞轮 | 小之 |
+142
View File
@@ -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
View File
@@ -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 1MVP 上线(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 端 + 数据飞轮 + 新里程碑 | 小之 |
+148
View File
@@ -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
> 💡 **提示**:每次提交审核前,建议打印此清单,逐项勾选,确保不遗漏。
+515
View File
@@ -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"
}
}
}
+5
View File
@@ -0,0 +1,5 @@
{
"dependencies": {
"jsdom": "^29.1.1"
}
}
+8
View File
@@ -0,0 +1,8 @@
# 前端环境变量
# 复制为 .env 或 .env.production 后修改
# API 后端地址(开发环境用 localhost,生产环境用真实域名)
VITE_API_BASE_URL=http://localhost:3006/api
# 应用名称(影响页面标题)
VITE_APP_NAME=AI磁场
+4
View File
@@ -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
+30
View File
@@ -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>
+9099
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -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"
}
}
+116
View File
@@ -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>
+90
View File
@@ -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
+11
View File
@@ -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,
};
}
+20
View File
@@ -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
}
}
+35
View File
@@ -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"
}
}
+70
View File
@@ -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>
+321
View File
@@ -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="把你记得的面试题写下来,帮大家提前准备:&#10;1. 请介绍一下你最熟悉的项目&#10;2. 解释一下闭包的原理&#10;..."
: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>
+159
View File
@@ -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>
+228
View File
@@ -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>
+156
View File
@@ -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>
+248
View File
@@ -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 {
// H5Native 二维码支付
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>
+242
View File
@@ -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>
+147
View File
@@ -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>
+548
View File
@@ -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>
+424
View File
@@ -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>
+178
View File
@@ -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>
+54
View File
@@ -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