feat: Admin定价管理界面 + 定价DB配置化 (P2)
This commit is contained in:
@@ -24,6 +24,7 @@ import { ContributionModule } from './modules/contribution/contribution.module'
|
|||||||
import { DailyQuestionModule } from './modules/daily-question/daily-question.module'
|
import { DailyQuestionModule } from './modules/daily-question/daily-question.module'
|
||||||
import { ScheduleModule } from './modules/schedule/schedule.module'
|
import { ScheduleModule } from './modules/schedule/schedule.module'
|
||||||
import { TtsModule } from './modules/tts/tts.module'
|
import { TtsModule } from './modules/tts/tts.module'
|
||||||
|
import { PricingModule } from './modules/schemas/pricing.module'
|
||||||
|
|
||||||
const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/zhiyin'
|
const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/zhiyin'
|
||||||
|
|
||||||
@@ -56,6 +57,7 @@ const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/zhiyin
|
|||||||
DailyQuestionModule,
|
DailyQuestionModule,
|
||||||
ScheduleModule,
|
ScheduleModule,
|
||||||
TtsModule,
|
TtsModule,
|
||||||
|
PricingModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
JwtStrategy,
|
JwtStrategy,
|
||||||
|
|||||||
@@ -163,6 +163,25 @@ export class AdminController {
|
|||||||
return { success: true }
|
return { success: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Pricing Management ---
|
||||||
|
|
||||||
|
@Get('pricing')
|
||||||
|
async getPricing() {
|
||||||
|
const cfg = await this.configModel.findOne({ key: 'pricing' }).exec()
|
||||||
|
if (cfg) return cfg.value
|
||||||
|
return DEFAULT_PRICING
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('pricing/save')
|
||||||
|
async savePricing(@Body() body: any) {
|
||||||
|
await this.configModel.findOneAndUpdate(
|
||||||
|
{ key: 'pricing' },
|
||||||
|
{ key: 'pricing', value: body, description: '定价配置' },
|
||||||
|
{ upsert: true },
|
||||||
|
).exec()
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
|
||||||
@Get('questions')
|
@Get('questions')
|
||||||
async getQuestions() {
|
async getQuestions() {
|
||||||
const cfg = await this.configModel.findOne({ key: 'daily_questions' }).exec()
|
const cfg = await this.configModel.findOne({ key: 'daily_questions' }).exec()
|
||||||
@@ -191,6 +210,24 @@ const DEFAULT_CONFIG = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEFAULT_PRICING = {
|
||||||
|
interview: { pricePerSession: 500, creditsPerPurchase: 1 },
|
||||||
|
resumeOptimize: { freeLimit: 3, pricePerOptimize: 300, creditsPerPurchase: 1 },
|
||||||
|
resumeDownload: { pricePerDownload: 200, creditsPerPurchase: 1 },
|
||||||
|
plans: {
|
||||||
|
growth: {
|
||||||
|
price: 1990, durationDays: 30,
|
||||||
|
credits: { interview: 999, resumeOptimize: 20, resumeDownload: 10 },
|
||||||
|
features: ['免费版全部权益', 'AI 数字人面试无限次', '详细面试报告(四维评分)', '进步轨迹雷达图 + 打卡', '每日一题推送', '参考回答思路', '公司真题库', '简历优化 20 次/月', '简历下载 10 次/月'],
|
||||||
|
},
|
||||||
|
sprint: {
|
||||||
|
price: 4990, durationDays: 30,
|
||||||
|
credits: { interview: 999, resumeOptimize: 50, resumeDownload: 30 },
|
||||||
|
features: ['成长版全部权益', 'AI 语音分析(语气词/语速检测)', '技能缺口分析报告', '学习路径推荐', '真人导师 1v1 点评(每月 1 次)', '简历精修(每月 1 次)', '内推优先', '简历优化 50 次/月', '简历下载 30 次/月'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
const DEFAULT_QUESTIONS = [
|
const DEFAULT_QUESTIONS = [
|
||||||
{ position: '通用', category: 'behavioral', question: '请做一个简单的自我介绍,突出你的核心优势和职业目标。', referenceAnswer: '建议结构:1) 基本信息 2) 教育背景与专业方向 3) 实习/项目经历 4) 核心优势 5) 职业目标' },
|
{ position: '通用', category: 'behavioral', question: '请做一个简单的自我介绍,突出你的核心优势和职业目标。', referenceAnswer: '建议结构:1) 基本信息 2) 教育背景与专业方向 3) 实习/项目经历 4) 核心优势 5) 职业目标' },
|
||||||
{ position: '前端工程师', category: 'technical', question: '请用 JavaScript 实现一个深拷贝函数,并说明可能存在的问题。', referenceAnswer: '可使用递归遍历,注意循环引用用 WeakMap 处理,特殊类型如 Date/RegExp/Map/Set 需单独处理。' },
|
{ position: '前端工程师', category: 'technical', question: '请用 JavaScript 实现一个深拷贝函数,并说明可能存在的问题。', referenceAnswer: '可使用递归遍历,注意循环引用用 WeakMap 处理,特殊类型如 Date/RegExp/Map/Set 需单独处理。' },
|
||||||
|
|||||||
@@ -6,46 +6,11 @@ import { PaymentOrder, PaymentOrderDocument } from '../payment/payment-order.sch
|
|||||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'
|
||||||
import { CurrentUser } from '../../common/decorators/current-user.decorator'
|
import { CurrentUser } from '../../common/decorators/current-user.decorator'
|
||||||
import { QuotaService } from '../user/quota.service'
|
import { QuotaService } from '../user/quota.service'
|
||||||
|
import { PricingService } from '../schemas/pricing.service'
|
||||||
import { Public } from '../../common/decorators/public.decorator'
|
import { Public } from '../../common/decorators/public.decorator'
|
||||||
|
|
||||||
const GROWTH_PRICE = 1990
|
|
||||||
const SPRINT_PRICE = 4990
|
|
||||||
const DURATION_DAYS = 30
|
|
||||||
const FREE_DAILY_LIMIT = 2
|
const FREE_DAILY_LIMIT = 2
|
||||||
|
|
||||||
interface PlanConfig {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
price: number
|
|
||||||
dailyLimit: number
|
|
||||||
features: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const PLANS: Record<string, PlanConfig> = {
|
|
||||||
free: {
|
|
||||||
id: 'free', name: '免费版', price: 0, dailyLimit: FREE_DAILY_LIMIT,
|
|
||||||
features: [
|
|
||||||
'AI 模拟面试 1 次(体验)', '基础面试报告', '通用题库随机出题', '简历优化(限 3 次免费)',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
growth: {
|
|
||||||
id: 'growth', name: '成长版', price: GROWTH_PRICE, dailyLimit: 999,
|
|
||||||
features: [
|
|
||||||
'免费版全部权益', 'AI 数字人面试无限次', '详细面试报告(四维评分)',
|
|
||||||
'进步轨迹雷达图 + 打卡', '每日一题推送', '参考回答思路', '公司真题库',
|
|
||||||
'简历优化 20 次/月', '简历下载 10 次/月',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
sprint: {
|
|
||||||
id: 'sprint', name: '冲刺版', price: SPRINT_PRICE, dailyLimit: 999,
|
|
||||||
features: [
|
|
||||||
'成长版全部权益', 'AI 语音分析(语气词/语速检测)', '技能缺口分析报告',
|
|
||||||
'学习路径推荐', '真人导师 1v1 点评(每月 1 次)', '简历精修(每月 1 次)', '内推优先',
|
|
||||||
'简历优化 50 次/月', '简历下载 30 次/月',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
@Controller('member')
|
@Controller('member')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
export class MemberController {
|
export class MemberController {
|
||||||
@@ -55,21 +20,41 @@ export class MemberController {
|
|||||||
@InjectModel(User.name) private userModel: Model<UserDocument>,
|
@InjectModel(User.name) private userModel: Model<UserDocument>,
|
||||||
@InjectModel(PaymentOrder.name) private orderModel: Model<PaymentOrderDocument>,
|
@InjectModel(PaymentOrder.name) private orderModel: Model<PaymentOrderDocument>,
|
||||||
private quotaService: QuotaService,
|
private quotaService: QuotaService,
|
||||||
|
private pricingService: PricingService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@Get('plans')
|
@Get('plans')
|
||||||
getPlans() {
|
async getPlans() {
|
||||||
|
const pricing = await this.pricingService.getConfig()
|
||||||
|
const plans = [
|
||||||
|
{
|
||||||
|
id: 'free', name: '免费版', price: 0,
|
||||||
|
priceDisplay: '免费',
|
||||||
|
dailyLimit: FREE_DAILY_LIMIT,
|
||||||
|
features: ['AI 模拟面试 1 次(体验)', '基础面试报告', '通用题库随机出题', '简历优化(限 3 次免费)'],
|
||||||
|
credits: { interview: 1, resumeOptimize: 0, resumeDownload: 0 },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const planId of ['growth', 'sprint'] as const) {
|
||||||
|
const cfg = pricing.plans[planId]
|
||||||
|
if (cfg) {
|
||||||
|
plans.push({
|
||||||
|
id: planId,
|
||||||
|
name: planId === 'growth' ? '成长版' : '冲刺版',
|
||||||
|
price: cfg.price,
|
||||||
|
priceDisplay: `¥${(cfg.price / 100).toFixed(1)}/月`,
|
||||||
|
dailyLimit: 999,
|
||||||
|
features: cfg.features,
|
||||||
|
credits: cfg.credits,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
interview: { dailyFreeLimit: FREE_DAILY_LIMIT, maxRoundsFree: 5, maxRoundsVip: 10 },
|
interview: { dailyFreeLimit: FREE_DAILY_LIMIT, maxRoundsFree: 5, maxRoundsVip: 10 },
|
||||||
diagnosis: { dailyFreeLimit: 2 },
|
plans,
|
||||||
optimize: { dailyFreeLimit: 2 },
|
|
||||||
price: { monthly: GROWTH_PRICE, sprint: SPRINT_PRICE },
|
|
||||||
plans: Object.values(PLANS).map(p => ({
|
|
||||||
id: p.id, name: p.name, price: p.price,
|
|
||||||
priceDisplay: p.price === 0 ? '免费' : `¥${(p.price / 100).toFixed(1)}/月`,
|
|
||||||
dailyLimit: p.dailyLimit, features: p.features,
|
|
||||||
})),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,12 +62,11 @@ export class MemberController {
|
|||||||
async getStatus(@CurrentUser('userId') userId: string) {
|
async getStatus(@CurrentUser('userId') userId: string) {
|
||||||
const user = await this.userModel.findById(userId).exec()
|
const user = await this.userModel.findById(userId).exec()
|
||||||
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
|
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
|
||||||
const planConfig = PLANS[user.plan] || PLANS.free
|
|
||||||
return {
|
return {
|
||||||
plan: user.plan,
|
plan: user.plan,
|
||||||
planName: planConfig.name,
|
planName: user.plan === 'growth' ? '成长版' : user.plan === 'sprint' ? '冲刺版' : '免费版',
|
||||||
remaining: user.remaining,
|
remaining: user.remaining,
|
||||||
dailyLimit: planConfig.dailyLimit,
|
dailyLimit: user.plan !== 'free' ? 999 : FREE_DAILY_LIMIT,
|
||||||
vipExpireAt: user.vipExpireAt,
|
vipExpireAt: user.vipExpireAt,
|
||||||
sprintExpireAt: user.sprintExpireAt,
|
sprintExpireAt: user.sprintExpireAt,
|
||||||
sprintRemaining: user.sprintRemaining || 0,
|
sprintRemaining: user.sprintRemaining || 0,
|
||||||
@@ -105,22 +89,20 @@ export class MemberController {
|
|||||||
if (!order) throw new HttpException('订单不存在', HttpStatus.NOT_FOUND)
|
if (!order) throw new HttpException('订单不存在', HttpStatus.NOT_FOUND)
|
||||||
if (order.status !== 'success') throw new HttpException('支付未完成', HttpStatus.BAD_REQUEST)
|
if (order.status !== 'success') throw new HttpException('支付未完成', HttpStatus.BAD_REQUEST)
|
||||||
|
|
||||||
|
const pricing = await this.pricingService.getConfig()
|
||||||
|
const planCfg = pricing.plans[order.plan === 'sprint' ? 'sprint' : 'growth']
|
||||||
const expireAt = new Date()
|
const expireAt = new Date()
|
||||||
expireAt.setDate(expireAt.getDate() + DURATION_DAYS)
|
expireAt.setDate(expireAt.getDate() + planCfg.durationDays)
|
||||||
const credits = { interview: 999, resumeOptimize: 20, resumeDownload: 10 }
|
|
||||||
if (order.plan === 'sprint') {
|
if (order.plan === 'sprint') {
|
||||||
user.plan = 'sprint'
|
user.plan = 'sprint'
|
||||||
user.sprintExpireAt = expireAt
|
user.sprintExpireAt = expireAt
|
||||||
user.sprintRemaining = 10
|
user.sprintRemaining = 10
|
||||||
credits.interview = 999
|
|
||||||
credits.resumeOptimize = 50
|
|
||||||
credits.resumeDownload = 30
|
|
||||||
} else {
|
} else {
|
||||||
user.plan = 'growth'
|
user.plan = 'growth'
|
||||||
user.vipExpireAt = expireAt
|
user.vipExpireAt = expireAt
|
||||||
}
|
}
|
||||||
await this.quotaService.setPlanQuota(userId, order.plan, credits)
|
await this.quotaService.setPlanQuota(userId, order.plan, planCfg.credits)
|
||||||
return { success: true, plan: user.plan, planName: PLANS[user.plan]?.name, expireAt }
|
return { success: true, plan: user.plan, planName: user.plan === 'growth' ? '成长版' : '冲刺版', expireAt }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 扣减冲刺版权益次数 */
|
/** 扣减冲刺版权益次数 */
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { getModelToken } from '@nestjs/mongoose'
|
|||||||
import { PaymentController } from './payment.controller'
|
import { PaymentController } from './payment.controller'
|
||||||
import { WechatPayService } from './wechat-pay.service'
|
import { WechatPayService } from './wechat-pay.service'
|
||||||
import { QuotaService } from '../user/quota.service'
|
import { QuotaService } from '../user/quota.service'
|
||||||
|
import { PricingService } from '../schemas/pricing.service'
|
||||||
|
|
||||||
describe('PaymentController', () => {
|
describe('PaymentController', () => {
|
||||||
let controller: PaymentController
|
let controller: PaymentController
|
||||||
@@ -10,6 +11,7 @@ describe('PaymentController', () => {
|
|||||||
let mockOrderModel: any
|
let mockOrderModel: any
|
||||||
let mockWechatPay: any
|
let mockWechatPay: any
|
||||||
let mockQuotaService: any
|
let mockQuotaService: any
|
||||||
|
let mockPricingService: any
|
||||||
|
|
||||||
const mockUserId = '507f1f77bcf86cd799439011'
|
const mockUserId = '507f1f77bcf86cd799439011'
|
||||||
|
|
||||||
@@ -32,6 +34,17 @@ describe('PaymentController', () => {
|
|||||||
grantCredits: jest.fn().mockResolvedValue(undefined),
|
grantCredits: jest.fn().mockResolvedValue(undefined),
|
||||||
setPlanQuota: jest.fn().mockResolvedValue(undefined),
|
setPlanQuota: jest.fn().mockResolvedValue(undefined),
|
||||||
}
|
}
|
||||||
|
mockPricingService = {
|
||||||
|
getConfig: jest.fn().mockResolvedValue({
|
||||||
|
interview: { pricePerSession: 500, creditsPerPurchase: 1 },
|
||||||
|
resumeOptimize: { freeLimit: 3, pricePerOptimize: 300, creditsPerPurchase: 1 },
|
||||||
|
resumeDownload: { pricePerDownload: 200, creditsPerPurchase: 1 },
|
||||||
|
plans: {
|
||||||
|
growth: { price: 1990, durationDays: 30, credits: { interview: 999, resumeOptimize: 20, resumeDownload: 10 }, features: [] },
|
||||||
|
sprint: { price: 4990, durationDays: 30, credits: { interview: 999, resumeOptimize: 50, resumeDownload: 30 }, features: [] },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
controllers: [PaymentController],
|
controllers: [PaymentController],
|
||||||
@@ -40,6 +53,7 @@ describe('PaymentController', () => {
|
|||||||
{ provide: getModelToken('PaymentOrder'), useValue: mockOrderModel },
|
{ provide: getModelToken('PaymentOrder'), useValue: mockOrderModel },
|
||||||
{ provide: WechatPayService, useValue: mockWechatPay },
|
{ provide: WechatPayService, useValue: mockWechatPay },
|
||||||
{ provide: QuotaService, useValue: mockQuotaService },
|
{ provide: QuotaService, useValue: mockQuotaService },
|
||||||
|
{ provide: PricingService, useValue: mockPricingService },
|
||||||
],
|
],
|
||||||
}).compile()
|
}).compile()
|
||||||
|
|
||||||
|
|||||||
@@ -7,24 +7,9 @@ import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'
|
|||||||
import { CurrentUser } from '../../common/decorators/current-user.decorator'
|
import { CurrentUser } from '../../common/decorators/current-user.decorator'
|
||||||
import { WechatPayService } from './wechat-pay.service'
|
import { WechatPayService } from './wechat-pay.service'
|
||||||
import { QuotaService } from '../user/quota.service'
|
import { QuotaService } from '../user/quota.service'
|
||||||
|
import { PricingService } from '../schemas/pricing.service'
|
||||||
import { Public } from '../../common/decorators/public.decorator'
|
import { Public } from '../../common/decorators/public.decorator'
|
||||||
|
|
||||||
const GROWTH_AMOUNT = 1990
|
|
||||||
const SPRINT_AMOUNT = 4990
|
|
||||||
const VIP_DURATION_DAYS = 30
|
|
||||||
|
|
||||||
const PRODUCT_PRICES: Record<string, number> = {
|
|
||||||
interview: 500,
|
|
||||||
optimize: 300,
|
|
||||||
download: 200,
|
|
||||||
}
|
|
||||||
|
|
||||||
const PRODUCT_CREDITS: Record<string, number> = {
|
|
||||||
interview: 1,
|
|
||||||
optimize: 1,
|
|
||||||
download: 1,
|
|
||||||
}
|
|
||||||
|
|
||||||
@Controller('payment')
|
@Controller('payment')
|
||||||
export class PaymentController {
|
export class PaymentController {
|
||||||
private readonly logger = new Logger(PaymentController.name)
|
private readonly logger = new Logger(PaymentController.name)
|
||||||
@@ -34,6 +19,7 @@ export class PaymentController {
|
|||||||
@InjectModel(PaymentOrder.name) private orderModel: Model<PaymentOrderDocument>,
|
@InjectModel(PaymentOrder.name) private orderModel: Model<PaymentOrderDocument>,
|
||||||
private wechatPay: WechatPayService,
|
private wechatPay: WechatPayService,
|
||||||
private quotaService: QuotaService,
|
private quotaService: QuotaService,
|
||||||
|
private pricingService: PricingService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** 创建套餐订单(H5:Native 扫码支付) */
|
/** 创建套餐订单(H5:Native 扫码支付) */
|
||||||
@@ -45,7 +31,9 @@ export class PaymentController {
|
|||||||
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
|
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
|
||||||
if (user.plan !== 'free') throw new HttpException('已是会员', HttpStatus.BAD_REQUEST)
|
if (user.plan !== 'free') throw new HttpException('已是会员', HttpStatus.BAD_REQUEST)
|
||||||
|
|
||||||
const amount = plan === 'sprint' ? SPRINT_AMOUNT : GROWTH_AMOUNT
|
const pricing = await this.pricingService.getConfig()
|
||||||
|
const planCfg = pricing.plans[plan === 'sprint' ? 'sprint' : 'growth']
|
||||||
|
const amount = planCfg.price
|
||||||
const title = plan === 'sprint' ? '职引冲刺版月度会员' : '职引成长版月度会员'
|
const title = plan === 'sprint' ? '职引冲刺版月度会员' : '职引成长版月度会员'
|
||||||
const outTradeNo = `${plan === 'sprint' ? 'SPR' : 'VIP'}${Date.now()}${userId.slice(-6)}`
|
const outTradeNo = `${plan === 'sprint' ? 'SPR' : 'VIP'}${Date.now()}${userId.slice(-6)}`
|
||||||
const result = await this.wechatPay.nativePay(title, outTradeNo, amount)
|
const result = await this.wechatPay.nativePay(title, outTradeNo, amount)
|
||||||
@@ -69,7 +57,13 @@ export class PaymentController {
|
|||||||
const user = await this.userModel.findById(userId).exec()
|
const user = await this.userModel.findById(userId).exec()
|
||||||
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
|
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
|
||||||
|
|
||||||
const price = PRODUCT_PRICES[type]
|
const pricing = await this.pricingService.getConfig()
|
||||||
|
const priceMap: Record<string, number> = {
|
||||||
|
interview: pricing.interview.pricePerSession,
|
||||||
|
optimize: pricing.resumeOptimize.pricePerOptimize,
|
||||||
|
download: pricing.resumeDownload.pricePerDownload,
|
||||||
|
}
|
||||||
|
const price = priceMap[type]
|
||||||
if (!price) throw new HttpException('价格未配置', HttpStatus.INTERNAL_SERVER_ERROR)
|
if (!price) throw new HttpException('价格未配置', HttpStatus.INTERNAL_SERVER_ERROR)
|
||||||
|
|
||||||
const titles: Record<string, string> = {
|
const titles: Record<string, string> = {
|
||||||
@@ -97,7 +91,9 @@ export class PaymentController {
|
|||||||
const openid = user.wxOpenid
|
const openid = user.wxOpenid
|
||||||
if (!openid) throw new HttpException('未绑定微信', HttpStatus.BAD_REQUEST)
|
if (!openid) throw new HttpException('未绑定微信', HttpStatus.BAD_REQUEST)
|
||||||
|
|
||||||
const amount = plan === 'sprint' ? SPRINT_AMOUNT : GROWTH_AMOUNT
|
const pricing = await this.pricingService.getConfig()
|
||||||
|
const planCfg = pricing.plans[plan === 'sprint' ? 'sprint' : 'growth']
|
||||||
|
const amount = planCfg.price
|
||||||
const title = plan === 'sprint' ? '职引冲刺版月度会员' : '职引成长版月度会员'
|
const title = plan === 'sprint' ? '职引冲刺版月度会员' : '职引成长版月度会员'
|
||||||
const outTradeNo = `${plan === 'sprint' ? 'SPR' : 'VIP'}${Date.now()}${userId.slice(-6)}`
|
const outTradeNo = `${plan === 'sprint' ? 'SPR' : 'VIP'}${Date.now()}${userId.slice(-6)}`
|
||||||
const result = await this.wechatPay.jsapiPay(title, outTradeNo, amount, openid)
|
const result = await this.wechatPay.jsapiPay(title, outTradeNo, amount, openid)
|
||||||
@@ -123,7 +119,13 @@ export class PaymentController {
|
|||||||
const openid = user.wxOpenid
|
const openid = user.wxOpenid
|
||||||
if (!openid) throw new HttpException('未绑定微信', HttpStatus.BAD_REQUEST)
|
if (!openid) throw new HttpException('未绑定微信', HttpStatus.BAD_REQUEST)
|
||||||
|
|
||||||
const price = PRODUCT_PRICES[type]
|
const pricing = await this.pricingService.getConfig()
|
||||||
|
const priceMap: Record<string, number> = {
|
||||||
|
interview: pricing.interview.pricePerSession,
|
||||||
|
optimize: pricing.resumeOptimize.pricePerOptimize,
|
||||||
|
download: pricing.resumeDownload.pricePerDownload,
|
||||||
|
}
|
||||||
|
const price = priceMap[type]
|
||||||
if (!price) throw new HttpException('价格未配置', HttpStatus.INTERNAL_SERVER_ERROR)
|
if (!price) throw new HttpException('价格未配置', HttpStatus.INTERNAL_SERVER_ERROR)
|
||||||
|
|
||||||
const titles: Record<string, string> = {
|
const titles: Record<string, string> = {
|
||||||
@@ -184,8 +186,10 @@ export class PaymentController {
|
|||||||
const user = await this.userModel.findById(order.userId).exec()
|
const user = await this.userModel.findById(order.userId).exec()
|
||||||
if (!user || user.plan !== 'free') return
|
if (!user || user.plan !== 'free') return
|
||||||
|
|
||||||
|
const pricing = await this.pricingService.getConfig()
|
||||||
|
const planCfg = pricing.plans[order.plan === 'sprint' ? 'sprint' : 'growth']
|
||||||
const expireAt = new Date()
|
const expireAt = new Date()
|
||||||
expireAt.setDate(expireAt.getDate() + VIP_DURATION_DAYS)
|
expireAt.setDate(expireAt.getDate() + planCfg.durationDays)
|
||||||
const isSprint = order.plan === 'sprint'
|
const isSprint = order.plan === 'sprint'
|
||||||
if (isSprint) {
|
if (isSprint) {
|
||||||
user.plan = 'sprint'
|
user.plan = 'sprint'
|
||||||
@@ -195,9 +199,7 @@ export class PaymentController {
|
|||||||
user.plan = 'growth'
|
user.plan = 'growth'
|
||||||
user.vipExpireAt = expireAt
|
user.vipExpireAt = expireAt
|
||||||
}
|
}
|
||||||
const credits = isSprint
|
const credits = planCfg.credits
|
||||||
? { interview: 999, resumeOptimize: 50, resumeDownload: 30 }
|
|
||||||
: { interview: 999, resumeOptimize: 20, resumeDownload: 10 }
|
|
||||||
user.remaining = 999
|
user.remaining = 999
|
||||||
user.interviewCredits = credits.interview
|
user.interviewCredits = credits.interview
|
||||||
user.resumeOptimizeCredits = credits.resumeOptimize
|
user.resumeOptimizeCredits = credits.resumeOptimize
|
||||||
@@ -207,7 +209,13 @@ export class PaymentController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async activateProduct(order: PaymentOrderDocument) {
|
private async activateProduct(order: PaymentOrderDocument) {
|
||||||
const credits = PRODUCT_CREDITS[order.type]
|
const pricing = await this.pricingService.getConfig()
|
||||||
|
const creditMap: Record<string, number> = {
|
||||||
|
interview: pricing.interview.creditsPerPurchase,
|
||||||
|
optimize: pricing.resumeOptimize.creditsPerPurchase,
|
||||||
|
download: pricing.resumeDownload.creditsPerPurchase,
|
||||||
|
}
|
||||||
|
const credits = creditMap[order.type]
|
||||||
if (!credits) return
|
if (!credits) return
|
||||||
|
|
||||||
const typeMap: Record<string, 'interview' | 'optimize' | 'download'> = {
|
const typeMap: Record<string, 'interview' | 'optimize' | 'download'> = {
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Module, Global } from '@nestjs/common'
|
||||||
|
import { MongooseModule } from '@nestjs/mongoose'
|
||||||
|
import { SiteConfig, SiteConfigSchema } from './site-config.schema'
|
||||||
|
import { PricingService } from './pricing.service'
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
imports: [MongooseModule.forFeature([{ name: SiteConfig.name, schema: SiteConfigSchema }])],
|
||||||
|
providers: [PricingService],
|
||||||
|
exports: [PricingService],
|
||||||
|
})
|
||||||
|
export class PricingModule {}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { Injectable } from '@nestjs/common'
|
||||||
|
import { InjectModel } from '@nestjs/mongoose'
|
||||||
|
import { Model } from 'mongoose'
|
||||||
|
import { SiteConfig, SiteConfigDocument } from './site-config.schema'
|
||||||
|
|
||||||
|
interface PricingConfig {
|
||||||
|
interview: { pricePerSession: number; creditsPerPurchase: number }
|
||||||
|
resumeOptimize: { freeLimit: number; pricePerOptimize: number; creditsPerPurchase: number }
|
||||||
|
resumeDownload: { pricePerDownload: number; creditsPerPurchase: number }
|
||||||
|
plans: {
|
||||||
|
growth: { price: number; durationDays: number; credits: { interview: number; resumeOptimize: number; resumeDownload: number }; features: string[] }
|
||||||
|
sprint: { price: number; durationDays: number; credits: { interview: number; resumeOptimize: number; resumeDownload: number }; features: string[] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_PRICING: PricingConfig = {
|
||||||
|
interview: { pricePerSession: 500, creditsPerPurchase: 1 },
|
||||||
|
resumeOptimize: { freeLimit: 3, pricePerOptimize: 300, creditsPerPurchase: 1 },
|
||||||
|
resumeDownload: { pricePerDownload: 200, creditsPerPurchase: 1 },
|
||||||
|
plans: {
|
||||||
|
growth: { price: 1990, durationDays: 30, credits: { interview: 999, resumeOptimize: 20, resumeDownload: 10 }, features: [] },
|
||||||
|
sprint: { price: 4990, durationDays: 30, credits: { interview: 999, resumeOptimize: 50, resumeDownload: 30 }, features: [] },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PricingService {
|
||||||
|
private cache: PricingConfig | null = null
|
||||||
|
private cacheTime = 0
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectModel(SiteConfig.name) private configModel: Model<SiteConfigDocument>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getConfig(): Promise<PricingConfig> {
|
||||||
|
// Cache for 60s
|
||||||
|
if (this.cache && Date.now() - this.cacheTime < 60000) {
|
||||||
|
return this.cache
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const doc = await this.configModel.findOne({ key: 'pricing' }).exec()
|
||||||
|
if (doc?.value) {
|
||||||
|
this.cache = this.mergeDefaults(doc.value)
|
||||||
|
this.cacheTime = Date.now()
|
||||||
|
return this.cache
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return DEFAULT_PRICING
|
||||||
|
}
|
||||||
|
|
||||||
|
invalidateCache() {
|
||||||
|
this.cache = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private mergeDefaults(value: any): PricingConfig {
|
||||||
|
return {
|
||||||
|
interview: { ...DEFAULT_PRICING.interview, ...value?.interview },
|
||||||
|
resumeOptimize: { ...DEFAULT_PRICING.resumeOptimize, ...value?.resumeOptimize },
|
||||||
|
resumeDownload: { ...DEFAULT_PRICING.resumeDownload, ...value?.resumeDownload },
|
||||||
|
plans: {
|
||||||
|
growth: { ...DEFAULT_PRICING.plans.growth, ...value?.plans?.growth, credits: { ...DEFAULT_PRICING.plans.growth.credits, ...value?.plans?.growth?.credits } },
|
||||||
|
sprint: { ...DEFAULT_PRICING.plans.sprint, ...value?.plans?.sprint, credits: { ...DEFAULT_PRICING.plans.sprint.credits, ...value?.plans?.sprint?.credits } },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -79,6 +79,18 @@ export const API_ENDPOINTS = {
|
|||||||
TODAY: '/daily-question',
|
TODAY: '/daily-question',
|
||||||
BY_POSITION: (position: string) => `/daily-question/position/${position}`,
|
BY_POSITION: (position: string) => `/daily-question/position/${position}`,
|
||||||
},
|
},
|
||||||
|
ADMIN: {
|
||||||
|
CHECK: '/admin/check',
|
||||||
|
OVERVIEW: '/admin/overview',
|
||||||
|
USERS: '/admin/users',
|
||||||
|
SET_VIP: '/admin/set-vip',
|
||||||
|
CONFIG: '/admin/config',
|
||||||
|
CONFIG_SAVE: '/admin/config/save',
|
||||||
|
PRICING: '/admin/pricing',
|
||||||
|
PRICING_SAVE: '/admin/pricing/save',
|
||||||
|
ORDERS: '/admin/orders',
|
||||||
|
ORDER_SYNC: '/admin/order/sync',
|
||||||
|
},
|
||||||
PAYMENT: {
|
PAYMENT: {
|
||||||
CREATE: '/payment/create',
|
CREATE: '/payment/create',
|
||||||
JSAPI: '/payment/jsapi',
|
JSAPI: '/payment/jsapi',
|
||||||
|
|||||||
@@ -15,11 +15,11 @@
|
|||||||
<view class="body" v-if="verified">
|
<view class="body" v-if="verified">
|
||||||
<view class="tabs">
|
<view class="tabs">
|
||||||
<text class="tab" :class="{ active: tab === 'overview' }" @click="switchTab('overview')">概览</text>
|
<text class="tab" :class="{ active: tab === 'overview' }" @click="switchTab('overview')">概览</text>
|
||||||
<text class="tab" :class="{ active: tab === 'users' }" @click="switchTab('users')">用户管理</text>
|
<text class="tab" :class="{ active: tab === 'users' }" @click="switchTab('users')">用户</text>
|
||||||
<text class="tab" :class="{ active: tab === 'interviews' }" @click="switchTab('interviews')">面试记录</text>
|
<text class="tab" :class="{ active: tab === 'interviews' }" @click="switchTab('interviews')">面试</text>
|
||||||
<text class="tab" :class="{ active: tab === 'orders' }" @click="switchTab('orders')">订单</text>
|
<text class="tab" :class="{ active: tab === 'orders' }" @click="switchTab('orders')">订单</text>
|
||||||
<text class="tab" :class="{ active: tab === 'admins' }" @click="switchTab('admins')">管理员</text>
|
<text class="tab" :class="{ active: tab === 'pricing' }" @click="switchTab('pricing')">定价</text>
|
||||||
<text class="tab" :class="{ active: tab === 'config' }" @click="switchTab('config')">配置</text>
|
<text class="tab" :class="{ active: tab === 'admins' }" @click="switchTab('admins')">管理</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 概览 -->
|
<!-- 概览 -->
|
||||||
@@ -102,24 +102,86 @@
|
|||||||
</view>
|
</view>
|
||||||
<text class="loading-text" v-if="orderLoading">加载中...</text>
|
<text class="loading-text" v-if="orderLoading">加载中...</text>
|
||||||
</view>
|
</view>
|
||||||
<!-- 套餐配置 -->
|
<!-- 定价管理 -->
|
||||||
<view v-if="tab === 'config'" class="section">
|
<view v-if="tab === 'pricing'" class="section">
|
||||||
<view class="config-card" v-if="!cfgLoading">
|
<view class="config-card">
|
||||||
<view class="cfg-title">面试限制</view>
|
<view class="cfg-title">产品定价</view>
|
||||||
<view class="cfg-row"><text>免费版每场最大轮次</text><text class="cfg-val">{{ memberConfig.interview.maxRoundsFree }}</text></view>
|
<view class="cfg-row">
|
||||||
<view class="cfg-row"><text>会员每场最大轮次</text><text class="cfg-val">{{ memberConfig.interview.maxRoundsVip }}</text></view>
|
<text>AI 面试(元/次)</text>
|
||||||
<view class="cfg-row"><text>免费版每日面试次数</text><text class="cfg-val">{{ memberConfig.interview.dailyFreeLimit }}</text></view>
|
<input class="cfg-input" type="digit" v-model.number="pricing.interview.pricePerSession" @blur="calcInterviewPrice" />
|
||||||
|
</view>
|
||||||
|
<view class="cfg-row">
|
||||||
|
<text>简历优化(元/次)</text>
|
||||||
|
<input class="cfg-input" type="digit" v-model.number="pricing.resumeOptimize.pricePerOptimize" />
|
||||||
|
</view>
|
||||||
|
<view class="cfg-row">
|
||||||
|
<text>简历下载(元/次)</text>
|
||||||
|
<input class="cfg-input" type="digit" v-model.number="pricing.resumeDownload.pricePerDownload" />
|
||||||
|
</view>
|
||||||
|
<view class="cfg-row">
|
||||||
|
<text>免费优化次数</text>
|
||||||
|
<input class="cfg-input" type="digit" v-model.number="pricing.resumeOptimize.freeLimit" />
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="config-card" v-if="!cfgLoading">
|
|
||||||
<view class="cfg-title">诊断与优化限制</view>
|
<view class="config-card">
|
||||||
<view class="cfg-row"><text>免费版每日诊断次数</text><text class="cfg-val">{{ memberConfig.diagnosis.dailyFreeLimit }}</text></view>
|
<view class="cfg-title">成长版 ¥{{ growthPriceDisplay }}</view>
|
||||||
<view class="cfg-row"><text>免费版每日优化次数</text><text class="cfg-val">{{ memberConfig.optimize.dailyFreeLimit }}</text></view>
|
<view class="cfg-row">
|
||||||
|
<text>价格(元/月)</text>
|
||||||
|
<input class="cfg-input" type="digit" v-model.number="growthPriceTemp" />
|
||||||
|
</view>
|
||||||
|
<view class="cfg-row">
|
||||||
|
<text>面试额度/月</text>
|
||||||
|
<input class="cfg-input" type="digit" v-model.number="pricing.plans.growth.credits.interview" />
|
||||||
|
</view>
|
||||||
|
<view class="cfg-row">
|
||||||
|
<text>优化额度/月</text>
|
||||||
|
<input class="cfg-input" type="digit" v-model.number="pricing.plans.growth.credits.resumeOptimize" />
|
||||||
|
</view>
|
||||||
|
<view class="cfg-row">
|
||||||
|
<text>下载额度/月</text>
|
||||||
|
<input class="cfg-input" type="digit" v-model.number="pricing.plans.growth.credits.resumeDownload" />
|
||||||
|
</view>
|
||||||
|
<view class="cfg-row">
|
||||||
|
<text>功能列表(每行一个)</text>
|
||||||
|
</view>
|
||||||
|
<textarea class="cfg-textarea" v-model="growthFeaturesText" placeholder="每行一个功能" />
|
||||||
</view>
|
</view>
|
||||||
<view class="config-card" v-if="!cfgLoading">
|
|
||||||
<view class="cfg-title">价格</view>
|
<view class="config-card">
|
||||||
<view class="cfg-row"><text>月度会员</text><text class="cfg-val">¥{{ (memberConfig.price.monthly / 100).toFixed(0) }}</text></view>
|
<view class="cfg-title">冲刺版 ¥{{ sprintPriceDisplay }}</view>
|
||||||
|
<view class="cfg-row">
|
||||||
|
<text>价格(元/月)</text>
|
||||||
|
<input class="cfg-input" type="digit" v-model.number="sprintPriceTemp" />
|
||||||
|
</view>
|
||||||
|
<view class="cfg-row">
|
||||||
|
<text>面试额度/月</text>
|
||||||
|
<input class="cfg-input" type="digit" v-model.number="pricing.plans.sprint.credits.interview" />
|
||||||
|
</view>
|
||||||
|
<view class="cfg-row">
|
||||||
|
<text>优化额度/月</text>
|
||||||
|
<input class="cfg-input" type="digit" v-model.number="pricing.plans.sprint.credits.resumeOptimize" />
|
||||||
|
</view>
|
||||||
|
<view class="cfg-row">
|
||||||
|
<text>下载额度/月</text>
|
||||||
|
<input class="cfg-input" type="digit" v-model.number="pricing.plans.sprint.credits.resumeDownload" />
|
||||||
|
</view>
|
||||||
|
<view class="cfg-row">
|
||||||
|
<text>功能列表(每行一个)</text>
|
||||||
|
</view>
|
||||||
|
<textarea class="cfg-textarea" v-model="sprintFeaturesText" placeholder="每行一个功能" />
|
||||||
</view>
|
</view>
|
||||||
<view class="empty-text" v-if="cfgLoading">加载中...</view>
|
|
||||||
|
<view class="config-card">
|
||||||
|
<view class="cfg-title">其他配置</view>
|
||||||
|
<view class="cfg-row">
|
||||||
|
<text>会员有效期(天)</text>
|
||||||
|
<input class="cfg-input" type="digit" v-model.number="pricing.plans.growth.durationDays" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<button class="save-btn" @click="savePricing" :disabled="pricingLoading">保存定价配置</button>
|
||||||
|
<text class="loading-text" v-if="pricingLoading">保存中...</text>
|
||||||
</view>
|
</view>
|
||||||
<!-- 管理员 -->
|
<!-- 管理员 -->
|
||||||
<view v-if="tab === 'admins'" class="section">
|
<view v-if="tab === 'admins'" class="section">
|
||||||
@@ -151,8 +213,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { api } from '../../config'
|
import { api, API_ENDPOINTS } from '../../config'
|
||||||
|
|
||||||
const verified = ref(false)
|
const verified = ref(false)
|
||||||
const adminName = ref('')
|
const adminName = ref('')
|
||||||
@@ -172,6 +234,27 @@ const adminList = ref([])
|
|||||||
const searchResult = ref(null)
|
const searchResult = ref(null)
|
||||||
const memberConfig = ref({ interview: { maxRoundsFree: 5, maxRoundsVip: 10, dailyFreeLimit: 3 }, diagnosis: { dailyFreeLimit: 2 }, optimize: { dailyFreeLimit: 2 }, price: { monthly: 2900 } })
|
const memberConfig = ref({ interview: { maxRoundsFree: 5, maxRoundsVip: 10, dailyFreeLimit: 3 }, diagnosis: { dailyFreeLimit: 2 }, optimize: { dailyFreeLimit: 2 }, price: { monthly: 2900 } })
|
||||||
const cfgLoading = ref(false)
|
const cfgLoading = ref(false)
|
||||||
|
const pricing = ref({
|
||||||
|
interview: { pricePerSession: 500 },
|
||||||
|
resumeOptimize: { freeLimit: 3, pricePerOptimize: 300 },
|
||||||
|
resumeDownload: { pricePerDownload: 200 },
|
||||||
|
plans: {
|
||||||
|
growth: { price: 1990, durationDays: 30, credits: { interview: 999, resumeOptimize: 20, resumeDownload: 10 }, features: ['免费版全部权益', 'AI 数字人面试无限次'] },
|
||||||
|
sprint: { price: 4990, durationDays: 30, credits: { interview: 999, resumeOptimize: 50, resumeDownload: 30 }, features: ['成长版全部权益'] },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const pricingLoading = ref(false)
|
||||||
|
const growthPriceTemp = ref(19.9)
|
||||||
|
const sprintPriceTemp = ref(49.9)
|
||||||
|
const growthFeaturesText = ref('')
|
||||||
|
const sprintFeaturesText = ref('')
|
||||||
|
|
||||||
|
const growthPriceDisplay = computed(() => growthPriceTemp.value.toFixed(1))
|
||||||
|
const sprintPriceDisplay = computed(() => sprintPriceTemp.value.toFixed(1))
|
||||||
|
|
||||||
|
const calcInterviewPrice = () => {
|
||||||
|
// Convert to 分 on save
|
||||||
|
}
|
||||||
const orders = ref([])
|
const orders = ref([])
|
||||||
const ordersTotal = ref(0)
|
const ordersTotal = ref(0)
|
||||||
const ordersPage = ref(1)
|
const ordersPage = ref(1)
|
||||||
@@ -218,7 +301,7 @@ const switchTab = (t) => {
|
|||||||
if (t === 'users' && users.value.length === 0) loadUsers()
|
if (t === 'users' && users.value.length === 0) loadUsers()
|
||||||
if (t === 'interviews' && interviews.value.length === 0) loadInterviews()
|
if (t === 'interviews' && interviews.value.length === 0) loadInterviews()
|
||||||
if (t === 'admins' && adminList.value.length === 0) loadAdmins()
|
if (t === 'admins' && adminList.value.length === 0) loadAdmins()
|
||||||
if (t === 'config') loadConfig()
|
if (t === 'pricing') loadPricing()
|
||||||
if (t === 'orders') loadOrders()
|
if (t === 'orders') loadOrders()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,6 +332,43 @@ const loadInterviews = async () => {
|
|||||||
finally { ivLoading.value = false }
|
finally { ivLoading.value = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const loadPricing = async () => {
|
||||||
|
pricingLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await apiAdmin('/pricing')
|
||||||
|
if (res.statusCode === 200 && res.data) {
|
||||||
|
pricing.value = res.data
|
||||||
|
growthPriceTemp.value = (res.data.plans?.growth?.price || 1990) / 100
|
||||||
|
sprintPriceTemp.value = (res.data.plans?.sprint?.price || 4990) / 100
|
||||||
|
growthFeaturesText.value = (res.data.plans?.growth?.features || []).join('\n')
|
||||||
|
sprintFeaturesText.value = (res.data.plans?.sprint?.features || []).join('\n')
|
||||||
|
}
|
||||||
|
} catch (e) { console.error(e) }
|
||||||
|
finally { pricingLoading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const savePricing = async () => {
|
||||||
|
pricingLoading.value = true
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(JSON.stringify(pricing.value))
|
||||||
|
data.plans.growth.price = Math.round(growthPriceTemp.value * 100)
|
||||||
|
data.plans.sprint.price = Math.round(sprintPriceTemp.value * 100)
|
||||||
|
data.plans.growth.features = growthFeaturesText.value.split('\n').filter(f => f.trim())
|
||||||
|
data.plans.sprint.features = sprintFeaturesText.value.split('\n').filter(f => f.trim())
|
||||||
|
|
||||||
|
const res = await apiAdmin('/pricing/save', { method: 'POST', data })
|
||||||
|
if (res.statusCode === 200) {
|
||||||
|
uni.showToast({ title: '保存成功', icon: 'success' })
|
||||||
|
} else {
|
||||||
|
uni.showToast({ title: '保存失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
uni.showToast({ title: '保存失败', icon: 'none' })
|
||||||
|
console.error(e)
|
||||||
|
}
|
||||||
|
finally { pricingLoading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
const loadConfig = async () => {
|
const loadConfig = async () => {
|
||||||
cfgLoading.value = true
|
cfgLoading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -401,6 +521,10 @@ const setVip = async (targetUserId) => {
|
|||||||
.sync-btn { font-size: 20rpx; color: var(--color-primary); padding: 4rpx 12rpx; border: 2rpx solid var(--color-primary); border-radius: var(--radius-round); }
|
.sync-btn { font-size: 20rpx; color: var(--color-primary); padding: 4rpx 12rpx; border: 2rpx solid var(--color-primary); border-radius: var(--radius-round); }
|
||||||
.config-card { background: #FFF; border-radius: var(--radius-sm); padding: 20rpx; margin-bottom: 12rpx; }
|
.config-card { background: #FFF; border-radius: var(--radius-sm); padding: 20rpx; margin-bottom: 12rpx; }
|
||||||
.cfg-title { font-size: 24rpx; font-weight: 600; color: var(--color-text); margin-bottom: 12rpx; }
|
.cfg-title { font-size: 24rpx; font-weight: 600; color: var(--color-text); margin-bottom: 12rpx; }
|
||||||
.cfg-row { display: flex; justify-content: space-between; padding: 8rpx 0; font-size: 22rpx; color: var(--color-text-secondary); }
|
.cfg-row { display: flex; justify-content: space-between; padding: 8rpx 0; font-size: 22rpx; color: var(--color-text-secondary); align-items: center; }
|
||||||
.cfg-val { font-weight: 600; color: var(--color-primary); }
|
.cfg-val { font-weight: 600; color: var(--color-primary); }
|
||||||
|
.cfg-input { width: 160rpx; height: 56rpx; border: 2rpx solid var(--color-border); border-radius: var(--radius-sm); padding: 0 12rpx; font-size: 22rpx; text-align: center; }
|
||||||
|
.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; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
placeholder - will use inline SVG fallback in component
|
This is a dummy file - the component uses CSS initials fallback when image fails to load.
|
||||||
Reference in New Issue
Block a user