diff --git a/docs/银城员工端-学练模块场景使用说明-20260722.docx b/docs/银城员工端-学练模块场景使用说明-20260722.docx new file mode 100644 index 00000000..d92835bc Binary files /dev/null and b/docs/银城员工端-学练模块场景使用说明-20260722.docx differ diff --git a/mobile-uni/src/pages.json b/mobile-uni/src/pages.json index acbd47c4..5264d2ea 100644 --- a/mobile-uni/src/pages.json +++ b/mobile-uni/src/pages.json @@ -25,6 +25,18 @@ "navigationBarTitleText": "工作助手" } }, + { + "path": "pages/user/broadcast/index", + "style": { + "navigationBarTitleText": "银城大喇叭" + } + }, + { + "path": "pages/user/broadcast/detail", + "style": { + "navigationBarTitleText": "消息详情" + } + }, { "path": "pages/user/assistant/index", "style": { diff --git a/mobile-uni/src/pages/supervisor/work-results/index.vue b/mobile-uni/src/pages/supervisor/work-results/index.vue index 501a0f87..4b0a723f 100644 --- a/mobile-uni/src/pages/supervisor/work-results/index.vue +++ b/mobile-uni/src/pages/supervisor/work-results/index.vue @@ -19,6 +19,11 @@ {{ result.pendingCount }}待跟进 {{ result.highPriorityCount }}高优问题 + + + 第 {{ result.pageNum }} / {{ totalPages }} 页 · 共 {{ result.contributorCount }} 名员工 + + {{ member.ownerName }}{{ member.total }} 条记录 · {{ member.pending }} 项待跟进 @@ -60,9 +65,16 @@ const loading = ref(false); const currentDate = () => new Date().toLocaleDateString('sv-SE', { timeZone: 'Asia/Shanghai' }); const today = ref(currentDate()); const workDate = ref(today.value); +const pageNum = ref(1); +const pageSize = 20; const emptyMessage = computed(() => workDate.value === today.value ? '当前项目今天还没有已确认的工作记录。' : '当前项目这一天没有已确认的工作记录。'); +const totalPages = computed(() => { + const count = result.value?.contributorCount ?? 0; + const size = result.value?.pageSize || pageSize; + return Math.max(1, Math.ceil(count / size)); +}); const memberItems = (member: WorkResult) => Object.values(member.sections || {}).flat(); const load = async () => { @@ -71,9 +83,13 @@ const load = async () => { message.value = ''; try { const identity = await fetchMobileIdentity(); + const previousProjectCode = currentProject.value?.projectCode; currentProject.value = reconcileCurrentProject(identity.projects || []); if (!currentProject.value) throw new Error('请先选择当前项目'); - result.value = await getProjectWorkResult(currentProject.value.projectCode, workDate.value); + if (previousProjectCode && previousProjectCode !== currentProject.value.projectCode) pageNum.value = 1; + const loaded = await getProjectWorkResult(currentProject.value.projectCode, workDate.value, pageNum.value, pageSize); + result.value = loaded; + pageNum.value = loaded.pageNum || pageNum.value; } catch (error) { message.value = error instanceof Error ? error.message : '团队成果读取失败'; } finally { @@ -82,9 +98,15 @@ const load = async () => { }; const changeWorkDate = (value: string) => { workDate.value = value; + pageNum.value = 1; result.value = null; void load(); }; +const changePage = (nextPage: number) => { + if (loading.value || nextPage < 1 || nextPage > totalPages.value) return; + pageNum.value = nextPage; + void load(); +}; const advance = async (item: WorkResultItem) => { const next = item.businessStatus === 'IN_PROGRESS' ? 'COMPLETED' : 'IN_PROGRESS'; try { await updateAssistantCaptureStatus(item.captureId, next); await load(); } @@ -100,5 +122,5 @@ onShow(() => { diff --git a/mobile-uni/src/pages/user/broadcast/detail.vue b/mobile-uni/src/pages/user/broadcast/detail.vue new file mode 100644 index 00000000..e68c8c2d --- /dev/null +++ b/mobile-uni/src/pages/user/broadcast/detail.vue @@ -0,0 +1,123 @@ + + + + + diff --git a/mobile-uni/src/pages/user/broadcast/index.vue b/mobile-uni/src/pages/user/broadcast/index.vue new file mode 100644 index 00000000..d9816fee --- /dev/null +++ b/mobile-uni/src/pages/user/broadcast/index.vue @@ -0,0 +1,178 @@ + + + + + diff --git a/mobile-uni/src/services/broadcast.ts b/mobile-uni/src/services/broadcast.ts new file mode 100644 index 00000000..d69b9fb2 --- /dev/null +++ b/mobile-uni/src/services/broadcast.ts @@ -0,0 +1,122 @@ +import { apiRequest } from './api'; + +export const broadcastQuestionContextStorageKey = 'aihr_broadcast_question_context'; +export const broadcastQuestionContextTtlMs = 15 * 60 * 1000; + +export interface BroadcastQuestionContext { + messageId: number; + accountKey: string; + expiresAt: number; +} + +export interface BroadcastMessageSummary { + id: number; + title: string; + read: boolean; + publishedAt: string; +} + +export interface BroadcastMessage extends BroadcastMessageSummary { + content: string; +} + +export interface BroadcastMessagePage { + total: number; + pageNum: number; + pageSize: number; + rows: BroadcastMessageSummary[]; +} + +export const getBroadcastUnreadCount = async () => { + const result = await apiRequest<{ unreadCount: number }>({ + url: '/api/aihr/broadcast/unread-count', + method: 'GET', + timeout: 15000 + }); + return Math.max(0, Number(result?.unreadCount) || 0); +}; + +export const listBroadcastMessages = (pageNum = 1, pageSize = 20) => + apiRequest({ + url: `/api/aihr/broadcast/messages?pageNum=${pageNum}&pageSize=${pageSize}`, + method: 'GET', + timeout: 15000 + }); + +export const getBroadcastMessage = (id: number) => + apiRequest({ + url: `/api/aihr/broadcast/messages/${id}`, + method: 'GET', + timeout: 15000 + }); + +export const markBroadcastMessageRead = (id: number) => + apiRequest({ + url: `/api/aihr/broadcast/messages/${id}/read`, + method: 'POST', + timeout: 15000 + }); + +const isSafeMessageId = (value: unknown): value is number => + typeof value === 'number' && Number.isSafeInteger(value) && value > 0; + +const parseQuestionContext = (value: unknown): BroadcastQuestionContext | null => { + if (!value || typeof value !== 'object') return null; + const candidate = value as Partial; + return isSafeMessageId(candidate.messageId) + && typeof candidate.accountKey === 'string' + && candidate.accountKey.length > 0 + && typeof candidate.expiresAt === 'number' + && Number.isSafeInteger(candidate.expiresAt) + && candidate.expiresAt > 0 + ? { + messageId: candidate.messageId, + accountKey: candidate.accountKey, + expiresAt: candidate.expiresAt + } + : null; +}; + +export const clearBroadcastQuestionContext = () => { + try { + uni.removeStorageSync(broadcastQuestionContextStorageKey); + } catch { + // Storage is only a navigation handoff. A failed cleanup must not expose content. + } +}; + +/** + * Stores only a short-lived, account-bound message identifier. The title, body and + * tenant are deliberately never written to local storage or navigation URLs. + */ +export const saveBroadcastQuestionContext = ( + messageId: number, + accountKey: string, + ttlMs = broadcastQuestionContextTtlMs +) => { + if (!isSafeMessageId(messageId) || !accountKey || !Number.isSafeInteger(ttlMs) || ttlMs <= 0) return false; + try { + uni.setStorageSync(broadcastQuestionContextStorageKey, { + messageId, + accountKey, + expiresAt: Date.now() + ttlMs + } satisfies BroadcastQuestionContext); + return true; + } catch { + return false; + } +}; + +/** Returns a valid account-bound context and clears stale, malformed or foreign data. */ +export const readBroadcastQuestionContext = (accountKey: string, now = Date.now()): BroadcastQuestionContext | null => { + try { + const context = parseQuestionContext(uni.getStorageSync(broadcastQuestionContextStorageKey)); + if (!context || context.accountKey !== accountKey || context.expiresAt <= now) { + clearBroadcastQuestionContext(); + return null; + } + return context; + } catch { + return null; + } +}; diff --git a/mobile-uni/src/services/knowledge.ts b/mobile-uni/src/services/knowledge.ts index 3d75c809..ee998c81 100644 --- a/mobile-uni/src/services/knowledge.ts +++ b/mobile-uni/src/services/knowledge.ts @@ -83,7 +83,8 @@ export const searchKnowledge = async ( position = getSelectedPosition(), conversation?: KnowledgeConversationState, projectCode?: string, - source: 'mobile_uni_sop' | 'mobile_uni_voice' = 'mobile_uni_sop' + source: 'mobile_uni_sop' | 'mobile_uni_voice' = 'mobile_uni_sop', + broadcastMessageId?: number ) => { const response = await apiRequest({ url: '/api/knowledge/query', @@ -96,7 +97,10 @@ export const searchKnowledge = async ( limit: 5, conversationId: conversation?.conversationId, contextVersion: conversation?.contextVersion, - projectCode + projectCode, + ...(typeof broadcastMessageId === 'number' && Number.isSafeInteger(broadcastMessageId) && broadcastMessageId > 0 + ? { broadcastMessageId } + : {}) }, timeout: 30000 }); @@ -198,7 +202,8 @@ const normalizeUnifiedResponse = (response: UnifiedKnowledgeResponse): Knowledge intent: response.intent, rewrittenQuery: response.rewrittenQuery, resources: response.resources || [], - memoryCandidate: response.memoryCandidate || legacy.memoryCandidate + memoryCandidate: response.memoryCandidate || legacy.memoryCandidate, + broadcastContext: response.broadcastContext || legacy.broadcastContext || null }; }; diff --git a/mobile-uni/src/services/work-results.ts b/mobile-uni/src/services/work-results.ts index ca8e2ed6..9baf6f47 100644 --- a/mobile-uni/src/services/work-results.ts +++ b/mobile-uni/src/services/work-results.ts @@ -1,9 +1,11 @@ import type { ProjectWorkResult, WorkResult } from '@/types/api'; import { apiRequest } from './api'; -const params = (projectCode: string, workDate?: string) => { +const params = (projectCode: string, workDate?: string, pageNum?: number, pageSize?: number) => { const query = new URLSearchParams({ projectCode }); if (workDate) query.set('workDate', workDate); + if (pageNum) query.set('pageNum', String(pageNum)); + if (pageSize) query.set('pageSize', String(pageSize)); return query.toString(); }; @@ -14,9 +16,9 @@ export const generateMyWorkResult = (projectCode: string, workDate?: string) => timeout: 20000 }); -export const getProjectWorkResult = (projectCode: string, workDate?: string) => +export const getProjectWorkResult = (projectCode: string, workDate?: string, pageNum = 1, pageSize = 20) => apiRequest({ - url: `/api/aihr/work-results/project?${params(projectCode, workDate)}`, + url: `/api/aihr/work-results/project?${params(projectCode, workDate, pageNum, pageSize)}`, method: 'GET', timeout: 20000 }); diff --git a/mobile-uni/src/types/api.ts b/mobile-uni/src/types/api.ts index f281ebbe..ab677cad 100644 --- a/mobile-uni/src/types/api.ts +++ b/mobile-uni/src/types/api.ts @@ -143,6 +143,7 @@ export interface KnowledgeSearchResponse { rewrittenQuery?: string; resources?: KnowledgeResource[]; memoryCandidate?: MemoryCandidate | null; + broadcastContext?: BroadcastKnowledgeContext | null; } export interface KnowledgeMediaSelectedFile { @@ -179,6 +180,13 @@ export interface KnowledgeConversationState { contextVersion: number; } +/** Server-confirmed metadata for a company-message consultation; never includes the message body. */ +export interface BroadcastKnowledgeContext { + messageId: number; + title: string; + publishedAt: string; +} + export interface UnifiedKnowledgeResponse { requestId: string; queryText: string; @@ -195,6 +203,7 @@ export interface UnifiedKnowledgeResponse { rewrittenQuery?: string; resources?: KnowledgeResource[]; memoryCandidate?: MemoryCandidate | null; + broadcastContext?: BroadcastKnowledgeContext | null; } export type MemoryCandidateStatus = 'DRAFT' | 'NEEDS_INPUT' | 'CONFIRMED' | 'DISMISSED' | 'EXPIRED' | string; @@ -316,6 +325,8 @@ export interface ProjectWorkResult { pendingCount: number; highPriorityCount: number; members: WorkResult[]; + pageNum: number; + pageSize: number; } export interface ServiceMemory { diff --git a/mobile-uni/tests/ask-memory.test.mjs b/mobile-uni/tests/ask-memory.test.mjs index db246c02..965c1882 100644 --- a/mobile-uni/tests/ask-memory.test.mjs +++ b/mobile-uni/tests/ask-memory.test.mjs @@ -59,3 +59,44 @@ test('项目选择面板避开底栏,普通消息滚底且确认卡从识别 assert.match(page, /next\?\.role === 'master' && next\.result\.memoryCandidate/); assert.match(page, /scrollToMessageStart\(id\)/); }); + +test('问师傅在真正开始会话后收起首次使用引导,保留对话主体', async () => { + const page = await read('../src/pages/user/sop/index.vue'); + + assert.match(page, /const conversationStarted = computed\(\(\) => messages\.value\.some/); + assert.match(page, /v-if="loggedIn && !conversationStarted" class="project-context-bar"/); + assert.match(page, /v-if="!conversationStarted" class="bubble-row left"/); + assert.match(page, /:quick-questions="conversationStarted \? \[\] : quickQuestions"/); +}); + +test('消息咨询只以消息 ID 作为受控上下文,并让每轮文字或语音请求携带它', async () => { + const [page, service, types] = await Promise.all([ + read('../src/pages/user/sop/index.vue'), + read('../src/services/knowledge.ts'), + read('../src/types/api.ts') + ]); + + assert.match(page, /getBroadcastMessage\(pending\.messageId\)/); + assert.match(page, /messageId: message\.id,\s*title: message\.title,\s*publishedAt: message\.publishedAt/); + assert.match(page, /broadcastMessageId \? undefined : currentProject\.value\?\.projectCode/); + assert.match(page, /options\.voice \? 'mobile_uni_voice' : 'mobile_uni_sop',\s*broadcastMessageId/); + assert.match(service, /broadcastMessageId\?: number/); + assert.match(service, /\? \{ broadcastMessageId \}/); + assert.match(service, /broadcastContext: response\.broadcastContext \|\| legacy\.broadcastContext \|\| null/); + assert.match(types, /export interface BroadcastKnowledgeContext/); + assert.match(types, /broadcastContext\?: BroadcastKnowledgeContext \| null/); +}); + +test('消息咨询会新开会话,隐藏媒体和数据工具,并在撤回时清空对话', async () => { + const page = await read('../src/pages/user/sop/index.vue'); + + assert.match(page, /error\.statusCode === 404 \|\| error\.bizCode === 404/); + assert.match(page, /const pending = readBroadcastQuestionContext\(accountKey\);\s*if \(!pending\) \{\s*if \(broadcastContext\.value\) \{\s*clearBroadcastConsultation\(\);\s*resetConversation\(false\);/); + assert.match(page, /const startNewConversation = \(\) => \{\s*clearBroadcastConsultation\(\);\s*resetConversation\(true\);/); + assert.match(page, /clearBroadcastConsultation\(\);\s*resetConversation\(false\);\s*pushSystem\('这条消息已撤回,无法继续咨询。'\)/); + assert.match(page, /const composerPanelItems = computed\(\(\) => broadcastContext\.value\s*\? \[\{ key: 'pick-audio'/); + assert.match(page, /if \(broadcastContext\.value\) \{\s*uni\.showToast\(\{ title: '消息咨询不支持数据查询/); + assert.match(page, /if \(broadcastContext\.value\) \{\s*uni\.showToast\(\{ title: '消息咨询暂不支持图片或视频/); + assert.match(page, /@click="viewBroadcastMessage">查看原消息/); + assert.match(page, /@click="endBroadcastConsultation">结束咨询/); +}); diff --git a/mobile-uni/tests/broadcast.test.mjs b/mobile-uni/tests/broadcast.test.mjs new file mode 100644 index 00000000..4fc59fd6 --- /dev/null +++ b/mobile-uni/tests/broadcast.test.mjs @@ -0,0 +1,76 @@ +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 service = await read('../src/services/broadcast.ts'); + + assert.match(service, /\/api\/aihr\/broadcast\/unread-count/); + assert.match(service, /\/api\/aihr\/broadcast\/messages\?pageNum=/); + assert.match(service, /\/api\/aihr\/broadcast\/messages\/\$\{id\}/); + assert.match(service, /\/read`/); + assert.doesNotMatch(service, /auth:\s*false/); +}); + +test('首页喇叭只在确认有未读消息时显示红点,未登录保留回跳', async () => { + const page = await read('../src/pages/user/today/index.vue'); + + assert.match(page, /v-if="hasUnreadBroadcast" class="bell-dot"/); + assert.match(page, /getBroadcastUnreadCount/); + assert.match(page, /const unreadBroadcastCount = ref\(null\)/); + assert.match(page, /const hasUnreadBroadcast = computed\(\(\) => typeof unreadBroadcastCount\.value === 'number'/); + assert.match(page, /const request = requests\.begin\('broadcast-unread'\);\s*unreadBroadcastCount\.value = null;/); + assert.match(page, /visibleAccountKey !== nextAccountKey[\s\S]*unreadBroadcastCount\.value = null;/); + assert.match(page, /