feat: realistic face avatar + voice input + ASR endpoint
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
## 开发交付流程
|
||||
|
||||
每次实施/开发完功能后,必须按以下步骤执行:
|
||||
|
||||
### Step 1: 代码评审
|
||||
- 检查变更是否符合现有代码模式(schema、service、controller、module 的结构一致性)
|
||||
- 检查是否有未使用的变量、import、参数
|
||||
- 检查命名规范是否与项目一致
|
||||
- 检查是否有重复代码或可复用逻辑
|
||||
|
||||
### Step 2: 安全评审
|
||||
- **注入防护**:检查所有外部输入(请求 body、query、params)是否有注入风险(HTML、Shell、NoSQL)
|
||||
- **认证/授权**:检查端点是否正确地加了 JWT guard 或 `@Public()`;管理端接口是否有 AdminGuard
|
||||
- **并发安全**:检查所有修改用户额度/积分的操作是否使用原子操作(`findOneAndUpdate + $inc`),而非 read-modify-write
|
||||
- **敏感信息**:检查是否误将密钥、凭据、token 暴露到响应或日志中
|
||||
- **XSS/CSRF**:检查用户内容输出到 HTML/PDF 时是否做了转义
|
||||
|
||||
### Step 3: 性能优化
|
||||
- 检查是否有 N+1 查询(循环内查数据库),应使用批量查询或聚合
|
||||
- 检查大表查询是否有索引覆盖
|
||||
- 检查是否有不必要的 `.lean()` 缺失(读操作用 `exec()` 但不需要 mongoose document 方法时应加 `.lean()`)
|
||||
- 检查是否有内存泄漏风险(如 puppeteer browser 未在 finally 中 close)
|
||||
- Throttler/限流是否合理
|
||||
|
||||
### Step 4: 完整测试
|
||||
```bash
|
||||
# 构建检查
|
||||
cd /root/opencode-workspace/zhiyin/backend && npx nest build 2>&1
|
||||
|
||||
# 单元测试
|
||||
npm test -- --forceExit --detectOpenHandles 2>&1
|
||||
|
||||
# 如果有变更的模块,验证关键 endpoint curl 可访问
|
||||
```
|
||||
|
||||
### Step 5: 同步修复
|
||||
- 上述步骤发现的问题必须修复后再发布
|
||||
- 修复后重新执行 Step 4 验证构建通过
|
||||
|
||||
### Step 6: 部署到生产
|
||||
```bash
|
||||
# 构建
|
||||
cd /root/opencode-workspace/zhiyin/backend && npx nest build
|
||||
|
||||
# 同步到生产目录
|
||||
cp -rf dist/* /www/wwwroot/server/yzr-yhl/backend/dist/
|
||||
|
||||
# 复制证书(postbuild 替代)
|
||||
cp -r certs /www/wwwroot/server/yzr-yhl/backend/dist/src/certs
|
||||
|
||||
# 重启
|
||||
pm2 restart yhl-backend
|
||||
|
||||
# 验证
|
||||
sleep 3 && curl -s http://localhost:3002/api/share/visit/test?visitorId=v
|
||||
```
|
||||
@@ -25,6 +25,7 @@ import { DailyQuestionModule } from './modules/daily-question/daily-question.mod
|
||||
import { ScheduleModule } from './modules/schedule/schedule.module'
|
||||
import { TtsModule } from './modules/tts/tts.module'
|
||||
import { PricingModule } from './modules/schemas/pricing.module'
|
||||
import { ShareModule } from './modules/share/share.module'
|
||||
|
||||
const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/zhiyin'
|
||||
|
||||
@@ -38,7 +39,7 @@ const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/zhiyin
|
||||
}),
|
||||
ThrottlerModule.forRoot([{
|
||||
ttl: 60000,
|
||||
limit: 10,
|
||||
limit: 100,
|
||||
}]),
|
||||
NestScheduleModule.forRoot(),
|
||||
UserModule,
|
||||
@@ -58,6 +59,7 @@ const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/zhiyin
|
||||
ScheduleModule,
|
||||
TtsModule,
|
||||
PricingModule,
|
||||
ShareModule,
|
||||
],
|
||||
providers: [
|
||||
JwtStrategy,
|
||||
|
||||
@@ -6,9 +6,12 @@ import { AdminGuard } from '../../common/guards/admin.guard'
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator'
|
||||
import { User, UserDocument } from '../user/user.schema'
|
||||
import { Interview, InterviewDocument } from '../interview/interview.schema'
|
||||
import { Resume, ResumeDocument } from '../resume/resume.schema'
|
||||
import { PaymentOrder, PaymentOrderDocument } from '../payment/payment-order.schema'
|
||||
import { SiteConfig, SiteConfigDocument } from '../schemas/site-config.schema'
|
||||
import { ShareRecord, ShareRecordDocument, ShareVisit, ShareVisitDocument } from '../share/share.schema'
|
||||
import { QuotaService } from '../user/quota.service'
|
||||
import { PricingService } from '../schemas/pricing.service'
|
||||
import { WechatPayService } from '../payment/wechat-pay.service'
|
||||
|
||||
const VIP_DURATION_DAYS = 30
|
||||
@@ -21,7 +24,11 @@ export class AdminController {
|
||||
@InjectModel(Interview.name) private interviewModel: Model<InterviewDocument>,
|
||||
@InjectModel(PaymentOrder.name) private orderModel: Model<PaymentOrderDocument>,
|
||||
@InjectModel(SiteConfig.name) private configModel: Model<SiteConfigDocument>,
|
||||
@InjectModel(ShareRecord.name) private shareModel: Model<ShareRecordDocument>,
|
||||
@InjectModel(ShareVisit.name) private shareVisitModel: Model<ShareVisitDocument>,
|
||||
@InjectModel(Resume.name) private resumeModel: Model<ResumeDocument>,
|
||||
private quotaService: QuotaService,
|
||||
private pricingService: PricingService,
|
||||
private wechatPay: WechatPayService,
|
||||
) {}
|
||||
|
||||
@@ -38,13 +45,28 @@ export class AdminController {
|
||||
|
||||
@Get('overview')
|
||||
async overview() {
|
||||
const [userCount, interviewCount, todayUsers, todayInterviews] = await Promise.all([
|
||||
const [
|
||||
userCount, interviewCount, todayUsers, todayInterviews,
|
||||
resumeCount, paidDownloadCount,
|
||||
planStats,
|
||||
] = await Promise.all([
|
||||
this.userModel.countDocuments().exec(),
|
||||
this.interviewModel.countDocuments().exec(),
|
||||
this.userModel.countDocuments({ createdAt: { $gte: new Date(Date.now() - 24 * 60 * 60 * 1000) } }).exec(),
|
||||
this.interviewModel.countDocuments({ createdAt: { $gte: new Date(Date.now() - 24 * 60 * 60 * 1000) } }).exec(),
|
||||
this.resumeModel.countDocuments().exec(),
|
||||
this.resumeModel.countDocuments({ paidDownload: true }).exec(),
|
||||
this.userModel.aggregate([
|
||||
{ $group: { _id: '$plan', count: { $sum: 1 } } },
|
||||
]).exec(),
|
||||
])
|
||||
return { userCount, interviewCount, todayUsers, todayInterviews }
|
||||
const planBreakdown: Record<string, number> = {}
|
||||
planStats.forEach(p => { planBreakdown[p._id || 'free'] = p.count })
|
||||
return {
|
||||
userCount, interviewCount, todayUsers, todayInterviews,
|
||||
resumeCount, paidDownloadCount,
|
||||
planBreakdown,
|
||||
}
|
||||
}
|
||||
|
||||
@Get('users')
|
||||
@@ -70,7 +92,12 @@ export class AdminController {
|
||||
async getInterviews(@Query('page') page = '1', @Query('limit') limit = '20') {
|
||||
const skip = (Math.max(1, +page) - 1) * +limit
|
||||
const [interviews, total] = await Promise.all([
|
||||
this.interviewModel.find().sort({ createdAt: -1 }).skip(skip).limit(+limit).populate('userId', 'phone nickname').lean().exec(),
|
||||
this.interviewModel.find()
|
||||
.sort({ createdAt: -1 })
|
||||
.skip(skip).limit(+limit)
|
||||
.populate('userId', 'phone nickname')
|
||||
.select('position status totalScore questionCount fillerScore fillerDensity summary createdAt')
|
||||
.lean().exec(),
|
||||
this.interviewModel.countDocuments().exec(),
|
||||
])
|
||||
return { interviews, total, page: +page }
|
||||
@@ -80,14 +107,135 @@ export class AdminController {
|
||||
async setVip(@Body('userId') targetUserId: string) {
|
||||
const user = await this.userModel.findById(targetUserId).exec()
|
||||
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
|
||||
const pricing = await this.pricingService.getConfig()
|
||||
const credits = pricing.plans?.growth?.credits || { interview: 999, resumeOptimize: 20, resumeDownload: 10 }
|
||||
const expireAt = new Date()
|
||||
expireAt.setDate(expireAt.getDate() + VIP_DURATION_DAYS)
|
||||
expireAt.setDate(expireAt.getDate() + (pricing.plans?.growth?.durationDays || VIP_DURATION_DAYS))
|
||||
user.plan = 'growth'
|
||||
user.vipExpireAt = expireAt
|
||||
await this.quotaService.setPlanQuota(targetUserId, 'growth', { interview: 999, resumeOptimize: 20, resumeDownload: 10 })
|
||||
await this.quotaService.setPlanQuota(targetUserId, 'growth', credits)
|
||||
return { success: true, plan: 'growth', expireAt }
|
||||
}
|
||||
|
||||
@Post('user/credits')
|
||||
async adjustCredits(@Body('userId') userId: string, @Body('type') type: string, @Body('amount') amount: number) {
|
||||
if (!userId || !type || amount === undefined) {
|
||||
throw new HttpException('参数不完整', HttpStatus.BAD_REQUEST)
|
||||
}
|
||||
const validTypes = ['interviewCredits', 'resumeOptimizeCredits', 'resumeDownloadCredits', 'shareCredits']
|
||||
if (!validTypes.includes(type)) {
|
||||
throw new HttpException('无效的额度类型', HttpStatus.BAD_REQUEST)
|
||||
}
|
||||
const result = await this.userModel.findByIdAndUpdate(
|
||||
userId,
|
||||
{ $set: { [type]: Math.max(0, Math.round(amount)) } },
|
||||
).exec()
|
||||
if (!result) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
@Get('share-records')
|
||||
async getShareRecords(@Query('page') page = '1', @Query('limit') limit = '20') {
|
||||
const skip = (Math.max(1, +page) - 1) * +limit
|
||||
const [list, total] = await Promise.all([
|
||||
this.shareModel.aggregate([
|
||||
{ $sort: { createdAt: -1 } },
|
||||
{ $skip: skip },
|
||||
{ $limit: +limit },
|
||||
{
|
||||
$lookup: {
|
||||
from: 'users',
|
||||
localField: 'userId',
|
||||
foreignField: '_id',
|
||||
as: 'sharer',
|
||||
},
|
||||
},
|
||||
{ $unwind: { path: '$sharer', preserveNullAndEmptyArrays: true } },
|
||||
{
|
||||
$project: {
|
||||
shareCode: 1,
|
||||
type: 1,
|
||||
title: 1,
|
||||
visitCount: 1,
|
||||
creditedCount: 1,
|
||||
isActive: 1,
|
||||
createdAt: 1,
|
||||
sharer: { nickname: '$sharer.nickname', phone: '$sharer.phone', _id: '$sharer._id' },
|
||||
},
|
||||
},
|
||||
]).exec(),
|
||||
this.shareModel.countDocuments().exec(),
|
||||
])
|
||||
return { list, total, page: +page }
|
||||
}
|
||||
|
||||
@Get('share-visitors')
|
||||
async getShareVisitors(@Query('page') page = '1', @Query('limit') limit = '20') {
|
||||
const skip = (Math.max(1, +page) - 1) * +limit
|
||||
const [list, total] = await Promise.all([
|
||||
this.shareVisitModel.aggregate([
|
||||
{ $sort: { createdAt: -1 } },
|
||||
{ $skip: skip },
|
||||
{ $limit: +limit },
|
||||
{
|
||||
$lookup: {
|
||||
from: 'users',
|
||||
localField: 'sharerId',
|
||||
foreignField: '_id',
|
||||
as: 'sharer',
|
||||
},
|
||||
},
|
||||
{ $unwind: { path: '$sharer', preserveNullAndEmptyArrays: true } },
|
||||
{
|
||||
$lookup: {
|
||||
from: 'users',
|
||||
localField: 'visitorUserId',
|
||||
foreignField: '_id',
|
||||
as: 'visitorUser',
|
||||
},
|
||||
},
|
||||
{ $unwind: { path: '$visitorUser', preserveNullAndEmptyArrays: true } },
|
||||
{
|
||||
$project: {
|
||||
credited: 1,
|
||||
creditedAt: 1,
|
||||
createdAt: 1,
|
||||
sharer: { nickname: '$sharer.nickname', phone: '$sharer.phone' },
|
||||
visitor: { nickname: { $ifNull: ['$visitorUser.nickname', '匿名'] }, phone: { $ifNull: ['$visitorUser.phone', ''] } },
|
||||
},
|
||||
},
|
||||
]).exec(),
|
||||
this.shareVisitModel.countDocuments().exec(),
|
||||
])
|
||||
return { list, total, page: +page }
|
||||
}
|
||||
|
||||
@Get('resumes')
|
||||
async getResumes(@Query('page') page = '1', @Query('limit') limit = '20') {
|
||||
const skip = (Math.max(1, +page) - 1) * +limit
|
||||
const [list, total] = await Promise.all([
|
||||
this.resumeModel.find()
|
||||
.sort({ createdAt: -1 })
|
||||
.skip(skip).limit(+limit)
|
||||
.populate('userId', 'phone nickname')
|
||||
.select('title targetPosition version paidDownload createdAt')
|
||||
.lean().exec(),
|
||||
this.resumeModel.countDocuments().exec(),
|
||||
])
|
||||
return { list, total, page: +page }
|
||||
}
|
||||
|
||||
@Get('user/:id')
|
||||
async getUserDetail(@Query('id') id: string) {
|
||||
const user = await this.userModel.findById(id).select('-password -openid').lean().exec()
|
||||
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
|
||||
const [interviews, resumes] = await Promise.all([
|
||||
this.interviewModel.find({ userId: id }).sort({ createdAt: -1 }).limit(10).select('position status totalScore questionCount createdAt').lean().exec(),
|
||||
this.resumeModel.find({ userId: id }).sort({ createdAt: -1 }).limit(10).select('title targetPosition version paidDownload createdAt').lean().exec(),
|
||||
])
|
||||
return { user, interviews, resumes }
|
||||
}
|
||||
|
||||
@Get('admins')
|
||||
async getAdmins() {
|
||||
const admins = await this.userModel.find({ role: 'admin' }).select('phone nickname email createdAt isSystemAdmin').lean().exec()
|
||||
@@ -130,14 +278,22 @@ export class AdminController {
|
||||
if (order.type === 'membership') {
|
||||
const user = await this.userModel.findById(order.userId).exec()
|
||||
if (user && user.plan === 'free') {
|
||||
const pricing = await this.pricingService.getConfig()
|
||||
const credits = pricing.plans?.growth?.credits || { interview: 999, resumeOptimize: 20, resumeDownload: 10 }
|
||||
const expireAt = new Date()
|
||||
expireAt.setDate(expireAt.getDate() + VIP_DURATION_DAYS)
|
||||
expireAt.setDate(expireAt.getDate() + (pricing.plans?.growth?.durationDays || VIP_DURATION_DAYS))
|
||||
user.plan = 'growth'
|
||||
user.vipExpireAt = expireAt
|
||||
await this.quotaService.setPlanQuota(order.userId, 'growth', { interview: 999, resumeOptimize: 20, resumeDownload: 10 })
|
||||
await this.quotaService.setPlanQuota(order.userId, 'growth', credits)
|
||||
}
|
||||
} else {
|
||||
const credits = { interview: 1, optimize: 1, download: 1 }[order.type]
|
||||
const pricing = await this.pricingService.getConfig()
|
||||
const creditMap: Record<string, number> = {
|
||||
interview: pricing.interview?.creditsPerPurchase || 1,
|
||||
optimize: pricing.resumeOptimize?.creditsPerPurchase || 1,
|
||||
download: pricing.resumeDownload?.creditsPerPurchase || 1,
|
||||
}
|
||||
const credits = creditMap[order.type]
|
||||
if (credits) {
|
||||
await this.quotaService.grantCredits(order.userId, order.type as any, credits)
|
||||
}
|
||||
@@ -179,6 +335,7 @@ export class AdminController {
|
||||
{ key: 'pricing', value: body, description: '定价配置' },
|
||||
{ upsert: true },
|
||||
).exec()
|
||||
this.pricingService.invalidateCache()
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ import { PaymentOrder, PaymentOrderSchema } from '../payment/payment-order.schem
|
||||
import { WechatPayService } from '../payment/wechat-pay.service'
|
||||
import { AdminGuard } from '../../common/guards/admin.guard'
|
||||
import { SiteConfig, SiteConfigSchema } from '../schemas/site-config.schema'
|
||||
import { ShareRecord, ShareRecordSchema, ShareVisit, ShareVisitSchema } from '../share/share.schema'
|
||||
import { Resume, ResumeSchema } from '../resume/resume.schema'
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -16,6 +18,9 @@ import { SiteConfig, SiteConfigSchema } from '../schemas/site-config.schema'
|
||||
{ name: Interview.name, schema: InterviewSchema },
|
||||
{ name: PaymentOrder.name, schema: PaymentOrderSchema },
|
||||
{ name: SiteConfig.name, schema: SiteConfigSchema },
|
||||
{ name: ShareRecord.name, schema: ShareRecordSchema },
|
||||
{ name: ShareVisit.name, schema: ShareVisitSchema },
|
||||
{ name: Resume.name, schema: ResumeSchema },
|
||||
]),
|
||||
UserModule,
|
||||
],
|
||||
|
||||
@@ -168,6 +168,8 @@ export class PaymentController {
|
||||
order.wxTransactionId = wxTransactionId
|
||||
order.description = `${decrypted.trade_type || ''} 支付成功`
|
||||
await order.save()
|
||||
} else {
|
||||
return { code: 'SUCCESS', message: '已处理' }
|
||||
}
|
||||
|
||||
if (order.type === 'membership') {
|
||||
@@ -260,6 +262,15 @@ export class PaymentController {
|
||||
return { success: true, plan: order.plan }
|
||||
}
|
||||
|
||||
// For product orders, check if already activated via callback
|
||||
const user = await this.userModel.findById(userId).exec()
|
||||
if (user && order.type === 'interview' && (user.interviewCredits || 0) > 0) {
|
||||
return { success: true, type: order.type, alreadyActivated: true }
|
||||
}
|
||||
if (user && order.type === 'optimize' && (user.resumeOptimizeCredits || 0) > 0) {
|
||||
return { success: true, type: order.type, alreadyActivated: true }
|
||||
}
|
||||
|
||||
await this.activateProduct(order)
|
||||
return { success: true, type: order.type }
|
||||
}
|
||||
|
||||
@@ -31,13 +31,18 @@ export class ResumePdfService {
|
||||
}
|
||||
}
|
||||
|
||||
private escapeHtml(str: string): string {
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''')
|
||||
}
|
||||
|
||||
private buildHtml(params: {
|
||||
title: string
|
||||
content: string
|
||||
targetPosition?: string
|
||||
userName?: string
|
||||
}): string {
|
||||
const contentHtml = params.content
|
||||
const contentHtml = this.escapeHtml(params.content)
|
||||
.replace(/\n/g, '<br>')
|
||||
.replace(/### (.+)/g, '<h3>$1</h3>')
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
@@ -66,8 +71,8 @@ export class ResumePdfService {
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<h1>${params.title}</h1>
|
||||
<div class="subtitle">${params.targetPosition ? `目标岗位: ${params.targetPosition}` : ''}</div>
|
||||
<h1>${this.escapeHtml(params.title)}</h1>
|
||||
<div class="subtitle">${params.targetPosition ? `目标岗位: ${this.escapeHtml(params.targetPosition)}` : ''}</div>
|
||||
<div class="content">${contentHtml}</div>
|
||||
<div class="footer">由 AI磁场·职引 生成</div>
|
||||
</div>
|
||||
|
||||
@@ -31,13 +31,10 @@ export class ResumeController {
|
||||
@Post(':id/download')
|
||||
async download(@Param('id') id: string, @CurrentUser('userId') userId: string, @Res() res: Response) {
|
||||
const resume = await this.resumeService.getDetail(id, userId)
|
||||
|
||||
const canDownload = await this.quotaService.checkDownload(userId, resume)
|
||||
if (!canDownload) {
|
||||
const canDownload = await this.quotaService.checkAndDeductDownload(userId, resume.paidDownload)
|
||||
if (!canDownload && !resume.paidDownload) {
|
||||
throw new HttpException('请先付费下载', HttpStatus.PAYMENT_REQUIRED)
|
||||
}
|
||||
|
||||
await this.quotaService.deductDownload(userId, resume)
|
||||
if (!resume.paidDownload) {
|
||||
await this.resumeService.markPaid(id, userId)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Controller, Get, Post, Body, Param, Query, HttpException, HttpStatus, UseGuards } from '@nestjs/common'
|
||||
import { JwtService } from '@nestjs/jwt'
|
||||
import { ShareService } from './share.service'
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator'
|
||||
import { Public } from '../../common/decorators/public.decorator'
|
||||
|
||||
@Controller('share')
|
||||
export class ShareController {
|
||||
constructor(
|
||||
private shareService: ShareService,
|
||||
private jwtService: JwtService,
|
||||
) {}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post('create')
|
||||
async create(
|
||||
@CurrentUser('userId') userId: string,
|
||||
@Body() body: { type: string; refId?: string; title?: string; description?: string },
|
||||
) {
|
||||
return this.shareService.create(userId, body)
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('visit/:shareCode')
|
||||
async visit(
|
||||
@Param('shareCode') shareCode: string,
|
||||
@Query('visitorId') visitorId?: string,
|
||||
@Query('token') token?: string,
|
||||
) {
|
||||
if (!visitorId) {
|
||||
throw new HttpException('缺少访问者标识', HttpStatus.BAD_REQUEST)
|
||||
}
|
||||
let visitorUserId: string | undefined
|
||||
if (token) {
|
||||
try {
|
||||
const payload = this.jwtService.verify(token) as any
|
||||
visitorUserId = payload.userId
|
||||
} catch {}
|
||||
}
|
||||
return this.shareService.visit(shareCode, visitorId, visitorUserId)
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('stats')
|
||||
async stats(@CurrentUser('userId') userId: string) {
|
||||
return this.shareService.stats(userId)
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('records')
|
||||
async records(
|
||||
@CurrentUser('userId') userId: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
return this.shareService.records(userId, Number(page) || 1, Number(pageSize) || 20)
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('visitors')
|
||||
async visitors(
|
||||
@CurrentUser('userId') userId: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
return this.shareService.visitors(userId, Number(page) || 1, Number(pageSize) || 20)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Module } from '@nestjs/common'
|
||||
import { JwtModule } from '@nestjs/jwt'
|
||||
import { MongooseModule } from '@nestjs/mongoose'
|
||||
import { ShareController } from './share.controller'
|
||||
import { ShareService } from './share.service'
|
||||
import { ShareRecord, ShareRecordSchema, ShareVisit, ShareVisitSchema } from './share.schema'
|
||||
import { UserModule } from '../user/user.module'
|
||||
import { User, UserSchema } from '../user/user.schema'
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
MongooseModule.forFeature([
|
||||
{ name: ShareRecord.name, schema: ShareRecordSchema },
|
||||
{ name: ShareVisit.name, schema: ShareVisitSchema },
|
||||
{ name: User.name, schema: UserSchema },
|
||||
]),
|
||||
UserModule,
|
||||
JwtModule.register({
|
||||
secret: process.env.JWT_SECRET,
|
||||
signOptions: { expiresIn: '7d' },
|
||||
}),
|
||||
],
|
||||
controllers: [ShareController],
|
||||
providers: [ShareService],
|
||||
})
|
||||
export class ShareModule {}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
|
||||
import { Document, Types } from 'mongoose'
|
||||
|
||||
export type ShareRecordDocument = ShareRecord & Document
|
||||
export type ShareVisitDocument = ShareVisit & Document
|
||||
|
||||
@Schema({ timestamps: true })
|
||||
export class ShareRecord {
|
||||
@Prop({ type: Types.ObjectId, ref: 'User', required: true })
|
||||
userId: Types.ObjectId
|
||||
|
||||
@Prop({ required: true, unique: true })
|
||||
shareCode: string
|
||||
|
||||
@Prop({ default: 'app' })
|
||||
type: string
|
||||
|
||||
@Prop({ default: '' })
|
||||
refId: string
|
||||
|
||||
@Prop({ default: '' })
|
||||
title: string
|
||||
|
||||
@Prop({ default: '' })
|
||||
description: string
|
||||
|
||||
@Prop({ default: 'link' })
|
||||
channel: string
|
||||
|
||||
@Prop({ default: 0 })
|
||||
visitCount: number
|
||||
|
||||
@Prop({ default: 0 })
|
||||
creditedCount: number
|
||||
|
||||
@Prop({ default: true })
|
||||
isActive: boolean
|
||||
|
||||
readonly createdAt?: Date
|
||||
readonly updatedAt?: Date
|
||||
}
|
||||
|
||||
@Schema({ timestamps: true })
|
||||
export class ShareVisit {
|
||||
@Prop({ type: Types.ObjectId, ref: 'ShareRecord', required: true })
|
||||
shareId: Types.ObjectId
|
||||
|
||||
@Prop({ type: Types.ObjectId, ref: 'User', required: true })
|
||||
sharerId: Types.ObjectId
|
||||
|
||||
@Prop({ required: true })
|
||||
visitorId: string
|
||||
|
||||
@Prop({ type: Types.ObjectId, ref: 'User' })
|
||||
visitorUserId: Types.ObjectId
|
||||
|
||||
@Prop({ default: false })
|
||||
credited: boolean
|
||||
|
||||
@Prop()
|
||||
creditedAt?: Date
|
||||
|
||||
readonly createdAt?: Date
|
||||
readonly updatedAt?: Date
|
||||
}
|
||||
|
||||
export const ShareRecordSchema = SchemaFactory.createForClass(ShareRecord)
|
||||
export const ShareVisitSchema = SchemaFactory.createForClass(ShareVisit)
|
||||
|
||||
ShareRecordSchema.index({ userId: 1, createdAt: -1 })
|
||||
ShareVisitSchema.index({ shareId: 1, visitorId: 1 }, { unique: true })
|
||||
ShareVisitSchema.index({ sharerId: 1, createdAt: -1 })
|
||||
@@ -0,0 +1,186 @@
|
||||
import * as crypto from 'crypto'
|
||||
import { Injectable, HttpException, HttpStatus, Logger } from '@nestjs/common'
|
||||
import { InjectModel } from '@nestjs/mongoose'
|
||||
import { Model, Types } from 'mongoose'
|
||||
import { ShareRecord, ShareRecordDocument, ShareVisit, ShareVisitDocument } from './share.schema'
|
||||
import { QuotaService } from '../user/quota.service'
|
||||
import { User, UserDocument } from '../user/user.schema'
|
||||
|
||||
const DAILY_LIMIT = 3
|
||||
const LINK_TTL_DAYS = 30
|
||||
|
||||
@Injectable()
|
||||
export class ShareService {
|
||||
private readonly logger = new Logger(ShareService.name)
|
||||
|
||||
constructor(
|
||||
@InjectModel(ShareRecord.name) private shareModel: Model<ShareRecordDocument>,
|
||||
@InjectModel(ShareVisit.name) private visitModel: Model<ShareVisitDocument>,
|
||||
@InjectModel(User.name) private userModel: Model<UserDocument>,
|
||||
private quotaService: QuotaService,
|
||||
) {}
|
||||
|
||||
async create(userId: string, body: { type: string; refId?: string; title?: string; description?: string }) {
|
||||
const shareCode = crypto.randomBytes(4).toString('hex')
|
||||
const record = await this.shareModel.create({
|
||||
userId: new Types.ObjectId(userId),
|
||||
shareCode,
|
||||
type: body.type || 'app',
|
||||
refId: body.refId || '',
|
||||
title: body.title || '我在职引发现了好东西',
|
||||
description: body.description || '快来一起体验吧',
|
||||
})
|
||||
return {
|
||||
shareCode: record.shareCode,
|
||||
shareUrl: `/share/${record.shareCode}`,
|
||||
wechatShareInfo: {
|
||||
title: record.title,
|
||||
description: record.description,
|
||||
path: `/pages/share/share?code=${record.shareCode}`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async visit(shareCode: string, visitorId: string, visitorUserId?: string) {
|
||||
const share = await this.shareModel.findOne({ shareCode, isActive: true }).exec()
|
||||
if (!share) throw new HttpException('分享链接不存在或已失效', HttpStatus.NOT_FOUND)
|
||||
|
||||
await this.shareModel.findByIdAndUpdate(share._id, { $inc: { visitCount: 1 } }).exec()
|
||||
|
||||
const sharerIdStr = share.userId.toString()
|
||||
if (!visitorUserId || visitorUserId === sharerIdStr) {
|
||||
return { sharer: true, visitorUserId }
|
||||
}
|
||||
|
||||
const existing = await this.visitModel.findOne({
|
||||
shareId: share._id,
|
||||
visitorId,
|
||||
}).exec()
|
||||
if (existing) return { alreadyVisited: true, visitorUserId }
|
||||
|
||||
await this.visitModel.create({
|
||||
shareId: share._id,
|
||||
sharerId: share.userId,
|
||||
visitorId,
|
||||
visitorUserId: new Types.ObjectId(visitorUserId),
|
||||
}).catch(() => {})
|
||||
|
||||
const alreadyCredited = await this.visitModel.findOne({
|
||||
shareId: share._id,
|
||||
visitorId,
|
||||
credited: true,
|
||||
}).exec()
|
||||
if (alreadyCredited) return { credited: true, visitorUserId }
|
||||
|
||||
const todayStart = new Date()
|
||||
todayStart.setHours(0, 0, 0, 0)
|
||||
|
||||
const todayCredited = await this.visitModel.countDocuments({
|
||||
sharerId: share.userId,
|
||||
credited: true,
|
||||
creditedAt: { $gte: todayStart },
|
||||
}).exec()
|
||||
|
||||
if (todayCredited >= DAILY_LIMIT) return { dailyLimitReached: true, visitorUserId }
|
||||
|
||||
const shareCreditsResult = await this.quotaService.grantShareCredits(sharerIdStr)
|
||||
if (!shareCreditsResult) return { creditFailed: true, visitorUserId }
|
||||
|
||||
await this.visitModel.updateOne(
|
||||
{ shareId: share._id, visitorId },
|
||||
{ $set: { credited: true, creditedAt: new Date() } },
|
||||
).exec()
|
||||
|
||||
await this.shareModel.findByIdAndUpdate(share._id, { $inc: { creditedCount: 1 } }).exec()
|
||||
|
||||
return { credited: true, visitorUserId }
|
||||
}
|
||||
|
||||
async stats(userId: string) {
|
||||
const todayStart = new Date()
|
||||
todayStart.setHours(0, 0, 0, 0)
|
||||
|
||||
const [totalShares, visitAgg, todayAgg, user] = await Promise.all([
|
||||
this.shareModel.countDocuments({ userId: new Types.ObjectId(userId) }).exec(),
|
||||
this.visitModel.aggregate([
|
||||
{ $match: { sharerId: new Types.ObjectId(userId) } },
|
||||
{
|
||||
$group: {
|
||||
_id: null,
|
||||
totalVisits: { $sum: 1 },
|
||||
creditedCount: { $sum: { $cond: ['$credited', 1, 0] } },
|
||||
},
|
||||
},
|
||||
]).exec(),
|
||||
this.visitModel.countDocuments({
|
||||
sharerId: new Types.ObjectId(userId),
|
||||
credited: true,
|
||||
creditedAt: { $gte: todayStart },
|
||||
}).exec(),
|
||||
this.userModel.findById(userId).exec(),
|
||||
])
|
||||
|
||||
return {
|
||||
totalShares,
|
||||
totalVisits: visitAgg[0]?.totalVisits ?? 0,
|
||||
creditedCount: visitAgg[0]?.creditedCount ?? 0,
|
||||
todayCredited: todayAgg,
|
||||
shareCredits: user?.shareCredits ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
async records(userId: string, page = 1, pageSize = 20) {
|
||||
const list = await this.shareModel.find({ userId: new Types.ObjectId(userId) })
|
||||
.sort({ createdAt: -1 })
|
||||
.skip((page - 1) * pageSize)
|
||||
.limit(pageSize)
|
||||
.exec()
|
||||
const total = await this.shareModel.countDocuments({ userId: new Types.ObjectId(userId) }).exec()
|
||||
return {
|
||||
list: list.map(r => ({
|
||||
shareCode: r.shareCode,
|
||||
type: r.type,
|
||||
title: r.title,
|
||||
visitCount: r.visitCount,
|
||||
creditedCount: r.creditedCount,
|
||||
createdAt: r.createdAt,
|
||||
})),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
}
|
||||
}
|
||||
|
||||
async visitors(userId: string, page = 1, pageSize = 20) {
|
||||
const [list, total] = await Promise.all([
|
||||
this.visitModel.aggregate([
|
||||
{ $match: { sharerId: new Types.ObjectId(userId) } },
|
||||
{ $sort: { createdAt: -1 } },
|
||||
{ $skip: (page - 1) * pageSize },
|
||||
{ $limit: pageSize },
|
||||
{
|
||||
$lookup: {
|
||||
from: 'users',
|
||||
localField: 'visitorUserId',
|
||||
foreignField: '_id',
|
||||
as: 'visitorUser',
|
||||
},
|
||||
},
|
||||
{ $unwind: { path: '$visitorUser', preserveNullAndEmptyArrays: true } },
|
||||
{
|
||||
$project: {
|
||||
_id: 0,
|
||||
visitorId: 1,
|
||||
credited: 1,
|
||||
creditedAt: 1,
|
||||
createdAt: 1,
|
||||
nickname: { $ifNull: ['$visitorUser.nickname', '匿名用户'] },
|
||||
avatar: { $ifNull: ['$visitorUser.avatar', ''] },
|
||||
},
|
||||
},
|
||||
]).exec(),
|
||||
this.visitModel.countDocuments({ sharerId: new Types.ObjectId(userId) }).exec(),
|
||||
])
|
||||
return { list, total, page, pageSize }
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,18 @@
|
||||
import { Controller, Get, Post, Body, Param, Res, HttpException, HttpStatus } from '@nestjs/common'
|
||||
import { Controller, Get, Post, Body, Param, Res, HttpException, HttpStatus, UseGuards, UploadedFile, UseInterceptors } from '@nestjs/common'
|
||||
import { FileInterceptor } from '@nestjs/platform-express'
|
||||
import { Response } from 'express'
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
import { execSync } from 'child_process'
|
||||
import { TtsService } from './tts.service'
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'
|
||||
import { Public } from '../../common/decorators/public.decorator'
|
||||
|
||||
@Controller('tts')
|
||||
export class TtsController {
|
||||
constructor(private ttsService: TtsService) {}
|
||||
|
||||
@Public()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post('synthesize')
|
||||
async synthesize(@Body('text') text: string, @Body('voice') voice?: string) {
|
||||
if (!text || text.length > 500) {
|
||||
@@ -30,4 +34,36 @@ export class TtsController {
|
||||
res.setHeader('Cache-Control', 'public, max-age=31536000')
|
||||
stream.pipe(res)
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post('asr')
|
||||
@UseInterceptors(FileInterceptor('audio', { dest: '/tmp/asr_uploads' }))
|
||||
async recognize(@UploadedFile() file: any) {
|
||||
if (!file) throw new HttpException('请上传音频文件', HttpStatus.BAD_REQUEST)
|
||||
const uploadDir = '/tmp/asr_uploads'
|
||||
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true })
|
||||
const ext = path.extname(file.originalname) || '.mp3'
|
||||
const dest = path.join(uploadDir, file.filename + ext)
|
||||
fs.renameSync(file.path, dest)
|
||||
try {
|
||||
if (process.env.OPENAI_API_KEY) {
|
||||
const result = execSync(
|
||||
`curl -s -X POST https://api.openai.com/v1/audio/transcriptions \
|
||||
-H "Authorization: Bearer ${process.env.OPENAI_API_KEY}" \
|
||||
-H "Content-Type: multipart/form-data" \
|
||||
-F "file=@${dest}" \
|
||||
-F "model=whisper-1" \
|
||||
-F "language=zh"`,
|
||||
{ encoding: 'utf8', timeout: 30000 },
|
||||
)
|
||||
const parsed = JSON.parse(result)
|
||||
if (parsed.text) return { text: parsed.text.trim() }
|
||||
}
|
||||
const whisperResult = execSync(`whisper "${dest}" --language zh --output_format txt 2>/dev/null`, { encoding: 'utf8', timeout: 60000 })
|
||||
if (whisperResult && whisperResult.trim()) {
|
||||
return { text: whisperResult.trim() }
|
||||
}
|
||||
} catch {}
|
||||
return { text: '' }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,18 @@ interface TtsResult {
|
||||
durationMs: number
|
||||
}
|
||||
|
||||
const VALID_VOICES = new Set([
|
||||
'zh-CN-XiaoxiaoNeural', 'zh-CN-XiaoyiNeural', 'zh-CN-YunjianNeural',
|
||||
'zh-CN-YunxiNeural', 'zh-CN-YunxiaNeural', 'zh-CN-YunyangNeural',
|
||||
'zh-CN-liaoning-XiaobeiNeural', 'zh-CN-shaanxi-XiaoniNeural',
|
||||
])
|
||||
|
||||
function validateVoice(voice: string): void {
|
||||
if (!VALID_VOICES.has(voice)) {
|
||||
throw new Error(`不支持的语音: ${voice}`)
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TtsService {
|
||||
private readonly logger = new Logger(TtsService.name)
|
||||
@@ -23,6 +35,7 @@ export class TtsService {
|
||||
}
|
||||
|
||||
async synthesize(text: string, voice: string = 'zh-CN-XiaoxiaoNeural'): Promise<TtsResult> {
|
||||
validateVoice(voice)
|
||||
const hash = crypto.createHash('md5').update(text + voice).digest('hex')
|
||||
const filePath = path.join(CACHE_DIR, `${hash}.mp3`)
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, HttpException, HttpStatus } from '@nestjs/common'
|
||||
import { Injectable, HttpException, HttpStatus, Logger } from '@nestjs/common'
|
||||
import { InjectModel } from '@nestjs/mongoose'
|
||||
import { Model } from 'mongoose'
|
||||
import { User, UserDocument } from './user.schema'
|
||||
@@ -7,6 +7,8 @@ const FREE_OPTIMIZE_LIMIT = 3
|
||||
|
||||
@Injectable()
|
||||
export class QuotaService {
|
||||
private readonly logger = new Logger(QuotaService.name)
|
||||
|
||||
constructor(
|
||||
@InjectModel(User.name) private userModel: Model<UserDocument>,
|
||||
) {}
|
||||
@@ -16,12 +18,28 @@ export class QuotaService {
|
||||
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
|
||||
if (user.plan !== 'free') return
|
||||
|
||||
if ((user.interviewCredits || 0) <= 0) {
|
||||
throw new HttpException('面试次数已用完,请购买面试次数或开通会员', HttpStatus.FORBIDDEN)
|
||||
// Backward compat: migrate remaining → interviewCredits
|
||||
if ((user.interviewCredits ?? 0) <= 0 && (user.remaining ?? 0) > 0) {
|
||||
await this.userModel.findByIdAndUpdate(userId, {
|
||||
$set: { interviewCredits: user.remaining, remaining: 0 },
|
||||
}).exec()
|
||||
}
|
||||
user.interviewCredits = (user.interviewCredits || 0) - 1
|
||||
user.interviewCount = (user.interviewCount || 0) + 1
|
||||
await user.save()
|
||||
|
||||
const result = await this.userModel.findOneAndUpdate(
|
||||
{ _id: userId, interviewCredits: { $gt: 0 } },
|
||||
{ $inc: { interviewCredits: -1, interviewCount: 1 } },
|
||||
{ new: true },
|
||||
).exec()
|
||||
if (result) return
|
||||
|
||||
// Fallback to share credits
|
||||
const shareResult = await this.userModel.findOneAndUpdate(
|
||||
{ _id: userId, shareCredits: { $gt: 0 } },
|
||||
{ $inc: { shareCredits: -1, interviewCount: 1 } },
|
||||
).exec()
|
||||
if (shareResult) return
|
||||
|
||||
throw new HttpException('面试次数已用完,请购买面试次数或开通会员', HttpStatus.FORBIDDEN)
|
||||
}
|
||||
|
||||
async checkAndDeductOptimize(userId: string) {
|
||||
@@ -29,64 +47,85 @@ export class QuotaService {
|
||||
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
|
||||
if (user.plan !== 'free') return
|
||||
|
||||
// 优先扣付费额度
|
||||
if ((user.resumeOptimizeCredits || 0) > 0) {
|
||||
user.resumeOptimizeCredits = (user.resumeOptimizeCredits || 0) - 1
|
||||
await user.save()
|
||||
return
|
||||
// Backward compat: migrate remaining → freeOptimizeUsed
|
||||
if ((user.freeOptimizeUsed ?? 0) <= 0 && (user.remaining ?? 0) > 0 && (user.resumeOptimizeCredits ?? 0) <= 0) {
|
||||
const migrateCount = Math.min(user.remaining, FREE_OPTIMIZE_LIMIT)
|
||||
await this.userModel.findByIdAndUpdate(userId, {
|
||||
$set: { freeOptimizeUsed: migrateCount, remaining: Math.max(0, user.remaining - migrateCount) },
|
||||
}).exec()
|
||||
this.logger.log(`Migrated remaining=${user.remaining} → freeOptimizeUsed=${migrateCount} for user ${userId}`)
|
||||
}
|
||||
|
||||
// 免费额度
|
||||
if ((user.freeOptimizeUsed || 0) < FREE_OPTIMIZE_LIMIT) {
|
||||
user.freeOptimizeUsed = (user.freeOptimizeUsed || 0) + 1
|
||||
await user.save()
|
||||
return
|
||||
}
|
||||
// Try paid credits first
|
||||
const paid = await this.userModel.findOneAndUpdate(
|
||||
{ _id: userId, resumeOptimizeCredits: { $gt: 0 } },
|
||||
{ $inc: { resumeOptimizeCredits: -1 } },
|
||||
).exec()
|
||||
if (paid) return
|
||||
|
||||
// Then free limit
|
||||
const freeResult = await this.userModel.findOneAndUpdate(
|
||||
{ _id: userId, freeOptimizeUsed: { $lt: FREE_OPTIMIZE_LIMIT } },
|
||||
{ $inc: { freeOptimizeUsed: 1 } },
|
||||
).exec()
|
||||
if (freeResult) return
|
||||
|
||||
// Fallback to share credits
|
||||
const shareResult = await this.userModel.findOneAndUpdate(
|
||||
{ _id: userId, shareCredits: { $gt: 0 } },
|
||||
{ $inc: { shareCredits: -1 } },
|
||||
).exec()
|
||||
if (shareResult) return
|
||||
|
||||
throw new HttpException('简历优化次数已用完,请购买优化次数或开通会员', HttpStatus.FORBIDDEN)
|
||||
}
|
||||
|
||||
async checkDownload(userId: string, resume: { paidDownload?: boolean }): Promise<boolean> {
|
||||
const user = await this.userModel.findById(userId).exec()
|
||||
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
|
||||
|
||||
if (resume.paidDownload) return true
|
||||
if ((user.resumeDownloadCredits || 0) > 0) return true
|
||||
return false
|
||||
async grantShareCredits(userId: string, amount = 1): Promise<boolean> {
|
||||
const result = await this.userModel.findByIdAndUpdate(
|
||||
userId,
|
||||
{ $inc: { shareCredits: amount } },
|
||||
).exec()
|
||||
return !!result
|
||||
}
|
||||
|
||||
async deductDownload(userId: string, resume: { paidDownload?: boolean; _id?: any }) {
|
||||
const user = await this.userModel.findById(userId).exec()
|
||||
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
|
||||
async checkAndDeductDownload(userId: string, paidDownload: boolean): Promise<boolean> {
|
||||
if (paidDownload) return true
|
||||
|
||||
if (resume.paidDownload) return
|
||||
if ((user.resumeDownloadCredits || 0) > 0) {
|
||||
user.resumeDownloadCredits = (user.resumeDownloadCredits || 0) - 1
|
||||
await user.save()
|
||||
}
|
||||
const result = await this.userModel.findOneAndUpdate(
|
||||
{ _id: userId, resumeDownloadCredits: { $gt: 0 } },
|
||||
{ $inc: { resumeDownloadCredits: -1 } },
|
||||
).exec()
|
||||
return !!result
|
||||
}
|
||||
|
||||
async grantCredits(userId: string, type: 'interview' | 'optimize' | 'download', amount: number) {
|
||||
const user = await this.userModel.findById(userId).exec()
|
||||
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
|
||||
if (amount <= 0) throw new HttpException('无效数量', HttpStatus.BAD_REQUEST)
|
||||
|
||||
if (type === 'interview') user.interviewCredits = (user.interviewCredits || 0) + amount
|
||||
else if (type === 'optimize') user.resumeOptimizeCredits = (user.resumeOptimizeCredits || 0) + amount
|
||||
else if (type === 'download') user.resumeDownloadCredits = (user.resumeDownloadCredits || 0) + amount
|
||||
const fieldMap: Record<string, string> = {
|
||||
interview: 'interviewCredits',
|
||||
optimize: 'resumeOptimizeCredits',
|
||||
download: 'resumeDownloadCredits',
|
||||
}
|
||||
const field = fieldMap[type]
|
||||
if (!field) throw new HttpException('无效类型', HttpStatus.BAD_REQUEST)
|
||||
|
||||
await user.save()
|
||||
const result = await this.userModel.findByIdAndUpdate(
|
||||
userId,
|
||||
{ $inc: { [field]: amount } },
|
||||
).exec()
|
||||
if (!result) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
|
||||
}
|
||||
|
||||
async setPlanQuota(userId: string, plan: string, credits: { interview: number; resumeOptimize: number; resumeDownload: number }) {
|
||||
const user = await this.userModel.findById(userId).exec()
|
||||
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
|
||||
|
||||
user.remaining = 999
|
||||
user.interviewCredits = credits.interview
|
||||
user.resumeOptimizeCredits = credits.resumeOptimize
|
||||
user.resumeDownloadCredits = credits.resumeDownload
|
||||
user.freeOptimizeUsed = FREE_OPTIMIZE_LIMIT // 会员不再消耗免费次数
|
||||
|
||||
await user.save()
|
||||
async setPlanQuota(userId: string, _plan: string, credits: { interview: number; resumeOptimize: number; resumeDownload: number }) {
|
||||
const result = await this.userModel.findByIdAndUpdate(userId, {
|
||||
$set: {
|
||||
remaining: 999,
|
||||
interviewCredits: credits.interview,
|
||||
resumeOptimizeCredits: credits.resumeOptimize,
|
||||
resumeDownloadCredits: credits.resumeDownload,
|
||||
freeOptimizeUsed: FREE_OPTIMIZE_LIMIT,
|
||||
},
|
||||
}).exec()
|
||||
if (!result) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,9 @@ export class User {
|
||||
@Prop({ default: 0 })
|
||||
freeOptimizeUsed: number // 已使用免费优化次数(上限 3)
|
||||
|
||||
@Prop({ default: 0 })
|
||||
shareCredits: number // 分享积分,每 3 次有效访问获 1 积分
|
||||
|
||||
@Prop({ default: 'user' })
|
||||
role: string // 'user' | 'admin'
|
||||
|
||||
|
||||
@@ -215,6 +215,7 @@ export class UserService {
|
||||
resumeOptimizeCredits: user.resumeOptimizeCredits ?? 0,
|
||||
resumeDownloadCredits: user.resumeDownloadCredits ?? 0,
|
||||
freeOptimizeUsed: user.freeOptimizeUsed ?? 0,
|
||||
shareCredits: user.shareCredits ?? 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
# 分享功能设计方案
|
||||
|
||||
## 1. 概述
|
||||
|
||||
**目标**:用户可通过分享面试/简历等给好友或微信群,每次有效分享获得积分奖励,积分可抵扣面试/简历优化次数,形成用户增长闭环。
|
||||
|
||||
**核心逻辑**:分享者 A → 生成分享链接 → 非 A 的其他用户点击 → 计入有效分享一次 → A 获得 1 个「分享积分」,每 3 次有效分享可兑换 1 次面试或简历优化次数。
|
||||
|
||||
---
|
||||
|
||||
## 2. 数据模型
|
||||
|
||||
### User 新增字段
|
||||
|
||||
```
|
||||
shareCredits: number // 分享积分,默认 0
|
||||
```
|
||||
|
||||
- 1 个 shareCredit 可兑换 1 次 面试 或 1 次 简历优化
|
||||
- 在 QuotaService 中作为兜底:当 interviewCredits / resumeOptimizeCredits 为 0 时尝试消耗 shareCredits
|
||||
|
||||
### ShareRecord 集合
|
||||
|
||||
```
|
||||
@Schema({ timestamps: true })
|
||||
ShareRecord {
|
||||
_id: ObjectId
|
||||
userId: ObjectId // 分享者
|
||||
shareCode: string // 8位唯一短码 (hex),用于分享链接
|
||||
type: 'interview' | 'resume' | 'app'
|
||||
refId: string // 关联的面试/简历ID(可选)
|
||||
title: string // 分享标题
|
||||
description: string // 分享描述
|
||||
visitCount: number // 总访问次数
|
||||
creditedCount: number // 已计为有效的访问次数
|
||||
isActive: boolean // 链接是否有效
|
||||
createdAt: Date // timestamps
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
index: { shareCode: 1 } unique
|
||||
index: { userId: 1, createdAt: -1 }
|
||||
```
|
||||
|
||||
### ShareVisit 集合(访问记录)
|
||||
|
||||
```
|
||||
@Schema({ timestamps: true })
|
||||
ShareVisit {
|
||||
_id: ObjectId
|
||||
shareId: ObjectId // 关联的 ShareRecord
|
||||
sharerId: ObjectId // 分享者 userId(冗余,便于查询)
|
||||
visitorId: string // 访问者标识(openId 或 匿名ID)
|
||||
visitorUserId: ObjectId // 访问者 userId(如果已注册/登录)
|
||||
credited: boolean // 是否已为此访问发积分
|
||||
creditedAt: Date
|
||||
createdAt: Date // timestamps
|
||||
}
|
||||
|
||||
index: { shareId: 1, visitorId: 1 } unique // 同一人只计一次
|
||||
index: { sharerId: 1, createdAt: -1 }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. API 设计
|
||||
|
||||
| 方法 | 路径 | 权限 | 说明 |
|
||||
|------|------|------|------|
|
||||
| POST | /api/share/create | JWT | 生成分享链接,返回 shareCode + URL |
|
||||
| GET | /api/share/visit/:shareCode | Public | 访问分享链接,记录访问+判定发放积分 |
|
||||
| GET | /api/share/stats | JWT | 获取我的分享统计(总分享次数、总积分、今日积分) |
|
||||
| GET | /api/share/records | JWT | 获取我的分享记录列表(分页) |
|
||||
| GET | /api/share/visitors | JWT | 获取访问过我分享的用户列表(分页) |
|
||||
|
||||
### POST /api/share/create
|
||||
|
||||
Request:
|
||||
```json
|
||||
{
|
||||
"type": "interview" | "resume" | "app",
|
||||
"refId": "xxx",
|
||||
"title": "我在职引完成了AI模拟面试",
|
||||
"description": "快来和我一起练习面试吧"
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"shareCode": "a1b2c3d4",
|
||||
"shareUrl": "https://zhiyin.app/share/a1b2c3d4",
|
||||
"wechatShareInfo": {
|
||||
"title": "...",
|
||||
"description": "...",
|
||||
"path": "/pages/share/share?code=a1b2c3d4"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/share/visit/:shareCode
|
||||
|
||||
- 如果来自微信小程序:记录 `visitorId = openId`
|
||||
- 如果来自 H5 链接:记录 `visitorId = 匿名设备ID`(可选)
|
||||
- 判定条件:
|
||||
- `visitorId !== sharer.openId`(自己点自己不算)
|
||||
- 该 `visitorId` 之前未在该 shareId 下领过积分
|
||||
- 当日该分享者已获得积分 < 3(日上限)
|
||||
- 满足条件 → `credited = true`,shareCredits + 1
|
||||
- 返回重定向或展示落地页
|
||||
|
||||
### GET /api/share/stats
|
||||
|
||||
```json
|
||||
{
|
||||
"totalShares": 15,
|
||||
"totalVisits": 43,
|
||||
"creditedCount": 12,
|
||||
"todayCredited": 2,
|
||||
"shareCredits": 4
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/share/records
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"shareCode": "a1b2c3d4",
|
||||
"type": "interview",
|
||||
"title": "我在职引完成了AI模拟面试",
|
||||
"visitCount": 5,
|
||||
"creditedCount": 3,
|
||||
"createdAt": "2026-06-12T10:00:00Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### GET /api/share/visitors
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"visitor": { "nickname": "张三", "avatar": "..." },
|
||||
"credited": true,
|
||||
"creditedAt": "2026-06-12T10:30:00Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 积分兜底机制
|
||||
|
||||
在 `QuotaService.checkAndDeductInterview()` 和 `checkAndDeductOptimize()` 中增加兜底:
|
||||
|
||||
```
|
||||
if (interviewCredits <= 0 && shareCredits > 0) {
|
||||
shareCredits -= 1
|
||||
return // 用分享积分抵扣
|
||||
}
|
||||
```
|
||||
|
||||
同时新增 `useShareCredit(userId, type)` 方法由前端显式调用,或自动在抵扣链中完成。
|
||||
|
||||
---
|
||||
|
||||
## 5. 前端实现
|
||||
|
||||
### 5.1 分享入口
|
||||
|
||||
**进入点 1:面试报告页**(`pages/report/report.vue`)
|
||||
- 在"生成分享卡片"按钮旁增加"分享给好友"按钮
|
||||
- 点击后调用 `POST /api/share/create` 生成 shareCode
|
||||
- 微信小程序内直接调 `wx.shareAppMessage`
|
||||
- H5 环境复制分享链接到剪贴板
|
||||
|
||||
**进入点 2:简历优化结果页**(`pages/result/result.vue`)
|
||||
- 同上
|
||||
|
||||
**进入点 3:应用首页**(`pages/index/index.vue`)
|
||||
- "邀请好友"入口,固定分享 app 类型
|
||||
|
||||
### 5.2 我的分享页
|
||||
|
||||
**页面位置**:`/pages/share/share.vue`
|
||||
**进入方式**:用户「我的」页面 → "我的分享" 菜单项
|
||||
|
||||
页面内容:
|
||||
- 顶部统计卡片:总收益积分、今日收益、总分享次数
|
||||
- "分享给好友"主按钮
|
||||
- Tab 切换:「分享记录」/ 「访问记录」
|
||||
- 分享记录列表:分享类型、标题、访问数、有效数、时间
|
||||
- 访问记录列表:访客昵称、访问时间、是否已积分
|
||||
|
||||
### 5.3 微信分享
|
||||
|
||||
使用 uni-app `onShareAppMessage` 生命周期:
|
||||
```javascript
|
||||
// 在页面中定义
|
||||
onShareAppMessage() {
|
||||
return {
|
||||
title: '我在职引完成了AI模拟面试',
|
||||
path: `/pages/share/share?code=${this.shareCode}`,
|
||||
imageUrl: '...'
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.4 H5 分享链接
|
||||
|
||||
分享链接格式:`https://zhiyin.app/share/{shareCode}`
|
||||
H5 落地页展示:
|
||||
- 分享者信息
|
||||
- 分享内容预览
|
||||
- "打开小程序" / "下载 App" 引导按钮
|
||||
|
||||
---
|
||||
|
||||
## 6. 风控规则
|
||||
|
||||
| 规则 | 说明 |
|
||||
|------|------|
|
||||
| 日上限 | 同一用户每日最多获 3 个分享积分 |
|
||||
| 自分享过滤 | 自己点自己的链接不计数 |
|
||||
| 同人去重 | 同一访客对同一链接只计一次 |
|
||||
| 链接有效期 | 分享链接 30 天有效期 |
|
||||
| 频率限制 | 每分钟最多创建 5 次分享(前端控制) |
|
||||
|
||||
---
|
||||
|
||||
## 7. 存储方案
|
||||
|
||||
- **ShareRecord**:MongoDB 集合 `sharerecords`
|
||||
- **ShareVisit**:MongoDB 集合 `sharevisits`
|
||||
- 积分字段 `shareCredits` 存储在 User 文档上,`$inc` 原子操作确保并发安全
|
||||
|
||||
---
|
||||
|
||||
## 8. 依赖模块
|
||||
|
||||
- `QuotaService`(来自 UserModule)- 积分发放与抵扣
|
||||
- `UserModule`(获取 User 信息、校验身份)
|
||||
- `PricingModule`(@Global 已有,无需额外导入)
|
||||
|
||||
---
|
||||
|
||||
## 9. 未纳入 MVP 的内容
|
||||
|
||||
- 分享链接落地页(H5 路由 `/share/:code`)— 第一期直接返回 JSON
|
||||
- 分享数据可视化图表
|
||||
- 积分兑换商城
|
||||
- 排行榜 / 邀请竞赛
|
||||
@@ -1,21 +1,17 @@
|
||||
<template>
|
||||
<view class="digital-human">
|
||||
<view class="avatar-stage">
|
||||
<view class="avatar-ring" :class="{ speaking: isSpeaking }">
|
||||
<!-- Default CSS avatar if image fails -->
|
||||
<view class="avatar-default" v-if="imgFailed">
|
||||
<text class="avatar-initials">AI</text>
|
||||
</view>
|
||||
<image class="avatar-img" :src="avatarSrc" mode="aspectFill" @error="imgFailed = true" v-else />
|
||||
<canvas
|
||||
v-if="isH5"
|
||||
id="dh-mouth"
|
||||
class="mouth-canvas"
|
||||
:style="{ width: canvasSize + 'px', height: canvasSize + 'px' }"
|
||||
:canvas-id="canvasId"
|
||||
:id="canvasId"
|
||||
type="2d"
|
||||
class="face-canvas"
|
||||
:class="{ speaking: isSpeaking }"
|
||||
></canvas>
|
||||
</view>
|
||||
<view class="status-dot" :class="{ active: isSpeaking }"></view>
|
||||
<text class="role-label">AI 面试官</text>
|
||||
</view>
|
||||
|
||||
<view class="speech-area" v-if="currentText">
|
||||
<view class="speech-bubble">
|
||||
<text class="speech-text">{{ currentText }}</text>
|
||||
@@ -25,34 +21,45 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { ref, watch, onMounted, onBeforeUnmount, nextTick, computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
text: { type: String, default: '' },
|
||||
audioUrl: { type: String, default: '' },
|
||||
avatarUrl: { type: String, default: '' },
|
||||
autoPlay: { type: Boolean, default: true },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['speaking-start', 'speaking-end'])
|
||||
|
||||
const isH5 = ref(false)
|
||||
const canvasSize = 280
|
||||
const canvasId = 'faceCanvas'
|
||||
const isSpeaking = ref(false)
|
||||
const currentText = ref('')
|
||||
const imgFailed = ref(false)
|
||||
|
||||
let audioEl = null
|
||||
let audioCtx = null
|
||||
let analyser = null
|
||||
let animFrameId = null
|
||||
let mouthScale = 0
|
||||
let blinkTimer = null
|
||||
let faceCtx = null
|
||||
|
||||
// Face animation state
|
||||
let blinkFrame = 0 // 0=open, 1=half, 2=closed, then back
|
||||
let mouthOpen = 0 // 0-1
|
||||
let targetMouth = 0
|
||||
let lastBlinkTime = 0
|
||||
let faceW = canvasSize
|
||||
let faceH = canvasSize
|
||||
|
||||
onMounted(() => {
|
||||
isH5.value = typeof window !== 'undefined' && typeof document !== 'undefined'
|
||||
initCanvas()
|
||||
nextTick(() => initCanvas())
|
||||
scheduleBlink()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopAudio()
|
||||
if (blinkTimer) clearTimeout(blinkTimer)
|
||||
if (animFrameId) cancelAnimationFrame(animFrameId)
|
||||
})
|
||||
|
||||
watch(() => props.audioUrl, (url) => {
|
||||
@@ -65,52 +72,264 @@ watch(() => props.text, (txt) => {
|
||||
currentText.value = txt
|
||||
})
|
||||
|
||||
const avatarSrc = computed(() => {
|
||||
return props.avatarUrl || '/static/default-avatar.png'
|
||||
})
|
||||
|
||||
function initCanvas() {
|
||||
if (!isH5.value) return
|
||||
const canvas = document.getElementById('dh-mouth')
|
||||
if (!canvas) return
|
||||
// Size the canvas to match mouth area (~40% width, ~15% height, centered bottom)
|
||||
const parent = canvas.parentElement
|
||||
if (!parent) return
|
||||
const rect = parent.getBoundingClientRect()
|
||||
canvas.width = rect.width * 0.4
|
||||
canvas.height = rect.height * 0.15
|
||||
canvas.style.width = canvas.width + 'px'
|
||||
canvas.style.height = canvas.height + 'px'
|
||||
canvas.style.position = 'absolute'
|
||||
canvas.style.bottom = '18%'
|
||||
canvas.style.left = '30%'
|
||||
const query = uni.createSelectorQuery()
|
||||
query.select('#' + canvasId).fields({ node: true, size: true }).exec((res) => {
|
||||
if (!res || !res[0]) return
|
||||
const canvas = res[0].node
|
||||
const ctx = canvas.getContext('2d')
|
||||
const dpr = uni.getSystemInfoSync().pixelRatio
|
||||
canvas.width = canvasSize * dpr
|
||||
canvas.height = canvasSize * dpr
|
||||
ctx.scale(dpr, dpr)
|
||||
faceCtx = ctx
|
||||
faceW = canvasSize
|
||||
faceH = canvasSize
|
||||
drawFace(ctx, 0, false)
|
||||
})
|
||||
}
|
||||
|
||||
function drawMouth(openRatio) {
|
||||
if (!isH5.value) return
|
||||
const canvas = document.getElementById('dh-mouth')
|
||||
if (!canvas) return
|
||||
const ctx = canvas.getContext('2d')
|
||||
function drawFace(ctx, mouthRatio, isBlinking) {
|
||||
if (!ctx) return
|
||||
const w = faceW
|
||||
const h = faceH
|
||||
const cx = w / 2
|
||||
const cy = h / 2
|
||||
|
||||
const w = canvas.width
|
||||
const h = canvas.height
|
||||
ctx.clearRect(0, 0, w, h)
|
||||
|
||||
const mouthH = Math.max(2, h * openRatio)
|
||||
const mouthW = w * 0.8
|
||||
// Background
|
||||
ctx.fillStyle = '#F0F2F5'
|
||||
ctx.beginPath()
|
||||
ctx.arc(cx, cy, w / 2, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
|
||||
// Hair - brown wavy hair
|
||||
ctx.fillStyle = '#3D2B1F'
|
||||
ctx.beginPath()
|
||||
ctx.ellipse(cx, cy - h * 0.28, w * 0.36, h * 0.22, 0, Math.PI, 0)
|
||||
ctx.fill()
|
||||
// Hair sides
|
||||
ctx.beginPath()
|
||||
ctx.ellipse(cx - w * 0.24, cy - h * 0.08, w * 0.08, h * 0.22, -0.2, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
ctx.beginPath()
|
||||
ctx.ellipse(cx + w * 0.24, cy - h * 0.08, w * 0.08, h * 0.22, 0.2, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
|
||||
// Face oval
|
||||
const faceW_ = w * 0.32
|
||||
const faceH_ = h * 0.38
|
||||
ctx.fillStyle = '#FDE8D0'
|
||||
ctx.beginPath()
|
||||
ctx.ellipse(cx, cy + h * 0.02, faceW_, faceH_, 0, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
|
||||
// Neck
|
||||
ctx.fillStyle = '#FDE8D0'
|
||||
ctx.beginPath()
|
||||
ctx.ellipse(cx, cy + h * 0.32, w * 0.14, h * 0.10, 0, 0, Math.PI)
|
||||
ctx.fill()
|
||||
|
||||
// Collar / shoulders suggestion
|
||||
ctx.fillStyle = '#4B5563'
|
||||
ctx.beginPath()
|
||||
ctx.ellipse(cx, cy + h * 0.42, w * 0.28, h * 0.08, 0, Math.PI, 0)
|
||||
ctx.fill()
|
||||
ctx.fillStyle = '#374151'
|
||||
ctx.beginPath()
|
||||
ctx.ellipse(cx, cy + h * 0.44, w * 0.30, h * 0.06, 0, Math.PI, 0)
|
||||
ctx.fill()
|
||||
|
||||
// Cheek blush
|
||||
ctx.fillStyle = 'rgba(255, 150, 150, 0.3)'
|
||||
ctx.beginPath()
|
||||
ctx.ellipse(cx - w * 0.18, cy + h * 0.08, w * 0.07, h * 0.04, 0, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
ctx.beginPath()
|
||||
ctx.ellipse(cx + w * 0.18, cy + h * 0.08, w * 0.07, h * 0.04, 0, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
|
||||
// Nose
|
||||
ctx.strokeStyle = '#E8C8A8'
|
||||
ctx.lineWidth = 2
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(cx, cy - h * 0.02)
|
||||
ctx.lineTo(cx - w * 0.03, cy + h * 0.06)
|
||||
ctx.stroke()
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(cx, cy - h * 0.02)
|
||||
ctx.lineTo(cx + w * 0.03, cy + h * 0.06)
|
||||
ctx.stroke()
|
||||
// Nose tip
|
||||
ctx.fillStyle = '#E8C8A8'
|
||||
ctx.beginPath()
|
||||
ctx.ellipse(cx, cy + h * 0.06, w * 0.025, h * 0.015, 0, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
|
||||
// Eyes
|
||||
const eyeY = cy - h * 0.02
|
||||
const eyeSpacing = w * 0.13
|
||||
|
||||
function drawEye(x, y, blinkRatio) {
|
||||
// Eye white
|
||||
const eyeW = w * 0.09
|
||||
const eyeH = h * 0.05
|
||||
const openH = eyeH * (1 - blinkRatio)
|
||||
|
||||
ctx.fillStyle = '#FFFFFF'
|
||||
ctx.beginPath()
|
||||
ctx.ellipse(x, y, eyeW, Math.max(openH, 1), 0, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
|
||||
if (blinkRatio < 0.8) {
|
||||
// Iris
|
||||
const irisR = eyeW * 0.55
|
||||
ctx.fillStyle = '#5B4033'
|
||||
ctx.beginPath()
|
||||
ctx.arc(x, y, irisR, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
|
||||
// Pupil
|
||||
ctx.fillStyle = '#1A1A1A'
|
||||
ctx.beginPath()
|
||||
ctx.arc(x, y, irisR * 0.5, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
|
||||
// Eye highlight
|
||||
ctx.fillStyle = '#FFFFFF'
|
||||
ctx.beginPath()
|
||||
ctx.arc(x - irisR * 0.3, y - irisR * 0.3, irisR * 0.3, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
ctx.beginPath()
|
||||
ctx.arc(x + irisR * 0.15, y + irisR * 0.15, irisR * 0.15, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
|
||||
// Upper eyelid line
|
||||
ctx.strokeStyle = '#4A3728'
|
||||
ctx.lineWidth = 1.5
|
||||
ctx.beginPath()
|
||||
const lidH = eyeH * 0.5 * (1 - blinkRatio)
|
||||
ctx.ellipse(x, y - lidH * 0.3, eyeW * 0.85, lidH * 0.8, 0, Math.PI, 0)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
// Eyelid (covers eye when blinking)
|
||||
if (blinkRatio > 0) {
|
||||
ctx.fillStyle = '#FDE8D0'
|
||||
ctx.beginPath()
|
||||
ctx.ellipse(x, y - eyeH * 0.3, eyeW * 1.1, eyeH * 0.7 * blinkRatio + 1, 0, Math.PI, 0)
|
||||
ctx.fill()
|
||||
ctx.beginPath()
|
||||
ctx.ellipse(x, y + eyeH * 0.3, eyeW * 1.1, eyeH * 0.7 * blinkRatio + 1, 0, 0, Math.PI)
|
||||
ctx.fill()
|
||||
}
|
||||
|
||||
// Eyelash line
|
||||
ctx.strokeStyle = '#3D2B1F'
|
||||
ctx.lineWidth = 1.2
|
||||
ctx.beginPath()
|
||||
ctx.ellipse(x, y, eyeW * 0.9, Math.max(openH * 0.7, 1), 0, 0, Math.PI * 2)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
const blinkRatio = isBlinking ? 0.95 : 0
|
||||
drawEye(cx - eyeSpacing, eyeY, blinkRatio)
|
||||
drawEye(cx + eyeSpacing, eyeY, blinkRatio)
|
||||
|
||||
// Eyebrows
|
||||
function drawEyebrow(x, y) {
|
||||
ctx.strokeStyle = '#3D2B1F'
|
||||
ctx.lineWidth = 3
|
||||
ctx.lineCap = 'round'
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x - w * 0.06, y - h * 0.06)
|
||||
ctx.quadraticCurveTo(x, y - h * 0.09, x + w * 0.06, y - h * 0.06)
|
||||
ctx.stroke()
|
||||
}
|
||||
drawEyebrow(cx - eyeSpacing, eyeY)
|
||||
drawEyebrow(cx + eyeSpacing, eyeY)
|
||||
|
||||
// Mouth
|
||||
const mouthX = cx
|
||||
const mouthY = cy + h * 0.16
|
||||
const mouthBaseW = w * 0.10
|
||||
const mouthBaseH = h * 0.02
|
||||
const openAmount = mouthRatio * h * 0.06
|
||||
|
||||
ctx.fillStyle = '#C97B84'
|
||||
ctx.beginPath()
|
||||
ctx.ellipse(w / 2, h / 2 + (h - mouthH) / 2, mouthW / 2, mouthH / 2, 0, 0, Math.PI * 2)
|
||||
ctx.ellipse(mouthX, mouthY + openAmount * 0.5, mouthBaseW + mouthRatio * w * 0.02, mouthBaseH + openAmount, 0, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
|
||||
if (openRatio > 0.1) {
|
||||
ctx.fillStyle = '#2D1B1E'
|
||||
// Mouth line (when closed)
|
||||
if (mouthRatio < 0.1) {
|
||||
ctx.strokeStyle = '#B06570'
|
||||
ctx.lineWidth = 1.5
|
||||
ctx.beginPath()
|
||||
ctx.ellipse(w / 2, h / 2 + (h - mouthH) / 2 + 1, mouthW / 4, mouthH / 4, 0, 0, Math.PI * 2)
|
||||
ctx.moveTo(mouthX - mouthBaseW, mouthY)
|
||||
ctx.quadraticCurveTo(mouthX, mouthY + 2, mouthX + mouthBaseW, mouthY)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
// Inner mouth when open
|
||||
if (mouthRatio > 0.15) {
|
||||
ctx.fillStyle = '#5C2E36'
|
||||
ctx.beginPath()
|
||||
ctx.ellipse(mouthX, mouthY + openAmount * 0.6, mouthBaseW * 0.7, openAmount * 0.7, 0, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
}
|
||||
|
||||
// Subtle smile lines
|
||||
ctx.strokeStyle = '#E8C8A8'
|
||||
ctx.lineWidth = 1
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(mouthX - mouthBaseW - w * 0.02, mouthY - h * 0.01)
|
||||
ctx.quadraticCurveTo(mouthX - mouthBaseW - w * 0.01, mouthY - h * 0.005, mouthX - mouthBaseW, mouthY)
|
||||
ctx.stroke()
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(mouthX + mouthBaseW + w * 0.02, mouthY - h * 0.01)
|
||||
ctx.quadraticCurveTo(mouthX + mouthBaseW + w * 0.01, mouthY - h * 0.005, mouthX + mouthBaseW, mouthY)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
function scheduleBlink() {
|
||||
const delay = 2000 + Math.random() * 3000
|
||||
blinkTimer = setTimeout(() => {
|
||||
doBlink()
|
||||
scheduleBlink()
|
||||
}, delay)
|
||||
}
|
||||
|
||||
function doBlink() {
|
||||
if (!faceCtx) return
|
||||
let frame = 0
|
||||
const totalFrames = 8
|
||||
|
||||
function blinkTick() {
|
||||
frame++
|
||||
const progress = frame / totalFrames
|
||||
const blinkAmt = progress < 0.5 ? progress * 2 : (1 - progress) * 2
|
||||
drawFace(faceCtx, mouthOpen, true)
|
||||
if (frame < totalFrames) {
|
||||
setTimeout(blinkTick, 30)
|
||||
} else {
|
||||
drawFace(faceCtx, mouthOpen, false)
|
||||
}
|
||||
}
|
||||
blinkTick()
|
||||
}
|
||||
|
||||
function animateLoop() {
|
||||
if (!faceCtx) return
|
||||
|
||||
// Smooth mouth movement
|
||||
mouthOpen += (targetMouth - mouthOpen) * 0.15
|
||||
if (Math.abs(mouthOpen - targetMouth) < 0.01) mouthOpen = targetMouth
|
||||
|
||||
drawFace(faceCtx, mouthOpen, false)
|
||||
|
||||
animFrameId = setTimeout(() => animateLoop(), 50)
|
||||
}
|
||||
|
||||
async function playAudio(url) {
|
||||
@@ -118,75 +337,86 @@ async function playAudio(url) {
|
||||
isSpeaking.value = true
|
||||
emit('speaking-start')
|
||||
|
||||
// Start mouth animation
|
||||
targetMouth = 0.4
|
||||
animateLoop()
|
||||
|
||||
try {
|
||||
audioEl = new Audio(url)
|
||||
// Use uni-app audio API
|
||||
const innerAudio = uni.createInnerAudioContext()
|
||||
audioEl = innerAudio
|
||||
innerAudio.src = url
|
||||
innerAudio.autoplay = true
|
||||
|
||||
if (isH5.value) {
|
||||
audioCtx = new (window.AudioContext || window.webkitAudioContext)()
|
||||
const source = audioCtx.createMediaElementSource(audioEl)
|
||||
analyser = audioCtx.createAnalyser()
|
||||
analyser.fftSize = 256
|
||||
source.connect(analyser)
|
||||
analyser.connect(audioCtx.destination)
|
||||
}
|
||||
innerAudio.onPlay(() => {
|
||||
// Start audio-driven mouth animation
|
||||
startAudioMouth(innerAudio)
|
||||
})
|
||||
|
||||
audioEl.onended = () => {
|
||||
innerAudio.onError(() => {
|
||||
finishSpeaking()
|
||||
}
|
||||
})
|
||||
|
||||
audioEl.onerror = () => {
|
||||
innerAudio.onEnded(() => {
|
||||
finishSpeaking()
|
||||
}
|
||||
})
|
||||
|
||||
await audioEl.play()
|
||||
innerAudio.onStop(() => {
|
||||
finishSpeaking()
|
||||
})
|
||||
|
||||
if (analyser) {
|
||||
animateMouth()
|
||||
}
|
||||
} catch (e) {
|
||||
finishSpeaking()
|
||||
}
|
||||
}
|
||||
|
||||
function animateMouth() {
|
||||
if (!analyser) return
|
||||
const dataArray = new Uint8Array(analyser.frequencyBinCount)
|
||||
function startAudioMouth(audio) {
|
||||
let lastTime = 0
|
||||
|
||||
function tick() {
|
||||
if (!isSpeaking.value) return
|
||||
analyser.getByteFrequencyData(dataArray)
|
||||
const sum = dataArray.reduce((a, b) => a + b, 0)
|
||||
const avg = sum / dataArray.length
|
||||
mouthScale = Math.min(1, avg / 128)
|
||||
// Smooth
|
||||
mouthScale = Math.max(0.05, mouthScale)
|
||||
drawMouth(mouthScale)
|
||||
animFrameId = requestAnimationFrame(tick)
|
||||
try {
|
||||
const currentTime = audio.currentTime
|
||||
if (currentTime > lastTime) {
|
||||
lastTime = currentTime
|
||||
// Random-ish mouth movement based on time for natural feel
|
||||
const t = currentTime * 8
|
||||
const amp = 0.2 + Math.abs(Math.sin(t)) * 0.3 + Math.abs(Math.sin(t * 1.7)) * 0.2
|
||||
targetMouth = Math.min(0.8, amp)
|
||||
}
|
||||
} catch {}
|
||||
|
||||
if (isSpeaking.value) {
|
||||
setTimeout(tick, 80)
|
||||
}
|
||||
}
|
||||
tick()
|
||||
}
|
||||
|
||||
function finishSpeaking() {
|
||||
isSpeaking.value = false
|
||||
emit('speaking-end')
|
||||
if (analyser) {
|
||||
mouthScale = 0
|
||||
drawMouth(0)
|
||||
targetMouth = 0
|
||||
setTimeout(() => {
|
||||
if (faceCtx && !isSpeaking.value) {
|
||||
mouthOpen = 0
|
||||
drawFace(faceCtx, 0, false)
|
||||
}
|
||||
}, 300)
|
||||
cleanupAudio()
|
||||
emit('speaking-end')
|
||||
}
|
||||
|
||||
function cleanupAudio() {
|
||||
if (animFrameId) { cancelAnimationFrame(animFrameId); animFrameId = null }
|
||||
if (audioCtx) { audioCtx.close().catch(() => {}); audioCtx = null }
|
||||
analyser = null
|
||||
if (animFrameId) { clearTimeout(animFrameId); animFrameId = null }
|
||||
if (audioEl) {
|
||||
try { audioEl.stop(); audioEl.destroy() } catch {}
|
||||
audioEl = null
|
||||
}
|
||||
}
|
||||
|
||||
function stopAudio() {
|
||||
if (audioEl) {
|
||||
try { audioEl.pause(); audioEl.src = '' } catch {}
|
||||
try { audioEl.stop(); audioEl.destroy() } catch {}
|
||||
}
|
||||
finishSpeaking()
|
||||
}
|
||||
@@ -199,56 +429,36 @@ defineExpose({ play: playAudio, stop: stopAudio })
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 20rpx 0;
|
||||
padding: 16rpx 0 8rpx;
|
||||
}
|
||||
|
||||
/* Avatar stage */
|
||||
.avatar-stage {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.avatar-ring {
|
||||
width: 200rpx;
|
||||
height: 200rpx;
|
||||
border-radius: 50%;
|
||||
border: 4rpx solid #E5E7EB;
|
||||
overflow: hidden;
|
||||
gap: 10rpx;
|
||||
position: relative;
|
||||
transition: border-color 0.3s, box-shadow 0.3s;
|
||||
}
|
||||
|
||||
.avatar-ring.speaking {
|
||||
border-color: #6366F1;
|
||||
box-shadow: 0 0 30rpx rgba(99, 102, 241, 0.3);
|
||||
.face-canvas {
|
||||
border-radius: 50%;
|
||||
transition: box-shadow 0.3s;
|
||||
}
|
||||
|
||||
.avatar-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
.face-canvas.speaking {
|
||||
box-shadow: 0 0 40rpx rgba(99, 102, 241, 0.4);
|
||||
}
|
||||
|
||||
.avatar-default {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg, #6366F1, #8B5CF6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
.status-dot {
|
||||
width: 14rpx;
|
||||
height: 14rpx;
|
||||
border-radius: 50%;
|
||||
background: #9CA3AF;
|
||||
position: absolute;
|
||||
top: 12rpx;
|
||||
right: 12rpx;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.avatar-initials {
|
||||
font-size: 48rpx;
|
||||
font-weight: 700;
|
||||
color: #FFFFFF;
|
||||
.status-dot.active {
|
||||
background: #10B981;
|
||||
box-shadow: 0 0 12rpx rgba(16, 185, 129, 0.6);
|
||||
}
|
||||
|
||||
.mouth-canvas {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.role-label {
|
||||
font-size: 22rpx;
|
||||
color: #6B7280;
|
||||
@@ -256,23 +466,19 @@ defineExpose({ play: playAudio, stop: stopAudio })
|
||||
padding: 4rpx 20rpx;
|
||||
border-radius: 20rpx;
|
||||
}
|
||||
|
||||
/* Speech bubble */
|
||||
.speech-area {
|
||||
margin-top: 24rpx;
|
||||
margin-top: 16rpx;
|
||||
padding: 0 40rpx;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.speech-bubble {
|
||||
background: #FFFFFF;
|
||||
border-radius: 16rpx;
|
||||
padding: 24rpx 28rpx;
|
||||
padding: 20rpx 24rpx;
|
||||
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.06);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.speech-bubble::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
@@ -282,10 +488,9 @@ defineExpose({ play: playAudio, stop: stopAudio })
|
||||
border-bottom-color: #FFFFFF;
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.speech-text {
|
||||
font-size: 28rpx;
|
||||
line-height: 1.7;
|
||||
font-size: 26rpx;
|
||||
line-height: 1.6;
|
||||
color: #1F2937;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -102,6 +102,12 @@ export const API_ENDPOINTS = {
|
||||
SYNTHESIZE: '/tts/synthesize',
|
||||
AUDIO: (hash: string) => `/tts/audio/${hash}`,
|
||||
},
|
||||
SHARE: {
|
||||
CREATE: '/share/create',
|
||||
STATS: '/share/stats',
|
||||
RECORDS: '/share/records',
|
||||
VISITORS: '/share/visitors',
|
||||
},
|
||||
} as const
|
||||
|
||||
const PROD_API_HOST = import.meta.env.VITE_PROD_API_HOST || 'https://zhiyinwx.yzrcloud.cn'
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "宇之然AI磁场",
|
||||
"appid": "__UNI__DEV__",
|
||||
"versionName": "1.0.6",
|
||||
"versionCode": "106",
|
||||
"versionName": "1.0.7",
|
||||
"versionCode": "107",
|
||||
"description": "职引 - 宇之然AI磁场旗下AI模拟面试平台,提供AI面试官模拟练习、简历智能优化、大厂面经题库,助你轻松应对校招面试。",
|
||||
"h5": {
|
||||
"title": "职引 - AI模拟面试 | 宇之然AI磁场",
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
{ "path": "pages/admin/admin", "style": { "navigationBarTitleText": "管理后台" } },
|
||||
{ "path": "pages/result/result", "style": { "navigationBarTitleText": "优化结果" } },
|
||||
{ "path": "pages/agreement/agreement", "style": { "navigationBarTitleText": "用户协议" } },
|
||||
{ "path": "pages/privacy/privacy", "style": { "navigationBarTitleText": "隐私政策" } }
|
||||
{ "path": "pages/privacy/privacy", "style": { "navigationBarTitleText": "隐私政策" } },
|
||||
{ "path": "pages/share/share", "style": { "navigationBarTitleText": "我的分享" } }
|
||||
],
|
||||
"tabBar": {
|
||||
"color": "#999999",
|
||||
|
||||
@@ -17,8 +17,10 @@
|
||||
<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 === 'resumes' }" @click="switchTab('resumes')">简历</text>
|
||||
<text class="tab" :class="{ active: tab === 'orders' }" @click="switchTab('orders')">订单</text>
|
||||
<text class="tab" :class="{ active: tab === 'pricing' }" @click="switchTab('pricing')">定价</text>
|
||||
<text class="tab" :class="{ active: tab === 'share' }" @click="switchTab('share')">分享</text>
|
||||
<text class="tab" :class="{ active: tab === 'admins' }" @click="switchTab('admins')">管理</text>
|
||||
</view>
|
||||
|
||||
@@ -35,6 +37,17 @@
|
||||
<text class="stat-label">总面试</text>
|
||||
<text class="stat-sub">今日 +{{ overview.todayInterviews }}</text>
|
||||
</view>
|
||||
<view class="stat-card">
|
||||
<text class="stat-num">{{ overview.resumeCount ?? 0 }}</text>
|
||||
<text class="stat-label">总简历</text>
|
||||
<text class="stat-sub">付费下载 {{ overview.paidDownloadCount ?? 0 }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="plan-cards">
|
||||
<view class="plan-card" v-for="(cnt, plan) in overview.planBreakdown" :key="plan">
|
||||
<text class="plan-num">{{ cnt }}</text>
|
||||
<text class="plan-label">{{ planNameMap[plan] || plan }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -46,11 +59,21 @@
|
||||
</view>
|
||||
<view class="user-list" v-if="!usersLoading">
|
||||
<view class="user-row" v-for="u in users" :key="u._id">
|
||||
<view class="user-main">
|
||||
<text class="user-phone">{{ u.phone || '--' }}</text>
|
||||
<text class="user-name">{{ u.nickname || '--' }}</text>
|
||||
<text class="user-plan" :class="{ vip: u.plan === 'vip' }">{{ u.plan === 'vip' ? '会员' : '免费' }}</text>
|
||||
<text class="user-remaining">剩{{ u.remaining || 0 }}次</text>
|
||||
<text class="user-vip-btn" v-if="u.plan !== 'vip'" @click="setVip(u._id)">设为会员</text>
|
||||
</view>
|
||||
<view class="user-badges">
|
||||
<text class="user-plan" :class="{ vip: u.plan === 'growth' || u.plan === 'vip' }">{{ u.plan === 'growth' || u.plan === 'vip' ? '会员' : '免费' }}</text>
|
||||
<text class="user-credit">面试:{{ u.interviewCredits ?? 0 }}</text>
|
||||
<text class="user-credit">优化:{{ u.resumeOptimizeCredits ?? 0 }}</text>
|
||||
<text class="user-credit">下载:{{ u.resumeDownloadCredits ?? 0 }}</text>
|
||||
<text class="user-credit share">分享:{{ u.shareCredits ?? 0 }}</text>
|
||||
</view>
|
||||
<view class="user-actions">
|
||||
<text class="user-action-btn" v-if="u.plan !== 'growth' && u.plan !== 'vip'" @click="setVip(u._id)">设为会员</text>
|
||||
<text class="user-action-btn credit" @click="openCreditModal(u)">调整额度</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="load-more" v-if="usersTotal > users.length" @click="loadMoreUsers">加载更多</text>
|
||||
</view>
|
||||
@@ -61,15 +84,41 @@
|
||||
<view v-if="tab === 'interviews'" class="section">
|
||||
<view class="iv-list" v-if="!ivLoading">
|
||||
<view class="iv-row" v-for="iv in interviews" :key="iv._id">
|
||||
<view class="iv-main">
|
||||
<text class="iv-pos">{{ iv.position }}</text>
|
||||
<text class="iv-user">{{ iv.userId?.phone || iv.userId?.nickname || '--' }}</text>
|
||||
</view>
|
||||
<view class="iv-meta">
|
||||
<text class="iv-status" :class="{ done: iv.status === 'completed' }">{{ iv.status === 'completed' ? '已完成' : '进行中' }}</text>
|
||||
<text class="iv-questions">{{ iv.questionCount || 0 }}题</text>
|
||||
<text class="iv-tag">{{ iv.questionCount || 0 }}题</text>
|
||||
<text class="iv-tag score">得分 {{ iv.totalScore ?? '-' }}</text>
|
||||
<text class="iv-tag filler" v-if="iv.fillerScore != null && iv.fillerScore > 0">语分析 {{ iv.fillerScore }}/{{ iv.fillerDensity ?? '-' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<text class="loading-text" v-if="ivLoading">加载中...</text>
|
||||
</view>
|
||||
|
||||
<!-- 简历 -->
|
||||
<view v-if="tab === 'resumes'" class="section">
|
||||
<view class="resume-list" v-if="!resumeLoading">
|
||||
<view class="resume-row" v-for="r in resumes" :key="r._id">
|
||||
<view class="resume-main">
|
||||
<text class="resume-title">{{ r.title }}</text>
|
||||
<text class="resume-user">{{ r.userId?.phone || r.userId?.nickname || '--' }}</text>
|
||||
</view>
|
||||
<view class="resume-meta">
|
||||
<text class="resume-tag">v{{ r.version }}</text>
|
||||
<text class="resume-tag" v-if="r.targetPosition">{{ r.targetPosition }}</text>
|
||||
<text class="resume-tag paid" v-if="r.paidDownload">付费下载</text>
|
||||
</view>
|
||||
<text class="resume-time">{{ r.createdAt?.slice(0,10) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="loading-text" v-if="resumeLoading">加载中...</text>
|
||||
<text class="empty-text" v-if="!resumeLoading && resumes.length === 0">暂无简历</text>
|
||||
</view>
|
||||
|
||||
|
||||
|
||||
<!-- 订单 -->
|
||||
@@ -183,6 +232,60 @@
|
||||
<button class="save-btn" @click="savePricing" :disabled="pricingLoading">保存定价配置</button>
|
||||
<text class="loading-text" v-if="pricingLoading">保存中...</text>
|
||||
</view>
|
||||
<!-- 分享 -->
|
||||
<view v-if="tab === 'share'" class="section">
|
||||
<view class="tabs in-tab">
|
||||
<text class="tab" :class="{ active: shareSubTab === 'records' }" @click="shareSubTab='records';loadShareRecords()">分享记录</text>
|
||||
<text class="tab" :class="{ active: shareSubTab === 'visitors' }" @click="shareSubTab='visitors';loadShareVisitors()">访问记录</text>
|
||||
</view>
|
||||
<view v-if="shareSubTab === 'records'">
|
||||
<view class="share-list" v-if="!shareLoading">
|
||||
<view class="share-row" v-for="r in shareRecords" :key="r.shareCode">
|
||||
<view class="share-main">
|
||||
<text class="share-title">{{ r.title }}</text>
|
||||
<text class="share-meta">{{ r.sharer?.nickname || '--' }} · {{ r.type }}</text>
|
||||
</view>
|
||||
<view class="share-stats">
|
||||
<text>访问 {{ r.visitCount }}</text>
|
||||
<text class="share-credited">有效 {{ r.creditedCount }}</text>
|
||||
</view>
|
||||
<text class="share-time">{{ r.createdAt?.slice(0,16).replace('T',' ') }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="loading-text" v-if="shareLoading">加载中...</text>
|
||||
<text class="empty-text" v-if="!shareLoading && shareRecords.length === 0">暂无分享记录</text>
|
||||
</view>
|
||||
<view v-if="shareSubTab === 'visitors'">
|
||||
<view class="share-list" v-if="!shareLoading">
|
||||
<view class="share-row" v-for="(v, i) in shareVisitors" :key="i">
|
||||
<view class="share-main">
|
||||
<text>分享者: {{ v.sharer?.nickname || '--' }}</text>
|
||||
<text class="share-meta">访客: {{ v.visitor?.nickname || '匿名' }}</text>
|
||||
</view>
|
||||
<view class="share-stats">
|
||||
<text class="badge" :class="v.credited ? 'badge-done' : 'badge-pend'">{{ v.credited ? '已积分' : '未积分' }}</text>
|
||||
</view>
|
||||
<text class="share-time">{{ v.createdAt?.slice(0,16).replace('T',' ') }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="loading-text" v-if="shareLoading">加载中...</text>
|
||||
<text class="empty-text" v-if="!shareLoading && shareVisitors.length === 0">暂无访问记录</text>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 额度调整弹窗 -->
|
||||
<view class="modal-mask" v-if="creditModal.show" @click="closeCreditModal">
|
||||
<view class="modal-content" @click.stop>
|
||||
<text class="modal-title">调整 {{ creditModal.user?.nickname || '用户' }} 的额度</text>
|
||||
<view class="cfg-row" v-for="t in creditTypes" :key="t.key">
|
||||
<text>{{ t.label }}</text>
|
||||
<input class="cfg-input" type="digit" v-model.number="t.value" :placeholder="t.key" />
|
||||
</view>
|
||||
<view class="modal-actions">
|
||||
<button class="modal-btn cancel" @click="closeCreditModal">取消</button>
|
||||
<button class="modal-btn confirm" @click="doAdjustCredits">确认调整</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 管理员 -->
|
||||
<view v-if="tab === 'admins'" class="section">
|
||||
<view class="search-bar">
|
||||
@@ -219,16 +322,20 @@ import { api, API_ENDPOINTS } from '../../config'
|
||||
const verified = ref(false)
|
||||
const adminName = ref('')
|
||||
const tab = ref('overview')
|
||||
const shareSubTab = ref('records')
|
||||
const loading = ref(false)
|
||||
const usersLoading = ref(false)
|
||||
const ivLoading = ref(false)
|
||||
const userKeyword = ref('')
|
||||
const usersPage = ref(1)
|
||||
|
||||
const overview = ref({ userCount: 0, interviewCount: 0, todayUsers: 0, todayInterviews: 0 })
|
||||
const overview = ref({ userCount: 0, interviewCount: 0, todayUsers: 0, todayInterviews: 0, resumeCount: 0, paidDownloadCount: 0, planBreakdown: {} })
|
||||
const planNameMap = { free: '免费', growth: '成长', sprint: '冲刺', vip: '会员' }
|
||||
const users = ref([])
|
||||
const usersTotal = ref(0)
|
||||
const interviews = ref([])
|
||||
const resumes = ref([])
|
||||
const resumeLoading = ref(false)
|
||||
const adminKeyword = ref('')
|
||||
const adminList = ref([])
|
||||
const searchResult = ref(null)
|
||||
@@ -261,6 +368,20 @@ const ordersPage = ref(1)
|
||||
const orderLoading = ref(false)
|
||||
const orderFilter = ref('')
|
||||
|
||||
// Share state
|
||||
const shareRecords = ref([])
|
||||
const shareVisitors = ref([])
|
||||
const shareLoading = ref(false)
|
||||
|
||||
// Credit modal
|
||||
const creditModal = ref({ show: false, user: null })
|
||||
const creditTypes = ref([
|
||||
{ key: 'interviewCredits', label: '面试次数', value: 0 },
|
||||
{ key: 'resumeOptimizeCredits', label: '优化次数', value: 0 },
|
||||
{ key: 'resumeDownloadCredits', label: '下载次数', value: 0 },
|
||||
{ key: 'shareCredits', label: '分享积分', value: 0 },
|
||||
])
|
||||
|
||||
const token = () => uni.getStorageSync('token') || ''
|
||||
|
||||
const apiAdmin = (path, opts = {}) => {
|
||||
@@ -300,6 +421,7 @@ const switchTab = (t) => {
|
||||
tab.value = t
|
||||
if (t === 'users' && users.value.length === 0) loadUsers()
|
||||
if (t === 'interviews' && interviews.value.length === 0) loadInterviews()
|
||||
if (t === 'resumes' && resumes.value.length === 0) loadResumes()
|
||||
if (t === 'admins' && adminList.value.length === 0) loadAdmins()
|
||||
if (t === 'pricing') loadPricing()
|
||||
if (t === 'orders') loadOrders()
|
||||
@@ -332,6 +454,15 @@ const loadInterviews = async () => {
|
||||
finally { ivLoading.value = false }
|
||||
}
|
||||
|
||||
const loadResumes = async () => {
|
||||
resumeLoading.value = true
|
||||
try {
|
||||
const res = await apiAdmin('/resumes?page=1&limit=20')
|
||||
if (res.statusCode === 200) resumes.value = res.data.list || []
|
||||
} catch (e) { console.error(e) }
|
||||
finally { resumeLoading.value = false }
|
||||
}
|
||||
|
||||
const loadPricing = async () => {
|
||||
pricingLoading.value = true
|
||||
try {
|
||||
@@ -462,6 +593,54 @@ const setVip = async (targetUserId) => {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const loadShareRecords = async () => {
|
||||
shareLoading.value = true
|
||||
try {
|
||||
const res = await apiAdmin('/share-records?page=1&limit=50')
|
||||
if (res.statusCode === 200) shareRecords.value = res.data.list || []
|
||||
} catch(e) { console.error(e) }
|
||||
finally { shareLoading.value = false }
|
||||
}
|
||||
|
||||
const loadShareVisitors = async () => {
|
||||
shareLoading.value = true
|
||||
try {
|
||||
const res = await apiAdmin('/share-visitors?page=1&limit=50')
|
||||
if (res.statusCode === 200) shareVisitors.value = res.data.list || []
|
||||
} catch(e) { console.error(e) }
|
||||
finally { shareLoading.value = false }
|
||||
}
|
||||
|
||||
const openCreditModal = (user) => {
|
||||
creditTypes.value = [
|
||||
{ key: 'interviewCredits', label: '面试次数', value: user.interviewCredits ?? 0 },
|
||||
{ key: 'resumeOptimizeCredits', label: '优化次数', value: user.resumeOptimizeCredits ?? 0 },
|
||||
{ key: 'resumeDownloadCredits', label: '下载次数', value: user.resumeDownloadCredits ?? 0 },
|
||||
{ key: 'shareCredits', label: '分享积分', value: user.shareCredits ?? 0 },
|
||||
]
|
||||
creditModal.value = { show: true, user }
|
||||
}
|
||||
|
||||
const closeCreditModal = () => {
|
||||
creditModal.value = { show: false, user: null }
|
||||
}
|
||||
|
||||
const doAdjustCredits = async () => {
|
||||
const userId = creditModal.value.user?._id
|
||||
if (!userId) return
|
||||
try {
|
||||
for (const t of creditTypes.value) {
|
||||
await apiAdmin('/user/credits', {
|
||||
method: 'POST',
|
||||
data: { userId, type: t.key, amount: t.value },
|
||||
})
|
||||
}
|
||||
uni.showToast({ title: '调整成功', icon: 'success' })
|
||||
closeCreditModal()
|
||||
loadUsers()
|
||||
} catch { uni.showToast({ title: '调整失败', icon: 'none' }) }
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -481,22 +660,37 @@ const setVip = async (targetUserId) => {
|
||||
.stat-num { font-size: 48rpx; font-weight: 800; color: var(--color-primary); display: block; }
|
||||
.stat-label { font-size: 22rpx; color: var(--color-text-secondary); margin-top: 8rpx; display: block; }
|
||||
.stat-sub { font-size: 20rpx; color: var(--color-success); margin-top: 4rpx; display: block; }
|
||||
.plan-cards { display: flex; gap: 12rpx; margin-top: 16rpx; }
|
||||
.plan-card { flex: 1; background: #FFF; border-radius: var(--radius-sm); padding: 20rpx; text-align: center; box-shadow: var(--shadow-sm); }
|
||||
.plan-num { font-size: 36rpx; font-weight: 700; color: var(--color-primary); display: block; }
|
||||
.plan-label { font-size: 20rpx; color: var(--color-text-secondary); margin-top: 4rpx; display: block; }
|
||||
.search-bar { display: flex; gap: 12rpx; margin-bottom: 16rpx; }
|
||||
.search-input { flex: 1; height: 64rpx; background: #FFF; border: 2rpx solid var(--color-border); border-radius: var(--radius-sm); padding: 0 16rpx; font-size: 24rpx; }
|
||||
.search-btn { height: 64rpx; padding: 0 24rpx; background: var(--color-primary); color: #FFF; border-radius: var(--radius-sm); font-size: 24rpx; border: none; }
|
||||
.user-row { background: #FFF; padding: 20rpx; border-radius: var(--radius-sm); margin-bottom: 8rpx; display: flex; flex-wrap: wrap; gap: 8rpx; }
|
||||
.user-row { background: #FFF; padding: 20rpx; border-radius: var(--radius-sm); margin-bottom: 8rpx; }
|
||||
.user-main { display: flex; gap: 12rpx; margin-bottom: 8rpx; }
|
||||
.user-phone { font-size: 24rpx; font-weight: 600; color: var(--color-text); }
|
||||
.user-name { font-size: 22rpx; color: var(--color-text-secondary); }
|
||||
.user-badges { display: flex; flex-wrap: wrap; gap: 6rpx; margin-bottom: 8rpx; }
|
||||
.user-plan { font-size: 20rpx; background: #EEF2FF; color: var(--color-primary); padding: 2rpx 12rpx; border-radius: var(--radius-round); }
|
||||
.user-remaining { font-size: 20rpx; color: var(--color-text-tertiary); }
|
||||
.user-plan.vip { background: #FEF3C7; color: #D97706; }
|
||||
.user-credit { font-size: 18rpx; background: #F3F4F6; color: var(--color-text-tertiary); padding: 2rpx 10rpx; border-radius: var(--radius-round); }
|
||||
.user-credit.share { background: #FFF7ED; color: #D97706; }
|
||||
.user-actions { display: flex; gap: 12rpx; }
|
||||
.user-action-btn { font-size: 20rpx; color: var(--color-primary); padding: 4rpx 16rpx; border: 2rpx solid var(--color-primary); border-radius: var(--radius-round); }
|
||||
.user-action-btn.credit { color: #D97706; border-color: #D97706; }
|
||||
.loading-text { text-align: center; padding: 40rpx; color: var(--color-text-tertiary); font-size: 24rpx; }
|
||||
.load-more { text-align: center; padding: 20rpx; color: var(--color-primary); font-size: 24rpx; display: block; }
|
||||
.iv-row { background: #FFF; padding: 20rpx; border-radius: var(--radius-sm); margin-bottom: 8rpx; display: flex; flex-wrap: wrap; gap: 8rpx; align-items: center; }
|
||||
.iv-row { background: #FFF; padding: 16rpx; border-radius: var(--radius-sm); margin-bottom: 8rpx; }
|
||||
.iv-main { display: flex; gap: 12rpx; margin-bottom: 6rpx; }
|
||||
.iv-pos { font-size: 24rpx; font-weight: 600; color: var(--color-text); }
|
||||
.iv-user { font-size: 22rpx; color: var(--color-text-secondary); }
|
||||
.iv-meta { display: flex; flex-wrap: wrap; gap: 6rpx; }
|
||||
.iv-status { font-size: 20rpx; background: #FFF7ED; color: var(--color-warning); padding: 2rpx 12rpx; border-radius: var(--radius-round); }
|
||||
.iv-status.done { background: #ECFDF5; color: var(--color-success); }
|
||||
.iv-questions { font-size: 20rpx; color: var(--color-text-tertiary); }
|
||||
.iv-tag { font-size: 18rpx; background: #F3F4F6; color: var(--color-text-tertiary); padding: 2rpx 10rpx; border-radius: var(--radius-round); }
|
||||
.iv-tag.score { background: #EEF2FF; color: var(--color-primary); }
|
||||
.iv-tag.filler { background: #FFF7ED; color: #D97706; }
|
||||
.section-label { font-size: 24rpx; font-weight: 600; color: var(--color-text); margin-bottom: 12rpx; margin-top: 12rpx; }
|
||||
.admin-row { background: #FFF; padding: 20rpx; border-radius: var(--radius-sm); margin-bottom: 8rpx; display: flex; flex-wrap: wrap; gap: 8rpx; align-items: center; }
|
||||
.admin-phone { font-size: 24rpx; font-weight: 600; color: var(--color-text); }
|
||||
@@ -505,6 +699,15 @@ 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; }
|
||||
.resume-list { display: flex; flex-direction: column; gap: 8rpx; }
|
||||
.resume-row { background: #FFF; padding: 16rpx; border-radius: var(--radius-sm); display: flex; align-items: center; gap: 12rpx; }
|
||||
.resume-main { flex: 1; display: flex; flex-direction: column; }
|
||||
.resume-title { font-size: 22rpx; font-weight: 600; color: var(--color-text); }
|
||||
.resume-user { font-size: 20rpx; color: var(--color-text-secondary); margin-top: 2rpx; }
|
||||
.resume-meta { display: flex; gap: 6rpx; }
|
||||
.resume-tag { font-size: 18rpx; background: #F3F4F6; color: var(--color-text-tertiary); padding: 2rpx 10rpx; border-radius: var(--radius-round); }
|
||||
.resume-tag.paid { background: #FEF3C7; color: #D97706; }
|
||||
.resume-time { font-size: 18rpx; color: #D1D5DB; white-space: nowrap; }
|
||||
.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; }
|
||||
@@ -527,4 +730,23 @@ const setVip = async (targetUserId) => {
|
||||
.cfg-textarea { width: 100%; height: 160rpx; border: 2rpx solid var(--color-border); border-radius: var(--radius-sm); padding: 12rpx; font-size: 22rpx; margin-top: 8rpx; box-sizing: border-box; }
|
||||
.save-btn { width: 100%; height: 72rpx; background: linear-gradient(135deg, var(--color-gradient-start), var(--color-gradient-mid)); color: #FFF; border-radius: var(--radius-sm); font-size: 26rpx; border: none; margin-top: 12rpx; }
|
||||
.save-btn:disabled { opacity: 0.6; }
|
||||
.in-tab { margin-bottom: 16rpx; }
|
||||
.share-list { display: flex; flex-direction: column; gap: 8rpx; }
|
||||
.share-row { background: #FFF; padding: 16rpx; border-radius: var(--radius-sm); display: flex; align-items: center; gap: 12rpx; }
|
||||
.share-main { flex: 1; display: flex; flex-direction: column; }
|
||||
.share-title { font-size: 22rpx; color: var(--color-text); font-weight: 500; }
|
||||
.share-meta { font-size: 18rpx; color: var(--color-text-tertiary); margin-top: 2rpx; }
|
||||
.share-stats { display: flex; flex-direction: column; align-items: flex-end; font-size: 20rpx; color: var(--color-text-tertiary); }
|
||||
.share-credited { color: var(--color-primary); }
|
||||
.share-time { font-size: 18rpx; color: #D1D5DB; white-space: nowrap; }
|
||||
.badge { font-size: 18rpx; padding: 2rpx 10rpx; border-radius: 6rpx; }
|
||||
.badge-done { background: #ECFDF5; color: #059669; }
|
||||
.badge-pend { background: #FEF3C7; color: #D97706; }
|
||||
.modal-mask { position: fixed; inset: 0; background: rgba(0,0,0,0.4); display: flex; align-items: center; justify-content: center; z-index: 999; }
|
||||
.modal-content { background: #FFF; border-radius: var(--radius-lg); padding: 40rpx 32rpx; width: 600rpx; max-width: 90vw; }
|
||||
.modal-title { font-size: 28rpx; font-weight: 600; color: var(--color-text); margin-bottom: 24rpx; }
|
||||
.modal-actions { display: flex; gap: 16rpx; margin-top: 32rpx; }
|
||||
.modal-btn { flex: 1; height: 72rpx; border-radius: var(--radius-sm); font-size: 26rpx; border: none; }
|
||||
.modal-btn.cancel { background: #F3F4F6; color: var(--color-text-secondary); }
|
||||
.modal-btn.confirm { background: var(--color-primary); color: #FFF; }
|
||||
</style>
|
||||
|
||||
@@ -53,11 +53,14 @@
|
||||
</scroll-view>
|
||||
|
||||
<view class="input-bar" v-if="!isComplete">
|
||||
<view class="mic-btn" :class="{ recording: isRecording }" @touchstart="startRecord" @touchend="stopRecord" @touchcancel="stopRecord">
|
||||
<text class="mic-icon">🎤</text>
|
||||
</view>
|
||||
<view class="input-box">
|
||||
<textarea class="input-area" v-model="inputText" placeholder="输入你的回答..." :auto-height="true" :maxlength="2000" :disabled="aiLoading" @confirm="sendAnswer" />
|
||||
</view>
|
||||
<view class="send-btn" :class="{ disabled: !inputText.trim() || aiLoading }" @click="sendAnswer">
|
||||
<text class="send-icon">➤</text>
|
||||
<view class="send-btn" :class="{ disabled: (!inputText.trim() && !isRecording) || aiLoading }" @click="sendAnswer">
|
||||
<text class="send-icon">{{ isRecording ? '◉' : '➤' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -88,6 +91,8 @@ const aiSpeechText = ref('')
|
||||
const aiAudioUrl = ref('')
|
||||
const isSpeaking = ref(false)
|
||||
const dhRef = ref(null)
|
||||
const isRecording = ref(false)
|
||||
let recorder = null
|
||||
|
||||
let timerSeconds = 0
|
||||
let timerInterval = null
|
||||
@@ -227,6 +232,43 @@ const confirmExit = () => {
|
||||
success: (r) => { if (r.confirm) uni.navigateBack() },
|
||||
})
|
||||
}
|
||||
|
||||
function startRecord() {
|
||||
if (aiLoading.value || isComplete.value) return
|
||||
isRecording.value = true
|
||||
recorder = uni.getRecorderManager()
|
||||
recorder.onStart(() => {})
|
||||
recorder.onError(() => { isRecording.value = false })
|
||||
recorder.start({ format: 'mp3' })
|
||||
uni.vibrateShort({ type: 'medium' })
|
||||
}
|
||||
|
||||
function stopRecord() {
|
||||
if (!recorder || !isRecording.value) return
|
||||
isRecording.value = false
|
||||
recorder.stop()
|
||||
recorder.onStop(async (res) => {
|
||||
if (!res.tempFilePath) return
|
||||
const audioPath = res.tempFilePath
|
||||
try {
|
||||
const uploadRes = await uni.uploadFile({
|
||||
url: api('/asr/recognize'),
|
||||
filePath: audioPath,
|
||||
name: 'audio',
|
||||
header: { 'Authorization': `Bearer ${token.value}` },
|
||||
})
|
||||
if (uploadRes.statusCode === 200 && uploadRes.data) {
|
||||
const data = typeof uploadRes.data === 'string' ? JSON.parse(uploadRes.data) : uploadRes.data
|
||||
if (data.text) {
|
||||
inputText.value = data.text
|
||||
uni.vibrateShort({ type: 'light' })
|
||||
return
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
uni.showToast({ title: '语音识别失败,请手动输入', icon: 'none' })
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -300,6 +342,18 @@ const confirmExit = () => {
|
||||
}
|
||||
.input-box { flex: 1; background: var(--color-bg); border-radius: var(--radius-md); padding: 12rpx 20rpx; }
|
||||
.input-area { width: 100%; font-size: 26rpx; color: var(--color-text); max-height: 160rpx; line-height: 1.5; }
|
||||
.mic-btn {
|
||||
width: 64rpx; height: 64rpx; border-radius: 50%; background: #F3F4F6;
|
||||
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.mic-btn:active { transform: scale(0.9); }
|
||||
.mic-btn.recording { background: #FEE2E2; animation: mic-pulse 1s infinite; }
|
||||
.mic-icon { font-size: 28rpx; }
|
||||
@keyframes mic-pulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4); }
|
||||
50% { box-shadow: 0 0 0 16rpx rgba(239, 68, 68, 0); }
|
||||
}
|
||||
.send-btn {
|
||||
width: 80rpx; height: 80rpx; background: linear-gradient(135deg, var(--color-gradient-start), var(--color-gradient-mid));
|
||||
border-radius: 50%; display: flex; align-items: center; justify-content: center; flex-shrink: 0;
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
<template>
|
||||
<view class="page fade-in">
|
||||
<!-- 统计卡片 -->
|
||||
<view class="stats-card">
|
||||
<view class="stat-row">
|
||||
<view class="stat-item">
|
||||
<text class="stat-value">{{ stats.shareCredits || 0 }}</text>
|
||||
<text class="stat-label">📦 我的积分</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="stat-sub-row">
|
||||
<view class="stat-sub-item">
|
||||
<text class="stat-sub-value">{{ stats.totalVisits || 0 }}</text>
|
||||
<text class="stat-sub-label">总点击</text>
|
||||
</view>
|
||||
<view class="stat-arrow">→</view>
|
||||
<view class="stat-sub-item">
|
||||
<text class="stat-sub-value">{{ stats.creditedCount || 0 }}</text>
|
||||
<text class="stat-sub-label">有效注册</text>
|
||||
</view>
|
||||
<view class="stat-arrow">→</view>
|
||||
<view class="stat-sub-item">
|
||||
<text class="stat-sub-value">{{ stats.shareCredits || 0 }}</text>
|
||||
<text class="stat-sub-label">获得积分</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="hint">
|
||||
💡 好友通过你的链接打开并 <text class="hint-em">登录/注册</text> 才算有效,每次有效得 1 积分
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 今日数据 -->
|
||||
<view class="today-card">
|
||||
<text class="today-title">今日数据</text>
|
||||
<view class="today-row">
|
||||
<view class="today-item">
|
||||
<text class="today-value">{{ todayStats.visits }}</text>
|
||||
<text class="today-label">点击</text>
|
||||
</view>
|
||||
<view class="today-item">
|
||||
<text class="today-value">{{ todayStats.credited }}</text>
|
||||
<text class="today-label">有效</text>
|
||||
</view>
|
||||
<view class="today-item">
|
||||
<text class="today-value">{{ 3 - todayStats.credited > 0 ? 3 - todayStats.credited : 0 }}</text>
|
||||
<text class="today-label">今日剩余</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="today-bar">
|
||||
<view class="today-bar-fill" :style="{ width: Math.min(100, (todayStats.credited / 3) * 100) + '%' }"></view>
|
||||
</view>
|
||||
<text class="today-hint">每日最多 3 次有效积分</text>
|
||||
</view>
|
||||
|
||||
<!-- 分享按钮 -->
|
||||
<view class="share-actions">
|
||||
<button class="share-btn wx-share" @click="shareToWechat" v-if="isWechat">
|
||||
<text class="btn-icon">💬</text>
|
||||
<text>分享给微信好友</text>
|
||||
</button>
|
||||
<button class="share-btn link-share" @click="copyLink">
|
||||
<text class="btn-icon">🔗</text>
|
||||
<text>复制分享链接</text>
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<!-- Tab 切换 -->
|
||||
<view class="tab-bar">
|
||||
<view class="tab-item" :class="{ active: tab === 'records' }" @click="tab = 'records'">分享记录</view>
|
||||
<view class="tab-item" :class="{ active: tab === 'visitors' }" @click="tab = 'visitors'">访问明细</view>
|
||||
</view>
|
||||
|
||||
<!-- 分享记录 -->
|
||||
<view class="list" v-if="tab === 'records'">
|
||||
<view class="list-item" v-for="item in records" :key="item.shareCode">
|
||||
<view class="item-main">
|
||||
<text class="item-type">{{ typeLabel(item.type) }}</text>
|
||||
<text class="item-title">{{ item.title }}</text>
|
||||
</view>
|
||||
<view class="item-stats">
|
||||
<text class="item-stat">👀 {{ item.visitCount }}点击</text>
|
||||
<text class="item-stat active">✅ {{ item.creditedCount }}有效</text>
|
||||
</view>
|
||||
<text class="item-time">{{ formatTime(item.createdAt) }}</text>
|
||||
</view>
|
||||
<view class="empty" v-if="records.length === 0">
|
||||
<text class="empty-icon">📤</text>
|
||||
<text class="empty-text">还没有分享记录</text>
|
||||
<text class="empty-hint">点击上方按钮分享给好友吧</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 访问明细 -->
|
||||
<view class="list" v-if="tab === 'visitors'">
|
||||
<view class="list-item" v-for="(item, idx) in visitors" :key="idx">
|
||||
<image class="visitor-avatar" :src="item.avatar || '/static/avatar-default.svg'" />
|
||||
<view class="item-main">
|
||||
<text class="item-title">{{ item.nickname || '👤 未登录访客' }}</text>
|
||||
<text class="item-time">{{ formatTime(item.createdAt) }}</text>
|
||||
</view>
|
||||
<view class="badge" :class="item.credited ? 'badge-done' : 'badge-pending'">
|
||||
{{ item.credited ? '✅ 已积分' : '⏳ 未注册' }}
|
||||
</view>
|
||||
</view>
|
||||
<view class="empty" v-if="visitors.length === 0">
|
||||
<text class="empty-icon">👀</text>
|
||||
<text class="empty-text">还没有访问记录</text>
|
||||
<text class="empty-hint">分享后好友访问就会出现在这里</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { api } from '../../config'
|
||||
|
||||
const tab = ref('records')
|
||||
const stats = ref({ totalShares: 0, totalVisits: 0, creditedCount: 0, todayCredited: 0, shareCredits: 0 })
|
||||
const records = ref([])
|
||||
const visitors = ref([])
|
||||
|
||||
const todayStats = computed(() => ({
|
||||
visits: stats.value.todayCredited + Math.round(Math.random() * 0),
|
||||
credited: stats.value.todayCredited || 0,
|
||||
}))
|
||||
|
||||
let isWechat = false
|
||||
|
||||
onMounted(() => {
|
||||
isWechat = navigator?.userAgent?.toLowerCase().includes('micromessenger') || false
|
||||
loadData()
|
||||
})
|
||||
|
||||
async function loadData() {
|
||||
const token = uni.getStorageSync('token')
|
||||
if (!token) { uni.showToast({ title: '请先登录', icon: 'none' }); return }
|
||||
const header = { Authorization: `Bearer ${token}` }
|
||||
|
||||
try {
|
||||
const [statsRes, recordsRes, visitorsRes] = await Promise.all([
|
||||
uni.request({ url: api('/share/stats'), method: 'GET', header }),
|
||||
uni.request({ url: api('/share/records'), method: 'GET', header }),
|
||||
uni.request({ url: api('/share/visitors'), method: 'GET', header }),
|
||||
])
|
||||
if (statsRes.statusCode === 200) stats.value = statsRes.data
|
||||
if (recordsRes.statusCode === 200) records.value = recordsRes.data.list || []
|
||||
if (visitorsRes.statusCode === 200) visitors.value = visitorsRes.data.list || []
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
async function shareToWechat() {
|
||||
const token = uni.getStorageSync('token')
|
||||
if (!token) return
|
||||
|
||||
try {
|
||||
const res = await uni.request({
|
||||
url: api('/share/create'),
|
||||
method: 'POST',
|
||||
data: { type: 'app', title: '我在AI磁场·职引练习面试', description: 'AI模拟面试+简历优化,快来一起提升吧' },
|
||||
header: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
if (res.statusCode !== 200) return
|
||||
|
||||
const data = res.data
|
||||
const path = data.wechatShareInfo?.path || `/pages/share/share?code=${data.shareCode}`
|
||||
|
||||
uni.shareAppMessage({
|
||||
provider: 'weixin',
|
||||
title: data.wechatShareInfo?.title || 'AI磁场·职引',
|
||||
description: data.wechatShareInfo?.description || '',
|
||||
path,
|
||||
imageUrl: 'https://zhiyinwx.yzrcloud.cn/static/share-card.png',
|
||||
success: () => { uni.showToast({ title: '分享成功', icon: 'success' }); loadData() },
|
||||
fail: () => { uni.showToast({ title: '分享取消', icon: 'none' }) },
|
||||
})
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
async function copyLink() {
|
||||
const token = uni.getStorageSync('token')
|
||||
if (!token) return
|
||||
|
||||
try {
|
||||
const res = await uni.request({
|
||||
url: api('/share/create'),
|
||||
method: 'POST',
|
||||
data: { type: 'app', title: '我在AI磁场·职引练习面试', description: 'AI模拟面试+简历优化,快来一起提升吧' },
|
||||
header: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
if (res.statusCode !== 200) return
|
||||
const shareUrl = `https://zhiyinwx.yzrcloud.cn/share/${res.data.shareCode}`
|
||||
uni.setClipboardData({ data: shareUrl, success: () => { uni.showToast({ title: '链接已复制' }); loadData() } })
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
function typeLabel(type) {
|
||||
const map = { app: '应用', interview: '面试', resume: '简历' }
|
||||
return map[type] || type
|
||||
}
|
||||
|
||||
function formatTime(t) {
|
||||
if (!t) return ''
|
||||
const d = new Date(t)
|
||||
return `${d.getMonth() + 1}/${d.getDate()} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page { min-height: 100vh; background: var(--color-bg); padding-bottom: 48rpx; }
|
||||
|
||||
/* Stats card */
|
||||
.stats-card { background: linear-gradient(135deg, #4F46E5 0%, #7C3AED 100%); margin: 24rpx 32rpx; border-radius: var(--radius-lg); padding: 32rpx; }
|
||||
.stat-row { display: flex; justify-content: center; margin-bottom: 20rpx; }
|
||||
.stat-item { display: flex; flex-direction: column; align-items: center; }
|
||||
.stat-value { font-size: 56rpx; font-weight: 800; color: #FFFFFF; }
|
||||
.stat-label { font-size: 22rpx; color: rgba(255,255,255,0.75); margin-top: 6rpx; }
|
||||
|
||||
.stat-sub-row { display: flex; align-items: center; justify-content: center; gap: 12rpx; padding: 16rpx 0; border-top: 1rpx solid rgba(255,255,255,0.15); border-bottom: 1rpx solid rgba(255,255,255,0.15); margin-bottom: 16rpx; }
|
||||
.stat-sub-item { display: flex; flex-direction: column; align-items: center; }
|
||||
.stat-sub-value { font-size: 32rpx; font-weight: 700; color: #FFFFFF; }
|
||||
.stat-sub-label { font-size: 18rpx; color: rgba(255,255,255,0.6); margin-top: 4rpx; }
|
||||
.stat-arrow { font-size: 24rpx; color: rgba(255,255,255,0.4); }
|
||||
.hint { font-size: 20rpx; color: rgba(255,255,255,0.7); text-align: center; line-height: 1.6; }
|
||||
.hint-em { color: #FCD34D; font-weight: 600; }
|
||||
|
||||
/* Today card */
|
||||
.today-card { background: #FFFFFF; margin: 0 32rpx 24rpx; border-radius: var(--radius-md); padding: 24rpx; box-shadow: var(--shadow-sm); }
|
||||
.today-title { font-size: 24rpx; font-weight: 600; color: var(--color-text); margin-bottom: 16rpx; }
|
||||
.today-row { display: flex; gap: 24rpx; margin-bottom: 16rpx; }
|
||||
.today-item { flex: 1; display: flex; flex-direction: column; align-items: center; }
|
||||
.today-value { font-size: 36rpx; font-weight: 700; color: var(--color-primary); }
|
||||
.today-label { font-size: 20rpx; color: var(--color-text-tertiary); margin-top: 4rpx; }
|
||||
.today-bar { height: 8rpx; background: #F3F4F6; border-radius: 4rpx; overflow: hidden; margin-bottom: 8rpx; }
|
||||
.today-bar-fill { height: 100%; background: linear-gradient(90deg, #4F46E5, #7C3AED); border-radius: 4rpx; transition: width 0.3s; }
|
||||
.today-hint { font-size: 18rpx; color: var(--color-text-tertiary); text-align: center; }
|
||||
|
||||
/* Share buttons */
|
||||
.share-actions { padding: 0 32rpx; display: flex; gap: 20rpx; }
|
||||
.share-btn { flex: 1; display: flex; align-items: center; justify-content: center; height: 88rpx; border-radius: var(--radius-md); font-size: 26rpx; font-weight: 500; border: none; }
|
||||
.share-btn:active { transform: scale(0.96); }
|
||||
.btn-icon { margin-right: 8rpx; font-size: 28rpx; }
|
||||
.wx-share { background: #07C160; color: #FFFFFF; }
|
||||
.link-share { background: #FFFFFF; color: var(--color-text); border: 2rpx solid var(--color-border); }
|
||||
|
||||
/* Tabs */
|
||||
.tab-bar { display: flex; margin: 32rpx 32rpx 0; border-bottom: 2rpx solid var(--color-border); }
|
||||
.tab-item { flex: 1; text-align: center; padding: 20rpx 0; font-size: 28rpx; color: #9CA3AF; font-weight: 500; position: relative; }
|
||||
.tab-item.active { color: var(--color-primary); }
|
||||
.tab-item.active::after { content: ''; position: absolute; bottom: -2rpx; left: 30%; right: 30%; height: 4rpx; background: var(--color-primary); border-radius: 2rpx; }
|
||||
|
||||
/* List */
|
||||
.list { padding: 0 32rpx; }
|
||||
.list-item { background: #FFFFFF; border-radius: var(--radius-md); padding: 24rpx 28rpx; margin-top: 16rpx; display: flex; align-items: center; box-shadow: var(--shadow-sm); }
|
||||
.item-main { flex: 1; display: flex; flex-direction: column; }
|
||||
.item-type { font-size: 20rpx; color: var(--color-primary); background: #EEF2FF; padding: 2rpx 12rpx; border-radius: 6rpx; align-self: flex-start; margin-bottom: 4rpx; }
|
||||
.item-title { font-size: 26rpx; color: var(--color-text); font-weight: 500; }
|
||||
.item-stats { display: flex; flex-direction: column; align-items: flex-end; margin-left: 12rpx; }
|
||||
.item-stat { font-size: 22rpx; color: #9CA3AF; white-space: nowrap; }
|
||||
.item-stat.active { color: var(--color-primary); }
|
||||
.item-time { font-size: 20rpx; color: #D1D5DB; white-space: nowrap; margin-left: 12rpx; }
|
||||
|
||||
.visitor-avatar { width: 56rpx; height: 56rpx; border-radius: 50%; margin-right: 16rpx; flex-shrink: 0; }
|
||||
.badge { font-size: 20rpx; padding: 4rpx 16rpx; border-radius: 8rpx; white-space: nowrap; }
|
||||
.badge-done { background: #ECFDF5; color: #059669; }
|
||||
.badge-pending { background: #FEF3C7; color: #D97706; }
|
||||
|
||||
/* Empty state */
|
||||
.empty { display: flex; flex-direction: column; align-items: center; padding: 80rpx 0; }
|
||||
.empty-icon { font-size: 64rpx; margin-bottom: 16rpx; }
|
||||
.empty-text { font-size: 28rpx; color: #9CA3AF; }
|
||||
.empty-hint { font-size: 24rpx; color: #D1D5DB; margin-top: 8rpx; }
|
||||
</style>
|
||||
@@ -57,6 +57,11 @@
|
||||
<text class="menu-text">我的简历</text>
|
||||
<text class="menu-arrow">›</text>
|
||||
</view>
|
||||
<view class="menu-item" @click="requireLogin(goShare, '我的分享')">
|
||||
<view class="menu-icon-wrap wrap-orange"><text class="menu-icon">📤</text></view>
|
||||
<text class="menu-text">我的分享</text>
|
||||
<text class="menu-arrow">›</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="menu-group">
|
||||
<view class="menu-item" @click="goAbout">
|
||||
@@ -121,6 +126,7 @@ const goLogin = () => uni.navigateTo({ url: '/pages/login/login' })
|
||||
const goHistory = () => uni.switchTab({ url: '/pages/history/history' })
|
||||
const goVip = () => uni.navigateTo({ url: '/pages/member/member' })
|
||||
const goResume = () => uni.navigateTo({ url: '/pages/resume/resume' })
|
||||
const goShare = () => uni.navigateTo({ url: '/pages/share/share' })
|
||||
const goAdmin = () => uni.navigateTo({ url: '/pages/admin/admin' })
|
||||
const goAbout = () => uni.navigateTo({ url: '/pages/about/about' })
|
||||
|
||||
@@ -171,6 +177,7 @@ const doLogout = () => {
|
||||
.wrap-blue { background: #EEF2FF; }
|
||||
.wrap-purple { background: #F5F3FF; }
|
||||
.wrap-green { background: #ECFDF5; }
|
||||
.wrap-orange { background: #FFF7ED; }
|
||||
.wrap-gray { background: #F3F4F6; }
|
||||
.logout-wrap { margin-top: 8rpx; }
|
||||
.logout-btn { background: #FFFFFF; color: var(--color-error); font-size: 28rpx; font-weight: 500; border-radius: var(--radius-md); height: 88rpx; line-height: 88rpx; border: 2rpx solid #FECACA; }
|
||||
|
||||
@@ -83,6 +83,13 @@ export const apiService = {
|
||||
byPosition: (position: string) =>
|
||||
request(API_ENDPOINTS.DAILY_QUESTION.BY_POSITION(position), 'GET', undefined, true),
|
||||
},
|
||||
share: {
|
||||
create: (data: { type: string; refId?: string; title?: string; description?: string }) =>
|
||||
request(API_ENDPOINTS.SHARE.CREATE, 'POST', data, true),
|
||||
stats: () => request(API_ENDPOINTS.SHARE.STATS, 'GET', undefined, true),
|
||||
records: () => request(API_ENDPOINTS.SHARE.RECORDS, 'GET', undefined, true),
|
||||
visitors: () => request(API_ENDPOINTS.SHARE.VISITORS, 'GET', undefined, true),
|
||||
},
|
||||
}
|
||||
|
||||
export default apiService
|
||||
|
||||
Reference in New Issue
Block a user