feat: restore learning feedback and knowledge capabilities
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
<template>
|
||||
<view class="page employee-page-shell exam-page">
|
||||
<view class="subpage-header">
|
||||
<button class="subpage-back-button" aria-label="返回学习" :disabled="submitting" @click="goBack">←</button>
|
||||
<view class="subpage-heading">
|
||||
<text class="employee-kicker">岗前 / 入职考试</text>
|
||||
<text class="page-title">{{ exam?.title || '考试任务' }}</text>
|
||||
<text class="page-subtitle">{{ examMeta }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<text v-if="message" class="status-message">{{ message }}</text>
|
||||
<view v-if="loading" class="panel employee-card">考试加载中…</view>
|
||||
<template v-else-if="exam">
|
||||
<view class="summary-card employee-card">
|
||||
<view><text class="summary-value">{{ exam.questionCount }}</text><text class="summary-label">题目</text></view>
|
||||
<view><text class="summary-value">{{ exam.passingScore }}</text><text class="summary-label">及格分</text></view>
|
||||
<view><text class="summary-value">{{ exam.totalScore }}</text><text class="summary-label">总分</text></view>
|
||||
</view>
|
||||
|
||||
<view v-if="result" class="panel employee-card result-panel" :class="result.passed ? 'passed' : 'failed'">
|
||||
<text class="result-title">{{ result.passed ? '考试通过' : '本次未通过' }}</text>
|
||||
<text class="result-score">{{ result.score }} 分</text>
|
||||
<text class="muted">{{ result.passed ? '上岗资格会按正式规则刷新。' : '请根据错题依据复习后再参加补考。' }}</text>
|
||||
</view>
|
||||
|
||||
<view class="question-list">
|
||||
<view v-for="(item, index) in exam.questions" :key="item.id" class="panel employee-card question-card">
|
||||
<view class="question-head">
|
||||
<text class="question-index">{{ index + 1 }} / {{ exam.questions.length }}</text>
|
||||
<button class="ghost-button speech-button" @click="speakQuestion(item)">{{ speechLabel(item) }}</button>
|
||||
</view>
|
||||
<text class="question-stem">{{ item.stem }}</text>
|
||||
<view v-if="item.options?.length" class="option-list">
|
||||
<button
|
||||
v-for="option in item.options"
|
||||
:key="option"
|
||||
class="option-button"
|
||||
:class="{ selected: isSelected(item, option) }"
|
||||
:disabled="Boolean(result)"
|
||||
@click="toggleOption(item, option)"
|
||||
>
|
||||
<text class="option-marker">{{ isSelected(item, option) ? '✓' : '' }}</text>
|
||||
<text>{{ option }}</text>
|
||||
</button>
|
||||
</view>
|
||||
<textarea
|
||||
v-else
|
||||
class="textarea short-answer"
|
||||
:value="String(answers[String(item.id)] || '')"
|
||||
:disabled="Boolean(result)"
|
||||
maxlength="500"
|
||||
placeholder="请输入回答"
|
||||
@input="setShortAnswer(item, $event)"
|
||||
/>
|
||||
<view v-if="result && item.explanation" class="explanation">
|
||||
<text class="explanation-title">参考依据</text>
|
||||
<text>{{ item.explanation }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="!result" class="submit-dock">
|
||||
<text>已答 {{ answeredCount }} / {{ exam.questions.length }} 题</text>
|
||||
<button class="primary-button" :disabled="submitting || answeredCount !== exam.questions.length" @click="submit">
|
||||
{{ submitting ? '提交中' : '提交试卷' }}
|
||||
</button>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref } from 'vue';
|
||||
import { onLoad } from '@dcloudio/uni-app';
|
||||
import type { ExamDetail, ExamQuestion, ExamSubmitResult } from '@/types/api';
|
||||
import { getMyExam, submitMyExam } from '@/services/exam';
|
||||
import { getAuth } from '@/services/auth';
|
||||
import { createSpeechPlaybackController, type SpeechPlaybackSnapshot } from '@/services/speech';
|
||||
|
||||
const id = ref(0);
|
||||
const exam = ref<ExamDetail | null>(null);
|
||||
const answers = ref<Record<string, string | string[]>>({});
|
||||
const result = ref<ExamSubmitResult | null>(null);
|
||||
const loading = ref(false);
|
||||
const submitting = ref(false);
|
||||
const message = ref('');
|
||||
const speech = ref<SpeechPlaybackSnapshot>({ key: '', status: 'idle' });
|
||||
const speechController = createSpeechPlaybackController(
|
||||
(snapshot) => { speech.value = snapshot; },
|
||||
(error) => { message.value = error; }
|
||||
);
|
||||
const examMeta = computed(() => exam.value
|
||||
? `${exam.value.positionCode || '通用岗位'}${exam.value.dueDate ? ` · 截止 ${exam.value.dueDate}` : ''}`
|
||||
: '读取正式发布的考试内容');
|
||||
const answeredCount = computed(() => Object.values(answers.value).filter((value) => Array.isArray(value) ? value.length : String(value).trim()).length);
|
||||
const storageKey = computed(() => `aihr_exam_draft:${getAuth().phone || 'anonymous'}:${id.value}`);
|
||||
|
||||
const goBack = () => uni.navigateBack({ delta: 1 });
|
||||
const requestId = () => `exam:${id.value}:${Date.now()}:${Math.random().toString(36).slice(2, 8)}`;
|
||||
const isSelected = (question: ExamQuestion, option: string) => {
|
||||
const value = answers.value[String(question.id)];
|
||||
return Array.isArray(value) ? value.includes(option) : value === option;
|
||||
};
|
||||
const toggleOption = (question: ExamQuestion, option: string) => {
|
||||
const key = String(question.id);
|
||||
if (question.format === 'multiple') {
|
||||
const current = Array.isArray(answers.value[key]) ? [...answers.value[key] as string[]] : [];
|
||||
answers.value[key] = current.includes(option) ? current.filter((item) => item !== option) : [...current, option];
|
||||
} else {
|
||||
answers.value[key] = option;
|
||||
}
|
||||
uni.setStorageSync(storageKey.value, answers.value);
|
||||
};
|
||||
const setShortAnswer = (question: ExamQuestion, event: unknown) => {
|
||||
const value = typeof event === 'object' && event !== null && 'detail' in event
|
||||
&& typeof (event as { detail?: unknown }).detail === 'object'
|
||||
&& (event as { detail?: { value?: unknown } }).detail !== null
|
||||
? String((event as { detail: { value?: unknown } }).detail.value || '')
|
||||
: '';
|
||||
answers.value[String(question.id)] = value;
|
||||
uni.setStorageSync(storageKey.value, answers.value);
|
||||
};
|
||||
const speakQuestion = (question: ExamQuestion) => speechController.toggle(`q-${question.id}`, [question.stem, ...(question.options || [])].join('。'));
|
||||
const speechLabel = (question: ExamQuestion) => speech.value.key === `q-${question.id}`
|
||||
? (speech.value.status === 'playing' ? '停止' : '生成中')
|
||||
: '播报题目';
|
||||
const load = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
exam.value = await getMyExam(id.value);
|
||||
const stored = uni.getStorageSync(storageKey.value);
|
||||
if (stored && typeof stored === 'object') answers.value = stored as Record<string, string | string[]>;
|
||||
} catch (error) { message.value = error instanceof Error ? error.message : '考试读取失败'; }
|
||||
finally { loading.value = false; }
|
||||
};
|
||||
const submit = async () => {
|
||||
if (!exam.value || submitting.value || answeredCount.value !== exam.value.questions.length) return;
|
||||
submitting.value = true;
|
||||
try {
|
||||
result.value = await submitMyExam(id.value, answers.value, requestId());
|
||||
const explanations = new Map(result.value.results.map((item) => [item.questionId, item.explanation]));
|
||||
exam.value.questions = exam.value.questions.map((item) => ({
|
||||
...item,
|
||||
explanation: explanations.get(item.id) || item.explanation
|
||||
}));
|
||||
uni.removeStorageSync(storageKey.value);
|
||||
message.value = result.value.passed ? '考试已通过,资格状态将刷新' : '试卷已提交,请根据错题复习';
|
||||
} catch (error) { message.value = error instanceof Error ? error.message : '试卷提交失败'; }
|
||||
finally { submitting.value = false; }
|
||||
};
|
||||
onLoad((options) => { id.value = Number(options?.id || 0); void load(); });
|
||||
onBeforeUnmount(() => speechController.destroy());
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.exam-page { max-width: var(--mobile-shell-max-width); margin: 0 auto; padding-bottom: 140px; }
|
||||
.status-message { display: block; margin-top: 14px; padding: 12px; border-radius: 10px; background: #fff0f1; color: #a9000d; }
|
||||
.summary-card { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-top: 18px; padding: 16px; text-align: center; }
|
||||
.summary-value, .summary-label { display: block; }
|
||||
.summary-value { font-size: 20px; font-weight: 900; }
|
||||
.summary-label { margin-top: 4px; color: var(--muted); font-size: 12px; }
|
||||
.result-panel { display: grid; gap: 6px; border-width: 2px; }
|
||||
.result-panel.passed { border-color: #7bc99a; }
|
||||
.result-panel.failed { border-color: #f0b06a; }
|
||||
.result-title { font-size: 18px; font-weight: 900; }
|
||||
.result-score { color: var(--brand); font-size: 36px; font-weight: 900; }
|
||||
.question-list { display: grid; gap: 4px; }
|
||||
.question-card { display: grid; gap: 14px; }
|
||||
.question-head { display: flex; align-items: center; justify-content: space-between; }
|
||||
.question-index { color: var(--knowledge); font-size: 13px; font-weight: 900; }
|
||||
.speech-button { min-height: 36px; padding: 0 10px; font-size: 12px; }
|
||||
.question-stem { overflow-wrap: anywhere; font-size: 17px; font-weight: 900; line-height: 1.65; }
|
||||
.option-list { display: grid; gap: 9px; }
|
||||
.option-button { display: flex; gap: 10px; min-height: 48px; align-items: center; padding: 10px 12px; border: 1px solid var(--line); background: #fff; color: var(--ink); text-align: left; }
|
||||
.option-button.selected { border-color: #ef9ca4; background: var(--employee-primary-soft); }
|
||||
.option-marker { flex: 0 0 24px; height: 24px; border: 1px solid #c8d0db; border-radius: 50%; color: var(--brand); line-height: 22px; text-align: center; }
|
||||
.short-answer { min-height: 120px; }
|
||||
.explanation { display: grid; gap: 6px; padding: 12px; border-radius: 10px; background: #f1f6fb; color: #435268; font-size: 13px; line-height: 1.6; }
|
||||
.explanation-title { font-weight: 900; }
|
||||
.submit-dock { position: fixed; z-index: 10; right: 0; bottom: 0; left: 0; display: grid; gap: 8px; max-width: var(--mobile-shell-max-width); margin: 0 auto; padding: 12px 16px calc(12px + env(safe-area-inset-bottom)); border-top: 1px solid var(--line); background: rgba(255,255,255,.96); color: var(--muted); font-size: 12px; }
|
||||
</style>
|
||||
@@ -156,6 +156,26 @@
|
||||
<view v-else-if="!onboardingLoading" class="empty">暂无正式岗前/入职任务,等待 HR 或组织系统派发</view>
|
||||
</view>
|
||||
|
||||
<view v-if="loggedIn" class="panel exam-panel">
|
||||
<view class="panel-head">
|
||||
<view>
|
||||
<text class="panel-title">考试任务</text>
|
||||
<text class="panel-caption">主管或经理发布的岗位考试,提交后自动评分并保留题目版本</text>
|
||||
</view>
|
||||
<button class="ghost-button compact-button" :disabled="examLoading" @click="loadExams">{{ examLoading ? '加载中' : '刷新' }}</button>
|
||||
</view>
|
||||
<view v-if="exams.length" class="exam-list">
|
||||
<view v-for="item in exams" :key="item.id" class="exam-row">
|
||||
<view class="exam-copy">
|
||||
<text class="exam-title">{{ item.title }}</text>
|
||||
<text class="exam-meta">{{ item.status }} · {{ item.questionCount }}题 · 及格{{ item.passingScore }}分{{ item.dueDate ? ` · 截止${item.dueDate}` : '' }}</text>
|
||||
</view>
|
||||
<button class="secondary-button compact-button" @click="openExam(item.id)">{{ item.submissionCount ? '再次查看' : '去考试' }}</button>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else-if="!examLoading" class="empty">暂无考试任务</view>
|
||||
</view>
|
||||
|
||||
<view v-if="loggedIn" class="panel qualification-panel">
|
||||
<view class="panel-head">
|
||||
<view>
|
||||
@@ -258,7 +278,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { onShow } from '@dcloudio/uni-app';
|
||||
import { onHide, onShow, onUnload } from '@dcloudio/uni-app';
|
||||
import type {
|
||||
KnowledgeSearchResponse,
|
||||
OnboardTask,
|
||||
@@ -266,7 +286,8 @@ import type {
|
||||
PracticeAssignment,
|
||||
PracticeMistake,
|
||||
PracticePrepCard,
|
||||
QualificationSummary
|
||||
QualificationSummary,
|
||||
ExamSummary
|
||||
} from '@/types/api';
|
||||
import { completeOnboardTask, getOnboardTasks, getPracticeAssignments, getPracticeMistakes, getPracticePrepCard, getQualification } from '@/services/practice';
|
||||
import { getPositionSopSummary, searchKnowledge } from '@/services/knowledge';
|
||||
@@ -274,6 +295,8 @@ import { getSelectedPositionProfile, isLoggedIn, rememberLoginRedirect } from '@
|
||||
import { buildLearningTopics, selectLearningTopic } from '@/services/learning';
|
||||
import { navigateBackToToday, resetPageScroll } from '@/services/navigation';
|
||||
import { ensureEmployeePosition } from '@/services/position';
|
||||
import { listMyExams } from '@/services/exam';
|
||||
import { createPageRequestScope, currentAccountKey, type RequestScopeTicket } from '@/services/request-scope';
|
||||
|
||||
const defaultScenarioId = 'complaint-water';
|
||||
const pendingAssignmentKey = 'aihr_pending_assignment_id';
|
||||
@@ -299,6 +322,10 @@ const qualification = ref<QualificationSummary | null>(null);
|
||||
const onboardingLoading = ref(false);
|
||||
const onboardingCompletingId = ref<number | null>(null);
|
||||
const qualificationLoading = ref(false);
|
||||
const exams = ref<ExamSummary[]>([]);
|
||||
const examLoading = ref(false);
|
||||
const requests = createPageRequestScope();
|
||||
let visibleAccountKey = '';
|
||||
|
||||
const defaultTopic = computed(() => `${positionProfile.value.value} 岗前 SOP`);
|
||||
const topics = computed(() => buildLearningTopics(
|
||||
@@ -390,112 +417,166 @@ const cleanOrderedText = (text = '') =>
|
||||
.replace(/^(?:[一二三四五六七八九十]+[、..]|[0-9]+[.)、..])\s*/, '')
|
||||
.trim();
|
||||
|
||||
const loadAssignments = async () => {
|
||||
const loadAssignmentsRequest = async (request: RequestScopeTicket) => {
|
||||
if (!loggedIn.value) return;
|
||||
assignmentLoading.value = true;
|
||||
try {
|
||||
assignments.value = await getPracticeAssignments(true);
|
||||
const nextAssignments = await getPracticeAssignments(true);
|
||||
if (!requests.isCurrent(request)) return;
|
||||
assignments.value = nextAssignments;
|
||||
} catch (error) {
|
||||
if (!requests.isCurrent(request)) return;
|
||||
assignments.value = [];
|
||||
handleRequestError(error, '学习任务加载失败');
|
||||
} finally {
|
||||
assignmentLoading.value = false;
|
||||
if (requests.isCurrent(request)) assignmentLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadMistakes = async () => {
|
||||
const loadAssignments = () => loadAssignmentsRequest(requests.begin('assignments'));
|
||||
|
||||
const loadMistakesRequest = async (request: RequestScopeTicket) => {
|
||||
if (!loggedIn.value) return;
|
||||
mistakeLoading.value = true;
|
||||
try {
|
||||
mistakes.value = await getPracticeMistakes();
|
||||
const nextMistakes = await getPracticeMistakes();
|
||||
if (!requests.isCurrent(request)) return;
|
||||
mistakes.value = nextMistakes;
|
||||
} catch (error) {
|
||||
if (!requests.isCurrent(request)) return;
|
||||
mistakes.value = [];
|
||||
handleRequestError(error, '错题本加载失败');
|
||||
} finally {
|
||||
mistakeLoading.value = false;
|
||||
if (requests.isCurrent(request)) mistakeLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadOnboarding = async () => {
|
||||
const loadMistakes = () => loadMistakesRequest(requests.begin('mistakes'));
|
||||
|
||||
const loadOnboardingRequest = async (request: RequestScopeTicket) => {
|
||||
if (!loggedIn.value) return;
|
||||
onboardingLoading.value = true;
|
||||
try {
|
||||
onboardTasks.value = await getOnboardTasks();
|
||||
const nextTasks = await getOnboardTasks();
|
||||
if (!requests.isCurrent(request)) return;
|
||||
onboardTasks.value = nextTasks;
|
||||
} catch (error) {
|
||||
if (!requests.isCurrent(request)) return;
|
||||
onboardTasks.value = [];
|
||||
exams.value = [];
|
||||
handleRequestError(error, '岗前任务加载失败');
|
||||
} finally {
|
||||
onboardingLoading.value = false;
|
||||
if (requests.isCurrent(request)) onboardingLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadOnboarding = () => loadOnboardingRequest(requests.begin('onboarding'));
|
||||
|
||||
const completeOnboarding = async (task: OnboardTask) => {
|
||||
if (onboardingCompletingId.value !== null || task.status === '已完成') return;
|
||||
const request = requests.begin('complete-onboarding');
|
||||
onboardingCompletingId.value = task.id;
|
||||
try {
|
||||
await completeOnboardTask(task.id);
|
||||
if (!requests.isCurrent(request)) return;
|
||||
onboardTasks.value = onboardTasks.value.map((item) => item.id === task.id
|
||||
? { ...item, status: '已完成', completedTime: new Date().toISOString() }
|
||||
: item);
|
||||
message.value = '任务已记录为完成,正式资格仍以 HR 复核为准';
|
||||
} catch (error) {
|
||||
if (!requests.isCurrent(request)) return;
|
||||
handleRequestError(error, '任务完成记录失败');
|
||||
} finally {
|
||||
onboardingCompletingId.value = null;
|
||||
if (requests.isCurrent(request)) onboardingCompletingId.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const loadQualification = async () => {
|
||||
const loadQualificationRequest = async (request: RequestScopeTicket) => {
|
||||
if (!loggedIn.value) return;
|
||||
qualificationLoading.value = true;
|
||||
try {
|
||||
qualification.value = await getQualification();
|
||||
const nextQualification = await getQualification();
|
||||
if (!requests.isCurrent(request)) return;
|
||||
qualification.value = nextQualification;
|
||||
} catch (error) {
|
||||
if (!requests.isCurrent(request)) return;
|
||||
qualification.value = { status: 'NOT_CONFIGURED', positionCode: '', gates: [] };
|
||||
handleRequestError(error, '上岗资格加载失败');
|
||||
} finally {
|
||||
qualificationLoading.value = false;
|
||||
if (requests.isCurrent(request)) qualificationLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadPrepCard = async () => {
|
||||
const loadQualification = () => loadQualificationRequest(requests.begin('qualification'));
|
||||
|
||||
const loadExamsRequest = async (request: RequestScopeTicket) => {
|
||||
if (!loggedIn.value) return;
|
||||
examLoading.value = true;
|
||||
try {
|
||||
const nextExams = await listMyExams();
|
||||
if (!requests.isCurrent(request)) return;
|
||||
exams.value = nextExams;
|
||||
} catch (error) {
|
||||
if (!requests.isCurrent(request)) return;
|
||||
exams.value = [];
|
||||
handleRequestError(error, '考试任务加载失败');
|
||||
} finally {
|
||||
if (requests.isCurrent(request)) examLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadExams = () => loadExamsRequest(requests.begin('exams'));
|
||||
|
||||
const loadPrepCardRequest = async (request: RequestScopeTicket) => {
|
||||
if (!loggedIn.value) return;
|
||||
prepLoading.value = true;
|
||||
try {
|
||||
prepCard.value = await getPracticePrepCard(prepScenarioId.value);
|
||||
const nextPrepCard = await getPracticePrepCard(prepScenarioId.value);
|
||||
if (!requests.isCurrent(request)) return;
|
||||
prepCard.value = nextPrepCard;
|
||||
} catch (error) {
|
||||
if (!requests.isCurrent(request)) return;
|
||||
prepCard.value = null;
|
||||
handleRequestError(error, '预习卡加载失败');
|
||||
} finally {
|
||||
prepLoading.value = false;
|
||||
if (requests.isCurrent(request)) prepLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadKnowledge = async (topic: string) => {
|
||||
const loadPrepCard = () => loadPrepCardRequest(requests.begin('prep-card'));
|
||||
|
||||
const loadKnowledge = async (topic: string, request: RequestScopeTicket = requests.begin('knowledge')) => {
|
||||
if (!loggedIn.value || knowledgeLoading.value) return;
|
||||
activeTopic.value = topic;
|
||||
knowledgeLoading.value = true;
|
||||
try {
|
||||
knowledgeResult.value = await searchKnowledge(topic);
|
||||
const nextKnowledge = await searchKnowledge(topic);
|
||||
if (!requests.isCurrent(request)) return;
|
||||
knowledgeResult.value = nextKnowledge;
|
||||
} catch (error) {
|
||||
if (!requests.isCurrent(request)) return;
|
||||
knowledgeResult.value = null;
|
||||
handleRequestError(error, '岗位 SOP 检索失败');
|
||||
} finally {
|
||||
knowledgeLoading.value = false;
|
||||
if (requests.isCurrent(request)) knowledgeLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadPositionSopSummary = async () => {
|
||||
const loadPositionSopSummary = async (request: RequestScopeTicket = requests.begin('position-sop')) => {
|
||||
if (!loggedIn.value) return;
|
||||
try {
|
||||
positionSopSummary.value = await getPositionSopSummary(positionProfile.value.value);
|
||||
const nextSummary = await getPositionSopSummary(positionProfile.value.value);
|
||||
if (!requests.isCurrent(request)) return;
|
||||
positionSopSummary.value = nextSummary;
|
||||
} catch (error) {
|
||||
if (!requests.isCurrent(request)) return;
|
||||
positionSopSummary.value = null;
|
||||
handleRequestError(error, '岗位学习范围加载失败');
|
||||
}
|
||||
};
|
||||
|
||||
const loadPage = async () => {
|
||||
const pageRequest = requests.begin('page');
|
||||
refreshAuthState();
|
||||
message.value = '';
|
||||
if (!loggedIn.value) {
|
||||
@@ -508,19 +589,49 @@ const loadPage = async () => {
|
||||
positionSopSummary.value = null;
|
||||
return;
|
||||
}
|
||||
if (!(await ensureEmployeePosition('/pages/user/learning/index'))) {
|
||||
if (!(await ensureEmployeePosition('/pages/user/learning/index')) || !requests.isCurrent(pageRequest)) {
|
||||
return;
|
||||
}
|
||||
refreshAuthState();
|
||||
await Promise.all([loadAssignments(), loadMistakes(), loadOnboarding(), loadQualification()]);
|
||||
await Promise.all([
|
||||
loadAssignmentsRequest(requests.begin('assignments')),
|
||||
loadMistakesRequest(requests.begin('mistakes')),
|
||||
loadOnboardingRequest(requests.begin('onboarding')),
|
||||
loadQualificationRequest(requests.begin('qualification')),
|
||||
loadExamsRequest(requests.begin('exams'))
|
||||
]);
|
||||
if (!requests.isCurrent(pageRequest)) return;
|
||||
activeTopic.value = selectLearningTopic(assignments.value, defaultTopic.value);
|
||||
await Promise.all([
|
||||
loadPositionSopSummary(),
|
||||
loadKnowledge(activeTopic.value),
|
||||
loadPrepCard()
|
||||
loadPositionSopSummary(requests.begin('position-sop')),
|
||||
loadKnowledge(activeTopic.value, requests.begin('knowledge')),
|
||||
loadPrepCardRequest(requests.begin('prep-card'))
|
||||
]);
|
||||
};
|
||||
|
||||
const invalidateRequests = () => {
|
||||
requests.invalidate();
|
||||
assignmentLoading.value = false;
|
||||
mistakeLoading.value = false;
|
||||
prepLoading.value = false;
|
||||
knowledgeLoading.value = false;
|
||||
onboardingLoading.value = false;
|
||||
onboardingCompletingId.value = null;
|
||||
qualificationLoading.value = false;
|
||||
examLoading.value = false;
|
||||
};
|
||||
|
||||
const clearPageData = () => {
|
||||
assignments.value = [];
|
||||
mistakes.value = [];
|
||||
onboardTasks.value = [];
|
||||
qualification.value = null;
|
||||
prepCard.value = null;
|
||||
knowledgeResult.value = null;
|
||||
positionSopSummary.value = null;
|
||||
exams.value = [];
|
||||
};
|
||||
|
||||
const goLogin = () => {
|
||||
rememberLoginRedirect('/pages/user/learning/index');
|
||||
uni.navigateTo({ url: '/pages/auth/login/index' });
|
||||
@@ -537,6 +648,10 @@ const openMistake = (item: PracticeMistake) => {
|
||||
uni.switchTab({ url: '/pages/user/practice/index' });
|
||||
};
|
||||
|
||||
const openExam = (id: number) => {
|
||||
uni.navigateTo({ url: `/pages/user/exam/index?id=${id}` });
|
||||
};
|
||||
|
||||
const goPractice = () => {
|
||||
if (firstPracticeAssignment.value) {
|
||||
uni.setStorageSync(pendingAssignmentKey, String(firstPracticeAssignment.value.id));
|
||||
@@ -554,9 +669,19 @@ const goCases = () => {
|
||||
};
|
||||
|
||||
onShow(() => {
|
||||
const nextAccountKey = currentAccountKey();
|
||||
if (visibleAccountKey && visibleAccountKey !== nextAccountKey) {
|
||||
clearPageData();
|
||||
message.value = '';
|
||||
}
|
||||
visibleAccountKey = nextAccountKey;
|
||||
requests.activate();
|
||||
resetPageScroll();
|
||||
void loadPage();
|
||||
});
|
||||
|
||||
onHide(invalidateRequests);
|
||||
onUnload(invalidateRequests);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -571,6 +696,45 @@ onShow(() => {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.exam-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.exam-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: #fafcff;
|
||||
}
|
||||
|
||||
.exam-copy {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.exam-title,
|
||||
.exam-meta {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.exam-title {
|
||||
font-weight: 900;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.exam-meta {
|
||||
margin-top: 5px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.position-sop-status {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
<template>
|
||||
<view class="page employee-page-shell points-page">
|
||||
<view class="subpage-header">
|
||||
<button class="subpage-back-button" aria-label="返回" @click="goBack">←</button>
|
||||
<view class="subpage-heading">
|
||||
<text class="employee-kicker">积分与学分分账</text>
|
||||
<text class="page-title">我的积分</text>
|
||||
<text class="page-subtitle">这里只记录积分和学习学分,不是现金、不能提现。</text>
|
||||
</view>
|
||||
</view>
|
||||
<text v-if="message" class="status-message">{{ message }}</text>
|
||||
<view class="balance-grid">
|
||||
<view v-for="item in balances" :key="item.accountType" class="balance-card employee-card">
|
||||
<text class="balance-label">{{ item.displayName }}</text>
|
||||
<text class="balance-value">{{ item.balance }}</text>
|
||||
<text class="balance-unit">{{ item.unitName }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="panel employee-card rules-card">
|
||||
<text class="panel-title">获得规则</text>
|
||||
<text>问题或回答审核通过后,按当前规则记基础积分;最佳答案可获额外积分。只有经人工确认并沉淀知识库的成果才可能记学习学分。</text>
|
||||
</view>
|
||||
<view class="panel employee-card ledger-card">
|
||||
<text class="panel-title">最近明细</text>
|
||||
<view v-if="ledger.length" class="ledger-list">
|
||||
<view v-for="item in ledger" :key="item.id" class="ledger-row">
|
||||
<view>
|
||||
<text class="ledger-title">{{ item.remark || item.sourceType }} · {{ item.accountType === 'STUDY_CREDIT' ? '学习学分' : '奖励积分' }}</text>
|
||||
<text class="ledger-time">{{ item.createTime }}</text>
|
||||
</view>
|
||||
<text class="ledger-points" :class="{ reverse: item.direction === 'REVERSE' }">
|
||||
{{ item.direction === 'REVERSE' ? '-' : '+' }}{{ Math.abs(item.points) }}{{ item.accountType === 'STUDY_CREDIT' ? ' 学分' : ' 积分' }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
<text v-else class="muted empty">暂无积分明细</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { onShow } from '@dcloudio/uni-app';
|
||||
import type { IncentiveBalance, IncentiveLedgerItem } from '@/types/api';
|
||||
import { getMyIncentiveBalances, getMyIncentiveLedger } from '@/services/questions';
|
||||
|
||||
const balances = ref<IncentiveBalance[]>([]);
|
||||
const ledger = ref<IncentiveLedgerItem[]>([]);
|
||||
const message = ref('');
|
||||
const goBack = () => uni.navigateBack({ delta: 1 });
|
||||
const load = async () => {
|
||||
message.value = '';
|
||||
try { [balances.value, ledger.value] = await Promise.all([getMyIncentiveBalances(), getMyIncentiveLedger()]); }
|
||||
catch (error) { message.value = error instanceof Error ? error.message : '积分读取失败'; }
|
||||
};
|
||||
onShow(() => { void load(); });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.points-page { max-width: var(--mobile-shell-max-width); margin: 0 auto; }
|
||||
.status-message { display: block; margin-top: 14px; color: #a9000d; }
|
||||
.balance-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin-top: 18px; }
|
||||
.balance-card { display: grid; gap: 4px; padding: 18px; }
|
||||
.balance-label, .balance-unit { color: var(--muted); font-size: 12px; }
|
||||
.balance-value { color: var(--brand); font-size: 34px; font-weight: 900; }
|
||||
.rules-card { color: var(--muted); font-size: 13px; line-height: 1.65; }
|
||||
.rules-card .panel-title { display: block; margin-bottom: 8px; }
|
||||
.ledger-card { display: grid; gap: 12px; }
|
||||
.ledger-list { display: grid; }
|
||||
.ledger-row { display: flex; gap: 12px; align-items: center; justify-content: space-between; padding: 12px 0; border-bottom: 1px solid var(--line); }
|
||||
.ledger-title, .ledger-time { display: block; }
|
||||
.ledger-title { font-weight: 800; }
|
||||
.ledger-time { margin-top: 4px; color: var(--muted); font-size: 12px; }
|
||||
.ledger-points { color: var(--success); font-size: 18px; font-weight: 900; }
|
||||
.ledger-points.reverse { color: var(--brand); }
|
||||
.empty { padding: 8px 0; }
|
||||
</style>
|
||||
@@ -84,6 +84,13 @@
|
||||
<view class="assignment-copy">
|
||||
<text class="assignment-title">{{ item.question || item.scenarioName }}</text>
|
||||
<text v-if="assignmentDesc(item)" class="assignment-desc">{{ assignmentDesc(item) }}</text>
|
||||
<button
|
||||
class="turn-voice-button daily-question-voice"
|
||||
:disabled="speechState.status === 'loading' && speechState.key !== `daily-${item.id}`"
|
||||
@click.stop="speakGenerated(`daily-${item.id}`, item.question || item.scenarioName)"
|
||||
>
|
||||
{{ speechButtonLabel(`daily-${item.id}`) }}
|
||||
</button>
|
||||
</view>
|
||||
<view class="daily-answer">
|
||||
<textarea
|
||||
@@ -111,6 +118,25 @@
|
||||
</button>
|
||||
</view>
|
||||
<text v-if="dailyAudioUrls[item.id]" class="voice-state">已附带语音回放</text>
|
||||
<view v-if="item.status === '已完成'" class="daily-feedback-card">
|
||||
<view class="daily-feedback-head">
|
||||
<text class="daily-feedback-title">点评与参考答案</text>
|
||||
<button
|
||||
class="turn-voice-button"
|
||||
:disabled="speechState.status === 'loading' && speechState.key !== `daily-result-${item.id}`"
|
||||
@click.stop="speakGenerated(`daily-result-${item.id}`, dailyFeedbackSpeech(item))"
|
||||
>
|
||||
{{ speechButtonLabel(`daily-result-${item.id}`) }}
|
||||
</button>
|
||||
</view>
|
||||
<text v-if="item.feedback" class="daily-feedback-copy">点评:{{ item.feedback }}</text>
|
||||
<text v-if="item.referenceAnswer" class="daily-reference-copy">参考答案:{{ item.referenceAnswer }}</text>
|
||||
<view class="item-feedback-actions">
|
||||
<text>这道题对工作有帮助吗?</text>
|
||||
<button :class="{ active: item.itemFeedback === 'useful' }" :disabled="feedbackSubmittingId === item.id" @click.stop="submitItemFeedback(item.id, 'useful')">有用</button>
|
||||
<button :class="{ active: item.itemFeedback === 'not_useful' }" :disabled="feedbackSubmittingId === item.id" @click.stop="submitItemFeedback(item.id, 'not_useful')">无用</button>
|
||||
</view>
|
||||
</view>
|
||||
<button class="primary-button small-button" :disabled="Boolean(answeringId) || item.status === '已完成'" @click="submitDaily(item)">
|
||||
{{ item.status === '已完成' ? '已完成' : '提交' }}
|
||||
</button>
|
||||
@@ -154,7 +180,17 @@
|
||||
</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>
|
||||
<view class="turn-heading">
|
||||
<text class="turn-role">{{ roleLabel(turn.role) }}</text>
|
||||
<button
|
||||
v-if="turn.role !== 'trainee'"
|
||||
class="turn-voice-button"
|
||||
:disabled="speechState.status === 'loading' && speechState.key !== `turn-${index}`"
|
||||
@click="speakGenerated(`turn-${index}`, turn.text)"
|
||||
>
|
||||
{{ speechButtonLabel(`turn-${index}`) }}
|
||||
</button>
|
||||
</view>
|
||||
<text class="turn-text">{{ turn.text }}</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -206,13 +242,27 @@
|
||||
</view>
|
||||
|
||||
<view v-if="result" class="panel result-card employee-card">
|
||||
<text class="result-label">本次评分</text>
|
||||
<view class="result-title-row">
|
||||
<text class="result-label">本次评分</text>
|
||||
<button
|
||||
class="turn-voice-button result-voice-button"
|
||||
:disabled="speechState.status === 'loading' && speechState.key !== 'practice-result'"
|
||||
@click="speakGenerated('practice-result', practiceResultSpeech())"
|
||||
>
|
||||
{{ speechButtonLabel('practice-result') }}
|
||||
</button>
|
||||
</view>
|
||||
<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 v-if="completedAssignmentId" class="item-feedback-actions result-feedback-actions">
|
||||
<text>这个专项对工作有帮助吗?</text>
|
||||
<button :class="{ active: completedAssignmentFeedback === 'useful' }" :disabled="feedbackSubmittingId === completedAssignmentId" @click="submitItemFeedback(completedAssignmentId, 'useful')">有用</button>
|
||||
<button :class="{ active: completedAssignmentFeedback === 'not_useful' }" :disabled="feedbackSubmittingId === completedAssignmentId" @click="submitItemFeedback(completedAssignmentId, 'not_useful')">无用</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="result" class="panel satisfaction-card employee-card">
|
||||
@@ -258,21 +308,23 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { onShow } from '@dcloudio/uni-app';
|
||||
import { onHide, onShow, onUnload } from '@dcloudio/uni-app';
|
||||
import type {
|
||||
PracticeFinishResponse,
|
||||
PracticePrepCard,
|
||||
PracticeAssignment,
|
||||
PracticeRole,
|
||||
SpeechPlaybackStatus,
|
||||
PracticeTurn,
|
||||
PracticeRecord
|
||||
} from '@/types/api';
|
||||
import { answerDailyDrill, finishPractice, getPracticeAssignments, getPracticeHistory, getPracticePrepCard, startPractice, submitPracticeSatisfaction, submitPracticeTurn } from '@/services/practice';
|
||||
import { answerDailyDrill, finishPractice, getPracticeAssignments, getPracticeHistory, getPracticePrepCard, startPractice, submitPracticeQuestionFeedback, submitPracticeSatisfaction, submitPracticeTurn } from '@/services/practice';
|
||||
import { getSelectedPosition, isLoggedIn, rememberLoginRedirect } from '@/services/auth';
|
||||
import { searchKnowledge } from '@/services/knowledge';
|
||||
import { resetPageScroll } from '@/services/navigation';
|
||||
import { ensureEmployeePosition } from '@/services/position';
|
||||
import { chooseSpeechAudio, transcribeSpeechBlob, transcribeSpeechFile } from '@/services/speech';
|
||||
import { chooseSpeechAudio, createSpeechPlaybackController, transcribeSpeechBlob, transcribeSpeechFile } from '@/services/speech';
|
||||
import { createPageRequestScope, currentAccountKey, type RequestScopeTicket } from '@/services/request-scope';
|
||||
|
||||
type PracticeStatus = 'idle' | 'starting' | 'active' | 'submitting' | 'finished';
|
||||
type RecentPracticeItem = {
|
||||
@@ -322,15 +374,32 @@ const dailyAudioOssIds = ref<Record<number, number | undefined>>({});
|
||||
const assignmentLoading = ref(false);
|
||||
const answeringId = ref<number | null>(null);
|
||||
const activeAssignmentId = ref<number | undefined>();
|
||||
const completedAssignmentId = ref<number | undefined>();
|
||||
const completedAssignmentFeedback = ref<'useful' | 'not_useful' | null>(null);
|
||||
const feedbackSubmittingId = ref<number | null>(null);
|
||||
const highlightedAssignmentId = ref<number | null>(null);
|
||||
const loggedIn = ref(false);
|
||||
const recording = ref(false);
|
||||
const recordingStarting = ref(false);
|
||||
const recordingAssignmentId = ref<number | null>(null);
|
||||
const transcribing = ref(false);
|
||||
const draftAudioUrl = ref('');
|
||||
const draftAudioOssId = ref<number | undefined>();
|
||||
const speechState = ref<{ key: string; status: SpeechPlaybackStatus }>({ key: '', status: 'idle' });
|
||||
const mediaRecorder = ref<MediaRecorder | null>(null);
|
||||
const mediaStream = ref<MediaStream | null>(null);
|
||||
let recordingCaptureGeneration = 0;
|
||||
let transcriptionGeneration = 0;
|
||||
let abortTranscription: (() => void) | null = null;
|
||||
let visibleAccountKey = '';
|
||||
const requests = createPageRequestScope();
|
||||
const speechPlayback = createSpeechPlaybackController(
|
||||
(state) => { speechState.value = state; },
|
||||
(text) => {
|
||||
message.value = text;
|
||||
uni.showToast({ title: text, icon: 'none' });
|
||||
}
|
||||
);
|
||||
const busy = computed(() => status.value === 'starting' || status.value === 'submitting');
|
||||
const practiceView = computed<'prep' | 'active' | 'result'>(() => {
|
||||
if (result.value) return 'result';
|
||||
@@ -385,6 +454,32 @@ const roleLabel = (role: PracticeRole) => {
|
||||
return '我';
|
||||
};
|
||||
|
||||
const speechButtonLabel = (key: string) => {
|
||||
if (speechState.value.key !== key) return '语音播报';
|
||||
if (speechState.value.status === 'loading') return '生成中';
|
||||
if (speechState.value.status === 'playing') return '停止播报';
|
||||
return '语音播报';
|
||||
};
|
||||
|
||||
const speakGenerated = (key: string, text?: string) => {
|
||||
void speechPlayback.toggle(key, text || '');
|
||||
};
|
||||
|
||||
const dailyFeedbackSpeech = (item: PracticeAssignment) => [
|
||||
typeof item.score === 'number' ? `本题得分${item.score}分` : '',
|
||||
item.feedback ? `点评,${item.feedback}` : '',
|
||||
item.referenceAnswer ? `参考答案,${item.referenceAnswer}` : ''
|
||||
].filter(Boolean).join('。');
|
||||
|
||||
const practiceResultSpeech = () => {
|
||||
if (!result.value) return '';
|
||||
return [
|
||||
`本次评分${result.value.totalScore}分`,
|
||||
result.value.aiComment ? `点评,${result.value.aiComment}` : '',
|
||||
result.value.mentorRewrite ? `老师傅建议,${result.value.mentorRewrite}` : ''
|
||||
].filter(Boolean).join('。');
|
||||
};
|
||||
|
||||
const assignmentDesc = (item: PracticeAssignment) => {
|
||||
if (item.source === 'daily' && item.status !== '已完成') return '提交后显示反馈与参考答案';
|
||||
if (item.source === 'daily' && item.status === '已完成') {
|
||||
@@ -411,6 +506,7 @@ const refreshAuthState = () => {
|
||||
};
|
||||
|
||||
const clearPracticeData = () => {
|
||||
speechPlayback.stop();
|
||||
cancelRecording();
|
||||
assignments.value = [];
|
||||
practiceHistory.value = [];
|
||||
@@ -443,6 +539,9 @@ const clearPracticeData = () => {
|
||||
answeringId.value = null;
|
||||
recordingAssignmentId.value = null;
|
||||
activeAssignmentId.value = undefined;
|
||||
completedAssignmentId.value = undefined;
|
||||
completedAssignmentFeedback.value = null;
|
||||
feedbackSubmittingId.value = null;
|
||||
highlightedAssignmentId.value = null;
|
||||
transcribing.value = false;
|
||||
clearDraftAudio();
|
||||
@@ -471,7 +570,17 @@ const stopMediaTracks = () => {
|
||||
mediaStream.value = null;
|
||||
};
|
||||
|
||||
const cancelTranscription = () => {
|
||||
transcriptionGeneration += 1;
|
||||
abortTranscription?.();
|
||||
abortTranscription = null;
|
||||
transcribing.value = false;
|
||||
};
|
||||
|
||||
const cancelRecording = () => {
|
||||
recordingCaptureGeneration += 1;
|
||||
cancelTranscription();
|
||||
recordingStarting.value = false;
|
||||
const recorder = mediaRecorder.value;
|
||||
if (recorder && recorder.state !== 'inactive') {
|
||||
recorder.ondataavailable = null;
|
||||
@@ -517,54 +626,77 @@ const applyDailyAsrResult = (assignmentId: number, result: { text?: string; audi
|
||||
|
||||
const transcribeBlob = async (blob: Blob, assignmentId?: number) => {
|
||||
if (transcribing.value) return;
|
||||
const generation = ++transcriptionGeneration;
|
||||
transcribing.value = true;
|
||||
message.value = '语音转写中';
|
||||
try {
|
||||
const result = await transcribeSpeechBlob(blob);
|
||||
const result = await transcribeSpeechBlob(blob, 'practice-audio.webm', (abort) => {
|
||||
if (generation === transcriptionGeneration) abortTranscription = abort;
|
||||
});
|
||||
if (generation !== transcriptionGeneration) return;
|
||||
if (assignmentId === undefined) {
|
||||
applyAsrResult(result);
|
||||
} else {
|
||||
applyDailyAsrResult(assignmentId, result);
|
||||
}
|
||||
} catch (error) {
|
||||
if (generation !== transcriptionGeneration) return;
|
||||
handleError(error, '语音转写失败');
|
||||
} finally {
|
||||
transcribing.value = false;
|
||||
if (generation === transcriptionGeneration) {
|
||||
abortTranscription = null;
|
||||
transcribing.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const pickAudio = async (assignmentId?: number) => {
|
||||
if (busy.value || recording.value || transcribing.value) return;
|
||||
if (!requireLogin()) return;
|
||||
const generation = ++transcriptionGeneration;
|
||||
transcribing.value = true;
|
||||
message.value = '语音转写中';
|
||||
try {
|
||||
const file = await chooseSpeechAudio();
|
||||
const result = await transcribeSpeechFile(file);
|
||||
if (generation !== transcriptionGeneration) return;
|
||||
const result = await transcribeSpeechFile(file, (abort) => {
|
||||
if (generation === transcriptionGeneration) abortTranscription = abort;
|
||||
});
|
||||
if (generation !== transcriptionGeneration) return;
|
||||
if (assignmentId === undefined) {
|
||||
applyAsrResult(result);
|
||||
} else {
|
||||
applyDailyAsrResult(assignmentId, result);
|
||||
}
|
||||
} catch (error) {
|
||||
if (generation !== transcriptionGeneration) return;
|
||||
handleError(error, '语音转写失败');
|
||||
} finally {
|
||||
transcribing.value = false;
|
||||
if (generation === transcriptionGeneration) {
|
||||
abortTranscription = null;
|
||||
transcribing.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const startRecording = async (assignmentId?: number) => {
|
||||
if (busy.value || transcribing.value) return;
|
||||
if (busy.value || recordingStarting.value || recording.value || transcribing.value) return;
|
||||
if (!requireLogin()) return;
|
||||
if (typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === 'undefined') {
|
||||
await pickAudio(assignmentId);
|
||||
return;
|
||||
}
|
||||
const generation = ++recordingCaptureGeneration;
|
||||
recordingStarting.value = true;
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
if (generation !== recordingCaptureGeneration) {
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
return;
|
||||
}
|
||||
mediaStream.value = stream;
|
||||
const recorder = new MediaRecorder(stream);
|
||||
const chunks: Blob[] = [];
|
||||
mediaStream.value = stream;
|
||||
mediaRecorder.value = recorder;
|
||||
recordingAssignmentId.value = assignmentId ?? null;
|
||||
recorder.ondataavailable = (event) => {
|
||||
@@ -588,7 +720,10 @@ const startRecording = async (assignmentId?: number) => {
|
||||
message.value = '正在录音';
|
||||
} catch (error) {
|
||||
recordingAssignmentId.value = null;
|
||||
await pickAudio(assignmentId);
|
||||
stopMediaTracks();
|
||||
if (generation === recordingCaptureGeneration) await pickAudio(assignmentId);
|
||||
} finally {
|
||||
if (generation === recordingCaptureGeneration) recordingStarting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -600,6 +735,7 @@ const stopRecording = () => {
|
||||
};
|
||||
|
||||
const toggleRecording = (assignmentId?: number) => {
|
||||
if (recordingStarting.value) return;
|
||||
if (recording.value) {
|
||||
stopRecording();
|
||||
return;
|
||||
@@ -622,7 +758,7 @@ const handleError = (error: unknown, fallback: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const loadAssignments = async () => {
|
||||
const loadAssignments = async (request: RequestScopeTicket = requests.begin('assignments')) => {
|
||||
refreshAuthState();
|
||||
if (!loggedIn.value) {
|
||||
clearPracticeData();
|
||||
@@ -630,26 +766,32 @@ const loadAssignments = async () => {
|
||||
}
|
||||
assignmentLoading.value = true;
|
||||
try {
|
||||
assignments.value = await getPracticeAssignments(true);
|
||||
const nextAssignments = await getPracticeAssignments(true);
|
||||
if (!requests.isCurrent(request)) return;
|
||||
let nextHistory: PracticeRecord[] = [];
|
||||
try {
|
||||
practiceHistory.value = await getPracticeHistory();
|
||||
nextHistory = await getPracticeHistory();
|
||||
} catch {
|
||||
practiceHistory.value = [];
|
||||
nextHistory = [];
|
||||
}
|
||||
drillAnswers.value = Object.fromEntries(assignments.value.map((item) => [item.id, item.userAnswer || '']));
|
||||
if (!requests.isCurrent(request)) return;
|
||||
assignments.value = nextAssignments;
|
||||
practiceHistory.value = nextHistory;
|
||||
drillAnswers.value = Object.fromEntries(nextAssignments.map((item) => [item.id, item.userAnswer || '']));
|
||||
dailyAudioUrls.value = Object.fromEntries(
|
||||
assignments.value.filter((item) => item.audioUrl).map((item) => [item.id, item.audioUrl || ''])
|
||||
nextAssignments.filter((item) => item.audioUrl).map((item) => [item.id, item.audioUrl || ''])
|
||||
);
|
||||
dailyAudioOssIds.value = Object.fromEntries(
|
||||
assignments.value
|
||||
nextAssignments
|
||||
.filter((item) => item.audioOssId !== undefined && item.audioOssId !== null)
|
||||
.map((item) => [item.id, Number(item.audioOssId)])
|
||||
);
|
||||
consumePendingAssignment();
|
||||
} catch (error) {
|
||||
if (!requests.isCurrent(request)) return;
|
||||
handleError(error, '今日任务加载失败');
|
||||
} finally {
|
||||
assignmentLoading.value = false;
|
||||
if (requests.isCurrent(request)) assignmentLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -693,10 +835,12 @@ const submitDaily = async (item: PracticeAssignment) => {
|
||||
message.value = '请先输入或录制处理话术';
|
||||
return;
|
||||
}
|
||||
const request = requests.begin(`daily-${item.id}`);
|
||||
answeringId.value = item.id;
|
||||
message.value = '';
|
||||
try {
|
||||
const result = await answerDailyDrill(item.id, answer, audioUrl, audioOssId);
|
||||
if (!requests.isCurrent(request)) return;
|
||||
assignments.value = assignments.value.map((row) =>
|
||||
row.id === item.id
|
||||
? {
|
||||
@@ -715,9 +859,28 @@ const submitDaily = async (item: PracticeAssignment) => {
|
||||
);
|
||||
message.value = `每日三题得分 ${result.score}(${result.scoreMode === 'LLM' ? 'AI语义评估' : '规则兜底'})`;
|
||||
} catch (error) {
|
||||
if (!requests.isCurrent(request)) return;
|
||||
handleError(error, '每日三题提交失败');
|
||||
} finally {
|
||||
answeringId.value = null;
|
||||
if (requests.isCurrent(request)) answeringId.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const submitItemFeedback = async (assignmentId: number, verdict: 'useful' | 'not_useful') => {
|
||||
if (feedbackSubmittingId.value) return;
|
||||
const request = requests.begin(`feedback-${assignmentId}`);
|
||||
feedbackSubmittingId.value = assignmentId;
|
||||
try {
|
||||
await submitPracticeQuestionFeedback(assignmentId, verdict);
|
||||
if (!requests.isCurrent(request)) return;
|
||||
assignments.value = assignments.value.map((item) => item.id === assignmentId ? { ...item, itemFeedback: verdict } : item);
|
||||
if (completedAssignmentId.value === assignmentId) completedAssignmentFeedback.value = verdict;
|
||||
message.value = verdict === 'useful' ? '已记录为有用' : '已记录为无用,题目会进入优化队列';
|
||||
} catch (error) {
|
||||
if (!requests.isCurrent(request)) return;
|
||||
handleError(error, '题目反馈提交失败');
|
||||
} finally {
|
||||
if (requests.isCurrent(request)) feedbackSubmittingId.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -729,7 +892,10 @@ const startAssignment = (item: PracticeAssignment) => {
|
||||
void start(item.scenarioId || defaultScenarioId, item.id);
|
||||
};
|
||||
|
||||
const loadPrepCard = async (scenarioId = selectedScenarioId.value) => {
|
||||
const loadPrepCard = async (
|
||||
scenarioId = selectedScenarioId.value,
|
||||
request: RequestScopeTicket = requests.begin('prep-card')
|
||||
) => {
|
||||
refreshAuthState();
|
||||
if (!loggedIn.value) {
|
||||
prepCard.value = null;
|
||||
@@ -737,18 +903,24 @@ const loadPrepCard = async (scenarioId = selectedScenarioId.value) => {
|
||||
}
|
||||
prepLoading.value = true;
|
||||
try {
|
||||
prepCard.value = await getPracticePrepCard(scenarioId);
|
||||
const nextPrepCard = await getPracticePrepCard(scenarioId);
|
||||
if (!requests.isCurrent(request)) return;
|
||||
prepCard.value = nextPrepCard;
|
||||
} catch (error) {
|
||||
if (!requests.isCurrent(request)) return;
|
||||
prepCard.value = null;
|
||||
handleError(error, '预习卡加载失败');
|
||||
} finally {
|
||||
prepLoading.value = false;
|
||||
if (requests.isCurrent(request)) prepLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const start = async (scenarioId = defaultScenarioId, assignmentId?: number) => {
|
||||
if (!canStartPractice.value) return;
|
||||
if (!requireLogin()) return;
|
||||
const request = requests.begin('session-start');
|
||||
speechPlayback.stop();
|
||||
cancelRecording();
|
||||
selectedScenarioId.value = scenarioId;
|
||||
status.value = 'starting';
|
||||
message.value = '';
|
||||
@@ -771,6 +943,7 @@ const start = async (scenarioId = defaultScenarioId, assignmentId?: number) => {
|
||||
activeAssignmentId.value = assignmentId;
|
||||
try {
|
||||
const data = await startPractice(scenarioId, assignmentId);
|
||||
if (!requests.isCurrent(request)) return;
|
||||
activeScenarioName.value = data.scenarioName || activeScenarioName.value;
|
||||
emotionScore.value = data.emotion ?? 0;
|
||||
trustScore.value = data.trust ?? 0;
|
||||
@@ -778,6 +951,7 @@ const start = async (scenarioId = defaultScenarioId, assignmentId?: number) => {
|
||||
appendTurn('customer', data.customerText);
|
||||
status.value = 'active';
|
||||
} catch (error) {
|
||||
if (!requests.isCurrent(request)) return;
|
||||
status.value = 'idle';
|
||||
activeAssignmentId.value = undefined;
|
||||
handleError(error, '训练启动失败');
|
||||
@@ -787,19 +961,22 @@ const start = async (scenarioId = defaultScenarioId, assignmentId?: number) => {
|
||||
const loadHelp = async () => {
|
||||
if (helpLoading.value || busy.value) return;
|
||||
if (!requireLogin()) return;
|
||||
const request = requests.begin('help');
|
||||
helpLoading.value = true;
|
||||
message.value = '';
|
||||
try {
|
||||
const data = await searchKnowledge(`${activeScenarioName.value} SOP 处理要点`);
|
||||
if (!requests.isCurrent(request)) return;
|
||||
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) {
|
||||
if (!requests.isCurrent(request)) return;
|
||||
handleError(error, '求助失败');
|
||||
} finally {
|
||||
helpLoading.value = false;
|
||||
if (requests.isCurrent(request)) helpLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -824,6 +1001,7 @@ const discardLastCoachAttempt = () => {
|
||||
const adjustCoachHint = async (style: string) => {
|
||||
if (busy.value || adjustingStyle.value) return;
|
||||
if (!sessionId.value || lastCoachRoundIndex.value === null || !lastTraineeText.value) return;
|
||||
const request = requests.begin('coach-adjust');
|
||||
adjustingStyle.value = style;
|
||||
message.value = '';
|
||||
try {
|
||||
@@ -834,15 +1012,17 @@ const adjustCoachHint = async (style: string) => {
|
||||
regenerate: true,
|
||||
style
|
||||
});
|
||||
if (!requests.isCurrent(request)) return;
|
||||
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) {
|
||||
if (!requests.isCurrent(request)) return;
|
||||
handleError(error, '建议调整失败');
|
||||
} finally {
|
||||
adjustingStyle.value = '';
|
||||
if (requests.isCurrent(request)) adjustingStyle.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -853,6 +1033,7 @@ const submit = async () => {
|
||||
message.value = '请先输入回应';
|
||||
return;
|
||||
}
|
||||
const request = requests.begin('turn-submit');
|
||||
status.value = 'submitting';
|
||||
message.value = '';
|
||||
const audioUrl = draftAudioUrl.value || undefined;
|
||||
@@ -872,6 +1053,7 @@ const submit = async () => {
|
||||
traineeAudioUrl: audioUrl,
|
||||
traineeAudioOssId: audioOssId
|
||||
});
|
||||
if (!requests.isCurrent(request)) return;
|
||||
lastTraineeText.value = text;
|
||||
lastCoachRoundIndex.value = submittedRound;
|
||||
emotionScore.value = data.emotion ?? emotionScore.value;
|
||||
@@ -891,12 +1073,14 @@ const submit = async () => {
|
||||
roundIndex.value = data.roundIndex;
|
||||
status.value = 'active';
|
||||
} catch (error) {
|
||||
if (!requests.isCurrent(request)) return;
|
||||
status.value = 'active';
|
||||
turns.value = previousTurns;
|
||||
draft.value = text;
|
||||
draftAudioUrl.value = audioUrl || '';
|
||||
draftAudioOssId.value = audioOssId;
|
||||
handleError(error, '提交失败');
|
||||
uni.showToast({ title: message.value || '提交失败', icon: 'none', duration: 3000 });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -907,15 +1091,22 @@ const finish = async (force = false) => {
|
||||
message.value = redFlagActive.value ? '请先按老师傅建议重说本轮' : '请先完成至少一轮回应';
|
||||
return;
|
||||
}
|
||||
const request = requests.begin('finish');
|
||||
status.value = 'submitting';
|
||||
message.value = '';
|
||||
try {
|
||||
result.value = await finishPractice(sessionId.value, activeAssignmentId.value);
|
||||
const assignmentId = activeAssignmentId.value;
|
||||
const nextResult = await finishPractice(sessionId.value, assignmentId);
|
||||
if (!requests.isCurrent(request)) return;
|
||||
result.value = nextResult;
|
||||
status.value = 'finished';
|
||||
message.value = '训练完成,主管端待复盘已更新';
|
||||
completedAssignmentId.value = assignmentId;
|
||||
completedAssignmentFeedback.value = null;
|
||||
activeAssignmentId.value = undefined;
|
||||
await loadAssignments();
|
||||
} catch (error) {
|
||||
if (!requests.isCurrent(request)) return;
|
||||
status.value = 'active';
|
||||
handleError(error, '评分失败');
|
||||
}
|
||||
@@ -923,32 +1114,66 @@ const finish = async (force = false) => {
|
||||
|
||||
const sendSatisfaction = async () => {
|
||||
if (!sessionId.value || satisfactionScore.value < 1 || satisfactionScore.value > 5 || satisfactionBusy.value) return;
|
||||
const request = requests.begin('satisfaction');
|
||||
satisfactionBusy.value = true;
|
||||
message.value = '';
|
||||
try {
|
||||
await submitPracticeSatisfaction(sessionId.value, satisfactionScore.value, satisfactionComment.value.trim());
|
||||
if (!requests.isCurrent(request)) return;
|
||||
satisfactionSubmitted.value = true;
|
||||
} catch (error) {
|
||||
if (!requests.isCurrent(request)) return;
|
||||
handleError(error, '体验反馈提交失败');
|
||||
} finally {
|
||||
satisfactionBusy.value = false;
|
||||
if (requests.isCurrent(request)) satisfactionBusy.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleShow = async () => {
|
||||
const pageRequest = requests.begin('page');
|
||||
resetPageScroll();
|
||||
refreshAuthState();
|
||||
if (loggedIn.value && !(await ensureEmployeePosition('/pages/user/practice/index'))) {
|
||||
if (loggedIn.value && (!(await ensureEmployeePosition('/pages/user/practice/index')) || !requests.isCurrent(pageRequest))) {
|
||||
return;
|
||||
}
|
||||
if (!requests.isCurrent(pageRequest)) return;
|
||||
positionLabel.value = getSelectedPosition() || '生活顾问';
|
||||
void loadAssignments();
|
||||
void loadPrepCard();
|
||||
void loadAssignments(requests.begin('assignments'));
|
||||
void loadPrepCard(selectedScenarioId.value, requests.begin('prep-card'));
|
||||
};
|
||||
|
||||
const invalidateRequests = () => {
|
||||
requests.invalidate();
|
||||
assignmentLoading.value = false;
|
||||
prepLoading.value = false;
|
||||
answeringId.value = null;
|
||||
feedbackSubmittingId.value = null;
|
||||
helpLoading.value = false;
|
||||
adjustingStyle.value = '';
|
||||
satisfactionBusy.value = false;
|
||||
if (status.value === 'starting') status.value = 'idle';
|
||||
if (status.value === 'submitting') status.value = sessionId.value ? 'active' : 'idle';
|
||||
};
|
||||
|
||||
onShow(() => {
|
||||
const nextAccountKey = currentAccountKey();
|
||||
if (visibleAccountKey && visibleAccountKey !== nextAccountKey) clearPracticeData();
|
||||
visibleAccountKey = nextAccountKey;
|
||||
requests.activate();
|
||||
void handleShow();
|
||||
});
|
||||
|
||||
onHide(() => {
|
||||
invalidateRequests();
|
||||
speechPlayback.stop();
|
||||
cancelRecording();
|
||||
});
|
||||
|
||||
onUnload(() => {
|
||||
invalidateRequests();
|
||||
speechPlayback.destroy();
|
||||
cancelRecording();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -1694,7 +1919,7 @@ onShow(() => {
|
||||
|
||||
.prep-mini text:last-child {
|
||||
color: var(--employee-muted);
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
@@ -1833,10 +2058,16 @@ onShow(() => {
|
||||
}
|
||||
|
||||
.daily-row .daily-answer {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.daily-row .small-button {
|
||||
width: 100%;
|
||||
margin-top: 10px;
|
||||
background: linear-gradient(135deg, #ed1f28, #ff4b4d);
|
||||
box-shadow: 0 6px 12px rgba(230, 0, 18, 0.16);
|
||||
}
|
||||
@@ -1872,7 +2103,7 @@ onShow(() => {
|
||||
|
||||
.capability-strip text:last-child {
|
||||
color: var(--employee-muted);
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.practice-head .page-title {
|
||||
@@ -2019,4 +2250,101 @@ onShow(() => {
|
||||
background: linear-gradient(135deg, #ed1f28, #ff4b4d);
|
||||
box-shadow: 0 8px 16px rgba(231, 0, 20, 0.18);
|
||||
}
|
||||
|
||||
.turn-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.turn-voice-button {
|
||||
width: auto;
|
||||
min-width: 76px;
|
||||
min-height: 32px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #f2b9bf;
|
||||
border-radius: 999px;
|
||||
background: #ffffff;
|
||||
color: #e60012;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
line-height: 30px;
|
||||
}
|
||||
|
||||
.daily-question-voice {
|
||||
margin: 8px 0 0;
|
||||
}
|
||||
|
||||
.daily-feedback-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
padding: 12px;
|
||||
border: 1px solid #f4c9cd;
|
||||
border-radius: 14px;
|
||||
background: #fff8f8;
|
||||
}
|
||||
|
||||
.daily-feedback-head,
|
||||
.result-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.daily-feedback-title {
|
||||
color: var(--employee-text);
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.daily-feedback-copy,
|
||||
.daily-reference-copy {
|
||||
color: var(--employee-text);
|
||||
font-size: 13px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.item-feedback-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-top: 4px;
|
||||
color: var(--employee-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.item-feedback-actions button {
|
||||
min-height: 34px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #d4dce6;
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
color: #4f5c70;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.item-feedback-actions button.active {
|
||||
border-color: #ef9da5;
|
||||
background: var(--employee-primary-soft);
|
||||
color: var(--employee-primary);
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.result-feedback-actions {
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--employee-line);
|
||||
}
|
||||
|
||||
.daily-reference-copy {
|
||||
color: var(--employee-muted);
|
||||
}
|
||||
|
||||
.result-voice-button {
|
||||
flex: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -22,22 +22,42 @@
|
||||
<view>
|
||||
<text class="panel-title">岗位身份</text>
|
||||
<text class="position-title">{{ positionProfile.value }} · {{ positionProfile.heroTitle }}</text>
|
||||
<text class="position-source">岗位来自人力系统组织同步,如需调整请联系 HR。</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="position-actions">
|
||||
<button
|
||||
v-for="item in positionCards"
|
||||
:key="item.value"
|
||||
class="position-chip"
|
||||
:class="{ active: item.value === positionProfile.value }"
|
||||
@click="changePosition(item.value)"
|
||||
>
|
||||
{{ item.value }}
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="profile" class="panel profile-summary">
|
||||
<view v-if="supervisorLearning" class="panel supervisor-mode-panel">
|
||||
<view>
|
||||
<text class="panel-title">当前为学员模式</text>
|
||||
<text class="position-source">主管组织身份未改变,仅切换到个人学习与训练界面。</text>
|
||||
</view>
|
||||
<button class="secondary-button account-action" @click="returnToSupervisor">返回主管工作台</button>
|
||||
</view>
|
||||
|
||||
<view class="panel display-panel">
|
||||
<view class="display-heading">
|
||||
<view>
|
||||
<text class="panel-title">显示与辅助</text>
|
||||
<text class="display-current">字体大小 · {{ currentFontSizeLabel }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="font-size-options" role="group" aria-label="字体大小">
|
||||
<button
|
||||
v-for="option in appFontSizeOptions"
|
||||
:key="option.value"
|
||||
class="font-size-option"
|
||||
:class="{ active: fontSize === option.value }"
|
||||
:aria-pressed="fontSize === option.value"
|
||||
@click="changeFontSize(option.value)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</view>
|
||||
<text class="display-note">选择后会同步到今日、练、问、我等全部页面,并保留你的设置。</text>
|
||||
</view>
|
||||
|
||||
<view v-if="loggedIn && profile" class="panel profile-summary">
|
||||
<text class="panel-title">成长摘要</text>
|
||||
<text class="ai-score-disclaimer">AI训练综合分仅作训练参考,不等于BRD五维绩效总分,人可复核/否决</text>
|
||||
<view class="score-row">
|
||||
@@ -92,7 +112,15 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="evidence" class="panel evidence-panel">
|
||||
<button v-if="loggedIn" class="panel points-entry" @click="openPoints">
|
||||
<view>
|
||||
<text class="panel-title">我的积分</text>
|
||||
<text class="points-entry-desc">查看问题榜奖励积分与学习学分明细</text>
|
||||
</view>
|
||||
<text class="points-entry-arrow">›</text>
|
||||
</button>
|
||||
|
||||
<view v-if="loggedIn && evidence" class="panel evidence-panel">
|
||||
<text class="panel-title">成长证据包(待人工复核)</text>
|
||||
<view class="evidence-grid">
|
||||
<view class="evidence-metric">
|
||||
@@ -153,20 +181,24 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import { onShow } from '@dcloudio/uni-app';
|
||||
import type { CompetencyProfile, PracticeRecord, PromotionEvidence } from '@/types/api';
|
||||
import {
|
||||
clearAuth,
|
||||
getAuth,
|
||||
getPositionProfile,
|
||||
getSelectedPositionProfile,
|
||||
isLoggedIn,
|
||||
rememberLoginRedirect,
|
||||
saveSelectedPosition,
|
||||
userPositionOptions,
|
||||
type UserPosition
|
||||
isSupervisorLearnerMode,
|
||||
leaveSupervisorLearnerMode,
|
||||
rememberLoginRedirect
|
||||
} from '@/services/auth';
|
||||
import {
|
||||
appFontSizeOptions,
|
||||
getAppFontSize,
|
||||
setAppFontSize,
|
||||
type AppFontSize
|
||||
} from '@/services/accessibility';
|
||||
import { getCompetencyProfile, getPracticeHistory, getPromotionEvidence } from '@/services/practice';
|
||||
import { resetPageScroll } from '@/services/navigation';
|
||||
import { ensureEmployeePosition } from '@/services/position';
|
||||
@@ -179,31 +211,62 @@ const profile = ref<CompetencyProfile | null>(null);
|
||||
const history = ref<PracticeRecord[]>([]);
|
||||
const evidence = ref<PromotionEvidence | null>(null);
|
||||
const positionProfile = ref(getSelectedPositionProfile());
|
||||
const positionCards = userPositionOptions.map((item) => getPositionProfile(item));
|
||||
const supervisorLearning = ref(false);
|
||||
const fontSize = ref<AppFontSize>(getAppFontSize());
|
||||
const currentFontSizeLabel = computed(() =>
|
||||
appFontSizeOptions.find((item) => item.value === fontSize.value)?.label || '标准'
|
||||
);
|
||||
let profileLoadGeneration = 0;
|
||||
let profileAccountPhone = '';
|
||||
|
||||
const refreshAuth = () => {
|
||||
const auth = getAuth();
|
||||
loggedIn.value = isLoggedIn();
|
||||
phone.value = auth.phone;
|
||||
positionProfile.value = getSelectedPositionProfile();
|
||||
supervisorLearning.value = isSupervisorLearnerMode();
|
||||
fontSize.value = getAppFontSize();
|
||||
};
|
||||
|
||||
const changeFontSize = (value: AppFontSize) => {
|
||||
fontSize.value = setAppFontSize(value);
|
||||
uni.showToast({ title: `已切换为${currentFontSizeLabel.value}`, icon: 'none' });
|
||||
};
|
||||
|
||||
const returnToSupervisor = () => {
|
||||
leaveSupervisorLearnerMode();
|
||||
supervisorLearning.value = false;
|
||||
uni.redirectTo({ url: '/pages/supervisor/index/index' });
|
||||
};
|
||||
|
||||
const openPoints = () => {
|
||||
uni.navigateTo({ url: '/pages/user/points/index' });
|
||||
};
|
||||
|
||||
const clearProfileData = () => {
|
||||
profile.value = null;
|
||||
history.value = [];
|
||||
evidence.value = null;
|
||||
profileAccountPhone = '';
|
||||
};
|
||||
|
||||
const loadData = async () => {
|
||||
const generation = ++profileLoadGeneration;
|
||||
refreshAuth();
|
||||
if (!loggedIn.value) {
|
||||
clearProfileData();
|
||||
return;
|
||||
}
|
||||
const requestedPhone = getAuth().phone;
|
||||
if (profileAccountPhone && profileAccountPhone !== requestedPhone) {
|
||||
clearProfileData();
|
||||
}
|
||||
if (!(await ensureEmployeePosition('/pages/user/profile/index'))) {
|
||||
return;
|
||||
}
|
||||
refreshAuth();
|
||||
if (generation !== profileLoadGeneration || !loggedIn.value) return;
|
||||
const accountPhone = getAuth().phone;
|
||||
loading.value = true;
|
||||
message.value = '';
|
||||
try {
|
||||
@@ -212,19 +275,27 @@ const loadData = async () => {
|
||||
getCompetencyProfile(),
|
||||
getPromotionEvidence()
|
||||
]);
|
||||
refreshAuth();
|
||||
if (generation !== profileLoadGeneration) return;
|
||||
if (!loggedIn.value || getAuth().phone !== accountPhone) {
|
||||
clearProfileData();
|
||||
return;
|
||||
}
|
||||
history.value = nextHistory || [];
|
||||
profile.value = nextProfile || null;
|
||||
evidence.value = nextEvidence || null;
|
||||
profileAccountPhone = accountPhone;
|
||||
} catch (error) {
|
||||
refreshAuth();
|
||||
if (!loggedIn.value) {
|
||||
if (generation !== profileLoadGeneration) return;
|
||||
if (!loggedIn.value || getAuth().phone !== accountPhone) {
|
||||
clearProfileData();
|
||||
message.value = '登录已过期,请重新登录';
|
||||
message.value = loggedIn.value ? '账号已切换,请刷新' : '登录已过期,请重新登录';
|
||||
return;
|
||||
}
|
||||
message.value = error instanceof Error ? error.message : '我的数据加载失败';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
if (generation === profileLoadGeneration) loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -234,19 +305,14 @@ const goLogin = () => {
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
profileLoadGeneration += 1;
|
||||
loading.value = false;
|
||||
clearAuth();
|
||||
clearProfileData();
|
||||
refreshAuth();
|
||||
uni.showToast({ title: '已退出', icon: 'none' });
|
||||
};
|
||||
|
||||
const changePosition = (position: UserPosition) => {
|
||||
const saved = saveSelectedPosition(position);
|
||||
if (!saved) return;
|
||||
positionProfile.value = getSelectedPositionProfile();
|
||||
uni.showToast({ title: '岗位已更新', icon: 'none' });
|
||||
};
|
||||
|
||||
const openHistory = (id?: number) => {
|
||||
if (!id) {
|
||||
message.value = '这条训练记录暂不支持查看详情';
|
||||
@@ -313,13 +379,58 @@ onShow(() => {
|
||||
}
|
||||
|
||||
.account-card,
|
||||
.history-head {
|
||||
.history-head,
|
||||
.supervisor-mode-panel,
|
||||
.display-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.display-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.display-current,
|
||||
.display-note {
|
||||
display: block;
|
||||
margin-top: 7px;
|
||||
color: var(--employee-muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.font-size-options {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
padding: 4px;
|
||||
border-radius: 14px;
|
||||
background: #f0f3f8;
|
||||
}
|
||||
|
||||
.font-size-option {
|
||||
min-width: 0;
|
||||
min-height: 42px;
|
||||
padding: 0 2px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 11px;
|
||||
background: transparent;
|
||||
color: var(--employee-text);
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.font-size-option.active {
|
||||
border-color: #f0c8cc;
|
||||
background: #ffffff;
|
||||
color: var(--employee-primary);
|
||||
box-shadow: 0 4px 12px rgba(24, 34, 48, 0.08);
|
||||
}
|
||||
|
||||
.account-card {
|
||||
margin-top: 16px;
|
||||
border-left: 4px solid var(--employee-primary);
|
||||
@@ -352,31 +463,12 @@ onShow(() => {
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.position-actions {
|
||||
display: grid;
|
||||
margin-top: 14px;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.position-chip {
|
||||
display: flex;
|
||||
min-height: 38px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid #dce5f2;
|
||||
border-radius: var(--employee-chip-radius);
|
||||
background: #f8fbff;
|
||||
color: #526176;
|
||||
font-size: 13px;
|
||||
font-weight: 900;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.position-chip.active {
|
||||
border-color: var(--employee-primary);
|
||||
background: var(--employee-primary-soft);
|
||||
color: var(--employee-primary);
|
||||
.position-source {
|
||||
display: block;
|
||||
margin-top: 7px;
|
||||
color: var(--employee-muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.profile-summary {
|
||||
@@ -572,6 +664,28 @@ onShow(() => {
|
||||
background: var(--employee-primary);
|
||||
}
|
||||
|
||||
.points-entry {
|
||||
display: flex;
|
||||
width: calc(100% - 0px);
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
color: var(--ink);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.points-entry-desc {
|
||||
display: block;
|
||||
margin-top: 5px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.points-entry-arrow {
|
||||
color: var(--muted);
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.dimension-row,
|
||||
.history-row {
|
||||
display: flex;
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
<template>
|
||||
<view class="page employee-page-shell detail-page">
|
||||
<view class="subpage-header">
|
||||
<button class="subpage-back-button" aria-label="返回问题榜" @click="goBack">←</button>
|
||||
<view class="subpage-heading">
|
||||
<text class="page-title">问题详情</text>
|
||||
<text class="page-subtitle">回答经审核后公开,最佳答案按当前规则获得积分或学习学分。</text>
|
||||
</view>
|
||||
</view>
|
||||
<text v-if="message" class="status-message">{{ message }}</text>
|
||||
<view v-if="loading" class="panel employee-card">加载中…</view>
|
||||
<template v-else-if="question">
|
||||
<view class="panel employee-card question-panel">
|
||||
<view class="question-meta">
|
||||
<text>{{ question.category || '工作求助' }} · {{ statusLabel(question.status) }}</text>
|
||||
<text v-if="question.rewardPoints > 0" class="reward">奖励 {{ question.rewardPoints }} {{ rewardUnit(question.rewardAccountType) }}</text>
|
||||
</view>
|
||||
<text class="question-title">{{ question.title }}</text>
|
||||
<text class="question-content">{{ question.content }}</text>
|
||||
<text class="author">{{ question.authorName }}{{ question.position ? ` · ${question.position}` : '' }} · {{ question.createTime }}</text>
|
||||
<view v-if="bestAnswer" class="best-summary">
|
||||
<text class="best-summary-title">回答已被采纳</text>
|
||||
<text>最佳答案:{{ bestAnswer.authorName }} · 奖励按后台规则幂等记账</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="canAnswer" class="panel employee-card answer-compose">
|
||||
<text class="panel-title">写下你的经验</text>
|
||||
<textarea v-model="answer" class="textarea" maxlength="1000" placeholder="说明具体做法、依据和注意事项;不要填写业主隐私。" />
|
||||
<button class="primary-button" :disabled="submitting || !answer.trim()" @click="submitAnswer">
|
||||
{{ submitting ? '提交中' : '提交回答' }}
|
||||
</button>
|
||||
<text class="muted hint">提交后进入审核;最佳答案按后台规则记入积分或学习学分账户。</text>
|
||||
</view>
|
||||
|
||||
<view class="answers-section">
|
||||
<text class="section-title">全部回答({{ question.answers?.length || 0 }})</text>
|
||||
<view v-if="question.answers?.length" class="answer-list">
|
||||
<view v-for="item in orderedAnswers" :key="item.id" class="answer-card employee-card" :class="{ best: item.best }">
|
||||
<view class="answer-head">
|
||||
<text>{{ item.authorName }}{{ item.authorPosition ? ` · ${item.authorPosition}` : '' }}</text>
|
||||
<text v-if="item.best" class="best-badge">最佳答案</text>
|
||||
<text v-else-if="item.recommended" class="recommend-badge">主管推荐</text>
|
||||
</view>
|
||||
<text class="answer-content">{{ item.content }}</text>
|
||||
<text class="answer-time">{{ item.createTime }}</text>
|
||||
<button
|
||||
v-if="canChooseBest(item)"
|
||||
class="ghost-button best-button"
|
||||
:disabled="choosingBestId === item.id"
|
||||
@click="chooseBest(item.id)"
|
||||
>{{ choosingBestId === item.id ? '采纳中' : '采纳为最佳答案' }}</button>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="panel employee-card empty">还没有公开回答,来分享第一条可复用经验吧。</view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { onLoad, onShow } from '@dcloudio/uni-app';
|
||||
import type { QuestionBoardAnswer, QuestionBoardItem } from '@/types/api';
|
||||
import { answerQuestion, chooseBestAnswer, getQuestion } from '@/services/questions';
|
||||
|
||||
const id = ref(0);
|
||||
const question = ref<QuestionBoardItem | null>(null);
|
||||
const answer = ref('');
|
||||
const loading = ref(false);
|
||||
const submitting = ref(false);
|
||||
const choosingBestId = ref(0);
|
||||
const message = ref('');
|
||||
const orderedAnswers = computed(() => [...(question.value?.answers || [])].sort((a, b) => Number(b.best) - Number(a.best)));
|
||||
const bestAnswer = computed(() => orderedAnswers.value.find((item) => item.best));
|
||||
const canAnswer = computed(() => question.value?.status === 'PUBLISHED' && !question.value.mine);
|
||||
const canChooseBest = (item?: QuestionBoardAnswer) => Boolean(
|
||||
item
|
||||
&& question.value?.mine
|
||||
&& question.value.status === 'PUBLISHED'
|
||||
&& !question.value.bestAnswerId
|
||||
&& !item.mine
|
||||
&& item.status === 'PUBLISHED'
|
||||
);
|
||||
const statusLabel = (value: string) => ({ PENDING: '待审核', PUBLISHED: '讨论中', CLOSED: '已解决', REJECTED: '未通过' }[value] || value);
|
||||
const rewardUnit = (accountType?: string) => accountType === 'STUDY_CREDIT' ? '学分' : '积分';
|
||||
const goBack = () => uni.navigateBack({ delta: 1 });
|
||||
const load = async () => {
|
||||
if (!id.value) return;
|
||||
loading.value = true;
|
||||
try { question.value = await getQuestion(id.value); }
|
||||
catch (error) { message.value = error instanceof Error ? error.message : '问题读取失败'; }
|
||||
finally { loading.value = false; }
|
||||
};
|
||||
const submitAnswer = async () => {
|
||||
if (!answer.value.trim() || submitting.value) return;
|
||||
submitting.value = true;
|
||||
message.value = '';
|
||||
try {
|
||||
await answerQuestion(id.value, answer.value.trim());
|
||||
answer.value = '';
|
||||
message.value = '回答已提交,审核通过后会公开';
|
||||
await load();
|
||||
} catch (error) { message.value = error instanceof Error ? error.message : '回答提交失败'; }
|
||||
finally { submitting.value = false; }
|
||||
};
|
||||
const chooseBest = async (answerId: number) => {
|
||||
if (!canChooseBest(orderedAnswers.value.find((item) => item.id === answerId)) || choosingBestId.value) return;
|
||||
choosingBestId.value = answerId;
|
||||
message.value = '';
|
||||
try {
|
||||
await chooseBestAnswer(id.value, answerId);
|
||||
message.value = '已采纳最佳答案,奖励已按后台规则记账';
|
||||
await load();
|
||||
} catch (error) {
|
||||
message.value = error instanceof Error ? error.message : '最佳答案设置失败';
|
||||
} finally {
|
||||
choosingBestId.value = 0;
|
||||
}
|
||||
};
|
||||
onLoad((options) => { id.value = Number(options?.id || 0); });
|
||||
onShow(() => { void load(); });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.detail-page { max-width: var(--mobile-shell-max-width); margin: 0 auto; }
|
||||
.status-message { display: block; margin-top: 14px; padding: 12px; border-radius: 10px; background: #fff0f1; color: #a9000d; }
|
||||
.question-panel, .answer-compose { display: grid; gap: 12px; }
|
||||
.question-meta, .answer-head { display: flex; gap: 8px; align-items: center; justify-content: space-between; color: var(--knowledge); font-size: 12px; font-weight: 900; }
|
||||
.reward { color: #a86100; }
|
||||
.question-title { overflow-wrap: anywhere; font-size: 20px; font-weight: 900; line-height: 1.45; }
|
||||
.question-content, .answer-content { overflow-wrap: anywhere; font-size: 15px; line-height: 1.75; white-space: pre-wrap; }
|
||||
.author, .answer-time, .hint { color: var(--muted); font-size: 12px; line-height: 1.5; }
|
||||
.answers-section { margin-top: 20px; }
|
||||
.section-title { display: block; font-size: 18px; font-weight: 900; }
|
||||
.answer-list { display: grid; gap: 12px; margin-top: 12px; }
|
||||
.answer-card { display: grid; gap: 10px; padding: 16px; }
|
||||
.answer-card.best { border: 2px solid #f0b64b; background: #fffaf0; }
|
||||
.best-badge { padding: 4px 8px; border-radius: 999px; background: #f3b23c; color: #fff; }
|
||||
.recommend-badge { color: var(--knowledge); }
|
||||
.empty { color: var(--muted); line-height: 1.6; }
|
||||
.best-summary { display:grid; gap:5px; padding:12px; border-radius:12px; background:#fff7df; color:#79500b; font-size:12px; line-height:1.55; }
|
||||
.best-summary-title { font-size:15px; font-weight:900; }
|
||||
.best-button { justify-self:start; min-height:40px; padding:0 14px; color:#8a5a00; }
|
||||
</style>
|
||||
@@ -0,0 +1,143 @@
|
||||
<template>
|
||||
<view class="page employee-page-shell question-page">
|
||||
<view class="subpage-header">
|
||||
<button class="subpage-back-button" aria-label="返回今日" @click="goToday">←</button>
|
||||
<view class="subpage-heading">
|
||||
<text class="employee-kicker">广谱师傅</text>
|
||||
<text class="page-title">开放问题榜</text>
|
||||
<text class="page-subtitle">全公司都能回答,用真实经验帮助一线同事。</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="hero-actions">
|
||||
<button class="hero-action employee-card" @click="goPublish">
|
||||
<text class="hero-action-title">今天遇到什么难题了?</text>
|
||||
<text class="hero-action-copy">发布真实工作求助</text>
|
||||
<text class="hero-action-link">立即发布 ›</text>
|
||||
</button>
|
||||
<button class="hero-action employee-card rewarded" @click="switchTab('REWARDED')">
|
||||
<text class="hero-action-title">有奖征问题</text>
|
||||
<text class="hero-action-copy">贡献值得讨论的好问题</text>
|
||||
<text class="hero-action-link">立即查看 ›</text>
|
||||
</button>
|
||||
</view>
|
||||
<button class="points-link" @click="goPoints">我的积分与学分 ›</button>
|
||||
|
||||
<view class="tab-scroll">
|
||||
<view class="tab-row">
|
||||
<button
|
||||
v-for="item in tabs"
|
||||
:key="item.key"
|
||||
class="filter-chip"
|
||||
:class="{ active: tab === item.key }"
|
||||
:aria-pressed="tab === item.key"
|
||||
@click="switchTab(item.key)"
|
||||
>{{ item.label }}</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="message" class="status-message">{{ message }}</view>
|
||||
<view v-if="loading" class="panel employee-card empty">问题加载中…</view>
|
||||
<view v-else-if="!questions.length" class="panel employee-card empty">
|
||||
<text class="empty-title">当前暂无问题</text>
|
||||
<text class="muted">你可以发起第一条真实工作求助。</text>
|
||||
</view>
|
||||
<view v-else class="question-list">
|
||||
<button v-for="item in questions" :key="item.id" class="question-card employee-card" @click="openQuestion(item.id)">
|
||||
<view class="question-meta">
|
||||
<text class="category">{{ item.category || '工作求助' }}</text>
|
||||
<text v-if="item.rewardPoints > 0" class="reward">奖励 {{ item.rewardPoints }} {{ rewardUnit(item.rewardAccountType) }}</text>
|
||||
</view>
|
||||
<text class="question-title">{{ item.title }}</text>
|
||||
<text v-if="item.content" class="question-excerpt">{{ excerpt(item.content) }}</text>
|
||||
<view class="question-foot">
|
||||
<text>{{ item.authorName }}{{ item.position ? ` · ${item.position}` : '' }}</text>
|
||||
<text>{{ item.answerCount }} 个回答 · {{ statusLabel(item.status) }}</text>
|
||||
</view>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { onShow } from '@dcloudio/uni-app';
|
||||
import type { QuestionBoardItem, QuestionBoardTab } from '@/types/api';
|
||||
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
|
||||
import { listQuestions } from '@/services/questions';
|
||||
import { resetPageScroll } from '@/services/navigation';
|
||||
|
||||
const path = '/pages/user/questions/index';
|
||||
const tabs: { key: QuestionBoardTab; label: string }[] = [
|
||||
{ key: 'HOT', label: '热门' },
|
||||
{ key: 'UNANSWERED', label: '待回答' },
|
||||
{ key: 'REWARDED', label: '有奖励' },
|
||||
{ key: 'MINE', label: '我的提问' }
|
||||
];
|
||||
const tab = ref<QuestionBoardTab>('HOT');
|
||||
const questions = ref<QuestionBoardItem[]>([]);
|
||||
const loading = ref(false);
|
||||
const message = ref('');
|
||||
|
||||
const ensureLogin = () => {
|
||||
if (isLoggedIn()) return true;
|
||||
rememberLoginRedirect(path);
|
||||
uni.navigateTo({ url: '/pages/auth/login/index' });
|
||||
return false;
|
||||
};
|
||||
const load = async () => {
|
||||
if (!ensureLogin()) return;
|
||||
loading.value = true;
|
||||
message.value = '';
|
||||
try {
|
||||
questions.value = await listQuestions(tab.value);
|
||||
} catch (error) {
|
||||
questions.value = [];
|
||||
message.value = error instanceof Error ? error.message : '问题榜加载失败';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
const switchTab = (value: QuestionBoardTab) => { tab.value = value; void load(); };
|
||||
const excerpt = (value: string) => value.length > 92 ? `${value.slice(0, 92)}…` : value;
|
||||
const rewardUnit = (accountType?: string) => accountType === 'STUDY_CREDIT' ? '学分' : '积分';
|
||||
const statusLabel = (value: string) => ({
|
||||
PENDING: '待审核',
|
||||
PUBLISHED: '讨论中',
|
||||
CLOSED: '已解决',
|
||||
REJECTED: '未通过'
|
||||
}[value] || value);
|
||||
const goToday = () => uni.switchTab({ url: '/pages/user/today/index' });
|
||||
const goPublish = () => { if (ensureLogin()) uni.navigateTo({ url: '/pages/user/questions/publish' }); };
|
||||
const goPoints = () => { if (ensureLogin()) uni.navigateTo({ url: '/pages/user/points/index' }); };
|
||||
const openQuestion = (id: number) => uni.navigateTo({ url: `/pages/user/questions/detail?id=${id}` });
|
||||
|
||||
onShow(() => { resetPageScroll(); void load(); });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.question-page { max-width: var(--mobile-shell-max-width); margin: 0 auto; }
|
||||
.hero-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 18px; }
|
||||
.hero-action { display:grid; gap:7px; min-height:132px; padding:16px; color:var(--ink); text-align:left; }
|
||||
.hero-action-title,.hero-action-copy,.hero-action-link { display:block; overflow-wrap:anywhere; }
|
||||
.hero-action-title { color:var(--brand); font-size:16px; font-weight:900; line-height:1.45; }
|
||||
.hero-action-copy { color:var(--muted); font-size:12px; line-height:1.5; }
|
||||
.hero-action-link { margin-top:auto; color:var(--brand); font-size:13px; font-weight:900; }
|
||||
.hero-action.rewarded .hero-action-title,.hero-action.rewarded .hero-action-link { color:#d66d00; }
|
||||
.points-link { display:block; min-height:40px; margin:8px 0 0 auto; background:transparent; color:var(--knowledge); font-size:13px; font-weight:800; }
|
||||
.tab-scroll { margin-top: 18px; }
|
||||
.tab-row { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 6px; }
|
||||
.filter-chip { width: 100%; min-width: 0; min-height: 40px; padding: 5px 4px; border: 1px solid var(--line); border-radius: 999px; background: #fff; color: var(--muted); font-size: 13px; line-height: 1.25; }
|
||||
.filter-chip.active { border-color: #f2a7ad; background: var(--employee-primary-soft); color: var(--brand); font-weight: 900; }
|
||||
.status-message, .empty { display: grid; gap: 6px; margin-top: 16px; padding: 16px; }
|
||||
.status-message { border-radius: 12px; background: #fff0f1; color: #a9000d; }
|
||||
.empty-title { font-weight: 900; }
|
||||
.question-list { display: grid; gap: 12px; margin-top: 16px; }
|
||||
.question-card { display: grid; width: 100%; gap: 10px; padding: 16px; color: inherit; text-align: left; }
|
||||
.question-meta, .question-foot { display: flex; gap: 8px; align-items: center; justify-content: space-between; }
|
||||
.category { color: var(--knowledge); font-size: 12px; font-weight: 900; }
|
||||
.reward { color: #a86100; font-size: 12px; font-weight: 900; }
|
||||
.question-title { overflow-wrap: anywhere; color: var(--ink); font-size: 17px; font-weight: 900; line-height: 1.45; }
|
||||
.question-excerpt { overflow-wrap: anywhere; color: var(--muted); font-size: 14px; line-height: 1.6; }
|
||||
.question-foot { color: #7b8494; font-size: 12px; }
|
||||
</style>
|
||||
@@ -0,0 +1,61 @@
|
||||
<template>
|
||||
<view class="page employee-page-shell publish-page">
|
||||
<view class="subpage-header">
|
||||
<button class="subpage-back-button" aria-label="返回" @click="goBack">←</button>
|
||||
<view class="subpage-heading">
|
||||
<text class="page-title">发起求助</text>
|
||||
<text class="page-subtitle">员工可发布真实工作问题;有奖征题由运营审核发布。</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="panel employee-card form-panel">
|
||||
<label class="field-label">问题标题</label>
|
||||
<input v-model="title" class="input" maxlength="80" placeholder="一句话说清楚遇到的难题" />
|
||||
<label class="field-label">分类</label>
|
||||
<picker :range="categories" :value="categoryIndex" @change="selectCategory">
|
||||
<view class="input picker-value">{{ categories[categoryIndex] }}</view>
|
||||
</picker>
|
||||
<label class="field-label">补充说明</label>
|
||||
<textarea v-model="content" class="textarea" maxlength="1000" placeholder="写清场景、已尝试的方法和希望获得的帮助;请删除业主隐私。" />
|
||||
<view class="privacy-note">提交后进入内容审核。不要填写姓名、手机号、房号、门禁、身份证或其他敏感信息。</view>
|
||||
<button class="primary-button" :disabled="submitting || !valid" @click="submit">
|
||||
{{ submitting ? '提交中' : '提交审核' }}
|
||||
</button>
|
||||
<text v-if="message" class="status-message">{{ message }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { publishQuestion } from '@/services/questions';
|
||||
|
||||
const categories = ['客户沟通', '工程报修', '秩序安全', '环境保洁', '收费服务', '其他'];
|
||||
const categoryIndex = ref(0);
|
||||
const title = ref('');
|
||||
const content = ref('');
|
||||
const submitting = ref(false);
|
||||
const message = ref('');
|
||||
const valid = computed(() => title.value.trim().length >= 6 && content.value.trim().length >= 12);
|
||||
const goBack = () => uni.navigateBack({ delta: 1 });
|
||||
const selectCategory = (event: { detail: { value: string } }) => { categoryIndex.value = Number(event.detail.value || 0); };
|
||||
const submit = async () => {
|
||||
if (!valid.value || submitting.value) return;
|
||||
submitting.value = true;
|
||||
message.value = '';
|
||||
try {
|
||||
const item = await publishQuestion({ title: title.value.trim(), content: content.value.trim(), category: categories[categoryIndex.value] });
|
||||
uni.showToast({ title: '已提交审核', icon: 'none' });
|
||||
setTimeout(() => uni.redirectTo({ url: `/pages/user/questions/detail?id=${item.id}` }), 350);
|
||||
} catch (error) { message.value = error instanceof Error ? error.message : '提交失败'; }
|
||||
finally { submitting.value = false; }
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.publish-page { max-width: var(--mobile-shell-max-width); margin: 0 auto; }
|
||||
.form-panel { display: grid; gap: 10px; }
|
||||
.field-label { margin-top: 4px; font-size: 14px; font-weight: 900; }
|
||||
.picker-value { display: flex; align-items: center; }
|
||||
.privacy-note { padding: 12px; border-radius: 10px; background: #fff9e9; color: #78500a; font-size: 12px; line-height: 1.55; }
|
||||
.status-message { color: #a9000d; }
|
||||
</style>
|
||||
@@ -8,6 +8,11 @@
|
||||
<text class="toolbar-tip">先把问题问清楚,再按需生成总结卡,不伪造分类能力。</text>
|
||||
</view>
|
||||
|
||||
<view class="source-switch employee-card" aria-label="问答来源">
|
||||
<button class="source-option active" aria-current="page">企业问师傅</button>
|
||||
<button class="source-option" @click="openWebAi">问 AI(全网)</button>
|
||||
</view>
|
||||
|
||||
<view class="panel query-panel employee-card">
|
||||
<view class="query-hero">
|
||||
<view class="query-copy">
|
||||
@@ -25,13 +30,47 @@
|
||||
maxlength="200"
|
||||
:placeholder="quickQuestionPlaceholder"
|
||||
/>
|
||||
<view class="voice-query-actions">
|
||||
<button
|
||||
class="secondary-button voice-query-button"
|
||||
:class="{ 'recording-active': voiceRecording }"
|
||||
:disabled="loading || voiceTranscribing"
|
||||
@click="toggleVoiceRecording"
|
||||
>
|
||||
{{ voiceRecording ? '停止录音' : voiceTranscribing ? '转写中' : '语音提问' }}
|
||||
</button>
|
||||
<button
|
||||
class="ghost-button voice-query-button"
|
||||
:disabled="loading || voiceRecording || voiceTranscribing"
|
||||
@click="pickVoiceAudio"
|
||||
>
|
||||
{{ voiceTranscribing ? '转写中' : '选择音频' }}
|
||||
</button>
|
||||
</view>
|
||||
<text class="voice-query-hint">录音会先转成文字,确认问题后再提交;麦克风不可用时可选择音频。</text>
|
||||
<view class="media-actions">
|
||||
<button class="ghost-button media-button" :disabled="loading" @click="pickMedia">
|
||||
+ 上传图片或视频
|
||||
</button>
|
||||
<text class="media-hint">图片≤10MB,视频≤100MB;仅用于本次现场分析</text>
|
||||
</view>
|
||||
<view v-if="selectedMedia" class="media-file">
|
||||
<view class="media-file-copy">
|
||||
<text class="media-kind">{{ selectedMedia.kind === 'video' ? '现场视频' : '现场图片' }}</text>
|
||||
<text class="media-name">{{ selectedMedia.name }}</text>
|
||||
</view>
|
||||
<button class="media-remove" :disabled="loading" aria-label="移除附件" @click="clearMedia">×</button>
|
||||
</view>
|
||||
<text class="query-caption">常见问题可以直接点下面快捷问法,结果会保留引用依据。</text>
|
||||
<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 query-submit" :disabled="loading" @click="search">
|
||||
<button class="secondary-button data-tool-button" :disabled="loading" @click="runDataTool(defaultToolCode)">
|
||||
{{ defaultToolCode === 'TEAM_PRACTICE_SUMMARY' ? '团队训练概况' : '我的训练概况' }}
|
||||
</button>
|
||||
<button class="primary-button query-submit" :disabled="loading || voiceRecording || voiceTranscribing" @click="search">
|
||||
{{ loading ? '检索中' : '问老师傅' }}
|
||||
</button>
|
||||
<text v-if="message" class="status-message">{{ message }}</text>
|
||||
@@ -61,7 +100,12 @@
|
||||
</view>
|
||||
|
||||
<view v-if="result" class="panel answer-panel employee-card">
|
||||
<text class="panel-title">师傅回答</text>
|
||||
<view class="answer-title-row">
|
||||
<text class="panel-title">师傅回答</text>
|
||||
<button class="voice-button" :disabled="speechState.status === 'loading' && speechState.key !== 'answer'" @click="toggleAnswerSpeech">
|
||||
{{ speechButtonLabel('answer') }}
|
||||
</button>
|
||||
</view>
|
||||
<view class="question-card">
|
||||
<text class="question-label">我的问题</text>
|
||||
<text class="question-text">{{ query }}</text>
|
||||
@@ -69,6 +113,7 @@
|
||||
<view class="answer-copy-card">
|
||||
<text class="answer-text">{{ result.answer || '未生成直接答案,请查看下方引用。' }}</text>
|
||||
</view>
|
||||
<text v-if="result.noEvidence" class="status-message">当前授权空间内没有足够依据。</text>
|
||||
|
||||
<view v-if="result.keyPoints?.length" class="keypoints">
|
||||
<text v-for="(point, index) in result.keyPoints" :key="point" class="keypoint">
|
||||
@@ -80,7 +125,7 @@
|
||||
<text v-for="item in result.cautions" :key="item">{{ item }}</text>
|
||||
</view>
|
||||
|
||||
<view class="feedback-actions">
|
||||
<view v-if="currentToolCode === ''" class="feedback-actions">
|
||||
<button
|
||||
class="secondary-button"
|
||||
:class="{ active: feedbackVerdict === 'up' }"
|
||||
@@ -107,12 +152,13 @@
|
||||
<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 v-if="snippet.spaceCode" class="snippet-id">知识空间:{{ snippet.spaceCode }}</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 employee-card">
|
||||
<view v-if="result && currentToolCode === ''" class="panel summary-panel employee-card">
|
||||
<text class="panel-title">总结卡</text>
|
||||
<text class="muted summary-copy">需要时再生成,适合把一段 SOP 整理成步骤卡、话术卡和提醒卡。</text>
|
||||
<button class="ghost-button" :disabled="summaryLoading" @click="buildSummaryCard">
|
||||
@@ -185,7 +231,13 @@
|
||||
</view>
|
||||
</scroll-view>
|
||||
<view class="modal-footer">
|
||||
<button class="primary-button footer-btn" @click="closeSummary">我知道了</button>
|
||||
<button class="secondary-button footer-btn" :disabled="downloadingCard" @click="downloadSummary">
|
||||
{{ downloadingCard ? '生成中' : '下载图片' }}
|
||||
</button>
|
||||
<button class="secondary-button footer-btn" @click="toggleSummarySpeech">
|
||||
{{ speechButtonLabel('summary') }}
|
||||
</button>
|
||||
<button class="primary-button footer-btn footer-close" @click="closeSummary">我知道了</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -194,20 +246,39 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { onShow } from '@dcloudio/uni-app';
|
||||
import { onHide, onShow, onUnload } from '@dcloudio/uni-app';
|
||||
import type {
|
||||
AnswerFeedbackVerdict,
|
||||
KnowledgeMediaSelectedFile,
|
||||
KnowledgeSearchResponse,
|
||||
SpeechPlaybackStatus,
|
||||
SummaryCardResponse
|
||||
} from '@/types/api';
|
||||
import {
|
||||
chooseKnowledgeMedia,
|
||||
generateSummaryCard,
|
||||
queryKnowledgeTool,
|
||||
searchKnowledge,
|
||||
searchKnowledgeWithMedia,
|
||||
submitAnswerFeedback
|
||||
} from '@/services/knowledge';
|
||||
import { getSelectedPosition, getSelectedPositionProfile, isLoggedIn, rememberLoginRedirect } from '@/services/auth';
|
||||
import {
|
||||
getSelectedPosition,
|
||||
getSelectedPositionProfile,
|
||||
isLoggedIn,
|
||||
isSupervisorLearnerMode,
|
||||
rememberLoginRedirect,
|
||||
roleKey
|
||||
} from '@/services/auth';
|
||||
import { resetPageScroll } from '@/services/navigation';
|
||||
import { ensureEmployeePosition } from '@/services/position';
|
||||
import {
|
||||
chooseSpeechAudio,
|
||||
createSpeechPlaybackController,
|
||||
transcribeSpeechBlob,
|
||||
transcribeSpeechFile
|
||||
} from '@/services/speech';
|
||||
import { downloadSummaryCardImage } from '@/services/summary-card-image';
|
||||
|
||||
const positionProfile = ref(getSelectedPositionProfile());
|
||||
const positionLabel = ref(getSelectedPosition());
|
||||
@@ -218,23 +289,62 @@ const result = ref<KnowledgeSearchResponse | null>(null);
|
||||
const summaryCard = ref<SummaryCardResponse | null>(null);
|
||||
const loading = ref(false);
|
||||
const summaryLoading = ref(false);
|
||||
const downloadingCard = ref(false);
|
||||
const selectedMedia = ref<KnowledgeMediaSelectedFile | null>(null);
|
||||
const speechState = ref<{ key: string; status: SpeechPlaybackStatus }>({ key: '', status: 'idle' });
|
||||
const voiceRecording = ref(false);
|
||||
const voiceTranscribing = ref(false);
|
||||
const voiceRecorder = ref<MediaRecorder | null>(null);
|
||||
const voiceStream = ref<MediaStream | null>(null);
|
||||
const voiceStarting = ref(false);
|
||||
const feedbackSubmitting = ref(false);
|
||||
const feedbackVerdict = ref<AnswerFeedbackVerdict | ''>('');
|
||||
const message = ref('');
|
||||
const loggedIn = ref(false);
|
||||
const pendingSopQueryKey = 'aihr_sop_pending_query';
|
||||
const pendingToolKey = 'aihr_sop_pending_tool';
|
||||
const currentToolCode = ref<'' | 'MY_PRACTICE_SUMMARY' | 'TEAM_PRACTICE_SUMMARY'>('');
|
||||
const currentRole = ref(String(uni.getStorageSync(roleKey) || ''));
|
||||
const supervisorLearner = ref(isSupervisorLearnerMode());
|
||||
let voiceCaptureGeneration = 0;
|
||||
let voiceTranscriptionGeneration = 0;
|
||||
let abortVoiceTranscription: (() => void) | null = null;
|
||||
let knowledgeSearchGeneration = 0;
|
||||
let activeMediaUploadTask: ReturnType<typeof uni.uploadFile> | null = null;
|
||||
const defaultToolCode = computed<'MY_PRACTICE_SUMMARY' | 'TEAM_PRACTICE_SUMMARY'>(() =>
|
||||
currentRole.value === 'supervisor' && !supervisorLearner.value
|
||||
? 'TEAM_PRACTICE_SUMMARY'
|
||||
: 'MY_PRACTICE_SUMMARY'
|
||||
);
|
||||
const speechPlayback = createSpeechPlaybackController(
|
||||
(state) => { speechState.value = state; },
|
||||
(text) => {
|
||||
message.value = text;
|
||||
uni.showToast({ title: text, icon: 'none' });
|
||||
}
|
||||
);
|
||||
|
||||
const resetResultState = () => {
|
||||
result.value = null;
|
||||
summaryCard.value = null;
|
||||
feedbackVerdict.value = '';
|
||||
feedbackSubmitting.value = false;
|
||||
currentToolCode.value = '';
|
||||
};
|
||||
|
||||
const refreshAuthState = () => {
|
||||
loggedIn.value = isLoggedIn();
|
||||
positionLabel.value = getSelectedPosition();
|
||||
positionProfile.value = getSelectedPositionProfile();
|
||||
currentRole.value = String(uni.getStorageSync(roleKey) || '');
|
||||
supervisorLearner.value = isSupervisorLearnerMode();
|
||||
};
|
||||
|
||||
const cancelPendingMediaSearch = () => {
|
||||
knowledgeSearchGeneration += 1;
|
||||
activeMediaUploadTask?.abort();
|
||||
activeMediaUploadTask = null;
|
||||
loading.value = false;
|
||||
};
|
||||
|
||||
const handleRequestError = (error: unknown, fallback: string, wasLoggedIn = loggedIn.value) => {
|
||||
@@ -249,12 +359,13 @@ const handleRequestError = (error: unknown, fallback: string, wasLoggedIn = logg
|
||||
};
|
||||
|
||||
const search = async () => {
|
||||
if (loading.value) return;
|
||||
const value = query.value.trim();
|
||||
if (loading.value || voiceRecording.value || voiceTranscribing.value) return;
|
||||
const value = query.value.trim() || (selectedMedia.value ? '请根据附件现场情况,告诉我应该按什么 SOP 处理' : '');
|
||||
if (!value) {
|
||||
message.value = '请先输入问题';
|
||||
return;
|
||||
}
|
||||
query.value = value;
|
||||
refreshAuthState();
|
||||
if (!loggedIn.value) {
|
||||
uni.setStorageSync(pendingSopQueryKey, value);
|
||||
@@ -267,25 +378,224 @@ const search = async () => {
|
||||
positionProfile.value = getSelectedPositionProfile();
|
||||
positionLabel.value = getSelectedPosition();
|
||||
loading.value = true;
|
||||
const generation = ++knowledgeSearchGeneration;
|
||||
message.value = '';
|
||||
resetResultState();
|
||||
try {
|
||||
result.value = await searchKnowledge(value);
|
||||
const nextResult = selectedMedia.value
|
||||
? await searchKnowledgeWithMedia(selectedMedia.value, value, getSelectedPosition(), (task) => {
|
||||
if (generation === knowledgeSearchGeneration) activeMediaUploadTask = task;
|
||||
})
|
||||
: await searchKnowledge(value);
|
||||
if (generation !== knowledgeSearchGeneration) return;
|
||||
result.value = nextResult;
|
||||
if (!result.value.answer && !result.value.snippets?.length) {
|
||||
message.value = '未检索到可引用内容';
|
||||
}
|
||||
} catch (error) {
|
||||
if (generation !== knowledgeSearchGeneration) return;
|
||||
handleRequestError(error, '检索失败', true);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
if (generation === knowledgeSearchGeneration) {
|
||||
activeMediaUploadTask = null;
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const runDataTool = async (toolCode: 'MY_PRACTICE_SUMMARY' | 'TEAM_PRACTICE_SUMMARY') => {
|
||||
if (loading.value) return;
|
||||
refreshAuthState();
|
||||
if (!loggedIn.value) {
|
||||
uni.setStorageSync(pendingToolKey, toolCode);
|
||||
goLogin();
|
||||
return;
|
||||
}
|
||||
const effectiveToolCode = supervisorLearner.value && toolCode === 'TEAM_PRACTICE_SUMMARY'
|
||||
? 'MY_PRACTICE_SUMMARY'
|
||||
: toolCode;
|
||||
const generation = ++knowledgeSearchGeneration;
|
||||
loading.value = true;
|
||||
message.value = '';
|
||||
resetResultState();
|
||||
selectedMedia.value = null;
|
||||
currentToolCode.value = effectiveToolCode;
|
||||
const question = effectiveToolCode === 'TEAM_PRACTICE_SUMMARY' ? '查询我的团队训练概况' : '查询我的训练概况';
|
||||
query.value = question;
|
||||
try {
|
||||
const nextResult = await queryKnowledgeTool(effectiveToolCode, question);
|
||||
if (generation !== knowledgeSearchGeneration) return;
|
||||
result.value = nextResult;
|
||||
} catch (error) {
|
||||
if (generation !== knowledgeSearchGeneration) return;
|
||||
handleRequestError(error, '训练概况查询失败', true);
|
||||
} finally {
|
||||
if (generation === knowledgeSearchGeneration) loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const useQuickQuestion = (value: string) => {
|
||||
selectedMedia.value = null;
|
||||
query.value = value;
|
||||
void search();
|
||||
};
|
||||
|
||||
const pickMedia = async () => {
|
||||
if (loading.value) return;
|
||||
try {
|
||||
selectedMedia.value = await chooseKnowledgeMedia();
|
||||
message.value = selectedMedia.value.kind === 'video'
|
||||
? '已选择视频,提交后会提取语音、画面文字和场景信息'
|
||||
: '已选择图片,可补充一句现场问题后提交';
|
||||
} catch (error) {
|
||||
const text = error instanceof Error ? error.message : '附件选择失败';
|
||||
if (text !== '未选择附件') message.value = text;
|
||||
}
|
||||
};
|
||||
|
||||
const clearMedia = () => {
|
||||
if (loading.value) return;
|
||||
selectedMedia.value = null;
|
||||
};
|
||||
|
||||
const stopVoiceTracks = () => {
|
||||
voiceStream.value?.getTracks().forEach((track) => track.stop());
|
||||
voiceStream.value = null;
|
||||
};
|
||||
|
||||
const cancelVoiceTranscription = () => {
|
||||
voiceTranscriptionGeneration += 1;
|
||||
abortVoiceTranscription?.();
|
||||
abortVoiceTranscription = null;
|
||||
voiceTranscribing.value = false;
|
||||
};
|
||||
|
||||
const cancelVoiceRecording = () => {
|
||||
voiceCaptureGeneration += 1;
|
||||
cancelVoiceTranscription();
|
||||
voiceStarting.value = false;
|
||||
const recorder = voiceRecorder.value;
|
||||
if (recorder && recorder.state !== 'inactive') {
|
||||
recorder.ondataavailable = null;
|
||||
recorder.onstop = null;
|
||||
recorder.stop();
|
||||
}
|
||||
voiceRecorder.value = null;
|
||||
stopVoiceTracks();
|
||||
voiceRecording.value = false;
|
||||
};
|
||||
|
||||
const applyVoiceTranscription = (text?: string) => {
|
||||
const value = (text || '').trim();
|
||||
if (!value) throw new Error('语音没有识别到文字');
|
||||
const current = query.value.trim();
|
||||
query.value = current ? `${current}\n${value}` : value;
|
||||
message.value = '语音已转成文字,请确认问题后提交';
|
||||
};
|
||||
|
||||
const transcribeVoiceBlob = async (blob: Blob) => {
|
||||
if (voiceTranscribing.value) return;
|
||||
const generation = ++voiceTranscriptionGeneration;
|
||||
voiceTranscribing.value = true;
|
||||
message.value = '语音转写中';
|
||||
try {
|
||||
const result = await transcribeSpeechBlob(blob, 'sop-question.webm', (abort) => {
|
||||
if (generation === voiceTranscriptionGeneration) abortVoiceTranscription = abort;
|
||||
});
|
||||
if (generation !== voiceTranscriptionGeneration) return;
|
||||
applyVoiceTranscription(result.text);
|
||||
} catch (error) {
|
||||
if (generation !== voiceTranscriptionGeneration) return;
|
||||
message.value = error instanceof Error ? error.message : '语音转写失败,请改用文字输入';
|
||||
} finally {
|
||||
if (generation === voiceTranscriptionGeneration) {
|
||||
abortVoiceTranscription = null;
|
||||
voiceTranscribing.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const pickVoiceAudio = async () => {
|
||||
if (loading.value || voiceRecording.value || voiceTranscribing.value) return;
|
||||
const generation = ++voiceTranscriptionGeneration;
|
||||
voiceTranscribing.value = true;
|
||||
message.value = '语音转写中';
|
||||
try {
|
||||
const file = await chooseSpeechAudio();
|
||||
if (generation !== voiceTranscriptionGeneration) return;
|
||||
const result = await transcribeSpeechFile(file, (abort) => {
|
||||
if (generation === voiceTranscriptionGeneration) abortVoiceTranscription = abort;
|
||||
});
|
||||
if (generation !== voiceTranscriptionGeneration) return;
|
||||
applyVoiceTranscription(result.text);
|
||||
} catch (error) {
|
||||
if (generation !== voiceTranscriptionGeneration) return;
|
||||
const text = error instanceof Error ? error.message : '语音转写失败,请改用文字输入';
|
||||
if (text !== '未选择音频') message.value = text;
|
||||
} finally {
|
||||
if (generation === voiceTranscriptionGeneration) {
|
||||
abortVoiceTranscription = null;
|
||||
voiceTranscribing.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const startVoiceRecording = async () => {
|
||||
if (loading.value || voiceStarting.value || voiceRecording.value || voiceTranscribing.value) return;
|
||||
if (typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === 'undefined') {
|
||||
await pickVoiceAudio();
|
||||
return;
|
||||
}
|
||||
const generation = ++voiceCaptureGeneration;
|
||||
voiceStarting.value = true;
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
if (generation !== voiceCaptureGeneration) {
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
return;
|
||||
}
|
||||
voiceStream.value = stream;
|
||||
const recorder = new MediaRecorder(stream);
|
||||
const chunks: Blob[] = [];
|
||||
voiceRecorder.value = recorder;
|
||||
recorder.ondataavailable = (event) => {
|
||||
if (event.data?.size) chunks.push(event.data);
|
||||
};
|
||||
recorder.onstop = () => {
|
||||
voiceRecording.value = false;
|
||||
voiceRecorder.value = null;
|
||||
stopVoiceTracks();
|
||||
const blob = new Blob(chunks, { type: recorder.mimeType || 'audio/webm' });
|
||||
if (!blob.size) {
|
||||
message.value = '未录到音频,请改用文字输入';
|
||||
return;
|
||||
}
|
||||
void transcribeVoiceBlob(blob);
|
||||
};
|
||||
recorder.start();
|
||||
voiceRecording.value = true;
|
||||
message.value = '正在录音,再点一次结束';
|
||||
} catch {
|
||||
stopVoiceTracks();
|
||||
if (generation === voiceCaptureGeneration) await pickVoiceAudio();
|
||||
} finally {
|
||||
if (generation === voiceCaptureGeneration) voiceStarting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const toggleVoiceRecording = () => {
|
||||
if (voiceStarting.value) return;
|
||||
if (voiceRecording.value) {
|
||||
const recorder = voiceRecorder.value;
|
||||
if (recorder && recorder.state !== 'inactive') {
|
||||
message.value = '录音结束,正在转写';
|
||||
recorder.stop();
|
||||
}
|
||||
return;
|
||||
}
|
||||
void startVoiceRecording();
|
||||
};
|
||||
|
||||
const snippetExcerpt = (text = '') => {
|
||||
const value = text.trim();
|
||||
return value.length > 260 ? `${value.slice(0, 260)}...` : value;
|
||||
@@ -350,14 +660,67 @@ const buildSummaryCard = async () => {
|
||||
};
|
||||
|
||||
const closeSummary = () => {
|
||||
if (speechState.value.key === 'summary') speechPlayback.stop();
|
||||
summaryCard.value = null;
|
||||
};
|
||||
|
||||
const speechButtonLabel = (key: string) => {
|
||||
if (speechState.value.key !== key) return '语音播报';
|
||||
if (speechState.value.status === 'loading') return '生成语音中';
|
||||
if (speechState.value.status === 'playing') return '停止播报';
|
||||
return '语音播报';
|
||||
};
|
||||
|
||||
const answerSpeechText = () => {
|
||||
if (!result.value) return '';
|
||||
return [
|
||||
result.value.answer,
|
||||
...(result.value.keyPoints || []).map((item, index) => `第${index + 1}点,${cleanOrderedText(item)}`),
|
||||
...(result.value.cautions || []).map((item) => `注意,${item}`)
|
||||
].filter(Boolean).join('。');
|
||||
};
|
||||
|
||||
const summarySpeechText = () => {
|
||||
if (!summaryCard.value) return '';
|
||||
return [
|
||||
summaryCard.value.title,
|
||||
...(summaryCard.value.steps || []).map((item, index) => `第${index + 1}步,${item.label},${item.desc}`),
|
||||
...(summaryCard.value.reminders || []).map((item) => `师傅提醒,${item}`)
|
||||
].filter(Boolean).join('。');
|
||||
};
|
||||
|
||||
const toggleAnswerSpeech = () => {
|
||||
void speechPlayback.toggle('answer', answerSpeechText());
|
||||
};
|
||||
|
||||
const toggleSummarySpeech = () => {
|
||||
void speechPlayback.toggle('summary', summarySpeechText());
|
||||
};
|
||||
|
||||
const downloadSummary = async () => {
|
||||
if (!summaryCard.value || downloadingCard.value) return;
|
||||
downloadingCard.value = true;
|
||||
try {
|
||||
await downloadSummaryCardImage(summaryCard.value);
|
||||
uni.showToast({ title: '总结卡图片已下载', icon: 'none' });
|
||||
} catch (error) {
|
||||
const text = error instanceof Error ? error.message : '总结卡下载失败';
|
||||
message.value = text;
|
||||
uni.showToast({ title: text, icon: 'none' });
|
||||
} finally {
|
||||
downloadingCard.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const goLogin = () => {
|
||||
rememberLoginRedirect('/pages/user/sop/index');
|
||||
uni.navigateTo({ url: '/pages/auth/login/index' });
|
||||
};
|
||||
|
||||
const openWebAi = () => {
|
||||
uni.navigateTo({ url: '/pages/user/web-ai/index' });
|
||||
};
|
||||
|
||||
const handleShow = async () => {
|
||||
resetPageScroll();
|
||||
refreshAuthState();
|
||||
@@ -369,6 +732,17 @@ const handleShow = async () => {
|
||||
resetResultState();
|
||||
}
|
||||
const pending = uni.getStorageSync(pendingSopQueryKey);
|
||||
const pendingTool = uni.getStorageSync(pendingToolKey);
|
||||
if (pendingTool === 'MY_PRACTICE_SUMMARY' || pendingTool === 'TEAM_PRACTICE_SUMMARY') {
|
||||
if (!loggedIn.value) {
|
||||
message.value = '登录后继续查询训练概况';
|
||||
goLogin();
|
||||
return;
|
||||
}
|
||||
uni.removeStorageSync(pendingToolKey);
|
||||
void runDataTool(pendingTool);
|
||||
return;
|
||||
}
|
||||
if (!pending) {
|
||||
if (loggedIn.value && query.value.trim() && message.value === '登录后继续查询该问题') {
|
||||
void search();
|
||||
@@ -387,6 +761,17 @@ const handleShow = async () => {
|
||||
onShow(() => {
|
||||
void handleShow();
|
||||
});
|
||||
|
||||
onHide(() => {
|
||||
speechPlayback.stop();
|
||||
cancelVoiceRecording();
|
||||
cancelPendingMediaSearch();
|
||||
});
|
||||
|
||||
onUnload(() => {
|
||||
speechPlayback.destroy();
|
||||
cancelVoiceRecording();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -414,6 +799,26 @@ onShow(() => {
|
||||
content: '';
|
||||
}
|
||||
|
||||
.source-switch {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 4px;
|
||||
margin-top: 16px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.source-option {
|
||||
min-height: 44px;
|
||||
background: transparent;
|
||||
color: var(--employee-muted);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.source-option.active {
|
||||
background: var(--employee-primary-soft);
|
||||
color: var(--employee-primary);
|
||||
}
|
||||
|
||||
.sop-eyebrow {
|
||||
display: inline-flex;
|
||||
margin-bottom: 12px;
|
||||
@@ -798,7 +1203,7 @@ onShow(() => {
|
||||
background: #168243;
|
||||
color: #ffffff;
|
||||
border-radius: 50%;
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -814,13 +1219,13 @@ onShow(() => {
|
||||
/* Steps Timeline */
|
||||
.timeline {
|
||||
position: relative;
|
||||
padding-left: 14px;
|
||||
padding-left: 18px;
|
||||
}
|
||||
|
||||
.timeline::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 5px;
|
||||
left: 8px;
|
||||
top: 6px;
|
||||
bottom: 6px;
|
||||
width: 2px;
|
||||
@@ -840,16 +1245,16 @@ onShow(() => {
|
||||
|
||||
.timeline-node {
|
||||
position: absolute;
|
||||
left: -14px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
left: -18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: #168243;
|
||||
color: #ffffff;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
@@ -921,9 +1326,9 @@ onShow(() => {
|
||||
|
||||
.q-badge,
|
||||
.a-badge {
|
||||
flex: 0 0 18px;
|
||||
height: 18px;
|
||||
font-size: 10px;
|
||||
flex: 0 0 22px;
|
||||
height: 22px;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
@@ -1283,4 +1688,131 @@ onShow(() => {
|
||||
.query-panel .query-caption {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.media-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.voice-query-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.voice-query-button {
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.voice-query-button.recording-active {
|
||||
border-color: var(--employee-primary);
|
||||
background: #fff0f1;
|
||||
color: var(--employee-primary);
|
||||
}
|
||||
|
||||
.voice-query-hint {
|
||||
display: block;
|
||||
margin-top: -2px;
|
||||
color: var(--employee-muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.media-button {
|
||||
min-height: 42px;
|
||||
margin: 0;
|
||||
padding: 0 14px;
|
||||
border-color: #cfe0ff;
|
||||
color: var(--employee-primary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.media-hint {
|
||||
flex: 1;
|
||||
color: var(--employee-muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.media-file {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #cfe0ff;
|
||||
border-radius: 14px;
|
||||
background: var(--employee-primary-soft);
|
||||
}
|
||||
|
||||
.media-file-copy {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.media-kind {
|
||||
color: var(--employee-primary);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.media-name {
|
||||
overflow: hidden;
|
||||
color: var(--employee-text);
|
||||
font-size: 13px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.media-remove {
|
||||
width: 32px;
|
||||
min-width: 32px;
|
||||
height: 32px;
|
||||
min-height: 32px;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
background: #ffffff;
|
||||
color: var(--employee-muted);
|
||||
font-size: 21px;
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
.answer-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.voice-button {
|
||||
min-height: 38px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #f3b7bd;
|
||||
border-radius: 999px;
|
||||
background: #fff7f8;
|
||||
color: var(--employee-primary);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.modal-footer .footer-btn {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.modal-footer .footer-close {
|
||||
grid-column: span 2;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -165,19 +165,36 @@
|
||||
<text class="tip-text">{{ tipBody }}</text>
|
||||
<text class="tip-arrow">›</text>
|
||||
</button>
|
||||
<button class="tip-card" @click="openPracticeSummary">
|
||||
<uni-icons class="tip-mark" type="staff" size="22" color="#e60012" />
|
||||
<text class="tip-label">训练</text>
|
||||
<text class="tip-divider">|</text>
|
||||
<text class="tip-text">查看我的训练概况</text>
|
||||
<text class="tip-arrow">›</text>
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<button class="question-board-card employee-card" @click="openQuestions">
|
||||
<view class="question-board-icon">榜</view>
|
||||
<view class="question-board-copy">
|
||||
<text class="question-board-title">问题榜</text>
|
||||
<text class="question-board-desc">看看大家常遇到的难题,也可以发起真实工作求助</text>
|
||||
</view>
|
||||
<text class="question-board-arrow">›</text>
|
||||
</button>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { onShow } from '@dcloudio/uni-app';
|
||||
import { onHide, onShow, onUnload } from '@dcloudio/uni-app';
|
||||
import type { PracticeAssignment, RoleHome } from '@/types/api';
|
||||
import { getMobileHome } from '@/services/home';
|
||||
import { getPracticeAssignments } from '@/services/practice';
|
||||
import { getSelectedPositionProfile, isLoggedIn, rememberLoginRedirect } from '@/services/auth';
|
||||
import { resetPageScroll } from '@/services/navigation';
|
||||
import { ensureEmployeePosition } from '@/services/position';
|
||||
import { createPageRequestScope, currentAccountKey } from '@/services/request-scope';
|
||||
|
||||
const home = ref<RoleHome | null>(null);
|
||||
const assignments = ref<PracticeAssignment[]>([]);
|
||||
@@ -187,7 +204,10 @@ const positionProfile = ref(getSelectedPositionProfile());
|
||||
const quickQuestions = computed(() => positionProfile.value.quickQuestions);
|
||||
const primaryTipQuestion = computed(() => quickQuestions.value[0] || '岗位SOP');
|
||||
const pendingSopQueryKey = 'aihr_sop_pending_query';
|
||||
const pendingToolKey = 'aihr_sop_pending_tool';
|
||||
const pendingAssignmentKey = 'aihr_pending_assignment_id';
|
||||
const requests = createPageRequestScope();
|
||||
let visibleAccountKey = '';
|
||||
|
||||
const chipIcons = ['chatbubble', 'help', 'wallet'];
|
||||
const chipIcon = (index: number) => chipIcons[index] || 'chatbubble';
|
||||
@@ -293,14 +313,18 @@ const tipBody = computed(() => {
|
||||
|
||||
const mentorBubbleText = computed(() => {
|
||||
if (!loggedIn.value) return '登录后看今天任务';
|
||||
if (primaryAssignment.value?.source === 'daily') return '今天先完成每日三题';
|
||||
if (primaryScheduleTitle.value) {
|
||||
const topic = primaryScheduleTitle.value.replace(/专项|训练|练习/g, '').trim();
|
||||
if (topic) return `今天先把${topic}练熟`;
|
||||
const topic = (primaryAssignment.value?.scenarioName || primaryScheduleTitle.value)
|
||||
.replace(/专项|训练|练习/g, '')
|
||||
.trim();
|
||||
if (topic) return `今天先把${topic.slice(0, 8)}${topic.length > 8 ? '…' : ''}练熟`;
|
||||
}
|
||||
return '今天先把投诉处理练熟';
|
||||
});
|
||||
|
||||
const loadHome = async () => {
|
||||
const request = requests.begin('home');
|
||||
loggedIn.value = isLoggedIn();
|
||||
positionProfile.value = getSelectedPositionProfile();
|
||||
message.value = '';
|
||||
@@ -309,7 +333,7 @@ const loadHome = async () => {
|
||||
home.value = null;
|
||||
return;
|
||||
}
|
||||
if (!(await ensureEmployeePosition('/pages/user/today/index'))) {
|
||||
if (!(await ensureEmployeePosition('/pages/user/today/index')) || !requests.isCurrent(request)) {
|
||||
return;
|
||||
}
|
||||
positionProfile.value = getSelectedPositionProfile();
|
||||
@@ -317,6 +341,7 @@ const loadHome = async () => {
|
||||
getMobileHome('user'),
|
||||
getPracticeAssignments(true)
|
||||
]);
|
||||
if (!requests.isCurrent(request)) return;
|
||||
if (homeResult.status === 'fulfilled') {
|
||||
home.value = homeResult.value;
|
||||
} else {
|
||||
@@ -345,6 +370,11 @@ const ask = (query: string) => {
|
||||
uni.switchTab({ url: '/pages/user/sop/index' });
|
||||
};
|
||||
|
||||
const openPracticeSummary = () => {
|
||||
uni.setStorageSync(pendingToolKey, 'MY_PRACTICE_SUMMARY');
|
||||
uni.switchTab({ url: '/pages/user/sop/index' });
|
||||
};
|
||||
|
||||
const goLogin = () => {
|
||||
rememberLoginRedirect('/pages/user/today/index');
|
||||
uni.navigateTo({ url: '/pages/auth/login/index' });
|
||||
@@ -357,6 +387,15 @@ const openPrimarySchedule = () => {
|
||||
uni.switchTab({ url: '/pages/user/practice/index' });
|
||||
};
|
||||
|
||||
const openQuestions = () => {
|
||||
if (!loggedIn.value) {
|
||||
rememberLoginRedirect('/pages/user/questions/index');
|
||||
uni.navigateTo({ url: '/pages/auth/login/index' });
|
||||
return;
|
||||
}
|
||||
uni.navigateTo({ url: '/pages/user/questions/index' });
|
||||
};
|
||||
|
||||
const openBuilding = (title: string) => {
|
||||
uni.navigateTo({ url: `/pages/common/building/index?title=${encodeURIComponent(title)}` });
|
||||
};
|
||||
@@ -382,9 +421,25 @@ const openTool = (title: string) => {
|
||||
};
|
||||
|
||||
onShow(() => {
|
||||
const nextAccountKey = currentAccountKey();
|
||||
if (visibleAccountKey && visibleAccountKey !== nextAccountKey) {
|
||||
home.value = null;
|
||||
assignments.value = [];
|
||||
message.value = '';
|
||||
}
|
||||
visibleAccountKey = nextAccountKey;
|
||||
requests.activate();
|
||||
resetPageScroll();
|
||||
void loadHome();
|
||||
});
|
||||
|
||||
onHide(() => {
|
||||
requests.invalidate();
|
||||
});
|
||||
|
||||
onUnload(() => {
|
||||
requests.invalidate();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -394,6 +449,57 @@ onShow(() => {
|
||||
background: #f7f7f7;
|
||||
}
|
||||
|
||||
.question-board-card {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
margin-top: 16px;
|
||||
padding: 16px;
|
||||
color: var(--ink);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.question-board-icon {
|
||||
display: flex;
|
||||
flex: 0 0 44px;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 14px;
|
||||
background: #fff0f1;
|
||||
color: var(--brand);
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.question-board-copy {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.question-board-title,
|
||||
.question-board-desc {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.question-board-title {
|
||||
font-size: 17px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.question-board-desc {
|
||||
margin-top: 4px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.question-board-arrow {
|
||||
color: var(--muted);
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.brand-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1030,7 +1136,7 @@ onShow(() => {
|
||||
|
||||
.map-desc {
|
||||
color: #8a8d93;
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
white-space: pre-line;
|
||||
}
|
||||
@@ -1123,7 +1229,7 @@ onShow(() => {
|
||||
}
|
||||
|
||||
.voice-desc {
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.chip-text {
|
||||
@@ -1138,4 +1244,103 @@ onShow(() => {
|
||||
padding-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
:global(html[data-aihr-font-size='extra-large'] .mentor-hero) {
|
||||
min-height: 270px;
|
||||
}
|
||||
|
||||
:global(html[data-aihr-font-size='extra-large'] .hero-copy) {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
padding-right: 18px;
|
||||
}
|
||||
|
||||
:global(html[data-aihr-font-size='extra-large'] .hero-tip) {
|
||||
max-width: 160px;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
:global(html[data-aihr-font-size='extra-large'] .mentor-stage) {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 154px;
|
||||
height: 154px;
|
||||
}
|
||||
|
||||
:global(html[data-aihr-font-size='extra-large'] .mentor-bubble) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:global(html[data-aihr-font-size='extra-large'] .mentor-image) {
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 144px;
|
||||
height: 154px;
|
||||
}
|
||||
|
||||
:global(html[data-aihr-font-size='extra-large'] .voice-panel) {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
:global(html[data-aihr-font-size='extra-large'] .voice-primary),
|
||||
:global(html[data-aihr-font-size='extra-large'] .voice-secondary) {
|
||||
width: 100%;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
:global(html[data-aihr-font-size='extra-large'] .voice-secondary) {
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid #f0f1f3;
|
||||
}
|
||||
|
||||
:global(html[data-aihr-font-size='extra-large'] .secondary-copy) {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
:global(html[data-aihr-font-size='extra-large'] .secondary-copy .voice-desc) {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
:global(html[data-aihr-font-size='extra-large'] .chip-row),
|
||||
:global(html[data-aihr-font-size='extra-large'] .map-grid) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
:global(html[data-aihr-font-size='extra-large'] .chip-text) {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
:global(html[data-aihr-font-size='extra-large'] .login-section) {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
:global(html[data-aihr-font-size='extra-large'] .login-button),
|
||||
:global(html[data-aihr-font-size='extra-large'] .schedule-action) {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
flex: 0 0 100%;
|
||||
}
|
||||
|
||||
:global(html[data-aihr-font-size='extra-large'] .schedule-row) {
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
:global(html[data-aihr-font-size='extra-large'] .schedule-copy) {
|
||||
flex-basis: calc(100% - 64px);
|
||||
}
|
||||
|
||||
:global(html[data-aihr-font-size='extra-large'] .tip-card) {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
:global(html[data-aihr-font-size='extra-large'] .tip-text) {
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
white-space: normal;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
<template>
|
||||
<view class="page employee-page-shell web-ai-page">
|
||||
<view class="subpage-header">
|
||||
<button class="subpage-back-button" aria-label="返回问师傅" @click="goBack">←</button>
|
||||
<view class="subpage-heading">
|
||||
<text class="employee-kicker">互联网信息 · 独立来源</text>
|
||||
<text class="page-title">问 AI(全网)</text>
|
||||
<text class="page-subtitle">检索公开网页并保留可核验引用,不与企业 SOP 混用。</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="source-switch employee-card">
|
||||
<button class="source-option" @click="goBack">企业问师傅</button>
|
||||
<button class="source-option active" aria-current="page">问 AI(全网)</button>
|
||||
</view>
|
||||
|
||||
<view class="warning-card">
|
||||
<text class="warning-title">发送前请注意</text>
|
||||
<text>问题会先脱敏,再发送给外部搜索与 AI 服务。答案来自互联网,不代表公司制度;涉及作业、消防、电梯或法律事项,请以公司 SOP 和官方渠道为准。</text>
|
||||
</view>
|
||||
|
||||
<view v-if="!loggedIn" class="panel employee-card login-panel">
|
||||
<view>
|
||||
<text class="panel-title">需要登录</text>
|
||||
<text class="muted block">登录后才能使用独立的全网检索额度和隐私告知。</text>
|
||||
</view>
|
||||
<button class="primary-button" @click="goLogin">去登录</button>
|
||||
</view>
|
||||
|
||||
<template v-else>
|
||||
<view class="panel employee-card query-panel">
|
||||
<text class="panel-title">你的问题</text>
|
||||
<textarea
|
||||
v-model="query"
|
||||
class="textarea query-input"
|
||||
:maxlength="capabilities?.maxQuestionLength || 500"
|
||||
placeholder="例如:最近物业服务行业有哪些官方新规?"
|
||||
/>
|
||||
<text class="query-hint">不要输入业主姓名、手机号、房号、身份证、门禁或公司未公开资料。最多 {{ capabilities?.maxQuestionLength || 500 }} 字。</text>
|
||||
<view v-if="capabilities && !capabilities.enabled" class="status-message">
|
||||
全网 AI 尚未由管理员启用,不会静默改用企业知识库。
|
||||
</view>
|
||||
<label v-if="capabilities?.enabled && capabilities.requiresConsent" class="consent-row">
|
||||
<checkbox :checked="consentChecked" color="#e60012" @click="consentChecked = !consentChecked" />
|
||||
<text>我已阅读并同意将脱敏后的问题发送到外部公开检索服务</text>
|
||||
</label>
|
||||
<button
|
||||
class="primary-button submit-button"
|
||||
:disabled="loading || !capabilities?.enabled || (capabilities.requiresConsent && !consentChecked) || !query.trim()"
|
||||
@click="search"
|
||||
>
|
||||
{{ loading ? '正在检索并核对来源' : '开始全网查询' }}
|
||||
</button>
|
||||
<text v-if="message" class="status-message">{{ message }}</text>
|
||||
</view>
|
||||
|
||||
<view v-if="result" class="panel employee-card answer-panel">
|
||||
<view class="answer-head">
|
||||
<text class="panel-title">全网回答</text>
|
||||
<button class="ghost-button compact-button" :disabled="speech.status === 'loading'" @click="toggleSpeech">
|
||||
{{ speech.status === 'playing' ? '停止播报' : speech.status === 'loading' ? '生成语音中' : '语音播报' }}
|
||||
</button>
|
||||
</view>
|
||||
<text v-if="result.status === 'NO_ANSWER'" class="no-evidence">已找到公开来源,但外部服务未返回可核验答案,请直接查看下方来源。</text>
|
||||
<text v-else-if="result.noEvidence" class="no-evidence">未找到可核验的公开依据,本次不生成推断答案。</text>
|
||||
<text v-else class="answer-text">{{ result.answer }}</text>
|
||||
<text v-if="result.redacted" class="redacted-note">已自动隐藏问题中的个人或门禁信息</text>
|
||||
<text class="answer-warning">{{ result.warning }}</text>
|
||||
</view>
|
||||
|
||||
<view v-if="result?.citations?.length" class="panel employee-card sources-panel">
|
||||
<text class="panel-title">公开来源</text>
|
||||
<a
|
||||
v-for="(item, index) in result.citations"
|
||||
:key="`${item.url}-${index}`"
|
||||
class="source-card"
|
||||
:href="item.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<text class="source-index">{{ index + 1 }}</text>
|
||||
<view class="source-copy">
|
||||
<text class="source-title">{{ item.title }}</text>
|
||||
<text class="source-domain">{{ item.domain }}{{ item.publishedAt ? ` · ${item.publishedAt}` : '' }}</text>
|
||||
<text class="source-snippet">{{ item.snippet }}</text>
|
||||
</view>
|
||||
</a>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, ref } from 'vue';
|
||||
import { onShow } from '@dcloudio/uni-app';
|
||||
import type { WebAiCapabilities, WebAiResponse } from '@/types/api';
|
||||
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
|
||||
import { createSpeechPlaybackController, type SpeechPlaybackSnapshot } from '@/services/speech';
|
||||
import { getWebAiCapabilities, queryWebAi } from '@/services/web-ai';
|
||||
|
||||
const path = '/pages/user/web-ai/index';
|
||||
const loggedIn = ref(false);
|
||||
const query = ref('');
|
||||
const message = ref('');
|
||||
const loading = ref(false);
|
||||
const consentChecked = ref(false);
|
||||
const capabilities = ref<WebAiCapabilities | null>(null);
|
||||
const result = ref<WebAiResponse | null>(null);
|
||||
const speech = ref<SpeechPlaybackSnapshot>({ key: '', status: 'idle' });
|
||||
const speechController = createSpeechPlaybackController(
|
||||
(snapshot) => { speech.value = snapshot; },
|
||||
(error) => { message.value = error; }
|
||||
);
|
||||
|
||||
const goBack = () => uni.switchTab({ url: '/pages/user/sop/index' });
|
||||
const goLogin = () => {
|
||||
rememberLoginRedirect(path);
|
||||
uni.navigateTo({ url: '/pages/auth/login/index' });
|
||||
};
|
||||
|
||||
const loadCapabilities = async () => {
|
||||
if (!loggedIn.value) return;
|
||||
try {
|
||||
capabilities.value = await getWebAiCapabilities();
|
||||
} catch (error) {
|
||||
message.value = error instanceof Error ? error.message : '全网 AI 状态读取失败';
|
||||
}
|
||||
};
|
||||
|
||||
const search = async () => {
|
||||
const value = query.value.trim();
|
||||
if (!value || !capabilities.value?.enabled) return;
|
||||
loading.value = true;
|
||||
message.value = '';
|
||||
result.value = null;
|
||||
speechController.stop();
|
||||
try {
|
||||
result.value = await queryWebAi(value, consentChecked.value);
|
||||
} catch (error) {
|
||||
message.value = error instanceof Error ? error.message : '全网查询失败';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSpeech = () => {
|
||||
if (!result.value?.answer) return;
|
||||
speechController.toggle('web-answer', result.value.answer);
|
||||
};
|
||||
|
||||
onShow(() => {
|
||||
loggedIn.value = isLoggedIn();
|
||||
capabilities.value = null;
|
||||
result.value = null;
|
||||
consentChecked.value = false;
|
||||
void loadCapabilities();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => speechController.destroy());
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.web-ai-page { max-width: var(--mobile-shell-max-width); margin: 0 auto; }
|
||||
.source-switch { display: grid; grid-template-columns: 1fr 1fr; gap: 4px; margin-top: 18px; padding: 4px; }
|
||||
.source-option { min-height: 44px; background: transparent; color: var(--muted); font-weight: 800; }
|
||||
.source-option.active { background: var(--employee-primary-soft); color: var(--brand); }
|
||||
.warning-card { display: grid; gap: 6px; margin-top: 14px; padding: 14px 16px; border: 1px solid #f0c879; border-radius: 16px; background: #fff9e9; color: #78500a; font-size: 13px; line-height: 1.6; }
|
||||
.warning-title { font-weight: 900; }
|
||||
.login-panel { display: grid; gap: 14px; }
|
||||
.block { display: block; margin-top: 6px; }
|
||||
.query-panel { display: grid; gap: 12px; }
|
||||
.query-input { min-height: 148px; }
|
||||
.query-hint { color: var(--muted); font-size: 12px; line-height: 1.55; }
|
||||
.consent-row { display: flex; gap: 8px; align-items: flex-start; color: #435268; font-size: 13px; line-height: 1.55; }
|
||||
.submit-button { width: 100%; }
|
||||
.status-message { display: block; padding: 10px 12px; border-radius: 8px; background: #fff0f1; color: #a9000d; font-size: 13px; line-height: 1.5; }
|
||||
.answer-head { display: flex; gap: 12px; align-items: center; justify-content: space-between; }
|
||||
.compact-button { min-height: 38px; padding: 0 12px; font-size: 13px; }
|
||||
.answer-text, .no-evidence { display: block; margin-top: 14px; color: var(--ink); font-size: 16px; line-height: 1.8; white-space: pre-wrap; }
|
||||
.no-evidence { color: var(--warning); font-weight: 800; }
|
||||
.answer-warning { display: block; margin-top: 12px; color: var(--warning); font-size: 12px; line-height: 1.5; }
|
||||
.redacted-note { display:block; margin-top:10px; color:var(--success); font-size:12px; font-weight:800; }
|
||||
.sources-panel { display: grid; gap: 12px; }
|
||||
.source-card { display: flex; gap: 10px; padding: 14px; border: 1px solid var(--line); border-radius: 12px; color: inherit; text-decoration: none; overflow: hidden; }
|
||||
.source-index { flex: 0 0 24px; height: 24px; border-radius: 50%; background: #eef3f9; color: #315b8f; font-weight: 900; line-height: 24px; text-align: center; }
|
||||
.source-copy { min-width: 0; }
|
||||
.source-title, .source-domain, .source-snippet { display: block; overflow-wrap: anywhere; }
|
||||
.source-title { font-weight: 900; line-height: 1.45; }
|
||||
.source-domain { margin-top: 4px; color: var(--knowledge); font-size: 12px; }
|
||||
.source-snippet { margin-top: 7px; color: var(--muted); font-size: 13px; line-height: 1.55; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user