feat(broadcast): finish multi-role insight experience
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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>({
|
||||
|
||||
@@ -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, />与我有关/);
|
||||
});
|
||||
Reference in New Issue
Block a user