8 Commits

Author SHA1 Message Date
yuzhiran c2ba810a02 fix: use onShow lifecycle to refresh login state in member center
1. member.vue: added onShow to refresh login state when returning from login page

2. manifest.json: bump to v1.0.15

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-18 21:23:20 +08:00
yuzhiran 3f1239c35e fix: add network error detection and timeout for mini program JSAPI payment
1. member.vue: check for res.statusCode === 0 (network failure) before accessing data, add 30s timeout

2. manifest.json: bump to v1.0.14

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-18 20:54:42 +08:00
yuzhiran 1be5b34906 chore: bump version to 1.0.13
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-18 20:39:56 +08:00
yuzhiran c58bb27575 fix: admin page reactive import + member payment toast show real error
1. admin.vue: 添加缺失的reactive导入,修复管理后台只显示标题不显示功能的问题

2. member.vue: 支付失败toast改为显示后端真实错误信息,而非硬编码'创建订单失败'

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-18 20:39:49 +08:00
yuzhiran e0de29fdd0 fix: AI LLM backup model not producing content; add retry for thinking models
- Replace backup model stepfun-ai/step-3.5-flash with meta/llama-3.1-8b-instruct
  (stepfun is a thinking model that uses all tokens on reasoning and
  never outputs content, causing all 3 fallthroughs to fail)
- Add retry with doubled max_tokens when primary model returns empty
  content (deepseek-v4-flash thinking can exhaust token budget)
- Increase backup timeout to 120s and max_tokens to min 2048
- Move callApi error handling to return null instead of throw for
  cleaner fallthrough logic with timeout logging
2026-06-18 19:42:40 +08:00
yuzhiran 6a3cc8544e fix: handle WeChat Pay public key mode in callback
- verifyAndDecrypt now processes decryption even when signature
  verification fails (decryption key is separate from signature key)
- Notify handler uses returnRaw flag to always decrypt resource
- Loud log when pub_key.pem verification fails, directs admin
  to download correct public key from merchant platform
