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

1412 lines
32 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="page sop-page employee-page-shell">
<view class="sop-head">
<text class="employee-kicker sop-eyebrow">SOP · 话术 · 工作流程</text>
<text class="page-title">问</text>
<text class="page-subtitle">数字师傅 · SOP问答</text>
<text class="sop-context">{{ positionLabel }} · 住宅项目</text>
<text class="toolbar-tip">先把问题问清楚,再按需生成总结卡,不伪造分类能力。</text>
</view>
<view class="panel query-panel employee-card">
<view class="query-hero">
<view class="query-copy">
<text class="query-title">问师傅</text>
<text class="query-subtitle">直接问工作中的 SOP、话术和流程</text>
</view>
<view class="query-visual" aria-hidden="true">
<view class="query-bubble primary"><text>•••</text></view>
<view class="query-bubble secondary"><text>•••</text></view>
</view>
</view>
<textarea
v-model="query"
class="textarea sop-query-textarea"
maxlength="200"
:placeholder="quickQuestionPlaceholder"
/>
<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">
{{ loading ? '检索中' : '问老师傅' }}
</button>
<text v-if="message" class="status-message">{{ message }}</text>
</view>
<view v-if="loggedIn && !result" class="knowledge-tip-card employee-card">
<view class="knowledge-tip-icon">⌕</view>
<view class="knowledge-tip-copy">
<text class="knowledge-tip-title">知识库引用可追溯</text>
<text class="knowledge-tip-desc">答案来自标准 SOP、附件来源,查得清楚、用得放心</text>
</view>
<text class="knowledge-tip-arrow">›</text>
</view>
<view v-if="loggedIn && !result && !loading" class="answer-empty employee-card">
<text class="answer-empty-icon">•••</text>
<text class="answer-empty-title">暂无相关回答</text>
<text class="answer-empty-desc">换个问题试试吧</text>
</view>
<view v-if="!loggedIn" class="panel login-panel employee-card">
<view>
<text class="panel-title">需要登录</text>
<text class="muted block">登录后才能查询 SOP、生成总结卡并反馈答案。</text>
</view>
<button class="primary-button login-button" @click="goLogin">去登录</button>
</view>
<view v-if="result" class="panel answer-panel employee-card">
<text class="panel-title">师傅回答</text>
<view class="question-card">
<text class="question-label">我的问题</text>
<text class="question-text">{{ query }}</text>
</view>
<view class="answer-copy-card">
<text class="answer-text">{{ result.answer || '未生成直接答案,请查看下方引用。' }}</text>
</view>
<view v-if="result.keyPoints?.length" class="keypoints">
<text v-for="(point, index) in result.keyPoints" :key="point" class="keypoint">
{{ index + 1 }}. {{ cleanOrderedText(point) }}
</text>
</view>
<view v-if="result.cautions?.length" class="cautions">
<text v-for="item in result.cautions" :key="item">{{ item }}</text>
</view>
<view class="feedback-actions">
<button
class="secondary-button"
:class="{ active: feedbackVerdict === 'up' }"
:disabled="feedbackSubmitting || Boolean(feedbackVerdict) || showNegativeFeedback"
@click="feedback('up')"
>
有帮助
</button>
<button
class="secondary-button"
:class="{ active: feedbackVerdict === 'down' || showNegativeFeedback, negative: feedbackVerdict === 'down' || showNegativeFeedback }"
:disabled="feedbackSubmitting || Boolean(feedbackVerdict)"
@click="openNegativeFeedback"
>
没解决
</button>
</view>
<view v-if="showNegativeFeedback && !feedbackVerdict" class="negative-feedback-panel">
<text class="negative-feedback-title">哪里没有解决?(可多选)</text>
<view class="negative-reason-list">
<button
v-for="item in feedbackReasonOptions"
:key="item.code"
class="negative-reason"
:class="{ active: selectedFeedbackReasons.includes(item.code) }"
:disabled="feedbackSubmitting"
@click="toggleFeedbackReason(item.code)"
>
{{ item.label }}
</button>
</view>
<textarea
v-model="feedbackComment"
class="textarea negative-feedback-comment"
maxlength="500"
placeholder="补充说明(可选)"
/>
<view class="negative-feedback-actions">
<button class="secondary-button" :disabled="feedbackSubmitting" @click="closeNegativeFeedback">取消</button>
<button
class="primary-button"
:disabled="feedbackSubmitting || !selectedFeedbackReasons.length"
@click="submitNegativeFeedback"
>
{{ feedbackSubmitting ? '提交中' : '提交反馈' }}
</button>
</view>
</view>
<text v-if="feedbackVerdict" class="feedback-status">
{{ feedbackVerdict === 'up' ? '已记录为有帮助' : '已提交未解决反馈,后续会继续优化这条答案' }}
</text>
</view>
<view v-if="result?.snippets?.length" class="panel sources-panel">
<text class="panel-title">引用依据</text>
<view v-for="(snippet, index) in result.snippets" :key="`${snippet.docTitle}-${index}`" class="snippet">
<text class="snippet-title">{{ snippet.docTitle }}</text>
<text class="snippet-content">{{ snippetExcerpt(snippet.content) }}</text>
<text v-if="snippet.fragmentId" class="snippet-id">片段 #{{ snippet.fragmentId }}</text>
</view>
</view>
<view v-if="result" class="panel summary-panel employee-card">
<text class="panel-title">总结卡</text>
<text class="muted summary-copy">需要时再生成,适合把一段 SOP 整理成步骤卡、话术卡和提醒卡。</text>
<button class="ghost-button" :disabled="summaryLoading" @click="buildSummaryCard">
{{ summaryLoading ? '生成中' : '生成总结卡' }}
</button>
</view>
<view v-if="summaryCard" class="modal-mask" @click="closeSummary">
<view class="modal-card" @click.stop>
<view class="modal-header">
<text class="modal-title">{{ summaryCard.title }}</text>
<button class="modal-close" aria-label="关闭总结卡" title="关闭总结卡" @click="closeSummary">×</button>
</view>
<scroll-view scroll-y class="modal-body">
<view v-if="summaryCard.steps?.length" class="info-section">
<view class="section-title-row">
<text class="badge-num">1</text>
<text class="info-section-title">核心步骤</text>
</view>
<view class="timeline">
<view v-for="(step, index) in summaryCard.steps" :key="index" class="timeline-item">
<view class="timeline-node">{{ index + 1 }}</view>
<view class="timeline-content">
<text class="timeline-label">{{ step.label }}</text>
<text class="timeline-desc">{{ step.desc }}</text>
</view>
</view>
</view>
</view>
<view v-if="summaryCard.scripts?.length" class="info-section speech-section">
<view class="section-title-row">
<text class="badge-num">2</text>
<text class="info-section-title">推荐话术</text>
</view>
<view class="speech-bubbles">
<view v-for="(script, index) in summaryCard.scripts" :key="index" class="speech-bubble">
<text class="quote-mark">“</text>
<text class="speech-text">{{ script }}</text>
</view>
</view>
</view>
<view v-if="summaryCard.objections?.length" class="info-section objection-section">
<view class="section-title-row">
<text class="badge-num">3</text>
<text class="info-section-title">常见异议与回应</text>
</view>
<view class="objection-list">
<view v-for="(obj, index) in summaryCard.objections" :key="index" class="objection-card">
<view class="objection-q">
<text class="q-badge">问</text>
<text class="q-text">{{ obj.concern }}</text>
</view>
<view class="objection-a">
<text class="a-badge">答</text>
<text class="a-text">{{ obj.response }}</text>
</view>
</view>
</view>
</view>
<view v-if="summaryCard.reminders?.length" class="info-section reminder-section">
<view class="section-title-row">
<text class="badge-num">4</text>
<text class="info-section-title">师傅提醒</text>
</view>
<view class="reminder-list">
<view v-for="(rem, index) in summaryCard.reminders" :key="index" class="reminder-item">
<text class="reminder-text">{{ rem }}</text>
</view>
</view>
</view>
</scroll-view>
<view class="modal-footer">
<button class="primary-button footer-btn" @click="closeSummary">我知道了</button>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import type {
AnswerFeedbackVerdict,
KnowledgeSearchResponse,
SummaryCardResponse
} from '@/types/api';
import {
generateSummaryCard,
searchKnowledge,
submitAnswerFeedback
} from '@/services/knowledge';
import { getSelectedPosition, getSelectedPositionProfile, isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import { resetPageScroll } from '@/services/navigation';
import { ensureEmployeePosition } from '@/services/position';
const positionProfile = ref(getSelectedPositionProfile());
const positionLabel = ref(getSelectedPosition());
const quickQuestions = computed(() => positionProfile.value.quickQuestions);
const quickQuestionPlaceholder = computed(() => `例如: ${quickQuestions.value[0] || '岗位SOP怎么处理?'}`);
const query = ref('');
const result = ref<KnowledgeSearchResponse | null>(null);
const summaryCard = ref<SummaryCardResponse | null>(null);
const loading = ref(false);
const summaryLoading = ref(false);
const feedbackSubmitting = ref(false);
const feedbackVerdict = ref<AnswerFeedbackVerdict | ''>('');
const showNegativeFeedback = ref(false);
const selectedFeedbackReasons = ref<string[]>([]);
const feedbackComment = ref('');
const message = ref('');
const loggedIn = ref(false);
const pendingSopQueryKey = 'aihr_sop_pending_query';
const feedbackReasonOptions = [
{ code: 'no_answer', label: '没有回答问题' },
{ code: 'not_specific', label: '步骤不够具体' },
{ code: 'wrong_reference', label: '引用依据不准确' },
{ code: 'outdated', label: '内容已经过期' },
{ code: 'not_applicable', label: '话术不适合现场' }
];
const resetResultState = () => {
result.value = null;
summaryCard.value = null;
feedbackVerdict.value = '';
feedbackSubmitting.value = false;
showNegativeFeedback.value = false;
selectedFeedbackReasons.value = [];
feedbackComment.value = '';
};
const refreshAuthState = () => {
loggedIn.value = isLoggedIn();
positionLabel.value = getSelectedPosition();
positionProfile.value = getSelectedPositionProfile();
};
const handleRequestError = (error: unknown, fallback: string, wasLoggedIn = loggedIn.value) => {
refreshAuthState();
if (wasLoggedIn && !loggedIn.value) {
resetResultState();
message.value = '登录已过期,请重新登录';
return true;
}
message.value = error instanceof Error ? error.message : fallback;
return false;
};
const search = async () => {
if (loading.value) return;
const value = query.value.trim();
if (!value) {
message.value = '请先输入问题';
return;
}
refreshAuthState();
if (!loggedIn.value) {
uni.setStorageSync(pendingSopQueryKey, value);
goLogin();
return;
}
if (!(await ensureEmployeePosition('/pages/user/sop/index'))) {
return;
}
positionProfile.value = getSelectedPositionProfile();
positionLabel.value = getSelectedPosition();
loading.value = true;
message.value = '';
resetResultState();
try {
result.value = await searchKnowledge(value);
resetPageScroll();
if (!result.value.answer && !result.value.snippets?.length) {
message.value = '未检索到可引用内容';
}
} catch (error) {
handleRequestError(error, '检索失败', true);
} finally {
loading.value = false;
}
};
const useQuickQuestion = (value: string) => {
query.value = value;
void search();
};
const snippetExcerpt = (text = '') => {
const value = text.trim();
return value.length > 260 ? `${value.slice(0, 260)}...` : value;
};
const cleanOrderedText = (text = '') =>
text
.trim()
.replace(/^(?:[一二三四五六七八九十]+[、..]|[0-9]+[.)、..])\s*/, '')
.trim();
const feedback = async (
verdict: AnswerFeedbackVerdict,
details: { reasonCodes?: string[]; comment?: string } = {}
) => {
if (!result.value || feedbackSubmitting.value || feedbackVerdict.value) return;
const fragmentIds = Array.from(
new Set(
(result.value.snippets || [])
.map((item) => item.fragmentId)
.filter((id): id is number => typeof id === 'number' && id > 0)
)
);
if (!fragmentIds.length) {
uni.showToast({ title: '暂无可反馈依据', icon: 'none' });
return;
}
feedbackSubmitting.value = true;
try {
await submitAnswerFeedback({
queryText: result.value.queryText || query.value,
fragmentIds,
verdict,
reviewId: result.value.reviewId,
reasonCodes: details.reasonCodes,
comment: details.comment
});
feedbackVerdict.value = verdict;
showNegativeFeedback.value = false;
uni.showToast({ title: verdict === 'down' ? '已进入复核' : '已记录', icon: 'none' });
} catch (error) {
if (!handleRequestError(error, '反馈失败', true)) {
uni.showToast({
title: message.value,
icon: 'none'
});
}
} finally {
feedbackSubmitting.value = false;
}
};
const openNegativeFeedback = () => {
if (feedbackSubmitting.value || feedbackVerdict.value) return;
showNegativeFeedback.value = true;
selectedFeedbackReasons.value = [];
feedbackComment.value = '';
};
const closeNegativeFeedback = () => {
if (feedbackSubmitting.value) return;
showNegativeFeedback.value = false;
};
const toggleFeedbackReason = (code: string) => {
selectedFeedbackReasons.value = selectedFeedbackReasons.value.includes(code)
? selectedFeedbackReasons.value.filter((item) => item !== code)
: [...selectedFeedbackReasons.value, code];
};
const submitNegativeFeedback = () => {
if (!selectedFeedbackReasons.value.length) return;
void feedback('down', {
reasonCodes: selectedFeedbackReasons.value,
comment: feedbackComment.value.trim()
});
};
const buildSummaryCard = async () => {
if (summaryLoading.value) return;
const value = (result.value?.queryText || query.value).trim();
if (!value) return;
summaryLoading.value = true;
message.value = '';
summaryCard.value = null;
try {
summaryCard.value = await generateSummaryCard(value);
} catch (error) {
handleRequestError(error, '总结卡生成失败', true);
} finally {
summaryLoading.value = false;
}
};
const closeSummary = () => {
summaryCard.value = null;
};
const goLogin = () => {
rememberLoginRedirect('/pages/user/sop/index');
uni.navigateTo({ url: '/pages/auth/login/index' });
};
const handleShow = async () => {
resetPageScroll();
refreshAuthState();
if (loggedIn.value && !(await ensureEmployeePosition('/pages/user/sop/index'))) {
return;
}
positionProfile.value = getSelectedPositionProfile();
if (!loggedIn.value) {
resetResultState();
}
const pending = uni.getStorageSync(pendingSopQueryKey);
if (!pending) {
if (loggedIn.value && query.value.trim() && message.value === '登录后继续查询该问题') {
void search();
}
return;
}
query.value = String(pending);
if (loggedIn.value) {
uni.removeStorageSync(pendingSopQueryKey);
void search();
} else {
message.value = '登录后继续查询该问题';
}
};
onShow(() => {
void handleShow();
});
</script>
<style scoped>
.sop-page {
padding: 0 16px 108px;
background: linear-gradient(180deg, var(--employee-bg-soft) 0%, var(--employee-bg) 100%);
}
.sop-head {
position: relative;
margin: 0 -16px;
padding: 30px 20px 24px;
border-bottom: 1px solid var(--employee-line);
background: linear-gradient(180deg, #eef4ff 0%, #f5f8fd 100%);
}
.sop-head::after {
position: absolute;
right: 20px;
bottom: 20px;
width: 42px;
height: 4px;
border-radius: 4px;
background: var(--employee-primary);
content: '';
}
.sop-eyebrow {
display: inline-flex;
margin-bottom: 12px;
}
.sop-head .page-title {
color: #1f2b3d;
font-size: 31px;
line-height: 1.12;
}
.sop-head .page-subtitle {
max-width: 292px;
margin-top: 10px;
color: #607086;
font-size: 14px;
line-height: 1.55;
}
.toolbar-tip {
display: block;
max-width: 286px;
margin-top: 14px;
color: #315b8f;
font-size: 12px;
line-height: 1.5;
}
.query-panel,
.answer-panel,
.summary-panel {
display: flex;
flex-direction: column;
gap: 14px;
}
.query-panel {
margin-top: 16px;
padding: 20px;
border-top: 0;
box-shadow: var(--employee-card-shadow);
}
.sop-query-textarea {
min-height: 112px;
border-color: #d6deea;
background: #f9fbff;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7);
}
.query-caption {
color: #6f7b8f;
font-size: 12px;
line-height: 1.5;
}
.query-panel .textarea {
min-height: 104px;
height: 104px;
border-color: #d9dade;
border-radius: 7px;
background: #fbfbfb;
color: #25282d;
}
.query-panel .chip-row {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 7px;
}
.query-panel .chip {
min-height: 38px;
padding: 0 10px;
border-color: #dbe5f4;
border-radius: 999px;
background: #f7faff;
color: #47607f;
font-size: 13px;
white-space: nowrap;
}
.query-submit {
width: 100%;
margin-top: 2px;
min-height: 50px;
border-radius: 18px;
background: linear-gradient(135deg, #2f6bff, #5a8cff);
box-shadow: 0 14px 28px rgba(47, 107, 255, 0.22);
}
.login-panel {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
margin-top: 22px;
border-left: 4px solid #e70014;
box-shadow: var(--employee-card-shadow);
}
.block {
display: block;
margin-top: 8px;
font-size: 13px;
line-height: 1.5;
}
.login-button {
width: 96px;
min-width: 96px;
flex: 0 0 96px;
padding: 0 8px;
white-space: nowrap;
}
.answer-text {
color: #243246;
font-size: 18px;
font-weight: 700;
line-height: 1.65;
}
.answer-panel {
border-color: var(--employee-line);
border-left: 4px solid var(--employee-primary);
box-shadow: var(--employee-card-shadow);
}
.answer-panel .panel-title {
color: var(--employee-primary);
}
.question-card,
.answer-copy-card {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 12px;
padding: 14px 16px;
border-radius: 14px;
}
.question-card {
background: #fff1f1;
}
.question-label {
color: #e34b4b;
font-size: 13px;
font-weight: 700;
}
.question-text {
color: #25282d;
font-size: 16px;
line-height: 1.55;
}
.answer-copy-card {
background: #fff;
}
.keypoints {
display: flex;
flex-direction: column;
gap: 8px;
}
.keypoint {
padding: 10px 12px;
border-left: 3px solid #168243;
border-radius: 5px;
background: #f2f8f4;
color: #45484e;
font-size: 15px;
line-height: 1.5;
}
.cautions {
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px 12px;
border-left: 3px solid #d98200;
border-radius: 5px;
background: #fff8e9;
color: #855000;
font-size: 13px;
line-height: 1.45;
}
.feedback-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.feedback-actions .active {
border-color: var(--employee-primary);
background: var(--employee-primary-soft);
color: var(--employee-primary);
}
.feedback-actions .negative {
border-color: var(--employee-danger);
background: #fff1f0;
color: #c34d45;
}
.negative-feedback-panel {
display: flex;
flex-direction: column;
gap: 12px;
padding: 14px;
border-radius: 16px;
background: #fff8f8;
}
.negative-feedback-title {
color: var(--employee-text);
font-size: 15px;
font-weight: 900;
}
.negative-reason-list {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.negative-reason {
min-height: 40px;
padding: 0 10px;
border: 1px solid #f1d7d9;
background: #ffffff;
color: #5c626c;
font-size: 13px;
text-align: left;
}
.negative-reason.active {
border-color: var(--employee-primary);
background: var(--employee-primary-soft);
color: var(--employee-primary);
}
.negative-feedback-comment {
min-height: 84px;
border-color: #f1d7d9;
}
.negative-feedback-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.feedback-status {
display: block;
padding: 10px 12px;
border-radius: 12px;
background: var(--employee-primary-soft);
color: var(--employee-primary);
font-size: 13px;
line-height: 1.5;
}
.sources-panel {
padding: 0;
border: 0;
background: transparent;
box-shadow: none;
}
.sources-panel .panel-title {
display: block;
margin-top: 26px;
color: #25282d;
font-size: 20px;
}
.snippet {
display: flex;
margin-top: 12px;
flex-direction: column;
gap: 6px;
padding: 14px;
border: 1px solid #e3e4e7;
border-color: var(--employee-line);
border-radius: 18px;
background: #ffffff;
box-shadow: 0 5px 14px rgba(37, 40, 45, 0.04);
}
.snippet-title {
color: #34373c;
font-weight: 800;
}
.snippet-content {
color: #60636a;
font-size: 14px;
line-height: 1.55;
}
.snippet-id {
color: #96999f;
font-size: 12px;
}
.summary-block {
display: flex;
margin-top: 14px;
flex-direction: column;
gap: 8px;
}
.summary-panel {
border-color: #f4e4c4;
background: #fffaf2;
box-shadow: var(--employee-card-shadow);
}
.summary-panel .muted {
font-size: 13px;
line-height: 1.5;
}
.summary-copy {
max-width: 278px;
}
.summary-panel .ghost-button {
min-height: 48px;
border: 1px solid #d7e1f5;
border-radius: 18px;
background: #f6f9ff;
color: #315b8f;
}
/* Modal mask overlay */
.modal-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(15, 23, 42, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 999;
padding: 20px;
}
.modal-card {
width: 100%;
max-width: 400px;
max-height: 80vh;
background: #ffffff;
border-radius: 16px;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.15), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
display: flex;
flex-direction: column;
overflow: hidden;
border-top: 5px solid #168243;
}
.modal-header {
padding: 16px 20px;
border-bottom: 1px solid #f1f5f9;
display: flex;
align-items: center;
justify-content: space-between;
}
.modal-title {
font-size: 18px;
font-weight: 900;
color: #0f172a;
}
.modal-close {
background: transparent;
border: none;
font-size: 24px;
color: #94a3b8;
cursor: pointer;
padding: 0;
line-height: 1;
width: auto;
min-height: auto;
}
.modal-body {
flex: 1;
padding: 20px;
overflow-y: auto;
max-height: 55vh;
}
.modal-footer {
padding: 16px 20px;
border-top: 1px solid #f1f5f9;
}
.footer-btn {
width: 100%;
background: var(--employee-primary);
box-shadow: 0 8px 18px rgba(47, 107, 255, 0.18);
color: #ffffff;
}
/* Infographic Sections */
.info-section {
margin-bottom: 24px;
}
.section-title-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 14px;
}
.badge-num {
width: 20px;
height: 20px;
background: #168243;
color: #ffffff;
border-radius: 50%;
font-size: 11px;
font-weight: 800;
display: flex;
align-items: center;
justify-content: center;
}
.info-section-title {
font-size: 15px;
font-weight: 900;
color: #1e293b;
}
/* Steps Timeline */
.timeline {
position: relative;
padding-left: 14px;
}
.timeline::before {
content: '';
position: absolute;
left: 5px;
top: 6px;
bottom: 6px;
width: 2px;
background: #e2e8f0;
}
.timeline-item {
position: relative;
padding-bottom: 16px;
display: flex;
gap: 12px;
}
.timeline-item:last-child {
padding-bottom: 0;
}
.timeline-node {
position: absolute;
left: -14px;
width: 12px;
height: 12px;
background: #168243;
color: #ffffff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 8px;
font-weight: 800;
}
.timeline-content {
flex: 1;
}
.timeline-label {
display: block;
font-size: 14px;
font-weight: 800;
color: #334155;
margin-bottom: 4px;
}
.timeline-desc {
display: block;
font-size: 12px;
color: #64748b;
line-height: 1.45;
}
/* Speech bubbles/scripts */
.speech-bubble {
position: relative;
background: #f2f8f4;
border-left: 3px solid #168243;
border-radius: 8px;
padding: 12px 14px;
margin-bottom: 10px;
}
.quote-mark {
position: absolute;
left: 6px;
top: -2px;
font-size: 24px;
color: rgba(22, 130, 67, 0.15);
font-family: Georgia, serif;
line-height: 1;
}
.speech-text {
font-size: 13.5px;
color: #1e293b;
line-height: 1.5;
position: relative;
z-index: 1;
}
/* Objections Q&A */
.objection-card {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 12px;
margin-bottom: 10px;
}
.objection-q,
.objection-a {
display: flex;
gap: 8px;
}
.objection-q {
margin-bottom: 8px;
}
.q-badge,
.a-badge {
flex: 0 0 18px;
height: 18px;
font-size: 10px;
font-weight: 800;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
}
.q-badge {
background: #fee2e2;
color: #b91c1c;
}
.a-badge {
background: #dcfce7;
color: #166534;
}
.q-text {
font-size: 13px;
font-weight: 800;
color: #334155;
line-height: 1.4;
}
.a-text {
font-size: 13px;
color: #475569;
line-height: 1.45;
}
/* Reminders alert box */
.reminder-item {
display: flex;
gap: 8px;
background: #fffbeb;
border-radius: 8px;
padding: 10px 12px;
margin-bottom: 8px;
align-items: flex-start;
border-left: 3px solid #d97706;
}
.reminder-text {
font-size: 13px;
color: #78350f;
line-height: 1.45;
}
/* 视觉稿对齐:与员工端练页统一的蓝白轻卡片体系 */
.sop-page {
padding: 0 16px 108px;
background: linear-gradient(180deg, var(--employee-bg-soft) 0%, var(--employee-bg) 100%);
}
.sop-head {
margin: 0 -16px;
padding: 30px 20px 24px;
border-bottom: 1px solid var(--employee-line);
background: linear-gradient(180deg, #eef4ff 0%, #f5f8fd 100%);
text-align: left;
}
.sop-eyebrow,
.toolbar-tip {
display: block;
}
.sop-head .page-title {
color: var(--employee-text);
font-size: 30px;
font-weight: 900;
}
.sop-head .page-subtitle {
max-width: none;
margin-top: 10px;
color: var(--employee-muted);
font-size: 14px;
}
.sop-context {
display: inline-flex;
margin-top: 14px;
padding: 7px 14px;
border: 1px solid #cfe0ff;
border-radius: 999px;
background: var(--employee-primary-soft);
color: var(--employee-primary);
font-size: 13px;
font-weight: 800;
}
.query-panel {
margin-top: 16px;
padding: 22px 18px 18px;
border: 0;
border-radius: var(--employee-card-radius);
background: #ffffff;
box-shadow: 0 8px 24px rgba(24, 28, 36, 0.06);
}
.query-panel .textarea,
.sop-query-textarea {
min-height: 126px;
height: 126px;
border-color: #dedfe2;
border-radius: 16px;
background: #ffffff;
}
.query-panel .chip {
min-height: 40px;
border-color: #cfe0ff;
border-radius: 999px;
background: #ffffff;
color: var(--employee-primary);
}
.query-submit {
min-height: 54px;
margin-top: 8px;
border-radius: 18px;
background: linear-gradient(135deg, #2f6bff, #5a8cff);
box-shadow: 0 8px 18px rgba(47, 107, 255, 0.2);
}
.knowledge-tip-card {
display: flex;
align-items: center;
gap: 12px;
margin-top: 14px;
padding: 16px;
border: 0;
border-radius: 18px;
border: 1px solid var(--employee-line);
border-radius: var(--employee-card-radius);
background: #ffffff;
box-shadow: var(--employee-card-shadow);
}
.knowledge-tip-icon {
display: flex;
width: 46px;
height: 46px;
align-items: center;
justify-content: center;
border-radius: 14px;
background: var(--employee-primary-soft);
color: var(--employee-primary);
font-size: 28px;
font-weight: 900;
}
.knowledge-tip-copy {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: 4px;
}
.knowledge-tip-title {
color: #25282d;
font-size: 16px;
font-weight: 900;
}
.knowledge-tip-desc {
color: #858991;
font-size: 12px;
line-height: 1.45;
}
.knowledge-tip-arrow {
color: #8c9098;
font-size: 26px;
}
.answer-empty {
display: flex;
align-items: center;
margin-top: 14px;
padding: 28px 18px;
flex-direction: column;
border: 0;
border-radius: var(--employee-card-radius);
background: #ffffff;
box-shadow: 0 8px 24px rgba(24, 28, 36, 0.04);
}
.answer-empty-icon {
color: #f2b4b6;
font-size: 22px;
letter-spacing: 5px;
}
.answer-empty-title {
margin-top: 12px;
color: #666a72;
font-size: 18px;
font-weight: 800;
}
.answer-empty-desc {
margin-top: 6px;
color: #9a9da3;
font-size: 13px;
}
.answer-panel {
border-color: var(--employee-line);
border-left-color: var(--employee-primary);
}
.answer-panel .panel-title,
.result-score {
color: var(--employee-primary);
}
.keypoint,
.speech-bubble {
border-left-color: var(--employee-primary);
background: var(--employee-primary-soft);
}
.cautions,
.reminder-item {
border-left-color: var(--employee-warning);
background: #fff8ec;
color: #8b5a1d;
}
.feedback-actions .active {
border-color: var(--employee-primary);
background: var(--employee-primary-soft);
color: var(--employee-primary);
}
.feedback-actions .negative {
border-color: var(--employee-danger);
background: #fff1f0;
color: #c34d45;
}
.summary-panel {
border-color: #f4e4c4;
border-radius: var(--employee-card-radius);
background: #ffffff;
}
.summary-panel .ghost-button {
border-color: #cfe0ff;
background: var(--employee-primary-soft);
color: var(--employee-primary);
}
.modal-card {
border-top-color: var(--employee-primary);
}
.badge-num,
.timeline-node {
background: var(--employee-primary);
}
.a-badge {
background: var(--employee-primary-soft);
color: var(--employee-primary);
}
.reminder-text {
color: #8b5a1d;
}
/* 原型对齐:问师傅主卡的标题与对话插画 */
.query-hero {
position: relative;
min-height: 84px;
overflow: hidden;
}
.query-copy {
position: relative;
z-index: 1;
display: flex;
max-width: 66%;
flex-direction: column;
gap: 7px;
}
.query-title {
color: var(--employee-text);
font-size: 30px;
font-weight: 900;
line-height: 1.1;
}
.query-subtitle {
color: var(--employee-muted);
font-size: 15px;
line-height: 1.45;
}
.query-visual {
position: absolute;
top: -6px;
right: -4px;
width: 42%;
height: 96px;
border-radius: 50%;
background: var(--employee-primary-soft);
}
.query-bubble {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
letter-spacing: 3px;
}
.query-bubble.primary {
top: 7px;
right: 26px;
width: 92px;
height: 56px;
border-radius: 28px 28px 28px 10px;
background: #7ba4ff;
color: #ffffff;
font-size: 18px;
}
.query-bubble.primary::after {
position: absolute;
bottom: -8px;
left: 18px;
width: 16px;
height: 16px;
background: #7ba4ff;
content: '';
transform: rotate(28deg);
}
.query-bubble.secondary {
right: 2px;
bottom: 5px;
width: 70px;
height: 42px;
border-radius: 22px 22px 8px 22px;
background: #ffffff;
box-shadow: 0 5px 14px rgba(47, 107, 255, 0.1);
color: #9bb8ff;
font-size: 13px;
}
.query-panel .query-caption {
display: none;
}
</style>