feat(web-task): animate top feedback banner
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useLiveQuery } from "dexie-react-hooks";
|
import { useLiveQuery } from "dexie-react-hooks";
|
||||||
|
import { CheckCircle2, CircleAlert } from "lucide-react";
|
||||||
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";
|
||||||
@@ -37,6 +38,11 @@ type TaskFormState = {
|
|||||||
ddlInput: string;
|
ddlInput: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type FeedbackNotice = {
|
||||||
|
message: string;
|
||||||
|
tone: "success" | "error";
|
||||||
|
};
|
||||||
|
|
||||||
const DEFAULT_FORM_STATE: TaskFormState = {
|
const DEFAULT_FORM_STATE: TaskFormState = {
|
||||||
title: "",
|
title: "",
|
||||||
contentJson: null,
|
contentJson: null,
|
||||||
@@ -124,7 +130,8 @@ export function TodoShellPage({ session }: TodoShellPageProps) {
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
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<FeedbackNotice | null>(null);
|
||||||
|
const [feedbackVisible, setFeedbackVisible] = useState(false);
|
||||||
const [draftReadyTaskId, setDraftReadyTaskId] = useState<string | null>(null);
|
const [draftReadyTaskId, setDraftReadyTaskId] = useState<string | null>(null);
|
||||||
const savedTaskSnapshotRef = useRef(serializeFormState(DEFAULT_FORM_STATE));
|
const savedTaskSnapshotRef = useRef(serializeFormState(DEFAULT_FORM_STATE));
|
||||||
|
|
||||||
@@ -236,6 +243,66 @@ export function TodoShellPage({ session }: TodoShellPageProps) {
|
|||||||
void persistDraft();
|
void persistDraft();
|
||||||
}, [draftReadyTaskId, formState, selectedTask, selectedTaskId, userId]);
|
}, [draftReadyTaskId, formState, selectedTask, selectedTaskId, userId]);
|
||||||
|
|
||||||
|
const showFeedback = useCallback((message: string, tone: FeedbackNotice["tone"]): void => {
|
||||||
|
setFeedback({ message, tone });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!feedback) {
|
||||||
|
setFeedbackVisible(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setFeedbackVisible(false);
|
||||||
|
const enterAnimationId = window.requestAnimationFrame(() => {
|
||||||
|
setFeedbackVisible(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
const visibleDuration = feedback.tone === "success" ? 2200 : 3200;
|
||||||
|
const hideTimeoutId = window.setTimeout(() => {
|
||||||
|
setFeedbackVisible(false);
|
||||||
|
}, visibleDuration);
|
||||||
|
|
||||||
|
const cleanupTimeoutId = window.setTimeout(() => {
|
||||||
|
setFeedback((currentFeedback) =>
|
||||||
|
currentFeedback?.message === feedback.message ? null : currentFeedback
|
||||||
|
);
|
||||||
|
}, visibleDuration + 260);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.cancelAnimationFrame(enterAnimationId);
|
||||||
|
window.clearTimeout(hideTimeoutId);
|
||||||
|
window.clearTimeout(cleanupTimeoutId);
|
||||||
|
};
|
||||||
|
}, [feedback]);
|
||||||
|
|
||||||
|
function renderFeedbackBanner() {
|
||||||
|
if (!feedback) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-none fixed inset-x-0 top-0 z-50 flex justify-center px-4 pt-4">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex min-w-[240px] max-w-[520px] items-center gap-3 rounded-2xl border px-4 py-3 shadow-[0_18px_50px_-24px_hsl(var(--foreground)/0.35)] backdrop-blur transition-all duration-300 ease-out",
|
||||||
|
feedbackVisible ? "translate-y-0 opacity-100" : "-translate-y-6 opacity-0",
|
||||||
|
feedback.tone === "success"
|
||||||
|
? "border-emerald-200 bg-emerald-50/95 text-emerald-900"
|
||||||
|
: "border-destructive/30 bg-background/95 text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{feedback.tone === "success" ? (
|
||||||
|
<CheckCircle2 className="h-5 w-5 shrink-0 text-emerald-600" />
|
||||||
|
) : (
|
||||||
|
<CircleAlert className="h-5 w-5 shrink-0 text-destructive" />
|
||||||
|
)}
|
||||||
|
<p className="text-sm font-medium">{feedback.message}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const handleCreateTask = useCallback(async (): Promise<void> => {
|
const handleCreateTask = useCallback(async (): Promise<void> => {
|
||||||
if (creating || !userId) {
|
if (creating || !userId) {
|
||||||
return;
|
return;
|
||||||
@@ -245,11 +312,11 @@ export function TodoShellPage({ session }: TodoShellPageProps) {
|
|||||||
setCreating(true);
|
setCreating(true);
|
||||||
const createdTask = await createLocalTask({ userId });
|
const createdTask = await createLocalTask({ userId });
|
||||||
setSelectedTaskId(createdTask.id);
|
setSelectedTaskId(createdTask.id);
|
||||||
setFeedback("已创建新任务。");
|
showFeedback("已创建新任务。", "success");
|
||||||
} finally {
|
} finally {
|
||||||
setCreating(false);
|
setCreating(false);
|
||||||
}
|
}
|
||||||
}, [creating, userId]);
|
}, [creating, showFeedback, userId]);
|
||||||
|
|
||||||
const handleSaveTask = useCallback(async (): Promise<void> => {
|
const handleSaveTask = useCallback(async (): Promise<void> => {
|
||||||
if (!selectedTaskId || saving) {
|
if (!selectedTaskId || saving) {
|
||||||
@@ -269,17 +336,17 @@ export function TodoShellPage({ session }: TodoShellPageProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!updatedTask) {
|
if (!updatedTask) {
|
||||||
setFeedback("任务不存在或已被删除。");
|
showFeedback("任务不存在或已被删除。", "error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
savedTaskSnapshotRef.current = serializeFormState(createFormStateFromTask(updatedTask));
|
savedTaskSnapshotRef.current = serializeFormState(createFormStateFromTask(updatedTask));
|
||||||
await deleteLocalTaskDraft(selectedTaskId);
|
await deleteLocalTaskDraft(selectedTaskId);
|
||||||
setFeedback("任务已保存。");
|
showFeedback("任务已保存。", "success");
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
}, [formState, saving, selectedTaskId]);
|
}, [formState, saving, selectedTaskId, showFeedback]);
|
||||||
|
|
||||||
const handleDeleteTask = useCallback(async (): Promise<void> => {
|
const handleDeleteTask = useCallback(async (): Promise<void> => {
|
||||||
if (!selectedTaskId || deleting) {
|
if (!selectedTaskId || deleting) {
|
||||||
@@ -290,16 +357,16 @@ export function TodoShellPage({ session }: TodoShellPageProps) {
|
|||||||
setDeleting(true);
|
setDeleting(true);
|
||||||
const deleted = await deleteLocalTask(selectedTaskId);
|
const deleted = await deleteLocalTask(selectedTaskId);
|
||||||
if (!deleted) {
|
if (!deleted) {
|
||||||
setFeedback("任务已不存在。");
|
showFeedback("任务已不存在。", "error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await deleteLocalTaskDraft(selectedTaskId);
|
await deleteLocalTaskDraft(selectedTaskId);
|
||||||
setFeedback("任务已删除。");
|
showFeedback("任务已删除。", "success");
|
||||||
} finally {
|
} finally {
|
||||||
setDeleting(false);
|
setDeleting(false);
|
||||||
}
|
}
|
||||||
}, [deleting, selectedTaskId]);
|
}, [deleting, selectedTaskId, showFeedback]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handleKeydown(event: KeyboardEvent): void {
|
function handleKeydown(event: KeyboardEvent): void {
|
||||||
@@ -326,196 +393,201 @@ export function TodoShellPage({ session }: TodoShellPageProps) {
|
|||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-2xl border border-border bg-card/90 p-6 text-sm text-muted-foreground">
|
<>
|
||||||
当前未建立登录会话,请先完成登录。
|
{renderFeedbackBanner()}
|
||||||
</div>
|
<div className="rounded-2xl border border-border bg-card/90 p-6 text-sm text-muted-foreground">
|
||||||
|
当前未建立登录会话,请先完成登录。
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const taskList = tasks ?? [];
|
const taskList = tasks ?? [];
|
||||||
|
|
||||||
return (
|
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">
|
{renderFeedbackBanner()}
|
||||||
<div className="mb-3 flex items-center justify-between gap-2">
|
<div className="grid gap-4 lg:grid-cols-[320px_minmax(0,1fr)]">
|
||||||
<h2 className="text-base font-semibold text-foreground">任务列表</h2>
|
<section className="rounded-2xl border border-border bg-card/90 p-4 shadow-[0_24px_70px_-42px_hsl(var(--primary)/0.6)] backdrop-blur">
|
||||||
<Button
|
<div className="mb-3 flex items-center justify-between gap-2">
|
||||||
type="button"
|
<h2 className="text-base font-semibold text-foreground">任务列表</h2>
|
||||||
size="sm"
|
|
||||||
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
|
||||||
onClick={handleCreateTask}
|
|
||||||
disabled={creating}
|
|
||||||
>
|
|
||||||
{creating ? "创建中..." : "新建任务"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{quotaSnapshot ? (
|
|
||||||
<p
|
|
||||||
className={cn(
|
|
||||||
"mb-3 text-xs",
|
|
||||||
quotaSnapshot.usedPercent >= 85 ? "text-destructive" : "text-muted-foreground"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
空间占用(估算):{formatStorageSize(quotaSnapshot.usedBytes)} /{" "}
|
|
||||||
{formatStorageSize(quotaSnapshot.quotaBytes)}({quotaSnapshot.usedPercent.toFixed(1)}%)
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{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
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
size="sm"
|
||||||
onClick={handleSaveTask}
|
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
||||||
disabled={!selectedTaskId || saving}
|
onClick={handleCreateTask}
|
||||||
|
disabled={creating}
|
||||||
>
|
>
|
||||||
{saving ? "保存中..." : "保存"}
|
{creating ? "创建中..." : "新建任务"}
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
className="border-destructive/50 text-destructive hover:bg-destructive/10"
|
|
||||||
onClick={handleDeleteTask}
|
|
||||||
disabled={!selectedTaskId || deleting}
|
|
||||||
>
|
|
||||||
{deleting ? "删除中..." : "删除"}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{!selectedTaskId || !selectedTask ? (
|
{quotaSnapshot ? (
|
||||||
<p className="rounded-xl border border-dashed border-border bg-muted/40 p-4 text-sm text-muted-foreground">
|
<p
|
||||||
请选择一个任务进行编辑。
|
className={cn(
|
||||||
</p>
|
"mb-3 text-xs",
|
||||||
) : (
|
quotaSnapshot.usedPercent >= 85 ? "text-destructive" : "text-muted-foreground"
|
||||||
<div className="space-y-3">
|
)}
|
||||||
<label className="block text-sm text-muted-foreground">
|
>
|
||||||
任务标题
|
空间占用(估算):{formatStorageSize(quotaSnapshot.usedBytes)} /{" "}
|
||||||
<input
|
{formatStorageSize(quotaSnapshot.quotaBytes)}({quotaSnapshot.usedPercent.toFixed(1)}
|
||||||
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}
|
</p>
|
||||||
onChange={(event) =>
|
) : null}
|
||||||
setFormState((previous) => ({
|
|
||||||
...previous,
|
|
||||||
title: event.target.value
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
placeholder="请输入任务标题"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
{taskList.length === 0 ? (
|
||||||
<label className="block text-sm text-muted-foreground">
|
<p className="rounded-xl border border-dashed border-border bg-muted/40 p-4 text-sm text-muted-foreground">
|
||||||
状态
|
还没有任务,点击右上角“新建任务”。
|
||||||
<select
|
</p>
|
||||||
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}
|
<div className="space-y-2">
|
||||||
onChange={(event) =>
|
{taskList.map((task) => {
|
||||||
setFormState((previous) => ({
|
const isActive = task.id === selectedTaskId;
|
||||||
...previous,
|
return (
|
||||||
status: event.target.value as LocalTaskStatus
|
<button
|
||||||
}))
|
key={task.id}
|
||||||
}
|
type="button"
|
||||||
>
|
className={cn(
|
||||||
{STATUS_OPTIONS.map((option) => (
|
"w-full rounded-xl border px-3 py-2 text-left transition-colors",
|
||||||
<option key={option.value} value={option.value}>
|
isActive
|
||||||
{option.label}
|
? "border-primary/45 bg-primary/10"
|
||||||
</option>
|
: "border-border bg-background hover:border-primary/25 hover:bg-primary/5"
|
||||||
))}
|
)}
|
||||||
</select>
|
onClick={() => setSelectedTaskId(task.id)}
|
||||||
</label>
|
>
|
||||||
|
<p className="truncate text-sm font-medium text-foreground">{task.title}</p>
|
||||||
<label className="block text-sm text-muted-foreground">
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
优先级
|
{task.status} · {task.priority} · 更新于 {formatUpdatedAt(task.updatedAt)}
|
||||||
<select
|
</p>
|
||||||
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"
|
</button>
|
||||||
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>
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
<label className="block text-sm text-muted-foreground">
|
<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">
|
||||||
<input
|
<h2 className="text-base font-semibold text-foreground">任务详情</h2>
|
||||||
type="datetime-local"
|
<div className="flex items-center gap-2">
|
||||||
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"
|
<Button
|
||||||
value={formState.ddlInput}
|
type="button"
|
||||||
onChange={(event) =>
|
variant="outline"
|
||||||
setFormState((previous) => ({
|
onClick={handleSaveTask}
|
||||||
...previous,
|
disabled={!selectedTaskId || saving}
|
||||||
ddlInput: event.target.value
|
>
|
||||||
}))
|
{saving ? "保存中..." : "保存"}
|
||||||
}
|
</Button>
|
||||||
/>
|
<Button
|
||||||
</label>
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="border-destructive/50 text-destructive hover:bg-destructive/10"
|
||||||
|
onClick={handleDeleteTask}
|
||||||
|
disabled={!selectedTaskId || deleting}
|
||||||
|
>
|
||||||
|
{deleting ? "删除中..." : "删除"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="block text-sm text-muted-foreground">
|
{!selectedTaskId || !selectedTask ? (
|
||||||
<p>任务内容</p>
|
<p className="rounded-xl border border-dashed border-border bg-muted/40 p-4 text-sm text-muted-foreground">
|
||||||
<div className="mt-1">
|
请选择一个任务进行编辑。
|
||||||
<TaskRichEditor
|
</p>
|
||||||
valueJson={formState.contentJson}
|
) : (
|
||||||
textFallback={formState.contentText}
|
<div className="space-y-3">
|
||||||
onChange={(payload) =>
|
<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) => ({
|
setFormState((previous) => ({
|
||||||
...previous,
|
...previous,
|
||||||
contentJson: payload.json,
|
title: event.target.value
|
||||||
contentText: payload.text
|
}))
|
||||||
|
}
|
||||||
|
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>
|
||||||
|
|
||||||
|
<div className="block text-sm text-muted-foreground">
|
||||||
|
<p>任务内容</p>
|
||||||
|
<div className="mt-1">
|
||||||
|
<TaskRichEditor
|
||||||
|
valueJson={formState.contentJson}
|
||||||
|
textFallback={formState.contentText}
|
||||||
|
onChange={(payload) =>
|
||||||
|
setFormState((previous) => ({
|
||||||
|
...previous,
|
||||||
|
contentJson: payload.json,
|
||||||
|
contentText: payload.text
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
</section>
|
||||||
|
</div>
|
||||||
{feedback ? <p className="mt-3 text-xs text-primary">{feedback}</p> : null}
|
</>
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user