新增 /api/ai/asr(multipart转写)与 /api/ai/tts(mp3 base64 dataURL),走 OpenAI 兼容 audio 接口(硅基流动 SenseVoice/CosyVoice2,复用模型管理 配置,新增 asr/tts 类目);移动端对练页接语音输入(MediaRecorder)与 客户台词自动播报,未配置/失败降级文本。ASR 限 5MB、TTS 限 300 字, multipart 头部字段去 CRLF 防注入。
2461 lines
60 KiB
Vue
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>
|