finance-tools/src/pages/Profile.tsx

540 lines
22 KiB
TypeScript
Raw Normal View History

2026-06-21 20:28:06 +00:00
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
2026-06-22 09:16:20 +00:00
import { useQuery, useQueryClient } from "@tanstack/react-query";
2026-06-21 20:28:06 +00:00
import { AttributeRadar } from "../components/AttributeRadar";
import { AppIcon } from "../components/PixelIcons";
import { Card, ErrorState, Loading, Tag } from "../components/ui";
import { usePlayerProgress } from "../hooks/usePlayerProgress";
import { api } from "../lib/api";
import { syncPlayerProgress } from "../lib/learningSync";
import {
LEVEL_TITLES,
type AchievementCategory,
type BranchEpithet,
type EpithetBranchKind,
} from "../lib/playerProgress";
const EPITHET_BRANCH_ORDER: EpithetBranchKind[] = [
"macro",
"pick",
"timing",
"discipline",
"pattern",
"strategy",
];
function fmtPct(v: number | null) {
if (v == null || Number.isNaN(v)) return "—";
return `${v.toFixed(0)}%`;
}
const TIER_LABEL = { bronze: "銅", silver: "銀", gold: "金", legend: "傳說" } as const;
export default function Profile() {
const qc = useQueryClient();
const { player, isLoading, isError } = usePlayerProgress();
const [resetBusy, setResetBusy] = useState(false);
const [resetMsg, setResetMsg] = useState<string | null>(null);
const [nameDraft, setNameDraft] = useState("");
const [nameBusy, setNameBusy] = useState(false);
const [nameMsg, setNameMsg] = useState<string | null>(null);
const [epithetBusy, setEpithetBusy] = useState<string | null>(null);
const [epithetMsg, setEpithetMsg] = useState<string | null>(null);
useEffect(() => {
if (player?.displayName) setNameDraft(player.displayName);
}, [player?.displayName]);
const equipEpithet = async (id: string | null) => {
setEpithetBusy(id ?? "auto");
setEpithetMsg(null);
try {
const res = await api.setPlayerEpithet(id);
syncPlayerProgress(qc);
setEpithetMsg(res.message || "已更新稱號");
} catch (e) {
setEpithetMsg((e as Error).message || "裝備失敗");
} finally {
setEpithetBusy(null);
}
};
const saveName = async () => {
const trimmed = nameDraft.trim();
if (trimmed.length < 2) {
setNameMsg("名稱至少 2 個字。");
return;
}
setNameBusy(true);
setNameMsg(null);
try {
const res = await api.setPlayerName(trimmed);
syncPlayerProgress(qc);
setNameDraft(res.displayName || trimmed);
setNameMsg(res.message || "已儲存");
} catch (e) {
setNameMsg((e as Error).message || "儲存失敗");
} finally {
setNameBusy(false);
}
};
const resetCharacter = async () => {
const ok = window.confirm(
"確定要重置角色養成?\n\n將清空心法進度、試煉、筆記、圖鑑、EXP金幣徽章以及交易上的復盤欄位。\n交易紀錄與帳號會保留。",
);
if (!ok) return;
setResetBusy(true);
setResetMsg(null);
try {
const res = await api.resetPlayerCharacter();
syncPlayerProgress(qc);
qc.invalidateQueries({ queryKey: ["skill-state"] });
qc.invalidateQueries({ queryKey: ["pattern-state"] });
qc.invalidateQueries({ queryKey: ["trades"] });
qc.invalidateQueries({ queryKey: ["trade-stats"] });
setResetMsg(res.message || "角色已重置。");
} catch (e) {
setResetMsg((e as Error).message || "重置失敗");
} finally {
setResetBusy(false);
}
};
if (isLoading) return <Loading label="載入角色資料…" />;
if (isError || !player) return <ErrorState detail="無法載入角色養成資料" />;
const xpPct = player.xpToNext ? Math.round((player.xpInLevel / player.xpToNext) * 100) : 100;
const unlockedCount = player.achievements.filter((a) => a.unlocked).length;
const questsByChapter = player.quests.reduce<Record<string, typeof player.quests>>((acc, q) => {
(acc[q.chapter] ||= []).push(q);
return acc;
}, {});
const epithetsByBranch = (player.epithets || []).reduce<Record<string, BranchEpithet[]>>((acc, e) => {
(acc[e.branch] ||= []).push(e);
return acc;
}, {});
const achievementsByCategory = player.achievements.reduce<
Record<AchievementCategory, typeof player.achievements>
>((acc, a) => {
(acc[a.category] ||= []).push(a);
return acc;
}, {} as Record<AchievementCategory, typeof player.achievements>);
return (
<>
<div className="page-head">
<div className="eyebrow"></div>
<h1 className="page-title-row">
<AppIcon name="wizard" size={38} framed glow variant="hero" />
</h1>
<p>EXP </p>
</div>
<Card className="profile-hero-card">
<div className="profile-hero">
<div className="profile-hero-avatar">
<AppIcon name="wizard" size={48} framed glow variant="hero" />
</div>
<div className="profile-hero-body">
<div className="profile-hero-top">
<div>
<div className="profile-name-form">
<label className="small muted" htmlFor="profile-display-name">
</label>
<div className="profile-name-row">
<input
id="profile-display-name"
className="profile-name-input"
value={nameDraft}
maxLength={16}
placeholder="取一個名字…"
onChange={(e) => setNameDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void saveName();
}}
/>
<button
type="button"
className="btn-ghost sm"
disabled={nameBusy || nameDraft.trim() === player.displayName}
onClick={() => void saveName()}
>
{nameBusy ? "儲存中…" : "儲存"}
</button>
</div>
{nameMsg ? <span className="small profile-name-msg">{nameMsg}</span> : null}
</div>
<div className="profile-hero-title">
Lv.{player.level} · {player.title}
{player.epithet ? <span className="profile-hero-epithet">{player.epithet}</span> : null}
</div>
<span className="profile-stage-tag">
<Tag tone="gold">{player.learningStage}</Tag>
</span>
</div>
<div className="profile-hero-gold">
<AppIcon name="coin" size={20} framed variant="nav" />
<span>{player.gold.toLocaleString()} G</span>
</div>
</div>
<p className="small profile-stage-hint">{player.learningStageHint}</p>
<div className="xp-label">
<span>EXP</span>
<span>
{player.xpInLevel.toLocaleString()} / {player.xpToNext.toLocaleString()}
</span>
</div>
<div className="bar">
<span style={{ width: `${xpPct}%` }} />
</div>
<p className="small muted profile-hero-xp-note"> {player.totalXp.toLocaleString()} EXP</p>
</div>
</div>
</Card>
<div className="grid g2 mt">
<Card title="冒險階級" ico={<AppIcon name="key" size={22} framed variant="nav" />}>
<p className="small muted" style={{ marginTop: 0, marginBottom: 12 }}>
<b>Lv</b>
</p>
<div className="profile-title-ladder">
{LEVEL_TITLES.map((t) => {
const unlocked = player.level >= t.minLevel;
const current = player.title === t.title;
return (
<div
key={t.minLevel}
className={`profile-title-step${unlocked ? " unlocked" : ""}${current ? " current" : ""}`}
>
<span className="profile-title-lv">Lv.{t.minLevel}</span>
<span className="profile-title-label">{t.title}</span>
{current ? <Tag tone="gold"></Tag> : unlocked ? <Tag></Tag> : <Tag></Tag>}
</div>
);
})}
</div>
</Card>
<Card title="支線稱號 · 裝備" ico={<AppIcon name="sword" size={22} framed variant="nav" />}>
<p className="small muted" style={{ marginTop: 0, marginBottom: 10 }}>
<b></b><b></b>
{player.dominantAttribute
? ` 目前主屬性:${player.attributes.find((a) => a.id === player.dominantAttribute)?.label || "—"}`
: " 多修煉後會出現主屬性。"}
</p>
<div className="profile-epithet-toolbar">
<button
type="button"
className={`btn-ghost sm${player.equippedEpithetId === "auto" || !player.equippedEpithetId ? " active" : ""}`}
disabled={epithetBusy !== null}
onClick={() => void equipEpithet(null)}
>
</button>
{player.epithet ? <Tag tone="gold">{player.epithet}</Tag> : <Tag></Tag>}
</div>
{epithetMsg ? <p className="small profile-name-msg">{epithetMsg}</p> : null}
</Card>
</div>
<Card title="支線稱號圖鑑" ico={<AppIcon name="map" size={22} framed variant="nav" />} className="mt">
<p className="small muted" style={{ marginTop: 0, marginBottom: 14 }}>
</p>
{EPITHET_BRANCH_ORDER.map((branch) => {
const items = epithetsByBranch[branch];
if (!items?.length) return null;
return (
<div key={branch} className="profile-epithet-branch">
<div className="profile-epithet-branch-title">{items[0].branchLabel}</div>
<div className="profile-epithet-grid">
{items.map((e) => {
const equipped = player.epithetId === e.id;
const isAutoEquipped =
(player.equippedEpithetId === "auto" || !player.equippedEpithetId) && equipped;
return (
<div
key={e.id}
className={`profile-epithet-card${e.unlocked ? " unlocked" : " locked"}${equipped ? " equipped" : ""}`}
>
<AppIcon name={e.icon} size={22} framed variant="nav" glow={e.unlocked && equipped} />
<div className="profile-epithet-body">
<div className="profile-epithet-name">{e.label}</div>
<div className="small muted">{e.desc}</div>
<div className="small muted profile-epithet-req">{e.requirement}</div>
{!e.unlocked && e.progress ? (
<div className="small profile-epithet-progress">{e.progress}</div>
) : null}
</div>
{e.unlocked ? (
<button
type="button"
className="btn-ghost sm"
disabled={epithetBusy !== null || equipped}
onClick={() => void equipEpithet(e.id)}
>
{epithetBusy === e.id
? "…"
: equipped
? isAutoEquipped
? "自動中"
: "已裝備"
: "裝備"}
</button>
) : (
<Tag></Tag>
)}
</div>
);
})}
</div>
</div>
);
})}
</Card>
<Card
title="經驗值怎麼來?"
ico={<AppIcon name="hourglass" size={22} framed variant="nav" />}
className="mt"
>
<p className="small muted" style={{ marginTop: 0 }}>
<b> &gt; </b><b> &gt; </b><b> &gt; </b>
</p>
<div className="profile-xp-grid">
{player.xpBreakdown.map((line) => (
<div key={line.label} className="profile-xp-line" title={line.hint}>
<div className="profile-xp-line-head">
<span>{line.label}</span>
<strong>+{line.xp.toLocaleString()}</strong>
</div>
<p className="small muted">{line.hint}</p>
</div>
))}
</div>
</Card>
<div className="grid g2 mt">
<Card title="五維屬性" ico={<AppIcon name="compass" size={22} framed variant="nav" />}>
<p className="small muted" style={{ marginTop: 0, marginBottom: 12 }}>
滿 100
</p>
<div className="radar-wrap">
<AttributeRadar attributes={player.attributes} />
<div className="attr-list">
{player.attributes.map((attr) => (
<div key={attr.id} className="attr-block">
<div className="attr-row" title={attr.hint}>
<span className="nm">{attr.label}</span>
<div className="bar">
<span style={{ width: `${attr.value}%` }} />
</div>
<span className="vv">{attr.value}</span>
</div>
<p className="small muted attr-formula-hint">{attr.hint}</p>
<div className="attr-breakdown">
<div className="attr-breakdown-head">
<span></span>
<span>
{attr.earned}/{attr.max}
</span>
</div>
{attr.breakdown.map((line) => (
<div key={line.label} className="attr-breakdown-row" title={line.detail}>
<span className="attr-bd-label">{line.label}</span>
<span className="attr-bd-score">
{line.earned}/{line.max}
</span>
<span className="attr-bd-detail">{line.detail}</span>
</div>
))}
</div>
</div>
))}
</div>
</div>
</Card>
<Card title="學習戰績" ico={<AppIcon name="chart" size={22} framed variant="nav" />}>
<div className="dl">
<div className="row">
<span className="k"></span>
<span className="v">
{player.skillStats.read} / {player.skillStats.total}
</span>
</div>
<div className="row">
<span className="k"></span>
<span className="v">{player.skillStats.mastered} </span>
</div>
<div className="row">
<span className="k"></span>
<span className="v">
{player.patternStats.collected} / {player.patternStats.total}
</span>
</div>
<div className="row">
<span className="k"></span>
<span className="v">{player.journalStats.withReflection} </span>
</div>
<div className="row">
<span className="k"></span>
<span className="v">{player.journalStats.withPrinciple} </span>
</div>
<div className="row">
<span className="k"></span>
<span className="v">{player.journalStats.distinctPrinciples} </span>
</div>
<div className="row">
<span className="k"></span>
<span className="v">{fmtPct(player.journalStats.discipline)}</span>
</div>
</div>
<div className="profile-quick-links">
<Link to="/skills" className="btn-ghost sm">
<AppIcon name="scroll" size={16} framed variant="nav" />
</Link>
<Link to="/patterns" className="btn-ghost sm">
<AppIcon name="cards" size={16} framed variant="nav" />
</Link>
<Link to="/journal" className="btn-ghost sm">
<AppIcon name="folder" size={16} framed variant="nav" />
</Link>
</div>
</Card>
</div>
<Card
title="冒險委託 · 投資學習路線"
ico={<AppIcon name="map" size={22} framed variant="nav" />}
className="mt"
>
<p className="small muted" style={{ marginTop: 0, marginBottom: 12 }}>
</p>
{Object.entries(questsByChapter).map(([chapter, items]) => (
<div key={chapter} className="profile-quest-chapter">
<div className="profile-quest-chapter-title">{chapter}</div>
{items.map((q) => (
<div key={q.id} className={`quest${q.done ? " done" : ""}`}>
<span className="chk">{q.done ? "✓" : ""}</span>
<div className="q-body">
<div className="q-title">
{q.title}
{q.progress ? <span className="profile-quest-progress"> {q.progress}</span> : null}
</div>
<div className="q-sub">{q.sub}</div>
<div className="profile-quest-why">{q.why}</div>
</div>
<span className="q-xp">+{q.xp} EXP</span>
{q.href && !q.done ? (
<Link to={q.href} className="profile-quest-go">
</Link>
) : null}
</div>
))}
</div>
))}
</Card>
<Card
title={`成就徽章 · ${unlockedCount}/${player.achievements.length}`}
ico={<AppIcon name="coin" size={22} framed variant="nav" />}
className="mt"
>
<div className="profile-ach-summary">
{player.achievementCategories.map((c) => (
<span key={c.id} className="profile-ach-pill">
{c.label} {c.unlocked}/{c.total}
</span>
))}
</div>
{player.achievementCategories.map((cat) => {
const items = achievementsByCategory[cat.id] || [];
if (!items.length) return null;
return (
<div key={cat.id} className="profile-ach-section">
<div className="profile-ach-section-title">
{cat.label}
<span className="muted small">
{cat.unlocked}/{cat.total}
</span>
</div>
<div className="ach-grid">
{items.map((a) => (
<div key={a.id} className={`ach${a.unlocked ? " unlocked" : " locked"}`}>
<div className="a-ic">
<AppIcon name={a.icon} size={22} framed variant="nav" glow={a.unlocked} />
</div>
<div>
<div className="a-nm">
{a.name}
{a.tier ? (
<span className={`profile-ach-tier tier-${a.tier}`}>{TIER_LABEL[a.tier]}</span>
) : null}
</div>
<div className="a-d">{a.desc}</div>
</div>
</div>
))}
</div>
</div>
);
})}
</Card>
<Card title="重置角色" ico={<AppIcon name="hourglass" size={22} framed variant="nav" />} className="mt">
<p className="small muted" style={{ marginTop: 0 }}>
Lv.1
</p>
{resetMsg ? <p className="small" style={{ color: "var(--teal)" }}>{resetMsg}</p> : null}
<button type="button" className="btn-ghost sm" disabled={resetBusy} onClick={() => void resetCharacter()}>
{resetBusy ? "重置中…" : "清空角色養成"}
</button>
</Card>
2026-06-22 09:16:20 +00:00
<Card title="內容統計" ico={<AppIcon name="folder" size={22} framed variant="nav" />} className="mt">
<ContentReadingStats />
</Card>
<Card title="重置角色" ico={<AppIcon name="hourglass" size={22} framed variant="nav" />} className="mt">
<p className="small muted" style={{ marginTop: 0 }}>
Lv.1
</p>
{resetMsg ? <p className="small" style={{ color: "var(--teal)" }}>{resetMsg}</p> : null}
<button type="button" className="btn-ghost sm" disabled={resetBusy} onClick={() => void resetCharacter()}>
{resetBusy ? "重置中…" : "清空角色養成"}
</button>
</Card>
2026-06-21 20:28:06 +00:00
</>
);
2026-06-22 09:16:20 +00:00
}
function ContentReadingStats() {
const q = useQuery({
queryKey: ["content-stats"],
queryFn: () => api.contentStats(),
staleTime: 30000,
});
if (!q.data) return null;
const stats = q.data;
const total = stats.total || 0;
if (!total) return <p className="muted small"> YouTube HyRead </p>;
return (
<div className="dl">
<div className="row"><span className="k">YouTube </span><span className="v">{stats.byKind?.youtube?.ready || 0} </span></div>
<div className="row"><span className="k">HyRead </span><span className="v">{stats.byKind?.hyread?.ready || 0} </span></div>
<div className="row"><span className="k"></span><span className="v">{total} </span></div>
</div>
);
2026-06-21 20:28:06 +00:00
}