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
@@ -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>