feat(web-task): persist local drafts and save shortcut
This commit is contained in:
@@ -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 ?? [];
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user