feat: add project-scoped work assistant results

This commit is contained in:
2026-07-21 04:44:15 +08:00
parent 53b15e506c
commit 4536d607e3
75 changed files with 3281 additions and 507 deletions
+399 -9
View File
@@ -2,12 +2,36 @@
<view class="page sop-chat-page">
<AskSourceHeader
active="assistant"
:context="`${positionLabel} · 住宅项目`"
:context="headerContext"
:loading="loading || voiceTranscribing"
@select-web="openWebAi"
@new-conversation="startNewConversation"
/>
<view v-if="loggedIn" class="project-context-bar">
<button
class="project-context-button"
:class="{ missing: availableProjects.length > 1 && !currentProject }"
:disabled="projectLoading || loading"
@click="openProjectPicker"
>
<view class="project-context-copy">
<text class="project-context-label">当前项目</text>
<text class="project-context-name">{{ projectContextLabel }}</text>
</view>
<uni-icons v-if="availableProjects.length > 1" type="down" size="15" color="#596474" />
</button>
<text v-if="!projectLoading && !availableProjects.length" class="project-context-note">未绑定项目,可继续查制度;项目工作请联系管理员</text>
</view>
<button v-if="loggedIn && currentProject" class="today-result-entry" @click="openTodayResult">
<view class="today-result-copy">
<text class="today-result-title">今日工作成果</text>
<text class="today-result-note">按当前项目汇总已确认记录</text>
</view>
<text class="today-result-arrow">查看 ›</text>
</button>
<scroll-view scroll-y class="chat-thread" :scroll-top="threadScrollTop" :scroll-with-animation="true">
<view class="thread-inner">
<view class="bubble-row left">
@@ -163,6 +187,7 @@
<MemoryCandidateCard
v-if="msg.result.memoryCandidate"
:candidate="msg.result.memoryCandidate"
:project-name="currentProject?.projectName || ''"
@dismissed="hideMemoryCandidate(msg)"
@refresh="refreshMemoryCandidate(msg, $event)"
/>
@@ -205,6 +230,38 @@
@tool="runDataTool(defaultToolCode)"
/>
<view v-if="projectPickerOpen" class="project-picker-mask" @click="closeProjectPicker">
<view class="project-picker-sheet" role="dialog" aria-label="选择当前项目" @click.stop>
<view class="project-picker-head">
<view>
<text class="project-picker-title">选择当前项目</text>
<text class="project-picker-subtitle">后续工作记录和对话只归入这个项目</text>
</view>
<button class="project-picker-close" aria-label="关闭项目选择" @click="closeProjectPicker">×</button>
</view>
<input
v-if="availableProjects.length > 6"
v-model.trim="projectSearch"
class="project-search"
maxlength="40"
placeholder="搜索项目名称"
/>
<scroll-view scroll-y class="project-picker-list">
<button
v-for="project in filteredProjects"
:key="project.projectCode"
class="project-option"
:class="{ selected: currentProject?.projectCode === project.projectCode }"
@click="selectProject(project)"
>
<text class="project-option-name">{{ project.projectName }}</text>
<text v-if="currentProject?.projectCode === project.projectCode" class="project-option-status">当前</text>
</button>
<text v-if="!filteredProjects.length" class="project-empty">没有匹配的项目</text>
</scroll-view>
</view>
</view>
<view v-if="summaryCard" class="modal-mask" @click="closeSummary">
<view class="modal-card" @click.stop>
<view class="modal-header">
@@ -285,7 +342,7 @@
</template>
<script setup lang="ts">
import { nextTick, ref } from 'vue';
import { computed, nextTick, ref } from 'vue';
import { onHide, onShow, onUnload } from '@dcloudio/uni-app';
import type {
AnswerFeedbackVerdict,
@@ -293,6 +350,7 @@ import type {
KnowledgeMediaSelectedFile,
KnowledgeResource,
KnowledgeSearchResponse,
MobileProject,
SpeechPlaybackStatus,
SummaryCardResponse
} from '@/types/api';
@@ -310,19 +368,20 @@ import {
import {
getSelectedPosition,
getSelectedPositionProfile,
reconcileCurrentProject,
isLoggedIn,
isSupervisorLearnerMode,
rememberLoginRedirect,
roleKey
roleKey,
saveCurrentProject
} from '@/services/auth';
import { resetPageScroll } from '@/services/navigation';
import { authenticatedFileUrl, releaseAuthenticatedFileUrl } from '@/services/api';
import { ensureEmployeePosition } from '@/services/position';
import { ensureEmployeePosition, fetchMobileIdentity } 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';
@@ -378,10 +437,33 @@ const selectedMedia = ref<KnowledgeMediaSelectedFile | null>(null);
const speechState = ref<{ key: string; status: SpeechPlaybackStatus }>({ key: '', status: 'idle' });
const voiceTranscribing = ref(false);
const loggedIn = ref(false);
const projectLoading = ref(false);
const availableProjects = ref<MobileProject[]>([]);
const currentProject = ref<MobileProject | null>(null);
const projectPickerOpen = ref(false);
const projectSearch = ref('');
const pendingSopQueryKey = 'aihr_sop_pending_query';
const pendingToolKey = 'aihr_sop_pending_tool';
const currentRole = ref(String(uni.getStorageSync(roleKey) || ''));
const supervisorLearner = ref(isSupervisorLearnerMode());
const headerContext = computed(() => `${positionLabel.value} · ${currentProject.value?.projectName || '住宅项目'}`);
const projectContextLabel = computed(() => {
if (projectLoading.value) return '正在读取…';
if (currentProject.value) return currentProject.value.projectName;
if (availableProjects.value.length > 1) return '请选择项目';
return '未绑定项目';
});
const filteredProjects = computed(() => {
const keyword = projectSearch.value.trim().toLowerCase();
const rows = keyword
? availableProjects.value.filter((project) => project.projectName.toLowerCase().includes(keyword))
: availableProjects.value;
return [...rows].sort((left, right) => {
if (left.projectCode === currentProject.value?.projectCode) return -1;
if (right.projectCode === currentProject.value?.projectCode) return 1;
return left.projectName.localeCompare(right.projectName, 'zh-CN');
});
});
const createConversationId = () => {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
@@ -391,6 +473,7 @@ const createConversationId = () => {
const conversationId = ref(createConversationId());
const contextVersion = ref(0);
let msgSeq = 0;
let scrollSeq = 0;
let voiceTranscriptionGeneration = 0;
let abortVoiceTranscription: (() => void) | null = null;
let knowledgeSearchGeneration = 0;
@@ -413,7 +496,7 @@ const speechPlayback = createSpeechPlaybackController(
const scrollToBottom = () => {
void nextTick(() => {
threadScrollTop.value = 100000 + msgSeq * 200;
threadScrollTop.value = 100000 + (++scrollSeq * 200);
});
};
@@ -471,6 +554,7 @@ const resetConversation = (notify: boolean) => {
messages.value = [];
summaryCard.value = null;
selectedMedia.value = null;
composerRef.value?.clearDraft();
conversationId.value = createConversationId();
contextVersion.value = 0;
if (notify) pushSystem('已开始新对话,后续问题将从新的上下文理解');
@@ -478,6 +562,91 @@ const resetConversation = (notify: boolean) => {
const startNewConversation = () => resetConversation(true);
const openProjectPicker = () => {
if (projectLoading.value) return;
if (!availableProjects.value.length) {
uni.showToast({ title: '当前账号未绑定项目', icon: 'none' });
return;
}
if (availableProjects.value.length === 1) return;
projectSearch.value = '';
projectPickerOpen.value = true;
};
const closeProjectPicker = () => {
if (!currentProject.value && availableProjects.value.length > 1) return;
projectPickerOpen.value = false;
projectSearch.value = '';
};
const hasUnfinishedProjectInput = () => Boolean(
selectedMedia.value
|| composerRef.value?.hasDraft()
|| messages.value.some((message) => message.role === 'master' && message.result.memoryCandidate)
);
const applyProject = (project: MobileProject) => {
const changed = currentProject.value?.projectCode !== project.projectCode;
saveCurrentProject(project);
currentProject.value = project;
projectPickerOpen.value = false;
projectSearch.value = '';
if (changed) {
resetConversation(false);
pushSystem(`已切换到“${project.projectName}”,后续记录和对话从新的项目上下文开始`);
}
};
const selectProject = (project: MobileProject) => {
if (project.projectCode === currentProject.value?.projectCode) {
projectPickerOpen.value = false;
return;
}
if (!hasUnfinishedProjectInput()) {
applyProject(project);
return;
}
uni.showModal({
title: '切换当前项目?',
content: '未发送内容或待确认记录会被清空,已保存记录不受影响。',
confirmText: '确认切换',
success: (result) => {
if (result.confirm) applyProject(project);
}
});
};
const loadProjectContext = async () => {
projectLoading.value = true;
try {
const identity = await fetchMobileIdentity();
availableProjects.value = identity.projects || [];
currentProject.value = reconcileCurrentProject(availableProjects.value);
if (!currentProject.value && availableProjects.value.length > 1) {
projectPickerOpen.value = true;
}
} catch (error) {
availableProjects.value = [];
currentProject.value = null;
uni.showToast({ title: requestErrorText(error, '项目列表读取失败'), icon: 'none' });
} finally {
projectLoading.value = false;
}
};
const ensureCurrentProject = () => {
if (projectLoading.value) {
uni.showToast({ title: '正在读取项目,请稍候', icon: 'none' });
return false;
}
if (availableProjects.value.length > 1 && !currentProject.value) {
projectPickerOpen.value = true;
uni.showToast({ title: '请先选择当前项目', icon: 'none' });
return false;
}
return true;
};
const refreshAuthState = () => {
loggedIn.value = isLoggedIn();
positionLabel.value = getSelectedPosition();
@@ -523,6 +692,7 @@ const sendQuestion = async (rawText: string, options: { voice?: boolean } = {})
if (!(await ensureEmployeePosition('/pages/user/sop/index')) || !isCurrentQuestion()) {
return;
}
if (!ensureCurrentProject()) return;
refreshAuthState();
if (!isCurrentQuestion()) return;
pushMsg<UserMsg>({
@@ -540,8 +710,14 @@ const sendQuestion = async (rawText: string, options: { voice?: boolean } = {})
const nextResult = media
? await searchKnowledgeWithMedia(media, text, getSelectedPosition(), (task) => {
if (generation === knowledgeSearchGeneration) activeMediaUploadTask = task;
}, conversationState())
: await searchKnowledge(text, getSelectedPosition(), conversationState());
}, conversationState(), currentProject.value?.projectCode)
: await searchKnowledge(
text,
getSelectedPosition(),
conversationState(),
currentProject.value?.projectCode,
options.voice ? 'mobile_uni_voice' : 'mobile_uni_sop'
);
if (generation !== knowledgeSearchGeneration) return;
applyConversationState(nextResult);
retireEarlierMemoryCandidate(nextResult.memoryCandidate?.id);
@@ -602,7 +778,8 @@ const runDataTool = async (toolCode: 'MY_PRACTICE_SUMMARY' | 'TEAM_PRACTICE_SUMM
loading.value = true;
const generation = ++knowledgeSearchGeneration;
try {
const nextResult = await queryKnowledgeTool(effectiveToolCode, question, conversationState());
const nextResult = await queryKnowledgeTool(
effectiveToolCode, question, conversationState(), currentProject.value?.projectCode);
if (generation !== knowledgeSearchGeneration) return;
applyConversationState(nextResult);
replaceMsg(pending.id, {
@@ -916,6 +1093,10 @@ const openWebAi = () => {
uni.navigateTo({ url: '/pages/user/web-ai/index' });
};
const openTodayResult = () => {
uni.navigateTo({ url: '/pages/user/work-results/index' });
};
const hideMemoryCandidate = (msg: MasterMsg) => {
retireEarlierMemoryCandidate(msg.result.memoryCandidate?.id);
};
@@ -948,6 +1129,8 @@ const handleShow = async () => {
if (loggedIn.value) {
const hasEmployeePosition = await ensureEmployeePosition('/pages/user/sop/index');
if (!isCurrentShow() || !hasEmployeePosition) return;
await loadProjectContext();
if (!isCurrentShow()) return;
}
positionProfile.value = getSelectedPositionProfile();
scrollToBottom();
@@ -2041,6 +2224,213 @@ onUnload(() => {
overflow: hidden;
}
.project-context-bar {
flex-shrink: 0;
padding: 7px 12px;
border-bottom: 1px solid #ebedf0;
background: #ffffff;
}
.today-result-entry {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: space-between;
margin: 0;
padding: 9px 18px;
border: 0;
border-bottom: 1px solid #ebedf0;
border-radius: 0;
background: #fff8f8;
text-align: left;
}
.today-result-entry::after { border: 0; }
.today-result-copy { display: flex; flex-direction: column; gap: 2px; }
.today-result-title { color: #c90012; font-size: 15px; font-weight: 700; }
.today-result-note { color: #8c929d; font-size: 12px; }
.today-result-arrow { color: #d9081c; font-size: 13px; }
.project-context-button {
display: flex;
box-sizing: border-box;
width: 100%;
min-height: 48px;
align-items: center;
justify-content: space-between;
margin: 0;
padding: 7px 12px;
border: 1px solid #e1e5ea;
border-radius: 13px;
background: #f8f9fb;
line-height: 1.2;
text-align: left;
}
.project-context-button.missing {
border-color: #efb4bb;
background: #fff7f8;
}
.project-context-button::after,
.project-picker-close::after,
.project-option::after {
border: none;
}
.project-context-copy {
display: flex;
min-width: 0;
flex-direction: column;
gap: 3px;
}
.project-context-label {
color: #8a93a0;
font-size: 11px;
}
.project-context-name {
overflow: hidden;
color: #29313c;
font-size: 15px;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.project-context-note {
display: block;
margin-top: 5px;
color: #9a6b24;
font-size: 11px;
line-height: 1.4;
}
.project-picker-mask {
position: fixed;
z-index: 1100;
inset: 0 0 var(--window-bottom, 0px);
display: flex;
align-items: flex-end;
background: rgba(15, 23, 42, 0.46);
}
.project-picker-sheet {
box-sizing: border-box;
width: 100%;
max-height: 72vh;
padding: 18px 16px max(18px, env(safe-area-inset-bottom));
border-radius: 22px 22px 0 0;
background: #ffffff;
box-shadow: 0 -10px 36px rgba(15, 23, 42, 0.16);
}
.project-picker-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.project-picker-title,
.project-picker-subtitle {
display: block;
}
.project-picker-title {
color: #18202b;
font-size: 19px;
font-weight: 800;
}
.project-picker-subtitle {
margin-top: 5px;
color: #7b8492;
font-size: 12px;
line-height: 1.45;
}
.project-picker-close {
display: flex;
flex: 0 0 40px;
width: 40px;
height: 40px;
min-height: 40px;
align-items: center;
justify-content: center;
margin: 0;
padding: 0;
border: none;
border-radius: 50%;
background: #f1f3f5;
color: #596474;
font-size: 22px;
}
.project-search {
box-sizing: border-box;
width: 100%;
height: 46px;
margin-top: 16px;
padding: 0 14px;
border: 1px solid #dde2e8;
border-radius: 12px;
background: #f8f9fb;
color: #29313c;
font-size: 15px;
}
.project-picker-list {
max-height: 48vh;
margin-top: 14px;
}
.project-option {
display: flex;
box-sizing: border-box;
width: 100%;
min-height: 54px;
align-items: center;
justify-content: space-between;
margin: 0 0 9px;
padding: 0 15px;
border: 1px solid #e4e7eb;
border-radius: 13px;
background: #ffffff;
text-align: left;
}
.project-option.selected {
border-color: #e60012;
background: #fff7f8;
}
.project-option-name {
min-width: 0;
overflow: hidden;
color: #29313c;
font-size: 16px;
font-weight: 650;
text-overflow: ellipsis;
white-space: nowrap;
}
.project-option-status {
flex-shrink: 0;
margin-left: 12px;
color: #d70016;
font-size: 13px;
}
.project-empty {
display: block;
padding: 28px 0;
color: #8a93a0;
font-size: 14px;
text-align: center;
}
.chat-thread {
flex: 1;
min-height: 0;