Files
prop-ai-hr/mobile/src/App.vue
T
admin e0490b28cb feat(aihr): add asr/tts speech gateway and mobile voice practice
新增 /api/ai/asr(multipart转写)与 /api/ai/tts(mp3 base64 dataURL),走
OpenAI 兼容 audio 接口(硅基流动 SenseVoice/CosyVoice2,复用模型管理
配置,新增 asr/tts 类目);移动端对练页接语音输入(MediaRecorder)与
客户台词自动播报,未配置/失败降级文本。ASR 限 5MB、TTS 限 300 字,
multipart 头部字段去 CRLF 防注入。
2026-07-03 20:55:38 +08:00

2461 lines
60 KiB
Vue

<template>
<main class="mobile-aihr">
<section v-if="!authToken" class="page auth-page">
<header class="top auth-top">
<div class="top-row">
<h1>AI人力助手</h1>
<div class="avatar avatar-woman" aria-hidden="true">
<span></span>
</div>
</div>
<h2>手机号登录</h2>
<p class="place">
<Icon name="map-pin" size="22" />
星河湾一期
</p>
</header>
<form class="login-panel" @submit.prevent="loginBySms">
<label>
<span>手机号</span>
<input v-model.trim="phone" inputmode="tel" maxlength="11" placeholder="请输入手机号" autocomplete="tel" />
</label>
<label>
<span>验证码</span>
<div class="code-row">
<input v-model.trim="smsCode" inputmode="numeric" maxlength="6" placeholder="短信验证码" autocomplete="one-time-code" />
<button type="button" :disabled="sendingCode" @click="sendSmsCode">{{ sendingCode ? '发送中' : '获取验证码' }}</button>
</div>
</label>
<p v-if="authMessage" class="auth-message">{{ authMessage }}</p>
<button class="login-submit" type="submit" :disabled="loggingIn">{{ loggingIn ? '登录中' : '登录' }}</button>
</form>
</section>
<section v-else class="page">
<header class="top">
<div class="top-row">
<h1>AI人力助手</h1>
<button class="role-pill" type="button" @click="cycleRole">
{{ role.roleLabel }}
<Icon name="chevron-down" size="16" />
</button>
<div class="avatar" :class="role.avatarTone" aria-hidden="true">
<span></span>
</div>
</div>
<h2>{{ role.greeting }} <span>👋</span></h2>
<p class="place">
<Icon name="map-pin" size="22" />
{{ role.location }}
</p>
</header>
<template v-if="roleKey === 'supervisor'">
<Card title="团队今日概况">
<div class="team-card">
<div class="team-copy">
<h3>{{ supervisorRole.team.title }}</h3>
<p>{{ supervisorRole.team.progressText }}</p>
<ProgressBar :value="supervisorRole.team.progress" />
<span>{{ supervisorRole.team.completionLabel }} <b>{{ supervisorRole.team.progress }}%</b></span>
</div>
<div class="ring-box">
<div class="ring" :style="ringStyle(supervisorRole.team.progress)">
<b>{{ supervisorRole.team.progress }}%</b>
<small>{{ supervisorRole.team.completionLabel }}</small>
</div>
<button type="button" @click="tap(supervisorRole.team.detailCta)">{{ supervisorRole.team.detailCta }}</button>
</div>
<div class="mini-stats">
<MiniStat
v-for="item in supervisorRole.team.stats"
:key="item.title"
:icon="item.icon"
:title="item.title"
:value="item.value"
:tone="item.tone"
/>
</div>
</div>
</Card>
<Card title="待处理">
<div class="todo-list">
<button v-for="item in supervisorRole.todos" :key="item.title" type="button" @click="tap(item.title)">
<IconBubble :icon="item.icon" :tone="item.tone" />
<span>
<b>{{ item.title }}</b>
<small>{{ item.desc }}</small>
</span>
<em>{{ item.count }}</em>
<Icon name="chevron-right" size="24" />
</button>
</div>
<div v-if="practiceReviews.length" class="record-list">
<h3>待复盘列表</h3>
<button v-for="item in practiceReviews" :key="item.id || `${item.time}-${item.trainee}-${item.scene}`" type="button" @click="openPracticeReview(item)">
<span>
<b>{{ item.trainee }} · {{ item.score }}分</b>
<small>{{ item.time }} · {{ item.scene }}</small>
</span>
<em>{{ item.status }}</em>
</button>
</div>
<em v-if="reviewMessage && !selectedReview" class="review-message">{{ reviewMessage }}</em>
<div v-if="selectedReview" class="review-detail">
<div class="review-detail-head">
<span>
<b>{{ selectedReview.trainee }} · {{ selectedReview.score }}分</b>
<small>{{ selectedReview.time }} · {{ selectedReview.scene }}</small>
</span>
<em>{{ selectedReview.status }}</em>
</div>
<div class="review-score-grid">
<p v-for="item in selectedReview.scoreItems" :key="item.label">
<span>{{ item.label }}</span>
<b>{{ item.value }}</b>
<small>{{ item.note }}</small>
</p>
</div>
<div class="practice-dialogue review-dialogue">
<p v-for="(turn, index) in selectedReview.dialogue" :key="`${turn.role}-${index}`" :class="turn.role">
<span>{{ turn.label }}</span>
{{ turn.text }}
</p>
</div>
<div class="review-summary">
<b>导师改写</b>
<p>{{ selectedReview.mentorRewrite }}</p>
<b>AI点评</b>
<p>{{ selectedReview.aiComment }}</p>
</div>
<button type="button" :disabled="markingReviewed || selectedReview.status !== '待复盘'" @click="markReviewDone">
{{ selectedReview.status === '待复盘' ? (markingReviewed ? '处理中' : '标记已复盘') : '已复盘' }}
</button>
<em v-if="reviewMessage">{{ reviewMessage }}</em>
</div>
</Card>
<Card :title="supervisorRole.toolTitle">
<ToolGrid :items="supervisorRole.tools" />
</Card>
<button class="suggestion" type="button" @click="tap(supervisorRole.suggestion.title)">
<IconBubble :icon="supervisorRole.suggestion.icon" :tone="supervisorRole.suggestion.tone" />
<span>
<b>{{ supervisorRole.suggestion.title }}</b>
<small>{{ supervisorRole.suggestion.desc }}</small>
</span>
<Icon name="chevron-right" size="26" />
</button>
</template>
<template v-else>
<Card title="今日任务" action="查看全部" @action="tap('查看全部')">
<div class="task-card">
<div class="task-copy">
<strong>{{ workerRole.primary.badge }}</strong>
<h3>{{ workerRole.primary.title }}</h3>
<p>{{ workerRole.primary.desc }}</p>
<span>进度 {{ workerRole.primary.progressText }}</span>
<ProgressBar :value="workerRole.primary.progress" />
</div>
<div class="headset-art">
<Icon :name="workerRole.primary.icon" size="104" stroke="1.7" />
</div>
<button type="button" @click="handlePrimaryAction">{{ workerRole.primary.cta }}</button>
</div>
<button class="review-card" type="button" @click="tap(workerRole.review.title)">
<IconBubble icon="document" tone="amber" />
<span>
<b>{{ workerRole.review.title }}</b>
<small>{{ workerRole.review.desc }}</small>
</span>
<Icon name="chevron-right" size="26" />
</button>
<div v-if="roleKey === 'user' && practiceStatus !== 'idle'" class="practice-panel">
<div class="practice-panel-head">
<span>三角色对练</span>
<b v-if="practiceResult">{{ practiceResult.totalScore }}分</b>
<small v-else>{{ practiceStatus === 'starting' ? '准备中' : '进行中' }}</small>
</div>
<div v-if="practiceResult" class="practice-result">
<strong>{{ practiceResult.aiComment }}</strong>
<p>{{ practiceResult.mentorRewrite }}</p>
<div class="practice-result-actions">
<button type="button" @click="startMobilePractice">再练一次</button>
<button type="button" @click="goSupervisor">查看主管端</button>
</div>
</div>
<div v-else class="practice-live">
<div class="practice-dialogue">
<p v-for="(turn, index) in practiceTurns" :key="`${turn.role}-${index}`" :class="turn.role">
<span>{{ turn.label }}</span>
{{ turn.text }}
</p>
</div>
<textarea v-model.trim="practiceDraft" rows="3" placeholder="输入你的回复" />
<div class="practice-actions">
<button type="button" :disabled="asrBusy" @click="toggleRecording">
{{ recording ? '停止录音' : asrBusy ? '识别中' : '语音输入' }}
</button>
<button type="button" @click="toggleVoice">{{ voiceEnabled ? '播报:开' : '播报:关' }}</button>
<button type="button" @click="fillPracticeReply">填入建议回复</button>
<button type="button" :disabled="practiceStatus === 'starting' || practiceStatus === 'submitting'" @click="submitMobilePractice">
{{ practiceStatus === 'submitting' ? '提交中' : '提交本轮' }}
</button>
</div>
</div>
<em v-if="practiceMessage">{{ practiceMessage }}</em>
</div>
</Card>
<Card :title="workerRole.toolTitle">
<ToolGrid :items="workerRole.tools" />
</Card>
<Card title="我的进度" :action="workerRole.recommend" compact-action @action="tap(workerRole.recommend)">
<div class="progress-grid">
<ProgressTile v-for="item in workerRole.metrics" :key="item.title" :item="item" />
</div>
<div v-if="competencyProfile" class="profile-card">
<span>
<small>综合能力</small>
<b>{{ competencyProfile.score }}</b>
</span>
<span>
<small>训练历史</small>
<b>{{ competencyProfile.completed }}</b>
</span>
<span>
<small>待复盘</small>
<b>{{ competencyProfile.pendingReview }}</b>
</span>
<div class="profile-dims">
<p v-for="item in competencyProfile.dimensions" :key="item.label">
<span>{{ item.label }}</span>
<b>{{ item.value }}</b>
<small>{{ item.note }}</small>
</p>
</div>
</div>
<div v-if="practiceHistory.length" class="record-list">
<h3>训练历史</h3>
<button v-for="item in practiceHistory" :key="`${item.time}-${item.scene}`" type="button" @click="tap(item.scene)">
<span>
<b>{{ item.scene }} · {{ item.score }}分</b>
<small>{{ item.time }} · {{ item.summary }}</small>
</span>
<em>{{ item.status }}</em>
</button>
</div>
</Card>
</template>
<nav class="tabbar" aria-label="底部导航">
<button v-for="tab in role.tabs" :key="tab.label" type="button" :class="{ active: tab.active }" @click="tap(tab.label)">
<Icon :name="tab.icon" size="28" />
<span>{{ tab.label }}</span>
</button>
</nav>
</section>
</main>
</template>
<script setup lang="ts">
import type { PropType } from 'vue';
import { computed, defineComponent, h, ref, watch } from 'vue';
type RoleKey = 'user' | 'candidate' | 'supervisor';
type Tone = 'teal' | 'blue' | 'orange' | 'purple' | 'amber';
type IconName =
| 'bars'
| 'book'
| 'book-search'
| 'briefcase'
| 'bulb'
| 'chevron-down'
| 'chevron-right'
| 'clipboard'
| 'document'
| 'folder'
| 'graduation'
| 'headset'
| 'home'
| 'map-pin'
| 'megaphone'
| 'mic'
| 'pen'
| 'presentation'
| 'target'
| 'upload'
| 'user'
| 'users'
| 'warning';
interface ToolItem {
icon: IconName;
title: string;
desc: string;
tone: Tone;
}
interface MetricItem {
icon: IconName;
title: string;
value: string;
desc: string;
tone: Tone;
progress: number;
}
interface PrimaryTask {
badge: string;
title: string;
desc: string;
progressText: string;
progress: number;
cta: string;
icon: IconName;
}
interface ReviewTask {
title: string;
desc: string;
}
interface TabItem {
label: string;
icon: IconName;
active: boolean;
}
interface TeamStat {
icon: IconName;
title: string;
value: string;
tone: Tone;
}
interface TeamOverview {
title: string;
progressText: string;
progress: number;
completionLabel: string;
detailCta: string;
stats: TeamStat[];
}
interface TodoItem extends ToolItem {
count: number;
}
interface SuggestionItem {
icon: IconName;
title: string;
desc: string;
tone: Tone;
}
interface RoleHome {
role: RoleKey;
roleLabel: string;
avatarTone: string;
greeting: string;
location: string;
toolTitle?: string;
recommend?: string;
primary?: PrimaryTask;
review?: ReviewTask;
tools: ToolItem[];
metrics?: MetricItem[];
tabs: TabItem[];
team?: TeamOverview;
todos?: TodoItem[];
suggestion?: SuggestionItem;
}
type PracticeStatus = 'idle' | 'starting' | 'active' | 'submitting' | 'finished';
type PracticeRole = 'customer' | 'trainee' | 'coach';
interface PracticeTurn {
role: PracticeRole;
label: string;
text: string;
}
interface PracticeStartResponse {
sessionId: string;
customerText: string;
}
interface PracticeTurnResponse {
customerText: string;
coachHint: string;
roundIndex: number;
finished: boolean;
}
interface PracticeFinishResponse {
totalScore: number;
mentorRewrite: string;
aiComment: string;
}
interface PracticeRecord {
id?: number;
sessionId?: string;
time: string;
trainee: string;
scene: string;
score: number;
status: string;
summary: string;
}
interface PracticeDialogue {
role: PracticeRole;
label: string;
text: string;
}
interface PracticeReviewDetail extends PracticeRecord {
mentorRewrite: string;
aiComment: string;
scoreItems: CompetencyDimension[];
dialogue: PracticeDialogue[];
}
interface CompetencyDimension {
label: string;
value: number | string;
note: string;
}
interface CompetencyProfile {
score: number;
completed: number;
pendingReview: number;
dimensions: CompetencyDimension[];
}
const aliases: Record<string, RoleKey> = {
h5: 'user',
user: 'user',
employee: 'user',
candidate: 'candidate',
supervisor: 'supervisor',
admin: 'supervisor',
manager: 'supervisor'
};
const rolePaths: Record<RoleKey, string> = {
user: '/h5/user',
candidate: '/h5/candidate',
supervisor: '/h5/supervisor'
};
const readRole = (): RoleKey => {
const last = window.location.pathname.split('/').filter(Boolean).at(-1) || 'user';
return aliases[last] || 'user';
};
const clampPercent = (value: number) => `${Math.max(0, Math.min(value, 100))}%`;
const tokenKey = 'aihr_mobile_access_token';
const clientIdKey = 'aihr_mobile_client_id';
const phoneKey = 'aihr_mobile_phone';
const phonePattern = /^1[3-9]\d{9}$/;
const roleKey = ref<RoleKey>(readRole());
const tapped = ref('');
const phone = ref(localStorage.getItem(phoneKey) || '');
const smsCode = ref('');
const authToken = ref(localStorage.getItem(tokenKey) || '');
const clientId = ref(localStorage.getItem(clientIdKey) || '');
const authMessage = ref('');
const sendingCode = ref(false);
const loggingIn = ref(false);
const practiceStatus = ref<PracticeStatus>('idle');
const practiceSessionId = ref('');
const practiceRoundIndex = ref(0);
const practiceDraft = ref('');
const practiceTurns = ref<PracticeTurn[]>([]);
const practiceResult = ref<PracticeFinishResponse | null>(null);
const practiceMessage = ref('');
const practiceHistory = ref<PracticeRecord[]>([]);
const practiceReviews = ref<PracticeRecord[]>([]);
const selectedReview = ref<PracticeReviewDetail | null>(null);
const reviewMessage = ref('');
const markingReviewed = ref(false);
const competencyProfile = ref<CompetencyProfile | null>(null);
const voiceEnabled = ref(true);
const recording = ref(false);
const asrBusy = ref(false);
let mediaRecorder: MediaRecorder | null = null;
let recordedChunks: Blob[] = [];
let customerAudio: HTMLAudioElement | null = null;
const syncPath = () => window.history.pushState({}, '', rolePaths[roleKey.value]);
const tap = (name: string) => {
tapped.value = name;
};
const cycleRole = () => {
const order: RoleKey[] = ['user', 'candidate', 'supervisor'];
roleKey.value = order[(order.indexOf(roleKey.value) + 1) % order.length];
syncPath();
};
interface ApiPayload<T> {
code?: number;
msg?: string;
data?: T;
}
interface LoginResult {
access_token: string;
expire_in: number;
client_id: string;
}
const readApi = async <T,>(response: Response): Promise<T> => {
const payload = (await response.json()) as ApiPayload<T>;
if (!response.ok || (payload.code !== undefined && payload.code !== 200)) {
throw new Error(payload.msg || `HTTP ${response.status}`);
}
return payload.data as T;
};
const apiHeaders = () => ({
'Content-Type': 'application/json',
...authOnlyHeaders()
});
// multipart 上传不能手动设置 Content-Type,浏览器需自动带 boundary
const authOnlyHeaders = () => ({
...(authToken.value ? { Authorization: `Bearer ${authToken.value}` } : {}),
...(clientId.value ? { clientid: clientId.value } : {})
});
const playCustomerVoice = async (text: string) => {
if (!voiceEnabled.value || !text) return;
try {
const data = await readApi<{ audioUrl: string }>(
await fetch('/dev-api/api/ai/tts', {
method: 'POST',
headers: apiHeaders(),
body: JSON.stringify({ text, voice: '' })
})
);
customerAudio?.pause();
customerAudio = new Audio(data.audioUrl);
void customerAudio.play();
} catch {
// TTS 未配置或失败时静默降级为纯文本
}
};
const toggleVoice = () => {
voiceEnabled.value = !voiceEnabled.value;
if (!voiceEnabled.value) customerAudio?.pause();
};
const toggleRecording = async () => {
if (recording.value) {
mediaRecorder?.stop();
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
recordedChunks = [];
mediaRecorder = new MediaRecorder(stream);
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) recordedChunks.push(event.data);
};
mediaRecorder.onstop = () => {
stream.getTracks().forEach((track) => track.stop());
recording.value = false;
void transcribeRecording(new Blob(recordedChunks, { type: mediaRecorder?.mimeType || 'audio/webm' }));
};
mediaRecorder.start();
recording.value = true;
practiceMessage.value = '';
} catch {
practiceMessage.value = '无法访问麦克风,请改用文字输入';
}
};
const transcribeRecording = async (blob: Blob) => {
asrBusy.value = true;
try {
const form = new FormData();
form.append('file', blob, 'practice.webm');
const data = await readApi<{ text: string }>(
await fetch('/dev-api/api/ai/asr', { method: 'POST', headers: authOnlyHeaders(), body: form })
);
practiceDraft.value = data.text;
} catch (error) {
practiceMessage.value = error instanceof Error ? error.message : '语音识别失败,请改用文字输入';
} finally {
asrBusy.value = false;
}
};
const handlePrimaryAction = () => {
if (roleKey.value !== 'user') {
tap(workerRole.value.primary.cta);
return;
}
void startMobilePractice();
};
const appendPracticeTurn = (role: PracticeRole, text: string) => {
const labels: Record<PracticeRole, string> = {
customer: 'AI业主',
trainee: '我的回复',
coach: 'AI教练'
};
practiceTurns.value.push({ role, label: labels[role], text });
if (role === 'customer') {
void playCustomerVoice(text);
}
};
const fillPracticeReply = () => {
const replies = [
'您先别着急,我马上帮您确认责任人和到场时间,10分钟内给您第一次反馈。',
'如果今天无法处理完,我会同步主管给您替代方案,并约定下一次反馈时间。'
];
practiceDraft.value = replies[Math.min(practiceRoundIndex.value, replies.length - 1)];
};
const startMobilePractice = async () => {
practiceStatus.value = 'starting';
practiceMessage.value = '';
practiceResult.value = null;
practiceDraft.value = '';
practiceTurns.value = [];
practiceRoundIndex.value = 0;
try {
const data = await readApi<PracticeStartResponse>(
await fetch('/dev-api/api/train/practice/start', {
method: 'POST',
headers: apiHeaders(),
body: JSON.stringify({ extPartyId: phone.value || 'mobile-user', scenarioId: 'complaint-water', mode: 'mobile' })
})
);
practiceSessionId.value = data.sessionId;
appendPracticeTurn('customer', data.customerText);
practiceStatus.value = 'active';
} catch (error) {
practiceStatus.value = 'idle';
practiceMessage.value = error instanceof Error ? error.message : '训练启动失败';
}
};
const finishMobilePractice = async () => {
const data = await readApi<PracticeFinishResponse>(
await fetch('/dev-api/api/train/practice/finish', {
method: 'POST',
headers: apiHeaders(),
body: JSON.stringify({ sessionId: practiceSessionId.value })
})
);
practiceResult.value = data;
practiceStatus.value = 'finished';
practiceMessage.value = '训练完成,主管端待复盘已更新';
void fetchMobileHome();
void fetchPracticeData();
};
const submitMobilePractice = async () => {
if (!practiceDraft.value) {
practiceMessage.value = '请先输入回复';
return;
}
const traineeText = practiceDraft.value;
practiceDraft.value = '';
practiceMessage.value = '';
practiceStatus.value = 'submitting';
appendPracticeTurn('trainee', traineeText);
try {
const data = await readApi<PracticeTurnResponse>(
await fetch('/dev-api/api/train/practice/turn', {
method: 'POST',
headers: apiHeaders(),
body: JSON.stringify({ sessionId: practiceSessionId.value, roundIndex: practiceRoundIndex.value, traineeText })
})
);
if (data.coachHint) {
appendPracticeTurn('coach', data.coachHint);
}
if (data.finished) {
await finishMobilePractice();
return;
}
appendPracticeTurn('customer', data.customerText);
practiceRoundIndex.value = data.roundIndex;
practiceStatus.value = 'active';
} catch (error) {
practiceStatus.value = 'active';
practiceMessage.value = error instanceof Error ? error.message : '提交失败';
}
};
const goSupervisor = () => {
roleKey.value = 'supervisor';
syncPath();
void fetchMobileHome();
};
const openPracticeReview = async (item: PracticeRecord) => {
if (!item.id) {
tap(item.trainee);
return;
}
reviewMessage.value = '';
try {
selectedReview.value = await readApi<PracticeReviewDetail>(
await fetch(`/dev-api/api/aihr/mobile/practice/reviews/${item.id}`, { headers: apiHeaders() })
);
} catch (error) {
reviewMessage.value = error instanceof Error ? error.message : '复盘详情加载失败';
}
};
const markReviewDone = async () => {
if (!selectedReview.value?.id) {
return;
}
markingReviewed.value = true;
reviewMessage.value = '';
const reviewId = selectedReview.value.id;
try {
await readApi<void>(
await fetch(`/dev-api/api/aihr/mobile/practice/reviews/${reviewId}/reviewed`, {
method: 'POST',
headers: apiHeaders()
})
);
selectedReview.value = { ...selectedReview.value, status: '已复盘' };
reviewMessage.value = '已标记复盘';
await fetchPracticeData();
await fetchMobileHome();
} catch (error) {
reviewMessage.value = error instanceof Error ? error.message : '标记失败';
} finally {
markingReviewed.value = false;
}
};
const validatePhone = () => {
if (!phonePattern.test(phone.value)) {
authMessage.value = '请输入正确手机号';
return false;
}
return true;
};
const sendSmsCode = async () => {
if (!validatePhone()) {
return;
}
sendingCode.value = true;
authMessage.value = '';
try {
await readApi<void>(await fetch(`/dev-api/resource/sms/code?phonenumber=${encodeURIComponent(phone.value)}`));
authMessage.value = '验证码已发送';
} catch (error) {
authMessage.value = error instanceof Error ? error.message : '验证码发送失败';
} finally {
sendingCode.value = false;
}
};
const loginBySms = async () => {
if (!validatePhone()) {
return;
}
if (!smsCode.value) {
authMessage.value = '请输入短信验证码';
return;
}
loggingIn.value = true;
authMessage.value = '';
try {
const data = await readApi<LoginResult>(
await fetch('/dev-api/auth/mobile/sms-login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phonenumber: phone.value, smsCode: smsCode.value, tenantId: '000000' })
})
);
authToken.value = data.access_token;
clientId.value = data.client_id;
localStorage.setItem(tokenKey, data.access_token);
localStorage.setItem(clientIdKey, data.client_id);
localStorage.setItem(phoneKey, phone.value);
smsCode.value = '';
void fetchMobileHome();
void fetchPracticeData();
} catch (error) {
authMessage.value = error instanceof Error ? error.message : '登录失败';
} finally {
loggingIn.value = false;
}
};
window.addEventListener('popstate', () => {
roleKey.value = readRole();
});
const employeeTabs: TabItem[] = [
{ label: '首页', icon: 'home', active: true },
{ label: '训练', icon: 'graduation', active: false },
{ label: '知识', icon: 'book', active: false },
{ label: '我的', icon: 'user', active: false }
];
const fallbackHomes: Record<RoleKey, RoleHome> = {
user: {
role: 'user',
roleLabel: '员工端',
avatarTone: 'avatar-woman',
greeting: '早上好,王敏',
location: '星河湾一期 · 客服管家',
toolTitle: '学习与工具',
recommend: '本周推荐:催费沟通专项',
primary: {
badge: '重点训练',
title: '投诉接待三角色对练',
desc: '3分钟语音练习 · 完成后自动评分',
progressText: '1/1',
progress: 100,
cta: '开始训练',
icon: 'headset'
},
review: {
title: '岗前SOP复习',
desc: '今日需完成 · 住宅投诉处理流程'
},
tools: [
{ icon: 'mic', title: '每日一练', desc: '每日3分钟\n提升服务能力', tone: 'teal' },
{ icon: 'book-search', title: '查SOP', desc: '随时查找\n标准流程', tone: 'blue' },
{ icon: 'folder', title: '案例学习', desc: '优秀案例\n对标提升', tone: 'orange' },
{ icon: 'pen', title: '提交素材', desc: '记录工作场景\n供审核入库', tone: 'purple' }
],
metrics: [
{ icon: 'bars', title: '完成', value: '6/10', desc: '今日任务进度', tone: 'teal', progress: 60 },
{ icon: 'target', title: '能力', value: '82', desc: '综合能力分', tone: 'blue', progress: 82 },
{ icon: 'clipboard', title: '待复盘', value: '2', desc: '待复盘任务', tone: 'orange', progress: 48 }
],
tabs: [...employeeTabs]
},
candidate: {
role: 'candidate',
roleLabel: '候选人端',
avatarTone: 'avatar-candidate',
greeting: '早上好,李娜',
location: '星河湾一期 · 客服管家候选人',
toolTitle: '面试准备',
recommend: '本周推荐:投诉接待基础',
primary: {
badge: '面试任务',
title: 'AI结构化面试',
desc: '8分钟问答 · 完成后生成面试记录',
progressText: '0/1',
progress: 22,
cta: '开始面试',
icon: 'briefcase'
},
review: {
title: '岗位SOP预习',
desc: '今日需完成 · 客服接待基础流程'
},
tools: [
{ icon: 'mic', title: '面试练习', desc: '模拟问答\n熟悉岗位', tone: 'teal' },
{ icon: 'book-search', title: '查岗位SOP', desc: '快速了解\n标准流程', tone: 'blue' },
{ icon: 'folder', title: '案例学习', desc: '典型场景\n提前预习', tone: 'orange' },
{ icon: 'upload', title: '补充资料', desc: '提交证书\n与经历', tone: 'purple' }
],
metrics: [
{ icon: 'bars', title: '进度', value: '2/4', desc: '候选任务', tone: 'teal', progress: 50 },
{ icon: 'target', title: '匹配度', value: '86', desc: '岗位画像分', tone: 'blue', progress: 86 },
{ icon: 'clipboard', title: '待补充', value: '1', desc: '资料项', tone: 'orange', progress: 35 }
],
tabs: [
{ label: '首页', icon: 'home', active: true },
{ label: '面试', icon: 'briefcase', active: false },
{ label: '知识', icon: 'book', active: false },
{ label: '我的', icon: 'user', active: false }
]
},
supervisor: {
role: 'supervisor',
roleLabel: '主管端',
avatarTone: 'avatar-man',
greeting: '早上好,李主管',
location: '星河湾一期 · 项目主管',
toolTitle: '快捷操作',
tools: [
{ icon: 'presentation', title: '指派训练', desc: '为员工分配训练任务', tone: 'teal' },
{ icon: 'target', title: '团队短板', desc: '查看短板分布\n与改进建议', tone: 'blue' },
{ icon: 'upload', title: '提交案例素材', desc: '上传案例素材\n供审核与沉淀', tone: 'orange' },
{ icon: 'book-search', title: '查SOP', desc: '快速搜索制度与\n标准流程', tone: 'purple' }
],
tabs: [
{ label: '首页', icon: 'home', active: true },
{ label: '团队', icon: 'users', active: false },
{ label: '案例', icon: 'folder', active: false },
{ label: '我的', icon: 'user', active: false }
],
team: {
title: '客服团队训练进度',
progressText: '12人已完成 · 3人待跟进',
progress: 80,
completionLabel: '完训率',
detailCta: '查看详情',
stats: [
{ icon: 'users', title: '完训率', value: '80%', tone: 'teal' },
{ icon: 'bars', title: '平均能力', value: '78', tone: 'teal' },
{ icon: 'warning', title: '高频短板', value: '3', tone: 'orange' }
]
},
todos: [
{ icon: 'presentation', title: '待复盘对练', desc: '查看对练记录,辅导与点评', count: 3, tone: 'teal' },
{ icon: 'folder', title: '待提交本周案例', desc: '提交优质案例素材,供审核入库', count: 1, tone: 'orange' },
{ icon: 'clipboard', title: '待指派专项', desc: '为低分或薄弱项员工指派训练专项', count: 2, tone: 'blue' }
],
suggestion: {
icon: 'bulb',
title: '建议:给催费沟通低分员工指派专项',
desc: '基于团队短板分析,建议开展「催费沟通技巧」专项训练',
tone: 'amber'
}
}
};
const apiHome = ref<RoleHome | null>(null);
const fetchMobileHome = async () => {
const currentRole = roleKey.value;
try {
const response = await fetch(`/dev-api/api/aihr/mobile/home/${currentRole}`, {
headers:
authToken.value && clientId.value
? { Accept: 'application/json', Authorization: `Bearer ${authToken.value}`, clientid: clientId.value }
: { Accept: 'application/json' }
});
const data = await readApi<RoleHome>(response);
if (!data) {
throw new Error('Invalid mobile home payload');
}
if (roleKey.value === currentRole) {
apiHome.value = data;
}
} catch {
if (roleKey.value === currentRole) {
apiHome.value = null;
}
}
};
const fetchPracticeData = async () => {
if (!authToken.value || roleKey.value === 'candidate') {
practiceHistory.value = [];
practiceReviews.value = [];
selectedReview.value = null;
competencyProfile.value = null;
return;
}
try {
const extPartyId = encodeURIComponent(phone.value || 'mobile-user');
if (roleKey.value === 'user') {
const [history, profile] = await Promise.all([
readApi<PracticeRecord[]>(await fetch(`/dev-api/api/aihr/mobile/practice/history?extPartyId=${extPartyId}`, { headers: apiHeaders() })),
readApi<CompetencyProfile>(await fetch(`/dev-api/api/aihr/mobile/profile?extPartyId=${extPartyId}`, { headers: apiHeaders() }))
]);
practiceHistory.value = history || [];
competencyProfile.value = profile || null;
practiceReviews.value = [];
selectedReview.value = null;
return;
}
practiceReviews.value =
(await readApi<PracticeRecord[]>(await fetch('/dev-api/api/aihr/mobile/practice/reviews', { headers: apiHeaders() }))) || [];
practiceHistory.value = [];
competencyProfile.value = null;
} catch {
practiceHistory.value = [];
practiceReviews.value = [];
selectedReview.value = null;
competencyProfile.value = null;
}
};
watch(
roleKey,
() => {
apiHome.value = null;
reviewMessage.value = '';
void fetchMobileHome();
void fetchPracticeData();
},
{ immediate: true }
);
const role = computed(() => (apiHome.value?.role === roleKey.value ? apiHome.value : fallbackHomes[roleKey.value]));
const workerRole = computed(
() =>
role.value as RoleHome & {
toolTitle: string;
recommend: string;
primary: PrimaryTask;
review: ReviewTask;
metrics: MetricItem[];
}
);
const supervisorRole = computed(
() =>
role.value as RoleHome & {
toolTitle: string;
team: TeamOverview;
todos: TodoItem[];
suggestion: SuggestionItem;
}
);
const ringStyle = (value: number) => ({ '--value': value } as Record<string, string | number>);
const iconPaths: Record<IconName, Array<[string, Record<string, string | number>?]>> = {
bars: [
['rect', { x: 5, y: 14, width: 3, height: 5, rx: 1 }],
['rect', { x: 11, y: 10, width: 3, height: 9, rx: 1 }],
['rect', { x: 17, y: 5, width: 3, height: 14, rx: 1 }]
],
book: [
['path', { d: 'M4 6.5A3.5 3.5 0 0 1 7.5 3H20v16H7.5A3.5 3.5 0 0 0 4 22V6.5Z' }],
['path', { d: 'M4 19a3.5 3.5 0 0 1 3.5-3.5H20' }]
],
'book-search': [
['path', { d: 'M4 6.5A3.5 3.5 0 0 1 7.5 3H18v10' }],
['path', { d: 'M4 19a3.5 3.5 0 0 1 3.5-3.5H13' }],
['circle', { cx: 17, cy: 17, r: 3 }],
['path', { d: 'm19.5 19.5 2 2' }]
],
briefcase: [
['path', { d: 'M9 6V5a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v1' }],
['rect', { x: 4, y: 6, width: 16, height: 13, rx: 2 }],
['path', { d: 'M4 11h16M10 11v2h4v-2' }]
],
bulb: [
['path', { d: 'M9 18h6M10 22h4M8 14a6 6 0 1 1 8 0c-.8.7-1.2 1.6-1.3 2.5H9.3C9.2 15.6 8.8 14.7 8 14Z' }]
],
'chevron-down': [['path', { d: 'm6 9 6 6 6-6' }]],
'chevron-right': [['path', { d: 'm9 5 7 7-7 7' }]],
clipboard: [
['rect', { x: 6, y: 5, width: 12, height: 16, rx: 2 }],
['path', { d: 'M9 5a3 3 0 0 1 6 0M9 11h6M9 15h6' }]
],
document: [
['path', { d: 'M7 3h7l4 4v14H7V3Z' }],
['path', { d: 'M14 3v5h5M10 12h5M10 16h5' }]
],
folder: [
['path', { d: 'M3 7h7l2 2h9v9.5A2.5 2.5 0 0 1 18.5 21h-13A2.5 2.5 0 0 1 3 18.5V7Z' }],
['path', { d: 'M3 9.5h18' }]
],
graduation: [
['path', { d: 'm3 8 9-4 9 4-9 4-9-4Z' }],
['path', { d: 'M7 10.5V15c2.5 2 7.5 2 10 0v-4.5M21 8v5' }]
],
headset: [
['path', { d: 'M4 14a8 8 0 0 1 16 0' }],
['rect', { x: 3, y: 13, width: 4, height: 7, rx: 2 }],
['rect', { x: 17, y: 13, width: 4, height: 7, rx: 2 }],
['path', { d: 'M19 20c0 2-2 3-5 3h-2M13 23v-2h3' }]
],
home: [
['path', { d: 'm3 11 9-8 9 8' }],
['path', { d: 'M5 10.5V21h5v-6h4v6h5V10.5' }]
],
'map-pin': [
['path', { d: 'M12 22s7-6.1 7-12a7 7 0 0 0-14 0c0 5.9 7 12 7 12Z' }],
['circle', { cx: 12, cy: 10, r: 2.5 }]
],
megaphone: [
['path', { d: 'M4 12h4l10-5v10L8 12H4Z' }],
['path', { d: 'M8 12v6a2 2 0 0 0 2 2h1' }]
],
mic: [
['rect', { x: 9, y: 3, width: 6, height: 11, rx: 3 }],
['path', { d: 'M5 11a7 7 0 0 0 14 0M12 18v4M8 22h8' }]
],
pen: [
['path', { d: 'M4 20h4L19 9a2.8 2.8 0 0 0-4-4L4 16v4Z' }],
['path', { d: 'm13.5 6.5 4 4' }]
],
presentation: [
['rect', { x: 4, y: 4, width: 16, height: 12, rx: 2 }],
['path', { d: 'M8 21h8M12 16v5M9 11l2 2 4-5' }]
],
target: [
['circle', { cx: 12, cy: 12, r: 8 }],
['circle', { cx: 12, cy: 12, r: 3 }],
['path', { d: 'm16 8 4-4M20 4v4M20 4h-4' }]
],
upload: [
['path', { d: 'M12 16V4M7 9l5-5 5 5' }],
['path', { d: 'M5 16v4h14v-4' }]
],
user: [
['circle', { cx: 12, cy: 8, r: 4 }],
['path', { d: 'M4 21a8 8 0 0 1 16 0' }]
],
users: [
['circle', { cx: 9, cy: 8, r: 3 }],
['circle', { cx: 17, cy: 10, r: 3 }],
['path', { d: 'M3 21a6 6 0 0 1 12 0M13 21a5 5 0 0 1 8 0' }]
],
warning: [
['path', { d: 'M12 3 22 20H2L12 3Z' }],
['path', { d: 'M12 9v5M12 17h.01' }]
]
};
const Icon = defineComponent({
props: {
name: { type: String as PropType<IconName>, required: true },
size: { type: [String, Number], default: 24 },
stroke: { type: [String, Number], default: 2 }
},
setup(props) {
return () =>
h(
'svg',
{
class: 'icon',
width: props.size,
height: props.size,
viewBox: '0 0 24 24',
fill: 'none',
stroke: 'currentColor',
'stroke-width': props.stroke,
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
'aria-hidden': 'true'
},
iconPaths[props.name].map(([tag, attrs]) => h(tag, attrs || {}))
);
}
});
const Card = defineComponent({
props: {
title: { type: String, required: true },
action: { type: String, default: '' },
compactAction: { type: Boolean, default: false }
},
emits: ['action'],
setup(props, { emit, slots }) {
return () =>
h('section', { class: ['section-card', { 'has-action': props.action }] }, [
h('div', { class: ['section-title', { 'compact-action': props.compactAction }] }, [
h('h2', props.title),
props.action
? h('button', { type: 'button', onClick: () => emit('action') }, [
props.compactAction ? h(Icon, { name: 'megaphone', size: 17 }) : null,
h('span', props.action),
h(Icon, { name: 'chevron-right', size: 20 })
])
: null
]),
slots.default?.()
]);
}
});
const ProgressBar = defineComponent({
props: {
value: { type: Number, required: true }
},
setup(props) {
return () => h('div', { class: 'bar' }, h('span', { style: { width: clampPercent(props.value) } }));
}
});
const IconBubble = defineComponent({
props: {
icon: { type: String as PropType<IconName>, required: true },
tone: { type: String as PropType<Tone>, default: 'teal' }
},
setup(props) {
return () => h('span', { class: ['icon-bubble', `tone-${props.tone}`] }, h(Icon, { name: props.icon, size: 31 }));
}
});
const ToolGrid = defineComponent({
props: {
items: { type: Array as PropType<ToolItem[]>, required: true }
},
setup(props) {
return () =>
h(
'div',
{ class: 'tool-grid' },
props.items.map((item) =>
h('button', { type: 'button', class: `tone-${item.tone}`, onClick: () => tap(item.title) }, [
h(IconBubble, { icon: item.icon, tone: item.tone }),
h('span', [h('b', item.title), h('small', item.desc)]),
h(Icon, { name: 'chevron-right', size: 24 })
])
)
);
}
});
const MiniStat = defineComponent({
props: {
icon: { type: String as PropType<IconName>, required: true },
title: { type: String, required: true },
value: { type: String, required: true },
tone: { type: String as PropType<Tone>, required: true }
},
setup(props) {
return () =>
h('div', { class: 'mini-stat' }, [
h(IconBubble, { icon: props.icon, tone: props.tone }),
h('span', [h('small', props.title), h('b', props.value)])
]);
}
});
const ProgressTile = defineComponent({
props: {
item: { type: Object as PropType<MetricItem>, required: true }
},
setup(props) {
return () =>
h('div', { class: ['progress-tile', `tone-${props.item.tone}`] }, [
h(Icon, { name: props.item.icon, size: 38 }),
h('small', props.item.title),
h('b', props.item.value),
h('em', props.item.desc),
h(ProgressBar, { value: props.item.progress })
]);
}
});
</script>
<style>
:global(html),
:global(body),
:global(#app) {
min-height: 100%;
margin: 0;
}
:global(body) {
overflow-x: hidden;
}
* {
box-sizing: border-box;
}
button {
border: 0;
background: transparent;
color: inherit;
cursor: pointer;
font: inherit;
}
.mobile-aihr {
min-height: 100vh;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(247, 250, 251, 0.98)),
radial-gradient(circle at 22% 3%, rgba(21, 172, 164, 0.13), transparent 34%),
radial-gradient(circle at 88% 8%, rgba(56, 132, 255, 0.1), transparent 27%);
color: #090f1d;
display: flex;
justify-content: flex-start;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
.page {
width: min(100vw, 426px);
min-height: 100vh;
padding: 4px 12px 96px;
position: relative;
}
.top {
padding: 0 13px;
}
.top-row {
display: grid;
grid-template-columns: 1fr auto auto;
gap: 10px;
align-items: center;
}
.top h1,
.top h2,
.section-title h2,
.task-card h3,
.team-card h3,
p {
margin: 0;
}
.top h1 {
font-size: 23px;
font-weight: 850;
letter-spacing: 0;
}
.role-pill {
height: 29px;
min-width: 72px;
border: 1px solid rgba(10, 158, 153, 0.58);
border-radius: 12px;
color: #079a95;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
font-size: 14px;
font-weight: 800;
background: rgba(255, 255, 255, 0.8);
}
.avatar {
width: 45px;
height: 45px;
border-radius: 999px;
background: #eef3f7;
overflow: hidden;
position: relative;
box-shadow: 0 8px 22px rgba(18, 32, 50, 0.12);
}
.avatar::before {
content: '';
position: absolute;
left: 50%;
top: 11px;
width: 17px;
height: 17px;
border-radius: 50%;
transform: translateX(-50%);
background: #f4c7af;
box-shadow: 0 23px 0 8px #d8edf8;
}
.avatar::after {
content: '';
position: absolute;
left: 13px;
top: 7px;
width: 20px;
height: 16px;
border-radius: 50% 50% 45% 45%;
background: #1f2731;
}
.avatar-man::after {
left: 14px;
top: 6px;
width: 19px;
height: 12px;
background: #111821;
}
.avatar-man::before {
box-shadow: 0 23px 0 8px #dce9f2;
}
.avatar-candidate::before {
box-shadow: 0 23px 0 8px #d8edf8;
}
.top h2 {
margin-top: 10px;
font-size: 35px;
line-height: 1.08;
font-weight: 900;
letter-spacing: 0;
}
.top h2 span {
font-size: 25px;
}
.place {
margin-top: 9px;
color: #606d82;
display: flex;
align-items: center;
gap: 7px;
font-size: 16px;
font-weight: 650;
}
.section-card {
margin-top: 0;
padding: 17px 13px 18px;
border: 1px solid rgba(229, 235, 240, 0.9);
border-radius: 17px;
background: rgba(255, 255, 255, 0.94);
box-shadow: 0 10px 26px rgba(18, 31, 49, 0.075);
}
.section-card.has-action {
position: relative;
}
.section-card.has-action > .section-title {
padding-right: 92px;
}
.section-title {
min-height: 28px;
margin-bottom: 15px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.section-title h2 {
position: relative;
padding-left: 14px;
font-size: 19px;
line-height: 1.2;
font-weight: 900;
}
.section-title h2::before {
content: '';
position: absolute;
left: 0;
top: 2px;
width: 4px;
height: 19px;
border-radius: 999px;
background: #16aaa3;
}
.section-title button {
position: absolute;
right: 13px;
top: 17px;
color: #687487;
display: inline-flex;
align-items: center;
gap: 2px;
white-space: nowrap;
font-size: 13px;
font-weight: 650;
}
.section-title.compact-action button {
right: 13px;
top: 18px;
height: 27px;
border-radius: 999px;
padding: 0 10px;
color: #079a95;
background: rgba(15, 174, 166, 0.1);
}
.task-card {
min-height: 164px;
padding: 16px 15px;
border: 1px solid rgba(20, 177, 169, 0.25);
border-radius: 14px;
background: linear-gradient(135deg, #f1ffff 0%, #fbffff 54%, #edf8f7 100%);
display: grid;
grid-template-columns: 1fr 120px;
gap: 6px 10px;
position: relative;
overflow: hidden;
}
.task-copy {
min-width: 0;
position: relative;
z-index: 1;
}
.task-copy strong {
display: inline-flex;
min-height: 27px;
align-items: center;
border-radius: 7px;
padding: 0 10px;
color: #068e88;
background: rgba(14, 172, 164, 0.12);
font-size: 14px;
font-weight: 900;
}
.task-copy h3 {
margin-top: 34px;
font-size: 27px;
line-height: 1.12;
font-weight: 900;
}
.task-copy p {
margin-top: 8px;
color: #4f5e76;
font-size: 15px;
font-weight: 650;
}
.task-copy span {
display: block;
margin-top: 24px;
color: #34435b;
font-size: 15px;
font-weight: 700;
}
.headset-art {
height: 115px;
color: #079c95;
display: grid;
place-items: center;
position: relative;
z-index: 0;
}
.headset-art::before,
.headset-art::after {
content: '';
position: absolute;
border-radius: 18px;
background: rgba(15, 174, 166, 0.08);
}
.headset-art::before {
width: 88px;
height: 73px;
right: 3px;
bottom: 12px;
}
.headset-art::after {
width: 28px;
height: 28px;
left: 1px;
top: 36px;
}
.task-card > button {
position: absolute;
right: 15px;
bottom: 15px;
min-width: 116px;
height: 48px;
border-radius: 10px;
color: #fff;
background: linear-gradient(135deg, #1cbdb4, #07948e);
box-shadow: 0 8px 19px rgba(9, 151, 144, 0.26);
font-size: 18px;
font-weight: 900;
}
.auth-page {
padding-top: 22px;
}
.auth-top {
margin-bottom: 18px;
}
.login-panel {
margin: 0 13px;
padding: 18px 14px;
border: 1px solid rgba(229, 235, 240, 0.9);
border-radius: 17px;
background: rgba(255, 255, 255, 0.95);
box-shadow: 0 10px 26px rgba(18, 31, 49, 0.075);
display: grid;
gap: 14px;
}
.login-panel label {
display: grid;
gap: 7px;
color: #59677d;
font-size: 14px;
font-weight: 750;
}
.login-panel input {
width: 100%;
height: 46px;
border: 1px solid rgba(20, 177, 169, 0.2);
border-radius: 11px;
padding: 0 12px;
color: #111827;
background: #fbffff;
font: inherit;
font-weight: 750;
outline: none;
}
.login-panel input:focus {
border-color: rgba(10, 158, 153, 0.6);
}
.code-row {
display: grid;
grid-template-columns: 1fr 112px;
gap: 9px;
}
.code-row button,
.login-submit {
height: 46px;
border-radius: 11px;
color: #fff;
background: linear-gradient(135deg, #1cbdb4, #07948e);
box-shadow: 0 8px 19px rgba(9, 151, 144, 0.2);
font-weight: 900;
}
.code-row button:disabled,
.login-submit:disabled {
opacity: 0.55;
}
.auth-message {
min-height: 18px;
color: #ef6a2e;
font-size: 13px;
font-weight: 750;
}
.bar {
height: 7px;
margin-top: 8px;
border-radius: 999px;
background: rgba(20, 177, 169, 0.18);
overflow: hidden;
}
.bar span {
display: block;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #19b8b0, #0a9a94);
}
.review-card,
.suggestion {
width: 100%;
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 13px;
text-align: left;
}
.review-card {
min-height: 81px;
margin-top: 13px;
padding: 13px;
border: 1px solid rgba(238, 183, 93, 0.42);
border-radius: 13px;
background: linear-gradient(135deg, rgba(255, 250, 241, 0.96), #fff);
}
.review-card b,
.suggestion b,
.todo-list b,
.tool-grid b {
display: block;
color: #101826;
font-size: 17px;
line-height: 1.2;
font-weight: 900;
}
.review-card small,
.suggestion small,
.todo-list small,
.tool-grid small {
display: block;
margin-top: 5px;
color: #607086;
font-size: 14px;
line-height: 1.35;
white-space: pre-line;
font-weight: 600;
}
.practice-panel {
margin-top: 13px;
padding: 13px;
border: 1px solid rgba(20, 177, 169, 0.25);
border-radius: 13px;
background: #f8ffff;
display: grid;
gap: 11px;
}
.practice-panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.practice-panel-head span,
.practice-panel-head b {
color: #0e1726;
font-size: 17px;
font-weight: 900;
}
.practice-panel-head small {
color: #0a9a94;
font-size: 13px;
font-weight: 800;
}
.practice-dialogue {
display: grid;
gap: 8px;
}
.practice-dialogue p {
padding: 9px 10px;
border-radius: 10px;
color: #1f2937;
background: #fff;
border: 1px solid rgba(213, 225, 233, 0.85);
font-size: 14px;
line-height: 1.4;
font-weight: 650;
}
.practice-dialogue p.trainee {
border-color: rgba(20, 177, 169, 0.28);
background: #effffd;
}
.practice-dialogue p.coach {
border-color: rgba(87, 154, 255, 0.22);
background: #f3f8ff;
}
.practice-dialogue span {
display: block;
margin-bottom: 4px;
color: #0a9a94;
font-size: 12px;
font-weight: 900;
}
.practice-live {
display: grid;
gap: 10px;
}
.practice-live textarea {
width: 100%;
resize: vertical;
min-height: 76px;
border: 1px solid rgba(20, 177, 169, 0.25);
border-radius: 11px;
padding: 10px;
color: #111827;
background: #fff;
font: inherit;
font-size: 14px;
font-weight: 650;
outline: none;
}
.practice-actions,
.practice-result-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 9px;
}
.practice-actions button,
.practice-result-actions button {
min-height: 40px;
border-radius: 10px;
color: #079a95;
background: rgba(15, 174, 166, 0.1);
font-size: 14px;
font-weight: 900;
}
.practice-actions button:last-child,
.practice-result-actions button:first-child {
color: #fff;
background: linear-gradient(135deg, #1cbdb4, #07948e);
box-shadow: 0 7px 15px rgba(9, 151, 144, 0.18);
}
.practice-actions button:disabled {
opacity: 0.55;
}
.practice-result {
display: grid;
gap: 9px;
}
.practice-result strong {
color: #0e1726;
font-size: 15px;
line-height: 1.35;
font-weight: 900;
}
.practice-result p {
color: #506078;
font-size: 14px;
line-height: 1.42;
font-weight: 650;
}
.practice-panel > em {
color: #0a9a94;
font-size: 13px;
font-style: normal;
font-weight: 800;
}
.tool-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.tool-grid button {
min-height: 88px;
border: 1px solid;
border-radius: 12px;
padding: 13px 24px 13px 9px;
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 8px;
position: relative;
text-align: left;
}
.tool-grid button > .icon {
position: absolute;
right: 7px;
top: 50%;
width: 18px;
height: 18px;
transform: translateY(-50%);
color: #9aa6b2;
}
.icon-bubble {
width: 52px;
height: 52px;
border-radius: 999px;
display: grid;
place-items: center;
flex: none;
}
.tone-teal {
color: #119f98;
border-color: rgba(24, 176, 168, 0.25);
background: linear-gradient(135deg, #f1fffe, #fbffff);
}
.tone-blue {
color: #2c7ded;
border-color: rgba(87, 154, 255, 0.25);
background: linear-gradient(135deg, #f3f8ff, #fbfdff);
}
.tone-orange {
color: #f07b28;
border-color: rgba(244, 164, 80, 0.28);
background: linear-gradient(135deg, #fff8ef, #fffdfb);
}
.tone-purple {
color: #7367df;
border-color: rgba(144, 131, 245, 0.25);
background: linear-gradient(135deg, #f7f5ff, #fff);
}
.tone-amber {
color: #edaa14;
border-color: rgba(238, 179, 54, 0.32);
background: linear-gradient(135deg, #fff9e9, #fff);
}
.progress-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.progress-tile {
min-height: 123px;
border: 1px solid;
border-radius: 12px;
padding: 14px 10px 12px;
}
.progress-tile small,
.progress-tile em {
display: block;
color: #657286;
font-style: normal;
font-weight: 650;
}
.progress-tile small {
margin-top: 5px;
font-size: 14px;
}
.progress-tile b {
display: block;
color: #09111f;
font-size: 31px;
line-height: 1.05;
font-weight: 900;
}
.progress-tile em {
margin-top: 8px;
font-size: 13px;
line-height: 1.2;
}
.progress-tile.tone-blue .bar span {
background: linear-gradient(90deg, #2f85ff, #1670f8);
}
.progress-tile.tone-orange .bar span {
background: linear-gradient(90deg, #ff8a36, #f27a24);
}
.profile-card {
margin-top: 12px;
padding: 12px;
border: 1px solid rgba(20, 177, 169, 0.22);
border-radius: 12px;
background: #f8ffff;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 9px;
}
.profile-card > span {
min-height: 58px;
border-radius: 10px;
background: #fff;
display: grid;
place-items: center;
}
.profile-card small {
color: #657286;
font-size: 12px;
font-weight: 700;
}
.profile-card b {
color: #09111f;
font-size: 24px;
line-height: 1;
font-weight: 900;
}
.profile-dims {
grid-column: 1 / -1;
display: grid;
gap: 8px;
}
.profile-dims p {
min-height: 38px;
padding: 8px 9px;
border-radius: 10px;
background: #fff;
display: grid;
grid-template-columns: 46px 36px 1fr;
align-items: center;
gap: 8px;
}
.profile-dims span {
color: #0a9a94;
font-size: 13px;
font-weight: 900;
}
.profile-dims b {
font-size: 19px;
}
.record-list {
margin-top: 12px;
display: grid;
gap: 8px;
}
.record-list h3 {
margin: 0;
color: #101826;
font-size: 17px;
font-weight: 900;
}
.record-list button {
min-height: 58px;
padding: 10px 11px;
border: 1px solid rgba(213, 225, 233, 0.9);
border-radius: 11px;
background: #fff;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 9px;
align-items: center;
text-align: left;
}
.record-list b,
.record-list small {
display: block;
}
.record-list b {
color: #101826;
font-size: 15px;
line-height: 1.25;
font-weight: 900;
}
.record-list small {
margin-top: 4px;
color: #607086;
font-size: 12px;
line-height: 1.35;
font-weight: 650;
}
.record-list em {
min-width: 48px;
height: 26px;
padding: 0 8px;
border-radius: 999px;
color: #079a95;
background: rgba(15, 174, 166, 0.1);
display: grid;
place-items: center;
font-size: 12px;
font-style: normal;
font-weight: 900;
}
.review-message,
.review-detail > em {
color: #0a9a94;
font-size: 13px;
font-style: normal;
font-weight: 800;
}
.review-detail {
margin-top: 12px;
padding: 12px;
border: 1px solid rgba(20, 177, 169, 0.24);
border-radius: 12px;
background: #f8ffff;
display: grid;
gap: 10px;
}
.review-detail-head {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
}
.review-detail-head b,
.review-summary b {
display: block;
color: #101826;
font-size: 15px;
font-weight: 900;
}
.review-detail-head small,
.review-summary p {
margin-top: 4px;
color: #607086;
font-size: 13px;
line-height: 1.4;
font-weight: 650;
}
.review-detail-head em {
min-width: 52px;
height: 26px;
padding: 0 8px;
border-radius: 999px;
color: #079a95;
background: rgba(15, 174, 166, 0.1);
display: grid;
place-items: center;
font-size: 12px;
font-style: normal;
font-weight: 900;
}
.review-score-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.review-score-grid p {
min-height: 58px;
border-radius: 10px;
background: #fff;
display: grid;
place-items: center;
}
.review-score-grid span,
.review-score-grid small {
color: #657286;
font-size: 12px;
font-weight: 700;
}
.review-score-grid b {
color: #09111f;
font-size: 22px;
line-height: 1;
font-weight: 900;
}
.review-dialogue {
max-height: 250px;
overflow: auto;
}
.review-detail > button {
min-height: 42px;
border-radius: 10px;
color: #fff;
background: linear-gradient(135deg, #1cbdb4, #07948e);
box-shadow: 0 7px 15px rgba(9, 151, 144, 0.18);
font-size: 14px;
font-weight: 900;
}
.review-detail > button:disabled {
color: #079a95;
background: rgba(15, 174, 166, 0.1);
box-shadow: none;
}
.team-card {
border: 1px solid rgba(20, 177, 169, 0.26);
border-radius: 14px;
padding: 20px 14px 12px;
background: linear-gradient(135deg, #f2fffe, #fbffff);
display: grid;
grid-template-columns: 1fr 154px;
gap: 12px;
}
.team-copy h3 {
font-size: 22px;
line-height: 1.15;
font-weight: 900;
}
.team-copy p {
margin-top: 14px;
color: #13223a;
font-size: 16px;
font-weight: 650;
}
.team-copy .bar {
margin-top: 33px;
}
.team-copy > span {
display: block;
margin-top: 13px;
color: #37465e;
font-size: 15px;
font-weight: 650;
}
.team-copy > span b {
color: #13223a;
font-size: 17px;
}
.ring-box {
display: grid;
justify-items: center;
align-content: start;
gap: 13px;
}
.ring {
width: 106px;
height: 106px;
border-radius: 50%;
background: conic-gradient(#12a7a0 calc(var(--value) * 1%), #d7e0e2 0);
display: grid;
place-items: center;
position: relative;
}
.ring::before {
content: '';
position: absolute;
inset: 16px;
border-radius: inherit;
background: #fff;
}
.ring b,
.ring small {
position: relative;
z-index: 1;
}
.ring b {
margin-top: 8px;
color: #13223a;
font-size: 27px;
line-height: 1;
font-weight: 900;
}
.ring small {
margin-top: -32px;
color: #4c5d73;
font-size: 13px;
font-weight: 650;
}
.ring-box button {
min-width: 105px;
height: 38px;
border-radius: 9px;
color: #fff;
background: linear-gradient(135deg, #1bbab1, #078f89);
box-shadow: 0 7px 16px rgba(10, 151, 144, 0.22);
font-size: 15px;
font-weight: 900;
}
.mini-stats {
grid-column: 1 / -1;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 9px;
}
.mini-stat {
min-height: 57px;
border: 1px solid rgba(213, 225, 233, 0.85);
border-radius: 11px;
background: rgba(255, 255, 255, 0.78);
display: grid;
grid-template-columns: auto 1fr;
align-items: center;
gap: 9px;
padding: 8px;
}
.mini-stat .icon-bubble {
width: 38px;
height: 38px;
}
.mini-stat small {
display: block;
color: #64748b;
font-size: 13px;
line-height: 1.1;
font-weight: 650;
}
.mini-stat b {
display: block;
color: #09111f;
font-size: 23px;
line-height: 1.05;
font-weight: 900;
}
.todo-list {
border: 1px solid #dfe8ef;
border-radius: 13px;
overflow: hidden;
}
.todo-list button {
width: 100%;
min-height: 70px;
padding: 11px 12px;
display: grid;
grid-template-columns: auto 1fr auto auto;
align-items: center;
gap: 12px;
background: #fff;
text-align: left;
}
.todo-list button + button {
border-top: 1px solid #dfe8ef;
}
.todo-list .icon-bubble {
width: 46px;
height: 46px;
}
.todo-list em {
width: 30px;
height: 30px;
border-radius: 999px;
color: #fff;
background: linear-gradient(135deg, #ff6363, #ef3535);
display: grid;
place-items: center;
font-size: 17px;
font-style: normal;
font-weight: 900;
}
.suggestion {
margin-top: 12px;
min-height: 62px;
padding: 11px 13px;
border: 1px solid rgba(239, 181, 54, 0.5);
border-radius: 14px;
background: linear-gradient(135deg, #fffaf0, #fffdf7);
}
.suggestion .icon-bubble {
width: 46px;
height: 46px;
}
.tabbar {
position: fixed;
left: 50%;
bottom: 0;
z-index: 5;
width: min(100vw, 426px);
transform: translateX(-50%);
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 2px;
padding: 7px 12px calc(7px + env(safe-area-inset-bottom));
border-top: 1px solid rgba(220, 228, 235, 0.9);
background: rgba(255, 255, 255, 0.97);
box-shadow: 0 -5px 20px rgba(18, 31, 49, 0.04);
}
.tabbar button {
min-height: 47px;
color: #5d6674;
display: grid;
justify-items: center;
align-content: center;
gap: 2px;
}
.tabbar button.active {
color: #10a8a1;
}
.tabbar span {
font-size: 13px;
line-height: 1;
font-weight: 750;
}
.icon {
display: block;
}
@media (max-width: 380px) {
.page {
padding-inline: 9px;
}
.top h2 {
font-size: 31px;
}
.task-card,
.team-card {
grid-template-columns: 1fr;
}
.headset-art {
display: none;
}
.task-card > button {
position: static;
margin-top: 8px;
}
.mini-stats,
.progress-grid,
.tool-grid {
grid-template-columns: 1fr;
}
}
@media (min-width: 640px) {
.mobile-aihr {
justify-content: center;
}
}
</style>