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
@@ -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,
+13 -1
View File
@@ -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": {
+1 -1
View File
@@ -190,7 +190,7 @@ const login = async () => {
if (!result.access_token || !result.client_id) {
throw new Error('登录响应缺少认证信息');
}
saveAuth(result, value);
saveAuth(result, value, '000000');
await goAfterLogin();
} catch (error) {
message.value = error instanceof Error ? error.message : '登录失败';
@@ -155,8 +155,9 @@ const loadReminders = () => {
}
};
const toggleReminder = (key: 'beforeDue' | 'autoReview', event: { detail?: { value?: boolean } }) => {
reminders.value = { ...reminders.value, [key]: Boolean(event.detail?.value) };
const toggleReminder = (key: 'beforeDue' | 'autoReview', event: Event | { detail?: { value?: boolean } }) => {
const value = 'detail' in event ? event.detail?.value : undefined;
reminders.value = { ...reminders.value, [key]: Boolean(value) };
uni.setStorageSync(reminderStorageKey, reminders.value);
};
@@ -270,6 +270,12 @@ interface QuickCell {
}
const quickCells = computed<QuickCell[]>(() => [
{
title: '团队今日成果',
meta: '记录与待跟进',
icon: 'file-text',
url: '/pages/supervisor/work-results/index'
},
{
title: '团队画像',
meta: `${teamTotal.value} 名成员`,
@@ -21,10 +21,16 @@
</view>
<view class="identity-row">
<text class="identity-label">可操作范围</text>
<text class="identity-value">团队画像、复盘、专项派发</text>
<text class="identity-value">团队画像、今日成果、复盘、专项派发</text>
</view>
</view>
<view class="panel learner-mode-card">
<text class="panel-title">团队工作成果</text>
<text class="muted learner-mode-desc">按当前项目查看今天的记录人数、待跟进事项和原始来源。</text>
<button class="secondary-button learner-mode-button" @click="openWorkResults">查看团队今日成果</button>
</view>
<view class="panel learner-mode-card">
<text class="panel-title">我的学习</text>
<text class="muted learner-mode-desc">以学员界面完成经理安排的学习与训练,不会修改你在人力系统中的主管身份。</text>
@@ -69,6 +75,10 @@ const enterLearnerMode = () => {
uni.switchTab({ url: '/pages/user/today/index' });
};
const openWorkResults = () => {
uni.navigateTo({ url: '/pages/supervisor/work-results/index' });
};
const logout = () => {
clearAuth();
rememberLoginRedirect('/pages/supervisor/profile/index');
@@ -0,0 +1,83 @@
<template>
<view class="page supervisor-result-page">
<view class="page-head">
<button class="back" @click="goBack">‹</button>
<view class="head-copy"><text class="title">团队今日成果</text><text class="subtitle">{{ result?.projectName || currentProject?.projectName || '当前项目' }} · {{ workDate }}</text></view>
<button class="refresh" @click="load">刷新</button>
</view>
<view v-if="message" class="message">{{ message }}</view>
<view v-if="result" class="metrics">
<view><text class="num">{{ result.contributorCount }}</text><text>今日记录人数</text></view>
<view><text class="num">{{ result.resultCount }}</text><text>已形成成果</text></view>
<view><text class="num warn">{{ result.pendingCount }}</text><text>待跟进</text></view>
<view><text class="num danger">{{ result.highPriorityCount }}</text><text>高优问题</text></view>
</view>
<view v-for="member in result?.members || []" :key="member.ownerUserId" class="member-card">
<view class="member-head">
<view><text class="member-name">{{ member.ownerName }}</text><text class="member-meta">{{ member.total }} 条记录 · {{ member.pending }} 项待跟进</text></view>
<text class="formed">{{ member.total ? '已形成成果' : '暂无记录' }}</text>
</view>
<view v-for="item in memberItems(member)" :key="item.captureId" class="member-item">
<view class="item-copy">
<text class="item-type">{{ item.category }}</text>
<text class="item-summary">{{ item.summary }}</text>
<text class="item-source">{{ sourceLabel(item.sourceType) }}</text>
</view>
<view class="item-actions">
<button v-if="item.sourceType !== 'TEXT'" class="source-action" @click="openSource(item)">原始来源</button>
<button v-if="['PENDING','IN_PROGRESS'].includes(item.businessStatus)" class="action" @click="advance(item)">{{ item.businessStatus === 'IN_PROGRESS' ? '完成' : '处理' }}</button>
</view>
</view>
</view>
<view v-if="result && !result.members.length" class="empty">当前项目今天还没有已确认的工作记录。</view>
<SupervisorTabBar active="today" />
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import type { MobileProject, ProjectWorkResult, WorkResult, WorkResultItem } from '@/types/api';
import { getCurrentProject, reconcileCurrentProject } from '@/services/auth';
import { fetchMobileIdentity } from '@/services/position';
import { updateAssistantCaptureStatus } from '@/services/personal-assistant';
import { openProtectedSource } from '@/services/source-preview';
import { getProjectWorkResult } from '@/services/work-results';
import SupervisorTabBar from '@/components/SupervisorTabBar.vue';
const result = ref<ProjectWorkResult | null>(null);
const currentProject = ref<MobileProject | null>(getCurrentProject());
const message = ref('');
const loading = ref(false);
const workDate = new Date().toLocaleDateString('sv-SE', { timeZone: 'Asia/Shanghai' });
const memberItems = (member: WorkResult) => Object.values(member.sections || {}).flat();
const load = async () => {
if (loading.value) return;
loading.value = true;
message.value = '';
try {
const identity = await fetchMobileIdentity();
currentProject.value = reconcileCurrentProject(identity.projects || []);
if (!currentProject.value) throw new Error('请先选择当前项目');
result.value = await getProjectWorkResult(currentProject.value.projectCode, workDate);
} catch (error) {
message.value = error instanceof Error ? error.message : '团队成果读取失败';
} finally {
loading.value = false;
}
};
const advance = async (item: WorkResultItem) => {
const next = item.businessStatus === 'IN_PROGRESS' ? 'COMPLETED' : 'IN_PROGRESS';
try { await updateAssistantCaptureStatus(item.captureId, next); await load(); }
catch (error) { uni.showToast({ title: error instanceof Error ? error.message : '状态更新失败', icon: 'none' }); }
};
const openSource = (item: WorkResultItem) => openProtectedSource(item.sourceUrl, item.sourceType);
const sourceLabel = (value: string) => ({ TEXT: '文字来源', VOICE: '原始语音', IMAGE: '现场照片', VIDEO: '现场视频' }[value] || '原始来源');
const goBack = () => uni.navigateBack({ fail: () => uni.redirectTo({ url: '/pages/supervisor/index/index' }) });
onShow(load);
</script>
<style scoped>
.supervisor-result-page{min-height:100vh;padding:18px 16px 100px;background:#f4f5f7;color:#171b23;box-sizing:border-box}.page-head{display:flex;align-items:center;gap:12px;margin-bottom:16px}.back{width:42px;height:42px;padding:0;border:1px solid #dfe2e7;border-radius:14px;background:#fff;font-size:32px;line-height:38px}.back::after,.refresh::after,.action::after,.source-action::after{border:0}.head-copy{display:flex;min-width:0;flex:1;flex-direction:column}.title{font-size:25px;font-weight:800}.subtitle{margin-top:3px;color:#7d8592;font-size:14px}.refresh{padding:8px 12px;border:1px solid #ef3340;border-radius:18px;background:#fff;color:#d9081c}.message,.empty{margin-bottom:14px;padding:14px;border-radius:14px;background:#fff0f1;color:#c6091b}.metrics{display:grid;grid-template-columns:repeat(2,1fr);gap:10px;margin-bottom:14px}.metrics>view{display:flex;flex-direction:column;padding:16px;border:1px solid #e5e7eb;border-radius:18px;background:#fff;color:#6f7784}.num{color:#171b23;font-size:30px;font-weight:800}.num.warn{color:#d98200}.num.danger{color:#e60012}.member-card{margin-bottom:14px;padding:16px;border:1px solid #e5e7eb;border-radius:20px;background:#fff;box-shadow:0 8px 26px rgba(31,38,50,.05)}.member-head{display:flex;align-items:center;justify-content:space-between;padding-bottom:12px;border-bottom:1px solid #eceef1}.member-head>view{display:flex;flex-direction:column}.member-name{font-size:19px;font-weight:800}.member-meta{margin-top:4px;color:#7d8592;font-size:13px}.formed{padding:4px 8px;border-radius:9px;background:#fff0f1;color:#d9081c;font-size:12px}.member-item{display:flex;align-items:center;gap:10px;padding:12px 0;border-bottom:1px solid #eff0f2}.member-item:last-child{border:0}.item-copy{display:flex;min-width:0;flex:1;flex-direction:column}.item-type{color:#d9081c;font-size:12px}.item-summary{margin-top:4px;font-size:15px;line-height:1.5}.item-source{margin-top:5px;color:#8a919d;font-size:12px}.item-actions{display:flex;align-items:flex-end;flex-direction:column;gap:6px}.source-action,.action{padding:7px 12px;border:1px solid #dfe2e7;border-radius:14px;background:#fff;color:#59616f;font-size:13px}.action{border-color:#ef3340;color:#d9081c}
</style>
@@ -111,6 +111,16 @@
</view>
<text class="points-entry-arrow">›</text>
</button>
<button class="feature-entry" @click="openWorkResults">
<view class="entry-icon">
<uni-icons type="calendar-filled" size="20" color="#ffffff" />
</view>
<view class="feature-entry-copy">
<text class="feature-entry-title">我的工作记录</text>
<text class="feature-entry-desc">今日成果 · 处理状态 · 原始来源</text>
</view>
<text class="points-entry-arrow">›</text>
</button>
<button class="feature-entry" @click="openPracticeSummary">
<view class="entry-icon">
<uni-icons type="list" size="20" color="#ffffff" />
@@ -302,6 +312,10 @@ const openMemories = () => {
uni.navigateTo({ url: '/pages/user/assistant/memories' });
};
const openWorkResults = () => {
uni.navigateTo({ url: '/pages/user/work-results/index' });
};
const openPracticeSummary = () => {
uni.setStorageSync('aihr_sop_pending_tool', 'MY_PRACTICE_SUMMARY');
uni.switchTab({ url: '/pages/user/sop/index' });
+25 -20
View File
@@ -3,10 +3,10 @@
<view class="report-header">
<button class="header-button back-button" aria-label="返回" @click="goBack"><uni-icons type="back" size="22" color="#25282d" /></button>
<view class="report-heading">
<text class="page-title">工作上报</text>
<text class="page-subtitle">数字师傅帮你整理</text>
<text class="page-title">成果投稿</text>
<text class="page-subtitle">把好经验沉淀给公司复用</text>
</view>
<button class="history-button" @click="openHistory">我的上报</button>
<button class="history-button" @click="openHistory">我的投稿</button>
</view>
<scroll-view
@@ -17,15 +17,15 @@
>
<view class="history-toolbar">
<view>
<text class="history-title">我的上报</text>
<text class="history-subtitle">这里展示你提交给企业审核的工作记录</text>
<text class="history-title">我的投稿</text>
<text class="history-subtitle">这里展示你提交给企业审核的案例、SOP、视频和知识</text>
</view>
<button class="text-button" @click="historyOpen = false">返回会话</button>
</view>
<view v-if="!loggedIn" class="login-card">
<text class="card-title">登录后查看上报</text>
<text class="muted-copy">上报内容会提交给企业审核,请先完成登录。</text>
<text class="card-title">登录后查看投稿</text>
<text class="muted-copy">投稿内容会提交给企业审核,请先完成登录。</text>
<button class="primary-action" @click="goLogin">去登录</button>
</view>
@@ -37,7 +37,7 @@
<view v-if="myReports.length" class="history-list">
<view v-for="item in myReports" :key="item.id" class="history-card">
<view class="history-card-head">
<text class="type-badge">上报类型:{{ item.typeLabel }}</text>
<text class="type-badge">投稿类型:{{ item.typeLabel }}</text>
<text class="status-badge" :class="item.status.toLowerCase()">{{ item.statusLabel }}</text>
</view>
<text class="history-card-title">{{ item.title }}</text>
@@ -47,7 +47,7 @@
<text v-if="item.reviewNote" class="review-note">审核意见:{{ item.reviewNote }}</text>
</view>
</view>
<view v-else-if="!loadingMine && !historyError" class="empty-card">还没有上报记录</view>
<view v-else-if="!loadingMine && !historyError" class="empty-card">还没有投稿记录</view>
</template>
</scroll-view>
@@ -63,7 +63,7 @@
<view class="bubble-row left">
<image class="avatar" src="/static/mentor-female-avatar.png" mode="aspectFill" />
<view class="bubble mentor-bubble">
<text class="bubble-text">今天处理了什么?按住说,也可以发照片或视频。</text>
<text class="bubble-text">哪段处置经验值得公司复用?按住说,也可以发照片或视频,我会整理成案例、SOP 或知识投稿。</text>
</view>
</view>
@@ -111,7 +111,7 @@
<view v-if="organizing || uploading" class="bubble-row left">
<image class="avatar" src="/static/mentor-female-avatar.png" mode="aspectFill" />
<view class="bubble mentor-bubble pending-bubble">
<text class="bubble-text">{{ uploading ? '附件上传中…' : '正在整理成工作上报…' }}</text>
<text class="bubble-text">{{ uploading ? '附件上传中…' : '正在整理成成果投稿…' }}</text>
</view>
</view>
@@ -119,7 +119,7 @@
<view class="confirm-head">
<view>
<text class="confirm-eyebrow">已整理,可继续编辑</text>
<text class="confirm-title">工作上报确认</text>
<text class="confirm-title">成果投稿确认</text>
</view>
<picker
:value="draftTypeIndex"
@@ -140,7 +140,7 @@
v-model="currentDraft.title"
class="draft-input"
maxlength="100"
placeholder="请填写上报标题"
placeholder="请填写投稿标题"
:disabled="draftLocked"
@input="markDraftEdited"
/>
@@ -186,7 +186,7 @@
{{ editingDraft ? '完成修改' : '修改' }}
</button>
<button class="submit-action" :disabled="submitActionDisabled" @click="confirmSubmit">
{{ submitting ? '上报中…' : '确认上报' }}
{{ submitting ? '投稿中…' : '确认投稿' }}
</button>
</view>
</view>
@@ -199,7 +199,7 @@
<text class="submitted-meta">
{{ submittedReport.typeLabel }} · {{ submittedReport.status === 'PENDING' ? '待审核' : submittedReport.statusLabel }}
</text>
<text class="submitted-id">上报编号 #{{ submittedReport.id }}</text>
<text class="submitted-id">投稿编号 #{{ submittedReport.id }}</text>
</view>
<text class="status-badge" :class="submittedReport.status.toLowerCase()">
{{ submittedReport.status }} · {{ submittedReport.statusLabel }}
@@ -662,12 +662,12 @@ const confirmSubmit = async () => {
const content = draft.content.trim();
if (!title || !content) {
editingDraft.value = true;
uni.showToast({ title: '请补全标题和上报内容', icon: 'none' });
uni.showToast({ title: '请补全标题和投稿内容', icon: 'none' });
return;
}
if (title.length > 100 || content.length > 2000) {
editingDraft.value = true;
uni.showToast({ title: title.length > 100 ? '标题不能超过100字' : '上报内容不能超过2000字', icon: 'none' });
uni.showToast({ title: title.length > 100 ? '标题不能超过100字' : '投稿内容不能超过2000字', icon: 'none' });
return;
}
if (privacyReviewRequired.value) {
@@ -712,8 +712,8 @@ const confirmSubmit = async () => {
}
const message = error instanceof Error ? error.message : '提交失败';
pushSystem(conflict
? message + '。请先到“我的上报”核对已有记录,再完成修改并重新确认。'
: rejected ? message : message + ',提交结果未确认,请点击确认上报重试');
? message + '。请先到“我的投稿”核对已有记录,再完成修改并重新确认。'
: rejected ? message : message + ',提交结果未确认,请点击确认投稿重试');
if (conflict) await loadMine(true);
} finally {
if (requestIsCurrent(generation, submitGeneration, accountKey)) submitting.value = false;
@@ -735,7 +735,7 @@ const loadMine = async (force = false) => {
}
} catch (error) {
if (!requestIsCurrent(generation, mineGeneration, accountKey)) return;
historyError.value = error instanceof Error ? error.message : '上报记录加载失败,请重试';
historyError.value = error instanceof Error ? error.message : '投稿记录加载失败,请重试';
} finally {
if (requestIsCurrent(generation, mineGeneration, accountKey)) loadingMine.value = false;
}
@@ -817,6 +817,11 @@ onShow(() => {
loggedIn.value = isLoggedIn();
if (loggedIn.value) {
void loadMine();
const prefill = String(uni.getStorageSync('aihr_work_report_prefill') || '').trim();
if (prefill) {
uni.removeStorageSync('aihr_work_report_prefill');
void organizeInput(prefill, { voice: false });
}
} else {
clearConversationState();
}
+399 -9
View File
@@ -2,12 +2,36 @@
<view class="page sop-chat-page">
<AskSourceHeader
active="assistant"
:context="`${positionLabel} · 住宅项目`"
:context="headerContext"
:loading="loading || voiceTranscribing"
@select-web="openWebAi"
@new-conversation="startNewConversation"
/>
<view v-if="loggedIn" class="project-context-bar">
<button
class="project-context-button"
:class="{ missing: availableProjects.length > 1 && !currentProject }"
:disabled="projectLoading || loading"
@click="openProjectPicker"
>
<view class="project-context-copy">
<text class="project-context-label">当前项目</text>
<text class="project-context-name">{{ projectContextLabel }}</text>
</view>
<uni-icons v-if="availableProjects.length > 1" type="down" size="15" color="#596474" />
</button>
<text v-if="!projectLoading && !availableProjects.length" class="project-context-note">未绑定项目,可继续查制度;项目工作请联系管理员</text>
</view>
<button v-if="loggedIn && currentProject" class="today-result-entry" @click="openTodayResult">
<view class="today-result-copy">
<text class="today-result-title">今日工作成果</text>
<text class="today-result-note">按当前项目汇总已确认记录</text>
</view>
<text class="today-result-arrow">查看 ›</text>
</button>
<scroll-view scroll-y class="chat-thread" :scroll-top="threadScrollTop" :scroll-with-animation="true">
<view class="thread-inner">
<view class="bubble-row left">
@@ -163,6 +187,7 @@
<MemoryCandidateCard
v-if="msg.result.memoryCandidate"
:candidate="msg.result.memoryCandidate"
:project-name="currentProject?.projectName || ''"
@dismissed="hideMemoryCandidate(msg)"
@refresh="refreshMemoryCandidate(msg, $event)"
/>
@@ -205,6 +230,38 @@
@tool="runDataTool(defaultToolCode)"
/>
<view v-if="projectPickerOpen" class="project-picker-mask" @click="closeProjectPicker">
<view class="project-picker-sheet" role="dialog" aria-label="选择当前项目" @click.stop>
<view class="project-picker-head">
<view>
<text class="project-picker-title">选择当前项目</text>
<text class="project-picker-subtitle">后续工作记录和对话只归入这个项目</text>
</view>
<button class="project-picker-close" aria-label="关闭项目选择" @click="closeProjectPicker">×</button>
</view>
<input
v-if="availableProjects.length > 6"
v-model.trim="projectSearch"
class="project-search"
maxlength="40"
placeholder="搜索项目名称"
/>
<scroll-view scroll-y class="project-picker-list">
<button
v-for="project in filteredProjects"
:key="project.projectCode"
class="project-option"
:class="{ selected: currentProject?.projectCode === project.projectCode }"
@click="selectProject(project)"
>
<text class="project-option-name">{{ project.projectName }}</text>
<text v-if="currentProject?.projectCode === project.projectCode" class="project-option-status">当前</text>
</button>
<text v-if="!filteredProjects.length" class="project-empty">没有匹配的项目</text>
</scroll-view>
</view>
</view>
<view v-if="summaryCard" class="modal-mask" @click="closeSummary">
<view class="modal-card" @click.stop>
<view class="modal-header">
@@ -285,7 +342,7 @@
</template>
<script setup lang="ts">
import { nextTick, ref } from 'vue';
import { computed, nextTick, ref } from 'vue';
import { onHide, onShow, onUnload } from '@dcloudio/uni-app';
import type {
AnswerFeedbackVerdict,
@@ -293,6 +350,7 @@ import type {
KnowledgeMediaSelectedFile,
KnowledgeResource,
KnowledgeSearchResponse,
MobileProject,
SpeechPlaybackStatus,
SummaryCardResponse
} from '@/types/api';
@@ -310,19 +368,20 @@ import {
import {
getSelectedPosition,
getSelectedPositionProfile,
reconcileCurrentProject,
isLoggedIn,
isSupervisorLearnerMode,
rememberLoginRedirect,
roleKey
roleKey,
saveCurrentProject
} from '@/services/auth';
import { resetPageScroll } from '@/services/navigation';
import { authenticatedFileUrl, releaseAuthenticatedFileUrl } from '@/services/api';
import { ensureEmployeePosition } from '@/services/position';
import { ensureEmployeePosition, fetchMobileIdentity } from '@/services/position';
import { chooseSpeechAudio, createSpeechPlaybackController, measureAudioDuration, MENTOR_VOICE_PROFILE, transcribeSpeechBlob, transcribeSpeechFile } from '@/services/speech';
import { downloadSummaryCardImage } from '@/services/summary-card-image';
import { getMemoryCandidates } from '@/services/personal-assistant';
import { currentAccountKey } from '@/services/request-scope';
import { computed } from 'vue';
type ToolCode = '' | 'MY_PRACTICE_SUMMARY' | 'TEAM_PRACTICE_SUMMARY';
@@ -378,10 +437,33 @@ const selectedMedia = ref<KnowledgeMediaSelectedFile | null>(null);
const speechState = ref<{ key: string; status: SpeechPlaybackStatus }>({ key: '', status: 'idle' });
const voiceTranscribing = ref(false);
const loggedIn = ref(false);
const projectLoading = ref(false);
const availableProjects = ref<MobileProject[]>([]);
const currentProject = ref<MobileProject | null>(null);
const projectPickerOpen = ref(false);
const projectSearch = ref('');
const pendingSopQueryKey = 'aihr_sop_pending_query';
const pendingToolKey = 'aihr_sop_pending_tool';
const currentRole = ref(String(uni.getStorageSync(roleKey) || ''));
const supervisorLearner = ref(isSupervisorLearnerMode());
const headerContext = computed(() => `${positionLabel.value} · ${currentProject.value?.projectName || '住宅项目'}`);
const projectContextLabel = computed(() => {
if (projectLoading.value) return '正在读取…';
if (currentProject.value) return currentProject.value.projectName;
if (availableProjects.value.length > 1) return '请选择项目';
return '未绑定项目';
});
const filteredProjects = computed(() => {
const keyword = projectSearch.value.trim().toLowerCase();
const rows = keyword
? availableProjects.value.filter((project) => project.projectName.toLowerCase().includes(keyword))
: availableProjects.value;
return [...rows].sort((left, right) => {
if (left.projectCode === currentProject.value?.projectCode) return -1;
if (right.projectCode === currentProject.value?.projectCode) return 1;
return left.projectName.localeCompare(right.projectName, 'zh-CN');
});
});
const createConversationId = () => {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
@@ -391,6 +473,7 @@ const createConversationId = () => {
const conversationId = ref(createConversationId());
const contextVersion = ref(0);
let msgSeq = 0;
let scrollSeq = 0;
let voiceTranscriptionGeneration = 0;
let abortVoiceTranscription: (() => void) | null = null;
let knowledgeSearchGeneration = 0;
@@ -413,7 +496,7 @@ const speechPlayback = createSpeechPlaybackController(
const scrollToBottom = () => {
void nextTick(() => {
threadScrollTop.value = 100000 + msgSeq * 200;
threadScrollTop.value = 100000 + (++scrollSeq * 200);
});
};
@@ -471,6 +554,7 @@ const resetConversation = (notify: boolean) => {
messages.value = [];
summaryCard.value = null;
selectedMedia.value = null;
composerRef.value?.clearDraft();
conversationId.value = createConversationId();
contextVersion.value = 0;
if (notify) pushSystem('已开始新对话,后续问题将从新的上下文理解');
@@ -478,6 +562,91 @@ const resetConversation = (notify: boolean) => {
const startNewConversation = () => resetConversation(true);
const openProjectPicker = () => {
if (projectLoading.value) return;
if (!availableProjects.value.length) {
uni.showToast({ title: '当前账号未绑定项目', icon: 'none' });
return;
}
if (availableProjects.value.length === 1) return;
projectSearch.value = '';
projectPickerOpen.value = true;
};
const closeProjectPicker = () => {
if (!currentProject.value && availableProjects.value.length > 1) return;
projectPickerOpen.value = false;
projectSearch.value = '';
};
const hasUnfinishedProjectInput = () => Boolean(
selectedMedia.value
|| composerRef.value?.hasDraft()
|| messages.value.some((message) => message.role === 'master' && message.result.memoryCandidate)
);
const applyProject = (project: MobileProject) => {
const changed = currentProject.value?.projectCode !== project.projectCode;
saveCurrentProject(project);
currentProject.value = project;
projectPickerOpen.value = false;
projectSearch.value = '';
if (changed) {
resetConversation(false);
pushSystem(`已切换到“${project.projectName}”,后续记录和对话从新的项目上下文开始`);
}
};
const selectProject = (project: MobileProject) => {
if (project.projectCode === currentProject.value?.projectCode) {
projectPickerOpen.value = false;
return;
}
if (!hasUnfinishedProjectInput()) {
applyProject(project);
return;
}
uni.showModal({
title: '切换当前项目?',
content: '未发送内容或待确认记录会被清空,已保存记录不受影响。',
confirmText: '确认切换',
success: (result) => {
if (result.confirm) applyProject(project);
}
});
};
const loadProjectContext = async () => {
projectLoading.value = true;
try {
const identity = await fetchMobileIdentity();
availableProjects.value = identity.projects || [];
currentProject.value = reconcileCurrentProject(availableProjects.value);
if (!currentProject.value && availableProjects.value.length > 1) {
projectPickerOpen.value = true;
}
} catch (error) {
availableProjects.value = [];
currentProject.value = null;
uni.showToast({ title: requestErrorText(error, '项目列表读取失败'), icon: 'none' });
} finally {
projectLoading.value = false;
}
};
const ensureCurrentProject = () => {
if (projectLoading.value) {
uni.showToast({ title: '正在读取项目,请稍候', icon: 'none' });
return false;
}
if (availableProjects.value.length > 1 && !currentProject.value) {
projectPickerOpen.value = true;
uni.showToast({ title: '请先选择当前项目', icon: 'none' });
return false;
}
return true;
};
const refreshAuthState = () => {
loggedIn.value = isLoggedIn();
positionLabel.value = getSelectedPosition();
@@ -523,6 +692,7 @@ const sendQuestion = async (rawText: string, options: { voice?: boolean } = {})
if (!(await ensureEmployeePosition('/pages/user/sop/index')) || !isCurrentQuestion()) {
return;
}
if (!ensureCurrentProject()) return;
refreshAuthState();
if (!isCurrentQuestion()) return;
pushMsg<UserMsg>({
@@ -540,8 +710,14 @@ const sendQuestion = async (rawText: string, options: { voice?: boolean } = {})
const nextResult = media
? await searchKnowledgeWithMedia(media, text, getSelectedPosition(), (task) => {
if (generation === knowledgeSearchGeneration) activeMediaUploadTask = task;
}, conversationState())
: await searchKnowledge(text, getSelectedPosition(), conversationState());
}, conversationState(), currentProject.value?.projectCode)
: await searchKnowledge(
text,
getSelectedPosition(),
conversationState(),
currentProject.value?.projectCode,
options.voice ? 'mobile_uni_voice' : 'mobile_uni_sop'
);
if (generation !== knowledgeSearchGeneration) return;
applyConversationState(nextResult);
retireEarlierMemoryCandidate(nextResult.memoryCandidate?.id);
@@ -602,7 +778,8 @@ const runDataTool = async (toolCode: 'MY_PRACTICE_SUMMARY' | 'TEAM_PRACTICE_SUMM
loading.value = true;
const generation = ++knowledgeSearchGeneration;
try {
const nextResult = await queryKnowledgeTool(effectiveToolCode, question, conversationState());
const nextResult = await queryKnowledgeTool(
effectiveToolCode, question, conversationState(), currentProject.value?.projectCode);
if (generation !== knowledgeSearchGeneration) return;
applyConversationState(nextResult);
replaceMsg(pending.id, {
@@ -916,6 +1093,10 @@ const openWebAi = () => {
uni.navigateTo({ url: '/pages/user/web-ai/index' });
};
const openTodayResult = () => {
uni.navigateTo({ url: '/pages/user/work-results/index' });
};
const hideMemoryCandidate = (msg: MasterMsg) => {
retireEarlierMemoryCandidate(msg.result.memoryCandidate?.id);
};
@@ -948,6 +1129,8 @@ const handleShow = async () => {
if (loggedIn.value) {
const hasEmployeePosition = await ensureEmployeePosition('/pages/user/sop/index');
if (!isCurrentShow() || !hasEmployeePosition) return;
await loadProjectContext();
if (!isCurrentShow()) return;
}
positionProfile.value = getSelectedPositionProfile();
scrollToBottom();
@@ -2041,6 +2224,213 @@ onUnload(() => {
overflow: hidden;
}
.project-context-bar {
flex-shrink: 0;
padding: 7px 12px;
border-bottom: 1px solid #ebedf0;
background: #ffffff;
}
.today-result-entry {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: space-between;
margin: 0;
padding: 9px 18px;
border: 0;
border-bottom: 1px solid #ebedf0;
border-radius: 0;
background: #fff8f8;
text-align: left;
}
.today-result-entry::after { border: 0; }
.today-result-copy { display: flex; flex-direction: column; gap: 2px; }
.today-result-title { color: #c90012; font-size: 15px; font-weight: 700; }
.today-result-note { color: #8c929d; font-size: 12px; }
.today-result-arrow { color: #d9081c; font-size: 13px; }
.project-context-button {
display: flex;
box-sizing: border-box;
width: 100%;
min-height: 48px;
align-items: center;
justify-content: space-between;
margin: 0;
padding: 7px 12px;
border: 1px solid #e1e5ea;
border-radius: 13px;
background: #f8f9fb;
line-height: 1.2;
text-align: left;
}
.project-context-button.missing {
border-color: #efb4bb;
background: #fff7f8;
}
.project-context-button::after,
.project-picker-close::after,
.project-option::after {
border: none;
}
.project-context-copy {
display: flex;
min-width: 0;
flex-direction: column;
gap: 3px;
}
.project-context-label {
color: #8a93a0;
font-size: 11px;
}
.project-context-name {
overflow: hidden;
color: #29313c;
font-size: 15px;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.project-context-note {
display: block;
margin-top: 5px;
color: #9a6b24;
font-size: 11px;
line-height: 1.4;
}
.project-picker-mask {
position: fixed;
z-index: 1100;
inset: 0 0 var(--window-bottom, 0px);
display: flex;
align-items: flex-end;
background: rgba(15, 23, 42, 0.46);
}
.project-picker-sheet {
box-sizing: border-box;
width: 100%;
max-height: 72vh;
padding: 18px 16px max(18px, env(safe-area-inset-bottom));
border-radius: 22px 22px 0 0;
background: #ffffff;
box-shadow: 0 -10px 36px rgba(15, 23, 42, 0.16);
}
.project-picker-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.project-picker-title,
.project-picker-subtitle {
display: block;
}
.project-picker-title {
color: #18202b;
font-size: 19px;
font-weight: 800;
}
.project-picker-subtitle {
margin-top: 5px;
color: #7b8492;
font-size: 12px;
line-height: 1.45;
}
.project-picker-close {
display: flex;
flex: 0 0 40px;
width: 40px;
height: 40px;
min-height: 40px;
align-items: center;
justify-content: center;
margin: 0;
padding: 0;
border: none;
border-radius: 50%;
background: #f1f3f5;
color: #596474;
font-size: 22px;
}
.project-search {
box-sizing: border-box;
width: 100%;
height: 46px;
margin-top: 16px;
padding: 0 14px;
border: 1px solid #dde2e8;
border-radius: 12px;
background: #f8f9fb;
color: #29313c;
font-size: 15px;
}
.project-picker-list {
max-height: 48vh;
margin-top: 14px;
}
.project-option {
display: flex;
box-sizing: border-box;
width: 100%;
min-height: 54px;
align-items: center;
justify-content: space-between;
margin: 0 0 9px;
padding: 0 15px;
border: 1px solid #e4e7eb;
border-radius: 13px;
background: #ffffff;
text-align: left;
}
.project-option.selected {
border-color: #e60012;
background: #fff7f8;
}
.project-option-name {
min-width: 0;
overflow: hidden;
color: #29313c;
font-size: 16px;
font-weight: 650;
text-overflow: ellipsis;
white-space: nowrap;
}
.project-option-status {
flex-shrink: 0;
margin-left: 12px;
color: #d70016;
font-size: 13px;
}
.project-empty {
display: block;
padding: 28px 0;
color: #8a93a0;
font-size: 14px;
text-align: center;
}
.chat-thread {
flex: 1;
min-height: 0;
+14 -3
View File
@@ -127,11 +127,18 @@
<text class="tool-sub">课程·三题</text>
</view>
</button>
<button class="tool-cell" @click="openWorkResults">
<view class="tool-icon">果</view>
<view class="tool-copy">
<text class="tool-name">今日成果</text>
<text class="tool-sub">记录·待跟进</text>
</view>
</button>
<button class="tool-cell" @click="openReport">
<view class="tool-icon">报</view>
<view class="tool-copy">
<text class="tool-name">工作上报</text>
<text class="tool-sub">案例·记录</text>
<text class="tool-name">成果投稿</text>
<text class="tool-sub">案例·SOP·知识</text>
</view>
</button>
<button class="tool-cell" @click="openQuestions">
@@ -429,6 +436,10 @@ const openReport = () => {
uni.navigateTo({ url: '/pages/user/report/index' });
};
const openWorkResults = () => {
uni.navigateTo({ url: '/pages/user/work-results/index' });
};
const ask = (query: string) => {
uni.setStorageSync(pendingSopQueryKey, query);
uni.switchTab({ url: '/pages/user/sop/index' });
@@ -937,7 +948,7 @@ onUnload(() => {
.tools-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
@@ -0,0 +1,130 @@
<template>
<view class="page result-page employee-page-shell">
<view class="page-head">
<button class="back" aria-label="返回" @click="goBack">‹</button>
<view class="head-copy">
<text class="title">今日工作成果</text>
<text class="subtitle">{{ result?.projectName || currentProject?.projectName || '请选择项目' }} · {{ workDate }}</text>
</view>
<button class="refresh" :disabled="loading" @click="load">{{ loading ? '整理中' : '重新整理' }}</button>
</view>
<view v-if="message" class="message">{{ message }}</view>
<view v-if="!currentProject && !loading" class="empty-card">
<text class="empty-title">请先在工作助手选择当前项目</text>
<button class="primary" @click="goAssistant">去工作助手</button>
</view>
<template v-else-if="result">
<view class="summary-card">
<view><text class="summary-number">{{ result.total }}</text><text class="summary-label">条工作记录</text></view>
<view><text class="summary-number warning">{{ result.pending }}</text><text class="summary-label">项待跟进</text></view>
<view><text class="summary-number">v{{ result.version }}</text><text class="summary-label">成果版本</text></view>
</view>
<view v-for="section in visibleSections" :key="section.key" class="section-card">
<view class="section-head">
<text class="section-title">{{ section.label }}</text>
<text class="section-count">{{ section.items.length }} 项</text>
</view>
<view v-for="item in section.items" :key="item.captureId" class="result-item">
<view class="item-main">
<view class="item-meta">
<text class="type-tag">{{ item.category }}</text>
<text class="status-tag">{{ statusLabel(item.businessStatus) }}</text>
</view>
<text class="item-summary">{{ item.summary }}</text>
<text class="item-time">{{ displayTime(item.occurredAt) }} · {{ sourceLabel(item.sourceType) }}</text>
</view>
<view class="item-actions">
<button v-if="item.sourceType !== 'TEXT'" class="text-action" @click="openSource(item)">原始来源</button>
<button
v-if="['PENDING', 'IN_PROGRESS'].includes(item.businessStatus)"
class="status-action"
@click="advance(item)"
>{{ item.businessStatus === 'IN_PROGRESS' ? '标记完成' : '开始处理' }}</button>
</view>
</view>
</view>
<view v-if="!result.total" class="empty-card">
<text class="empty-title">今天还没有已确认的工作记录</text>
<text class="empty-desc">在工作助手里按住说话或发照片,确认后会自动汇总到这里。</text>
<button class="primary" @click="goAssistant">去记录一件事</button>
</view>
</template>
</view>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import type { MobileProject, WorkResult, WorkResultItem } from '@/types/api';
import { getCurrentProject, reconcileCurrentProject } from '@/services/auth';
import { fetchMobileIdentity } from '@/services/position';
import { updateAssistantCaptureStatus } from '@/services/personal-assistant';
import { openProtectedSource } from '@/services/source-preview';
import { generateMyWorkResult } from '@/services/work-results';
const result = ref<WorkResult | null>(null);
const currentProject = ref<MobileProject | null>(getCurrentProject());
const loading = ref(false);
const message = ref('');
const workDate = new Date().toLocaleDateString('sv-SE', { timeZone: 'Asia/Shanghai' });
const sectionLabels: Record<string, string> = {
completed: '今日已完成',
inspection: '巡检发现',
services: '业主服务',
leads: '画像与服务线索',
attendance: '到岗与晨会',
pending: '未闭环与下一步'
};
const visibleSections = computed(() => Object.entries(result.value?.sections || {})
.map(([key, items]) => ({ key, label: sectionLabels[key] || key, items }))
.filter((section) => section.items.length));
const load = async () => {
if (loading.value) return;
loading.value = true;
message.value = '';
try {
const identity = await fetchMobileIdentity();
currentProject.value = reconcileCurrentProject(identity.projects || []);
if (!currentProject.value) {
result.value = null;
return;
}
result.value = await generateMyWorkResult(currentProject.value.projectCode, workDate);
} catch (error) {
message.value = error instanceof Error ? error.message : '今日工作成果读取失败';
} finally {
loading.value = false;
}
};
const advance = async (item: WorkResultItem) => {
const next = item.businessStatus === 'IN_PROGRESS' ? 'COMPLETED' : 'IN_PROGRESS';
try {
await updateAssistantCaptureStatus(item.captureId, next);
await load();
} catch (error) {
uni.showToast({ title: error instanceof Error ? error.message : '状态更新失败', icon: 'none' });
}
};
const openSource = (item: WorkResultItem) => openProtectedSource(item.sourceUrl, item.sourceType);
const statusLabel = (value: string) => ({
RECORDED: '已记录', PENDING: '待跟进', IN_PROGRESS: '处理中', COMPLETED: '已完成', VOID: '已作废'
}[value] || value);
const sourceLabel = (value: string) => ({ TEXT: '文字', VOICE: '语音', IMAGE: '照片', VIDEO: '视频' }[value] || '文字');
const displayTime = (value: string) => value?.replace('T', ' ').slice(11, 16) || '--:--';
const goAssistant = () => uni.switchTab({ url: '/pages/user/sop/index' });
const goBack = () => uni.navigateBack({ fail: () => uni.switchTab({ url: '/pages/user/today/index' }) });
onShow(load);
</script>
<style scoped>
.result-page{min-height:100vh;padding:18px 16px 40px;background:#f4f5f7;color:#171b23;box-sizing:border-box}.page-head{display:flex;align-items:center;gap:12px;margin-bottom:16px}.back{width:42px;height:42px;padding:0;border:1px solid #dfe2e7;border-radius:14px;background:#fff;font-size:32px;line-height:38px}.back::after,.refresh::after,.text-action::after,.status-action::after{border:0}.head-copy{display:flex;min-width:0;flex:1;flex-direction:column}.title{font-size:25px;font-weight:800}.subtitle{margin-top:3px;color:#7d8592;font-size:14px}.refresh{padding:8px 12px;border:1px solid #ef3340;border-radius:18px;background:#fff;color:#d9081c;font-size:13px}.message{margin-bottom:12px;padding:10px 12px;border-radius:12px;background:#fff0f1;color:#c6091b}.summary-card,.section-card,.empty-card{margin-bottom:14px;border:1px solid #e5e7eb;border-radius:20px;background:#fff;box-shadow:0 8px 26px rgba(31,38,50,.05)}.summary-card{display:grid;grid-template-columns:repeat(3,1fr);padding:18px 8px;text-align:center}.summary-card>view{display:flex;flex-direction:column;border-right:1px solid #eceef1}.summary-card>view:last-child{border:0}.summary-number{color:#e60012;font-size:27px;font-weight:800}.summary-number.warning{color:#d98200}.summary-label{margin-top:4px;color:#747c89;font-size:12px}.section-card{padding:16px}.section-head{display:flex;align-items:center;justify-content:space-between;padding-bottom:12px;border-bottom:1px solid #eceef1}.section-title{font-size:19px;font-weight:800}.section-count{color:#8a919d;font-size:13px}.result-item{padding:14px 0;border-bottom:1px solid #eff0f2}.result-item:last-child{border:0}.item-meta{display:flex;gap:8px;align-items:center}.type-tag,.status-tag{padding:3px 8px;border-radius:9px;background:#fff0f1;color:#d9081c;font-size:12px}.status-tag{background:#f2f3f5;color:#59616f}.item-summary{display:block;margin-top:9px;font-size:16px;line-height:1.55}.item-time{display:block;margin-top:6px;color:#8a919d;font-size:12px}.item-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:10px}.text-action,.status-action{padding:7px 11px;border:1px solid #dfe2e7;border-radius:14px;background:#fff;font-size:13px}.status-action{border-color:#ef3340;color:#d9081c}.empty-card{display:flex;align-items:center;flex-direction:column;padding:34px 22px;text-align:center}.empty-title{font-size:18px;font-weight:700}.empty-desc{margin:9px 0 18px;color:#7d8592;line-height:1.6}.primary{margin-top:18px;padding:10px 22px;border:0;border-radius:20px;background:#e60012;color:#fff}
</style>
+46 -2
View File
@@ -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();
+14 -6
View File
@@ -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
});
+27 -1
View File
@@ -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',
+9 -12
View File
@@ -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();
+49
View File
@@ -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
});
};
+22
View File
@@ -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
});
+67
View File
@@ -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 {