feat: 登录页密码+验证码双模式 / 首页岗位优化 / 法律页面 / 后端接口完善
- 前端:登录页重构,支持密码登录、验证码登录、注册三种模式 - 前端:首页热门岗位添加「参考示例」标签,去虚构数据 - 前端:面试页顶部优化,岗位名+状态标签展示 - 前端:新增用户协议、隐私政策页面及免责声明 - 后端:新增 POST /api/user/register 注册接口 - 后端:新增 POST /api/user/set-password 设置密码接口 - 后端:修复 user.schema.ts unique 索引导致 null 冲突问题 - 后端:新增 payment-order.schema、positions.schema、site-config.schema - 后端:package.json 新增 postbuild 脚本自动复制证书 - 管理后台:新增订单管理 Tab
This commit is contained in:
@@ -0,0 +1,36 @@
|
|||||||
|
# 2026-06-09 首页岗位 & 面试页优化
|
||||||
|
|
||||||
|
## 首页热门岗位去虚构化
|
||||||
|
- 标题旁增加灰色"参考示例"标签(`.section-tag-demo`),避免用户误以为真实招聘信息
|
||||||
|
- 公司名和薪资分行展示,薪资用蓝色小标签突出
|
||||||
|
- 公司名无数据时 fallback 为 "参考公司",薪资为 "参考薪资"
|
||||||
|
- 去掉虚构的"xx人面过"数字,改为"立即模拟"文字按钮
|
||||||
|
|
||||||
|
## 面试页顶部优化
|
||||||
|
- 岗位名 + "面试中" 状态标签,替代原来的 `{position}面试` 拼接写法
|
||||||
|
- 岗位名字体加大加粗(26rpx, 600),"面试中"用半透明白底圆角标签
|
||||||
|
|
||||||
|
## 登录页 Bug 修复
|
||||||
|
- 去掉验证码登录中多余的"获取验证码"大按钮(原第57行)
|
||||||
|
- "获取验证码"按钮改为 inline 形式,放在邮箱输入框旁边
|
||||||
|
- 按钮文字动态显示:未发送时"获取验证码",已发送后"重新获取",冷却中显示倒计时
|
||||||
|
- 验证码输入框在 `emailSent=true` 后正常显示
|
||||||
|
- 登录按钮始终显示,邮箱未验证或验证码为空时 disabled
|
||||||
|
- 样式更新:`.inline-row` / `.inline-input` / `.code-btn`(蓝色实心按钮)
|
||||||
|
|
||||||
|
## 后端编译错误修复
|
||||||
|
- `wechat-pay.service.ts`:`createDecipheriv` 第2个参数改为 `Buffer.from(key)`
|
||||||
|
- `positions.controller.ts`:`HotPosition & { _id?: string }` 替代 `HotPosition`
|
||||||
|
- `user.schema.ts`:去掉 `phone`/`wxOpenid`/`email` 的 `unique: true`,避免 null 值 duplicate key
|
||||||
|
- `package.json`:`postbuild` 脚本用 `fs.cpSync` 自动复制证书到 `dist/certs`
|
||||||
|
- **后端新增**:
|
||||||
|
- `POST /api/user/register` — 邮箱+密码注册(若已存在但无密码则补设)
|
||||||
|
- `POST /api/user/set-password` — 已登录用户设置/修改密码
|
||||||
|
- `loginByEmail` 返回 `isNew` + `hasPassword` 标记
|
||||||
|
- **前端重构**:
|
||||||
|
- 主 Tab:登录 | 注册 | 微信登录(小程序)
|
||||||
|
- 登录子 Tab:密码登录(默认) | 验证码登录
|
||||||
|
- 密码登录:邮箱 + 密码
|
||||||
|
- 验证码登录:邮箱 + 验证码(新用户自动注册)
|
||||||
|
- 注册:邮箱 + 密码 + 确认密码
|
||||||
|
- 验证码登录后若 `!hasPassword`:弹出"设置登录密码"引导弹窗(可跳过)
|
||||||
Generated
+46
@@ -19,6 +19,7 @@
|
|||||||
"@nestjs/serve-static": "^4.0.2",
|
"@nestjs/serve-static": "^4.0.2",
|
||||||
"@nestjs/throttler": "^6.5.0",
|
"@nestjs/throttler": "^6.5.0",
|
||||||
"axios": "^1.16.1",
|
"axios": "^1.16.1",
|
||||||
|
"bcrypt": "^6.0.0",
|
||||||
"cache-manager": "^7.2.8",
|
"cache-manager": "^7.2.8",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.0",
|
"class-validator": "^0.14.0",
|
||||||
@@ -40,6 +41,7 @@
|
|||||||
"@nestjs/cli": "^10.3.0",
|
"@nestjs/cli": "^10.3.0",
|
||||||
"@nestjs/schematics": "^10.1.0",
|
"@nestjs/schematics": "^10.1.0",
|
||||||
"@nestjs/testing": "^10.4.22",
|
"@nestjs/testing": "^10.4.22",
|
||||||
|
"@types/bcrypt": "^6.0.0",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/node": "^20.10.0",
|
"@types/node": "^20.10.0",
|
||||||
"@types/nodemailer": "^8.0.0",
|
"@types/nodemailer": "^8.0.0",
|
||||||
@@ -2361,6 +2363,16 @@
|
|||||||
"@babel/types": "^7.28.2"
|
"@babel/types": "^7.28.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/bcrypt": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/cookiejar": {
|
"node_modules/@types/cookiejar": {
|
||||||
"version": "2.1.5",
|
"version": "2.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz",
|
||||||
@@ -3399,6 +3411,20 @@
|
|||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bcrypt": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"node-addon-api": "^8.3.0",
|
||||||
|
"node-gyp-build": "^4.8.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/binary-extensions": {
|
"node_modules/binary-extensions": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||||
@@ -7425,6 +7451,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/node-addon-api": {
|
||||||
|
"version": "8.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.8.0.tgz",
|
||||||
|
"integrity": "sha512-c5Ko1fZJIJmzhFIkhRN76WTq+fC6tWnGy9CXA0fA+XygsWZmEwG8vmbkNqxMyoaa0Tin4djul49NzdVcJJcjeA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^18 || ^20 || >= 21"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/node-emoji": {
|
"node_modules/node-emoji": {
|
||||||
"version": "1.11.0",
|
"version": "1.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-1.11.0.tgz",
|
"resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-1.11.0.tgz",
|
||||||
@@ -7477,6 +7512,17 @@
|
|||||||
"webidl-conversions": "^3.0.0"
|
"webidl-conversions": "^3.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-gyp-build": {
|
||||||
|
"version": "4.8.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
|
||||||
|
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"node-gyp-build": "bin.js",
|
||||||
|
"node-gyp-build-optional": "optional.js",
|
||||||
|
"node-gyp-build-test": "build-test.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/node-int64": {
|
"node_modules/node-int64": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
"start:dev": "nest start --watch",
|
"start:dev": "nest start --watch",
|
||||||
"start:prod": "node dist/main",
|
"start:prod": "node dist/main",
|
||||||
"build": "nest build",
|
"build": "nest build",
|
||||||
|
"postbuild": "node -e \"const fs=require('fs');if(fs.existsSync('certs')){fs.cpSync('certs','dist/certs',{recursive:true})}\"",
|
||||||
"test": "jest --forceExit --detectOpenHandles",
|
"test": "jest --forceExit --detectOpenHandles",
|
||||||
"test:watch": "jest --watch --forceExit",
|
"test:watch": "jest --watch --forceExit",
|
||||||
"test:cov": "jest --coverage --forceExit"
|
"test:cov": "jest --coverage --forceExit"
|
||||||
@@ -44,6 +45,7 @@
|
|||||||
"@nestjs/serve-static": "^4.0.2",
|
"@nestjs/serve-static": "^4.0.2",
|
||||||
"@nestjs/throttler": "^6.5.0",
|
"@nestjs/throttler": "^6.5.0",
|
||||||
"axios": "^1.16.1",
|
"axios": "^1.16.1",
|
||||||
|
"bcrypt": "^6.0.0",
|
||||||
"cache-manager": "^7.2.8",
|
"cache-manager": "^7.2.8",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.0",
|
"class-validator": "^0.14.0",
|
||||||
@@ -65,6 +67,7 @@
|
|||||||
"@nestjs/cli": "^10.3.0",
|
"@nestjs/cli": "^10.3.0",
|
||||||
"@nestjs/schematics": "^10.1.0",
|
"@nestjs/schematics": "^10.1.0",
|
||||||
"@nestjs/testing": "^10.4.22",
|
"@nestjs/testing": "^10.4.22",
|
||||||
|
"@types/bcrypt": "^6.0.0",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/node": "^20.10.0",
|
"@types/node": "^20.10.0",
|
||||||
"@types/nodemailer": "^8.0.0",
|
"@types/nodemailer": "^8.0.0",
|
||||||
|
|||||||
@@ -6,12 +6,20 @@ import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'
|
|||||||
import { CurrentUser } from '../../common/decorators/current-user.decorator'
|
import { CurrentUser } from '../../common/decorators/current-user.decorator'
|
||||||
import { User, UserDocument } from '../user/user.schema'
|
import { User, UserDocument } from '../user/user.schema'
|
||||||
import { Interview, InterviewDocument } from '../interview/interview.schema'
|
import { Interview, InterviewDocument } from '../interview/interview.schema'
|
||||||
|
import { PaymentOrder, PaymentOrderDocument } from '../payment/payment-order.schema'
|
||||||
|
import { SiteConfig, SiteConfigDocument } from '../schemas/site-config.schema'
|
||||||
|
import { WechatPayService } from '../payment/wechat-pay.service'
|
||||||
|
|
||||||
|
const VIP_DURATION_DAYS = 30
|
||||||
|
|
||||||
@Controller('admin')
|
@Controller('admin')
|
||||||
export class AdminController {
|
export class AdminController {
|
||||||
constructor(
|
constructor(
|
||||||
@InjectModel(User.name) private userModel: Model<UserDocument>,
|
@InjectModel(User.name) private userModel: Model<UserDocument>,
|
||||||
@InjectModel(Interview.name) private interviewModel: Model<InterviewDocument>,
|
@InjectModel(Interview.name) private interviewModel: Model<InterviewDocument>,
|
||||||
|
@InjectModel(PaymentOrder.name) private orderModel: Model<PaymentOrderDocument>,
|
||||||
|
@InjectModel(SiteConfig.name) private configModel: Model<SiteConfigDocument>,
|
||||||
|
private wechatPay: WechatPayService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get('check')
|
@Get('check')
|
||||||
@@ -108,26 +116,115 @@ export class AdminController {
|
|||||||
return { success: true, message: '已设为管理员' }
|
return { success: true, message: '已设为管理员' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── 订单管理 ──────────────
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Get('orders')
|
||||||
|
async getOrders(@Query('page') page = '1', @Query('limit') limit = '20', @Query('status') status: string, @CurrentUser('userId') adminUserId: string) {
|
||||||
|
const admin = await this.userModel.findById(adminUserId).exec()
|
||||||
|
if (admin?.role !== 'admin') throw new HttpException('无权限', HttpStatus.FORBIDDEN)
|
||||||
|
const filter: any = {}
|
||||||
|
if (status) filter.status = status
|
||||||
|
const skip = (Math.max(1, +page) - 1) * +limit
|
||||||
|
const [orders, total] = await Promise.all([
|
||||||
|
this.orderModel.find(filter).sort({ createdAt: -1 }).skip(skip).limit(+limit).lean().exec(),
|
||||||
|
this.orderModel.countDocuments(filter).exec(),
|
||||||
|
])
|
||||||
|
return { orders, total, page: +page }
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Post('order/sync')
|
||||||
|
async syncOrder(@Body('outTradeNo') outTradeNo: string, @CurrentUser('userId') adminUserId: string) {
|
||||||
|
const admin = await this.userModel.findById(adminUserId).exec()
|
||||||
|
if (admin?.role !== 'admin') throw new HttpException('无权限', HttpStatus.FORBIDDEN)
|
||||||
|
const wxResult = await this.wechatPay.queryOrder(outTradeNo)
|
||||||
|
const tradeState = wxResult?.trade_state
|
||||||
|
const order = await this.orderModel.findOne({ outTradeNo }).exec()
|
||||||
|
if (!order) throw new HttpException('订单不存在', HttpStatus.NOT_FOUND)
|
||||||
|
if (tradeState === 'SUCCESS' && order.status === 'pending') {
|
||||||
|
order.status = 'success'
|
||||||
|
order.wxTransactionId = wxResult.transaction_id
|
||||||
|
order.paidAt = new Date()
|
||||||
|
await order.save()
|
||||||
|
const user = await this.userModel.findById(order.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 { order, wxResult }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 系统配置(支持管理后台编辑) ──────
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Get('config')
|
@Get('config')
|
||||||
async getConfig(@CurrentUser('userId') adminUserId: string) {
|
async getConfig(@CurrentUser('userId') adminUserId: string) {
|
||||||
const admin = await this.userModel.findById(adminUserId).exec()
|
const admin = await this.userModel.findById(adminUserId).exec()
|
||||||
if (admin?.role !== 'admin') throw new HttpException('无权限', HttpStatus.FORBIDDEN)
|
if (admin?.role !== 'admin') throw new HttpException('无权限', HttpStatus.FORBIDDEN)
|
||||||
return {
|
// 从数据库读,不存在则返回默认值
|
||||||
interview: {
|
const cfg = await this.configModel.findOne({ key: 'site_config' }).exec()
|
||||||
maxRoundsFree: 5,
|
if (cfg) return cfg.value
|
||||||
maxRoundsVip: 10,
|
return DEFAULT_CONFIG
|
||||||
dailyFreeLimit: 3,
|
|
||||||
},
|
|
||||||
diagnosis: {
|
|
||||||
dailyFreeLimit: 2,
|
|
||||||
},
|
|
||||||
optimize: {
|
|
||||||
dailyFreeLimit: 2,
|
|
||||||
},
|
|
||||||
price: {
|
|
||||||
monthly: 2900, // 分
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Post('config/save')
|
||||||
|
async saveConfig(@Body() body: any, @CurrentUser('userId') adminUserId: string) {
|
||||||
|
const admin = await this.userModel.findById(adminUserId).exec()
|
||||||
|
if (admin?.role !== 'admin') throw new HttpException('无权限', HttpStatus.FORBIDDEN)
|
||||||
|
await this.configModel.findOneAndUpdate(
|
||||||
|
{ key: 'site_config' },
|
||||||
|
{ key: 'site_config', value: body, description: '站点配置' },
|
||||||
|
{ upsert: true },
|
||||||
|
).exec()
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 每日一题管理 ──────────────
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Get('questions')
|
||||||
|
async getQuestions(@CurrentUser('userId') adminUserId: string) {
|
||||||
|
const admin = await this.userModel.findById(adminUserId).exec()
|
||||||
|
if (admin?.role !== 'admin') throw new HttpException('无权限', HttpStatus.FORBIDDEN)
|
||||||
|
const cfg = await this.configModel.findOne({ key: 'daily_questions' }).exec()
|
||||||
|
return cfg?.value || DEFAULT_QUESTIONS
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Post('questions/save')
|
||||||
|
async saveQuestions(@Body() body: any, @CurrentUser('userId') adminUserId: string) {
|
||||||
|
const admin = await this.userModel.findById(adminUserId).exec()
|
||||||
|
if (admin?.role !== 'admin') throw new HttpException('无权限', HttpStatus.FORBIDDEN)
|
||||||
|
await this.configModel.findOneAndUpdate(
|
||||||
|
{ key: 'daily_questions' },
|
||||||
|
{ key: 'daily_questions', value: body, description: '每日一题题库' },
|
||||||
|
{ upsert: true },
|
||||||
|
).exec()
|
||||||
|
return { success: true }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG = {
|
||||||
|
interview: { maxRoundsFree: 5, maxRoundsVip: 10, dailyFreeLimit: 3 },
|
||||||
|
diagnosis: { dailyFreeLimit: 2 },
|
||||||
|
optimize: { dailyFreeLimit: 2 },
|
||||||
|
price: { monthly: 1990 },
|
||||||
|
plans: {
|
||||||
|
free: { name: '免费版', price: 0, features: ['每日 3 次 AI 模拟面试', '每场最多 5 轮 AI 对话', '基础面试报告', '简历诊断', '简历优化'] },
|
||||||
|
growth: { name: '成长版', price: 1990, features: ['免费版全部权益', '无限面试次数', '每场最多 10 轮 AI 对话', '详细面试报告(四维评分)', '进步轨迹雷达图 + 打卡', '参考回答思路', '公司真题库'] },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_QUESTIONS = [
|
||||||
|
{ position: '通用', category: 'behavioral', question: '请做一个简单的自我介绍,突出你的核心优势和职业目标。', referenceAnswer: '建议结构:1) 基本信息 2) 教育背景与专业方向 3) 实习/项目经历 4) 核心优势 5) 职业目标' },
|
||||||
|
{ position: '前端工程师', category: 'technical', question: '请用 JavaScript 实现一个深拷贝函数,并说明可能存在的问题。', referenceAnswer: '可使用递归遍历,注意循环引用用 WeakMap 处理,特殊类型如 Date/RegExp/Map/Set 需单独处理。' },
|
||||||
|
{ position: '后端工程师', category: 'technical', question: '请说说你对 RESTful API 设计的理解,以及和 GraphQL 的区别。', referenceAnswer: 'RESTful 以资源为核心,使用 HTTP 方法操作;GraphQL 客户端可指定返回字段,减少过度获取。' },
|
||||||
|
{ position: 'AI 算法工程师', category: 'technical', question: '解释一下 Transformer 架构中的 Self-Attention 机制是如何工作的。', referenceAnswer: 'Self-Attention 通过 QKV 计算注意力权重,公式为 Attention(Q,K,V)=softmax(QK^T/√d)V。' },
|
||||||
|
]
|
||||||
|
|||||||
@@ -3,12 +3,21 @@ import { MongooseModule } from '@nestjs/mongoose'
|
|||||||
import { AdminController } from './admin.controller'
|
import { AdminController } from './admin.controller'
|
||||||
import { User, UserSchema } from '../user/user.schema'
|
import { User, UserSchema } from '../user/user.schema'
|
||||||
import { Interview, InterviewSchema } from '../interview/interview.schema'
|
import { Interview, InterviewSchema } from '../interview/interview.schema'
|
||||||
|
import { PaymentOrder, PaymentOrderSchema } from '../payment/payment-order.schema'
|
||||||
|
import { WechatPayService } from '../payment/wechat-pay.service'
|
||||||
|
|
||||||
|
import { SiteConfig, SiteConfigSchema } from '../schemas/site-config.schema'
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
MongooseModule.forFeature([{ name: User.name, schema: UserSchema }]),
|
MongooseModule.forFeature([
|
||||||
MongooseModule.forFeature([{ name: Interview.name, schema: InterviewSchema }]),
|
{ name: User.name, schema: UserSchema },
|
||||||
|
{ name: Interview.name, schema: InterviewSchema },
|
||||||
|
{ name: PaymentOrder.name, schema: PaymentOrderSchema },
|
||||||
|
{ name: SiteConfig.name, schema: SiteConfigSchema },
|
||||||
|
]),
|
||||||
],
|
],
|
||||||
controllers: [AdminController],
|
controllers: [AdminController],
|
||||||
|
providers: [WechatPayService],
|
||||||
})
|
})
|
||||||
export class AdminModule {}
|
export class AdminModule {}
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
|
||||||
|
import { Document } from 'mongoose'
|
||||||
|
|
||||||
|
export type PaymentOrderDocument = PaymentOrder & Document
|
||||||
|
|
||||||
|
@Schema({ timestamps: true })
|
||||||
|
export class PaymentOrder {
|
||||||
|
@Prop({ required: true, index: true })
|
||||||
|
outTradeNo: string
|
||||||
|
|
||||||
|
@Prop({ required: true, index: true })
|
||||||
|
userId: string
|
||||||
|
|
||||||
|
@Prop({ default: '' })
|
||||||
|
userPhone?: string
|
||||||
|
|
||||||
|
@Prop({ required: true })
|
||||||
|
amount: number // 分
|
||||||
|
|
||||||
|
@Prop({ required: true })
|
||||||
|
title: string
|
||||||
|
|
||||||
|
@Prop({ default: '' })
|
||||||
|
description?: string
|
||||||
|
|
||||||
|
// pending | success | failed | refunded | partial_refund
|
||||||
|
@Prop({ default: 'pending' })
|
||||||
|
status: string
|
||||||
|
|
||||||
|
@Prop({ default: 'native' })
|
||||||
|
channel: string // native | jsapi
|
||||||
|
|
||||||
|
@Prop()
|
||||||
|
paidAt?: Date
|
||||||
|
|
||||||
|
@Prop()
|
||||||
|
wxTransactionId?: string
|
||||||
|
|
||||||
|
// 退款
|
||||||
|
@Prop({ default: 0 })
|
||||||
|
refundAmount?: number
|
||||||
|
|
||||||
|
@Prop()
|
||||||
|
refundedAt?: Date
|
||||||
|
|
||||||
|
@Prop()
|
||||||
|
refundReason?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PaymentOrderSchema = SchemaFactory.createForClass(PaymentOrder)
|
||||||
|
PaymentOrderSchema.index({ createdAt: -1 })
|
||||||
|
PaymentOrderSchema.index({ status: 1, createdAt: -1 })
|
||||||
@@ -1,19 +1,21 @@
|
|||||||
import { Controller, Post, Body, UseGuards, HttpException, HttpStatus } from '@nestjs/common'
|
import { Controller, Post, Get, Query, Body, UseGuards, HttpException, HttpStatus } from '@nestjs/common'
|
||||||
import { InjectModel } from '@nestjs/mongoose'
|
import { InjectModel } from '@nestjs/mongoose'
|
||||||
import { Model } from 'mongoose'
|
import { Model } from 'mongoose'
|
||||||
import { User, UserDocument } from '../user/user.schema'
|
import { User, UserDocument } from '../user/user.schema'
|
||||||
|
import { PaymentOrder, PaymentOrderDocument } from './payment-order.schema'
|
||||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'
|
||||||
import { CurrentUser } from '../../common/decorators/current-user.decorator'
|
import { CurrentUser } from '../../common/decorators/current-user.decorator'
|
||||||
import { WechatPayService } from './wechat-pay.service'
|
import { WechatPayService } from './wechat-pay.service'
|
||||||
import { Public } from '../../common/decorators/public.decorator'
|
import { Public } from '../../common/decorators/public.decorator'
|
||||||
|
|
||||||
const VIP_AMOUNT = 2900 // 29 元(分)
|
const VIP_AMOUNT = 1990 // 19.9 元(分)
|
||||||
const VIP_DURATION_DAYS = 30
|
const VIP_DURATION_DAYS = 30
|
||||||
|
|
||||||
@Controller('payment')
|
@Controller('payment')
|
||||||
export class PaymentController {
|
export class PaymentController {
|
||||||
constructor(
|
constructor(
|
||||||
@InjectModel(User.name) private userModel: Model<UserDocument>,
|
@InjectModel(User.name) private userModel: Model<UserDocument>,
|
||||||
|
@InjectModel(PaymentOrder.name) private orderModel: Model<PaymentOrderDocument>,
|
||||||
private wechatPay: WechatPayService,
|
private wechatPay: WechatPayService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -26,20 +28,28 @@ export class PaymentController {
|
|||||||
if (user.plan === 'vip') throw new HttpException('已是会员', HttpStatus.BAD_REQUEST)
|
if (user.plan === 'vip') throw new HttpException('已是会员', HttpStatus.BAD_REQUEST)
|
||||||
|
|
||||||
const outTradeNo = `VIP${Date.now()}${userId.slice(-6)}`
|
const outTradeNo = `VIP${Date.now()}${userId.slice(-6)}`
|
||||||
const result = await this.wechatPay.nativePay(
|
const result = await this.wechatPay.nativePay('AI磁场月度会员', outTradeNo, VIP_AMOUNT)
|
||||||
'职引月度会员',
|
|
||||||
|
// 保存订单
|
||||||
|
await this.orderModel.create({
|
||||||
outTradeNo,
|
outTradeNo,
|
||||||
VIP_AMOUNT,
|
userId,
|
||||||
)
|
userPhone: user.phone || '',
|
||||||
return {
|
|
||||||
outTradeNo: result.outTradeNo,
|
|
||||||
codeUrl: result.codeUrl, // 二维码链接
|
|
||||||
amount: VIP_AMOUNT,
|
amount: VIP_AMOUNT,
|
||||||
title: '职引月度会员',
|
title: 'AI磁场月度会员',
|
||||||
|
status: 'pending',
|
||||||
|
channel: 'native',
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
outTradeNo,
|
||||||
|
codeUrl: result.codeUrl,
|
||||||
|
amount: VIP_AMOUNT,
|
||||||
|
title: 'AI磁场月度会员',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** JSAPI 支付(微信小程序/公众号内使用) */
|
/** JSAPI 支付(微信小程序) */
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Post('jsapi')
|
@Post('jsapi')
|
||||||
async jsapi(@CurrentUser('userId') userId: string, @Body('openid') openid: string) {
|
async jsapi(@CurrentUser('userId') userId: string, @Body('openid') openid: string) {
|
||||||
@@ -49,21 +59,45 @@ export class PaymentController {
|
|||||||
if (user.plan === 'vip') throw new HttpException('已是会员', HttpStatus.BAD_REQUEST)
|
if (user.plan === 'vip') throw new HttpException('已是会员', HttpStatus.BAD_REQUEST)
|
||||||
|
|
||||||
const outTradeNo = `VIP${Date.now()}${userId.slice(-6)}`
|
const outTradeNo = `VIP${Date.now()}${userId.slice(-6)}`
|
||||||
const result = await this.wechatPay.jsapiPay('职引月度会员', outTradeNo, VIP_AMOUNT, openid)
|
const result = await this.wechatPay.jsapiPay('AI磁场月度会员', outTradeNo, VIP_AMOUNT, openid)
|
||||||
|
|
||||||
|
// 保存订单
|
||||||
|
await this.orderModel.create({
|
||||||
|
outTradeNo,
|
||||||
|
userId,
|
||||||
|
userPhone: user.phone || '',
|
||||||
|
amount: VIP_AMOUNT,
|
||||||
|
title: 'AI磁场月度会员',
|
||||||
|
status: 'pending',
|
||||||
|
channel: 'jsapi',
|
||||||
|
})
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 支付回调通知 */
|
/** 支付回调通知 */
|
||||||
@Public()
|
@Public()
|
||||||
@Post('notify')
|
@Post('notify')
|
||||||
async notify(@Body() body: any, @Body('headers') headers: any) {
|
async notify(@Body() body: any) {
|
||||||
// 实际运行时从 request 读取 header
|
|
||||||
try {
|
try {
|
||||||
const decrypted = this.wechatPay.verifyAndDecrypt(body, '', '', '')
|
const decrypted = this.wechatPay.verifyAndDecrypt(body, '', '', '')
|
||||||
if (!decrypted) return { code: 'FAIL', message: '验签失败' }
|
if (!decrypted) return { code: 'FAIL', message: '验签失败' }
|
||||||
// 处理成功支付
|
|
||||||
const outTradeNo = decrypted.out_trade_no
|
const outTradeNo = decrypted.out_trade_no
|
||||||
const userId = outTradeNo.slice(-6) // 从订单号取 userId
|
const wxTransactionId = decrypted.transaction_id
|
||||||
|
|
||||||
|
// 更新订单状态
|
||||||
|
const order = await this.orderModel.findOne({ outTradeNo }).exec()
|
||||||
|
if (order && order.status === 'pending') {
|
||||||
|
order.status = 'success'
|
||||||
|
order.paidAt = new Date()
|
||||||
|
order.wxTransactionId = wxTransactionId
|
||||||
|
order.description = `${decrypted.trade_type || ''} 支付成功`
|
||||||
|
await order.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 开通会员
|
||||||
|
const userId = outTradeNo.slice(-6)
|
||||||
const user = await this.userModel.findOne({ _id: { $regex: userId + '$' } }).exec()
|
const user = await this.userModel.findOne({ _id: { $regex: userId + '$' } }).exec()
|
||||||
if (user && user.plan !== 'vip') {
|
if (user && user.plan !== 'vip') {
|
||||||
const expireAt = new Date()
|
const expireAt = new Date()
|
||||||
@@ -79,7 +113,7 @@ export class PaymentController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 查询订单 */
|
/** 查询订单(微信侧) */
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Post('query')
|
@Post('query')
|
||||||
async query(@Body('outTradeNo') outTradeNo: string) {
|
async query(@Body('outTradeNo') outTradeNo: string) {
|
||||||
|
|||||||
@@ -4,8 +4,13 @@ import { PaymentController } from './payment.controller'
|
|||||||
import { WechatPayService } from './wechat-pay.service'
|
import { WechatPayService } from './wechat-pay.service'
|
||||||
import { User, UserSchema } from '../user/user.schema'
|
import { User, UserSchema } from '../user/user.schema'
|
||||||
|
|
||||||
|
import { PaymentOrder, PaymentOrderSchema } from './payment-order.schema'
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [MongooseModule.forFeature([{ name: User.name, schema: UserSchema }])],
|
imports: [MongooseModule.forFeature([
|
||||||
|
{ name: User.name, schema: UserSchema },
|
||||||
|
{ name: PaymentOrder.name, schema: PaymentOrderSchema },
|
||||||
|
])],
|
||||||
controllers: [PaymentController],
|
controllers: [PaymentController],
|
||||||
providers: [WechatPayService],
|
providers: [WechatPayService],
|
||||||
exports: [WechatPayService],
|
exports: [WechatPayService],
|
||||||
|
|||||||
@@ -135,10 +135,11 @@ export class WechatPayService {
|
|||||||
const associatedData = resource.associated_data || ''
|
const associatedData = resource.associated_data || ''
|
||||||
const nonce = resource.nonce
|
const nonce = resource.nonce
|
||||||
const key = API_V3_KEY
|
const key = API_V3_KEY
|
||||||
|
if (!key) throw new Error('WX_API_V3_KEY 未配置')
|
||||||
// AES-256-GCM 解密
|
// AES-256-GCM 解密
|
||||||
const authTag = ciphertext.subarray(ciphertext.length - 16)
|
const authTag = ciphertext.subarray(ciphertext.length - 16)
|
||||||
const data = ciphertext.subarray(0, ciphertext.length - 16)
|
const data = ciphertext.subarray(0, ciphertext.length - 16)
|
||||||
const decipher = crypto.createDecipheriv('aes-256-gcm', key, nonce)
|
const decipher = crypto.createDecipheriv('aes-256-gcm', Buffer.from(key), nonce)
|
||||||
decipher.setAAD(Buffer.from(associatedData))
|
decipher.setAAD(Buffer.from(associatedData))
|
||||||
decipher.setAuthTag(authTag)
|
decipher.setAuthTag(authTag)
|
||||||
const decrypted = decipher.update(data) + decipher.final('utf8')
|
const decrypted = decipher.update(data) + decipher.final('utf8')
|
||||||
|
|||||||
@@ -1,17 +1,54 @@
|
|||||||
import { Controller, Get } from '@nestjs/common'
|
import { Controller, Get, Body, Post, Delete, Param, UseGuards, HttpException, HttpStatus } from '@nestjs/common'
|
||||||
|
import { InjectModel } from '@nestjs/mongoose'
|
||||||
|
import { Model } from 'mongoose'
|
||||||
|
import { HotPosition, HotPositionDocument } from './positions.schema'
|
||||||
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'
|
||||||
import { Public } from '../../common/decorators/public.decorator'
|
import { Public } from '../../common/decorators/public.decorator'
|
||||||
|
import { CurrentUser } from '../../common/decorators/current-user.decorator'
|
||||||
|
import { User, UserDocument } from '../user/user.schema'
|
||||||
|
|
||||||
@Controller('positions')
|
@Controller('positions')
|
||||||
export class PositionsController {
|
export class PositionsController {
|
||||||
|
constructor(
|
||||||
|
@InjectModel(HotPosition.name) private positionModel: Model<HotPositionDocument>,
|
||||||
|
@InjectModel(User.name) private userModel: Model<UserDocument>,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@Get('hot')
|
@Get('hot')
|
||||||
hot() {
|
async hot() {
|
||||||
return [
|
return this.positionModel.find({ active: true }).sort({ sort: 1 }).lean().exec()
|
||||||
{ name: '前端工程师', salary: '15-25K', company: '腾讯' },
|
}
|
||||||
{ name: '后端工程师', salary: '18-30K', company: '阿里巴巴' },
|
|
||||||
{ name: 'AI 算法工程师', salary: '20-35K', company: '字节跳动' },
|
// ─── 管理后台 CRUD ──────────────────────
|
||||||
{ name: '产品经理', salary: '12-20K', company: '美团' },
|
|
||||||
{ name: 'UI 设计师', salary: '10-18K', company: '网易' },
|
@UseGuards(JwtAuthGuard)
|
||||||
]
|
@Post('admin/list')
|
||||||
|
async adminList(@CurrentUser('userId') adminUserId: string) {
|
||||||
|
const admin = await this.userModel.findById(adminUserId).exec()
|
||||||
|
if (admin?.role !== 'admin') throw new HttpException('无权限', HttpStatus.FORBIDDEN)
|
||||||
|
return this.positionModel.find().sort({ sort: 1 }).lean().exec()
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Post('admin/save')
|
||||||
|
async save(@Body() body: HotPosition & { _id?: string }, @CurrentUser('userId') adminUserId: string) {
|
||||||
|
const admin = await this.userModel.findById(adminUserId).exec()
|
||||||
|
if (admin?.role !== 'admin') throw new HttpException('无权限', HttpStatus.FORBIDDEN)
|
||||||
|
if (body._id) {
|
||||||
|
await this.positionModel.findByIdAndUpdate(body._id, body).exec()
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
const created = await this.positionModel.create(body)
|
||||||
|
return { success: true, id: created._id }
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Delete('admin/:id')
|
||||||
|
async remove(@Param('id') id: string, @CurrentUser('userId') adminUserId: string) {
|
||||||
|
const admin = await this.userModel.findById(adminUserId).exec()
|
||||||
|
if (admin?.role !== 'admin') throw new HttpException('无权限', HttpStatus.FORBIDDEN)
|
||||||
|
await this.positionModel.findByIdAndDelete(id).exec()
|
||||||
|
return { success: true }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
import { Module } from '@nestjs/common'
|
import { Module } from '@nestjs/common'
|
||||||
|
import { MongooseModule } from '@nestjs/mongoose'
|
||||||
import { PositionsController } from './positions.controller'
|
import { PositionsController } from './positions.controller'
|
||||||
|
import { HotPosition, HotPositionSchema } from './positions.schema'
|
||||||
|
import { User, UserSchema } from '../user/user.schema'
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [
|
||||||
|
MongooseModule.forFeature([
|
||||||
|
{ name: HotPosition.name, schema: HotPositionSchema },
|
||||||
|
{ name: User.name, schema: UserSchema },
|
||||||
|
]),
|
||||||
|
],
|
||||||
controllers: [PositionsController],
|
controllers: [PositionsController],
|
||||||
})
|
})
|
||||||
export class PositionsModule {}
|
export class PositionsModule {}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
|
||||||
|
import { Document } from 'mongoose'
|
||||||
|
|
||||||
|
export type HotPositionDocument = HotPosition & Document
|
||||||
|
|
||||||
|
@Schema({ timestamps: true })
|
||||||
|
export class HotPosition {
|
||||||
|
@Prop({ required: true })
|
||||||
|
name: string
|
||||||
|
|
||||||
|
@Prop({ default: '' })
|
||||||
|
salary?: string
|
||||||
|
|
||||||
|
@Prop({ default: '' })
|
||||||
|
company?: string
|
||||||
|
|
||||||
|
@Prop({ default: '' })
|
||||||
|
icon?: string
|
||||||
|
|
||||||
|
@Prop({ default: 0 })
|
||||||
|
sort: number
|
||||||
|
|
||||||
|
@Prop({ default: true })
|
||||||
|
active: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HotPositionSchema = SchemaFactory.createForClass(HotPosition)
|
||||||
|
HotPositionSchema.index({ sort: 1 })
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
|
||||||
|
import { Document } from 'mongoose'
|
||||||
|
|
||||||
|
export type SiteConfigDocument = SiteConfig & Document
|
||||||
|
|
||||||
|
@Schema({ timestamps: true })
|
||||||
|
export class SiteConfig {
|
||||||
|
@Prop({ required: true, unique: true })
|
||||||
|
key: string
|
||||||
|
|
||||||
|
@Prop({ required: true, type: Object })
|
||||||
|
value: any
|
||||||
|
|
||||||
|
@Prop({ default: '' })
|
||||||
|
description?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SiteConfigSchema = SchemaFactory.createForClass(SiteConfig)
|
||||||
@@ -32,6 +32,20 @@ export class UserController {
|
|||||||
return this.userService.loginByEmail(email, code)
|
return this.userService.loginByEmail(email, code)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 密码登录
|
||||||
|
@Public()
|
||||||
|
@Post('password-login')
|
||||||
|
async passwordLogin(@Body('email') email: string, @Body('password') password: string) {
|
||||||
|
return this.userService.loginByPassword(email, password)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 邮箱+密码注册
|
||||||
|
@Public()
|
||||||
|
@Post('register')
|
||||||
|
async register(@Body('email') email: string, @Body('password') password: string) {
|
||||||
|
return this.userService.registerWithPassword(email, password)
|
||||||
|
}
|
||||||
|
|
||||||
// 微信静默登录
|
// 微信静默登录
|
||||||
@Public()
|
@Public()
|
||||||
@Post('wx-login')
|
@Post('wx-login')
|
||||||
@@ -53,4 +67,9 @@ export class UserController {
|
|||||||
async getUsage(@CurrentUser('userId') userId: string) {
|
async getUsage(@CurrentUser('userId') userId: string) {
|
||||||
return this.userService.getUsage(userId)
|
return this.userService.getUsage(userId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('set-password')
|
||||||
|
async setPassword(@CurrentUser('userId') userId: string, @Body('password') password: string) {
|
||||||
|
return this.userService.setPassword(userId, password)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ export type UserDocument = User & Document
|
|||||||
|
|
||||||
@Schema({ timestamps: true })
|
@Schema({ timestamps: true })
|
||||||
export class User {
|
export class User {
|
||||||
@Prop({ unique: true, sparse: true })
|
@Prop({ sparse: true })
|
||||||
phone?: string
|
phone?: string
|
||||||
|
|
||||||
@Prop({ unique: true, sparse: true })
|
@Prop({ sparse: true })
|
||||||
wxOpenid?: string
|
wxOpenid?: string
|
||||||
|
|
||||||
@Prop({ default: '' })
|
@Prop({ default: '' })
|
||||||
@@ -35,8 +35,11 @@ export class User {
|
|||||||
@Prop({ default: false })
|
@Prop({ default: false })
|
||||||
isSystemAdmin: boolean
|
isSystemAdmin: boolean
|
||||||
|
|
||||||
@Prop({ unique: true, sparse: true })
|
@Prop({ sparse: true })
|
||||||
email?: string
|
email?: string
|
||||||
|
|
||||||
|
@Prop({ default: '' })
|
||||||
|
password?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const UserSchema = SchemaFactory.createForClass(User)
|
export const UserSchema = SchemaFactory.createForClass(User)
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Injectable, HttpException, HttpStatus } from '@nestjs/common'
|
import * as bcrypt from 'bcrypt'
|
||||||
|
import { Injectable, HttpException, HttpStatus } from '@nestjs/common'
|
||||||
import { InjectModel } from '@nestjs/mongoose'
|
import { InjectModel } from '@nestjs/mongoose'
|
||||||
import { Model } from 'mongoose'
|
import { Model } from 'mongoose'
|
||||||
import { JwtService } from '@nestjs/jwt'
|
import { JwtService } from '@nestjs/jwt'
|
||||||
@@ -104,13 +105,59 @@ export class UserService {
|
|||||||
|
|
||||||
// 按邮箱查找或创建用户
|
// 按邮箱查找或创建用户
|
||||||
let user = await this.userModel.findOne({ email }).exec()
|
let user = await this.userModel.findOne({ email }).exec()
|
||||||
|
let isNew = false
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
isNew = true
|
||||||
const nick = email.split('@')[0]
|
const nick = email.split('@')[0]
|
||||||
user = await this.userModel.create({ email, nickname: nick, remaining: 3 })
|
user = await this.userModel.create({ email, nickname: nick, remaining: 3 })
|
||||||
}
|
}
|
||||||
|
return { ...this.generateAuthResponse(user), isNew, hasPassword: !!user.password }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔐 密码登录
|
||||||
|
async loginByPassword(email: string, password: string) {
|
||||||
|
const user = await this.userModel.findOne({ email }).exec()
|
||||||
|
if (!user) throw new HttpException('账号不存在', HttpStatus.NOT_FOUND)
|
||||||
|
if (!user.password) throw new HttpException('该账号未设置密码,请使用验证码登录', HttpStatus.UNAUTHORIZED)
|
||||||
|
const match = await bcrypt.compare(password, user.password)
|
||||||
|
if (!match) throw new HttpException('密码错误', HttpStatus.UNAUTHORIZED)
|
||||||
return this.generateAuthResponse(user)
|
return this.generateAuthResponse(user)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 📝 邮箱+密码注册
|
||||||
|
async registerWithPassword(email: string, password: string) {
|
||||||
|
if (!email || !email.includes('@')) {
|
||||||
|
throw new HttpException('请输入正确的邮箱地址', HttpStatus.BAD_REQUEST)
|
||||||
|
}
|
||||||
|
if (!password || password.length < 6) {
|
||||||
|
throw new HttpException('密码至少6位', HttpStatus.BAD_REQUEST)
|
||||||
|
}
|
||||||
|
const existing = await this.userModel.findOne({ email }).exec()
|
||||||
|
if (existing) {
|
||||||
|
if (existing.password) {
|
||||||
|
throw new HttpException('该邮箱已注册,请直接登录', HttpStatus.CONFLICT)
|
||||||
|
}
|
||||||
|
// 已有验证码注册的用户,补充设置密码
|
||||||
|
existing.password = await bcrypt.hash(password, 10)
|
||||||
|
await existing.save()
|
||||||
|
return this.generateAuthResponse(existing)
|
||||||
|
}
|
||||||
|
const nick = email.split('@')[0]
|
||||||
|
const hashed = await bcrypt.hash(password, 10)
|
||||||
|
const user = await this.userModel.create({ email, nickname: nick, password: hashed, remaining: 3 })
|
||||||
|
return this.generateAuthResponse(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔑 已登录用户设置/修改密码
|
||||||
|
async setPassword(userId: string, password: string) {
|
||||||
|
if (!password || password.length < 6) {
|
||||||
|
throw new HttpException('密码至少6位', HttpStatus.BAD_REQUEST)
|
||||||
|
}
|
||||||
|
const hashed = await bcrypt.hash(password, 10)
|
||||||
|
await this.userModel.findByIdAndUpdate(userId, { password: hashed })
|
||||||
|
return { message: '密码设置成功' }
|
||||||
|
}
|
||||||
|
|
||||||
async getInfo(userId: string) {
|
async getInfo(userId: string) {
|
||||||
const user = await this.userModel.findById(userId).exec()
|
const user = await this.userModel.findById(userId).exec()
|
||||||
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
|
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
|
||||||
|
|||||||
@@ -4,11 +4,11 @@
|
|||||||
<meta charset="UTF-8" />
|
<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" />
|
<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" />
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
|
||||||
<title>AI磁场 - 智能面试模拟 · 简历优化 · 求职辅导</title>
|
<title>职引 - AI模拟面试 | 宇之然AI磁场 · 智能面试练习 · 简历优化 · 面经题库</title>
|
||||||
<meta name="description" content="AI磁场提供AI模拟面试、简历优化、实习推荐等求职服务,帮助你在真实面试前充分准备,提升求职成功率。" />
|
<meta name="description" content="职引是宇之然AI磁场旗下的AI模拟面试平台,提供AI面试官模拟练习、简历智能诊断优化、大厂面经题库、实习推荐等一站式求职服务,帮助你在真实面试前充分准备。" />
|
||||||
<meta name="keywords" content="AI面试,模拟面试,简历优化,求职辅导,面试练习,AI磁场" />
|
<meta name="keywords" content="职引,宇之然AI磁场,AI面试,模拟面试,面试练习,简历优化,求职辅导,AI模拟面试官,面试题库,面经,校招面试,实习面试,简历诊断,面试技巧" />
|
||||||
<meta property="og:title" content="AI磁场 - 智能面试模拟平台" />
|
<meta property="og:title" content="职引 - AI模拟面试平台 | 宇之然AI磁场" />
|
||||||
<meta property="og:description" content="AI驱动的一站式求职准备平台,涵盖模拟面试、简历优化、岗位推荐等核心功能。" />
|
<meta property="og:description" content="AI驱动的一站式求职准备平台,涵盖AI模拟面试、简历优化、面经分享、实习推荐等核心功能。" />
|
||||||
<meta property="og:type" content="website" />
|
<meta property="og:type" content="website" />
|
||||||
<meta name="applicable-device" content="mobile" />
|
<meta name="applicable-device" content="mobile" />
|
||||||
<link rel="canonical" href="https://aicc.yzrcloud.cn" />
|
<link rel="canonical" href="https://aicc.yzrcloud.cn" />
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
{
|
{
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "WebApplication",
|
"@type": "WebApplication",
|
||||||
"name": "AI磁场",
|
"name": "宇之然AI磁场",
|
||||||
"description": "AI驱动的求职面试模拟与简历优化平台",
|
"description": "AI驱动的求职面试模拟与简历优化平台",
|
||||||
"applicationCategory": "EducationalApplication",
|
"applicationCategory": "EducationalApplication",
|
||||||
"operatingSystem": "Web, WeChat Mini Program"
|
"operatingSystem": "Web, WeChat Mini Program"
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
"appid": "__UNI__DEV__",
|
"appid": "__UNI__DEV__",
|
||||||
"versionName": "1.0.0",
|
"versionName": "1.0.0",
|
||||||
"versionCode": "100",
|
"versionCode": "100",
|
||||||
"description": "AI 面试模拟 - 先模拟,再面试",
|
"description": "职引 - 宇之然AI磁场旗下AI模拟面试平台,提供AI面试官模拟练习、简历智能优化、大厂面经题库,助你轻松应对校招面试。",
|
||||||
"h5": {
|
"h5": {
|
||||||
"title": "AI磁场",
|
"title": "职引 - AI模拟面试 | 宇之然AI磁场",
|
||||||
"router": {
|
"router": {
|
||||||
"mode": "hash"
|
"mode": "hash"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,21 @@
|
|||||||
{
|
{
|
||||||
"pages": [
|
"pages": [
|
||||||
{ "path": "pages/index/index", "style": { "navigationBarTitleText": "职引 - 先模拟,再上场" } },
|
{ "path": "pages/index/index", "style": { "navigationBarTitleText": "职引 - AI模拟面试" } },
|
||||||
{ "path": "pages/interview/interview", "style": { "navigationBarTitleText": "模拟面试" } },
|
{ "path": "pages/interview/interview", "style": { "navigationBarTitleText": "AI模拟面试" } },
|
||||||
{ "path": "pages/report/report", "style": { "navigationBarTitleText": "面试报告" } },
|
{ "path": "pages/report/report", "style": { "navigationBarTitleText": "面试报告" } },
|
||||||
{ "path": "pages/member/member", "style": { "navigationBarTitleText": "会员中心" } },
|
{ "path": "pages/member/member", "style": { "navigationBarTitleText": "会员中心" } },
|
||||||
{ "path": "pages/progress/progress", "style": { "navigationBarTitleText": "进步轨迹" } },
|
{ "path": "pages/progress/progress", "style": { "navigationBarTitleText": "进步轨迹" } },
|
||||||
{ "path": "pages/contribute/contribute", "style": { "navigationBarTitleText": "贡献面经" } },
|
{ "path": "pages/contribute/contribute", "style": { "navigationBarTitleText": "面经分享" } },
|
||||||
{ "path": "pages/login/login", "style": { "navigationBarTitleText": "登录" } },
|
{ "path": "pages/login/login", "style": { "navigationBarTitleText": "登录" } },
|
||||||
{ "path": "pages/history/history", "style": { "navigationBarTitleText": "面试记录" } },
|
{ "path": "pages/history/history", "style": { "navigationBarTitleText": "面试记录" } },
|
||||||
{ "path": "pages/user/user", "style": { "navigationBarTitleText": "我的" } },
|
{ "path": "pages/user/user", "style": { "navigationBarTitleText": "我的" } },
|
||||||
{ "path": "pages/resume/resume", "style": { "navigationBarTitleText": "我的简历" } },
|
{ "path": "pages/resume/resume", "style": { "navigationBarTitleText": "简历优化" } },
|
||||||
{ "path": "pages/internship/internship", "style": { "navigationBarTitleText": "实习搜索" } },
|
{ "path": "pages/internship/internship", "style": { "navigationBarTitleText": "实习搜索" } },
|
||||||
{ "path": "pages/about/about", "style": { "navigationBarTitleText": "关于" } },
|
{ "path": "pages/about/about", "style": { "navigationBarTitleText": "关于职引" } },
|
||||||
{ "path": "pages/admin/admin", "style": { "navigationBarTitleText": "管理后台" } },
|
{ "path": "pages/admin/admin", "style": { "navigationBarTitleText": "管理后台" } },
|
||||||
{ "path": "pages/result/result", "style": { "navigationBarTitleText": "优化结果" } }
|
{ "path": "pages/result/result", "style": { "navigationBarTitleText": "优化结果" } },
|
||||||
|
{ "path": "pages/agreement/agreement", "style": { "navigationBarTitleText": "用户协议" } },
|
||||||
|
{ "path": "pages/privacy/privacy", "style": { "navigationBarTitleText": "隐私政策" } }
|
||||||
],
|
],
|
||||||
"tabBar": {
|
"tabBar": {
|
||||||
"color": "#999999",
|
"color": "#999999",
|
||||||
@@ -22,13 +24,13 @@
|
|||||||
"borderStyle": "black",
|
"borderStyle": "black",
|
||||||
"list": [
|
"list": [
|
||||||
{ "pagePath": "pages/index/index", "text": "面试", "iconPath": "static/tabbar/home.png", "selectedIconPath": "static/tabbar/home-active.png" },
|
{ "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/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" }
|
{ "pagePath": "pages/user/user", "text": "我的", "iconPath": "static/tabbar/user.png", "selectedIconPath": "static/tabbar/user-active.png" }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"globalStyle": {
|
"globalStyle": {
|
||||||
"navigationBarTextStyle": "black",
|
"navigationBarTextStyle": "black",
|
||||||
"navigationBarTitleText": "职引",
|
"navigationBarTitleText": "职引 - AI模拟面试",
|
||||||
"navigationBarBackgroundColor": "#ffffff",
|
"navigationBarBackgroundColor": "#ffffff",
|
||||||
"backgroundColor": "#f5f6f7"
|
"backgroundColor": "#f5f6f7"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,65 +6,57 @@
|
|||||||
</view>
|
</view>
|
||||||
<view class="info-section">
|
<view class="info-section">
|
||||||
<text class="info-label">产品名称</text>
|
<text class="info-label">产品名称</text>
|
||||||
<text class="info-value">职引 · AI 面试模拟</text>
|
<text class="info-value">职引 · 宇之然AI磁场</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="info-section">
|
<view class="info-section">
|
||||||
<text class="info-label">开发团队</text>
|
<text class="info-label">开发团队</text>
|
||||||
<text class="info-value">宇之然</text>
|
<text class="info-value">宇之然</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="desc">
|
<view class="info-section">
|
||||||
<text>职引是一款 AI 驱动的面试模拟工具,帮助求职者通过模拟真实面试、简历诊断和优化,提升面试通过率。</text>
|
<text class="info-label">联系邮箱</text>
|
||||||
|
<text class="info-value">contact@yuzhiran.com</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="link-section">
|
||||||
|
<view class="link-item" @click="goAgreement">
|
||||||
|
<text class="link-text">用户协议</text>
|
||||||
|
<text class="link-arrow">›</text>
|
||||||
|
</view>
|
||||||
|
<view class="link-item" @click="goPrivacy">
|
||||||
|
<text class="link-text">隐私政策</text>
|
||||||
|
<text class="link-arrow">›</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="disclaimer">
|
||||||
|
<text class="disclaimer-title">⚠️ AI生成内容免责声明</text>
|
||||||
|
<text class="disclaimer-text">
|
||||||
|
本平台的模拟面试、简历诊断、简历优化等功能由人工智能模型生成,仅供参考和学习用途。AI输出的内容可能存在不准确、不完整或过时的情况,不构成任何专业建议。用户在做出重要决策前,请务必结合自身判断核实相关信息。宇之然AI磁场不对因使用AI生成内容导致的任何直接或间接损失承担责任。
|
||||||
|
</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup>
|
||||||
|
const goAgreement = () => uni.navigateTo({ url: '/pages/agreement/agreement' })
|
||||||
|
const goPrivacy = () => uni.navigateTo({ url: '/pages/privacy/privacy' })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.page {
|
.page { background: var(--color-bg); min-height: 100vh; padding: 60rpx 32rpx; }
|
||||||
|
.logo-area { text-align: center; padding: 60rpx 0 40rpx; }
|
||||||
background: #f5f6f7;
|
.logo { font-size: 48rpx; font-weight: 800; background: linear-gradient(135deg, var(--color-gradient-start), var(--color-gradient-end)); -webkit-background-clip: text; -webkit-text-fill-color: transparent; display: block; margin-bottom: 12rpx; }
|
||||||
padding: 60rpx 30rpx;
|
.version { font-size: 24rpx; color: var(--color-text-tertiary); }
|
||||||
}
|
.info-section { background: #FFF; padding: 24rpx 30rpx; border-radius: var(--radius-md); margin-bottom: 12rpx; display: flex; justify-content: space-between; align-items: center; }
|
||||||
.logo-area {
|
.info-label { font-size: 26rpx; color: var(--color-text-secondary); }
|
||||||
text-align: center;
|
.info-value { font-size: 26rpx; color: var(--color-text); font-weight: 500; }
|
||||||
padding: 80rpx 0;
|
.link-section { background: #FFF; border-radius: var(--radius-md); margin-top: 24rpx; overflow: hidden; }
|
||||||
}
|
.link-item { display: flex; justify-content: space-between; align-items: center; padding: 28rpx 30rpx; border-bottom: 1rpx solid var(--color-border); }
|
||||||
.logo {
|
.link-item:last-child { border-bottom: none; }
|
||||||
font-size: 48rpx;
|
.link-item:active { background: var(--color-bg); }
|
||||||
font-weight: 700;
|
.link-text { font-size: 26rpx; color: var(--color-text); }
|
||||||
background: linear-gradient(135deg, #4F46E5, #7C3AED);
|
.link-arrow { font-size: 32rpx; color: var(--color-text-tertiary); }
|
||||||
-webkit-background-clip: text;
|
.disclaimer { margin-top: 40rpx; background: #FFF8E1; border-radius: var(--radius-md); padding: 24rpx; }
|
||||||
-webkit-text-fill-color: transparent;
|
.disclaimer-title { font-size: 24rpx; font-weight: 700; color: #F59E0B; display: block; margin-bottom: 12rpx; }
|
||||||
display: block;
|
.disclaimer-text { font-size: 22rpx; color: var(--color-text-secondary); line-height: 1.8; }
|
||||||
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>
|
</style>
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
<text class="tab" :class="{ active: tab === 'overview' }" @click="switchTab('overview')">概览</text>
|
<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 === 'users' }" @click="switchTab('users')">用户管理</text>
|
||||||
<text class="tab" :class="{ active: tab === 'interviews' }" @click="switchTab('interviews')">面试记录</text>
|
<text class="tab" :class="{ active: tab === 'interviews' }" @click="switchTab('interviews')">面试记录</text>
|
||||||
|
<text class="tab" :class="{ active: tab === 'orders' }" @click="switchTab('orders')">订单</text>
|
||||||
<text class="tab" :class="{ active: tab === 'admins' }" @click="switchTab('admins')">管理员</text>
|
<text class="tab" :class="{ active: tab === 'admins' }" @click="switchTab('admins')">管理员</text>
|
||||||
<text class="tab" :class="{ active: tab === 'config' }" @click="switchTab('config')">配置</text>
|
<text class="tab" :class="{ active: tab === 'config' }" @click="switchTab('config')">配置</text>
|
||||||
</view>
|
</view>
|
||||||
@@ -70,6 +71,37 @@
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<!-- 订单 -->
|
||||||
|
<view v-if="tab === 'orders'" class="section">
|
||||||
|
<view class="tabs in-tab">
|
||||||
|
<text class="tab" :class="{ active: orderFilter === '' }" @click="orderFilter='';loadOrders()">全部</text>
|
||||||
|
<text class="tab" :class="{ active: orderFilter === 'pending' }" @click="orderFilter='pending';loadOrders()">待支付</text>
|
||||||
|
<text class="tab" :class="{ active: orderFilter === 'success' }" @click="orderFilter='success';loadOrders()">已支付</text>
|
||||||
|
<text class="tab" :class="{ active: orderFilter === 'refunded' }" @click="orderFilter='refunded';loadOrders()">已退款</text>
|
||||||
|
</view>
|
||||||
|
<view class="order-list" v-if="!orderLoading">
|
||||||
|
<view class="order-row" v-for="o in orders" :key="o._id">
|
||||||
|
<view class="order-info">
|
||||||
|
<text class="order-id">订单号: {{ o.outTradeNo }}</text>
|
||||||
|
<text class="order-user">用户: {{ o.userPhone || o.userId.slice(-6) }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="order-meta">
|
||||||
|
<text class="order-amount">¥{{ (o.amount / 100).toFixed(1) }}</text>
|
||||||
|
<view class="order-status" :class="o.status === 'success' ? 'paid' : o.status === 'refunded' ? 'refund' : 'pend'">
|
||||||
|
{{ o.status === 'success' ? '已支付' : o.status === 'refunded' ? '已退款' : '待支付' }}
|
||||||
|
</view>
|
||||||
|
<text class="order-time">{{ o.createdAt ? o.createdAt.slice(0,16).replace('T',' ') : '--' }}</text>
|
||||||
|
<view class="order-actions" v-if="o.status === 'pending'">
|
||||||
|
<text class="sync-btn" @click="syncOrder(o.outTradeNo)">同步</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<text class="load-more" v-if="ordersTotal > orders.length" @click="loadMoreOrders">加载更多</text>
|
||||||
|
<text class="empty-text" v-if="orders.length === 0 && !orderLoading">暂无订单</text>
|
||||||
|
</view>
|
||||||
|
<text class="loading-text" v-if="orderLoading">加载中...</text>
|
||||||
|
</view>
|
||||||
<!-- 套餐配置 -->
|
<!-- 套餐配置 -->
|
||||||
<view v-if="tab === 'config'" class="section">
|
<view v-if="tab === 'config'" class="section">
|
||||||
<view class="config-card" v-if="!cfgLoading">
|
<view class="config-card" v-if="!cfgLoading">
|
||||||
@@ -140,6 +172,11 @@ const adminList = ref([])
|
|||||||
const searchResult = ref(null)
|
const searchResult = ref(null)
|
||||||
const memberConfig = ref({ interview: { maxRoundsFree: 5, maxRoundsVip: 10, dailyFreeLimit: 3 }, diagnosis: { dailyFreeLimit: 2 }, optimize: { dailyFreeLimit: 2 }, price: { monthly: 2900 } })
|
const memberConfig = ref({ interview: { maxRoundsFree: 5, maxRoundsVip: 10, dailyFreeLimit: 3 }, diagnosis: { dailyFreeLimit: 2 }, optimize: { dailyFreeLimit: 2 }, price: { monthly: 2900 } })
|
||||||
const cfgLoading = ref(false)
|
const cfgLoading = ref(false)
|
||||||
|
const orders = ref([])
|
||||||
|
const ordersTotal = ref(0)
|
||||||
|
const ordersPage = ref(1)
|
||||||
|
const orderLoading = ref(false)
|
||||||
|
const orderFilter = ref('')
|
||||||
|
|
||||||
const token = () => uni.getStorageSync('token') || ''
|
const token = () => uni.getStorageSync('token') || ''
|
||||||
|
|
||||||
@@ -182,6 +219,7 @@ const switchTab = (t) => {
|
|||||||
if (t === 'interviews' && interviews.value.length === 0) loadInterviews()
|
if (t === 'interviews' && interviews.value.length === 0) loadInterviews()
|
||||||
if (t === 'admins' && adminList.value.length === 0) loadAdmins()
|
if (t === 'admins' && adminList.value.length === 0) loadAdmins()
|
||||||
if (t === 'config') loadConfig()
|
if (t === 'config') loadConfig()
|
||||||
|
if (t === 'orders') loadOrders()
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadUsers = async () => {
|
const loadUsers = async () => {
|
||||||
@@ -220,6 +258,39 @@ const loadConfig = async () => {
|
|||||||
finally { cfgLoading.value = false }
|
finally { cfgLoading.value = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const loadOrders = async () => {
|
||||||
|
orderLoading.value = true
|
||||||
|
ordersPage.value = 1
|
||||||
|
try {
|
||||||
|
let url = '/orders?page=1&limit=20'
|
||||||
|
if (orderFilter.value) url += '&status=' + orderFilter.value
|
||||||
|
const res = await apiAdmin(url)
|
||||||
|
if (res.statusCode === 200) { orders.value = res.data.orders || []; ordersTotal.value = res.data.total || 0 }
|
||||||
|
} catch(e) { console.error(e) }
|
||||||
|
finally { orderLoading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadMoreOrders = async () => {
|
||||||
|
ordersPage.value++
|
||||||
|
let url = '/orders?page=' + ordersPage.value + '&limit=20'
|
||||||
|
if (orderFilter.value) url += '&status=' + orderFilter.value
|
||||||
|
try {
|
||||||
|
const res = await apiAdmin(url)
|
||||||
|
if (res.statusCode === 200) orders.value = [...orders.value, ...(res.data.orders || [])]
|
||||||
|
} catch(e) { console.error(e) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncOrder = async (outTradeNo) => {
|
||||||
|
uni.showToast({ title: '同步中...', icon: 'none' })
|
||||||
|
try {
|
||||||
|
const res = await apiAdmin('/order/sync', { method: 'POST', data: { outTradeNo } })
|
||||||
|
if (res.statusCode === 200) {
|
||||||
|
uni.showToast({ title: '同步完成', icon: 'success' })
|
||||||
|
loadOrders()
|
||||||
|
} else { uni.showToast({ title: '同步失败', icon: 'none' }) }
|
||||||
|
} catch { uni.showToast({ title: '同步失败', icon: 'none' }) }
|
||||||
|
}
|
||||||
|
|
||||||
const loadAdmins = async () => {
|
const loadAdmins = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await apiAdmin('/admins')
|
const res = await apiAdmin('/admins')
|
||||||
@@ -314,6 +385,20 @@ const setVip = async (targetUserId) => {
|
|||||||
.admin-set-btn.done { color: var(--color-success); border-color: var(--color-success); }
|
.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); }
|
.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; }
|
.empty-text { text-align: center; padding: 20rpx; color: var(--color-text-tertiary); font-size: 22rpx; display: block; }
|
||||||
|
.order-list { display: flex; flex-direction: column; gap: 8rpx; }
|
||||||
|
.order-row { background: #FFF; border-radius: var(--radius-sm); padding: 16rpx; }
|
||||||
|
.order-info { display: flex; justify-content: space-between; margin-bottom: 8rpx; }
|
||||||
|
.order-id { font-size: 22rpx; color: var(--color-text); font-weight: 500; }
|
||||||
|
.order-user { font-size: 20rpx; color: var(--color-text-tertiary); }
|
||||||
|
.order-meta { display: flex; align-items: center; gap: 12rpx; }
|
||||||
|
.order-amount { font-size: 28rpx; font-weight: 700; color: var(--color-primary); }
|
||||||
|
.order-status { font-size: 20rpx; padding: 2rpx 12rpx; border-radius: var(--radius-round); }
|
||||||
|
.order-status.paid { background: #ECFDF5; color: var(--color-success); }
|
||||||
|
.order-status.refund { background: #FEF3C7; color: var(--color-warning); }
|
||||||
|
.order-status.pend { background: #F3F4F6; color: var(--color-text-tertiary); }
|
||||||
|
.order-time { font-size: 20rpx; color: var(--color-text-tertiary); flex: 1; text-align: right; }
|
||||||
|
.order-actions { }
|
||||||
|
.sync-btn { font-size: 20rpx; color: var(--color-primary); padding: 4rpx 12rpx; border: 2rpx solid var(--color-primary); border-radius: var(--radius-round); }
|
||||||
.config-card { background: #FFF; border-radius: var(--radius-sm); padding: 20rpx; margin-bottom: 12rpx; }
|
.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-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-row { display: flex; justify-content: space-between; padding: 8rpx 0; font-size: 22rpx; color: var(--color-text-secondary); }
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<template>
|
||||||
|
<view class="page">
|
||||||
|
<view class="section">
|
||||||
|
<text class="title">用户协议</text>
|
||||||
|
<text class="update">最后更新:2026年6月8日</text>
|
||||||
|
|
||||||
|
<text class="h2">1. 接受条款</text>
|
||||||
|
<text class="body">欢迎使用宇之然AI磁场(以下简称"本平台")。在访问或使用本平台前,请仔细阅读本用户协议。通过注册、登录或使用本平台的任何服务,即表示您已阅读、理解并同意受本协议约束。</text>
|
||||||
|
|
||||||
|
<text class="h2">2. 服务说明</text>
|
||||||
|
<text class="body">本平台提供基于人工智能技术的模拟面试、简历诊断与优化、面经分享、题库练习等求职辅助服务。所有AI生成的内容仅供参考,不构成专业建议。</text>
|
||||||
|
|
||||||
|
<text class="h2">3. 用户账户</text>
|
||||||
|
<text class="body">3.1 您在注册时需提供真实、准确的邮箱或手机号信息。\n3.2 您对账户下的所有行为负责,请妥善保管登录凭证。\n3.3 如发现账户被盗用,请立即联系我们。</text>
|
||||||
|
|
||||||
|
<text class="h2">4. 用户行为规范</text>
|
||||||
|
<text class="body">4.1 不得利用本平台从事任何违法活动。\n4.2 不得恶意刷量、攻击系统或干扰服务正常运行。\n4.3 不得侵犯他人知识产权或隐私权。\n4.4 面经分享内容应为真实体验,不得捏造或抄袭。</text>
|
||||||
|
|
||||||
|
<text class="h2">5. 免责声明</text>
|
||||||
|
<text class="body">5.1 本平台的AI面试、简历诊断、简历优化等功能输出由人工智能模型生成,仅供用户参考,不构成任何形式的专业建议。\n5.2 用户应结合自身判断核实AI输出的准确性和适用性,平台不对因使用AI生成内容导致的任何损失承担责任。\n5.3 面经内容由用户自发贡献,平台不保证其真实性、完整性或时效性。</text>
|
||||||
|
|
||||||
|
<text class="h2">6. 会员服务</text>
|
||||||
|
<text class="body">6.1 会员服务为订阅制,按月度计费。\n6.2 会员生效后费用不予退还,但可按剩余天数申请等额延期。\n6.3 平台有权在提前通知的情况下调整会员权益和价格。</text>
|
||||||
|
|
||||||
|
<text class="h2">7. 知识产权</text>
|
||||||
|
<text class="body">本平台的品牌、LOGO、软件著作权等知识产权归宇之然所有。未经授权,不得复制、修改或用于商业用途。</text>
|
||||||
|
|
||||||
|
<text class="h2">8. 协议变更</text>
|
||||||
|
<text class="body">本平台有权随时修改本协议,修改后的协议一经发布即生效。如您继续使用服务,视为接受修改后的协议。</text>
|
||||||
|
|
||||||
|
<text class="h2">9. 联系我们</text>
|
||||||
|
<text class="body">邮箱:contact@yuzhiran.com</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page { background: var(--color-bg); min-height: 100vh; padding: 32rpx; }
|
||||||
|
.section { background: #FFF; border-radius: var(--radius-lg); padding: 32rpx; }
|
||||||
|
.title { font-size: 36rpx; font-weight: 800; color: var(--color-text); display: block; margin-bottom: 8rpx; }
|
||||||
|
.update { font-size: 22rpx; color: var(--color-text-tertiary); display: block; margin-bottom: 32rpx; }
|
||||||
|
.h2 { font-size: 28rpx; font-weight: 700; color: var(--color-text); display: block; margin-top: 28rpx; margin-bottom: 12rpx; }
|
||||||
|
.body { font-size: 26rpx; color: var(--color-text-secondary); line-height: 1.8; white-space: pre-wrap; display: block; }
|
||||||
|
</style>
|
||||||
@@ -74,19 +74,27 @@
|
|||||||
<!-- 热门岗位 -->
|
<!-- 热门岗位 -->
|
||||||
<view class="section">
|
<view class="section">
|
||||||
<view class="section-header">
|
<view class="section-header">
|
||||||
|
<view class="section-title-row">
|
||||||
<text class="section-title">热门岗位</text>
|
<text class="section-title">热门岗位</text>
|
||||||
|
<text class="section-tag-demo">参考示例</text>
|
||||||
|
</view>
|
||||||
<text class="section-desc">点击直接面试</text>
|
<text class="section-desc">点击直接面试</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="position-list card" v-if="!positionsLoading">
|
<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-item" v-for="(pos, idx) in hotPositions" :key="idx" @click="startInterview(pos)">
|
||||||
<view class="pos-left">
|
<view class="pos-left">
|
||||||
<view class="pos-rank">{{ idx + 1 }}</view>
|
<text class="pos-icon">{{ posIcons[idx] || '💼' }}</text>
|
||||||
<view class="pos-body">
|
<view class="pos-body">
|
||||||
<text class="pos-name">{{ pos.name }}</text>
|
<text class="pos-name">{{ pos.name }}</text>
|
||||||
<text class="pos-company">{{ pos.company }}</text>
|
<view class="pos-meta-row">
|
||||||
|
<text class="pos-company">{{ pos.company || '参考公司' }}</text>
|
||||||
|
<text class="pos-salary">{{ pos.salary || '参考薪资' }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<text class="pos-salary">{{ pos.salary }}</text>
|
</view>
|
||||||
|
<view class="pos-action">
|
||||||
|
<text class="pos-action-text">立即模拟</text>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="loading-tip" v-if="positionsLoading">加载岗位中...</view>
|
<view class="loading-tip" v-if="positionsLoading">加载岗位中...</view>
|
||||||
@@ -103,6 +111,7 @@ import { api } from '../../config'
|
|||||||
const userInfo = ref(null)
|
const userInfo = ref(null)
|
||||||
const greeting = ref('')
|
const greeting = ref('')
|
||||||
const hotPositions = ref([])
|
const hotPositions = ref([])
|
||||||
|
const posIcons = ['💻', '⚙️', '🤖', '📊', '🎨', '🧪', '📱', '🔧']
|
||||||
const positionsLoading = ref(true)
|
const positionsLoading = ref(true)
|
||||||
const dailyQuestion = ref(null)
|
const dailyQuestion = ref(null)
|
||||||
const showAnswer = ref(false)
|
const showAnswer = ref(false)
|
||||||
@@ -175,8 +184,10 @@ const startInterview = (pos) => uni.navigateTo({ url: `/pages/interview/intervie
|
|||||||
|
|
||||||
.section { padding: 32rpx 32rpx 0; }
|
.section { padding: 32rpx 32rpx 0; }
|
||||||
.section:first-of-type { margin-top: -40rpx; padding-top: 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-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20rpx; }
|
||||||
.section-title { font-size: 30rpx; font-weight: 700; color: var(--color-text); }
|
.section-title { font-size: 30rpx; font-weight: 700; color: var(--color-text); }
|
||||||
|
.section-title-row { display: flex; align-items: center; gap: 12rpx; }
|
||||||
|
.section-tag-demo { font-size: 18rpx; color: #9CA3AF; background: #F3F4F6; padding: 2rpx 10rpx; border-radius: 6rpx; }
|
||||||
.section-desc { font-size: 22rpx; color: var(--color-primary); }
|
.section-desc { font-size: 22rpx; color: var(--color-primary); }
|
||||||
|
|
||||||
.feature-list { display: flex; flex-direction: column; gap: 16rpx; }
|
.feature-list { display: flex; flex-direction: column; gap: 16rpx; }
|
||||||
@@ -217,12 +228,16 @@ const startInterview = (pos) => uni.navigateTo({ url: `/pages/interview/intervie
|
|||||||
.position-list { border-radius: var(--radius-lg); overflow: hidden; }
|
.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 { 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-item:last-child { border-bottom: none; }
|
||||||
.pos-left { display: flex; align-items: center; gap: 16rpx; }
|
.pos-item:active { background: var(--color-bg); }
|
||||||
.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-left { display: flex; align-items: center; gap: 16rpx; flex: 1; min-width: 0; }
|
||||||
.pos-body { display: flex; flex-direction: column; }
|
.pos-icon { font-size: 36rpx; width: 56rpx; height: 56rpx; display: flex; align-items: center; justify-content: center; background: #F3F4F6; border-radius: 14rpx; flex-shrink: 0; }
|
||||||
|
.pos-body { display: flex; flex-direction: column; flex: 1; min-width: 0; }
|
||||||
.pos-name { font-size: 28rpx; font-weight: 600; color: var(--color-text); }
|
.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-meta-row { display: flex; align-items: center; gap: 10rpx; margin-top: 4rpx; }
|
||||||
.pos-salary { font-size: 24rpx; color: var(--color-primary); font-weight: 600; }
|
.pos-company { font-size: 20rpx; color: var(--color-text-tertiary); }
|
||||||
|
.pos-salary { font-size: 20rpx; color: var(--color-primary); background: #EEF2FF; padding: 2rpx 10rpx; border-radius: 6rpx; }
|
||||||
|
.pos-action { flex-shrink: 0; margin-left: 16rpx; }
|
||||||
|
.pos-action-text { font-size: 22rpx; 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); }
|
.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; }
|
.bottom-spacer { height: 40rpx; }
|
||||||
</style>
|
</style>
|
||||||
@@ -5,6 +5,10 @@
|
|||||||
<view class="topbar-inner">
|
<view class="topbar-inner">
|
||||||
<view class="back-btn" @click="confirmExit"><text class="back-arrow">‹</text></view>
|
<view class="back-btn" @click="confirmExit"><text class="back-arrow">‹</text></view>
|
||||||
<view class="topbar-center">
|
<view class="topbar-center">
|
||||||
|
<view class="topbar-pos-row">
|
||||||
|
<text class="topbar-position">{{ position || 'AI面试' }}</text>
|
||||||
|
<text class="topbar-status">面试中</text>
|
||||||
|
</view>
|
||||||
<view class="progress-track" v-if="interviewId">
|
<view class="progress-track" v-if="interviewId">
|
||||||
<view class="progress-fill" :style="{ width: progressPercent + '%' }"></view>
|
<view class="progress-fill" :style="{ width: progressPercent + '%' }"></view>
|
||||||
</view>
|
</view>
|
||||||
@@ -43,6 +47,8 @@
|
|||||||
<text class="send-icon">➤</text>
|
<text class="send-icon">➤</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
<!-- AI 免责提示 -->
|
||||||
|
<view class="disclaimer-bar" v-if="!isComplete">AI 生成内容仅供参考,请核实重要信息</view>
|
||||||
|
|
||||||
<!-- Complete -->
|
<!-- Complete -->
|
||||||
<view class="complete-bar" v-else>
|
<view class="complete-bar" v-else>
|
||||||
@@ -56,14 +62,14 @@ import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
|||||||
import { onLoad } from '@dcloudio/uni-app'
|
import { onLoad } from '@dcloudio/uni-app'
|
||||||
import { api } from '../../config'
|
import { api } from '../../config'
|
||||||
|
|
||||||
const messages = ref([{ role: 'ai', content: '你好!我是 AI 面试官,准备好就开始吧!' }])
|
const messages = ref([{ role: 'ai', content: '你好!我是 AI 面试官,请选择岗位开始模拟面试!' }])
|
||||||
const inputText = ref('')
|
const inputText = ref('')
|
||||||
const aiLoading = ref(false)
|
const aiLoading = ref(false)
|
||||||
const interviewId = ref('')
|
const interviewId = ref('')
|
||||||
const answeredCount = ref(0)
|
const answeredCount = ref(0)
|
||||||
const isComplete = ref(false)
|
const isComplete = ref(false)
|
||||||
const scrollToId = ref('')
|
const scrollToId = ref('')
|
||||||
const position = ref('通用岗位')
|
const position = ref('')
|
||||||
let timerSeconds = 0
|
let timerSeconds = 0
|
||||||
let timerInterval = null
|
let timerInterval = null
|
||||||
|
|
||||||
@@ -75,7 +81,11 @@ const formatTime = computed(() => {
|
|||||||
const token = computed(() => uni.getStorageSync('token') || '')
|
const token = computed(() => uni.getStorageSync('token') || '')
|
||||||
|
|
||||||
onLoad((options) => {
|
onLoad((options) => {
|
||||||
if (options?.position) position.value = decodeURIComponent(options.position)
|
if (options?.position) {
|
||||||
|
const pos = decodeURIComponent(options.position)
|
||||||
|
position.value = pos
|
||||||
|
messages.value = [{ role: 'ai', content: `你好!我是你的专属 ${pos} 面试官,准备好了就开始吧!` }]
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(() => { timerInterval = setInterval(() => timerSeconds++, 1000); if (token.value) startInterview() })
|
onMounted(() => { timerInterval = setInterval(() => timerSeconds++, 1000); if (token.value) startInterview() })
|
||||||
@@ -151,9 +161,12 @@ const confirmExit = () => {
|
|||||||
}
|
}
|
||||||
.back-arrow { font-size: 36rpx; color: #FFFFFF; font-weight: 300; line-height: 1; }
|
.back-arrow { font-size: 36rpx; color: #FFFFFF; font-weight: 300; line-height: 1; }
|
||||||
.topbar-center { flex: 1; display: flex; flex-direction: column; gap: 8rpx; }
|
.topbar-center { flex: 1; display: flex; flex-direction: column; gap: 8rpx; }
|
||||||
|
.topbar-pos-row { display: flex; align-items: center; gap: 10rpx; }
|
||||||
|
.topbar-position { font-size: 26rpx; color: #FFFFFF; font-weight: 600; }
|
||||||
|
.topbar-status { font-size: 18rpx; color: #FFFFFF; background: rgba(255,255,255,0.2); padding: 2rpx 12rpx; border-radius: 20rpx; }
|
||||||
.progress-track { height: 6rpx; background: rgba(255,255,255,0.2); border-radius: 3rpx; overflow: hidden; }
|
.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); }
|
.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-timer { font-size: 20rpx; color: rgba(255,255,255,0.7); font-variant-numeric: tabular-nums; }
|
||||||
.topbar-right { width: 60rpx; flex-shrink: 0; }
|
.topbar-right { width: 60rpx; flex-shrink: 0; }
|
||||||
|
|
||||||
/* ===== Chat ===== */
|
/* ===== Chat ===== */
|
||||||
@@ -207,4 +220,5 @@ const confirmExit = () => {
|
|||||||
/* Complete */
|
/* Complete */
|
||||||
.complete-bar { background: #FFFFFF; padding: 20rpx 24rpx; padding-bottom: calc(20rpx + var(--safe-bottom)); }
|
.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; }
|
.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; }
|
||||||
|
.disclaimer-bar { text-align: center; font-size: 20rpx; color: var(--color-text-tertiary); padding: 8rpx 24rpx; background: #FFFFFF; border-top: 1rpx solid var(--color-border); }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -8,105 +8,285 @@
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="form-section">
|
<view class="form-section">
|
||||||
<!-- 登录方式切换 -->
|
<!-- 主 Tab:登录 / 注册 / 微信 -->
|
||||||
<view class="tab-bar">
|
<view class="tab-bar">
|
||||||
<text class="tab" :class="{ active: mode === 'email' }" @click="mode='email'">邮箱登录</text>
|
<text class="tab" :class="{ active: mainTab === 'login' }" @click="mainTab='login'">登录</text>
|
||||||
<text class="tab" :class="{ active: mode === 'wechat' }" @click="mode='wechat'" v-if="isMp">微信登录</text>
|
<text class="tab" :class="{ active: mainTab === 'register' }" @click="mainTab='register'">注册</text>
|
||||||
|
<text class="tab" :class="{ active: mainTab === 'wechat' }" @click="mainTab='wechat'" v-if="isMp">微信登录</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 邮箱登录 -->
|
<!-- ========== 登录 ========== -->
|
||||||
<view class="card" v-if="mode === 'email'">
|
<view class="card" v-if="mainTab === 'login'">
|
||||||
<text class="card-title">邮箱登录</text>
|
<!-- 子 Tab:密码 / 验证码 -->
|
||||||
|
<view class="sub-tab-bar">
|
||||||
|
<text class="sub-tab" :class="{ active: loginMode === 'password' }" @click="loginMode='password'">密码登录</text>
|
||||||
|
<text class="sub-tab" :class="{ active: loginMode === 'code' }" @click="loginMode='code'">验证码登录</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 密码登录 -->
|
||||||
|
<view v-if="loginMode === 'password'">
|
||||||
<view class="field">
|
<view class="field">
|
||||||
<text class="field-label">邮箱</text>
|
<text class="field-label">邮箱</text>
|
||||||
<input class="input" type="text" v-model="email" placeholder="请输入邮箱" @confirm="sendEmailCode" />
|
<input class="input" type="text" v-model="email" placeholder="请输入邮箱" />
|
||||||
|
</view>
|
||||||
|
<view class="field">
|
||||||
|
<text class="field-label">密码</text>
|
||||||
|
<input class="input" type="password" v-model="password" placeholder="请输入密码" @confirm="doPasswordLogin" />
|
||||||
|
</view>
|
||||||
|
<button class="login-btn" :disabled="!canPasswordLogin || pwdLoading" @click="doPasswordLogin">
|
||||||
|
{{ pwdLoading ? '登录中...' : '登录' }}
|
||||||
|
</button>
|
||||||
|
<view class="switch-hint" @click="loginMode='code'">忘记密码?使用验证码登录</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="field" v-if="emailSent">
|
<!-- 验证码登录 -->
|
||||||
<text class="field-label">验证码</text>
|
<view v-else>
|
||||||
<view class="code-row">
|
<!-- 调试信息(发布前删掉) -->
|
||||||
<input class="input code-input" type="number" maxlength="6" v-model="emailCode" placeholder="6位验证码" @confirm="doEmailLogin" />
|
<view class="debug-info" v-if="true">debug: emailSent={{emailSent}} cooldown={{cooldown}}</view>
|
||||||
<button class="code-btn" :disabled="cooldown > 0" @click="sendEmailCode">
|
<view class="field">
|
||||||
{{ cooldown > 0 ? cooldown + 's' : '获取验证码' }}
|
<text class="field-label">邮箱</text>
|
||||||
|
<view class="inline-row">
|
||||||
|
<input class="input inline-input" type="text" v-model="email" placeholder="请输入邮箱" />
|
||||||
|
<button class="code-btn" :disabled="cooldown > 0 || !email" @click="sendEmailCode">
|
||||||
|
{{ cooldown > 0 ? cooldown + 's' : (emailSent ? '重新获取' : '获取验证码') }}
|
||||||
</button>
|
</button>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
<view class="field" v-if="emailSent">
|
||||||
<button class="login-btn" v-if="!emailSent" @click="sendEmailCode">{{ emailSending ? '发送中...' : '获取验证码' }}</button>
|
<text class="field-label">验证码</text>
|
||||||
<button class="login-btn" v-else :disabled="!emailCode" @click="doEmailLogin">{{ emailLoading ? '登录中...' : '登录' }}</button>
|
<input class="input" type="number" maxlength="6" v-model="emailCode" placeholder="请输入6位验证码" />
|
||||||
|
</view>
|
||||||
|
<button class="login-btn" :disabled="!emailSent || !emailCode || emailLoading" @click="doEmailLogin">
|
||||||
|
{{ emailLoading ? '登录中...' : '登录' }}
|
||||||
|
</button>
|
||||||
|
<view class="switch-hint" @click="loginMode='password'">已有密码?使用密码登录</view>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 微信登录(仅小程序) -->
|
<!-- ========== 注册 ========== -->
|
||||||
<view class="card" v-if="mode === 'wechat' && isMp">
|
<view class="card" v-if="mainTab === 'register'">
|
||||||
|
<text class="card-title">创建账号</text>
|
||||||
|
<text class="card-sub">注册后享受 AI 面试模拟服务</text>
|
||||||
|
<view class="field">
|
||||||
|
<text class="field-label">邮箱</text>
|
||||||
|
<input class="input" type="text" v-model="email" placeholder="请输入邮箱" />
|
||||||
|
</view>
|
||||||
|
<view class="field">
|
||||||
|
<text class="field-label">密码</text>
|
||||||
|
<input class="input" type="password" v-model="password" placeholder="至少6位密码" />
|
||||||
|
</view>
|
||||||
|
<view class="field">
|
||||||
|
<text class="field-label">确认密码</text>
|
||||||
|
<input class="input" type="password" v-model="confirmPassword" placeholder="再次输入密码" @confirm="doRegister" />
|
||||||
|
</view>
|
||||||
|
<button class="login-btn" :disabled="!canRegister || regLoading" @click="doRegister">
|
||||||
|
{{ regLoading ? '注册中...' : '注册' }}
|
||||||
|
</button>
|
||||||
|
<view class="switch-hint" @click="mainTab='login'">已有账号?去登录</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- ========== 微信一键登录 ========== -->
|
||||||
|
<view class="card" v-if="mainTab === 'wechat' && isMp">
|
||||||
<text class="card-title">微信一键登录</text>
|
<text class="card-title">微信一键登录</text>
|
||||||
<text class="card-sub">授权后自动创建账号</text>
|
<text class="card-sub">授权后自动创建账号</text>
|
||||||
<button class="login-btn wx-btn" @click="doWxLogin">{{ wxLoading ? '登录中...' : '微信一键登录' }}</button>
|
<button class="login-btn wx-btn" @click="doWxLogin">{{ wxLoading ? '登录中...' : '微信一键登录' }}</button>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<!-- 法律声明 -->
|
||||||
|
<view class="legal">
|
||||||
|
<text class="legal-text">登录即表示同意</text>
|
||||||
|
<text class="legal-link" @click="goAgreement">《用户协议》</text>
|
||||||
|
<text class="legal-text">和</text>
|
||||||
|
<text class="legal-link" @click="goPrivacy">《隐私政策》</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 设置密码弹窗(验证码登录后引导) -->
|
||||||
|
<view class="overlay" v-if="showSetPwd" @click="showSetPwd=false"></view>
|
||||||
|
<view class="pwd-modal" v-if="showSetPwd">
|
||||||
|
<text class="modal-title">设置登录密码</text>
|
||||||
|
<text class="modal-desc">设置密码后,下次可直接用密码登录,无需等待验证码</text>
|
||||||
|
<input class="input" type="password" v-model="newPassword" placeholder="至少6位密码" />
|
||||||
|
<view class="modal-btns">
|
||||||
|
<text class="modal-btn skip" @click="skipSetPwd">暂不设置</text>
|
||||||
|
<text class="modal-btn confirm" @click="doSetPassword">确认设置</text>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||||
import { api } from '../../config'
|
import { api } from '../../config'
|
||||||
|
|
||||||
const mode = ref('email')
|
const mainTab = ref('login')
|
||||||
|
const loginMode = ref('password') // 'password' | 'code'
|
||||||
const isMp = ref(false)
|
const isMp = ref(false)
|
||||||
|
|
||||||
const email = ref('')
|
const email = ref('')
|
||||||
|
const password = ref('')
|
||||||
|
const confirmPassword = ref('')
|
||||||
const emailCode = ref('')
|
const emailCode = ref('')
|
||||||
const emailSent = ref(false)
|
const emailSent = ref(false)
|
||||||
const emailSending = ref(false)
|
|
||||||
const emailLoading = ref(false)
|
const emailLoading = ref(false)
|
||||||
|
const pwdLoading = ref(false)
|
||||||
|
const regLoading = ref(false)
|
||||||
|
const wxLoading = ref(false)
|
||||||
const cooldown = ref(0)
|
const cooldown = ref(0)
|
||||||
let timer = null
|
let timer = null
|
||||||
|
|
||||||
|
// 设置密码弹窗
|
||||||
|
const showSetPwd = ref(false)
|
||||||
|
const newPassword = ref('')
|
||||||
|
|
||||||
|
const canPasswordLogin = computed(() => email.value.trim() && password.value.length >= 6 && !pwdLoading.value)
|
||||||
|
const canRegister = computed(() => email.value.trim() && password.value.length >= 6 && password.value === confirmPassword.value && !regLoading.value)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// #ifdef MP-WEIXIN
|
// #ifdef MP-WEIXIN
|
||||||
isMp.value = true
|
isMp.value = true
|
||||||
mode.value = 'wechat'
|
mainTab.value = 'wechat'
|
||||||
// #endif
|
// #endif
|
||||||
})
|
})
|
||||||
|
|
||||||
// 邮箱验证码
|
onBeforeUnmount(() => { if (timer) clearInterval(timer) })
|
||||||
const sendEmailCode = async () => {
|
|
||||||
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
// 辅助
|
||||||
if (!re.test(email.value)) { uni.showToast({ title: '请输入正确的邮箱', icon: 'none' }); return }
|
const showToast = (title, icon = 'none') => uni.showToast({ title, icon })
|
||||||
emailSending.value = true
|
const loginSuccess = (data) => {
|
||||||
|
uni.setStorageSync('token', data.token)
|
||||||
|
if (data.user) uni.setStorageSync('userInfo', JSON.stringify(data.user))
|
||||||
|
showToast('登录成功', 'success')
|
||||||
|
setTimeout(() => uni.navigateBack(), 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== 密码登录 ======
|
||||||
|
const doPasswordLogin = async () => {
|
||||||
|
if (!canPasswordLogin.value) return
|
||||||
|
pwdLoading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await uni.request({ url: api('/user/send-email-code'), method: 'POST', data: { email: email.value } })
|
const res = await uni.request({
|
||||||
|
url: api('/user/password-login'), method: 'POST',
|
||||||
|
header: { 'Content-Type': 'application/json' },
|
||||||
|
data: { email: email.value.trim(), password: password.value },
|
||||||
|
})
|
||||||
|
if (res.statusCode === 200 && res.data?.token) {
|
||||||
|
loginSuccess(res.data)
|
||||||
|
} else {
|
||||||
|
showToast(res.data?.message || '登录失败')
|
||||||
|
}
|
||||||
|
} catch { showToast('网络错误') }
|
||||||
|
finally { pwdLoading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== 验证码 ======
|
||||||
|
const sendEmailCode = () => {
|
||||||
|
if (cooldown.value > 0) { showToast('请稍后再试'); return }
|
||||||
|
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||||
|
if (!re.test(email.value)) { showToast('请输入正确的邮箱'); return }
|
||||||
|
console.log('[sendEmailCode] 发送中,email:', email.value)
|
||||||
|
uni.request({
|
||||||
|
url: api('/user/send-email-code'),
|
||||||
|
method: 'POST',
|
||||||
|
header: { 'Content-Type': 'application/json' },
|
||||||
|
data: { email: email.value },
|
||||||
|
success: (res) => {
|
||||||
|
console.log('[sendEmailCode] success res:', JSON.stringify(res))
|
||||||
if (res.statusCode === 200) {
|
if (res.statusCode === 200) {
|
||||||
emailSent.value = true
|
emailSent.value = true
|
||||||
uni.showToast({ title: '验证码已发送', icon: 'success' })
|
console.log('[sendEmailCode] emailSent 设为 true')
|
||||||
|
showToast('验证码已发送', 'success')
|
||||||
startCooldown()
|
startCooldown()
|
||||||
} else { uni.showToast({ title: res.data?.message || '发送失败', icon: 'none' }) }
|
} else {
|
||||||
} catch { uni.showToast({ title: '网络错误', icon: 'none' }) }
|
const msg = (res.data && res.data.message) || '发送失败'
|
||||||
finally { emailSending.value = false }
|
showToast(msg)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fail: (err) => {
|
||||||
|
console.error('[sendEmailCode] fail:', err)
|
||||||
|
showToast('网络错误')
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const startCooldown = () => {
|
const startCooldown = () => {
|
||||||
cooldown.value = 60
|
cooldown.value = 60
|
||||||
if (timer) clearInterval(timer)
|
if (timer) clearTimeout(timer)
|
||||||
timer = setInterval(() => { if (--cooldown.value <= 0) { clearInterval(timer); timer = null } }, 1000)
|
const tick = () => {
|
||||||
|
cooldown.value--
|
||||||
|
if (cooldown.value <= 0) {
|
||||||
|
timer = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
timer = setTimeout(tick, 1000)
|
||||||
|
}
|
||||||
|
timer = setTimeout(tick, 1000)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 邮箱登录
|
|
||||||
const doEmailLogin = async () => {
|
const doEmailLogin = async () => {
|
||||||
if (!emailCode.value) return
|
if (!emailCode.value) return
|
||||||
emailLoading.value = true
|
emailLoading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await uni.request({ url: api('/user/email-login'), method: 'POST', data: { email: email.value, code: emailCode.value } })
|
const res = await uni.request({
|
||||||
|
url: api('/user/email-login'), method: 'POST',
|
||||||
|
header: { 'Content-Type': 'application/json' },
|
||||||
|
data: { email: email.value.trim(), code: emailCode.value },
|
||||||
|
})
|
||||||
if (res.statusCode === 200 && res.data?.token) {
|
if (res.statusCode === 200 && res.data?.token) {
|
||||||
uni.setStorageSync('token', res.data.token)
|
loginSuccess(res.data)
|
||||||
if (res.data.user) uni.setStorageSync('userInfo', JSON.stringify(res.data.user))
|
// 新用户(isNew)且没有密码 → 引导设置密码
|
||||||
uni.showToast({ title: '登录成功', icon: 'success' })
|
if (res.data.isNew || !res.data.hasPassword) {
|
||||||
setTimeout(() => uni.navigateBack(), 500)
|
setTimeout(() => { showSetPwd.value = true; newPassword.value = '' }, 800)
|
||||||
} else { uni.showToast({ title: res.data?.message || '登录失败', icon: 'none' }) }
|
}
|
||||||
} catch { uni.showToast({ title: '登录失败', icon: 'none' }) }
|
} else {
|
||||||
|
showToast(res.data?.message || '登录失败')
|
||||||
|
}
|
||||||
|
} catch { showToast('网络错误') }
|
||||||
finally { emailLoading.value = false }
|
finally { emailLoading.value = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 微信静默登录
|
// ====== 注册 ======
|
||||||
|
const doRegister = async () => {
|
||||||
|
if (!canRegister.value) return
|
||||||
|
regLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await uni.request({
|
||||||
|
url: api('/user/register'), method: 'POST',
|
||||||
|
header: { 'Content-Type': 'application/json' },
|
||||||
|
data: { email: email.value.trim(), password: password.value },
|
||||||
|
})
|
||||||
|
if (res.statusCode === 200 && res.data?.token) {
|
||||||
|
loginSuccess(res.data)
|
||||||
|
} else if (res.statusCode === 409) {
|
||||||
|
showToast('该邮箱已注册,请直接登录')
|
||||||
|
mainTab.value = 'login'
|
||||||
|
} else {
|
||||||
|
showToast(res.data?.message || '注册失败')
|
||||||
|
}
|
||||||
|
} catch { showToast('网络错误') }
|
||||||
|
finally { regLoading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== 设置密码 ======
|
||||||
|
const doSetPassword = async () => {
|
||||||
|
if (newPassword.value.length < 6) { showToast('密码至少6位'); return }
|
||||||
|
const token = uni.getStorageSync('token')
|
||||||
|
try {
|
||||||
|
const res = await uni.request({
|
||||||
|
url: api('/user/set-password'), method: 'POST',
|
||||||
|
header: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||||
|
data: { password: newPassword.value },
|
||||||
|
})
|
||||||
|
if (res.statusCode === 200 || res.statusCode === 201) {
|
||||||
|
showToast('密码设置成功', 'success')
|
||||||
|
showSetPwd.value = false
|
||||||
|
}
|
||||||
|
} catch { showToast('设置失败') }
|
||||||
|
}
|
||||||
|
|
||||||
|
const skipSetPwd = () => { showSetPwd.value = false }
|
||||||
|
|
||||||
|
// ====== 微信登录 ======
|
||||||
const doWxLogin = async () => {
|
const doWxLogin = async () => {
|
||||||
// #ifdef MP-WEIXIN
|
// #ifdef MP-WEIXIN
|
||||||
wxLoading.value = true
|
wxLoading.value = true
|
||||||
@@ -114,17 +294,16 @@ const doWxLogin = async () => {
|
|||||||
const { code } = await uni.login()
|
const { code } = await uni.login()
|
||||||
const res = await uni.request({ url: api('/user/wx-login'), method: 'POST', data: { code } })
|
const res = await uni.request({ url: api('/user/wx-login'), method: 'POST', data: { code } })
|
||||||
if (res.statusCode === 200 && res.data?.token) {
|
if (res.statusCode === 200 && res.data?.token) {
|
||||||
uni.setStorageSync('token', res.data.token)
|
loginSuccess(res.data)
|
||||||
if (res.data.user) uni.setStorageSync('userInfo', JSON.stringify(res.data.user))
|
} else { showToast('微信登录失败') }
|
||||||
uni.showToast({ title: '登录成功', icon: 'success' })
|
} catch { showToast('微信登录失败') }
|
||||||
setTimeout(() => uni.navigateBack(), 500)
|
|
||||||
} else { uni.showToast({ title: '微信登录失败', icon: 'none' }) }
|
|
||||||
} catch { uni.showToast({ title: '微信登录失败', icon: 'none' }) }
|
|
||||||
finally { wxLoading.value = false }
|
finally { wxLoading.value = false }
|
||||||
// #endif
|
// #endif
|
||||||
}
|
}
|
||||||
|
|
||||||
const wxLoading = ref(false)
|
// ====== 法律页面 ======
|
||||||
|
const goAgreement = () => uni.navigateTo({ url: '/pages/agreement/agreement' })
|
||||||
|
const goPrivacy = () => uni.navigateTo({ url: '/pages/privacy/privacy' })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -134,23 +313,50 @@ const wxLoading = ref(false)
|
|||||||
.brand-tagline { font-size: 24rpx; color: var(--color-text-tertiary); margin-top: 8rpx; display: block; }
|
.brand-tagline { font-size: 24rpx; color: var(--color-text-tertiary); margin-top: 8rpx; display: block; }
|
||||||
.form-section { padding: 0 32rpx; flex: 1; }
|
.form-section { padding: 0 32rpx; flex: 1; }
|
||||||
|
|
||||||
/* Tab */
|
/* ===== Main Tab ===== */
|
||||||
.tab-bar { display: flex; gap: 0; margin-bottom: 24rpx; background: #FFFFFF; border-radius: var(--radius-md); padding: 4rpx; }
|
.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 { flex: 1; text-align: center; padding: 16rpx; font-size: 26rpx; color: var(--color-text-secondary); border-radius: var(--radius-sm); transition: all 0.2s;}
|
||||||
.tab.active { background: var(--color-primary); color: #FFFFFF; font-weight: 600; }
|
.tab.active { background: var(--color-primary); color: #FFFFFF; font-weight: 600; }
|
||||||
|
|
||||||
/* Form */
|
/* ===== Sub Tab ===== */
|
||||||
|
.sub-tab-bar { display: flex; gap: 0; margin-bottom: 24rpx; background: #F9FAFB; border-radius: var(--radius-sm); padding: 4rpx; }
|
||||||
|
.sub-tab { flex: 1; text-align: center; padding: 12rpx; font-size: 24rpx; color: var(--color-text-tertiary); border-radius: var(--radius-sm); transition: all 0.2s;}
|
||||||
|
.sub-tab.active { background: #FFFFFF; color: var(--color-text); font-weight: 600; box-shadow: 0 1rpx 4rpx rgba(0,0,0,0.06); }
|
||||||
|
|
||||||
|
/* ===== Card ===== */
|
||||||
.card { background: #FFFFFF; border-radius: var(--radius-lg); padding: 32rpx; box-shadow: var(--shadow-sm); }
|
.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-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; }
|
.card-sub { font-size: 22rpx; color: var(--color-text-tertiary); margin-top: 6rpx; margin-bottom: 24rpx; display: block; }
|
||||||
|
|
||||||
|
/* ===== Fields ===== */
|
||||||
.field { margin-bottom: 20rpx; }
|
.field { margin-bottom: 20rpx; }
|
||||||
.field-label { font-size: 22rpx; color: var(--color-text-secondary); margin-bottom: 8rpx; display: block; }
|
.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; }
|
.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; }
|
.inline-row { display: flex; gap: 12rpx; align-items: center; }
|
||||||
.code-input { flex: 1; }
|
.inline-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 { height: 72rpx; padding: 0 20rpx; background: var(--color-primary); color: #FFFFFF; border: none; border-radius: var(--radius-sm); font-size: 24rpx; white-space: nowrap; flex-shrink: 0; line-height: 72rpx; }
|
||||||
.code-btn:disabled { color: var(--color-text-tertiary); }
|
.code-btn:disabled { background: #D1D5DB; color: #9CA3AF; }
|
||||||
|
|
||||||
|
/* ===== Buttons ===== */
|
||||||
.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 { 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; }
|
.login-btn:disabled { opacity: 0.5; }
|
||||||
.wx-btn { background: linear-gradient(135deg, #07C160, #06AD56); }
|
.wx-btn { background: linear-gradient(135deg, #07C160, #06AD56); }
|
||||||
|
|
||||||
|
/* ===== Switch Hint ===== */
|
||||||
|
.switch-hint { text-align: center; font-size: 22rpx; color: var(--color-primary); padding: 20rpx 0 4rpx; }
|
||||||
|
|
||||||
|
/* ===== Legal ===== */
|
||||||
|
.legal { display: flex; justify-content: center; align-items: center; gap: 4rpx; margin-top: 24rpx; flex-wrap: wrap; }
|
||||||
|
.legal-text { font-size: 22rpx; color: var(--color-text-tertiary); }
|
||||||
|
.legal-link { font-size: 22rpx; color: var(--color-primary); }
|
||||||
|
|
||||||
|
/* ===== Password Modal ===== */
|
||||||
|
.overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.4); z-index: 100; }
|
||||||
|
.pwd-modal { position: fixed; left: 32rpx; right: 32rpx; top: 50%; transform: translateY(-50%); background: #FFFFFF; border-radius: var(--radius-lg); padding: 40rpx 32rpx; z-index: 101; }
|
||||||
|
.modal-title { font-size: 30rpx; font-weight: 700; color: var(--color-text); display: block; text-align: center; }
|
||||||
|
.modal-desc { font-size: 22rpx; color: var(--color-text-tertiary); text-align: center; display: block; margin: 12rpx 0 24rpx; line-height: 1.5; }
|
||||||
|
.modal-btns { display: flex; gap: 16rpx; margin-top: 24rpx; }
|
||||||
|
.modal-btn { flex: 1; text-align: center; padding: 20rpx; font-size: 26rpx; border-radius: var(--radius-sm); }
|
||||||
|
.modal-btn.skip { background: #F3F4F6; color: var(--color-text-secondary); }
|
||||||
|
.modal-btn.confirm { background: linear-gradient(135deg, var(--color-gradient-start), var(--color-gradient-mid)); color: #FFFFFF; font-weight: 600; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<template>
|
||||||
|
<view class="page">
|
||||||
|
<view class="section">
|
||||||
|
<text class="title">隐私政策</text>
|
||||||
|
<text class="update">最后更新:2026年6月8日</text>
|
||||||
|
|
||||||
|
<text class="body">宇之然AI磁场(以下简称"本平台")重视您的隐私。本隐私政策说明我们如何收集、使用、存储和保护您的个人信息。</text>
|
||||||
|
|
||||||
|
<text class="h2">1. 收集的信息</text>
|
||||||
|
<text class="body">1.1 账户信息:注册时收集的邮箱或手机号。\n1.2 个人资料:您自愿填写的昵称、头像、教育背景、简历内容等。\n1.3 使用数据:面试记录、答题内容、诊断报告、操作日志等。\n1.4 设备信息:设备型号、操作系统版本、网络环境等(仅用于服务优化)。\n1.5 微信信息:当您使用微信登录时,我们会获取您的微信OpenID。</text>
|
||||||
|
|
||||||
|
<text class="h2">2. 信息使用</text>
|
||||||
|
<text class="body">2.1 提供和优化AI模拟面试、简历诊断等核心服务。\n2.2 生成个性化面试报告和进步轨迹分析。\n2.3 改善服务质量和用户体验。\n2.4 发送服务通知(如会员到期提醒)。</text>
|
||||||
|
|
||||||
|
<text class="h2">3. 信息存储与保护</text>
|
||||||
|
<text class="body">3.1 您的数据存储在安全的云服务器上,采用加密传输(HTTPS)和存储。\n3.2 我们采取业界标准的安全措施防止数据泄露、篡改或丢失。\n3.3 您的简历和面试数据不会分享给第三方,除非获得您的明确同意或法律要求。</text>
|
||||||
|
|
||||||
|
<text class="h2">4. AI数据处理说明</text>
|
||||||
|
<text class="body">4.1 您在面试、简历诊断等场景中输入的文本内容会被发送至AI服务商进行处理,用于生成AI回复。\n4.2 我们不会将您的个人身份信息(如手机号、邮箱)发送给AI服务商。\n4.3 AI服务商不会将您的输入数据用于模型训练或其他目的。</text>
|
||||||
|
|
||||||
|
<text class="h2">5. 数据删除</text>
|
||||||
|
<text class="body">您可以在「我的」页面注销账户,或发送邮件至 contact@yuzhiran.com 申请删除您的所有数据。我们将在15个工作日内处理完成。</text>
|
||||||
|
|
||||||
|
<text class="h2">6. Cookie与本地存储</text>
|
||||||
|
<text class="body">本平台使用本地存储(localStorage)保存您的登录状态和偏好设置,不会使用第三方追踪Cookie。</text>
|
||||||
|
|
||||||
|
<text class="h2">7. 政策更新</text>
|
||||||
|
<text class="body">我们可能不时更新本隐私政策。重大变更会通过应用内通知或邮件告知您。</text>
|
||||||
|
|
||||||
|
<text class="h2">8. 联系方式</text>
|
||||||
|
<text class="body">如有任何隐私相关问题,请联系:contact@yuzhiran.com</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page { background: var(--color-bg); min-height: 100vh; padding: 32rpx; }
|
||||||
|
.section { background: #FFF; border-radius: var(--radius-lg); padding: 32rpx; }
|
||||||
|
.title { font-size: 36rpx; font-weight: 800; color: var(--color-text); display: block; margin-bottom: 8rpx; }
|
||||||
|
.update { font-size: 22rpx; color: var(--color-text-tertiary); display: block; margin-bottom: 32rpx; }
|
||||||
|
.h2 { font-size: 28rpx; font-weight: 700; color: var(--color-text); display: block; margin-top: 28rpx; margin-bottom: 12rpx; }
|
||||||
|
.body { font-size: 26rpx; color: var(--color-text-secondary); line-height: 1.8; white-space: pre-wrap; display: block; }
|
||||||
|
</style>
|
||||||
@@ -138,6 +138,9 @@
|
|||||||
<button class="act-download" @click="downloadResult('txt')">📥 下载为 TXT</button>
|
<button class="act-download" @click="downloadResult('txt')">📥 下载为 TXT</button>
|
||||||
<button class="act-download outline" @click="downloadResult('html')">📄 预览 HTML</button>
|
<button class="act-download outline" @click="downloadResult('html')">📄 预览 HTML</button>
|
||||||
</view>
|
</view>
|
||||||
|
<view class="disclaimer" v-if="result">
|
||||||
|
<text>⚠️ 以上内容由 AI 生成,仅供参考,请在提交前自行核实重要信息。</text>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -421,4 +424,6 @@ const goLogin = () => uni.navigateTo({ url: '/pages/login/login' })
|
|||||||
.empty-icon { font-size: 64rpx; margin-bottom: 16rpx; opacity: 0.5; }
|
.empty-icon { font-size: 64rpx; margin-bottom: 16rpx; opacity: 0.5; }
|
||||||
.empty-title { font-size: 28rpx; font-weight: 600; color: var(--color-text); }
|
.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; }
|
.empty-desc { font-size: 22rpx; color: var(--color-text-tertiary); margin-top: 8rpx; }
|
||||||
|
.disclaimer { margin-top: 16rpx; padding: 12rpx; background: #FFF8E1; border-radius: var(--radius-sm); }
|
||||||
|
.disclaimer text { font-size: 20rpx; color: #92400E; line-height: 1.6; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user