511 lines
14 KiB
Vue
511 lines
14 KiB
Vue
<template>
|
||
<view class="page cases-page">
|
||
<view class="subpage-header">
|
||
<button
|
||
class="subpage-back-button"
|
||
aria-label="返回今日"
|
||
title="返回今日"
|
||
@click="navigateBackToToday"
|
||
>
|
||
←
|
||
</button>
|
||
<view class="subpage-heading">
|
||
<text class="page-title">案例素材</text>
|
||
<text class="page-subtitle">项目负责人或主管可把一线语音整理成培训案例;其他员工可查看本项目已入库案例。</text>
|
||
</view>
|
||
</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-else-if="canSubmitCases" class="panel">
|
||
<text class="panel-title">提交素材</text>
|
||
<view class="file-box" :class="{ 'has-file': Boolean(selectedFile) }">
|
||
<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-else-if="loggedIn" class="panel permission-panel">
|
||
<text class="panel-title">案例学习</text>
|
||
<text class="muted block">当前岗位可查看本项目已入库案例;提交案例素材需要主管或项目负责人权限。</text>
|
||
</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>
|
||
<view v-if="organizeResult.learningPoints?.length" class="case-learning-block">
|
||
<text class="detail-label">学习点</text>
|
||
<view v-for="point in organizeResult.learningPoints" :key="point" class="learning-point">
|
||
<text class="learning-bullet">•</text>
|
||
<text class="case-note">{{ point }}</text>
|
||
</view>
|
||
</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.caseId}-${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>
|
||
<view class="record-actions">
|
||
<text class="record-status">{{ record.status }}</text>
|
||
<button class="secondary-button detail-button" :disabled="caseDetailLoading" @click="openCaseDetail(record)">查看详情</button>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
<view v-else class="empty">暂无入库案例</view>
|
||
</view>
|
||
|
||
<view v-if="caseDetail" class="panel case-detail-panel">
|
||
<view class="section-head">
|
||
<view>
|
||
<text class="panel-title">案例详情</text>
|
||
<text class="record-desc">{{ caseDetail.time }} · {{ caseDetail.owner }} · {{ caseDetail.status }}</text>
|
||
</view>
|
||
<button class="ghost-button refresh-button" @click="caseDetail = null">关闭</button>
|
||
</view>
|
||
<text class="detail-label">原始转写</text>
|
||
<text class="case-content">{{ caseDetail.transcript || '暂无转写内容' }}</text>
|
||
<text class="detail-label">案例摘要</text>
|
||
<text class="case-note">{{ caseDetail.summary || '暂无案例摘要' }}</text>
|
||
<view v-if="caseDetail.learningPoints?.length" class="case-learning-block">
|
||
<text class="detail-label">学习点</text>
|
||
<view v-for="point in caseDetail.learningPoints" :key="point" class="learning-point">
|
||
<text class="learning-bullet">•</text>
|
||
<text class="case-note">{{ point }}</text>
|
||
</view>
|
||
</view>
|
||
<view v-if="caseDetail.supervisorComment" class="case-learning-block">
|
||
<text class="detail-label">主管点评</text>
|
||
<text class="case-note">{{ caseDetail.supervisorComment }}</text>
|
||
</view>
|
||
<view v-if="caseDetail.mediaUrl" class="case-media-block">
|
||
<text class="detail-label">原始音频</text>
|
||
<audio class="case-audio" :src="caseDetail.mediaUrl" controls />
|
||
</view>
|
||
<text class="case-note">当前版本已保留原始音频回放、学习点和主管点评;预渲染视频样片仍待正式媒体资源接入。</text>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, ref } from 'vue';
|
||
import { onShow } from '@dcloudio/uni-app';
|
||
import type { CaseCurateResponse, CaseDetailResponse, CaseOrganizeResponse, CaseRecord, CaseSelectedFile, CaseUploadResponse } from '@/types/api';
|
||
import { chooseCaseAudio, curateCase, getCaseCapabilities, getCaseDetail, getCaseRecords, organizeCase, uploadCaseAudio } from '@/services/case';
|
||
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
|
||
import { navigateBackToToday, resetPageScroll } from '@/services/navigation';
|
||
import { ensureEmployeePosition } from '@/services/position';
|
||
|
||
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 caseDetail = ref<CaseDetailResponse | null>(null);
|
||
const canSubmitCases = ref(false);
|
||
const status = ref<'idle' | 'uploading' | 'organizing' | 'curating'>('idle');
|
||
const recordsLoading = ref(false);
|
||
const caseDetailLoading = 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 = [];
|
||
caseDetail.value = null;
|
||
canSubmitCases.value = false;
|
||
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 loadCaseCapabilities = async () => {
|
||
if (!loggedIn.value) {
|
||
canSubmitCases.value = false;
|
||
return;
|
||
}
|
||
try {
|
||
const capabilities = await getCaseCapabilities();
|
||
canSubmitCases.value = Boolean(capabilities.canSubmit);
|
||
} catch (error) {
|
||
canSubmitCases.value = false;
|
||
handleRequestError(error, '案例权限加载失败', true);
|
||
}
|
||
};
|
||
|
||
const refreshRecords = () => {
|
||
if (!loggedIn.value) {
|
||
goLogin();
|
||
return;
|
||
}
|
||
void loadRecords();
|
||
};
|
||
|
||
const openCaseDetail = async (record: CaseRecord) => {
|
||
if (!record.caseId || caseDetailLoading.value) return;
|
||
caseDetailLoading.value = true;
|
||
message.value = '';
|
||
try {
|
||
caseDetail.value = await getCaseDetail(record.caseId);
|
||
} catch (error) {
|
||
handleRequestError(error, '案例详情加载失败');
|
||
} finally {
|
||
caseDetailLoading.value = false;
|
||
}
|
||
};
|
||
|
||
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`;
|
||
};
|
||
|
||
const handleShow = async () => {
|
||
resetPageScroll();
|
||
refreshAuthState();
|
||
if (loggedIn.value) {
|
||
if (!(await ensureEmployeePosition('/pages/user/cases/index'))) {
|
||
return;
|
||
}
|
||
void loadCaseCapabilities();
|
||
void loadRecords();
|
||
} else {
|
||
clearCaseData();
|
||
}
|
||
};
|
||
|
||
onShow(() => {
|
||
void handleShow();
|
||
});
|
||
</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-box.has-file {
|
||
border-color: #168243;
|
||
background: #f2f8f4;
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
.record-actions {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: flex-end;
|
||
gap: 8px;
|
||
}
|
||
|
||
.detail-button {
|
||
min-width: 84px;
|
||
padding: 6px 10px;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.case-detail-panel {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
}
|
||
|
||
.detail-label {
|
||
color: #1a1a1a;
|
||
font-size: 14px;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.case-media-block {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
|
||
.case-learning-block {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
}
|
||
|
||
.learning-point {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: 6px;
|
||
}
|
||
|
||
.learning-bullet {
|
||
color: #c0392b;
|
||
line-height: 1.6;
|
||
}
|
||
|
||
.case-audio {
|
||
width: 100%;
|
||
}
|
||
|
||
.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;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
}
|
||
|
||
.record-row > view {
|
||
min-width: 0;
|
||
flex: 1;
|
||
}
|
||
|
||
.record-status {
|
||
flex: 0 0 auto;
|
||
color: #16a34a;
|
||
font-size: 13px;
|
||
font-weight: 900;
|
||
white-space: nowrap;
|
||
}
|
||
</style>
|