feat: realistic face avatar + voice input + ASR endpoint

This commit is contained in:
yuzhiran
2026-06-12 15:32:04 +08:00
parent 6fe84b6ef8
commit 8191cf4b41
26 changed files with 1934 additions and 228 deletions
+343 -138
View File
@@ -1,21 +1,17 @@
<template>
<view class="digital-human">
<view class="avatar-stage">
<view class="avatar-ring" :class="{ speaking: isSpeaking }">
<!-- Default CSS avatar if image fails -->
<view class="avatar-default" v-if="imgFailed">
<text class="avatar-initials">AI</text>
</view>
<image class="avatar-img" :src="avatarSrc" mode="aspectFill" @error="imgFailed = true" v-else />
<canvas
v-if="isH5"
id="dh-mouth"
class="mouth-canvas"
></canvas>
</view>
<canvas
:style="{ width: canvasSize + 'px', height: canvasSize + 'px' }"
:canvas-id="canvasId"
:id="canvasId"
type="2d"
class="face-canvas"
:class="{ speaking: isSpeaking }"
></canvas>
<view class="status-dot" :class="{ active: isSpeaking }"></view>
<text class="role-label">AI 面试官</text>
</view>
<view class="speech-area" v-if="currentText">
<view class="speech-bubble">
<text class="speech-text">{{ currentText }}</text>
@@ -25,34 +21,45 @@
</template>
<script setup>
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { ref, watch, onMounted, onBeforeUnmount, nextTick, computed } from 'vue'
const props = defineProps({
text: { type: String, default: '' },
audioUrl: { type: String, default: '' },
avatarUrl: { type: String, default: '' },
autoPlay: { type: Boolean, default: true },
})
const emit = defineEmits(['speaking-start', 'speaking-end'])
const isH5 = ref(false)
const canvasSize = 280
const canvasId = 'faceCanvas'
const isSpeaking = ref(false)
const currentText = ref('')
const imgFailed = ref(false)
let audioEl = null
let audioCtx = null
let analyser = null
let animFrameId = null
let mouthScale = 0
let blinkTimer = null
let faceCtx = null
// Face animation state
let blinkFrame = 0 // 0=open, 1=half, 2=closed, then back
let mouthOpen = 0 // 0-1
let targetMouth = 0
let lastBlinkTime = 0
let faceW = canvasSize
let faceH = canvasSize
onMounted(() => {
isH5.value = typeof window !== 'undefined' && typeof document !== 'undefined'
initCanvas()
nextTick(() => initCanvas())
scheduleBlink()
})
onBeforeUnmount(() => {
stopAudio()
if (blinkTimer) clearTimeout(blinkTimer)
if (animFrameId) cancelAnimationFrame(animFrameId)
})
watch(() => props.audioUrl, (url) => {
@@ -65,52 +72,264 @@ watch(() => props.text, (txt) => {
currentText.value = txt
})
const avatarSrc = computed(() => {
return props.avatarUrl || '/static/default-avatar.png'
})
function initCanvas() {
if (!isH5.value) return
const canvas = document.getElementById('dh-mouth')
if (!canvas) return
// Size the canvas to match mouth area (~40% width, ~15% height, centered bottom)
const parent = canvas.parentElement
if (!parent) return
const rect = parent.getBoundingClientRect()
canvas.width = rect.width * 0.4
canvas.height = rect.height * 0.15
canvas.style.width = canvas.width + 'px'
canvas.style.height = canvas.height + 'px'
canvas.style.position = 'absolute'
canvas.style.bottom = '18%'
canvas.style.left = '30%'
const query = uni.createSelectorQuery()
query.select('#' + canvasId).fields({ node: true, size: true }).exec((res) => {
if (!res || !res[0]) return
const canvas = res[0].node
const ctx = canvas.getContext('2d')
const dpr = uni.getSystemInfoSync().pixelRatio
canvas.width = canvasSize * dpr
canvas.height = canvasSize * dpr
ctx.scale(dpr, dpr)
faceCtx = ctx
faceW = canvasSize
faceH = canvasSize
drawFace(ctx, 0, false)
})
}
function drawMouth(openRatio) {
if (!isH5.value) return
const canvas = document.getElementById('dh-mouth')
if (!canvas) return
const ctx = canvas.getContext('2d')
function drawFace(ctx, mouthRatio, isBlinking) {
if (!ctx) return
const w = faceW
const h = faceH
const cx = w / 2
const cy = h / 2
const w = canvas.width
const h = canvas.height
ctx.clearRect(0, 0, w, h)
const mouthH = Math.max(2, h * openRatio)
const mouthW = w * 0.8
// Background
ctx.fillStyle = '#F0F2F5'
ctx.beginPath()
ctx.arc(cx, cy, w / 2, 0, Math.PI * 2)
ctx.fill()
// Hair - brown wavy hair
ctx.fillStyle = '#3D2B1F'
ctx.beginPath()
ctx.ellipse(cx, cy - h * 0.28, w * 0.36, h * 0.22, 0, Math.PI, 0)
ctx.fill()
// Hair sides
ctx.beginPath()
ctx.ellipse(cx - w * 0.24, cy - h * 0.08, w * 0.08, h * 0.22, -0.2, 0, Math.PI * 2)
ctx.fill()
ctx.beginPath()
ctx.ellipse(cx + w * 0.24, cy - h * 0.08, w * 0.08, h * 0.22, 0.2, 0, Math.PI * 2)
ctx.fill()
// Face oval
const faceW_ = w * 0.32
const faceH_ = h * 0.38
ctx.fillStyle = '#FDE8D0'
ctx.beginPath()
ctx.ellipse(cx, cy + h * 0.02, faceW_, faceH_, 0, 0, Math.PI * 2)
ctx.fill()
// Neck
ctx.fillStyle = '#FDE8D0'
ctx.beginPath()
ctx.ellipse(cx, cy + h * 0.32, w * 0.14, h * 0.10, 0, 0, Math.PI)
ctx.fill()
// Collar / shoulders suggestion
ctx.fillStyle = '#4B5563'
ctx.beginPath()
ctx.ellipse(cx, cy + h * 0.42, w * 0.28, h * 0.08, 0, Math.PI, 0)
ctx.fill()
ctx.fillStyle = '#374151'
ctx.beginPath()
ctx.ellipse(cx, cy + h * 0.44, w * 0.30, h * 0.06, 0, Math.PI, 0)
ctx.fill()
// Cheek blush
ctx.fillStyle = 'rgba(255, 150, 150, 0.3)'
ctx.beginPath()
ctx.ellipse(cx - w * 0.18, cy + h * 0.08, w * 0.07, h * 0.04, 0, 0, Math.PI * 2)
ctx.fill()
ctx.beginPath()
ctx.ellipse(cx + w * 0.18, cy + h * 0.08, w * 0.07, h * 0.04, 0, 0, Math.PI * 2)
ctx.fill()
// Nose
ctx.strokeStyle = '#E8C8A8'
ctx.lineWidth = 2
ctx.beginPath()
ctx.moveTo(cx, cy - h * 0.02)
ctx.lineTo(cx - w * 0.03, cy + h * 0.06)
ctx.stroke()
ctx.beginPath()
ctx.moveTo(cx, cy - h * 0.02)
ctx.lineTo(cx + w * 0.03, cy + h * 0.06)
ctx.stroke()
// Nose tip
ctx.fillStyle = '#E8C8A8'
ctx.beginPath()
ctx.ellipse(cx, cy + h * 0.06, w * 0.025, h * 0.015, 0, 0, Math.PI * 2)
ctx.fill()
// Eyes
const eyeY = cy - h * 0.02
const eyeSpacing = w * 0.13
function drawEye(x, y, blinkRatio) {
// Eye white
const eyeW = w * 0.09
const eyeH = h * 0.05
const openH = eyeH * (1 - blinkRatio)
ctx.fillStyle = '#FFFFFF'
ctx.beginPath()
ctx.ellipse(x, y, eyeW, Math.max(openH, 1), 0, 0, Math.PI * 2)
ctx.fill()
if (blinkRatio < 0.8) {
// Iris
const irisR = eyeW * 0.55
ctx.fillStyle = '#5B4033'
ctx.beginPath()
ctx.arc(x, y, irisR, 0, Math.PI * 2)
ctx.fill()
// Pupil
ctx.fillStyle = '#1A1A1A'
ctx.beginPath()
ctx.arc(x, y, irisR * 0.5, 0, Math.PI * 2)
ctx.fill()
// Eye highlight
ctx.fillStyle = '#FFFFFF'
ctx.beginPath()
ctx.arc(x - irisR * 0.3, y - irisR * 0.3, irisR * 0.3, 0, Math.PI * 2)
ctx.fill()
ctx.beginPath()
ctx.arc(x + irisR * 0.15, y + irisR * 0.15, irisR * 0.15, 0, Math.PI * 2)
ctx.fill()
// Upper eyelid line
ctx.strokeStyle = '#4A3728'
ctx.lineWidth = 1.5
ctx.beginPath()
const lidH = eyeH * 0.5 * (1 - blinkRatio)
ctx.ellipse(x, y - lidH * 0.3, eyeW * 0.85, lidH * 0.8, 0, Math.PI, 0)
ctx.stroke()
}
// Eyelid (covers eye when blinking)
if (blinkRatio > 0) {
ctx.fillStyle = '#FDE8D0'
ctx.beginPath()
ctx.ellipse(x, y - eyeH * 0.3, eyeW * 1.1, eyeH * 0.7 * blinkRatio + 1, 0, Math.PI, 0)
ctx.fill()
ctx.beginPath()
ctx.ellipse(x, y + eyeH * 0.3, eyeW * 1.1, eyeH * 0.7 * blinkRatio + 1, 0, 0, Math.PI)
ctx.fill()
}
// Eyelash line
ctx.strokeStyle = '#3D2B1F'
ctx.lineWidth = 1.2
ctx.beginPath()
ctx.ellipse(x, y, eyeW * 0.9, Math.max(openH * 0.7, 1), 0, 0, Math.PI * 2)
ctx.stroke()
}
const blinkRatio = isBlinking ? 0.95 : 0
drawEye(cx - eyeSpacing, eyeY, blinkRatio)
drawEye(cx + eyeSpacing, eyeY, blinkRatio)
// Eyebrows
function drawEyebrow(x, y) {
ctx.strokeStyle = '#3D2B1F'
ctx.lineWidth = 3
ctx.lineCap = 'round'
ctx.beginPath()
ctx.moveTo(x - w * 0.06, y - h * 0.06)
ctx.quadraticCurveTo(x, y - h * 0.09, x + w * 0.06, y - h * 0.06)
ctx.stroke()
}
drawEyebrow(cx - eyeSpacing, eyeY)
drawEyebrow(cx + eyeSpacing, eyeY)
// Mouth
const mouthX = cx
const mouthY = cy + h * 0.16
const mouthBaseW = w * 0.10
const mouthBaseH = h * 0.02
const openAmount = mouthRatio * h * 0.06
ctx.fillStyle = '#C97B84'
ctx.beginPath()
ctx.ellipse(w / 2, h / 2 + (h - mouthH) / 2, mouthW / 2, mouthH / 2, 0, 0, Math.PI * 2)
ctx.ellipse(mouthX, mouthY + openAmount * 0.5, mouthBaseW + mouthRatio * w * 0.02, mouthBaseH + openAmount, 0, 0, Math.PI * 2)
ctx.fill()
if (openRatio > 0.1) {
ctx.fillStyle = '#2D1B1E'
// Mouth line (when closed)
if (mouthRatio < 0.1) {
ctx.strokeStyle = '#B06570'
ctx.lineWidth = 1.5
ctx.beginPath()
ctx.ellipse(w / 2, h / 2 + (h - mouthH) / 2 + 1, mouthW / 4, mouthH / 4, 0, 0, Math.PI * 2)
ctx.moveTo(mouthX - mouthBaseW, mouthY)
ctx.quadraticCurveTo(mouthX, mouthY + 2, mouthX + mouthBaseW, mouthY)
ctx.stroke()
}
// Inner mouth when open
if (mouthRatio > 0.15) {
ctx.fillStyle = '#5C2E36'
ctx.beginPath()
ctx.ellipse(mouthX, mouthY + openAmount * 0.6, mouthBaseW * 0.7, openAmount * 0.7, 0, 0, Math.PI * 2)
ctx.fill()
}
// Subtle smile lines
ctx.strokeStyle = '#E8C8A8'
ctx.lineWidth = 1
ctx.beginPath()
ctx.moveTo(mouthX - mouthBaseW - w * 0.02, mouthY - h * 0.01)
ctx.quadraticCurveTo(mouthX - mouthBaseW - w * 0.01, mouthY - h * 0.005, mouthX - mouthBaseW, mouthY)
ctx.stroke()
ctx.beginPath()
ctx.moveTo(mouthX + mouthBaseW + w * 0.02, mouthY - h * 0.01)
ctx.quadraticCurveTo(mouthX + mouthBaseW + w * 0.01, mouthY - h * 0.005, mouthX + mouthBaseW, mouthY)
ctx.stroke()
}
function scheduleBlink() {
const delay = 2000 + Math.random() * 3000
blinkTimer = setTimeout(() => {
doBlink()
scheduleBlink()
}, delay)
}
function doBlink() {
if (!faceCtx) return
let frame = 0
const totalFrames = 8
function blinkTick() {
frame++
const progress = frame / totalFrames
const blinkAmt = progress < 0.5 ? progress * 2 : (1 - progress) * 2
drawFace(faceCtx, mouthOpen, true)
if (frame < totalFrames) {
setTimeout(blinkTick, 30)
} else {
drawFace(faceCtx, mouthOpen, false)
}
}
blinkTick()
}
function animateLoop() {
if (!faceCtx) return
// Smooth mouth movement
mouthOpen += (targetMouth - mouthOpen) * 0.15
if (Math.abs(mouthOpen - targetMouth) < 0.01) mouthOpen = targetMouth
drawFace(faceCtx, mouthOpen, false)
animFrameId = setTimeout(() => animateLoop(), 50)
}
async function playAudio(url) {
@@ -118,75 +337,86 @@ async function playAudio(url) {
isSpeaking.value = true
emit('speaking-start')
// Start mouth animation
targetMouth = 0.4
animateLoop()
try {
audioEl = new Audio(url)
// Use uni-app audio API
const innerAudio = uni.createInnerAudioContext()
audioEl = innerAudio
innerAudio.src = url
innerAudio.autoplay = true
if (isH5.value) {
audioCtx = new (window.AudioContext || window.webkitAudioContext)()
const source = audioCtx.createMediaElementSource(audioEl)
analyser = audioCtx.createAnalyser()
analyser.fftSize = 256
source.connect(analyser)
analyser.connect(audioCtx.destination)
}
innerAudio.onPlay(() => {
// Start audio-driven mouth animation
startAudioMouth(innerAudio)
})
audioEl.onended = () => {
innerAudio.onError(() => {
finishSpeaking()
}
})
audioEl.onerror = () => {
innerAudio.onEnded(() => {
finishSpeaking()
}
})
await audioEl.play()
innerAudio.onStop(() => {
finishSpeaking()
})
if (analyser) {
animateMouth()
}
} catch (e) {
finishSpeaking()
}
}
function animateMouth() {
if (!analyser) return
const dataArray = new Uint8Array(analyser.frequencyBinCount)
function startAudioMouth(audio) {
let lastTime = 0
function tick() {
if (!isSpeaking.value) return
analyser.getByteFrequencyData(dataArray)
const sum = dataArray.reduce((a, b) => a + b, 0)
const avg = sum / dataArray.length
mouthScale = Math.min(1, avg / 128)
// Smooth
mouthScale = Math.max(0.05, mouthScale)
drawMouth(mouthScale)
animFrameId = requestAnimationFrame(tick)
}
try {
const currentTime = audio.currentTime
if (currentTime > lastTime) {
lastTime = currentTime
// Random-ish mouth movement based on time for natural feel
const t = currentTime * 8
const amp = 0.2 + Math.abs(Math.sin(t)) * 0.3 + Math.abs(Math.sin(t * 1.7)) * 0.2
targetMouth = Math.min(0.8, amp)
}
} catch {}
if (isSpeaking.value) {
setTimeout(tick, 80)
}
}
tick()
}
function finishSpeaking() {
isSpeaking.value = false
emit('speaking-end')
if (analyser) {
mouthScale = 0
drawMouth(0)
}
targetMouth = 0
setTimeout(() => {
if (faceCtx && !isSpeaking.value) {
mouthOpen = 0
drawFace(faceCtx, 0, false)
}
}, 300)
cleanupAudio()
emit('speaking-end')
}
function cleanupAudio() {
if (animFrameId) { cancelAnimationFrame(animFrameId); animFrameId = null }
if (audioCtx) { audioCtx.close().catch(() => {}); audioCtx = null }
analyser = null
audioEl = null
if (animFrameId) { clearTimeout(animFrameId); animFrameId = null }
if (audioEl) {
try { audioEl.stop(); audioEl.destroy() } catch {}
audioEl = null
}
}
function stopAudio() {
if (audioEl) {
try { audioEl.pause(); audioEl.src = '' } catch {}
try { audioEl.stop(); audioEl.destroy() } catch {}
}
finishSpeaking()
}
@@ -199,56 +429,36 @@ defineExpose({ play: playAudio, stop: stopAudio })
display: flex;
flex-direction: column;
align-items: center;
padding: 20rpx 0;
padding: 16rpx 0 8rpx;
}
/* Avatar stage */
.avatar-stage {
display: flex;
flex-direction: column;
align-items: center;
gap: 12rpx;
}
.avatar-ring {
width: 200rpx;
height: 200rpx;
border-radius: 50%;
border: 4rpx solid #E5E7EB;
overflow: hidden;
gap: 10rpx;
position: relative;
transition: border-color 0.3s, box-shadow 0.3s;
}
.avatar-ring.speaking {
border-color: #6366F1;
box-shadow: 0 0 30rpx rgba(99, 102, 241, 0.3);
.face-canvas {
border-radius: 50%;
transition: box-shadow 0.3s;
}
.avatar-img {
width: 100%;
height: 100%;
.face-canvas.speaking {
box-shadow: 0 0 40rpx rgba(99, 102, 241, 0.4);
}
.avatar-default {
width: 100%;
height: 100%;
background: linear-gradient(135deg, #6366F1, #8B5CF6);
display: flex;
align-items: center;
justify-content: center;
.status-dot {
width: 14rpx;
height: 14rpx;
border-radius: 50%;
background: #9CA3AF;
position: absolute;
top: 12rpx;
right: 12rpx;
transition: background 0.3s;
}
.avatar-initials {
font-size: 48rpx;
font-weight: 700;
color: #FFFFFF;
.status-dot.active {
background: #10B981;
box-shadow: 0 0 12rpx rgba(16, 185, 129, 0.6);
}
.mouth-canvas {
pointer-events: none;
}
.role-label {
font-size: 22rpx;
color: #6B7280;
@@ -256,23 +466,19 @@ defineExpose({ play: playAudio, stop: stopAudio })
padding: 4rpx 20rpx;
border-radius: 20rpx;
}
/* Speech bubble */
.speech-area {
margin-top: 24rpx;
margin-top: 16rpx;
padding: 0 40rpx;
width: 100%;
box-sizing: border-box;
}
.speech-bubble {
background: #FFFFFF;
border-radius: 16rpx;
padding: 24rpx 28rpx;
padding: 20rpx 24rpx;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.06);
position: relative;
}
.speech-bubble::before {
content: '';
position: absolute;
@@ -282,10 +488,9 @@ defineExpose({ play: playAudio, stop: stopAudio })
border-bottom-color: #FFFFFF;
border-top: none;
}
.speech-text {
font-size: 28rpx;
line-height: 1.7;
font-size: 26rpx;
line-height: 1.6;
color: #1F2937;
}
</style>
+11 -5
View File
@@ -98,11 +98,17 @@ export const API_ENDPOINTS = {
CHECK: (outTradeNo: string) => `/payment/check/${outTradeNo}`,
ACTIVATE: '/payment/activate',
},
TTS: {
SYNTHESIZE: '/tts/synthesize',
AUDIO: (hash: string) => `/tts/audio/${hash}`,
},
} as const
TTS: {
SYNTHESIZE: '/tts/synthesize',
AUDIO: (hash: string) => `/tts/audio/${hash}`,
},
SHARE: {
CREATE: '/share/create',
STATS: '/share/stats',
RECORDS: '/share/records',
VISITORS: '/share/visitors',
},
} as const
const PROD_API_HOST = import.meta.env.VITE_PROD_API_HOST || 'https://zhiyinwx.yzrcloud.cn'
const DEV_API_HOST = 'http://localhost:3006'
+2 -2
View File
@@ -1,8 +1,8 @@
{
"name": "宇之然AI磁场",
"appid": "__UNI__DEV__",
"versionName": "1.0.6",
"versionCode": "106",
"versionName": "1.0.7",
"versionCode": "107",
"description": "职引 - 宇之然AI磁场旗下AI模拟面试平台,提供AI面试官模拟练习、简历智能优化、大厂面经题库,助你轻松应对校招面试。",
"h5": {
"title": "职引 - AI模拟面试 | 宇之然AI磁场",
+2 -1
View File
@@ -16,7 +16,8 @@
{ "path": "pages/admin/admin", "style": { "navigationBarTitleText": "管理后台" } },
{ "path": "pages/result/result", "style": { "navigationBarTitleText": "优化结果" } },
{ "path": "pages/agreement/agreement", "style": { "navigationBarTitleText": "用户协议" } },
{ "path": "pages/privacy/privacy", "style": { "navigationBarTitleText": "隐私政策" } }
{ "path": "pages/privacy/privacy", "style": { "navigationBarTitleText": "隐私政策" } },
{ "path": "pages/share/share", "style": { "navigationBarTitleText": "我的分享" } }
],
"tabBar": {
"color": "#999999",
+234 -12
View File
@@ -17,8 +17,10 @@
<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 === 'resumes' }" @click="switchTab('resumes')">简历</text>
<text class="tab" :class="{ active: tab === 'orders' }" @click="switchTab('orders')">订单</text>
<text class="tab" :class="{ active: tab === 'pricing' }" @click="switchTab('pricing')">定价</text>
<text class="tab" :class="{ active: tab === 'share' }" @click="switchTab('share')">分享</text>
<text class="tab" :class="{ active: tab === 'admins' }" @click="switchTab('admins')">管理</text>
</view>
@@ -35,6 +37,17 @@
<text class="stat-label">总面试</text>
<text class="stat-sub">今日 +{{ overview.todayInterviews }}</text>
</view>
<view class="stat-card">
<text class="stat-num">{{ overview.resumeCount ?? 0 }}</text>
<text class="stat-label">总简历</text>
<text class="stat-sub">付费下载 {{ overview.paidDownloadCount ?? 0 }}</text>
</view>
</view>
<view class="plan-cards">
<view class="plan-card" v-for="(cnt, plan) in overview.planBreakdown" :key="plan">
<text class="plan-num">{{ cnt }}</text>
<text class="plan-label">{{ planNameMap[plan] || plan }}</text>
</view>
</view>
</view>
@@ -46,12 +59,22 @@
</view>
<view class="user-list" v-if="!usersLoading">
<view class="user-row" v-for="u in users" :key="u._id">
<view class="user-main">
<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>
<view class="user-badges">
<text class="user-plan" :class="{ vip: u.plan === 'growth' || u.plan === 'vip' }">{{ u.plan === 'growth' || u.plan === 'vip' ? '会员' : '免费' }}</text>
<text class="user-credit">面试:{{ u.interviewCredits ?? 0 }}</text>
<text class="user-credit">优化:{{ u.resumeOptimizeCredits ?? 0 }}</text>
<text class="user-credit">下载:{{ u.resumeDownloadCredits ?? 0 }}</text>
<text class="user-credit share">分享:{{ u.shareCredits ?? 0 }}</text>
</view>
<view class="user-actions">
<text class="user-action-btn" v-if="u.plan !== 'growth' && u.plan !== 'vip'" @click="setVip(u._id)">设为会员</text>
<text class="user-action-btn credit" @click="openCreditModal(u)">调整额度</text>
</view>
</view>
<text class="load-more" v-if="usersTotal > users.length" @click="loadMoreUsers">加载更多</text>
</view>
<text class="loading-text" v-if="usersLoading">加载中...</text>
@@ -61,15 +84,41 @@
<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 class="iv-main">
<text class="iv-pos">{{ iv.position }}</text>
<text class="iv-user">{{ iv.userId?.phone || iv.userId?.nickname || '--' }}</text>
</view>
<view class="iv-meta">
<text class="iv-status" :class="{ done: iv.status === 'completed' }">{{ iv.status === 'completed' ? '已完成' : '进行中' }}</text>
<text class="iv-tag">{{ iv.questionCount || 0 }}</text>
<text class="iv-tag score">得分 {{ iv.totalScore ?? '-' }}</text>
<text class="iv-tag filler" v-if="iv.fillerScore != null && iv.fillerScore > 0">语分析 {{ iv.fillerScore }}/{{ iv.fillerDensity ?? '-' }}</text>
</view>
</view>
</view>
<text class="loading-text" v-if="ivLoading">加载中...</text>
</view>
<!-- 简历 -->
<view v-if="tab === 'resumes'" class="section">
<view class="resume-list" v-if="!resumeLoading">
<view class="resume-row" v-for="r in resumes" :key="r._id">
<view class="resume-main">
<text class="resume-title">{{ r.title }}</text>
<text class="resume-user">{{ r.userId?.phone || r.userId?.nickname || '--' }}</text>
</view>
<view class="resume-meta">
<text class="resume-tag">v{{ r.version }}</text>
<text class="resume-tag" v-if="r.targetPosition">{{ r.targetPosition }}</text>
<text class="resume-tag paid" v-if="r.paidDownload">付费下载</text>
</view>
<text class="resume-time">{{ r.createdAt?.slice(0,10) }}</text>
</view>
</view>
<text class="loading-text" v-if="resumeLoading">加载中...</text>
<text class="empty-text" v-if="!resumeLoading && resumes.length === 0">暂无简历</text>
</view>
<!-- 订单 -->
@@ -183,6 +232,60 @@
<button class="save-btn" @click="savePricing" :disabled="pricingLoading">保存定价配置</button>
<text class="loading-text" v-if="pricingLoading">保存中...</text>
</view>
<!-- 分享 -->
<view v-if="tab === 'share'" class="section">
<view class="tabs in-tab">
<text class="tab" :class="{ active: shareSubTab === 'records' }" @click="shareSubTab='records';loadShareRecords()">分享记录</text>
<text class="tab" :class="{ active: shareSubTab === 'visitors' }" @click="shareSubTab='visitors';loadShareVisitors()">访问记录</text>
</view>
<view v-if="shareSubTab === 'records'">
<view class="share-list" v-if="!shareLoading">
<view class="share-row" v-for="r in shareRecords" :key="r.shareCode">
<view class="share-main">
<text class="share-title">{{ r.title }}</text>
<text class="share-meta">{{ r.sharer?.nickname || '--' }} · {{ r.type }}</text>
</view>
<view class="share-stats">
<text>访问 {{ r.visitCount }}</text>
<text class="share-credited">有效 {{ r.creditedCount }}</text>
</view>
<text class="share-time">{{ r.createdAt?.slice(0,16).replace('T',' ') }}</text>
</view>
</view>
<text class="loading-text" v-if="shareLoading">加载中...</text>
<text class="empty-text" v-if="!shareLoading && shareRecords.length === 0">暂无分享记录</text>
</view>
<view v-if="shareSubTab === 'visitors'">
<view class="share-list" v-if="!shareLoading">
<view class="share-row" v-for="(v, i) in shareVisitors" :key="i">
<view class="share-main">
<text>分享者: {{ v.sharer?.nickname || '--' }}</text>
<text class="share-meta">访客: {{ v.visitor?.nickname || '匿名' }}</text>
</view>
<view class="share-stats">
<text class="badge" :class="v.credited ? 'badge-done' : 'badge-pend'">{{ v.credited ? '已积分' : '未积分' }}</text>
</view>
<text class="share-time">{{ v.createdAt?.slice(0,16).replace('T',' ') }}</text>
</view>
</view>
<text class="loading-text" v-if="shareLoading">加载中...</text>
<text class="empty-text" v-if="!shareLoading && shareVisitors.length === 0">暂无访问记录</text>
</view>
</view>
<!-- 额度调整弹窗 -->
<view class="modal-mask" v-if="creditModal.show" @click="closeCreditModal">
<view class="modal-content" @click.stop>
<text class="modal-title">调整 {{ creditModal.user?.nickname || '用户' }} 的额度</text>
<view class="cfg-row" v-for="t in creditTypes" :key="t.key">
<text>{{ t.label }}</text>
<input class="cfg-input" type="digit" v-model.number="t.value" :placeholder="t.key" />
</view>
<view class="modal-actions">
<button class="modal-btn cancel" @click="closeCreditModal">取消</button>
<button class="modal-btn confirm" @click="doAdjustCredits">确认调整</button>
</view>
</view>
</view>
<!-- 管理员 -->
<view v-if="tab === 'admins'" class="section">
<view class="search-bar">
@@ -219,16 +322,20 @@ import { api, API_ENDPOINTS } from '../../config'
const verified = ref(false)
const adminName = ref('')
const tab = ref('overview')
const shareSubTab = ref('records')
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 overview = ref({ userCount: 0, interviewCount: 0, todayUsers: 0, todayInterviews: 0, resumeCount: 0, paidDownloadCount: 0, planBreakdown: {} })
const planNameMap = { free: '免费', growth: '成长', sprint: '冲刺', vip: '会员' }
const users = ref([])
const usersTotal = ref(0)
const interviews = ref([])
const resumes = ref([])
const resumeLoading = ref(false)
const adminKeyword = ref('')
const adminList = ref([])
const searchResult = ref(null)
@@ -261,6 +368,20 @@ const ordersPage = ref(1)
const orderLoading = ref(false)
const orderFilter = ref('')
// Share state
const shareRecords = ref([])
const shareVisitors = ref([])
const shareLoading = ref(false)
// Credit modal
const creditModal = ref({ show: false, user: null })
const creditTypes = ref([
{ key: 'interviewCredits', label: '面试次数', value: 0 },
{ key: 'resumeOptimizeCredits', label: '优化次数', value: 0 },
{ key: 'resumeDownloadCredits', label: '下载次数', value: 0 },
{ key: 'shareCredits', label: '分享积分', value: 0 },
])
const token = () => uni.getStorageSync('token') || ''
const apiAdmin = (path, opts = {}) => {
@@ -300,6 +421,7 @@ const switchTab = (t) => {
tab.value = t
if (t === 'users' && users.value.length === 0) loadUsers()
if (t === 'interviews' && interviews.value.length === 0) loadInterviews()
if (t === 'resumes' && resumes.value.length === 0) loadResumes()
if (t === 'admins' && adminList.value.length === 0) loadAdmins()
if (t === 'pricing') loadPricing()
if (t === 'orders') loadOrders()
@@ -332,6 +454,15 @@ const loadInterviews = async () => {
finally { ivLoading.value = false }
}
const loadResumes = async () => {
resumeLoading.value = true
try {
const res = await apiAdmin('/resumes?page=1&limit=20')
if (res.statusCode === 200) resumes.value = res.data.list || []
} catch (e) { console.error(e) }
finally { resumeLoading.value = false }
}
const loadPricing = async () => {
pricingLoading.value = true
try {
@@ -462,6 +593,54 @@ const setVip = async (targetUserId) => {
}
})
}
const loadShareRecords = async () => {
shareLoading.value = true
try {
const res = await apiAdmin('/share-records?page=1&limit=50')
if (res.statusCode === 200) shareRecords.value = res.data.list || []
} catch(e) { console.error(e) }
finally { shareLoading.value = false }
}
const loadShareVisitors = async () => {
shareLoading.value = true
try {
const res = await apiAdmin('/share-visitors?page=1&limit=50')
if (res.statusCode === 200) shareVisitors.value = res.data.list || []
} catch(e) { console.error(e) }
finally { shareLoading.value = false }
}
const openCreditModal = (user) => {
creditTypes.value = [
{ key: 'interviewCredits', label: '面试次数', value: user.interviewCredits ?? 0 },
{ key: 'resumeOptimizeCredits', label: '优化次数', value: user.resumeOptimizeCredits ?? 0 },
{ key: 'resumeDownloadCredits', label: '下载次数', value: user.resumeDownloadCredits ?? 0 },
{ key: 'shareCredits', label: '分享积分', value: user.shareCredits ?? 0 },
]
creditModal.value = { show: true, user }
}
const closeCreditModal = () => {
creditModal.value = { show: false, user: null }
}
const doAdjustCredits = async () => {
const userId = creditModal.value.user?._id
if (!userId) return
try {
for (const t of creditTypes.value) {
await apiAdmin('/user/credits', {
method: 'POST',
data: { userId, type: t.key, amount: t.value },
})
}
uni.showToast({ title: '调整成功', icon: 'success' })
closeCreditModal()
loadUsers()
} catch { uni.showToast({ title: '调整失败', icon: 'none' }) }
}
</script>
<style scoped>
@@ -481,22 +660,37 @@ const setVip = async (targetUserId) => {
.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; }
.plan-cards { display: flex; gap: 12rpx; margin-top: 16rpx; }
.plan-card { flex: 1; background: #FFF; border-radius: var(--radius-sm); padding: 20rpx; text-align: center; box-shadow: var(--shadow-sm); }
.plan-num { font-size: 36rpx; font-weight: 700; color: var(--color-primary); display: block; }
.plan-label { font-size: 20rpx; color: var(--color-text-secondary); 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-row { background: #FFF; padding: 20rpx; border-radius: var(--radius-sm); margin-bottom: 8rpx; }
.user-main { display: flex; gap: 12rpx; margin-bottom: 8rpx; }
.user-phone { font-size: 24rpx; font-weight: 600; color: var(--color-text); }
.user-name { font-size: 22rpx; color: var(--color-text-secondary); }
.user-badges { display: flex; flex-wrap: wrap; gap: 6rpx; margin-bottom: 8rpx; }
.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); }
.user-plan.vip { background: #FEF3C7; color: #D97706; }
.user-credit { font-size: 18rpx; background: #F3F4F6; color: var(--color-text-tertiary); padding: 2rpx 10rpx; border-radius: var(--radius-round); }
.user-credit.share { background: #FFF7ED; color: #D97706; }
.user-actions { display: flex; gap: 12rpx; }
.user-action-btn { font-size: 20rpx; color: var(--color-primary); padding: 4rpx 16rpx; border: 2rpx solid var(--color-primary); border-radius: var(--radius-round); }
.user-action-btn.credit { color: #D97706; border-color: #D97706; }
.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-row { background: #FFF; padding: 16rpx; border-radius: var(--radius-sm); margin-bottom: 8rpx; }
.iv-main { display: flex; gap: 12rpx; margin-bottom: 6rpx; }
.iv-pos { font-size: 24rpx; font-weight: 600; color: var(--color-text); }
.iv-user { font-size: 22rpx; color: var(--color-text-secondary); }
.iv-meta { display: flex; flex-wrap: wrap; gap: 6rpx; }
.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); }
.iv-tag { font-size: 18rpx; background: #F3F4F6; color: var(--color-text-tertiary); padding: 2rpx 10rpx; border-radius: var(--radius-round); }
.iv-tag.score { background: #EEF2FF; color: var(--color-primary); }
.iv-tag.filler { background: #FFF7ED; color: #D97706; }
.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); }
@@ -505,6 +699,15 @@ const setVip = async (targetUserId) => {
.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; }
.resume-list { display: flex; flex-direction: column; gap: 8rpx; }
.resume-row { background: #FFF; padding: 16rpx; border-radius: var(--radius-sm); display: flex; align-items: center; gap: 12rpx; }
.resume-main { flex: 1; display: flex; flex-direction: column; }
.resume-title { font-size: 22rpx; font-weight: 600; color: var(--color-text); }
.resume-user { font-size: 20rpx; color: var(--color-text-secondary); margin-top: 2rpx; }
.resume-meta { display: flex; gap: 6rpx; }
.resume-tag { font-size: 18rpx; background: #F3F4F6; color: var(--color-text-tertiary); padding: 2rpx 10rpx; border-radius: var(--radius-round); }
.resume-tag.paid { background: #FEF3C7; color: #D97706; }
.resume-time { font-size: 18rpx; color: #D1D5DB; white-space: nowrap; }
.order-list { display: flex; flex-direction: column; gap: 8rpx; }
.order-row { background: #FFF; border-radius: var(--radius-sm); padding: 16rpx; }
.order-info { display: flex; justify-content: space-between; margin-bottom: 8rpx; }
@@ -527,4 +730,23 @@ const setVip = async (targetUserId) => {
.cfg-textarea { width: 100%; height: 160rpx; border: 2rpx solid var(--color-border); border-radius: var(--radius-sm); padding: 12rpx; font-size: 22rpx; margin-top: 8rpx; box-sizing: border-box; }
.save-btn { width: 100%; 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; margin-top: 12rpx; }
.save-btn:disabled { opacity: 0.6; }
.in-tab { margin-bottom: 16rpx; }
.share-list { display: flex; flex-direction: column; gap: 8rpx; }
.share-row { background: #FFF; padding: 16rpx; border-radius: var(--radius-sm); display: flex; align-items: center; gap: 12rpx; }
.share-main { flex: 1; display: flex; flex-direction: column; }
.share-title { font-size: 22rpx; color: var(--color-text); font-weight: 500; }
.share-meta { font-size: 18rpx; color: var(--color-text-tertiary); margin-top: 2rpx; }
.share-stats { display: flex; flex-direction: column; align-items: flex-end; font-size: 20rpx; color: var(--color-text-tertiary); }
.share-credited { color: var(--color-primary); }
.share-time { font-size: 18rpx; color: #D1D5DB; white-space: nowrap; }
.badge { font-size: 18rpx; padding: 2rpx 10rpx; border-radius: 6rpx; }
.badge-done { background: #ECFDF5; color: #059669; }
.badge-pend { background: #FEF3C7; color: #D97706; }
.modal-mask { position: fixed; inset: 0; background: rgba(0,0,0,0.4); display: flex; align-items: center; justify-content: center; z-index: 999; }
.modal-content { background: #FFF; border-radius: var(--radius-lg); padding: 40rpx 32rpx; width: 600rpx; max-width: 90vw; }
.modal-title { font-size: 28rpx; font-weight: 600; color: var(--color-text); margin-bottom: 24rpx; }
.modal-actions { display: flex; gap: 16rpx; margin-top: 32rpx; }
.modal-btn { flex: 1; height: 72rpx; border-radius: var(--radius-sm); font-size: 26rpx; border: none; }
.modal-btn.cancel { background: #F3F4F6; color: var(--color-text-secondary); }
.modal-btn.confirm { background: var(--color-primary); color: #FFF; }
</style>
+56 -2
View File
@@ -53,11 +53,14 @@
</scroll-view>
<view class="input-bar" v-if="!isComplete">
<view class="mic-btn" :class="{ recording: isRecording }" @touchstart="startRecord" @touchend="stopRecord" @touchcancel="stopRecord">
<text class="mic-icon">🎤</text>
</view>
<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 class="send-btn" :class="{ disabled: (!inputText.trim() && !isRecording) || aiLoading }" @click="sendAnswer">
<text class="send-icon">{{ isRecording ? '◉' : '➤' }}</text>
</view>
</view>
@@ -88,6 +91,8 @@ const aiSpeechText = ref('')
const aiAudioUrl = ref('')
const isSpeaking = ref(false)
const dhRef = ref(null)
const isRecording = ref(false)
let recorder = null
let timerSeconds = 0
let timerInterval = null
@@ -227,6 +232,43 @@ const confirmExit = () => {
success: (r) => { if (r.confirm) uni.navigateBack() },
})
}
function startRecord() {
if (aiLoading.value || isComplete.value) return
isRecording.value = true
recorder = uni.getRecorderManager()
recorder.onStart(() => {})
recorder.onError(() => { isRecording.value = false })
recorder.start({ format: 'mp3' })
uni.vibrateShort({ type: 'medium' })
}
function stopRecord() {
if (!recorder || !isRecording.value) return
isRecording.value = false
recorder.stop()
recorder.onStop(async (res) => {
if (!res.tempFilePath) return
const audioPath = res.tempFilePath
try {
const uploadRes = await uni.uploadFile({
url: api('/asr/recognize'),
filePath: audioPath,
name: 'audio',
header: { 'Authorization': `Bearer ${token.value}` },
})
if (uploadRes.statusCode === 200 && uploadRes.data) {
const data = typeof uploadRes.data === 'string' ? JSON.parse(uploadRes.data) : uploadRes.data
if (data.text) {
inputText.value = data.text
uni.vibrateShort({ type: 'light' })
return
}
}
} catch {}
uni.showToast({ title: '语音识别失败,请手动输入', icon: 'none' })
})
}
</script>
<style scoped>
@@ -300,6 +342,18 @@ const confirmExit = () => {
}
.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; }
.mic-btn {
width: 64rpx; height: 64rpx; border-radius: 50%; background: #F3F4F6;
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
transition: all 0.2s;
}
.mic-btn:active { transform: scale(0.9); }
.mic-btn.recording { background: #FEE2E2; animation: mic-pulse 1s infinite; }
.mic-icon { font-size: 28rpx; }
@keyframes mic-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4); }
50% { box-shadow: 0 0 0 16rpx rgba(239, 68, 68, 0); }
}
.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;
+273
View File
@@ -0,0 +1,273 @@
<template>
<view class="page fade-in">
<!-- 统计卡片 -->
<view class="stats-card">
<view class="stat-row">
<view class="stat-item">
<text class="stat-value">{{ stats.shareCredits || 0 }}</text>
<text class="stat-label">📦 我的积分</text>
</view>
</view>
<view class="stat-sub-row">
<view class="stat-sub-item">
<text class="stat-sub-value">{{ stats.totalVisits || 0 }}</text>
<text class="stat-sub-label">总点击</text>
</view>
<view class="stat-arrow"></view>
<view class="stat-sub-item">
<text class="stat-sub-value">{{ stats.creditedCount || 0 }}</text>
<text class="stat-sub-label">有效注册</text>
</view>
<view class="stat-arrow"></view>
<view class="stat-sub-item">
<text class="stat-sub-value">{{ stats.shareCredits || 0 }}</text>
<text class="stat-sub-label">获得积分</text>
</view>
</view>
<view class="hint">
💡 好友通过你的链接打开并 <text class="hint-em">登录/注册</text> 才算有效每次有效得 1 积分
</view>
</view>
<!-- 今日数据 -->
<view class="today-card">
<text class="today-title">今日数据</text>
<view class="today-row">
<view class="today-item">
<text class="today-value">{{ todayStats.visits }}</text>
<text class="today-label">点击</text>
</view>
<view class="today-item">
<text class="today-value">{{ todayStats.credited }}</text>
<text class="today-label">有效</text>
</view>
<view class="today-item">
<text class="today-value">{{ 3 - todayStats.credited > 0 ? 3 - todayStats.credited : 0 }}</text>
<text class="today-label">今日剩余</text>
</view>
</view>
<view class="today-bar">
<view class="today-bar-fill" :style="{ width: Math.min(100, (todayStats.credited / 3) * 100) + '%' }"></view>
</view>
<text class="today-hint">每日最多 3 次有效积分</text>
</view>
<!-- 分享按钮 -->
<view class="share-actions">
<button class="share-btn wx-share" @click="shareToWechat" v-if="isWechat">
<text class="btn-icon">💬</text>
<text>分享给微信好友</text>
</button>
<button class="share-btn link-share" @click="copyLink">
<text class="btn-icon">🔗</text>
<text>复制分享链接</text>
</button>
</view>
<!-- Tab 切换 -->
<view class="tab-bar">
<view class="tab-item" :class="{ active: tab === 'records' }" @click="tab = 'records'">分享记录</view>
<view class="tab-item" :class="{ active: tab === 'visitors' }" @click="tab = 'visitors'">访问明细</view>
</view>
<!-- 分享记录 -->
<view class="list" v-if="tab === 'records'">
<view class="list-item" v-for="item in records" :key="item.shareCode">
<view class="item-main">
<text class="item-type">{{ typeLabel(item.type) }}</text>
<text class="item-title">{{ item.title }}</text>
</view>
<view class="item-stats">
<text class="item-stat">👀 {{ item.visitCount }}点击</text>
<text class="item-stat active"> {{ item.creditedCount }}有效</text>
</view>
<text class="item-time">{{ formatTime(item.createdAt) }}</text>
</view>
<view class="empty" v-if="records.length === 0">
<text class="empty-icon">📤</text>
<text class="empty-text">还没有分享记录</text>
<text class="empty-hint">点击上方按钮分享给好友吧</text>
</view>
</view>
<!-- 访问明细 -->
<view class="list" v-if="tab === 'visitors'">
<view class="list-item" v-for="(item, idx) in visitors" :key="idx">
<image class="visitor-avatar" :src="item.avatar || '/static/avatar-default.svg'" />
<view class="item-main">
<text class="item-title">{{ item.nickname || '👤 未登录访客' }}</text>
<text class="item-time">{{ formatTime(item.createdAt) }}</text>
</view>
<view class="badge" :class="item.credited ? 'badge-done' : 'badge-pending'">
{{ item.credited ? '✅ 已积分' : '⏳ 未注册' }}
</view>
</view>
<view class="empty" v-if="visitors.length === 0">
<text class="empty-icon">👀</text>
<text class="empty-text">还没有访问记录</text>
<text class="empty-hint">分享后好友访问就会出现在这里</text>
</view>
</view>
</view>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { api } from '../../config'
const tab = ref('records')
const stats = ref({ totalShares: 0, totalVisits: 0, creditedCount: 0, todayCredited: 0, shareCredits: 0 })
const records = ref([])
const visitors = ref([])
const todayStats = computed(() => ({
visits: stats.value.todayCredited + Math.round(Math.random() * 0),
credited: stats.value.todayCredited || 0,
}))
let isWechat = false
onMounted(() => {
isWechat = navigator?.userAgent?.toLowerCase().includes('micromessenger') || false
loadData()
})
async function loadData() {
const token = uni.getStorageSync('token')
if (!token) { uni.showToast({ title: '请先登录', icon: 'none' }); return }
const header = { Authorization: `Bearer ${token}` }
try {
const [statsRes, recordsRes, visitorsRes] = await Promise.all([
uni.request({ url: api('/share/stats'), method: 'GET', header }),
uni.request({ url: api('/share/records'), method: 'GET', header }),
uni.request({ url: api('/share/visitors'), method: 'GET', header }),
])
if (statsRes.statusCode === 200) stats.value = statsRes.data
if (recordsRes.statusCode === 200) records.value = recordsRes.data.list || []
if (visitorsRes.statusCode === 200) visitors.value = visitorsRes.data.list || []
} catch (e) { console.error(e) }
}
async function shareToWechat() {
const token = uni.getStorageSync('token')
if (!token) return
try {
const res = await uni.request({
url: api('/share/create'),
method: 'POST',
data: { type: 'app', title: '我在AI磁场·职引练习面试', description: 'AI模拟面试+简历优化,快来一起提升吧' },
header: { Authorization: `Bearer ${token}` },
})
if (res.statusCode !== 200) return
const data = res.data
const path = data.wechatShareInfo?.path || `/pages/share/share?code=${data.shareCode}`
uni.shareAppMessage({
provider: 'weixin',
title: data.wechatShareInfo?.title || 'AI磁场·职引',
description: data.wechatShareInfo?.description || '',
path,
imageUrl: 'https://zhiyinwx.yzrcloud.cn/static/share-card.png',
success: () => { uni.showToast({ title: '分享成功', icon: 'success' }); loadData() },
fail: () => { uni.showToast({ title: '分享取消', icon: 'none' }) },
})
} catch (e) { console.error(e) }
}
async function copyLink() {
const token = uni.getStorageSync('token')
if (!token) return
try {
const res = await uni.request({
url: api('/share/create'),
method: 'POST',
data: { type: 'app', title: '我在AI磁场·职引练习面试', description: 'AI模拟面试+简历优化,快来一起提升吧' },
header: { Authorization: `Bearer ${token}` },
})
if (res.statusCode !== 200) return
const shareUrl = `https://zhiyinwx.yzrcloud.cn/share/${res.data.shareCode}`
uni.setClipboardData({ data: shareUrl, success: () => { uni.showToast({ title: '链接已复制' }); loadData() } })
} catch (e) { console.error(e) }
}
function typeLabel(type) {
const map = { app: '应用', interview: '面试', resume: '简历' }
return map[type] || type
}
function formatTime(t) {
if (!t) return ''
const d = new Date(t)
return `${d.getMonth() + 1}/${d.getDate()} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}
</script>
<style scoped>
.page { min-height: 100vh; background: var(--color-bg); padding-bottom: 48rpx; }
/* Stats card */
.stats-card { background: linear-gradient(135deg, #4F46E5 0%, #7C3AED 100%); margin: 24rpx 32rpx; border-radius: var(--radius-lg); padding: 32rpx; }
.stat-row { display: flex; justify-content: center; margin-bottom: 20rpx; }
.stat-item { display: flex; flex-direction: column; align-items: center; }
.stat-value { font-size: 56rpx; font-weight: 800; color: #FFFFFF; }
.stat-label { font-size: 22rpx; color: rgba(255,255,255,0.75); margin-top: 6rpx; }
.stat-sub-row { display: flex; align-items: center; justify-content: center; gap: 12rpx; padding: 16rpx 0; border-top: 1rpx solid rgba(255,255,255,0.15); border-bottom: 1rpx solid rgba(255,255,255,0.15); margin-bottom: 16rpx; }
.stat-sub-item { display: flex; flex-direction: column; align-items: center; }
.stat-sub-value { font-size: 32rpx; font-weight: 700; color: #FFFFFF; }
.stat-sub-label { font-size: 18rpx; color: rgba(255,255,255,0.6); margin-top: 4rpx; }
.stat-arrow { font-size: 24rpx; color: rgba(255,255,255,0.4); }
.hint { font-size: 20rpx; color: rgba(255,255,255,0.7); text-align: center; line-height: 1.6; }
.hint-em { color: #FCD34D; font-weight: 600; }
/* Today card */
.today-card { background: #FFFFFF; margin: 0 32rpx 24rpx; border-radius: var(--radius-md); padding: 24rpx; box-shadow: var(--shadow-sm); }
.today-title { font-size: 24rpx; font-weight: 600; color: var(--color-text); margin-bottom: 16rpx; }
.today-row { display: flex; gap: 24rpx; margin-bottom: 16rpx; }
.today-item { flex: 1; display: flex; flex-direction: column; align-items: center; }
.today-value { font-size: 36rpx; font-weight: 700; color: var(--color-primary); }
.today-label { font-size: 20rpx; color: var(--color-text-tertiary); margin-top: 4rpx; }
.today-bar { height: 8rpx; background: #F3F4F6; border-radius: 4rpx; overflow: hidden; margin-bottom: 8rpx; }
.today-bar-fill { height: 100%; background: linear-gradient(90deg, #4F46E5, #7C3AED); border-radius: 4rpx; transition: width 0.3s; }
.today-hint { font-size: 18rpx; color: var(--color-text-tertiary); text-align: center; }
/* Share buttons */
.share-actions { padding: 0 32rpx; display: flex; gap: 20rpx; }
.share-btn { flex: 1; display: flex; align-items: center; justify-content: center; height: 88rpx; border-radius: var(--radius-md); font-size: 26rpx; font-weight: 500; border: none; }
.share-btn:active { transform: scale(0.96); }
.btn-icon { margin-right: 8rpx; font-size: 28rpx; }
.wx-share { background: #07C160; color: #FFFFFF; }
.link-share { background: #FFFFFF; color: var(--color-text); border: 2rpx solid var(--color-border); }
/* Tabs */
.tab-bar { display: flex; margin: 32rpx 32rpx 0; border-bottom: 2rpx solid var(--color-border); }
.tab-item { flex: 1; text-align: center; padding: 20rpx 0; font-size: 28rpx; color: #9CA3AF; font-weight: 500; position: relative; }
.tab-item.active { color: var(--color-primary); }
.tab-item.active::after { content: ''; position: absolute; bottom: -2rpx; left: 30%; right: 30%; height: 4rpx; background: var(--color-primary); border-radius: 2rpx; }
/* List */
.list { padding: 0 32rpx; }
.list-item { background: #FFFFFF; border-radius: var(--radius-md); padding: 24rpx 28rpx; margin-top: 16rpx; display: flex; align-items: center; box-shadow: var(--shadow-sm); }
.item-main { flex: 1; display: flex; flex-direction: column; }
.item-type { font-size: 20rpx; color: var(--color-primary); background: #EEF2FF; padding: 2rpx 12rpx; border-radius: 6rpx; align-self: flex-start; margin-bottom: 4rpx; }
.item-title { font-size: 26rpx; color: var(--color-text); font-weight: 500; }
.item-stats { display: flex; flex-direction: column; align-items: flex-end; margin-left: 12rpx; }
.item-stat { font-size: 22rpx; color: #9CA3AF; white-space: nowrap; }
.item-stat.active { color: var(--color-primary); }
.item-time { font-size: 20rpx; color: #D1D5DB; white-space: nowrap; margin-left: 12rpx; }
.visitor-avatar { width: 56rpx; height: 56rpx; border-radius: 50%; margin-right: 16rpx; flex-shrink: 0; }
.badge { font-size: 20rpx; padding: 4rpx 16rpx; border-radius: 8rpx; white-space: nowrap; }
.badge-done { background: #ECFDF5; color: #059669; }
.badge-pending { background: #FEF3C7; color: #D97706; }
/* Empty state */
.empty { display: flex; flex-direction: column; align-items: center; padding: 80rpx 0; }
.empty-icon { font-size: 64rpx; margin-bottom: 16rpx; }
.empty-text { font-size: 28rpx; color: #9CA3AF; }
.empty-hint { font-size: 24rpx; color: #D1D5DB; margin-top: 8rpx; }
</style>
+7
View File
@@ -57,6 +57,11 @@
<text class="menu-text">我的简历</text>
<text class="menu-arrow"></text>
</view>
<view class="menu-item" @click="requireLogin(goShare, '我的分享')">
<view class="menu-icon-wrap wrap-orange"><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">
@@ -121,6 +126,7 @@ 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 goShare = () => uni.navigateTo({ url: '/pages/share/share' })
const goAdmin = () => uni.navigateTo({ url: '/pages/admin/admin' })
const goAbout = () => uni.navigateTo({ url: '/pages/about/about' })
@@ -171,6 +177,7 @@ const doLogout = () => {
.wrap-blue { background: #EEF2FF; }
.wrap-purple { background: #F5F3FF; }
.wrap-green { background: #ECFDF5; }
.wrap-orange { background: #FFF7ED; }
.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; }
+7
View File
@@ -83,6 +83,13 @@ export const apiService = {
byPosition: (position: string) =>
request(API_ENDPOINTS.DAILY_QUESTION.BY_POSITION(position), 'GET', undefined, true),
},
share: {
create: (data: { type: string; refId?: string; title?: string; description?: string }) =>
request(API_ENDPOINTS.SHARE.CREATE, 'POST', data, true),
stats: () => request(API_ENDPOINTS.SHARE.STATS, 'GET', undefined, true),
records: () => request(API_ENDPOINTS.SHARE.RECORDS, 'GET', undefined, true),
visitors: () => request(API_ENDPOINTS.SHARE.VISITORS, 'GET', undefined, true),
},
}
export default apiService