This commit was merged in pull request #1.
This commit is contained in:
2026-06-02 20:56:07 +08:00
parent f824177aa5
commit bb3ad71d21
38 changed files with 1304 additions and 532 deletions
+9 -1
View File
@@ -1,5 +1,5 @@
import { defineRequest } from "../common";
import type { IAdventureInfo, ICharacterInfo, IHelperInfo, ISkillInfo } from "./type";
import type { IAdventureInfo, IAlterInfo, ICharacterInfo, IHelperInfo, ISkillInfo } from "./type";
export default defineRequest((request) => {
return {
@@ -7,6 +7,10 @@ export default defineRequest((request) => {
const res = await request.get<IAdventureInfo[]>("/adventure");
return res.data;
},
async alterByName(name: string) {
const res = await request.get<IAlterInfo>("/adventure", { params: { name } });
return res.data;
},
async createToken(alter: string, equVersion?: string) {
const res = await request.get<string>(`/token/get/${alter}`, { params: { equVersion } });
return res.data;
@@ -23,6 +27,10 @@ export default defineRequest((request) => {
const res = await request.get(`/dnfhelper/${uid}/`);
return res.data;
},
async getConfigFromHelperAlter(uid: string, alter: string) {
const res = await request.get(`/dnfhelper/${uid}/${alter}/`);
return res.data;
},
async getSkillFromCode(code: string) {
const res = await request.post(`/skillTree/`, { code });
return res.data;
@@ -572,7 +572,7 @@ const leftPanelOpenSections = ["equipped", "stats", "suit", "equipped-list"];
<td class="px-1.5 py-1.5">
<div class="flex items-center gap-2">
<img
:src="skill.icon"
:src="getImageURL(skill.icon)"
:alt="skill.name"
class="size-4 rounded-sm object-cover sm:size-5"
/>
@@ -6,12 +6,21 @@ import {
ChevronRight,
ChevronsLeft,
ChevronsRight,
Settings2,
Sparkles,
} from "lucide-vue-next";
import { useConfigStore, useInfoStore } from "@/stores";
import type { IEquipment, ISuit } from "@/api/info/type";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Sheet,
SheetContent,
SheetDescription,
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";
@@ -54,7 +63,8 @@ const suitId = ref("");
const oathSuitId = ref("");
const suitRarity = ref<string>("");
const oathSuitRarity = ref<string>("");
const sidePanelTab = ref<SidePanelTab>("detail");
const sidePanelTab = ref<SidePanelTab>("equipped");
const triggerOptionsOpen = ref(false);
const equippedPart = ref("");
const equippedOathPart = ref("0");
const previewEquipKey = ref("");
@@ -75,7 +85,8 @@ const panelLayoutVars = computed(() => ({
const iconWrapStyle = computed(() => ({
...panelLayoutVars.value,
width: "var(--equip-grid-width)",
maxWidth: "var(--equip-grid-width)",
width: "100%",
}));
const isOathEquipment = (equip?: IEquipment) =>
@@ -805,25 +816,29 @@ const toggleOathRarity = (value: unknown) => {
const next = normalizeRarityValue(value);
oathSuitRarity.value = !next || oathSuitRarity.value === next ? "" : next;
};
const setTriggerOption = (id: number, value: string) => {
configStore.config.options[id.toString()] = Number.parseInt(value, 10);
};
</script>
<template>
<div
class="grid min-h-full min-w-0 gap-4 pb-6 md:h-full md:pb-0 md:grid-cols-[minmax(0,1fr)_320px]"
class="grid min-h-full min-w-0 gap-4 pb-6 lg:h-full lg:pb-0 lg:grid-cols-[minmax(0,1fr)_300px] xl:grid-cols-[minmax(0,1fr)_320px]"
>
<div
class="min-h-0 min-w-0 grid items-stretch gap-4 md:h-full [grid-template-columns:repeat(auto-fit,minmax(min(100%,18rem),1fr))]"
class="min-h-0 min-w-0 grid items-stretch gap-4 lg:h-full [grid-template-columns:repeat(auto-fit,minmax(min(100%,18rem),1fr))]"
:style="panelLayoutVars"
>
<div class="min-h-0 min-w-0 md:h-full">
<div class="min-h-0 min-w-0 lg:h-full">
<Card
class="w-full md:flex md:h-full md:flex-col md:overflow-hidden"
class="w-full lg:flex lg:h-full lg:flex-col lg:overflow-hidden"
:style="panelLayoutVars"
>
<!-- <CardHeader class="pb-3">
<CardTitle>武器/称号/宠物/秘宝</CardTitle>
</CardHeader> -->
<CardContent class="space-y-4 md:min-h-0 md:flex-1 md:overflow-y-auto">
<CardContent class="space-y-4 overflow-x-hidden lg:min-h-0 lg:flex-1 lg:overflow-y-auto">
<Tabs v-model="weaponTab" class="gap-0" size="sm">
<TabsList>
<TabsTrigger value="weapon">武器</TabsTrigger>
@@ -832,7 +847,7 @@ const toggleOathRarity = (value: unknown) => {
</TabsList>
</Tabs>
<div class="overflow-x-auto">
<div class="overflow-x-hidden">
<div class="mx-auto flex flex-wrap content-start gap-1" :style="iconWrapStyle">
<template v-if="weaponTab === 'weapon'">
<EquipmentCell
@@ -878,6 +893,50 @@ const toggleOathRarity = (value: unknown) => {
</TabsList>
</Tabs>
<Sheet v-model:open="triggerOptionsOpen">
<SheetTrigger as-child>
<Button
v-if="infoStore.options.length"
size="icon-sm"
variant="outline"
title="装备触发条件"
>
<Settings2 class="size-4" />
</Button>
</SheetTrigger>
<SheetContent class="w-full overflow-y-auto sm:max-w-md">
<SheetHeader>
<SheetTitle>装备触发条件</SheetTitle>
<SheetDescription>配置会立即写回当前角色计算条件。</SheetDescription>
</SheetHeader>
<div class="grid gap-3 px-4">
<label
v-for="option in infoStore.options"
:key="option.id"
class="grid gap-1 text-sm"
>
<span class="text-muted-foreground">{{ option.name }}</span>
<select
class="h-9 rounded-md border border-input bg-background px-2"
:value="String(configStore.config.options[option.id.toString()] ?? 0)"
@change="
(event) =>
setTriggerOption(option.id, (event.target as HTMLSelectElement).value)
"
>
<option
v-for="(item, index) in option.options"
:key="`${option.id}-${index}`"
:value="String(index)"
>
{{ item }}
</option>
</select>
</label>
</div>
</SheetContent>
</Sheet>
<ColorSwatchPickerRoot
v-if="equipTab === 'suit'"
class="ml-auto flex items-center gap-2"
@@ -908,7 +967,7 @@ const toggleOathRarity = (value: unknown) => {
<template v-if="equipTab === 'suit'">
<div
class="mx-auto flex w-max max-w-full flex-nowrap overflow-x-auto pb-1"
class="mx-auto flex flex-wrap content-start gap-1 overflow-x-hidden pb-1"
:style="iconWrapStyle"
>
<button
@@ -941,7 +1000,7 @@ const toggleOathRarity = (value: unknown) => {
</button>
</div>
<div class="overflow-x-auto">
<div class="overflow-x-hidden">
<div class="mx-auto flex flex-wrap content-start gap-1" :style="iconWrapStyle">
<EquipmentCell
v-for="(equip, index) in suitGrid"
@@ -966,7 +1025,7 @@ const toggleOathRarity = (value: unknown) => {
>
{{ section.title }}
</div> -->
<div class="overflow-x-auto">
<div class="overflow-x-hidden">
<div class="mx-auto flex flex-wrap content-start gap-1" :style="iconWrapStyle">
<EquipmentCell
v-for="(equip, index) in section.items"
@@ -991,21 +1050,21 @@ const toggleOathRarity = (value: unknown) => {
</Card>
</div>
<div class="min-h-0 min-w-0 md:h-full">
<div class="min-h-0 min-w-0 lg:h-full">
<Card
class="w-full md:flex md:h-full md:flex-col md:overflow-hidden"
class="w-full lg:flex lg:h-full lg:flex-col lg:overflow-hidden"
:style="panelLayoutVars"
>
<!-- <CardHeader>
<CardTitle>誓约</CardTitle>
</CardHeader> -->
<CardContent class="space-y-4 md:min-h-0 md:flex-1 md:overflow-y-auto">
<CardContent class="space-y-4 overflow-x-hidden lg:min-h-0 lg:flex-1 lg:overflow-y-auto">
<div class="flex items-center gap-2">
<Tabs v-model="oathTab" class="gap-0">
<TabsList>
<TabsTrigger value="suit">誓约</TabsTrigger>
<!-- <TabsTrigger value="universal">通宝</TabsTrigger>
<TabsTrigger value="other">其他</TabsTrigger> -->
<TabsTrigger value="universal">通宝</TabsTrigger>
<TabsTrigger value="other">其他</TabsTrigger>
</TabsList>
</Tabs>
@@ -1039,7 +1098,7 @@ const toggleOathRarity = (value: unknown) => {
<template v-if="oathTab === 'suit'">
<div
class="mx-auto flex w-max max-w-full flex-nowrap overflow-x-auto pb-1"
class="mx-auto flex flex-wrap content-start gap-1 overflow-x-hidden pb-1"
:style="iconWrapStyle"
>
<button
@@ -1084,7 +1143,7 @@ const toggleOathRarity = (value: unknown) => {
>
{{ section.title }}
</div> -->
<div class="overflow-x-auto">
<div class="overflow-x-hidden">
<div class="mx-auto flex flex-wrap content-start gap-1" :style="iconWrapStyle">
<EquipmentCell
v-for="(equip, index) in section.items"
@@ -1109,7 +1168,7 @@ const toggleOathRarity = (value: unknown) => {
</template>
<template v-else-if="oathTab === 'universal'">
<div class="overflow-x-auto">
<div class="overflow-x-hidden">
<div class="mx-auto flex flex-wrap content-start gap-1" :style="iconWrapStyle">
<EquipmentCell
v-for="(equip, index) in oathUniversalGrid"
@@ -1123,10 +1182,16 @@ const toggleOathRarity = (value: unknown) => {
/>
</div>
</div>
<div
v-if="!oathUniversalGrid.some(Boolean)"
class="py-6 text-center text-xs text-muted-foreground"
>
当前暂无誓约通宝数据
</div>
</template>
<template v-else>
<div class="overflow-x-auto">
<div class="overflow-x-hidden">
<div class="mx-auto flex flex-wrap content-start gap-1" :style="iconWrapStyle">
<EquipmentCell
v-for="(equip, index) in oathOtherGrid"
@@ -1140,14 +1205,20 @@ const toggleOathRarity = (value: unknown) => {
/>
</div>
</div>
<div
v-if="!oathOtherGrid.some(Boolean)"
class="py-6 text-center text-xs text-muted-foreground"
>
当前暂无其它誓约数据
</div>
</template>
</CardContent>
</Card>
</div>
</div>
<div class="min-h-0 space-y-4 md:h-full md:self-stretch">
<Card class="md:flex md:h-full md:flex-col md:overflow-hidden">
<div class="min-h-0 space-y-4 lg:h-full lg:self-stretch">
<Card class="lg:flex lg:h-full lg:flex-col lg:overflow-hidden">
<CardHeader>
<CardTitle class="text-base">
<Tabs v-model="sidePanelTab" class="gap-0">
@@ -11,12 +11,13 @@ import {
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import type { IJade } from "@/api/info/type";
import ResultPanel from "@/pages/Dcalc/Character/Result/Index.vue";
const configStore = useConfigStore();
const infoStore = useInfoStore();
const moduleMode = ref<"equip" | "oath" | "avatar" | "global">("equip");
const moduleMode = ref<"equip" | "oath" | "avatar" | "jade" | "sundry" | "global">("equip");
const forgeViewTab = ref<"config" | "result">("result");
const notice = ref("");
const noticeType = ref<"error" | "info">("info");
@@ -27,6 +28,8 @@ const avatarOptionKeyword = ref("");
const emblemOptionKeyword = ref("");
const onlyConfiguredAvatarParts = ref(false);
const onlyConfiguredEmblemSlots = ref(false);
const equipBatchApply = ref(false);
const oathBatchApply = ref(false);
const noEnchantAvatarParts = ["头部", "帽子", "脸部", "胸部", "上衣", "腰带", "下装", "鞋"];
@@ -49,6 +52,19 @@ const emblemLevels = [
"史诗Lv.1",
];
const jadeKeys = ["0", "1", "2", "3"] as const;
const sundryFields = [
{ key: "fog", label: "迷雾系统", defaultValue: 10 },
{ key: "adventure", label: "冒险团", defaultValue: 30 },
{ key: "marriage_house", label: "结婚房屋", defaultValue: 4 },
{ key: "marriage_ring", label: "结婚戒指", defaultValue: 2 },
{ key: "contract", label: "晶体契约", defaultValue: 1 },
{ key: "collection_type", label: "收集箱类型", defaultValue: 1 },
{ key: "collection_num_0", label: "收集箱数量一", defaultValue: 5 },
{ key: "collection_num_1", label: "收集箱数量二", defaultValue: 5 },
{ key: "costume_card", label: "名称装扮", defaultValue: 1 },
] as const;
const setNotice = (message: string, type: "error" | "info" = "info") => {
notice.value = message;
noticeType.value = type;
@@ -71,12 +87,21 @@ const summary = computed(() => {
const emblemCount = Object.values(configStore.config.emblems || {}).reduce((acc, item) => {
return acc + (item.items?.filter(Boolean).length ?? 0);
}, 0);
const jadeCount = Object.values(configStore.config.jades || {}).filter(
(item) => Number(item.id) >= 0,
).length;
const sundryCount = sundryFields.filter((item) => {
const value = configStore.config.sundry?.[item.key] ?? item.defaultValue;
return value !== 0 && value !== "" && value !== undefined;
}).length;
return {
equipCount,
avatarCount,
oathCount,
emblemCount,
jadeCount,
sundryCount,
emblemSlots: infoStore.emblemParts.length,
};
});
@@ -182,9 +207,127 @@ const emblemMaxCount = computed(() => {
return 4;
});
const setNumberField = (
const setOathAdaptation = (part: string, value: string) => {
const target = configStore.config.oaths[part];
if (!target) return;
const parsed = Number.parseInt(value || "0", 10);
if (Number.isNaN(parsed)) {
setNotice(`誓约槽位 ${part} 的输入无效,请输入数字。`, "error");
return;
}
const partsToApply = oathBatchApply.value ? infoStore.oathParts : [part];
partsToApply.forEach((item) => {
if (!configStore.config.oaths[item]) {
configStore.config.oaths[item] = { ...configStore.defaultOathConfig };
}
configStore.config.oaths[item].adaptation = Math.max(0, parsed);
});
setNotice(oathBatchApply.value ? "已批量更新全部誓约槽位适配值。" : `已更新誓约槽位 ${part} 的适配值。`);
trackSource(oathBatchApply.value ? "誓约适配已批量更新" : `誓约槽位 ${part} 适配已更新`);
};
const equipCfg = (part: string) => configStore.config.equips[part] ?? configStore.defaultEqusConfig;
const oathCfg = (part: string) => configStore.config.oaths[part] ?? configStore.defaultOathConfig;
const avatarCfg = (part: string) =>
configStore.config.avatar[part] ?? configStore.defaultAvatarConfig;
const canUpgradeEquip = (part: string) => !["称号", "宠物"].includes(part);
const canSetSecret = (part: string) => ["武器", "辅助装备", "魔法石", "耳环"].includes(part);
const equipEnchantOptions = (part: string) =>
infoStore.enchants
.filter(
(item) =>
item.position.includes(part) &&
!item.position.includes("武器装扮") &&
!item.position.includes("宠物装备"),
)
.sort((a, b) => (b.fame ?? 0) - (a.fame ?? 0));
const formatFloat = (value: number, digits = 1) => Number(value.toFixed(digits)).toString();
const ensureJade = (key: string) => {
if (!configStore.config.jades[key]) {
configStore.config.jades[key] = { id: -1, value: 0 };
}
return configStore.config.jades[key];
};
const jadeValueOptions = (id: number) => {
if (id < 0) return [];
const current = infoStore.jades.find((item) => item.id === id) as IJade | undefined;
if (!current) return [];
const result: { id: number; value: string }[] = [];
const max = Number(current.max);
const min = Number(current.min);
const step = Number(current.pre);
if (current.unit.includes("等级")) {
result.push({ id: 1, value: current.unit });
} else if (step > 0) {
for (let value = max; value >= min; value = Number((value - step).toFixed(1))) {
if (value !== 0) {
result.push({
id: value,
value: `${value > 0 ? "+" : ""}${formatFloat(value, 1)}${current.unit}`,
});
}
}
}
return result;
};
const setJadeId = (key: string, value: string) => {
const target = ensureJade(key);
const id = Number.parseInt(value, 10);
target.id = Number.isNaN(id) ? -1 : id;
target.value = jadeValueOptions(target.id)[0]?.id ?? 0;
setNotice(`已更新辟邪玉槽位 ${Number(key) + 1}。`);
trackSource(`辟邪玉槽位 ${Number(key) + 1} 已更新`);
};
const setJadeValue = (key: string, value: string) => {
const target = ensureJade(key);
const parsed = Number(value);
target.value = Number.isNaN(parsed) ? 0 : parsed;
setNotice(`已更新辟邪玉槽位 ${Number(key) + 1} 数值。`);
trackSource(`辟邪玉槽位 ${Number(key) + 1} 数值已更新`);
};
const resetJades = () => {
jadeKeys.forEach((key) => {
configStore.config.jades[key] = { id: -1, value: 0 };
});
setNotice("已重置辟邪玉配置。");
trackSource("辟邪玉配置已重置");
};
const sundryOptions = (key: string) => infoStore.sundries[key]?.options ?? [];
const sundryValue = (key: string, defaultValue: number) => {
return String(configStore.config.sundry?.[key] ?? defaultValue);
};
const setSundryValue = (key: string, value: string) => {
const parsed = Number(value);
configStore.config.sundry[key] = Number.isNaN(parsed) ? value : parsed;
setNotice("已更新其它系统配置。");
trackSource(`其它系统 ${key} 已更新`);
};
const resetSundry = () => {
sundryFields.forEach((field) => {
configStore.config.sundry[field.key] = field.defaultValue;
});
setNotice("已重置其它系统配置。");
trackSource("其它系统配置已重置");
};
const setEquipNumberField = (
part: string,
field: "reinforce" | "refine" | "adaptation",
field: "reinforce" | "reinforceType" | "refine" | "adaptation" | "precision",
value: string,
) => {
const target = configStore.config.equips[part];
@@ -196,29 +339,45 @@ const setNumberField = (
return;
}
target[field] = Math.max(0, parsed);
setNotice(`已更新 ${part} 的打造参数。`);
const normalized =
field === "precision" ? Math.max(0, Math.min(parsed, 100)) : Math.max(0, parsed);
const canBatch = equipBatchApply.value && ["reinforceType", "reinforce", "adaptation"].includes(field);
const partsToApply = canBatch ? infoStore.parts.filter(canUpgradeEquip) : [part];
partsToApply.forEach((item) => {
if (!configStore.config.equips[item]) {
configStore.config.equips[item] = { ...configStore.defaultEqusConfig };
}
configStore.config.equips[item][field] = normalized;
});
setNotice(canBatch ? "已批量更新装备打造参数。" : `已更新 ${part} 的打造参数。`);
trackSource(canBatch ? `装备 ${field} 已批量更新` : `装备 ${part} ${field} 已更新`);
};
const setOathAdaptation = (part: string, value: string) => {
const target = configStore.config.oaths[part];
const setEquipEnchant = (part: string, value: string) => {
const target = configStore.config.equips[part];
if (!target) return;
const parsed = Number.parseInt(value || "0", 10);
if (Number.isNaN(parsed)) {
setNotice(`誓约槽位 ${part} 的输入无效,请输入数字。`, "error");
return;
}
const enchant = infoStore.enchants.find((item) => String(item.id) === value);
const partsToApply =
equipBatchApply.value && enchant?.position.length
? enchant.position.filter((item) => configStore.config.equips[item])
: [part];
target.adaptation = Math.max(0, parsed);
setNotice(`已更新誓约槽位 ${part} 的适配值。`);
partsToApply.forEach((item) => {
if (!configStore.config.equips[item]) {
configStore.config.equips[item] = { ...configStore.defaultEqusConfig };
}
configStore.config.equips[item].enchant = value;
});
setNotice(equipBatchApply.value && partsToApply.length > 1 ? "已批量更新装备附魔。" : `已更新 ${part} 的附魔。`);
trackSource(
equipBatchApply.value && partsToApply.length > 1 ? "装备附魔已批量更新" : `装备 ${part} 附魔已更新`,
);
};
const equipCfg = (part: string) => configStore.config.equips[part] ?? configStore.defaultEqusConfig;
const oathCfg = (part: string) => configStore.config.oaths[part] ?? configStore.defaultOathConfig;
const avatarCfg = (part: string) =>
configStore.config.avatar[part] ?? configStore.defaultAvatarConfig;
const ensureAvatarPart = (part: string) => {
if (!configStore.config.avatar[part]) {
configStore.config.avatar[part] = { ...configStore.defaultAvatarConfig };
@@ -339,6 +498,12 @@ const resetAllForge = () => {
infoStore.emblemParts.forEach((part) => {
configStore.config.emblems[part] = { ...configStore.defaultEmblemConfig };
});
jadeKeys.forEach((key) => {
configStore.config.jades[key] = { id: -1, value: 0 };
});
sundryFields.forEach((field) => {
configStore.config.sundry[field.key] = field.defaultValue;
});
setNotice("已重置当前角色的打造参数。", "info");
trackSource("当前角色 Forge 配置已快速重置");
};
@@ -352,7 +517,8 @@ const resetAllForge = () => {
<span>Forge 面板</span>
<span class="text-xs font-normal text-muted-foreground">
装备 {{ summary.equipCount }} · 时装 {{ summary.avatarCount }} · 誓约
{{ summary.oathCount }} · 徽章 {{ summary.emblemCount }}
{{ summary.oathCount }} · 徽章 {{ summary.emblemCount }} · 辟邪玉
{{ summary.jadeCount }} · 其它 {{ summary.sundryCount }}
</span>
</CardTitle>
<CardDescription>Forge 进阶:子模块可编辑,统一浅色/深色模式。</CardDescription>
@@ -400,6 +566,20 @@ const resetAllForge = () => {
>
时装徽章
</Button>
<Button
size="sm"
:variant="moduleMode === 'jade' ? 'default' : 'ghost'"
@click="moduleMode = 'jade'"
>
辟邪玉
</Button>
<Button
size="sm"
:variant="moduleMode === 'sundry' ? 'default' : 'ghost'"
@click="moduleMode = 'sundry'"
>
其它
</Button>
<Button
size="sm"
:variant="moduleMode === 'global' ? 'default' : 'ghost'"
@@ -433,6 +613,18 @@ const resetAllForge = () => {
>时装、徽章、等级与条目配置。</AccordionContent
>
</AccordionItem>
<AccordionItem value="jade">
<AccordionTrigger @click="moduleMode = 'jade'">辟邪玉</AccordionTrigger>
<AccordionContent class="text-xs text-muted-foreground"
>4 个辟邪玉词条与数值配置。</AccordionContent
>
</AccordionItem>
<AccordionItem value="sundry">
<AccordionTrigger @click="moduleMode = 'sundry'">其它</AccordionTrigger>
<AccordionContent class="text-xs text-muted-foreground"
>迷雾、冒险团、结婚、晶体契约等系统配置。</AccordionContent
>
</AccordionItem>
<AccordionItem value="global">
<AccordionTrigger @click="moduleMode = 'global'">全局</AccordionTrigger>
<AccordionContent class="text-xs text-muted-foreground"
@@ -483,40 +675,121 @@ const resetAllForge = () => {
<CardDescription>强化、锻造、品级编辑并即时回写配置。</CardDescription>
</CardHeader>
<CardContent class="space-y-2 text-xs sm:space-y-3 sm:text-sm">
<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="grid grid-cols-1 gap-2 rounded-md border p-2 md:grid-cols-[7rem_1fr_6rem_6rem_6rem_auto] md:items-center md:p-3"
class="space-y-3 rounded-md border p-2 md:p-3"
>
<div class="font-medium">{{ part }}</div>
<div class="truncate text-muted-foreground">
{{ equipNameById.get(equipCfg(part).id) || "未选择装备(可在装备页选择)" }}
<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">
{{ equipNameById.get(equipCfg(part).id) || "未选择装备(可在装备页选择)" }}
</div>
<Button size="sm" variant="ghost" @click="resetEquipPart(part)">重置</Button>
</div>
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2 xl:grid-cols-4">
<label v-if="canUpgradeEquip(part)" class="space-y-1">
<span class="text-muted-foreground">强化类型</span>
<select
class="h-8 w-full rounded-md border border-input bg-background px-2"
:value="String(equipCfg(part).reinforceType)"
@change="
(event) =>
setEquipNumberField(
part,
'reinforceType',
(event.target as HTMLSelectElement).value,
)
"
>
<option value="0">强化</option>
<option value="1">增幅</option>
</select>
</label>
<label v-if="canUpgradeEquip(part)" class="space-y-1">
<span class="text-muted-foreground">强化/增幅</span>
<Input
:model-value="String(equipCfg(part).reinforce)"
type="number"
min="0"
max="30"
class="h-8 text-xs"
@update:model-value="
(value) => setEquipNumberField(part, 'reinforce', String(value))
"
/>
</label>
<label class="space-y-1">
<span class="text-muted-foreground">锻造</span>
<Input
:model-value="String(equipCfg(part).refine)"
type="number"
min="0"
max="8"
class="h-8 text-xs"
@update:model-value="
(value) => setEquipNumberField(part, 'refine', String(value))
"
/>
</label>
<label v-if="canUpgradeEquip(part)" class="space-y-1">
<span class="text-muted-foreground">调适</span>
<Input
:model-value="String(equipCfg(part).adaptation)"
type="number"
min="0"
max="3"
class="h-8 text-xs"
@update:model-value="
(value) => setEquipNumberField(part, 'adaptation', String(value))
"
/>
</label>
<label v-if="equipEnchantOptions(part).length" class="space-y-1 sm:col-span-2">
<span class="text-muted-foreground">附魔</span>
<select
class="h-8 w-full min-w-0 rounded-md border border-input bg-background px-2"
:value="equipCfg(part).enchant"
@change="
(event) => setEquipEnchant(part, (event.target as HTMLSelectElement).value)
"
>
<option value="">无</option>
<option
v-for="item in equipEnchantOptions(part)"
:key="item.id"
:value="String(item.id)"
>
{{ item.detail }}
</option>
</select>
</label>
<label v-if="canSetSecret(part)" class="space-y-1">
<span class="text-muted-foreground">秘宝精度</span>
<Input
:model-value="String(equipCfg(part).precision)"
type="number"
min="0"
max="100"
class="h-8 text-xs"
@update:model-value="
(value) => setEquipNumberField(part, 'precision', String(value))
"
/>
</label>
</div>
<Input
:model-value="String(equipCfg(part).reinforce)"
type="number"
min="0"
class="h-8 text-xs"
placeholder="强化"
@update:model-value="(value) => setNumberField(part, 'reinforce', String(value))"
/>
<Input
:model-value="String(equipCfg(part).refine)"
type="number"
min="0"
class="h-8 text-xs"
placeholder="锻造"
@update:model-value="(value) => setNumberField(part, 'refine', String(value))"
/>
<Input
:model-value="String(equipCfg(part).adaptation)"
type="number"
min="0"
class="h-8 text-xs"
placeholder="品级"
@update:model-value="(value) => setNumberField(part, 'adaptation', String(value))"
/>
<Button size="sm" variant="ghost" @click="resetEquipPart(part)">重置</Button>
</div>
</CardContent>
</Card>
@@ -532,6 +805,12 @@ const resetAllForge = () => {
<CardDescription>编辑誓约槽位适配值。</CardDescription>
</CardHeader>
<CardContent class="space-y-2 text-xs sm:space-y-3 sm:text-sm">
<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="oathBatchApply" type="checkbox" class="size-4" />
</label>
<div
v-for="part in oathParts"
:key="part"
@@ -779,6 +1058,104 @@ const resetAllForge = () => {
</Card>
</div>
<div
v-if="forgeViewTab === 'config' && moduleMode === 'jade'"
class="grid grid-cols-1 gap-4"
>
<Card class="border-border/60 bg-card/90">
<CardHeader>
<CardTitle class="flex items-center gap-2">
<Sparkles class="size-4" />
辟邪玉
</CardTitle>
<CardDescription>从旧版 Forge 迁移的 4 个辟邪玉词条与数值配置。</CardDescription>
</CardHeader>
<CardContent class="space-y-2 text-xs sm:space-y-3 sm:text-sm">
<div
v-for="key in jadeKeys"
:key="key"
class="grid grid-cols-1 gap-2 rounded-md border p-2 md:grid-cols-[6rem_1fr_12rem] md:items-center md:p-3"
>
<div class="font-medium">槽位 {{ Number(key) + 1 }}</div>
<select
class="h-8 min-w-0 rounded-md border border-input bg-background px-2"
:value="String(ensureJade(key).id)"
@change="(event) => setJadeId(key, (event.target as HTMLSelectElement).value)"
>
<option value="-1">无</option>
<option v-for="item in infoStore.jades" :key="item.id" :value="String(item.id)">
{{ item.name }}
</option>
</select>
<select
class="h-8 min-w-0 rounded-md border border-input bg-background px-2"
:value="String(ensureJade(key).value)"
:disabled="ensureJade(key).id < 0"
@change="(event) => setJadeValue(key, (event.target as HTMLSelectElement).value)"
>
<option value="0">无</option>
<option
v-for="item in jadeValueOptions(ensureJade(key).id)"
:key="item.id"
:value="String(item.id)"
>
{{ item.value }}
</option>
</select>
</div>
<div class="flex justify-end">
<Button size="sm" variant="outline" @click="resetJades">重置辟邪玉</Button>
</div>
</CardContent>
</Card>
</div>
<div
v-if="forgeViewTab === 'config' && moduleMode === 'sundry'"
class="grid grid-cols-1 gap-4"
>
<Card class="border-border/60 bg-card/90">
<CardHeader>
<CardTitle class="flex items-center gap-2">
<ShieldCheck class="size-4" />
其它系统
</CardTitle>
<CardDescription>迷雾、冒险团、结婚系统、晶体契约、收集箱等配置。</CardDescription>
</CardHeader>
<CardContent class="space-y-2 text-xs sm:space-y-3 sm:text-sm">
<div class="grid grid-cols-1 gap-2 md:grid-cols-2 xl:grid-cols-3">
<div
v-for="field in sundryFields"
:key="field.key"
class="grid grid-cols-1 gap-2 rounded-md border p-2 sm:grid-cols-[6rem_1fr] sm:items-center sm:p-3"
>
<div class="font-medium">{{ field.label }}</div>
<select
class="h-8 min-w-0 rounded-md border border-input bg-background px-2"
:value="sundryValue(field.key, field.defaultValue)"
@change="
(event) => setSundryValue(field.key, (event.target as HTMLSelectElement).value)
"
>
<option
v-for="item in sundryOptions(field.key)"
:key="`${field.key}-${item.value}`"
:value="String(item.value)"
>
{{ item.name }}
</option>
</select>
</div>
</div>
<div class="flex justify-end">
<Button size="sm" variant="outline" @click="resetSundry">重置其它系统</Button>
</div>
</CardContent>
</Card>
</div>
<div
v-if="forgeViewTab === 'config' && moduleMode === 'global'"
class="grid grid-cols-1 gap-4 lg:grid-cols-2"
@@ -815,6 +1192,8 @@ const resetAllForge = () => {
<div>誓约槽位: {{ summary.oathCount }}</div>
<div>已配置时装: {{ summary.avatarCount }}</div>
<div>已配置徽章条目: {{ summary.emblemCount }}</div>
<div>已配置辟邪玉: {{ summary.jadeCount }}</div>
<div>其它系统项目: {{ summary.sundryCount }}</div>
</CardContent>
</Card>
</div>
+230 -2
View File
@@ -1,13 +1,27 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { PanelRight } from "lucide-vue-next";
import { FileInput, PanelRight, WandSparkles } from "lucide-vue-next";
import api from "@/api";
import { Button } from "@/components/ui/button";
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
import { Input } from "@/components/ui/input";
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import { Textarea } from "@/components/ui/textarea";
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 { 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;
@@ -17,14 +31,50 @@ const route = useRoute();
const router = useRouter();
const infoStore = useInfoStore();
const configStore = useConfigStore();
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);
const helperUid = ref("");
const skillCode = ref("");
const helperImportLoading = ref(false);
const skillImportLoading = ref(false);
const importStatus = ref("");
const importError = ref("");
const helperRole = ref<IHelperInfo["roleList"][number]>();
let mediaQuery: MediaQueryList | null = null;
type AvatarConfig = Record<
string,
{
id: string;
enchant: string;
emblem_0: string;
emblem_1: string;
option: string;
}
>;
interface HelperConfigPayload {
equips?: Record<string, IConfigEquip>;
avatar?: AvatarConfig;
avatars?: AvatarConfig;
avatarLink?: string;
emblems?: Record<string, IConfigEmblem>;
fogOathLv?: unknown;
oaths?: Record<string, IConfigOath>;
}
interface SkillImportPayload {
skills?: typeof configStore.config.skills;
}
const updateMobileLayout = () => {
isMobileLayout.value = mediaQuery?.matches ?? false;
if (!isMobileLayout.value) {
@@ -55,6 +105,7 @@ const loadCharacter = async () => {
loadError.value = "";
try {
await infoStore.createCharacter(props.alter);
prepareHelperRoleImport();
} catch (error) {
loadError.value = error instanceof Error ? error.message : "角色数据加载失败";
} finally {
@@ -66,6 +117,103 @@ const switchTab = (tab: (typeof tabs)[number]["key"]) => {
router.push(`/dcalc/character/${props.alter}/${tab}`);
};
const resetImportMessage = () => {
importStatus.value = "";
importError.value = "";
};
const applyHelperConfig = (payload: HelperConfigPayload) => {
if (payload.equips) configStore.config.equips = payload.equips;
if (payload.avatars || payload.avatar) {
configStore.config.avatar = payload.avatars ?? payload.avatar ?? configStore.config.avatar;
}
if (payload.avatarLink !== undefined) configStore.config.avatarLink = payload.avatarLink;
if (payload.emblems) configStore.config.emblems = payload.emblems;
if (payload.fogOathLv !== undefined) configStore.config.sundry.fog = payload.fogOathLv;
if (payload.oaths) configStore.config.oaths = payload.oaths;
configStore.pushChangeSource("Import", "助手打造配置已导入");
};
const importHelperConfig = async () => {
resetImportMessage();
const uid = helperUid.value.trim();
if (!uid) {
importError.value = "请输入助手社区 ID。";
return;
}
helperImportLoading.value = true;
try {
const payload = (await api.getConfigFromHelper(uid)) as HelperConfigPayload;
applyHelperConfig(payload);
helperImportOpen.value = false;
helperUid.value = "";
importStatus.value = "打造导入成功。";
} catch (error) {
importError.value = error instanceof Error ? error.message : "打造导入失败。";
} finally {
helperImportLoading.value = false;
}
};
const importHelperRoleConfig = async () => {
resetImportMessage();
const uid = helperStore.helperInfo?.id;
const roleId = helperRole.value?.roleId;
if (!uid || !roleId) {
importError.value = "未找到可导入的助手角色。";
return;
}
helperImportLoading.value = true;
try {
const payload = (await api.getConfigFromHelperAlter(uid, roleId)) as HelperConfigPayload;
applyHelperConfig(payload);
helperRoleImportOpen.value = false;
importStatus.value = "助手角色打造导入成功。";
} catch (error) {
importError.value = error instanceof Error ? error.message : "助手角色打造导入失败。";
} finally {
helperImportLoading.value = false;
}
};
const importSkillConfig = async () => {
resetImportMessage();
const code = skillCode.value.trim();
if (!code) {
importError.value = "请输入技能加点代码。";
return;
}
skillImportLoading.value = true;
try {
const payload = (await api.getSkillFromCode(code)) as SkillImportPayload;
if (payload.skills) {
configStore.config.skills = payload.skills;
configStore.pushChangeSource("Import", "技能加点代码已导入");
}
skillImportOpen.value = false;
skillCode.value = "";
importStatus.value = "技能导入成功。";
} catch (error) {
importError.value = error instanceof Error ? error.message : "技能导入失败。";
} finally {
skillImportLoading.value = false;
}
};
const prepareHelperRoleImport = () => {
const roleId = route.query.roleId;
if (typeof roleId !== "string") return;
const target = helperStore.helperInfo?.roleList.find((item) => item.roleId === roleId);
if (!target) return;
helperRole.value = target;
helperRoleImportOpen.value = true;
};
onMounted(() => {
loadCharacter();
window.addEventListener("beforeunload", configStore.saveConfig);
@@ -81,6 +229,13 @@ watch(
},
);
watch(
() => route.query.roleId,
() => {
prepareHelperRoleImport();
},
);
watch(showGlobalResultPanel, (show) => {
if (!show) {
isResultSheetOpen.value = false;
@@ -125,6 +280,79 @@ onUnmounted(() => {
</TabsList>
</Tabs>
<div class="flex flex-wrap items-center gap-2">
<Sheet v-model:open="helperImportOpen">
<SheetTrigger as-child>
<Button size="sm" variant="outline" @click="resetImportMessage">
<FileInput class="size-4" />
打造导入
</Button>
</SheetTrigger>
<SheetContent class="w-full overflow-y-auto sm:max-w-md">
<SheetHeader>
<SheetTitle>打造导入</SheetTitle>
<SheetDescription>助手社区 ID</SheetDescription>
</SheetHeader>
<div class="grid gap-3 px-4">
<Input v-model="helperUid" placeholder="社区 ID" @keyup.enter="importHelperConfig" />
<p v-if="importError" class="text-sm text-destructive">{{ importError }}</p>
</div>
<SheetFooter>
<Button :disabled="helperImportLoading" @click="importHelperConfig">
{{ helperImportLoading ? "导入中..." : "导入" }}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
<Sheet v-model:open="skillImportOpen">
<SheetTrigger as-child>
<Button size="sm" variant="outline" @click="resetImportMessage">
<WandSparkles class="size-4" />
技能导入
</Button>
</SheetTrigger>
<SheetContent class="w-full overflow-y-auto sm:max-w-md">
<SheetHeader>
<SheetTitle>技能导入</SheetTitle>
<SheetDescription>技能加点代码</SheetDescription>
</SheetHeader>
<div class="grid gap-3 px-4">
<Textarea v-model="skillCode" class="min-h-28" placeholder="技能代码" />
<p v-if="importError" class="text-sm text-destructive">{{ importError }}</p>
</div>
<SheetFooter>
<Button :disabled="skillImportLoading" @click="importSkillConfig">
{{ skillImportLoading ? "导入中..." : "导入" }}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
<p v-if="importStatus" class="text-xs text-muted-foreground">{{ importStatus }}</p>
</div>
<Sheet v-model:open="helperRoleImportOpen">
<SheetContent class="w-full overflow-y-auto sm:max-w-md">
<SheetHeader>
<SheetTitle>助手角色导入</SheetTitle>
<SheetDescription>{{ helperRole?.roleName || "助手角色" }}</SheetDescription>
</SheetHeader>
<div class="grid gap-2 px-4 text-sm">
<div class="rounded-md border p-3">
<div class="font-medium">{{ helperRole?.roleJob }}</div>
<div class="text-muted-foreground">名望 {{ helperRole?.fame ?? "-" }}</div>
</div>
<p v-if="importError" class="text-sm text-destructive">{{ importError }}</p>
</div>
<SheetFooter>
<Button :disabled="helperImportLoading" @click="importHelperRoleConfig">
{{ helperImportLoading ? "导入中..." : "导入" }}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
<div
:class="[
'grid min-h-0 flex-1 grid-cols-1 gap-4',
@@ -1,21 +1,13 @@
<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 } from "@/components/ui/card";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
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 EquList from "@/components/dnf/Equipment/List/index.vue";
import OathList from "@/components/dnf/Equipment/Oath/index.vue";
import { useConfigStore, useInfoStore } from "@/stores";
const configStore = useConfigStore();
const infoStore = useInfoStore();
@@ -26,8 +18,6 @@ const equippedOathPart = ref("0");
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;
@@ -35,57 +25,26 @@ const resultError = computed(() => {
return error instanceof Error ? error.message : String(error);
});
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);
};
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) =>
Math.max(0, Number(getSkillCountEntry(skill).count) || 0);
const getStandardSkill = (skill: IResultSkill) =>
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 skills = computed(() => resultState.value?.skills ?? []);
const totalWeightedDamage = computed(() =>
skills.value.reduce((sum, item) => sum + item.damage * getSkillCount(item), 0),
skills.value.reduce((sum, item) => {
const count = configStore.skillCountConfig.find(
(config) => config.name === item.name && config.mode === item.mode,
)?.count;
return sum + item.damage * Math.max(0, Number(count ?? 1) || 0);
}, 0),
);
const totalStandardDamage = computed(() => {
if (!standard.value) return 0;
return skills.value.reduce((sum, item) => {
const base = getStandardSkill(item);
if (!base) return sum;
return sum + base.damage * getStandardSkillCount(item);
}, 0);
const hasVisibleResult = computed(
() => (resultState.value?.skills?.length ?? 0) > 0 || (resultState.value?.info?.length ?? 0) > 0,
);
const statusText = computed(() => {
if (isLoading.value) return "计算中...";
if (resultError.value) return resultError.value;
if (!hasVisibleResult.value) return "暂无结果";
return "";
});
const formatNumber = (value: number) => {
@@ -93,152 +52,54 @@ const formatNumber = (value: number) => {
if (Math.abs(value) >= 100000000) return `${(value / 100000000).toFixed(2)}e`;
return value.toLocaleString("zh-CN", { maximumFractionDigits: 2 });
};
const totalDiffLabel = computed(() => {
if (!totalStandardDamage.value) return formatNumber(totalWeightedDamage.value);
const diff =
((totalWeightedDamage.value - totalStandardDamage.value) / totalStandardDamage.value) * 100;
return `${diff >= 0 ? "+" : ""}${diff.toFixed(2)}%`;
});
const totalDiffClass = computed(() => {
if (!totalStandardDamage.value) return "text-foreground";
return totalWeightedDamage.value >= totalStandardDamage.value
? "text-emerald-600"
: "text-red-600";
});
const hasVisibleResult = computed(
() => (resultState.value?.skills?.length ?? 0) > 0 || (resultState.value?.info?.length ?? 0) > 0,
);
const changeSources = computed(() => configStore.changeSourceLogs.slice(0, 8));
const clearChangeSources = () => {
configStore.clearChangeSource();
};
const defaultOpenSections = ["overview", "equipped"];
const statusText = computed(() => {
if (isLoading.value) return "计算中...";
if (resultError.value) return resultError.value;
if (!hasVisibleResult.value) return "暂无结果,请先编辑配置。";
if (isBufferClass.value) return "请前往计算详情查看。";
return "";
});
</script>
<template>
<div class="flex h-full min-h-0 flex-col gap-3">
<Card>
<CardContent class="py-2">
<Accordion type="multiple" :default-value="defaultOpenSections" class="w-full">
<AccordionItem value="overview">
<AccordionTrigger>结果概览</AccordionTrigger>
<AccordionContent>
<div class="space-y-3 px-1">
<div class="flex flex-wrap gap-2">
<Button size="sm" variant="outline" class="h-8 px-2.5" @click="setStandard"
>设为基准</Button
>
<Button size="sm" variant="outline" class="h-8 px-2.5" @click="clearStandard"
>清空基准</Button
>
</div>
<Card class="shrink-0">
<CardContent class="p-3">
<div class="rounded-md border border-border/60 px-3 py-2">
<div class="text-xs text-muted-foreground">总伤害</div>
<div
v-if="statusText"
class="mt-1 text-sm"
:class="resultError ? 'text-destructive' : 'text-muted-foreground'"
>
{{ statusText }}
</div>
<div v-else class="mt-1 truncate text-xl font-semibold tabular-nums">
{{ formatNumber(totalWeightedDamage) }}
</div>
</div>
</CardContent>
</Card>
<div
v-if="isLoading || resultError || !hasVisibleResult || isBufferClass"
class="rounded-md border border-border/60 px-3 py-2 text-center text-sm"
:class="resultError ? 'text-destructive' : 'text-muted-foreground'"
>
{{ statusText }}
</div>
<Card class="min-h-0 flex-1">
<CardContent class="flex h-full min-h-0 flex-col gap-2 p-3 text-xs">
<Tabs :model-value="displayTab" class="shrink-0">
<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-else
class="flex items-center justify-between rounded-md border border-border/60 px-3 py-2"
>
<span class="text-sm text-muted-foreground">合计伤害</span>
<span class="text-sm font-semibold" :class="totalDiffClass">{{
totalDiffLabel
}}</span>
</div>
<ScrollArea class="min-h-0 flex-1 rounded-md border p-2">
<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 v-if="resultError" class="flex justify-center">
<Button size="sm" variant="outline" @click="retryCalc">重试计算</Button>
</div>
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem value="equipped">
<AccordionTrigger>穿戴信息</AccordionTrigger>
<AccordionContent>
<div class="space-y-2 px-1 text-xs">
<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>
<ScrollArea class="max-h-56 rounded-md border p-2">
<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>
<template #scrollbar>
<ScrollBar orientation="vertical" />
</template>
</ScrollArea>
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem value="changes">
<AccordionTrigger>配置变更来源</AccordionTrigger>
<AccordionContent>
<div class="space-y-2 px-1">
<div class="flex items-center justify-between text-xs text-muted-foreground">
<span>最近触发计算的操作记录</span>
<Button size="sm" variant="ghost" class="h-7 px-2" @click="clearChangeSources"
>清空</Button
>
</div>
<div v-if="changeSources.length === 0" class="text-xs 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 text-xs"
>
<div class="flex items-center justify-between gap-2">
<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>
</AccordionContent>
</AccordionItem>
</Accordion>
<template #scrollbar>
<ScrollBar orientation="vertical" />
</template>
</ScrollArea>
</CardContent>
</Card>
</div>
@@ -28,7 +28,7 @@ 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]",
rightPanelTab.value === "result" ? "xl:w-[360px]" : "xl:w-[550px]",
);
/* ─── Constants ─── */
@@ -193,6 +193,19 @@ const upOptionsForSlot = (slotIdx: number) => {
.sort((a, b) => a.learnLv - b.learnLv);
};
const firstAwakeSkill = computed(() =>
infoStore.skills.find((item) => item.learnLv === 50 && item.type === "active" && item.bind),
);
const secondAwakeSkill = computed(() =>
infoStore.skills.find((item) => item.learnLv === 85 && item.type === "active" && item.bind),
);
const canBindAwake = computed(() => !!firstAwakeSkill.value || !!secondAwakeSkill.value);
const setBindAwake = (level: 50 | 85) => {
configStore.config.bindAwake = level;
track(`觉醒绑定切换为 ${level === 50 ? "一觉" : "二觉"}`);
};
const setUPSlotSkill = (slotIdx: number, nextId: string) => {
const slot = upSlots.value[slotIdx];
if (!slot) return;
@@ -337,6 +350,33 @@ const resetAllSkills = () => {
: '',
]"
/>
<div
v-if="skill.learnLv === 100 && skill.bind && canBindAwake"
class="awake-bind-panel"
@click.stop
>
<button
v-if="firstAwakeSkill"
class="awake-bind-btn"
:class="{ active: configStore.config.bindAwake === 50 }"
title="绑定一觉"
@click.stop="setBindAwake(50)"
>
<img :src="getImageURL(firstAwakeSkill.icon)" :alt="firstAwakeSkill.name" />
</button>
<button
v-if="secondAwakeSkill"
class="awake-bind-btn"
:class="{ active: configStore.config.bindAwake === 85 }"
title="绑定二觉"
@click.stop="setBindAwake(85)"
>
<img
:src="getImageURL(secondAwakeSkill.icon)"
:alt="secondAwakeSkill.name"
/>
</button>
</div>
<div
v-if="activeSkillId === skill.id.toString()"
class="action-row"
@@ -399,6 +439,36 @@ const resetAllSkills = () => {
: '',
]"
/>
<div
v-if="cell.learnLv === 100 && cell.bind && canBindAwake"
class="awake-bind-panel"
@click.stop
>
<button
v-if="firstAwakeSkill"
class="awake-bind-btn"
:class="{ active: configStore.config.bindAwake === 50 }"
title="绑定一觉"
@click.stop="setBindAwake(50)"
>
<img
:src="getImageURL(firstAwakeSkill.icon)"
:alt="firstAwakeSkill.name"
/>
</button>
<button
v-if="secondAwakeSkill"
class="awake-bind-btn"
:class="{ active: configStore.config.bindAwake === 85 }"
title="绑定二觉"
@click.stop="setBindAwake(85)"
>
<img
:src="getImageURL(secondAwakeSkill.icon)"
:alt="secondAwakeSkill.name"
/>
</button>
</div>
<div
v-if="activeSkillId === cell.id.toString()"
class="action-row"
@@ -844,6 +914,41 @@ const resetAllSkills = () => {
border-radius: 2px;
}
.awake-bind-panel {
position: absolute;
top: 10px;
left: 50%;
z-index: 8;
display: flex;
gap: 2px;
transform: translateX(-50%);
border-radius: 4px;
background: color-mix(in oklab, var(--background) 88%, transparent);
padding: 2px;
box-shadow: 0 0 0 1px color-mix(in oklab, var(--border) 80%, transparent);
}
.awake-bind-btn {
width: 22px;
height: 22px;
border: 1px solid color-mix(in oklab, var(--border) 70%, transparent);
border-radius: 3px;
overflow: hidden;
opacity: 0.62;
}
.awake-bind-btn.active {
border-color: var(--primary);
opacity: 1;
box-shadow: 0 0 0 1px var(--primary);
}
.awake-bind-btn img {
width: 100%;
height: 100%;
object-fit: cover;
}
/* 4 action buttons at the bottom */
.action-row {
position: absolute;
+10 -1
View File
@@ -1,5 +1,7 @@
<script setup lang="ts">
import { computed } from "vue";
import { useRouter } from "vue-router";
import api from "@/api";
import { Button } from "@/components/ui/button";
// import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
@@ -26,6 +28,7 @@ const props = defineProps<{
const { list } = toRefs(props);
const helperStore = useDnfHelperStore();
const router = useRouter();
const sortedList = computed(() => {
return [...list.value].sort((left, right) => right.fame - left.fame);
@@ -34,6 +37,12 @@ const sortedList = computed(() => {
const formatFame = (fame: number) => {
return new Intl.NumberFormat("zh-CN").format(fame);
};
const importRole = async (item: (typeof props.list)[number]) => {
const alter = await api.alterByName(item.roleJob);
if (!alter?.value) return;
await router.push(`/dcalc/character/${alter.value}/equipment?roleId=${item.roleId}`);
};
</script>
<template>
@@ -64,7 +73,7 @@ const formatFame = (fame: number) => {
<CardHeader class="pb-3">
<CardTitle class="text-base flex">
{{ item.roleName }}
<Button class="ml-auto" size="sm" variant="outline" @click="$router.push('/dcalc/home')"
<Button class="ml-auto" size="sm" variant="outline" @click="importRole(item)"
>导入</Button
>
</CardTitle>
+2 -2
View File
@@ -102,7 +102,7 @@ onMounted(() => {
:disabled="!child.open"
@click="openAlter(child)"
>
<item class="flex items-center gap-2">
<div class="flex items-center gap-2">
<img
:src="
getImageURL(`characters/${adventure.name}/${child.name}/material/Seicing_3.png`)
@@ -111,7 +111,7 @@ onMounted(() => {
class="size-8 rounded-sm object-cover"
/>
<span class="truncate">{{ child.title }}</span>
</item>
</div>
<ArrowRight class="size-3" />
</Button>
</CardContent>