feat: add project-scoped work assistant results
This commit is contained in:
@@ -111,6 +111,16 @@
|
||||
</view>
|
||||
<text class="points-entry-arrow">›</text>
|
||||
</button>
|
||||
<button class="feature-entry" @click="openWorkResults">
|
||||
<view class="entry-icon">
|
||||
<uni-icons type="calendar-filled" size="20" color="#ffffff" />
|
||||
</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="openPracticeSummary">
|
||||
<view class="entry-icon">
|
||||
<uni-icons type="list" size="20" color="#ffffff" />
|
||||
@@ -302,6 +312,10 @@ const openMemories = () => {
|
||||
uni.navigateTo({ url: '/pages/user/assistant/memories' });
|
||||
};
|
||||
|
||||
const openWorkResults = () => {
|
||||
uni.navigateTo({ url: '/pages/user/work-results/index' });
|
||||
};
|
||||
|
||||
const openPracticeSummary = () => {
|
||||
uni.setStorageSync('aihr_sop_pending_tool', 'MY_PRACTICE_SUMMARY');
|
||||
uni.switchTab({ url: '/pages/user/sop/index' });
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
<view class="report-header">
|
||||
<button class="header-button back-button" aria-label="返回" @click="goBack"><uni-icons type="back" size="22" color="#25282d" /></button>
|
||||
<view class="report-heading">
|
||||
<text class="page-title">工作上报</text>
|
||||
<text class="page-subtitle">数字师傅帮你整理</text>
|
||||
<text class="page-title">成果投稿</text>
|
||||
<text class="page-subtitle">把好经验沉淀给公司复用</text>
|
||||
</view>
|
||||
<button class="history-button" @click="openHistory">我的上报</button>
|
||||
<button class="history-button" @click="openHistory">我的投稿</button>
|
||||
</view>
|
||||
|
||||
<scroll-view
|
||||
@@ -17,15 +17,15 @@
|
||||
>
|
||||
<view class="history-toolbar">
|
||||
<view>
|
||||
<text class="history-title">我的上报</text>
|
||||
<text class="history-subtitle">这里展示你提交给企业审核的工作记录</text>
|
||||
<text class="history-title">我的投稿</text>
|
||||
<text class="history-subtitle">这里展示你提交给企业审核的案例、SOP、视频和知识</text>
|
||||
</view>
|
||||
<button class="text-button" @click="historyOpen = false">返回会话</button>
|
||||
</view>
|
||||
|
||||
<view v-if="!loggedIn" class="login-card">
|
||||
<text class="card-title">登录后查看上报</text>
|
||||
<text class="muted-copy">上报内容会提交给企业审核,请先完成登录。</text>
|
||||
<text class="card-title">登录后查看投稿</text>
|
||||
<text class="muted-copy">投稿内容会提交给企业审核,请先完成登录。</text>
|
||||
<button class="primary-action" @click="goLogin">去登录</button>
|
||||
</view>
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
<view v-if="myReports.length" class="history-list">
|
||||
<view v-for="item in myReports" :key="item.id" class="history-card">
|
||||
<view class="history-card-head">
|
||||
<text class="type-badge">上报类型:{{ item.typeLabel }}</text>
|
||||
<text class="type-badge">投稿类型:{{ item.typeLabel }}</text>
|
||||
<text class="status-badge" :class="item.status.toLowerCase()">{{ item.statusLabel }}</text>
|
||||
</view>
|
||||
<text class="history-card-title">{{ item.title }}</text>
|
||||
@@ -47,7 +47,7 @@
|
||||
<text v-if="item.reviewNote" class="review-note">审核意见:{{ item.reviewNote }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else-if="!loadingMine && !historyError" class="empty-card">还没有上报记录</view>
|
||||
<view v-else-if="!loadingMine && !historyError" class="empty-card">还没有投稿记录</view>
|
||||
</template>
|
||||
</scroll-view>
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
<view class="bubble-row left">
|
||||
<image class="avatar" src="/static/mentor-female-avatar.png" mode="aspectFill" />
|
||||
<view class="bubble mentor-bubble">
|
||||
<text class="bubble-text">今天处理了什么?按住说,也可以发照片或视频。</text>
|
||||
<text class="bubble-text">哪段处置经验值得公司复用?按住说,也可以发照片或视频,我会整理成案例、SOP 或知识投稿。</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -111,7 +111,7 @@
|
||||
<view v-if="organizing || uploading" class="bubble-row left">
|
||||
<image class="avatar" src="/static/mentor-female-avatar.png" mode="aspectFill" />
|
||||
<view class="bubble mentor-bubble pending-bubble">
|
||||
<text class="bubble-text">{{ uploading ? '附件上传中…' : '正在整理成工作上报…' }}</text>
|
||||
<text class="bubble-text">{{ uploading ? '附件上传中…' : '正在整理成成果投稿…' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -119,7 +119,7 @@
|
||||
<view class="confirm-head">
|
||||
<view>
|
||||
<text class="confirm-eyebrow">已整理,可继续编辑</text>
|
||||
<text class="confirm-title">工作上报确认</text>
|
||||
<text class="confirm-title">成果投稿确认</text>
|
||||
</view>
|
||||
<picker
|
||||
:value="draftTypeIndex"
|
||||
@@ -140,7 +140,7 @@
|
||||
v-model="currentDraft.title"
|
||||
class="draft-input"
|
||||
maxlength="100"
|
||||
placeholder="请填写上报标题"
|
||||
placeholder="请填写投稿标题"
|
||||
:disabled="draftLocked"
|
||||
@input="markDraftEdited"
|
||||
/>
|
||||
@@ -186,7 +186,7 @@
|
||||
{{ editingDraft ? '完成修改' : '修改' }}
|
||||
</button>
|
||||
<button class="submit-action" :disabled="submitActionDisabled" @click="confirmSubmit">
|
||||
{{ submitting ? '上报中…' : '确认上报' }}
|
||||
{{ submitting ? '投稿中…' : '确认投稿' }}
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
@@ -199,7 +199,7 @@
|
||||
<text class="submitted-meta">
|
||||
{{ submittedReport.typeLabel }} · {{ submittedReport.status === 'PENDING' ? '待审核' : submittedReport.statusLabel }}
|
||||
</text>
|
||||
<text class="submitted-id">上报编号 #{{ submittedReport.id }}</text>
|
||||
<text class="submitted-id">投稿编号 #{{ submittedReport.id }}</text>
|
||||
</view>
|
||||
<text class="status-badge" :class="submittedReport.status.toLowerCase()">
|
||||
{{ submittedReport.status }} · {{ submittedReport.statusLabel }}
|
||||
@@ -662,12 +662,12 @@ const confirmSubmit = async () => {
|
||||
const content = draft.content.trim();
|
||||
if (!title || !content) {
|
||||
editingDraft.value = true;
|
||||
uni.showToast({ title: '请补全标题和上报内容', icon: 'none' });
|
||||
uni.showToast({ title: '请补全标题和投稿内容', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
if (title.length > 100 || content.length > 2000) {
|
||||
editingDraft.value = true;
|
||||
uni.showToast({ title: title.length > 100 ? '标题不能超过100字' : '上报内容不能超过2000字', icon: 'none' });
|
||||
uni.showToast({ title: title.length > 100 ? '标题不能超过100字' : '投稿内容不能超过2000字', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
if (privacyReviewRequired.value) {
|
||||
@@ -712,8 +712,8 @@ const confirmSubmit = async () => {
|
||||
}
|
||||
const message = error instanceof Error ? error.message : '提交失败';
|
||||
pushSystem(conflict
|
||||
? message + '。请先到“我的上报”核对已有记录,再完成修改并重新确认。'
|
||||
: rejected ? message : message + ',提交结果未确认,请点击确认上报重试');
|
||||
? message + '。请先到“我的投稿”核对已有记录,再完成修改并重新确认。'
|
||||
: rejected ? message : message + ',提交结果未确认,请点击确认投稿重试');
|
||||
if (conflict) await loadMine(true);
|
||||
} finally {
|
||||
if (requestIsCurrent(generation, submitGeneration, accountKey)) submitting.value = false;
|
||||
@@ -735,7 +735,7 @@ const loadMine = async (force = false) => {
|
||||
}
|
||||
} catch (error) {
|
||||
if (!requestIsCurrent(generation, mineGeneration, accountKey)) return;
|
||||
historyError.value = error instanceof Error ? error.message : '上报记录加载失败,请重试';
|
||||
historyError.value = error instanceof Error ? error.message : '投稿记录加载失败,请重试';
|
||||
} finally {
|
||||
if (requestIsCurrent(generation, mineGeneration, accountKey)) loadingMine.value = false;
|
||||
}
|
||||
@@ -817,6 +817,11 @@ onShow(() => {
|
||||
loggedIn.value = isLoggedIn();
|
||||
if (loggedIn.value) {
|
||||
void loadMine();
|
||||
const prefill = String(uni.getStorageSync('aihr_work_report_prefill') || '').trim();
|
||||
if (prefill) {
|
||||
uni.removeStorageSync('aihr_work_report_prefill');
|
||||
void organizeInput(prefill, { voice: false });
|
||||
}
|
||||
} else {
|
||||
clearConversationState();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -127,11 +127,18 @@
|
||||
<text class="tool-sub">课程·三题</text>
|
||||
</view>
|
||||
</button>
|
||||
<button class="tool-cell" @click="openWorkResults">
|
||||
<view class="tool-icon">果</view>
|
||||
<view class="tool-copy">
|
||||
<text class="tool-name">今日成果</text>
|
||||
<text class="tool-sub">记录·待跟进</text>
|
||||
</view>
|
||||
</button>
|
||||
<button class="tool-cell" @click="openReport">
|
||||
<view class="tool-icon">报</view>
|
||||
<view class="tool-copy">
|
||||
<text class="tool-name">工作上报</text>
|
||||
<text class="tool-sub">案例·记录</text>
|
||||
<text class="tool-name">成果投稿</text>
|
||||
<text class="tool-sub">案例·SOP·知识</text>
|
||||
</view>
|
||||
</button>
|
||||
<button class="tool-cell" @click="openQuestions">
|
||||
@@ -429,6 +436,10 @@ const openReport = () => {
|
||||
uni.navigateTo({ url: '/pages/user/report/index' });
|
||||
};
|
||||
|
||||
const openWorkResults = () => {
|
||||
uni.navigateTo({ url: '/pages/user/work-results/index' });
|
||||
};
|
||||
|
||||
const ask = (query: string) => {
|
||||
uni.setStorageSync(pendingSopQueryKey, query);
|
||||
uni.switchTab({ url: '/pages/user/sop/index' });
|
||||
@@ -937,7 +948,7 @@ onUnload(() => {
|
||||
|
||||
.tools-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
<template>
|
||||
<view class="page result-page employee-page-shell">
|
||||
<view class="page-head">
|
||||
<button class="back" aria-label="返回" @click="goBack">‹</button>
|
||||
<view class="head-copy">
|
||||
<text class="title">今日工作成果</text>
|
||||
<text class="subtitle">{{ result?.projectName || currentProject?.projectName || '请选择项目' }} · {{ workDate }}</text>
|
||||
</view>
|
||||
<button class="refresh" :disabled="loading" @click="load">{{ loading ? '整理中' : '重新整理' }}</button>
|
||||
</view>
|
||||
|
||||
<view v-if="message" class="message">{{ message }}</view>
|
||||
<view v-if="!currentProject && !loading" class="empty-card">
|
||||
<text class="empty-title">请先在工作助手选择当前项目</text>
|
||||
<button class="primary" @click="goAssistant">去工作助手</button>
|
||||
</view>
|
||||
|
||||
<template v-else-if="result">
|
||||
<view class="summary-card">
|
||||
<view><text class="summary-number">{{ result.total }}</text><text class="summary-label">条工作记录</text></view>
|
||||
<view><text class="summary-number warning">{{ result.pending }}</text><text class="summary-label">项待跟进</text></view>
|
||||
<view><text class="summary-number">v{{ result.version }}</text><text class="summary-label">成果版本</text></view>
|
||||
</view>
|
||||
|
||||
<view v-for="section in visibleSections" :key="section.key" class="section-card">
|
||||
<view class="section-head">
|
||||
<text class="section-title">{{ section.label }}</text>
|
||||
<text class="section-count">{{ section.items.length }} 项</text>
|
||||
</view>
|
||||
<view v-for="item in section.items" :key="item.captureId" class="result-item">
|
||||
<view class="item-main">
|
||||
<view class="item-meta">
|
||||
<text class="type-tag">{{ item.category }}</text>
|
||||
<text class="status-tag">{{ statusLabel(item.businessStatus) }}</text>
|
||||
</view>
|
||||
<text class="item-summary">{{ item.summary }}</text>
|
||||
<text class="item-time">{{ displayTime(item.occurredAt) }} · {{ sourceLabel(item.sourceType) }}</text>
|
||||
</view>
|
||||
<view class="item-actions">
|
||||
<button v-if="item.sourceType !== 'TEXT'" class="text-action" @click="openSource(item)">原始来源</button>
|
||||
<button
|
||||
v-if="['PENDING', 'IN_PROGRESS'].includes(item.businessStatus)"
|
||||
class="status-action"
|
||||
@click="advance(item)"
|
||||
>{{ item.businessStatus === 'IN_PROGRESS' ? '标记完成' : '开始处理' }}</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="!result.total" class="empty-card">
|
||||
<text class="empty-title">今天还没有已确认的工作记录</text>
|
||||
<text class="empty-desc">在工作助手里按住说话或发照片,确认后会自动汇总到这里。</text>
|
||||
<button class="primary" @click="goAssistant">去记录一件事</button>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { onShow } from '@dcloudio/uni-app';
|
||||
import type { MobileProject, WorkResult, WorkResultItem } from '@/types/api';
|
||||
import { getCurrentProject, reconcileCurrentProject } from '@/services/auth';
|
||||
import { fetchMobileIdentity } from '@/services/position';
|
||||
import { updateAssistantCaptureStatus } from '@/services/personal-assistant';
|
||||
import { openProtectedSource } from '@/services/source-preview';
|
||||
import { generateMyWorkResult } from '@/services/work-results';
|
||||
|
||||
const result = ref<WorkResult | null>(null);
|
||||
const currentProject = ref<MobileProject | null>(getCurrentProject());
|
||||
const loading = ref(false);
|
||||
const message = ref('');
|
||||
const workDate = new Date().toLocaleDateString('sv-SE', { timeZone: 'Asia/Shanghai' });
|
||||
const sectionLabels: Record<string, string> = {
|
||||
completed: '今日已完成',
|
||||
inspection: '巡检发现',
|
||||
services: '业主服务',
|
||||
leads: '画像与服务线索',
|
||||
attendance: '到岗与晨会',
|
||||
pending: '未闭环与下一步'
|
||||
};
|
||||
const visibleSections = computed(() => Object.entries(result.value?.sections || {})
|
||||
.map(([key, items]) => ({ key, label: sectionLabels[key] || key, items }))
|
||||
.filter((section) => section.items.length));
|
||||
|
||||
const load = async () => {
|
||||
if (loading.value) return;
|
||||
loading.value = true;
|
||||
message.value = '';
|
||||
try {
|
||||
const identity = await fetchMobileIdentity();
|
||||
currentProject.value = reconcileCurrentProject(identity.projects || []);
|
||||
if (!currentProject.value) {
|
||||
result.value = null;
|
||||
return;
|
||||
}
|
||||
result.value = await generateMyWorkResult(currentProject.value.projectCode, workDate);
|
||||
} catch (error) {
|
||||
message.value = error instanceof Error ? error.message : '今日工作成果读取失败';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const advance = async (item: WorkResultItem) => {
|
||||
const next = item.businessStatus === 'IN_PROGRESS' ? 'COMPLETED' : 'IN_PROGRESS';
|
||||
try {
|
||||
await updateAssistantCaptureStatus(item.captureId, next);
|
||||
await load();
|
||||
} catch (error) {
|
||||
uni.showToast({ title: error instanceof Error ? error.message : '状态更新失败', icon: 'none' });
|
||||
}
|
||||
};
|
||||
|
||||
const openSource = (item: WorkResultItem) => openProtectedSource(item.sourceUrl, item.sourceType);
|
||||
|
||||
const statusLabel = (value: string) => ({
|
||||
RECORDED: '已记录', PENDING: '待跟进', IN_PROGRESS: '处理中', COMPLETED: '已完成', VOID: '已作废'
|
||||
}[value] || value);
|
||||
const sourceLabel = (value: string) => ({ TEXT: '文字', VOICE: '语音', IMAGE: '照片', VIDEO: '视频' }[value] || '文字');
|
||||
const displayTime = (value: string) => value?.replace('T', ' ').slice(11, 16) || '--:--';
|
||||
const goAssistant = () => uni.switchTab({ url: '/pages/user/sop/index' });
|
||||
const goBack = () => uni.navigateBack({ fail: () => uni.switchTab({ url: '/pages/user/today/index' }) });
|
||||
|
||||
onShow(load);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.result-page{min-height:100vh;padding:18px 16px 40px;background:#f4f5f7;color:#171b23;box-sizing:border-box}.page-head{display:flex;align-items:center;gap:12px;margin-bottom:16px}.back{width:42px;height:42px;padding:0;border:1px solid #dfe2e7;border-radius:14px;background:#fff;font-size:32px;line-height:38px}.back::after,.refresh::after,.text-action::after,.status-action::after{border:0}.head-copy{display:flex;min-width:0;flex:1;flex-direction:column}.title{font-size:25px;font-weight:800}.subtitle{margin-top:3px;color:#7d8592;font-size:14px}.refresh{padding:8px 12px;border:1px solid #ef3340;border-radius:18px;background:#fff;color:#d9081c;font-size:13px}.message{margin-bottom:12px;padding:10px 12px;border-radius:12px;background:#fff0f1;color:#c6091b}.summary-card,.section-card,.empty-card{margin-bottom:14px;border:1px solid #e5e7eb;border-radius:20px;background:#fff;box-shadow:0 8px 26px rgba(31,38,50,.05)}.summary-card{display:grid;grid-template-columns:repeat(3,1fr);padding:18px 8px;text-align:center}.summary-card>view{display:flex;flex-direction:column;border-right:1px solid #eceef1}.summary-card>view:last-child{border:0}.summary-number{color:#e60012;font-size:27px;font-weight:800}.summary-number.warning{color:#d98200}.summary-label{margin-top:4px;color:#747c89;font-size:12px}.section-card{padding:16px}.section-head{display:flex;align-items:center;justify-content:space-between;padding-bottom:12px;border-bottom:1px solid #eceef1}.section-title{font-size:19px;font-weight:800}.section-count{color:#8a919d;font-size:13px}.result-item{padding:14px 0;border-bottom:1px solid #eff0f2}.result-item:last-child{border:0}.item-meta{display:flex;gap:8px;align-items:center}.type-tag,.status-tag{padding:3px 8px;border-radius:9px;background:#fff0f1;color:#d9081c;font-size:12px}.status-tag{background:#f2f3f5;color:#59616f}.item-summary{display:block;margin-top:9px;font-size:16px;line-height:1.55}.item-time{display:block;margin-top:6px;color:#8a919d;font-size:12px}.item-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:10px}.text-action,.status-action{padding:7px 11px;border:1px solid #dfe2e7;border-radius:14px;background:#fff;font-size:13px}.status-action{border-color:#ef3340;color:#d9081c}.empty-card{display:flex;align-items:center;flex-direction:column;padding:34px 22px;text-align:center}.empty-title{font-size:18px;font-weight:700}.empty-desc{margin:9px 0 18px;color:#7d8592;line-height:1.6}.primary{margin-top:18px;padding:10px 22px;border:0;border-radius:20px;background:#e60012;color:#fff}
|
||||
</style>
|
||||
Reference in New Issue
Block a user