Files
dcalc/frontendNew/src/pages/Dcalc/Character/Skill/index.vue
T
2026-06-02 20:56:07 +08:00

987 lines
36 KiB
Vue
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.
<script setup lang="ts">
import { computed, ref } from "vue";
import { useConfigStore, useInfoStore } from "@/stores";
import { Button } from "@/components/ui/button";
import Tabs from "@/components/ui/tabs/Tabs.vue";
import TabsList from "@/components/ui/tabs/TabsList.vue";
import TabsTrigger from "@/components/ui/tabs/TabsTrigger.vue";
import { getImageURL } from "@/utils";
import type { ISkill } from "@/api/info/type";
import ResultPanel from "@/pages/Dcalc/Character/Result/Index.vue";
import SkillDetailPanel from "./components/SkillDetail/index.vue";
import vp1Img from "./img/vp_1.png";
import vp2Img from "./img/vp_2.png";
import up1Img from "./img/up_1.png";
import up2Img from "./img/up_2.png";
const vpImg = (mode: number) => (mode === 1 ? vp1Img : vp2Img);
const upImg = (mode: number) => (mode === 1 ? up1Img : up2Img);
const infoStore = useInfoStore();
const configStore = useConfigStore();
/* ─── UI state ─── */
const tab = ref(0); // 0 = 技能加点 | 1 = 技能定制
const activeSkillId = ref("");
const notice = ref("");
const noticeType = ref<"error" | "info">("info");
const openUpPickerSlot = ref<number | null>(null);
const rightPanelTab = ref<"detail" | "result">("result");
const leftPanelWidthClass = computed(() =>
rightPanelTab.value === "result" ? "xl:w-[360px]" : "xl:w-[550px]",
);
/* ─── Constants ─── */
const VP_LIMIT = 5;
const UP_LIMIT = 3;
const UP_SLOT_COUNT = 3;
// Match original lvList
const lvList = [15, 20, 25, 30, 35, 40, 45, 50, 60, 70, 80, 90, 100];
const COLUMNS = 11; // positions 0-10
/* ─── Tree row data ─── */
interface ITreeRow {
label: string;
lv: number;
isExtra: boolean;
extraSkills: ISkill[];
cells: (ISkill | null)[];
}
const getSkillsInRange = (lvStart: number, lvEnd: number): ISkill[] =>
infoStore.skills.filter((s) => s.line > lvStart - 5 && s.line <= Math.max(lvEnd - 5, 1));
const getSkillAt = (lvStart: number, lvEnd: number, col: number): ISkill | null =>
infoStore.skills.find(
(s) => s.line > lvStart - 5 && s.line <= Math.max(lvEnd - 5, 1) && s.position === col,
) ?? null;
const treeRows = computed((): ITreeRow[] => {
const rows: ITreeRow[] = [];
const extraSkills = getSkillsInRange(0, 15);
if (extraSkills.length > 0) {
rows.push({ label: "其余", lv: 0, isExtra: true, extraSkills, cells: [] });
}
lvList.forEach((lv, idx) => {
const nextLv = lvList[idx + 1] ?? 150;
const inRange = getSkillsInRange(lv, nextLv);
if (inRange.length === 0) return;
const label = lv % 10 === 0 ? String(lv) : "";
const cells = Array.from({ length: COLUMNS }, (_, col) => getSkillAt(lv, nextLv, col));
rows.push({ label, lv, isExtra: false, extraSkills: [], cells });
});
return rows;
});
/* ─── Config helpers ─── */
const setNotice = (msg: string, type: "error" | "info" = "info") => {
notice.value = msg;
noticeType.value = type;
};
const track = (detail: string) => configStore.pushChangeSource("Skill", detail);
const skillById = (id: string) => infoStore.skills.find((s) => s.id.toString() === id);
const skillCfg = (id: string) => {
if (!configStore.config.skills[id]) {
const skill = skillById(id);
configStore.config.skills[id] = { lv: skill?.maxLearnLv ?? 0, vp: 0, up: 0 };
}
return configStore.config.skills[id];
};
/* ─── Level color ─── */
const lvColor = (id: string, maxLearnLv: number): string => {
const lv = skillCfg(id).lv;
if (lv < maxLearnLv) return "#32E432";
if (lv > maxLearnLv) return "#58E0FC";
return "currentColor";
};
/* ─── Active / detail ─── */
const selectedSkill = computed(() =>
activeSkillId.value ? skillById(activeSkillId.value) : undefined,
);
const chooseSkill = (id: string) => {
activeSkillId.value = id;
};
const clearActive = () => {
activeSkillId.value = "";
};
/* ─── Level actions ─── */
const updateLv = (id: string, value: number) => {
const target = skillCfg(id);
const skill = skillById(id);
if (!skill) return;
const max = Math.max(skill.maxLv ?? 0, skill.learnLv ?? 0);
const next = Math.min(Math.max(value, 0), max);
target.lv = next;
track(`${skill.name} 等级调整为 ${next}`);
if (target.lv <= 0 && target.up > 0) {
target.up = 0;
track(`${skill.name} 等级归零,UP 自动清空`);
}
};
const stepLv = (id: string, step: number) => updateLv(id, skillCfg(id).lv + step);
const setMaxLv = (id: string) => updateLv(id, skillById(id)?.maxLearnLv ?? 0);
const resetLv = (id: string) => updateLv(id, 0);
/* ─── VP ─── */
const usedVPCount = computed(
() =>
infoStore.skills
.filter((s) => s.hasVP)
.filter((s) => (configStore.config.skills[s.id.toString()]?.vp ?? 0) > 0).length,
);
const vpSkills = computed(() =>
infoStore.skills.filter((s) => s.hasVP).sort((a, b) => a.learnLv - b.learnLv),
);
const setVPMode = (id: string, mode: 0 | 1 | 2) => {
const skill = skillById(id);
if (!skill) return;
if (!skill.hasVP && mode !== 0) {
setNotice(`${skill.name} 不支持 VP。`, "error");
return;
}
const target = skillCfg(id);
if (target.vp === 0 && mode > 0 && usedVPCount.value >= VP_LIMIT) {
setNotice(`VP 最多选 ${VP_LIMIT} 个技能。`, "error");
return;
}
target.vp = mode;
track(`${skill.name} VP → ${mode === 0 ? "关闭" : `形态${mode}`}`);
};
/* ─── UP ─── */
const upSelectedIds = computed(() =>
infoStore.skills
.filter((s) => s.hasUP && (configStore.config.skills[s.id.toString()]?.up ?? 0) > 0)
.map((s) => s.id.toString())
.sort((a, b) => (skillById(a)?.learnLv ?? 0) - (skillById(b)?.learnLv ?? 0)),
);
const usedUPCount = computed(() => upSelectedIds.value.length);
const upSlots = computed(() => {
const slots = upSelectedIds.value.map((id) => ({
skillId: id,
mode: (configStore.config.skills[id]?.up ?? 1) as 1 | 2,
}));
while (slots.length < UP_SLOT_COUNT) slots.push({ skillId: "", mode: 1 as 1 | 2 });
return slots.slice(0, UP_SLOT_COUNT);
});
const upOptionsForSlot = (slotIdx: number) => {
const currentId = upSlots.value[slotIdx]?.skillId ?? "";
const others = new Set(
upSlots.value.map((s, i) => (i === slotIdx ? "" : s.skillId)).filter(Boolean),
);
return infoStore.skills
.filter((s) => s.hasUP)
.filter((s) => {
const id = s.id.toString();
const lv = configStore.config.skills[id]?.lv ?? 0;
return (lv > 0 || id === currentId) && (!others.has(id) || id === currentId);
})
.sort((a, b) => a.learnLv - b.learnLv);
};
const firstAwakeSkill = computed(() =>
infoStore.skills.find((item) => item.learnLv === 50 && item.type === "active" && item.bind),
);
const secondAwakeSkill = computed(() =>
infoStore.skills.find((item) => item.learnLv === 85 && item.type === "active" && item.bind),
);
const canBindAwake = computed(() => !!firstAwakeSkill.value || !!secondAwakeSkill.value);
const setBindAwake = (level: 50 | 85) => {
configStore.config.bindAwake = level;
track(`觉醒绑定切换为 ${level === 50 ? "一觉" : "二觉"}`);
};
const setUPSlotSkill = (slotIdx: number, nextId: string) => {
const slot = upSlots.value[slotIdx];
if (!slot) return;
const prevId = slot.skillId;
if (!nextId) {
if (prevId) {
skillCfg(prevId).up = 0;
track(`${skillById(prevId)?.name ?? prevId} 从 UP 槽位 ${slotIdx + 1} 移除`);
}
openUpPickerSlot.value = null;
return;
}
const target = skillById(nextId);
if (!target) return;
if ((skillCfg(nextId).lv ?? 0) <= 0) {
setNotice(`${target.name} 等级为 0,无法 UP。`, "error");
return;
}
if (upSlots.value.some((s, i) => i !== slotIdx && s.skillId === nextId)) {
setNotice(`${target.name} 已在其他槽位。`, "error");
return;
}
if (!prevId && usedUPCount.value >= UP_LIMIT) {
setNotice(`UP 最多选 ${UP_LIMIT} 个技能。`, "error");
return;
}
if (prevId && prevId !== nextId) skillCfg(prevId).up = 0;
skillCfg(nextId).up = slot.mode;
track(`${target.name} 设置 UP 槽位 ${slotIdx + 1} 形态 ${slot.mode}`);
openUpPickerSlot.value = null;
};
const setUPSlotMode = (slotIdx: number, mode: 1 | 2) => {
const skillId = upSlots.value[slotIdx]?.skillId;
if (!skillId) {
setNotice(`请先为槽位 ${slotIdx + 1} 选择技能。`, "error");
return;
}
skillCfg(skillId).up = mode;
track(`${skillById(skillId)?.name} UP 形态 → ${mode}`);
};
const exchangeUPMode = (slotIdx: number) => {
const slot = upSlots.value[slotIdx];
if (!slot?.skillId) return;
setUPSlotMode(slotIdx, slot.mode === 1 ? 2 : 1);
};
const resetAllSkills = () => {
infoStore.skills.forEach((s) => {
configStore.config.skills[s.id.toString()] = { lv: s.maxLearnLv ?? 0, vp: 0, up: 0 };
});
track("技能配置已重置为默认值");
};
</script>
<template>
<div class="flex h-full flex-col overflow-hidden xl:flex-row" @click.self="clearActive">
<!-- ══ LEFT PANEL ══ -->
<div
:class="[
'max-h-[52vh] w-full shrink-0 border-b border-border/50 xl:max-h-none xl:border-b-0 xl:border-r',
leftPanelWidthClass,
]"
>
<div class="flex h-full flex-col overflow-hidden">
<!-- Tab bar -->
<div
class="h-10 shrink-0 flex border-b border-border/50 bg-muted/20 text-[15px] font-bold select-none"
>
<button
class="flex-1 flex items-center justify-center transition-colors border-b-2"
:class="
tab === 0
? 'border-primary text-foreground'
: 'border-transparent text-muted-foreground hover:text-foreground'
"
@click="tab = 0"
>
技能加点
</button>
<div class="w-px bg-border/40 my-2" />
<button
class="flex-1 flex items-center justify-center transition-colors border-b-2"
:class="
tab === 1
? 'border-primary text-foreground'
: 'border-transparent text-muted-foreground hover:text-foreground'
"
@click="tab = 1"
>
技能定制
</button>
</div>
<!-- ── TAB 0: Skill Tree ── -->
<div
v-show="tab === 0"
class="flex-1 overflow-y-auto overflow-x-auto"
@click.self="clearActive"
>
<div class="min-w-max py-1" @click.self="clearActive">
<template v-for="row in treeRows" :key="row.lv">
<div class="flex items-center px-1 gap-[2px] mb-px">
<!-- Level label: 34×34 (matches original) -->
<div
class="w-[34px] h-[34px] shrink-0 flex items-center justify-center text-[11px] text-muted-foreground leading-[1.2] text-center"
>
<template v-if="row.isExtra">其<br />余</template>
<template v-else>{{ row.label }}</template>
</div>
<!-- "其余": sequential list -->
<template v-if="row.isExtra">
<div
v-for="skill in row.extraSkills"
:key="skill.id"
class="skill-cell"
:title="skill.name"
@click.stop="chooseSkill(skill.id.toString())"
>
<div class="badge-row">
<img
v-if="skillCfg(skill.id.toString()).vp > 0"
:src="vpImg(skillCfg(skill.id.toString()).vp)"
class="badge-img"
/>
<img
v-if="skillCfg(skill.id.toString()).up > 0"
:src="upImg(skillCfg(skill.id.toString()).up)"
class="badge-img"
/>
</div>
<img
:src="getImageURL(skill.icon)"
:alt="skill.name"
class="skill-icon"
:class="[
skill.type === 'passive' ? 'opacity-60' : '',
activeSkillId === skill.id.toString()
? 'ring-2 ring-primary ring-offset-1 ring-offset-background'
: '',
]"
/>
<div
v-if="skill.learnLv === 100 && skill.bind && canBindAwake"
class="awake-bind-panel"
@click.stop
>
<button
v-if="firstAwakeSkill"
class="awake-bind-btn"
:class="{ active: configStore.config.bindAwake === 50 }"
title="绑定一觉"
@click.stop="setBindAwake(50)"
>
<img :src="getImageURL(firstAwakeSkill.icon)" :alt="firstAwakeSkill.name" />
</button>
<button
v-if="secondAwakeSkill"
class="awake-bind-btn"
:class="{ active: configStore.config.bindAwake === 85 }"
title="绑定二觉"
@click.stop="setBindAwake(85)"
>
<img
:src="getImageURL(secondAwakeSkill.icon)"
:alt="secondAwakeSkill.name"
/>
</button>
</div>
<div
v-if="activeSkillId === skill.id.toString()"
class="action-row"
@click.stop
>
<button class="skill-btn" @click.stop="resetLv(skill.id.toString())">
«
</button>
<button class="skill-btn" @click.stop="stepLv(skill.id.toString(), -1)">
‹
</button>
<button class="skill-btn" @click.stop="stepLv(skill.id.toString(), 1)">
›
</button>
<button class="skill-btn" @click.stop="setMaxLv(skill.id.toString())">
»
</button>
</div>
<div
v-else
class="lv-num"
:style="{ color: lvColor(skill.id.toString(), skill.maxLearnLv) }"
>
{{ skillCfg(skill.id.toString()).lv }}
</div>
</div>
</template>
<!-- Normal row: 11 fixed columns -->
<template v-else>
<div
v-for="(cell, colIdx) in row.cells"
:key="colIdx"
class="skill-cell"
:class="cell ? 'cursor-pointer' : 'pointer-events-none'"
:title="cell?.name"
@click.stop="cell && chooseSkill(cell.id.toString())"
>
<template v-if="cell">
<div class="badge-row">
<img
v-if="skillCfg(cell.id.toString()).vp > 0"
:src="vpImg(skillCfg(cell.id.toString()).vp)"
class="badge-img"
/>
<img
v-if="skillCfg(cell.id.toString()).up > 0"
:src="upImg(skillCfg(cell.id.toString()).up)"
class="badge-img"
/>
</div>
<img
:src="getImageURL(cell.icon)"
:alt="cell.name"
class="skill-icon"
:class="[
cell.type === 'passive' ? 'opacity-60' : '',
activeSkillId === cell.id.toString()
? 'ring-2 ring-primary ring-offset-1 ring-offset-background'
: '',
]"
/>
<div
v-if="cell.learnLv === 100 && cell.bind && canBindAwake"
class="awake-bind-panel"
@click.stop
>
<button
v-if="firstAwakeSkill"
class="awake-bind-btn"
:class="{ active: configStore.config.bindAwake === 50 }"
title="绑定一觉"
@click.stop="setBindAwake(50)"
>
<img
:src="getImageURL(firstAwakeSkill.icon)"
:alt="firstAwakeSkill.name"
/>
</button>
<button
v-if="secondAwakeSkill"
class="awake-bind-btn"
:class="{ active: configStore.config.bindAwake === 85 }"
title="绑定二觉"
@click.stop="setBindAwake(85)"
>
<img
:src="getImageURL(secondAwakeSkill.icon)"
:alt="secondAwakeSkill.name"
/>
</button>
</div>
<div
v-if="activeSkillId === cell.id.toString()"
class="action-row"
@click.stop
>
<button class="skill-btn" @click.stop="resetLv(cell.id.toString())">
«
</button>
<button class="skill-btn" @click.stop="stepLv(cell.id.toString(), -1)">
‹
</button>
<button class="skill-btn" @click.stop="stepLv(cell.id.toString(), 1)">
›
</button>
<button class="skill-btn" @click.stop="setMaxLv(cell.id.toString())">
»
</button>
</div>
<div
v-else
class="lv-num"
:style="{ color: lvColor(cell.id.toString(), cell.maxLearnLv) }"
>
{{ skillCfg(cell.id.toString()).lv }}
</div>
</template>
</div>
</template>
</div>
</template>
</div>
</div>
<!-- ── TAB 1: Customization ── -->
<div v-show="tab === 1" class="flex-1 overflow-y-auto flex flex-col min-h-0">
<!-- UP section -->
<div class="shrink-0 border-b border-border/50">
<div
class="px-3 pt-3 pb-1 text-xs font-semibold text-muted-foreground flex justify-between"
>
<span>技能突破 (UP)</span>
<span>{{ usedUPCount }}/{{ UP_LIMIT }}</span>
</div>
<div class="px-3 pb-3 space-y-2">
<div
v-for="(slot, idx) in upSlots"
:key="idx"
class="flex items-center gap-2 rounded-md border border-border/50 bg-muted/10 p-2"
>
<div class="text-[10px] text-muted-foreground w-6 shrink-0 text-center">
{{ idx + 1 }}
</div>
<!-- Mode 1 icon -->
<button
class="relative w-8 h-8 rounded border flex items-center justify-center shrink-0 transition-colors"
:class="
slot.skillId && slot.mode === 1
? 'border-purple-400 ring-1 ring-purple-400/50 bg-purple-500/10'
: 'border-border/50 hover:border-border'
"
@click="
slot.skillId
? setUPSlotMode(idx, 1)
: (openUpPickerSlot = openUpPickerSlot === idx ? null : idx)
"
>
<template v-if="slot.skillId">
<img
:src="getImageURL(skillById(slot.skillId)?.icon ?? '')"
class="w-7 h-7 rounded object-cover"
/>
<span
v-if="slot.mode === 1"
class="absolute -bottom-1 -right-1 text-[8px] bg-purple-500 text-white rounded-sm px-0.5 leading-3"
>1</span
>
</template>
<span v-else class="text-[10px] text-muted-foreground">1</span>
</button>
<!-- Exchange -->
<button
class="text-muted-foreground hover:text-foreground shrink-0 text-sm"
:disabled="!slot.skillId"
title="切换形态"
@click="exchangeUPMode(idx)"
>
⇄
</button>
<!-- Mode 2 icon -->
<button
class="relative w-8 h-8 rounded border flex items-center justify-center shrink-0 transition-colors"
:class="
slot.skillId && slot.mode === 2
? 'border-purple-400 ring-1 ring-purple-400/50 bg-purple-500/10'
: 'border-border/50 hover:border-border'
"
@click="
slot.skillId
? setUPSlotMode(idx, 2)
: (openUpPickerSlot = openUpPickerSlot === idx ? null : idx)
"
>
<template v-if="slot.skillId">
<img
:src="getImageURL(skillById(slot.skillId)?.icon ?? '')"
class="w-7 h-7 rounded object-cover"
/>
<span
v-if="slot.mode === 2"
class="absolute -bottom-1 -right-1 text-[8px] bg-purple-500 text-white rounded-sm px-0.5 leading-3"
>2</span
>
</template>
<span v-else class="text-[10px] text-muted-foreground">2</span>
</button>
<!-- Skill name -->
<div class="flex-1 min-w-0">
<div
class="text-xs truncate"
:title="slot.skillId ? (skillById(slot.skillId)?.name ?? '') : ''"
>
{{ slot.skillId ? skillById(slot.skillId)?.name : "未选择技能" }}
</div>
</div>
<!-- Pick button -->
<button
class="text-[10px] border border-border/50 rounded px-1.5 py-0.5 text-muted-foreground hover:text-foreground shrink-0"
@click="openUpPickerSlot = openUpPickerSlot === idx ? null : idx"
>
{{ slot.skillId ? "更换" : "选择" }}
</button>
</div>
<!-- Icon picker -->
<div
v-if="openUpPickerSlot !== null"
class="rounded-md border border-border/50 bg-muted/20 p-2"
>
<div class="flex flex-wrap gap-1">
<button
class="w-7 h-7 rounded border border-dashed border-border/60 text-[9px] text-muted-foreground hover:bg-muted/50"
@click="setUPSlotSkill(openUpPickerSlot!, '')"
>
清
</button>
<button
v-for="item in upOptionsForSlot(openUpPickerSlot!)"
:key="item.id"
class="relative w-7 h-7 rounded border transition-all hover:ring-1 ring-primary"
:class="
upSlots[openUpPickerSlot!]?.skillId === item.id.toString()
? 'border-primary ring-1'
: 'border-border/60'
"
:title="item.name"
@click="setUPSlotSkill(openUpPickerSlot!, item.id.toString())"
>
<img
:src="getImageURL(item.icon)"
:alt="item.name"
class="w-full h-full rounded object-cover"
/>
</button>
</div>
</div>
</div>
</div>
<!-- VP section -->
<div class="flex-1 overflow-y-auto min-h-0">
<div
class="px-3 pt-3 pb-1 text-xs font-semibold text-muted-foreground flex justify-between sticky top-0 bg-background/80 backdrop-blur-sm z-10"
>
<span>技能进化 (VP)</span>
<span>{{ usedVPCount }}/{{ VP_LIMIT }}</span>
</div>
<div class="divide-y divide-border/30">
<div
v-for="skill in vpSkills"
:key="skill.id"
class="px-3 py-2.5 flex items-start gap-2"
>
<!-- Lv + icon -->
<div class="shrink-0 flex flex-col items-center gap-1 pt-0.5">
<span class="text-[10px] text-muted-foreground">{{ skill.learnLv }}</span>
<img
:src="getImageURL(skill.icon)"
:alt="skill.name"
class="w-7 h-7 rounded object-cover"
/>
</div>
<!-- Name -->
<div
class="shrink-0 w-[4.5rem] text-[11px] text-muted-foreground truncate pt-1.5"
:title="skill.name"
>
{{ skill.name }}
</div>
<!-- Two VP choice cards -->
<div class="flex flex-1 gap-1.5">
<button
v-for="mode in [1, 2] as const"
:key="mode"
class="flex-1 rounded border p-1.5 text-left transition-colors hover:bg-muted/30"
:class="
skillCfg(skill.id.toString()).vp === mode
? 'border-purple-400 bg-purple-500/10'
: 'border-border/50'
"
@click="
setVPMode(
skill.id.toString(),
skillCfg(skill.id.toString()).vp === mode ? 0 : mode,
)
"
>
<div
class="text-[10px] font-semibold"
:class="
skillCfg(skill.id.toString()).vp === mode
? 'text-purple-400'
: 'text-muted-foreground'
"
>
形态 {{ mode }}
</div>
<div
class="mt-0.5 text-[10px] text-muted-foreground leading-tight line-clamp-2"
>
{{ skill.vps[mode - 1]?.name || "—" }}
</div>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ══ RIGHT PANEL: Skill Detail ══ -->
<div class="flex-1 min-h-0 flex flex-col overflow-hidden" @click.self="clearActive">
<div class="border-b border-border/50 px-3 py-2">
<Tabs :model-value="rightPanelTab" class="gap-0">
<TabsList>
<TabsTrigger value="result" @click="rightPanelTab = 'result'">结果面板</TabsTrigger>
<TabsTrigger value="detail" @click="rightPanelTab = 'detail'">技能详情</TabsTrigger>
</TabsList>
</Tabs>
</div>
<div v-if="rightPanelTab === 'result'" class="min-h-0 flex-1 overflow-hidden p-3">
<ResultPanel />
</div>
<template v-else>
<!-- Notice bar -->
<div
v-if="notice"
class="px-4 py-2 text-xs border-b shrink-0"
:class="
noticeType === 'error'
? 'bg-destructive/10 text-destructive border-destructive/30'
: 'bg-muted/30 text-muted-foreground border-border/40'
"
>
{{ notice }}
</div>
<!-- Skill detail -->
<div v-if="selectedSkill" class="flex-1 overflow-y-auto p-5 space-y-5">
<!-- Header + Level control: left-right layout -->
<div class="flex items-center gap-4">
<!-- Left: icon + name + type -->
<div class="flex items-center gap-3 flex-1 min-w-0">
<img
:src="getImageURL(selectedSkill.icon)"
:alt="selectedSkill.name"
class="w-14 h-14 shrink-0 rounded-lg object-cover border border-border/60"
/>
<div class="min-w-0">
<div class="text-base font-semibold truncate">{{ selectedSkill.name }}</div>
<div class="text-xs text-muted-foreground mt-1">
{{ selectedSkill.type === "passive" ? "被动" : "主动" }} · Lv
{{ selectedSkill.learnLv }} 解锁
</div>
</div>
</div>
<!-- Right: level number + buttons -->
<div class="shrink-0 flex flex-col items-end gap-2">
<div class="flex items-baseline gap-1.5">
<span
class="text-3xl font-bold tabular-nums"
:style="{ color: lvColor(selectedSkill.id.toString(), selectedSkill.maxLearnLv) }"
>{{ skillCfg(selectedSkill.id.toString()).lv }}</span
>
<span class="text-xs text-muted-foreground">/ {{ selectedSkill.maxLearnLv }}</span>
</div>
<div class="flex gap-1">
<Button
size="sm"
variant="outline"
class="h-7 px-2 text-xs"
@click="resetLv(selectedSkill.id.toString())"
>归零</Button
>
<Button
size="sm"
variant="outline"
class="h-7 px-2 text-xs"
@click="stepLv(selectedSkill.id.toString(), -1)"
>−1</Button
>
<Button
size="sm"
variant="outline"
class="h-7 px-2 text-xs"
@click="stepLv(selectedSkill.id.toString(), 1)"
>+1</Button
>
<Button
size="sm"
variant="outline"
class="h-7 px-2 text-xs"
@click="setMaxLv(selectedSkill.id.toString())"
>满级</Button
>
</div>
</div>
</div>
<!-- VP -->
<div v-if="selectedSkill.hasVP">
<div class="text-sm font-medium mb-2">VP 进化形态</div>
<div class="flex gap-2">
<button
v-for="mode in [1, 2] as const"
:key="mode"
class="flex-1 rounded-md border p-2.5 text-left text-xs transition-colors hover:bg-muted/30"
:class="
skillCfg(selectedSkill.id.toString()).vp === mode
? 'border-purple-400 bg-purple-500/10'
: 'border-border/50'
"
@click="
setVPMode(
selectedSkill.id.toString(),
skillCfg(selectedSkill.id.toString()).vp === mode ? 0 : mode,
)
"
>
<div
class="font-semibold"
:class="
skillCfg(selectedSkill.id.toString()).vp === mode
? 'text-purple-400'
: 'text-muted-foreground'
"
>
形态 {{ mode }}
</div>
<div class="mt-1.5 text-muted-foreground leading-snug">
{{ selectedSkill.vps[mode - 1]?.name || "—" }}
</div>
</button>
</div>
</div>
<!-- UP -->
<div v-if="selectedSkill.hasUP">
<div class="text-sm font-medium mb-1">UP 突破状态</div>
<div class="text-xs text-muted-foreground">
{{
skillCfg(selectedSkill.id.toString()).up === 0
? "未配置"
: `已配置 形态 ${skillCfg(selectedSkill.id.toString()).up}`
}}
</div>
</div>
<!-- API-fetched skill detail: 基本信息 / 技能描述 / 技能属性 / 技能进化 -->
<SkillDetailPanel
:skill-id="selectedSkill.uuid"
:level="skillCfg(selectedSkill.id.toString()).lv"
/>
</div>
<!-- Empty state -->
<div v-else class="flex-1 flex flex-col items-center justify-center gap-4 p-6 text-center">
<div class="text-sm text-muted-foreground">点击左侧技能查看详情</div>
<div class="text-xs text-muted-foreground space-y-1">
<div>VP 已用: {{ usedVPCount }} / {{ VP_LIMIT }}</div>
<div>UP 已用: {{ usedUPCount }} / {{ UP_LIMIT }}</div>
</div>
<Button size="sm" variant="outline" @click="resetAllSkills">重置全部技能</Button>
</div>
</template>
</div>
</div>
</template>
<style scoped>
/* 35×55px skill cell — matches original dimensions */
.skill-cell {
position: relative;
width: 35px;
height: 55px;
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: center;
}
/* Top 14px: badge row */
.badge-row {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 14px;
display: flex;
justify-content: center;
gap: 2px;
}
.badge-img {
width: 14px;
height: 14px;
object-fit: contain;
}
/* 28×28px icon at y=14px */
.skill-icon {
position: absolute;
top: 14px;
width: 28px;
height: 28px;
object-fit: cover;
border-radius: 2px;
}
.awake-bind-panel {
position: absolute;
top: 10px;
left: 50%;
z-index: 8;
display: flex;
gap: 2px;
transform: translateX(-50%);
border-radius: 4px;
background: color-mix(in oklab, var(--background) 88%, transparent);
padding: 2px;
box-shadow: 0 0 0 1px color-mix(in oklab, var(--border) 80%, transparent);
}
.awake-bind-btn {
width: 22px;
height: 22px;
border: 1px solid color-mix(in oklab, var(--border) 70%, transparent);
border-radius: 3px;
overflow: hidden;
opacity: 0.62;
}
.awake-bind-btn.active {
border-color: var(--primary);
opacity: 1;
box-shadow: 0 0 0 1px var(--primary);
}
.awake-bind-btn img {
width: 100%;
height: 100%;
object-fit: cover;
}
/* 4 action buttons at the bottom */
.action-row {
position: absolute;
bottom: 0;
display: flex;
align-items: center;
gap: 1px;
}
.skill-btn {
width: 19px;
height: 15px;
font-size: 11px;
background-color: color-mix(in oklab, var(--background) 90%, transparent);
border: 1px solid color-mix(in oklab, var(--border) 70%, transparent);
border-radius: 2px;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
font-weight: 700;
cursor: pointer;
}
.skill-btn:hover {
background-color: var(--muted);
}
/* Level number */
.lv-num {
position: absolute;
bottom: 2px;
font-size: 10px;
width: 100%;
text-align: center;
}
</style>