feat(遊戲化): 新增大廳、附身、時間流、後日談進度等遊戲化系統功能
新增角色大廳(lobby)邀約與通話、附身(possession)、時鐘(clock)抽象、 時間流與睡眠負債、後日談進度、行為選擇與旁觀反應等模組,並補齊對應冒煙 測試(R~V)與單元測試;同步調整 web/mobile 對應頁面與元件。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user