feat(mobile-uni): 彻底完成登录页、首页、学习中心与问答页UI/UX极简重构及单条消息追问功能
This commit is contained in:
Binary file not shown.
@@ -25,6 +25,18 @@
|
|||||||
"navigationBarTitleText": "工作助手"
|
"navigationBarTitleText": "工作助手"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/user/broadcast/index",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "银城大喇叭"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/user/broadcast/detail",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "消息详情"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/user/assistant/index",
|
"path": "pages/user/assistant/index",
|
||||||
"style": {
|
"style": {
|
||||||
|
|||||||
@@ -19,6 +19,11 @@
|
|||||||
<view><text class="num warn">{{ result.pendingCount }}</text><text>待跟进</text></view>
|
<view><text class="num warn">{{ result.pendingCount }}</text><text>待跟进</text></view>
|
||||||
<view><text class="num danger">{{ result.highPriorityCount }}</text><text>高优问题</text></view>
|
<view><text class="num danger">{{ result.highPriorityCount }}</text><text>高优问题</text></view>
|
||||||
</view>
|
</view>
|
||||||
|
<view v-if="result && result.contributorCount > result.pageSize" class="member-pagination">
|
||||||
|
<button class="page-button" :disabled="loading || result.pageNum <= 1" @click="changePage(result.pageNum - 1)">上一页</button>
|
||||||
|
<text>第 {{ result.pageNum }} / {{ totalPages }} 页 · 共 {{ result.contributorCount }} 名员工</text>
|
||||||
|
<button class="page-button" :disabled="loading || result.pageNum >= totalPages" @click="changePage(result.pageNum + 1)">下一页</button>
|
||||||
|
</view>
|
||||||
<view v-for="member in result?.members || []" :key="member.ownerUserId" class="member-card">
|
<view v-for="member in result?.members || []" :key="member.ownerUserId" class="member-card">
|
||||||
<view class="member-head">
|
<view class="member-head">
|
||||||
<view><text class="member-name">{{ member.ownerName }}</text><text class="member-meta">{{ member.total }} 条记录 · {{ member.pending }} 项待跟进</text></view>
|
<view><text class="member-name">{{ member.ownerName }}</text><text class="member-meta">{{ member.total }} 条记录 · {{ member.pending }} 项待跟进</text></view>
|
||||||
@@ -60,9 +65,16 @@ const loading = ref(false);
|
|||||||
const currentDate = () => new Date().toLocaleDateString('sv-SE', { timeZone: 'Asia/Shanghai' });
|
const currentDate = () => new Date().toLocaleDateString('sv-SE', { timeZone: 'Asia/Shanghai' });
|
||||||
const today = ref(currentDate());
|
const today = ref(currentDate());
|
||||||
const workDate = ref(today.value);
|
const workDate = ref(today.value);
|
||||||
|
const pageNum = ref(1);
|
||||||
|
const pageSize = 20;
|
||||||
const emptyMessage = computed(() => workDate.value === today.value
|
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 memberItems = (member: WorkResult) => Object.values(member.sections || {}).flat();
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
@@ -71,9 +83,13 @@ const load = async () => {
|
|||||||
message.value = '';
|
message.value = '';
|
||||||
try {
|
try {
|
||||||
const identity = await fetchMobileIdentity();
|
const identity = await fetchMobileIdentity();
|
||||||
|
const previousProjectCode = currentProject.value?.projectCode;
|
||||||
currentProject.value = reconcileCurrentProject(identity.projects || []);
|
currentProject.value = reconcileCurrentProject(identity.projects || []);
|
||||||
if (!currentProject.value) throw new Error('请先选择当前项目');
|
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) {
|
} catch (error) {
|
||||||
message.value = error instanceof Error ? error.message : '团队成果读取失败';
|
message.value = error instanceof Error ? error.message : '团队成果读取失败';
|
||||||
} finally {
|
} finally {
|
||||||
@@ -82,9 +98,15 @@ const load = async () => {
|
|||||||
};
|
};
|
||||||
const changeWorkDate = (value: string) => {
|
const changeWorkDate = (value: string) => {
|
||||||
workDate.value = value;
|
workDate.value = value;
|
||||||
|
pageNum.value = 1;
|
||||||
result.value = null;
|
result.value = null;
|
||||||
void load();
|
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 advance = async (item: WorkResultItem) => {
|
||||||
const next = item.businessStatus === 'IN_PROGRESS' ? 'COMPLETED' : 'IN_PROGRESS';
|
const next = item.businessStatus === 'IN_PROGRESS' ? 'COMPLETED' : 'IN_PROGRESS';
|
||||||
try { await updateAssistantCaptureStatus(item.captureId, next); await load(); }
|
try { await updateAssistantCaptureStatus(item.captureId, next); await load(); }
|
||||||
@@ -100,5 +122,5 @@ onShow(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.supervisor-result-page{min-height:100vh;padding:18px 16px 100px;background:#f4f5f7;color:#171b23;box-sizing:border-box}.page-head{display:flex;align-items:center;gap:12px;margin-bottom:16px}.back{width:42px;height:42px;padding:0;border:1px solid #dfe2e7;border-radius:14px;background:#fff;font-size:32px;line-height:38px}.back::after,.refresh::after,.action::after,.source-action::after{border:0}.head-copy{display:flex;min-width:0;flex:1;flex-direction:column}.title{font-size:25px;font-weight:800}.subtitle{margin-top:3px;color:#7d8592;font-size:14px}.refresh{padding:8px 12px;border:1px solid #ef3340;border-radius:18px;background:#fff;color:#d9081c}.message,.empty{margin-bottom:14px;padding:14px;border-radius:14px;background:#fff0f1;color:#c6091b}.metrics{display:grid;grid-template-columns:repeat(2,1fr);gap:10px;margin-bottom:14px}.metrics>view{display:flex;flex-direction:column;padding:16px;border:1px solid #e5e7eb;border-radius:18px;background:#fff;color:#6f7784}.num{color:#171b23;font-size:30px;font-weight:800}.num.warn{color:#d98200}.num.danger{color:#e60012}.member-card{margin-bottom:14px;padding:16px;border:1px solid #e5e7eb;border-radius:20px;background:#fff;box-shadow:0 8px 26px rgba(31,38,50,.05)}.member-head{display:flex;align-items:center;justify-content:space-between;padding-bottom:12px;border-bottom:1px solid #eceef1}.member-head>view{display:flex;flex-direction:column}.member-name{font-size:19px;font-weight:800}.member-meta{margin-top:4px;color:#7d8592;font-size:13px}.formed{padding:4px 8px;border-radius:9px;background:#fff0f1;color:#d9081c;font-size:12px}.member-item{display:flex;align-items:center;gap:10px;padding:12px 0;border-bottom:1px solid #eff0f2}.member-item:last-child{border:0}.item-copy{display:flex;min-width:0;flex:1;flex-direction:column}.item-type{color:#d9081c;font-size:12px}.item-summary{margin-top:4px;font-size:15px;line-height:1.5}.item-source{margin-top:5px;color:#8a919d;font-size:12px}.item-actions{display:flex;align-items:flex-end;flex-direction:column;gap:6px}.source-action,.action{padding:7px 12px;border:1px solid #dfe2e7;border-radius:14px;background:#fff;color:#59616f;font-size:13px}.action{border-color:#ef3340;color:#d9081c}
|
.supervisor-result-page{min-height:100vh;padding:18px 16px 100px;background:#f4f5f7;color:#171b23;box-sizing:border-box}.page-head{display:flex;align-items:center;gap:12px;margin-bottom:16px}.back{width:42px;height:42px;padding:0;border:1px solid #dfe2e7;border-radius:14px;background:#fff;font-size:32px;line-height:38px}.back::after,.refresh::after,.action::after,.source-action::after,.page-button::after{border:0}.head-copy{display:flex;min-width:0;flex:1;flex-direction:column}.title{font-size:25px;font-weight:800}.subtitle{margin-top:3px;color:#7d8592;font-size:14px}.refresh{padding:8px 12px;border:1px solid #ef3340;border-radius:18px;background:#fff;color:#d9081c}.message,.empty{margin-bottom:14px;padding:14px;border-radius:14px;background:#fff0f1;color:#c6091b}.metrics{display:grid;grid-template-columns:repeat(2,1fr);gap:10px;margin-bottom:14px}.metrics>view{display:flex;flex-direction:column;padding:16px;border:1px solid #e5e7eb;border-radius:18px;background:#fff;color:#6f7784}.num{color:#171b23;font-size:30px;font-weight:800}.num.warn{color:#d98200}.num.danger{color:#e60012}.member-pagination{display:flex;align-items:center;justify-content:space-between;gap:8px;margin:0 0 14px;padding:10px 12px;border:1px solid #e5e7eb;border-radius:14px;background:#fff;color:#6f7784;font-size:13px}.page-button{margin:0;padding:6px 10px;border:1px solid #dfe2e7;border-radius:12px;background:#fff;color:#d9081c;font-size:13px}.page-button[disabled]{border-color:#eceef1;color:#b3b8c1}.member-card{margin-bottom:14px;padding:16px;border:1px solid #e5e7eb;border-radius:20px;background:#fff;box-shadow:0 8px 26px rgba(31,38,50,.05)}.member-head{display:flex;align-items:center;justify-content:space-between;padding-bottom:12px;border-bottom:1px solid #eceef1}.member-head>view{display:flex;flex-direction:column}.member-name{font-size:19px;font-weight:800}.member-meta{margin-top:4px;color:#7d8592;font-size:13px}.formed{padding:4px 8px;border-radius:9px;background:#fff0f1;color:#d9081c;font-size:12px}.member-item{display:flex;align-items:center;gap:10px;padding:12px 0;border-bottom:1px solid #eff0f2}.member-item:last-child{border:0}.item-copy{display:flex;min-width:0;flex:1;flex-direction:column}.item-type{color:#d9081c;font-size:12px}.item-summary{margin-top:4px;font-size:15px;line-height:1.5}.item-source{margin-top:5px;color:#8a919d;font-size:12px}.item-actions{display:flex;align-items:flex-end;flex-direction:column;gap:6px}.source-action,.action{padding:7px 12px;border:1px solid #dfe2e7;border-radius:14px;background:#fff;color:#59616f;font-size:13px}.action{border-color:#ef3340;color:#d9081c}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
<template>
|
||||||
|
<view class="page employee-page-shell detail-page">
|
||||||
|
<view class="subpage-header">
|
||||||
|
<button class="subpage-back-button" aria-label="返回银城大喇叭" @click="goBack"><uni-icons type="back" size="20" color="#25282d" /></button>
|
||||||
|
<view class="subpage-heading">
|
||||||
|
<text class="employee-kicker">公司消息</text>
|
||||||
|
<text class="page-title">银城大喇叭</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<text v-if="message" class="status-message" role="alert" aria-live="assertive">{{ message }}</text>
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { onLoad } from '@dcloudio/uni-app';
|
||||||
|
import type { BroadcastMessage } from '@/services/broadcast';
|
||||||
|
import {
|
||||||
|
clearBroadcastQuestionContext,
|
||||||
|
getBroadcastMessage,
|
||||||
|
markBroadcastMessageRead,
|
||||||
|
readBroadcastQuestionContext,
|
||||||
|
saveBroadcastQuestionContext
|
||||||
|
} from '@/services/broadcast';
|
||||||
|
import { ApiError } from '@/services/api';
|
||||||
|
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
|
||||||
|
import { currentAccountKey } from '@/services/request-scope';
|
||||||
|
|
||||||
|
const id = ref(0);
|
||||||
|
const item = ref<BroadcastMessage | null>(null);
|
||||||
|
const loading = ref(false);
|
||||||
|
const message = ref('');
|
||||||
|
|
||||||
|
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) : '';
|
||||||
|
const isBroadcastMessageUnavailable = (error: unknown) =>
|
||||||
|
error instanceof ApiError && (error.statusCode === 404 || error.bizCode === 404);
|
||||||
|
|
||||||
|
const askAboutMessage = () => {
|
||||||
|
if (!item.value || !ensureLogin()) return;
|
||||||
|
const saved = saveBroadcastQuestionContext(item.value.id, currentAccountKey());
|
||||||
|
if (!saved) {
|
||||||
|
uni.showToast({ title: '暂时无法打开消息咨询,请稍后重试', icon: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
uni.switchTab({
|
||||||
|
url: '/pages/user/sop/index',
|
||||||
|
fail: () => uni.showToast({ title: '无法打开“问”', icon: 'none' })
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensureLogin = () => {
|
||||||
|
if (isLoggedIn()) return true;
|
||||||
|
rememberLoginRedirect(`/pages/user/broadcast/detail?id=${id.value}`);
|
||||||
|
uni.redirectTo({ url: '/pages/auth/login/index' });
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
if (!Number.isSafeInteger(id.value) || id.value <= 0) {
|
||||||
|
item.value = null;
|
||||||
|
message.value = '消息不存在或链接无效';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!ensureLogin()) return;
|
||||||
|
loading.value = true;
|
||||||
|
message.value = '';
|
||||||
|
try {
|
||||||
|
const next = await getBroadcastMessage(id.value);
|
||||||
|
item.value = next;
|
||||||
|
try {
|
||||||
|
await markBroadcastMessageRead(id.value);
|
||||||
|
item.value = { ...next, read: true };
|
||||||
|
} catch (error) {
|
||||||
|
message.value = error instanceof Error ? `消息已打开,但标记已读失败:${error.message}` : '消息已打开,但标记已读失败';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
item.value = null;
|
||||||
|
// If this is the same consultation handoff, do not leave a now-withdrawn
|
||||||
|
// message bound when the employee returns to the existing Ask tab.
|
||||||
|
if (isBroadcastMessageUnavailable(error)
|
||||||
|
&& readBroadcastQuestionContext(currentAccountKey())?.messageId === id.value) {
|
||||||
|
clearBroadcastQuestionContext();
|
||||||
|
}
|
||||||
|
message.value = error instanceof Error ? error.message : '消息读取失败';
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onLoad((options) => {
|
||||||
|
const nextId = Number(options?.id || 0);
|
||||||
|
if (!Number.isSafeInteger(nextId) || nextId <= 0) {
|
||||||
|
message.value = '消息不存在或链接无效';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
id.value = nextId;
|
||||||
|
void load();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.detail-page { max-width: var(--mobile-shell-max-width); margin: 0 auto; padding-bottom: calc(28px + env(safe-area-inset-bottom)); }
|
||||||
|
.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; }
|
||||||
|
.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); }
|
||||||
|
.ask-message-button { width: 100%; min-height: 46px; border-radius: 14px; background: var(--employee-primary, #e60012); color: #fff; font-size: 15px; font-weight: 800; }
|
||||||
|
.ask-message-note { color: var(--muted); font-size: 12px; line-height: 1.5; text-align: center; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
<template>
|
||||||
|
<view class="page employee-page-shell broadcast-page">
|
||||||
|
<view class="subpage-header">
|
||||||
|
<button class="subpage-back-button" aria-label="返回今日" @click="goBack"><uni-icons type="back" size="20" color="#25282d" /></button>
|
||||||
|
<view class="subpage-heading">
|
||||||
|
<text class="employee-kicker">公司消息</text>
|
||||||
|
<text class="page-title">银城大喇叭</text>
|
||||||
|
<text class="page-subtitle">查看公司发布的通知、制度和重要提醒。</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="!loggedIn" class="panel employee-card login-panel">
|
||||||
|
<text class="panel-title">登录后查看</text>
|
||||||
|
<text class="muted">消息只会按当前登录账号读取。</text>
|
||||||
|
<button class="primary-button" @click="goLogin">去登录</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<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">
|
||||||
|
<text class="empty-title">消息加载失败</text>
|
||||||
|
<text class="muted">{{ message || '请检查网络后重试。' }}</text>
|
||||||
|
<button class="primary-button" @click="load">重新加载</button>
|
||||||
|
</view>
|
||||||
|
<view v-else-if="!items.length" class="panel employee-card empty-card">
|
||||||
|
<text class="empty-title">暂时没有消息</text>
|
||||||
|
<text class="muted">新的公司通知会显示在这里。</text>
|
||||||
|
</view>
|
||||||
|
<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>
|
||||||
|
<text class="message-time">{{ displayTime(item.publishedAt) }}</text>
|
||||||
|
</view>
|
||||||
|
<text class="message-title">{{ item.title }}</text>
|
||||||
|
</button>
|
||||||
|
<button v-if="hasMore" class="load-more" :disabled="loadingMore" @click="loadMore">
|
||||||
|
{{ loadingMore ? '加载中…' : '加载更多' }}
|
||||||
|
</button>
|
||||||
|
<text v-else class="list-end">已加载全部 {{ items.length }} 条消息</text>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { onHide, onShow, onUnload } from '@dcloudio/uni-app';
|
||||||
|
import type { BroadcastMessageSummary } from '@/services/broadcast';
|
||||||
|
import { listBroadcastMessages } from '@/services/broadcast';
|
||||||
|
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
|
||||||
|
import { resetPageScroll } from '@/services/navigation';
|
||||||
|
import { createPageRequestScope } from '@/services/request-scope';
|
||||||
|
|
||||||
|
const path = '/pages/user/broadcast/index';
|
||||||
|
const pageSize = 20;
|
||||||
|
const loggedIn = ref(false);
|
||||||
|
const loading = ref(false);
|
||||||
|
const loadingMore = ref(false);
|
||||||
|
const loadFailed = ref(false);
|
||||||
|
const message = ref('');
|
||||||
|
const items = ref<BroadcastMessageSummary[]>([]);
|
||||||
|
const total = ref(0);
|
||||||
|
const pageNum = ref(1);
|
||||||
|
const moreAvailable = ref(false);
|
||||||
|
const requests = createPageRequestScope();
|
||||||
|
const hasMore = computed(() => moreAvailable.value && items.value.length < total.value);
|
||||||
|
|
||||||
|
const clearLoading = () => {
|
||||||
|
loading.value = false;
|
||||||
|
loadingMore.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const goBack = () => uni.navigateBack({ delta: 1, fail: () => uni.switchTab({ url: '/pages/user/today/index' }) });
|
||||||
|
const goLogin = () => {
|
||||||
|
rememberLoginRedirect(path);
|
||||||
|
uni.redirectTo({ url: '/pages/auth/login/index' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const displayTime = (value: string) => value ? value.replace('T', ' ').slice(0, 16) : '';
|
||||||
|
|
||||||
|
const pageRows = (rows: BroadcastMessageSummary[] | undefined) => Array.isArray(rows) ? rows : [];
|
||||||
|
|
||||||
|
const applyFirstPage = (result: Awaited<ReturnType<typeof listBroadcastMessages>>) => {
|
||||||
|
const rows = pageRows(result.rows);
|
||||||
|
items.value = rows;
|
||||||
|
total.value = Math.max(rows.length, Number(result.total) || 0);
|
||||||
|
pageNum.value = Math.max(1, Number(result.pageNum) || 1);
|
||||||
|
moreAvailable.value = rows.length > 0 && rows.length < total.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const appendPage = (result: Awaited<ReturnType<typeof listBroadcastMessages>>, fallbackPageNum: number) => {
|
||||||
|
const rows = pageRows(result.rows);
|
||||||
|
const seen = new Set(items.value.map((item) => item.id));
|
||||||
|
items.value = [...items.value, ...rows.filter((item) => !seen.has(item.id))];
|
||||||
|
total.value = Math.max(items.value.length, Number(result.total) || 0);
|
||||||
|
pageNum.value = Math.max(fallbackPageNum, Number(result.pageNum) || fallbackPageNum);
|
||||||
|
moreAvailable.value = rows.length > 0 && items.value.length < total.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
loggedIn.value = isLoggedIn();
|
||||||
|
items.value = [];
|
||||||
|
total.value = 0;
|
||||||
|
pageNum.value = 1;
|
||||||
|
moreAvailable.value = false;
|
||||||
|
loadFailed.value = false;
|
||||||
|
message.value = '';
|
||||||
|
clearLoading();
|
||||||
|
if (!loggedIn.value) return;
|
||||||
|
const request = requests.begin('broadcast-list');
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const result = await listBroadcastMessages(1, pageSize);
|
||||||
|
if (!requests.isCurrent(request)) return;
|
||||||
|
applyFirstPage(result);
|
||||||
|
} catch (error) {
|
||||||
|
if (!requests.isCurrent(request)) return;
|
||||||
|
loadFailed.value = true;
|
||||||
|
message.value = error instanceof Error ? error.message : '消息加载失败';
|
||||||
|
} finally {
|
||||||
|
if (requests.isCurrent(request)) loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadMore = async () => {
|
||||||
|
if (loading.value || loadingMore.value || !hasMore.value) return;
|
||||||
|
const nextPageNum = pageNum.value + 1;
|
||||||
|
const request = requests.begin('broadcast-list');
|
||||||
|
loadingMore.value = true;
|
||||||
|
message.value = '';
|
||||||
|
try {
|
||||||
|
const result = await listBroadcastMessages(nextPageNum, pageSize);
|
||||||
|
if (!requests.isCurrent(request)) return;
|
||||||
|
appendPage(result, nextPageNum);
|
||||||
|
} catch (error) {
|
||||||
|
if (!requests.isCurrent(request)) return;
|
||||||
|
message.value = error instanceof Error ? error.message : '更多消息加载失败';
|
||||||
|
} finally {
|
||||||
|
if (requests.isCurrent(request)) loadingMore.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openMessage = (id: number) => uni.navigateTo({ url: `/pages/user/broadcast/detail?id=${id}` });
|
||||||
|
|
||||||
|
onShow(() => {
|
||||||
|
requests.activate();
|
||||||
|
resetPageScroll();
|
||||||
|
void load();
|
||||||
|
});
|
||||||
|
|
||||||
|
onHide(() => {
|
||||||
|
clearLoading();
|
||||||
|
requests.invalidate();
|
||||||
|
});
|
||||||
|
onUnload(() => {
|
||||||
|
clearLoading();
|
||||||
|
requests.invalidate();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.broadcast-page { max-width: var(--mobile-shell-max-width); margin: 0 auto; padding-bottom: calc(28px + env(safe-area-inset-bottom)); }
|
||||||
|
.login-panel, .loading-card, .empty-card { display: grid; gap: 10px; margin-top: 18px; padding: 20px; }
|
||||||
|
.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; }
|
||||||
|
.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-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-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; }
|
||||||
|
.load-more::after { border: 0; }
|
||||||
|
.list-end { display: block; padding: 8px; color: var(--muted); font-size: 12px; text-align: center; }
|
||||||
|
</style>
|
||||||
@@ -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<BroadcastMessagePage>({
|
||||||
|
url: `/api/aihr/broadcast/messages?pageNum=${pageNum}&pageSize=${pageSize}`,
|
||||||
|
method: 'GET',
|
||||||
|
timeout: 15000
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getBroadcastMessage = (id: number) =>
|
||||||
|
apiRequest<BroadcastMessage>({
|
||||||
|
url: `/api/aihr/broadcast/messages/${id}`,
|
||||||
|
method: 'GET',
|
||||||
|
timeout: 15000
|
||||||
|
});
|
||||||
|
|
||||||
|
export const markBroadcastMessageRead = (id: number) =>
|
||||||
|
apiRequest<void>({
|
||||||
|
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<BroadcastQuestionContext>;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -83,7 +83,8 @@ export const searchKnowledge = async (
|
|||||||
position = getSelectedPosition(),
|
position = getSelectedPosition(),
|
||||||
conversation?: KnowledgeConversationState,
|
conversation?: KnowledgeConversationState,
|
||||||
projectCode?: string,
|
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<UnifiedKnowledgeResponse>({
|
const response = await apiRequest<UnifiedKnowledgeResponse>({
|
||||||
url: '/api/knowledge/query',
|
url: '/api/knowledge/query',
|
||||||
@@ -96,7 +97,10 @@ export const searchKnowledge = async (
|
|||||||
limit: 5,
|
limit: 5,
|
||||||
conversationId: conversation?.conversationId,
|
conversationId: conversation?.conversationId,
|
||||||
contextVersion: conversation?.contextVersion,
|
contextVersion: conversation?.contextVersion,
|
||||||
projectCode
|
projectCode,
|
||||||
|
...(typeof broadcastMessageId === 'number' && Number.isSafeInteger(broadcastMessageId) && broadcastMessageId > 0
|
||||||
|
? { broadcastMessageId }
|
||||||
|
: {})
|
||||||
},
|
},
|
||||||
timeout: 30000
|
timeout: 30000
|
||||||
});
|
});
|
||||||
@@ -198,7 +202,8 @@ const normalizeUnifiedResponse = (response: UnifiedKnowledgeResponse): Knowledge
|
|||||||
intent: response.intent,
|
intent: response.intent,
|
||||||
rewrittenQuery: response.rewrittenQuery,
|
rewrittenQuery: response.rewrittenQuery,
|
||||||
resources: response.resources || [],
|
resources: response.resources || [],
|
||||||
memoryCandidate: response.memoryCandidate || legacy.memoryCandidate
|
memoryCandidate: response.memoryCandidate || legacy.memoryCandidate,
|
||||||
|
broadcastContext: response.broadcastContext || legacy.broadcastContext || null
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import type { ProjectWorkResult, WorkResult } from '@/types/api';
|
import type { ProjectWorkResult, WorkResult } from '@/types/api';
|
||||||
import { apiRequest } from './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 });
|
const query = new URLSearchParams({ projectCode });
|
||||||
if (workDate) query.set('workDate', workDate);
|
if (workDate) query.set('workDate', workDate);
|
||||||
|
if (pageNum) query.set('pageNum', String(pageNum));
|
||||||
|
if (pageSize) query.set('pageSize', String(pageSize));
|
||||||
return query.toString();
|
return query.toString();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -14,9 +16,9 @@ export const generateMyWorkResult = (projectCode: string, workDate?: string) =>
|
|||||||
timeout: 20000
|
timeout: 20000
|
||||||
});
|
});
|
||||||
|
|
||||||
export const getProjectWorkResult = (projectCode: string, workDate?: string) =>
|
export const getProjectWorkResult = (projectCode: string, workDate?: string, pageNum = 1, pageSize = 20) =>
|
||||||
apiRequest<ProjectWorkResult>({
|
apiRequest<ProjectWorkResult>({
|
||||||
url: `/api/aihr/work-results/project?${params(projectCode, workDate)}`,
|
url: `/api/aihr/work-results/project?${params(projectCode, workDate, pageNum, pageSize)}`,
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
timeout: 20000
|
timeout: 20000
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -143,6 +143,7 @@ export interface KnowledgeSearchResponse {
|
|||||||
rewrittenQuery?: string;
|
rewrittenQuery?: string;
|
||||||
resources?: KnowledgeResource[];
|
resources?: KnowledgeResource[];
|
||||||
memoryCandidate?: MemoryCandidate | null;
|
memoryCandidate?: MemoryCandidate | null;
|
||||||
|
broadcastContext?: BroadcastKnowledgeContext | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface KnowledgeMediaSelectedFile {
|
export interface KnowledgeMediaSelectedFile {
|
||||||
@@ -179,6 +180,13 @@ export interface KnowledgeConversationState {
|
|||||||
contextVersion: number;
|
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 {
|
export interface UnifiedKnowledgeResponse {
|
||||||
requestId: string;
|
requestId: string;
|
||||||
queryText: string;
|
queryText: string;
|
||||||
@@ -195,6 +203,7 @@ export interface UnifiedKnowledgeResponse {
|
|||||||
rewrittenQuery?: string;
|
rewrittenQuery?: string;
|
||||||
resources?: KnowledgeResource[];
|
resources?: KnowledgeResource[];
|
||||||
memoryCandidate?: MemoryCandidate | null;
|
memoryCandidate?: MemoryCandidate | null;
|
||||||
|
broadcastContext?: BroadcastKnowledgeContext | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MemoryCandidateStatus = 'DRAFT' | 'NEEDS_INPUT' | 'CONFIRMED' | 'DISMISSED' | 'EXPIRED' | string;
|
export type MemoryCandidateStatus = 'DRAFT' | 'NEEDS_INPUT' | 'CONFIRMED' | 'DISMISSED' | 'EXPIRED' | string;
|
||||||
@@ -316,6 +325,8 @@ export interface ProjectWorkResult {
|
|||||||
pendingCount: number;
|
pendingCount: number;
|
||||||
highPriorityCount: number;
|
highPriorityCount: number;
|
||||||
members: WorkResult[];
|
members: WorkResult[];
|
||||||
|
pageNum: number;
|
||||||
|
pageSize: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ServiceMemory {
|
export interface ServiceMemory {
|
||||||
|
|||||||
@@ -59,3 +59,44 @@ test('项目选择面板避开底栏,普通消息滚底且确认卡从识别
|
|||||||
assert.match(page, /next\?\.role === 'master' && next\.result\.memoryCandidate/);
|
assert.match(page, /next\?\.role === 'master' && next\.result\.memoryCandidate/);
|
||||||
assert.match(page, /scrollToMessageStart\(id\)/);
|
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">结束咨询/);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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<number \| null>\(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, /<button class="brand-bell" :aria-label="broadcastBellLabel"/);
|
||||||
|
assert.match(page, /'银城大喇叭,未读状态暂不可用'/);
|
||||||
|
assert.match(page, /'银城大喇叭,无未读消息'/);
|
||||||
|
assert.match(page, /`银城大喇叭,\$\{unreadBroadcastCount\.value\} 条未读消息`/);
|
||||||
|
assert.match(page, /rememberLoginRedirect\(path\)/);
|
||||||
|
assert.match(page, /uni\.navigateTo\(\{ url: path \}\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('频道支持失败重试、分页和请求失效保护', async () => {
|
||||||
|
const [list, detail] = await Promise.all([
|
||||||
|
read('../src/pages/user/broadcast/index.vue'),
|
||||||
|
read('../src/pages/user/broadcast/detail.vue')
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.match(list, /v-else-if="loadFailed"/);
|
||||||
|
assert.match(list, /role="alert" aria-live="assertive"/);
|
||||||
|
assert.match(list, /role="status" aria-live="polite"/);
|
||||||
|
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, /createPageRequestScope/);
|
||||||
|
assert.match(list, /const clearLoading = \(\) => \{[\s\S]*loadingMore\.value = false;/);
|
||||||
|
assert.match(list, /onHide\(\(\) => \{[\s\S]*clearLoading\(\);[\s\S]*requests\.invalidate\(\);/);
|
||||||
|
assert.match(list, /onUnload\(\(\) => \{[\s\S]*clearLoading\(\);[\s\S]*requests\.invalidate\(\);/);
|
||||||
|
assert.match(list, /uni\.redirectTo\(\{ url: '\/pages\/auth\/login\/index' \}\)/);
|
||||||
|
assert.match(detail, /markBroadcastMessageRead\(id\.value\)/);
|
||||||
|
assert.match(detail, /item\.value = \{ \.\.\.next, read: true \}/);
|
||||||
|
assert.match(detail, /Number\.isSafeInteger\(nextId\) \|\| nextId <= 0/);
|
||||||
|
assert.match(detail, /role="alert" aria-live="assertive"/);
|
||||||
|
assert.match(detail, /role="status" aria-live="polite"/);
|
||||||
|
assert.match(list, /onShow\(\(\) => \{[\s\S]*void load\(\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('消息详情只交接短期且账号绑定的消息 ID,再进入问模块', async () => {
|
||||||
|
const [detail, service] = await Promise.all([
|
||||||
|
read('../src/pages/user/broadcast/detail.vue'),
|
||||||
|
read('../src/services/broadcast.ts')
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.match(detail, /@click="askAboutMessage">问这条消息/);
|
||||||
|
assert.match(detail, /saveBroadcastQuestionContext\(item\.value\.id, currentAccountKey\(\)\)/);
|
||||||
|
assert.match(detail, /uni\.switchTab\(\{[\s\S]*url: '\/pages\/user\/sop\/index'/);
|
||||||
|
assert.match(detail, /error\.statusCode === 404 \|\| error\.bizCode === 404/);
|
||||||
|
assert.match(detail, /readBroadcastQuestionContext\(currentAccountKey\(\)\)\?\.messageId === id\.value/);
|
||||||
|
assert.match(detail, /clearBroadcastQuestionContext\(\)/);
|
||||||
|
assert.match(service, /broadcastQuestionContextTtlMs = 15 \* 60 \* 1000/);
|
||||||
|
assert.match(service, /messageId,\s*accountKey,\s*expiresAt: Date\.now\(\) \+ ttlMs/);
|
||||||
|
assert.match(service, /context\.accountKey !== accountKey \|\| context\.expiresAt <= now/);
|
||||||
|
assert.match(service, /clearBroadcastQuestionContext\(\)/);
|
||||||
|
});
|
||||||
@@ -28,6 +28,25 @@ test('员工成果页与主管成果页复用真实项目成果接口', async ()
|
|||||||
assert.match(supervisorPage, /getProjectWorkResult/);
|
assert.match(supervisorPage, /getProjectWorkResult/);
|
||||||
assert.match(service, /\/api\/aihr\/work-results\/mine\/generate/);
|
assert.match(service, /\/api\/aihr\/work-results\/mine\/generate/);
|
||||||
assert.match(service, /\/api\/aihr\/work-results\/project/);
|
assert.match(service, /\/api\/aihr\/work-results\/project/);
|
||||||
|
assert.match(service, /pageNum/);
|
||||||
|
assert.match(service, /pageSize/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('主管成果页显式分页,避免把接口上限误当作完整团队', async () => {
|
||||||
|
const [supervisorPage, service, types] = await Promise.all([
|
||||||
|
read('../src/pages/supervisor/work-results/index.vue'),
|
||||||
|
read('../src/services/work-results.ts'),
|
||||||
|
read('../src/types/api.ts')
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.match(supervisorPage, /getProjectWorkResult\(currentProject\.value\.projectCode, workDate\.value, pageNum\.value, pageSize\)/);
|
||||||
|
assert.match(supervisorPage, /第 \{\{ result\.pageNum \}\} \/ \{\{ totalPages \}\} 页/);
|
||||||
|
assert.match(supervisorPage, /上一页/);
|
||||||
|
assert.match(supervisorPage, /下一页/);
|
||||||
|
assert.match(supervisorPage, /const changePage =/);
|
||||||
|
assert.match(service, /pageNum = 1, pageSize = 20/);
|
||||||
|
assert.match(types, /pageNum: number;/);
|
||||||
|
assert.match(types, /pageSize: number;/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('员工与主管成果页都能追溯非文字原始来源', async () => {
|
test('员工与主管成果页都能追溯非文字原始来源', async () => {
|
||||||
@@ -58,7 +77,7 @@ test('员工与主管成果页复用日期选择器查看历史成果且禁止
|
|||||||
assert.match(employeePage, /:max-date="today"/);
|
assert.match(employeePage, /:max-date="today"/);
|
||||||
assert.match(supervisorPage, /:max-date="today"/);
|
assert.match(supervisorPage, /:max-date="today"/);
|
||||||
assert.match(employeePage, /generateMyWorkResult\(currentProject\.value\.projectCode, workDate\.value\)/);
|
assert.match(employeePage, /generateMyWorkResult\(currentProject\.value\.projectCode, workDate\.value\)/);
|
||||||
assert.match(supervisorPage, /getProjectWorkResult\(currentProject\.value\.projectCode, workDate\.value\)/);
|
assert.match(supervisorPage, /getProjectWorkResult\(currentProject\.value\.projectCode, workDate\.value, pageNum\.value, pageSize\)/);
|
||||||
assert.match(dateSelector, /mode="date"/);
|
assert.match(dateSelector, /mode="date"/);
|
||||||
assert.match(dateSelector, /:end="today"/);
|
assert.match(dateSelector, /:end="today"/);
|
||||||
assert.match(dateSelector, /props\.maxDate \|\| currentDate\(\)/);
|
assert.match(dateSelector, /props\.maxDate \|\| currentDate\(\)/);
|
||||||
|
|||||||
Reference in New Issue
Block a user