Files
Kokorone/apps/web/app/tasks/TasksView.tsx
jiantw83andClaude Sonnet 5 1bea06e3cd feat(遊戲化): 新增大廳、附身、時間流、後日談進度等遊戲化系統功能
新增角色大廳(lobby)邀約與通話、附身(possession)、時鐘(clock)抽象、
時間流與睡眠負債、後日談進度、行為選擇與旁觀反應等模組,並補齊對應冒煙
測試(R~V)與單元測試;同步調整 web/mobile 對應頁面與元件。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 09:10:26 +08:00

265 lines
9.3 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useEffect, useState } from "react";
import {
completeTask,
createTask,
failTask,
fireTaskNow,
listTasks,
parseTask,
type ParseTaskResult,
type Task,
type TaskType,
} from "../../lib/api";
const CHARACTER_ID = "seed-character-genki";
const USER_ID = "seed-user-primary";
const TASK_TYPE_LABELS: Record<TaskType, string> = {
ONE_TIME: "單次提醒",
RECURRING: "週期提醒",
CONDITIONAL: "條件提醒",
QUERY_ORGANIZE: "查資料/整理",
TODO_TRACKING: "代辦追蹤",
};
const STATUS_LABELS: Record<Task["status"], string> = {
PENDING: "尚未觸發",
TRIGGERED: "已觸發,等候回應",
COMPLETED: "已完成",
FAILED: "失敗",
CANCELLED: "已取消",
};
function isOverdue(task: Task): boolean {
return task.status === "TRIGGERED" && task.overdueNudgeCount > 0;
}
// J-8 委託介面:自然語言輸入→(辨識成功即直接建立/辨識失敗則手動表單建立)→列表→觸發→完成或失敗,
// 對應計畫§委託子系統「角色不只陪聊,還能接受委託」。
export function TasksView() {
const [text, setText] = useState("");
const [tasks, setTasks] = useState<Task[]>([]);
const [parseResult, setParseResult] = useState<ParseTaskResult | null>(null);
const [manualDraft, setManualDraft] = useState<{ content: string; triggerAt: string } | null>(null);
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
async function refresh() {
try {
setTasks(await listTasks(CHARACTER_ID, USER_ID));
} catch {
setError("無法連線到 api 服務,委託清單暫時無法顯示");
}
}
useEffect(() => {
void refresh();
}, []);
async function handleParse() {
const trimmed = text.trim();
if (!trimmed || pending) {
return;
}
setPending(true);
setError(null);
setParseResult(null);
setManualDraft(null);
try {
const result = await parseTask(CHARACTER_ID, USER_ID, trimmed);
setParseResult(result);
if (result.created) {
setText("");
await refresh();
} else {
// 辨識失敗:退回手動表單,讓使用者自己補齊內容後透過 createTask() 建立。
setManualDraft({ content: trimmed, triggerAt: "" });
}
} catch {
setError("委託解析失敗,請確認 api 服務是否已啟動");
} finally {
setPending(false);
}
}
async function handleManualCreate() {
if (!manualDraft || !manualDraft.content.trim() || pending) {
return;
}
setPending(true);
setError(null);
try {
await createTask(CHARACTER_ID, USER_ID, {
type: "TODO_TRACKING",
content: manualDraft.content.trim(),
rawText: text.trim() || manualDraft.content.trim(),
triggerAt: manualDraft.triggerAt ? new Date(manualDraft.triggerAt).toISOString() : undefined,
});
setText("");
setManualDraft(null);
setParseResult(null);
await refresh();
} catch {
setError("委託建立失敗,請確認 api 服務是否已啟動");
} finally {
setPending(false);
}
}
async function handleAction(action: (taskId: string) => Promise<Task>, taskId: string) {
setPending(true);
setError(null);
try {
await action(taskId);
await refresh();
} catch {
setError("操作失敗,請確認 api 服務是否已啟動");
} finally {
setPending(false);
}
}
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2 rounded-2xl border p-4" style={{ borderColor: "var(--line)" }}>
<label className="text-xs" style={{ color: "var(--ink-soft)" }}>
跟她說一句話,讓她幫你記著(例如「三點提醒我開會」)
</label>
<div className="flex gap-2">
<input
value={text}
onChange={(event) => setText(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
void handleParse();
}
}}
placeholder="三點提醒我開會"
className="flex-1 rounded-full border bg-transparent px-3 py-2 text-sm outline-none"
style={{ borderColor: "var(--line)", color: "var(--ink)" }}
disabled={pending}
/>
<button
type="button"
onClick={() => void handleParse()}
disabled={pending}
className="rounded-full px-4 py-2 text-sm font-bold text-white disabled:opacity-50"
style={{ background: "var(--accent-bright)" }}
>
送出
</button>
</div>
{parseResult?.created && (
<p className="text-xs" style={{ color: "var(--accent)" }}>
已建立委託:{parseResult.task?.content}
{parseResult.performanceLine ? `——${parseResult.performanceLine}` : ""}
</p>
)}
{manualDraft && (
<div className="flex flex-col gap-2 rounded-xl p-3 text-xs" style={{ background: "var(--bg-soft)" }}>
<p style={{ color: "var(--ink-soft)" }}>沒辨識出明確時間,改用手動表單建立(分類為「代辦追蹤」):</p>
<input
value={manualDraft.content}
onChange={(event) => setManualDraft({ ...manualDraft, content: event.target.value })}
placeholder="委託內容"
className="rounded-full border bg-transparent px-3 py-1.5"
style={{ borderColor: "var(--line)", color: "var(--ink)" }}
/>
<input
type="datetime-local"
value={manualDraft.triggerAt}
onChange={(event) => setManualDraft({ ...manualDraft, triggerAt: event.target.value })}
className="rounded-full border bg-transparent px-3 py-1.5"
style={{ borderColor: "var(--line)", color: "var(--ink)" }}
/>
<button
type="button"
onClick={() => void handleManualCreate()}
disabled={pending}
className="self-start rounded-full px-4 py-1.5 font-bold text-white disabled:opacity-50"
style={{ background: "var(--accent-bright)" }}
>
確認建立
</button>
</div>
)}
{error && (
<p className="text-xs" style={{ color: "var(--accent)" }}>
{error}
</p>
)}
</div>
<div className="flex flex-col gap-2">
{tasks.length === 0 && (
<p className="text-xs" style={{ color: "var(--ink-soft)" }}>
目前沒有委託。
</p>
)}
{tasks.map((task) => (
<div key={task.id} className="flex flex-col gap-1.5 rounded-2xl border p-3 text-xs" style={{ borderColor: "var(--line)" }}>
<div className="flex items-center justify-between">
<span className="font-bold" style={{ color: "var(--ink)" }}>
{task.content}
</span>
<span
className="rounded-full px-2 py-0.5"
style={{
background: isOverdue(task) ? "var(--accent-bright)" : "var(--chip-bg)",
color: isOverdue(task) ? "#fff" : "var(--accent)",
}}
>
{isOverdue(task) ? "逾期未回應" : STATUS_LABELS[task.status]}
</span>
</div>
<span style={{ color: "var(--ink-soft)" }}>
{TASK_TYPE_LABELS[task.type]}
{task.triggerAt ? `.${new Date(task.triggerAt).toLocaleString("zh-TW")}` : ""}
</span>
{task.triggerMessage && <span style={{ color: "var(--ink-soft)" }}>她說:{task.triggerMessage}</span>}
{(task.status === "PENDING" || task.status === "TRIGGERED") && (
<div className="flex gap-2 pt-1">
{task.status === "PENDING" && (
<button
type="button"
onClick={() => void handleAction((id) => fireTaskNow(id).then(() => listTasks(CHARACTER_ID, USER_ID).then((all) => all.find((t) => t.id === id)!)), task.id)}
disabled={pending}
className="rounded-full border px-3 py-1 disabled:opacity-50"
style={{ borderColor: "var(--line)", color: "var(--ink-soft)" }}
>
立即觸發(測試用)
</button>
)}
<button
type="button"
onClick={() => void handleAction(completeTask, task.id)}
disabled={pending}
className="rounded-full px-3 py-1 font-bold text-white disabled:opacity-50"
style={{ background: "var(--accent-bright)" }}
>
完成
</button>
<button
type="button"
onClick={() => void handleAction(failTask, task.id)}
disabled={pending}
className="rounded-full border px-3 py-1 disabled:opacity-50"
style={{ borderColor: "var(--line)", color: "var(--ink-soft)" }}
>
失敗
</button>
</div>
)}
</div>
))}
</div>
</div>
);
}