feat(web-task): persist local drafts and save shortcut

This commit is contained in:
2026-04-05 23:59:03 +08:00
parent 8ef7c75948
commit 73e0f1312c
3 changed files with 189 additions and 24 deletions
+137 -24
View File
@@ -1,9 +1,19 @@
import { useEffect, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useLiveQuery } from "dexie-react-hooks"; import { useLiveQuery } from "dexie-react-hooks";
import { TaskRichEditor } from "@/components/task-rich-editor"; import { TaskRichEditor } from "@/components/task-rich-editor";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { LocalTaskPriority, LocalTaskStatus } from "@/services/local-db"; import type {
LocalTaskDraftRecord,
LocalTaskPriority,
LocalTaskRecord,
LocalTaskStatus
} from "@/services/local-db";
import {
deleteLocalTaskDraft,
getLocalTaskDraft,
saveLocalTaskDraft
} from "@/services/local-task-draft-repo";
import { import {
createLocalTask, createLocalTask,
deleteLocalTask, deleteLocalTask,
@@ -82,6 +92,32 @@ function formatUpdatedAt(timestamp: number): string {
}); });
} }
function createFormStateFromTask(task: LocalTaskRecord): TaskFormState {
return {
title: task.title,
contentJson: task.contentJson,
contentText: task.contentText ?? "",
priority: task.priority,
status: task.status,
ddlInput: toDatetimeLocalValue(task.ddlAt)
};
}
function createFormStateFromDraft(draft: LocalTaskDraftRecord): TaskFormState {
return {
title: draft.title,
contentJson: draft.contentJson,
contentText: draft.contentText,
priority: draft.priority,
status: draft.status,
ddlInput: draft.ddlInput
};
}
function serializeFormState(formState: TaskFormState): string {
return JSON.stringify(formState);
}
export function TodoShellPage({ session }: TodoShellPageProps) { export function TodoShellPage({ session }: TodoShellPageProps) {
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null); const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
const [formState, setFormState] = useState<TaskFormState>(DEFAULT_FORM_STATE); const [formState, setFormState] = useState<TaskFormState>(DEFAULT_FORM_STATE);
@@ -89,6 +125,8 @@ export function TodoShellPage({ session }: TodoShellPageProps) {
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
const [feedback, setFeedback] = useState<string | null>(null); const [feedback, setFeedback] = useState<string | null>(null);
const [draftReadyTaskId, setDraftReadyTaskId] = useState<string | null>(null);
const savedTaskSnapshotRef = useRef(serializeFormState(DEFAULT_FORM_STATE));
const userId = session?.user.id ?? ""; const userId = session?.user.id ?? "";
@@ -134,30 +172,71 @@ export function TodoShellPage({ session }: TodoShellPageProps) {
}, [selectedTaskId, tasks]); }, [selectedTaskId, tasks]);
useEffect(() => { useEffect(() => {
if (!selectedTask) { if (!selectedTaskId) {
setFormState(DEFAULT_FORM_STATE); setFormState(DEFAULT_FORM_STATE);
setDraftReadyTaskId(null);
savedTaskSnapshotRef.current = serializeFormState(DEFAULT_FORM_STATE);
return; return;
} }
setFormState({ if (!selectedTask) {
title: selectedTask.title, return;
contentJson: selectedTask.contentJson, }
contentText: selectedTask.contentText ?? "",
priority: selectedTask.priority,
status: selectedTask.status,
ddlInput: toDatetimeLocalValue(selectedTask.ddlAt)
});
}, [selectedTask]);
if (!session) { let cancelled = false;
return ( const currentTask = selectedTask;
<div className="rounded-2xl border border-border bg-card/90 p-6 text-sm text-muted-foreground">
</div>
);
}
async function handleCreateTask(): Promise<void> { async function hydrateFormState(): Promise<void> {
const persistedTaskState = createFormStateFromTask(currentTask);
const localDraft = await getLocalTaskDraft(currentTask.id);
if (cancelled) {
return;
}
savedTaskSnapshotRef.current = serializeFormState(persistedTaskState);
setFormState(localDraft ? createFormStateFromDraft(localDraft) : persistedTaskState);
setDraftReadyTaskId(currentTask.id);
}
void hydrateFormState();
return () => {
cancelled = true;
};
}, [selectedTask, selectedTaskId]);
useEffect(() => {
if (!selectedTaskId || !selectedTask || draftReadyTaskId !== selectedTaskId || !userId) {
return;
}
const currentSnapshot = serializeFormState(formState);
const currentTaskId = selectedTaskId;
const currentUserId = userId;
async function persistDraft(): Promise<void> {
if (currentSnapshot === savedTaskSnapshotRef.current) {
await deleteLocalTaskDraft(currentTaskId);
return;
}
await saveLocalTaskDraft({
taskId: currentTaskId,
userId: currentUserId,
title: formState.title,
contentJson: formState.contentJson,
contentText: formState.contentText,
priority: formState.priority,
status: formState.status,
ddlInput: formState.ddlInput
});
}
void persistDraft();
}, [draftReadyTaskId, formState, selectedTask, selectedTaskId, userId]);
const handleCreateTask = useCallback(async (): Promise<void> => {
if (creating || !userId) { if (creating || !userId) {
return; return;
} }
@@ -170,9 +249,9 @@ export function TodoShellPage({ session }: TodoShellPageProps) {
} finally { } finally {
setCreating(false); setCreating(false);
} }
} }, [creating, userId]);
async function handleSaveTask(): Promise<void> { const handleSaveTask = useCallback(async (): Promise<void> => {
if (!selectedTaskId || saving) { if (!selectedTaskId || saving) {
return; return;
} }
@@ -194,13 +273,15 @@ export function TodoShellPage({ session }: TodoShellPageProps) {
return; return;
} }
savedTaskSnapshotRef.current = serializeFormState(createFormStateFromTask(updatedTask));
await deleteLocalTaskDraft(selectedTaskId);
setFeedback("任务已保存。"); setFeedback("任务已保存。");
} finally { } finally {
setSaving(false); setSaving(false);
} }
} }, [formState, saving, selectedTaskId]);
async function handleDeleteTask(): Promise<void> { const handleDeleteTask = useCallback(async (): Promise<void> => {
if (!selectedTaskId || deleting) { if (!selectedTaskId || deleting) {
return; return;
} }
@@ -213,10 +294,42 @@ export function TodoShellPage({ session }: TodoShellPageProps) {
return; return;
} }
await deleteLocalTaskDraft(selectedTaskId);
setFeedback("任务已删除。"); setFeedback("任务已删除。");
} finally { } finally {
setDeleting(false); setDeleting(false);
} }
}, [deleting, selectedTaskId]);
useEffect(() => {
function handleKeydown(event: KeyboardEvent): void {
const isSaveShortcut = (event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "s";
if (!isSaveShortcut) {
return;
}
event.preventDefault();
if (!selectedTaskId || saving) {
return;
}
void handleSaveTask();
}
window.addEventListener("keydown", handleKeydown);
return () => {
window.removeEventListener("keydown", handleKeydown);
};
}, [handleSaveTask, saving, selectedTaskId]);
if (!session) {
return (
<div className="rounded-2xl border border-border bg-card/90 p-6 text-sm text-muted-foreground">
</div>
);
} }
const taskList = tasks ?? []; const taskList = tasks ?? [];
+20
View File
@@ -35,9 +35,22 @@ export type LocalOpLogRecord = {
errorMessage: string | null; errorMessage: string | null;
}; };
export type LocalTaskDraftRecord = {
taskId: string;
userId: string;
title: string;
contentJson: string | null;
contentText: string;
priority: LocalTaskPriority;
status: LocalTaskStatus;
ddlInput: string;
updatedAt: number;
};
class TodoLocalDb extends Dexie { class TodoLocalDb extends Dexie {
declare tasks: Table<LocalTaskRecord, string>; declare tasks: Table<LocalTaskRecord, string>;
declare opLogs: Table<LocalOpLogRecord, string>; declare opLogs: Table<LocalOpLogRecord, string>;
declare taskDrafts: Table<LocalTaskDraftRecord, string>;
constructor() { constructor() {
super("todolist-web-db"); super("todolist-web-db");
@@ -47,8 +60,15 @@ class TodoLocalDb extends Dexie {
op_logs: "&opId,entityId,entityType,action,clientTs,syncedAt" op_logs: "&opId,entityId,entityType,action,clientTs,syncedAt"
}); });
this.version(2).stores({
tasks: "&id,userId,status,priority,ddlAt,updatedAt,deletedAt",
op_logs: "&opId,entityId,entityType,action,clientTs,syncedAt",
task_drafts: "&taskId,userId,updatedAt"
});
this.tasks = this.table("tasks"); this.tasks = this.table("tasks");
this.opLogs = this.table("op_logs"); this.opLogs = this.table("op_logs");
this.taskDrafts = this.table("task_drafts");
} }
} }
@@ -0,0 +1,32 @@
import { localDb, type LocalTaskDraftRecord } from "@/services/local-db";
export type SaveLocalTaskDraftInput = {
taskId: string;
userId: string;
title: string;
contentJson: string | null;
contentText: string;
priority: LocalTaskDraftRecord["priority"];
status: LocalTaskDraftRecord["status"];
ddlInput: string;
};
export async function getLocalTaskDraft(taskId: string): Promise<LocalTaskDraftRecord | undefined> {
return localDb.taskDrafts.get(taskId);
}
export async function saveLocalTaskDraft(
input: SaveLocalTaskDraftInput
): Promise<LocalTaskDraftRecord> {
const draft: LocalTaskDraftRecord = {
...input,
updatedAt: Date.now()
};
await localDb.taskDrafts.put(draft);
return draft;
}
export async function deleteLocalTaskDraft(taskId: string): Promise<void> {
await localDb.taskDrafts.delete(taskId);
}