feat: add project-scoped work assistant results
This commit is contained in:
@@ -248,7 +248,14 @@ const setDraft = (text: string) => {
|
||||
draft.value = text;
|
||||
};
|
||||
|
||||
defineExpose({ teardown, setDraft });
|
||||
const hasDraft = () => Boolean(draft.value.trim() || props.mediaName);
|
||||
|
||||
const clearDraft = () => {
|
||||
draft.value = '';
|
||||
panelOpen.value = false;
|
||||
};
|
||||
|
||||
defineExpose({ teardown, setDraft, hasDraft, clearDraft });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<text class="memory-tag success">{{ targetLabel }}</text>
|
||||
</view>
|
||||
<text class="saved-copy">{{ completedCopy }}</text>
|
||||
<button v-if="props.candidate.memoryType === 'CASE'" class="contribution-action" @click="goContribution">整理成成果投稿</button>
|
||||
</template>
|
||||
|
||||
<template v-else-if="candidate.status === 'DRAFT' || candidate.status === 'NEEDS_INPUT'">
|
||||
@@ -37,6 +38,10 @@
|
||||
</view>
|
||||
|
||||
<view v-else class="memory-rows">
|
||||
<view class="memory-row">
|
||||
<text class="row-label">当前项目</text>
|
||||
<text class="row-value">{{ projectLabel }}</text>
|
||||
</view>
|
||||
<view class="memory-row">
|
||||
<text class="row-label">对象</text>
|
||||
<text class="row-value">{{ subjectLabel || '待补充' }}</text>
|
||||
@@ -54,8 +59,16 @@
|
||||
<text class="row-value">{{ assistantCandidate ? '由你确认' : targetLabel }}</text>
|
||||
</view>
|
||||
<view class="memory-row">
|
||||
<text class="row-label">记录时间</text>
|
||||
<text class="row-value">{{ displayDate(draft.occurredAt) }}</text>
|
||||
<text class="row-label">发生日期</text>
|
||||
<text class="row-value">{{ draft.workDate || displayDate(draft.occurredAt).slice(0, 10) }}</text>
|
||||
</view>
|
||||
<view class="memory-row">
|
||||
<text class="row-label">原始来源</text>
|
||||
<text class="row-value">{{ sourceLabel }}</text>
|
||||
</view>
|
||||
<view class="memory-row">
|
||||
<text class="row-label">处理状态</text>
|
||||
<text class="row-value">{{ businessStatusLabel }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -91,7 +104,7 @@
|
||||
class="memory-action scope private"
|
||||
:disabled="submitting"
|
||||
@click="confirm('PRIVATE')"
|
||||
>仅自己保存</button>
|
||||
>确认记录</button>
|
||||
<button
|
||||
v-if="assistantCandidate"
|
||||
class="memory-action scope company"
|
||||
@@ -125,8 +138,10 @@ import { currentAccountKey } from '@/services/request-scope';
|
||||
const props = withDefaults(defineProps<{
|
||||
candidate: MemoryCandidate;
|
||||
compact?: boolean;
|
||||
projectName?: string;
|
||||
}>(), {
|
||||
compact: false
|
||||
compact: false,
|
||||
projectName: ''
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -147,7 +162,9 @@ const cloneDraft = (value: MemoryCandidateDraft): MemoryCandidateDraft => ({
|
||||
detail: value.detail || '',
|
||||
status: value.status || 'OPEN',
|
||||
occurredAt: value.occurredAt || null,
|
||||
followUpAt: value.followUpAt || null
|
||||
followUpAt: value.followUpAt || null,
|
||||
workDate: value.workDate || (value.occurredAt || '').slice(0, 10),
|
||||
businessStatus: value.businessStatus || 'RECORDED'
|
||||
});
|
||||
|
||||
const draft = ref(cloneDraft(props.candidate.draft));
|
||||
@@ -168,6 +185,20 @@ const missingProjectLocationLabels = computed(() => [
|
||||
{ label: '房号', value: draft.value.roomNo }
|
||||
].filter((item) => !item.value?.trim()).map((item) => item.label));
|
||||
const assistantCandidate = computed(() => props.candidate.targetDomain === 'ASSISTANT_CAPTURE');
|
||||
const projectLabel = computed(() => props.projectName || (draft.value.projectCode ? '当前授权项目' : '个人记录'));
|
||||
const sourceLabel = computed(() => ({
|
||||
TEXT: '文字',
|
||||
VOICE: '原始语音与转写',
|
||||
IMAGE: '现场照片',
|
||||
VIDEO: '现场视频'
|
||||
}[props.candidate.source?.sourceType || 'TEXT'] || '文字'));
|
||||
const businessStatusLabel = computed(() => ({
|
||||
RECORDED: '已记录',
|
||||
PENDING: '待跟进',
|
||||
IN_PROGRESS: '处理中',
|
||||
COMPLETED: '已完成',
|
||||
VOID: '已作废'
|
||||
}[draft.value.businessStatus || 'RECORDED'] || '已记录'));
|
||||
|
||||
const memoryTypeLabel = computed(() => ({
|
||||
RESIDENT_NEED: '住户需求',
|
||||
@@ -177,6 +208,9 @@ const memoryTypeLabel = computed(() => ({
|
||||
PROJECT_NOTE: '工作记录',
|
||||
SERVICE_FACT: '服务事实',
|
||||
FOLLOW_UP: '待跟进事项',
|
||||
ATTENDANCE: '到岗/晨会记录',
|
||||
INSPECTION: '巡检问题',
|
||||
SERVICE_RECORD: '业主服务记录',
|
||||
PERSONAL_NOTE: '个人想法'
|
||||
}[props.candidate.memoryType] || '工作事项'));
|
||||
|
||||
@@ -187,8 +221,8 @@ const targetLabel = computed(() => ({
|
||||
}[confirmedTarget.value || props.candidate.targetDomain] || '我的资料与记忆'));
|
||||
|
||||
const completedCopy = computed(() => confirmedTarget.value === 'COMPANY'
|
||||
? '已提交公司处理,可在“我的资料与记忆”中查看流转状态。'
|
||||
: '这条信息已保存,后续工作助手可按权限查询。');
|
||||
? '已进入公司待流转,可在“我的资料与记忆”中查看状态。'
|
||||
: '这条信息已记录,并会计入对应项目的今日工作成果。');
|
||||
|
||||
const terminalStatusTitle = computed(() => ({
|
||||
CONFIRMED: '这条事项已保存',
|
||||
@@ -282,6 +316,11 @@ const dismiss = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const goContribution = () => {
|
||||
uni.setStorageSync('aihr_work_report_prefill', draft.value.summary || draft.value.detail || '');
|
||||
uni.navigateTo({ url: '/pages/user/report/index' });
|
||||
};
|
||||
|
||||
watch(
|
||||
() => [props.candidate.id, props.candidate.version] as const,
|
||||
() => {
|
||||
@@ -312,6 +351,18 @@ watch(
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.contribution-action {
|
||||
margin-top: 12px;
|
||||
padding: 9px 14px;
|
||||
border: 1px solid #ef3340;
|
||||
border-radius: 16px;
|
||||
background: #fff;
|
||||
color: #d9081c;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.contribution-action::after { border: 0; }
|
||||
|
||||
.memory-head,
|
||||
.memory-row,
|
||||
.field-row,
|
||||
|
||||
@@ -88,7 +88,13 @@
|
||||
{
|
||||
"path": "pages/user/report/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "工作上报"
|
||||
"navigationBarTitleText": "成果投稿"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/user/work-results/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "今日工作成果"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -169,6 +175,12 @@
|
||||
"navigationBarTitleText": "我的主管工作台"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/supervisor/work-results/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "团队今日成果"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/supervisor/exams/index",
|
||||
"style": {
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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>
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { LoginResult } from '@/types/api';
|
||||
import type { LoginResult, MobileProject } from '@/types/api';
|
||||
|
||||
export const tokenKey = 'aihr_mobile_access_token';
|
||||
export const clientIdKey = 'aihr_mobile_client_id';
|
||||
export const phoneKey = 'aihr_mobile_phone';
|
||||
export const tenantIdKey = 'aihr_mobile_tenant_id';
|
||||
export const loginRedirectKey = 'aihr_mobile_login_redirect';
|
||||
export const positionKey = 'aihr_mobile_position';
|
||||
export const positionSourceKey = 'aihr_mobile_position_source';
|
||||
@@ -25,6 +26,7 @@ export interface AuthState {
|
||||
token: string;
|
||||
clientId: string;
|
||||
phone: string;
|
||||
tenantId: string;
|
||||
position: UserPosition | '';
|
||||
}
|
||||
|
||||
@@ -112,6 +114,7 @@ export const getAuth = (): AuthState => ({
|
||||
token: uni.getStorageSync(tokenKey) || '',
|
||||
clientId: uni.getStorageSync(clientIdKey) || '',
|
||||
phone: uni.getStorageSync(phoneKey) || '',
|
||||
tenantId: uni.getStorageSync(tenantIdKey) || '000000',
|
||||
position: normalizePosition(uni.getStorageSync(positionKey))
|
||||
});
|
||||
|
||||
@@ -134,7 +137,45 @@ export const leaveSupervisorLearnerMode = () => {
|
||||
uni.removeStorageSync(supervisorLearnerModeKey);
|
||||
};
|
||||
|
||||
export const saveAuth = (result: LoginResult, phone: string) => {
|
||||
const projectStorageKey = (tenantId: string, phone: string) =>
|
||||
`aihr_mobile_current_project:${tenantId || '000000'}:${phone || 'anonymous'}`;
|
||||
|
||||
export const getCurrentProject = (): MobileProject | null => {
|
||||
const auth = getAuth();
|
||||
const stored = uni.getStorageSync(projectStorageKey(auth.tenantId, auth.phone));
|
||||
if (!stored || typeof stored !== 'object') return null;
|
||||
const project = stored as Partial<MobileProject>;
|
||||
return project.projectCode && project.projectName
|
||||
? { projectCode: String(project.projectCode), projectName: String(project.projectName) }
|
||||
: null;
|
||||
};
|
||||
|
||||
export const saveCurrentProject = (project: MobileProject) => {
|
||||
const auth = getAuth();
|
||||
uni.setStorageSync(projectStorageKey(auth.tenantId, auth.phone), project);
|
||||
};
|
||||
|
||||
export const clearCurrentProject = () => {
|
||||
const auth = getAuth();
|
||||
uni.removeStorageSync(projectStorageKey(auth.tenantId, auth.phone));
|
||||
};
|
||||
|
||||
export const reconcileCurrentProject = (projects: MobileProject[]) => {
|
||||
const current = getCurrentProject();
|
||||
const matched = current && projects.find((project) => project.projectCode === current.projectCode);
|
||||
if (matched) {
|
||||
saveCurrentProject(matched);
|
||||
return matched;
|
||||
}
|
||||
clearCurrentProject();
|
||||
if (projects.length === 1) {
|
||||
saveCurrentProject(projects[0]);
|
||||
return projects[0];
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const saveAuth = (result: LoginResult, phone: string, tenantId = '000000') => {
|
||||
const previousPhone = uni.getStorageSync(phoneKey) || '';
|
||||
if (previousPhone !== phone) {
|
||||
clearOrgPosition();
|
||||
@@ -144,12 +185,15 @@ export const saveAuth = (result: LoginResult, phone: string) => {
|
||||
uni.setStorageSync(tokenKey, result.access_token);
|
||||
uni.setStorageSync(clientIdKey, result.client_id);
|
||||
uni.setStorageSync(phoneKey, phone);
|
||||
uni.setStorageSync(tenantIdKey, tenantId);
|
||||
};
|
||||
|
||||
export const clearAuth = () => {
|
||||
clearCurrentProject();
|
||||
uni.removeStorageSync(tokenKey);
|
||||
uni.removeStorageSync(clientIdKey);
|
||||
uni.removeStorageSync(phoneKey);
|
||||
uni.removeStorageSync(tenantIdKey);
|
||||
clearOrgPosition();
|
||||
uni.removeStorageSync(roleKey);
|
||||
leaveSupervisorLearnerMode();
|
||||
|
||||
@@ -81,7 +81,9 @@ export const mergeKnowledgeSnippets = (...groups: RawKnowledgeSnippet[][]): Know
|
||||
export const searchKnowledge = async (
|
||||
queryText: string,
|
||||
position = getSelectedPosition(),
|
||||
conversation?: KnowledgeConversationState
|
||||
conversation?: KnowledgeConversationState,
|
||||
projectCode?: string,
|
||||
source: 'mobile_uni_sop' | 'mobile_uni_voice' = 'mobile_uni_sop'
|
||||
) => {
|
||||
const response = await apiRequest<UnifiedKnowledgeResponse>({
|
||||
url: '/api/knowledge/query',
|
||||
@@ -90,9 +92,11 @@ export const searchKnowledge = async (
|
||||
queryText,
|
||||
category: 'sop',
|
||||
position,
|
||||
source,
|
||||
limit: 5,
|
||||
conversationId: conversation?.conversationId,
|
||||
contextVersion: conversation?.contextVersion
|
||||
contextVersion: conversation?.contextVersion,
|
||||
projectCode
|
||||
},
|
||||
timeout: 30000
|
||||
});
|
||||
@@ -104,7 +108,8 @@ export const searchKnowledgeWithMedia = (
|
||||
queryText: string,
|
||||
position = getSelectedPosition(),
|
||||
onUploadTask?: (task: ReturnType<typeof uni.uploadFile> | null) => void,
|
||||
conversation?: KnowledgeConversationState
|
||||
conversation?: KnowledgeConversationState,
|
||||
projectCode?: string
|
||||
) => new Promise<KnowledgeSearchResponse>((resolve, reject) => {
|
||||
const task = uni.uploadFile({
|
||||
url: apiUrl('/api/knowledge/query-media'),
|
||||
@@ -117,7 +122,8 @@ export const searchKnowledgeWithMedia = (
|
||||
...(conversation ? {
|
||||
conversationId: conversation.conversationId,
|
||||
contextVersion: String(conversation.contextVersion)
|
||||
} : {})
|
||||
} : {}),
|
||||
...(projectCode ? { projectCode } : {})
|
||||
},
|
||||
timeout: 360000,
|
||||
success: (response) => {
|
||||
@@ -137,7 +143,8 @@ export const searchKnowledgeWithMedia = (
|
||||
export const queryKnowledgeTool = async (
|
||||
toolCode: 'MY_PRACTICE_SUMMARY' | 'TEAM_PRACTICE_SUMMARY',
|
||||
queryText: string,
|
||||
conversation?: KnowledgeConversationState
|
||||
conversation?: KnowledgeConversationState,
|
||||
projectCode?: string
|
||||
) => {
|
||||
const response = await apiRequest<UnifiedKnowledgeResponse>({
|
||||
url: '/api/knowledge/query',
|
||||
@@ -148,7 +155,8 @@ export const queryKnowledgeTool = async (
|
||||
limit: 5,
|
||||
toolCode,
|
||||
conversationId: conversation?.conversationId,
|
||||
contextVersion: conversation?.contextVersion
|
||||
contextVersion: conversation?.contextVersion,
|
||||
projectCode
|
||||
},
|
||||
timeout: 30000
|
||||
});
|
||||
|
||||
@@ -5,7 +5,8 @@ import type {
|
||||
MemoryConfirmResult,
|
||||
ServiceMemory
|
||||
} from '@/types/api';
|
||||
import { apiRequest } from './api';
|
||||
import type { CaptureStatusHistory } from '@/types/api';
|
||||
import { apiRequest, apiUrl } from './api';
|
||||
|
||||
const candidateBase = '/api/aihr/personal-assistant/memory-candidates';
|
||||
|
||||
@@ -52,6 +53,31 @@ export const getAssistantCaptures = async () =>
|
||||
timeout: 15000
|
||||
}));
|
||||
|
||||
export const updateAssistantCaptureStatus = (
|
||||
captureId: number,
|
||||
businessStatus: 'RECORDED' | 'PENDING' | 'IN_PROGRESS' | 'COMPLETED' | 'VOID',
|
||||
note = ''
|
||||
) => apiRequest<AssistantCapture>({
|
||||
url: `/api/aihr/personal-assistant/assistant-captures/${captureId}/status`,
|
||||
method: 'POST',
|
||||
data: {
|
||||
businessStatus,
|
||||
note,
|
||||
idempotencyKey: `capture-status-${captureId}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
|
||||
},
|
||||
timeout: 15000
|
||||
});
|
||||
|
||||
export const getAssistantCaptureStatusHistory = (captureId: number) =>
|
||||
apiRequest<CaptureStatusHistory[]>({
|
||||
url: `/api/aihr/personal-assistant/assistant-captures/${captureId}/status-history`,
|
||||
method: 'GET',
|
||||
timeout: 15000
|
||||
});
|
||||
|
||||
export const assistantCaptureSourceUrl = (captureId: number) =>
|
||||
apiUrl(`/api/aihr/personal-assistant/assistant-captures/${captureId}/source`);
|
||||
|
||||
export const getServiceMemories = async () => {
|
||||
const rows = rowsOf(await apiRequest<RawServiceMemory[] | { rows?: RawServiceMemory[]; records?: RawServiceMemory[] }>({
|
||||
url: '/api/aihr/service-memories',
|
||||
|
||||
@@ -12,6 +12,13 @@ import {
|
||||
type UserPosition
|
||||
} from './auth';
|
||||
|
||||
export const fetchMobileIdentity = () => apiRequest<MobileIdentity>({
|
||||
url: '/api/aihr/mobile/me',
|
||||
method: 'GET',
|
||||
timeout: 15000,
|
||||
clearAuthOnUnauthorized: false
|
||||
});
|
||||
|
||||
export const userTabPages = new Set([
|
||||
'/pages/user/today/index',
|
||||
'/pages/user/practice/index',
|
||||
@@ -35,12 +42,7 @@ export const routeForMobileRole = (role: RoleKey, requested: string) => {
|
||||
|
||||
export const hydrateMobileRole = async (): Promise<RoleKey> => {
|
||||
try {
|
||||
const identity = await apiRequest<MobileIdentity>({
|
||||
url: '/api/aihr/mobile/me',
|
||||
method: 'GET',
|
||||
timeout: 15000,
|
||||
clearAuthOnUnauthorized: false
|
||||
});
|
||||
const identity = await fetchMobileIdentity();
|
||||
const role = identity.role === 'supervisor' ? 'supervisor' : 'user';
|
||||
uni.setStorageSync(roleKey, role);
|
||||
if (role !== 'supervisor') leaveSupervisorLearnerMode();
|
||||
@@ -71,12 +73,7 @@ export type OrgPositionResult =
|
||||
|
||||
export const fetchOrgPositionResult = async (): Promise<OrgPositionResult> => {
|
||||
try {
|
||||
const identity = await apiRequest<MobileIdentity>({
|
||||
url: '/api/aihr/mobile/me',
|
||||
method: 'GET',
|
||||
timeout: 15000,
|
||||
clearAuthOnUnauthorized: false
|
||||
});
|
||||
const identity = await fetchMobileIdentity();
|
||||
const officialPosition = saveOrgPosition(identity.positionName);
|
||||
if (!officialPosition) {
|
||||
clearOrgPosition();
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { apiUrl, authHeaders } from './api';
|
||||
|
||||
type PreviewMediaApi = (options: {
|
||||
sources: Array<{ url: string; type: 'video' }>;
|
||||
fail?: () => void;
|
||||
}) => void;
|
||||
|
||||
let sourceAudio: UniApp.InnerAudioContext | null = null;
|
||||
|
||||
const fail = () => uni.showToast({ title: '原始来源读取失败', icon: 'none' });
|
||||
|
||||
export const openProtectedSource = (sourceUrl: string, sourceType: string) => {
|
||||
uni.downloadFile({
|
||||
url: apiUrl(sourceUrl),
|
||||
header: authHeaders(false),
|
||||
success: (response) => {
|
||||
if (response.statusCode !== 200 || !response.tempFilePath) {
|
||||
fail();
|
||||
return;
|
||||
}
|
||||
if (sourceType === 'IMAGE') {
|
||||
uni.previewImage({ urls: [response.tempFilePath] });
|
||||
return;
|
||||
}
|
||||
if (sourceType === 'VOICE') {
|
||||
sourceAudio?.destroy();
|
||||
sourceAudio = uni.createInnerAudioContext();
|
||||
sourceAudio.src = response.tempFilePath;
|
||||
sourceAudio.onError(fail);
|
||||
sourceAudio.play();
|
||||
return;
|
||||
}
|
||||
if (sourceType === 'VIDEO') {
|
||||
const previewMedia = (uni as typeof uni & { previewMedia?: PreviewMediaApi }).previewMedia;
|
||||
if (!previewMedia) {
|
||||
uni.showToast({ title: '当前端暂不支持视频预览', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
previewMedia({
|
||||
sources: [{ url: response.tempFilePath, type: 'video' }],
|
||||
fail
|
||||
});
|
||||
return;
|
||||
}
|
||||
uni.showToast({ title: '当前来源类型暂不支持预览', icon: 'none' });
|
||||
},
|
||||
fail
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { ProjectWorkResult, WorkResult } from '@/types/api';
|
||||
import { apiRequest } from './api';
|
||||
|
||||
const params = (projectCode: string, workDate?: string) => {
|
||||
const query = new URLSearchParams({ projectCode });
|
||||
if (workDate) query.set('workDate', workDate);
|
||||
return query.toString();
|
||||
};
|
||||
|
||||
export const generateMyWorkResult = (projectCode: string, workDate?: string) =>
|
||||
apiRequest<WorkResult>({
|
||||
url: `/api/aihr/work-results/mine/generate?${params(projectCode, workDate)}`,
|
||||
method: 'POST',
|
||||
timeout: 20000
|
||||
});
|
||||
|
||||
export const getProjectWorkResult = (projectCode: string, workDate?: string) =>
|
||||
apiRequest<ProjectWorkResult>({
|
||||
url: `/api/aihr/work-results/project?${params(projectCode, workDate)}`,
|
||||
method: 'GET',
|
||||
timeout: 20000
|
||||
});
|
||||
@@ -11,11 +11,17 @@ export interface LoginResult {
|
||||
|
||||
export type RoleKey = 'user' | 'supervisor' | 'candidate';
|
||||
|
||||
export interface MobileProject {
|
||||
projectCode: string;
|
||||
projectName: string;
|
||||
}
|
||||
|
||||
export interface MobileIdentity {
|
||||
role: RoleKey;
|
||||
roleLabel: string;
|
||||
positionName: string;
|
||||
positionLevel: string;
|
||||
projects: MobileProject[];
|
||||
}
|
||||
|
||||
export interface TtsResponse {
|
||||
@@ -207,6 +213,18 @@ export interface MemoryCandidateDraft {
|
||||
summary: string;
|
||||
detail: string;
|
||||
status: string;
|
||||
workDate?: string | null;
|
||||
businessStatus?: string | null;
|
||||
}
|
||||
|
||||
export interface WorkSourceSnapshot {
|
||||
sourceType: 'TEXT' | 'VOICE' | 'IMAGE' | 'VIDEO' | string;
|
||||
transcript?: string | null;
|
||||
ossId?: number | null;
|
||||
fileName?: string | null;
|
||||
mimeType?: string | null;
|
||||
fileSize?: number | null;
|
||||
recognizedSummary?: string | null;
|
||||
}
|
||||
|
||||
export interface MemoryCandidate {
|
||||
@@ -218,6 +236,7 @@ export interface MemoryCandidate {
|
||||
draft: MemoryCandidateDraft;
|
||||
missingFields: string[];
|
||||
expiresAt: string | null;
|
||||
source?: WorkSourceSnapshot | null;
|
||||
}
|
||||
|
||||
export interface MemoryConfirmRequest {
|
||||
@@ -247,8 +266,56 @@ export interface AssistantCapture {
|
||||
detail?: string | null;
|
||||
occurredAt: string;
|
||||
followUpAt?: string | null;
|
||||
workDate?: string | null;
|
||||
businessStatus: 'RECORDED' | 'PENDING' | 'IN_PROGRESS' | 'COMPLETED' | 'VOID' | string;
|
||||
deliveryStatus: 'NOT_REQUIRED' | 'PENDING' | 'DELIVERED' | 'FAILED' | string;
|
||||
createdAt: string;
|
||||
source?: WorkSourceSnapshot | null;
|
||||
}
|
||||
|
||||
export interface CaptureStatusHistory {
|
||||
id: number;
|
||||
fromStatus: string;
|
||||
toStatus: string;
|
||||
note?: string | null;
|
||||
operatorUserId: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface WorkResultItem {
|
||||
captureId: number;
|
||||
suggestedType: string;
|
||||
category: string;
|
||||
summary: string;
|
||||
businessStatus: string;
|
||||
deliveryStatus: string;
|
||||
occurredAt: string;
|
||||
sourceType: string;
|
||||
sourceUrl: string;
|
||||
}
|
||||
|
||||
export interface WorkResult {
|
||||
ownerUserId: number;
|
||||
ownerName: string;
|
||||
projectCode: string;
|
||||
projectName: string;
|
||||
workDate: string;
|
||||
version: number;
|
||||
generatedAt: string;
|
||||
sections: Record<string, WorkResultItem[]>;
|
||||
total: number;
|
||||
pending: number;
|
||||
}
|
||||
|
||||
export interface ProjectWorkResult {
|
||||
projectCode: string;
|
||||
projectName: string;
|
||||
workDate: string;
|
||||
contributorCount: number;
|
||||
resultCount: number;
|
||||
pendingCount: number;
|
||||
highPriorityCount: number;
|
||||
members: WorkResult[];
|
||||
}
|
||||
|
||||
export interface ServiceMemory {
|
||||
|
||||
@@ -29,3 +29,29 @@ test('问师傅支持显式新对话、版本回写和受控原文件或视频
|
||||
assert.match(page, /msg\.result\.rewrittenQuery !== msg\.result\.queryText/);
|
||||
assert.match(page, /已结合本次对话理解/);
|
||||
});
|
||||
|
||||
test('工作助手按项目名称选择并为文字、媒体和工具请求携带项目上下文', async () => {
|
||||
const [page, service, auth] = await Promise.all([
|
||||
read('../src/pages/user/sop/index.vue'),
|
||||
read('../src/services/knowledge.ts'),
|
||||
read('../src/services/auth.ts')
|
||||
]);
|
||||
|
||||
assert.match(page, />当前项目</);
|
||||
assert.match(page, /选择当前项目/);
|
||||
assert.match(page, /搜索项目名称/);
|
||||
assert.match(page, /saveCurrentProject\(project\)/);
|
||||
assert.match(page, /resetConversation\(false\)/);
|
||||
assert.match(page, /currentProject\.value\?\.projectCode/);
|
||||
assert.match(service, /projectCode/);
|
||||
assert.match(auth, /aihr_mobile_current_project:\$\{tenantId/);
|
||||
assert.match(auth, /reconcileCurrentProject/);
|
||||
});
|
||||
|
||||
test('项目选择面板避开底栏且确认卡内容增长后重新滚到底部', async () => {
|
||||
const page = await read('../src/pages/user/sop/index.vue');
|
||||
|
||||
assert.match(page, /inset:\s*0 0 var\(--window-bottom, 0px\)/);
|
||||
assert.match(page, /let scrollSeq = 0/);
|
||||
assert.match(page, /threadScrollTop\.value = 100000 \+ \(\+\+scrollSeq \* 200\)/);
|
||||
});
|
||||
|
||||
@@ -25,6 +25,8 @@ after(async () => {
|
||||
});
|
||||
|
||||
const { createPageRequestScope } = await vite.ssrLoadModule('/src/services/request-scope.ts');
|
||||
const { getCurrentProject, reconcileCurrentProject, saveAuth, saveCurrentProject } =
|
||||
await vite.ssrLoadModule('/src/services/auth.ts');
|
||||
|
||||
const loginAs = (phone, token) => {
|
||||
storage.set('aihr_mobile_phone', phone);
|
||||
@@ -89,3 +91,21 @@ test('五个账号敏感页在显示时捕获账号并在隐藏和卸载时失
|
||||
assert.match(source, /requests\.isCurrent\(/);
|
||||
}
|
||||
});
|
||||
|
||||
test('当前项目按租户和登录账号隔离并在权限变化后重新校验', () => {
|
||||
saveAuth({ access_token: 'token-a', client_id: 'mobile-client' }, '13800138000', '000000');
|
||||
saveCurrentProject({ projectCode: 'P1', projectName: '星河湾一期' });
|
||||
assert.equal(getCurrentProject()?.projectCode, 'P1');
|
||||
|
||||
saveAuth({ access_token: 'token-b', client_id: 'mobile-client' }, '13900139000', '000000');
|
||||
assert.equal(getCurrentProject(), null);
|
||||
assert.equal(reconcileCurrentProject([
|
||||
{ projectCode: 'P2', projectName: '星河湾二期' }
|
||||
])?.projectCode, 'P2');
|
||||
|
||||
assert.equal(reconcileCurrentProject([
|
||||
{ projectCode: 'P3', projectName: '星河湾三期' },
|
||||
{ projectCode: 'P4', projectName: '星河湾四期' }
|
||||
]), null);
|
||||
assert.equal(getCurrentProject(), null);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
|
||||
const read = (path) => readFile(new URL(path, import.meta.url), 'utf8');
|
||||
|
||||
test('员工与主管成果页只允许推进待跟进或处理中记录', async () => {
|
||||
const [employeePage, supervisorPage] = await Promise.all([
|
||||
read('../src/pages/user/work-results/index.vue'),
|
||||
read('../src/pages/supervisor/work-results/index.vue')
|
||||
]);
|
||||
|
||||
const actionableStatuses = /\['PENDING',\s*'IN_PROGRESS'\]\.includes\(item\.businessStatus\)/;
|
||||
assert.match(employeePage, actionableStatuses);
|
||||
assert.match(supervisorPage, /\['PENDING','IN_PROGRESS'\]\.includes\(item\.businessStatus\)/);
|
||||
assert.doesNotMatch(employeePage, /!\['COMPLETED',\s*'VOID'\]\.includes\(item\.businessStatus\)/);
|
||||
assert.doesNotMatch(supervisorPage, /!\['COMPLETED','VOID'\]\.includes\(item\.businessStatus\)/);
|
||||
});
|
||||
|
||||
test('员工成果页与主管成果页复用真实项目成果接口', async () => {
|
||||
const [employeePage, supervisorPage, service] = await Promise.all([
|
||||
read('../src/pages/user/work-results/index.vue'),
|
||||
read('../src/pages/supervisor/work-results/index.vue'),
|
||||
read('../src/services/work-results.ts')
|
||||
]);
|
||||
|
||||
assert.match(employeePage, /generateMyWorkResult/);
|
||||
assert.match(supervisorPage, /getProjectWorkResult/);
|
||||
assert.match(service, /\/api\/aihr\/work-results\/mine\/generate/);
|
||||
assert.match(service, /\/api\/aihr\/work-results\/project/);
|
||||
});
|
||||
|
||||
test('员工与主管成果页都能追溯非文字原始来源', async () => {
|
||||
const [employeePage, supervisorPage, preview] = await Promise.all([
|
||||
read('../src/pages/user/work-results/index.vue'),
|
||||
read('../src/pages/supervisor/work-results/index.vue'),
|
||||
read('../src/services/source-preview.ts')
|
||||
]);
|
||||
|
||||
assert.match(employeePage, /openProtectedSource/);
|
||||
assert.match(supervisorPage, /原始来源/);
|
||||
assert.match(supervisorPage, /openProtectedSource/);
|
||||
assert.match(preview, /createInnerAudioContext/);
|
||||
assert.match(preview, /previewMedia/);
|
||||
assert.doesNotMatch(employeePage, /openDocument/);
|
||||
});
|
||||
Reference in New Issue
Block a user