This commit is contained in:
2026-07-11 11:14:12 +08:00
parent ee631cdc54
commit 74c6634186
18 changed files with 594 additions and 300 deletions
@@ -72,9 +72,7 @@ const partName = (part: string) => {
<div
class="equ-list"
:class="{ 'equ-list--plain': !withBg }"
:style="{
backgroundImage: withBg ? `url(${getImageURL('/common/head.png')})` : 'none',
}"
:style="withBg ? { backgroundImage: `url(${getImageURL('/common/head.png')})` } : undefined"
>
<template v-for="part in parts" :key="part">
<button class="equ-slot" :style="infoStyle(part)" type="button" @click="choosePart(part)">
@@ -74,9 +74,9 @@ const choosePart = (part: string) => {
<div
class="oath-list"
:class="{ 'oath-list--plain': !withBg }"
:style="{
backgroundImage: withBg ? `url(${getImageURL('/common/oath/head.png')})` : 'none',
}"
:style="
withBg ? { backgroundImage: `url(${getImageURL('/common/oath/head.png')})` } : undefined
"
>
<template v-for="part in parts" :key="part">
<button class="oath-slot" :style="infoStyle(part)" type="button" @click="choosePart(part)">
@@ -590,11 +590,16 @@ const chooseEquip = (
if (options?.oath || isOathEquipment(equip)) {
const oathConfig =
configStore.config.oaths[equip.oathPos] ??
(configStore.config.oaths[equip.oathPos] = { id: "", adaptation: 0 });
(configStore.config.oaths[equip.oathPos] = configStore.createDefaultOathConfig());
oathConfig.id = oathConfig.id === equip.id ? "" : equip.id;
configStore.pushChangeSource("Equipment", `${oathConfig.id ? "已穿戴" : "已卸下"}${equip.name}`);
return;
}
configStore.chooseEqu(equip, false, options?.subweapon ?? false);
configStore.pushChangeSource(
"Equipment",
`${isSelected(equip, options) ? "已穿戴" : "已卸下"}${equip.name}`,
);
if (isSelected(equip, options)) {
sidePanelTab.value = "equipped";
@@ -642,16 +647,29 @@ const wearCurrentSuitByRarity = (isOath = false, targetSuitId?: string) => {
.filter((item) => item.rarity === currentRarity)
.filter((item) => (isOath ? true : !item.name.includes("黑牙")));
let equippedCount = 0;
source.forEach((item) => {
if (isOath && item.oathPos) {
const oathConfig =
configStore.config.oaths[item.oathPos] ??
(configStore.config.oaths[item.oathPos] = { id: "", adaptation: 0 });
(configStore.config.oaths[item.oathPos] = configStore.createDefaultOathConfig());
oathConfig.id = item.id;
equippedCount += 1;
return;
}
configStore.chooseEqu(item, false, false);
const part = item.itemType === "武器" ? "武器" : item.itemDetailType;
const equipmentConfig = configStore.config.equips[part];
if (!equipmentConfig) return;
equipmentConfig.id = item.id;
equippedCount += 1;
});
if (equippedCount) {
configStore.pushChangeSource(
"Equipment",
`已按 ${currentRarity} 穿戴 ${equippedCount} 件${isOath ? "誓约" : "套装"}装备`,
);
}
};
const formatLv = (lv: number, rarity: string) =>
@@ -65,7 +65,14 @@ const cellStyle = {
@mouseenter="$emit('preview')"
@contextmenu.prevent="$emit('rightClick')"
>
<img :src="imageUrl" :alt="equipment.name" class="size-full object-contain" />
<img
:src="imageUrl"
:alt="equipment.name"
class="size-full object-contain"
loading="lazy"
decoding="async"
fetchpriority="low"
/>
<div v-if="inactive" class="absolute inset-0 bg-black/45" />
</button>
<div
@@ -20,6 +20,8 @@ import {
} from "@/components/ui/accordion";
import type { IJade } from "@/api/info/type";
import ResultPanel from "@/pages/Dcalc/Character/Result/Index.vue";
import EquipmentList from "@/components/dnf/Equipment/List/index.vue";
import OathList from "@/components/dnf/Equipment/Oath/index.vue";
const configStore = useConfigStore();
const infoStore = useInfoStore();
@@ -31,6 +33,8 @@ const noticeType = ref<"error" | "info">("info");
const avatarPart = ref("头部");
const emblemPart = ref("彩色");
const equipPart = ref("头肩");
const oathPart = ref("0");
const avatarOptionKeyword = ref("");
const emblemOptionKeyword = ref("");
const onlyConfiguredAvatarParts = ref(false);
@@ -113,7 +117,11 @@ const summary = computed(() => {
};
});
const parts = computed(() => infoStore.parts.filter((part) => configStore.config.equips[part]));
const parts = computed(() =>
[...infoStore.parts, "副武器"].filter(
(part, index, all) => all.indexOf(part) === index && configStore.config.equips[part],
),
);
const oathParts = computed(() =>
infoStore.oathParts.filter((part) => configStore.config.oaths[part]),
);
@@ -238,6 +246,16 @@ const setOathAdaptation = (part: string, value: string) => {
trackSource(oathBatchApply.value ? "誓约适配已批量更新" : `誓约槽位 ${part} 适配已更新`);
};
const setOathUpgrade = (part: string, index: number, value: string) => {
const target = configStore.config.oaths[part];
if (!target) return;
const next = Array.isArray(target.upgrade) ? [...target.upgrade] : [0, 0, 0, 0];
next[index] = Number.parseInt(value || "0", 10) || 0;
target.upgrade = next;
setNotice(`已更新誓约升级槽位 ${index + 1}。`);
trackSource(`誓约升级槽位 ${index + 1} 已更新`);
};
const equipCfg = (part: string) => configStore.config.equips[part] ?? configStore.defaultEqusConfig;
const oathCfg = (part: string) => configStore.config.oaths[part] ?? configStore.defaultOathConfig;
const avatarCfg = (part: string) =>
@@ -691,13 +709,35 @@ const resetAllForge = () => {
<CardDescription>强化、锻造、品级编辑并即时回写配置。</CardDescription>
</CardHeader>
<CardContent class="space-y-2 text-xs sm:space-y-3 sm:text-sm">
<div class="grid gap-4 rounded-md border bg-muted/15 p-3 md:grid-cols-[280px_1fr] md:items-center">
<EquipmentList
v-model:part="equipPart"
:detail="configStore.config.equips"
:with-bg="false"
:with-pet="false"
:with-sub-weapon="(infoStore.infos?.subweapons?.length ?? 0) > 0"
/>
<div class="flex min-w-0 flex-col gap-2">
<div class="text-muted-foreground">当前部位</div>
<div class="text-base font-semibold">{{ equipPart }}</div>
<div class="truncate text-muted-foreground">
{{ equipNameById.get(equipCfg(equipPart).id) || "未选择装备(可在装备页选择)" }}
</div>
<div class="text-xs text-muted-foreground">点击左侧装备槽切换当前编辑部位。</div>
</div>
</div>
<label
class="flex flex-wrap items-center justify-between gap-2 rounded-md border bg-muted/30 p-2 text-muted-foreground"
>
<span>批量应用到同类部位</span>
<input v-model="equipBatchApply" type="checkbox" class="size-4" />
</label>
<div v-for="part in parts" :key="part" class="space-y-3 rounded-md border p-2 md:p-3">
<div
v-for="part in parts"
v-show="part === equipPart"
:key="part"
class="space-y-3 rounded-md border p-2 md:p-3"
>
<div class="grid grid-cols-[5rem_minmax(0,1fr)_auto] items-center gap-2">
<div class="font-medium">{{ part }}</div>
<div class="truncate text-muted-foreground">
@@ -818,6 +858,23 @@ const resetAllForge = () => {
<CardDescription>编辑誓约槽位适配值。</CardDescription>
</CardHeader>
<CardContent class="space-y-2 text-xs sm:space-y-3 sm:text-sm">
<div class="grid gap-4 rounded-md border bg-muted/15 p-3 md:grid-cols-[280px_1fr] md:items-center">
<OathList
v-model:part="oathPart"
:detail="configStore.config.oaths"
:with-bg="false"
/>
<div class="flex min-w-0 flex-col gap-2">
<div class="text-muted-foreground">当前誓约槽位</div>
<div class="text-base font-semibold">
{{ oathPart === "11" ? "誓约" : `星蕴石 ${Number(oathPart) + 1}` }}
</div>
<div class="truncate text-muted-foreground">
{{ oathNameById.get(oathCfg(oathPart).id) || "未选择誓约(可在装备页选择)" }}
</div>
<div class="text-xs text-muted-foreground">点击左侧誓约槽切换当前编辑槽位。</div>
</div>
</div>
<label
class="flex flex-wrap items-center justify-between gap-2 rounded-md border bg-muted/30 p-2 text-muted-foreground"
>
@@ -826,14 +883,16 @@ const resetAllForge = () => {
</label>
<div
v-for="part in oathParts"
v-show="part === oathPart"
:key="part"
class="grid grid-cols-1 gap-2 rounded-md border p-2 md:grid-cols-[6rem_1fr_7rem_auto] md:items-center md:p-3"
class="grid grid-cols-1 gap-2 rounded-md border p-2 md:grid-cols-[6rem_minmax(0,1fr)_24rem_auto] md:items-center md:p-3"
>
<div class="font-medium">槽位 {{ part }}</div>
<div class="truncate text-muted-foreground">
{{ oathNameById.get(oathCfg(part).id) || "未选择誓约(可在装备页选择)" }}
</div>
<Input
v-if="part !== '11'"
:model-value="String(oathCfg(part).adaptation)"
type="number"
min="0"
@@ -841,6 +900,21 @@ const resetAllForge = () => {
placeholder="适配"
@update:model-value="(value) => setOathAdaptation(part, String(value))"
/>
<div v-else class="grid grid-cols-2 gap-1 md:grid-cols-4">
<Select
v-for="index in 4"
:key="index"
:model-value="String(oathCfg(part).upgrade?.[index - 1] ?? 0)"
@update:model-value="(value) => setOathUpgrade(part, index - 1, String(value))"
>
<SelectTrigger class="h-8 min-w-20 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="0">无</SelectItem>
<SelectItem value="1">技攻 +4%</SelectItem>
<SelectItem value="2">CD -8%</SelectItem>
</SelectContent>
</Select>
</div>
<Button size="sm" variant="ghost" @click="resetOathPart(part)">重置</Button>
</div>
</CardContent>
@@ -860,6 +934,19 @@ const resetAllForge = () => {
<CardDescription>支持附魔、徽章项、品级和属性值编辑。</CardDescription>
</CardHeader>
<CardContent class="space-y-2 text-xs sm:space-y-3 sm:text-sm">
<div class="grid grid-cols-4 gap-2 rounded-md border bg-muted/15 p-3 sm:grid-cols-6">
<Button
v-for="part in avatarParts"
:key="part"
size="sm"
:variant="avatarPart === part ? 'default' : 'outline'"
class="h-12 min-w-0 px-1 text-xs"
:title="part"
@click="avatarPart = part"
>
<span class="truncate">{{ part.replace("宠物装备-", "") }}</span>
</Button>
</div>
<div class="grid grid-cols-1 gap-2 md:grid-cols-[7rem_1fr] md:items-center">
<div class="text-muted-foreground">筛选</div>
<div class="flex items-center gap-2">
@@ -1021,6 +1108,18 @@ const resetAllForge = () => {
<CardDescription>支持等级、徽章条目与槽位数量规则。</CardDescription>
</CardHeader>
<CardContent class="space-y-2 text-xs sm:space-y-3 sm:text-sm">
<div class="grid grid-cols-3 gap-2 rounded-md border bg-muted/15 p-3 sm:grid-cols-6">
<Button
v-for="part in infoStore.emblemParts"
:key="part"
size="sm"
:variant="emblemPart === part ? 'default' : 'outline'"
class="h-12 rounded-full px-2 text-xs"
@click="emblemPart = part"
>
{{ part }}
</Button>
</div>
<div class="grid grid-cols-1 gap-2 md:grid-cols-[7rem_1fr] md:items-center">
<div class="text-muted-foreground">筛选</div>
<div class="flex items-center gap-2">
+34 -61
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { FileInput, PanelRight, WandSparkles } from "lucide-vue-next";
import { FileInput, WandSparkles } from "lucide-vue-next";
import api from "@/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -22,7 +22,6 @@ import { useConfigStore, useInfoStore } from "@/stores";
import { useDnfHelperStore } from "@/stores/dnfhelper";
import type { IConfigEmblem, IConfigEquip, IConfigOath } from "@/stores/config";
import type { IHelperInfo } from "@/api/info/type";
import ResultPanel from "@/pages/Dcalc/Character/Result/Index.vue";
const props = defineProps<{
alter: string;
}>();
@@ -35,8 +34,6 @@ const helperStore = useDnfHelperStore();
const loading = ref(false);
const loadError = ref("");
const isExecuting = ref(false);
const isMobileLayout = ref(false);
const isResultSheetOpen = ref(false);
const helperImportOpen = ref(false);
const skillImportOpen = ref(false);
const helperRoleImportOpen = ref(false);
@@ -48,7 +45,8 @@ const importStatus = ref("");
const importError = ref("");
const helperRole = ref<IHelperInfo["roleList"][number]>();
let mediaQuery: MediaQueryList | null = null;
let calcDebounceTimer: ReturnType<typeof setTimeout> | undefined;
let calcQueued = false;
type AvatarConfig = Record<
string,
@@ -75,13 +73,6 @@ interface SkillImportPayload {
skills?: typeof configStore.config.skills;
}
const updateMobileLayout = () => {
isMobileLayout.value = mediaQuery?.matches ?? false;
if (!isMobileLayout.value) {
isResultSheetOpen.value = false;
}
};
const tabs = [
{ label: "装备", key: "equipment" },
{ label: "技能", key: "skill" },
@@ -97,14 +88,12 @@ const activeTab = computed(() => {
return "equipment";
});
// Result panel is now embedded inside Equipment/Skill/Forge pages to avoid duplicate rendering.
const showGlobalResultPanel = computed(() => false);
const loadCharacter = async () => {
loading.value = true;
loadError.value = "";
try {
await infoStore.createCharacter(props.alter);
const loaded = await infoStore.createCharacter(props.alter);
if (!loaded) return;
prepareHelperRoleImport();
} catch (error) {
loadError.value = error instanceof Error ? error.message : "角色数据加载失败";
@@ -217,9 +206,6 @@ const prepareHelperRoleImport = () => {
onMounted(() => {
loadCharacter();
window.addEventListener("beforeunload", configStore.saveConfig);
mediaQuery = window.matchMedia("(max-width: 1279px)");
updateMobileLayout();
mediaQuery.addEventListener("change", updateMobileLayout);
});
watch(
@@ -236,30 +222,42 @@ watch(
},
);
watch(showGlobalResultPanel, (show) => {
if (!show) {
isResultSheetOpen.value = false;
const executeCalculation = async () => {
if (isExecuting.value) {
calcQueued = true;
return;
}
});
isExecuting.value = true;
try {
await configStore.result.execute();
} finally {
configStore.saveConfig();
isExecuting.value = false;
if (calcQueued) {
calcQueued = false;
scheduleCalculation();
}
}
};
const scheduleCalculation = () => {
if (calcDebounceTimer) clearTimeout(calcDebounceTimer);
calcDebounceTimer = setTimeout(() => {
calcDebounceTimer = undefined;
void executeCalculation();
}, 120);
};
const stopConfigWatch = watch(
() => JSON.stringify(configStore.config),
async () => {
if (isExecuting.value) return;
isExecuting.value = true;
try {
await configStore.result.execute();
configStore.saveConfig();
} finally {
isExecuting.value = false;
}
},
{ deep: true },
scheduleCalculation,
{ deep: true, flush: "post" },
);
onUnmounted(() => {
stopConfigWatch();
mediaQuery?.removeEventListener("change", updateMobileLayout);
if (calcDebounceTimer) clearTimeout(calcDebounceTimer);
window.removeEventListener("beforeunload", configStore.saveConfig);
configStore.saveConfig();
});
@@ -353,35 +351,10 @@ onUnmounted(() => {
</SheetContent>
</Sheet>
<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="grid min-h-0 flex-1 grid-cols-1 gap-4">
<div class="min-w-0 overflow-hidden">
<router-view />
</div>
<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>
@@ -0,0 +1 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="1" y="1" width="16" height="16" fill="url(#bg)"/><rect x="0.5" y="0.5" width="17" height="17" stroke="#5D4F30"/><rect x="8" y="4" width="2" height="10" fill="#B7975F"/><rect x="4" y="8" width="10" height="2" fill="#B7975F"/><defs><linearGradient id="bg" x1="9" y1="1" x2="9" y2="17"><stop stop-color="#451A1F"/><stop offset="1" stop-color="#271111"/></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 485 B

@@ -0,0 +1 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="1" y="1" width="16" height="16" fill="url(#bg)"/><rect x="0.5" y="0.5" width="17" height="17" stroke="#5D4F30"/><path d="M9 9L3.75 14.1962V3.80385L9 9Z" fill="#B7975F"/><path d="M15 9L9.75 14.1962V3.80385L15 9Z" fill="#B7975F"/><defs><linearGradient id="bg" x1="9" y1="1" x2="9" y2="17"><stop stop-color="#451A1F"/><stop offset="1" stop-color="#271111"/></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 489 B

@@ -0,0 +1 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="1" y="1" width="16" height="16" fill="url(#bg)"/><rect x="0.5" y="0.5" width="17" height="17" stroke="#5D4F30"/><path d="M9 9L14.25 3.80385V14.1962L9 9Z" fill="#B7975F"/><path d="M3 9L8.25 3.80385V14.1962L3 9Z" fill="#B7975F"/><defs><linearGradient id="bg" x1="9" y1="1" x2="9" y2="17"><stop stop-color="#103562"/><stop offset="1" stop-color="#092343"/></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 488 B

@@ -0,0 +1 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="1" y="1" width="16" height="16" fill="url(#bg)"/><rect x="0.5" y="0.5" width="17" height="17" stroke="#5D4F30"/><rect x="4" y="8" width="10" height="2" fill="#B7975F"/><defs><linearGradient id="bg" x1="9" y1="1" x2="9" y2="17"><stop stop-color="#103562"/><stop offset="1" stop-color="#092343"/></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 429 B

@@ -13,6 +13,10 @@ 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";
import minImg from "./img/min.svg";
import subImg from "./img/sub.svg";
import addImg from "./img/add.svg";
import maxImg from "./img/max.svg";
const vpImg = (mode: number) => (mode === 1 ? vp1Img : vp2Img);
const upImg = (mode: number) => (mode === 1 ? up1Img : up2Img);
@@ -27,11 +31,7 @@ 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:min-w-[460px]"
: "xl:w-[550px] xl:min-w-[460px]",
);
const leftPanelWidthClass = "xl:w-[550px] xl:min-w-[550px]";
/* ─── Constants ─── */
const VP_LIMIT = 5;
@@ -101,7 +101,7 @@ const lvColor = (id: string, maxLearnLv: number): string => {
const lv = skillCfg(id).lv;
if (lv < maxLearnLv) return "#32E432";
if (lv > maxLearnLv) return "#58E0FC";
return "currentColor";
return "#F5F5F5";
};
/* ─── Active / detail ─── */
@@ -311,7 +311,7 @@ const resetAllSkills = () => {
>
<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">
<div class="skill-tree-row flex items-center gap-[2px] px-1">
<!-- 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"
@@ -325,7 +325,8 @@ const resetAllSkills = () => {
<div
v-for="skill in row.extraSkills"
:key="skill.id"
class="skill-cell"
class="skill-cell has-skill"
:class="{ 'is-active': activeSkillId === skill.id.toString() }"
:title="skill.name"
@click.stop="chooseSkill(skill.id.toString())"
>
@@ -361,15 +362,18 @@ const resetAllSkills = () => {
v-if="firstAwakeSkill"
class="awake-bind-btn"
:class="{ active: configStore.config.bindAwake === 50 }"
:aria-pressed="configStore.config.bindAwake === 50"
title="绑定一觉"
@click.stop="setBindAwake(50)"
>
<img :src="getImageURL(firstAwakeSkill.icon)" :alt="firstAwakeSkill.name" />
<span>一觉</span>
</button>
<button
v-if="secondAwakeSkill"
class="awake-bind-btn"
:class="{ active: configStore.config.bindAwake === 85 }"
:aria-pressed="configStore.config.bindAwake === 85"
title="绑定二觉"
@click.stop="setBindAwake(85)"
>
@@ -377,6 +381,7 @@ const resetAllSkills = () => {
:src="getImageURL(secondAwakeSkill.icon)"
:alt="secondAwakeSkill.name"
/>
<span>二觉</span>
</button>
</div>
<div
@@ -384,18 +389,10 @@ const resetAllSkills = () => {
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>
<button class="skill-btn" title="等级归零" @click.stop="resetLv(skill.id.toString())"><img :src="minImg" alt="等级归零" /></button>
<button class="skill-btn" title="等级减一" @click.stop="stepLv(skill.id.toString(), -1)"><img :src="subImg" alt="等级减一" /></button>
<button class="skill-btn" title="等级加一" @click.stop="stepLv(skill.id.toString(), 1)"><img :src="addImg" alt="等级加一" /></button>
<button class="skill-btn" title="加至上限" @click.stop="setMaxLv(skill.id.toString())"><img :src="maxImg" alt="加至上限" /></button>
</div>
<div
v-else
@@ -413,7 +410,10 @@ const resetAllSkills = () => {
v-for="(cell, colIdx) in row.cells"
:key="colIdx"
class="skill-cell"
:class="cell ? 'cursor-pointer' : 'pointer-events-none'"
:class="[
cell ? 'cursor-pointer has-skill' : 'pointer-events-none',
cell && activeSkillId === cell.id.toString() ? 'is-active' : '',
]"
:title="cell?.name"
@click.stop="cell && chooseSkill(cell.id.toString())"
>
@@ -450,6 +450,7 @@ const resetAllSkills = () => {
v-if="firstAwakeSkill"
class="awake-bind-btn"
:class="{ active: configStore.config.bindAwake === 50 }"
:aria-pressed="configStore.config.bindAwake === 50"
title="绑定一觉"
@click.stop="setBindAwake(50)"
>
@@ -457,11 +458,13 @@ const resetAllSkills = () => {
:src="getImageURL(firstAwakeSkill.icon)"
:alt="firstAwakeSkill.name"
/>
<span>一觉</span>
</button>
<button
v-if="secondAwakeSkill"
class="awake-bind-btn"
:class="{ active: configStore.config.bindAwake === 85 }"
:aria-pressed="configStore.config.bindAwake === 85"
title="绑定二觉"
@click.stop="setBindAwake(85)"
>
@@ -469,6 +472,7 @@ const resetAllSkills = () => {
:src="getImageURL(secondAwakeSkill.icon)"
:alt="secondAwakeSkill.name"
/>
<span>二觉</span>
</button>
</div>
<div
@@ -476,18 +480,10 @@ const resetAllSkills = () => {
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" title="等级归零" @click.stop="resetLv(cell.id.toString())"><img :src="minImg" alt="等级归零" /></button>
<button class="skill-btn" title="等级减一" @click.stop="stepLv(cell.id.toString(), -1)"><img :src="subImg" alt="等级减一" /></button>
<button class="skill-btn" title="等级加一" @click.stop="stepLv(cell.id.toString(), 1)"><img :src="addImg" alt="等级加一" /></button>
<button class="skill-btn" title="加至上限" @click.stop="setMaxLv(cell.id.toString())"><img :src="maxImg" alt="加至上限" /></button>
</div>
<div
v-else
@@ -878,16 +874,33 @@ const resetAllSkills = () => {
</template>
<style scoped>
/* 35×55px skill cell — matches original dimensions */
.skill-tree-row {
min-height: 80px;
border-bottom: 1px solid color-mix(in oklab, var(--border) 55%, transparent);
background: color-mix(in oklab, var(--background) 96%, var(--muted));
}
.skill-tree-row:nth-child(odd) {
background: color-mix(in oklab, var(--muted) 42%, var(--background));
}
.skill-cell {
position: relative;
width: 35px;
height: 55px;
width: 42px;
height: 76px;
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: center;
}
.skill-cell.has-skill {
background: url("./img/skill.png") center 8px / 34px 54px no-repeat;
}
.skill-cell.is-active {
z-index: 6;
background-image: url("./img/skillActive.png");
background-position: center top;
background-size: 88px 78px;
}
/* Top 14px: badge row */
.badge-row {
@@ -909,7 +922,7 @@ const resetAllSkills = () => {
/* 28×28px icon at y=14px */
.skill-icon {
position: absolute;
top: 14px;
top: 13px;
width: 28px;
height: 28px;
object-fit: cover;
@@ -918,54 +931,68 @@ const resetAllSkills = () => {
.awake-bind-panel {
position: absolute;
top: 10px;
top: 7px;
left: 50%;
z-index: 8;
display: flex;
gap: 2px;
width: 148px;
height: 58px;
align-items: center;
justify-content: space-between;
gap: 6px;
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);
border: 1px solid var(--border);
border-radius: 6px;
background: var(--popover);
padding: 4px;
color: var(--popover-foreground);
box-shadow: 0 8px 24px color-mix(in oklab, var(--foreground) 18%, 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;
display: flex;
width: 66px;
height: 48px;
align-items: center;
justify-content: center;
gap: 4px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--background);
color: var(--muted-foreground);
font-size: 10px;
opacity: 0.78;
}
.awake-bind-btn.active {
border-color: var(--primary);
background: var(--accent);
color: var(--accent-foreground);
opacity: 1;
box-shadow: 0 0 0 1px var(--primary);
box-shadow: 0 0 0 1px color-mix(in oklab, var(--primary) 55%, transparent);
}
.awake-bind-btn img {
width: 100%;
height: 100%;
width: 28px;
height: 28px;
flex: 0 0 auto;
object-fit: cover;
}
/* 4 action buttons at the bottom */
.action-row {
position: absolute;
bottom: 0;
bottom: 3px;
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;
width: 18px;
height: 18px;
border: 0;
padding: 0;
background: transparent;
display: flex;
align-items: center;
justify-content: center;
@@ -973,6 +1000,11 @@ const resetAllSkills = () => {
font-weight: 700;
cursor: pointer;
}
.skill-btn img {
display: block;
width: 18px;
height: 18px;
}
.skill-btn:hover {
background-color: var(--muted);
}
@@ -980,7 +1012,7 @@ const resetAllSkills = () => {
/* Level number */
.lv-num {
position: absolute;
bottom: 2px;
bottom: 18px;
font-size: 10px;
width: 100%;
text-align: center;
@@ -13,11 +13,20 @@ import {
CardTitle,
} from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { getImageURL } from "@/utils";
const router = useRouter();
const adventures = ref<IAdventureInfo[]>([]);
const loading = ref(false);
const helperId = ref("");
const versionPickerOpen = ref(false);
const selectedAlter = ref<IAlterInfo>();
const loadAdventures = async () => {
loading.value = true;
@@ -42,10 +51,22 @@ const openAlter = (alter: IAlterInfo) => {
if (!alter.open) {
return;
}
if (alter.options?.length) {
selectedAlter.value = alter;
versionPickerOpen.value = true;
return;
}
const target = alter.value || alter.name;
router.push(`/dcalc/character/${target}/equipment`);
};
const openAlterOption = (value?: string, name?: string) => {
const target = value || name;
if (!target) return;
versionPickerOpen.value = false;
router.push(`/dcalc/character/${target}/equipment`);
};
const gotoHelper = () => {
if (!helperId.value.trim()) {
router.push("/dcalc/dnfhelper");
@@ -126,5 +147,28 @@ onMounted(() => {
赣ICP备2023003492号
</a>
</div>
<Sheet v-model:open="versionPickerOpen">
<SheetContent class="w-full sm:max-w-sm">
<SheetHeader>
<SheetTitle>选择职业版本</SheetTitle>
<SheetDescription>
{{ selectedAlter?.title }} 提供多个计算入口,请选择需要使用的版本。
</SheetDescription>
</SheetHeader>
<div class="flex flex-col gap-2 px-4">
<Button
v-for="option in selectedAlter?.options || []"
:key="option.value || option.name"
variant="outline"
class="h-auto justify-between py-3"
@click="openAlterOption(option.value, option.name)"
>
<span>{{ option.title }}</span>
<ArrowRight data-icon="inline-end" />
</Button>
</div>
</SheetContent>
</Sheet>
</main>
</template>
+34 -37
View File
@@ -34,6 +34,7 @@ export interface IConfigEquip {
export interface IConfigOath {
id: string;
adaptation: number;
upgrade: number[];
}
export interface IConfigEmblem {
@@ -107,6 +108,7 @@ const defaultAvatarConfig = {
const createDefaultOathConfig = (): IConfigOath => ({
id: "",
adaptation: 0,
upgrade: [0, 0, 0, 0],
});
const defaultOathConfig = createDefaultOathConfig();
@@ -118,22 +120,24 @@ const createDefaultEmblemConfig = (): IConfigEmblem => ({
const defaultEmblemConfig = createDefaultEmblemConfig();
const createDefaultConfig = (): IConfig => ({
skills: {},
equips: {},
oaths: {},
oathSkill: "1",
jades: {},
avatar: {},
sundry: {},
bindAwake: 50,
DSB: false,
BUFF: false,
options: {},
avatarLink: "",
emblems: {},
});
export const useConfigStore = defineStore("configStore", () => {
const config = ref<IConfig>({
skills: {},
equips: {},
oaths: {},
oathSkill: "1",
jades: {},
avatar: {},
sundry: {},
bindAwake: 50,
DSB: false,
BUFF: false,
options: {},
avatarLink: "",
emblems: {},
});
const config = ref<IConfig>(createDefaultConfig());
const skillCountConfig = ref<IResultSkillCount[]>([]);
const changeSourceLogs = ref<IChangeSourceLog[]>([]);
@@ -142,32 +146,22 @@ export const useConfigStore = defineStore("configStore", () => {
const infoStore = useInfoStore();
const alter = infoStore.infos?.alter;
// A character with no persisted data must start clean instead of inheriting the last role's
// in-memory configuration or skill-count overrides.
config.value = createDefaultConfig();
skillCountConfig.value = [];
if (alter) {
const localConfig = localStorage.getItem(`dcalc/${alter}/config`);
const localSkillConfig = localStorage.getItem(`dcalc/${alter}/config/skillCount`);
if (localConfig) {
config.value = JSON.parse(localConfig);
try {
if (localConfig) config.value = JSON.parse(localConfig) as IConfig;
if (localSkillConfig) skillCountConfig.value = JSON.parse(localSkillConfig) as IResultSkillCount[];
} catch {
// Ignore malformed legacy browser data and continue with the current schema defaults.
config.value = createDefaultConfig();
skillCountConfig.value = [];
}
if (localSkillConfig) {
skillCountConfig.value = JSON.parse(localSkillConfig);
}
}
if (!config.value) {
config.value = {
skills: {},
equips: {},
oaths: {},
oathSkill: "1",
jades: {},
avatar: {},
sundry: {},
bindAwake: 50,
DSB: false,
BUFF: false,
options: {},
avatarLink: "",
emblems: {},
};
}
if (!config.value?.equips) config.value.equips = {};
if (!config.value?.skills) config.value.skills = {};
@@ -205,6 +199,8 @@ export const useConfigStore = defineStore("configStore", () => {
config.value &&
!config.value?.oaths.hasOwnProperty(p) &&
(config.value.oaths[p.toString()] = createDefaultOathConfig());
const oath = config.value.oaths[p.toString()];
if (oath && !Array.isArray(oath.upgrade)) oath.upgrade = [0, 0, 0, 0];
});
infoStore.options.forEach((option) => {
@@ -310,6 +306,7 @@ export const useConfigStore = defineStore("configStore", () => {
chooseEqu,
skillCountConfig,
defaultEqusConfig,
createDefaultConfig,
defaultAvatarConfig,
defaultOathConfig,
createDefaultOathConfig,
+10 -2
View File
@@ -163,9 +163,17 @@ export const useInfoStore = defineStore("infoStore", () => {
const createCharacter = async (alter: string, equVersion?: string) => {
const time = new Date().getTime();
alter_token.value = btoa(JSON.stringify({ alter, equVersion, time }));
infos.value = await api.characterInfo();
const token = btoa(JSON.stringify({ alter, equVersion, time }));
alter_token.value = token;
const nextInfos = await api.characterInfo();
// Navigation can start another character request before this one returns. In that case the
// latest Alter-Token owns the store and this response must not overwrite it.
if (alter_token.value !== token) return false;
infos.value = nextInfos;
useConfigStore().loadConfig();
return true;
};
return {