987 lines
36 KiB
Vue
987 lines
36 KiB
Vue
<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>
|