feat(work-results): support historical project outcomes

This commit is contained in:
2026-07-22 00:29:19 +08:00
parent 49189474c1
commit d7664e45a7
26 changed files with 479 additions and 49 deletions
@@ -0,0 +1,79 @@
<template>
<view class="work-date-selector" aria-label="选择工作成果日期">
<button class="date-step" :disabled="disabled" @click="shiftDate(-1)">前一天</button>
<picker
class="date-picker"
mode="date"
:value="modelValue"
:end="today"
:disabled="disabled"
@change="selectDate"
>
<view class="date-current">
<text class="date-label">{{ displayDate }}</text>
<text class="date-hint">{{ modelValue === today ? '选择历史日期' : '点此选择日期' }}</text>
</view>
</picker>
<button class="date-step" :disabled="disabled || modelValue >= today" @click="shiftDate(1)">后一天</button>
</view>
</template>
<script setup lang="ts">
import { computed } from 'vue';
const props = withDefaults(defineProps<{
modelValue: string;
maxDate?: string;
disabled?: boolean;
}>(), {
disabled: false
});
const emit = defineEmits<{
change: [value: string];
}>();
const currentDate = () => new Date().toLocaleDateString('sv-SE', { timeZone: 'Asia/Shanghai' });
const today = computed(() => props.maxDate || currentDate());
const displayDate = computed(() => {
if (props.modelValue === today.value) return `今天 · ${props.modelValue}`;
const [year, month, day] = props.modelValue.split('-').map(Number);
if (!year || !month || !day) return props.modelValue;
return new Intl.DateTimeFormat('zh-CN', {
month: 'long',
day: 'numeric',
weekday: 'short',
timeZone: 'Asia/Shanghai'
}).format(new Date(Date.UTC(year, month - 1, day, 4)));
});
const shiftDate = (days: number) => {
if (props.disabled) return;
const [year, month, day] = props.modelValue.split('-').map(Number);
if (!year || !month || !day) return;
const nextDate = new Date(Date.UTC(year, month - 1, day + days)).toISOString().slice(0, 10);
if (nextDate > today.value || nextDate === props.modelValue) return;
emit('change', nextDate);
};
const selectDate = (event: { detail: { value: string } }) => {
const value = event.detail.value;
if (!value || value > today.value || value === props.modelValue) return;
emit('change', value);
};
</script>
<style scoped>
.work-date-selector{display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:stretch;gap:8px;margin-bottom:14px;padding:8px;border:1px solid #e5e7eb;border-radius:18px;background:#fff;box-shadow:0 6px 20px rgba(31,38,50,.04)}
.date-step{display:flex;align-items:center;justify-content:center;min-width:72px;min-height:48px;margin:0;padding:0 10px;border:0;border-radius:13px;background:#f5f6f8;color:#303744;font-size:14px;line-height:1}
.date-step::after{border:0}
.date-step[disabled]{background:#fafafa;color:#b9bec7;opacity:1}
.date-picker{min-width:0}
.date-current{display:flex;align-items:center;justify-content:center;min-height:48px;flex-direction:column;border-radius:13px;background:#fff0f1;text-align:center}
.date-label{color:#171b23;font-size:15px;font-weight:800}
.date-hint{margin-top:3px;color:#d9081c;font-size:11px}
html[data-aihr-font-size='extra-large'] .work-date-selector{grid-template-columns:1fr 1.5fr 1fr}
html[data-aihr-font-size='extra-large'] .date-step{min-width:0;padding:0 6px;font-size:13px}
html[data-aihr-font-size='extra-large'] .date-label{font-size:14px}
</style>
+2 -2
View File
@@ -94,7 +94,7 @@
{
"path": "pages/user/work-results/index",
"style": {
"navigationBarTitleText": "今日工作成果"
"navigationBarTitleText": "工作成果"
}
},
{
@@ -178,7 +178,7 @@
{
"path": "pages/supervisor/work-results/index",
"style": {
"navigationBarTitleText": "团队今日成果"
"navigationBarTitleText": "团队工作成果"
}
},
{
@@ -2,12 +2,19 @@
<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>
<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>
<WorkDateSelector
v-if="currentProject"
:model-value="workDate"
:max-date="today"
:disabled="loading"
@change="changeWorkDate"
/>
<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.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>
@@ -29,13 +36,13 @@
</view>
</view>
</view>
<view v-if="result && !result.members.length" class="empty">当前项目今天还没有已确认的工作记录。</view>
<view v-if="result && !result.members.length" class="empty">{{ emptyMessage }}</view>
<SupervisorTabBar active="today" />
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { computed, ref } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import type { MobileProject, ProjectWorkResult, WorkResult, WorkResultItem } from '@/types/api';
import { getCurrentProject, reconcileCurrentProject } from '@/services/auth';
@@ -44,12 +51,18 @@ import { updateAssistantCaptureStatus } from '@/services/personal-assistant';
import { openProtectedSource } from '@/services/source-preview';
import { getProjectWorkResult } from '@/services/work-results';
import SupervisorTabBar from '@/components/SupervisorTabBar.vue';
import WorkDateSelector from '@/components/WorkDateSelector.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 currentDate = () => new Date().toLocaleDateString('sv-SE', { timeZone: 'Asia/Shanghai' });
const today = ref(currentDate());
const workDate = ref(today.value);
const emptyMessage = computed(() => workDate.value === today.value
? '当前项目今天还没有已确认的工作记录。'
: '当前项目这一天没有已确认的工作记录。');
const memberItems = (member: WorkResult) => Object.values(member.sections || {}).flat();
const load = async () => {
@@ -60,13 +73,18 @@ const load = async () => {
const identity = await fetchMobileIdentity();
currentProject.value = reconcileCurrentProject(identity.projects || []);
if (!currentProject.value) throw new Error('请先选择当前项目');
result.value = await getProjectWorkResult(currentProject.value.projectCode, workDate);
result.value = await getProjectWorkResult(currentProject.value.projectCode, workDate.value);
} catch (error) {
message.value = error instanceof Error ? error.message : '团队成果读取失败';
} finally {
loading.value = false;
}
};
const changeWorkDate = (value: string) => {
workDate.value = value;
result.value = null;
void load();
};
const advance = async (item: WorkResultItem) => {
const next = item.businessStatus === 'IN_PROGRESS' ? 'COMPLETED' : 'IN_PROGRESS';
try { await updateAssistantCaptureStatus(item.captureId, next); await load(); }
@@ -75,7 +93,10 @@ const advance = async (item: WorkResultItem) => {
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);
onShow(() => {
today.value = currentDate();
load();
});
</script>
<style scoped>
@@ -3,12 +3,20 @@
<view class="page-head">
<button class="back" aria-label="返回" @click="goBack">‹</button>
<view class="head-copy">
<text class="title">今日工作成果</text>
<text class="title">工作成果</text>
<text class="subtitle">{{ result?.projectName || currentProject?.projectName || '请选择项目' }} · {{ workDate }}</text>
</view>
<button class="refresh" :disabled="loading" @click="load">{{ loading ? '整理中' : '重新整理' }}</button>
</view>
<WorkDateSelector
v-if="currentProject"
:model-value="workDate"
:max-date="today"
:disabled="loading"
@change="changeWorkDate"
/>
<view v-if="message" class="message">{{ message }}</view>
<view v-if="!currentProject && !loading" class="empty-card">
<text class="empty-title">请先在工作助手选择当前项目</text>
@@ -48,9 +56,9 @@
</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>
<text class="empty-title">{{ emptyTitle }}</text>
<text class="empty-desc">{{ emptyDescription }}</text>
<button class="primary" @click="handleEmptyAction">{{ emptyActionLabel }}</button>
</view>
</template>
</view>
@@ -65,14 +73,23 @@ import { fetchMobileIdentity } from '@/services/position';
import { updateAssistantCaptureStatus } from '@/services/personal-assistant';
import { openProtectedSource } from '@/services/source-preview';
import { generateMyWorkResult } from '@/services/work-results';
import WorkDateSelector from '@/components/WorkDateSelector.vue';
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 currentDate = () => new Date().toLocaleDateString('sv-SE', { timeZone: 'Asia/Shanghai' });
const today = ref(currentDate());
const workDate = ref(today.value);
const isToday = computed(() => workDate.value === today.value);
const emptyTitle = computed(() => isToday.value ? '今天还没有已确认的工作记录' : '这一天没有已确认的工作记录');
const emptyDescription = computed(() => isToday.value
? '在工作助手里按住说话或发照片,确认后会自动汇总到这里。'
: '可以继续查看其他日期,或回到今天记录新的工作。');
const emptyActionLabel = computed(() => isToday.value ? '去记录一件事' : '回到今天');
const sectionLabels: Record<string, string> = {
completed: '今日已完成',
completed: '当日已完成',
inspection: '巡检发现',
services: '业主服务',
leads: '画像与服务线索',
@@ -94,14 +111,20 @@ const load = async () => {
result.value = null;
return;
}
result.value = await generateMyWorkResult(currentProject.value.projectCode, workDate);
result.value = await generateMyWorkResult(currentProject.value.projectCode, workDate.value);
} catch (error) {
message.value = error instanceof Error ? error.message : '今日工作成果读取失败';
message.value = error instanceof Error ? error.message : '工作成果读取失败';
} finally {
loading.value = false;
}
};
const changeWorkDate = (value: string) => {
workDate.value = value;
result.value = null;
void load();
};
const advance = async (item: WorkResultItem) => {
const next = item.businessStatus === 'IN_PROGRESS' ? 'COMPLETED' : 'IN_PROGRESS';
try {
@@ -120,9 +143,13 @@ const statusLabel = (value: string) => ({
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 handleEmptyAction = () => isToday.value ? goAssistant() : changeWorkDate(today.value);
const goBack = () => uni.navigateBack({ fail: () => uni.switchTab({ url: '/pages/user/today/index' }) });
onShow(load);
onShow(() => {
today.value = currentDate();
load();
});
</script>
<style scoped>
+28
View File
@@ -44,3 +44,31 @@ test('员工与主管成果页都能追溯非文字原始来源', async () => {
assert.match(preview, /previewMedia/);
assert.doesNotMatch(employeePage, /openDocument/);
});
test('员工与主管成果页复用日期选择器查看历史成果且禁止未来日期', async () => {
const [employeePage, supervisorPage, dateSelector, pages] = await Promise.all([
read('../src/pages/user/work-results/index.vue'),
read('../src/pages/supervisor/work-results/index.vue'),
read('../src/components/WorkDateSelector.vue'),
read('../src/pages.json')
]);
assert.match(employeePage, /WorkDateSelector/);
assert.match(supervisorPage, /WorkDateSelector/);
assert.match(employeePage, /:max-date="today"/);
assert.match(supervisorPage, /:max-date="today"/);
assert.match(employeePage, /generateMyWorkResult\(currentProject\.value\.projectCode, workDate\.value\)/);
assert.match(supervisorPage, /getProjectWorkResult\(currentProject\.value\.projectCode, workDate\.value\)/);
assert.match(dateSelector, /mode="date"/);
assert.match(dateSelector, /:end="today"/);
assert.match(dateSelector, /props\.maxDate \|\| currentDate\(\)/);
assert.match(dateSelector, /nextDate > today\.value/);
assert.match(employeePage, /这一天没有已确认的工作记录/);
assert.match(employeePage, /回到今天/);
assert.match(employeePage, /completed: '当日已完成'/);
assert.match(supervisorPage, /当前项目这一天没有已确认的工作记录/);
assert.match(supervisorPage, />记录人数</);
assert.doesNotMatch(supervisorPage, /今日记录人数/);
assert.match(pages, /"navigationBarTitleText": "工作成果"/);
assert.match(pages, /"navigationBarTitleText": "团队工作成果"/);
});