finance-tools/src/pages/Profile.tsx

540 lines
22 KiB
TypeScript
Raw 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.

import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { useQuery, useQueryClient } from "@tanstack/react-query";
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>
<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>
</>
);
}
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>
);
}