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 7906f3c36d
commit 14f46cf318
20 changed files with 2453 additions and 452 deletions
+20
View File
@@ -28,6 +28,7 @@ import type {
MindSpacePageDeletePreview,
MindSpacePageDeleteResult,
MindSpaceQuota,
MindSpaceScheduleReminder,
MindSpaceSaveCategory,
MindSpacePublication,
MindSpacePublicationStats,
@@ -580,6 +581,25 @@ export async function getMindSpace(): Promise<MindSpace> {
return result.data;
}
export async function ignoreMindSpaceScheduleReminder(reminderId: string) {
const result = await apiFetch<{ data: MindSpaceScheduleReminder }>(
`/mindspace/v1/schedule/reminders/${encodeURIComponent(reminderId)}/ignore`,
{ method: 'POST' },
);
return result.data;
}
export async function deleteMindSpaceScheduleReminders(ids: string[]) {
const result = await apiFetch<{ data: { deleted: number } }>(
'/mindspace/v1/schedule/reminders/bulk-delete',
{
method: 'POST',
body: JSON.stringify({ ids }),
},
);
return result.data;
}
export async function listMindSpaceCleanupItems(): Promise<{
items: MindSpaceCleanupItem[];
totalBytes: number;
+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>
+97
View File
@@ -5416,6 +5416,15 @@ body,
font-size: 13px;
}
.mindspace-schedule-subhead {
margin: 0 0 0.35rem;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #7a857f;
}
.mindspace-schedule-list {
display: grid;
gap: 7px;
@@ -5433,6 +5442,94 @@ body,
background: rgba(24, 33, 29, 0.045);
}
.mindspace-schedule-item.has-checkbox {
grid-template-columns: 22px 72px 52px minmax(0, 1fr) auto;
}
.mindspace-schedule-item.mindspace-schedule-item-digest {
grid-template-columns: 72px 52px minmax(0, 1fr);
}
.mindspace-schedule-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-top: 10px;
}
.mindspace-schedule-select-all {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: #68716c;
}
.mindspace-schedule-toolbar-btn {
border: 0;
border-radius: 999px;
padding: 6px 12px;
font-size: 12px;
cursor: pointer;
background: rgba(24, 33, 29, 0.08);
color: #18211d;
}
.mindspace-schedule-toolbar-btn.danger {
background: rgba(196, 74, 58, 0.12);
color: #a33f32;
}
.mindspace-schedule-toolbar-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.mindspace-schedule-checkbox {
display: flex;
align-items: center;
}
.mindspace-schedule-item-body strong {
display: block;
}
.mindspace-schedule-notify {
display: inline-flex;
justify-content: center;
border-radius: 999px;
padding: 3px 6px;
font-size: 11px;
line-height: 1.2;
white-space: nowrap;
}
.mindspace-schedule-notify.is-pending {
color: #8a5b16;
background: rgba(238, 176, 78, 0.22);
}
.mindspace-schedule-notify.is-sent {
color: #2f6f57;
background: rgba(47, 111, 87, 0.16);
}
.mindspace-schedule-ignore {
border: 0;
border-radius: 999px;
padding: 5px 10px;
font-size: 11px;
cursor: pointer;
color: #68716c;
background: rgba(24, 33, 29, 0.08);
}
.mindspace-schedule-ignore:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.mindspace-schedule-item time {
color: #2f6f57;
font-size: 12px;
+17
View File
@@ -359,8 +359,25 @@ export type MindSpaceDigestSubscription = {
attempts?: number;
};
export type MindSpaceScheduleReminder = {
id: string;
itemId: string;
itemTitle: string;
itemKind: 'task' | 'event';
itemTimezone: string;
itemStartAt?: number | null;
itemEndAt?: number | null;
remindAt: number;
offsetMinutes?: number | null;
channel: 'wechat' | 'in_app';
status: 'pending' | 'locked' | 'sent' | 'failed' | 'cancelled';
sentAt?: number | null;
};
export type MindSpaceSchedule = {
todayTodoItems: MindSpaceScheduleItem[];
upcomingItems: MindSpaceScheduleItem[];
upcomingReminders: MindSpaceScheduleReminder[];
digestSubscriptions: MindSpaceDigestSubscription[];
};