This commit is contained in:
2026-05-18 20:02:16 +08:00
parent 1565c4735f
commit 74cf99289f
14 changed files with 2250 additions and 237 deletions
@@ -25,6 +25,7 @@ import EquList from "@/components/dnf/Equipment/List/index.vue";
import OathList from "@/components/dnf/Equipment/Oath/index.vue";
import EquipmentCell from "./components/EquipmentCell.vue";
import { getImageURL } from "@/utils";
import ResultPanel from "@/pages/Dcalc/Character/Result/Index.vue";
type WeaponTab = "weapon" | "else" | "subweapon";
type EquipTab = "suit" | "universal";
@@ -1153,34 +1154,19 @@ const toggleOathRarity = (value: unknown) => {
<CardTitle class="text-base">
<Tabs v-model="sidePanelTab" class="gap-0">
<TabsList>
<TabsTrigger value="equipped">当前已穿戴</TabsTrigger>
<TabsTrigger value="equipped">结果面板</TabsTrigger>
<TabsTrigger value="detail">装备属性</TabsTrigger>
</TabsList>
</Tabs></CardTitle
>
<CardDescription v-if="sidePanelTab === 'equipped'"
>按部位查看当前选择,点击可切到属性预览。</CardDescription
>通过 Tab 查看装备/誓约穿戴与计算结果。</CardDescription
>
<CardDescription v-else>当前聚焦的装备会展示单件与套装属性。</CardDescription>
</CardHeader>
<CardContent v-if="sidePanelTab === 'equipped'" class="min-h-0 flex-1 overflow-hidden">
<div class="h-full space-y-3 overflow-y-auto pr-1">
<div class="space-y-1">
<div class="text-xs text-muted-foreground">装备</div>
<EquList
v-model:part="equippedPart"
:detail="configStore.config.equips"
:with-sub-weapon="canUseSubWeapon"
:with-pet="true"
/>
</div>
<div class="space-y-1">
<div class="text-xs text-muted-foreground">誓约</div>
<OathList v-model:part="equippedOathPart" :detail="configStore.config.oaths" />
</div>
</div>
<CardContent v-if="sidePanelTab === 'equipped'" class="min-h-0 flex-1 overflow-hidden p-0">
<ResultPanel />
</CardContent>
<CardContent
@@ -5,11 +5,13 @@ import { useConfigStore, useInfoStore } from "@/stores";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import ResultPanel from "@/pages/Dcalc/Character/Result/Index.vue";
const configStore = useConfigStore();
const infoStore = useInfoStore();
const moduleMode = ref<"equip" | "oath" | "avatar" | "global">("equip");
const forgeViewTab = ref<"config" | "result">("result");
const notice = ref("");
const noticeType = ref<"error" | "info">("info");
@@ -337,7 +339,7 @@ const resetAllForge = () => {
</script>
<template>
<div class="space-y-4">
<div class="flex h-full min-h-0 flex-col gap-4 overflow-hidden">
<Card class="border-border/60 bg-gradient-to-b from-background to-muted/30">
<CardHeader>
<CardTitle class="flex flex-wrap items-center justify-between gap-2">
@@ -351,6 +353,30 @@ const resetAllForge = () => {
</CardHeader>
<CardContent class="space-y-4">
<div class="inline-flex flex-wrap rounded-md border p-1">
<Button
size="sm"
:variant="forgeViewTab === 'result' ? 'default' : 'ghost'"
@click="forgeViewTab = 'result'"
>
结果面板
</Button>
<Button
size="sm"
:variant="forgeViewTab === 'config' ? 'default' : 'ghost'"
@click="forgeViewTab = 'config'"
>
配置编辑
</Button>
<Button
size="sm"
:variant="forgeViewTab === 'result' ? 'default' : 'ghost'"
@click="forgeViewTab = 'result'"
>
计算结果
</Button>
</div>
<div v-if="forgeViewTab === 'config'" class="inline-flex flex-wrap rounded-md border p-1">
<Button
size="sm"
:variant="moduleMode === 'equip' ? 'default' : 'ghost'"
@@ -402,7 +428,17 @@ const resetAllForge = () => {
</CardContent>
</Card>
<div v-if="moduleMode === 'equip'" class="grid grid-cols-1 gap-4">
<div class="min-h-0 flex-1 overflow-y-auto pr-1">
<div v-if="forgeViewTab === 'result'" class="h-full min-h-[20rem] overflow-hidden">
<div class="h-full overflow-hidden">
<ResultPanel />
</div>
</div>
<div
v-if="forgeViewTab === 'config' && moduleMode === 'equip'"
class="grid grid-cols-1 gap-4"
>
<Card class="border-border/60 bg-card/90">
<CardHeader>
<CardTitle class="flex items-center gap-2">
@@ -451,7 +487,7 @@ const resetAllForge = () => {
</Card>
</div>
<div v-if="moduleMode === 'oath'" class="grid grid-cols-1 gap-4">
<div v-if="forgeViewTab === 'config' && moduleMode === 'oath'" class="grid grid-cols-1 gap-4">
<Card class="border-border/60 bg-card/90">
<CardHeader>
<CardTitle class="flex items-center gap-2">
@@ -484,7 +520,10 @@ const resetAllForge = () => {
</Card>
</div>
<div v-if="moduleMode === 'avatar'" class="grid grid-cols-1 gap-4 xl:grid-cols-2">
<div
v-if="forgeViewTab === 'config' && moduleMode === 'avatar'"
class="grid grid-cols-1 gap-4 xl:grid-cols-2"
>
<Card class="border-border/60 bg-card/90">
<CardHeader>
<CardTitle class="flex items-center gap-2">
@@ -691,7 +730,10 @@ const resetAllForge = () => {
</Card>
</div>
<div v-if="moduleMode === 'global'" class="grid grid-cols-1 gap-4 lg:grid-cols-2">
<div
v-if="forgeViewTab === 'config' && moduleMode === 'global'"
class="grid grid-cols-1 gap-4 lg:grid-cols-2"
>
<Card class="border-border/60 bg-card/90">
<CardHeader>
<CardTitle class="flex items-center gap-2">
@@ -727,5 +769,6 @@ const resetAllForge = () => {
</CardContent>
</Card>
</div>
</div>
</div>
</template>
@@ -1,9 +1,10 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { LoaderCircle, RefreshCcw } from "lucide-vue-next";
import { LoaderCircle, PanelRight, RefreshCcw } from "lucide-vue-next";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
import Tabs from "@/components/ui/tabs/Tabs.vue";
import TabsList from "@/components/ui/tabs/TabsList.vue";
import TabsTrigger from "@/components/ui/tabs/TabsTrigger.vue";
@@ -20,6 +21,17 @@ const configStore = useConfigStore();
const loading = ref(false);
const loadError = ref("");
const isExecuting = ref(false);
const isMobileLayout = ref(false);
const isResultSheetOpen = ref(false);
let mediaQuery: MediaQueryList | null = null;
const updateMobileLayout = () => {
isMobileLayout.value = mediaQuery?.matches ?? false;
if (!isMobileLayout.value) {
isResultSheetOpen.value = false;
}
};
const tabs = [
{ label: "装备", key: "equipment" },
@@ -34,6 +46,8 @@ const activeTab = computed(() => {
return "equipment";
});
const showGlobalResultPanel = computed(() => activeTab.value !== "equipment");
const loadCharacter = async () => {
loading.value = true;
loadError.value = "";
@@ -53,6 +67,9 @@ const switchTab = (tab: (typeof tabs)[number]["key"]) => {
onMounted(() => {
loadCharacter();
window.addEventListener("beforeunload", configStore.saveConfig);
mediaQuery = window.matchMedia("(max-width: 1279px)");
updateMobileLayout();
mediaQuery.addEventListener("change", updateMobileLayout);
});
watch(
@@ -62,6 +79,12 @@ watch(
},
);
watch(showGlobalResultPanel, (show) => {
if (!show) {
isResultSheetOpen.value = false;
}
});
const stopConfigWatch = watch(
() => JSON.stringify(configStore.config),
async () => {
@@ -79,6 +102,7 @@ const stopConfigWatch = watch(
onUnmounted(() => {
stopConfigWatch();
mediaQuery?.removeEventListener("change", updateMobileLayout);
window.removeEventListener("beforeunload", configStore.saveConfig);
configStore.saveConfig();
});
@@ -89,15 +113,12 @@ onUnmounted(() => {
<Card class="flex-none">
<CardHeader>
<CardTitle class="flex items-center justify-between">
<span>角色配置 · {{ alter }}</span>
<span>角色配置</span>
<Button variant="outline" size="sm" :disabled="loading" @click="loadCharacter">
<RefreshCcw :class="{ 'animate-spin': loading }" />
刷新
</Button>
</CardTitle>
<CardDescription
>此页面承接旧版 character 模块,逐步迁移装备/技能/打造功能。</CardDescription
>
</CardHeader>
<CardContent class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<Tabs :model-value="activeTab" class="gap-0">
@@ -123,13 +144,35 @@ onUnmounted(() => {
</CardContent>
</Card>
<div class="grid min-h-0 flex-1 grid-cols-1 gap-4 xl:grid-cols-[minmax(0,1fr)_22rem]">
<div
:class="[
'grid min-h-0 flex-1 grid-cols-1 gap-4',
showGlobalResultPanel ? 'xl:grid-cols-[minmax(0,1fr)_24rem]' : 'xl:grid-cols-1',
]"
>
<div class="min-w-0 overflow-hidden">
<router-view />
</div>
<aside class="min-h-0">
<aside v-if="!isMobileLayout && showGlobalResultPanel" class="min-h-0">
<ResultPanel />
</aside>
</div>
<Sheet v-if="isMobileLayout && showGlobalResultPanel" v-model:open="isResultSheetOpen">
<SheetTrigger as-child>
<Button class="fixed bottom-5 right-4 z-30 shadow-lg" size="sm">
<PanelRight class="size-4" />
查看结果
</Button>
</SheetTrigger>
<SheetContent side="bottom" class="h-[78vh] overflow-hidden px-3 pb-3">
<SheetHeader class="pb-2">
<SheetTitle>实时计算结果</SheetTitle>
</SheetHeader>
<div class="h-[calc(78vh-4.5rem)] overflow-hidden">
<ResultPanel />
</div>
</SheetContent>
</Sheet>
</section>
</template>
@@ -1,28 +1,90 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import type { IResultSkill } from "@/api/calc/type";
import { useConfigStore, useInfoStore } from "@/stores";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
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 EquList from "@/components/dnf/Equipment/List/index.vue";
import OathList from "@/components/dnf/Equipment/Oath/index.vue";
const configStore = useConfigStore();
const infoStore = useInfoStore();
const displayTab = ref<"equ" | "oath">("equ");
const skillSort = ref<"damage" | "cd">("damage");
const equippedPart = ref("");
const equippedOathPart = ref("0");
const skillSort = ref<"damage" | "cd" | "buff-main" | "buff-atk" | "buff-str" | "buff-ratio">(
"damage",
);
const resultState = computed(() => configStore.result.state);
const isLoading = computed(() => configStore.result.isLoading);
const isBufferClass = computed(() => !!resultState.value?.buffer);
const standard = computed(() => infoStore.standard);
const resultError = computed(() => {
const error = configStore.result.error;
if (!error) return "";
return error instanceof Error ? error.message : String(error);
});
const retryCalc = async () => {
await configStore.result.execute();
};
const getSkillCountEntry = (skill: IResultSkill) => {
const name = skill.name;
const mode = skill.mode;
const existed = configStore.skillCountConfig.find(
(item) => item.name === name && item.mode === mode,
);
if (existed) return existed;
const created = { name, mode, count: 1 };
configStore.skillCountConfig.push(created);
return created;
};
const getSkillCount = (skill: IResultSkill) => {
return Math.max(0, Number(getSkillCountEntry(skill).count) || 0);
};
const setSkillCount = (skill: IResultSkill, raw: string | number) => {
const parsed = Number(raw);
const entry = getSkillCountEntry(skill);
entry.count = Number.isFinite(parsed) ? Math.max(0, parsed) : 0;
};
const getStandardSkill = (skill: IResultSkill) => {
return standard.value?.skills.find(
(item) => item.name === skill.name && item.mode === skill.mode,
);
};
const getStandardSkillCount = (skill: IResultSkill) => {
const count = standard.value?.skillCount.find(
(item) => item.name === skill.name && item.mode === skill.mode,
)?.count;
return Number(count ?? 0);
};
const sortedSkills = computed(() => {
const skills = [...(resultState.value?.skills ?? [])];
if (isBufferClass.value) {
const metricIndexMap: Record<string, number> = {
"buff-main": 0,
"buff-atk": 1,
"buff-str": 2,
"buff-ratio": 3,
};
const metricIndex = metricIndexMap[skillSort.value] ?? 2;
return skills.sort(
(left, right) => (right.buff?.[metricIndex] ?? 0) - (left.buff?.[metricIndex] ?? 0),
);
}
if (skillSort.value === "cd") {
return skills.sort((left, right) => left.cd - right.cd);
}
@@ -33,6 +95,35 @@ const totalDamage = computed(() => {
return sortedSkills.value.reduce((sum, item) => sum + item.damage, 0);
});
const totalWeightedDamage = computed(() => {
return sortedSkills.value.reduce((sum, item) => sum + item.damage * getSkillCount(item), 0);
});
const totalStandardDamage = computed(() => {
if (!standard.value) return 0;
return sortedSkills.value.reduce((sum, item) => {
const base = getStandardSkill(item);
if (!base) return sum;
const count = getStandardSkillCount(item);
return sum + base.damage * count;
}, 0);
});
const totalDiffLabel = computed(() => {
if (!totalStandardDamage.value) return formatNumber(totalWeightedDamage.value);
const diff =
((totalWeightedDamage.value - totalStandardDamage.value) / totalStandardDamage.value) * 100;
const prefix = diff >= 0 ? "+" : "";
return `${prefix}${diff.toFixed(2)}%`;
});
const totalDiffClass = computed(() => {
if (!totalStandardDamage.value) return "text-foreground";
return totalWeightedDamage.value >= totalStandardDamage.value
? "text-emerald-600"
: "text-red-600";
});
const formatNumber = (value: number) => {
if (!Number.isFinite(value)) return "0";
if (Math.abs(value) >= 100000000) {
@@ -55,24 +146,52 @@ const changeSources = computed(() => configStore.changeSourceLogs.slice(0, 8));
const clearChangeSources = () => {
configStore.clearChangeSource();
};
const formatPercent = (value: number) => `${value.toFixed(2)}%`;
const formatDeltaPercent = (current: number, baseline: number) => {
if (!baseline) return formatNumber(current);
const ratio = ((current - baseline) / baseline) * 100;
return `${ratio >= 0 ? "+" : ""}${ratio.toFixed(2)}%`;
};
const diffClass = (current: number, baseline: number) => {
if (!baseline) return "text-foreground";
return current >= baseline ? "text-emerald-600" : "text-red-600";
};
const hasVisibleResult = computed(() => {
return (resultState.value?.skills?.length ?? 0) > 0 || (resultState.value?.info?.length ?? 0) > 0;
});
</script>
<template>
<div class="grid h-full min-h-0 grid-rows-[auto_auto_1fr] gap-3">
<div class="flex h-full min-h-0 flex-col gap-3">
<Card>
<CardHeader class="pb-2">
<CardTitle class="text-base">结果面板</CardTitle>
<CardDescription>展示当前配置的实时计算结果。</CardDescription>
</CardHeader>
<CardContent class="flex gap-2">
<Button size="sm" variant="outline" @click="setStandard">设为基准</Button>
<Button size="sm" variant="outline" @click="clearStandard">清空基准</Button>
<CardContent class="flex flex-wrap gap-2">
<Button
size="sm"
variant="outline"
class="h-8 px-2.5 text-xs sm:text-sm"
@click="setStandard"
>设为基准</Button
>
<Button
size="sm"
variant="outline"
class="h-8 px-2.5 text-xs sm:text-sm"
@click="clearStandard"
>清空基准</Button
>
</CardContent>
</Card>
<Card>
<CardHeader class="pb-2">
<CardTitle class="text-sm">套装摘要</CardTitle>
<CardTitle class="text-sm">穿戴信息</CardTitle>
</CardHeader>
<CardContent class="space-y-2 text-xs">
<Tabs :model-value="displayTab">
@@ -83,27 +202,16 @@ const clearChangeSources = () => {
</Tabs>
<div v-if="displayTab === 'equ'" class="rounded-md border p-2">
<div v-if="resultState?.suits?.suit">
<div class="font-medium">{{ resultState.suits.suit.name }}</div>
<div class="text-muted-foreground">
{{ resultState.suits.suit.rarity }} · {{ resultState.suits.suit.point }}点
</div>
</div>
<div v-else class="text-muted-foreground">暂无装备套装信息</div>
<EquList
v-model:part="equippedPart"
:detail="configStore.config.equips"
:with-sub-weapon="(infoStore.infos?.alter?.includes('vegabond') ?? false)"
:with-pet="true"
/>
</div>
<div v-else class="rounded-md border p-2">
<div v-if="resultState?.suits?.oath">
<div class="font-medium">{{ resultState.suits.oath.name }}</div>
<div class="text-muted-foreground">
{{ resultState.suits.oath.rarity }} · {{ resultState.suits.oath.point }}点
</div>
</div>
<div v-else class="text-muted-foreground">暂无誓约套装信息</div>
</div>
<div v-if="resultState?.suits?.correction" class="text-muted-foreground">
套装积分修正:{{ resultState.suits.correction }}
<OathList v-model:part="equippedOathPart" :detail="configStore.config.oaths" />
</div>
</CardContent>
</Card>
@@ -134,73 +242,255 @@ const clearChangeSources = () => {
</CardContent>
</Card>
<Card class="min-h-0">
<Card class="min-h-0 flex-1">
<CardHeader class="pb-2">
<CardTitle class="text-sm">技能伤害</CardTitle>
<CardDescription class="flex items-center gap-2">
<CardTitle class="text-sm">{{ isBufferClass ? "技能加成" : "技能伤害" }}</CardTitle>
<CardDescription class="flex flex-wrap items-center gap-1.5">
<Button
v-if="!isBufferClass"
size="sm"
variant="ghost"
class="h-7 px-2 text-xs"
:class="{ 'text-foreground': skillSort === 'damage' }"
@click="skillSort = 'damage'"
>
按伤害
</Button>
<Button
v-if="!isBufferClass"
size="sm"
variant="ghost"
class="h-7 px-2 text-xs"
:class="{ 'text-foreground': skillSort === 'cd' }"
@click="skillSort = 'cd'"
>
按 CD
</Button>
<Button
v-if="isBufferClass"
size="sm"
variant="ghost"
class="h-7 px-2 text-xs"
:class="{ 'text-foreground': skillSort === 'buff-main' }"
@click="skillSort = 'buff-main'"
>
适用
</Button>
<Button
v-if="isBufferClass"
size="sm"
variant="ghost"
class="h-7 px-2 text-xs"
:class="{ 'text-foreground': skillSort === 'buff-atk' }"
@click="skillSort = 'buff-atk'"
>
三攻
</Button>
<Button
v-if="isBufferClass"
size="sm"
variant="ghost"
class="h-7 px-2 text-xs"
:class="{ 'text-foreground': skillSort === 'buff-str' }"
@click="skillSort = 'buff-str'"
>
力智
</Button>
<Button
v-if="isBufferClass"
size="sm"
variant="ghost"
class="h-7 px-2 text-xs"
:class="{ 'text-foreground': skillSort === 'buff-ratio' }"
@click="skillSort = 'buff-ratio'"
>
增伤
</Button>
</CardDescription>
</CardHeader>
<CardContent class="min-h-0">
<CardContent class="min-h-0 flex h-full flex-col">
<div v-if="isLoading" class="py-6 text-center text-sm text-muted-foreground">计算中...</div>
<div v-else-if="resultError" class="py-6 text-center text-sm text-destructive">
{{ resultError }}
<div
v-else-if="resultError"
class="flex flex-col items-center gap-3 py-6 text-center text-sm text-destructive"
>
<span>{{ resultError }}</span>
<Button size="sm" variant="outline" @click="retryCalc">重试计算</Button>
</div>
<div
v-else-if="!resultState || sortedSkills.length === 0"
v-else-if="!hasVisibleResult || sortedSkills.length === 0"
class="py-6 text-center text-sm text-muted-foreground"
>
暂无结果,请先编辑配置。
</div>
<div v-else class="max-h-[48vh] overflow-auto rounded-md border">
<table class="w-full table-auto text-xs">
<thead class="bg-muted/60 sticky top-0 z-10">
<tr>
<th class="px-2 py-2 text-left">技能</th>
<th class="px-2 py-2 text-right">CD</th>
<th class="px-2 py-2 text-right">伤害</th>
<th class="px-2 py-2 text-right">占比</th>
</tr>
</thead>
<tbody>
<tr
v-for="skill in sortedSkills"
:key="`${skill.name}-${skill.mode}`"
class="border-t hover:bg-muted/30"
>
<td class="px-2 py-1.5">
<div class="flex items-center gap-2">
<img
:src="skill.icon"
:alt="skill.name"
class="size-5 rounded-sm object-cover"
/>
<span class="truncate">{{ skill.name }}</span>
</div>
</td>
<td class="px-2 py-1.5 text-right">{{ skill.cd.toFixed(1) }}</td>
<td class="px-2 py-1.5 text-right font-medium">{{ formatNumber(skill.damage) }}</td>
<td class="px-2 py-1.5 text-right text-muted-foreground">
{{ totalDamage > 0 ? ((skill.damage / totalDamage) * 100).toFixed(1) : "0.0" }}%
</td>
</tr>
</tbody>
</table>
<div v-else class="flex min-h-0 h-full flex-col">
<div class="min-h-0 flex-1 overflow-auto rounded-md border">
<table class="w-full table-fixed text-[11px] sm:text-xs">
<colgroup v-if="!isBufferClass">
<col class="w-[9.5rem] sm:w-[11rem]" />
<col class="hidden sm:table-column sm:w-[4.25rem]" />
<col class="w-[4.5rem]" />
<col class="hidden md:table-column md:w-[5.5rem]" />
<col class="w-[5.5rem] sm:w-[6rem]" />
<col class="w-[4.5rem]" />
</colgroup>
<colgroup v-else>
<col class="w-[9rem] sm:w-[10rem]" />
<col class="hidden sm:table-column sm:w-[4.25rem]" />
<col class="w-[4.75rem]" />
<col class="w-[4.75rem]" />
<col class="w-[4.75rem]" />
<col class="w-[4.75rem]" />
</colgroup>
<thead class="bg-muted/60 sticky top-0 z-10">
<tr v-if="!isBufferClass">
<th class="px-1.5 py-1.5 text-left">技能</th>
<th class="hidden px-1.5 py-1.5 text-right sm:table-cell">CD</th>
<th class="px-1.5 py-1.5 text-right">次数</th>
<th class="hidden px-1.5 py-1.5 text-right md:table-cell">秒伤</th>
<th class="px-1.5 py-1.5 text-right">总伤害</th>
<th class="px-1.5 py-1.5 text-right">占比</th>
</tr>
<tr v-else>
<th class="px-1.5 py-1.5 text-left">技能</th>
<th class="hidden px-1.5 py-1.5 text-right sm:table-cell">CD</th>
<th class="px-1.5 py-1.5 text-right">适用</th>
<th class="px-1.5 py-1.5 text-right">三攻</th>
<th class="px-1.5 py-1.5 text-right">力智</th>
<th class="px-1.5 py-1.5 text-right">增伤</th>
</tr>
</thead>
<tbody>
<tr
v-for="skill in sortedSkills"
:key="`${skill.name}-${skill.mode}`"
class="border-t hover:bg-muted/30"
>
<td class="px-1.5 py-1.5">
<div class="flex items-center gap-2">
<img
:src="skill.icon"
:alt="skill.name"
class="size-4 rounded-sm object-cover sm:size-5"
/>
<span class="max-w-[6.5rem] truncate sm:max-w-[8.5rem]"
>{{ skill.name }}{{ skill.mode ? ` [${skill.mode}]` : "" }}</span
>
</div>
</td>
<td class="hidden px-1.5 py-1.5 text-right sm:table-cell">
{{ skill.cd.toFixed(1) }}
</td>
<template v-if="!isBufferClass">
<td class="px-1.5 py-1.5 text-right">
<Input
class="ml-auto h-7 w-12 px-1 text-right sm:w-14"
type="number"
:model-value="getSkillCount(skill)"
@update:model-value="(value) => setSkillCount(skill, value)"
/>
</td>
<td class="hidden px-1.5 py-1.5 text-right text-muted-foreground md:table-cell">
{{
formatNumber(
skill.cd > 0 ? (skill.damage * getSkillCount(skill)) / skill.cd : 0,
)
}}
</td>
<td
class="px-1.5 py-1.5 text-right font-medium"
:class="
diffClass(
skill.damage * getSkillCount(skill),
(getStandardSkill(skill)?.damage ?? 0) * getStandardSkillCount(skill),
)
"
>
{{
formatDeltaPercent(
skill.damage * getSkillCount(skill),
(getStandardSkill(skill)?.damage ?? 0) * getStandardSkillCount(skill),
)
}}
</td>
<td class="px-1.5 py-1.5 text-right text-muted-foreground">
{{
totalWeightedDamage > 0
? formatPercent(
(skill.damage * getSkillCount(skill) * 100) / totalWeightedDamage,
)
: "0.00%"
}}
</td>
</template>
<template v-else>
<td class="hidden px-1.5 py-1.5 text-right sm:table-cell">
{{ skill.cd.toFixed(1) }}
</td>
<td
class="px-1.5 py-1.5 text-right"
:class="
diffClass(skill.buff?.[0] ?? 0, getStandardSkill(skill)?.buff?.[0] ?? 0)
"
>
{{
formatDeltaPercent(
skill.buff?.[0] ?? 0,
getStandardSkill(skill)?.buff?.[0] ?? 0,
)
}}
</td>
<td
class="px-1.5 py-1.5 text-right"
:class="
diffClass(skill.buff?.[1] ?? 0, getStandardSkill(skill)?.buff?.[1] ?? 0)
"
>
{{
formatDeltaPercent(
skill.buff?.[1] ?? 0,
getStandardSkill(skill)?.buff?.[1] ?? 0,
)
}}
</td>
<td
class="px-1.5 py-1.5 text-right"
:class="
diffClass(skill.buff?.[2] ?? 0, getStandardSkill(skill)?.buff?.[2] ?? 0)
"
>
{{
formatDeltaPercent(
skill.buff?.[2] ?? 0,
getStandardSkill(skill)?.buff?.[2] ?? 0,
)
}}
</td>
<td
class="px-1.5 py-1.5 text-right"
:class="
diffClass(skill.buff?.[3] ?? 0, getStandardSkill(skill)?.buff?.[3] ?? 0)
"
>
{{
formatDeltaPercent(
skill.buff?.[3] ?? 0,
getStandardSkill(skill)?.buff?.[3] ?? 0,
)
}}
</td>
</template>
</tr>
</tbody>
</table>
</div>
<div
v-if="!isBufferClass"
class="mt-2 text-right text-sm font-semibold"
:class="totalDiffClass"
>
合计:{{ totalDiffLabel }}
</div>
</div>
</CardContent>
</Card>
@@ -2,8 +2,12 @@
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";
const infoStore = useInfoStore();
const configStore = useConfigStore();
@@ -14,6 +18,10 @@ 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-[460px]" : "xl:w-[550px]",
);
/* ─── Constants ─── */
const VP_LIMIT = 5;
@@ -34,14 +42,11 @@ interface ITreeRow {
}
const getSkillsInRange = (lvStart: number, lvEnd: number): ISkill[] =>
infoStore.skills.filter(
(s) => s.line > lvStart - 5 && s.line <= Math.max(lvEnd - 5, 1),
);
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,
(s) => s.line > lvStart - 5 && s.line <= Math.max(lvEnd - 5, 1) && s.position === col,
) ?? null;
const treeRows = computed((): ITreeRow[] => {
@@ -93,8 +98,12 @@ const lvColor = (id: string, maxLearnLv: number): string => {
const selectedSkill = computed(() =>
activeSkillId.value ? skillById(activeSkillId.value) : undefined,
);
const chooseSkill = (id: string) => { activeSkillId.value = id; };
const clearActive = () => { activeSkillId.value = ""; };
const chooseSkill = (id: string) => {
activeSkillId.value = id;
};
const clearActive = () => {
activeSkillId.value = "";
};
/* ─── Level actions ─── */
const updateLv = (id: string, value: number) => {
@@ -115,11 +124,11 @@ 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 usedVPCount = computed(
() =>
infoStore.skills
.filter((s) => s.hasVP)
.filter((s) => (configStore.config.skills[s.id.toString()]?.vp ?? 0) > 0).length,
);
const vpSkills = computed(() =>
@@ -129,7 +138,10 @@ const vpSkills = computed(() =>
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; }
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");
@@ -187,11 +199,18 @@ const setUPSlotSkill = (slotIdx: number, nextId: string) => {
}
const target = skillById(nextId);
if (!target) return;
if ((skillCfg(nextId).lv ?? 0) <= 0) { setNotice(`${target.name} 等级为 0,无法 UP。`, "error"); 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;
setNotice(`${target.name} 已在其他槽位。`, "error");
return;
}
if (!prevId && usedUPCount.value >= UP_LIMIT) {
setNotice(`UP 最多选 ${UP_LIMIT} 个技能。`, "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}`);
@@ -200,7 +219,10 @@ const setUPSlotSkill = (slotIdx: number, nextId: string) => {
const setUPSlotMode = (slotIdx: number, mode: 1 | 2) => {
const skillId = upSlots.value[slotIdx]?.skillId;
if (!skillId) { setNotice(`请先为槽位 ${slotIdx + 1} 选择技能。`, "error"); return; }
if (!skillId) {
setNotice(`请先为槽位 ${slotIdx + 1} 选择技能。`, "error");
return;
}
skillCfg(skillId).up = mode;
track(`${skillById(skillId)?.name} UP 形态 → ${mode}`);
};
@@ -220,9 +242,15 @@ const resetAllSkills = () => {
</script>
<template>
<div class="flex h-full overflow-hidden" @click.self="clearActive">
<div class="flex h-full flex-col overflow-hidden xl:flex-row" @click.self="clearActive">
<!-- ══ LEFT PANEL ══ -->
<div class="w-[550px] shrink-0 flex flex-col border-r border-border/50 overflow-hidden">
<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"
@@ -279,8 +307,12 @@ const resetAllSkills = () => {
@click.stop="chooseSkill(skill.id.toString())"
>
<div class="badge-row">
<span v-if="skillCfg(skill.id.toString()).vp > 0" class="badge badge-vp">VP</span>
<span v-if="skillCfg(skill.id.toString()).up > 0" class="badge badge-up">UP</span>
<span v-if="skillCfg(skill.id.toString()).vp > 0" class="badge badge-vp"
>VP</span
>
<span v-if="skillCfg(skill.id.toString()).up > 0" class="badge badge-up"
>UP</span
>
</div>
<img
:src="getImageURL(skill.icon)"
@@ -295,8 +327,12 @@ const resetAllSkills = () => {
/>
<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="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
@@ -321,8 +357,12 @@ const resetAllSkills = () => {
>
<template v-if="cell">
<div class="badge-row">
<span v-if="skillCfg(cell.id.toString()).vp > 0" class="badge badge-vp">VP</span>
<span v-if="skillCfg(cell.id.toString()).up > 0" class="badge badge-up">UP</span>
<span v-if="skillCfg(cell.id.toString()).vp > 0" class="badge badge-vp"
>VP</span
>
<span v-if="skillCfg(cell.id.toString()).up > 0" class="badge badge-up"
>UP</span
>
</div>
<img
:src="getImageURL(cell.icon)"
@@ -337,9 +377,15 @@ const resetAllSkills = () => {
/>
<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>
<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
@@ -360,7 +406,9 @@ const resetAllSkills = () => {
<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">
<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>
@@ -370,7 +418,9 @@ const resetAllSkills = () => {
: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>
<div class="text-[10px] text-muted-foreground w-6 shrink-0 text-center">
{{ idx + 1 }}
</div>
<!-- Mode 1 icon -->
<button
@@ -387,8 +437,15 @@ const resetAllSkills = () => {
"
>
<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>
<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>
@@ -399,7 +456,9 @@ const resetAllSkills = () => {
:disabled="!slot.skillId"
title="切换形态"
@click="exchangeUPMode(idx)"
>⇄</button>
>
⇄
</button>
<!-- Mode 2 icon -->
<button
@@ -416,15 +475,25 @@ const resetAllSkills = () => {
"
>
<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>
<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 ?? '') : ''">
<div
class="text-xs truncate"
:title="slot.skillId ? (skillById(slot.skillId)?.name ?? '') : ''"
>
{{ slot.skillId ? skillById(slot.skillId)?.name : "未选择技能" }}
</div>
</div>
@@ -433,16 +502,23 @@ const resetAllSkills = () => {
<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>
>
{{ slot.skillId ? "更换" : "选择" }}
</button>
</div>
<!-- Icon picker -->
<div v-if="openUpPickerSlot !== null" class="rounded-md border border-border/50 bg-muted/20 p-2">
<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>
<button
v-for="item in upOptionsForSlot(openUpPickerSlot!)"
:key="item.id"
@@ -455,7 +531,11 @@ const resetAllSkills = () => {
: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" />
<img
:src="getImageURL(item.icon)"
:alt="item.name"
class="w-full h-full rounded object-cover"
/>
</button>
</div>
</div>
@@ -464,19 +544,32 @@ const resetAllSkills = () => {
<!-- 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">
<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">
<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" />
<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">
<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 -->
@@ -490,12 +583,23 @@ const resetAllSkills = () => {
? 'border-purple-400 bg-purple-500/10'
: 'border-border/50'
"
@click="setVPMode(skill.id.toString(), skillCfg(skill.id.toString()).vp === mode ? 0 : mode)"
@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>
: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>
@@ -505,104 +609,158 @@ const resetAllSkills = () => {
</div>
</div>
</div>
</div>
</div>
<!-- ══ RIGHT PANEL: Skill Detail ══ -->
<div class="flex-1 flex flex-col overflow-y-auto" @click.self="clearActive">
<!-- 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>
<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>
<!-- Skill detail -->
<div v-if="selectedSkill" class="p-5 space-y-5">
<!-- Header -->
<div class="flex items-center gap-3">
<img
:src="getImageURL(selectedSkill.icon)"
:alt="selectedSkill.name"
class="w-14 h-14 rounded-lg object-cover border border-border/60"
/>
<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 -->
<div class="flex items-center gap-3">
<img
:src="getImageURL(selectedSkill.icon)"
:alt="selectedSkill.name"
class="w-14 h-14 rounded-lg object-cover border border-border/60"
/>
<div>
<div class="text-base font-semibold">{{ selectedSkill.name }}</div>
<div class="text-xs text-muted-foreground mt-1">
{{ selectedSkill.type === "passive" ? "被动" : "主动" }} · Lv
{{ selectedSkill.learnLv }} 解锁
</div>
</div>
</div>
<!-- Level control -->
<div>
<div class="text-base font-semibold">{{ selectedSkill.name }}</div>
<div class="text-xs text-muted-foreground mt-1">
{{ selectedSkill.type === "passive" ? "被动" : "主动" }} ·
Lv {{ selectedSkill.learnLv }} 解锁
<div class="flex items-baseline gap-2 mb-2.5">
<span
class="text-4xl 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.5">
<Button
size="sm"
variant="outline"
class="h-7 px-2.5 text-xs"
@click="resetLv(selectedSkill.id.toString())"
>归零</Button
>
<Button
size="sm"
variant="outline"
class="h-7 px-2.5 text-xs"
@click="stepLv(selectedSkill.id.toString(), -1)"
>−1</Button
>
<Button
size="sm"
variant="outline"
class="h-7 px-2.5 text-xs"
@click="stepLv(selectedSkill.id.toString(), 1)"
>+1</Button
>
<Button
size="sm"
variant="outline"
class="h-7 px-2.5 text-xs"
@click="setMaxLv(selectedSkill.id.toString())"
>满级</Button
>
</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>
</div>
<!-- Level control -->
<div>
<div class="flex items-baseline gap-2 mb-2.5">
<span
class="text-4xl 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.5">
<Button size="sm" variant="outline" class="h-7 px-2.5 text-xs" @click="resetLv(selectedSkill.id.toString())">归零</Button>
<Button size="sm" variant="outline" class="h-7 px-2.5 text-xs" @click="stepLv(selectedSkill.id.toString(), -1)">−1</Button>
<Button size="sm" variant="outline" class="h-7 px-2.5 text-xs" @click="stepLv(selectedSkill.id.toString(), 1)">+1</Button>
<Button size="sm" variant="outline" class="h-7 px-2.5 text-xs" @click="setMaxLv(selectedSkill.id.toString())">满级</Button>
<!-- 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>
<!-- 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>
</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>
@@ -635,8 +793,12 @@ const resetAllSkills = () => {
line-height: 14px;
font-weight: 700;
}
.badge-vp { color: #c084fc; } /* purple-400 */
.badge-up { color: #fb923c; } /* amber-400 */
.badge-vp {
color: #c084fc;
} /* purple-400 */
.badge-up {
color: #fb923c;
} /* amber-400 */
/* 28×28px icon at y=14px */
.skill-icon {
@@ -67,15 +67,12 @@ watch(
<Card>
<CardHeader>
<CardTitle class="flex items-center justify-between">
<span>角色配置 · {{ alter }}</span>
<span>角色配置</span>
<Button variant="outline" size="sm" :disabled="loading" @click="loadCharacter">
<RefreshCcw :class="{ 'animate-spin': loading }" />
刷新
</Button>
</CardTitle>
<CardDescription
>此页面承接旧版 character 模块,逐步迁移装备/技能/打造功能。</CardDescription
>
</CardHeader>
<CardContent class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div class="inline-flex gap-2">