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 }}高优问题
+
{{ 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 @@
+
+
+
+
+ {{ message }}
+ 消息加载中…
+
+ {{ item.title }}
+ 发布时间:{{ displayTime(item.publishedAt) }}
+ {{ item.content }}
+
+
+ 将在“问”中围绕本条消息继续咨询
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+ 登录后查看
+ 消息只会按当前登录账号读取。
+
+
+
+
+ {{ message }}
+ 消息加载中…
+
+ 消息加载失败
+ {{ message || '请检查网络后重试。' }}
+
+
+
+ 暂时没有消息
+ 新的公司通知会显示在这里。
+
+
+
+
+ 已加载全部 {{ items.length }} 条消息
+
+
+
+
+
+
+
+
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, /