feat: add project-scoped work assistant results
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user