2026-06-18 19:36:19 +08:00
yuzhiran c161ffbc3c feat: payment refund support + admin payment management
- Add refund()/queryRefund()/downloadPlatformCerts() to WechatPayService
- Add refundId field to PaymentOrder schema
- Fix WeChat Pay callback to auto-download platform certs on verification failure
- Fix syncOrder to handle sprint plan properly
- Add admin refund, refund-query, order-detail endpoints
- Add refund UI (button, modal, query) to admin.vue orders tab
- Fix member.vue MP payment: pass outTradeNo instead of prepayId to pollPayResult
2026-06-18 19:33:10 +08:00
yuzhiran 7e1bf669ab fix: show toast when clicking login without agreeing to privacy terms
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-18 18:54:44 +08:00
9 changed files with 312 additions and 87 deletions
+56 -9
View File
@@ -279,12 +279,19 @@ export class AdminController {
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 planId = order.plan === 'sprint' ? 'sprint' : 'growth'
const planCfg = pricing.plans?.[planId]
const credits = planCfg?.credits || { interview: 999, resumeOptimize: 20, resumeDownload: 10 }
const expireAt = new Date()
expireAt.setDate(expireAt.getDate() + (pricing.plans?.growth?.durationDays || VIP_DURATION_DAYS))
user.plan = 'growth'
user.vipExpireAt = expireAt
await this.quotaService.setPlanQuota(order.userId, 'growth', credits)
expireAt.setDate(expireAt.getDate() + (planCfg?.durationDays || VIP_DURATION_DAYS))
user.plan = planId
if (planId === 'sprint') {
user.sprintExpireAt = expireAt
user.sprintRemaining = 10
} else {
user.vipExpireAt = expireAt
}
await this.quotaService.setPlanQuota(order.userId, planId, credits)
}
} else {
const pricing = await this.pricingService.getConfig()
@@ -302,6 +309,46 @@ export class AdminController {
return { order, wxResult }
}
/** 订单详情(含用户信息) */
@Get('order/:outTradeNo')
async getOrderDetail(@Param('outTradeNo') outTradeNo: string) {
const order = await this.orderModel.findOne({ outTradeNo }).lean().exec()
if (!order) throw new HttpException('订单不存在', HttpStatus.NOT_FOUND)
const user = await this.userModel.findById(order.userId).select('phone nickname plan').lean().exec()
return { order, user }
}
/** 发起退款 */
@Post('order/refund')
async refundOrder(@Body('outTradeNo') outTradeNo: string, @Body('amount') amount?: number, @Body('reason') reason?: string) {
const order = await this.orderModel.findOne({ outTradeNo }).exec()
if (!order) throw new HttpException('订单不存在', HttpStatus.NOT_FOUND)
if (order.status !== 'success') throw new HttpException('仅支付成功的订单可退款', HttpStatus.BAD_REQUEST)
if (order.refundAmount && order.refundAmount > 0) throw new HttpException('该订单已退款', HttpStatus.BAD_REQUEST)
const result = await this.wechatPay.refund(outTradeNo, order.amount, amount || order.amount, reason)
const refundId = result?.refund_id || ''
order.status = 'refunded'
order.refundAmount = amount || order.amount
order.refundedAt = new Date()
order.refundReason = reason || ''
order.refundId = refundId
await order.save()
return { success: true, refundId }
}
/** 查询微信侧退款状态 */
@Get('order/refund/:outTradeNo')
async queryRefund(@Param('outTradeNo') outTradeNo: string) {
const order = await this.orderModel.findOne({ outTradeNo }).lean().exec()
if (!order) throw new HttpException('订单不存在', HttpStatus.NOT_FOUND)
if (!order.refundId) return { localStatus: order.status, message: '无微信退款单号' }
const wxResult = await this.wechatPay.queryRefund(order.refundId)
return { localStatus: order.status, wxRefund: wxResult }
}
@Get('config')
async getConfig() {
const cfg = await this.configModel.findOne({ key: 'site_config' }).exec()
@@ -362,8 +409,8 @@ const DEFAULT_CONFIG = {
optimize: { dailyFreeLimit: 2 },
price: { monthly: 1990 },
plans: {
free: { name: '免费版', price: 0, features: ['AI 模拟面试 1 次(体验)', '每场最多 5 轮 AI 对话', '基础面试报告', '简历优化(限 3 次)'] },
growth: { name: '成长版', price: 1990, features: ['免费版全部权益', 'AI 数字人面试无限次', '每场最多 10 轮 AI 对话', '详细面试报告(四维评分)', '进步轨迹雷达图 + 打卡', '参考回答思路', '公司真题库', '简历优化 20 次/月', '简历下载 10 次/月'] },
free: { name: '免费版', price: 0, features: ['AI 模拟面试 1 次(体验)', '基础面试报告', '通用题库随机出题', '简历优化(限 3 次免费'] },
growth: { name: '成长版', price: 1990, features: ['免费版全部权益', 'AI 数字人面试无限次', '详细面试报告(四维评分)', '进步轨迹雷达图 + 打卡', '每日一题', '参考回答思路', '公司真题库', '简历优化 20 次/月', '简历下载 10 次/月'] },
},
}
@@ -375,12 +422,12 @@ const DEFAULT_PRICING = {
growth: {
price: 1990, durationDays: 30,
credits: { interview: 999, resumeOptimize: 20, resumeDownload: 10 },
features: ['免费版全部权益', 'AI 数字人面试无限次', '详细面试报告(四维评分)', '进步轨迹雷达图 + 打卡', '每日一题推送', '参考回答思路', '公司真题库', '简历优化 20 次/月', '简历下载 10 次/月'],
features: ['免费版全部权益', 'AI 数字人面试无限次', '详细面试报告(四维评分)', '进步轨迹雷达图 + 打卡', '每日一题', '参考回答思路', '公司真题库', '简历优化 20 次/月', '简历下载 10 次/月'],
},
sprint: {
price: 4990, durationDays: 30,
credits: { interview: 999, resumeOptimize: 50, resumeDownload: 30 },
features: ['成长版全部权益', 'AI 语音分析(语气词/语速检测)', '技能缺口分析报告', '学习路径推荐', '真人导师 1v1 点评(每月 1 次)', '简历精修(每月 1 次)', '内推优先', '简历优化 50 次/月', '简历下载 30 次/月'],
features: ['成长版全部权益', 'AI 语音分析(语气词/语速检测)', '技能缺口分析报告', '公司真题库精选', '简历优化 50 次/月', '简历下载 30 次/月'],
},
},
}
+38 -29
View File
@@ -22,65 +22,74 @@ export class AiService {
private readonly backupUrl = process.env.AI_BACKUP_URL || "https://integrate.api.nvidia.com/v1"
private readonly backupKey = process.env.AI_BACKUP_KEY || ""
private readonly backupModel = process.env.AI_BACKUP_MODEL || "stepfun-ai/step-3.5-flash"
private readonly backupModel = process.env.AI_BACKUP_MODEL || "meta/llama-3.1-8b-instruct"
async call(options: AiCallOptions): Promise<string> {
const { systemPrompt, userMessage, temperature = 0.7, maxTokens = 2048 } = options
// Try primary AI (deepseek-v4-flash on sensenova)
try {
const result = await this.callApi(this.primaryUrl, this.primaryKey, this.primaryModel, systemPrompt, userMessage, temperature, maxTokens)
const result = await this.callApi(this.primaryUrl, this.primaryKey, this.primaryModel, systemPrompt, userMessage, temperature, maxTokens, 60000)
if (result) return result
// Primary returned empty content (thinking model exhausted tokens); retry with more tokens
const retry = await this.callApi(this.primaryUrl, this.primaryKey, this.primaryModel, systemPrompt, userMessage, temperature, Math.min(maxTokens * 2, 4096), 60000)
if (retry) return retry
} catch (e) {
this.logger.warn(`Primary AI failed: ${(e as Error).message}, trying primary fallback...`)
}
// Try primary fallback model (sensenova-6.7-flash-lite, same provider)
try {
const result = await this.callApi(this.primaryUrl, this.primaryKey, this.primaryFallbackModel, systemPrompt, userMessage, temperature, maxTokens)
const result = await this.callApi(this.primaryUrl, this.primaryKey, this.primaryFallbackModel, systemPrompt, userMessage, temperature, maxTokens, 60000)
if (result) return result
} catch (e) {
this.logger.warn(`Primary fallback AI also failed: ${(e as Error).message}, trying backup...`)
}
// Try backup AI (NVIDIA)
// Try backup AI (NVIDIA - meta/llama-3.1-8b-instruct)
try {
const result = await this.callApi(this.backupUrl, this.backupKey, this.backupModel, systemPrompt, userMessage, temperature, maxTokens)
const result = await this.callApi(this.backupUrl, this.backupKey, this.backupModel, systemPrompt, userMessage, temperature, Math.max(maxTokens, 2048), 120000)
if (result) return result
} catch (e) {
this.logger.warn(`Backup AI also failed: ${(e as Error).message}`)
}
// Final fallback
throw new Error("AI \u670d\u52a1\u6682\u65f6\u4e0d\u53ef\u7528\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5")
throw new Error("AI 服务暂时不可用,请稍后重试")
}
private async callApi(
baseUrl: string, apiKey: string, model: string,
systemPrompt: string, userMessage: string,
temperature: number, maxTokens: number,
temperature: number, maxTokens: number, timeout: number,
): Promise<string | null> {
const res = await axios.post(
`${baseUrl}/chat/completions`,
{
model,
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: userMessage },
],
temperature,
max_tokens: maxTokens,
},
{
headers: {
"Authorization": `Bearer ${apiKey}`,
"Content-Type": "application/json",
try {
const res = await axios.post(
`${baseUrl}/chat/completions`,
{
model,
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: userMessage },
],
temperature,
max_tokens: maxTokens,
},
timeout: 60000,
httpsAgent: httpAgent,
transitional: { clarifyTimeoutError: true },
},
)
return res.data?.choices?.[0]?.message?.content || null
{
headers: {
"Authorization": `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
timeout,
httpsAgent: httpAgent,
transitional: { clarifyTimeoutError: true },
},
)
return res.data?.choices?.[0]?.message?.content || null
} catch (e: any) {
if (e.code === 'ECONNABORTED') {
this.logger.warn(`AI call timeout (${timeout}ms): ${model}`)
}
return null
}
}
}
@@ -54,6 +54,9 @@ export class PaymentOrder {
@Prop()
refundReason?: string
@Prop()
refundId?: string // 微信退款单号
}
export const PaymentOrderSchema = SchemaFactory.createForClass(PaymentOrder)
@@ -150,8 +150,8 @@ export class PaymentController {
const wechatSignature = req.headers['wechatpay-signature'] || ''
const wechatTimestamp = req.headers['wechatpay-timestamp'] || ''
const wechatNonce = req.headers['wechatpay-nonce'] || ''
const decrypted = this.wechatPay.verifyAndDecrypt(body, wechatSignature, wechatTimestamp, wechatNonce)
if (!decrypted) return { code: 'FAIL', message: '验签失败' }
const decrypted = this.wechatPay.verifyAndDecrypt(body, wechatSignature, wechatTimestamp, wechatNonce, true)
if (!decrypted) return { code: 'FAIL', message: '处理失败' }
const outTradeNo = decrypted.out_trade_no
const wxTransactionId = decrypted.transaction_id
@@ -121,40 +121,92 @@ export class WechatPayService {
}
/** 验证并解密回调通知 */
verifyAndDecrypt(body: any, wechatSignature: string, wechatTimestamp: string, wechatNonce: string) {
// 1. 验签
verifyAndDecrypt(body: any, wechatSignature: string, wechatTimestamp: string, wechatNonce: string, returnRaw?: boolean) {
let verified = false
const message = `${wechatTimestamp}\n${wechatNonce}\n${JSON.stringify(body)}\n`
const certDir = path.resolve(__dirname, '../../certs')
if (!fs.existsSync(certDir)) {
this.logger.error(`证书目录不存在: ${certDir}`)
const pemPath = path.join(certDir, 'pub_key.pem')
if (fs.existsSync(pemPath)) {
const platformCert = fs.readFileSync(pemPath, 'utf8')
const verify = crypto.createVerify('RSA-SHA256').update(message)
verified = verify.verify(platformCert, wechatSignature, 'base64')
} else {
this.logger.warn('pub_key.pem 不存在,跳过验签')
}
if (!verified) {
this.logger.warn(`微信支付回调验签失败 — 请从商户平台下载最新公钥覆盖 pub_key.pem (https://pay.weixin.qq.com/)`)
}
// 2. 解密 resource(解密不依赖公钥,即使验签失败也尝试解密)
try {
const resource = body.resource
const ciphertext = Buffer.from(resource.ciphertext, 'base64')
const associatedData = resource.associated_data || ''
const nonce = resource.nonce
const key = API_V3_KEY
if (!key) throw new Error('WX_API_V3_KEY 未配置')
const authTag = ciphertext.subarray(ciphertext.length - 16)
const data = ciphertext.subarray(0, ciphertext.length - 16)
const decipher = crypto.createDecipheriv('aes-256-gcm', Buffer.from(key), nonce)
decipher.setAAD(Buffer.from(associatedData))
decipher.setAuthTag(authTag)
const decrypted = decipher.update(data) + decipher.final('utf8')
const parsed = JSON.parse(decrypted)
if (returnRaw) return parsed
if (!verified) return null
return parsed
} catch (e) {
this.logger.error(`解密回调 resource 失败: ${e.message}`)
return null
}
const platformCert = fs.readFileSync(path.join(certDir, 'pub_key.pem'), 'utf8')
const verify = crypto.createVerify('RSA-SHA256').update(message)
const isValid = verify.verify(platformCert, wechatSignature, 'base64')
if (!isValid) {
this.logger.warn('微信支付回调验签失败')
return null
}
// 2. 解密 resource
const resource = body.resource
const ciphertext = Buffer.from(resource.ciphertext, 'base64')
const associatedData = resource.associated_data || ''
const nonce = resource.nonce
const key = API_V3_KEY
if (!key) throw new Error('WX_API_V3_KEY 未配置')
// AES-256-GCM 解密
const authTag = ciphertext.subarray(ciphertext.length - 16)
const data = ciphertext.subarray(0, ciphertext.length - 16)
const decipher = crypto.createDecipheriv('aes-256-gcm', Buffer.from(key), nonce)
decipher.setAAD(Buffer.from(associatedData))
decipher.setAuthTag(authTag)
const decrypted = decipher.update(data) + decipher.final('utf8')
return JSON.parse(decrypted)
}
/** 查询订单 */
async queryOrder(outTradeNo: string) {
return this.request('GET', `/v3/pay/transactions/out-trade-no/${outTradeNo}?mchid=${MCHID}`)
}
/** 退款 */
async refund(outTradeNo: string, total: number, refundAmount?: number, reason?: string) {
const body: any = {
out_trade_no: outTradeNo,
out_refund_no: `RF${Date.now()}`,
amount: { refund: refundAmount || total, total, currency: 'CNY' },
}
if (reason) body.reason = reason
return this.request('POST', '/v3/refund/domestic/refunds', body)
}
/** 查询退款 */
async queryRefund(outRefundNo: string) {
return this.request('GET', `/v3/refund/domestic/refunds/${outRefundNo}`)
}
/** 下载微信平台证书(首次部署/证书过期时调用) */
async downloadPlatformCerts(): Promise<string[]> {
if (!API_V3_KEY) throw new Error('WX_API_V3_KEY 未配置')
const certs = await this.request('GET', '/v3/certificates')
const downloaded: string[] = []
const certDir = path.resolve(__dirname, '../../certs')
if (!fs.existsSync(certDir)) fs.mkdirSync(certDir, { recursive: true })
for (const item of certs.data || []) {
const { serial_no, effective_time, expire_time, encrypt_certificate } = item
const { algorithm, nonce, associated_data, ciphertext } = encrypt_certificate
if (algorithm !== 'AEAD_AES_256_GCM' || !nonce) continue
const cipherBuf = Buffer.from(ciphertext, 'base64')
const authTag = cipherBuf.subarray(cipherBuf.length - 16)
const data = cipherBuf.subarray(0, cipherBuf.length - 16)
const decipher = crypto.createDecipheriv('aes-256-gcm', Buffer.from(API_V3_KEY), nonce)
decipher.setAAD(Buffer.from(associated_data))
decipher.setAuthTag(authTag)
const decrypted = decipher.update(data) + decipher.final('utf8')
const pemPath = path.join(certDir, 'pub_key.pem')
fs.writeFileSync(pemPath, decrypted)
downloaded.push(serial_no)
this.logger.log(`微信平台证书已更新: ${serial_no}, 有效期至 ${expire_time}`)
}
return downloaded
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
{
"name": "宇之然AI磁场",
"appid": "__UNI__DEV__",
"versionName": "1.0.12",
"versionCode": "112",
"versionName": "1.0.15",
"versionCode": "115",
"description": "职引 - 宇之然AI磁场旗下AI模拟面试平台,提供AI面试官模拟练习、简历智能优化、大厂面经题库,助你轻松应对校招面试。",
"h5": {
"title": "职引 - AI模拟面试 | 宇之然AI磁场",
+90 -3
View File
@@ -142,8 +142,10 @@
{{ o.status === 'success' ? '已支付' : o.status === 'refunded' ? '已退款' : '待支付' }}
</view>
<text class="order-time">{{ o.createdAt ? o.createdAt.slice(0,16).replace('T',' ') : '--' }}</text>
<view class="order-actions" v-if="o.status === 'pending'">
<text class="sync-btn" @click="syncOrder(o.outTradeNo)">同步</text>
<view class="order-actions">
<text class="sync-btn" v-if="o.status === 'pending'" @click="syncOrder(o.outTradeNo)">同步</text>
<text class="refund-btn" v-if="o.status === 'success'" @click="openRefundModal(o)">退款</text>
<text class="sync-btn" v-if="o.status === 'refunded'" @click="queryRefund(o.outTradeNo)">查询</text>
</view>
</view>
</view>
@@ -338,6 +340,29 @@
</view>
</view>
<!-- 退款弹窗 -->
<view class="modal-mask" v-if="refundModal.show" @click="closeRefundModal">
<view class="modal-content" @click.stop>
<text class="modal-title">退款 - {{ refundModal.order?.outTradeNo }}</text>
<view class="cfg-row">
<text>订单金额</text>
<text class="cfg-val">¥{{ ((refundModal.order?.amount || 0) / 100).toFixed(1) }}</text>
</view>
<view class="cfg-row">
<text>退款金额</text>
<input class="cfg-input" type="digit" v-model.number="refundAmount" :placeholder="((refundModal.order?.amount || 0) / 100).toFixed(1)" />
</view>
<view class="cfg-row">
<text>退款原因</text>
<input class="cfg-input" style="width:300rpx" v-model="refundReason" placeholder="选填" />
</view>
<view class="modal-actions">
<button class="modal-btn cancel" @click="closeRefundModal">取消</button>
<button class="modal-btn confirm" style="background:#EF4444" @click="doRefund">确认退款</button>
</view>
</view>
</view>
<!-- 管理员 -->
<view v-if="tab === 'admins'" class="section">
<view class="search-bar">
@@ -368,7 +393,7 @@
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, reactive } from 'vue'
import { api, API_ENDPOINTS } from '../../config'
const verified = ref(false)
@@ -448,6 +473,67 @@ const creditTypes = ref([
{ key: 'shareCredits', label: '分享积分', value: 0 },
])
// Refund modal
const refundModal = ref({ show: false, order: null })
const refundAmount = ref(0)
const refundReason = ref('')
const openRefundModal = (order) => {
refundModal.value = { show: true, order }
refundAmount.value = order.amount / 100
refundReason.value = ''
}
const closeRefundModal = () => {
refundModal.value = { show: false, order: null }
}
const doRefund = async () => {
const order = refundModal.value.order
if (!order) return
uni.showModal({
title: '确认退款', content: `确定对订单 ${order.outTradeNo} 退款 ¥${refundAmount.value.toFixed(1)}`,
success: async (r) => {
if (!r.confirm) return
try {
const res = await apiAdmin('/order/refund', {
method: 'POST',
body: { outTradeNo: order.outTradeNo, amount: Math.round(refundAmount.value * 100), reason: refundReason.value },
})
if (res.statusCode === 200) {
uni.showToast({ title: '退款成功', icon: 'success' })
closeRefundModal()
loadOrders()
} else {
uni.showToast({ title: res.data?.message || '退款失败', icon: 'none' })
}
} catch (e) {
uni.showToast({ title: '退款失败', icon: 'none' })
}
},
})
}
const queryRefund = async (outTradeNo) => {
uni.showToast({ title: '查询中...', icon: 'none' })
try {
const res = await apiAdmin('/order/refund/' + outTradeNo)
if (res.statusCode === 200) {
const wx = res.data.wxRefund
if (wx) {
uni.showModal({
title: '退款状态',
content: `微信状态: ${wx.status || '--'}\n退款金额: ¥${(wx.amount?.refund || 0) / 100}\n建议以微信侧为准`,
})
} else {
uni.showToast({ title: '本地状态: ' + (res.data.localStatus || '--'), icon: 'none' })
}
} else {
uni.showToast({ title: '查询失败', icon: 'none' })
}
} catch { uni.showToast({ title: '查询失败', icon: 'none' }) }
}
const token = () => uni.getStorageSync('token') || ''
const apiAdmin = (path, opts = {}) => {
@@ -860,6 +946,7 @@ onMounted(() => { doVerify() })
.order-time { font-size: 20rpx; color: var(--color-text-tertiary); flex: 1; text-align: right; }
.order-actions { }
.sync-btn { font-size: 20rpx; color: var(--color-primary); padding: 4rpx 12rpx; border: 2rpx solid var(--color-primary); border-radius: var(--radius-round); }
.refund-btn { font-size: 20rpx; color: #EF4444; padding: 4rpx 12rpx; border: 2rpx solid #EF4444; border-radius: var(--radius-round); }
.config-card { background: #FFF; border-radius: var(--radius-sm); padding: 20rpx; margin-bottom: 12rpx; }
.cfg-title { font-size: 24rpx; font-weight: 600; color: var(--color-text); margin-bottom: 12rpx; }
.cfg-row { display: flex; justify-content: space-between; padding: 8rpx 0; font-size: 22rpx; color: var(--color-text-secondary); align-items: center; }
+12 -7
View File
@@ -33,7 +33,7 @@
<text class="field-label">密码</text>
<input class="input" type="password" v-model="password" placeholder="请输入密码" @confirm="doPasswordLogin" />
</view>
<button class="login-btn" :disabled="!canPasswordLogin || pwdLoading || !agreed" @click="doPasswordLogin">
<button class="login-btn" :disabled="!canPasswordLogin || pwdLoading" @click="doPasswordLogin">
{{ pwdLoading ? '登录中...' : '登录' }}
</button>
<view class="switch-hint" @click="loginMode='code'">忘记密码使用验证码登录</view>
@@ -54,7 +54,7 @@
<text class="field-label">验证码</text>
<input class="input" type="number" maxlength="6" v-model="emailCode" placeholder="请输入6位验证码" />
</view>
<button class="login-btn" :disabled="!emailSent || !emailCode || emailLoading || !agreed" @click="doEmailLogin">
<button class="login-btn" :disabled="!emailSent || !emailCode || emailLoading" @click="doEmailLogin">
{{ emailLoading ? '登录中...' : '登录' }}
</button>
<view class="switch-hint" @click="loginMode='password'">已有密码使用密码登录</view>
@@ -77,7 +77,7 @@
<text class="field-label">确认密码</text>
<input class="input" type="password" v-model="confirmPassword" placeholder="再次输入密码" @confirm="doRegister" />
</view>
<button class="login-btn" :disabled="!canRegister || regLoading || !agreed" @click="doRegister">
<button class="login-btn" :disabled="!canRegister || regLoading" @click="doRegister">
{{ regLoading ? '注册中...' : '注册' }}
</button>
<view class="switch-hint" @click="mainTab='login'">已有账号去登录</view>
@@ -87,7 +87,7 @@
<view class="card" v-if="mainTab === 'wechat' && isMp">
<text class="card-title">微信一键登录</text>
<text class="card-sub">授权后自动创建账号</text>
<button class="login-btn wx-btn" :disabled="wxLoading || !agreed" @click="doWxLogin">{{ wxLoading ? '登录中...' : '微信一键登录' }}</button>
<button class="login-btn wx-btn" :disabled="wxLoading" @click="doWxLogin">{{ wxLoading ? '登录中...' : '微信一键登录' }}</button>
</view>
<!-- 法律声明 - 用户自主勾选同意 -->
@@ -156,6 +156,10 @@ onBeforeUnmount(() => { if (timer) { clearTimeout(timer); timer = null } })
// 辅助
const showToast = (title, icon = 'none') => uni.showToast({ title, icon })
const checkAgreed = () => {
if (!agreed.value) { showToast('请阅读并同意《用户服务协议》和《隐私政策》'); return false }
return true
}
const loginSuccess = (data) => {
uni.setStorageSync('token', data.token)
if (data.user) uni.setStorageSync('userInfo', JSON.stringify(data.user))
@@ -165,7 +169,7 @@ const loginSuccess = (data) => {
// ====== 密码登录 ======
const doPasswordLogin = async () => {
if (!canPasswordLogin.value) return
if (!canPasswordLogin.value || !checkAgreed()) return
pwdLoading.value = true
try {
const res = await uni.request({
@@ -227,7 +231,7 @@ const startCooldown = () => {
}
const doEmailLogin = async () => {
if (!emailCode.value) return
if (!emailCode.value || !checkAgreed()) return
emailLoading.value = true
try {
const res = await uni.request({
@@ -250,7 +254,7 @@ const doEmailLogin = async () => {
// ====== 注册 ======
const doRegister = async () => {
if (!canRegister.value) return
if (!canRegister.value || !checkAgreed()) return
regLoading.value = true
try {
const res = await uni.request({
@@ -292,6 +296,7 @@ const skipSetPwd = () => { showSetPwd.value = false }
// ====== 微信登录 ======
const doWxLogin = async () => {
// #ifdef MP-WEIXIN
if (!checkAgreed()) { wxLoading.value = false; return }
wxLoading.value = true
try {
const wxResp = await uni.login()
+31 -9
View File
@@ -89,6 +89,7 @@
<script setup>
import { ref, onMounted, nextTick } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { api } from '../../config'
import UQRCode from 'uqrcodejs'
@@ -107,18 +108,21 @@ const growthPriceText = ref('¥19.9')
const sprintPriceText = ref('¥49.9')
const currentOutTradeNo = ref('')
const freeFeatures = ref(['AI 模拟面试 1 次(体验)', '基础面试报告', '通用题库随机出题', '简历优化(限 3 次免费)'])
const growthFeatures = ref(['免费版全部权益', 'AI 数字人面试无限次', '详细面试报告(四维评分)', '进步轨迹雷达图 + 打卡', '每日一题推送', '参考回答思路', '公司真题库', '每场最多 10 轮 AI 对话'])
const sprintFeatures = ref(['成长版全部权益', 'AI 语音分析(语气词/语速检测)', '技能缺口分析报告', '学习路径推荐', '真人导师 1v1 点评(每月 1 次)', '简历精修(每月 1 次)', '内推优先', 'AI 实时提示功能'])
const growthFeatures = ref(['免费版全部权益', 'AI 数字人面试无限次', '详细面试报告(四维评分)', '进步轨迹雷达图 + 打卡', '每日一题', '参考回答思路', '公司真题库'])
const sprintFeatures = ref(['成长版全部权益', 'AI 语音分析(语气词/语速检测)', '技能缺口分析报告', '公司真题库精选'])
const token = () => uni.getStorageSync('token') || ''
onMounted(async () => {
const refreshState = async () => {
// #ifdef MP-WEIXIN
isMp.value = true
// #endif
const t = token()
if (!t) return
if (!t) {
isLoggedIn.value = false
return
}
isLoggedIn.value = true
try {
@@ -146,7 +150,10 @@ onMounted(async () => {
}
}
} catch (e) { /* ignore */ }
})
}
onMounted(() => { refreshState() })
onShow(() => { refreshState() })
const goLogin = () => uni.navigateTo({ url: '/pages/login/login' })
@@ -183,6 +190,7 @@ const startPay = async (selectedPlan) => {
url: api('/payment/jsapi'), method: 'POST',
data: { plan: planLabel },
header: { 'Authorization': `Bearer ${t}`, 'Content-Type': 'application/json' },
timeout: 30000,
})
payLoading.value = false
@@ -197,13 +205,22 @@ const startPay = async (selectedPlan) => {
package: pp.package,
signType: pp.signType || 'RSA',
paySign: pp.paySign,
success: () => pollPayResult(res.data.prepayId, planLabel),
success: () => {
const no = currentOutTradeNo.value || res.data.outTradeNo
pollPayResult(no, planLabel)
},
fail: (err) => { payError.value = '支付取消或失败'; uni.showToast({ title: '支付取消', icon: 'none' }) },
})
} else if (!res.statusCode || res.statusCode === 0) {
payLoading.value = false
const errMsg = '网络连接失败,请检查网络后重试'
payError.value = errMsg
uni.showToast({ title: errMsg, icon: 'none' })
} else {
payLoading.value = false
payError.value = res.data?.message || '创建订单失败'
uni.showToast({ title: '创建订单失败', icon: 'none' })
const errMsg = res.data?.message || '创建订单失败'
payError.value = errMsg
uni.showToast({ title: errMsg, icon: 'none' })
}
} catch (e) {
payLoading.value = false
@@ -238,9 +255,14 @@ const startPay = async (selectedPlan) => {
})
// 轮询支付结果
pollPayResult(res.data.outTradeNo, planLabel)
} else if (!res.statusCode || res.statusCode === 0) {
payLoading.value = false
const errMsg = '网络连接失败,请检查网络后重试'
payError.value = errMsg
uni.showToast({ title: errMsg, icon: 'none' })
} else {
payError.value = res.data?.message || '支付服务暂不可用'
uni.showToast({ title: '支付服务暂不可用', icon: 'none' })
uni.showToast({ title: res.data?.message || '支付服务暂不可用', icon: 'none' })
}
} catch (e) {
payLoading.value = false