v4.2 冲刺版+每日推送+支付修复+全量代码评审

## 新增功能
- 冲刺版 ¥49.9/月:完整支付→激活→权益扣减链路
- 每日一题定时推送(@nestjs/schedule,早8点微信订阅消息)
- miniprogram-ci 编译上传脚本(scripts/upload-mp.js)

## Bug修复
- 套餐值统一:vip→growth/sprint(interview轮次限制、analyze次数检查)
- member/pay 移除开发绕过:改为订单校验后激活
- progress→report 参数名不匹配:id→interviewId
- result.vue resume.create() 参数传错(对象→独立参数)
- resume.vue analyze请求缺少Authorization header
- bank.vue contribution请求缺少Authorization header
- member.vue startPay() 缺少try/catch导致网络错误崩溃
- login.vue 调试面板 v-if="true" 生产泄漏

## 配置
- 微信支付生产证书就位(商户号1113760598)
- .env 清理冗余文件(删除.example/.production)
- WX_NOTIFY_URL 更新为 zhiyinwx.yzrcloud.cn

## 文档
- PROJECT-STATUS.md v4.1→v4.2,状态全面更新
- DEPLOYMENT.md 新增小程序编译上传章节、清理检查清单
This commit is contained in:
yuzhiran
2026-06-09 20:03:05 +08:00
parent 37cfdfe93c
commit 9276ab9028
44 changed files with 15205 additions and 2062 deletions
+197
View File
@@ -0,0 +1,197 @@
<template>
<view class="page">
<!-- 搜索栏 -->
<view class="search-bar">
<input class="search-input" v-model="keyword" placeholder="搜索公司名称..." @confirm="searchCompany" />
<button class="search-btn" @tap="searchCompany">搜索</button>
</view>
<!-- 热门公司 -->
<view class="section" v-if="!searching">
<view class="section-title">热门公司题库</view>
<view class="company-grid">
<view
class="company-card"
v-for="c in hotCompanies"
:key="c.name"
@tap="selectCompany(c.name)"
>
<view class="company-name">{{ c.name }}</view>
<view class="company-count">{{ c.positions }} 个岗位</view>
</view>
</view>
</view>
<!-- 搜索结果岗位列表 -->
<view class="section" v-if="selectedCompany && !loadingPositions">
<view class="section-title-row">
<text class="section-title">{{ selectedCompany }} - 岗位列表</text>
<text class="back-link" @tap="selectedCompany = ''">返回</text>
</view>
<view class="position-list">
<view
class="position-item"
v-for="p in positions"
:key="p.position"
@tap="selectPosition(p.position)"
>
<view class="position-name">{{ p.position }}</view>
<view class="position-meta">{{ p.questionCount }} · {{ p.contributionCount }} 人贡献</view>
</view>
<view class="empty-state" v-if="positions.length === 0">
<text>暂无该公司的面经数据</text>
<text class="sub-text">成为第一个贡献者吧</text>
</view>
</view>
</view>
<!-- 题目列表 -->
<view class="section" v-if="selectedPosition && !loadingQuestions">
<view class="section-title-row">
<text class="section-title">{{ selectedCompany }} · {{ selectedPosition }}</text>
<text class="back-link" @tap="selectedPosition = ''">返回</text>
</view>
<view class="question-list">
<view class="question-item" v-for="(q, i) in questions" :key="i">
<view class="q-header">
<text class="q-num">#{{ i + 1 }}</text>
<text class="q-tag">{{ q.type === 'technical' ? '技术' : '行为' }}</text>
<text class="q-diff">{{ difficultyLabel(q.difficulty) }}</text>
<text class="q-freq">{{ q.frequency }} 次提及</text>
</view>
<view class="q-content">{{ q.content }}</view>
<view class="q-tags" v-if="q.tags && q.tags.length">
<text class="tag" v-for="t in q.tags" :key="t">{{ t }}</text>
</view>
<view class="q-answer" v-if="q.referenceAnswer">
<text class="answer-label">参考思路</text>
<text class="answer-text">{{ q.referenceAnswer }}</text>
</view>
</view>
<view class="empty-state" v-if="questions.length === 0">
<text>暂无题目数据</text>
</view>
</view>
</view>
<!-- 加载态 -->
<view class="loading" v-if="loadingPositions || loadingQuestions">
<text>加载中...</text>
</view>
</view>
</template>
<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,
selectedCompany: '',
selectedPosition: '',
positions: [],
questions: [],
loadingPositions: false,
loadingQuestions: false,
}
},
methods: {
difficultyLabel(d) {
const map = { junior: '简单', medium: '中等', senior: '困难' }
return map[d] || d || '中等'
},
async searchCompany() {
const kw = this.keyword.trim()
if (!kw) return
this.selectedCompany = kw
this.selectedPosition = ''
await this.loadPositions(kw)
},
async selectCompany(name) {
this.selectedCompany = name
this.keyword = name
this.selectedPosition = ''
await this.loadPositions(name)
},
async loadPositions(company) {
this.loadingPositions = true
const token = uni.getStorageSync('token') || ''
const header = token ? { 'Authorization': `Bearer ${token}` } : {}
try {
const res = await uni.request({ url: api(`/contribution/company/${encodeURIComponent(company)}`), method: 'GET', header })
this.positions = res.data || []
} catch (e) {
this.positions = []
}
this.loadingPositions = false
},
async selectPosition(position) {
this.selectedPosition = position
await this.loadQuestions(this.selectedCompany, position)
},
async loadQuestions(company, position) {
this.loadingQuestions = true
const token = uni.getStorageSync('token') || ''
const header = token ? { 'Authorization': `Bearer ${token}` } : {}
try {
const res = await uni.request({
url: api(`/contribution/company/${encodeURIComponent(company)}/position/${encodeURIComponent(position)}`),
method: 'GET',
header,
})
this.questions = res.data?.questions || []
} catch (e) {
this.questions = []
}
this.loadingQuestions = false
},
},
}
</script>
<style scoped>
.page { padding: 20rpx; min-height: 100vh; background: #f5f6f7; }
.search-bar { display: flex; gap: 20rpx; margin-bottom: 30rpx; }
.search-input { flex: 1; height: 80rpx; background: #fff; border-radius: 40rpx; padding: 0 30rpx; font-size: 28rpx; }
.search-btn { height: 80rpx; line-height: 80rpx; padding: 0 40rpx; background: #4F46E5; color: #fff; border-radius: 40rpx; font-size: 28rpx; }
.section { margin-bottom: 30rpx; background: #fff; border-radius: 16rpx; padding: 30rpx; }
.section-title { font-size: 32rpx; font-weight: 600; margin-bottom: 20rpx; display: block; }
.section-title-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20rpx; }
.back-link { color: #4F46E5; font-size: 26rpx; }
.company-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 20rpx; }
.company-card { background: #F3F0FF; border-radius: 12rpx; padding: 24rpx; text-align: center; }
.company-name { font-size: 28rpx; font-weight: 500; color: #333; }
.company-count { font-size: 22rpx; color: #999; margin-top: 8rpx; }
.position-item { padding: 24rpx; border-bottom: 1rpx solid #f0f0f0; }
.position-name { font-size: 28rpx; font-weight: 500; color: #333; }
.position-meta { font-size: 24rpx; color: #999; margin-top: 8rpx; }
.question-item { padding: 24rpx; border-bottom: 1rpx solid #f0f0f0; }
.q-header { display: flex; align-items: center; gap: 12rpx; margin-bottom: 12rpx; }
.q-num { font-size: 24rpx; color: #4F46E5; font-weight: 600; }
.q-tag { font-size: 22rpx; background: #E8F5E9; color: #2E7D32; padding: 2rpx 12rpx; border-radius: 6rpx; }
.q-diff { font-size: 22rpx; background: #FFF3E0; color: #E65100; padding: 2rpx 12rpx; border-radius: 6rpx; }
.q-freq { font-size: 22rpx; color: #999; margin-left: auto; }
.q-content { font-size: 28rpx; color: #333; line-height: 1.6; }
.q-tags { display: flex; flex-wrap: wrap; gap: 8rpx; margin-top: 12rpx; }
.tag { font-size: 22rpx; background: #F3F0FF; color: #4F46E5; padding: 4rpx 16rpx; border-radius: 20rpx; }
.q-answer { margin-top: 16rpx; padding: 16rpx; background: #F8F9FA; border-radius: 8rpx; }
.answer-label { font-size: 24rpx; color: #4F46E5; font-weight: 500; }
.answer-text { font-size: 26rpx; color: #555; line-height: 1.6; }
.empty-state { text-align: center; padding: 60rpx 0; color: #999; font-size: 28rpx; }
.sub-text { display: block; margin-top: 12rpx; font-size: 24rpx; color: #bbb; }
.loading { text-align: center; padding: 60rpx; color: #999; }
</style>