Files
dcalc/frontendNew/src/pages/Dcalc/Character/CalcDetail/index.vue
T
2026-05-28 13:16:24 +08:00

748 lines
29 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 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 {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Input } from "@/components/ui/input";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
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 EquList from "@/components/dnf/Equipment/List/index.vue";
import OathList from "@/components/dnf/Equipment/Oath/index.vue";
const configStore = useConfigStore();
const infoStore = useInfoStore();
/* ─── UI state ─── */
const displayTab = ref<"equ" | "oath">("equ");
const equippedPart = ref("");
const equippedOathPart = ref("0");
const skillSort = ref<"damage" | "cd" | "buff-main" | "buff-atk" | "buff-str" | "buff-ratio">(
"damage",
);
/* ─── Computed ─── */
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 hasVisibleResult = computed(() => {
return (resultState.value?.skills?.length ?? 0) > 0 || (resultState.value?.info?.length ?? 0) > 0;
});
/* ─── Skill helpers ─── */
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);
}
return skills.sort((left, right) => right.damage - left.damage);
});
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";
});
/* ─── Format helpers ─── */
const formatNumber = (value: number) => {
if (!Number.isFinite(value)) return "0";
if (Math.abs(value) >= 100000000) {
return `${(value / 100000000).toFixed(2)}e`;
}
return value.toLocaleString("zh-CN", { maximumFractionDigits: 2 });
};
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";
};
/* ─── Actions ─── */
const retryCalc = async () => {
await configStore.result.execute();
};
const setStandard = () => {
if (!resultState.value?.uuid) return;
infoStore.setStandard(resultState.value, configStore.skillCountConfig);
};
const clearStandard = () => {
infoStore.setStandard(undefined);
};
/* ─── Suit / oath skill ─── */
const suit = computed(() => resultState.value?.suits?.suit);
const oathSuit = computed(() => resultState.value?.suits?.oath);
const oathSkillsSorted = computed(() => {
const skillOrder = ["2", "1", "3"];
const res = [...(oathSuit.value?.skills ?? [])].sort((a, b) => {
const ai = skillOrder.indexOf(a.skillId);
const bi = skillOrder.indexOf(b.skillId);
return (ai === -1 ? skillOrder.length : ai) - (bi === -1 ? skillOrder.length : bi);
});
return res.map((a) => ({
...a,
active:
a.enable &&
(a.skillId === configStore.config.oathSkill ||
(!configStore.config.oathSkill && a.id === oathSuit.value?.id)),
}));
});
const selectOathSkill = (skill: { skillId: string; enable: boolean }) => {
if (!skill.enable) return;
configStore.config.oathSkill = skill.skillId;
};
/* ─── Equipped items list ─── */
const partOrder = [
"武器",
"上衣",
"下装",
"头肩",
"腰带",
"鞋",
"项链",
"手镯",
"戒指",
"辅助装备",
"魔法石",
"耳环",
];
const equById = (id: string) => infoStore.equips.find((item) => item.id === id);
const adaptation = (part: string) => {
const eq = equById(configStore.config.equips?.[part]?.id ?? "");
return Math.min(eq?.max_adaptation ?? 0, configStore.config.equips?.[part]?.adaptation ?? 0);
};
const rarityColor = (rarity: string): string => {
switch (rarity) {
case "太初":
return "text-[#5aff62]";
case "史诗":
case "智慧产物":
return "text-[#fcb600]";
case "神话":
return "text-[#f8c63d]";
case "神器":
return "text-[#ef28c1]";
case "稀有":
return "text-[#9879da]";
case "传说":
return "text-[#ff7700]";
case "勇者":
return "text-[#ff6666]";
case "高级":
return "text-[#68d5ed]";
default:
return "text-[#fcb600]";
}
};
const equippedParts = computed(() =>
partOrder.filter((part) => !!configStore.config.equips?.[part]?.id),
);
/* ─── Change sources ─── */
const changeSources = computed(() => configStore.changeSourceLogs.slice(0, 8));
const clearChangeSources = () => configStore.clearChangeSource();
const leftPanelOpenSections = ["equipped", "stats", "suit", "equipped-list"];
</script>
<template>
<div class="flex h-full min-h-0 flex-col gap-4 lg:flex-row">
<!-- ══ Left panel: character info ══ -->
<div
class="flex w-full flex-none flex-col gap-3 lg:w-72 lg:overflow-y-auto lg:overflow-x-hidden"
>
<Card>
<CardContent class="py-2">
<Accordion type="multiple" :default-value="leftPanelOpenSections" class="w-full">
<AccordionItem value="equipped">
<AccordionTrigger>穿戴概览</AccordionTrigger>
<AccordionContent>
<div class="space-y-2 px-1">
<Tabs :model-value="displayTab">
<TabsList class="grid w-full grid-cols-2">
<TabsTrigger value="equ" @click="displayTab = 'equ'">装备</TabsTrigger>
<TabsTrigger value="oath" @click="displayTab = 'oath'">誓约</TabsTrigger>
</TabsList>
</Tabs>
<div v-if="displayTab === 'equ'">
<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>
<OathList v-model:part="equippedOathPart" :detail="configStore.config.oaths" />
</div>
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem v-if="resultState?.info?.length" value="stats">
<AccordionTrigger>角色属性</AccordionTrigger>
<AccordionContent>
<div class="grid grid-cols-2 gap-x-3 gap-y-0.5 px-1 text-xs">
<template v-for="info in resultState.info" :key="info.id">
<div
class="flex justify-between gap-1"
:class="
typeof info.value === 'string' && info.value.includes('/')
? 'col-span-2'
: ''
"
>
<span class="shrink-0 text-muted-foreground">{{ info.name }}</span>
<span class="text-right font-medium text-emerald-600">{{ info.value }}</span>
</div>
</template>
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem v-if="suit || oathSuit" value="suit">
<AccordionTrigger>套装与誓约</AccordionTrigger>
<AccordionContent>
<div class="space-y-1 px-1 text-xs">
<div v-if="suit" class="flex items-center gap-1.5">
<img
:src="getImageURL(`/equipment${suit.imageUrl}`)"
alt=""
class="size-5 object-contain"
/>
<span :class="rarityColor(suit.rarity)" class="flex-1"
>{{ suit.name }} 套装</span
>
<span class="shrink-0 text-muted-foreground">{{ suit.rarity }}</span>
<span class="shrink-0 font-medium">{{ suit.point }}点</span>
</div>
<div v-if="oathSuit" class="flex items-center gap-1.5">
<img
:src="getImageURL(`/equipment${oathSuit.imageUrl}`)"
alt=""
class="size-5 object-contain"
/>
<span :class="rarityColor(oathSuit.rarity)" class="flex-1"
>{{ oathSuit.name }} 誓约</span
>
<span class="shrink-0 text-muted-foreground">{{ oathSuit.rarity }}</span>
<span class="shrink-0 font-medium">{{ oathSuit.point }}点</span>
</div>
<div
v-if="oathSkillsSorted.length"
class="flex items-center justify-center gap-4 pt-1"
>
<button
v-for="skill in oathSkillsSorted"
:key="skill.id"
type="button"
:disabled="!skill.enable"
class="rounded-full transition-opacity"
:class="[
skill.enable
? 'cursor-pointer opacity-100'
: 'cursor-not-allowed opacity-40',
skill.active ? 'ring-2 ring-amber-400 ring-offset-1' : '',
]"
@click="selectOathSkill(skill)"
>
<img
:src="getImageURL(skill.imageUrl)"
:alt="skill.name"
class="size-8 rounded-full object-contain"
/>
</button>
</div>
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem v-if="equippedParts.length" value="equipped-list">
<AccordionTrigger>穿戴装备信息</AccordionTrigger>
<AccordionContent>
<ScrollArea class="max-h-64 px-1">
<div class="space-y-0.5 text-xs">
<div
v-for="part in equippedParts"
:key="part"
class="flex items-center gap-1.5 rounded px-1 py-0.5 hover:bg-muted/40"
>
<img
:src="getImageURL(`/equipment/part/${part}.png`)"
:alt="part"
class="size-4 shrink-0 object-contain"
/>
<img
v-if="adaptation(part) > 0"
:src="getImageURL(`/equipment/adaptation/${adaptation(part)}.png`)"
alt=""
class="h-2.5 w-3 shrink-0 object-contain"
/>
<span
v-if="(configStore.config.equips[part]?.reinforce ?? 0) > 0"
class="shrink-0 font-medium"
:class="
configStore.config.equips[part]?.reinforceType === 1
? 'text-[#ef28c1]'
: 'text-[#68d5ed]'
"
>+{{ configStore.config.equips[part]?.reinforce }}</span
>
<span
v-if="(configStore.config.equips[part]?.refine ?? 0) > 0"
class="shrink-0 text-[#9879da]"
>({{ configStore.config.equips[part]?.refine }})</span
>
<span
class="truncate"
:class="
rarityColor(
equById(configStore.config.equips[part]?.id ?? '')?.rarity ?? '',
)
"
>{{ equById(configStore.config.equips[part]?.id ?? "")?.name ?? "—" }}</span
>
</div>
</div>
<template #scrollbar>
<ScrollBar orientation="vertical" />
</template>
</ScrollArea>
</AccordionContent>
</AccordionItem>
</Accordion>
</CardContent>
</Card>
</div>
<!-- ══ Right panel: skill damage table ══ -->
<div class="flex min-w-0 flex-1 flex-col gap-3">
<!-- Action row -->
<Card>
<CardContent class="flex flex-wrap gap-2 pt-3 pb-3">
<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>
<!-- Skill table card -->
<Card class="min-h-0 flex-1">
<CardHeader class="pb-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="flex h-full min-h-0 flex-col">
<div v-if="isLoading" class="py-6 text-center text-sm text-muted-foreground">
计算中...
</div>
<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="!hasVisibleResult || sortedSkills.length === 0"
class="py-6 text-center text-sm text-muted-foreground"
>
暂无结果,请先编辑配置。
</div>
<div v-else class="flex h-full min-h-0 flex-col">
<div class="min-h-0 flex-1 overflow-hidden rounded-md border">
<ScrollArea class="h-full w-full">
<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="sticky top-0 z-10 bg-muted/60">
<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>
<template #scrollbar>
<ScrollBar orientation="horizontal" />
<ScrollBar orientation="vertical" />
</template>
</ScrollArea>
</div>
<div
v-if="!isBufferClass"
class="mt-2 text-right text-sm font-semibold"
:class="totalDiffClass"
>
合计:{{ totalDiffLabel }}
</div>
</div>
</CardContent>
</Card>
<!-- Change sources -->
<Card>
<CardHeader class="pb-1 pt-3">
<CardTitle class="text-xs text-muted-foreground">配置变更来源</CardTitle>
</CardHeader>
<CardContent class="pb-3">
<div class="space-y-1 text-xs">
<div class="flex items-center justify-between pb-1">
<span class="text-muted-foreground">用于回归定位最近触发计算的操作来源。</span>
<Button size="sm" variant="ghost" class="h-6 px-2 text-xs" @click="clearChangeSources"
>清空</Button
>
</div>
<div v-if="changeSources.length === 0" class="text-muted-foreground">
暂无变更来源记录。
</div>
<ScrollArea v-else class="max-h-40 rounded-md border border-border/60 p-2">
<div class="space-y-1">
<div
v-for="item in changeSources"
:key="item.id"
class="rounded-md border border-border/60 bg-muted/20 px-2 py-1"
>
<div class="flex items-center justify-between">
<span class="font-medium">{{ item.source }}</span>
<span class="text-muted-foreground">{{ item.time }}</span>
</div>
<div class="text-muted-foreground">{{ item.detail }}</div>
</div>
</div>
<template #scrollbar>
<ScrollBar orientation="vertical" />
</template>
</ScrollArea>
</div>
</CardContent>
</Card>
</div>
</div>
</template>