diff --git a/.workbuddy/memory/2026-06-09.md b/.workbuddy/memory/2026-06-09.md new file mode 100644 index 0000000..b092b3e --- /dev/null +++ b/.workbuddy/memory/2026-06-09.md @@ -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`:弹出"设置登录密码"引导弹窗(可跳过) diff --git a/backend/package-lock.json b/backend/package-lock.json index ef38803..44c4900 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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", diff --git a/backend/package.json b/backend/package.json index 91af44f..a4be327 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/src/modules/admin/admin.controller.ts b/backend/src/modules/admin/admin.controller.ts index 2731e6a..94c32a5 100644 --- a/backend/src/modules/admin/admin.controller.ts +++ b/backend/src/modules/admin/admin.controller.ts @@ -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, @InjectModel(Interview.name) private interviewModel: Model, + @InjectModel(PaymentOrder.name) private orderModel: Model, + @InjectModel(SiteConfig.name) private configModel: Model, + 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 } -} \ No newline at end of file + + @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。' }, +] diff --git a/backend/src/modules/admin/admin.module.ts b/backend/src/modules/admin/admin.module.ts index c95d39d..124beb5 100644 --- a/backend/src/modules/admin/admin.module.ts +++ b/backend/src/modules/admin/admin.module.ts @@ -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 {} diff --git a/backend/src/modules/payment/payment-order.schema.ts b/backend/src/modules/payment/payment-order.schema.ts new file mode 100644 index 0000000..d504b18 --- /dev/null +++ b/backend/src/modules/payment/payment-order.schema.ts @@ -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 }) diff --git a/backend/src/modules/payment/payment.controller.ts b/backend/src/modules/payment/payment.controller.ts index 84fcf90..6061e5f 100644 --- a/backend/src/modules/payment/payment.controller.ts +++ b/backend/src/modules/payment/payment.controller.ts @@ -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, + @InjectModel(PaymentOrder.name) private orderModel: Model, 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) { diff --git a/backend/src/modules/payment/payment.module.ts b/backend/src/modules/payment/payment.module.ts index 219e322..49e0bbf 100644 --- a/backend/src/modules/payment/payment.module.ts +++ b/backend/src/modules/payment/payment.module.ts @@ -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], diff --git a/backend/src/modules/payment/wechat-pay.service.ts b/backend/src/modules/payment/wechat-pay.service.ts index 678e6c9..54ac922 100644 --- a/backend/src/modules/payment/wechat-pay.service.ts +++ b/backend/src/modules/payment/wechat-pay.service.ts @@ -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') diff --git a/backend/src/modules/positions/positions.controller.ts b/backend/src/modules/positions/positions.controller.ts index 45278bc..b4b844c 100644 --- a/backend/src/modules/positions/positions.controller.ts +++ b/backend/src/modules/positions/positions.controller.ts @@ -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, + @InjectModel(User.name) private userModel: Model, + ) {} + @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 } } } diff --git a/backend/src/modules/positions/positions.module.ts b/backend/src/modules/positions/positions.module.ts index 1186fea..6c585cf 100644 --- a/backend/src/modules/positions/positions.module.ts +++ b/backend/src/modules/positions/positions.module.ts @@ -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 {} diff --git a/backend/src/modules/positions/positions.schema.ts b/backend/src/modules/positions/positions.schema.ts new file mode 100644 index 0000000..682f4f3 --- /dev/null +++ b/backend/src/modules/positions/positions.schema.ts @@ -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 }) diff --git a/backend/src/modules/schemas/site-config.schema.ts b/backend/src/modules/schemas/site-config.schema.ts new file mode 100644 index 0000000..4999e6c --- /dev/null +++ b/backend/src/modules/schemas/site-config.schema.ts @@ -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) diff --git a/backend/src/modules/user/user.controller.ts b/backend/src/modules/user/user.controller.ts index cbb8a4a..089d30e 100644 --- a/backend/src/modules/user/user.controller.ts +++ b/backend/src/modules/user/user.controller.ts @@ -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) + } } diff --git a/backend/src/modules/user/user.schema.ts b/backend/src/modules/user/user.schema.ts index 31d469a..766b29e 100644 --- a/backend/src/modules/user/user.schema.ts +++ b/backend/src/modules/user/user.schema.ts @@ -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) \ No newline at end of file diff --git a/backend/src/modules/user/user.service.ts b/backend/src/modules/user/user.service.ts index 6764fa5..a75ea15 100644 --- a/backend/src/modules/user/user.service.ts +++ b/backend/src/modules/user/user.service.ts @@ -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) diff --git a/zhiyin-app/index.html b/zhiyin-app/index.html index cf3c70b..9d1d698 100644 --- a/zhiyin-app/index.html +++ b/zhiyin-app/index.html @@ -4,11 +4,11 @@ - AI磁场 - 智能面试模拟 · 简历优化 · 求职辅导 - - - - + 职引 - AI模拟面试 | 宇之然AI磁场 · 智能面试练习 · 简历优化 · 面经题库 + + + + @@ -16,7 +16,7 @@ { "@context": "https://schema.org", "@type": "WebApplication", - "name": "AI磁场", + "name": "宇之然AI磁场", "description": "AI驱动的求职面试模拟与简历优化平台", "applicationCategory": "EducationalApplication", "operatingSystem": "Web, WeChat Mini Program" diff --git a/zhiyin-app/src/manifest.json b/zhiyin-app/src/manifest.json index b6d6b2f..51413a4 100644 --- a/zhiyin-app/src/manifest.json +++ b/zhiyin-app/src/manifest.json @@ -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" } diff --git a/zhiyin-app/src/pages.json b/zhiyin-app/src/pages.json index fac8730..19480ee 100644 --- a/zhiyin-app/src/pages.json +++ b/zhiyin-app/src/pages.json @@ -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,14 +24,14 @@ "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" } -} \ No newline at end of file +} diff --git a/zhiyin-app/src/pages/about/about.vue b/zhiyin-app/src/pages/about/about.vue index b99aeb7..0cebed7 100644 --- a/zhiyin-app/src/pages/about/about.vue +++ b/zhiyin-app/src/pages/about/about.vue @@ -6,65 +6,57 @@ 产品名称 - 职引 · AI 面试模拟 + 职引 · 宇之然AI磁场 开发团队 宇之然 - - 职引是一款 AI 驱动的面试模拟工具,帮助求职者通过模拟真实面试、简历诊断和优化,提升面试通过率。 + + 联系邮箱 + contact@yuzhiran.com + + + + + 用户协议 + + + + 隐私政策 + + + + + + ⚠️ AI生成内容免责声明 + + 本平台的模拟面试、简历诊断、简历优化等功能由人工智能模型生成,仅供参考和学习用途。AI输出的内容可能存在不准确、不完整或过时的情况,不构成任何专业建议。用户在做出重要决策前,请务必结合自身判断核实相关信息。宇之然AI磁场不对因使用AI生成内容导致的任何直接或间接损失承担责任。 + - \ No newline at end of file +.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; } + diff --git a/zhiyin-app/src/pages/admin/admin.vue b/zhiyin-app/src/pages/admin/admin.vue index 91c70b6..aa4ba33 100644 --- a/zhiyin-app/src/pages/admin/admin.vue +++ b/zhiyin-app/src/pages/admin/admin.vue @@ -17,6 +17,7 @@ 概览 用户管理 面试记录 + 订单 管理员 配置 @@ -70,6 +71,37 @@ + + + + + 全部 + 待支付 + 已支付 + 已退款 + + + + + 订单号: {{ o.outTradeNo }} + 用户: {{ o.userPhone || o.userId.slice(-6) }} + + + ¥{{ (o.amount / 100).toFixed(1) }} + + {{ o.status === 'success' ? '已支付' : o.status === 'refunded' ? '已退款' : '待支付' }} + + {{ o.createdAt ? o.createdAt.slice(0,16).replace('T',' ') : '--' }} + + 同步 + + + + 加载更多 + 暂无订单 + + 加载中... + @@ -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); } diff --git a/zhiyin-app/src/pages/agreement/agreement.vue b/zhiyin-app/src/pages/agreement/agreement.vue new file mode 100644 index 0000000..f83b18c --- /dev/null +++ b/zhiyin-app/src/pages/agreement/agreement.vue @@ -0,0 +1,44 @@ + + + diff --git a/zhiyin-app/src/pages/index/index.vue b/zhiyin-app/src/pages/index/index.vue index 7441b9c..c52ee86 100644 --- a/zhiyin-app/src/pages/index/index.vue +++ b/zhiyin-app/src/pages/index/index.vue @@ -74,19 +74,27 @@ - 热门岗位 + + 热门岗位 + + 点击直接面试 - {{ idx + 1 }} + {{ posIcons[idx] || '💼' }} {{ pos.name }} - {{ pos.company }} + + {{ pos.company || '参考公司' }} + {{ pos.salary || '参考薪资' }} + - {{ pos.salary }} + + 立即模拟 + 加载岗位中... @@ -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; } \ No newline at end of file diff --git a/zhiyin-app/src/pages/interview/interview.vue b/zhiyin-app/src/pages/interview/interview.vue index a26a5aa..6bb1c25 100644 --- a/zhiyin-app/src/pages/interview/interview.vue +++ b/zhiyin-app/src/pages/interview/interview.vue @@ -5,6 +5,10 @@ + + {{ position || 'AI面试' }} + 面试中 + @@ -43,6 +47,8 @@ + + AI 生成内容仅供参考,请核实重要信息 @@ -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); } diff --git a/zhiyin-app/src/pages/login/login.vue b/zhiyin-app/src/pages/login/login.vue index 6c91452..74d931c 100644 --- a/zhiyin-app/src/pages/login/login.vue +++ b/zhiyin-app/src/pages/login/login.vue @@ -8,105 +8,285 @@ - + - 邮箱登录 - 微信登录 + 登录 + 注册 + 微信登录 - - - 邮箱登录 + + + + + 密码登录 + 验证码登录 + + + + + 邮箱 + + + + 密码 + + + + 忘记密码?使用验证码登录 + + + + + + debug: emailSent={{emailSent}} cooldown={{cooldown}} + + 邮箱 + + + + + + + 验证码 + + + + 已有密码?使用密码登录 + + + + + + 创建账号 + 注册后享受 AI 面试模拟服务 邮箱 - + - - - 验证码 - - - - + + 密码 + - - - + + 确认密码 + + + + 已有账号?去登录 - - + + 微信一键登录 授权后自动创建账号 + + + + 登录即表示同意 + 《用户协议》 + + 《隐私政策》 + + + + + + + 设置登录密码 + 设置密码后,下次可直接用密码登录,无需等待验证码 + + + 暂不设置 + 确认设置 + diff --git a/zhiyin-app/src/pages/privacy/privacy.vue b/zhiyin-app/src/pages/privacy/privacy.vue new file mode 100644 index 0000000..debabd6 --- /dev/null +++ b/zhiyin-app/src/pages/privacy/privacy.vue @@ -0,0 +1,43 @@ + + + diff --git a/zhiyin-app/src/pages/resume/resume.vue b/zhiyin-app/src/pages/resume/resume.vue index ede0bf3..d361491 100644 --- a/zhiyin-app/src/pages/resume/resume.vue +++ b/zhiyin-app/src/pages/resume/resume.vue @@ -138,6 +138,9 @@ + + ⚠️ 以上内容由 AI 生成,仅供参考,请在提交前自行核实重要信息。 + @@ -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; }