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 { TaskRichEditor } from "@/components/task-rich-editor";
import { Button } from "@/components/ui/button";
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 {
createLocalTask,
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) {
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
const [formState, setFormState] = useState<TaskFormState>(DEFAULT_FORM_STATE);
@@ -89,6 +125,8 @@ export function TodoShellPage({ session }: TodoShellPageProps) {
const [creating, setCreating] = useState(false);
const [deleting, setDeleting] = useState(false);
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 ?? "";
@@ -134,30 +172,71 @@ export function TodoShellPage({ session }: TodoShellPageProps) {
}, [selectedTaskId, tasks]);
useEffect(() => {
if (!selectedTask) {
if (!selectedTaskId) {
setFormState(DEFAULT_FORM_STATE);
setDraftReadyTaskId(null);
savedTaskSnapshotRef.current = serializeFormState(DEFAULT_FORM_STATE);
return;
}
setFormState({
title: selectedTask.title,
contentJson: selectedTask.contentJson,
contentText: selectedTask.contentText ?? "",
priority: selectedTask.priority,
status: selectedTask.status,
ddlInput: toDatetimeLocalValue(selectedTask.ddlAt)
});
}, [selectedTask]);
if (!session) {
return (
<div className="rounded-2xl border border-border bg-card/90 p-6 text-sm text-muted-foreground">
</div>
);
if (!selectedTask) {
return;
}
async function handleCreateTask(): Promise<void> {
let cancelled = false;
const currentTask = selectedTask;
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) {
return;
}
@@ -170,9 +249,9 @@ export function TodoShellPage({ session }: TodoShellPageProps) {
} finally {
setCreating(false);
}
}
}, [creating, userId]);
async function handleSaveTask(): Promise<void> {
const handleSaveTask = useCallback(async (): Promise<void> => {
if (!selectedTaskId || saving) {
return;
}
@@ -194,13 +273,15 @@ export function TodoShellPage({ session }: TodoShellPageProps) {
return;
}
savedTaskSnapshotRef.current = serializeFormState(createFormStateFromTask(updatedTask));
await deleteLocalTaskDraft(selectedTaskId);
setFeedback("任务已保存。");
} finally {
setSaving(false);
}
}
}, [formState, saving, selectedTaskId]);
async function handleDeleteTask(): Promise<void> {
const handleDeleteTask = useCallback(async (): Promise<void> => {
if (!selectedTaskId || deleting) {
return;
}
@@ -213,10 +294,42 @@ export function TodoShellPage({ session }: TodoShellPageProps) {
return;
}
await deleteLocalTaskDraft(selectedTaskId);
setFeedback("任务已删除。");
} finally {
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 ?? [];
+20
View File
@@ -35,9 +35,22 @@ export type LocalOpLogRecord = {
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 {
declare tasks: Table<LocalTaskRecord, string>;
declare opLogs: Table<LocalOpLogRecord, string>;
declare taskDrafts: Table<LocalTaskDraftRecord, string>;
constructor() {
super("todolist-web-db");
@@ -47,8 +60,15 @@ class TodoLocalDb extends Dexie {
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.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);
}