feat(broadcast): finish multi-role insight experience

This commit is contained in:
2026-07-25 15:18:58 +08:00
parent 7990db6883
commit 230dfb4dfc
20 changed files with 552 additions and 81 deletions
+108 -7
View File
@@ -14,10 +14,14 @@
<text class="message-title">{{ item.title }}</text>
<view class="detail-tags">
<text v-if="item.requiredRead" class="detail-tag required">必读消息</text>
<text v-if="item.targeted" class="detail-tag related">与我有关{{ item.targetReason ? ` · ${item.targetReason}` : '' }}</text>
<text v-if="item.targeted" class="detail-tag related">定向提醒{{ item.targetReason ? ` · ${item.targetReason}` : '' }}</text>
</view>
<text class="message-time">发布时间:{{ displayTime(item.publishedAt) }}</text>
<text class="message-content">{{ item.content }}</text>
<view class="detail-actions">
<button class="ask-message-button" @click="askAboutMessage">问这条消息</button>
<text class="ask-message-note">将在“问”中围绕本条消息继续咨询</text>
</view>
<view v-if="item.attachment" class="attachment-card">
<view class="attachment-head">
<view>
@@ -30,19 +34,67 @@
<button class="download-button" :disabled="downloading" @click="downloadAttachment">
{{ downloading ? '正在读取…' : '查看原文件' }}
</button>
</view>
<view class="detail-actions">
<button class="ask-message-button" @click="askAboutMessage">问这条消息</button>
<text class="ask-message-note">将在“问”中围绕本条消息继续咨询</text>
<view v-if="item.attachment.perspectives.length" class="perspective-panel">
<view class="perspective-heading">
<view>
<text class="perspective-kicker">
{{ selectedPerspective?.code === item.attachment.defaultPerspectiveCode ? '我的岗位视角' : '多岗位解读' }}
</text>
<text class="perspective-title">{{ selectedPerspective?.label }}</text>
</view>
<text v-if="item.attachment.insightStatus === 'PARTIAL'" class="insight-state partial">部分完成</text>
</view>
<scroll-view class="perspective-tabs" scroll-x>
<view class="perspective-tab-row" role="group" aria-label="岗位解读切换">
<button
v-for="perspective in item.attachment.perspectives"
:key="perspective.code"
class="perspective-tab"
:class="{ active: selectedPerspective?.code === perspective.code }"
:aria-pressed="selectedPerspective?.code === perspective.code"
@click="selectPerspective(perspective.code)"
>{{ perspective.label }}</button>
</view>
</scroll-view>
<text v-if="selectedPerspective?.summary" class="perspective-summary">{{ selectedPerspective.summary }}</text>
<view v-for="section in perspectiveSections" :key="section.label" class="perspective-section">
<text class="perspective-section-title">{{ section.label }}</text>
<text v-for="entry in section.items" :key="entry" class="perspective-item">· {{ entry }}</text>
</view>
<button
v-if="selectedPerspective?.evidence.length"
class="evidence-toggle"
:aria-expanded="evidenceExpanded"
@click="evidenceExpanded = !evidenceExpanded"
>
{{ evidenceExpanded ? '收起原文依据' : `查看原文依据(${selectedPerspective.evidence.length})` }}
</button>
<view v-if="evidenceExpanded" class="evidence-list">
<view v-for="evidence in selectedPerspective?.evidence" :key="`${evidence.paragraphIndex}-${evidence.quote}`" class="evidence-item">
<text class="evidence-index">第 {{ evidence.paragraphIndex }} 段</text>
<text class="evidence-quote">“{{ evidence.quote }}”</text>
</view>
</view>
</view>
<view v-else-if="item.attachment.insightStatus === 'PENDING'" class="insight-state-card" role="status">
<text>AI 正在从不同岗位解读,不影响查看原文件</text>
<button class="insight-refresh" @click="load">刷新解读</button>
</view>
<view v-else-if="item.attachment.insightStatus === 'FAILED'" class="insight-state-card failed">
多岗位解读暂不可用,可继续查看原文件或咨询
</view>
<view v-else-if="item.attachment.insightStatus === 'NOT_PROCESSED'" class="insight-state-card">
该文件暂未生成多岗位解读
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { computed, ref } from 'vue';
import { onLoad } from '@dcloudio/uni-app';
import type { BroadcastMessage } from '@/services/broadcast';
import type { BroadcastAttachment, BroadcastMessage } from '@/services/broadcast';
import {
clearBroadcastQuestionContext,
getBroadcastMessage,
@@ -60,6 +112,19 @@ const item = ref<BroadcastMessage | null>(null);
const loading = ref(false);
const message = ref('');
const downloading = ref(false);
const selectedPerspectiveCode = ref('');
const evidenceExpanded = ref(false);
const selectedPerspective = computed(() =>
item.value?.attachment?.perspectives.find((perspective) => perspective.code === selectedPerspectiveCode.value)
|| item.value?.attachment?.perspectives[0]
|| null
);
const perspectiveSections = computed(() => selectedPerspective.value ? [
{ label: '关注重点', items: selectedPerspective.value.concerns },
{ label: '岗位影响', items: selectedPerspective.value.impacts },
{ label: '需要行动', items: selectedPerspective.value.actions },
{ label: '风险提醒', items: selectedPerspective.value.risks }
].filter((section) => section.items.length) : []);
const goBack = () => uni.navigateBack({ delta: 1, fail: () => uni.switchTab({ url: '/pages/user/today/index' }) });
const displayTime = (value: string) => value ? value.replace('T', ' ').slice(0, 16) : '';
@@ -70,6 +135,17 @@ const formatFileSize = (bytes: number) => {
if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
};
const selectPerspective = (code: string) => {
selectedPerspectiveCode.value = code;
evidenceExpanded.value = false;
};
const selectInitialPerspective = (attachment?: BroadcastAttachment) => {
const perspectives = attachment?.perspectives || [];
const preferred = perspectives.some((perspective) => perspective.code === attachment?.defaultPerspectiveCode)
? attachment?.defaultPerspectiveCode
: perspectives[0]?.code;
selectPerspective(preferred || '');
};
const downloadAttachment = async () => {
if (!item.value?.attachment || downloading.value) return;
@@ -130,6 +206,7 @@ const load = async () => {
message.value = '';
try {
const next = await getBroadcastMessage(id.value);
selectInitialPerspective(next.attachment);
item.value = next;
try {
await markBroadcastMessageRead(id.value);
@@ -179,6 +256,30 @@ onLoad((options) => {
.attachment-name { display: block; margin-top: 4px; overflow-wrap: anywhere; color: var(--ink); font-size: 14px; font-weight: 900; }
.attachment-size { flex: none; color: var(--muted); font-size: 12px; }
.attachment-summary { overflow-wrap: anywhere; color: #596273; font-size: 13px; line-height: 1.65; }
.perspective-panel { display: grid; gap: 12px; margin-top: 4px; padding-top: 14px; border-top: 1px solid #f0e3e5; }
.perspective-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.perspective-kicker { display: block; color: var(--brand); font-size: 11px; font-weight: 900; }
.perspective-title { display: block; margin-top: 3px; color: var(--ink); font-size: 17px; font-weight: 900; }
.insight-state { flex: none; padding: 3px 7px; border-radius: 999px; font-size: 11px; font-weight: 800; }
.insight-state.partial { background: #fff4df; color: #986000; }
.perspective-tabs { width: 100%; white-space: nowrap; }
.perspective-tab-row { display: inline-flex; gap: 8px; padding-bottom: 2px; }
.perspective-tab { display: inline-flex; min-height: 34px; align-items: center; justify-content: center; padding: 0 12px; border: 1px solid #e5e7eb; border-radius: 999px; background: #fff; color: #596273; font-size: 12px; font-weight: 800; line-height: 1; }
.perspective-tab::after, .evidence-toggle::after { border: 0; }
.perspective-tab.active { border-color: #f0a6ac; background: #fff0f1; color: var(--brand); }
.perspective-summary { color: var(--ink); font-size: 14px; font-weight: 700; line-height: 1.65; }
.perspective-section { display: grid; gap: 5px; }
.perspective-section-title { color: #30343b; font-size: 13px; font-weight: 900; }
.perspective-item { overflow-wrap: anywhere; color: #596273; font-size: 13px; line-height: 1.55; }
.evidence-toggle { display: flex; min-height: 36px; align-items: center; justify-content: center; padding: 0; border-radius: 10px; background: #f4f5f7; color: #596273; font-size: 12px; font-weight: 800; }
.evidence-list { display: grid; gap: 8px; }
.evidence-item { display: grid; gap: 4px; padding: 10px; border-left: 3px solid #d9dde4; background: #f7f8fa; }
.evidence-index { color: #7a8290; font-size: 11px; font-weight: 800; }
.evidence-quote { overflow-wrap: anywhere; color: #596273; font-size: 12px; line-height: 1.55; }
.insight-state-card { display: grid; gap: 8px; padding: 10px 12px; border-radius: 10px; background: #f4f5f7; color: #596273; font-size: 12px; line-height: 1.55; }
.insight-state-card.failed { background: #fff4f4; color: #9d2730; }
.insight-refresh { display: flex; min-height: 34px; align-items: center; justify-content: center; padding: 0; border-radius: 9px; background: #fff; color: var(--brand); font-size: 12px; font-weight: 800; }
.insight-refresh::after { border: 0; }
.download-button { display: flex; min-height: 40px; align-items: center; justify-content: center; padding: 0; border: 1px solid #f0a6ac; border-radius: 12px; background: #fff; color: var(--brand); font-size: 13px; font-weight: 900; }
.download-button::after { border: 0; }
.detail-actions { display: grid; gap: 8px; margin-top: 8px; padding-top: 16px; border-top: 1px solid var(--employee-line, #ebedf0); }
@@ -52,7 +52,7 @@
<view class="message-tags">
<text class="message-state" :class="{ read: item.read }">{{ item.read ? '已读' : '未读' }}</text>
<text v-if="item.requiredRead" class="message-tag required">必读</text>
<text v-if="item.targeted" class="message-tag related">与我有关</text>
<text v-if="item.targeted" class="message-tag related">定向提醒</text>
</view>
<text class="message-time">{{ displayTime(item.publishedAt) }}</text>
</view>
@@ -90,7 +90,7 @@ const moreAvailable = ref(false);
const scope = ref<BroadcastMessageScope>('ALL');
const scopeOptions: Array<{ value: BroadcastMessageScope; label: string }> = [
{ value: 'ALL', label: '全部' },
{ value: 'RELATED', label: '与我有关' },
{ value: 'RELATED', label: '定向提醒' },
{ value: 'REQUIRED_UNREAD', label: '必读未读' }
];
const requests = createPageRequestScope();
+34 -2
View File
@@ -4,6 +4,7 @@ export const broadcastQuestionContextStorageKey = 'aihr_broadcast_question_conte
export const broadcastQuestionContextTtlMs = 15 * 60 * 1000;
export type BroadcastMessageScope = 'ALL' | 'RELATED' | 'REQUIRED_UNREAD';
export type BroadcastInsightStatus = 'NOT_PROCESSED' | 'PENDING' | 'READY' | 'PARTIAL' | 'FAILED';
export interface BroadcastQuestionContext {
messageId: number;
@@ -26,6 +27,22 @@ export interface BroadcastMessage extends BroadcastMessageSummary {
attachment?: BroadcastAttachment;
}
export interface BroadcastEvidence {
paragraphIndex: number;
quote: string;
}
export interface BroadcastPerspective {
code: string;
label: string;
summary?: string;
concerns: string[];
impacts: string[];
actions: string[];
risks: string[];
evidence: BroadcastEvidence[];
}
export interface BroadcastAttachment {
id: number;
fileName: string;
@@ -34,6 +51,10 @@ export interface BroadcastAttachment {
status: 'READY';
summary?: string;
downloadUrl: string;
insightStatus: BroadcastInsightStatus;
defaultPerspectiveCode?: string;
perspectiveLabels: string[];
perspectives: BroadcastPerspective[];
}
export interface BroadcastMessagePage {
@@ -59,12 +80,23 @@ export const listBroadcastMessages = (pageNum = 1, pageSize = 20, scope: Broadca
timeout: 15000
});
export const getBroadcastMessage = (id: number) =>
apiRequest<BroadcastMessage>({
const normalizeBroadcastAttachment = (attachment: BroadcastAttachment): BroadcastAttachment => ({
...attachment,
insightStatus: attachment.insightStatus || 'NOT_PROCESSED',
perspectiveLabels: Array.isArray(attachment.perspectiveLabels) ? attachment.perspectiveLabels : [],
perspectives: Array.isArray(attachment.perspectives) ? attachment.perspectives : []
});
export const getBroadcastMessage = async (id: number) => {
const message = await apiRequest<BroadcastMessage>({
url: `/api/aihr/broadcast/messages/${id}`,
method: 'GET',
timeout: 15000
});
return message.attachment
? { ...message, attachment: normalizeBroadcastAttachment(message.attachment) }
: message;
};
export const markBroadcastMessageRead = (id: number) =>
apiRequest<void>({
+2 -1
View File
@@ -4,7 +4,7 @@ import test from 'node:test';
const read = (path) => readFile(new URL(path, import.meta.url), 'utf8');
test('M1 员工端可筛选与我有关和必读未读消息,并保留公开频道', async () => {
test('M1 员工端可筛选定向提醒和必读未读消息,并保留公开频道', async () => {
const [service, list, detail] = await Promise.all([
read('../src/services/broadcast.ts'),
read('../src/pages/user/broadcast/index.vue'),
@@ -19,6 +19,7 @@ test('M1 员工端可筛选与我有关和必读未读消息,并保留公开
assert.match(list, /listBroadcastMessages\(1, pageSize, scope\.value\)/);
assert.match(list, /item\.requiredRead/);
assert.match(list, /item\.targeted/);
assert.match(list, /定向提醒/);
assert.match(detail, /item\.requiredRead/);
assert.match(detail, /item\.targeted/);
});
@@ -0,0 +1,41 @@
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, list, detail] = await Promise.all([
read('../src/services/broadcast.ts'),
read('../src/pages/user/broadcast/index.vue'),
read('../src/pages/user/broadcast/detail.vue')
]);
assert.match(service, /export type BroadcastInsightStatus/);
assert.match(service, /interface BroadcastPerspective/);
assert.match(service, /defaultPerspectiveCode\?: string/);
assert.match(service, /perspectives: BroadcastPerspective\[\]/);
assert.match(service, /normalizeBroadcastAttachment/);
assert.match(service, /perspectives: Array\.isArray\(attachment\.perspectives\) \? attachment\.perspectives : \[\]/);
assert.match(service, /insightStatus: attachment\.insightStatus \|\| 'NOT_PROCESSED'/);
assert.match(detail, /多岗位解读/);
assert.match(detail, /defaultPerspectiveCode/);
assert.match(detail, /selectedPerspective/);
assert.match(detail, /selectPerspective/);
assert.match(detail, /原文依据/);
assert.match(detail, /insightStatus === 'PENDING'/);
assert.match(detail, /刷新解读/);
assert.match(detail, /insightStatus === 'PARTIAL'/);
assert.match(detail, /insightStatus === 'FAILED'/);
assert.match(detail, /scroll-x/);
assert.match(detail, /role="group"/);
assert.match(detail, /aria-pressed/);
assert.ok(detail.indexOf('attachment-summary') < detail.indexOf('perspective-panel'));
assert.ok(detail.indexOf('download-button') < detail.indexOf('perspective-panel'));
assert.ok(detail.indexOf('detail-actions') < detail.indexOf('perspective-panel'));
assert.match(list, /定向提醒/);
assert.doesNotMatch(list, />与我有关</);
assert.doesNotMatch(detail, />与我有关/);
});