diff --git a/apps/web/package.json b/apps/web/package.json
index d3cc23b..45d4755 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -15,6 +15,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dexie": "^4.4.2",
+ "dexie-react-hooks": "^4.4.0",
"lucide-react": "^1.7.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
diff --git a/apps/web/src/pages/todo-shell-page.tsx b/apps/web/src/pages/todo-shell-page.tsx
index cb6878b..74e19c7 100644
--- a/apps/web/src/pages/todo-shell-page.tsx
+++ b/apps/web/src/pages/todo-shell-page.tsx
@@ -1,33 +1,381 @@
-import type { WebSession } from "@/services/session-storage";
+import { useEffect, useState } from "react";
+import { useLiveQuery } from "dexie-react-hooks";
+import { Button } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
+import {
+ createLocalTask,
+ deleteLocalTask,
+ getLocalTaskById,
+ listLocalTasksByUser,
+ updateLocalTask
+} from "@/services/local-task-repo";
+import type { LocalTaskPriority, LocalTaskStatus } from "@/services/local-db";
+import type { WebSession } from "@/services/session-storage";
type TodoShellPageProps = {
session: WebSession | null;
};
+type TaskFormState = {
+ title: string;
+ contentText: string;
+ priority: LocalTaskPriority;
+ status: LocalTaskStatus;
+ ddlInput: string;
+};
+
+const DEFAULT_FORM_STATE: TaskFormState = {
+ title: "",
+ contentText: "",
+ priority: "MEDIUM",
+ status: "TODO",
+ ddlInput: ""
+};
+
+const PRIORITY_OPTIONS: Array<{ value: LocalTaskPriority; label: string }> = [
+ { value: "LOW", label: "低" },
+ { value: "MEDIUM", label: "中" },
+ { value: "HIGH", label: "高" },
+ { value: "URGENT", label: "紧急" }
+];
+
+const STATUS_OPTIONS: Array<{ value: LocalTaskStatus; label: string }> = [
+ { value: "TODO", label: "待办" },
+ { value: "IN_PROGRESS", label: "进行中" },
+ { value: "DONE", label: "已完成" },
+ { value: "ARCHIVED", label: "已归档" }
+];
+
+function toDatetimeLocalValue(timestamp: number | null): string {
+ if (timestamp === null) {
+ return "";
+ }
+
+ const date = new Date(timestamp);
+ const timezoneOffset = date.getTimezoneOffset() * 60_000;
+ return new Date(timestamp - timezoneOffset).toISOString().slice(0, 16);
+}
+
+function parseDatetimeLocalValue(value: string): number | null {
+ if (!value) {
+ return null;
+ }
+
+ const parsed = new Date(value).getTime();
+ if (Number.isNaN(parsed)) {
+ return null;
+ }
+
+ return parsed;
+}
+
+function formatUpdatedAt(timestamp: number): string {
+ return new Date(timestamp).toLocaleString("zh-CN", {
+ month: "2-digit",
+ day: "2-digit",
+ hour: "2-digit",
+ minute: "2-digit"
+ });
+}
+
export function TodoShellPage({ session }: TodoShellPageProps) {
- return (
-
-
TodoList 工作台
-
- {session ? `当前登录邮箱:${session.user.email}` : "当前未建立登录会话,请先完成登录。"}
-
-
-
-
-
+ const [selectedTaskId, setSelectedTaskId] = useState
(null);
+ const [formState, setFormState] = useState(DEFAULT_FORM_STATE);
+ const [saving, setSaving] = useState(false);
+ const [creating, setCreating] = useState(false);
+ const [deleting, setDeleting] = useState(false);
+ const [feedback, setFeedback] = useState(null);
+
+ const userId = session?.user.id ?? "";
+
+ const tasks = useLiveQuery(async () => {
+ if (!userId) {
+ return [];
+ }
+
+ return listLocalTasksByUser(userId);
+ }, [userId]);
+
+ const selectedTask = useLiveQuery(async () => {
+ if (!selectedTaskId) {
+ return undefined;
+ }
+
+ return getLocalTaskById(selectedTaskId);
+ }, [selectedTaskId]);
+
+ useEffect(() => {
+ if (!tasks || tasks.length === 0) {
+ setSelectedTaskId(null);
+ return;
+ }
+
+ if (!selectedTaskId) {
+ setSelectedTaskId(tasks[0].id);
+ return;
+ }
+
+ const exists = tasks.some((task) => task.id === selectedTaskId);
+ if (!exists) {
+ setSelectedTaskId(tasks[0].id);
+ }
+ }, [selectedTaskId, tasks]);
+
+ useEffect(() => {
+ if (!selectedTask) {
+ setFormState(DEFAULT_FORM_STATE);
+ return;
+ }
+
+ setFormState({
+ title: selectedTask.title,
+ contentText: selectedTask.contentText ?? "",
+ priority: selectedTask.priority,
+ status: selectedTask.status,
+ ddlInput: toDatetimeLocalValue(selectedTask.ddlAt)
+ });
+ }, [selectedTask]);
+
+ if (!session) {
+ return (
+
+ 当前未建立登录会话,请先完成登录。
-
- 当前为界面阶段,统计卡片将在任务数据接入后显示真实结果。
-
+ );
+ }
+
+ async function handleCreateTask(): Promise {
+ if (creating || !userId) {
+ return;
+ }
+
+ try {
+ setCreating(true);
+ const createdTask = await createLocalTask({ userId });
+ setSelectedTaskId(createdTask.id);
+ setFeedback("已创建新任务。");
+ } finally {
+ setCreating(false);
+ }
+ }
+
+ async function handleSaveTask(): Promise {
+ if (!selectedTaskId || saving) {
+ return;
+ }
+
+ try {
+ setSaving(true);
+ const updatedTask = await updateLocalTask({
+ id: selectedTaskId,
+ title: formState.title,
+ contentText: formState.contentText || null,
+ contentJson: null,
+ priority: formState.priority,
+ status: formState.status,
+ ddlAt: parseDatetimeLocalValue(formState.ddlInput)
+ });
+
+ if (!updatedTask) {
+ setFeedback("任务不存在或已被删除。");
+ return;
+ }
+
+ setFeedback("任务已保存。");
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ async function handleDeleteTask(): Promise {
+ if (!selectedTaskId || deleting) {
+ return;
+ }
+
+ try {
+ setDeleting(true);
+ const deleted = await deleteLocalTask(selectedTaskId);
+ if (!deleted) {
+ setFeedback("任务已不存在。");
+ return;
+ }
+
+ setFeedback("任务已删除。");
+ } finally {
+ setDeleting(false);
+ }
+ }
+
+ const taskList = tasks ?? [];
+
+ return (
+
+
+
+
任务列表
+
+
+
+ {taskList.length === 0 ? (
+
+ 还没有任务,点击右上角“新建任务”。
+
+ ) : (
+
+ {taskList.map((task) => {
+ const isActive = task.id === selectedTaskId;
+ return (
+
+ );
+ })}
+
+ )}
+
+
+
+
+
任务详情
+
+
+
+
+
+
+ {!selectedTaskId || !selectedTask ? (
+
+ 请选择一个任务进行编辑。
+
+ ) : (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ {feedback ? {feedback}
: null}
+
);
}
diff --git a/apps/web/src/services/local-task-repo.ts b/apps/web/src/services/local-task-repo.ts
new file mode 100644
index 0000000..cd8edfd
--- /dev/null
+++ b/apps/web/src/services/local-task-repo.ts
@@ -0,0 +1,161 @@
+import {
+ localDb,
+ type LocalOpLogRecord,
+ type LocalTaskPriority,
+ type LocalTaskRecord,
+ type LocalTaskStatus,
+ type SyncActionType
+} from "@/services/local-db";
+
+const DEVICE_ID_STORAGE_KEY = "todolist.web.device-id";
+
+export type CreateLocalTaskInput = {
+ userId: string;
+ title?: string;
+};
+
+export type UpdateLocalTaskInput = {
+ id: string;
+ title?: string;
+ contentText?: string | null;
+ contentJson?: string | null;
+ priority?: LocalTaskPriority;
+ status?: LocalTaskStatus;
+ ddlAt?: number | null;
+};
+
+function resolveDeviceId(): string {
+ const savedDeviceId = window.localStorage.getItem(DEVICE_ID_STORAGE_KEY);
+ if (savedDeviceId) {
+ return savedDeviceId;
+ }
+
+ const nextDeviceId = crypto.randomUUID();
+ window.localStorage.setItem(DEVICE_ID_STORAGE_KEY, nextDeviceId);
+ return nextDeviceId;
+}
+
+function createOpLogRecord(
+ entityId: string,
+ action: SyncActionType,
+ payload: string
+): LocalOpLogRecord {
+ return {
+ opId: crypto.randomUUID(),
+ entityId,
+ entityType: "TASK",
+ action,
+ payload,
+ clientTs: Date.now(),
+ deviceId: resolveDeviceId(),
+ syncedAt: null,
+ retryCount: 0,
+ errorMessage: null
+ };
+}
+
+export async function listLocalTasksByUser(userId: string): Promise {
+ const tasks = await localDb.tasks.where("userId").equals(userId).toArray();
+ return tasks
+ .filter((task) => task.deletedAt === null)
+ .sort((left, right) => right.updatedAt - left.updatedAt);
+}
+
+export async function getLocalTaskById(id: string): Promise {
+ const task = await localDb.tasks.get(id);
+ if (!task || task.deletedAt !== null) {
+ return undefined;
+ }
+
+ return task;
+}
+
+export async function createLocalTask(input: CreateLocalTaskInput): Promise {
+ const now = Date.now();
+ const task: LocalTaskRecord = {
+ id: crypto.randomUUID(),
+ userId: input.userId,
+ title: input.title?.trim() ? input.title.trim() : "未命名任务",
+ contentJson: null,
+ contentText: null,
+ priority: "MEDIUM",
+ status: "TODO",
+ ddlAt: null,
+ createdAt: now,
+ updatedAt: now,
+ deletedAt: null
+ };
+
+ const opLog = createOpLogRecord(task.id, "CREATE", JSON.stringify(task));
+
+ await localDb.transaction("rw", localDb.tasks, localDb.opLogs, async () => {
+ await localDb.tasks.add(task);
+ await localDb.opLogs.add(opLog);
+ });
+
+ return task;
+}
+
+export async function updateLocalTask(
+ input: UpdateLocalTaskInput
+): Promise {
+ const currentTask = await getLocalTaskById(input.id);
+ if (!currentTask) {
+ return undefined;
+ }
+
+ const nextTask: LocalTaskRecord = {
+ ...currentTask,
+ title: input.title !== undefined ? input.title.trim() || "未命名任务" : currentTask.title,
+ contentText: input.contentText !== undefined ? input.contentText : currentTask.contentText,
+ contentJson: input.contentJson !== undefined ? input.contentJson : currentTask.contentJson,
+ priority: input.priority ?? currentTask.priority,
+ status: input.status ?? currentTask.status,
+ ddlAt: input.ddlAt !== undefined ? input.ddlAt : currentTask.ddlAt,
+ updatedAt: Date.now()
+ };
+
+ const opLog = createOpLogRecord(
+ nextTask.id,
+ "UPDATE",
+ JSON.stringify({
+ title: nextTask.title,
+ contentText: nextTask.contentText,
+ contentJson: nextTask.contentJson,
+ priority: nextTask.priority,
+ status: nextTask.status,
+ ddlAt: nextTask.ddlAt,
+ updatedAt: nextTask.updatedAt
+ })
+ );
+
+ await localDb.transaction("rw", localDb.tasks, localDb.opLogs, async () => {
+ await localDb.tasks.put(nextTask);
+ await localDb.opLogs.add(opLog);
+ });
+
+ return nextTask;
+}
+
+export async function deleteLocalTask(id: string): Promise {
+ const currentTask = await getLocalTaskById(id);
+ if (!currentTask) {
+ return false;
+ }
+
+ const deletedAt = Date.now();
+ const nextTask: LocalTaskRecord = {
+ ...currentTask,
+ deletedAt,
+ updatedAt: deletedAt
+ };
+
+ const opLog = createOpLogRecord(id, "DELETE", JSON.stringify({ deletedAt }));
+
+ await localDb.transaction("rw", localDb.tasks, localDb.opLogs, async () => {
+ await localDb.tasks.put(nextTask);
+ await localDb.opLogs.add(opLog);
+ });
+
+ return true;
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 4b40b2d..5dc8b55 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -158,6 +158,9 @@ importers:
dexie:
specifier: ^4.4.2
version: 4.4.2
+ dexie-react-hooks:
+ specifier: ^4.4.0
+ version: 4.4.0(dexie@4.4.2)(react@19.2.4)
lucide-react:
specifier: ^1.7.0
version: 1.7.0(react@19.2.4)
@@ -3969,6 +3972,15 @@ packages:
}
engines: { node: ">=8" }
+ dexie-react-hooks@4.4.0:
+ resolution:
+ {
+ integrity: sha512-ObLXBS5+4BJU8vtSvBx6b9fY6zZYgniAtwxzjCHsUQadgbqYN6935X2/1TWw4Rf2N1aZV1io5/ziox4vKuxABA==
+ }
+ peerDependencies:
+ dexie: ">=4.2.0-alpha.1 <5.0.0"
+ react: ">=16"
+
dexie@4.4.2:
resolution:
{
@@ -10867,6 +10879,11 @@ snapshots:
detect-newline@3.1.0: {}
+ dexie-react-hooks@4.4.0(dexie@4.4.2)(react@19.2.4):
+ dependencies:
+ dexie: 4.4.2
+ react: 19.2.4
+
dexie@4.4.2: {}
dezalgo@1.0.4: