feat(aihr): align figma pages for release
This commit is contained in:
@@ -0,0 +1,343 @@
|
||||
<template>
|
||||
<view class="page history-detail-page">
|
||||
<view class="subpage-header">
|
||||
<button class="subpage-back-button" aria-label="返回我的页面" title="返回我的页面" @click="goBack">←</button>
|
||||
<view class="subpage-heading">
|
||||
<text class="page-title">训练详情</text>
|
||||
<text class="page-subtitle">{{ detail ? `${detail.scene} · ${detail.time}` : '训练记录' }}</text>
|
||||
</view>
|
||||
<button class="ghost-button refresh-button" :disabled="loading" @click="loadDetail">
|
||||
{{ loading ? '加载中' : '刷新' }}
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<view v-if="message" class="status-message">{{ message }}</view>
|
||||
|
||||
<view v-if="!loggedIn" class="panel login-panel">
|
||||
<text class="panel-title">需要登录</text>
|
||||
<text class="muted block">登录后查看训练详情。</text>
|
||||
<button class="primary-button login-button" @click="goLogin">去登录</button>
|
||||
</view>
|
||||
|
||||
<template v-else-if="detail">
|
||||
<view class="panel summary-panel">
|
||||
<view class="summary-head">
|
||||
<view>
|
||||
<text class="detail-title">{{ detail.scene }}</text>
|
||||
<text class="detail-meta">{{ detail.status }} · {{ detail.score }}分</text>
|
||||
</view>
|
||||
<text class="score-pill">{{ detail.score }}</text>
|
||||
</view>
|
||||
<text class="detail-desc">{{ detail.summary || detail.aiComment || '暂无训练总结' }}</text>
|
||||
</view>
|
||||
|
||||
<view v-if="detail.scoreItems?.length" class="panel">
|
||||
<text class="panel-title">评分明细</text>
|
||||
<view v-for="item in detail.scoreItems" :key="item.label" class="score-row">
|
||||
<view>
|
||||
<text class="score-label">{{ item.label }}</text>
|
||||
<text class="score-note">{{ item.note }}</text>
|
||||
</view>
|
||||
<text class="score-value">{{ item.value }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="detail.dialogue?.length" class="panel">
|
||||
<text class="panel-title">对话记录</text>
|
||||
<view v-for="(turn, index) in detail.dialogue" :key="`${turn.role}-${index}`" class="dialogue-row">
|
||||
<text class="dialogue-label">{{ turn.label }}</text>
|
||||
<text class="dialogue-text">{{ turn.text }}</text>
|
||||
<view v-if="hasAudio(turn)" class="audio-block">
|
||||
<audio v-if="audioSrc(turn, index)" class="audio-player" controls :src="audioSrc(turn, index)" />
|
||||
<button v-else class="secondary-button" :disabled="audioLoading[index]" @click="loadAudio(turn, index)">
|
||||
{{ audioLoading[index] ? '加载中' : '加载录音' }}
|
||||
</button>
|
||||
<text v-if="audioErrors[index]" class="audio-error">{{ audioErrors[index] }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="detail.annotations?.length || detail.mentorRewrite" class="panel">
|
||||
<text class="panel-title">复盘建议</text>
|
||||
<view v-for="item in detail.annotations" :key="`${item.turnIndex}-${item.issue}`" class="annotation-row">
|
||||
<text class="annotation-issue">{{ item.issue }}</text>
|
||||
<text class="annotation-rewrite">建议:{{ item.rewrite }}</text>
|
||||
<text class="annotation-sop">依据:{{ item.sopPoint }}</text>
|
||||
</view>
|
||||
<view v-if="detail.mentorRewrite" class="mentor-block">
|
||||
<text class="subsection-title">推荐话术</text>
|
||||
<text class="mentor-text">{{ detail.mentorRewrite }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<view v-else-if="loggedIn && !loading" class="panel empty-panel">
|
||||
<text class="empty">{{ message || '训练记录不存在或不属于当前账号' }}</text>
|
||||
<button class="secondary-button" @click="goBack">返回我的页面</button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onLoad, onUnload } from '@dcloudio/uni-app';
|
||||
import { ref } from 'vue';
|
||||
import type { ReviewDetail, ReviewDialogue } from '@/types/api';
|
||||
import { authenticatedFileUrl, releaseAuthenticatedFileUrl } from '@/services/api';
|
||||
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
|
||||
import { getPracticeHistoryDetail } from '@/services/practice';
|
||||
import { resetPageScroll } from '@/services/navigation';
|
||||
|
||||
const detail = ref<ReviewDetail | null>(null);
|
||||
const reviewId = ref(0);
|
||||
const loggedIn = ref(isLoggedIn());
|
||||
const loading = ref(false);
|
||||
const message = ref('');
|
||||
const audioSources = ref<Record<number, string>>({});
|
||||
const audioLoading = ref<Record<number, boolean>>({});
|
||||
const audioErrors = ref<Record<number, string>>({});
|
||||
|
||||
const audioSrc = (turn: ReviewDialogue, index: number) => audioSources.value[index] || turn.audioUrl || '';
|
||||
const hasAudio = (turn: ReviewDialogue) => Boolean(turn.audioOssId || turn.audioUrl);
|
||||
|
||||
const releaseAudio = () => {
|
||||
Object.values(audioSources.value).forEach(releaseAuthenticatedFileUrl);
|
||||
audioSources.value = {};
|
||||
};
|
||||
|
||||
const loadAudio = async (turn: ReviewDialogue, index: number) => {
|
||||
if (!turn.audioOssId || audioLoading.value[index]) return;
|
||||
audioLoading.value = { ...audioLoading.value, [index]: true };
|
||||
audioErrors.value = { ...audioErrors.value, [index]: '' };
|
||||
try {
|
||||
audioSources.value = {
|
||||
...audioSources.value,
|
||||
[index]: await authenticatedFileUrl(`/api/aihr/mobile/oss/${turn.audioOssId}`)
|
||||
};
|
||||
} catch {
|
||||
audioErrors.value = { ...audioErrors.value, [index]: '录音加载失败,请稍后重试' };
|
||||
} finally {
|
||||
audioLoading.value = { ...audioLoading.value, [index]: false };
|
||||
}
|
||||
};
|
||||
|
||||
const loadDetail = async () => {
|
||||
loggedIn.value = isLoggedIn();
|
||||
if (!loggedIn.value) return;
|
||||
if (!reviewId.value) {
|
||||
message.value = '缺少有效的训练记录ID';
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
message.value = '';
|
||||
releaseAudio();
|
||||
try {
|
||||
detail.value = await getPracticeHistoryDetail(reviewId.value);
|
||||
} catch (error) {
|
||||
detail.value = null;
|
||||
message.value = error instanceof Error ? error.message : '训练详情加载失败';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const goBack = () => uni.navigateBack({ delta: 1 });
|
||||
const goLogin = () => {
|
||||
rememberLoginRedirect(`/pages/user/history/index?id=${reviewId.value}`);
|
||||
uni.navigateTo({ url: '/pages/auth/login/index' });
|
||||
};
|
||||
|
||||
onLoad((query) => {
|
||||
reviewId.value = Number(query?.id || 0);
|
||||
resetPageScroll();
|
||||
void loadDetail();
|
||||
});
|
||||
|
||||
onUnload(releaseAudio);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.history-detail-page {
|
||||
padding: 0 18px 96px;
|
||||
}
|
||||
|
||||
.subpage-header,
|
||||
.summary-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.subpage-header {
|
||||
padding: 20px 0 16px;
|
||||
}
|
||||
|
||||
.subpage-back-button {
|
||||
width: 36px;
|
||||
min-width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
border: 1px solid #e1e2e5;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: #25282d;
|
||||
font-size: 20px;
|
||||
line-height: 34px;
|
||||
}
|
||||
|
||||
.subpage-heading {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.page-title,
|
||||
.detail-title,
|
||||
.panel-title,
|
||||
.score-label,
|
||||
.dialogue-label,
|
||||
.annotation-issue,
|
||||
.subsection-title {
|
||||
display: block;
|
||||
color: #25282d;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.page-subtitle,
|
||||
.detail-meta,
|
||||
.detail-desc,
|
||||
.score-note,
|
||||
.dialogue-text,
|
||||
.annotation-rewrite,
|
||||
.annotation-sop,
|
||||
.mentor-text,
|
||||
.muted,
|
||||
.audio-error {
|
||||
display: block;
|
||||
color: #73767c;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.page-subtitle,
|
||||
.detail-meta {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
margin-bottom: 12px;
|
||||
padding: 16px;
|
||||
border: 1px solid #e1e2e5;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.summary-panel {
|
||||
border-top: 3px solid #e60012;
|
||||
}
|
||||
|
||||
.score-pill {
|
||||
color: #e60012;
|
||||
font-size: 24px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.detail-desc {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.score-row,
|
||||
.dialogue-row,
|
||||
.annotation-row {
|
||||
padding: 12px 0;
|
||||
border-top: 1px solid #ececee;
|
||||
}
|
||||
|
||||
.score-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.score-note,
|
||||
.dialogue-text,
|
||||
.annotation-rewrite,
|
||||
.annotation-sop {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.score-value {
|
||||
color: #e60012;
|
||||
font-size: 20px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.dialogue-row:first-of-type,
|
||||
.annotation-row:first-of-type {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.dialogue-text,
|
||||
.mentor-text {
|
||||
color: #34373c;
|
||||
}
|
||||
|
||||
.audio-block {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.audio-player {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.audio-error {
|
||||
margin-top: 6px;
|
||||
color: #c43b3b;
|
||||
}
|
||||
|
||||
.mentor-block {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #ececee;
|
||||
}
|
||||
|
||||
.mentor-text {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.refresh-button,
|
||||
.secondary-button,
|
||||
.primary-button {
|
||||
min-height: 36px;
|
||||
padding: 0 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.refresh-button,
|
||||
.secondary-button {
|
||||
border: 1px solid #dedee1;
|
||||
background: #fff;
|
||||
color: #66696f;
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
border: 0;
|
||||
background: #e60012;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.login-panel,
|
||||
.empty-panel {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-button,
|
||||
.empty-panel button {
|
||||
margin-top: 14px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user