feat(schedule): 待办提醒推送、行事历仅展示提醒与管理 API

单次提醒 worker 投递、local 时间写入校验、未来 7 天提醒列表与忽略/批量删除;行事历去掉事项重复展示。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-06-30 02:02:58 +08:00
parent 06eb129a28
commit 2c91689692
20 changed files with 2453 additions and 452 deletions
+237 -61
View File
@@ -8,11 +8,13 @@ import {
createMindSpacePageFromAsset,
deleteMindSpaceAsset,
deleteMindSpacePage,
deleteMindSpaceScheduleReminders,
bindMindSpacePageLiveEdit,
getMindSpaceAgentJob,
getMindSpace,
getMindSpacePage,
getMindSpacePageDeletePreview,
ignoreMindSpaceScheduleReminder,
listMindSpaceAgentJobs,
listMindSpaceAssets,
listMindSpaceCleanupItems,
@@ -34,6 +36,7 @@ import type {
MindSpacePage,
MindSpacePageDeletePreview,
MindSpaceQuota,
MindSpaceScheduleReminder,
MindSpaceSaveCategory,
PortalUser,
} from '../types';
@@ -119,33 +122,40 @@ const JOB_STATUS_LABELS: Record<MindSpaceAgentJob['status'], string> = {
timed_out: '超时',
};
const PREVIEW_SCHEDULE_ITEMS = [
const PREVIEW_SCHEDULE_REMINDERS = [
{
id: 'schedule-preview-1',
time: '09:30',
kind: '待办',
title: '跟进客户报价单',
meta: 'OA 工作区资料',
tone: 'task',
},
{
id: 'schedule-preview-2',
time: '14:00',
kind: '日程',
title: '产品评审会',
meta: '提前 30 分钟提醒',
tone: 'event',
},
{
id: 'schedule-preview-3',
time: '明天',
kind: '提醒',
id: 'schedule-preview-rem-1',
time: '明天 09:00',
title: '带项目材料去会议',
meta: '提前 30 分钟 · 服务号推送',
notifyLabel: '待通知',
canIgnore: true,
},
{
id: 'schedule-preview-rem-2',
time: '今天 14:00',
title: '产品评审会',
meta: '服务号推送',
tone: 'reminder',
notifyLabel: '已通知',
canIgnore: false,
},
] as const;
function scheduleReminderNotifyLabel(status: MindSpaceScheduleReminder['status']) {
return status === 'sent' ? '已通知' : '待通知';
}
function formatReminderEventWindow(reminder: MindSpaceScheduleReminder) {
const tz = reminder.itemTimezone || 'Asia/Shanghai';
const start = reminder.itemStartAt ?? null;
const end = reminder.itemEndAt ?? null;
if (!start) return null;
const startLabel = formatScheduleWhen(start, tz);
if (!end || end === start) return `事项 ${startLabel}`;
const endClock = formatScheduleClock(end, tz);
return `事项 ${startLabel} ~ ${endClock}`;
}
function formatScheduleClock(value: number | null | undefined, timezone?: string | null) {
if (typeof value !== 'number' || Number.isNaN(value)) return '—';
try {
@@ -175,6 +185,48 @@ function formatDigestSchedule(digest: {
return nextRun === '—' ? time : `${time} · 下次 ${nextRun}`;
}
function scheduleLocalDateParts(epochMs: number, timezone?: string | null) {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: timezone || 'Asia/Shanghai',
year: 'numeric',
month: 'numeric',
day: 'numeric',
}).formatToParts(new Date(epochMs));
const pick = (type: string) => Number(parts.find((part) => part.type === type)?.value ?? 0);
return { year: pick('year'), month: pick('month'), day: pick('day') };
}
function isSameScheduleDay(aMs: number, bMs: number, timezone?: string | null) {
const a = scheduleLocalDateParts(aMs, timezone);
const b = scheduleLocalDateParts(bMs, timezone);
return a.year === b.year && a.month === b.month && a.day === b.day;
}
function formatScheduleDayLabel(epochMs: number, timezone?: string | null) {
const tz = timezone || 'Asia/Shanghai';
const now = Date.now();
if (isSameScheduleDay(epochMs, now, tz)) return '今天';
if (isSameScheduleDay(epochMs, now + 24 * 60 * 60 * 1000, tz)) return '明天';
try {
return new Intl.DateTimeFormat('zh-CN', {
timeZone: tz,
month: 'numeric',
day: 'numeric',
weekday: 'short',
}).format(new Date(epochMs));
} catch {
return formatScheduleDayLabel(epochMs, 'Asia/Shanghai');
}
}
function formatScheduleWhen(epochMs: number | null | undefined, timezone?: string | null, allDay = false) {
if (typeof epochMs !== 'number' || Number.isNaN(epochMs)) return '—';
const day = formatScheduleDayLabel(epochMs, timezone);
if (allDay) return day;
const clock = formatScheduleClock(epochMs, timezone);
return clock === '—' ? day : `${day} ${clock}`;
}
const PREVIEW_PAGE_READERS: Record<string, { kicker: string; body: string[] }> = {
'page-1': {
kicker: '市场趋势',
@@ -534,6 +586,8 @@ export function MindSpaceView({
const [allPagesLoading, setAllPagesLoading] = useState(false);
const [selectedAllPageIds, setSelectedAllPageIds] = useState<string[]>([]);
const [bulkDeletingAllPages, setBulkDeletingAllPages] = useState(false);
const [selectedScheduleReminderIds, setSelectedScheduleReminderIds] = useState<string[]>([]);
const [scheduleReminderActionLoading, setScheduleReminderActionLoading] = useState(false);
const [recentPagesTotal, setRecentPagesTotal] = useState(0);
const [exportingPageId, setExportingPageId] = useState<string | null>(null);
const [sharePayload, setSharePayload] = useState<SharePayload | null>(null);
@@ -1040,6 +1094,27 @@ export function MindSpaceView({
void load();
}, []);
useEffect(() => {
if (previewMode) return;
const refreshSchedule = () => {
if (!selectedCategory && !selectedPageId && !newPageOpen && !agentJobsPanelOpen && !allPagesOpen) {
void refreshSpaceQuietly();
}
};
window.addEventListener('focus', refreshSchedule);
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') refreshSchedule();
});
return () => window.removeEventListener('focus', refreshSchedule);
}, [
previewMode,
selectedCategory,
selectedPageId,
newPageOpen,
agentJobsPanelOpen,
allPagesOpen,
]);
useEffect(() => {
if (initialPageId) setSelectedPageId(initialPageId);
}, [initialPageId]);
@@ -1813,19 +1888,74 @@ export function MindSpaceView({
const showHomeModules =
space && !selectedCategory && !selectedPageId && !newPageOpen && !agentJobsPanelOpen && !allPagesOpen;
const scheduleItems = previewMode
? PREVIEW_SCHEDULE_ITEMS
: (space?.schedule?.todayTodoItems ?? []).map((item) => ({
id: item.id,
time: item.allDay ? '全天' : formatScheduleClock(item.dueAt ?? item.startAt, item.timezone),
kind: item.kind === 'event' ? '日程' : '待办',
title: item.title,
meta:
item.description?.trim() ||
(item.sourceChannel === 'wechat' ? '来自服务号' : '来自空间同步'),
tone: item.kind === 'event' ? 'event' : 'task',
}));
const rawUpcomingReminders = previewMode ? [] : space?.schedule?.upcomingReminders ?? [];
const scheduleReminders = previewMode
? PREVIEW_SCHEDULE_REMINDERS.map((item) => ({ ...item }))
: rawUpcomingReminders.map((reminder) => {
const metaParts = [
formatReminderEventWindow(reminder),
reminder.offsetMinutes != null && reminder.offsetMinutes > 0
? `提前 ${reminder.offsetMinutes} 分钟`
: null,
reminder.channel === 'wechat' ? '服务号推送' : '站内提醒',
].filter(Boolean);
return {
id: reminder.id,
time: formatScheduleWhen(reminder.remindAt, reminder.itemTimezone),
title: reminder.itemTitle || '待办提醒',
meta: metaParts.join(' · '),
notifyLabel: scheduleReminderNotifyLabel(reminder.status),
canIgnore: reminder.status === 'pending' || reminder.status === 'locked',
};
});
const scheduleDigests = previewMode ? [] : space?.schedule?.digestSubscriptions ?? [];
const pendingReminderCount = scheduleReminders.filter((item) => item.notifyLabel === '待通知').length;
const hasScheduleContent = scheduleReminders.length > 0 || scheduleDigests.length > 0;
const toggleScheduleReminderSelection = (reminderId: string) => {
setSelectedScheduleReminderIds((prev) =>
prev.includes(reminderId) ? prev.filter((id) => id !== reminderId) : [...prev, reminderId],
);
};
const toggleAllScheduleReminders = () => {
if (selectedScheduleReminderIds.length === scheduleReminders.length) {
setSelectedScheduleReminderIds([]);
} else {
setSelectedScheduleReminderIds(scheduleReminders.map((item) => item.id));
}
};
const ignoreScheduleReminder = async (reminderId: string) => {
if (previewMode) return;
setScheduleReminderActionLoading(true);
setError(null);
try {
await ignoreMindSpaceScheduleReminder(reminderId);
setSelectedScheduleReminderIds((prev) => prev.filter((id) => id !== reminderId));
await refreshSpaceQuietly();
} catch (err) {
setError(err instanceof Error ? err.message : '忽略提醒失败');
} finally {
setScheduleReminderActionLoading(false);
}
};
const deleteSelectedScheduleReminders = async () => {
if (previewMode || selectedScheduleReminderIds.length === 0) return;
setScheduleReminderActionLoading(true);
setError(null);
try {
await deleteMindSpaceScheduleReminders(selectedScheduleReminderIds);
setSelectedScheduleReminderIds([]);
await refreshSpaceQuietly();
} catch (err) {
setError(err instanceof Error ? err.message : '删除提醒失败');
} finally {
setScheduleReminderActionLoading(false);
}
};
const selectedPreviewPage = selectedPageId
? pages.find((page) => page.id === selectedPageId) ?? null
: null;
@@ -2082,24 +2212,83 @@ export function MindSpaceView({
</button>
</div>
<div className="mindspace-schedule-summary">
<strong> {scheduleItems.length || 0} </strong>
<span>
{scheduleItems.length > 0
? '已有待办记录'
: scheduleDigests.length > 0
? `${scheduleDigests.length} 个服务号待办推送`
: '还没有安排'}
</span>
<strong>
7 {scheduleReminders.length} · {pendingReminderCount}
</strong>
<span>{hasScheduleContent ? '仅展示提醒通知' : '还没有安排'}</span>
</div>
{scheduleReminders.length > 0 && (
<div className="mindspace-schedule-toolbar">
<label className="mindspace-schedule-select-all">
<input
type="checkbox"
checked={
scheduleReminders.length > 0 &&
selectedScheduleReminderIds.length === scheduleReminders.length
}
onChange={() => toggleAllScheduleReminders()}
disabled={scheduleReminderActionLoading || previewMode}
/>
<span></span>
</label>
<button
type="button"
className="mindspace-schedule-toolbar-btn danger"
disabled={
previewMode ||
scheduleReminderActionLoading ||
selectedScheduleReminderIds.length === 0
}
onClick={() => void deleteSelectedScheduleReminders()}
>
{scheduleReminderActionLoading ? '处理中…' : '删除提醒'}
</button>
</div>
)}
{scheduleReminders.length > 0 && (
<div className="mindspace-schedule-list">
{scheduleReminders.map((item) => (
<article className="mindspace-schedule-item has-checkbox" key={item.id}>
<label className="mindspace-schedule-checkbox">
<input
type="checkbox"
checked={selectedScheduleReminderIds.includes(item.id)}
onChange={() => toggleScheduleReminderSelection(item.id)}
disabled={scheduleReminderActionLoading || previewMode}
/>
</label>
<time>{item.time}</time>
<span
className={`mindspace-schedule-notify is-${item.notifyLabel === '已通知' ? 'sent' : 'pending'}`}
>
{item.notifyLabel}
</span>
<div className="mindspace-schedule-item-body">
<strong>{item.title}</strong>
<small>{item.meta}</small>
</div>
{item.canIgnore && !previewMode && (
<button
type="button"
className="mindspace-schedule-ignore"
disabled={scheduleReminderActionLoading}
onClick={() => void ignoreScheduleReminder(item.id)}
>
</button>
)}
</article>
))}
</div>
)}
{scheduleDigests.length > 0 && (
<div className="mindspace-schedule-list">
<p className="mindspace-schedule-subhead"></p>
{scheduleDigests.map((digest) => (
<article className="mindspace-schedule-item" key={digest.id}>
<article className="mindspace-schedule-item mindspace-schedule-item-digest" key={digest.id}>
<time>{String(digest.hour).padStart(2, '0')}:{String(digest.minute).padStart(2, '0')}</time>
<span className={`mindspace-schedule-kind is-${digest.status === 'active' ? 'event' : 'reminder'}`}>
</span>
<div>
<span className="mindspace-schedule-notify is-pending"></span>
<div className="mindspace-schedule-item-body">
<strong></strong>
<small>{formatDigestSchedule(digest)}</small>
</div>
@@ -2107,22 +2296,9 @@ export function MindSpaceView({
))}
</div>
)}
{scheduleItems.length > 0 ? (
<div className="mindspace-schedule-list">
{scheduleItems.map((item) => (
<article className="mindspace-schedule-item" key={item.id}>
<time>{item.time}</time>
<span className={`mindspace-schedule-kind is-${item.tone}`}>{item.kind}</span>
<div>
<strong>{item.title}</strong>
<small>{item.meta}</small>
</div>
</article>
))}
</div>
) : (
{!hasScheduleContent && (
<p className="mindspace-schedule-empty">
7 6 5 55
</p>
)}
</section>