feat(web-task): implement inbox and task detail views

This commit is contained in:
2026-04-05 17:22:04 +08:00
parent b106d91f8a
commit bb0a09d627
4 changed files with 550 additions and 23 deletions
+1
View File
@@ -15,6 +15,7 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dexie": "^4.4.2", "dexie": "^4.4.2",
"dexie-react-hooks": "^4.4.0",
"lucide-react": "^1.7.0", "lucide-react": "^1.7.0",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
+371 -23
View File
@@ -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 = { type TodoShellPageProps = {
session: WebSession | null; 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) { export function TodoShellPage({ session }: TodoShellPageProps) {
return ( const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
<div className="rounded-2xl border border-border bg-card/90 p-6 shadow-[0_24px_70px_-42px_hsl(var(--primary)/0.6)] backdrop-blur"> const [formState, setFormState] = useState<TaskFormState>(DEFAULT_FORM_STATE);
<h1 className="text-2xl font-semibold text-foreground">TodoList </h1> const [saving, setSaving] = useState(false);
<p className="mt-2 text-sm text-muted-foreground"> const [creating, setCreating] = useState(false);
{session ? `当前登录邮箱:${session.user.email}` : "当前未建立登录会话,请先完成登录。"} const [deleting, setDeleting] = useState(false);
</p> const [feedback, setFeedback] = useState<string | null>(null);
<div className="mt-6 grid gap-3 sm:grid-cols-3">
<div className="rounded-xl border border-border bg-muted/40 p-4"> const userId = session?.user.id ?? "";
<p className="text-xs text-muted-foreground"></p>
<p className="mt-2 text-lg font-semibold text-foreground"></p> const tasks = useLiveQuery(async () => {
</div> if (!userId) {
<div className="rounded-xl border border-border bg-muted/40 p-4"> return [];
<p className="text-xs text-muted-foreground"></p> }
<p className="mt-2 text-lg font-semibold text-foreground"></p>
</div> return listLocalTasksByUser(userId);
<div className="rounded-xl border border-border bg-muted/40 p-4"> }, [userId]);
<p className="text-xs text-muted-foreground"></p>
<p className="mt-2 text-lg font-semibold text-foreground"></p> const selectedTask = useLiveQuery(async () => {
</div> 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 (
<div className="rounded-2xl border border-border bg-card/90 p-6 text-sm text-muted-foreground">
</div> </div>
<p className="mt-4 text-xs text-muted-foreground"> );
}
</p>
async function handleCreateTask(): Promise<void> {
if (creating || !userId) {
return;
}
try {
setCreating(true);
const createdTask = await createLocalTask({ userId });
setSelectedTaskId(createdTask.id);
setFeedback("已创建新任务。");
} finally {
setCreating(false);
}
}
async function handleSaveTask(): Promise<void> {
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<void> {
if (!selectedTaskId || deleting) {
return;
}
try {
setDeleting(true);
const deleted = await deleteLocalTask(selectedTaskId);
if (!deleted) {
setFeedback("任务已不存在。");
return;
}
setFeedback("任务已删除。");
} finally {
setDeleting(false);
}
}
const taskList = tasks ?? [];
return (
<div className="grid gap-4 lg:grid-cols-[320px_minmax(0,1fr)]">
<section className="rounded-2xl border border-border bg-card/90 p-4 shadow-[0_24px_70px_-42px_hsl(var(--primary)/0.6)] backdrop-blur">
<div className="mb-3 flex items-center justify-between gap-2">
<h2 className="text-base font-semibold text-foreground"></h2>
<Button
type="button"
size="sm"
className="bg-primary text-primary-foreground hover:bg-primary/90"
onClick={handleCreateTask}
disabled={creating}
>
{creating ? "创建中..." : "新建任务"}
</Button>
</div>
{taskList.length === 0 ? (
<p className="rounded-xl border border-dashed border-border bg-muted/40 p-4 text-sm text-muted-foreground">
</p>
) : (
<div className="space-y-2">
{taskList.map((task) => {
const isActive = task.id === selectedTaskId;
return (
<button
key={task.id}
type="button"
className={cn(
"w-full rounded-xl border px-3 py-2 text-left transition-colors",
isActive
? "border-primary/45 bg-primary/10"
: "border-border bg-background hover:border-primary/25 hover:bg-primary/5"
)}
onClick={() => setSelectedTaskId(task.id)}
>
<p className="truncate text-sm font-medium text-foreground">{task.title}</p>
<p className="mt-1 text-xs text-muted-foreground">
{task.status} · {task.priority} · {formatUpdatedAt(task.updatedAt)}
</p>
</button>
);
})}
</div>
)}
</section>
<section className="rounded-2xl border border-border bg-card/90 p-4 shadow-[0_24px_70px_-42px_hsl(var(--primary)/0.6)] backdrop-blur">
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<h2 className="text-base font-semibold text-foreground"></h2>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
onClick={handleSaveTask}
disabled={!selectedTaskId || saving}
>
{saving ? "保存中..." : "保存"}
</Button>
<Button
type="button"
variant="outline"
className="border-destructive/50 text-destructive hover:bg-destructive/10"
onClick={handleDeleteTask}
disabled={!selectedTaskId || deleting}
>
{deleting ? "删除中..." : "删除"}
</Button>
</div>
</div>
{!selectedTaskId || !selectedTask ? (
<p className="rounded-xl border border-dashed border-border bg-muted/40 p-4 text-sm text-muted-foreground">
</p>
) : (
<div className="space-y-3">
<label className="block text-sm text-muted-foreground">
<input
className="mt-1 w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground outline-none focus:border-primary focus:ring-2 focus:ring-ring/30"
value={formState.title}
onChange={(event) =>
setFormState((previous) => ({
...previous,
title: event.target.value
}))
}
placeholder="请输入任务标题"
/>
</label>
<div className="grid gap-3 sm:grid-cols-2">
<label className="block text-sm text-muted-foreground">
<select
className="mt-1 w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground outline-none focus:border-primary focus:ring-2 focus:ring-ring/30"
value={formState.status}
onChange={(event) =>
setFormState((previous) => ({
...previous,
status: event.target.value as LocalTaskStatus
}))
}
>
{STATUS_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label className="block text-sm text-muted-foreground">
<select
className="mt-1 w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground outline-none focus:border-primary focus:ring-2 focus:ring-ring/30"
value={formState.priority}
onChange={(event) =>
setFormState((previous) => ({
...previous,
priority: event.target.value as LocalTaskPriority
}))
}
>
{PRIORITY_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
</div>
<label className="block text-sm text-muted-foreground">
<input
type="datetime-local"
className="mt-1 w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground outline-none focus:border-primary focus:ring-2 focus:ring-ring/30"
value={formState.ddlInput}
onChange={(event) =>
setFormState((previous) => ({
...previous,
ddlInput: event.target.value
}))
}
/>
</label>
<label className="block text-sm text-muted-foreground">
<textarea
className="mt-1 min-h-40 w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground outline-none focus:border-primary focus:ring-2 focus:ring-ring/30"
value={formState.contentText}
onChange={(event) =>
setFormState((previous) => ({
...previous,
contentText: event.target.value
}))
}
placeholder="输入任务详情(当前为本地文本版,富文本将在后续迭代接入)"
/>
</label>
</div>
)}
{feedback ? <p className="mt-3 text-xs text-primary">{feedback}</p> : null}
</section>
</div> </div>
); );
} }
+161
View File
@@ -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<LocalTaskRecord[]> {
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<LocalTaskRecord | undefined> {
const task = await localDb.tasks.get(id);
if (!task || task.deletedAt !== null) {
return undefined;
}
return task;
}
export async function createLocalTask(input: CreateLocalTaskInput): Promise<LocalTaskRecord> {
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<LocalTaskRecord | undefined> {
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<boolean> {
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;
}
+17
View File
@@ -158,6 +158,9 @@ importers:
dexie: dexie:
specifier: ^4.4.2 specifier: ^4.4.2
version: 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: lucide-react:
specifier: ^1.7.0 specifier: ^1.7.0
version: 1.7.0(react@19.2.4) version: 1.7.0(react@19.2.4)
@@ -3969,6 +3972,15 @@ packages:
} }
engines: { node: ">=8" } 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: dexie@4.4.2:
resolution: resolution:
{ {
@@ -10867,6 +10879,11 @@ snapshots:
detect-newline@3.1.0: {} 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: {} dexie@4.4.2: {}
dezalgo@1.0.4: dezalgo@1.0.4: