feat: latest code update

This commit is contained in:
yuzhiran
2026-06-16 13:18:36 +08:00
parent 5a49d15696
commit 96c367e0f8
17 changed files with 198 additions and 107 deletions
@@ -0,0 +1,10 @@
{
"sessionID": "ses_15492f54bffepl01q9FkaRyN28",
"updatedAt": "2026-06-16T01:20:56.855Z",
"sources": {
"background-task": {
"state": "idle",
"updatedAt": "2026-06-16T01:20:56.855Z"
}
}
}
@@ -1,4 +1,4 @@
import { Controller, Get, Post, Body, Query, HttpException, HttpStatus, UseGuards } from '@nestjs/common'
import { Controller, Get, Post, Body, Query, Param, HttpException, HttpStatus, UseGuards } from '@nestjs/common'
import { InjectModel } from '@nestjs/mongoose'
import { Model } from 'mongoose'
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'
@@ -226,7 +226,7 @@ export class AdminController {
}
@Get('user/:id')
async getUserDetail(@Query('id') id: string) {
async getUserDetail(@Param('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([
@@ -2,6 +2,7 @@ import { Controller, Post, Get, Body, Param, UseGuards, Logger } from '@nestjs/c
import { InjectModel } from '@nestjs/mongoose'
import { Model } from 'mongoose'
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'
import { Public } from '../../common/decorators/public.decorator'
import { CurrentUser } from '../../common/decorators/current-user.decorator'
import { AiService } from '../ai/ai.service'
import { Contribution, ContributionDocument } from '../schemas/contribution.schema'
@@ -186,4 +187,19 @@ export class ContributionController {
.select('company position rounds experience createdAt')
.exec()
}
@Public()
@Get('companies/hot')
async getHotCompanies() {
const banks = await this.companyBankModel.aggregate([
{ $group: { _id: '$company', positionCount: { $sum: 1 }, totalContributions: { $sum: '$contributionCount' } } },
{ $sort: { totalContributions: -1, positionCount: -1 } },
{ $project: { _id: 0, name: '$_id', positionCount: 1 } },
]).exec()
if (banks.length > 0) return banks
const DEFAULT_COMPANIES = ['腾讯', '字节跳动', '阿里巴巴', '美团', '百度', '京东', '网易', '小红书']
return DEFAULT_COMPANIES.map((name, i) => ({ name, positionCount: 0, sort: i }))
}
}
@@ -18,8 +18,8 @@ const DEFAULT_PRICING: PricingConfig = {
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: [] },
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 次/月'] },
},
}
+7 -9
View File
@@ -47,15 +47,6 @@ export class QuotaService {
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
if (user.plan !== 'free') 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}`)
}
// Try paid credits first
const paid = await this.userModel.findOneAndUpdate(
{ _id: userId, resumeOptimizeCredits: { $gt: 0 } },
@@ -63,6 +54,13 @@ export class QuotaService {
).exec()
if (paid) return
// Try old remaining credits (backward compat)
const oldRemaining = await this.userModel.findOneAndUpdate(
{ _id: userId, remaining: { $gt: 0 } },
{ $inc: { remaining: -1 } },
).exec()
if (oldRemaining) return
// Then free limit
const freeResult = await this.userModel.findOneAndUpdate(
{ _id: userId, freeOptimizeUsed: { $lt: FREE_OPTIMIZE_LIMIT } },
+5 -14
View File
@@ -64,14 +64,14 @@
<text class="user-name">{{ u.nickname || '--' }}</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-plan" :class="{ vip: u.plan === 'growth' || u.plan === 'sprint' }">{{ u.plan === 'growth' || u.plan === 'sprint' ? '会员' : '免费' }}</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" v-if="u.plan === 'free'" @click="setVip(u._id)">设为会员</text>
<text class="user-action-btn credit" @click="openCreditModal(u)">调整额度</text>
</view>
</view>
@@ -339,7 +339,7 @@ const resumeLoading = ref(false)
const adminKeyword = ref('')
const adminList = ref([])
const searchResult = ref(null)
const memberConfig = ref({ interview: { maxRoundsFree: 5, maxRoundsVip: 10, dailyFreeLimit: 3 }, diagnosis: { dailyFreeLimit: 2 }, optimize: { dailyFreeLimit: 2 }, price: { monthly: 2900 } })
const memberConfig = ref({ interview: { maxRoundsFree: 5, maxRoundsVip: 10, dailyFreeLimit: 3 }, diagnosis: { dailyFreeLimit: 2 }, optimize: { dailyFreeLimit: 2 }, price: { monthly: 1990 } })
const cfgLoading = ref(false)
const pricing = ref({
interview: { pricePerSession: 500 },
@@ -360,7 +360,7 @@ const growthPriceDisplay = computed(() => growthPriceTemp.value.toFixed(1))
const sprintPriceDisplay = computed(() => sprintPriceTemp.value.toFixed(1))
const calcInterviewPrice = () => {
// Convert to 分 on save
// Handled in savePricing via growthPriceTemp / sprintPriceTemp
}
const orders = ref([])
const ordersTotal = ref(0)
@@ -399,7 +399,7 @@ const doVerify = async () => {
try {
const res = await apiAdmin('/check')
if (res.statusCode === 200 && res.data?.isAdmin) {
adminName.value = '管理员'
adminName.value = res.data.nickname || res.data.username || '管理员'
verified.value = true
loadOverview()
} else throw new Error('无管理员权限')
@@ -500,15 +500,6 @@ const savePricing = async () => {
finally { pricingLoading.value = false }
}
const loadConfig = async () => {
cfgLoading.value = true
try {
const res = await apiAdmin('/config')
if (res.statusCode === 200) memberConfig.value = res.data
} catch(e) { console.error(e) }
finally { cfgLoading.value = false }
}
const loadOrders = async () => {
orderLoading.value = true
ordersPage.value = 1
+13 -13
View File
@@ -17,7 +17,7 @@
@tap="selectCompany(c.name)"
>
<view class="company-name">{{ c.name }}</view>
<view class="company-count">{{ c.positions }} 个岗位</view>
<view class="company-count">{{ c.positionCount > 0 ? c.positionCount + ' 个岗位' : '暂无题库' }}</view>
</view>
</view>
</view>
@@ -84,23 +84,12 @@
<script>
import { api } from '../../config'
const HOT_COMPANIES = [
{ name: '腾讯', positions: 5 },
{ name: '字节跳动', positions: 4 },
{ name: '阿里巴巴', positions: 5 },
{ name: '美团', positions: 3 },
{ name: '百度', positions: 4 },
{ name: '京东', positions: 3 },
{ name: '网易', positions: 3 },
{ name: '小红书', positions: 2 },
]
export default {
data() {
return {
keyword: '',
searching: false,
hotCompanies: HOT_COMPANIES,
hotCompanies: [],
selectedCompany: '',
selectedPosition: '',
positions: [],
@@ -109,7 +98,18 @@ export default {
loadingQuestions: false,
}
},
onLoad() {
this.loadHotCompanies()
},
methods: {
async loadHotCompanies() {
try {
const res = await uni.request({ url: api('/contribution/companies/hot'), method: 'GET' })
if (res.statusCode === 200) this.hotCompanies = res.data || []
} catch (e) {
console.error(e)
}
},
difficultyLabel(d) {
const map = { junior: '简单', medium: '中等', senior: '困难' }
return map[d] || d || '中等'
+13 -5
View File
@@ -75,10 +75,12 @@
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { api } from '../../config'
const props = defineProps({ interviewId: String, position: String })
const interviewId = ref('')
const urlPosition = ref('')
const form = ref({ company: '', position: '', rounds: '', experience: '', tags: [] })
const questionsText = ref('')
const customTag = ref('')
@@ -89,8 +91,14 @@ const presetTags = ['算法题多', '重视项目经历', '面试官nice', '压
const token = () => uni.getStorageSync('token') || ''
onMounted(() => {
if (props.position) form.value.position = props.position
onLoad((options) => {
if (options?.position) {
urlPosition.value = decodeURIComponent(options.position)
form.value.position = urlPosition.value
}
if (options?.interviewId) {
interviewId.value = options.interviewId
}
})
const toggleTag = (tag) => {
@@ -122,7 +130,7 @@ const submit = async () => {
url: api('/contribution'), method: 'POST',
header: { 'Authorization': `Bearer ${token()}`, 'Content-Type': 'application/json' },
data: {
interviewId: props.interviewId || '',
interviewId: interviewId.value || '',
company: form.value.company.trim(),
position: form.value.position.trim(),
rounds: form.value.rounds.trim(),
+18 -8
View File
@@ -13,7 +13,7 @@
<text class="user-name">{{ userInfo.nickname || '同学' }}</text>
<view class="user-tags">
<text class="tag tag-plan">{{ userInfo.plan || '免费版' }}</text>
<text class="tag tag-remaining">剩余 {{ userInfo.remaining || 0 }} </text>
<text class="tag tag-remaining">{{ userInfo.interviewCredits > 0 ? '剩余 ' + userInfo.interviewCredits + ' 次' : '已用完' }}</text>
</view>
</view>
<text class="arrow"></text>
@@ -112,19 +112,18 @@
<view class="section-header">
<view class="section-title-row">
<text class="section-title">热门岗位</text>
<text class="section-tag-demo">参考示例</text>
</view>
<text class="section-desc">点击直接面试</text>
</view>
<view class="position-list card" v-if="!positionsLoading">
<view class="pos-item" v-for="(pos, idx) in hotPositions" :key="idx" @click="startInterview(pos)">
<view class="pos-left">
<text class="pos-icon">{{ posIcons[idx] || '💼' }}</text>
<text class="pos-icon">{{ pos.icon || posIcons[idx % posIcons.length] || '💼' }}</text>
<view class="pos-body">
<text class="pos-name">{{ pos.name }}</text>
<view class="pos-meta-row">
<text class="pos-company">{{ pos.company || '参考公司' }}</text>
<text class="pos-salary">{{ pos.salary || '参考薪资' }}</text>
<view class="pos-meta-row" v-if="pos.company || pos.salary">
<text class="pos-company">{{ pos.company }}</text>
<text class="pos-salary">{{ pos.salary }}</text>
</view>
</view>
</view>
@@ -190,7 +189,19 @@ onMounted(async () => {
onShow(loadUserInfo)
const refreshDaily = () => { showAnswer.value = false; /* trigger reload */ }
const refreshDaily = async () => {
showAnswer.value = false
try {
const t = uni.getStorageSync('token')
if (t) {
const qres = await uni.request({
url: api('/daily-question'), method: 'GET',
header: { 'Authorization': `Bearer ${t}` }
})
if (qres.statusCode === 200 && qres.data) dailyQuestion.value = qres.data
}
} catch (e) { /* silent */ }
}
const goProfile = () => uni.switchTab({ url: '/pages/user/user' })
const goLogin = () => uni.navigateTo({ url: '/pages/login/login' })
@@ -242,7 +253,6 @@ const startInterview = (pos) => uni.navigateTo({ url: `/pages/interview/intervie
.section-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20rpx; }
.section-title { font-size: 30rpx; font-weight: 700; color: var(--color-text); }
.section-title-row { display: flex; align-items: center; gap: 12rpx; }
.section-tag-demo { font-size: 18rpx; color: #9CA3AF; background: #F3F4F6; padding: 2rpx 10rpx; border-radius: 6rpx; }
.section-desc { font-size: 22rpx; color: var(--color-primary); }
.feature-list { display: flex; flex-direction: column; gap: 16rpx; }
+33 -15
View File
@@ -100,12 +100,13 @@ let recorder = null
let timerSeconds = 0
let timerInterval = null
const progressPercent = computed(() => Math.min((answeredCount.value / 5) * 100, 100))
let MAX_QUESTIONS = 10
const progressPercent = computed(() => Math.min((answeredCount.value / MAX_QUESTIONS) * 100, 100))
const formatTime = computed(() => {
const m = Math.floor(timerSeconds / 60); const s = timerSeconds % 60
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
})
const token = computed(() => uni.getStorageSync('token') || '')
const token = () => uni.getStorageSync('token') || ''
onLoad((options) => {
if (options?.position) {
@@ -117,7 +118,7 @@ onLoad((options) => {
onMounted(() => {
timerInterval = setInterval(() => timerSeconds++, 1000)
if (token.value) startInterview()
if (token()) startInterview()
})
onBeforeUnmount(() => {
@@ -125,7 +126,7 @@ onBeforeUnmount(() => {
})
const checkLogin = () => {
if (!token.value) {
if (!token()) {
uni.showModal({
title: '请先登录', content: '登录后即可开始 AI 模拟面试', confirmText: '去登录',
success: (r) => { if (r.confirm) uni.navigateTo({ url: '/pages/login/login' }) },
@@ -141,18 +142,22 @@ const startInterview = async () => {
try {
const res = await uni.request({
url: api('/interview/create'), method: 'POST',
header: { 'Authorization': `Bearer ${token.value}`, 'Content-Type': 'application/json' },
header: { 'Authorization': `Bearer ${token()}`, 'Content-Type': 'application/json' },
data: { position: position.value },
})
if (res.statusCode === 200 && res.data) {
interviewId.value = res.data.id
messages.value = res.data.messages || messages.value
answeredCount.value = res.data.questionCount || 0
if (res.data.totalQuestions) MAX_QUESTIONS = res.data.totalQuestions
// Speak first question in avatar mode
if (avatarMode.value && res.data.messages?.length) {
const last = res.data.messages[res.data.messages.length - 1]
if (last?.role === 'ai') await speakAiText(last.content)
}
} else {
const msg = res.data?.message || '创建面试失败'
messages.value.push({ role: 'ai', content: msg })
}
} catch {
messages.value.push({ role: 'ai', content: '创建面试失败,请重试' })
@@ -164,8 +169,11 @@ const startInterview = async () => {
const sendAnswer = async () => {
if (!inputText.value.trim() || aiLoading.value || isComplete.value) return
if (!token.value) { checkLogin(); return }
if (!interviewId.value) { await startInterview(); return }
if (!token()) { checkLogin(); return }
if (!interviewId.value) {
await startInterview()
if (!interviewId.value) return // creation failed, don't discard answer
}
const answer = inputText.value.trim()
messages.value.push({ role: 'user', content: answer })
@@ -176,19 +184,24 @@ const sendAnswer = async () => {
try {
const res = await uni.request({
url: api(`/interview/${interviewId.value}/answer`), method: 'POST',
header: { 'Authorization': `Bearer ${token.value}`, 'Content-Type': 'application/json' },
header: { 'Authorization': `Bearer ${token()}`, 'Content-Type': 'application/json' },
data: avatarMode.value ? { answer, avatar: true } : { answer },
})
if (res.statusCode === 200 && res.data?.messages) {
const aiMsg = res.data.messages.find(m => m.role === 'ai')
messages.value.push(...res.data.messages)
if (avatarMode.value && aiMsg) {
// Only push AI messages from response to avoid duplicating the user message already added above
const newAiMessages = res.data.messages.filter(m => m.role === 'ai')
if (newAiMessages.length > 0) messages.value.push(...newAiMessages)
if (avatarMode.value && aiMsg) {
await speakAiText(aiMsg.content, res.data.ttsHash, res.data.ttsAmplitude)
}
answeredCount.value = res.data.questionCount || answeredCount.value + 1
if (res.data.ttsHash && !avatarMode.value) {
// Still got TTS but not in avatar mode, just show text
}
if (res.data.totalQuestions) MAX_QUESTIONS = res.data.totalQuestions
} else if (res.statusCode === 403) {
messages.value.push({ role: 'ai', content: res.data?.message || '面试次数已用完' })
isComplete.value = true
} else {
messages.value.push({ role: 'ai', content: res.data?.message || '回答提交失败' })
}
} catch {
messages.value.push({ role: 'ai', content: '回答提交失败,请重试' })
@@ -207,7 +220,7 @@ async function speakAiText(text, ttsHash, ttsAmplitude) {
try {
const synthRes = await uni.request({
url: api('/tts/synthesize'), method: 'POST',
header: { 'Content-Type': 'application/json' },
header: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token()}` },
data: { text },
})
if (synthRes.statusCode === 200 && synthRes.data?.hash) {
@@ -240,12 +253,17 @@ const confirmExit = () => {
function startRecord() {
if (aiLoading.value || isComplete.value) return
// #ifdef MP-WEIXIN
isRecording.value = true
recorder = uni.getRecorderManager()
recorder.onStart(() => {})
recorder.onError(() => { isRecording.value = false })
recorder.start({ format: 'mp3' })
uni.vibrateShort({ type: 'medium' })
// #endif
// #ifndef MP-WEIXIN
uni.showToast({ title: '语音输入仅支持小程序', icon: 'none' })
// #endif
}
function stopRecord() {
@@ -260,7 +278,7 @@ function stopRecord() {
url: api(API_ENDPOINTS.TTS.ASR),
filePath: audioPath,
name: 'audio',
header: { 'Authorization': `Bearer ${token.value}` },
header: { 'Authorization': `Bearer ${token()}` },
})
console.log('[ASR] upload response:', uploadRes.statusCode, typeof uploadRes.data === 'string' ? uploadRes.data.slice(0, 200) : JSON.stringify(uploadRes.data).slice(0, 200))
if (uploadRes.statusCode === 200 && uploadRes.data) {
+1 -1
View File
@@ -147,7 +147,7 @@ onMounted(() => {
// #endif
})
onBeforeUnmount(() => { if (timer) clearInterval(timer) })
onBeforeUnmount(() => { if (timer) { clearTimeout(timer); timer = null } })
// 辅助
const showToast = (title, icon = 'none') => uni.showToast({ title, icon })
+19 -14
View File
@@ -42,7 +42,7 @@
<view class="plan-badge sprint-badge">🚀 冲刺</view>
<view class="plan-header">
<text class="plan-name">冲刺版</text>
<text class="plan-price"><text class="price-num price-sprint">¥49.9</text><text class="price-unit">/</text></text>
<text class="plan-price"><text class="price-num price-sprint">{{ sprintPriceText }}</text><text class="price-unit">/</text></text>
</view>
<view class="plan-features">
<text class="feat" v-for="f in sprintFeatures" :key="f"> {{ f }}</text>
@@ -50,7 +50,7 @@
<view class="plan-action" v-if="!isLoggedIn" @click="goLogin">登录后开通</view>
<view class="plan-action owned" v-else-if="plan === 'sprint'"> 已开通</view>
<view class="plan-action" v-else-if="plan === 'growth'" @click="startPay('sprint')">升级至冲刺版</view>
<view class="plan-action" v-else @click="startPay('sprint')">¥49.9/ 立即开通</view>
<view class="plan-action" v-else @click="startPay('sprint')">{{ sprintPriceText }}/ 立即开通</view>
</view>
</view>
@@ -104,16 +104,11 @@ const payError = ref('')
const payingPlanName = ref('')
const payingPlan = ref('')
const growthPriceText = ref('¥19.9')
const sprintPriceText = ref('¥49.9')
const currentOutTradeNo = ref('')
const freeFeatures = ['每日 2 次 AI 模拟面试', '基础面试报告', '通用题库随机出题', '简历诊断(限 3 次)']
const growthFeatures = [
'免费版全部权益', '无限面试次数', '详细面试报告(四维评分)',
'进步轨迹雷达图 + 打卡', '每日一题推送', '参考回答思路', '公司真题库',
]
const sprintFeatures = [
'成长版全部权益', 'AI 语音分析(语气词/语速检测)', '技能缺口分析报告',
'学习路径推荐', '真人导师 1v1 点评(每月 1 次)', '简历精修(每月 1 次)', '内推优先',
]
const freeFeatures = ref(['AI 模拟面试 1 次(体验)', '基础面试报告', '通用题库随机出题', '简历优化(限 3 次免费'])
const growthFeatures = ref(['免费版全部权益', 'AI 数字人面试无限次', '详细面试报告(四维评分)', '进步轨迹雷达图 + 打卡', '每日一题推送', '参考回答思路', '公司真题库', '每场最多 10 轮 AI 对话'])
const sprintFeatures = ref(['成长版全部权益', 'AI 语音分析(语气词/语速检测)', '技能缺口分析报告', '学习路径推荐', '真人导师 1v1 点评(每月 1 次)', '简历精修(每月 1 次)', '内推优先', 'AI 实时提示功能'])
const token = () => uni.getStorageSync('token') || ''
@@ -136,9 +131,19 @@ onMounted(async () => {
plan.value = d.plan || 'free'
currentPlanName.value = d.planName || '免费版'
}
if (lres.statusCode === 200 && lres.data?.price) {
const p = lres.data.price
growthPriceText.value = `¥${(p.monthly / 100).toFixed(1)}`
if (lres.statusCode === 200 && lres.data) {
const plans = Array.isArray(lres.data.plans) ? lres.data.plans : (Array.isArray(lres.data) ? lres.data : [])
const growth = plans.find((p) => p.id === 'growth')
const sprint = plans.find((p) => p.id === 'sprint')
if (growth) {
growthPriceText.value = `¥${(growth.price / 100).toFixed(1)}`
if (growth.features?.length) growthFeatures.value = growth.features
}
if (sprint?.features?.length) sprintFeatures.value = sprint.features
if (sprint) sprintPriceText.value = `¥${(sprint.price / 100).toFixed(1)}`
if (lres.data.price?.monthly) {
growthPriceText.value = `¥${(lres.data.price.monthly / 100).toFixed(1)}`
}
}
} catch (e) { /* ignore */ }
})
+24 -7
View File
@@ -134,6 +134,7 @@
<script setup>
import { ref, onMounted } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { api } from '../../config'
const stats = ref({ completedInterviews: 0, avgScore: 0, streak: 0 })
@@ -155,6 +156,16 @@ onMounted(async () => {
const t = token()
if (!t) return
await loadProgressData()
})
onShow(async () => {
if (token()) await loadProgressData()
})
async function loadProgressData() {
const t = token()
if (!t) return
try {
// Load progress
const res = await uni.request({
@@ -166,7 +177,7 @@ onMounted(async () => {
progress.value = d
dimensions.value = dimensions.value.map(dim => ({
...dim,
value: d.dimensions?.[dim.key] || Math.round(50 + Math.random() * 30),
value: d.dimensions?.[dim.key] || 0,
}))
}
} catch (e) { console.error(e) }
@@ -191,23 +202,29 @@ onMounted(async () => {
}
} catch (e) { console.error(e) }
// Build week days
buildWeekDays()
}
function buildWeekDays() {
const days = ['日', '一', '二', '三', '四', '五', '六']
const today = new Date()
const arr = []
const checkinDates = (progress.value.checkins || []).map((c) => {
const d = new Date(c.date || c.createdAt)
return `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`
})
for (let i = 6; i >= 0; i--) {
const d = new Date(today)
d.setDate(d.getDate() - i)
const isToday = i === 0
// Mark days with interviews (simulate based on streak)
const key = `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`
arr.push({
label: days[d.getDay()],
isToday,
done: i < (stats.value.streak || 0),
isToday: i === 0,
done: checkinDates.includes(key),
})
}
weekDays.value = arr
})
}
const formatDate = (d) => {
if (!d) return ''
+10 -9
View File
@@ -156,7 +156,9 @@ onLoad(async (options) => {
}))
}
}
}).catch(() => {})
}).catch(e => {
console.error('[report] auto-complete failed:', e)
})
}
}
} catch(e) { console.error(e) }
@@ -288,15 +290,12 @@ async function generateCard() {
ctx.setFillStyle('rgba(165,180,252,0.5)')
ctx.fillText('扫码开始你的模拟面试 → 在微信搜索"职引"小程序', w / 2, 690)
// QR code hint (simulated)
// QR text hint
ctx.setFillStyle('#FFFFFF')
ctx.setFontSize(12)
ctx.setFontSize(16)
ctx.setTextAlign('center')
ctx.fillText('⬛ ⬛ ⬛ ⬛ ⬛', w / 2, 760)
ctx.fillText('⬛ ⬛ ⬛ ⬛ ⬛', w / 2, 780)
ctx.fillText('⬛ ⬛ ⬛ ⬛ ⬛', w / 2, 800)
ctx.fillText('⬛ ⬛ ⬛ ⬛ ⬛', w / 2, 820)
ctx.fillText('微信小程序', w / 2, 855)
ctx.fillText('在微信搜索「职引」小程序', w / 2, 760)
ctx.fillText('查看完整面试报告', w / 2, 790)
ctx.draw(false, async () => {
try {
@@ -306,7 +305,9 @@ async function generateCard() {
itemList: ['保存到相册', '分享给好友'],
success: (res) => {
if (res.tapIndex === 0) {
uni.saveImageToPhotosAlbum({ filePath: tempRes.tempFilePath })
uni.saveImageToPhotosAlbum({ filePath: tempRes.tempFilePath, success: () => uni.showToast({ title: '已保存到相册', icon: 'success' }) })
} else if (res.tapIndex === 1) {
uni.shareAppMessage ? uni.shareAppMessage({ title: '我的面试报告', imageUrl: tempRes.tempFilePath }) : uni.showToast({ title: '请截图后分享', icon: 'none' })
}
},
})
+9 -6
View File
@@ -16,7 +16,7 @@
<text class="score-num">{{ diagnosisResult.score }}</text>
<text class="score-label">/100</text>
</view>
<text class="summary-text">{{ diagnosisResult.summary }}</text>
<text class="summary-text" v-if="diagnosisResult.summary">{{ diagnosisResult.summary }}</text>
</view>
<!-- 岗位匹配度诊断模式 -->
@@ -136,16 +136,19 @@ onLoad(async (options: any) => {
function applyResult(data: any) {
loading.value = false;
if (isOptimize.value) {
optimizedContent.value = data.optimizedContent || '';
changes.value = data.changes || [];
highlights.value = data.highlights || [];
optimizedContent.value = data.optimized || '';
changes.value = (data.changes || []).map((c: any) =>
typeof c === 'string' ? { section: c, description: c } : c
);
highlights.value = [];
} else {
diagnosisResult.value = data;
changes.value = (data.issues || []).map((i: any) => ({
...i,
typeLabel: i.type === 'structure' ? '结构' : i.type === 'content' ? '内容' : i.type === 'keywords' ? '关键词' : i.type === 'achievement' ? '成就' : '格式',
typeLabel: i.level === 'high' ? '严重' : i.level === 'medium' ? '中等' : '轻微',
description: i.desc || i.description,
}));
highlights.value = data.strengths || [];
highlights.value = data.suggestions || [];
}
}
+4 -2
View File
@@ -125,10 +125,12 @@ const todayStats = computed(() => ({
credited: stats.value.todayCredited || 0,
}))
let isWechat = false
const isWechat = ref(false)
onMounted(() => {
isWechat = navigator?.userAgent?.toLowerCase().includes('micromessenger') || false
// #ifdef H5
isWechat.value = navigator?.userAgent?.toLowerCase().includes('micromessenger') || false
// #endif
loadData()
})
+12
View File
@@ -99,6 +99,18 @@ const refreshState = () => {
try { const s = uni.getStorageSync('userInfo'); if (s) userInfo.value = JSON.parse(s) } catch(e) {}
loadStats()
checkAdmin()
// Fetch fresh user info from API to update stale cache (e.g. credits changed after interview)
fetchUserInfo()
}
const fetchUserInfo = async () => {
try {
const res = await uni.request({ url: api('/user/info'), method: 'GET', header: { 'Authorization': `Bearer ${token.value}` } })
if (res.statusCode === 200 && res.data) {
userInfo.value = res.data
uni.setStorageSync('userInfo', JSON.stringify(res.data))
}
} catch(e) { /* silent - cached data is fallback */ }
}
onMounted(refreshState)