Files
zhiyin/zhiyin-app/src/pages/index/index.vue
T
yuzhiran 37cfdfe93c feat: 登录页密码+验证码双模式 / 首页岗位优化 / 法律页面 / 后端接口完善
- 前端:登录页重构,支持密码登录、验证码登录、注册三种模式
- 前端:首页热门岗位添加「参考示例」标签,去虚构数据
- 前端:面试页顶部优化,岗位名+状态标签展示
- 前端:新增用户协议、隐私政策页面及免责声明
- 后端:新增 POST /api/user/register 注册接口
- 后端:新增 POST /api/user/set-password 设置密码接口
- 后端:修复 user.schema.ts unique 索引导致 null 冲突问题
- 后端:新增 payment-order.schema、positions.schema、site-config.schema
- 后端:package.json 新增 postbuild 脚本自动复制证书
- 管理后台:新增订单管理 Tab
2026-06-09 15:39:17 +08:00

243 lines
12 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="page fade-in">
<view class="hero">
<text class="hero-title">{{ greeting }}</text>
<text class="hero-sub">试试下面的功能开启你的求职练习</text>
<view class="user-card card" v-if="userInfo" @click="goProfile">
<image class="avatar" :src="userInfo.avatar || '/static/avatar-default.svg'" mode="aspectFill" />
<view class="user-meta">
<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>
</view>
</view>
<text class="arrow"></text>
</view>
</view>
<!-- 功能入口 -->
<view class="section">
<view class="feature-list">
<view class="feature-primary card" @click="goInterview">
<view class="fp-left">
<view class="fp-icon fp-interview"><text class="fp-emoji">🎙</text></view>
<view class="fp-body">
<text class="fp-name">模拟面试</text>
<text class="fp-brief">AI 面试官 · 真实场景 · 即时反馈</text>
</view>
</view>
<text class="fp-action">开始</text>
</view>
<view class="feature-secondary">
<view class="fs-card card" @click="goProgress">
<view class="fs-top">
<view class="fs-icon fs-progress"><text class="fs-emoji">📊</text></view>
<text class="fs-name">进步轨迹</text>
</view>
<text class="fs-brief">能力雷达 · 打卡记录 · 成长曲线</text>
</view>
<view class="fs-card card" @click="goContribute">
<view class="fs-top">
<view class="fs-icon fs-contribute"><text class="fs-emoji">💡</text></view>
<text class="fs-name">贡献面经</text>
</view>
<text class="fs-brief">分享经验 · 共建题库 · 帮更多人</text>
</view>
</view>
</view>
</view>
<!-- 每日一题 -->
<view class="section" v-if="dailyQuestion">
<view class="section-header">
<text class="section-title">📮 每日一题</text>
<text class="section-desc" @click="refreshDaily">换一题</text>
</view>
<view class="daily-card card">
<text class="daily-tag">{{ dailyQuestion.category || '综合' }}</text>
<text class="daily-question">{{ dailyQuestion.question }}</text>
<view class="daily-answer" v-if="showAnswer">
<text class="daily-answer-label">💡 参考思路</text>
<text class="daily-answer-text">{{ dailyQuestion.referenceAnswer }}</text>
</view>
<view class="daily-actions">
<text class="daily-action" @click="showAnswer = !showAnswer">
{{ showAnswer ? '收起思路' : '查看思路' }}
</text>
<text class="daily-action primary" @click="goInterview">模拟练习 </text>
</view>
</view>
</view>
<!-- 热门岗位 -->
<view class="section">
<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>
<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>
</view>
</view>
<view class="pos-action">
<text class="pos-action-text">立即模拟</text>
</view>
</view>
</view>
<view class="loading-tip" v-if="positionsLoading">加载岗位中...</view>
</view>
<view class="bottom-spacer"></view>
</view>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { api } from '../../config'
const userInfo = ref(null)
const greeting = ref('')
const hotPositions = ref([])
const posIcons = ['💻', '⚙️', '🤖', '📊', '🎨', '🧪', '📱', '🔧']
const positionsLoading = ref(true)
const dailyQuestion = ref(null)
const showAnswer = ref(false)
onMounted(async () => {
try { const s = uni.getStorageSync('userInfo'); if (s) userInfo.value = JSON.parse(s) } catch (e) {}
const h = new Date().getHours()
if (h < 6) greeting.value = '夜深了,早点休息 🌙'
else if (h < 12) greeting.value = '早上好 ☀️'
else if (h < 14) greeting.value = '中午好 🌤'
else if (h < 18) greeting.value = '下午好 🌥'
else greeting.value = '晚上好 🌆'
// 每日一题
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 */ }
// 热门岗位
try {
const res = await uni.request({ url: api('/positions/hot'), method: 'GET' })
if (res.statusCode === 200) hotPositions.value = res.data || []
} catch (e) { console.error(e) }
finally { positionsLoading.value = false }
})
const refreshDaily = () => { showAnswer.value = false; /* trigger reload */ }
const goProfile = () => uni.switchTab({ url: '/pages/user/user' })
const goInterview = () => uni.navigateTo({ url: '/pages/interview/interview' })
const goProgress = () => uni.navigateTo({ url: '/pages/progress/progress' })
const goContribute = () => uni.navigateTo({ url: '/pages/contribute/contribute' })
const startInterview = (pos) => uni.navigateTo({ url: `/pages/interview/interview?position=${encodeURIComponent(pos.name)}` })
</script>
<style scoped>
.page { height: 100%; overflow-y: auto; background: var(--color-bg); }
.hero {
background: linear-gradient(135deg, var(--color-gradient-start) 0%, var(--color-gradient-mid) 50%, var(--color-gradient-end) 100%);
padding: 48rpx 32rpx 72rpx; border-radius: 0 0 48rpx 48rpx;
}
.hero-title { font-size: 40rpx; font-weight: 700; color: #FFF; display: block; line-height: 1.3; }
.hero-sub { font-size: 22rpx; color: rgba(255,255,255,0.7); margin-top: 8rpx; display: block; }
.user-card {
background: rgba(255,255,255,0.95); backdrop-filter: blur(20rpx);
border-radius: var(--radius-xl); padding: 24rpx 28rpx;
display: flex; align-items: center; margin-top: 24rpx;
box-shadow: 0 8rpx 32rpx rgba(0,0,0,0.1);
}
.avatar { width: 88rpx; height: 88rpx; border-radius: 50%; margin-right: 20rpx; border: 3rpx solid var(--color-primary-light); flex-shrink: 0; }
.user-meta { flex: 1; min-width: 0; }
.user-name { font-size: 30rpx; font-weight: 600; color: var(--color-text); }
.user-tags { display: flex; gap: 10rpx; margin-top: 10rpx; }
.tag { font-size: 20rpx; padding: 4rpx 14rpx; border-radius: var(--radius-round); font-weight: 500; }
.tag-plan { background: #EEF2FF; color: var(--color-primary); }
.tag-remaining { background: #ECFDF5; color: var(--color-success); }
.arrow { font-size: 36rpx; color: #D1D5DB; margin-left: 12rpx; }
.section { padding: 32rpx 32rpx 0; }
.section:first-of-type { margin-top: -40rpx; padding-top: 0; }
.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; }
.feature-primary {
display: flex; align-items: center; justify-content: space-between;
padding: 24rpx 28rpx; border-radius: var(--radius-lg);
background: linear-gradient(135deg, #EEF2FF, #DBEAFE);
}
.fp-left { display: flex; align-items: center; gap: 20rpx; flex: 1; }
.fp-icon { width: 64rpx; height: 64rpx; border-radius: 18rpx; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.fp-interview { background: linear-gradient(135deg, var(--color-gradient-start), var(--color-gradient-mid)); }
.fp-emoji { font-size: 32rpx; }
.fp-body { flex: 1; min-width: 0; }
.fp-name { font-size: 30rpx; font-weight: 700; color: var(--color-text); }
.fp-brief { font-size: 20rpx; color: var(--color-text-secondary); margin-top: 4rpx; display: block; }
.fp-action { font-size: 28rpx; color: var(--color-primary); font-weight: 600; flex-shrink: 0; }
.feature-secondary { display: grid; grid-template-columns: 1fr 1fr; gap: 16rpx; }
.fs-card { padding: 20rpx; border-radius: var(--radius-lg); }
.fs-top { display: flex; align-items: center; gap: 10rpx; }
.fs-icon { width: 44rpx; height: 44rpx; border-radius: 12rpx; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.fs-emoji { font-size: 20rpx; }
.fs-name { font-size: 26rpx; font-weight: 600; color: var(--color-text); }
.fs-brief { font-size: 18rpx; color: var(--color-text-secondary); margin-top: 10rpx; display: block; }
.fs-progress { background: linear-gradient(135deg, #EEF2FF, #C7D2FE); }
.fs-contribute { background: linear-gradient(135deg, #ECFDF5, #A7F3D0); }
/* 每日一题 */
.daily-card { padding: 24rpx; border-radius: var(--radius-lg); }
.daily-tag { display: inline-block; padding: 4rpx 14rpx; background: #EEF2FF; color: var(--color-primary); font-size: 20rpx; border-radius: var(--radius-round); margin-bottom: 12rpx; }
.daily-question { font-size: 28rpx; font-weight: 600; color: var(--color-text); line-height: 1.6; display: block; }
.daily-answer { margin-top: 16rpx; padding: 20rpx; background: #F9FAFB; border-radius: var(--radius-md); }
.daily-answer-label { font-size: 22rpx; font-weight: 600; color: var(--color-text-secondary); display: block; margin-bottom: 8rpx; }
.daily-answer-text { font-size: 24rpx; color: var(--color-text-secondary); line-height: 1.6; }
.daily-actions { display: flex; justify-content: space-between; margin-top: 16rpx; padding-top: 16rpx; border-top: 1rpx solid var(--color-border); }
.daily-action { font-size: 24rpx; color: var(--color-text-secondary); }
.daily-action.primary { color: var(--color-primary); font-weight: 600; }
.position-list { border-radius: var(--radius-lg); overflow: hidden; }
.pos-item { padding: 24rpx 28rpx; border-bottom: 1rpx solid var(--color-border); display: flex; justify-content: space-between; align-items: center; }
.pos-item:last-child { border-bottom: none; }
.pos-item:active { background: var(--color-bg); }
.pos-left { display: flex; align-items: center; gap: 16rpx; flex: 1; min-width: 0; }
.pos-icon { font-size: 36rpx; width: 56rpx; height: 56rpx; display: flex; align-items: center; justify-content: center; background: #F3F4F6; border-radius: 14rpx; flex-shrink: 0; }
.pos-body { display: flex; flex-direction: column; flex: 1; min-width: 0; }
.pos-name { font-size: 28rpx; font-weight: 600; color: var(--color-text); }
.pos-meta-row { display: flex; align-items: center; gap: 10rpx; margin-top: 4rpx; }
.pos-company { font-size: 20rpx; color: var(--color-text-tertiary); }
.pos-salary { font-size: 20rpx; color: var(--color-primary); background: #EEF2FF; padding: 2rpx 10rpx; border-radius: 6rpx; }
.pos-action { flex-shrink: 0; margin-left: 16rpx; }
.pos-action-text { font-size: 22rpx; color: var(--color-primary); font-weight: 600; }
.loading-tip { text-align: center; padding: 40rpx; font-size: 24rpx; color: var(--color-text-tertiary); background: #FFF; border-radius: var(--radius-lg); }
.bottom-spacer { height: 40rpx; }
</style>