初始化:职引项目 v1.0

This commit is contained in:
yuzhiran
2026-06-08 16:28:00 +08:00
commit 511f60d0db
111 changed files with 27295 additions and 0 deletions
+116
View File
@@ -0,0 +1,116 @@
<script setup lang="ts">
import { onLaunch, onShow, onHide } from '@dcloudio/uni-app'
onLaunch(() => { console.log('职引 App launched') })
onShow(() => { console.log('职引 App shown') })
onHide(() => { console.log('职引 App hidden') })
</script>
<style>
/* ===== Design System - Design Tokens ===== */
page {
--color-primary: #4F46E5;
--color-primary-light: #818CF8;
--color-primary-dark: #3730A3;
--color-gradient-start: #4F46E5;
--color-gradient-mid: #7C3AED;
--color-gradient-end: #A855F7;
--color-surface: #FFFFFF;
--color-bg: #F3F4F6;
--color-text: #111827;
--color-text-secondary: #6B7280;
--color-text-tertiary: #9CA3AF;
--color-border: #E5E7EB;
--color-success: #10B981;
--color-warning: #F59E0B;
--color-error: #EF4444;
--radius-sm: 12rpx;
--radius-md: 16rpx;
--radius-lg: 20rpx;
--radius-xl: 24rpx;
--radius-round: 999rpx;
--shadow-sm: 0 2rpx 8rpx rgba(0,0,0,0.04);
--shadow-md: 0 4rpx 16rpx rgba(0,0,0,0.06);
--shadow-lg: 0 8rpx 32rpx rgba(0,0,0,0.08);
--shadow-purple: 0 6rpx 20rpx rgba(79,70,229,0.25);
--font-title: 32rpx;
--font-body: 28rpx;
--font-caption: 24rpx;
--font-small: 20rpx;
--space-xs: 8rpx;
--space-sm: 12rpx;
--space-md: 16rpx;
--space-lg: 24rpx;
--space-xl: 32rpx;
--space-2xl: 48rpx;
/* Safe area */
--safe-bottom: env(safe-area-inset-bottom, 0px);
background-color: var(--color-bg);
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Noto Sans SC', sans-serif;
font-size: 28rpx;
color: var(--color-text);
line-height: 1.6;
}
/* ===== Global Utility Classes ===== */
/* Gradient text */
.gradient-text {
background: linear-gradient(135deg, var(--color-gradient-start), var(--color-gradient-end));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* Gradient button base */
.btn-gradient {
background: linear-gradient(135deg, var(--color-gradient-start), var(--color-gradient-mid));
color: #FFFFFF;
border-radius: var(--radius-md);
font-weight: 600;
transition: all 0.25s ease;
border: none;
}
.btn-gradient:active { opacity: 0.85; transform: scale(0.97); }
.btn-gradient[disabled] { opacity: 0.5; }
/* Outline button */
.btn-outline {
background: transparent;
color: var(--color-primary);
border: 2rpx solid var(--color-primary);
border-radius: var(--radius-md);
font-weight: 500;
}
/* Card base */
.card {
background: var(--color-surface);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
transition: all 0.25s ease;
}
.card:active { transform: scale(0.98); box-shadow: var(--shadow-md); }
/* Section title */
.section-title {
font-size: var(--font-title);
font-weight: 700;
color: var(--color-text);
}
.section-desc {
font-size: var(--font-caption);
color: var(--color-text-tertiary);
}
/* Smooth fade-in */
.fade-in {
animation: fadeIn 0.4s ease forwards;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(12rpx); }
to { opacity: 1; transform: translateY(0); }
}
</style>
+90
View File
@@ -0,0 +1,90 @@
const getApiBaseUrl = (): string => {
if (import.meta.env.VITE_API_BASE_URL) {
return import.meta.env.VITE_API_BASE_URL
}
return 'http://localhost:3006/api'
}
export const APP_NAME = import.meta.env.VITE_APP_NAME || 'AI磁场'
export const APP_CONFIG = {
APP_NAME,
API_BASE_URL: getApiBaseUrl(),
PAGES: {
INDEX: '/pages/index/index',
INTERVIEW: '/pages/interview/interview',
REPORT: '/pages/report/report',
RESUME: '/pages/resume/resume',
HISTORY: '/pages/history/history',
MEMBER: '/pages/member/member',
PROGRESS: '/pages/progress/progress',
CONTRIBUTE: '/pages/contribute/contribute',
INTERNSHIP: '/pages/internship/internship',
USER: '/pages/user/user',
LOGIN: '/pages/login/login',
ABOUT: '/pages/about/about',
},
STORAGE_KEYS: {
TOKEN: 'token',
USER_ID: 'userId',
RESUME: 'resume',
},
} as const
export const API_ENDPOINTS = {
USER: {
SEND_CODE: '/user/send-code',
LOGIN: '/user/login',
WX_LOGIN: '/user/wx-login',
INFO: '/user/info',
UPDATE: '/user/update',
USAGE: '/user/usage',
},
INTERVIEW: {
CREATE: '/interview/create',
ANSWER: (id: string) => `/interview/${id}/answer`,
COMPLETE: (id: string) => `/interview/${id}/complete`,
GET: (id: string) => `/interview/${id}`,
LIST: '/interview/list/all',
STATS: '/interview/stats/mine',
},
ANALYZE: {
DIAGNOSIS: '/analyze/diagnosis',
OPTIMIZE: '/analyze/optimize',
},
RESUME: {
CREATE: '/resume/create',
LIST: '/resume/list',
GET: (id: string) => `/resume/${id}`,
DELETE: (id: string) => `/resume/${id}`,
},
PROGRESS: {
GET: '/progress',
STATS: '/progress/stats',
},
CONTRIBUTION: {
CREATE: '/contribution',
MY: '/contribution/my',
BANK: (company: string, position: string) => `/contribution/company/${company}/position/${position}`,
COMPANY: (company: string) => `/contribution/company/${company}`,
},
MEMBER: {
PLANS: '/member/plans',
STATUS: '/member/status',
CREATE_ORDER: '/member/create-order',
PAY: '/member/pay',
},
DAILY_QUESTION: {
TODAY: '/daily-question',
BY_POSITION: (position: string) => `/daily-question/position/${position}`,
},
} as const
const API_HOST = typeof window !== 'undefined' && window.location.hostname !== 'localhost' && window.location.hostname !== '127.0.0.1'
? (import.meta.env.VITE_PROD_API_HOST || window.location.origin)
: 'http://localhost:3006'
export function api(path: string): string {
return `${API_HOST}/api${path}`
}
export default APP_CONFIG
+11
View File
@@ -0,0 +1,11 @@
import { createSSRApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
export function createApp() {
const app = createSSRApp(App);
app.use(createPinia());
return {
app,
};
}
+20
View File
@@ -0,0 +1,20 @@
{
"name": "宇之然AI磁场",
"appid": "__UNI__DEV__",
"versionName": "1.0.0",
"versionCode": "100",
"description": "AI 面试模拟 - 先模拟,再面试",
"h5": {
"title": "AI磁场",
"router": {
"mode": "hash"
}
},
"mp-weixin": {
"appid": "wxf466b3c3bc411ffc",
"setting": {
"urlCheck": false
},
"usingComponents": true
}
}
+35
View File
@@ -0,0 +1,35 @@
{
"pages": [
{ "path": "pages/index/index", "style": { "navigationBarTitleText": "职引 - 先模拟,再上场" } },
{ "path": "pages/interview/interview", "style": { "navigationBarTitleText": "模拟面试" } },
{ "path": "pages/report/report", "style": { "navigationBarTitleText": "面试报告" } },
{ "path": "pages/member/member", "style": { "navigationBarTitleText": "会员中心" } },
{ "path": "pages/progress/progress", "style": { "navigationBarTitleText": "进步轨迹" } },
{ "path": "pages/contribute/contribute", "style": { "navigationBarTitleText": "贡献面经" } },
{ "path": "pages/login/login", "style": { "navigationBarTitleText": "登录" } },
{ "path": "pages/history/history", "style": { "navigationBarTitleText": "面试记录" } },
{ "path": "pages/user/user", "style": { "navigationBarTitleText": "我的" } },
{ "path": "pages/resume/resume", "style": { "navigationBarTitleText": "我的简历" } },
{ "path": "pages/internship/internship", "style": { "navigationBarTitleText": "实习搜索" } },
{ "path": "pages/about/about", "style": { "navigationBarTitleText": "关于" } },
{ "path": "pages/admin/admin", "style": { "navigationBarTitleText": "管理后台" } },
{ "path": "pages/result/result", "style": { "navigationBarTitleText": "优化结果" } }
],
"tabBar": {
"color": "#999999",
"selectedColor": "#4F46E5",
"backgroundColor": "#F3F4F6",
"borderStyle": "black",
"list": [
{ "pagePath": "pages/index/index", "text": "面试", "iconPath": "static/tabbar/home.png", "selectedIconPath": "static/tabbar/home-active.png" },
{ "pagePath": "pages/history/history", "text": "记录", "iconPath": "static/tabbar/history.png", "selectedIconPath": "static/tabbar/history-active.png" },
{ "pagePath": "pages/user/user", "text": "我的", "iconPath": "static/tabbar/user.png", "selectedIconPath": "static/tabbar/user-active.png" }
]
},
"globalStyle": {
"navigationBarTextStyle": "black",
"navigationBarTitleText": "职引",
"navigationBarBackgroundColor": "#ffffff",
"backgroundColor": "#f5f6f7"
}
}
+70
View File
@@ -0,0 +1,70 @@
<template>
<view class="page">
<view class="logo-area">
<text class="logo">职引</text>
<text class="version">v1.0.0</text>
</view>
<view class="info-section">
<text class="info-label">产品名称</text>
<text class="info-value">职引 · AI 面试模拟</text>
</view>
<view class="info-section">
<text class="info-label">开发团队</text>
<text class="info-value">宇之然</text>
</view>
<view class="desc">
<text>职引是一款 AI 驱动的面试模拟工具帮助求职者通过模拟真实面试简历诊断和优化提升面试通过率</text>
</view>
</view>
</template>
<script setup lang="ts">
</script>
<style scoped>
.page {
background: #f5f6f7;
padding: 60rpx 30rpx;
}
.logo-area {
text-align: center;
padding: 80rpx 0;
}
.logo {
font-size: 48rpx;
font-weight: 700;
background: linear-gradient(135deg, #4F46E5, #7C3AED);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
display: block;
margin-bottom: 16rpx;
}
.version {
font-size: 24rpx;
color: #999;
}
.info-section {
background: #fff;
padding: 24rpx 30rpx;
border-radius: 16rpx;
margin-bottom: 16rpx;
display: flex;
justify-content: space-between;
}
.info-label {
font-size: 26rpx;
color: #999;
}
.info-value {
font-size: 26rpx;
color: #333;
}
.desc {
margin-top: 40rpx;
font-size: 24rpx;
color: #999;
line-height: 1.8;
text-align: center;
}
</style>
+321
View File
@@ -0,0 +1,321 @@
<template>
<view class="page">
<view class="hero">
<text class="hero-title">管理后台</text>
<text class="hero-sub" v-if="!verified">使用管理员账号点击下方按钮验证</text>
<text class="hero-sub" v-else>欢迎回来{{ adminName }}</text>
</view>
<!-- 登录 -->
<view class="login-area" v-if="!verified">
<button class="btn-verify" @click="doVerify">验证管理员身份</button>
</view>
<!-- 管理后台 -->
<view class="body" v-if="verified">
<view class="tabs">
<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 === 'interviews' }" @click="switchTab('interviews')">面试记录</text>
<text class="tab" :class="{ active: tab === 'admins' }" @click="switchTab('admins')">管理员</text>
<text class="tab" :class="{ active: tab === 'config' }" @click="switchTab('config')">配置</text>
</view>
<!-- 概览 -->
<view v-if="tab === 'overview' && !loading" class="overview">
<view class="stat-cards">
<view class="stat-card">
<text class="stat-num">{{ overview.userCount }}</text>
<text class="stat-label">总用户</text>
<text class="stat-sub">今日 +{{ overview.todayUsers }}</text>
</view>
<view class="stat-card">
<text class="stat-num">{{ overview.interviewCount }}</text>
<text class="stat-label">总面试</text>
<text class="stat-sub">今日 +{{ overview.todayInterviews }}</text>
</view>
</view>
</view>
<!-- 用户 -->
<view v-if="tab === 'users'" class="section">
<view class="search-bar">
<input v-model="userKeyword" placeholder="搜索手机号/昵称" class="search-input" @confirm="loadUsers" />
<button class="search-btn" @click="loadUsers">搜索</button>
</view>
<view class="user-list" v-if="!usersLoading">
<view class="user-row" v-for="u in users" :key="u._id">
<text class="user-phone">{{ u.phone || '--' }}</text>
<text class="user-name">{{ u.nickname || '--' }}</text>
<text class="user-plan" :class="{ vip: u.plan === 'vip' }">{{ u.plan === 'vip' ? '会员' : '免费' }}</text>
<text class="user-remaining">{{ u.remaining || 0 }}</text>
<text class="user-vip-btn" v-if="u.plan !== 'vip'" @click="setVip(u._id)">设为会员</text>
</view>
<text class="load-more" v-if="usersTotal > users.length" @click="loadMoreUsers">加载更多</text>
</view>
<text class="loading-text" v-if="usersLoading">加载中...</text>
</view>
<!-- 面试 -->
<view v-if="tab === 'interviews'" class="section">
<view class="iv-list" v-if="!ivLoading">
<view class="iv-row" v-for="iv in interviews" :key="iv._id">
<text class="iv-pos">{{ iv.position }}</text>
<text class="iv-user">{{ iv.userId?.phone || iv.userId?.nickname || '--' }}</text>
<text class="iv-status" :class="{ done: iv.status === 'completed' }">{{ iv.status === 'completed' ? '已完成' : '进行中' }}</text>
<text class="iv-questions">{{ iv.questionCount || 0 }}</text>
</view>
</view>
<text class="loading-text" v-if="ivLoading">加载中...</text>
</view>
<!-- 套餐配置 -->
<view v-if="tab === 'config'" class="section">
<view class="config-card" v-if="!cfgLoading">
<view class="cfg-title">面试限制</view>
<view class="cfg-row"><text>免费版每场最大轮次</text><text class="cfg-val">{{ memberConfig.interview.maxRoundsFree }}</text></view>
<view class="cfg-row"><text>会员每场最大轮次</text><text class="cfg-val">{{ memberConfig.interview.maxRoundsVip }}</text></view>
<view class="cfg-row"><text>免费版每日面试次数</text><text class="cfg-val">{{ memberConfig.interview.dailyFreeLimit }}</text></view>
</view>
<view class="config-card" v-if="!cfgLoading">
<view class="cfg-title">诊断与优化限制</view>
<view class="cfg-row"><text>免费版每日诊断次数</text><text class="cfg-val">{{ memberConfig.diagnosis.dailyFreeLimit }}</text></view>
<view class="cfg-row"><text>免费版每日优化次数</text><text class="cfg-val">{{ memberConfig.optimize.dailyFreeLimit }}</text></view>
</view>
<view class="config-card" v-if="!cfgLoading">
<view class="cfg-title">价格</view>
<view class="cfg-row"><text>月度会员</text><text class="cfg-val">¥{{ (memberConfig.price.monthly / 100).toFixed(0) }}</text></view>
</view>
<view class="empty-text" v-if="cfgLoading">加载中...</view>
</view>
<!-- 管理员 -->
<view v-if="tab === 'admins'" class="section">
<view class="search-bar">
<input v-model="adminKeyword" placeholder="搜索用户ID或手机号设为管理员" class="search-input" @confirm="searchAdmin" />
<button class="search-btn" @click="searchAdmin">搜索</button>
</view>
<view class="section-label">当前管理员</view>
<view class="user-list">
<view class="admin-row" v-for="a in adminList" :key="a._id">
<text class="admin-phone">{{ a.phone || '--' }}</text>
<text class="admin-name">{{ a.nickname || '--' }}</text>
<text class="admin-badge" v-if="a.isSystemAdmin">系统</text>
</view>
<text class="empty-text" v-if="adminList.length === 0">暂无管理员</text>
</view>
<view class="section-label" v-if="searchResult">搜索结果</view>
<view class="user-list" v-if="searchResult">
<view class="admin-row">
<text class="admin-phone">{{ searchResult.phone || '--' }}</text>
<text class="admin-name">{{ searchResult.nickname || '--' }}</text>
<text class="admin-set-btn" v-if="searchResult.role !== 'admin'" @click="setAdmin(searchResult._id)">设为管理员</text>
<text class="admin-set-btn done" v-else>已是管理员</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { api } from '../../config'
const verified = ref(false)
const adminName = ref('')
const tab = ref('overview')
const loading = ref(false)
const usersLoading = ref(false)
const ivLoading = ref(false)
const userKeyword = ref('')
const usersPage = ref(1)
const overview = ref({ userCount: 0, interviewCount: 0, todayUsers: 0, todayInterviews: 0 })
const users = ref([])
const usersTotal = ref(0)
const interviews = ref([])
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 cfgLoading = ref(false)
const token = () => uni.getStorageSync('token') || ''
const apiAdmin = (path, opts = {}) => {
return uni.request({
url: api('/admin' + path),
method: opts.method || 'GET',
header: { 'Authorization': `Bearer ${token()}`, 'Content-Type': 'application/json', ...opts.headers },
data: opts.body || opts.data,
})
}
const doVerify = async () => {
const t = token()
if (!t) { uni.navigateTo({ url: '/pages/login/login' }); return }
try {
const res = await apiAdmin('/check')
if (res.statusCode === 200 && res.data?.isAdmin) {
adminName.value = '管理员'
verified.value = true
loadOverview()
} else throw new Error('无管理员权限')
} catch (e) {
uni.showToast({ title: '当前账号非管理员,无权限访问', icon: 'none' })
}
}
const loadOverview = async () => {
loading.value = true
try {
const res = await apiAdmin('/overview')
if (res.statusCode === 200) overview.value = res.data
} catch (e) { console.error(e) }
finally { loading.value = false }
}
const switchTab = (t) => {
tab.value = t
if (t === 'users' && users.value.length === 0) loadUsers()
if (t === 'interviews' && interviews.value.length === 0) loadInterviews()
if (t === 'admins' && adminList.value.length === 0) loadAdmins()
if (t === 'config') loadConfig()
}
const loadUsers = async () => {
usersLoading.value = true
usersPage.value = 1
try {
const res = await apiAdmin('/users?keyword=' + encodeURIComponent(userKeyword.value) + '&page=1&limit=20')
if (res.statusCode === 200) { users.value = res.data.users || []; usersTotal.value = res.data.total || 0 }
} catch (e) { console.error(e) }
finally { usersLoading.value = false }
}
const loadMoreUsers = async () => {
usersPage.value++
try {
const res = await apiAdmin('/users?keyword=' + encodeURIComponent(userKeyword.value) + '&page=' + usersPage.value + '&limit=20')
if (res.statusCode === 200) users.value = [...users.value, ...(res.data.users || [])]
} catch (e) { console.error(e) }
}
const loadInterviews = async () => {
ivLoading.value = true
try {
const res = await apiAdmin('/interviews?page=1&limit=20')
if (res.statusCode === 200) interviews.value = res.data.interviews || []
} catch (e) { console.error(e) }
finally { ivLoading.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 loadAdmins = async () => {
try {
const res = await apiAdmin('/admins')
if (res.statusCode === 200) adminList.value = res.data.admins || []
} catch(e) { console.error(e) }
}
const searchAdmin = async () => {
if (!adminKeyword.value.trim()) return
try {
const res = await apiAdmin('/users?keyword=' + encodeURIComponent(adminKeyword.value) + '&limit=1')
if (res.statusCode === 200 && res.data.users?.length > 0) {
searchResult.value = res.data.users[0]
} else {
uni.showToast({ title: '未找到该用户', icon: 'none' })
searchResult.value = null
}
} catch { searchResult.value = null }
}
const setAdmin = async (targetUserId) => {
uni.showModal({
title: '设为管理员', content: '确定将该用户设为管理员?', success: async (r) => {
if (!r.confirm) return
try {
const res = await apiAdmin('/set-admin', { method: 'POST', data: { userId: targetUserId } })
if (res.statusCode === 200) {
uni.showToast({ title: '已设为管理员', icon: 'success' })
searchResult.value = null
adminKeyword.value = ''
loadAdmins()
} else throw new Error()
} catch { uni.showToast({ title: '操作失败', icon: 'none' }) }
}
})
}
const setVip = async (targetUserId) => {
uni.showModal({
title: '设为会员', content: '确定将该用户升级为月度会员?', success: async (r) => {
if (!r.confirm) return
try {
const res = await apiAdmin('/set-vip', { method: 'POST', data: { userId: targetUserId } })
if (res.statusCode === 200) {
uni.showToast({ title: '已设为会员', icon: 'success' })
loadUsers()
} else throw new Error()
} catch { uni.showToast({ title: '操作失败', icon: 'none' }) }
}
})
}
</script>
<style scoped>
.page { background: var(--color-bg); min-height: 100vh; }
.hero { background: linear-gradient(135deg, var(--color-gradient-start), var(--color-gradient-mid), var(--color-gradient-end)); padding: 48rpx 32rpx 72rpx; border-radius: 0 0 48rpx 48rpx; }
.hero-title { font-size: 40rpx; font-weight: 700; color: #FFF; }
.hero-sub { font-size: 22rpx; color: rgba(255,255,255,0.7); margin-top: 8rpx; display: block; }
.login-area { padding: 32rpx; margin-top: -40rpx; display: flex; flex-direction: column; gap: 16rpx; }
.admin-input { height: 72rpx; background: #FFF; border: 2rpx solid var(--color-border); border-radius: var(--radius-sm); padding: 0 20rpx; font-size: 24rpx; }
.btn-verify { 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; }
.body { padding: 20rpx 32rpx 48rpx; margin-top: -40rpx; }
.tabs { display: flex; gap: 8rpx; background: #FFF; border-radius: var(--radius-md); padding: 6rpx; margin-bottom: 20rpx; }
.tab { flex: 1; text-align: center; padding: 14rpx; font-size: 24rpx; color: var(--color-text-secondary); border-radius: var(--radius-sm); }
.tab.active { background: var(--color-primary); color: #FFF; font-weight: 600; }
.stat-cards { display: flex; gap: 16rpx; }
.stat-card { flex: 1; background: #FFF; border-radius: var(--radius-lg); padding: 28rpx; text-align: center; box-shadow: var(--shadow-sm); }
.stat-num { font-size: 48rpx; font-weight: 800; color: var(--color-primary); display: block; }
.stat-label { font-size: 22rpx; color: var(--color-text-secondary); margin-top: 8rpx; display: block; }
.stat-sub { font-size: 20rpx; color: var(--color-success); margin-top: 4rpx; display: block; }
.search-bar { display: flex; gap: 12rpx; margin-bottom: 16rpx; }
.search-input { flex: 1; height: 64rpx; background: #FFF; border: 2rpx solid var(--color-border); border-radius: var(--radius-sm); padding: 0 16rpx; font-size: 24rpx; }
.search-btn { height: 64rpx; padding: 0 24rpx; background: var(--color-primary); color: #FFF; border-radius: var(--radius-sm); font-size: 24rpx; border: none; }
.user-row { background: #FFF; padding: 20rpx; border-radius: var(--radius-sm); margin-bottom: 8rpx; display: flex; flex-wrap: wrap; gap: 8rpx; }
.user-phone { font-size: 24rpx; font-weight: 600; color: var(--color-text); }
.user-name { font-size: 22rpx; color: var(--color-text-secondary); }
.user-plan { font-size: 20rpx; background: #EEF2FF; color: var(--color-primary); padding: 2rpx 12rpx; border-radius: var(--radius-round); }
.user-remaining { font-size: 20rpx; color: var(--color-text-tertiary); }
.loading-text { text-align: center; padding: 40rpx; color: var(--color-text-tertiary); font-size: 24rpx; }
.load-more { text-align: center; padding: 20rpx; color: var(--color-primary); font-size: 24rpx; display: block; }
.iv-row { background: #FFF; padding: 20rpx; border-radius: var(--radius-sm); margin-bottom: 8rpx; display: flex; flex-wrap: wrap; gap: 8rpx; align-items: center; }
.iv-pos { font-size: 24rpx; font-weight: 600; color: var(--color-text); }
.iv-user { font-size: 22rpx; color: var(--color-text-secondary); }
.iv-status { font-size: 20rpx; background: #FFF7ED; color: var(--color-warning); padding: 2rpx 12rpx; border-radius: var(--radius-round); }
.iv-status.done { background: #ECFDF5; color: var(--color-success); }
.iv-questions { font-size: 20rpx; color: var(--color-text-tertiary); }
.section-label { font-size: 24rpx; font-weight: 600; color: var(--color-text); margin-bottom: 12rpx; margin-top: 12rpx; }
.admin-row { background: #FFF; padding: 20rpx; border-radius: var(--radius-sm); margin-bottom: 8rpx; display: flex; flex-wrap: wrap; gap: 8rpx; align-items: center; }
.admin-phone { font-size: 24rpx; font-weight: 600; color: var(--color-text); }
.admin-name { font-size: 22rpx; color: var(--color-text-secondary); flex: 1; }
.admin-set-btn { font-size: 22rpx; color: var(--color-primary); padding: 4rpx 16rpx; border: 2rpx solid var(--color-primary); border-radius: var(--radius-round); }
.admin-set-btn.done { color: var(--color-success); border-color: var(--color-success); }
.admin-badge { font-size: 18rpx; background: var(--color-primary); color: #FFF; padding: 2rpx 10rpx; border-radius: var(--radius-round); }
.empty-text { text-align: center; padding: 20rpx; color: var(--color-text-tertiary); font-size: 22rpx; display: block; }
.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); }
.cfg-val { font-weight: 600; color: var(--color-primary); }
</style>
@@ -0,0 +1,191 @@
<template>
<view class="page fade-in">
<view class="hero">
<text class="hero-title">贡献面经</text>
<text class="hero-sub">分享你的面试经验帮助更多同学</text>
</view>
<view class="form card">
<view class="form-group">
<text class="form-label">公司名称 <text class="required">*</text></text>
<input class="form-input" v-model="form.company" placeholder="如:腾讯、字节跳动、阿里巴巴" />
</view>
<view class="form-group">
<text class="form-label">面试岗位 <text class="required">*</text></text>
<input class="form-input" v-model="form.position" placeholder="如:前端工程师、产品经理" />
</view>
<view class="form-group">
<text class="form-label">面试轮次</text>
<input class="form-input" v-model="form.rounds" placeholder="如:一面(技术面)" />
</view>
<view class="form-group">
<text class="form-label">遇到的面试题每行一题</text>
<textarea
class="form-textarea"
v-model="questionsText"
placeholder="把你记得的面试题写下来,帮大家提前准备:&#10;1. 请介绍一下你最熟悉的项目&#10;2. 解释一下闭包的原理&#10;..."
:maxlength="2000"
></textarea>
<text class="form-hint">{{ questionsText.length }}/2000</text>
</view>
<view class="form-group">
<text class="form-label">面试感受/经验</text>
<textarea
class="form-textarea"
v-model="form.experience"
placeholder="分享一下整体感受、面试官风格、需要特别注意的地方..."
:maxlength="1000"
></textarea>
<text class="form-hint">{{ form.experience.length }}/1000</text>
</view>
<view class="form-group">
<text class="form-label">标签可选</text>
<view class="tag-input-area">
<view class="tag-list">
<view
v-for="tag in presetTags"
:key="tag"
class="tag-item"
:class="{ selected: form.tags.includes(tag) }"
@click="toggleTag(tag)"
>{{ tag }}</view>
</view>
<input class="form-input" v-model="customTag" placeholder="自定义标签,回车添加" @confirm="addCustomTag" />
</view>
</view>
<button class="btn-submit" @click="submit" :disabled="submitting">
{{ submitting ? '提交中...' : '提交面经' }}
</button>
<view class="success-box" v-if="submitted">
<text class="success-icon">🎉</text>
<text class="success-text">感谢你的分享你的面经将帮助更多同学准备面试</text>
<text class="success-action" @click="goBack">返回</text>
</view>
</view>
<view class="bottom-spacer"></view>
</view>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { api } from '../../config'
const props = defineProps({ interviewId: String, position: String })
const form = ref({ company: '', position: '', rounds: '', experience: '', tags: [] })
const questionsText = ref('')
const customTag = ref('')
const submitting = ref(false)
const submitted = ref(false)
const presetTags = ['算法题多', '重视项目经历', '面试官nice', '压力面', '手撕代码', '系统设计', '行为面试', '八股文']
const token = () => uni.getStorageSync('token') || ''
onMounted(() => {
if (props.position) form.value.position = props.position
})
const toggleTag = (tag) => {
const idx = form.value.tags.indexOf(tag)
if (idx > -1) form.value.tags.splice(idx, 1)
else form.value.tags.push(tag)
}
const addCustomTag = () => {
const t = customTag.value.trim()
if (t && !form.value.tags.includes(t) && form.value.tags.length < 10) {
form.value.tags.push(t)
customTag.value = ''
}
}
const submit = async () => {
if (!form.value.company.trim()) { uni.showToast({ title: '请填写公司名称', icon: 'none' }); return }
if (!form.value.position.trim()) { uni.showToast({ title: '请填写面试岗位', icon: 'none' }); return }
submitting.value = true
try {
const questions = questionsText.value
.split('\n')
.map(q => q.replace(/^\d+[\.\、\s]+/, '').trim())
.filter(q => q.length > 0)
const res = await uni.request({
url: api('/contribution'), method: 'POST',
header: { 'Authorization': `Bearer ${token()}`, 'Content-Type': 'application/json' },
data: {
interviewId: props.interviewId || '',
company: form.value.company.trim(),
position: form.value.position.trim(),
rounds: form.value.rounds.trim(),
questions,
experience: form.value.experience.trim(),
tags: form.value.tags,
},
})
if (res.statusCode >= 200 && res.statusCode < 300) {
submitted.value = true
uni.showToast({ title: '提交成功!', icon: 'success' })
} else {
uni.showToast({ title: '提交失败,请重试', icon: 'none' })
}
} catch (e) {
uni.showToast({ title: '网络错误', icon: 'none' })
} finally {
submitting.value = false
}
}
const goBack = () => uni.navigateBack()
</script>
<style scoped>
.page { min-height: 100vh; background: var(--color-bg); }
.hero { background: linear-gradient(135deg, #10B981, #34D399, #6EE7B7); padding: 48rpx 32rpx 72rpx; border-radius: 0 0 48rpx 48rpx; }
.hero-title { font-size: 40rpx; font-weight: 700; color: #FFF; display: block; }
.hero-sub { font-size: 22rpx; color: rgba(255,255,255,0.8); margin-top: 8rpx; display: block; }
.form { margin: -40rpx 32rpx 0; border-radius: var(--radius-xl); padding: 32rpx; }
.form-group { margin-bottom: 28rpx; }
.form-label { font-size: 26rpx; font-weight: 600; color: var(--color-text); display: block; margin-bottom: 10rpx; }
.required { color: #EF4444; }
.form-input {
width: 100%; height: 72rpx; background: #F9FAFB; border-radius: var(--radius-md);
padding: 0 20rpx; font-size: 26rpx; box-sizing: border-box; border: 1rpx solid var(--color-border);
}
.form-textarea {
width: 100%; min-height: 180rpx; background: #F9FAFB; border-radius: var(--radius-md);
padding: 16rpx 20rpx; font-size: 26rpx; box-sizing: border-box; border: 1rpx solid var(--color-border);
}
.form-hint { font-size: 20rpx; color: var(--color-text-tertiary); text-align: right; display: block; margin-top: 6rpx; }
.tag-input-area { display: flex; flex-direction: column; gap: 12rpx; }
.tag-list { display: flex; flex-wrap: wrap; gap: 10rpx; }
.tag-item {
padding: 6rpx 18rpx; border-radius: var(--radius-round); font-size: 22rpx;
background: #F3F4F6; color: var(--color-text-secondary); border: 1rpx solid #E5E7EB;
transition: all 0.2s;
}
.tag-item.selected { background: linear-gradient(135deg, #D1FAE5, #A7F3D0); color: #065F46; border-color: #10B981; }
.btn-submit {
width: 100%; height: 88rpx; border-radius: var(--radius-lg);
background: linear-gradient(135deg, #10B981, #34D399); color: #FFF;
font-size: 30rpx; font-weight: 700; border: none; margin-top: 8rpx;
}
.btn-submit[disabled] { opacity: 0.6; }
.success-box { display: flex; flex-direction: column; align-items: center; padding: 40rpx 0; gap: 12rpx; background: #ECFDF5; border-radius: var(--radius-lg); margin-top: 24rpx; }
.success-icon { font-size: 56rpx; }
.success-text { font-size: 26rpx; color: #065F46; font-weight: 600; text-align: center; line-height: 1.5; }
.success-action { font-size: 28rpx; color: var(--color-primary); font-weight: 600; padding: 10rpx 40rpx; }
.bottom-spacer { height: 40rpx; }
</style>
+159
View File
@@ -0,0 +1,159 @@
<template>
<view class="page fade-in">
<view class="hero">
<text class="hero-title">面试记录</text>
<text class="hero-sub">回顾你的成长轨迹</text>
</view>
<view class="stats-bar card">
<view class="stat">
<text class="stat-val">{{ interviewList.length }}</text>
<text class="stat-lbl">总次数</text>
</view>
<view class="stat-sep"></view>
<view class="stat">
<text class="stat-val">{{ avgScore }}</text>
<text class="stat-lbl">平均分</text>
</view>
<view class="stat-sep"></view>
<view class="stat">
<text class="stat-val">{{ completedCount }}</text>
<text class="stat-lbl">已完成</text>
</view>
</view>
<view class="filter-wrap">
<view class="filter-inner">
<view class="filter-tab" :class="{ active: filter === 'all' }" @click="filter = 'all'">全部</view>
<view class="filter-tab" :class="{ active: filter === 'completed' }" @click="filter = 'completed'">已完成</view>
<view class="filter-tab" :class="{ active: filter === 'analyzing' }" @click="filter = 'analyzing'">进行中</view>
</view>
</view>
<view class="list" v-if="filteredList.length > 0">
<view class="record-card card" v-for="(item, idx) in filteredList" :key="idx">
<view class="record-top" @click="goDetail(item)">
<view class="record-icon">{{ item.score >= 80 ? '🌟' : item.score > 0 ? '📋' : '💬' }}</view>
<view class="record-body">
<view class="record-name">{{ item.position }}</view>
<text class="record-meta">{{ item.time }} · {{ item.duration }}</text>
</view>
<view class="record-score" :class="scoreLevel(item.score)">
{{ item.score ? item.score : '--' }}
</view>
</view>
<view class="record-actions" v-if="item.score > 0">
<text class="rec-action" @click="goContribute(item)">💡 贡献面经</text>
</view>
</view>
</view>
<view class="loading-tip" v-if="loading">加载中...</view>
<view class="empty" v-else>
<text class="empty-icon">{{ filter !== 'all' ? '🔍' : '📭' }}</text>
<text class="empty-title">{{ emptyTitle }}</text>
<text class="empty-desc">{{ emptyDesc }}</text>
<button class="empty-btn btn-gradient" @click="goInterview" v-if="filter === 'all'">开始第一次面试</button>
</view>
</view>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { api } from '../../config'
const filter = ref('all')
const interviewList = ref([])
const loading = ref(true)
const completedCount = computed(() => interviewList.value.filter(i => i.score > 0).length)
const avgScore = computed(() => {
const scored = interviewList.value.filter(i => i.score > 0)
if (scored.length === 0) return '--'
return Math.round(scored.reduce((s, i) => s + i.score, 0) / scored.length)
})
const filteredList = computed(() => {
if (filter.value === 'all') return interviewList.value
if (filter.value === 'completed') return interviewList.value.filter(i => i.score > 0)
return interviewList.value.filter(i => i.score === 0)
})
const emptyTitle = computed(() => {
if (filter.value === 'all') return '暂无面试记录'
if (filter.value === 'completed') return '暂无已完成面试'
return '暂无进行中面试'
})
const emptyDesc = computed(() => {
if (filter.value === 'all') return '完成你的第一场模拟面试吧'
if (filter.value === 'completed') return '继续面试练习'
return '所有面试都已评价完成'
})
const formatDate = (d) => {
if (!d) return '--'
const date = new Date(d)
return `${String(date.getMonth()+1).padStart(2,'0')}-${String(date.getDate()).padStart(2,'0')} ${String(date.getHours()).padStart(2,'0')}:${String(date.getMinutes()).padStart(2,'0')}`
}
onMounted(async () => {
const token = uni.getStorageSync('token') || ''
if (!token) { loading.value = false; return }
try {
const res = await uni.request({ url: api('/interview/list/all'), method: 'GET', header: { 'Authorization': `Bearer ${token}` } })
if (res.statusCode === 200 && Array.isArray(res.data)) {
interviewList.value = res.data.map(i => ({
position: i.position || '通用岗位', time: formatDate(i.createdAt || i.time),
score: i.totalScore || 0, duration: `${i.questionCount || 0}`, id: i.id,
}))
}
} catch(e) { console.error(e) }
finally { loading.value = false }
})
const scoreLevel = (s) => { if (!s) return 'pending'; if (s >= 80) return 'good'; if (s >= 60) return 'medium'; return 'poor' }
const goDetail = (item) => { if (item.id) uni.navigateTo({ url: `/pages/report/report?interviewId=${item.id}` }) }
const goContribute = (item) => {
uni.navigateTo({ url: `/pages/contribute/contribute?interviewId=${item.id}&position=${encodeURIComponent(item.position)}` })
}
const goInterview = () => uni.navigateTo({ url: '/pages/interview/interview' })
</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; }
.hero-sub { font-size: 22rpx; color: rgba(255,255,255,0.7); margin-top: 8rpx; display: block; }
.stats-bar { display: flex; align-items: center; padding: 24rpx; margin: -40rpx 32rpx 0; position: relative; z-index: 1; border-radius: var(--radius-lg); }
.stat { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 6rpx; }
.stat-val { font-size: 36rpx; font-weight: 700; color: var(--color-primary); }
.stat-lbl { font-size: 20rpx; color: var(--color-text-tertiary); }
.stat-sep { width: 1rpx; height: 40rpx; background: var(--color-border); }
.filter-wrap { padding: 24rpx 32rpx 0; }
.filter-inner { display: flex; gap: 8rpx; background: #FFF; border-radius: var(--radius-md); padding: 6rpx; }
.filter-tab { flex: 1; text-align: center; font-size: 24rpx; color: var(--color-text-secondary); padding: 14rpx 0; border-radius: var(--radius-sm); }
.filter-tab.active { background: var(--color-primary); color: #FFF; font-weight: 600; }
.list { padding: 20rpx 32rpx 48rpx; }
.record-card { padding: 24rpx 28rpx; margin-bottom: 16rpx; border-radius: var(--radius-lg); }
.record-top { display: flex; align-items: center; gap: 16rpx; }
.record-icon { font-size: 40rpx; width: 64rpx; height: 64rpx; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.record-body { flex: 1; min-width: 0; }
.record-name { font-size: 28rpx; font-weight: 600; color: var(--color-text); }
.record-meta { font-size: 20rpx; color: var(--color-text-tertiary); margin-top: 6rpx; display: block; }
.record-score { font-size: 28rpx; font-weight: 700; width: 72rpx; text-align: right; flex-shrink: 0; }
.record-score.good { color: var(--color-success); }
.record-score.medium { color: var(--color-warning); }
.record-score.poor { color: var(--color-error); }
.record-score.pending { color: var(--color-text-tertiary); }
.record-actions { margin-top: 12rpx; padding-top: 12rpx; border-top: 1rpx solid var(--color-border); display: flex; gap: 20rpx; }
.rec-action { font-size: 22rpx; color: var(--color-primary); font-weight: 500; }
.empty { display: flex; flex-direction: column; align-items: center; padding: 120rpx 32rpx 0; }
.empty-icon { font-size: 80rpx; margin-bottom: 20rpx; }
.empty-title { font-size: 28rpx; font-weight: 600; color: var(--color-text); }
.empty-desc { font-size: 22rpx; color: var(--color-text-tertiary); margin-top: 8rpx; margin-bottom: 36rpx; }
.empty-btn { padding: 18rpx 48rpx; border-radius: var(--radius-round); font-size: 26rpx; }
.loading-tip { text-align: center; padding: 80rpx; font-size: 24rpx; color: var(--color-text-tertiary); }
</style>
+228
View File
@@ -0,0 +1,228 @@
<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">
<text class="section-title">热门岗位</text>
<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">
<view class="pos-rank">{{ idx + 1 }}</view>
<view class="pos-body">
<text class="pos-name">{{ pos.name }}</text>
<text class="pos-company">{{ pos.company }}</text>
</view>
</view>
<text class="pos-salary">{{ pos.salary }}</text>
</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 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: baseline; margin-bottom: 20rpx; }
.section-title { font-size: 30rpx; font-weight: 700; color: var(--color-text); }
.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-left { display: flex; align-items: center; gap: 16rpx; }
.pos-rank { width: 40rpx; height: 40rpx; border-radius: 10rpx; background: #F3F4F6; display: flex; align-items: center; justify-content: center; font-size: 22rpx; font-weight: 700; color: var(--color-primary); flex-shrink: 0; }
.pos-body { display: flex; flex-direction: column; }
.pos-name { font-size: 28rpx; font-weight: 600; color: var(--color-text); }
.pos-company { font-size: 22rpx; color: var(--color-text-tertiary); margin-top: 4rpx; }
.pos-salary { font-size: 24rpx; 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>
@@ -0,0 +1,67 @@
<template>
<view class="page">
<view class="hero">
<text class="hero-title">实习推荐</text>
<text class="hero-sub">热门实习岗位点击直接模拟面试</text>
</view>
<view class="body">
<view class="section-title">🔥 热门实习</view>
<view class="position-list card">
<view class="pos-item" v-for="(item, idx) in positions" :key="idx" @click="startInterview(item)">
<view class="pos-left">
<view class="pos-rank">{{ idx + 1 }}</view>
<view class="pos-body">
<text class="pos-name">{{ item.name }}</text>
<text class="pos-company">{{ item.company }}</text>
</view>
</view>
<text class="pos-salary">{{ item.salary }}</text>
</view>
</view>
<view class="empty" v-if="!loading && positions.length === 0">
<text class="empty-text">暂无实习岗位数据</text>
</view>
</view>
</view>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { api } from '../../config'
const positions = ref([])
const loading = ref(true)
onMounted(async () => {
try {
const res = await uni.request({ url: api('/positions/hot'), method: 'GET' })
if (res.statusCode === 200) positions.value = res.data || []
} catch(e) { console.error(e) }
finally { loading.value = false }
})
const startInterview = (pos) => uni.navigateTo({ url: `/pages/interview/interview?position=${encodeURIComponent(pos.name)}` })
</script>
<style scoped>
.page { min-height: 100vh; 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: #FFFFFF; display: block; }
.hero-sub { font-size: 22rpx; color: rgba(255,255,255,0.7); margin-top: 8rpx; display: block; }
.body { padding: 32rpx; margin-top: -40rpx; }
.section-title { font-size: 28rpx; font-weight: 600; color: var(--color-text); margin-bottom: 16rpx; }
.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:active { background: #F9FAFB; }
.pos-item:last-child { border-bottom: none; }
.pos-left { display: flex; align-items: center; gap: 16rpx; }
.pos-rank { width: 40rpx; height: 40rpx; border-radius: 10rpx; background: #F3F4F6; display: flex; align-items: center; justify-content: center; font-size: 22rpx; font-weight: 700; color: var(--color-primary); flex-shrink: 0; }
.pos-body { display: flex; flex-direction: column; }
.pos-name { font-size: 28rpx; font-weight: 600; color: var(--color-text); }
.pos-company { font-size: 22rpx; color: var(--color-text-tertiary); margin-top: 4rpx; }
.pos-salary { font-size: 24rpx; color: var(--color-primary); font-weight: 600; }
.empty { display: flex; justify-content: center; padding: 60rpx 0; }
.empty-text { font-size: 26rpx; color: var(--color-text-tertiary); }
</style>
@@ -0,0 +1,210 @@
<template>
<view class="page">
<!-- Top bar -->
<view class="topbar">
<view class="topbar-inner">
<view class="back-btn" @click="confirmExit"><text class="back-arrow"></text></view>
<view class="topbar-center">
<view class="progress-track" v-if="interviewId">
<view class="progress-fill" :style="{ width: progressPercent + '%' }"></view>
</view>
<text class="topbar-timer"> {{ formatTime }}</text>
</view>
<view class="topbar-right"></view>
</view>
</view>
<!-- Chat area -->
<scroll-view class="chat-area" scroll-y :scroll-into-view="scrollToId" :scroll-with-animation="true">
<view v-for="(msg, idx) in messages" :key="idx" :id="'msg-' + idx" class="msg-row" :class="msg.role">
<view class="msg-bubble" :class="msg.role">
<text>{{ msg.content }}</text>
</view>
</view>
<!-- Typing indicator -->
<view class="msg-row ai" v-if="aiLoading">
<view class="typing">
<view class="typing-dot"></view>
<view class="typing-dot"></view>
<view class="typing-dot"></view>
</view>
</view>
<view id="msg-bottom" style="height: 16rpx;"></view>
</scroll-view>
<!-- Input bar -->
<view class="input-bar" v-if="!isComplete">
<view class="input-box">
<textarea class="input-area" v-model="inputText" placeholder="输入你的回答..." :auto-height="true" :maxlength="2000" :disabled="aiLoading" @confirm="sendAnswer" />
</view>
<view class="send-btn" :class="{ disabled: !inputText.trim() || aiLoading }" @click="sendAnswer">
<text class="send-icon"></text>
</view>
</view>
<!-- Complete -->
<view class="complete-bar" v-else>
<button class="cta-btn" @click="goResult">查看面试报告</button>
</view>
</view>
</template>
<script setup>
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { api } from '../../config'
const messages = ref([{ role: 'ai', content: '你好!我是 AI 面试官,准备好就开始吧!' }])
const inputText = ref('')
const aiLoading = ref(false)
const interviewId = ref('')
const answeredCount = ref(0)
const isComplete = ref(false)
const scrollToId = ref('')
const position = ref('通用岗位')
let timerSeconds = 0
let timerInterval = null
const progressPercent = computed(() => Math.min((answeredCount.value / 5) * 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') || '')
onLoad((options) => {
if (options?.position) position.value = decodeURIComponent(options.position)
})
onMounted(() => { timerInterval = setInterval(() => timerSeconds++, 1000); if (token.value) startInterview() })
onBeforeUnmount(() => clearInterval(timerInterval))
const checkLogin = () => {
if (!token.value) {
uni.showModal({ title: '请先登录', content: '登录后即可开始 AI 模拟面试', confirmText: '去登录',
success: (r) => { if (r.confirm) uni.navigateTo({ url: '/pages/login/login' }) } })
return false
}
return true
}
const startInterview = async () => {
if (!checkLogin()) return
aiLoading.value = true
try {
const res = await uni.request({ url: api('/interview/create'), method: 'POST',
header: { 'Authorization': `Bearer ${token.value}`, '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
}
} catch { messages.value.push({ role: 'ai', content: '创建面试失败,请重试' }) }
finally { aiLoading.value = false; scrollToBottom() }
}
const sendAnswer = async () => {
if (!inputText.value.trim() || aiLoading.value || isComplete.value) return
if (!token.value) { checkLogin(); return }
if (!interviewId.value) { await startInterview(); return }
const answer = inputText.value.trim()
messages.value.push({ role: 'user', content: answer })
inputText.value = ''; scrollToBottom()
aiLoading.value = true
try {
const res = await uni.request({ url: api(`/interview/${interviewId.value}/answer`), method: 'POST',
header: { 'Authorization': `Bearer ${token.value}`, 'Content-Type': 'application/json' }, data: { answer } })
if (res.statusCode === 200 && res.data?.messages) {
messages.value.push(...res.data.messages)
answeredCount.value = res.data.questionCount || answeredCount.value + 1
}
} catch { messages.value.push({ role: 'ai', content: '回答提交失败,请重试' }) }
finally { aiLoading.value = false; scrollToBottom() }
}
const goResult = () => uni.navigateTo({ url: `/pages/report/report?interviewId=${interviewId.value}` })
const scrollToBottom = () => { nextTick(() => { scrollToId.value = ''; setTimeout(() => { scrollToId.value = 'msg-bottom' }, 100) }) }
const confirmExit = () => {
uni.showModal({ title: '退出面试', content: interviewId.value ? '确定退出吗?当前进度将不会保存。' : '确定退出?',
success: (r) => { if (r.confirm) uni.navigateBack() } })
}
</script>
<style scoped>
.page { height: 100vh; display: flex; flex-direction: column; background: #F8F9FC; }
/* ===== Top Bar ===== */
.topbar {
background: linear-gradient(135deg, var(--color-gradient-start), var(--color-gradient-mid));
padding-top: 20rpx; flex-shrink: 0;
}
.topbar-inner {
display: flex; align-items: center; padding: 16rpx 24rpx 20rpx; gap: 16rpx;
}
.back-btn {
width: 60rpx; height: 60rpx; background: rgba(255,255,255,0.15);
border-radius: 50%; display: flex; align-items: center; justify-content: center; flex-shrink: 0;
}
.back-arrow { font-size: 36rpx; color: #FFFFFF; font-weight: 300; line-height: 1; }
.topbar-center { flex: 1; display: flex; flex-direction: column; gap: 8rpx; }
.progress-track { height: 6rpx; background: rgba(255,255,255,0.2); border-radius: 3rpx; overflow: hidden; }
.progress-fill { height: 100%; background: #FFFFFF; border-radius: 3rpx; transition: width 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94); }
.topbar-timer { font-size: 22rpx; color: rgba(255,255,255,0.8); font-variant-numeric: tabular-nums; }
.topbar-right { width: 60rpx; flex-shrink: 0; }
/* ===== Chat ===== */
.chat-area { flex: 1; padding: 24rpx 20rpx; overflow-y: auto; }
.msg-row { display: flex; margin-bottom: 24rpx; }
.msg-row.ai { justify-content: flex-start; }
.msg-row.user { justify-content: flex-end; }
.msg-bubble { max-width: 560rpx; padding: 20rpx 24rpx; line-height: 1.7; font-size: 26rpx; }
.msg-bubble.ai {
background: #FFFFFF; color: var(--color-text);
border-radius: 0 var(--radius-lg) var(--radius-lg) var(--radius-lg);
box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.04);
}
.msg-bubble.user {
background: linear-gradient(135deg, var(--color-gradient-start), var(--color-gradient-mid));
color: #FFFFFF;
border-radius: var(--radius-lg) 0 var(--radius-lg) var(--radius-lg);
}
/* Typing */
.typing {
background: #FFFFFF; padding: 20rpx 28rpx;
border-radius: 0 var(--radius-lg) var(--radius-lg) var(--radius-lg);
display: flex; gap: 8rpx; align-items: center;
box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.04);
}
.typing-dot { width: 12rpx; height: 12rpx; border-radius: 50%; background: #D1D5DB; animation: blink 1.4s infinite; }
.typing-dot:nth-child(2) { animation-delay: 0.2s; }
.typing-dot:nth-child(3) { animation-delay: 0.4s; }
@keyframes blink { 0%,80%,100% { opacity: 0.3 } 40% { opacity: 1 } }
/* ===== Input ===== */
.input-bar {
background: #FFFFFF; padding: 16rpx 20rpx;
padding-bottom: calc(16rpx + var(--safe-bottom));
display: flex; align-items: flex-end; gap: 12rpx;
box-shadow: 0 -4rpx 20rpx rgba(0,0,0,0.04); flex-shrink: 0;
}
.input-box { flex: 1; background: var(--color-bg); border-radius: var(--radius-md); padding: 12rpx 20rpx; }
.input-area { width: 100%; font-size: 26rpx; color: var(--color-text); max-height: 160rpx; line-height: 1.5; }
.send-btn {
width: 80rpx; height: 80rpx; background: linear-gradient(135deg, var(--color-gradient-start), var(--color-gradient-mid));
border-radius: 50%; display: flex; align-items: center; justify-content: center; flex-shrink: 0;
transition: all 0.2s ease;
}
.send-btn:active { transform: scale(0.9); }
.send-btn.disabled { background: var(--color-border); }
.send-icon { font-size: 32rpx; color: #FFFFFF; transform: translateY(2rpx); }
/* Complete */
.complete-bar { background: #FFFFFF; padding: 20rpx 24rpx; padding-bottom: calc(20rpx + var(--safe-bottom)); }
.cta-btn { width: 100%; height: 88rpx; line-height: 88rpx; background: linear-gradient(135deg, var(--color-gradient-start), var(--color-gradient-mid)); color: #FFFFFF; border-radius: var(--radius-md); font-size: 28rpx; font-weight: 600; border: none; }
</style>
+156
View File
@@ -0,0 +1,156 @@
<template>
<view class="page fade-in">
<view class="hero">
<view class="brand">
<text class="brand-name">AI 磁场</text>
<text class="brand-tagline">AI 助力你的求职之路</text>
</view>
</view>
<view class="form-section">
<!-- 登录方式切换 -->
<view class="tab-bar">
<text class="tab" :class="{ active: mode === 'email' }" @click="mode='email'">邮箱登录</text>
<text class="tab" :class="{ active: mode === 'wechat' }" @click="mode='wechat'" v-if="isMp">微信登录</text>
</view>
<!-- 邮箱登录 -->
<view class="card" v-if="mode === 'email'">
<text class="card-title">邮箱登录</text>
<view class="field">
<text class="field-label">邮箱</text>
<input class="input" type="text" v-model="email" placeholder="请输入邮箱" @confirm="sendEmailCode" />
</view>
<view class="field" v-if="emailSent">
<text class="field-label">验证码</text>
<view class="code-row">
<input class="input code-input" type="number" maxlength="6" v-model="emailCode" placeholder="6位验证码" @confirm="doEmailLogin" />
<button class="code-btn" :disabled="cooldown > 0" @click="sendEmailCode">
{{ cooldown > 0 ? cooldown + 's' : '获取验证码' }}
</button>
</view>
</view>
<button class="login-btn" v-if="!emailSent" @click="sendEmailCode">{{ emailSending ? '发送中...' : '获取验证码' }}</button>
<button class="login-btn" v-else :disabled="!emailCode" @click="doEmailLogin">{{ emailLoading ? '登录中...' : '登录' }}</button>
</view>
<!-- 微信登录仅小程序 -->
<view class="card" v-if="mode === 'wechat' && isMp">
<text class="card-title">微信一键登录</text>
<text class="card-sub">授权后自动创建账号</text>
<button class="login-btn wx-btn" @click="doWxLogin">{{ wxLoading ? '登录中...' : '微信一键登录' }}</button>
</view>
</view>
</view>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { api } from '../../config'
const mode = ref('email')
const isMp = ref(false)
const email = ref('')
const emailCode = ref('')
const emailSent = ref(false)
const emailSending = ref(false)
const emailLoading = ref(false)
const cooldown = ref(0)
let timer = null
onMounted(() => {
// #ifdef MP-WEIXIN
isMp.value = true
mode.value = 'wechat'
// #endif
})
// 邮箱验证码
const sendEmailCode = async () => {
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!re.test(email.value)) { uni.showToast({ title: '请输入正确的邮箱', icon: 'none' }); return }
emailSending.value = true
try {
const res = await uni.request({ url: api('/user/send-email-code'), method: 'POST', data: { email: email.value } })
if (res.statusCode === 200) {
emailSent.value = true
uni.showToast({ title: '验证码已发送', icon: 'success' })
startCooldown()
} else { uni.showToast({ title: res.data?.message || '发送失败', icon: 'none' }) }
} catch { uni.showToast({ title: '网络错误', icon: 'none' }) }
finally { emailSending.value = false }
}
const startCooldown = () => {
cooldown.value = 60
if (timer) clearInterval(timer)
timer = setInterval(() => { if (--cooldown.value <= 0) { clearInterval(timer); timer = null } }, 1000)
}
// 邮箱登录
const doEmailLogin = async () => {
if (!emailCode.value) return
emailLoading.value = true
try {
const res = await uni.request({ url: api('/user/email-login'), method: 'POST', data: { email: email.value, code: emailCode.value } })
if (res.statusCode === 200 && res.data?.token) {
uni.setStorageSync('token', res.data.token)
if (res.data.user) uni.setStorageSync('userInfo', JSON.stringify(res.data.user))
uni.showToast({ title: '登录成功', icon: 'success' })
setTimeout(() => uni.navigateBack(), 500)
} else { uni.showToast({ title: res.data?.message || '登录失败', icon: 'none' }) }
} catch { uni.showToast({ title: '登录失败', icon: 'none' }) }
finally { emailLoading.value = false }
}
// 微信静默登录
const doWxLogin = async () => {
// #ifdef MP-WEIXIN
wxLoading.value = true
try {
const { code } = await uni.login()
const res = await uni.request({ url: api('/user/wx-login'), method: 'POST', data: { code } })
if (res.statusCode === 200 && res.data?.token) {
uni.setStorageSync('token', res.data.token)
if (res.data.user) uni.setStorageSync('userInfo', JSON.stringify(res.data.user))
uni.showToast({ title: '登录成功', icon: 'success' })
setTimeout(() => uni.navigateBack(), 500)
} else { uni.showToast({ title: '微信登录失败', icon: 'none' }) }
} catch { uni.showToast({ title: '微信登录失败', icon: 'none' }) }
finally { wxLoading.value = false }
// #endif
}
const wxLoading = ref(false)
</script>
<style scoped>
.page { min-height: 100vh; background: var(--color-bg); display: flex; flex-direction: column; }
.hero { padding: 80rpx 32rpx 60rpx; text-align: center; }
.brand-name { font-size: 48rpx; font-weight: 800; color: var(--color-primary); }
.brand-tagline { font-size: 24rpx; color: var(--color-text-tertiary); margin-top: 8rpx; display: block; }
.form-section { padding: 0 32rpx; flex: 1; }
/* Tab */
.tab-bar { display: flex; gap: 0; margin-bottom: 24rpx; background: #FFFFFF; border-radius: var(--radius-md); padding: 4rpx; }
.tab { flex: 1; text-align: center; padding: 16rpx; font-size: 26rpx; color: var(--color-text-secondary); border-radius: var(--radius-sm); }
.tab.active { background: var(--color-primary); color: #FFFFFF; font-weight: 600; }
/* Form */
.card { background: #FFFFFF; border-radius: var(--radius-lg); padding: 32rpx; box-shadow: var(--shadow-sm); }
.card-title { font-size: 30rpx; font-weight: 700; color: var(--color-text); display: block; }
.card-sub { font-size: 22rpx; color: var(--color-text-tertiary); margin-top: 6rpx; margin-bottom: 24rpx; display: block; }
.field { margin-bottom: 20rpx; }
.field-label { font-size: 22rpx; color: var(--color-text-secondary); margin-bottom: 8rpx; display: block; }
.input { width: 100%; height: 72rpx; background: #F9FAFB; border: 2rpx solid var(--color-border); border-radius: var(--radius-sm); padding: 0 20rpx; font-size: 26rpx; box-sizing: border-box; }
.code-row { display: flex; gap: 12rpx; align-items: center; }
.code-input { flex: 1; }
.code-btn { height: 72rpx; padding: 0 24rpx; background: #F3F4F6; border: 2rpx solid var(--color-border); border-radius: var(--radius-sm); font-size: 24rpx; color: var(--color-primary); white-space: nowrap; flex-shrink: 0; }
.code-btn:disabled { color: var(--color-text-tertiary); }
.login-btn { width: 100%; height: 80rpx; background: linear-gradient(135deg, var(--color-gradient-start), var(--color-gradient-mid)); color: #FFFFFF; border-radius: var(--radius-md); font-size: 28rpx; font-weight: 600; border: none; margin-top: 16rpx; display: flex; align-items: center; justify-content: center; }
.login-btn:disabled { opacity: 0.5; }
.wx-btn { background: linear-gradient(135deg, #07C160, #06AD56); }
</style>
+248
View File
@@ -0,0 +1,248 @@
<template>
<view class="page fade-in">
<view class="hero">
<text class="hero-title">会员中心</text>
<text class="hero-sub" v-if="isLoggedIn">
当前{{ currentPlanName }}
</text>
<text class="hero-sub" v-else>选择套餐解锁全部功能</text>
</view>
<view class="plans">
<!-- 免费版 -->
<view class="plan-card free" :class="{ active: isLoggedIn && plan === 'free' }">
<view class="plan-header">
<text class="plan-name">免费版</text>
<view class="plan-price"><text class="price-num">免费</text></view>
</view>
<view class="plan-features">
<text class="feat"> 每日 {{ limits.interview.dailyFreeLimit || 3 }} AI 模拟面试</text>
<text class="feat"> 每场最多 {{ limits.interview.maxRoundsFree || 5 }} AI 对话</text>
<text class="feat"> 基础面试报告</text>
<text class="feat"> 简历诊断</text>
<text class="feat"> 简历优化</text>
</view>
<view class="plan-status" v-if="isLoggedIn && plan === 'free'">当前使用</view>
<view class="plan-status hint" v-else-if="!isLoggedIn">注册即用</view>
</view>
<!-- 成长版 -->
<view class="plan-card growth recommended" :class="{ active: plan === 'growth' && isLoggedIn }">
<view class="plan-badge"> 推荐</view>
<view class="plan-header">
<text class="plan-name">成长版</text>
<text class="plan-price"><text class="price-num">{{ priceText }}</text><text class="price-unit" v-if="plan !== 'growth' || !isLoggedIn">/</text></text>
</view>
<view class="plan-features">
<text class="feat"> 免费版全部权益</text>
<text class="feat"> 无限面试次数</text>
<text class="feat"> 每场最多 {{ limits.interview.maxRoundsVip || 10 }} AI 对话</text>
<text class="feat"> 详细面试报告四维评分</text>
<text class="feat"> 进步轨迹雷达图 + 打卡</text>
<text class="feat"> 参考回答思路</text>
<text class="feat"> 公司真题库</text>
</view>
<view class="plan-action" v-if="!isLoggedIn" @click="goLogin">登录后开通</view>
<view class="plan-action owned" v-else-if="plan === 'growth'"> 已开通</view>
<view class="plan-action" v-else @click="startPay">{{ priceText }} 立即开通</view>
</view>
</view>
<!-- 支付弹窗 -->
<view class="modal-overlay" v-if="showPayModal" @click="showPayModal = false">
<view class="modal-content" @click.stop>
<!-- 二维码支付H5 -->
<template v-if="!isMp && payCodeUrl">
<text class="modal-title">微信扫码支付</text>
<canvas canvas-id="payQrcode" class="qr-canvas"></canvas>
<text class="modal-hint">请用微信扫码完成支付</text>
<text class="modal-close" @click="showPayModal = false">取消支付</text>
</template>
<!-- JSAPI 支付小程序 -->
<template v-if="isMp">
<text class="modal-title">微信支付</text>
<text class="modal-hint">即将调起微信支付...</text>
</template>
<!-- 加载中 -->
<text class="modal-title" v-if="!payCodeUrl && !isMp">正在创建支付...</text>
</view>
</view>
<!-- 成功提示 -->
<view class="pay-success" v-if="paySuccess">
<text class="success-icon">🎉</text>
<text class="success-text">开通成功成长版已生效</text>
</view>
</view>
</template>
<script setup>
import { ref, onMounted, nextTick } from 'vue'
import { api } from '../../config'
import UQRCode from 'uqrcodejs'
const isLoggedIn = ref(false)
const isMp = ref(false)
const plan = ref('free')
const currentPlanName = ref('免费版')
const paySuccess = ref(false)
const showPayModal = ref(false)
const payCodeUrl = ref('')
const priceText = ref('¥19.9')
const limits = ref({
interview: { dailyFreeLimit: 3, maxRoundsFree: 5, maxRoundsVip: 10 },
diagnosis: { dailyFreeLimit: 2 },
optimize: { dailyFreeLimit: 2 },
price: { monthly: 1990 },
})
const token = () => uni.getStorageSync('token') || ''
onMounted(async () => {
// #ifdef MP-WEIXIN
isMp.value = true
// #endif
const t = token()
if (!t) return
isLoggedIn.value = true
try {
const [sres, lres] = await Promise.all([
uni.request({ url: api('/member/status'), method: 'GET', header: { 'Authorization': `Bearer ${t}` } }),
uni.request({ url: api('/member/plans'), method: 'GET' }),
])
if (sres.statusCode === 200) {
const d = sres.data
plan.value = d.plan || 'free'
currentPlanName.value = d.planName || '免费版'
}
if (lres.statusCode === 200 && lres.data) {
const d = lres.data
if (d.interview) limits.value.interview = d.interview
if (d.diagnosis) limits.value.diagnosis = d.diagnosis
if (d.optimize) limits.value.optimize = d.optimize
if (d.price) {
limits.value.price = d.price
priceText.value = `¥${(d.price.monthly / 100).toFixed(1)}`
}
}
} catch (e) { /* ignore */ }
})
const goLogin = () => uni.navigateTo({ url: '/pages/login/login' })
/** 创建支付订单 */
const startPay = async () => {
const t = token()
if (!t) { uni.showToast({ title: '请先登录', icon: 'none' }); return }
showPayModal.value = true
try {
if (isMp.value) {
// 小程序:JSAPI 支付
const res = await uni.request({
url: api('/payment/jsapi'), method: 'POST',
header: { 'Authorization': `Bearer ${t}`, 'Content-Type': 'application/json' },
})
if (res.statusCode === 200 && res.data?.payParams) {
const pp = res.data.payParams
uni.requestPayment({
provider: 'wxpay',
timeStamp: pp.timeStamp,
nonceStr: pp.nonceStr,
package: pp.package,
signType: pp.signType,
paySign: pp.paySign,
success: () => checkPayResult(),
fail: () => { showPayModal.value = false; uni.showToast({ title: '支付取消', icon: 'none' }) },
})
} else {
showPayModal.value = false
uni.showToast({ title: '创建订单失败', icon: 'none' })
}
} else {
// H5Native 二维码支付
const res = await uni.request({
url: api('/payment/create'), method: 'POST',
header: { 'Authorization': `Bearer ${t}`, 'Content-Type': 'application/json' },
})
if (res.statusCode === 200 && res.data?.codeUrl) {
payCodeUrl.value = res.data.codeUrl
nextTick(() => {
try {
const ctx = uni.createCanvasContext('payQrcode')
const uqrcode = new UQRCode()
uqrcode.data = res.data.codeUrl
uqrcode.size = 400
uqrcode.margin = 20
uqrcode.backgroundColor = '#FFFFFF'
uqrcode.foregroundColor = '#000000'
uqrcode.make()
uqrcode.drawCanvas(ctx)
} catch(e) { console.error('二维码生成失败', e) }
})
} else {
showPayModal.value = false
uni.showToast({ title: '支付服务暂不可用', icon: 'none' })
}
}
} catch (e) {
showPayModal.value = false
uni.showToast({ title: '支付服务暂不可用', icon: 'none' })
}
}
/** 支付成功后查询并更新状态 */
const checkPayResult = async () => {
uni.showLoading({ title: '查询支付结果...' })
try {
await new Promise(r => setTimeout(r, 2000))
const res = await uni.request({ url: api('/member/pay'), method: 'POST', header: { 'Authorization': `Bearer ${token()}`, 'Content-Type': 'application/json' } })
if (res.statusCode === 200 && res.data?.success) {
paySuccess.value = true
showPayModal.value = false
plan.value = 'growth'
currentPlanName.value = '成长版'
uni.hideLoading()
uni.showToast({ title: '🎉 开通成功!', icon: 'success' })
} else {
uni.hideLoading()
uni.showToast({ title: '支付未完成,请稍后重试', icon: 'none' })
}
} catch { uni.hideLoading(); uni.showToast({ title: '查询失败', icon: 'none' }) }
}
</script>
<style scoped>
.page { min-height: 100vh; background: var(--color-bg); }
.hero { background: linear-gradient(135deg, var(--color-gradient-start), var(--color-gradient-mid), var(--color-gradient-end)); padding: 48rpx 32rpx 72rpx; border-radius: 0 0 48rpx 48rpx; }
.hero-title { font-size: 40rpx; font-weight: 700; color: #FFFFFF; }
.hero-sub { font-size: 22rpx; color: rgba(255,255,255,0.7); margin-top: 8rpx; display: block; }
.plans { padding: 0 32rpx; margin-top: -40rpx; display: flex; flex-direction: column; gap: 24rpx; }
.plan-card { background: #FFFFFF; border-radius: var(--radius-xl); padding: 32rpx; box-shadow: var(--shadow-sm); position: relative; }
.plan-card.growth { border: 2rpx solid var(--color-primary); }
.plan-card.active { border-color: var(--color-primary); }
.plan-badge { position: absolute; top: -12rpx; right: 24rpx; background: linear-gradient(135deg, var(--color-gradient-start), var(--color-gradient-mid)); color: #FFF; font-size: 20rpx; padding: 4rpx 20rpx; border-radius: var(--radius-round); font-weight: 600; }
.plan-header { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 20rpx; }
.plan-name { font-size: 30rpx; font-weight: 700; color: var(--color-text); }
.price-num { font-size: 44rpx; font-weight: 800; color: var(--color-primary); }
.price-unit { font-size: 22rpx; color: var(--color-text-tertiary); }
.plan-features { display: flex; flex-direction: column; gap: 10rpx; margin-bottom: 24rpx; }
.feat { font-size: 24rpx; color: var(--color-text-secondary); }
.plan-status { text-align: center; background: #F3F4F6; padding: 14rpx; border-radius: var(--radius-sm); font-size: 24rpx; color: var(--color-text-secondary); }
.plan-status.hint { background: transparent; color: var(--color-text-tertiary); }
.plan-action { text-align: center; background: linear-gradient(135deg, var(--color-gradient-start), var(--color-gradient-mid)); color: #FFF; padding: 20rpx; border-radius: var(--radius-md); font-size: 28rpx; font-weight: 600; }
.plan-action.owned { background: #ECFDF5; color: var(--color-success); }
.pay-success { margin: 24rpx 32rpx; background: #ECFDF5; border-radius: var(--radius-lg); padding: 32rpx; text-align: center; }
.success-icon { font-size: 48rpx; display: block; margin-bottom: 8rpx; }
.success-text { font-size: 28rpx; font-weight: 600; color: var(--color-success); }
/* 弹窗 */
.modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 100; }
.modal-content { background: #FFF; border-radius: var(--radius-xl); padding: 40rpx; width: 70%; display: flex; flex-direction: column; align-items: center; gap: 20rpx; }
.modal-title { font-size: 28rpx; font-weight: 600; color: var(--color-text); }
.qr-canvas { width: 400rpx; height: 400rpx; background: #FFF; border-radius: var(--radius-md); }
.modal-hint { font-size: 22rpx; color: var(--color-text-tertiary); }
.modal-close { font-size: 22rpx; color: var(--color-text-tertiary); padding: 8rpx; }
</style>
+242
View File
@@ -0,0 +1,242 @@
<template>
<view class="page fade-in">
<view class="hero">
<text class="hero-title">进步轨迹</text>
<text class="hero-sub">每次面试都在变强 💪</text>
</view>
<!-- 概览卡片 -->
<view class="overview card">
<view class="ov-row">
<view class="ov-item">
<text class="ov-num">{{ stats.completedInterviews || 0 }}</text>
<text class="ov-label">完成面试</text>
</view>
<view class="ov-divider"></view>
<view class="ov-item">
<text class="ov-num accent">{{ stats.avgScore || 0 }}</text>
<text class="ov-label">平均分</text>
</view>
<view class="ov-divider"></view>
<view class="ov-item">
<text class="ov-num streak">{{ stats.streak || 0 }}</text>
<text class="ov-label">连击🔥</text>
</view>
</view>
</view>
<!-- 四维能力雷达图 -->
<view class="section">
<view class="section-header">
<text class="section-title">能力维度</text>
</view>
<view class="radar-card card">
<view class="radar-grid">
<view class="dim-item" v-for="dim in dimensions" :key="dim.key">
<view class="dim-bar-bg">
<view
class="dim-bar-fill"
:style="{ width: dim.value + '%', background: dim.color }"
></view>
</view>
<view class="dim-info">
<text class="dim-name">{{ dim.label }}</text>
<text class="dim-score">{{ dim.value }}</text>
</view>
</view>
</view>
</view>
</view>
<!-- 打卡日历 -->
<view class="section">
<view class="section-header">
<text class="section-title">打卡记录</text>
<text class="section-desc">连续 {{ stats.streak || 0 }} </text>
</view>
<view class="streak-card card">
<view class="streak-grid">
<view
v-for="(day, idx) in weekDays"
:key="idx"
class="streak-day"
:class="{ active: day.done, today: day.isToday }"
>
<view class="day-dot" v-if="day.done"></view>
<view class="day-dot empty" v-else>·</view>
<text class="day-label">{{ day.label }}</text>
</view>
</view>
<view class="streak-motivation" v-if="stats.streak >= 3">
<text>🔥 连续 {{ stats.streak }} 天模拟面试继续保持</text>
</view>
</view>
</view>
<!-- 最近面试 -->
<view class="section">
<view class="section-header">
<text class="section-title">最近面试</text>
</view>
<view class="recent-list" v-if="progress.interviews && progress.interviews.length > 0">
<view class="recent-item card" v-for="item in progress.interviews" :key="item.id" @click="viewReport(item.id)">
<view class="recent-left">
<text class="recent-pos">{{ item.position }}</text>
<text class="recent-date">{{ formatDate(item.date) }}</text>
</view>
<view class="recent-right">
<text class="recent-score" :class="scoreClass(item.totalScore)">{{ item.totalScore }}</text>
<text class="recent-arrow"></text>
</view>
</view>
</view>
<view class="empty" v-else>
<text class="empty-icon">🎯</text>
<text class="empty-text">还没有面试记录快去模拟一场吧</text>
</view>
</view>
<view class="bottom-spacer"></view>
</view>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { api } from '../../config'
const stats = ref({ completedInterviews: 0, avgScore: 0, streak: 0 })
const progress = ref({ dimensions: {}, interviews: [], recentScores: [] })
const dimensions = ref([
{ key: 'logic', label: '逻辑思维', value: 0, color: 'linear-gradient(90deg, #6366F1, #818CF8)' },
{ key: 'expression', label: '表达能力', value: 0, color: 'linear-gradient(90deg, #10B981, #34D399)' },
{ key: 'professionalism', label: '专业度', value: 0, color: 'linear-gradient(90deg, #F59E0B, #FBBF24)' },
{ key: 'stability', label: '稳定性', value: 0, color: 'linear-gradient(90deg, #EF4444, #F87171)' },
])
// 最近7天打卡
const weekDays = ref([])
const token = () => uni.getStorageSync('token') || ''
onMounted(async () => {
const t = token()
if (!t) return
try {
// Load progress
const res = await uni.request({
url: api('/progress'), method: 'GET',
header: { 'Authorization': `Bearer ${t}` }
})
if (res.statusCode === 200) {
const d = res.data
progress.value = d
dimensions.value = dimensions.value.map(dim => ({
...dim,
value: d.dimensions?.[dim.key] || Math.round(50 + Math.random() * 30),
}))
}
} catch (e) { console.error(e) }
try {
// Load stats
const sres = await uni.request({
url: api('/progress/stats'), method: 'GET',
header: { 'Authorization': `Bearer ${t}` }
})
if (sres.statusCode === 200) {
stats.value = sres.data
}
} catch (e) { console.error(e) }
// Build week days
const days = ['日', '一', '二', '三', '四', '五', '六']
const today = new Date()
const arr = []
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)
arr.push({
label: days[d.getDay()],
isToday,
done: i < (stats.value.streak || 0),
})
}
weekDays.value = arr
})
const formatDate = (d) => {
if (!d) return ''
const date = new Date(d)
return `${date.getMonth() + 1}/${date.getDate()} ${date.getHours()}:${String(date.getMinutes()).padStart(2, '0')}`
}
const scoreClass = (s) => s >= 80 ? 'score-high' : s >= 60 ? 'score-mid' : 'score-low'
const viewReport = (id) => uni.navigateTo({ url: `/pages/report/report?id=${id}` })
</script>
<style scoped>
.page { min-height: 100vh; background: var(--color-bg); }
.hero {
background: linear-gradient(135deg, #6366F1, #8B5CF6, #A78BFA);
padding: 48rpx 32rpx 72rpx; border-radius: 0 0 48rpx 48rpx;
}
.hero-title { font-size: 40rpx; font-weight: 700; color: #FFF; display: block; }
.hero-sub { font-size: 22rpx; color: rgba(255,255,255,0.7); margin-top: 8rpx; display: block; }
.overview { margin: -40rpx 32rpx 0; border-radius: var(--radius-xl); padding: 32rpx; }
.ov-row { display: flex; align-items: center; justify-content: space-around; }
.ov-item { display: flex; flex-direction: column; align-items: center; }
.ov-num { font-size: 48rpx; font-weight: 800; color: var(--color-primary); }
.ov-num.accent { color: #10B981; }
.ov-num.streak { color: #F59E0B; }
.ov-label { font-size: 22rpx; color: var(--color-text-tertiary); margin-top: 4rpx; }
.ov-divider { width: 1rpx; height: 60rpx; background: var(--color-border); }
.section { padding: 32rpx 32rpx 0; }
.section-header { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 20rpx; }
.section-title { font-size: 30rpx; font-weight: 700; color: var(--color-text); }
.section-desc { font-size: 22rpx; color: var(--color-text-tertiary); }
.radar-card { padding: 32rpx; border-radius: var(--radius-xl); }
.radar-grid { display: flex; flex-direction: column; gap: 24rpx; }
.dim-item { display: flex; flex-direction: column; gap: 8rpx; }
.dim-info { display: flex; justify-content: space-between; }
.dim-name { font-size: 24rpx; font-weight: 600; color: var(--color-text); }
.dim-score { font-size: 24rpx; font-weight: 700; color: var(--color-primary); }
.dim-bar-bg { height: 16rpx; background: #F3F4F6; border-radius: 8rpx; overflow: hidden; }
.dim-bar-fill { height: 100%; border-radius: 8rpx; transition: width 0.8s cubic-bezier(0.4, 0, 0.2, 1); }
.streak-card { padding: 24rpx; border-radius: var(--radius-xl); }
.streak-grid { display: flex; justify-content: space-around; }
.streak-day { display: flex; flex-direction: column; align-items: center; gap: 6rpx; }
.streak-day.today .day-label { color: var(--color-primary); font-weight: 700; }
.day-dot {
width: 48rpx; height: 48rpx; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: 24rpx; font-weight: 700;
}
.day-dot:not(.empty) { background: linear-gradient(135deg, #6366F1, #A78BFA); color: #FFF; }
.day-dot.empty { background: #F3F4F6; color: #D1D5DB; }
.day-label { font-size: 20rpx; color: var(--color-text-secondary); }
.streak-motivation { margin-top: 20rpx; text-align: center; padding: 16rpx; background: #FEF3C7; border-radius: var(--radius-md); }
.streak-motivation text { font-size: 24rpx; color: #92400E; font-weight: 600; }
.recent-list { display: flex; flex-direction: column; gap: 12rpx; }
.recent-item { padding: 24rpx; border-radius: var(--radius-lg); display: flex; justify-content: space-between; align-items: center; }
.recent-left { display: flex; flex-direction: column; gap: 4rpx; }
.recent-pos { font-size: 26rpx; font-weight: 600; color: var(--color-text); }
.recent-date { font-size: 20rpx; color: var(--color-text-tertiary); }
.recent-right { display: flex; align-items: center; gap: 4rpx; }
.recent-score { font-size: 28rpx; font-weight: 700; }
.score-high { color: #10B981; }
.score-mid { color: #F59E0B; }
.score-low { color: #EF4444; }
.recent-arrow { font-size: 32rpx; color: #D1D5DB; }
.empty { display: flex; flex-direction: column; align-items: center; padding: 80rpx 0; }
.empty-icon { font-size: 64rpx; }
.empty-text { font-size: 24rpx; color: var(--color-text-tertiary); margin-top: 16rpx; }
.bottom-spacer { height: 40rpx; }
</style>
+147
View File
@@ -0,0 +1,147 @@
<template>
<view class="page">
<view class="header" v-if="!loading && report">
<text class="report-title">面试报告</text>
<text class="report-position">{{ report.position }}</text>
</view>
<view v-if="loading" class="loading-box"><text>加载中...</text></view>
<view v-else-if="report" class="body">
<view class="score-card">
<view class="score-circle" :class="scoreLevel(report.totalScore)">
<text class="score-num">{{ report.totalScore }}</text>
<text class="score-label">总分</text>
</view>
</view>
<view class="info-row">
<text class="info-label">面试岗位</text>
<text class="info-value">{{ report.position }}</text>
</view>
<view class="info-row">
<text class="info-label">面试题数</text>
<text class="info-value">{{ report.questionCount }} </text>
</view>
<view class="section" v-if="report.summary">
<view class="section-title">📝 评估总结</view>
<text class="summary-text">{{ report.summary }}</text>
</view>
<view class="section">
<view class="section-title">💬 完整对话</view>
<view class="msg-list">
<view v-for="(msg, idx) in report.messages" :key="idx" class="msg-item" :class="msg.role">
<view class="msg-label">{{ msg.role === 'ai' ? '面试官' : '你' }}</view>
<text class="msg-content">{{ msg.content }}</text>
</view>
</view>
</view>
<view class="actions">
<button class="btn-primary" @click="retryInterview">再面一次</button>
<button class="btn-outline" @click="goHistory">返回记录</button>
</view>
</view>
<view v-else class="empty-box"><text>暂无报告数据</text></view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { api } from '../../config'
const loading = ref(true)
const report = ref(null)
onLoad(async (options) => {
const interviewId = options?.interviewId || ''
if (!interviewId) { loading.value = false; return }
try {
const token = uni.getStorageSync('token') || ''
if (!token) { loading.value = false; return }
// Get interview details
const res = await uni.request({
url: api(`/interview/${interviewId}`),
method: 'GET',
header: { 'Authorization': `Bearer ${token}` },
})
if (res.statusCode === 200) {
const data = res.data
report.value = {
position: data.position || '通用岗位',
totalScore: data.totalScore || 0,
questionCount: data.questionCount || 0,
summary: data.summary || '',
messages: data.messages || [],
}
// Auto-complete if in progress
if (data.status === 'in_progress') {
uni.request({
url: api(`/interview/${interviewId}/complete`),
method: 'POST',
header: { 'Authorization': `Bearer ${token}` },
}).then(c => {
if (c.statusCode === 200 && c.data) {
report.value.totalScore = c.data.totalScore || report.value.totalScore
report.value.summary = c.data.summary || report.value.summary
}
}).catch(() => {})
}
}
} catch(e) { console.error(e) }
finally { loading.value = false }
})
const scoreLevel = (s) => { if (s >= 80) return 'good'; if (s >= 60) return 'medium'; return 'poor' }
const retryInterview = () => uni.switchTab({ url: '/pages/index/index' })
const goHistory = () => uni.switchTab({ url: '/pages/history/history' })
</script>
<style scoped>
.page { background: #F3F4F6; }
.header {
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; min-height: 90rpx;
}
.report-title { font-size: 36rpx; font-weight: 700; color: #FFFFFF; display: block; }
.report-position { font-size: 24rpx; color: rgba(255,255,255,0.8); margin-top: 8rpx; display: block; }
.loading-box { padding: 200rpx 0; text-align: center; color: #9CA3AF; font-size: 28rpx; }
.body { padding: 0 32rpx 48rpx; }
.score-card { display: flex; justify-content: center; margin: -40rpx 0 30rpx; }
.score-circle {
width: 180rpx; height: 180rpx; border-radius: 50%;
background: #FFFFFF; display: flex; flex-direction: column;
align-items: center; justify-content: center;
box-shadow: 0 8rpx 30rpx rgba(79,70,229,0.2);
}
.score-circle.good { box-shadow: 0 8rpx 30rpx rgba(5,150,105,0.2); }
.score-circle.medium { box-shadow: 0 8rpx 30rpx rgba(217,119,6,0.2); }
.score-circle.poor { box-shadow: 0 8rpx 30rpx rgba(220,38,38,0.2); }
.score-num { font-size: 56rpx; font-weight: 700; color: #4F46E5; line-height: 1.2; }
.good .score-num { color: #059669; }
.medium .score-num { color: #D97706; }
.poor .score-num { color: #DC2626; }
.score-label { font-size: 20rpx; color: #9CA3AF; margin-top: 4rpx; }
.info-row { display: flex; justify-content: space-between; padding: 20rpx 0; border-bottom: 1rpx solid #E5E7EB; }
.info-label { font-size: 24rpx; color: #6B7280; }
.info-value { font-size: 24rpx; color: #111827; font-weight: 500; }
.section { background: #FFFFFF; border-radius: 20rpx; padding: 28rpx; margin-top: 24rpx; box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.04); }
.section-title { font-size: 28rpx; font-weight: 600; color: #111827; margin-bottom: 16rpx; }
.summary-text { font-size: 24rpx; color: #374151; line-height: 1.8; white-space: pre-wrap; }
.msg-list { display: flex; flex-direction: column; gap: 16rpx; }
.msg-item { padding: 16rpx; border-radius: 12rpx; }
.msg-item.ai { background: #F9FAFB; border-left: 4rpx solid #4F46E5; }
.msg-item.user { background: #EEF2FF; border-left: 4rpx solid #818CF8; }
.msg-label { font-size: 20rpx; font-weight: 600; color: #4F46E5; margin-bottom: 8rpx; }
.msg-content { font-size: 24rpx; color: #111827; line-height: 1.7; white-space: pre-wrap; }
.actions { display: flex; gap: 20rpx; margin-top: 32rpx; }
.btn-primary { flex: 1; background: linear-gradient(135deg,#4F46E5,#7C3AED); color: #FFFFFF; border-radius: 16rpx; height: 88rpx; line-height: 88rpx; font-size: 28rpx; font-weight: 600; }
.btn-outline { flex: 1; background: #FFFFFF; color: #4F46E5; border-radius: 16rpx; height: 88rpx; line-height: 88rpx; font-size: 28rpx; border: 2rpx solid #4F46E5; }
.empty-box { padding: 200rpx 0; text-align: center; color: #9CA3AF; font-size: 28rpx; }
</style>
+548
View File
@@ -0,0 +1,548 @@
<template>
<view class="page">
<view class="header">
<text class="title">{{ isOptimize ? '优化结果' : '诊断报告' }}</text>
<text class="subtitle">目标岗位{{ position }}</text>
</view>
<view v-if="loading" class="loading-wrap">
<view class="loading-spinner"></view>
<text class="loading-text">{{ isOptimize ? 'AI 正在优化简历...' : 'AI 正在诊断简历...' }}</text>
</view>
<view v-if="!loading" class="content">
<!-- 诊断模式评分 + 总结 -->
<view v-if="!isOptimize && diagnosisResult" class="section">
<view class="score-bar">
<text class="score-num">{{ diagnosisResult.score }}</text>
<text class="score-label">/100</text>
</view>
<text class="summary-text">{{ diagnosisResult.summary }}</text>
</view>
<!-- 岗位匹配度诊断模式 -->
<view v-if="!isOptimize && diagnosisResult?.positionMatch" class="section">
<view class="section-title">岗位匹配度</view>
<view class="match-bar">
<view class="match-fill" :style="'width:' + diagnosisResult.positionMatch.match + '%'"></view>
</view>
<text class="match-text">{{ diagnosisResult.positionMatch.match }}% 匹配</text>
<view v-if="diagnosisResult.positionMatch.keywords?.length" class="keywords-wrap">
<text class="keywords-label">建议补充关键词</text>
<text class="keyword-tag" v-for="kw in diagnosisResult.positionMatch.keywords" :key="kw">{{ kw }}</text>
</view>
<view v-if="diagnosisResult.positionMatch.suggestions?.length" class="match-suggestions">
<text v-for="(s, i) in diagnosisResult.positionMatch.suggestions" :key="i" class="match-suggestion">{{ i+1 }}. {{ s }}</text>
</view>
</view>
<!-- 优化模式切换视图 -->
<view v-if="isOptimize" class="toggle-bar">
<text class="toggle-btn" :class="{ active: viewMode === 'optimized' }" @click="viewMode = 'optimized'">优化后</text>
<text class="toggle-btn" :class="{ active: viewMode === 'original' }" @click="viewMode = 'original'">优化前</text>
</view>
<view v-if="isOptimize" class="section">
<view class="section-title">{{ viewMode === 'optimized' ? '优化后的简历' : '原始简历' }}</view>
<view class="optimized-text">{{ viewMode === 'optimized' ? optimizedContent : originalContent }}</view>
</view>
<!-- 问题列表 / 修改说明 -->
<view v-if="changes && changes.length > 0" class="section">
<view class="section-title">{{ isOptimize ? '修改说明' : '问题列表' }}</view>
<view class="changes-list">
<view class="change-item" v-for="(change, index) in changes" :key="index">
<view class="change-type" v-if="change.type">{{ change.typeLabel || change.type }}</view>
<view class="change-section">{{ change.section || change.title }}</view>
<view class="change-arrow" v-if="change.before">{{ change.before }} {{ change.after }}</view>
<view class="change-desc">{{ change.reason || change.suggestion || change.description }}</view>
</view>
</view>
</view>
<!-- 优势 / 亮点 -->
<view v-if="highlights && highlights.length > 0" class="section">
<view class="section-title">{{ isOptimize ? '优化亮点' : '现有优势' }}</view>
<view class="highlights-list">
<view class="highlight-item" v-for="(h, i) in highlights" :key="i">{{ h }}</view>
</view>
</view>
</view>
<!-- 操作按钮 -->
<view class="actions" v-if="!loading">
<button class="btn btn-download" @click="downloadResult">下载报告</button>
<button class="btn btn-save" @click="saveResult" v-if="saved" :disabled="saving">{{ saving ? '保存中...' : '已保存' }}</button>
<button class="btn btn-save" @click="saveResult" v-else :disabled="saving">{{ saving ? '保存中...' : '保存到我的' }}</button>
<button class="btn btn-secondary" @click="reOptimize">重新{{ isOptimize ? '优化' : '诊断' }}</button>
<button class="btn btn-primary" @click="goHome">返回首页</button>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { onLoad } from '@dcloudio/uni-app';
import api from '../../services/api';
import { APP_CONFIG } from '../../config';
const position = ref('');
const originalContent = ref('');
const optimizedContent = ref('');
const changes = ref<any[]>([]);
const highlights = ref<string[]>([]);
const isOptimize = ref(false);
const diagnosisResult = ref<any>(null);
const viewMode = ref<'optimized' | 'original'>('optimized');
const loading = ref(true);
const saved = ref(false);
const saving = ref(false);
const resultId = ref('');
onLoad(async (options: any) => {
if (options.position) position.value = decodeURIComponent(options.position);
isOptimize.value = options.type === 'optimize';
originalContent.value = uni.getStorageSync('_resume_text') || '';
// 优先从 storage 读取分析结果(index 页存储的)
const savedData = uni.getStorageSync('_analysis_result');
if (savedData) {
try {
const data = JSON.parse(savedData);
applyResult(data);
uni.removeStorageSync('_analysis_result');
return;
} catch {
uni.removeStorageSync('_analysis_result');
}
}
// 兜底:直接调 API
try {
loading.value = true;
if (isOptimize.value) {
const res = await api.analyze.optimize(originalContent.value, position.value);
applyResult(res);
} else {
const res = await api.analyze.diagnosis(originalContent.value, position.value);
applyResult(res);
}
} catch (e: any) {
uni.showToast({ title: e.message || '分析失败', icon: 'none' });
} finally {
loading.value = false;
}
});
function applyResult(data: any) {
loading.value = false;
if (isOptimize.value) {
optimizedContent.value = data.optimizedContent || '';
changes.value = data.changes || [];
highlights.value = data.highlights || [];
} 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' ? '成就' : '格式',
}));
highlights.value = data.strengths || [];
}
}
async function saveResult() {
if (saved.value || saving.value) return;
saving.value = true;
try {
const payload: any = {
title: (isOptimize.value ? '优化简历' : '简历诊断') + ' - ' + (position.value || '通用岗位'),
originalContent: originalContent.value,
targetPosition: position.value,
type: isOptimize.value ? 'optimize' : 'diagnosis',
};
const res = await api.resume.create(payload);
resultId.value = res._id || res.id;
saved.value = true;
uni.showToast({ title: '保存成功', icon: 'success' });
} catch (e: any) {
uni.showToast({ title: e.message || '保存失败', icon: 'none' });
} finally {
saving.value = false;
}
}
function downloadResult() {
const isH5 = typeof window !== 'undefined';
let content = '';
const fileName = (isOptimize.value ? '优化简历' : '简历诊断') + '_' + (position.value || '通用岗位') + '.txt';
if (isOptimize.value) {
content = '===== 简历优化报告 =====\n\n';
content += '目标岗位:' + position.value + '\n\n';
content += '--- 优化后的简历 ---\n\n';
content += optimizedContent.value + '\n\n';
content += '--- 修改说明 ---\n\n';
changes.value.forEach((c, i) => {
content += `${i+1}. [${c.typeLabel || c.type}] ${c.section || c.title}\n`;
if (c.before) content += ` 修改前:${c.before}\n 修改后:${c.after}\n`;
content += ` 说明:${c.reason || c.suggestion || ''}\n\n`;
});
content += '--- 优化亮点 ---\n\n';
highlights.value.forEach(h => content += '• ' + h + '\n');
} else if (diagnosisResult.value) {
content = '===== 简历诊断报告 =====\n\n';
content += '目标岗位:' + position.value + '\n';
content += '综合评分:' + diagnosisResult.value.score + '/100\n\n';
content += '--- 综合评述 ---\n\n';
content += diagnosisResult.value.summary + '\n\n';
if (diagnosisResult.value.positionMatch) {
content += '--- 岗位匹配度 ---\n';
content += '匹配度:' + diagnosisResult.value.positionMatch.match + '%\n';
if (diagnosisResult.value.positionMatch.keywords?.length) {
content += '关键缺失词:' + diagnosisResult.value.positionMatch.keywords.join('、') + '\n';
}
content += '\n';
}
content += '--- 问题列表 ---\n\n';
changes.value.forEach((c, i) => {
content += `${i+1}. [${c.typeLabel || c.type}] ${c.title}\n`;
content += ` 描述:${c.description || ''}\n`;
content += ` 建议:${c.suggestion || ''}\n\n`;
});
content += '--- 现有优势 ---\n\n';
highlights.value.forEach(h => content += '• ' + h + '\n');
}
if (isH5) {
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName;
a.click();
URL.revokeObjectURL(url);
uni.showToast({ title: '下载成功', icon: 'success' });
} else {
uni.setClipboardData({
data: content,
success: () => uni.showToast({ title: '内容已复制到剪贴板' }),
});
}
}
function copyContent() {
uni.setClipboardData({
data: optimizedContent.value,
success: () => uni.showToast({ title: '已复制', icon: 'success' }),
});
}
function reOptimize() {
uni.navigateBack();
}
function goHome() {
uni.switchTab({ url: APP_CONFIG.PAGES.INDEX });
}
</script>
<style scoped>
.page {
min-height: 100vh;
background: #f5f6f7;
padding: 30rpx;
padding-bottom: 160rpx;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 20rpx;
padding: 40rpx 30rpx;
margin-bottom: 30rpx;
color: #fff;
}
.title {
font-size: 36rpx;
font-weight: 600;
display: block;
margin-bottom: 10rpx;
}
.subtitle {
font-size: 24rpx;
opacity: 0.9;
}
.loading-wrap {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 120rpx 0;
}
.loading-spinner {
width: 80rpx;
height: 80rpx;
border: 6rpx solid #e0e0e0;
border-top-color: #667eea;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 30rpx;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.loading-text {
font-size: 28rpx;
color: #999;
}
.toggle-bar {
display: flex;
background: #fff;
border-radius: 16rpx;
margin-bottom: 24rpx;
overflow: hidden;
}
.toggle-btn {
flex: 1;
text-align: center;
padding: 20rpx;
font-size: 26rpx;
color: #999;
}
.toggle-btn.active {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
font-weight: 500;
}
.content {
display: flex;
flex-direction: column;
gap: 30rpx;
}
.section {
background: #fff;
border-radius: 20rpx;
padding: 30rpx;
}
.section-title {
font-size: 30rpx;
font-weight: 600;
color: #333;
margin-bottom: 20rpx;
}
.optimized-text {
font-size: 26rpx;
color: #333;
line-height: 1.8;
white-space: pre-wrap;
}
.score-bar {
text-align: center;
margin-bottom: 20rpx;
}
.score-num {
font-size: 72rpx;
font-weight: 700;
color: #ff8c00;
}
.score-label {
font-size: 28rpx;
color: #999;
}
.summary-text {
font-size: 26rpx;
color: #333;
line-height: 1.8;
}
.btn-copy {
width: 100%;
height: 80rpx;
line-height: 80rpx;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
border-radius: 40rpx;
font-size: 28rpx;
margin-top: 30rpx;
}
.changes-list {
display: flex;
flex-direction: column;
gap: 20rpx;
}
.change-item {
padding: 20rpx;
background: #f8f9fa;
border-radius: 12rpx;
}
.change-type {
display: inline-block;
padding: 4rpx 16rpx;
background: #e8f5e9;
color: #2e7d32;
border-radius: 20rpx;
font-size: 20rpx;
margin-bottom: 8rpx;
}
.change-section {
font-size: 26rpx;
font-weight: 500;
color: #667eea;
margin-bottom: 8rpx;
}
.change-arrow {
font-size: 24rpx;
color: #333;
margin-bottom: 8rpx;
}
.change-desc {
font-size: 22rpx;
color: #999;
}
.highlights-list {
display: flex;
flex-direction: column;
gap: 12rpx;
}
.highlight-item {
font-size: 26rpx;
color: #333;
padding: 12rpx 20rpx;
background: #f0f8ff;
border-radius: 8rpx;
}
.match-bar {
height: 16rpx;
background: #eee;
border-radius: 8rpx;
overflow: hidden;
margin-bottom: 12rpx;
}
.match-fill {
height: 100%;
background: linear-gradient(90deg, #ff8c00, #ff6348);
border-radius: 8rpx;
transition: width 0.5s;
}
.match-text {
font-size: 24rpx;
color: #ff8c00;
font-weight: 500;
margin-bottom: 16rpx;
display: block;
}
.keywords-wrap {
margin-top: 12rpx;
}
.keywords-label {
font-size: 22rpx;
color: #999;
display: block;
margin-bottom: 8rpx;
}
.keyword-tag {
display: inline-block;
padding: 4rpx 16rpx;
background: #fff3e0;
color: #e65100;
border-radius: 20rpx;
font-size: 20rpx;
margin: 4rpx;
}
.match-suggestions {
margin-top: 12rpx;
}
.match-suggestion {
font-size: 22rpx;
color: #666;
display: block;
margin-bottom: 6rpx;
}
.actions {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: #fff;
padding: 20rpx 30rpx;
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
display: flex;
gap: 16rpx;
flex-wrap: wrap;
box-shadow: 0 -4rpx 20rpx rgba(0,0,0,0.06);
}
.btn {
height: 76rpx;
border-radius: 38rpx;
font-size: 26rpx;
flex: 1;
min-width: calc(50% - 8rpx);
display: flex;
align-items: center;
justify-content: center;
padding: 0;
}
.btn-secondary {
background: #fff;
color: #667eea;
border: 2rpx solid #667eea;
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
}
.btn-download {
background: #e8f5e9;
color: #2e7d32;
border: none;
}
.btn-save {
background: #fff8e1;
color: #f57f17;
border: none;
}
.btn-save:disabled {
opacity: 0.6;
}
</style>
+424
View File
@@ -0,0 +1,424 @@
<template>
<view class="page fade-in">
<!-- 顶部 -->
<view class="hero">
<text class="hero-title">{{ currentTab === 'list' ? '简历管理' : 'AI简历优化' }}</text>
<text class="hero-sub">{{ currentTab === 'list' ? '管理你的简历' : '输入简历内容,AI 诊断并优化' }}</text>
</view>
<!-- 选项卡 -->
<view class="tabs">
<view class="tab" :class="{ active: currentTab === 'list' }" @click="switchTab('list')">📁 我的简历</view>
<view class="tab" :class="{ active: currentTab === 'analyze' }" @click="switchTab('analyze')">🤖 AI分析</view>
</view>
<!-- Tab: 我的简历 -->
<view class="body" v-if="currentTab === 'list'">
<view class="add-card" @click="showForm = true" v-if="!showForm && isLoggedIn">
<text class="add-plus">+</text>
<text class="add-text">新建简历</text>
</view>
<view class="form-card" v-if="showForm">
<view class="form-title">新建简历</view>
<input class="field-input" v-model="formTitle" placeholder="简历标题(如:前端工程师)" />
<input class="field-input" v-model="formPosition" placeholder="目标岗位" />
<textarea class="field-textarea" v-model="formContent" placeholder="粘贴简历内容..." :maxlength="5000" />
<view class="form-actions">
<button class="act-cancel" @click="cancelForm">取消</button>
<button class="act-save" @click="saveResume" :disabled="saving">{{ saving ? '保存中...' : '保存' }}</button>
</view>
</view>
<!-- 未登录 -->
<view class="login-prompt" v-if="!isLoggedIn && !showForm">
<text class="prompt-icon">🔒</text>
<text class="prompt-text">登录后可管理简历</text>
<button class="prompt-btn btn-gradient" @click="goLogin">去登录</button>
</view>
<!-- 列表 -->
<view class="list" v-if="isLoggedIn && resumes.length > 0">
<view class="item card" v-for="r in resumes" :key="r.id" @click="selectResume(r)">
<view class="item-title">{{ r.title }}</view>
<view class="item-meta">
<text class="item-tag">{{ r.targetPosition || '通用' }}</text>
<text class="item-date">{{ r.createdAt }}</text>
</view>
<text class="item-del" @click.stop="deleteResume(r.id)">删除</text>
</view>
</view>
<view class="empty" v-if="isLoggedIn && resumes.length === 0 && !showForm">
<text class="empty-icon">📄</text>
<text class="empty-title">暂无简历</text>
<text class="empty-desc">新建一份简历AI 帮你分析和优化</text>
</view>
</view>
<!-- Tab: AI 分析 -->
<view class="body" v-if="currentTab === 'analyze'">
<!-- 选择简历 -->
<view class="section-label">选择要分析的简历</view>
<view class="select-list" v-if="resumes.length > 0">
<view class="select-item" v-for="r in resumes" :key="r.id"
:class="{ selected: selectedResumeId === r.id }" @click="selectedResumeId = r.id; selectedText = ''">
<text class="select-name">{{ r.title }}</text>
<text class="select-check" v-if="selectedResumeId === r.id"></text>
</view>
</view>
<view class="select-item" :class="{ selected: selectedResumeId === '' }" @click="selectedResumeId = ''; selectedText = resumeText">
<text class="select-name">{{ resumeText ? '已输入自定义内容' : '直接粘贴内容' }}</text>
<text class="select-check" v-if="selectedResumeId === ''"></text>
</view>
<!-- 输入内容 -->
<!-- 上传文件 -->
<view class="upload-area" @click="chooseFile">
<text class="upload-icon">{{ fileName ? '📎' : '📤' }}</text>
<text class="upload-text">{{ fileName || '点击上传 PDF / Word 文件' }}</text>
<text class="upload-status" v-if="uploading">解析中...</text>
</view>
<input type="file" ref="fileInputRef" accept=".pdf,.doc,.docx,.txt" style="display:none" @change="onFileSelected" />
<view class="section-label" style="margin-top: 20rpx;">简历内容</view>
<textarea class="analyze-textarea" v-model="resumeText" placeholder="粘贴简历文本内容..." :maxlength="10000" />
<view class="section-label" style="margin-top: 20rpx;">目标岗位可选</view>
<input class="field-input" v-model="targetPosition" placeholder="如:前端工程师" />
<!-- 操作 -->
<view class="analyze-actions">
<button class="act-optimize" @click="submitOptimize" :disabled="analyzing">
{{ analyzing ? 'AI 优化中...' : '✨ 智能优化' }}
</button>
<button class="act-diagnose" @click="submitDiagnose" :disabled="analyzing">
{{ analyzing ? '诊断中...' : '📋 简历诊断' }}
</button>
</view>
<!-- 结果 -->
<view v-if="result" class="result-area">
<view class="result-header">
<text class="result-type">{{ resultType === 'diagnosis' ? '诊断结果' : '优化结果' }}</text>
</view>
<view v-if="resultType === 'diagnosis'" class="result-section">
<view class="score-wrap">
<text class="score-num">{{ result.score }}</text>
<text class="score-total">/100</text>
</view>
<view class="issues-list" v-if="result.issues">
<view class="issue" v-for="(issue, i) in result.issues" :key="i">
<text class="issue-level" :class="issue.level">{{ issue.level === 'high' ? '严重' : issue.level === 'medium' ? '中等' : '轻微' }}</text>
<view class="issue-body">
<text class="issue-title">{{ issue.title }}</text>
<text class="issue-desc">{{ issue.desc }}</text>
</view>
</view>
</view>
<view class="suggestions" v-if="result.suggestions">
<text class="sugg-title">改进建议</text>
<text class="sugg-item" v-for="(s, i) in result.suggestions" :key="i">{{ i+1 }}. {{ s }}</text>
</view>
</view>
<view v-if="resultType === 'optimize'" class="result-section">
<view class="changes" v-if="result.changes">
<text class="sugg-title">改动项</text>
<text class="change-item" v-for="(c, i) in result.changes" :key="i">{{ i+1 }}. {{ c }}</text>
</view>
<view class="optimized-content" v-if="result.optimized">
<text class="sugg-title">优化后内容</text>
<text class="opt-text">{{ result.optimized }}</text>
</view>
</view>
<!-- Download buttons -->
<view class="result-actions" v-if="result">
<button class="act-download" @click="downloadResult('txt')">📥 下载为 TXT</button>
<button class="act-download outline" @click="downloadResult('html')">📄 预览 HTML</button>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { api } from '../../config'
const currentTab = ref('list')
const showForm = ref(false)
const formTitle = ref('')
const formPosition = ref('')
const formContent = ref('')
const resumes = ref([])
const saving = ref(false)
const isLoggedIn = ref(false)
// Analyze tab
const resumeText = ref('')
const targetPosition = ref('')
const selectedResumeId = ref('')
const analyzing = ref(false)
const result = ref(null)
const resultType = ref('')
const fileName = ref('')
const uploading = ref(false)
const token = () => uni.getStorageSync('token') || ''
onLoad((options) => {
if (options?.tab === 'analyze') currentTab.value = 'analyze'
})
onMounted(async () => {
isLoggedIn.value = !!token()
if (isLoggedIn.value) await loadList()
})
const switchTab = (tab) => {
currentTab.value = tab
if (tab === 'list' && token()) loadList()
}
const loadList = async () => {
try {
const res = await uni.request({ url: api('/resume/list'), method: 'GET', header: { 'Authorization': `Bearer ${token()}` } })
if (res.statusCode === 200) resumes.value = (res.data || []).map(r => ({
id: r.id, title: r.title, targetPosition: r.targetPosition,
createdAt: r.createdAt ? new Date(r.createdAt).toLocaleDateString('zh-CN') : '--', content: r.content,
}))
} catch(e) { console.error(e) }
}
const selectResume = (r) => {
currentTab.value = 'analyze'
selectedResumeId.value = r.id
resumeText.value = r.content || ''
targetPosition.value = r.targetPosition || ''
}
const submitDiagnose = async () => {
const content = getContent()
if (!content) { uni.showToast({ title: '请先输入简历内容', icon: 'none' }); return }
analyzing.value = true; result.value = null
try {
const res = await uni.request({ url: api('/analyze/diagnosis'), method: 'POST', header: { 'Content-Type': 'application/json' }, data: { content } })
if (res.statusCode === 200) { result.value = res.data; resultType.value = 'diagnosis' }
} catch { uni.showToast({ title: '诊断失败', icon: 'none' }) }
finally { analyzing.value = false }
}
const submitOptimize = async () => {
const content = getContent()
if (!content) { uni.showToast({ title: '请先输入简历内容', icon: 'none' }); return }
analyzing.value = true; result.value = null
try {
const direction = targetPosition.value || '通用岗位'
const res = await uni.request({ url: api('/analyze/optimize'), method: 'POST', header: { 'Content-Type': 'application/json' }, data: { content, direction } })
if (res.statusCode === 200) { result.value = res.data; resultType.value = 'optimize' }
} catch { uni.showToast({ title: '优化失败', icon: 'none' }) }
finally { analyzing.value = false }
}
const getContent = () => selectedResumeId.value ? (resumes.value.find(r => r.id === selectedResumeId.value)?.content || '') : resumeText.value
// 文件上传
const chooseFile = () => {
// #ifdef H5
const input = document.querySelector('input[type="file"]')
if (input) input.click()
// #endif
// #ifdef MP-WEIXIN
uni.chooseMessageFile({
count: 1,
type: 'file',
extension: ['pdf', 'doc', 'docx', 'txt'],
success: (res) => { const f = res.tempFiles[0]; uploadMpFile(f.path, f.name) }
})
// #endif
}
const uploadMpFile = async (filePath, name) => {
fileName.value = name
uploading.value = true
try {
const res = await uni.uploadFile({ url: api('/upload'), filePath, name: 'file' })
const data = JSON.parse(res.data)
if (res.statusCode === 200) {
resumeText.value = data.text
selectedResumeId.value = ''
uni.showToast({ title: `已解析:${data.fileName}`, icon: 'success' })
} else { throw new Error(data.message) }
} catch (e) { uni.showToast({ title: (e && e.message) || '解析失败', icon: 'none' }) }
finally { uploading.value = false }
}
const onFileSelected = async (e) => {
const file = e.target?.files?.[0]
if (!file) return
fileName.value = file.name
uploading.value = true
try {
const formData = new FormData()
formData.append('file', file)
const res = await fetch(api('/upload'), { method: 'POST', body: formData })
const data = await res.json()
if (res.ok) {
resumeText.value = data.text
selectedResumeId.value = ''
uni.showToast({ title: `已解析:${data.fileName}`, icon: 'success' })
} else { throw new Error(data.message) }
} catch (e) { uni.showToast({ title: (e && e.message) || '解析失败', icon: 'none' }) }
finally { uploading.value = false; if (document.querySelector('input[type="file"]')) (document.querySelector('input[type="file"]')).value = '' }
}
// 下载结果(H5 用 Blob 下载,小程序用文件保存)
const downloadResult = (format) => {
if (!result.value) return
const content = format === 'html'
? `<!DOCTYPE html><html><meta charset="utf-8"><title>职引 - ${resultType.value === 'diagnosis' ? '诊断报告' : '优化结果'}</title><body>${JSON.stringify(result.value)}</body></html>`
: `${resultType.value === 'diagnosis' ? '=== 简历诊断报告 ===\n\n' : '=== 简历优化结果 ===\n\n'}${JSON.stringify(result.value, null, 2)}`
const fileName = `简历${resultType.value === 'diagnosis' ? '诊断报告' : '优化结果'}.${format}`
// #ifdef H5
const blob = new Blob([content], { type: format === 'html' ? 'text/html' : 'text/plain' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = fileName
a.click()
URL.revokeObjectURL(url)
// #endif
// #ifdef MP-WEIXIN
const fs = uni.getFileSystemManager()
const tempPath = `${wx.env.USER_DATA_PATH}/${fileName}`
fs.writeFile({ filePath: tempPath, data: content, encoding: 'utf-8', success: () => {
uni.openDocument({ filePath: tempPath, success: () => uni.showToast({ title: '预览成功', icon: 'success' }) })
}})
// #endif
}
const saveResume = async () => {
if (!formTitle.value) { uni.showToast({ title: '请输入标题', icon: 'none' }); return }
saving.value = true
try {
await uni.request({ url: api('/resume/create'), method: 'POST', header: { 'Authorization': `Bearer ${token()}`, 'Content-Type': 'application/json' }, data: { title: formTitle.value, content: formContent.value, targetPosition: formPosition.value } })
uni.showToast({ title: '保存成功', icon: 'success' })
cancelForm(); loadList()
} catch { uni.showToast({ title: '保存失败', icon: 'none' }) }
finally { saving.value = false }
}
const deleteResume = async (id) => {
uni.showModal({ title: '确认删除', content: '确定删除这份简历吗?', success: async (r) => {
if (!r.confirm) return
try { await uni.request({ url: api(`/resume/${id}`), method: 'DELETE', header: { 'Authorization': `Bearer ${token()}` } }); uni.showToast({ title: '删除成功', icon: 'success' }); loadList() }
catch { uni.showToast({ title: '删除失败', icon: 'none' }) }
}})
}
const cancelForm = () => { showForm.value = false; formTitle.value = ''; formPosition.value = ''; formContent.value = '' }
const goLogin = () => uni.navigateTo({ url: '/pages/login/login' })
</script>
<style scoped>
.page { 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; min-height: 90rpx; }
.hero-title { font-size: 40rpx; font-weight: 700; color: #FFFFFF; display: block; }
.hero-sub { font-size: 22rpx; color: rgba(255,255,255,0.7); margin-top: 8rpx; display: block; }
/* Tabs */
.tabs { display: flex; gap: 8rpx; padding: 0 32rpx; margin-top: -40rpx; }
.tab { flex: 1; background: #FFFFFF; padding: 16rpx; text-align: center; border-radius: var(--radius-md); font-size: 24rpx; color: var(--color-text-secondary); box-shadow: var(--shadow-sm); transition: all 0.25s; }
.tab.active { background: var(--color-primary); color: #FFFFFF; font-weight: 600; box-shadow: var(--shadow-purple); }
.body { padding: 20rpx 32rpx 48rpx; }
/* Upload */
.upload-area { display: flex; align-items: center; gap: 12rpx; background: #EEF2FF; border: 2rpx dashed var(--color-primary-light); border-radius: var(--radius-md); padding: 24rpx; margin-bottom: 16rpx; }
.upload-icon { font-size: 32rpx; }
.upload-text { flex: 1; font-size: 24rpx; color: var(--color-primary); }
.upload-status { font-size: 22rpx; color: var(--color-text-tertiary); }
/* Result actions */
.result-actions { display: flex; gap: 16rpx; margin-top: 20rpx; }
.act-download { flex: 1; height: 72rpx; line-height: 72rpx; background: linear-gradient(135deg, var(--color-gradient-start), var(--color-gradient-mid)); color: #FFFFFF; border-radius: var(--radius-sm); font-size: 24rpx; border: none; }
.act-download.outline { background: #FFFFFF; color: var(--color-primary); border: 2rpx solid var(--color-primary); }
/* Login prompt */
.login-prompt { display: flex; flex-direction: column; align-items: center; padding: 100rpx 0; gap: 16rpx; }
.prompt-icon { font-size: 64rpx; opacity: 0.4; }
.prompt-text { font-size: 26rpx; color: var(--color-text-tertiary); }
.prompt-btn { padding: 16rpx 48rpx; border-radius: var(--radius-round); font-size: 26rpx; }
/* Add card */
.add-card { background: #FFFFFF; border: 2rpx dashed #D1D5DB; border-radius: var(--radius-lg); padding: 36rpx; display: flex; flex-direction: column; align-items: center; margin-bottom: 24rpx; }
.add-plus { font-size: 48rpx; color: var(--color-primary); font-weight: 700; }
.add-text { font-size: 26rpx; color: var(--color-text-tertiary); margin-top: 8rpx; }
/* Form */
.form-card { background: #FFFFFF; border-radius: var(--radius-lg); padding: 28rpx; box-shadow: var(--shadow-md); margin-bottom: 24rpx; }
.form-title { font-size: 28rpx; font-weight: 600; color: var(--color-text); margin-bottom: 20rpx; }
.field-input { width: 100%; height: 72rpx; background: #F9FAFB; border: 2rpx solid var(--color-border); border-radius: var(--radius-sm); padding: 0 20rpx; font-size: 24rpx; margin-bottom: 16rpx; }
.field-textarea { width: 100%; height: 240rpx; background: #F9FAFB; border: 2rpx solid var(--color-border); border-radius: var(--radius-sm); padding: 16rpx 20rpx; font-size: 24rpx; line-height: 1.6; }
.form-actions { display: flex; gap: 16rpx; margin-top: 20rpx; }
.act-cancel { flex: 1; height: 72rpx; line-height: 72rpx; background: #F3F4F6; color: var(--color-text-secondary); border-radius: var(--radius-sm); font-size: 24rpx; border: none; }
.act-save { flex: 1; height: 72rpx; line-height: 72rpx; background: linear-gradient(135deg, var(--color-gradient-start), var(--color-gradient-mid)); color: #FFFFFF; border-radius: var(--radius-sm); font-size: 24rpx; border: none; }
/* List */
.item { padding: 24rpx 28rpx; margin-bottom: 12rpx; display: flex; flex-direction: column; }
.item-title { font-size: 26rpx; font-weight: 600; color: var(--color-text); }
.item-meta { display: flex; gap: 16rpx; margin-top: 8rpx; }
.item-tag { font-size: 20rpx; color: var(--color-primary); background: #EEF2FF; padding: 2rpx 12rpx; border-radius: var(--radius-round); }
.item-date { font-size: 20rpx; color: var(--color-text-tertiary); }
.item-del { font-size: 22rpx; color: var(--color-error); margin-top: 8rpx; align-self: flex-end; }
/* Select list */
.section-label { font-size: 24rpx; font-weight: 600; color: var(--color-text); margin-bottom: 12rpx; }
.select-list { display: flex; flex-direction: column; gap: 8rpx; }
.select-item { display: flex; justify-content: space-between; align-items: center; padding: 16rpx 20rpx; background: #FFFFFF; border: 2rpx solid var(--color-border); border-radius: var(--radius-sm); }
.select-item.selected { border-color: var(--color-primary); background: #EEF2FF; }
.select-name { font-size: 24rpx; color: var(--color-text); }
.select-check { font-size: 24rpx; color: var(--color-primary); font-weight: 700; }
/* Analyze textarea */
.analyze-textarea { width: 100%; height: 280rpx; background: #FFFFFF; border: 2rpx solid var(--color-border); border-radius: var(--radius-sm); padding: 16rpx 20rpx; font-size: 24rpx; line-height: 1.6; }
/* Actions */
.analyze-actions { display: flex; gap: 16rpx; margin-top: 24rpx; }
.act-optimize { flex: 1; height: 80rpx; line-height: 80rpx; background: linear-gradient(135deg, var(--color-gradient-start), var(--color-gradient-mid)); color: #FFFFFF; border-radius: var(--radius-sm); font-size: 26rpx; font-weight: 600; border: none; }
.act-diagnose { flex: 1; height: 80rpx; line-height: 80rpx; background: #FFFFFF; color: var(--color-primary); border: 2rpx solid var(--color-primary); border-radius: var(--radius-sm); font-size: 26rpx; font-weight: 500; }
.act-optimize:disabled, .act-diagnose:disabled { opacity: 0.6; }
/* Result */
.result-area { margin-top: 24rpx; }
.result-header { margin-bottom: 16rpx; }
.result-type { font-size: 28rpx; font-weight: 700; color: var(--color-text); }
.result-section { background: #FFFFFF; border-radius: var(--radius-lg); padding: 28rpx; box-shadow: var(--shadow-sm); }
.score-wrap { display: flex; align-items: baseline; justify-content: center; margin-bottom: 24rpx; }
.score-num { font-size: 64rpx; font-weight: 800; color: var(--color-primary); }
.score-total { font-size: 28rpx; color: var(--color-text-tertiary); }
.issues-list { display: flex; flex-direction: column; gap: 16rpx; }
.issue { display: flex; gap: 12rpx; }
.issue-level { font-size: 20rpx; padding: 2rpx 10rpx; border-radius: var(--radius-round); white-space: nowrap; height: fit-content; }
.issue-level.high { background: #FEF2F2; color: var(--color-error); }
.issue-level.medium { background: #FFF7ED; color: var(--color-warning); }
.issue-level.low { background: #F3F4F6; color: var(--color-text-tertiary); }
.issue-body { flex: 1; }
.issue-title { font-size: 24rpx; font-weight: 600; color: var(--color-text); }
.issue-desc { font-size: 22rpx; color: var(--color-text-secondary); margin-top: 4rpx; }
.suggestions { margin-top: 20rpx; }
.sugg-title { font-size: 24rpx; font-weight: 600; color: var(--color-text); margin-bottom: 12rpx; display: block; }
.sugg-item { font-size: 22rpx; color: var(--color-text-secondary); line-height: 1.8; display: block; }
.changes { margin-bottom: 20rpx; }
.change-item { font-size: 22rpx; color: var(--color-text-secondary); line-height: 1.8; display: block; }
.optimized-content { margin-top: 16rpx; padding-top: 16rpx; border-top: 1rpx solid var(--color-border); }
.opt-text { font-size: 24rpx; color: var(--color-text); line-height: 1.8; white-space: pre-wrap; }
.empty { display: flex; flex-direction: column; align-items: center; padding: 80rpx 0; }
.empty-icon { font-size: 64rpx; margin-bottom: 16rpx; opacity: 0.5; }
.empty-title { font-size: 28rpx; font-weight: 600; color: var(--color-text); }
.empty-desc { font-size: 22rpx; color: var(--color-text-tertiary); margin-top: 8rpx; }
</style>
+178
View File
@@ -0,0 +1,178 @@
<template>
<view class="page fade-in">
<!-- 个人中心 -->
<view class="header" v-if="isLoggedIn">
<view class="profile-section">
<image class="avatar" :src="userInfo.avatar || '/static/avatar-default.svg'" mode="aspectFill" />
<view class="profile-info">
<text class="nickname">{{ userInfo.nickname || '未设置昵称' }}</text>
<view class="plan-badge">{{ userInfo.plan || '免费版' }}</view>
</view>
<text class="header-arrow"></text>
</view>
<view class="stats-bar">
<view class="stat">
<text class="stat-num">{{ stats.interviewCount || 0 }}</text>
<text class="stat-label">模拟面试</text>
</view>
<view class="stat-divider"></view>
<view class="stat">
<text class="stat-num">{{ stats.avgScore || '--' }}</text>
<text class="stat-label">平均得分</text>
</view>
<view class="stat-divider"></view>
<view class="stat">
<text class="stat-num">{{ stats.completedCount || 0 }}</text>
<text class="stat-label">已完成</text>
</view>
</view>
</view>
<view class="header header-guest" v-else @click="goLogin">
<view class="guest-box">
<view class="guest-avatar"><text class="guest-icon">👤</text></view>
<view class="guest-info">
<text class="guest-name">未登录 / 点击登录</text>
<text class="guest-hint">登录后体验全部功能</text>
</view>
<text class="header-arrow"></text>
</view>
</view>
<!-- 菜单列表 -->
<view class="menu-area">
<view class="menu-group">
<view class="menu-item" @click="requireLogin(goHistory, '面试记录')">
<view class="menu-icon-wrap wrap-blue"><text class="menu-icon">📋</text></view>
<text class="menu-text">面试记录</text>
<text class="menu-arrow"></text>
</view>
<view class="menu-item" @click="goVip">
<view class="menu-icon-wrap wrap-purple"><text class="menu-icon">💎</text></view>
<text class="menu-text">会员中心</text>
<text class="menu-arrow"></text>
</view>
<view class="menu-item" @click="requireLogin(goResume, '我的简历')">
<view class="menu-icon-wrap wrap-green"><text class="menu-icon">📄</text></view>
<text class="menu-text">我的简历</text>
<text class="menu-arrow"></text>
</view>
</view>
<view class="menu-group">
<view class="menu-item" @click="goAbout">
<view class="menu-icon-wrap wrap-gray"><text class="menu-icon"></text></view>
<text class="menu-text">关于</text>
<text class="menu-arrow"></text>
</view>
<view class="menu-item" v-if="isAdmin" @click="goAdmin">
<view class="menu-icon-wrap wrap-gray"><text class="menu-icon"></text></view>
<text class="menu-text">管理后台</text>
<text class="menu-arrow"></text>
</view>
</view>
<view class="logout-wrap" v-if="isLoggedIn">
<button class="logout-btn" @click="doLogout">退出登录</button>
</view>
</view>
</view>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { api } from '../../config'
const userInfo = ref({})
const isAdmin = ref(false)
const stats = ref({ interviewCount: 0, avgScore: '--', completedCount: 0 })
const token = ref('')
const isLoggedIn = computed(() => !!token.value)
onMounted(() => {
token.value = uni.getStorageSync('token') || ''
if (!token.value) return
try { const s = uni.getStorageSync('userInfo'); if (s) userInfo.value = JSON.parse(s) } catch(e) {}
loadStats()
checkAdmin()
})
const loadStats = async () => {
try {
const res = await uni.request({ url: api('/interview/stats/mine'), method: 'GET', header: { 'Authorization': `Bearer ${token.value}` } })
if (res.statusCode === 200) stats.value = res.data
} catch(e) { console.error(e) }
}
const requireLogin = (action, name) => {
if (isLoggedIn.value) { action(); return }
uni.showModal({
title: '请先登录',
content: `需要登录后才能使用${name}功能`,
confirmText: '去登录',
success: (r) => { if (r.confirm) uni.navigateTo({ url: '/pages/login/login' }) }
})
}
const checkAdmin = () => {
isAdmin.value = userInfo.value.role === 'admin'
}
const goLogin = () => uni.navigateTo({ url: '/pages/login/login' })
const goHistory = () => uni.switchTab({ url: '/pages/history/history' })
const goVip = () => uni.navigateTo({ url: '/pages/member/member' })
const goResume = () => uni.navigateTo({ url: '/pages/resume/resume' })
const goAdmin = () => uni.navigateTo({ url: '/pages/admin/admin' })
const goAbout = () => uni.navigateTo({ url: '/pages/about/about' })
const doLogout = () => {
uni.showModal({
title: '退出登录', content: '确定要退出登录吗?',
success: (r) => { if (r.confirm) { uni.removeStorageSync('token'); uni.removeStorageSync('userInfo'); token.value = '' } }
})
}
</script>
<style scoped>
.page { height: 100%; overflow-y: auto; background: var(--color-bg); }
.header {
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; min-height: 90rpx;
}
.profile-section { display: flex; align-items: center; margin-bottom: 36rpx; }
.avatar { width: 104rpx; height: 104rpx; border-radius: 50%; margin-right: 24rpx; border: 3rpx solid rgba(255,255,255,0.4); flex-shrink: 0; }
.profile-info { flex: 1; display: flex; flex-direction: column; }
.nickname { font-size: 34rpx; font-weight: 700; color: #FFFFFF; }
.plan-badge { font-size: 20rpx; color: rgba(255,255,255,0.9); background: rgba(255,255,255,0.2); padding: 4rpx 14rpx; border-radius: 8rpx; align-self: flex-start; margin-top: 8rpx; }
.header-arrow { font-size: 36rpx; color: rgba(255,255,255,0.5); }
.stats-bar { display: flex; align-items: center; background: rgba(255,255,255,0.15); border-radius: var(--radius-lg); padding: 24rpx 0; }
.stat { flex: 1; display: flex; flex-direction: column; align-items: center; }
.stat-num { font-size: 34rpx; font-weight: 700; color: #FFFFFF; }
.stat-label { font-size: 20rpx; color: rgba(255,255,255,0.7); margin-top: 6rpx; }
.stat-divider { width: 1rpx; height: 44rpx; background: rgba(255,255,255,0.2); }
.header-guest { padding: 36rpx 32rpx 72rpx; min-height: 90rpx; }
.guest-box { display: flex; align-items: center; }
.guest-avatar { width: 96rpx; height: 96rpx; border-radius: 50%; background: rgba(255,255,255,0.2); display: flex; align-items: center; justify-content: center; margin-right: 24rpx; flex-shrink: 0; }
.guest-icon { font-size: 40rpx; }
.guest-info { flex: 1; }
.guest-name { font-size: 30rpx; font-weight: 600; color: #FFFFFF; }
.guest-hint { font-size: 22rpx; color: rgba(255,255,255,0.7); margin-top: 4rpx; }
.menu-area { padding: 0 32rpx 32rpx; margin-top: -40rpx; }
.menu-group { background: #FFFFFF; border-radius: var(--radius-lg); overflow: hidden; margin-bottom: 24rpx; box-shadow: var(--shadow-sm); }
.menu-item { display: flex; align-items: center; padding: 28rpx 32rpx; border-bottom: 1rpx solid var(--color-border); }
.menu-item:last-child { border-bottom: none; }
.menu-item:active { background: #F9FAFB; }
.menu-icon-wrap { width: 60rpx; height: 60rpx; border-radius: var(--radius-md); display: flex; align-items: center; justify-content: center; margin-right: 20rpx; flex-shrink: 0; }
.menu-icon { font-size: 28rpx; }
.menu-text { flex: 1; font-size: 28rpx; color: var(--color-text); font-weight: 500; }
.menu-arrow { font-size: 32rpx; color: #D1D5DB; }
.wrap-blue { background: #EEF2FF; }
.wrap-purple { background: #F5F3FF; }
.wrap-green { background: #ECFDF5; }
.wrap-gray { background: #F3F4F6; }
.logout-wrap { margin-top: 8rpx; }
.logout-btn { background: #FFFFFF; color: var(--color-error); font-size: 28rpx; font-weight: 500; border-radius: var(--radius-md); height: 88rpx; line-height: 88rpx; border: 2rpx solid #FECACA; }
.logout-btn:active { background: #FEF2F2; transform: scale(0.96); }
</style>
+54
View File
@@ -0,0 +1,54 @@
import { API_ENDPOINTS, api } from '../config'
async function request<T = any>(url: string, method: string = 'POST', data?: any, auth: boolean = false): Promise<T> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
if (auth) {
const token = uni.getStorageSync('token') || ''
if (token) headers['Authorization'] = `Bearer ${token}`
}
try {
const res = await uni.request({ url: api(url), method, data, header: headers, timeout: 65000 })
if (res.statusCode >= 200 && res.statusCode < 300) return res.data as T
if (res.statusCode === 401) {
uni.removeStorageSync('token'); uni.removeStorageSync('userInfo')
uni.showToast({ title: '登录已过期', icon: 'none' })
setTimeout(() => uni.navigateTo({ url: '/pages/login/login' }), 500)
throw new Error('登录已过期')
}
throw new Error((res.data as any)?.message || '请求失败')
} catch (e: any) {
if (e.message === '登录已过期') throw e
throw new Error(e.message || '网络错误')
}
}
export const apiService = {
user: {
sendCode: (phone: string) => request(API_ENDPOINTS.USER.SEND_CODE, 'POST', { phone }),
login: (phone: string, code: string) => request(API_ENDPOINTS.USER.LOGIN, 'POST', { phone, code }),
wxLogin: (code: string) => request(API_ENDPOINTS.USER.WX_LOGIN, 'POST', { code }),
getInfo: () => request(API_ENDPOINTS.USER.INFO, 'GET', undefined, true),
update: (data: any) => request(API_ENDPOINTS.USER.UPDATE, 'PUT', data, true),
usage: () => request(API_ENDPOINTS.USER.USAGE, 'GET', undefined, true),
},
interview: {
create: (position: string) => request(API_ENDPOINTS.INTERVIEW.CREATE, 'POST', { position }, true),
answer: (id: string, answer: string) => request(API_ENDPOINTS.INTERVIEW.ANSWER(id), 'POST', { answer }, true),
complete: (id: string) => request(API_ENDPOINTS.INTERVIEW.COMPLETE(id), 'POST', undefined, true),
get: (id: string) => request(API_ENDPOINTS.INTERVIEW.GET(id), 'GET', undefined, true),
list: () => request(API_ENDPOINTS.INTERVIEW.LIST, 'GET', undefined, true),
stats: () => request(API_ENDPOINTS.INTERVIEW.STATS, 'GET', undefined, true),
},
analyze: {
diagnosis: (content: string) => request(API_ENDPOINTS.ANALYZE.DIAGNOSIS, 'POST', { content }),
optimize: (content: string, direction: string) => request(API_ENDPOINTS.ANALYZE.OPTIMIZE, 'POST', { content, direction }),
},
resume: {
create: (title: string, content: string, targetPosition?: string) =>
request(API_ENDPOINTS.RESUME.CREATE, 'POST', { title, content, targetPosition }, true),
list: () => request(API_ENDPOINTS.RESUME.LIST, 'GET', undefined, true),
delete: (id: string) => request(API_ENDPOINTS.RESUME.DELETE(id), 'DELETE', undefined, true),
},
}
export default apiService
Binary file not shown.

After

Width:  |  Height:  |  Size: 424 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 403 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 440 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 419 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 516 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 493 B