Files
prop-ai-hr/mobile-uni/src/pages/user/cases/index.vue
T

511 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>