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
+1 -1
View File
@@ -190,7 +190,7 @@ const login = async () => {
if (!result.access_token || !result.client_id) {
throw new Error('登录响应缺少认证信息');
}
saveAuth(result, value);
saveAuth(result, value, '000000');
await goAfterLogin();
} catch (error) {
message.value = error instanceof Error ? error.message : '登录失败';
@@ -155,8 +155,9 @@ const loadReminders = () => {
}
};
const toggleReminder = (key: 'beforeDue' | 'autoReview', event: { detail?: { value?: boolean } }) => {
reminders.value = { ...reminders.value, [key]: Boolean(event.detail?.value) };
const toggleReminder = (key: 'beforeDue' | 'autoReview', event: Event | { detail?: { value?: boolean } }) => {
const value = 'detail' in event ? event.detail?.value : undefined;
reminders.value = { ...reminders.value, [key]: Boolean(value) };
uni.setStorageSync(reminderStorageKey, reminders.value);
};
@@ -270,6 +270,12 @@ interface QuickCell {
}
const quickCells = computed<QuickCell[]>(() => [
{
title: '团队今日成果',
meta: '记录与待跟进',
icon: 'file-text',
url: '/pages/supervisor/work-results/index'
},
{
title: '团队画像',
meta: `${teamTotal.value} 名成员`,
@@ -21,10 +21,16 @@
</view>
<view class="identity-row">
<text class="identity-label">可操作范围</text>
<text class="identity-value">团队画像、复盘、专项派发</text>
<text class="identity-value">团队画像、今日成果、复盘、专项派发</text>
</view>
</view>
<view class="panel learner-mode-card">
<text class="panel-title">团队工作成果</text>
<text class="muted learner-mode-desc">按当前项目查看今天的记录人数、待跟进事项和原始来源。</text>
<button class="secondary-button learner-mode-button" @click="openWorkResults">查看团队今日成果</button>
</view>
<view class="panel learner-mode-card">
<text class="panel-title">我的学习</text>
<text class="muted learner-mode-desc">以学员界面完成经理安排的学习与训练,不会修改你在人力系统中的主管身份。</text>
@@ -69,6 +75,10 @@ const enterLearnerMode = () => {
uni.switchTab({ url: '/pages/user/today/index' });
};
const openWorkResults = () => {
uni.navigateTo({ url: '/pages/supervisor/work-results/index' });
};
const logout = () => {
clearAuth();
rememberLoginRedirect('/pages/supervisor/profile/index');
@@ -0,0 +1,83 @@
<template>
<view class="page supervisor-result-page">
<view class="page-head">
<button class="back" @click="goBack">‹</button>
<view class="head-copy"><text class="title">团队今日成果</text><text class="subtitle">{{ result?.projectName || currentProject?.projectName || '当前项目' }} · {{ workDate }}</text></view>
<button class="refresh" @click="load">刷新</button>
</view>
<view v-if="message" class="message">{{ message }}</view>
<view v-if="result" class="metrics">
<view><text class="num">{{ result.contributorCount }}</text><text>今日记录人数</text></view>
<view><text class="num">{{ result.resultCount }}</text><text>已形成成果</text></view>
<view><text class="num warn">{{ result.pendingCount }}</text><text>待跟进</text></view>
<view><text class="num danger">{{ result.highPriorityCount }}</text><text>高优问题</text></view>
</view>
<view v-for="member in result?.members || []" :key="member.ownerUserId" class="member-card">
<view class="member-head">
<view><text class="member-name">{{ member.ownerName }}</text><text class="member-meta">{{ member.total }} 条记录 · {{ member.pending }} 项待跟进</text></view>
<text class="formed">{{ member.total ? '已形成成果' : '暂无记录' }}</text>
</view>
<view v-for="item in memberItems(member)" :key="item.captureId" class="member-item">
<view class="item-copy">
<text class="item-type">{{ item.category }}</text>
<text class="item-summary">{{ item.summary }}</text>
<text class="item-source">{{ sourceLabel(item.sourceType) }}</text>
</view>
<view class="item-actions">
<button v-if="item.sourceType !== 'TEXT'" class="source-action" @click="openSource(item)">原始来源</button>
<button v-if="['PENDING','IN_PROGRESS'].includes(item.businessStatus)" class="action" @click="advance(item)">{{ item.businessStatus === 'IN_PROGRESS' ? '完成' : '处理' }}</button>
</view>
</view>
</view>
<view v-if="result && !result.members.length" class="empty">当前项目今天还没有已确认的工作记录。</view>
<SupervisorTabBar active="today" />
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import type { MobileProject, ProjectWorkResult, 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 { getProjectWorkResult } from '@/services/work-results';
import SupervisorTabBar from '@/components/SupervisorTabBar.vue';
const result = ref<ProjectWorkResult | null>(null);
const currentProject = ref<MobileProject | null>(getCurrentProject());
const message = ref('');
const loading = ref(false);
const workDate = new Date().toLocaleDateString('sv-SE', { timeZone: 'Asia/Shanghai' });
const memberItems = (member: WorkResult) => Object.values(member.sections || {}).flat();
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) throw new Error('请先选择当前项目');
result.value = await getProjectWorkResult(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 sourceLabel = (value: string) => ({ TEXT: '文字来源', VOICE: '原始语音', IMAGE: '现场照片', VIDEO: '现场视频' }[value] || '原始来源');
const goBack = () => uni.navigateBack({ fail: () => uni.redirectTo({ url: '/pages/supervisor/index/index' }) });
onShow(load);
</script>
<style scoped>
.supervisor-result-page{min-height:100vh;padding:18px 16px 100px;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,.action::after,.source-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}.message,.empty{margin-bottom:14px;padding:14px;border-radius:14px;background:#fff0f1;color:#c6091b}.metrics{display:grid;grid-template-columns:repeat(2,1fr);gap:10px;margin-bottom:14px}.metrics>view{display:flex;flex-direction:column;padding:16px;border:1px solid #e5e7eb;border-radius:18px;background:#fff;color:#6f7784}.num{color:#171b23;font-size:30px;font-weight:800}.num.warn{color:#d98200}.num.danger{color:#e60012}.member-card{margin-bottom:14px;padding:16px;border:1px solid #e5e7eb;border-radius:20px;background:#fff;box-shadow:0 8px 26px rgba(31,38,50,.05)}.member-head{display:flex;align-items:center;justify-content:space-between;padding-bottom:12px;border-bottom:1px solid #eceef1}.member-head>view{display:flex;flex-direction:column}.member-name{font-size:19px;font-weight:800}.member-meta{margin-top:4px;color:#7d8592;font-size:13px}.formed{padding:4px 8px;border-radius:9px;background:#fff0f1;color:#d9081c;font-size:12px}.member-item{display:flex;align-items:center;gap:10px;padding:12px 0;border-bottom:1px solid #eff0f2}.member-item:last-child{border:0}.item-copy{display:flex;min-width:0;flex:1;flex-direction:column}.item-type{color:#d9081c;font-size:12px}.item-summary{margin-top:4px;font-size:15px;line-height:1.5}.item-source{margin-top:5px;color:#8a919d;font-size:12px}.item-actions{display:flex;align-items:flex-end;flex-direction:column;gap:6px}.source-action,.action{padding:7px 12px;border:1px solid #dfe2e7;border-radius:14px;background:#fff;color:#59616f;font-size:13px}.action{border-color:#ef3340;color:#d9081c}
</style>
@@ -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' });
+25 -20
View File
@@ -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();
}
+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;
+14 -3
View File
@@ -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>