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>理解 > 勝率</b>、<b>反思 > 交易次數</b>、<b>精通 > 瀏覽</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
|
|
|
|
}
|