Files
vueapp/pages/assessment/assessment.vue
T
2026-06-11 12:12:55 +08:00

1158 lines
28 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="assessment-page">
<view class="top-app-bar">
<button class="icon-button" aria-label="返回" @click="goBack">
<view class="back-icon"></view>
</button>
<text class="app-title">AI 学习助手</text>
</view>
<scroll-view class="report-content" scroll-y>
<view class="report-head">
<text class="report-title">训练总结与 AI 评估报告</text>
<view class="report-meta">
<text>评估日期{{ report.date }}</text>
<text>模拟编号{{ report.no }}</text>
<text v-if="report.caseTitle">病例{{ report.caseTitle }}</text>
</view>
</view>
<view class="content-stack">
<view class="overall-card">
<view class="score-ring">
<svg class="ring-svg" viewBox="0 0 100 100">
<circle class="ring-track" cx="50" cy="50" fill="transparent" r="42" stroke-width="10"></circle>
<circle
class="ring-value"
cx="50"
cy="50"
fill="transparent"
r="42"
stroke-dasharray="264"
:stroke-dashoffset="scoreDashOffset"
stroke-linecap="round"
stroke-width="10"
></circle>
</svg>
<view class="score-center">
<text class="score-value">{{ report.scoreText }}</text>
<text class="score-total">/100</text>
</view>
</view>
<view class="overall-copy">
<text class="overall-title">本次考核评价<text class="primary-text">{{ report.level }}</text></text>
<text class="overall-desc">{{ report.overallComment }}</text>
</view>
</view>
<view class="report-card">
<view class="section-heading">
<view class="hub-icon"></view>
<text>临床胜任力雷达图</text>
</view>
<view class="radar-wrap">
<svg v-if="radarItems.length >= 3" class="radar-svg" viewBox="0 0 400 400">
<circle class="radar-grid" cx="200" cy="200" r="160"></circle>
<circle class="radar-grid" cx="200" cy="200" r="120"></circle>
<circle class="radar-grid" cx="200" cy="200" r="80"></circle>
<circle class="radar-grid" cx="200" cy="200" r="40"></circle>
<line
v-for="item in radarItems"
:key="`axis-${item.label}`"
class="radar-grid"
x1="200"
:x2="item.axisX"
y1="200"
:y2="item.axisY"
></line>
<polygon class="radar-area" :points="radarPolygonPoints"></polygon>
<text
v-for="item in radarItems"
:key="`label-${item.label}`"
class="radar-label"
:text-anchor="item.anchor"
:x="item.labelX"
:y="item.labelY"
>{{ item.label }}</text>
</svg>
<view v-else class="empty-data radar-empty">
<text>{{ emptyRadarText }}</text>
</view>
</view>
</view>
<view class="report-card">
<view class="section-heading breakdown-title">
<view class="analytics-icon"></view>
<text>分项得分与解析</text>
</view>
<view class="breakdown-list">
<view v-if="breakdownItems.length === 0" class="empty-data">
<text>{{ emptyBreakdownText }}</text>
</view>
<block v-else>
<view
v-for="(item, index) in breakdownItems"
:key="`${item.label}-${index}`"
class="breakdown-item"
>
<view class="breakdown-head">
<text>{{ item.label }}</text>
<text class="breakdown-score">{{ item.displayScore }}</text>
</view>
<view class="progress-track">
<view class="progress-fill" :style="{ width: barsReady ? `${item.percent}%` : '0%' }"></view>
</view>
<view class="analysis-box">
<text>{{ item.analysis }}</text>
</view>
</view>
</block>
</view>
</view>
<view class="mentor-section">
<view class="mentor-head">
<image class="mentor-avatar" src="/static/config-doctor.png" mode="aspectFill"></image>
<view class="mentor-title-group">
<text class="mentor-title">王主任点评</text>
<text class="mentor-subtitle">Director's Mentorship</text>
</view>
</view>
<view class="mentor-bubble">
<view class="mentor-tail"></view>
<text class="mentor-copy">{{ mentorComment }}</text>
<view class="mentor-action-row">
<button class="read-button" @click="openLearningAssistant">
<text>去查阅</text>
<view class="arrow-forward-icon"></view>
</button>
</view>
</view>
</view>
</view>
</scroll-view>
<view class="footer-actions">
<button
class="download-button"
:class="{ disabled: !activeEvaluationId || exportingPdf }"
:disabled="!activeEvaluationId || exportingPdf"
@click="downloadPdfReport"
>
{{ exportingPdf ? 'PDF 生成中...' : '下载完整 PDF 报告' }}
</button>
<button class="next-button" @click="goHome">
开始下一轮强化训练
</button>
</view>
<view class="toast" :class="{ visible: toastVisible }">{{ toastMessage }}</view>
</view>
</template>
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, reactive, ref } from 'vue'
import {
downloadEvaluationPdf,
fetchEvaluationDetail,
generateEvaluation,
type EvaluationDetail,
type EvaluationResult
} from '../../api/assessment'
import { readActiveSessionId } from '../../api/session'
type BreakdownItem = {
label: string
percent: number
displayScore: string
analysis: string
}
type RadarItem = BreakdownItem & {
axisX: number
axisY: number
labelX: number
labelY: number
pointX: number
pointY: number
anchor: 'start' | 'middle' | 'end'
}
const report = reactive({
date: '生成中',
no: '生成中',
caseTitle: '',
score: 0,
scoreText: '--',
level: '生成中',
overallComment: '评价生成中请稍候'
})
const barsReady = ref(false)
const toastMessage = ref('')
const toastVisible = ref(false)
const evaluation = ref<EvaluationResult | null>(null)
const evaluationDetail = ref<EvaluationDetail | null>(null)
const activeEvaluationId = ref<number | null>(null)
const exportingPdf = ref(false)
const evaluationLoaded = ref(false)
const evaluationFailed = ref(false)
const scoreDashOffset = computed(() => {
return 264 - (264 * report.score) / 100
})
const mentorComment = computed(() => report.overallComment || emptyReportText.value)
const emptyReportText = computed(() => {
if (evaluationFailed.value) return '评价生成失败请稍后重试'
if (!evaluationLoaded.value) return '评价生成中请稍候'
return '本次评价暂无详细点评'
})
const emptyBreakdownText = computed(() => {
if (evaluationFailed.value) return '评价生成失败请稍后重试'
if (!evaluationLoaded.value) return '分项评分生成中'
return '暂无分项评分'
})
const emptyRadarText = computed(() => {
if (evaluationFailed.value) return '评价生成失败暂无雷达图'
if (!evaluationLoaded.value) return '雷达图生成中'
return '暂无足够分项数据生成雷达图'
})
const breakdownItems = computed<BreakdownItem[]>(() => {
const result = evaluationDetail.value || evaluation.value
if (!result) return []
if (Array.isArray(result.dimension_scores) && result.dimension_scores.length > 0) {
return result.dimension_scores.map(item => {
const percent = item.max_score > 0 ? clampScore((Number(item.score) / Number(item.max_score)) * 100) : clampScore(Number(item.score))
const analysis = [
item.comment,
item.improvement ? `改进建议:${item.improvement}` : ''
].filter(Boolean).join(' ')
return {
label: item.dimension,
percent,
displayScore: item.max_score > 0 ? `${item.score}/${item.max_score}` : `${item.score}%`,
analysis: analysis || '暂无分项解析'
}
})
}
if (Array.isArray(result.score_details) && result.score_details.length > 0) {
return result.score_details.map(item => ({
label: item.dimension,
percent: clampScore(Number(item.score)),
displayScore: `${item.score}%`,
analysis: item.comment || item.deducted_reason || '暂无分项解析'
}))
}
if (
evaluationDetail.value &&
Array.isArray(evaluation.value?.dimension_scores) &&
evaluation.value.dimension_scores.length > 0
) {
return evaluation.value.dimension_scores.map(item => {
const score = Number(item.score)
const maxScore = Number(item.max_score)
const percent = maxScore > 0 ? clampScore((score / maxScore) * 100) : clampScore(score)
const analysis = [
item.comment,
item.improvement ? `改进建议:${item.improvement}` : ''
].filter(Boolean).join(' ')
return {
label: item.dimension,
percent,
displayScore: maxScore > 0 ? `${item.score}/${item.max_score}` : `${item.score}%`,
analysis: analysis || '暂无分项解析'
}
})
}
if (
evaluationDetail.value &&
Array.isArray(evaluation.value?.score_details) &&
evaluation.value.score_details.length > 0
) {
return evaluation.value.score_details.map(item => ({
label: item.dimension,
percent: clampScore(Number(item.score)),
displayScore: `${item.score}%`,
analysis: item.comment || item.deducted_reason || '暂无分项解析'
}))
}
return []
})
const radarItems = computed<RadarItem[]>(() => {
const items = breakdownItems.value.slice(0, 6)
const total = items.length
if (total < 3) return []
return items.map((item, index) => {
const angle = -Math.PI / 2 + (Math.PI * 2 * index) / total
const axisRadius = 160
const labelRadius = 184
const pointRadius = axisRadius * (item.percent / 100)
const labelX = 200 + Math.cos(angle) * labelRadius
const labelY = 200 + Math.sin(angle) * labelRadius
return {
...item,
axisX: roundSvgValue(200 + Math.cos(angle) * axisRadius),
axisY: roundSvgValue(200 + Math.sin(angle) * axisRadius),
labelX: roundSvgValue(labelX),
labelY: roundSvgValue(labelY),
pointX: roundSvgValue(200 + Math.cos(angle) * pointRadius),
pointY: roundSvgValue(200 + Math.sin(angle) * pointRadius),
anchor: labelX < 170 ? 'end' : labelX > 230 ? 'start' : 'middle'
}
})
})
const radarPolygonPoints = computed(() => {
return radarItems.value.map(item => `${item.pointX},${item.pointY}`).join(' ')
})
let toastTimer: ReturnType<typeof setTimeout> | null = null
function goBack() {
if (typeof getCurrentPages === 'function' && getCurrentPages().length > 1) {
uni.navigateBack()
return
}
goHome()
}
function goHome() {
uni.reLaunch({
url: '/pages/home/home'
})
}
function openLearningAssistant() {
uni.navigateTo({
url: '/pages/learning-assistant/learning-assistant'
})
}
function showToast(message: string) {
if (toastTimer) clearTimeout(toastTimer)
toastMessage.value = message
toastVisible.value = true
toastTimer = setTimeout(() => {
toastVisible.value = false
}, 2200)
}
function formatReportDate(date: Date) {
return `${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日`
}
function formatApiDate(value?: string) {
if (!value) return formatReportDate(new Date())
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return formatReportDate(date)
}
function clampScore(value: number) {
if (!Number.isFinite(value)) return 0
return Math.max(0, Math.min(100, Math.round(value)))
}
function roundSvgValue(value: number) {
return Math.round(value * 100) / 100
}
function getLevel(score: number) {
if (score >= 90) return '优秀'
if (score >= 80) return '优良'
if (score >= 70) return '良好'
if (score >= 60) return '合格'
return '待加强'
}
function applyEvaluation(result: EvaluationResult) {
evaluation.value = result
activeEvaluationId.value = result.evaluation_id
report.no = `EV-${result.evaluation_id}`
updateReportScore(result.total_score)
report.level = getLevel(report.score)
report.date = formatReportDate(new Date())
report.overallComment = result.overall_comment || '本次评价暂无详细点评'
}
function applyEvaluationDetail(detail: EvaluationDetail) {
evaluationDetail.value = detail
activeEvaluationId.value = detail.evaluation_id
report.no = `EV-${detail.evaluation_id}`
report.caseTitle = detail.case_title || ''
report.date = formatApiDate(detail.created_at)
updateReportScore(detail.total_score)
report.level = getLevel(report.score)
report.overallComment = detail.overall_comment || evaluation.value?.overall_comment || '本次评价暂无详细点评'
}
function updateReportScore(score: number) {
report.score = clampScore(Number(score))
report.scoreText = Number.isFinite(Number(score)) ? String(report.score) : '--'
}
function resetReportToError() {
evaluation.value = null
evaluationDetail.value = null
activeEvaluationId.value = null
report.date = '--'
report.no = '--'
report.caseTitle = ''
report.score = 0
report.scoreText = '--'
report.level = '生成失败'
report.overallComment = '评价生成失败请稍后重试'
}
function animateBars() {
barsReady.value = false
nextTick(() => {
setTimeout(() => {
barsReady.value = true
}, 120)
})
}
async function loadEvaluation() {
evaluationLoaded.value = false
evaluationFailed.value = false
try {
if (isTeachingAssessment()) {
await loadTeachingEvaluation()
return
}
const sessionId = readActiveSessionId()
report.no = `SID-${sessionId}`
const result = await generateEvaluation(sessionId, 'percentage')
uni.setStorageSync('clinical-thinking-evaluation', result)
applyEvaluation(result)
const detail = await fetchEvaluationDetail(result.evaluation_id)
uni.setStorageSync('clinical-thinking-evaluation-detail', detail)
applyEvaluationDetail(detail)
evaluationLoaded.value = true
} catch (error) {
evaluationFailed.value = true
resetReportToError()
showToast(error instanceof Error ? error.message : '评价生成失败')
} finally {
animateBars()
}
}
async function loadTeachingEvaluation() {
const evaluationId = readTeachingEvaluationId()
const storedEvaluation = readStoredTeachingEvaluation()
if (storedEvaluation) {
applyEvaluation(storedEvaluation)
}
const detail = await fetchEvaluationDetail(evaluationId)
uni.setStorageSync('clinical-thinking-evaluation-detail', detail)
applyEvaluationDetail(detail)
evaluationLoaded.value = true
}
function isTeachingAssessment() {
return uni.getStorageSync('clinical-thinking-case-mode') === 'teaching' && Boolean(readTeachingEvaluationId(false))
}
function readTeachingEvaluationId(throwOnMissing = true) {
const value = uni.getStorageSync('clinical-thinking-teaching-evaluation-id')
const evaluationId = Number(value)
if (Number.isInteger(evaluationId) && evaluationId > 0) return evaluationId
if (throwOnMissing) throw new Error('未找到教学评价请先完成教学题目')
return 0
}
function readStoredTeachingEvaluation() {
const value = uni.getStorageSync('clinical-thinking-teaching-evaluation')
if (value && typeof value === 'object') return value as EvaluationResult
return null
}
async function downloadPdfReport() {
if (!activeEvaluationId.value || exportingPdf.value) return
exportingPdf.value = true
try {
const result = await downloadEvaluationPdf(activeEvaluationId.value)
uni.setStorageSync('clinical-thinking-evaluation-pdf', {
fileName: result.fileName,
filePath: result.filePath || ''
})
await triggerBrowserPdfDownload(result)
showToast('PDF 已生成')
} catch (error) {
showToast(error instanceof Error ? error.message : 'PDF 下载失败')
} finally {
exportingPdf.value = false
}
}
async function triggerBrowserPdfDownload(result: Awaited<ReturnType<typeof downloadEvaluationPdf>>) {
if (result.blob) {
if (typeof window === 'undefined' || typeof document === 'undefined') return
const blobUrl = window.URL.createObjectURL(result.blob)
startBrowserDownload(blobUrl, result.fileName)
window.setTimeout(() => {
window.URL.revokeObjectURL(blobUrl)
}, 1000)
return
}
if (!result.filePath) {
throw new Error('PDF 下载地址为空')
}
const downloadUrl = resolvePdfDownloadUrl(result.filePath)
const fileName = result.fileName
if (typeof window === 'undefined' || typeof document === 'undefined') {
uni.downloadFile({
url: downloadUrl,
fail: () => showToast('PDF 下载失败')
})
return
}
try {
const response = await fetch(downloadUrl, {
method: 'GET',
credentials: 'include'
})
if (!response.ok) {
throw new Error(`PDF 下载失败(${response.status}`)
}
const blob = await response.blob()
const blobUrl = window.URL.createObjectURL(blob)
startBrowserDownload(blobUrl, fileName)
window.setTimeout(() => {
window.URL.revokeObjectURL(blobUrl)
}, 1000)
} catch (error) {
startBrowserDownload(downloadUrl, fileName)
}
}
function resolvePdfDownloadUrl(filePath: string) {
const value = filePath.trim()
if (/^https?:\/\//i.test(value)) return value
if (value.startsWith('/')) return value
return `/${value}`
}
function startBrowserDownload(url: string, fileName: string) {
const link = document.createElement('a')
link.href = url
link.download = fileName
link.rel = 'noopener'
link.style.display = 'none'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
onMounted(() => {
void loadEvaluation()
})
onUnmounted(() => {
if (toastTimer) clearTimeout(toastTimer)
})
</script>
<style scoped>
page {
min-height: 100%;
background: #f9f9ff;
}
.assessment-page {
position: relative;
width: 390px;
max-width: 100vw;
min-height: 100vh;
margin: 0 auto;
overflow: hidden;
background: #f9f9ff;
color: #191c21;
font-family: Inter, -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Helvetica Neue', Arial, sans-serif;
-webkit-tap-highlight-color: transparent;
}
.assessment-page view,
.assessment-page text,
.assessment-page button,
.assessment-page scroll-view {
box-sizing: border-box;
}
.assessment-page ::-webkit-scrollbar {
width: 0;
height: 0;
background: transparent;
}
.top-app-bar {
position: fixed;
left: 50%;
top: 0;
z-index: 50;
width: 390px;
max-width: 100vw;
height: 56px;
padding: 0 16px;
border-bottom: 1px solid rgba(194, 198, 212, 0.3);
background: rgba(249, 249, 255, 0.82);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
display: flex;
align-items: center;
justify-content: center;
transform: translateX(-50%);
}
.app-title {
position: absolute;
left: 50%;
top: 50%;
margin-left: 0;
transform: translate(-50%, -50%);
color: #191c21;
font-size: 20px;
line-height: 28px;
font-weight: 700;
letter-spacing: 0;
}
.icon-button,
.read-button,
.download-button,
.next-button {
padding: 0;
border: 0;
background: transparent;
}
.icon-button::after,
.read-button::after,
.download-button::after,
.next-button::after {
border: 0;
}
.icon-button {
position: absolute;
left: 16px;
top: 8px;
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.icon-button:active {
background: rgba(225, 226, 234, 0.5);
}
.back-icon,
.hub-icon,
.analytics-icon,
.arrow-forward-icon {
background: #424752;
-webkit-mask-position: center;
-webkit-mask-repeat: no-repeat;
-webkit-mask-size: contain;
mask-position: center;
mask-repeat: no-repeat;
mask-size: contain;
}
.back-icon {
width: 24px;
height: 24px;
background: #191c21;
-webkit-mask-image: url("data:image/svg+xml,%3Csvg%20viewBox='0%200%2024%2024'%20xmlns='http://www.w3.org/2000/svg'%3E%3Cpath%20d='M20%2011H7.83l5.59-5.59L12%204l-8%208%208%208%201.42-1.41L7.83%2013H20v-2z'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg%20viewBox='0%200%2024%2024'%20xmlns='http://www.w3.org/2000/svg'%3E%3Cpath%20d='M20%2011H7.83l5.59-5.59L12%204l-8%208%208%208%201.42-1.41L7.83%2013H20v-2z'/%3E%3C/svg%3E");
}
.report-content {
width: 100%;
height: calc(100vh - 145px);
margin-top: 56px;
padding: 24px 16px 24px;
position: relative;
z-index: 0;
}
.report-head {
padding: 0 0 24px;
display: flex;
flex-direction: column;
gap: 8px;
}
.report-title {
color: #00478d;
font-size: 24px;
line-height: 32px;
font-weight: 700;
letter-spacing: 0;
}
.report-meta {
display: flex;
flex-direction: column;
gap: 2px;
color: rgba(66, 71, 82, 0.8);
font-size: 14px;
line-height: 20px;
}
.content-stack {
display: flex;
flex-direction: column;
gap: 16px;
}
.overall-card,
.report-card,
.mentor-section {
border: 1px solid rgba(194, 198, 212, 0.3);
border-radius: 8px;
background: #ffffff;
box-shadow: 0 1px 4px rgba(25, 28, 33, 0.04);
}
.overall-card {
padding: 20px;
display: flex;
align-items: center;
gap: 16px;
}
.score-ring {
position: relative;
width: 80px;
height: 80px;
flex: 0 0 auto;
}
.ring-svg {
width: 100%;
height: 100%;
transform: rotate(-90deg);
transform-origin: 50% 50%;
}
.ring-track {
stroke: #e7e8f0;
}
.ring-value {
stroke: #00478d;
transition: stroke-dashoffset 0.8s ease;
}
.score-center {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.score-value {
color: #00478d;
font-size: 20px;
line-height: 22px;
font-weight: 700;
}
.score-total {
color: #424752;
font-size: 10px;
line-height: 12px;
font-weight: 500;
}
.overall-copy {
flex: 1;
display: flex;
flex-direction: column;
gap: 6px;
}
.overall-title {
color: #191c21;
font-size: 14px;
line-height: 20px;
font-weight: 600;
}
.primary-text {
color: #00478d;
font-weight: 700;
}
.overall-desc {
color: #424752;
font-size: 13px;
line-height: 18px;
}
.report-card {
padding: 20px;
}
.section-heading {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
color: #00478d;
font-size: 14px;
line-height: 20px;
font-weight: 700;
}
.breakdown-title {
margin-bottom: 24px;
}
.hub-icon,
.analytics-icon {
width: 18px;
height: 18px;
background: #00478d;
}
.hub-icon {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg%20viewBox='0%200%2024%2024'%20xmlns='http://www.w3.org/2000/svg'%3E%3Cpath%20d='M12%202a3%203%200%200%201%202%205.24V9h3a3%203%200%201%201-2.83%204H14v3.76A3%203%200%201%201%2012%2016a2.9%202.9%200%200%201%201%20.18V13H9.83A3%203%200%201%201%2010%2011H13V7.24A3%203%200%200%201%2012%202z'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg%20viewBox='0%200%2024%2024'%20xmlns='http://www.w3.org/2000/svg'%3E%3Cpath%20d='M12%202a3%203%200%200%201%202%205.24V9h3a3%203%200%201%201-2.83%204H14v3.76A3%203%200%201%201%2012%2016a2.9%202.9%200%200%201%201%20.18V13H9.83A3%203%200%201%201%2010%2011H13V7.24A3%203%200%200%201%2012%202z'/%3E%3C/svg%3E");
}
.analytics-icon {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg%20viewBox='0%200%2024%2024'%20xmlns='http://www.w3.org/2000/svg'%3E%3Cpath%20d='M3%203h2v18H3V3zm16%207h2v11h-2V10zM8%2013h2v8H8v-8zm5-6h2v14h-2V7z'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg%20viewBox='0%200%2024%2024'%20xmlns='http://www.w3.org/2000/svg'%3E%3Cpath%20d='M3%203h2v18H3V3zm16%207h2v11h-2V10zM8%2013h2v8H8v-8zm5-6h2v14h-2V7z'/%3E%3C/svg%3E");
}
.radar-wrap {
display: flex;
justify-content: center;
padding: 8px 0;
}
.radar-svg {
width: 100%;
max-width: 240px;
aspect-ratio: 1;
}
.radar-grid {
stroke: #e2e8f0;
stroke-width: 1;
fill: none;
}
.radar-area {
fill: rgba(0, 71, 141, 0.15);
stroke: #00478d;
stroke-width: 2;
}
.radar-label {
fill: #424752;
font-size: 14px;
font-weight: 500;
letter-spacing: 0;
}
.empty-data {
width: 100%;
min-height: 80px;
padding: 18px 14px;
border: 1px dashed rgba(194, 198, 212, 0.7);
border-radius: 8px;
background: rgba(242, 243, 251, 0.55);
color: rgba(66, 71, 82, 0.78);
font-size: 13px;
line-height: 20px;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
}
.radar-empty {
min-height: 180px;
}
.breakdown-list {
display: flex;
flex-direction: column;
gap: 24px;
}
.breakdown-item {
display: flex;
flex-direction: column;
gap: 8px;
}
.breakdown-head {
display: flex;
align-items: center;
justify-content: space-between;
color: #191c21;
font-size: 12px;
line-height: 16px;
font-weight: 600;
}
.breakdown-score {
color: #00478d;
font-weight: 700;
}
.progress-track {
width: 100%;
height: 6px;
border-radius: 999px;
background: #e7e8f0;
overflow: hidden;
}
.progress-fill {
height: 100%;
border-radius: 999px;
background: #00478d;
transition: width 1s cubic-bezier(0.4, 0, 0.2, 1);
}
.analysis-box {
padding: 12px;
border: 1px solid rgba(225, 226, 234, 0.35);
border-radius: 8px;
background: rgba(242, 243, 251, 0.5);
color: #424752;
font-size: 13px;
line-height: 20px;
}
.mentor-section {
margin-bottom: 16px;
padding: 20px;
border-color: rgba(0, 71, 141, 0.1);
background: rgba(0, 94, 184, 0.06);
}
.mentor-head {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.mentor-avatar {
width: 48px;
height: 48px;
border: 1px solid #ffffff;
border-radius: 50%;
box-shadow: 0 1px 4px rgba(25, 28, 33, 0.12);
}
.mentor-title-group {
display: flex;
flex-direction: column;
gap: 2px;
}
.mentor-title {
color: #00478d;
font-size: 14px;
line-height: 20px;
font-weight: 700;
}
.mentor-subtitle {
color: rgba(66, 71, 82, 0.7);
font-size: 10px;
line-height: 14px;
font-weight: 500;
letter-spacing: 0;
}
.mentor-bubble {
position: relative;
padding: 16px;
border: 1px solid rgba(0, 71, 141, 0.05);
border-radius: 8px;
background: #ffffff;
box-shadow: 0 1px 4px rgba(25, 28, 33, 0.04);
}
.mentor-tail {
position: absolute;
left: 24px;
top: -7px;
width: 12px;
height: 12px;
border-left: 1px solid rgba(0, 71, 141, 0.05);
border-top: 1px solid rgba(0, 71, 141, 0.05);
background: #ffffff;
transform: rotate(45deg);
}
.mentor-copy {
color: #191c21;
font-size: 14px;
line-height: 22px;
font-style: italic;
}
.mentor-action-row {
margin-top: 16px;
width: 100%;
display: flex;
justify-content: flex-end;
}
.read-button {
width: fit-content;
min-height: 32px;
margin: 0 0 0 auto;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
color: #00478d;
font-size: 14px;
line-height: 20px;
font-weight: 600;
}
.read-button:active {
transform: scale(0.95);
}
.arrow-forward-icon {
width: 18px;
height: 18px;
background: #00478d;
-webkit-mask-image: url("data:image/svg+xml,%3Csvg%20viewBox='0%200%2024%2024'%20xmlns='http://www.w3.org/2000/svg'%3E%3Cpath%20d='M12%204l1.41%201.41L8.83%2010H20v2H8.83l4.58%204.59L12%2018l-7-7%207-7z'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg%20viewBox='0%200%2024%2024'%20xmlns='http://www.w3.org/2000/svg'%3E%3Cpath%20d='M12%204l1.41%201.41L8.83%2010H20v2H8.83l4.58%204.59L12%2018l-7-7%207-7z'/%3E%3C/svg%3E");
transform: rotate(180deg);
}
.footer-actions {
position: fixed;
left: 50%;
bottom: 0;
z-index: 80;
width: 390px;
max-width: 100vw;
padding: 16px;
border-top: 1px solid rgba(194, 198, 212, 0.3);
background: rgba(249, 249, 255, 0.9);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
display: flex;
gap: 12px;
transform: translateX(-50%);
}
.download-button,
.next-button {
flex: 1;
min-height: 48px;
padding: 0 10px;
border-radius: 8px;
font-size: 14px;
line-height: 20px;
font-weight: 600;
text-align: center;
display: flex;
align-items: center;
justify-content: center;
}
.download-button {
border: 1px solid #00478d;
background: transparent;
color: #00478d;
}
.download-button.disabled {
border-color: rgba(66, 71, 82, 0.24);
color: rgba(66, 71, 82, 0.45);
}
.next-button {
background: #00478d;
box-shadow: 0 2px 8px rgba(0, 71, 141, 0.2);
color: #ffffff;
}
.download-button:active,
.next-button:active {
transform: scale(0.98);
}
.toast {
position: fixed;
left: 50%;
bottom: 92px;
z-index: 100;
max-width: 320px;
padding: 12px 20px;
border-radius: 12px;
background: #2e3037;
color: #eff0f8;
font-size: 14px;
line-height: 20px;
font-weight: 600;
text-align: center;
pointer-events: none;
opacity: 0;
transform: translate(-50%, 16px);
transition: opacity 0.3s ease, transform 0.3s ease;
}
.toast.visible {
opacity: 1;
transform: translate(-50%, 0);
}
@media (min-width: 768px) {
.assessment-page {
box-shadow: 0 24px 64px rgba(25, 28, 33, 0.18);
}
}
</style>