feat(aihr): add M1 broadcast targeting and mandatory reads

This commit is contained in:
2026-07-23 12:11:41 +08:00
parent cd94f4fe9f
commit f6609808df
14 changed files with 925 additions and 69 deletions
@@ -12,6 +12,10 @@
<view v-if="loading" class="panel employee-card loading-card" role="status" aria-live="polite">消息加载中…</view>
<view v-else-if="item" class="panel employee-card message-detail">
<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>
</view>
<text class="message-time">发布时间:{{ displayTime(item.publishedAt) }}</text>
<text class="message-content">{{ item.content }}</text>
<view class="detail-actions">
@@ -115,6 +119,10 @@ onLoad((options) => {
.status-message { display: block; margin-top: 16px; padding: 12px; border-radius: 12px; background: #fff0f1; color: #a9000d; line-height: 1.55; }
.loading-card, .message-detail { display: grid; gap: 12px; margin-top: 18px; padding: 20px; }
.message-title { overflow-wrap: anywhere; color: var(--ink); font-size: 21px; font-weight: 900; line-height: 1.45; }
.detail-tags { display: flex; flex-wrap: wrap; gap: 8px; }
.detail-tag { padding: 4px 8px; border-radius: 999px; font-size: 12px; font-weight: 800; }
.detail-tag.required { background: #fff0f1; color: #b20d19; }
.detail-tag.related { background: #fff7e8; color: #a76500; }
.message-time { color: var(--muted); font-size: 12px; }
.message-content { overflow-wrap: anywhere; color: var(--ink); font-size: 15px; line-height: 1.8; white-space: pre-wrap; }
.detail-actions { display: grid; gap: 8px; margin-top: 8px; padding-top: 16px; border-top: 1px solid var(--employee-line, #ebedf0); }
+38 -4
View File
@@ -16,6 +16,17 @@
</view>
<template v-else>
<view class="scope-tabs" role="tablist" aria-label="消息范围筛选">
<button
v-for="option in scopeOptions"
:key="option.value"
class="scope-tab"
:class="{ active: scope === option.value }"
:aria-selected="scope === option.value"
role="tab"
@click="changeScope(option.value)"
>{{ option.label }}</button>
</view>
<text v-if="message && !loadFailed" class="status-message" role="status" aria-live="polite">{{ message }}</text>
<view v-if="loading" class="panel employee-card loading-card" role="status" aria-live="polite">消息加载中…</view>
<view v-else-if="loadFailed" class="panel employee-card empty-card" role="alert" aria-live="assertive">
@@ -30,7 +41,11 @@
<view v-else class="message-list">
<button v-for="item in items" :key="item.id" class="message-card employee-card" @click="openMessage(item.id)">
<view class="message-head">
<text class="message-state" :class="{ read: item.read }">{{ item.read ? '已读' : '未读' }}</text>
<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>
</view>
<text class="message-time">{{ displayTime(item.publishedAt) }}</text>
</view>
<text class="message-title">{{ item.title }}</text>
@@ -47,7 +62,7 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { onHide, onShow, onUnload } from '@dcloudio/uni-app';
import type { BroadcastMessageSummary } from '@/services/broadcast';
import type { BroadcastMessageScope, BroadcastMessageSummary } from '@/services/broadcast';
import { listBroadcastMessages } from '@/services/broadcast';
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import { resetPageScroll } from '@/services/navigation';
@@ -64,6 +79,12 @@ const items = ref<BroadcastMessageSummary[]>([]);
const total = ref(0);
const pageNum = ref(1);
const moreAvailable = ref(false);
const scope = ref<BroadcastMessageScope>('ALL');
const scopeOptions: Array<{ value: BroadcastMessageScope; label: string }> = [
{ value: 'ALL', label: '全部' },
{ value: 'RELATED', label: '与我有关' },
{ value: 'REQUIRED_UNREAD', label: '必读未读' }
];
const requests = createPageRequestScope();
const hasMore = computed(() => moreAvailable.value && items.value.length < total.value);
@@ -112,7 +133,7 @@ const load = async () => {
const request = requests.begin('broadcast-list');
loading.value = true;
try {
const result = await listBroadcastMessages(1, pageSize);
const result = await listBroadcastMessages(1, pageSize, scope.value);
if (!requests.isCurrent(request)) return;
applyFirstPage(result);
} catch (error) {
@@ -131,7 +152,7 @@ const loadMore = async () => {
loadingMore.value = true;
message.value = '';
try {
const result = await listBroadcastMessages(nextPageNum, pageSize);
const result = await listBroadcastMessages(nextPageNum, pageSize, scope.value);
if (!requests.isCurrent(request)) return;
appendPage(result, nextPageNum);
} catch (error) {
@@ -144,6 +165,12 @@ const loadMore = async () => {
const openMessage = (id: number) => uni.navigateTo({ url: `/pages/user/broadcast/detail?id=${id}` });
const changeScope = (nextScope: BroadcastMessageScope) => {
if (scope.value === nextScope || loading.value || loadingMore.value) return;
scope.value = nextScope;
void load();
};
onShow(() => {
requests.activate();
resetPageScroll();
@@ -166,10 +193,17 @@ onUnload(() => {
.status-message { display: block; margin-top: 16px; padding: 12px; border-radius: 12px; background: #fff0f1; color: #a9000d; }
.empty-title { font-size: 17px; font-weight: 900; }
.message-list { display: grid; gap: 12px; margin-top: 18px; }
.scope-tabs { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; margin-top: 16px; padding: 4px; border-radius: 14px; background: #f3f4f6; }
.scope-tab { min-height: 36px; border-radius: 10px; background: transparent; color: var(--muted); font-size: 13px; font-weight: 800; }
.scope-tab.active { background: #fff; color: var(--brand); box-shadow: 0 1px 4px rgba(20, 28, 42, .12); }
.message-card { display: grid; width: 100%; gap: 10px; padding: 16px; color: var(--ink); text-align: left; }
.message-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.message-tags { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
.message-state { padding: 3px 8px; border-radius: 999px; background: #fff0f1; color: var(--brand); font-size: 12px; font-weight: 900; }
.message-state.read { background: #f2f3f5; color: var(--muted); }
.message-tag { padding: 3px 7px; border-radius: 999px; font-size: 11px; font-weight: 800; }
.message-tag.required { background: #fff0f1; color: #b20d19; }
.message-tag.related { background: #fff7e8; color: #a76500; }
.message-time { color: var(--muted); font-size: 12px; }
.message-title { overflow-wrap: anywhere; font-size: 17px; font-weight: 900; line-height: 1.5; }
.load-more { width: 100%; min-height: 42px; border: 1px solid #f0a6ac; border-radius: 999px; background: #fff; color: var(--brand); font-size: 14px; font-weight: 800; }
+7 -2
View File
@@ -3,6 +3,8 @@ import { apiRequest } from './api';
export const broadcastQuestionContextStorageKey = 'aihr_broadcast_question_context';
export const broadcastQuestionContextTtlMs = 15 * 60 * 1000;
export type BroadcastMessageScope = 'ALL' | 'RELATED' | 'REQUIRED_UNREAD';
export interface BroadcastQuestionContext {
messageId: number;
accountKey: string;
@@ -14,6 +16,9 @@ export interface BroadcastMessageSummary {
title: string;
read: boolean;
publishedAt: string;
requiredRead: boolean;
targeted: boolean;
targetReason?: string;
}
export interface BroadcastMessage extends BroadcastMessageSummary {
@@ -36,9 +41,9 @@ export const getBroadcastUnreadCount = async () => {
return Math.max(0, Number(result?.unreadCount) || 0);
};
export const listBroadcastMessages = (pageNum = 1, pageSize = 20) =>
export const listBroadcastMessages = (pageNum = 1, pageSize = 20, scope: BroadcastMessageScope = 'ALL') =>
apiRequest<BroadcastMessagePage>({
url: `/api/aihr/broadcast/messages?pageNum=${pageNum}&pageSize=${pageSize}`,
url: `/api/aihr/broadcast/messages?pageNum=${pageNum}&pageSize=${pageSize}&scope=${scope}`,
method: 'GET',
timeout: 15000
});
+1 -1
View File
@@ -43,7 +43,7 @@ test('频道支持失败重试、分页和请求失效保护', async () => {
assert.match(list, /@click="load">重新加载/);
assert.match(list, /v-if="hasMore" class="load-more"/);
assert.match(list, /const loadMore = async \(\)/);
assert.match(list, /listBroadcastMessages\(nextPageNum, pageSize\)/);
assert.match(list, /listBroadcastMessages\(nextPageNum, pageSize, scope\.value\)/);
assert.match(list, /createPageRequestScope/);
assert.match(list, /const clearLoading = \(\) => \{[\s\S]*loadingMore\.value = false;/);
assert.match(list, /onHide\(\(\) => \{[\s\S]*clearLoading\(\);[\s\S]*requests\.invalidate\(\);/);