feat(personal-assistant): add confirmed project memory

This commit is contained in:
2026-07-19 08:19:24 +08:00
parent 151cc26caf
commit 24da833fa6
31 changed files with 4037 additions and 60 deletions
@@ -0,0 +1,342 @@
<template>
<view class="page employee-page-shell memories-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>
<view v-if="!loggedIn" class="panel employee-card login-panel">
<text class="panel-title">登录后查看</text>
<text class="muted">这些内容只按你的账号和当前项目权限读取。</text>
<button class="primary-button" @click="goLogin">去登录</button>
</view>
<template v-else>
<text v-if="message" class="status-message">{{ message }}</text>
<view class="section-head">
<view>
<text class="section-title">待确认</text>
<text class="section-note">只有你点“确认保存”后才会成为长期记录</text>
</view>
<button class="refresh-button" :disabled="loading" @click="load">刷新</button>
</view>
<view v-if="candidates.length" class="candidate-list">
<MemoryCandidateCard
v-for="candidate in candidates"
:key="`${candidate.id}-${candidate.version}`"
:candidate="candidate"
compact
@confirmed="removeCandidate(candidate.id, '已确认保存')"
@dismissed="removeCandidate(candidate.id, '已暂不保存')"
@refresh="load"
/>
</view>
<view v-else class="empty-card employee-card">
<text class="empty-title">暂无待确认事项</text>
<text class="empty-copy">在工作助手里说“记一下”,识别到完整信息后会先让你确认。</text>
</view>
<view class="section-head followup-head">
<view>
<text class="section-title">项目跟进</text>
<text class="section-note">仅展示当前账号有权限查看的项目服务记录</text>
</view>
</view>
<view v-if="serviceMemories.length" class="service-list">
<view v-for="item in serviceMemories" :key="`${item.id}-${item.version}`" class="service-card employee-card">
<view class="service-head">
<text class="service-category">{{ item.category || memoryTypeLabel(item.memoryType) }}</text>
<text class="service-status" :class="statusClass(item.status)">{{ statusLabel(item.status) }}</text>
</view>
<text v-if="item.projectCode" class="service-project">项目:{{ item.projectCode }}</text>
<text class="service-subject">{{ serviceSubject(item) || '项目服务事项' }}</text>
<text class="service-summary">{{ item.summary }}</text>
<view class="service-meta">
<text>记录 {{ displayDate(item.occurredAt) }}</text>
<text v-if="item.followUpAt">跟进 {{ displayDate(item.followUpAt) }}</text>
<text v-if="item.updatedAt">更新 {{ displayDate(item.updatedAt) }}</text>
</view>
</view>
</view>
<view v-else class="empty-card employee-card">
<text class="empty-title">暂无项目跟进</text>
<text class="empty-copy">经你确认的住户需求、服务事实和待跟进事项会显示在这里。</text>
</view>
</template>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { onHide, onShow, onUnload } from '@dcloudio/uni-app';
import type { MemoryCandidate, ServiceMemory } from '@/types/api';
import MemoryCandidateCard from '@/components/chat/MemoryCandidateCard.vue';
import { getMemoryCandidates, getServiceMemories } from '@/services/personal-assistant';
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import { ensureEmployeePosition } from '@/services/position';
import { resetPageScroll } from '@/services/navigation';
import { createPageRequestScope } from '@/services/request-scope';
const path = '/pages/user/assistant/memories';
const loggedIn = ref(false);
const loading = ref(false);
const message = ref('');
const candidates = ref<MemoryCandidate[]>([]);
const serviceMemories = ref<ServiceMemory[]>([]);
const requests = createPageRequestScope();
const goBack = () => uni.navigateBack({ delta: 1 });
const goLogin = () => {
rememberLoginRedirect(path);
uni.navigateTo({ url: '/pages/auth/login/index' });
};
const displayDate = (value?: string | null) => value ? value.replace('T', ' ').slice(0, 16) : '未填写';
const memoryTypeLabel = (type = '') => ({
RESIDENT_NEED: '住户需求',
SERVICE_FACT: '服务事实',
FOLLOW_UP: '待跟进事项'
}[type] || '工作事项');
const statusLabel = (status = '') => ({
OPEN: '待跟进',
IN_PROGRESS: '跟进中',
RESOLVED: '已完成',
VOID: '已作废'
}[status] || status || '已记录');
const statusClass = (status = '') => status === 'VOID' ? 'void' : status === 'RESOLVED' ? 'done' : '';
const serviceSubject = (item: ServiceMemory) => {
return [
item.buildingName,
item.unitName,
item.roomNo
].filter(Boolean).join('');
};
const removeCandidate = (candidateId: number, toast: string) => {
candidates.value = candidates.value.filter((item) => item.id !== candidateId);
uni.showToast({ title: toast, icon: 'none' });
void loadServiceMemories();
};
const loadServiceMemories = async () => {
const request = requests.begin('service-memories');
try {
const result = await getServiceMemories();
if (requests.isCurrent(request)) serviceMemories.value = result;
} catch (error) {
if (requests.isCurrent(request)) {
message.value = error instanceof Error ? error.message : '项目跟进读取失败';
}
}
};
const load = async () => {
loggedIn.value = isLoggedIn();
candidates.value = [];
serviceMemories.value = [];
message.value = '';
if (!loggedIn.value) return;
if (!(await ensureEmployeePosition(path))) return;
const request = requests.begin('memory-page');
loading.value = true;
const [candidateResult, serviceResult] = await Promise.allSettled([
getMemoryCandidates('DRAFT'),
getServiceMemories()
]);
if (!requests.isCurrent(request)) return;
if (candidateResult.status === 'fulfilled') candidates.value = candidateResult.value;
if (serviceResult.status === 'fulfilled') serviceMemories.value = serviceResult.value;
const errors = [candidateResult, serviceResult]
.filter((item): item is PromiseRejectedResult => item.status === 'rejected')
.map((item) => item.reason instanceof Error ? item.reason.message : '读取失败');
message.value = errors.join(';');
loading.value = false;
};
onShow(() => {
requests.activate();
resetPageScroll();
void load();
});
onHide(() => requests.invalidate());
onUnload(() => requests.invalidate());
</script>
<style scoped>
.memories-page {
max-width: var(--mobile-shell-max-width);
margin: 0 auto;
padding-bottom: calc(32px + env(safe-area-inset-bottom));
}
.status-message {
display: block;
margin-top: 14px;
color: #a9000d;
font-size: 13px;
line-height: 1.5;
}
.login-panel,
.empty-card {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 18px;
}
.login-panel .primary-button {
width: 100%;
margin-top: 4px;
}
.section-head,
.service-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.section-head {
margin-top: 22px;
}
.section-head.followup-head {
margin-top: 30px;
}
.section-title,
.section-note,
.empty-title,
.empty-copy,
.service-subject,
.service-summary {
display: block;
}
.section-title {
color: #18202b;
font-size: 20px;
font-weight: 900;
}
.section-note {
margin-top: 5px;
color: #8a93a0;
font-size: 12px;
line-height: 1.45;
}
.refresh-button {
flex-shrink: 0;
min-height: 38px;
margin: 0;
padding: 0 13px;
border: 1px solid #dde2e8;
border-radius: 999px;
background: #ffffff;
color: #596474;
font-size: 12px;
}
.candidate-list,
.service-list {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 12px;
}
.empty-card {
padding: 18px;
}
.empty-title {
color: #29313c;
font-weight: 800;
}
.empty-copy {
color: #8a93a0;
font-size: 13px;
line-height: 1.55;
}
.service-card {
padding: 17px;
border-radius: 18px;
}
.service-category {
color: #29313c;
font-size: 13px;
font-weight: 800;
}
.service-status {
padding: 4px 9px;
border-radius: 999px;
background: #fff0f2;
color: #d70016;
font-size: 11px;
}
.service-status.done {
background: #edf8f1;
color: #168243;
}
.service-status.void {
background: #f2f3f5;
color: #7d8590;
}
.service-subject {
margin-top: 12px;
color: #18202b;
font-size: 17px;
font-weight: 900;
}
.service-project {
display: block;
margin-top: 11px;
color: #727c89;
font-size: 12px;
line-height: 1.4;
}
.service-project + .service-subject {
margin-top: 5px;
}
.service-summary {
margin-top: 8px;
color: #4f5967;
font-size: 14px;
line-height: 1.6;
}
.service-meta {
display: flex;
flex-wrap: wrap;
gap: 6px 12px;
margin-top: 12px;
padding-top: 10px;
border-top: 1px solid #edf0f3;
color: #8a93a0;
font-size: 11px;
}
</style>
@@ -125,6 +125,14 @@
<view v-if="loggedIn" class="panel feature-entries">
<text class="panel-title">常用功能</text>
<button class="feature-entry" @click="openMemories">
<view class="feature-entry-icon memory">记</view>
<view class="feature-entry-copy">
<text class="feature-entry-title">我的资料与记忆</text>
<text class="feature-entry-desc">确认待保存事项 · 查看项目跟进</text>
</view>
<text class="points-entry-arrow">›</text>
</button>
<button class="feature-entry" @click="openLearning">
<view class="feature-entry-icon learn">学</view>
<view class="feature-entry-copy">
@@ -292,6 +300,10 @@ const openLearning = () => {
uni.navigateTo({ url: '/pages/user/learning/index' });
};
const openMemories = () => {
uni.navigateTo({ url: '/pages/user/assistant/memories' });
};
const openPracticeSummary = () => {
uni.setStorageSync('aihr_sop_pending_tool', 'MY_PRACTICE_SUMMARY');
uni.switchTab({ url: '/pages/user/sop/index' });
@@ -868,6 +880,10 @@ onShow(() => {
background: #1f5c9d;
}
.feature-entry-icon.memory {
background: #5d6878;
}
.feature-entry-icon.summary {
background: #e70014;
}
+126 -9
View File
@@ -9,8 +9,8 @@
</view>
</view>
<view class="source-switch chat-source-switch" aria-label="问答来源">
<button class="source-option active" aria-current="page">企业问师傅</button>
<button class="source-option" @click="openWebAi">问 AI(全网)</button>
<button class="source-option active" aria-current="page">工作助手</button>
<button class="source-option" @click="openWebAi">查全网</button>
</view>
</view>
@@ -19,7 +19,7 @@
<view class="bubble-row left">
<image class="avatar master-avatar" src="/static/mentor-female-avatar.png" mode="aspectFill" />
<view class="bubble master">
<text class="bubble-text">你好,我是老师傅。工作里的 SOP、话术、流程直接问我,答案都带引用依据;也可以按住下面的按钮说话。</text>
<text class="bubble-text">工作里的问题直接问我,我会查制度、SOP、项目记录和经你确认保存的事项;也可以按住下面的按钮说话。</text>
</view>
</view>
@@ -87,6 +87,7 @@
<button v-if="msg.toolCode === ''" class="bubble-action" :disabled="summaryLoading" @click="buildSummaryCard(msg)">
{{ summaryLoading ? '生成中' : '总结卡' }}
</button>
<button v-if="msg.result.noEvidence" class="bubble-action primary" @click="openWebAi">去查全网</button>
</view>
<view v-if="msg.toolCode === ''" class="bubble-actions feedback-row">
<button
@@ -112,8 +113,14 @@
<view v-if="msg.citationsOpen && msg.result.snippets?.length" class="bubble-citations">
<view v-for="(snippet, index) in msg.result.snippets" :key="`${snippet.docTitle}-${index}`" class="citation">
<text class="citation-domain" :class="{ project: snippet.domain === 'PROJECT_SERVICE' }">
{{ citationDomainLabel(snippet) }}
</text>
<text class="citation-title">{{ snippet.docTitle }}</text>
<text v-if="snippet.spaceCode" class="citation-meta">知识空间:{{ snippet.spaceCode }}</text>
<text v-if="snippet.domain === 'PROJECT_SERVICE'" class="citation-meta">
{{ serviceMemoryStatusLabel(snippet.status) }} · 发生 {{ displayCitationDate(snippet.occurredAt) }} · 更新 {{ displayCitationDate(snippet.updatedAt) }}
</text>
<text v-else-if="snippet.spaceCode" class="citation-meta">知识空间:{{ snippet.spaceCode }}</text>
<text class="citation-content">{{ snippetExcerpt(snippet.content) }}</text>
<text v-if="snippet.fragmentId" class="citation-meta">片段 #{{ snippet.fragmentId }}</text>
</view>
@@ -149,6 +156,13 @@
/>
</view>
</view>
<MemoryCandidateCard
v-if="msg.result.memoryCandidate"
:candidate="msg.result.memoryCandidate"
@dismissed="hideMemoryCandidate(msg)"
@refresh="refreshMemoryCandidate(msg, $event)"
/>
</view>
</view>
</template>
@@ -156,7 +170,7 @@
<view v-if="!loggedIn" class="bubble-row left">
<image class="avatar master-avatar" src="/static/mentor-female-avatar.png" mode="aspectFill" />
<view class="bubble master">
<text class="bubble-text">登录后才能查询 SOP、生成总结卡并反馈答案。</text>
<text class="bubble-text">登录后才能使用工作助手、确认保存事项并查询内部资料。</text>
<view class="bubble-actions">
<button class="bubble-action primary" @click="goLogin">去登录</button>
</view>
@@ -167,6 +181,8 @@
</view>
</scroll-view>
<view class="scope-note">资料范围:自动 · 仅查询当前有权限的内部资料</view>
<ChatComposer
ref="composerRef"
:disabled="!loggedIn"
@@ -278,6 +294,7 @@ import type {
SummaryCardResponse
} from '@/types/api';
import ChatComposer from '@/components/chat/ChatComposer.vue';
import MemoryCandidateCard from '@/components/chat/MemoryCandidateCard.vue';
import {
chooseKnowledgeMedia,
generateSummaryCard,
@@ -299,6 +316,8 @@ import { authenticatedFileUrl, releaseAuthenticatedFileUrl } from '@/services/ap
import { ensureEmployeePosition } from '@/services/position';
import { chooseSpeechAudio, createSpeechPlaybackController, measureAudioDuration, MENTOR_VOICE_PROFILE, transcribeSpeechBlob, transcribeSpeechFile } from '@/services/speech';
import { downloadSummaryCardImage } from '@/services/summary-card-image';
import { getMemoryCandidates } from '@/services/personal-assistant';
import { currentAccountKey } from '@/services/request-scope';
import { computed } from 'vue';
type ToolCode = '' | 'MY_PRACTICE_SUMMARY' | 'TEAM_PRACTICE_SUMMARY';
@@ -372,6 +391,9 @@ let voiceTranscriptionGeneration = 0;
let abortVoiceTranscription: (() => void) | null = null;
let knowledgeSearchGeneration = 0;
let activeMediaUploadTask: ReturnType<typeof uni.uploadFile> | null = null;
let candidateRefreshGeneration = 0;
let showGeneration = 0;
let visibleAccountKey = '';
const defaultToolCode = computed<'MY_PRACTICE_SUMMARY' | 'TEAM_PRACTICE_SUMMARY'>(() =>
currentRole.value === 'supervisor' && !supervisorLearner.value
@@ -418,6 +440,15 @@ const applyConversationState = (result: KnowledgeSearchResponse) => {
}
};
const retireEarlierMemoryCandidate = (candidateId?: number) => {
if (!candidateId) return;
messages.value.forEach((item) => {
if (item.role === 'master' && item.result.memoryCandidate?.id === candidateId) {
item.result.memoryCandidate = null;
}
});
};
const releaseMessageResources = () => {
messages.value.forEach((msg) => {
if (msg.role !== 'master') return;
@@ -430,6 +461,7 @@ const releaseMessageResources = () => {
const resetConversation = (notify: boolean) => {
cancelPendingMediaSearch();
candidateRefreshGeneration += 1;
releaseMessageResources();
speechPlayback.stop();
messages.value = [];
@@ -474,16 +506,21 @@ const sendQuestion = async (rawText: string, options: { voice?: boolean } = {})
uni.showToast({ title: '请先输入问题', icon: 'none' });
return;
}
const requestShowGeneration = showGeneration;
const requestAccountKey = currentAccountKey();
const isCurrentQuestion = () => requestShowGeneration === showGeneration
&& requestAccountKey === currentAccountKey();
refreshAuthState();
if (!loggedIn.value) {
uni.setStorageSync(pendingSopQueryKey, text);
goLogin();
return;
}
if (!(await ensureEmployeePosition('/pages/user/sop/index'))) {
if (!(await ensureEmployeePosition('/pages/user/sop/index')) || !isCurrentQuestion()) {
return;
}
refreshAuthState();
if (!isCurrentQuestion()) return;
pushMsg<UserMsg>({
role: 'user',
text,
@@ -503,6 +540,7 @@ const sendQuestion = async (rawText: string, options: { voice?: boolean } = {})
: await searchKnowledge(text, getSelectedPosition(), conversationState());
if (generation !== knowledgeSearchGeneration) return;
applyConversationState(nextResult);
retireEarlierMemoryCandidate(nextResult.memoryCandidate?.id);
const spoken = (nextResult.answer || '').trim();
replaceMsg(pending.id, {
id: pending.id,
@@ -518,7 +556,7 @@ const sendQuestion = async (rawText: string, options: { voice?: boolean } = {})
transcriptOpen: false
});
if (spoken) void prepareVoice(pending.id, spoken);
if (!nextResult.answer && !nextResult.snippets?.length) {
if (!nextResult.answer && !nextResult.snippets?.length && !nextResult.memoryCandidate) {
pushSystem('未检索到可引用内容,换个问法试试');
}
} catch (error) {
@@ -748,6 +786,22 @@ const cleanOrderedText = (text = '') =>
.replace(/^(?:[一二三四五六七八九十]+[、..]|[0-9]+[.)、..])\s*/, '')
.trim();
const citationDomainLabel = (snippet: KnowledgeSearchResponse['snippets'][number]) => {
if (snippet.domain === 'PROJECT_SERVICE') return '项目记录';
if (snippet.domain === 'PERSONAL') return '我的资料';
if (snippet.sourceType === 'DATA_TOOL') return '数据工具';
return '企业 SOP';
};
const serviceMemoryStatusLabel = (status?: string | null) => ({
OPEN: '待跟进',
IN_PROGRESS: '跟进中',
RESOLVED: '已完成',
VOID: '已作废'
}[status || ''] || '状态未知');
const displayCitationDate = (value?: string | null) => value ? value.replace('T', ' ').slice(0, 16) : '未记录';
const feedback = async (verdict: AnswerFeedbackVerdict, msg: MasterMsg) => {
if (msg.feedbackSubmitting || msg.feedbackVerdict) return;
const fragmentIds = Array.from(
@@ -858,30 +912,60 @@ const openWebAi = () => {
uni.navigateTo({ url: '/pages/user/web-ai/index' });
};
const hideMemoryCandidate = (msg: MasterMsg) => {
retireEarlierMemoryCandidate(msg.result.memoryCandidate?.id);
};
const refreshMemoryCandidate = async (msg: MasterMsg, candidateId: number) => {
const generation = ++candidateRefreshGeneration;
const accountKey = currentAccountKey();
try {
const candidates = await getMemoryCandidates('DRAFT');
if (generation !== candidateRefreshGeneration || accountKey !== currentAccountKey()) return;
msg.result.memoryCandidate = candidates.find((item) => item.id === candidateId) || null;
} catch (error) {
if (generation !== candidateRefreshGeneration || accountKey !== currentAccountKey()) return;
uni.showToast({ title: requestErrorText(error, '确认卡刷新失败'), icon: 'none' });
}
};
const handleShow = async () => {
const generation = ++showGeneration;
resetPageScroll();
const accountKey = currentAccountKey();
const isCurrentShow = () => generation === showGeneration && accountKey === currentAccountKey();
if (visibleAccountKey && visibleAccountKey !== accountKey) resetConversation(false);
visibleAccountKey = accountKey;
refreshAuthState();
if (!loggedIn.value && messages.value.length) {
resetConversation(false);
}
if (loggedIn.value && !(await ensureEmployeePosition('/pages/user/sop/index'))) {
return;
if (!isCurrentShow()) return;
if (loggedIn.value) {
const hasEmployeePosition = await ensureEmployeePosition('/pages/user/sop/index');
if (!isCurrentShow() || !hasEmployeePosition) return;
}
positionProfile.value = getSelectedPositionProfile();
scrollToBottom();
if (!isCurrentShow()) return;
const pending = uni.getStorageSync(pendingSopQueryKey);
if (!isCurrentShow()) return;
const pendingTool = uni.getStorageSync(pendingToolKey);
if (pendingTool === 'MY_PRACTICE_SUMMARY' || pendingTool === 'TEAM_PRACTICE_SUMMARY') {
if (!loggedIn.value) {
goLogin();
return;
}
if (!isCurrentShow()) return;
uni.removeStorageSync(pendingToolKey);
if (!isCurrentShow()) return;
void runDataTool(pendingTool);
return;
}
if (pending && loggedIn.value) {
if (!isCurrentShow()) return;
uni.removeStorageSync(pendingSopQueryKey);
if (!isCurrentShow()) return;
void sendQuestion(String(pending));
}
};
@@ -891,6 +975,8 @@ onShow(() => {
});
onHide(() => {
showGeneration += 1;
candidateRefreshGeneration += 1;
speechPlayback.stop();
composerRef.value?.teardown();
cancelVoiceTranscription();
@@ -898,6 +984,7 @@ onHide(() => {
});
onUnload(() => {
showGeneration += 1;
releaseMessageResources();
speechPlayback.destroy();
composerRef.value?.teardown();
@@ -2295,6 +2382,22 @@ onUnload(() => {
font-weight: 700;
}
.citation-domain {
display: inline-flex;
margin-bottom: 5px;
padding: 2px 7px;
border-radius: 999px;
background: #eef3f8;
color: #526176;
font-size: 12px;
font-weight: 700;
}
.citation-domain.project {
background: #fff0f2;
color: #c8001a;
}
.citation-meta {
display: block;
margin-top: 2px;
@@ -2423,4 +2526,18 @@ onUnload(() => {
color: #98a1ad;
font-size: 12px;
}
.scope-note {
flex-shrink: 0;
padding: 6px 14px;
border-top: 1px solid #eceef1;
background: #ffffff;
color: #8a93a0;
font-size: 12px;
line-height: 1.4;
}
.master-col :deep(.memory-card) {
max-width: 100%;
}
</style>
+5 -5
View File
@@ -3,14 +3,14 @@
<view class="chat-head">
<view class="chat-head-row">
<view class="chat-head-left">
<button class="chat-back" aria-label="返回问师傅" @click="goBack">←</button>
<text class="chat-title">问 AI(全网)</text>
<button class="chat-back" aria-label="返回工作助手" @click="goBack">←</button>
<text class="chat-title">查全网</text>
</view>
<text class="chat-context">互联网信息 · 独立来源</text>
</view>
<view class="source-switch chat-source-switch" aria-label="问答来源">
<button class="source-option" @click="goBack">企业问师傅</button>
<button class="source-option active" aria-current="page">问 AI(全网)</button>
<button class="source-option" @click="goBack">工作助手</button>
<button class="source-option active" aria-current="page">查全网</button>
</view>
</view>
@@ -19,7 +19,7 @@
<view class="bubble-row left">
<view class="avatar ai-avatar">AI</view>
<view class="bubble master">
<text class="bubble-text">我是全网 AI,检索公开网页并保留可核验引用,不与企业 SOP 混用。问题会先脱敏再发送到外部服务;答案来自互联网,不代表公司制度,涉及作业、消防、电梯或法律事项请以公司 SOP 为准。</text>
<text class="bubble-text">这里单独查询公开网页并保留可核验引用,不与工作助手的内部资料混用。问题会先脱敏再发送到外部服务;答案来自互联网,不代表公司制度,涉及作业、消防、电梯或法律事项请以公司 SOP 为准。</text>
<text class="bubble-subnote">不要输入业主姓名、手机号、房号、身份证、门禁或公司未公开资料。</text>
</view>
</view>