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:
yuzhiran
2026-06-09 15:39:17 +08:00
parent 511f60d0db
commit 37cfdfe93c
27 changed files with 1045 additions and 195 deletions
+36
View File
@@ -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`:弹出"设置登录密码"引导弹窗(可跳过)
+46
View File
@@ -19,6 +19,7 @@
"@nestjs/serve-static": "^4.0.2",
"@nestjs/throttler": "^6.5.0",
"axios": "^1.16.1",
"bcrypt": "^6.0.0",
"cache-manager": "^7.2.8",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
@@ -40,6 +41,7 @@
"@nestjs/cli": "^10.3.0",
"@nestjs/schematics": "^10.1.0",
"@nestjs/testing": "^10.4.22",
"@types/bcrypt": "^6.0.0",
"@types/jest": "^30.0.0",
"@types/node": "^20.10.0",
"@types/nodemailer": "^8.0.0",
@@ -2361,6 +2363,16 @@
"@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": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz",
@@ -3399,6 +3411,20 @@
"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": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -7425,6 +7451,15 @@
"dev": true,
"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": {
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-1.11.0.tgz",
@@ -7477,6 +7512,17 @@
"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": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
+3
View File
@@ -8,6 +8,7 @@
"start:dev": "nest start --watch",
"start:prod": "node dist/main",
"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:watch": "jest --watch --forceExit",
"test:cov": "jest --coverage --forceExit"
@@ -44,6 +45,7 @@
"@nestjs/serve-static": "^4.0.2",
"@nestjs/throttler": "^6.5.0",
"axios": "^1.16.1",
"bcrypt": "^6.0.0",
"cache-manager": "^7.2.8",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
@@ -65,6 +67,7 @@
"@nestjs/cli": "^10.3.0",
"@nestjs/schematics": "^10.1.0",
"@nestjs/testing": "^10.4.22",
"@types/bcrypt": "^6.0.0",
"@types/jest": "^30.0.0",
"@types/node": "^20.10.0",
"@types/nodemailer": "^8.0.0",
+113 -16
View File
@@ -6,12 +6,20 @@ import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'
import { CurrentUser } from '../../common/decorators/current-user.decorator'
import { User, UserDocument } from '../user/user.schema'
import { Interview, InterviewDocument } from '../interview/interview.schema'
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')
export class AdminController {
constructor(
@InjectModel(User.name) private userModel: Model<UserDocument>,
@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')
@@ -108,26 +116,115 @@ export class AdminController {
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)
@Get('config')
async getConfig(@CurrentUser('userId') adminUserId: string) {
const admin = await this.userModel.findById(adminUserId).exec()
if (admin?.role !== 'admin') throw new HttpException('无权限', HttpStatus.FORBIDDEN)
return {
interview: {
maxRoundsFree: 5,
maxRoundsVip: 10,
dailyFreeLimit: 3,
},
diagnosis: {
dailyFreeLimit: 2,
},
optimize: {
dailyFreeLimit: 2,
},
price: {
monthly: 2900, // 分
},
}
// 从数据库读,不存在则返回默认值
const cfg = await this.configModel.findOne({ key: 'site_config' }).exec()
if (cfg) return cfg.value
return DEFAULT_CONFIG
}
@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。' },
]
+11 -2
View File
@@ -3,12 +3,21 @@ import { MongooseModule } from '@nestjs/mongoose'
import { AdminController } from './admin.controller'
import { User, UserSchema } from '../user/user.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({
imports: [
MongooseModule.forFeature([{ name: User.name, schema: UserSchema }]),
MongooseModule.forFeature([{ name: Interview.name, schema: InterviewSchema }]),
MongooseModule.forFeature([
{ name: User.name, schema: UserSchema },
{ name: Interview.name, schema: InterviewSchema },
{ name: PaymentOrder.name, schema: PaymentOrderSchema },
{ name: SiteConfig.name, schema: SiteConfigSchema },
]),
],
controllers: [AdminController],
providers: [WechatPayService],
})
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 { Model } from 'mongoose'
import { User, UserDocument } from '../user/user.schema'
import { PaymentOrder, PaymentOrderDocument } from './payment-order.schema'
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'
import { CurrentUser } from '../../common/decorators/current-user.decorator'
import { WechatPayService } from './wechat-pay.service'
import { Public } from '../../common/decorators/public.decorator'
const VIP_AMOUNT = 2900 // 29 元(分)
const VIP_AMOUNT = 1990 // 19.9 元(分)
const VIP_DURATION_DAYS = 30
@Controller('payment')
export class PaymentController {
constructor(
@InjectModel(User.name) private userModel: Model<UserDocument>,
@InjectModel(PaymentOrder.name) private orderModel: Model<PaymentOrderDocument>,
private wechatPay: WechatPayService,
) {}
@@ -26,20 +28,28 @@ export class PaymentController {
if (user.plan === 'vip') throw new HttpException('已是会员', HttpStatus.BAD_REQUEST)
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,
VIP_AMOUNT,
)
return {
outTradeNo: result.outTradeNo,
codeUrl: result.codeUrl, // 二维码链接
userId,
userPhone: user.phone || '',
amount: VIP_AMOUNT,
title: '职引月度会员',
title: 'AI磁场月度会员',
status: 'pending',
channel: 'native',
})
return {
outTradeNo,
codeUrl: result.codeUrl,
amount: VIP_AMOUNT,
title: 'AI磁场月度会员',
}
}
/** JSAPI 支付(微信小程序/公众号内使用 */
/** JSAPI 支付(微信小程序) */
@UseGuards(JwtAuthGuard)
@Post('jsapi')
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)
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
}
/** 支付回调通知 */
@Public()
@Post('notify')
async notify(@Body() body: any, @Body('headers') headers: any) {
// 实际运行时从 request 读取 header
async notify(@Body() body: any) {
try {
const decrypted = this.wechatPay.verifyAndDecrypt(body, '', '', '')
if (!decrypted) return { code: 'FAIL', message: '验签失败' }
// 处理成功支付
const outTradeNo = decrypted.out_trade_no
const userId = outTradeNo.slice(-6) // 从订单号取 userId
const 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()
if (user && user.plan !== 'vip') {
const expireAt = new Date()
@@ -79,7 +113,7 @@ export class PaymentController {
}
}
/** 查询订单 */
/** 查询订单(微信侧) */
@UseGuards(JwtAuthGuard)
@Post('query')
async query(@Body('outTradeNo') outTradeNo: string) {
@@ -4,8 +4,13 @@ import { PaymentController } from './payment.controller'
import { WechatPayService } from './wechat-pay.service'
import { User, UserSchema } from '../user/user.schema'
import { PaymentOrder, PaymentOrderSchema } from './payment-order.schema'
@Module({
imports: [MongooseModule.forFeature([{ name: User.name, schema: UserSchema }])],
imports: [MongooseModule.forFeature([
{ name: User.name, schema: UserSchema },
{ name: PaymentOrder.name, schema: PaymentOrderSchema },
])],
controllers: [PaymentController],
providers: [WechatPayService],
exports: [WechatPayService],
@@ -135,10 +135,11 @@ export class WechatPayService {
const associatedData = resource.associated_data || ''
const nonce = resource.nonce
const key = API_V3_KEY
if (!key) throw new Error('WX_API_V3_KEY 未配置')
// AES-256-GCM 解密
const authTag = ciphertext.subarray(ciphertext.length - 16)
const data = ciphertext.subarray(0, ciphertext.length - 16)
const decipher = crypto.createDecipheriv('aes-256-gcm', key, nonce)
const decipher = crypto.createDecipheriv('aes-256-gcm', Buffer.from(key), nonce)
decipher.setAAD(Buffer.from(associatedData))
decipher.setAuthTag(authTag)
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 { CurrentUser } from '../../common/decorators/current-user.decorator'
import { User, UserDocument } from '../user/user.schema'
@Controller('positions')
export class PositionsController {
constructor(
@InjectModel(HotPosition.name) private positionModel: Model<HotPositionDocument>,
@InjectModel(User.name) private userModel: Model<UserDocument>,
) {}
@Public()
@Get('hot')
hot() {
return [
{ name: '前端工程师', salary: '15-25K', company: '腾讯' },
{ name: '后端工程师', salary: '18-30K', company: '阿里巴巴' },
{ name: 'AI 算法工程师', salary: '20-35K', company: '字节跳动' },
{ name: '产品经理', salary: '12-20K', company: '美团' },
{ name: 'UI 设计师', salary: '10-18K', company: '网易' },
]
async hot() {
return this.positionModel.find({ active: true }).sort({ sort: 1 }).lean().exec()
}
// ─── 管理后台 CRUD ──────────────────────
@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 { MongooseModule } from '@nestjs/mongoose'
import { PositionsController } from './positions.controller'
import { HotPosition, HotPositionSchema } from './positions.schema'
import { User, UserSchema } from '../user/user.schema'
@Module({
imports: [
MongooseModule.forFeature([
{ name: HotPosition.name, schema: HotPositionSchema },
{ name: User.name, schema: UserSchema },
]),
],
controllers: [PositionsController],
})
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)
}
// 密码登录
@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()
@Post('wx-login')
@@ -53,4 +67,9 @@ export class UserController {
async getUsage(@CurrentUser('userId') userId: string) {
return this.userService.getUsage(userId)
}
@Post('set-password')
async setPassword(@CurrentUser('userId') userId: string, @Body('password') password: string) {
return this.userService.setPassword(userId, password)
}
}
+6 -3
View File
@@ -5,10 +5,10 @@ export type UserDocument = User & Document
@Schema({ timestamps: true })
export class User {
@Prop({ unique: true, sparse: true })
@Prop({ sparse: true })
phone?: string
@Prop({ unique: true, sparse: true })
@Prop({ sparse: true })
wxOpenid?: string
@Prop({ default: '' })
@@ -35,8 +35,11 @@ export class User {
@Prop({ default: false })
isSystemAdmin: boolean
@Prop({ unique: true, sparse: true })
@Prop({ sparse: true })
email?: string
@Prop({ default: '' })
password?: string
}
export const UserSchema = SchemaFactory.createForClass(User)
+48 -1
View File
@@ -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 { Model } from 'mongoose'
import { JwtService } from '@nestjs/jwt'
@@ -104,13 +105,59 @@ export class UserService {
// 按邮箱查找或创建用户
let user = await this.userModel.findOne({ email }).exec()
let isNew = false
if (!user) {
isNew = true
const nick = email.split('@')[0]
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)
}
// 📝 邮箱+密码注册
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) {
const user = await this.userModel.findById(userId).exec()
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
+6 -6
View File
@@ -4,11 +4,11 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no" />
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
<title>AI磁场 - 智能面试模拟 · 简历优化 · 求职辅导</title>
<meta name="description" content="AI磁场提供AI模拟面试、简历优化、实习推荐等求职服务,帮助你在真实面试前充分准备,提升求职成功率。" />
<meta name="keywords" content="AI面试,模拟面试,简历优化,求职辅导,面试练习,AI磁场" />
<meta property="og:title" content="AI磁场 - 智能面试模拟平台" />
<meta property="og:description" content="AI驱动的一站式求职准备平台,涵盖模拟面试、简历优化、岗位推荐等核心功能。" />
<title>职引 - AI模拟面试 | 宇之然AI磁场 · 智能面试练习 · 简历优化 · 面经题库</title>
<meta name="description" content="职引是宇之然AI磁场旗下的AI模拟面试平台,提供AI面试官模拟练习、简历智能诊断优化、大厂面经题库、实习推荐等一站式求职服务,帮助你在真实面试前充分准备。" />
<meta name="keywords" content="职引,宇之然AI磁场,AI面试,模拟面试,面试练习,简历优化,求职辅导,AI模拟面试官,面试题库,面经,校招面试,实习面试,简历诊断,面试技巧" />
<meta property="og:title" content="职引 - AI模拟面试平台 | 宇之然AI磁场" />
<meta property="og:description" content="AI驱动的一站式求职准备平台,涵盖AI模拟面试、简历优化、面经分享、实习推荐等核心功能。" />
<meta property="og:type" content="website" />
<meta name="applicable-device" content="mobile" />
<link rel="canonical" href="https://aicc.yzrcloud.cn" />
@@ -16,7 +16,7 @@
{
"@context": "https://schema.org",
"@type": "WebApplication",
"name": "AI磁场",
"name": "宇之然AI磁场",
"description": "AI驱动的求职面试模拟与简历优化平台",
"applicationCategory": "EducationalApplication",
"operatingSystem": "Web, WeChat Mini Program"
+2 -2
View File
@@ -3,9 +3,9 @@
"appid": "__UNI__DEV__",
"versionName": "1.0.0",
"versionCode": "100",
"description": "AI 面试模拟 - 先模拟,再面试",
"description": "职引 - 宇之然AI磁场旗下AI模拟面试平台,提供AI面试官模拟练习、简历智能优化、大厂面经题库,助你轻松应对校招面试",
"h5": {
"title": "AI磁场",
"title": "职引 - AI模拟面试 | 宇之然AI磁场",
"router": {
"mode": "hash"
}
+10 -8
View File
@@ -1,19 +1,21 @@
{
"pages": [
{ "path": "pages/index/index", "style": { "navigationBarTitleText": "职引 - 先模拟,再上场" } },
{ "path": "pages/interview/interview", "style": { "navigationBarTitleText": "模拟面试" } },
{ "path": "pages/index/index", "style": { "navigationBarTitleText": "职引 - AI模拟面试" } },
{ "path": "pages/interview/interview", "style": { "navigationBarTitleText": "AI模拟面试" } },
{ "path": "pages/report/report", "style": { "navigationBarTitleText": "面试报告" } },
{ "path": "pages/member/member", "style": { "navigationBarTitleText": "会员中心" } },
{ "path": "pages/progress/progress", "style": { "navigationBarTitleText": "进步轨迹" } },
{ "path": "pages/contribute/contribute", "style": { "navigationBarTitleText": "贡献面经" } },
{ "path": "pages/contribute/contribute", "style": { "navigationBarTitleText": "面经分享" } },
{ "path": "pages/login/login", "style": { "navigationBarTitleText": "登录" } },
{ "path": "pages/history/history", "style": { "navigationBarTitleText": "面试记录" } },
{ "path": "pages/user/user", "style": { "navigationBarTitleText": "我的" } },
{ "path": "pages/resume/resume", "style": { "navigationBarTitleText": "我的简历" } },
{ "path": "pages/resume/resume", "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/result/result", "style": { "navigationBarTitleText": "优化结果" } }
{ "path": "pages/result/result", "style": { "navigationBarTitleText": "优化结果" } },
{ "path": "pages/agreement/agreement", "style": { "navigationBarTitleText": "用户协议" } },
{ "path": "pages/privacy/privacy", "style": { "navigationBarTitleText": "隐私政策" } }
],
"tabBar": {
"color": "#999999",
@@ -22,13 +24,13 @@
"borderStyle": "black",
"list": [
{ "pagePath": "pages/index/index", "text": "面试", "iconPath": "static/tabbar/home.png", "selectedIconPath": "static/tabbar/home-active.png" },
{ "pagePath": "pages/history/history", "text": "记录", "iconPath": "static/tabbar/history.png", "selectedIconPath": "static/tabbar/history-active.png" },
{ "pagePath": "pages/history/history", "text": "面经", "iconPath": "static/tabbar/history.png", "selectedIconPath": "static/tabbar/history-active.png" },
{ "pagePath": "pages/user/user", "text": "我的", "iconPath": "static/tabbar/user.png", "selectedIconPath": "static/tabbar/user-active.png" }
]
},
"globalStyle": {
"navigationBarTextStyle": "black",
"navigationBarTitleText": "职引",
"navigationBarTitleText": "职引 - AI模拟面试",
"navigationBarBackgroundColor": "#ffffff",
"backgroundColor": "#f5f6f7"
}
+41 -49
View File
@@ -6,65 +6,57 @@
</view>
<view class="info-section">
<text class="info-label">产品名称</text>
<text class="info-value">职引 · AI 面试模拟</text>
<text class="info-value">职引 · 宇之然AI磁场</text>
</view>
<view class="info-section">
<text class="info-label">开发团队</text>
<text class="info-value">宇之然</text>
</view>
<view class="desc">
<text>职引是一款 AI 驱动的面试模拟工具帮助求职者通过模拟真实面试简历诊断和优化提升面试通过率</text>
<view class="info-section">
<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>
</template>
<script setup lang="ts">
<script setup>
const goAgreement = () => uni.navigateTo({ url: '/pages/agreement/agreement' })
const goPrivacy = () => uni.navigateTo({ url: '/pages/privacy/privacy' })
</script>
<style scoped>
.page {
background: #f5f6f7;
padding: 60rpx 30rpx;
}
.logo-area {
text-align: center;
padding: 80rpx 0;
}
.logo {
font-size: 48rpx;
font-weight: 700;
background: linear-gradient(135deg, #4F46E5, #7C3AED);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
display: block;
margin-bottom: 16rpx;
}
.version {
font-size: 24rpx;
color: #999;
}
.info-section {
background: #fff;
padding: 24rpx 30rpx;
border-radius: 16rpx;
margin-bottom: 16rpx;
display: flex;
justify-content: space-between;
}
.info-label {
font-size: 26rpx;
color: #999;
}
.info-value {
font-size: 26rpx;
color: #333;
}
.desc {
margin-top: 40rpx;
font-size: 24rpx;
color: #999;
line-height: 1.8;
text-align: center;
}
.page { background: var(--color-bg); min-height: 100vh; padding: 60rpx 32rpx; }
.logo-area { text-align: center; padding: 60rpx 0 40rpx; }
.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; }
.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; }
.info-label { font-size: 26rpx; color: var(--color-text-secondary); }
.info-value { font-size: 26rpx; color: var(--color-text); font-weight: 500; }
.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); }
.link-item:last-child { border-bottom: none; }
.link-item:active { background: var(--color-bg); }
.link-text { font-size: 26rpx; color: var(--color-text); }
.link-arrow { font-size: 32rpx; color: var(--color-text-tertiary); }
.disclaimer { margin-top: 40rpx; background: #FFF8E1; border-radius: var(--radius-md); padding: 24rpx; }
.disclaimer-title { font-size: 24rpx; font-weight: 700; color: #F59E0B; display: block; margin-bottom: 12rpx; }
.disclaimer-text { font-size: 22rpx; color: var(--color-text-secondary); line-height: 1.8; }
</style>
+85
View File
@@ -17,6 +17,7 @@
<text class="tab" :class="{ active: tab === 'overview' }" @click="switchTab('overview')">概览</text>
<text class="tab" :class="{ active: tab === 'users' }" @click="switchTab('users')">用户管理</text>
<text class="tab" :class="{ active: tab === 'interviews' }" @click="switchTab('interviews')">面试记录</text>
<text class="tab" :class="{ active: tab === 'orders' }" @click="switchTab('orders')">订单</text>
<text class="tab" :class="{ active: tab === 'admins' }" @click="switchTab('admins')">管理员</text>
<text class="tab" :class="{ active: tab === 'config' }" @click="switchTab('config')">配置</text>
</view>
@@ -70,6 +71,37 @@
</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 class="config-card" v-if="!cfgLoading">
@@ -140,6 +172,11 @@ const adminList = ref([])
const searchResult = ref(null)
const memberConfig = ref({ interview: { maxRoundsFree: 5, maxRoundsVip: 10, dailyFreeLimit: 3 }, diagnosis: { dailyFreeLimit: 2 }, optimize: { dailyFreeLimit: 2 }, price: { monthly: 2900 } })
const cfgLoading = ref(false)
const orders = ref([])
const ordersTotal = ref(0)
const ordersPage = ref(1)
const orderLoading = ref(false)
const orderFilter = ref('')
const token = () => uni.getStorageSync('token') || ''
@@ -182,6 +219,7 @@ const switchTab = (t) => {
if (t === 'interviews' && interviews.value.length === 0) loadInterviews()
if (t === 'admins' && adminList.value.length === 0) loadAdmins()
if (t === 'config') loadConfig()
if (t === 'orders') loadOrders()
}
const loadUsers = async () => {
@@ -220,6 +258,39 @@ const loadConfig = async () => {
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 () => {
try {
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-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; }
.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; }
.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); }
@@ -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>
+25 -10
View File
@@ -74,19 +74,27 @@
<!-- 热门岗位 -->
<view class="section">
<view class="section-header">
<text class="section-title">热门岗位</text>
<view class="section-title-row">
<text class="section-title">热门岗位</text>
<text class="section-tag-demo">参考示例</text>
</view>
<text class="section-desc">点击直接面试</text>
</view>
<view class="position-list card" v-if="!positionsLoading">
<view class="pos-item" v-for="(pos, idx) in hotPositions" :key="idx" @click="startInterview(pos)">
<view class="pos-left">
<view class="pos-rank">{{ idx + 1 }}</view>
<text class="pos-icon">{{ posIcons[idx] || '💼' }}</text>
<view class="pos-body">
<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>
<text class="pos-salary">{{ pos.salary }}</text>
<view class="pos-action">
<text class="pos-action-text">立即模拟</text>
</view>
</view>
</view>
<view class="loading-tip" v-if="positionsLoading">加载岗位中...</view>
@@ -103,6 +111,7 @@ import { api } from '../../config'
const userInfo = ref(null)
const greeting = ref('')
const hotPositions = ref([])
const posIcons = ['💻', '⚙️', '🤖', '📊', '🎨', '🧪', '📱', '🔧']
const positionsLoading = ref(true)
const dailyQuestion = ref(null)
const showAnswer = ref(false)
@@ -175,8 +184,10 @@ const startInterview = (pos) => uni.navigateTo({ url: `/pages/interview/intervie
.section { padding: 32rpx 32rpx 0; }
.section:first-of-type { margin-top: -40rpx; padding-top: 0; }
.section-header { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 20rpx; }
.section-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-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); }
.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; }
.pos-item { padding: 24rpx 28rpx; border-bottom: 1rpx solid var(--color-border); display: flex; justify-content: space-between; align-items: center; }
.pos-item:last-child { border-bottom: none; }
.pos-left { display: flex; align-items: center; gap: 16rpx; }
.pos-rank { width: 40rpx; height: 40rpx; border-radius: 10rpx; background: #F3F4F6; display: flex; align-items: center; justify-content: center; font-size: 22rpx; font-weight: 700; color: var(--color-primary); flex-shrink: 0; }
.pos-body { display: flex; flex-direction: column; }
.pos-item:active { background: var(--color-bg); }
.pos-left { display: flex; align-items: center; gap: 16rpx; flex: 1; min-width: 0; }
.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-company { font-size: 22rpx; color: var(--color-text-tertiary); margin-top: 4rpx; }
.pos-salary { font-size: 24rpx; color: var(--color-primary); font-weight: 600; }
.pos-meta-row { display: flex; align-items: center; gap: 10rpx; margin-top: 4rpx; }
.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); }
.bottom-spacer { height: 40rpx; }
</style>
+18 -4
View File
@@ -5,6 +5,10 @@
<view class="topbar-inner">
<view class="back-btn" @click="confirmExit"><text class="back-arrow"></text></view>
<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-fill" :style="{ width: progressPercent + '%' }"></view>
</view>
@@ -43,6 +47,8 @@
<text class="send-icon"></text>
</view>
</view>
<!-- AI 免责提示 -->
<view class="disclaimer-bar" v-if="!isComplete">AI 生成内容仅供参考请核实重要信息</view>
<!-- Complete -->
<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 { api } from '../../config'
const messages = ref([{ role: 'ai', content: '你好!我是 AI 面试官,准备好就开始吧' }])
const messages = ref([{ role: 'ai', content: '你好!我是 AI 面试官,请选择岗位开始模拟面试' }])
const inputText = ref('')
const aiLoading = ref(false)
const interviewId = ref('')
const answeredCount = ref(0)
const isComplete = ref(false)
const scrollToId = ref('')
const position = ref('通用岗位')
const position = ref('')
let timerSeconds = 0
let timerInterval = null
@@ -75,7 +81,11 @@ const formatTime = computed(() => {
const token = computed(() => uni.getStorageSync('token') || '')
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() })
@@ -151,9 +161,12 @@ const confirmExit = () => {
}
.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-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-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; }
/* ===== Chat ===== */
@@ -207,4 +220,5 @@ const confirmExit = () => {
/* Complete */
.complete-bar { background: #FFFFFF; padding: 20rpx 24rpx; padding-bottom: calc(20rpx + var(--safe-bottom)); }
.cta-btn { width: 100%; height: 88rpx; line-height: 88rpx; background: linear-gradient(135deg, var(--color-gradient-start), var(--color-gradient-mid)); color: #FFFFFF; border-radius: var(--radius-md); font-size: 28rpx; font-weight: 600; border: none; }
.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>
+269 -63
View File
@@ -8,105 +8,285 @@
</view>
<view class="form-section">
<!-- 登录方式切换 -->
<!-- Tab登录 / 注册 / 微信 -->
<view class="tab-bar">
<text class="tab" :class="{ active: mode === 'email' }" @click="mode='email'">邮箱登录</text>
<text class="tab" :class="{ active: mode === 'wechat' }" @click="mode='wechat'" v-if="isMp">微信登录</text>
<text class="tab" :class="{ active: mainTab === 'login' }" @click="mainTab='login'">登录</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 class="card" v-if="mode === 'email'">
<text class="card-title">邮箱登录</text>
<!-- ========== 登录 ========== -->
<view class="card" v-if="mainTab === 'login'">
<!-- 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">
<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="请输入密码" @confirm="doPasswordLogin" />
</view>
<button class="login-btn" :disabled="!canPasswordLogin || pwdLoading" @click="doPasswordLogin">
{{ pwdLoading ? '登录中...' : '登录' }}
</button>
<view class="switch-hint" @click="loginMode='code'">忘记密码使用验证码登录</view>
</view>
<!-- 验证码登录 -->
<view v-else>
<!-- 调试信息发布前删掉 -->
<view class="debug-info" v-if="true">debug: emailSent={{emailSent}} cooldown={{cooldown}}</view>
<view class="field">
<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>
</view>
</view>
<view class="field" v-if="emailSent">
<text class="field-label">验证码</text>
<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 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="请输入邮箱" @confirm="sendEmailCode" />
<input class="input" type="text" v-model="email" placeholder="请输入邮箱" />
</view>
<view class="field" v-if="emailSent">
<text class="field-label">验证码</text>
<view class="code-row">
<input class="input code-input" type="number" maxlength="6" v-model="emailCode" placeholder="6位验证码" @confirm="doEmailLogin" />
<button class="code-btn" :disabled="cooldown > 0" @click="sendEmailCode">
{{ cooldown > 0 ? cooldown + 's' : '获取验证码' }}
</button>
</view>
<view class="field">
<text class="field-label">密码</text>
<input class="input" type="password" v-model="password" placeholder="至少6位密码" />
</view>
<button class="login-btn" v-if="!emailSent" @click="sendEmailCode">{{ emailSending ? '发送中...' : '获取验证码' }}</button>
<button class="login-btn" v-else :disabled="!emailCode" @click="doEmailLogin">{{ emailLoading ? '登录中...' : '登录' }}</button>
<view 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="mode === 'wechat' && isMp">
<!-- ========== 微信一键登录 ========== -->
<view class="card" v-if="mainTab === 'wechat' && isMp">
<text class="card-title">微信一键登录</text>
<text class="card-sub">授权后自动创建账号</text>
<button class="login-btn wx-btn" @click="doWxLogin">{{ wxLoading ? '登录中...' : '微信一键登录' }}</button>
</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>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { api } from '../../config'
const mode = ref('email')
const mainTab = ref('login')
const loginMode = ref('password') // 'password' | 'code'
const isMp = ref(false)
const email = ref('')
const password = ref('')
const confirmPassword = ref('')
const emailCode = ref('')
const emailSent = ref(false)
const emailSending = ref(false)
const emailLoading = ref(false)
const pwdLoading = ref(false)
const regLoading = ref(false)
const wxLoading = ref(false)
const cooldown = ref(0)
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(() => {
// #ifdef MP-WEIXIN
isMp.value = true
mode.value = 'wechat'
mainTab.value = 'wechat'
// #endif
})
// 邮箱验证码
const sendEmailCode = async () => {
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!re.test(email.value)) { uni.showToast({ title: '请输入正确的邮箱', icon: 'none' }); return }
emailSending.value = true
onBeforeUnmount(() => { if (timer) clearInterval(timer) })
// 辅助
const showToast = (title, icon = 'none') => uni.showToast({ title, icon })
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 {
const res = await uni.request({ url: api('/user/send-email-code'), method: 'POST', data: { email: email.value } })
if (res.statusCode === 200) {
emailSent.value = true
uni.showToast({ title: '验证码已发送', icon: 'success' })
startCooldown()
} else { uni.showToast({ title: res.data?.message || '发送失败', icon: 'none' }) }
} catch { uni.showToast({ title: '网络错误', icon: 'none' }) }
finally { emailSending.value = false }
const 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) {
emailSent.value = true
console.log('[sendEmailCode] emailSent 设为 true')
showToast('验证码已发送', 'success')
startCooldown()
} else {
const msg = (res.data && res.data.message) || '发送失败'
showToast(msg)
}
},
fail: (err) => {
console.error('[sendEmailCode] fail:', err)
showToast('网络错误')
}
})
}
const startCooldown = () => {
cooldown.value = 60
if (timer) clearInterval(timer)
timer = setInterval(() => { if (--cooldown.value <= 0) { clearInterval(timer); timer = null } }, 1000)
if (timer) clearTimeout(timer)
const tick = () => {
cooldown.value--
if (cooldown.value <= 0) {
timer = null
return
}
timer = setTimeout(tick, 1000)
}
timer = setTimeout(tick, 1000)
}
// 邮箱登录
const doEmailLogin = async () => {
if (!emailCode.value) return
emailLoading.value = true
try {
const res = await uni.request({ url: api('/user/email-login'), method: 'POST', data: { email: email.value, code: emailCode.value } })
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) {
uni.setStorageSync('token', res.data.token)
if (res.data.user) uni.setStorageSync('userInfo', JSON.stringify(res.data.user))
uni.showToast({ title: '登录成功', icon: 'success' })
setTimeout(() => uni.navigateBack(), 500)
} else { uni.showToast({ title: res.data?.message || '登录失败', icon: 'none' }) }
} catch { uni.showToast({ title: '登录失败', icon: 'none' }) }
loginSuccess(res.data)
// 新用户(isNew)且没有密码 → 引导设置密码
if (res.data.isNew || !res.data.hasPassword) {
setTimeout(() => { showSetPwd.value = true; newPassword.value = '' }, 800)
}
} else {
showToast(res.data?.message || '登录失败')
}
} catch { showToast('网络错误') }
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 () => {
// #ifdef MP-WEIXIN
wxLoading.value = true
@@ -114,17 +294,16 @@ const doWxLogin = async () => {
const { code } = await uni.login()
const res = await uni.request({ url: api('/user/wx-login'), method: 'POST', data: { code } })
if (res.statusCode === 200 && res.data?.token) {
uni.setStorageSync('token', res.data.token)
if (res.data.user) uni.setStorageSync('userInfo', JSON.stringify(res.data.user))
uni.showToast({ title: '登录成功', icon: 'success' })
setTimeout(() => uni.navigateBack(), 500)
} else { uni.showToast({ title: '微信登录失败', icon: 'none' }) }
} catch { uni.showToast({ title: '微信登录失败', icon: 'none' }) }
loginSuccess(res.data)
} else { showToast('微信登录失败') }
} catch { showToast('微信登录失败') }
finally { wxLoading.value = false }
// #endif
}
const wxLoading = ref(false)
// ====== 法律页面 ======
const goAgreement = () => uni.navigateTo({ url: '/pages/agreement/agreement' })
const goPrivacy = () => uni.navigateTo({ url: '/pages/privacy/privacy' })
</script>
<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; }
.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 { 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; }
/* 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-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; }
/* ===== Fields ===== */
.field { margin-bottom: 20rpx; }
.field-label { font-size: 22rpx; color: var(--color-text-secondary); margin-bottom: 8rpx; display: block; }
.input { width: 100%; height: 72rpx; background: #F9FAFB; border: 2rpx solid var(--color-border); border-radius: var(--radius-sm); padding: 0 20rpx; font-size: 26rpx; box-sizing: border-box; }
.code-row { display: flex; gap: 12rpx; align-items: center; }
.code-input { flex: 1; }
.code-btn { height: 72rpx; padding: 0 24rpx; background: #F3F4F6; border: 2rpx solid var(--color-border); border-radius: var(--radius-sm); font-size: 24rpx; color: var(--color-primary); white-space: nowrap; flex-shrink: 0; }
.code-btn:disabled { color: var(--color-text-tertiary); }
.inline-row { display: flex; gap: 12rpx; align-items: center; }
.inline-input { flex: 1; }
.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 { 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:disabled { opacity: 0.5; }
.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>
+43
View File
@@ -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>
+5
View File
@@ -138,6 +138,9 @@
<button class="act-download" @click="downloadResult('txt')">📥 下载为 TXT</button>
<button class="act-download outline" @click="downloadResult('html')">📄 预览 HTML</button>
</view>
<view class="disclaimer" v-if="result">
<text> 以上内容由 AI 生成仅供参考请在提交前自行核实重要信息</text>
</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-title { font-size: 28rpx; font-weight: 600; color: var(--color-text); }
.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>