dev
@@ -0,0 +1,60 @@
|
||||
# frontend -> frontendNew 迁移计划(执行中)
|
||||
|
||||
## 目标
|
||||
|
||||
- 以 frontendNew 为主线承接 dcalc 核心能力。
|
||||
- 已完成的 equipment 作为基线,不重做主体,仅做一致性优化。
|
||||
- 当前优先迁移 Skill、Forge、Result,策略为“核心功能优先,视觉后置”。
|
||||
- 迁移期间保持 frontend 与 frontendNew 双端并行可用。
|
||||
|
||||
## 当前状态
|
||||
|
||||
- 已完成:`Character/EquipmentDictionary` 主体迁移并接入路由。
|
||||
- 进行中:`Character/Skill`、`Character/Forge` 从占位页向可编辑页迁移。
|
||||
- 新启动:`Character/Result` 结果面板接入与自动计算链路。
|
||||
|
||||
## 分阶段计划
|
||||
|
||||
### Phase 1 - 共享链路打通
|
||||
|
||||
1. 统一 Character 页角色加载、配置变更监听、结果自动计算触发。
|
||||
2. 将结果面板并入 Character 页面,形成“配置 + 结果”并行布局。
|
||||
3. 统一配置持久化触发点,保障刷新后状态可恢复。
|
||||
|
||||
### Phase 2 - Skill MVP
|
||||
|
||||
1. 完成技能列表、搜索、基础 VP/UP 配置回写。
|
||||
2. 先确保写入 `configStore.config.skills`,后补复杂交互约束。
|
||||
3. 目标:可参与结果计算。
|
||||
|
||||
### Phase 3 - Forge MVP
|
||||
|
||||
1. 子模块优先级:Equipments > Avatars > Oaths > Emblems/Jades/Sundry。
|
||||
2. 每个子模块完成即验证配置回写和计算结果联动。
|
||||
3. 目标:关键打造项可编辑并进入计算。
|
||||
|
||||
### Phase 4 - Result MVP
|
||||
|
||||
1. 展示基础属性、套装/誓约信息、技能伤害列表。
|
||||
2. 支持伤害排序和基准设定/清空。
|
||||
3. 第二轮补技能次数编辑、Buffer 细分、高级对比。
|
||||
|
||||
### Phase 5 - 一致性优化与回归
|
||||
|
||||
1. 统一加载态/空态/错误态文案。
|
||||
2. 双端同角色同配置结果比对,输出差异清单。
|
||||
3. 阻塞级问题清零后进入主用切换。
|
||||
|
||||
## 验收标准
|
||||
|
||||
1. 路由可用:`/dcalc/character/:alter/equipment|skill|forge`。
|
||||
2. 数据可用:Skill 与 Forge 配置改动能触发结果刷新。
|
||||
3. 结果可用:Result 面板稳定展示基础属性与技能伤害。
|
||||
4. 持久化可用:刷新后配置不丢失。
|
||||
5. 双端可比对:关键字段(伤害、套装、核心属性)一致。
|
||||
|
||||
## 本次提交范围(已执行)
|
||||
|
||||
1. 新增本计划文档。
|
||||
2. 新增 Result MVP 组件并接入 Character 页面。
|
||||
3. 在 Character 页面接入配置变更自动计算与离开页面自动保存。
|
||||
@@ -1,13 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { computed, ref } from "vue";
|
||||
import { Hammer, ShieldCheck, Sparkles } from "lucide-vue-next";
|
||||
import { useConfigStore, useInfoStore } from "@/stores";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
const configStore = useConfigStore();
|
||||
const infoStore = useInfoStore();
|
||||
|
||||
const moduleMode = ref<"equip" | "oath" | "avatar" | "global">("equip");
|
||||
const notice = ref("");
|
||||
const noticeType = ref<"error" | "info">("info");
|
||||
|
||||
const avatarPart = ref("头部");
|
||||
const emblemPart = ref("彩色");
|
||||
const avatarOptionKeyword = ref("");
|
||||
const emblemOptionKeyword = ref("");
|
||||
const onlyConfiguredAvatarParts = ref(false);
|
||||
const onlyConfiguredEmblemSlots = ref(false);
|
||||
|
||||
const noEnchantAvatarParts = ["头部", "帽子", "脸部", "胸部", "上衣", "腰带", "下装", "鞋"];
|
||||
|
||||
const emblemLevels = [
|
||||
"稀有Lv.1",
|
||||
"稀有Lv.2",
|
||||
"稀有Lv.3",
|
||||
"稀有Lv.4",
|
||||
"稀有Lv.5",
|
||||
"神器Lv.1",
|
||||
"神器Lv.2",
|
||||
"神器Lv.3",
|
||||
"神器Lv.4",
|
||||
"神器Lv.5",
|
||||
"传说Lv.1",
|
||||
"传说Lv.2",
|
||||
"传说Lv.3",
|
||||
"传说Lv.4",
|
||||
"传说Lv.5",
|
||||
"史诗Lv.1",
|
||||
];
|
||||
|
||||
const setNotice = (message: string, type: "error" | "info" = "info") => {
|
||||
notice.value = message;
|
||||
noticeType.value = type;
|
||||
};
|
||||
|
||||
const trackSource = (detail: string) => {
|
||||
configStore.pushChangeSource("Forge", detail);
|
||||
};
|
||||
|
||||
const summary = computed(() => {
|
||||
const equipCount = Object.values(configStore.config.equips || {}).filter(
|
||||
(item) => !!item.id,
|
||||
@@ -18,59 +60,672 @@ const summary = computed(() => {
|
||||
const oathCount = Object.values(configStore.config.oaths || {}).filter(
|
||||
(item) => !!item.id,
|
||||
).length;
|
||||
const emblemCount = Object.values(configStore.config.emblems || {}).reduce((acc, item) => {
|
||||
return acc + (item.items?.filter(Boolean).length ?? 0);
|
||||
}, 0);
|
||||
|
||||
return {
|
||||
equipCount,
|
||||
avatarCount,
|
||||
oathCount,
|
||||
emblemCount,
|
||||
emblemSlots: infoStore.emblemParts.length,
|
||||
};
|
||||
});
|
||||
|
||||
const parts = computed(() => infoStore.parts.filter((part) => configStore.config.equips[part]));
|
||||
const oathParts = computed(() =>
|
||||
infoStore.oathParts.filter((part) => configStore.config.oaths[part]),
|
||||
);
|
||||
const avatarParts = computed(() => {
|
||||
const all = infoStore.avatarParts.filter((part) => !!configStore.config.avatar[part]);
|
||||
if (!onlyConfiguredAvatarParts.value) return all;
|
||||
return all.filter((part) => {
|
||||
const cfg = configStore.config.avatar[part];
|
||||
if (!cfg) return false;
|
||||
return !!cfg.id || !!cfg.enchant || !!cfg.emblem_0 || !!cfg.emblem_1 || cfg.option !== "0";
|
||||
});
|
||||
});
|
||||
|
||||
const oathNameById = computed(() => {
|
||||
const map = new Map<string, string>();
|
||||
infoStore.oaths.forEach((item) => {
|
||||
if (!map.has(item.id)) {
|
||||
map.set(item.id, item.name);
|
||||
}
|
||||
});
|
||||
return map;
|
||||
});
|
||||
|
||||
const equipNameById = computed(() => {
|
||||
const map = new Map<string, string>();
|
||||
infoStore.equips.forEach((item) => map.set(item.id, item.name));
|
||||
return map;
|
||||
});
|
||||
|
||||
const avatarEnchantOptions = computed(() => {
|
||||
const keyword = avatarOptionKeyword.value.trim().toLowerCase();
|
||||
return infoStore.enchants
|
||||
.filter((item) => item.position.includes(avatarPart.value))
|
||||
.filter((item) => !keyword || item.detail.toLowerCase().includes(keyword))
|
||||
.sort((a, b) => (b.fame ?? 0) - (a.fame ?? 0));
|
||||
});
|
||||
|
||||
const avatarSuitOptions = computed(() => {
|
||||
return infoStore.avatars[avatarPart.value] ?? [];
|
||||
});
|
||||
|
||||
const avatarEmblemOptions = computed(() => {
|
||||
const keyword = avatarOptionKeyword.value.trim().toLowerCase();
|
||||
return infoStore.emblems
|
||||
.filter((item) => item.type !== "白金")
|
||||
.filter((item) => {
|
||||
if (!keyword) return true;
|
||||
return (
|
||||
item.value.toLowerCase().includes(keyword) ||
|
||||
item.rarity.toLowerCase().includes(keyword) ||
|
||||
item.type.toLowerCase().includes(keyword)
|
||||
);
|
||||
})
|
||||
.sort((a, b) => b.fame - a.fame);
|
||||
});
|
||||
|
||||
const hasAvatarEnchant = computed(() => !noEnchantAvatarParts.includes(avatarPart.value));
|
||||
|
||||
const emblemOptions = computed(() => {
|
||||
const keyword = emblemOptionKeyword.value.trim().toLowerCase();
|
||||
const selected = new Set((emblemCfg.value.items || []).filter(Boolean));
|
||||
|
||||
if (emblemPart.value === "彩色") {
|
||||
return infoStore.emblems
|
||||
.filter((a) => a.type !== "白金")
|
||||
.filter((a) => {
|
||||
const keywordOk =
|
||||
!keyword ||
|
||||
a.value.toLowerCase().includes(keyword) ||
|
||||
a.rarity.toLowerCase().includes(keyword) ||
|
||||
a.type.toLowerCase().includes(keyword);
|
||||
const configuredOk = !onlyConfiguredEmblemSlots.value || selected.has(String(a.id));
|
||||
return keywordOk && configuredOk;
|
||||
})
|
||||
.sort((a, b) => b.fame - a.fame);
|
||||
}
|
||||
|
||||
return infoStore.emblems
|
||||
.filter((a) => {
|
||||
const color = emblemPart.value.charAt(0);
|
||||
return a.type === emblemPart.value || a.type === "彩色" || a.type.includes(color);
|
||||
})
|
||||
.filter((a) => {
|
||||
const keywordOk =
|
||||
!keyword ||
|
||||
a.value.toLowerCase().includes(keyword) ||
|
||||
a.rarity.toLowerCase().includes(keyword) ||
|
||||
a.type.toLowerCase().includes(keyword);
|
||||
const configuredOk = !onlyConfiguredEmblemSlots.value || selected.has(String(a.id));
|
||||
return keywordOk && configuredOk;
|
||||
})
|
||||
.sort((a, b) => b.fame - a.fame);
|
||||
});
|
||||
|
||||
const emblemMaxCount = computed(() => {
|
||||
if (emblemPart.value === "彩色") return 6;
|
||||
if (emblemPart.value === "白金") return 2;
|
||||
return 4;
|
||||
});
|
||||
|
||||
const setNumberField = (
|
||||
part: string,
|
||||
field: "reinforce" | "refine" | "adaptation",
|
||||
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;
|
||||
}
|
||||
|
||||
target[field] = Math.max(0, parsed);
|
||||
setNotice(`已更新 ${part} 的打造参数。`);
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
target.adaptation = Math.max(0, parsed);
|
||||
setNotice(`已更新誓约槽位 ${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 };
|
||||
}
|
||||
return configStore.config.avatar[part];
|
||||
};
|
||||
|
||||
const setAvatarField = (
|
||||
field: "id" | "enchant" | "emblem_0" | "emblem_1" | "option",
|
||||
value: string,
|
||||
) => {
|
||||
const target = ensureAvatarPart(avatarPart.value);
|
||||
target[field] = value;
|
||||
|
||||
if (field === "id") {
|
||||
const options =
|
||||
avatarSuitOptions.value.find((item) => item.id.toString() === value)?.options ?? [];
|
||||
if (options.length === 0) {
|
||||
target.option = "0";
|
||||
} else if (!options.includes(target.option)) {
|
||||
target.option = options[0] ?? "0";
|
||||
}
|
||||
}
|
||||
|
||||
setNotice(`已更新 ${avatarPart.value} 的配置。`);
|
||||
trackSource(`时装 ${avatarPart.value} ${field} 已更新`);
|
||||
};
|
||||
|
||||
const resetAvatarPart = () => {
|
||||
configStore.config.avatar[avatarPart.value] = { ...configStore.defaultAvatarConfig };
|
||||
setNotice(`已重置 ${avatarPart.value} 配置。`);
|
||||
trackSource(`时装 ${avatarPart.value} 已重置`);
|
||||
};
|
||||
|
||||
const ensureEmblemPart = (part: string) => {
|
||||
if (!configStore.config.emblems[part]) {
|
||||
configStore.config.emblems[part] = { ...configStore.defaultEmblemConfig };
|
||||
}
|
||||
return configStore.config.emblems[part];
|
||||
};
|
||||
|
||||
const emblemCfg = computed(() => ensureEmblemPart(emblemPart.value));
|
||||
|
||||
const setEmblemLv = (value: string) => {
|
||||
const parsed = Number.parseInt(value || "0", 10);
|
||||
if (Number.isNaN(parsed)) {
|
||||
setNotice("徽章等级输入无效。", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const target = ensureEmblemPart(emblemPart.value);
|
||||
target.lv = Math.max(0, Math.min(parsed, emblemLevels.length - 1));
|
||||
|
||||
if (emblemPart.value === "彩色") {
|
||||
["红色", "蓝色", "黄色", "绿色"].forEach((part) => {
|
||||
const partCfg = ensureEmblemPart(part);
|
||||
partCfg.lv = Math.max(partCfg.lv || 0, target.lv);
|
||||
});
|
||||
} else {
|
||||
const colorCfg = ensureEmblemPart("彩色");
|
||||
colorCfg.lv = Math.min(colorCfg.lv || 0, target.lv);
|
||||
}
|
||||
|
||||
setNotice(`已更新 ${emblemPart.value} 徽章等级。`);
|
||||
trackSource(`徽章 ${emblemPart.value} 等级已更新`);
|
||||
};
|
||||
|
||||
const setEmblemItem = (index: number, value: string) => {
|
||||
const target = ensureEmblemPart(emblemPart.value);
|
||||
const items = [...(target.items ?? configStore.defaultEmblemConfig.items)];
|
||||
while (items.length < 6) items.push("");
|
||||
items[index] = value;
|
||||
target.items = items;
|
||||
setNotice(`已更新 ${emblemPart.value} 徽章位 ${index + 1}。`);
|
||||
trackSource(`徽章 ${emblemPart.value} 槽位 ${index + 1} 已更新`);
|
||||
};
|
||||
|
||||
const resetEmblemPart = () => {
|
||||
configStore.config.emblems[emblemPart.value] = { ...configStore.defaultEmblemConfig };
|
||||
setNotice(`已重置 ${emblemPart.value} 徽章配置。`);
|
||||
trackSource(`徽章 ${emblemPart.value} 已重置`);
|
||||
};
|
||||
|
||||
const resetEquipPart = (part: string) => {
|
||||
configStore.config.equips[part] = {
|
||||
...configStore.defaultEqusConfig,
|
||||
id: configStore.config.equips[part]?.id ?? "",
|
||||
};
|
||||
setNotice(`已重置 ${part} 的打造参数。`);
|
||||
trackSource(`装备 ${part} 打造参数已重置`);
|
||||
};
|
||||
|
||||
const resetOathPart = (part: string) => {
|
||||
configStore.config.oaths[part] = {
|
||||
...configStore.defaultOathConfig,
|
||||
id: configStore.config.oaths[part]?.id ?? "",
|
||||
};
|
||||
setNotice(`已重置誓约槽位 ${part}。`);
|
||||
trackSource(`誓约槽位 ${part} 已重置`);
|
||||
};
|
||||
|
||||
const resetAllForge = () => {
|
||||
parts.value.forEach((part) => {
|
||||
configStore.config.equips[part] = {
|
||||
...configStore.defaultEqusConfig,
|
||||
id: configStore.config.equips[part]?.id ?? "",
|
||||
};
|
||||
});
|
||||
oathParts.value.forEach((part) => {
|
||||
configStore.config.oaths[part] = {
|
||||
...configStore.defaultOathConfig,
|
||||
id: configStore.config.oaths[part]?.id ?? "",
|
||||
};
|
||||
});
|
||||
avatarParts.value.forEach((part) => {
|
||||
configStore.config.avatar[part] = { ...configStore.defaultAvatarConfig };
|
||||
});
|
||||
infoStore.emblemParts.forEach((part) => {
|
||||
configStore.config.emblems[part] = { ...configStore.defaultEmblemConfig };
|
||||
});
|
||||
setNotice("已重置当前角色的打造参数。", "info");
|
||||
trackSource("当前角色 Forge 配置已快速重置");
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||
<Card>
|
||||
<div class="space-y-4">
|
||||
<Card class="border-border/60 bg-gradient-to-b from-background to-muted/30">
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2">
|
||||
<Hammer class="size-4" />
|
||||
装备打造
|
||||
<CardTitle class="flex flex-wrap items-center justify-between gap-2">
|
||||
<span>Forge 面板</span>
|
||||
<span class="text-xs font-normal text-muted-foreground">
|
||||
装备 {{ summary.equipCount }} · 时装 {{ summary.avatarCount }} · 誓约
|
||||
{{ summary.oathCount }} · 徽章 {{ summary.emblemCount }}
|
||||
</span>
|
||||
</CardTitle>
|
||||
<CardDescription>已迁移基础统计,后续补齐增幅/强化/锻造编辑器。</CardDescription>
|
||||
<CardDescription>Forge 进阶:子模块可编辑,统一浅色/深色模式。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-2 text-sm text-muted-foreground">
|
||||
<div>已配置装备: {{ summary.equipCount }}</div>
|
||||
<div>誓约槽位: {{ summary.oathCount }}</div>
|
||||
<CardContent class="space-y-4">
|
||||
<div class="inline-flex flex-wrap rounded-md border p-1">
|
||||
<Button
|
||||
size="sm"
|
||||
:variant="moduleMode === 'equip' ? 'default' : 'ghost'"
|
||||
@click="moduleMode = 'equip'"
|
||||
>
|
||||
装备打造
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
:variant="moduleMode === 'oath' ? 'default' : 'ghost'"
|
||||
@click="moduleMode = 'oath'"
|
||||
>
|
||||
誓约
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
:variant="moduleMode === 'avatar' ? 'default' : 'ghost'"
|
||||
@click="moduleMode = 'avatar'"
|
||||
>
|
||||
时装徽章
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
:variant="moduleMode === 'global' ? 'default' : 'ghost'"
|
||||
@click="moduleMode = 'global'"
|
||||
>
|
||||
全局
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="outline" @click="resetAllForge">快速重置打造</Button>
|
||||
<router-link to="/dcalc/dnfhelper">
|
||||
<Button size="sm" variant="outline">前往助手导入</Button>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="notice"
|
||||
class="rounded-md border px-3 py-2 text-xs"
|
||||
:class="
|
||||
noticeType === 'error'
|
||||
? 'border-destructive/40 bg-destructive/10 text-destructive'
|
||||
: 'border-border/60 bg-muted/40 text-muted-foreground'
|
||||
"
|
||||
>
|
||||
{{ notice }}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2">
|
||||
<Sparkles class="size-4" />
|
||||
时装与徽章
|
||||
</CardTitle>
|
||||
<CardDescription>保留旧版数据结构,逐步替换为 shadcn-vue 控件。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-2 text-sm text-muted-foreground">
|
||||
<div>已配置时装: {{ summary.avatarCount }}</div>
|
||||
<div>徽章位类型: {{ summary.emblemSlots }}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div v-if="moduleMode === 'equip'" class="grid grid-cols-1 gap-4">
|
||||
<Card class="border-border/60 bg-card/90">
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2">
|
||||
<Hammer class="size-4" />
|
||||
装备打造编辑器
|
||||
</CardTitle>
|
||||
<CardDescription>强化、锻造、品级编辑并即时回写配置。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-3">
|
||||
<div
|
||||
v-for="part in parts"
|
||||
:key="part"
|
||||
class="grid grid-cols-1 gap-2 rounded-md border p-3 text-sm md:grid-cols-[7rem_1fr_6rem_6rem_6rem_auto] md:items-center"
|
||||
>
|
||||
<div class="font-medium">{{ part }}</div>
|
||||
<div class="truncate text-muted-foreground">
|
||||
{{ equipNameById.get(equipCfg(part).id) || "未选择装备(可在装备页选择)" }}
|
||||
</div>
|
||||
<Input
|
||||
:model-value="String(equipCfg(part).reinforce)"
|
||||
type="number"
|
||||
min="0"
|
||||
class="h-8"
|
||||
placeholder="强化"
|
||||
@update:model-value="(value) => setNumberField(part, 'reinforce', String(value))"
|
||||
/>
|
||||
<Input
|
||||
:model-value="String(equipCfg(part).refine)"
|
||||
type="number"
|
||||
min="0"
|
||||
class="h-8"
|
||||
placeholder="锻造"
|
||||
@update:model-value="(value) => setNumberField(part, 'refine', String(value))"
|
||||
/>
|
||||
<Input
|
||||
:model-value="String(equipCfg(part).adaptation)"
|
||||
type="number"
|
||||
min="0"
|
||||
class="h-8"
|
||||
placeholder="品级"
|
||||
@update:model-value="(value) => setNumberField(part, 'adaptation', String(value))"
|
||||
/>
|
||||
<Button size="sm" variant="ghost" @click="resetEquipPart(part)">重置</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2">
|
||||
<ShieldCheck class="size-4" />
|
||||
助手导入
|
||||
</CardTitle>
|
||||
<CardDescription>沿用助手识别能力快速回填打造数据。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<router-link to="/dcalc/dnfhelper">
|
||||
<Button variant="outline">前往助手导入</Button>
|
||||
</router-link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div v-if="moduleMode === 'oath'" class="grid grid-cols-1 gap-4">
|
||||
<Card class="border-border/60 bg-card/90">
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2">
|
||||
<ShieldCheck class="size-4" />
|
||||
誓约子模块
|
||||
</CardTitle>
|
||||
<CardDescription>编辑誓约槽位适配值。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-3">
|
||||
<div
|
||||
v-for="part in oathParts"
|
||||
:key="part"
|
||||
class="grid grid-cols-1 gap-2 rounded-md border p-3 text-sm md:grid-cols-[6rem_1fr_7rem_auto] md:items-center"
|
||||
>
|
||||
<div class="font-medium">槽位 {{ part }}</div>
|
||||
<div class="truncate text-muted-foreground">
|
||||
{{ oathNameById.get(oathCfg(part).id) || "未选择誓约(可在装备页选择)" }}
|
||||
</div>
|
||||
<Input
|
||||
:model-value="String(oathCfg(part).adaptation)"
|
||||
type="number"
|
||||
min="0"
|
||||
class="h-8"
|
||||
placeholder="适配"
|
||||
@update:model-value="(value) => setOathAdaptation(part, String(value))"
|
||||
/>
|
||||
<Button size="sm" variant="ghost" @click="resetOathPart(part)">重置</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div v-if="moduleMode === 'avatar'" class="grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||||
<Card class="border-border/60 bg-card/90">
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2">
|
||||
<Sparkles class="size-4" />
|
||||
时装编辑
|
||||
</CardTitle>
|
||||
<CardDescription>支持附魔、徽章项、品级和属性值编辑。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-3 text-sm">
|
||||
<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">
|
||||
<Input v-model="avatarOptionKeyword" class="h-8" placeholder="搜索附魔/徽章可选项" />
|
||||
<label class="inline-flex items-center gap-1 text-xs whitespace-nowrap">
|
||||
<input v-model="onlyConfiguredAvatarParts" type="checkbox" class="size-3.5" />
|
||||
仅已配置部位
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-2 md:grid-cols-[7rem_1fr] md:items-center">
|
||||
<div class="text-muted-foreground">部位</div>
|
||||
<select
|
||||
class="h-8 rounded-md border border-input bg-background px-2"
|
||||
v-model="avatarPart"
|
||||
>
|
||||
<option v-for="part in avatarParts" :key="part" :value="part">{{ part }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="hasAvatarEnchant"
|
||||
class="grid grid-cols-1 gap-2 md:grid-cols-[7rem_1fr] md:items-center"
|
||||
>
|
||||
<div class="text-muted-foreground">附魔</div>
|
||||
<select
|
||||
class="h-8 rounded-md border border-input bg-background px-2"
|
||||
:value="avatarCfg(avatarPart).enchant"
|
||||
@change="
|
||||
(event) => setAvatarField('enchant', (event.target as HTMLSelectElement).value)
|
||||
"
|
||||
>
|
||||
<option value="">无</option>
|
||||
<option v-for="item in avatarEnchantOptions" :key="item.id" :value="String(item.id)">
|
||||
{{ item.detail }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="grid grid-cols-1 gap-2 md:grid-cols-[7rem_1fr] md:items-center">
|
||||
<div class="text-muted-foreground">品级</div>
|
||||
<select
|
||||
class="h-8 rounded-md border border-input bg-background px-2"
|
||||
:value="avatarCfg(avatarPart).id"
|
||||
@change="(event) => setAvatarField('id', (event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option value="">未选择</option>
|
||||
<option v-for="item in avatarSuitOptions" :key="item.id" :value="String(item.id)">
|
||||
{{ item.suit || item.rarity || item.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-2 md:grid-cols-[7rem_1fr] md:items-center">
|
||||
<div class="text-muted-foreground">属性</div>
|
||||
<select
|
||||
class="h-8 rounded-md border border-input bg-background px-2"
|
||||
:value="avatarCfg(avatarPart).option"
|
||||
@change="
|
||||
(event) => setAvatarField('option', (event.target as HTMLSelectElement).value)
|
||||
"
|
||||
>
|
||||
<option value="0">无</option>
|
||||
<option
|
||||
v-for="opt in avatarSuitOptions.find(
|
||||
(x) => String(x.id) === avatarCfg(avatarPart).id,
|
||||
)?.options || []"
|
||||
:key="opt"
|
||||
:value="opt"
|
||||
>
|
||||
{{ opt }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="grid grid-cols-1 gap-2 md:grid-cols-[7rem_1fr] md:items-center">
|
||||
<div class="text-muted-foreground">徽章一</div>
|
||||
<select
|
||||
class="h-8 rounded-md border border-input bg-background px-2"
|
||||
:value="avatarCfg(avatarPart).emblem_0"
|
||||
@change="
|
||||
(event) => setAvatarField('emblem_0', (event.target as HTMLSelectElement).value)
|
||||
"
|
||||
>
|
||||
<option value="">无</option>
|
||||
<option v-for="item in avatarEmblemOptions" :key="item.id" :value="String(item.id)">
|
||||
[{{ item.rarity }}] {{ item.value }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-2 md:grid-cols-[7rem_1fr] md:items-center">
|
||||
<div class="text-muted-foreground">徽章二</div>
|
||||
<select
|
||||
class="h-8 rounded-md border border-input bg-background px-2"
|
||||
:value="avatarCfg(avatarPart).emblem_1"
|
||||
@change="
|
||||
(event) => setAvatarField('emblem_1', (event.target as HTMLSelectElement).value)
|
||||
"
|
||||
>
|
||||
<option value="">无</option>
|
||||
<option v-for="item in avatarEmblemOptions" :key="item.id" :value="String(item.id)">
|
||||
[{{ item.rarity }}] {{ item.value }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<Button size="sm" variant="outline" @click="resetAvatarPart">重置当前部位</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card class="border-border/60 bg-card/90">
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2">
|
||||
<Sparkles class="size-4" />
|
||||
徽章编辑
|
||||
</CardTitle>
|
||||
<CardDescription>支持等级、徽章条目与槽位数量规则。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-3 text-sm">
|
||||
<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">
|
||||
<Input v-model="emblemOptionKeyword" class="h-8" placeholder="搜索徽章可选项" />
|
||||
<label class="inline-flex items-center gap-1 text-xs whitespace-nowrap">
|
||||
<input v-model="onlyConfiguredEmblemSlots" type="checkbox" class="size-3.5" />
|
||||
仅已配置项
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-2 md:grid-cols-[7rem_1fr] md:items-center">
|
||||
<div class="text-muted-foreground">槽位类型</div>
|
||||
<select
|
||||
class="h-8 rounded-md border border-input bg-background px-2"
|
||||
v-model="emblemPart"
|
||||
>
|
||||
<option v-for="part in infoStore.emblemParts" :key="part" :value="part">
|
||||
{{ part }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="emblemPart !== '白金'"
|
||||
class="grid grid-cols-1 gap-2 md:grid-cols-[7rem_1fr] md:items-center"
|
||||
>
|
||||
<div class="text-muted-foreground">等级</div>
|
||||
<select
|
||||
class="h-8 rounded-md border border-input bg-background px-2"
|
||||
:value="String(emblemCfg.lv ?? 0)"
|
||||
@change="(event) => setEmblemLv((event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option v-for="(label, index) in emblemLevels" :key="label" :value="String(index)">
|
||||
{{ label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="text-muted-foreground">徽章条目</div>
|
||||
<div class="grid grid-cols-1 gap-2 md:grid-cols-2">
|
||||
<div
|
||||
v-for="index in emblemMaxCount"
|
||||
:key="index"
|
||||
v-show="!onlyConfiguredEmblemSlots || !!(emblemCfg.items[index - 1] || '')"
|
||||
class="space-y-1"
|
||||
>
|
||||
<div class="text-xs text-muted-foreground">槽位 {{ index }}</div>
|
||||
<select
|
||||
class="h-8 w-full rounded-md border border-input bg-background px-2"
|
||||
:value="emblemCfg.items[index - 1] || ''"
|
||||
@change="
|
||||
(event) => setEmblemItem(index - 1, (event.target as HTMLSelectElement).value)
|
||||
"
|
||||
>
|
||||
<option value="">无</option>
|
||||
<option v-for="item in emblemOptions" :key="item.id" :value="String(item.id)">
|
||||
[{{ item.rarity }}] {{ item.value }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<Button size="sm" variant="outline" @click="resetEmblemPart">重置当前徽章槽</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div v-if="moduleMode === 'global'" class="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<Card class="border-border/60 bg-card/90">
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2">
|
||||
<ShieldCheck class="size-4" />
|
||||
计算开关
|
||||
</CardTitle>
|
||||
<CardDescription>从旧版 Character 页底部迁移的全局参数。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-2 text-sm text-muted-foreground">
|
||||
<label class="flex items-center justify-between rounded-md border p-2">
|
||||
<span>系统奶 DSB</span>
|
||||
<input v-model="configStore.config.DSB" type="checkbox" class="size-4" />
|
||||
</label>
|
||||
<label class="flex items-center justify-between rounded-md border p-2">
|
||||
<span>组队 BUFF</span>
|
||||
<input v-model="configStore.config.BUFF" type="checkbox" class="size-4" />
|
||||
</label>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card class="border-border/60 bg-card/90">
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2">
|
||||
<ShieldCheck class="size-4" />
|
||||
当前统计
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-2 text-sm text-muted-foreground">
|
||||
<div>已配置装备: {{ summary.equipCount }}</div>
|
||||
<div>誓约槽位: {{ summary.oathCount }}</div>
|
||||
<div>已配置时装: {{ summary.avatarCount }}</div>
|
||||
<div>已配置徽章条目: {{ summary.emblemCount }}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { LoaderCircle, RefreshCcw } from "lucide-vue-next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -7,7 +7,8 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
|
||||
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 { useInfoStore } from "@/stores";
|
||||
import { useConfigStore, useInfoStore } from "@/stores";
|
||||
import ResultPanel from "@/pages/Dcalc/Character/Result/Index.vue";
|
||||
const props = defineProps<{
|
||||
alter: string;
|
||||
}>();
|
||||
@@ -15,8 +16,10 @@ const props = defineProps<{
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const infoStore = useInfoStore();
|
||||
const configStore = useConfigStore();
|
||||
const loading = ref(false);
|
||||
const loadError = ref("");
|
||||
const isExecuting = ref(false);
|
||||
|
||||
const tabs = [
|
||||
{ label: "装备", key: "equipment" },
|
||||
@@ -49,6 +52,7 @@ const switchTab = (tab: (typeof tabs)[number]["key"]) => {
|
||||
|
||||
onMounted(() => {
|
||||
loadCharacter();
|
||||
window.addEventListener("beforeunload", configStore.saveConfig);
|
||||
});
|
||||
|
||||
watch(
|
||||
@@ -57,6 +61,27 @@ watch(
|
||||
loadCharacter();
|
||||
},
|
||||
);
|
||||
|
||||
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 },
|
||||
);
|
||||
|
||||
onUnmounted(() => {
|
||||
stopConfigWatch();
|
||||
window.removeEventListener("beforeunload", configStore.saveConfig);
|
||||
configStore.saveConfig();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -98,8 +123,13 @@ watch(
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<router-view />
|
||||
<div class="grid min-h-0 flex-1 grid-cols-1 gap-4 xl:grid-cols-[minmax(0,1fr)_22rem]">
|
||||
<div class="min-w-0 overflow-hidden">
|
||||
<router-view />
|
||||
</div>
|
||||
<aside class="min-h-0">
|
||||
<ResultPanel />
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { useConfigStore, useInfoStore } from "@/stores";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import Tabs from "@/components/ui/tabs/Tabs.vue";
|
||||
import TabsList from "@/components/ui/tabs/TabsList.vue";
|
||||
import TabsTrigger from "@/components/ui/tabs/TabsTrigger.vue";
|
||||
|
||||
const configStore = useConfigStore();
|
||||
const infoStore = useInfoStore();
|
||||
|
||||
const displayTab = ref<"equ" | "oath">("equ");
|
||||
const skillSort = ref<"damage" | "cd">("damage");
|
||||
|
||||
const resultState = computed(() => configStore.result.state);
|
||||
const isLoading = computed(() => configStore.result.isLoading);
|
||||
const resultError = computed(() => {
|
||||
const error = configStore.result.error;
|
||||
if (!error) return "";
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
});
|
||||
|
||||
const sortedSkills = computed(() => {
|
||||
const skills = [...(resultState.value?.skills ?? [])];
|
||||
if (skillSort.value === "cd") {
|
||||
return skills.sort((left, right) => left.cd - right.cd);
|
||||
}
|
||||
return skills.sort((left, right) => right.damage - left.damage);
|
||||
});
|
||||
|
||||
const totalDamage = computed(() => {
|
||||
return sortedSkills.value.reduce((sum, item) => sum + item.damage, 0);
|
||||
});
|
||||
|
||||
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 setStandard = () => {
|
||||
if (!resultState.value?.uuid) return;
|
||||
infoStore.setStandard(resultState.value, configStore.skillCountConfig);
|
||||
};
|
||||
|
||||
const clearStandard = () => {
|
||||
infoStore.setStandard(undefined);
|
||||
};
|
||||
|
||||
const changeSources = computed(() => configStore.changeSourceLogs.slice(0, 8));
|
||||
|
||||
const clearChangeSources = () => {
|
||||
configStore.clearChangeSource();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid h-full min-h-0 grid-rows-[auto_auto_1fr] gap-3">
|
||||
<Card>
|
||||
<CardHeader class="pb-2">
|
||||
<CardTitle class="text-base">结果面板</CardTitle>
|
||||
<CardDescription>展示当前配置的实时计算结果。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="flex gap-2">
|
||||
<Button size="sm" variant="outline" @click="setStandard">设为基准</Button>
|
||||
<Button size="sm" variant="outline" @click="clearStandard">清空基准</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader class="pb-2">
|
||||
<CardTitle class="text-sm">套装摘要</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-2 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>
|
||||
|
||||
<div v-if="displayTab === 'equ'" class="rounded-md border p-2">
|
||||
<div v-if="resultState?.suits?.suit">
|
||||
<div class="font-medium">{{ resultState.suits.suit.name }}</div>
|
||||
<div class="text-muted-foreground">
|
||||
{{ resultState.suits.suit.rarity }} · {{ resultState.suits.suit.point }}点
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-muted-foreground">暂无装备套装信息</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="rounded-md border p-2">
|
||||
<div v-if="resultState?.suits?.oath">
|
||||
<div class="font-medium">{{ resultState.suits.oath.name }}</div>
|
||||
<div class="text-muted-foreground">
|
||||
{{ resultState.suits.oath.rarity }} · {{ resultState.suits.oath.point }}点
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-muted-foreground">暂无誓约套装信息</div>
|
||||
</div>
|
||||
|
||||
<div v-if="resultState?.suits?.correction" class="text-muted-foreground">
|
||||
套装积分修正:{{ resultState.suits.correction }}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader class="pb-2">
|
||||
<CardTitle class="text-sm">配置变更来源</CardTitle>
|
||||
<CardDescription class="flex items-center justify-between">
|
||||
<span>用于回归定位最近触发计算的操作来源。</span>
|
||||
<Button size="sm" variant="ghost" @click="clearChangeSources">清空</Button>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-1 text-xs">
|
||||
<div v-if="changeSources.length === 0" class="text-muted-foreground">
|
||||
暂无变更来源记录。
|
||||
</div>
|
||||
<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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card class="min-h-0">
|
||||
<CardHeader class="pb-2">
|
||||
<CardTitle class="text-sm">技能伤害</CardTitle>
|
||||
<CardDescription class="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
:class="{ 'text-foreground': skillSort === 'damage' }"
|
||||
@click="skillSort = 'damage'"
|
||||
>
|
||||
按伤害
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
:class="{ 'text-foreground': skillSort === 'cd' }"
|
||||
@click="skillSort = 'cd'"
|
||||
>
|
||||
按 CD
|
||||
</Button>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="min-h-0">
|
||||
<div v-if="isLoading" class="py-6 text-center text-sm text-muted-foreground">计算中...</div>
|
||||
<div v-else-if="resultError" class="py-6 text-center text-sm text-destructive">
|
||||
{{ resultError }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="!resultState || sortedSkills.length === 0"
|
||||
class="py-6 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
暂无结果,请先编辑配置。
|
||||
</div>
|
||||
<div v-else class="max-h-[48vh] overflow-auto rounded-md border">
|
||||
<table class="w-full table-auto text-xs">
|
||||
<thead class="bg-muted/60 sticky top-0 z-10">
|
||||
<tr>
|
||||
<th class="px-2 py-2 text-left">技能</th>
|
||||
<th class="px-2 py-2 text-right">CD</th>
|
||||
<th class="px-2 py-2 text-right">伤害</th>
|
||||
<th class="px-2 py-2 text-right">占比</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="skill in sortedSkills"
|
||||
:key="`${skill.name}-${skill.mode}`"
|
||||
class="border-t hover:bg-muted/30"
|
||||
>
|
||||
<td class="px-2 py-1.5">
|
||||
<div class="flex items-center gap-2">
|
||||
<img
|
||||
:src="skill.icon"
|
||||
:alt="skill.name"
|
||||
class="size-5 rounded-sm object-cover"
|
||||
/>
|
||||
<span class="truncate">{{ skill.name }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-2 py-1.5 text-right">{{ skill.cd.toFixed(1) }}</td>
|
||||
<td class="px-2 py-1.5 text-right font-medium">{{ formatNumber(skill.damage) }}</td>
|
||||
<td class="px-2 py-1.5 text-right text-muted-foreground">
|
||||
{{ totalDamage > 0 ? ((skill.damage / totalDamage) * 100).toFixed(1) : "0.0" }}%
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 545 B |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1001 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 736 B |
|
After Width: | Height: | Size: 681 B |
@@ -1,54 +1,685 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { Search } from "lucide-vue-next";
|
||||
import { useInfoStore } from "@/stores";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { useConfigStore, useInfoStore } from "@/stores";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getImageURL } from "@/utils";
|
||||
import type { ISkill } from "@/api/info/type";
|
||||
|
||||
const infoStore = useInfoStore();
|
||||
const keyword = ref("");
|
||||
const configStore = useConfigStore();
|
||||
|
||||
const skills = computed(() => {
|
||||
const source = infoStore.skills ?? [];
|
||||
const text = keyword.value.trim().toLowerCase();
|
||||
if (!text) return source;
|
||||
return source.filter((skill) => {
|
||||
return skill.name.toLowerCase().includes(text) || skill.type.toLowerCase().includes(text);
|
||||
/* ─── UI state ─── */
|
||||
const tab = ref(0); // 0 = 技能加点 | 1 = 技能定制
|
||||
const activeSkillId = ref("");
|
||||
const notice = ref("");
|
||||
const noticeType = ref<"error" | "info">("info");
|
||||
const openUpPickerSlot = ref<number | null>(null);
|
||||
|
||||
/* ─── Constants ─── */
|
||||
const VP_LIMIT = 5;
|
||||
const UP_LIMIT = 3;
|
||||
const UP_SLOT_COUNT = 3;
|
||||
|
||||
// Match original lvList
|
||||
const lvList = [15, 20, 25, 30, 35, 40, 45, 50, 60, 70, 80, 90, 100];
|
||||
const COLUMNS = 11; // positions 0-10
|
||||
|
||||
/* ─── Tree row data ─── */
|
||||
interface ITreeRow {
|
||||
label: string;
|
||||
lv: number;
|
||||
isExtra: boolean;
|
||||
extraSkills: ISkill[];
|
||||
cells: (ISkill | null)[];
|
||||
}
|
||||
|
||||
const getSkillsInRange = (lvStart: number, lvEnd: number): ISkill[] =>
|
||||
infoStore.skills.filter(
|
||||
(s) => s.line > lvStart - 5 && s.line <= Math.max(lvEnd - 5, 1),
|
||||
);
|
||||
|
||||
const getSkillAt = (lvStart: number, lvEnd: number, col: number): ISkill | null =>
|
||||
infoStore.skills.find(
|
||||
(s) =>
|
||||
s.line > lvStart - 5 && s.line <= Math.max(lvEnd - 5, 1) && s.position === col,
|
||||
) ?? null;
|
||||
|
||||
const treeRows = computed((): ITreeRow[] => {
|
||||
const rows: ITreeRow[] = [];
|
||||
|
||||
const extraSkills = getSkillsInRange(0, 15);
|
||||
if (extraSkills.length > 0) {
|
||||
rows.push({ label: "其余", lv: 0, isExtra: true, extraSkills, cells: [] });
|
||||
}
|
||||
|
||||
lvList.forEach((lv, idx) => {
|
||||
const nextLv = lvList[idx + 1] ?? 150;
|
||||
const inRange = getSkillsInRange(lv, nextLv);
|
||||
if (inRange.length === 0) return;
|
||||
const label = lv % 10 === 0 ? String(lv) : "";
|
||||
const cells = Array.from({ length: COLUMNS }, (_, col) => getSkillAt(lv, nextLv, col));
|
||||
rows.push({ label, lv, isExtra: false, extraSkills: [], cells });
|
||||
});
|
||||
|
||||
return rows;
|
||||
});
|
||||
|
||||
/* ─── Config helpers ─── */
|
||||
const setNotice = (msg: string, type: "error" | "info" = "info") => {
|
||||
notice.value = msg;
|
||||
noticeType.value = type;
|
||||
};
|
||||
const track = (detail: string) => configStore.pushChangeSource("Skill", detail);
|
||||
|
||||
const skillById = (id: string) => infoStore.skills.find((s) => s.id.toString() === id);
|
||||
|
||||
const skillCfg = (id: string) => {
|
||||
if (!configStore.config.skills[id]) {
|
||||
const skill = skillById(id);
|
||||
configStore.config.skills[id] = { lv: skill?.maxLearnLv ?? 0, vp: 0, up: 0 };
|
||||
}
|
||||
return configStore.config.skills[id];
|
||||
};
|
||||
|
||||
/* ─── Level color ─── */
|
||||
const lvColor = (id: string, maxLearnLv: number): string => {
|
||||
const lv = skillCfg(id).lv;
|
||||
if (lv < maxLearnLv) return "#32E432";
|
||||
if (lv > maxLearnLv) return "#58E0FC";
|
||||
return "currentColor";
|
||||
};
|
||||
|
||||
/* ─── Active / detail ─── */
|
||||
const selectedSkill = computed(() =>
|
||||
activeSkillId.value ? skillById(activeSkillId.value) : undefined,
|
||||
);
|
||||
const chooseSkill = (id: string) => { activeSkillId.value = id; };
|
||||
const clearActive = () => { activeSkillId.value = ""; };
|
||||
|
||||
/* ─── Level actions ─── */
|
||||
const updateLv = (id: string, value: number) => {
|
||||
const target = skillCfg(id);
|
||||
const skill = skillById(id);
|
||||
if (!skill) return;
|
||||
const max = Math.max(skill.maxLv ?? 0, skill.learnLv ?? 0);
|
||||
const next = Math.min(Math.max(value, 0), max);
|
||||
target.lv = next;
|
||||
track(`${skill.name} 等级调整为 ${next}`);
|
||||
if (target.lv <= 0 && target.up > 0) {
|
||||
target.up = 0;
|
||||
track(`${skill.name} 等级归零,UP 自动清空`);
|
||||
}
|
||||
};
|
||||
const stepLv = (id: string, step: number) => updateLv(id, skillCfg(id).lv + step);
|
||||
const setMaxLv = (id: string) => updateLv(id, skillById(id)?.maxLearnLv ?? 0);
|
||||
const resetLv = (id: string) => updateLv(id, 0);
|
||||
|
||||
/* ─── VP ─── */
|
||||
const usedVPCount = computed(() =>
|
||||
infoStore.skills
|
||||
.filter((s) => s.hasVP)
|
||||
.filter((s) => (configStore.config.skills[s.id.toString()]?.vp ?? 0) > 0)
|
||||
.length,
|
||||
);
|
||||
|
||||
const vpSkills = computed(() =>
|
||||
infoStore.skills.filter((s) => s.hasVP).sort((a, b) => a.learnLv - b.learnLv),
|
||||
);
|
||||
|
||||
const setVPMode = (id: string, mode: 0 | 1 | 2) => {
|
||||
const skill = skillById(id);
|
||||
if (!skill) return;
|
||||
if (!skill.hasVP && mode !== 0) { setNotice(`${skill.name} 不支持 VP。`, "error"); return; }
|
||||
const target = skillCfg(id);
|
||||
if (target.vp === 0 && mode > 0 && usedVPCount.value >= VP_LIMIT) {
|
||||
setNotice(`VP 最多选 ${VP_LIMIT} 个技能。`, "error");
|
||||
return;
|
||||
}
|
||||
target.vp = mode;
|
||||
track(`${skill.name} VP → ${mode === 0 ? "关闭" : `形态${mode}`}`);
|
||||
};
|
||||
|
||||
/* ─── UP ─── */
|
||||
const upSelectedIds = computed(() =>
|
||||
infoStore.skills
|
||||
.filter((s) => s.hasUP && (configStore.config.skills[s.id.toString()]?.up ?? 0) > 0)
|
||||
.map((s) => s.id.toString())
|
||||
.sort((a, b) => (skillById(a)?.learnLv ?? 0) - (skillById(b)?.learnLv ?? 0)),
|
||||
);
|
||||
|
||||
const usedUPCount = computed(() => upSelectedIds.value.length);
|
||||
|
||||
const upSlots = computed(() => {
|
||||
const slots = upSelectedIds.value.map((id) => ({
|
||||
skillId: id,
|
||||
mode: (configStore.config.skills[id]?.up ?? 1) as 1 | 2,
|
||||
}));
|
||||
while (slots.length < UP_SLOT_COUNT) slots.push({ skillId: "", mode: 1 as 1 | 2 });
|
||||
return slots.slice(0, UP_SLOT_COUNT);
|
||||
});
|
||||
|
||||
const upOptionsForSlot = (slotIdx: number) => {
|
||||
const currentId = upSlots.value[slotIdx]?.skillId ?? "";
|
||||
const others = new Set(
|
||||
upSlots.value.map((s, i) => (i === slotIdx ? "" : s.skillId)).filter(Boolean),
|
||||
);
|
||||
return infoStore.skills
|
||||
.filter((s) => s.hasUP)
|
||||
.filter((s) => {
|
||||
const id = s.id.toString();
|
||||
const lv = configStore.config.skills[id]?.lv ?? 0;
|
||||
return (lv > 0 || id === currentId) && (!others.has(id) || id === currentId);
|
||||
})
|
||||
.sort((a, b) => a.learnLv - b.learnLv);
|
||||
};
|
||||
|
||||
const setUPSlotSkill = (slotIdx: number, nextId: string) => {
|
||||
const slot = upSlots.value[slotIdx];
|
||||
if (!slot) return;
|
||||
const prevId = slot.skillId;
|
||||
if (!nextId) {
|
||||
if (prevId) {
|
||||
skillCfg(prevId).up = 0;
|
||||
track(`${skillById(prevId)?.name ?? prevId} 从 UP 槽位 ${slotIdx + 1} 移除`);
|
||||
}
|
||||
openUpPickerSlot.value = null;
|
||||
return;
|
||||
}
|
||||
const target = skillById(nextId);
|
||||
if (!target) return;
|
||||
if ((skillCfg(nextId).lv ?? 0) <= 0) { setNotice(`${target.name} 等级为 0,无法 UP。`, "error"); return; }
|
||||
if (upSlots.value.some((s, i) => i !== slotIdx && s.skillId === nextId)) {
|
||||
setNotice(`${target.name} 已在其他槽位。`, "error"); return;
|
||||
}
|
||||
if (!prevId && usedUPCount.value >= UP_LIMIT) { setNotice(`UP 最多选 ${UP_LIMIT} 个技能。`, "error"); return; }
|
||||
if (prevId && prevId !== nextId) skillCfg(prevId).up = 0;
|
||||
skillCfg(nextId).up = slot.mode;
|
||||
track(`${target.name} 设置 UP 槽位 ${slotIdx + 1} 形态 ${slot.mode}`);
|
||||
openUpPickerSlot.value = null;
|
||||
};
|
||||
|
||||
const setUPSlotMode = (slotIdx: number, mode: 1 | 2) => {
|
||||
const skillId = upSlots.value[slotIdx]?.skillId;
|
||||
if (!skillId) { setNotice(`请先为槽位 ${slotIdx + 1} 选择技能。`, "error"); return; }
|
||||
skillCfg(skillId).up = mode;
|
||||
track(`${skillById(skillId)?.name} UP 形态 → ${mode}`);
|
||||
};
|
||||
|
||||
const exchangeUPMode = (slotIdx: number) => {
|
||||
const slot = upSlots.value[slotIdx];
|
||||
if (!slot?.skillId) return;
|
||||
setUPSlotMode(slotIdx, slot.mode === 1 ? 2 : 1);
|
||||
};
|
||||
|
||||
const resetAllSkills = () => {
|
||||
infoStore.skills.forEach((s) => {
|
||||
configStore.config.skills[s.id.toString()] = { lv: s.maxLearnLv ?? 0, vp: 0, up: 0 };
|
||||
});
|
||||
track("技能配置已重置为默认值");
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>技能面板</CardTitle>
|
||||
<CardDescription>与旧版技能页等价的基础浏览能力,后续补齐 VP/符文配置。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<div class="relative">
|
||||
<Search class="text-muted-foreground absolute left-2.5 top-2.5 size-4" />
|
||||
<Input v-model="keyword" class="pl-8" placeholder="搜索技能名 / 类型" />
|
||||
<div class="flex h-full overflow-hidden" @click.self="clearActive">
|
||||
<!-- ══ LEFT PANEL ══ -->
|
||||
<div class="w-[550px] shrink-0 flex flex-col border-r border-border/50 overflow-hidden">
|
||||
<!-- Tab bar -->
|
||||
<div
|
||||
class="h-10 shrink-0 flex border-b border-border/50 bg-muted/20 text-[15px] font-bold select-none"
|
||||
>
|
||||
<button
|
||||
class="flex-1 flex items-center justify-center transition-colors border-b-2"
|
||||
:class="
|
||||
tab === 0
|
||||
? 'border-primary text-foreground'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||||
"
|
||||
@click="tab = 0"
|
||||
>
|
||||
技能加点
|
||||
</button>
|
||||
<div class="w-px bg-border/40 my-2" />
|
||||
<button
|
||||
class="flex-1 flex items-center justify-center transition-colors border-b-2"
|
||||
:class="
|
||||
tab === 1
|
||||
? 'border-primary text-foreground'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||||
"
|
||||
@click="tab = 1"
|
||||
>
|
||||
技能定制
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
<Card v-for="skill in skills" :key="skill.id" size="sm">
|
||||
<CardHeader>
|
||||
<CardTitle class="text-sm">{{ skill.name }}</CardTitle>
|
||||
<CardDescription>{{ skill.type }} · 最高等级 {{ skill.maxLv }}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="flex items-center gap-3 pt-0">
|
||||
<img :src="skill.icon" :alt="skill.name" class="size-11 rounded-md object-cover" />
|
||||
<div class="text-xs text-muted-foreground">
|
||||
<div>可学等级: {{ skill.maxLearnLv }}</div>
|
||||
<div>学习等级: {{ skill.learnLv }}</div>
|
||||
<!-- ── TAB 0: Skill Tree ── -->
|
||||
<div
|
||||
v-show="tab === 0"
|
||||
class="flex-1 overflow-y-auto overflow-x-auto"
|
||||
@click.self="clearActive"
|
||||
>
|
||||
<div class="min-w-max py-1" @click.self="clearActive">
|
||||
<template v-for="row in treeRows" :key="row.lv">
|
||||
<div class="flex items-center px-1 gap-[2px] mb-px">
|
||||
<!-- Level label: 34×34 (matches original) -->
|
||||
<div
|
||||
class="w-[34px] h-[34px] shrink-0 flex items-center justify-center text-[11px] text-muted-foreground leading-[1.2] text-center"
|
||||
>
|
||||
<template v-if="row.isExtra">其<br />余</template>
|
||||
<template v-else>{{ row.label }}</template>
|
||||
</div>
|
||||
|
||||
<!-- "其余": sequential list -->
|
||||
<template v-if="row.isExtra">
|
||||
<div
|
||||
v-for="skill in row.extraSkills"
|
||||
:key="skill.id"
|
||||
class="skill-cell"
|
||||
:title="skill.name"
|
||||
@click.stop="chooseSkill(skill.id.toString())"
|
||||
>
|
||||
<div class="badge-row">
|
||||
<span v-if="skillCfg(skill.id.toString()).vp > 0" class="badge badge-vp">VP</span>
|
||||
<span v-if="skillCfg(skill.id.toString()).up > 0" class="badge badge-up">UP</span>
|
||||
</div>
|
||||
<img
|
||||
:src="getImageURL(skill.icon)"
|
||||
:alt="skill.name"
|
||||
class="skill-icon"
|
||||
:class="[
|
||||
skill.type === 'passive' ? 'opacity-60' : '',
|
||||
activeSkillId === skill.id.toString()
|
||||
? 'ring-2 ring-primary ring-offset-1 ring-offset-background'
|
||||
: '',
|
||||
]"
|
||||
/>
|
||||
<div v-if="activeSkillId === skill.id.toString()" class="action-row" @click.stop>
|
||||
<button class="skill-btn" @click.stop="resetLv(skill.id.toString())">«</button>
|
||||
<button class="skill-btn" @click.stop="stepLv(skill.id.toString(), -1)">‹</button>
|
||||
<button class="skill-btn" @click.stop="stepLv(skill.id.toString(), 1)">›</button>
|
||||
<button class="skill-btn" @click.stop="setMaxLv(skill.id.toString())">»</button>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="lv-num"
|
||||
:style="{ color: lvColor(skill.id.toString(), skill.maxLearnLv) }"
|
||||
>
|
||||
{{ skillCfg(skill.id.toString()).lv }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Normal row: 11 fixed columns -->
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="(cell, colIdx) in row.cells"
|
||||
:key="colIdx"
|
||||
class="skill-cell"
|
||||
:class="cell ? 'cursor-pointer' : 'pointer-events-none'"
|
||||
:title="cell?.name"
|
||||
@click.stop="cell && chooseSkill(cell.id.toString())"
|
||||
>
|
||||
<template v-if="cell">
|
||||
<div class="badge-row">
|
||||
<span v-if="skillCfg(cell.id.toString()).vp > 0" class="badge badge-vp">VP</span>
|
||||
<span v-if="skillCfg(cell.id.toString()).up > 0" class="badge badge-up">UP</span>
|
||||
</div>
|
||||
<img
|
||||
:src="getImageURL(cell.icon)"
|
||||
:alt="cell.name"
|
||||
class="skill-icon"
|
||||
:class="[
|
||||
cell.type === 'passive' ? 'opacity-60' : '',
|
||||
activeSkillId === cell.id.toString()
|
||||
? 'ring-2 ring-primary ring-offset-1 ring-offset-background'
|
||||
: '',
|
||||
]"
|
||||
/>
|
||||
<div v-if="activeSkillId === cell.id.toString()" class="action-row" @click.stop>
|
||||
<button class="skill-btn" @click.stop="resetLv(cell.id.toString())">«</button>
|
||||
<button class="skill-btn" @click.stop="stepLv(cell.id.toString(), -1)">‹</button>
|
||||
<button class="skill-btn" @click.stop="stepLv(cell.id.toString(), 1)">›</button>
|
||||
<button class="skill-btn" @click.stop="setMaxLv(cell.id.toString())">»</button>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="lv-num"
|
||||
:style="{ color: lvColor(cell.id.toString(), cell.maxLearnLv) }"
|
||||
>
|
||||
{{ skillCfg(cell.id.toString()).lv }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="skills.length === 0" class="py-10 text-center text-sm text-muted-foreground">
|
||||
当前筛选条件没有匹配技能。
|
||||
<!-- ── TAB 1: Customization ── -->
|
||||
<div v-show="tab === 1" class="flex-1 overflow-y-auto flex flex-col min-h-0">
|
||||
<!-- UP section -->
|
||||
<div class="shrink-0 border-b border-border/50">
|
||||
<div class="px-3 pt-3 pb-1 text-xs font-semibold text-muted-foreground flex justify-between">
|
||||
<span>技能突破 (UP)</span>
|
||||
<span>{{ usedUPCount }}/{{ UP_LIMIT }}</span>
|
||||
</div>
|
||||
<div class="px-3 pb-3 space-y-2">
|
||||
<div
|
||||
v-for="(slot, idx) in upSlots"
|
||||
:key="idx"
|
||||
class="flex items-center gap-2 rounded-md border border-border/50 bg-muted/10 p-2"
|
||||
>
|
||||
<div class="text-[10px] text-muted-foreground w-6 shrink-0 text-center">{{ idx + 1 }}</div>
|
||||
|
||||
<!-- Mode 1 icon -->
|
||||
<button
|
||||
class="relative w-8 h-8 rounded border flex items-center justify-center shrink-0 transition-colors"
|
||||
:class="
|
||||
slot.skillId && slot.mode === 1
|
||||
? 'border-purple-400 ring-1 ring-purple-400/50 bg-purple-500/10'
|
||||
: 'border-border/50 hover:border-border'
|
||||
"
|
||||
@click="
|
||||
slot.skillId
|
||||
? setUPSlotMode(idx, 1)
|
||||
: (openUpPickerSlot = openUpPickerSlot === idx ? null : idx)
|
||||
"
|
||||
>
|
||||
<template v-if="slot.skillId">
|
||||
<img :src="getImageURL(skillById(slot.skillId)?.icon ?? '')" class="w-7 h-7 rounded object-cover" />
|
||||
<span v-if="slot.mode === 1" class="absolute -bottom-1 -right-1 text-[8px] bg-purple-500 text-white rounded-sm px-0.5 leading-3">1</span>
|
||||
</template>
|
||||
<span v-else class="text-[10px] text-muted-foreground">1</span>
|
||||
</button>
|
||||
|
||||
<!-- Exchange -->
|
||||
<button
|
||||
class="text-muted-foreground hover:text-foreground shrink-0 text-sm"
|
||||
:disabled="!slot.skillId"
|
||||
title="切换形态"
|
||||
@click="exchangeUPMode(idx)"
|
||||
>⇄</button>
|
||||
|
||||
<!-- Mode 2 icon -->
|
||||
<button
|
||||
class="relative w-8 h-8 rounded border flex items-center justify-center shrink-0 transition-colors"
|
||||
:class="
|
||||
slot.skillId && slot.mode === 2
|
||||
? 'border-purple-400 ring-1 ring-purple-400/50 bg-purple-500/10'
|
||||
: 'border-border/50 hover:border-border'
|
||||
"
|
||||
@click="
|
||||
slot.skillId
|
||||
? setUPSlotMode(idx, 2)
|
||||
: (openUpPickerSlot = openUpPickerSlot === idx ? null : idx)
|
||||
"
|
||||
>
|
||||
<template v-if="slot.skillId">
|
||||
<img :src="getImageURL(skillById(slot.skillId)?.icon ?? '')" class="w-7 h-7 rounded object-cover" />
|
||||
<span v-if="slot.mode === 2" class="absolute -bottom-1 -right-1 text-[8px] bg-purple-500 text-white rounded-sm px-0.5 leading-3">2</span>
|
||||
</template>
|
||||
<span v-else class="text-[10px] text-muted-foreground">2</span>
|
||||
</button>
|
||||
|
||||
<!-- Skill name -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-xs truncate" :title="slot.skillId ? (skillById(slot.skillId)?.name ?? '') : ''">
|
||||
{{ slot.skillId ? skillById(slot.skillId)?.name : "未选择技能" }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pick button -->
|
||||
<button
|
||||
class="text-[10px] border border-border/50 rounded px-1.5 py-0.5 text-muted-foreground hover:text-foreground shrink-0"
|
||||
@click="openUpPickerSlot = openUpPickerSlot === idx ? null : idx"
|
||||
>{{ slot.skillId ? "更换" : "选择" }}</button>
|
||||
</div>
|
||||
|
||||
<!-- Icon picker -->
|
||||
<div v-if="openUpPickerSlot !== null" class="rounded-md border border-border/50 bg-muted/20 p-2">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<button
|
||||
class="w-7 h-7 rounded border border-dashed border-border/60 text-[9px] text-muted-foreground hover:bg-muted/50"
|
||||
@click="setUPSlotSkill(openUpPickerSlot!, '')"
|
||||
>清</button>
|
||||
<button
|
||||
v-for="item in upOptionsForSlot(openUpPickerSlot!)"
|
||||
:key="item.id"
|
||||
class="relative w-7 h-7 rounded border transition-all hover:ring-1 ring-primary"
|
||||
:class="
|
||||
upSlots[openUpPickerSlot!]?.skillId === item.id.toString()
|
||||
? 'border-primary ring-1'
|
||||
: 'border-border/60'
|
||||
"
|
||||
:title="item.name"
|
||||
@click="setUPSlotSkill(openUpPickerSlot!, item.id.toString())"
|
||||
>
|
||||
<img :src="getImageURL(item.icon)" :alt="item.name" class="w-full h-full rounded object-cover" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- VP section -->
|
||||
<div class="flex-1 overflow-y-auto min-h-0">
|
||||
<div class="px-3 pt-3 pb-1 text-xs font-semibold text-muted-foreground flex justify-between sticky top-0 bg-background/80 backdrop-blur-sm z-10">
|
||||
<span>技能进化 (VP)</span>
|
||||
<span>{{ usedVPCount }}/{{ VP_LIMIT }}</span>
|
||||
</div>
|
||||
<div class="divide-y divide-border/30">
|
||||
<div v-for="skill in vpSkills" :key="skill.id" class="px-3 py-2.5 flex items-start gap-2">
|
||||
<!-- Lv + icon -->
|
||||
<div class="shrink-0 flex flex-col items-center gap-1 pt-0.5">
|
||||
<span class="text-[10px] text-muted-foreground">{{ skill.learnLv }}</span>
|
||||
<img :src="getImageURL(skill.icon)" :alt="skill.name" class="w-7 h-7 rounded object-cover" />
|
||||
</div>
|
||||
<!-- Name -->
|
||||
<div class="shrink-0 w-[4.5rem] text-[11px] text-muted-foreground truncate pt-1.5" :title="skill.name">
|
||||
{{ skill.name }}
|
||||
</div>
|
||||
<!-- Two VP choice cards -->
|
||||
<div class="flex flex-1 gap-1.5">
|
||||
<button
|
||||
v-for="mode in [1, 2] as const"
|
||||
:key="mode"
|
||||
class="flex-1 rounded border p-1.5 text-left transition-colors hover:bg-muted/30"
|
||||
:class="
|
||||
skillCfg(skill.id.toString()).vp === mode
|
||||
? 'border-purple-400 bg-purple-500/10'
|
||||
: 'border-border/50'
|
||||
"
|
||||
@click="setVPMode(skill.id.toString(), skillCfg(skill.id.toString()).vp === mode ? 0 : mode)"
|
||||
>
|
||||
<div
|
||||
class="text-[10px] font-semibold"
|
||||
:class="skillCfg(skill.id.toString()).vp === mode ? 'text-purple-400' : 'text-muted-foreground'"
|
||||
>形态 {{ mode }}</div>
|
||||
<div class="mt-0.5 text-[10px] text-muted-foreground leading-tight line-clamp-2">
|
||||
{{ skill.vps[mode - 1]?.name || "—" }}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- ══ RIGHT PANEL: Skill Detail ══ -->
|
||||
<div class="flex-1 flex flex-col overflow-y-auto" @click.self="clearActive">
|
||||
<!-- Notice bar -->
|
||||
<div
|
||||
v-if="notice"
|
||||
class="px-4 py-2 text-xs border-b shrink-0"
|
||||
:class="
|
||||
noticeType === 'error'
|
||||
? 'bg-destructive/10 text-destructive border-destructive/30'
|
||||
: 'bg-muted/30 text-muted-foreground border-border/40'
|
||||
"
|
||||
>{{ notice }}</div>
|
||||
|
||||
<!-- Skill detail -->
|
||||
<div v-if="selectedSkill" class="p-5 space-y-5">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-3">
|
||||
<img
|
||||
:src="getImageURL(selectedSkill.icon)"
|
||||
:alt="selectedSkill.name"
|
||||
class="w-14 h-14 rounded-lg object-cover border border-border/60"
|
||||
/>
|
||||
<div>
|
||||
<div class="text-base font-semibold">{{ selectedSkill.name }}</div>
|
||||
<div class="text-xs text-muted-foreground mt-1">
|
||||
{{ selectedSkill.type === "passive" ? "被动" : "主动" }} ·
|
||||
Lv {{ selectedSkill.learnLv }} 解锁
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Level control -->
|
||||
<div>
|
||||
<div class="flex items-baseline gap-2 mb-2.5">
|
||||
<span
|
||||
class="text-4xl font-bold tabular-nums"
|
||||
:style="{ color: lvColor(selectedSkill.id.toString(), selectedSkill.maxLearnLv) }"
|
||||
>{{ skillCfg(selectedSkill.id.toString()).lv }}</span>
|
||||
<span class="text-xs text-muted-foreground">/ 满 {{ selectedSkill.maxLearnLv }}</span>
|
||||
</div>
|
||||
<div class="flex gap-1.5">
|
||||
<Button size="sm" variant="outline" class="h-7 px-2.5 text-xs" @click="resetLv(selectedSkill.id.toString())">归零</Button>
|
||||
<Button size="sm" variant="outline" class="h-7 px-2.5 text-xs" @click="stepLv(selectedSkill.id.toString(), -1)">−1</Button>
|
||||
<Button size="sm" variant="outline" class="h-7 px-2.5 text-xs" @click="stepLv(selectedSkill.id.toString(), 1)">+1</Button>
|
||||
<Button size="sm" variant="outline" class="h-7 px-2.5 text-xs" @click="setMaxLv(selectedSkill.id.toString())">满级</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- VP -->
|
||||
<div v-if="selectedSkill.hasVP">
|
||||
<div class="text-sm font-medium mb-2">VP 进化形态</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
v-for="mode in [1, 2] as const"
|
||||
:key="mode"
|
||||
class="flex-1 rounded-md border p-2.5 text-left text-xs transition-colors hover:bg-muted/30"
|
||||
:class="
|
||||
skillCfg(selectedSkill.id.toString()).vp === mode
|
||||
? 'border-purple-400 bg-purple-500/10'
|
||||
: 'border-border/50'
|
||||
"
|
||||
@click="setVPMode(selectedSkill.id.toString(), skillCfg(selectedSkill.id.toString()).vp === mode ? 0 : mode)"
|
||||
>
|
||||
<div
|
||||
class="font-semibold"
|
||||
:class="skillCfg(selectedSkill.id.toString()).vp === mode ? 'text-purple-400' : 'text-muted-foreground'"
|
||||
>形态 {{ mode }}</div>
|
||||
<div class="mt-1.5 text-muted-foreground leading-snug">
|
||||
{{ selectedSkill.vps[mode - 1]?.name || "—" }}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- UP -->
|
||||
<div v-if="selectedSkill.hasUP">
|
||||
<div class="text-sm font-medium mb-1">UP 突破状态</div>
|
||||
<div class="text-xs text-muted-foreground">
|
||||
{{
|
||||
skillCfg(selectedSkill.id.toString()).up === 0
|
||||
? "未配置"
|
||||
: `已配置 形态 ${skillCfg(selectedSkill.id.toString()).up}`
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-else class="flex-1 flex flex-col items-center justify-center gap-4 p-6 text-center">
|
||||
<div class="text-sm text-muted-foreground">点击左侧技能查看详情</div>
|
||||
<div class="text-xs text-muted-foreground space-y-1">
|
||||
<div>VP 已用: {{ usedVPCount }} / {{ VP_LIMIT }}</div>
|
||||
<div>UP 已用: {{ usedUPCount }} / {{ UP_LIMIT }}</div>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" @click="resetAllSkills">重置全部技能</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 35×55px skill cell — matches original dimensions */
|
||||
.skill-cell {
|
||||
position: relative;
|
||||
width: 35px;
|
||||
height: 55px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Top 14px: badge row */
|
||||
.badge-row {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 14px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
}
|
||||
.badge {
|
||||
font-size: 8px;
|
||||
line-height: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.badge-vp { color: #c084fc; } /* purple-400 */
|
||||
.badge-up { color: #fb923c; } /* amber-400 */
|
||||
|
||||
/* 28×28px icon at y=14px */
|
||||
.skill-icon {
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
object-fit: cover;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* 4 action buttons at the bottom */
|
||||
.action-row {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1px;
|
||||
}
|
||||
.skill-btn {
|
||||
width: 19px;
|
||||
height: 15px;
|
||||
font-size: 11px;
|
||||
background-color: color-mix(in oklab, var(--background) 90%, transparent);
|
||||
border: 1px solid color-mix(in oklab, var(--border) 70%, transparent);
|
||||
border-radius: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
.skill-btn:hover {
|
||||
background-color: var(--muted);
|
||||
}
|
||||
|
||||
/* Level number */
|
||||
.lv-num {
|
||||
position: absolute;
|
||||
bottom: 2px;
|
||||
font-size: 10px;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -16,8 +16,6 @@ import type { IResultSkillCount } from "@/api/calc/type";
|
||||
// | '魔法石'
|
||||
// | '武器',
|
||||
|
||||
|
||||
|
||||
export interface IConfigEquip {
|
||||
id: string;
|
||||
reinforce: number;
|
||||
@@ -76,6 +74,13 @@ export interface IConfig {
|
||||
emblems: Record<string, IConfigEmblem>;
|
||||
}
|
||||
|
||||
export interface IChangeSourceLog {
|
||||
id: number;
|
||||
source: string;
|
||||
detail: string;
|
||||
time: string;
|
||||
}
|
||||
|
||||
const defaultEqusConfig = {
|
||||
id: "",
|
||||
reinforce: 0,
|
||||
@@ -127,6 +132,7 @@ export const useConfigStore = defineStore("configStore", () => {
|
||||
});
|
||||
|
||||
const skillCountConfig = ref<IResultSkillCount[]>([]);
|
||||
const changeSourceLogs = ref<IChangeSourceLog[]>([]);
|
||||
|
||||
const loadConfig = () => {
|
||||
const infoStore = useInfoStore();
|
||||
@@ -263,6 +269,20 @@ export const useConfigStore = defineStore("configStore", () => {
|
||||
}
|
||||
};
|
||||
|
||||
const pushChangeSource = (source: string, detail: string) => {
|
||||
const next: IChangeSourceLog = {
|
||||
id: Date.now() + Math.floor(Math.random() * 1000),
|
||||
source,
|
||||
detail,
|
||||
time: new Date().toLocaleTimeString("zh-CN", { hour12: false }),
|
||||
};
|
||||
changeSourceLogs.value = [next, ...changeSourceLogs.value].slice(0, 30);
|
||||
};
|
||||
|
||||
const clearChangeSource = () => {
|
||||
changeSourceLogs.value = [];
|
||||
};
|
||||
|
||||
const result = useAsyncState(
|
||||
() => {
|
||||
return calc();
|
||||
@@ -289,5 +309,8 @@ export const useConfigStore = defineStore("configStore", () => {
|
||||
defaultAvatarConfig,
|
||||
defaultOathConfig,
|
||||
defaultEmblemConfig,
|
||||
changeSourceLogs,
|
||||
pushChangeSource,
|
||||
clearChangeSource,
|
||||
};
|
||||
});
|
||||
|
||||