feat(aihr): complete uni-app role workflows

This commit is contained in:
2026-07-09 10:42:20 +08:00
parent e4070c8b8c
commit 678543e8a5
73 changed files with 15696 additions and 134 deletions
+346
View File
@@ -0,0 +1,346 @@
<template>
<view class="page cases-page">
<text class="page-title">案例素材</text>
<text class="page-subtitle">把一线语音整理成可复用培训案例。上传后依次完成 ASR、AI 整理和入库。</text>
<view v-if="message" class="status-message">{{ message }}</view>
<view v-if="!loggedIn" class="panel login-panel">
<view>
<text class="panel-title">需要登录</text>
<text class="muted block">登录后才能提交案例素材。</text>
</view>
<button class="primary-button login-button" @click="goLogin">去登录</button>
</view>
<view v-else class="panel">
<text class="panel-title">提交素材</text>
<view class="file-box">
<text class="file-title">{{ selectedFile?.name || '未选择音频' }}</text>
<text class="file-desc">{{ selectedFile ? formatSize(selectedFile.size) : '支持 mp3 / wav / m4a / webm 等音频' }}</text>
</view>
<view class="inline-actions case-actions">
<button class="secondary-button" :disabled="busy" @click="selectAudio">选择音频</button>
<button class="primary-button" :disabled="busy || !selectedFile" @click="submitCase">
{{ busy ? statusText : '上传并入库' }}
</button>
</view>
</view>
<view v-if="uploadResult" class="panel">
<text class="panel-title">ASR 转写</text>
<view class="tag-row">
<text v-for="tag in uploadResult.tags" :key="tag.text" class="case-tag">{{ tag.text }}</text>
</view>
<text class="case-content">{{ uploadResult.transcript }}</text>
</view>
<view v-if="organizeResult" class="panel">
<text class="panel-title">结构化案例稿</text>
<view v-for="item in organizeResult.summary" :key="item.label" class="summary-row">
<text class="summary-label">{{ item.label }}</text>
<text class="summary-text">{{ item.text }}</text>
</view>
<text class="case-note">{{ organizeResult.aiSummary }}</text>
</view>
<view v-if="curateResult?.records?.length" class="panel">
<text class="panel-title">入库结果</text>
<view v-for="record in curateResult.records" :key="record.title" class="record-row">
<view>
<text class="record-title">{{ record.title }}</text>
<text class="record-desc">{{ record.time }} · {{ record.owner }} · {{ record.type }}</text>
<text class="record-summary">{{ record.summary }}</text>
</view>
<text class="record-status">{{ record.status }}</text>
</view>
<text v-if="curateResult.sampleHint" class="case-note">{{ curateResult.sampleHint }}</text>
</view>
<view v-if="loggedIn" class="panel">
<view class="section-head">
<text class="panel-title">最近案例</text>
<button class="ghost-button refresh-button" :disabled="recordsLoading" @click="refreshRecords">
刷新
</button>
</view>
<view v-if="records.length" class="record-list">
<view v-for="record in records" :key="`${record.title}-${record.time}`" class="record-row">
<view>
<text class="record-title">{{ record.title }}</text>
<text class="record-desc">{{ record.time }} · {{ record.owner }} · {{ record.type }}</text>
<text class="record-summary">{{ record.summary }}</text>
</view>
<text class="record-status">{{ record.status }}</text>
</view>
</view>
<view v-else class="empty">暂无入库案例</view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import type { CaseCurateResponse, CaseOrganizeResponse, CaseRecord, CaseSelectedFile, CaseUploadResponse } from '@/types/api';
import { chooseCaseAudio, curateCase, getCaseRecords, organizeCase, uploadCaseAudio } from '@/services/case';
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
const loggedIn = ref(false);
const selectedFile = ref<CaseSelectedFile | null>(null);
const uploadResult = ref<CaseUploadResponse | null>(null);
const organizeResult = ref<CaseOrganizeResponse | null>(null);
const curateResult = ref<CaseCurateResponse | null>(null);
const records = ref<CaseRecord[]>([]);
const status = ref<'idle' | 'uploading' | 'organizing' | 'curating'>('idle');
const recordsLoading = ref(false);
const message = ref('');
const busy = computed(() => status.value !== 'idle');
const statusText = computed(() => {
if (status.value === 'uploading') return '转写中';
if (status.value === 'organizing') return '整理中';
if (status.value === 'curating') return '入库中';
return '处理中';
});
const resetResult = () => {
uploadResult.value = null;
organizeResult.value = null;
curateResult.value = null;
};
const clearCaseData = () => {
selectedFile.value = null;
records.value = [];
resetResult();
};
const refreshAuthState = () => {
loggedIn.value = isLoggedIn();
};
const handleRequestError = (error: unknown, fallback: string, wasLoggedIn = loggedIn.value) => {
refreshAuthState();
if (wasLoggedIn && !loggedIn.value) {
clearCaseData();
message.value = '登录已过期,请重新登录';
return true;
}
message.value = error instanceof Error ? error.message : fallback;
return false;
};
const selectAudio = async () => {
if (busy.value || !loggedIn.value) return;
message.value = '';
try {
selectedFile.value = await chooseCaseAudio();
resetResult();
} catch (error) {
message.value = error instanceof Error ? error.message : '选择音频失败';
}
};
const loadRecords = async () => {
if (recordsLoading.value) return;
if (!loggedIn.value) {
records.value = [];
return;
}
recordsLoading.value = true;
try {
records.value = await getCaseRecords();
} catch (error) {
handleRequestError(error, '最近案例加载失败', true);
} finally {
recordsLoading.value = false;
}
};
const refreshRecords = () => {
if (!loggedIn.value) {
goLogin();
return;
}
void loadRecords();
};
const submitCase = async () => {
if (!selectedFile.value || busy.value) return;
message.value = '';
resetResult();
try {
status.value = 'uploading';
uploadResult.value = await uploadCaseAudio(selectedFile.value);
status.value = 'organizing';
organizeResult.value = await organizeCase(uploadResult.value.caseId);
status.value = 'curating';
curateResult.value = await curateCase(uploadResult.value.caseId);
await loadRecords();
selectedFile.value = null;
uni.showToast({ title: '案例已入库', icon: 'none' });
} catch (error) {
handleRequestError(error, '案例处理失败', true);
} finally {
status.value = 'idle';
}
};
const goLogin = () => {
rememberLoginRedirect('/pages/user/cases/index');
uni.navigateTo({ url: '/pages/auth/login/index' });
};
const formatSize = (size: number) => {
if (!size) return '未知大小';
if (size < 1024 * 1024) return `${Math.max(1, Math.round(size / 1024))} KB`;
return `${(size / 1024 / 1024).toFixed(1)} MB`;
};
onShow(() => {
refreshAuthState();
if (loggedIn.value) {
void loadRecords();
} else {
clearCaseData();
}
});
</script>
<style scoped>
.cases-page {
padding-bottom: 92px;
background: #f4f7fb;
}
.login-panel {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.refresh-button {
min-height: 36px;
padding: 0 12px;
font-size: 13px;
}
.block {
display: block;
margin-top: 8px;
font-size: 14px;
}
.login-button {
min-width: 84px;
}
.file-box {
margin-top: 14px;
padding: 14px;
border: 1px dashed #cbd5e1;
border-radius: 12px;
background: #f8fafc;
}
.file-title,
.file-desc,
.case-content,
.case-note,
.record-title,
.record-desc,
.record-summary {
display: block;
}
.file-title,
.record-title {
color: #0f172a;
font-size: 15px;
font-weight: 900;
}
.file-desc,
.record-desc {
margin-top: 6px;
color: #64748b;
font-size: 13px;
}
.case-actions {
margin-top: 12px;
}
.case-actions button {
flex: 1;
}
.tag-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.case-tag {
padding: 5px 8px;
border-radius: 999px;
background: #eff6ff;
color: #1d4ed8;
font-size: 12px;
font-weight: 800;
}
.case-content,
.case-note,
.record-summary {
margin-top: 12px;
color: #334155;
font-size: 14px;
line-height: 1.6;
}
.summary-row,
.record-row {
margin-top: 12px;
padding: 12px;
border-radius: 12px;
background: #f8fafc;
}
.summary-label {
display: block;
color: #1d4ed8;
font-size: 13px;
font-weight: 900;
}
.summary-text {
display: block;
margin-top: 6px;
color: #334155;
font-size: 14px;
line-height: 1.55;
}
.record-row {
display: flex;
justify-content: space-between;
gap: 12px;
}
.record-status {
flex: 0 0 auto;
color: #16a34a;
font-size: 13px;
font-weight: 900;
}
</style>
@@ -0,0 +1,832 @@
<template>
<view class="page practice-page">
<view class="practice-head">
<text class="page-title">场景练</text>
<text class="page-subtitle">按当前场景完成三角色对练,结束后自动评分并写入训练记录。</text>
</view>
<view v-if="message" class="status-message">{{ message }}</view>
<view v-if="!loggedIn" class="panel login-panel">
<view>
<text class="panel-title">需要登录</text>
<text class="muted block">登录后查看今日任务、开始练习并保存训练记录。</text>
</view>
<button class="primary-button login-button" @click="goLogin">去登录</button>
</view>
<view v-if="loggedIn" class="panel assignment-panel">
<view class="assignment-head">
<text class="panel-title">今日任务</text>
<button class="ghost-button refresh-button" :disabled="assignmentLoading" @click="loadAssignments">刷新</button>
</view>
<view v-if="assignments.length" class="assignment-list">
<view v-for="item in assignments" :key="item.id" class="assignment-row">
<view class="assignment-copy">
<text class="assignment-badge">{{ item.source === 'daily' ? '每日三题' : '主管专项' }}</text>
<text class="assignment-title">{{ item.question || item.scenarioName }}</text>
<text v-if="assignmentDesc(item)" class="assignment-desc">{{ assignmentDesc(item) }}</text>
<text v-if="item.status === '已完成' && item.referenceAnswer" class="assignment-reference">参考:{{ item.referenceAnswer }}</text>
</view>
<view v-if="item.source === 'daily'" class="daily-answer">
<textarea
v-model="drillAnswers[item.id]"
class="textarea daily-textarea"
maxlength="220"
:disabled="item.status === '已完成'"
placeholder="写一句你的处理话术"
/>
<button class="primary-button small-button" :disabled="Boolean(answeringId) || item.status === '已完成'" @click="submitDaily(item)">
{{ item.status === '已完成' ? '已完成' : '提交' }}
</button>
</view>
<button v-else class="secondary-button small-button" :disabled="!canStartPractice" @click="startAssignment(item)">开始专项</button>
</view>
</view>
<view v-else class="empty">暂无今日任务</view>
</view>
<view v-if="loggedIn" class="panel scenario-card">
<text class="scenario-badge">今日小训</text>
<text class="scenario-title">{{ activeScenarioName }}</text>
<text class="scenario-desc">业主投诉服务或设施问题时,练习承接情绪、说明流程、给出反馈节点。</text>
<view v-if="prepCard" class="prep-card">
<view class="prep-head">
<text>训练前预习卡</text>
</view>
<view class="prep-block">
<text class="prep-label">要点</text>
<text v-for="item in prepCard.keyPoints" :key="`point-${item}`" class="prep-line">{{ item }}</text>
</view>
<view class="prep-block">
<text class="prep-label">红线</text>
<text v-for="item in prepCard.redLines" :key="`red-${item}`" class="prep-line">{{ item }}</text>
</view>
<view class="prep-block">
<text class="prep-label">话术</text>
<text v-for="item in prepCard.scripts" :key="`script-${item}`" class="prep-line">{{ item }}</text>
</view>
</view>
<text v-else-if="prepLoading" class="muted">预习卡加载中</text>
<button
v-if="status === 'idle' || status === 'finished'"
class="primary-button"
:disabled="busy"
@click="start()"
>
{{ status === 'finished' ? '再练一次' : '开始练习' }}
</button>
</view>
<view v-if="turns.length" class="panel dialogue-card">
<view class="dialogue-head">
<text class="panel-title">对话</text>
<text class="round-label">第 {{ Math.min(roundIndex + 1, 3) }} 轮</text>
</view>
<view v-if="hasCoachState" class="coach-state">
<view class="state-row">
<text>情绪 {{ emotionScore }}</text>
<view class="state-bar">
<view class="state-fill emotion-fill" :style="{ width: `${emotionScore}%` }" />
</view>
</view>
<view class="state-row">
<text>信任 {{ trustScore }}</text>
<view class="state-bar">
<view class="state-fill trust-fill" :style="{ width: `${trustScore}%` }" />
</view>
</view>
<text v-if="redFlagActive" class="red-flag">红线纠偏:先承接诉求,避免推责、绝对承诺或情绪对抗。</text>
</view>
<view class="turn-list">
<view v-for="(turn, index) in turns" :key="index" class="turn-row" :class="turn.role">
<text class="turn-role">{{ roleLabel(turn.role) }}</text>
<text class="turn-text">{{ turn.text }}</text>
</view>
</view>
<view v-if="canAdjustCoach" class="style-actions">
<button
v-for="item in styleOptions"
:key="item.value"
class="chip style-chip"
:disabled="busy || Boolean(adjustingStyle)"
@click="adjustCoachHint(item.value)"
>
{{ adjustingStyle === item.value ? '调整中' : item.label }}
</button>
</view>
</view>
<view v-if="status === 'active' || status === 'submitting'" class="panel reply-card">
<text class="panel-title">你的回应</text>
<textarea
v-model="draft"
class="textarea"
maxlength="500"
placeholder="先承接业主情绪,再说明处理动作和反馈时间。"
/>
<view class="reply-actions">
<button class="secondary-button" :disabled="busy || helpLoading" @click="loadHelp">
{{ helpLoading ? '求助中' : '求助' }}
</button>
<button class="secondary-button" :disabled="busy || !canFinishPractice" @click="finish()">结束评分</button>
<button class="primary-button" :disabled="busy" @click="submit">
{{ status === 'submitting' ? '提交中' : '提交回应' }}
</button>
</view>
</view>
<view v-if="helpAnswer || helpRefs.length" class="panel help-card">
<text class="panel-title">SOP 提示</text>
<text v-if="helpAnswer" class="help-answer">{{ helpAnswer }}</text>
<view v-if="helpRefs.length" class="help-refs">
<text v-for="item in helpRefs" :key="item" class="help-ref">{{ item }}</text>
</view>
</view>
<view v-if="result" class="panel result-card">
<text class="result-label">本次评分</text>
<text class="result-score">{{ result.totalScore }}</text>
<text class="result-comment">{{ result.aiComment }}</text>
<view v-if="result.mentorRewrite" class="mentor-rewrite">
<text>老师傅建议</text>
<text>{{ result.mentorRewrite }}</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import type {
PracticeFinishResponse,
PracticePrepCard,
PracticeAssignment,
PracticeRole,
PracticeTurn
} from '@/types/api';
import { answerDailyDrill, finishPractice, getPracticeAssignments, getPracticePrepCard, startPractice, submitPracticeTurn } from '@/services/practice';
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import { searchKnowledge } from '@/services/knowledge';
type PracticeStatus = 'idle' | 'starting' | 'active' | 'submitting' | 'finished';
const defaultScenarioId = 'complaint-water';
const defaultScenarioName = '投诉接待';
const status = ref<PracticeStatus>('idle');
const sessionId = ref('');
const roundIndex = ref(0);
const draft = ref('');
const message = ref('');
const turns = ref<PracticeTurn[]>([]);
const result = ref<PracticeFinishResponse | null>(null);
const selectedScenarioId = ref(defaultScenarioId);
const prepCard = ref<PracticePrepCard | null>(null);
const prepLoading = ref(false);
const activeScenarioName = ref(defaultScenarioName);
const helpLoading = ref(false);
const helpAnswer = ref('');
const helpRefs = ref<string[]>([]);
const lastTraineeText = ref('');
const lastCoachRoundIndex = ref<number | null>(null);
const adjustingStyle = ref('');
const emotionScore = ref(0);
const trustScore = ref(0);
const redFlagActive = ref(false);
const assignments = ref<PracticeAssignment[]>([]);
const drillAnswers = ref<Record<number, string>>({});
const assignmentLoading = ref(false);
const answeringId = ref<number | null>(null);
const activeAssignmentId = ref<number | undefined>();
const loggedIn = ref(false);
const busy = computed(() => status.value === 'starting' || status.value === 'submitting');
const canStartPractice = computed(() => status.value === 'idle' || status.value === 'finished');
const hasTraineeReply = computed(() => turns.value.some((item) => item.role === 'trainee'));
const canFinishPractice = computed(() => hasTraineeReply.value && !redFlagActive.value);
const hasCoachState = computed(() => emotionScore.value > 0 || trustScore.value > 0 || redFlagActive.value);
const canAdjustCoach = computed(() => status.value === 'active' && lastCoachRoundIndex.value !== null && turns.value.some((item) => item.role === 'coach'));
const styleOptions = [
{ value: 'serious', label: '再严肃' },
{ value: 'gentle', label: '再温柔' },
{ value: 'detailed', label: '更细致' },
{ value: 'professional', label: '更专业' }
];
const roleLabel = (role: PracticeRole) => {
if (role === 'customer') return '业主';
if (role === 'coach') return '老师傅';
return '我';
};
const assignmentDesc = (item: PracticeAssignment) => {
if (item.source === 'daily' && item.status !== '已完成') return '提交后显示反馈与参考答案';
return item.feedback || item.reason;
};
const goLogin = () => {
rememberLoginRedirect('/pages/user/practice/index');
uni.navigateTo({ url: '/pages/auth/login/index' });
};
const refreshAuthState = () => {
loggedIn.value = isLoggedIn();
};
const clearPracticeData = () => {
assignments.value = [];
drillAnswers.value = {};
prepCard.value = null;
prepLoading.value = false;
turns.value = [];
result.value = null;
sessionId.value = '';
roundIndex.value = 0;
draft.value = '';
status.value = 'idle';
selectedScenarioId.value = defaultScenarioId;
activeScenarioName.value = defaultScenarioName;
helpLoading.value = false;
helpAnswer.value = '';
helpRefs.value = [];
lastTraineeText.value = '';
lastCoachRoundIndex.value = null;
adjustingStyle.value = '';
emotionScore.value = 0;
trustScore.value = 0;
redFlagActive.value = false;
answeringId.value = null;
activeAssignmentId.value = undefined;
};
const requireLogin = () => {
refreshAuthState();
if (loggedIn.value) return true;
message.value = '请先登录后再开始训练';
goLogin();
return false;
};
const appendTurn = (role: PracticeRole, text?: string) => {
if (!text) return;
turns.value.push({ role, text });
};
const handleError = (error: unknown, fallback: string) => {
const text = error instanceof Error ? error.message : fallback;
refreshAuthState();
if (!loggedIn.value) {
clearPracticeData();
message.value = '登录已过期,请重新登录';
goLogin();
return;
}
message.value = text;
if (text.includes('认证') || text.includes('登录') || text.includes('401')) {
goLogin();
}
};
const loadAssignments = async () => {
refreshAuthState();
if (!loggedIn.value) {
clearPracticeData();
return;
}
assignmentLoading.value = true;
try {
assignments.value = await getPracticeAssignments(true);
drillAnswers.value = Object.fromEntries(assignments.value.map((item) => [item.id, item.userAnswer || '']));
} catch (error) {
handleError(error, '今日任务加载失败');
} finally {
assignmentLoading.value = false;
}
};
const submitDaily = async (item: PracticeAssignment) => {
if (answeringId.value) return;
const answer = (drillAnswers.value[item.id] || '').trim();
if (!answer) {
message.value = '请先填写处理话术';
return;
}
answeringId.value = item.id;
message.value = '';
try {
const result = await answerDailyDrill(item.id, answer);
assignments.value = assignments.value.map((row) =>
row.id === item.id
? { ...row, status: result.status, userAnswer: answer, feedback: result.feedback, referenceAnswer: result.referenceAnswer, correct: result.correct }
: row
);
message.value = `每日三题得分 ${result.score}`;
} catch (error) {
handleError(error, '每日三题提交失败');
} finally {
answeringId.value = null;
}
};
const startAssignment = (item: PracticeAssignment) => {
if (!canStartPractice.value) return;
selectedScenarioId.value = item.scenarioId || defaultScenarioId;
activeScenarioName.value = item.scenarioName || defaultScenarioName;
void loadPrepCard(selectedScenarioId.value);
void start(item.scenarioId || defaultScenarioId, item.id);
};
const loadPrepCard = async (scenarioId = selectedScenarioId.value) => {
refreshAuthState();
if (!loggedIn.value) {
prepCard.value = null;
return;
}
prepLoading.value = true;
try {
prepCard.value = await getPracticePrepCard(scenarioId);
} catch (error) {
prepCard.value = null;
handleError(error, '预习卡加载失败');
} finally {
prepLoading.value = false;
}
};
const start = async (scenarioId = defaultScenarioId, assignmentId?: number) => {
if (!canStartPractice.value) return;
if (!requireLogin()) return;
selectedScenarioId.value = scenarioId;
status.value = 'starting';
message.value = '';
result.value = null;
turns.value = [];
draft.value = '';
helpAnswer.value = '';
helpRefs.value = [];
lastTraineeText.value = '';
lastCoachRoundIndex.value = null;
adjustingStyle.value = '';
emotionScore.value = 0;
trustScore.value = 0;
redFlagActive.value = false;
roundIndex.value = 0;
activeAssignmentId.value = assignmentId;
try {
const data = await startPractice(scenarioId, assignmentId);
activeScenarioName.value = data.scenarioName || activeScenarioName.value;
emotionScore.value = data.emotion ?? 0;
trustScore.value = data.trust ?? 0;
sessionId.value = data.sessionId;
appendTurn('customer', data.customerText);
status.value = 'active';
} catch (error) {
status.value = 'idle';
activeAssignmentId.value = undefined;
handleError(error, '训练启动失败');
}
};
const loadHelp = async () => {
if (helpLoading.value || busy.value) return;
if (!requireLogin()) return;
helpLoading.value = true;
message.value = '';
try {
const data = await searchKnowledge(`${activeScenarioName.value} SOP 处理要点`);
if (!data.answer) {
throw new Error('未检索到可用 SOP 提示');
}
helpAnswer.value = data.answer;
helpRefs.value = (data.snippets || []).map((item) => item.docTitle).filter(Boolean).slice(0, 3);
} catch (error) {
handleError(error, '求助失败');
} finally {
helpLoading.value = false;
}
};
const replaceLastCoachHint = (text?: string) => {
if (!text) return;
for (let index = turns.value.length - 1; index >= 0; index -= 1) {
if (turns.value[index].role === 'coach') {
turns.value[index] = { role: 'coach', text };
return;
}
}
appendTurn('coach', text);
};
const discardLastCoachAttempt = () => {
const next = [...turns.value];
if (next[next.length - 1]?.role === 'coach') next.pop();
if (next[next.length - 1]?.role === 'trainee') next.pop();
turns.value = next;
};
const adjustCoachHint = async (style: string) => {
if (busy.value || adjustingStyle.value) return;
if (!sessionId.value || lastCoachRoundIndex.value === null || !lastTraineeText.value) return;
adjustingStyle.value = style;
message.value = '';
try {
const data = await submitPracticeTurn({
sessionId: sessionId.value,
roundIndex: lastCoachRoundIndex.value,
traineeText: lastTraineeText.value,
regenerate: true,
style
});
replaceLastCoachHint(data.coachHint);
emotionScore.value = data.emotion ?? emotionScore.value;
trustScore.value = data.trust ?? trustScore.value;
redFlagActive.value = Boolean(data.redFlag);
if (data.message) message.value = data.message;
} catch (error) {
handleError(error, '建议调整失败');
} finally {
adjustingStyle.value = '';
}
};
const submit = async () => {
if (busy.value) return;
const text = draft.value.trim();
if (!text) {
message.value = '请先输入回应';
return;
}
status.value = 'submitting';
message.value = '';
draft.value = '';
const retryingRedFlag = redFlagActive.value && lastCoachRoundIndex.value === roundIndex.value;
if (retryingRedFlag) discardLastCoachAttempt();
appendTurn('trainee', text);
try {
const submittedRound = roundIndex.value;
const data = await submitPracticeTurn({
sessionId: sessionId.value,
roundIndex: submittedRound,
traineeText: text
});
lastTraineeText.value = text;
lastCoachRoundIndex.value = submittedRound;
emotionScore.value = data.emotion ?? emotionScore.value;
trustScore.value = data.trust ?? trustScore.value;
redFlagActive.value = Boolean(data.redFlag);
appendTurn('coach', data.redFlag ? data.coachHint || '先承接诉求,再给处理节点。' : data.coachHint);
if (data.redFlag) {
message.value = '命中红线,请按老师傅建议重说本轮';
status.value = 'active';
return;
}
if (data.finished) {
await finish(true);
return;
}
appendTurn('customer', data.customerText);
roundIndex.value = data.roundIndex;
status.value = 'active';
} catch (error) {
status.value = 'active';
handleError(error, '提交失败');
}
};
const finish = async (force = false) => {
if (!force && busy.value) return;
if (!sessionId.value) return;
if (!canFinishPractice.value) {
message.value = redFlagActive.value ? '请先按老师傅建议重说本轮' : '请先完成至少一轮回应';
return;
}
status.value = 'submitting';
message.value = '';
try {
result.value = await finishPractice(sessionId.value, activeAssignmentId.value);
status.value = 'finished';
message.value = '训练完成,主管端待复盘已更新';
activeAssignmentId.value = undefined;
await loadAssignments();
} catch (error) {
status.value = 'active';
handleError(error, '评分失败');
}
};
onShow(() => {
refreshAuthState();
void loadAssignments();
void loadPrepCard();
});
</script>
<style scoped>
.practice-page {
padding-bottom: 92px;
background: #f4f7fb;
}
.practice-head {
padding-top: 8px;
}
.scenario-card,
.reply-card,
.help-card,
.result-card {
display: flex;
flex-direction: column;
gap: 12px;
}
.assignment-head,
.daily-answer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.refresh-button,
.small-button {
min-width: 82px;
}
.assignment-list {
margin-top: 12px;
}
.assignment-row {
margin-top: 10px;
padding: 12px;
border-radius: 14px;
background: #f8fafc;
}
.assignment-badge {
display: inline-flex;
width: fit-content;
padding: 4px 8px;
border-radius: 999px;
background: #e0f2fe;
color: #0369a1;
font-size: 12px;
font-weight: 800;
}
.assignment-title {
display: block;
margin-top: 8px;
color: #0f172a;
font-size: 15px;
font-weight: 900;
line-height: 1.45;
}
.assignment-desc,
.assignment-reference {
display: block;
margin-top: 6px;
color: #64748b;
font-size: 13px;
line-height: 1.45;
}
.daily-answer {
margin-top: 10px;
align-items: flex-end;
}
.daily-textarea {
min-height: 82px;
}
.scenario-badge {
width: fit-content;
padding: 5px 10px;
border-radius: 999px;
background: #dbeafe;
color: #1d4ed8;
font-size: 13px;
font-weight: 900;
}
.scenario-title {
color: #0f172a;
font-size: 24px;
font-weight: 900;
}
.scenario-desc {
color: #64748b;
font-size: 15px;
line-height: 1.55;
}
.prep-card {
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px;
border-radius: 14px;
background: #f8fafc;
}
.prep-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.prep-head text:first-child,
.prep-label {
color: #0f172a;
font-size: 13px;
font-weight: 900;
}
.prep-head text:last-child {
color: #94a3b8;
font-size: 12px;
}
.prep-block {
display: flex;
flex-direction: column;
gap: 5px;
}
.prep-line {
color: #475569;
font-size: 13px;
line-height: 1.45;
}
.dialogue-head,
.reply-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.round-label {
color: #64748b;
font-size: 13px;
}
.coach-state {
display: flex;
margin-top: 12px;
flex-direction: column;
gap: 8px;
}
.state-row {
display: grid;
align-items: center;
grid-template-columns: 72px 1fr;
gap: 10px;
color: #475569;
font-size: 13px;
font-weight: 800;
}
.state-bar {
height: 8px;
overflow: hidden;
border-radius: 999px;
background: #e2e8f0;
}
.state-fill {
height: 100%;
border-radius: 999px;
}
.emotion-fill {
background: #f97316;
}
.trust-fill {
background: #16a34a;
}
.red-flag {
padding: 9px 10px;
border-radius: 12px;
background: #fef2f2;
color: #b91c1c;
font-size: 13px;
font-weight: 800;
line-height: 1.45;
}
.turn-list {
display: flex;
margin-top: 14px;
flex-direction: column;
gap: 10px;
}
.turn-row {
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px;
border-radius: 14px;
background: #f8fafc;
}
.turn-row.trainee {
background: #eff6ff;
}
.turn-row.coach {
background: #fff7ed;
}
.turn-role {
color: #334155;
font-size: 13px;
font-weight: 900;
}
.turn-text {
color: #172033;
font-size: 15px;
line-height: 1.55;
}
.style-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.style-chip {
width: auto;
}
.reply-actions button {
flex: 1;
}
.help-answer {
color: #334155;
font-size: 15px;
line-height: 1.6;
}
.help-refs {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.help-ref {
padding: 5px 8px;
border-radius: 999px;
background: #e0f2fe;
color: #0369a1;
font-size: 12px;
font-weight: 800;
}
.result-label {
color: #64748b;
font-size: 14px;
font-weight: 800;
}
.result-score {
color: #1d4ed8;
font-size: 44px;
font-weight: 900;
line-height: 1;
}
.result-comment,
.mentor-rewrite text:last-child {
color: #334155;
font-size: 15px;
line-height: 1.55;
}
.mentor-rewrite {
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px;
border-radius: 12px;
background: #f8fafc;
}
.mentor-rewrite text:first-child {
color: #0f172a;
font-weight: 900;
}
</style>
+445
View File
@@ -0,0 +1,445 @@
<template>
<view class="page profile-page">
<text class="page-title">我</text>
<text class="page-subtitle">{{ phone || '未登录' }}</text>
<view v-if="message" class="status-message">{{ message }}</view>
<view class="panel account-card">
<view>
<text class="panel-title">账号</text>
<text class="account-phone">{{ phone || '未登录' }}</text>
</view>
<button v-if="!loggedIn" class="primary-button account-action" @click="goLogin">去登录</button>
<button v-else class="secondary-button account-action" @click="logout">退出</button>
</view>
<view v-if="profile" class="panel">
<text class="panel-title">成长摘要</text>
<view class="score-row">
<view class="score-main">
<text class="score-value">{{ profile.score }}</text>
<text class="score-label">综合能力</text>
</view>
<view class="metric-grid">
<view class="metric">
<text>{{ profile.completed }}</text>
<text>已训练</text>
</view>
<view class="metric">
<text>{{ profile.pendingReview }}</text>
<text>待复盘</text>
</view>
<view class="metric">
<text>{{ profile.trainingMinutes || 0 }}</text>
<text>分钟</text>
</view>
<view class="metric">
<text>{{ profile.aiLevel || '初级' }}</text>
<text>AI等级</text>
</view>
</view>
</view>
<view v-if="profile.dimensions?.length" class="dimension-list">
<view v-for="item in profile.dimensions" :key="item.label" class="dimension-row">
<view>
<text class="dimension-title">{{ item.label }}</text>
<text class="dimension-note">{{ item.note }}</text>
</view>
<text class="dimension-score">{{ item.value }}</text>
</view>
</view>
<view v-if="profile.growthPath?.length" class="growth-section">
<text class="section-label">成长路径</text>
<view v-for="stage in profile.growthPath" :key="stage.level" class="growth-row">
<view class="growth-copy">
<text class="growth-title">{{ stage.level }} · {{ stage.status }}</text>
<text class="growth-desc">{{ stage.evidence }}</text>
</view>
<view class="growth-progress">
<view class="growth-bar">
<view class="growth-fill" :style="{ width: `${stage.progress}%` }" />
</view>
<text>{{ stage.progress }}%</text>
</view>
</view>
</view>
</view>
<view v-if="evidence" class="panel evidence-panel">
<text class="panel-title">成长证据包</text>
<view class="evidence-grid">
<view class="evidence-metric">
<text>{{ evidence.currentLevel }}</text>
<text>当前等级</text>
</view>
<view class="evidence-metric">
<text>{{ evidence.assessmentScore }}</text>
<text>测评分</text>
</view>
<view class="evidence-metric">
<text>{{ evidence.trainingMinutes }}</text>
<text>训练分钟</text>
</view>
<view class="evidence-metric">
<text>{{ evidence.contribution }}</text>
<text>贡献度</text>
</view>
</view>
<view v-if="evidence.evidenceItems?.length" class="evidence-list">
<text v-for="item in evidence.evidenceItems" :key="item" class="evidence-item">{{ item }}</text>
</view>
</view>
<view v-if="loggedIn" class="panel">
<view class="history-head">
<text class="panel-title">最近练过</text>
<button class="ghost-button refresh-button" :disabled="loading" @click="loadData">刷新</button>
</view>
<view v-if="history.length" class="history-list">
<view v-for="item in history" :key="item.id || item.sessionId || item.time" class="history-row">
<view>
<text class="history-title">{{ item.scene }}</text>
<text class="history-desc">{{ item.time }} · {{ item.summary }}</text>
</view>
<view class="history-score">
<text>{{ item.score }}</text>
<text>{{ item.status }}</text>
</view>
</view>
</view>
<view v-else class="empty">暂无训练记录</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import type { CompetencyProfile, PracticeRecord, PromotionEvidence } from '@/types/api';
import { clearAuth, getAuth, isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import { getCompetencyProfile, getPracticeHistory, getPromotionEvidence } from '@/services/practice';
const loggedIn = ref(false);
const phone = ref('');
const loading = ref(false);
const message = ref('');
const profile = ref<CompetencyProfile | null>(null);
const history = ref<PracticeRecord[]>([]);
const evidence = ref<PromotionEvidence | null>(null);
const refreshAuth = () => {
const auth = getAuth();
loggedIn.value = isLoggedIn();
phone.value = auth.phone;
};
const clearProfileData = () => {
profile.value = null;
history.value = [];
evidence.value = null;
};
const loadData = async () => {
refreshAuth();
if (!loggedIn.value) {
clearProfileData();
return;
}
loading.value = true;
message.value = '';
try {
const [nextHistory, nextProfile, nextEvidence] = await Promise.all([
getPracticeHistory(),
getCompetencyProfile(),
getPromotionEvidence()
]);
history.value = nextHistory || [];
profile.value = nextProfile || null;
evidence.value = nextEvidence || null;
} catch (error) {
refreshAuth();
if (!loggedIn.value) {
clearProfileData();
message.value = '登录已过期,请重新登录';
return;
}
message.value = error instanceof Error ? error.message : '我的数据加载失败';
} finally {
loading.value = false;
}
};
const goLogin = () => {
rememberLoginRedirect('/pages/user/profile/index');
uni.navigateTo({ url: '/pages/auth/login/index' });
};
const logout = () => {
clearAuth();
clearProfileData();
refreshAuth();
uni.showToast({ title: '已退出', icon: 'none' });
};
onShow(loadData);
</script>
<style scoped>
.profile-page {
padding-bottom: 92px;
background: #f4f7fb;
}
.account-card,
.history-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.account-phone {
display: block;
margin-top: 8px;
color: #64748b;
font-size: 14px;
}
.account-action {
min-width: 84px;
}
.score-row {
display: grid;
margin-top: 16px;
grid-template-columns: 104px 1fr;
gap: 14px;
}
.score-main {
display: flex;
min-height: 124px;
flex-direction: column;
align-items: center;
justify-content: center;
border-radius: 18px;
background: #eff6ff;
}
.score-value {
color: #1d4ed8;
font-size: 42px;
font-weight: 900;
line-height: 1;
}
.score-label {
margin-top: 8px;
color: #475569;
font-size: 13px;
}
.metric-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.metric {
display: flex;
min-height: 58px;
flex-direction: column;
justify-content: center;
padding: 8px 10px;
border-radius: 14px;
background: #f8fafc;
}
.metric text:first-child {
color: #0f172a;
font-size: 18px;
font-weight: 900;
}
.metric text:last-child {
margin-top: 4px;
color: #64748b;
font-size: 12px;
}
.evidence-panel {
background: #fff7ed;
}
.evidence-grid {
display: grid;
margin-top: 14px;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.evidence-metric {
display: flex;
min-height: 58px;
flex-direction: column;
justify-content: center;
padding: 8px 10px;
border-radius: 14px;
background: #ffffff;
}
.evidence-metric text:first-child {
color: #9a3412;
font-size: 18px;
font-weight: 900;
}
.evidence-metric text:last-child {
margin-top: 4px;
color: #64748b;
font-size: 12px;
}
.evidence-list {
margin-top: 12px;
}
.evidence-item {
display: block;
padding: 9px 0;
border-top: 1px solid #fed7aa;
color: #7c2d12;
font-size: 13px;
line-height: 1.45;
}
.dimension-list,
.history-list {
margin-top: 14px;
}
.growth-section {
margin-top: 18px;
}
.section-label {
display: block;
color: #334155;
font-size: 14px;
font-weight: 900;
}
.growth-row {
display: grid;
grid-template-columns: 1fr 96px;
gap: 12px;
padding: 12px 0;
border-top: 1px solid #e2e8f0;
}
.growth-row:first-of-type {
margin-top: 10px;
}
.growth-title,
.growth-desc {
display: block;
}
.growth-title {
color: #0f172a;
font-size: 14px;
font-weight: 900;
}
.growth-desc {
margin-top: 5px;
color: #64748b;
font-size: 12px;
line-height: 1.4;
}
.growth-progress {
display: flex;
min-width: 0;
flex-direction: column;
align-items: flex-end;
justify-content: center;
gap: 6px;
color: #1d4ed8;
font-size: 12px;
font-weight: 900;
}
.growth-bar {
width: 88px;
height: 6px;
overflow: hidden;
border-radius: 999px;
background: #dbeafe;
}
.growth-fill {
height: 100%;
border-radius: inherit;
background: #2563eb;
}
.dimension-row,
.history-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 0;
border-top: 1px solid #e2e8f0;
}
.dimension-title,
.history-title {
display: block;
color: #0f172a;
font-weight: 900;
}
.dimension-note,
.history-desc {
display: block;
margin-top: 5px;
color: #64748b;
font-size: 13px;
line-height: 1.4;
}
.dimension-score {
color: #1d4ed8;
font-size: 20px;
font-weight: 900;
}
.refresh-button {
min-height: 36px;
padding: 0 12px;
font-size: 13px;
}
.history-score {
display: flex;
min-width: 52px;
flex-direction: column;
align-items: flex-end;
}
.history-score text:first-child {
color: #1d4ed8;
font-size: 20px;
font-weight: 900;
}
.history-score text:last-child {
margin-top: 4px;
color: #64748b;
font-size: 12px;
}
</style>
+393
View File
@@ -0,0 +1,393 @@
<template>
<view class="page sop-page">
<text class="page-title">问师傅</text>
<text class="page-subtitle">直接问工作中的 SOP、话术和流程。答案来自知识库检索,引用在后面。</text>
<view class="panel query-panel">
<textarea
v-model="query"
class="textarea"
maxlength="200"
placeholder="例如: 业主投诉噪音,生活顾问应该怎么处理?"
/>
<view class="chip-row">
<button v-for="item in quickQuestions" :key="item" class="chip" :disabled="loading" @click="useQuickQuestion(item)">
{{ item }}
</button>
</view>
<button class="primary-button" :disabled="loading" @click="search">
{{ loading ? '检索中' : '查询' }}
</button>
<text v-if="message" class="status-message">{{ message }}</text>
</view>
<view v-if="!loggedIn" class="panel login-panel">
<view>
<text class="panel-title">需要登录</text>
<text class="muted block">登录后才能查询 SOP、生成总结卡并反馈答案。</text>
</view>
<button class="primary-button login-button" @click="goLogin">去登录</button>
</view>
<view v-if="result" class="panel answer-panel">
<text class="panel-title">直给答案</text>
<text class="answer-text">{{ result.answer || '未生成直接答案,请查看下方引用。' }}</text>
<view v-if="result.keyPoints?.length" class="keypoints">
<text v-for="(point, index) in result.keyPoints" :key="point" class="keypoint">
{{ index + 1 }}. {{ point }}
</text>
</view>
<view v-if="result.cautions?.length" class="cautions">
<text v-for="item in result.cautions" :key="item">{{ item }}</text>
</view>
<view class="feedback-actions">
<button
class="secondary-button"
:class="{ active: feedbackVerdict === 'up' }"
:disabled="feedbackSubmitting || Boolean(feedbackVerdict)"
@click="feedback('up')"
>
有用
</button>
<button
class="secondary-button"
:class="{ active: feedbackVerdict === 'down' }"
:disabled="feedbackSubmitting || Boolean(feedbackVerdict)"
@click="feedback('down')"
>
不准
</button>
</view>
</view>
<view v-if="result?.snippets?.length" class="panel">
<text class="panel-title">引用依据</text>
<view v-for="(snippet, index) in result.snippets" :key="`${snippet.docTitle}-${index}`" class="snippet">
<text class="snippet-title">{{ snippet.docTitle }}</text>
<text class="snippet-content">{{ snippetExcerpt(snippet.content) }}</text>
<text v-if="snippet.fragmentId" class="snippet-id">片段 #{{ snippet.fragmentId }}</text>
</view>
</view>
<view v-if="result" class="panel summary-panel">
<text class="panel-title">总结卡</text>
<text class="muted">需要时再生成,避免每次查询都调用模型。</text>
<button class="ghost-button" :disabled="summaryLoading" @click="buildSummaryCard">
{{ summaryLoading ? '生成中' : '生成总结卡' }}
</button>
</view>
<view v-if="summaryCard" class="panel">
<text class="panel-title">{{ summaryCard.title }}</text>
<view class="summary-block">
<text class="summary-heading">步骤</text>
<text v-for="(step, index) in summaryCard.steps" :key="`${step.label}-${index}`" class="summary-line">
{{ index + 1 }}. {{ step.label }}: {{ step.desc }}
</text>
</view>
<view v-if="summaryCard.scripts.length" class="summary-block">
<text class="summary-heading">可说话术</text>
<text v-for="item in summaryCard.scripts" :key="item" class="summary-line">{{ item }}</text>
</view>
<view v-if="summaryCard.objections.length" class="summary-block">
<text class="summary-heading">异议回应</text>
<text v-for="item in summaryCard.objections" :key="item.concern" class="summary-line">
{{ item.concern }}: {{ item.response }}
</text>
</view>
<view v-if="summaryCard.reminders.length" class="summary-block">
<text class="summary-heading">提醒</text>
<text v-for="item in summaryCard.reminders" :key="item" class="summary-line">{{ item }}</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import type {
AnswerFeedbackVerdict,
KnowledgeSearchResponse,
SummaryCardResponse
} from '@/types/api';
import {
generateSummaryCard,
searchKnowledge,
submitAnswerFeedback
} from '@/services/knowledge';
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
const quickQuestions = ['业主投诉噪音怎么安抚', '停车费催缴怎么说', '生活顾问的一天做什么'];
const query = ref('');
const result = ref<KnowledgeSearchResponse | null>(null);
const summaryCard = ref<SummaryCardResponse | null>(null);
const loading = ref(false);
const summaryLoading = ref(false);
const feedbackSubmitting = ref(false);
const feedbackVerdict = ref<AnswerFeedbackVerdict | ''>('');
const message = ref('');
const loggedIn = ref(false);
const pendingSopQueryKey = 'aihr_sop_pending_query';
const resetResultState = () => {
result.value = null;
summaryCard.value = null;
feedbackVerdict.value = '';
feedbackSubmitting.value = false;
};
const refreshAuthState = () => {
loggedIn.value = isLoggedIn();
};
const handleRequestError = (error: unknown, fallback: string, wasLoggedIn = loggedIn.value) => {
refreshAuthState();
if (wasLoggedIn && !loggedIn.value) {
resetResultState();
message.value = '登录已过期,请重新登录';
return true;
}
message.value = error instanceof Error ? error.message : fallback;
return false;
};
const search = async () => {
if (loading.value) return;
const value = query.value.trim();
if (!value) {
message.value = '请先输入问题';
return;
}
refreshAuthState();
if (!loggedIn.value) {
uni.setStorageSync(pendingSopQueryKey, value);
goLogin();
return;
}
loading.value = true;
message.value = '';
resetResultState();
try {
result.value = await searchKnowledge(value);
if (!result.value.answer && !result.value.snippets?.length) {
message.value = '未检索到可引用内容';
}
} catch (error) {
handleRequestError(error, '检索失败', true);
} finally {
loading.value = false;
}
};
const useQuickQuestion = (value: string) => {
query.value = value;
void search();
};
const snippetExcerpt = (text = '') => {
const value = text.trim();
return value.length > 260 ? `${value.slice(0, 260)}...` : value;
};
const feedback = async (verdict: AnswerFeedbackVerdict) => {
if (!result.value || feedbackSubmitting.value || feedbackVerdict.value) return;
const fragmentIds = Array.from(
new Set(
(result.value.snippets || [])
.map((item) => item.fragmentId)
.filter((id): id is number => typeof id === 'number' && id > 0)
)
);
if (!fragmentIds.length) {
uni.showToast({ title: '暂无可反馈依据', icon: 'none' });
return;
}
feedbackSubmitting.value = true;
try {
await submitAnswerFeedback({
queryText: result.value.queryText || query.value,
fragmentIds,
verdict
});
feedbackVerdict.value = verdict;
uni.showToast({ title: verdict === 'down' ? '已进入复核' : '已记录', icon: 'none' });
} catch (error) {
if (!handleRequestError(error, '反馈失败', true)) {
uni.showToast({
title: message.value,
icon: 'none'
});
}
} finally {
feedbackSubmitting.value = false;
}
};
const buildSummaryCard = async () => {
if (summaryLoading.value) return;
const value = (result.value?.queryText || query.value).trim();
if (!value) return;
summaryLoading.value = true;
message.value = '';
summaryCard.value = null;
try {
summaryCard.value = await generateSummaryCard(value);
} catch (error) {
handleRequestError(error, '总结卡生成失败', true);
} finally {
summaryLoading.value = false;
}
};
const goLogin = () => {
rememberLoginRedirect('/pages/user/sop/index');
uni.navigateTo({ url: '/pages/auth/login/index' });
};
onShow(() => {
refreshAuthState();
if (!loggedIn.value) {
resetResultState();
}
const pending = uni.getStorageSync(pendingSopQueryKey);
if (!pending) {
if (loggedIn.value && query.value.trim() && message.value === '登录后继续查询该问题') {
void search();
}
return;
}
query.value = String(pending);
if (loggedIn.value) {
uni.removeStorageSync(pendingSopQueryKey);
void search();
} else {
message.value = '登录后继续查询该问题';
}
});
</script>
<style scoped>
.sop-page {
padding-bottom: 48px;
}
.query-panel,
.answer-panel,
.summary-panel {
display: flex;
flex-direction: column;
gap: 14px;
}
.login-panel {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.block {
display: block;
margin-top: 8px;
font-size: 14px;
}
.login-button {
min-width: 84px;
}
.answer-text {
color: #0f172a;
font-size: 18px;
font-weight: 700;
line-height: 1.65;
}
.keypoints {
display: flex;
flex-direction: column;
gap: 8px;
}
.keypoint {
padding: 10px 12px;
border-radius: 10px;
background: #f8fafc;
color: #334155;
font-size: 15px;
line-height: 1.5;
}
.cautions {
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px 12px;
border-radius: 10px;
background: #fff7ed;
color: #9a3412;
font-size: 13px;
line-height: 1.45;
}
.feedback-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.feedback-actions .active {
border-color: #16a34a;
background: #dcfce7;
color: #166534;
}
.snippet {
display: flex;
margin-top: 12px;
flex-direction: column;
gap: 6px;
padding: 12px;
border-radius: 12px;
background: #f8fafc;
}
.snippet-title {
color: #0f172a;
font-weight: 800;
}
.snippet-content {
color: #475569;
font-size: 14px;
line-height: 1.55;
}
.snippet-id {
color: #94a3b8;
font-size: 12px;
}
.summary-block {
display: flex;
margin-top: 14px;
flex-direction: column;
gap: 8px;
}
.summary-heading {
color: #1d4ed8;
font-weight: 800;
}
.summary-line {
color: #334155;
font-size: 15px;
line-height: 1.55;
}
</style>
+614
View File
@@ -0,0 +1,614 @@
<template>
<view class="page today-page">
<view v-if="message" class="status-message">{{ message }}</view>
<view class="mentor-hero">
<view class="hero-copy">
<text class="hero-role">生活顾问</text>
<text class="hero-title">客服管家</text>
<text class="hero-sub">有问题,随时问老师傅</text>
</view>
<image class="mentor-image" src="/static/mentor.png" mode="aspectFit" />
<view class="mentor-bubble">
<text>{{ mentorBubbleText }}</text>
</view>
</view>
<view class="voice-panel">
<button class="voice-primary" @click="goSop">
<text class="voice-mark">问</text>
<text>问老师傅</text>
</button>
<button class="voice-secondary" @click="goSop">
<text class="keyboard-mark">键</text>
<text>打字问</text>
</button>
</view>
<view class="mentor-chips">
<view class="chip-row">
<button v-for="item in quickQuestions" :key="item" class="chip" @click="ask(item)">
{{ item }}
</button>
</view>
</view>
<view class="section-card login-section" v-if="!loggedIn">
<view>
<text class="login-title">登录后查看今日安排</text>
<text class="login-desc">训练任务、复习提醒和成长数据会按你的手机号展示。</text>
</view>
<button class="primary-button login-button" @click="goLogin">去登录</button>
</view>
<view class="section-card" v-else-if="home?.primary">
<view class="section-head">
<view class="section-title">
<text class="section-icon">日</text>
<text>今日安排</text>
</view>
<button class="section-link" @click="openTool('训练')">查看全部</button>
</view>
<view class="schedule-list">
<view class="schedule-row">
<text class="step-dot blue">1</text>
<view class="schedule-copy">
<text class="schedule-title">每日小训:{{ home.primary.title.replace('三角色对练', '') }}</text>
<text class="schedule-desc">{{ home.primary.desc }}</text>
</view>
<button class="schedule-action blue-action" @click="openTool(home.primary.cta)">开始</button>
</view>
<view class="schedule-row" v-if="home.review">
<text class="step-dot orange">2</text>
<view class="schedule-copy">
<text class="schedule-title">今日复习:{{ home.review.title }}</text>
<text class="schedule-desc">{{ home.review.desc }}</text>
</view>
<button class="schedule-action orange-action" @click="ask(home.review.title)">去复习</button>
</view>
</view>
</view>
<view v-if="loggedIn" class="section-card">
<view class="section-head">
<view class="section-title">
<text class="section-icon">图</text>
<text>{{ loggedIn ? '我的地图' : '常用入口' }}</text>
</view>
</view>
<view v-if="mapTiles.length" class="map-grid">
<button
v-for="tile in mapTiles"
:key="tile.title"
class="map-tile"
:class="tile.tone"
@click="openTool(tile.title)"
>
<image class="map-icon" :src="tile.icon" mode="aspectFit" />
<view class="map-copy">
<text class="map-title">{{ tile.title }}</text>
<text class="map-desc">{{ tile.desc }}</text>
</view>
<text class="map-arrow">›</text>
</button>
</view>
<view v-else class="empty">暂无工具入口</view>
</view>
<button class="tip-card" @click="ask('催费话术')">
<text class="tip-mark">贴</text>
<text>今日小贴士:催费沟通先共情,再说明,再给方案。</text>
<text class="tip-arrow">›</text>
</button>
</view>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import type { RoleHome } from '@/types/api';
import { getMobileHome } from '@/services/home';
import { getAuth, isLoggedIn, rememberLoginRedirect } from '@/services/auth';
const home = ref<RoleHome | null>(null);
const message = ref('');
const loggedIn = ref(false);
const quickQuestions = ['催费话术', '投诉应对', '收费标准'];
const pendingSopQueryKey = 'aihr_sop_pending_query';
const iconFor = (title: string) => {
if (title.includes('SOP') || title.includes('查')) return '/static/map-icons/ask.png';
if (title.includes('练')) return '/static/map-icons/practice.png';
if (title.includes('学') || title.includes('案例')) return '/static/map-icons/learn.png';
return '/static/map-icons/report.png';
};
const toneFor = (title: string) => {
if (title.includes('SOP') || title.includes('查')) return 'ask';
if (title.includes('练')) return 'practice';
if (title.includes('学') || title.includes('案例')) return 'learn';
return 'report';
};
const mapTiles = computed(() => {
if (!home.value?.tools?.length) return [];
return home.value.tools.map((item) => ({
title: item.title,
desc: item.desc.replace(/\n/g, ' / '),
tone: toneFor(item.title),
icon: iconFor(item.title)
}));
});
const mentorName = computed(() => {
if (!loggedIn.value) return '同事';
const phone = getAuth().phone.trim();
return phone ? `尾号${phone.slice(-4)}的同事` : '同事';
});
const mentorBubbleText = computed(() =>
loggedIn.value ? `${mentorName.value},今天先做两件事` : '同事,登录后看今天任务'
);
const loadHome = async () => {
loggedIn.value = isLoggedIn();
message.value = '';
if (!loggedIn.value) {
home.value = null;
return;
}
try {
home.value = await getMobileHome('user');
} catch (error) {
home.value = null;
message.value = error instanceof Error ? error.message : '首页数据加载失败';
}
};
const goSop = () => {
uni.switchTab({ url: '/pages/user/sop/index' });
};
const ask = (query: string) => {
uni.setStorageSync(pendingSopQueryKey, query);
uni.switchTab({ url: '/pages/user/sop/index' });
};
const goLogin = () => {
rememberLoginRedirect('/pages/user/today/index');
uni.navigateTo({ url: '/pages/auth/login/index' });
};
const openTool = (title: string) => {
if (title.includes('问') || title.includes('SOP') || title.includes('sop')) {
goSop();
return;
}
if (title.includes('练') || title.includes('训练') || title.includes('开始')) {
uni.switchTab({ url: '/pages/user/practice/index' });
return;
}
if (title.includes('案例') || title.includes('素材') || title.includes('提交')) {
uni.navigateTo({ url: '/pages/user/cases/index' });
return;
}
uni.navigateTo({ url: `/pages/common/building/index?title=${encodeURIComponent(title)}` });
};
onShow(loadHome);
</script>
<style scoped>
.today-page {
min-height: 100vh;
padding: 0 14px 86px;
background: #f4f7fb;
}
.mentor-hero {
position: relative;
height: 224px;
margin: 0 -14px;
overflow: hidden;
background:
linear-gradient(180deg, rgba(237, 247, 255, 0.72), rgba(244, 247, 251, 0.98)),
linear-gradient(135deg, #d9ecff 0%, #f7fbff 45%, #dfeff2 100%);
}
.mentor-hero::before {
position: absolute;
right: -36px;
bottom: 12px;
width: 190px;
height: 96px;
border-radius: 30px;
background: rgba(255, 255, 255, 0.46);
content: '';
}
.hero-copy {
position: absolute;
left: 22px;
top: 34px;
z-index: 2;
display: flex;
flex-direction: column;
}
.hero-role,
.hero-title {
color: #11235a;
font-size: 22px;
font-weight: 900;
line-height: 1.08;
}
.hero-sub {
margin-top: 8px;
color: #1e3a8a;
font-size: 13px;
font-weight: 700;
}
.mentor-image {
position: absolute;
right: 88px;
bottom: 14px;
z-index: 1;
width: 104px;
height: 138px;
border-radius: 24px;
box-shadow: 0 14px 36px rgba(15, 23, 42, 0.14);
}
.mentor-bubble {
position: absolute;
right: 16px;
top: 80px;
z-index: 3;
width: 128px;
padding: 12px 14px;
border-radius: 18px;
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.12);
}
.mentor-bubble text {
color: #111827;
font-size: 17px;
font-weight: 700;
line-height: 1.45;
}
.voice-panel {
position: relative;
z-index: 4;
display: grid;
margin: -34px 8px 0;
padding: 16px;
grid-template-columns: 1fr 1fr;
gap: 14px;
border-radius: 22px;
background: #ffffff;
box-shadow: 0 14px 38px rgba(15, 23, 42, 0.12);
}
.voice-primary,
.voice-secondary {
display: flex;
min-height: 64px;
align-items: center;
justify-content: center;
gap: 10px;
border-radius: 18px;
background: transparent;
color: #1d4ed8;
font-size: 20px;
font-weight: 800;
}
.voice-secondary {
color: #4b5563;
background: #f1f5f9;
}
.voice-primary text:last-child,
.voice-secondary text:last-child {
white-space: nowrap;
}
.voice-mark,
.keyboard-mark {
display: flex;
width: 46px;
height: 46px;
align-items: center;
justify-content: center;
border-radius: 50%;
background: linear-gradient(135deg, #2563eb, #1d9bf0);
color: #ffffff;
font-size: 18px;
font-weight: 900;
}
.keyboard-mark {
background: #e5e7eb;
color: #4b5563;
}
.mentor-chips {
margin-top: 14px;
}
.mentor-chips .chip-row {
justify-content: space-between;
gap: 8px;
}
.mentor-chips .chip {
min-height: 42px;
flex: 1;
border: 1px solid rgba(203, 213, 225, 0.7);
background: #ffffff;
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.06);
color: #1f2937;
font-size: 15px;
font-weight: 800;
}
.section-card {
margin-top: 18px;
padding: 18px;
border-radius: 18px;
background: #ffffff;
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.07);
}
.login-section {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.login-title,
.login-desc {
display: block;
}
.login-title {
color: #111827;
font-size: 18px;
font-weight: 900;
}
.login-desc {
margin-top: 6px;
color: #64748b;
font-size: 13px;
line-height: 1.4;
}
.login-button {
min-width: 84px;
}
.section-head,
.section-title {
display: flex;
align-items: center;
}
.section-head {
justify-content: space-between;
}
.section-title {
gap: 9px;
color: #111827;
font-size: 21px;
font-weight: 900;
}
.section-icon {
display: flex;
width: 24px;
height: 24px;
align-items: center;
justify-content: center;
border-radius: 8px;
background: #dbeafe;
color: #2563eb;
font-size: 13px;
font-weight: 900;
}
.section-link {
color: #64748b;
font-size: 14px;
}
.schedule-list {
display: flex;
margin-top: 16px;
flex-direction: column;
gap: 10px;
}
.schedule-row {
display: flex;
min-height: 82px;
align-items: center;
gap: 12px;
padding: 12px;
border-radius: 15px;
background: #f8fafc;
}
.step-dot {
display: flex;
width: 28px;
height: 28px;
flex: none;
align-items: center;
justify-content: center;
border-radius: 50%;
color: #ffffff;
font-size: 15px;
font-weight: 900;
}
.step-dot.blue {
background: #2563eb;
}
.step-dot.orange {
background: #f97316;
}
.schedule-copy {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: 5px;
}
.schedule-title {
color: #111827;
font-size: 16px;
font-weight: 900;
line-height: 1.25;
}
.schedule-desc {
color: #64748b;
font-size: 13px;
line-height: 1.35;
}
.schedule-action {
width: 68px;
min-height: 42px;
flex: none;
border-radius: 12px;
color: #ffffff;
font-size: 16px;
font-weight: 900;
}
.blue-action {
background: #2563eb;
}
.orange-action {
background: #f97316;
}
.map-grid {
display: grid;
margin-top: 16px;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.map-tile {
position: relative;
display: flex;
min-height: 108px;
align-items: center;
gap: 12px;
padding: 16px 12px;
border-radius: 17px;
overflow: hidden;
text-align: left;
}
.map-tile.ask {
background: linear-gradient(135deg, #1d4ed8, #2682ff);
}
.map-tile.practice {
background: linear-gradient(135deg, #16a34a, #22c55e);
}
.map-tile.learn {
background: linear-gradient(135deg, #f59e0b, #fb923c);
}
.map-tile.report {
background: linear-gradient(135deg, #ef4444, #f97316);
}
.map-icon {
width: 42px;
height: 42px;
flex: none;
filter: brightness(0) invert(1);
}
.map-copy {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
}
.map-title {
color: #ffffff;
font-size: 24px;
font-weight: 900;
line-height: 1.1;
}
.map-desc {
margin-top: 6px;
color: rgba(255, 255, 255, 0.9);
font-size: 13px;
line-height: 1.35;
}
.map-arrow {
color: #ffffff;
font-size: 28px;
font-weight: 500;
}
.tip-card {
display: flex;
margin-top: 18px;
min-height: 58px;
align-items: center;
gap: 10px;
padding: 0 14px;
border-radius: 16px;
background: #ffffff;
color: #475569;
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.06);
}
.tip-mark {
display: flex;
width: 28px;
height: 28px;
flex: none;
align-items: center;
justify-content: center;
border-radius: 50%;
background: #fef3c7;
color: #d97706;
font-size: 13px;
font-weight: 900;
}
.tip-card text:nth-child(2) {
flex: 1;
font-size: 14px;
line-height: 1.45;
}
.tip-arrow {
color: #94a3b8;
font-size: 24px;
}
</style>