迁移
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
# frontend -> frontendNew function matrix
|
||||
|
||||
| Capability | frontend baseline | frontendNew acceptance |
|
||||
| --- | --- | --- |
|
||||
| Home and role entry | Select a role and open its calculator | `/dcalc/home` opens the selected role under `/dcalc/character/:alter/equipment` |
|
||||
| Assistant | Bind ID, list roles, import build data | Bind, refresh, role-targeted import, and ID import preserve the same API payloads |
|
||||
| Equipment | Select/toggle equipment by category and bulk-wear a suit rarity | Weapon, subweapon, suit, universal, title/pet, treasure, oath, trigger options, preview, and idempotent bulk wear work |
|
||||
| Skill | Edit level, VP, UP, awakening, and import code | Writes the same `config.skills` and `bindAwake` fields, then recalculates |
|
||||
| Forge | Edit equipment, oath, avatar, emblem, jade, and global fields | Writes the same config fields, persists, recalculates, and records source changes |
|
||||
| Result | Inspect equipment/oath, skills, baseline, and skill counts | Full detail route plus one local summary panel; baseline, deltas, retries, states, and sorting work |
|
||||
| Persistence | Per-role config and skill counts survive reload | Uses unchanged `dcalc/<alter>/config` and `dcalc/<alter>/config/skillCount` keys |
|
||||
|
||||
## Non-baseline additions retained
|
||||
|
||||
- DNF VP routes and pages remain exclusive to `frontendNew`.
|
||||
- Legacy URLs are intentionally unsupported; no redirect or alias is part of acceptance.
|
||||
@@ -1,60 +1,34 @@
|
||||
# frontend -> frontendNew 迁移计划(执行中)
|
||||
# frontend -> frontendNew migration status
|
||||
|
||||
## 目标
|
||||
## Completed scope
|
||||
|
||||
- 以 frontendNew 为主线承接 dcalc 核心能力。
|
||||
- 已完成的 equipment 作为基线,不重做主体,仅做一致性优化。
|
||||
- 当前优先迁移 Skill、Forge、Result,策略为“核心功能优先,视觉后置”。
|
||||
- 迁移期间保持 frontend 与 frontendNew 双端并行可用。
|
||||
- `frontendNew` is the only supported application surface. Character routes are under
|
||||
`/dcalc/character/:alter/(equipment|skill|forge|result)`; legacy `frontend` URLs are not
|
||||
redirected or maintained.
|
||||
- The existing API contracts, Pinia configuration schema, and `dcalc/<alter>/config` storage
|
||||
keys remain unchanged.
|
||||
- Equipment, Skill, Forge, imports, result details, baseline comparison, and change-source logs
|
||||
are implemented in `frontendNew`.
|
||||
|
||||
## 当前状态
|
||||
## Parity safeguards added
|
||||
|
||||
- 已完成:`Character/EquipmentDictionary` 主体迁移并接入路由。
|
||||
- 进行中:`Character/Skill`、`Character/Forge` 从占位页向可编辑页迁移。
|
||||
- 新启动:`Character/Result` 结果面板接入与自动计算链路。
|
||||
- Suite double-click now applies the selected rarity as an idempotent bulk wear operation for both
|
||||
equipment and oaths. It never toggles already equipped items off.
|
||||
- Equipment choices and bulk wear actions append a single `Equipment` change-source record; Skill,
|
||||
Forge, and import actions keep their existing records.
|
||||
- Character configuration changes are debounced and serialized. A change received during an active
|
||||
calculation queues one follow-up calculation and every completed attempt persists the latest
|
||||
configuration.
|
||||
- Character switches reset in-memory configuration and skill counts before loading that role's
|
||||
persisted data. Stale character responses are ignored by their `Alter-Token`.
|
||||
- The unused global result sheet was removed from the Character shell. Equipment, Skill, and Forge
|
||||
retain their one local summary panel; `/result` remains the full calculation-detail route.
|
||||
|
||||
## 分阶段计划
|
||||
## Out of scope
|
||||
|
||||
### Phase 1 - 共享链路打通
|
||||
- Legacy `frontend` build errors and its uncommitted files are not modified.
|
||||
- DNF VP remains a `frontendNew`-only feature.
|
||||
- No backend API, request payload, configuration field, or local-storage migration is introduced.
|
||||
|
||||
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 页面接入配置变更自动计算与离开页面自动保存。
|
||||
See [MIGRATION_FUNCTION_MATRIX.md](./MIGRATION_FUNCTION_MATRIX.md) and
|
||||
[MIGRATION_REGRESSION_CHECKLIST.md](./MIGRATION_REGRESSION_CHECKLIST.md) for acceptance coverage.
|
||||
|
||||
@@ -1,56 +1,40 @@
|
||||
# Character Migration Regression Checklist
|
||||
# Character migration regression checklist
|
||||
|
||||
Last updated: 2026-05-28
|
||||
Scope: frontendNew Character module (Equipment / Skill / Forge / Result)
|
||||
Last updated: 2026-07-10
|
||||
|
||||
## 1. Skill page
|
||||
## Routes and imports
|
||||
|
||||
- [ ] Enter Skill page and switch between `结果面板` and `技能详情`.
|
||||
- [ ] Verify there is only one visible result panel in Skill page.
|
||||
- [ ] Change at least one skill level and confirm result recalculates automatically.
|
||||
- [ ] Set and clear baseline in result panel.
|
||||
- [ ] Open `/dcalc/home`, select a role, and enter Equipment, Skill, Forge, and Result.
|
||||
- [ ] Bind an assistant account, refresh its role list, and import a selected role through `roleId`.
|
||||
- [ ] Import a build by community ID and a skill build by skill code; verify the result refreshes.
|
||||
|
||||
## 2. Forge page
|
||||
## Equipment and oath configuration
|
||||
|
||||
- [ ] Enter Forge page and switch between `结果面板` and `配置编辑`.
|
||||
- [ ] In mobile width, use accordion module groups to switch `装备打造 / 誓约 / 时装徽章 / 全局`.
|
||||
- [ ] Edit reinforce/refine/adaptation and confirm result recalculates automatically.
|
||||
- [ ] Trigger quick reset and confirm change source logs are appended.
|
||||
- [ ] Select and deselect weapon, subweapon (where supported), suit, universal, title/pet, treasure,
|
||||
oath suit, oath universal, and other oath entries.
|
||||
- [ ] Change trigger options, preview equipment, select an oath skill, and move preview suit/oath levels.
|
||||
- [ ] Select a suit rarity, double-click a suit icon, and verify all matching pieces are worn.
|
||||
- [ ] Repeat the double-click action and verify pieces remain worn rather than being toggled off.
|
||||
- [ ] Verify the latest Equipment change-source record describes the single or bulk action.
|
||||
|
||||
## 3. Result states (4-state)
|
||||
## Skill, forge, and result
|
||||
|
||||
- [ ] Loading: trigger compute and confirm loading state appears.
|
||||
- [ ] Error: simulate invalid calculation and confirm retry button appears.
|
||||
- [ ] Empty: clear config to no-result state and confirm empty hint appears.
|
||||
- [ ] Success: valid config renders result summary and details.
|
||||
- [ ] Change skill levels, VP/UP selections, and bound awakening; verify exactly one eventual recalculation.
|
||||
- [ ] Edit equipment forging, oath adaptation, avatar, emblems, jade, and global options; refresh and
|
||||
confirm persistence.
|
||||
- [ ] Switch quickly between two roles, including one without saved data; verify neither equipment
|
||||
configuration nor skill-count overrides leak from the previous role.
|
||||
- [ ] Verify Result states: loading, error/retry, empty, and successful detail rendering.
|
||||
- [ ] Set a baseline, change skill counts, verify total and row deltas, then clear the baseline.
|
||||
- [ ] Confirm the result summary appears once per Equipment, Skill, and Forge page, while `/result`
|
||||
shows the full detail view.
|
||||
|
||||
## 4. Baseline comparison
|
||||
## Automated validation
|
||||
|
||||
- [ ] Set current result as baseline and verify delta labels update.
|
||||
- [ ] Modify skill counts and verify total diff and row diff update.
|
||||
- [ ] Clear baseline and verify diff falls back to absolute values.
|
||||
```sh
|
||||
cd frontendNew
|
||||
pnpm type-check
|
||||
pnpm build
|
||||
```
|
||||
|
||||
## 5. Change source logs
|
||||
|
||||
- [ ] Perform updates in Equipment / Skill / Forge and verify source names and details.
|
||||
- [ ] Verify log list is scrollable and supports clear action.
|
||||
- [ ] Confirm latest records appear first.
|
||||
|
||||
## 6. This iteration patches
|
||||
|
||||
- [x] CalcDetail left panel switched to accordion groups.
|
||||
- [x] Forge config module selector supports mobile accordion groups.
|
||||
- [x] Forge form density reduced for small screens (`text-xs`, compact paddings).
|
||||
|
||||
## 7. Sidebar helper binding UX
|
||||
|
||||
- [ ] Without helper binding, sidebar user section should show placeholder name and guide text.
|
||||
- [ ] Dropdown should provide `绑定/同步助手` entry and open `/dcalc/dnfhelper`.
|
||||
- [ ] `取消绑定` should clear helper info via store logout action.
|
||||
- [x] dnfhelper store keeps backward-compatible `refleshHelperInfo` alias.
|
||||
|
||||
## Validation commands
|
||||
|
||||
- `cd frontendNew && pnpm type-check`
|
||||
- `cd frontendNew && pnpm lint`
|
||||
- `cd frontendNew && pnpm build`
|
||||
Both commands passed locally on 2026-07-10 using the installed project dependencies.
|
||||
|
||||
@@ -72,9 +72,7 @@ const partName = (part: string) => {
|
||||
<div
|
||||
class="equ-list"
|
||||
:class="{ 'equ-list--plain': !withBg }"
|
||||
:style="{
|
||||
backgroundImage: withBg ? `url(${getImageURL('/common/head.png')})` : 'none',
|
||||
}"
|
||||
:style="withBg ? { backgroundImage: `url(${getImageURL('/common/head.png')})` } : undefined"
|
||||
>
|
||||
<template v-for="part in parts" :key="part">
|
||||
<button class="equ-slot" :style="infoStyle(part)" type="button" @click="choosePart(part)">
|
||||
|
||||
@@ -74,9 +74,9 @@ const choosePart = (part: string) => {
|
||||
<div
|
||||
class="oath-list"
|
||||
:class="{ 'oath-list--plain': !withBg }"
|
||||
:style="{
|
||||
backgroundImage: withBg ? `url(${getImageURL('/common/oath/head.png')})` : 'none',
|
||||
}"
|
||||
:style="
|
||||
withBg ? { backgroundImage: `url(${getImageURL('/common/oath/head.png')})` } : undefined
|
||||
"
|
||||
>
|
||||
<template v-for="part in parts" :key="part">
|
||||
<button class="oath-slot" :style="infoStyle(part)" type="button" @click="choosePart(part)">
|
||||
|
||||
@@ -590,11 +590,16 @@ const chooseEquip = (
|
||||
if (options?.oath || isOathEquipment(equip)) {
|
||||
const oathConfig =
|
||||
configStore.config.oaths[equip.oathPos] ??
|
||||
(configStore.config.oaths[equip.oathPos] = { id: "", adaptation: 0 });
|
||||
(configStore.config.oaths[equip.oathPos] = configStore.createDefaultOathConfig());
|
||||
oathConfig.id = oathConfig.id === equip.id ? "" : equip.id;
|
||||
configStore.pushChangeSource("Equipment", `${oathConfig.id ? "已穿戴" : "已卸下"}${equip.name}`);
|
||||
return;
|
||||
}
|
||||
configStore.chooseEqu(equip, false, options?.subweapon ?? false);
|
||||
configStore.pushChangeSource(
|
||||
"Equipment",
|
||||
`${isSelected(equip, options) ? "已穿戴" : "已卸下"}${equip.name}`,
|
||||
);
|
||||
|
||||
if (isSelected(equip, options)) {
|
||||
sidePanelTab.value = "equipped";
|
||||
@@ -642,16 +647,29 @@ const wearCurrentSuitByRarity = (isOath = false, targetSuitId?: string) => {
|
||||
.filter((item) => item.rarity === currentRarity)
|
||||
.filter((item) => (isOath ? true : !item.name.includes("黑牙")));
|
||||
|
||||
let equippedCount = 0;
|
||||
source.forEach((item) => {
|
||||
if (isOath && item.oathPos) {
|
||||
const oathConfig =
|
||||
configStore.config.oaths[item.oathPos] ??
|
||||
(configStore.config.oaths[item.oathPos] = { id: "", adaptation: 0 });
|
||||
(configStore.config.oaths[item.oathPos] = configStore.createDefaultOathConfig());
|
||||
oathConfig.id = item.id;
|
||||
equippedCount += 1;
|
||||
return;
|
||||
}
|
||||
configStore.chooseEqu(item, false, false);
|
||||
const part = item.itemType === "武器" ? "武器" : item.itemDetailType;
|
||||
const equipmentConfig = configStore.config.equips[part];
|
||||
if (!equipmentConfig) return;
|
||||
equipmentConfig.id = item.id;
|
||||
equippedCount += 1;
|
||||
});
|
||||
|
||||
if (equippedCount) {
|
||||
configStore.pushChangeSource(
|
||||
"Equipment",
|
||||
`已按 ${currentRarity} 穿戴 ${equippedCount} 件${isOath ? "誓约" : "套装"}装备`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const formatLv = (lv: number, rarity: string) =>
|
||||
|
||||
+8
-1
@@ -65,7 +65,14 @@ const cellStyle = {
|
||||
@mouseenter="$emit('preview')"
|
||||
@contextmenu.prevent="$emit('rightClick')"
|
||||
>
|
||||
<img :src="imageUrl" :alt="equipment.name" class="size-full object-contain" />
|
||||
<img
|
||||
:src="imageUrl"
|
||||
:alt="equipment.name"
|
||||
class="size-full object-contain"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
fetchpriority="low"
|
||||
/>
|
||||
<div v-if="inactive" class="absolute inset-0 bg-black/45" />
|
||||
</button>
|
||||
<div
|
||||
|
||||
@@ -20,6 +20,8 @@ import {
|
||||
} from "@/components/ui/accordion";
|
||||
import type { IJade } from "@/api/info/type";
|
||||
import ResultPanel from "@/pages/Dcalc/Character/Result/Index.vue";
|
||||
import EquipmentList from "@/components/dnf/Equipment/List/index.vue";
|
||||
import OathList from "@/components/dnf/Equipment/Oath/index.vue";
|
||||
|
||||
const configStore = useConfigStore();
|
||||
const infoStore = useInfoStore();
|
||||
@@ -31,6 +33,8 @@ const noticeType = ref<"error" | "info">("info");
|
||||
|
||||
const avatarPart = ref("头部");
|
||||
const emblemPart = ref("彩色");
|
||||
const equipPart = ref("头肩");
|
||||
const oathPart = ref("0");
|
||||
const avatarOptionKeyword = ref("");
|
||||
const emblemOptionKeyword = ref("");
|
||||
const onlyConfiguredAvatarParts = ref(false);
|
||||
@@ -113,7 +117,11 @@ const summary = computed(() => {
|
||||
};
|
||||
});
|
||||
|
||||
const parts = computed(() => infoStore.parts.filter((part) => configStore.config.equips[part]));
|
||||
const parts = computed(() =>
|
||||
[...infoStore.parts, "副武器"].filter(
|
||||
(part, index, all) => all.indexOf(part) === index && configStore.config.equips[part],
|
||||
),
|
||||
);
|
||||
const oathParts = computed(() =>
|
||||
infoStore.oathParts.filter((part) => configStore.config.oaths[part]),
|
||||
);
|
||||
@@ -238,6 +246,16 @@ const setOathAdaptation = (part: string, value: string) => {
|
||||
trackSource(oathBatchApply.value ? "誓约适配已批量更新" : `誓约槽位 ${part} 适配已更新`);
|
||||
};
|
||||
|
||||
const setOathUpgrade = (part: string, index: number, value: string) => {
|
||||
const target = configStore.config.oaths[part];
|
||||
if (!target) return;
|
||||
const next = Array.isArray(target.upgrade) ? [...target.upgrade] : [0, 0, 0, 0];
|
||||
next[index] = Number.parseInt(value || "0", 10) || 0;
|
||||
target.upgrade = next;
|
||||
setNotice(`已更新誓约升级槽位 ${index + 1}。`);
|
||||
trackSource(`誓约升级槽位 ${index + 1} 已更新`);
|
||||
};
|
||||
|
||||
const equipCfg = (part: string) => configStore.config.equips[part] ?? configStore.defaultEqusConfig;
|
||||
const oathCfg = (part: string) => configStore.config.oaths[part] ?? configStore.defaultOathConfig;
|
||||
const avatarCfg = (part: string) =>
|
||||
@@ -691,13 +709,35 @@ const resetAllForge = () => {
|
||||
<CardDescription>强化、锻造、品级编辑并即时回写配置。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-2 text-xs sm:space-y-3 sm:text-sm">
|
||||
<div class="grid gap-4 rounded-md border bg-muted/15 p-3 md:grid-cols-[280px_1fr] md:items-center">
|
||||
<EquipmentList
|
||||
v-model:part="equipPart"
|
||||
:detail="configStore.config.equips"
|
||||
:with-bg="false"
|
||||
:with-pet="false"
|
||||
:with-sub-weapon="(infoStore.infos?.subweapons?.length ?? 0) > 0"
|
||||
/>
|
||||
<div class="flex min-w-0 flex-col gap-2">
|
||||
<div class="text-muted-foreground">当前部位</div>
|
||||
<div class="text-base font-semibold">{{ equipPart }}</div>
|
||||
<div class="truncate text-muted-foreground">
|
||||
{{ equipNameById.get(equipCfg(equipPart).id) || "未选择装备(可在装备页选择)" }}
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground">点击左侧装备槽切换当前编辑部位。</div>
|
||||
</div>
|
||||
</div>
|
||||
<label
|
||||
class="flex flex-wrap items-center justify-between gap-2 rounded-md border bg-muted/30 p-2 text-muted-foreground"
|
||||
>
|
||||
<span>批量应用到同类部位</span>
|
||||
<input v-model="equipBatchApply" type="checkbox" class="size-4" />
|
||||
</label>
|
||||
<div v-for="part in parts" :key="part" class="space-y-3 rounded-md border p-2 md:p-3">
|
||||
<div
|
||||
v-for="part in parts"
|
||||
v-show="part === equipPart"
|
||||
:key="part"
|
||||
class="space-y-3 rounded-md border p-2 md:p-3"
|
||||
>
|
||||
<div class="grid grid-cols-[5rem_minmax(0,1fr)_auto] items-center gap-2">
|
||||
<div class="font-medium">{{ part }}</div>
|
||||
<div class="truncate text-muted-foreground">
|
||||
@@ -818,6 +858,23 @@ const resetAllForge = () => {
|
||||
<CardDescription>编辑誓约槽位适配值。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-2 text-xs sm:space-y-3 sm:text-sm">
|
||||
<div class="grid gap-4 rounded-md border bg-muted/15 p-3 md:grid-cols-[280px_1fr] md:items-center">
|
||||
<OathList
|
||||
v-model:part="oathPart"
|
||||
:detail="configStore.config.oaths"
|
||||
:with-bg="false"
|
||||
/>
|
||||
<div class="flex min-w-0 flex-col gap-2">
|
||||
<div class="text-muted-foreground">当前誓约槽位</div>
|
||||
<div class="text-base font-semibold">
|
||||
{{ oathPart === "11" ? "誓约" : `星蕴石 ${Number(oathPart) + 1}` }}
|
||||
</div>
|
||||
<div class="truncate text-muted-foreground">
|
||||
{{ oathNameById.get(oathCfg(oathPart).id) || "未选择誓约(可在装备页选择)" }}
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground">点击左侧誓约槽切换当前编辑槽位。</div>
|
||||
</div>
|
||||
</div>
|
||||
<label
|
||||
class="flex flex-wrap items-center justify-between gap-2 rounded-md border bg-muted/30 p-2 text-muted-foreground"
|
||||
>
|
||||
@@ -826,14 +883,16 @@ const resetAllForge = () => {
|
||||
</label>
|
||||
<div
|
||||
v-for="part in oathParts"
|
||||
v-show="part === oathPart"
|
||||
:key="part"
|
||||
class="grid grid-cols-1 gap-2 rounded-md border p-2 md:grid-cols-[6rem_1fr_7rem_auto] md:items-center md:p-3"
|
||||
class="grid grid-cols-1 gap-2 rounded-md border p-2 md:grid-cols-[6rem_minmax(0,1fr)_24rem_auto] md:items-center md:p-3"
|
||||
>
|
||||
<div class="font-medium">槽位 {{ part }}</div>
|
||||
<div class="truncate text-muted-foreground">
|
||||
{{ oathNameById.get(oathCfg(part).id) || "未选择誓约(可在装备页选择)" }}
|
||||
</div>
|
||||
<Input
|
||||
v-if="part !== '11'"
|
||||
:model-value="String(oathCfg(part).adaptation)"
|
||||
type="number"
|
||||
min="0"
|
||||
@@ -841,6 +900,21 @@ const resetAllForge = () => {
|
||||
placeholder="适配"
|
||||
@update:model-value="(value) => setOathAdaptation(part, String(value))"
|
||||
/>
|
||||
<div v-else class="grid grid-cols-2 gap-1 md:grid-cols-4">
|
||||
<Select
|
||||
v-for="index in 4"
|
||||
:key="index"
|
||||
:model-value="String(oathCfg(part).upgrade?.[index - 1] ?? 0)"
|
||||
@update:model-value="(value) => setOathUpgrade(part, index - 1, String(value))"
|
||||
>
|
||||
<SelectTrigger class="h-8 min-w-20 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0">无</SelectItem>
|
||||
<SelectItem value="1">技攻 +4%</SelectItem>
|
||||
<SelectItem value="2">CD -8%</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button size="sm" variant="ghost" @click="resetOathPart(part)">重置</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -860,6 +934,19 @@ const resetAllForge = () => {
|
||||
<CardDescription>支持附魔、徽章项、品级和属性值编辑。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-2 text-xs sm:space-y-3 sm:text-sm">
|
||||
<div class="grid grid-cols-4 gap-2 rounded-md border bg-muted/15 p-3 sm:grid-cols-6">
|
||||
<Button
|
||||
v-for="part in avatarParts"
|
||||
:key="part"
|
||||
size="sm"
|
||||
:variant="avatarPart === part ? 'default' : 'outline'"
|
||||
class="h-12 min-w-0 px-1 text-xs"
|
||||
:title="part"
|
||||
@click="avatarPart = part"
|
||||
>
|
||||
<span class="truncate">{{ part.replace("宠物装备-", "") }}</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-2 md:grid-cols-[7rem_1fr] md:items-center">
|
||||
<div class="text-muted-foreground">筛选</div>
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -1021,6 +1108,18 @@ const resetAllForge = () => {
|
||||
<CardDescription>支持等级、徽章条目与槽位数量规则。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-2 text-xs sm:space-y-3 sm:text-sm">
|
||||
<div class="grid grid-cols-3 gap-2 rounded-md border bg-muted/15 p-3 sm:grid-cols-6">
|
||||
<Button
|
||||
v-for="part in infoStore.emblemParts"
|
||||
:key="part"
|
||||
size="sm"
|
||||
:variant="emblemPart === part ? 'default' : 'outline'"
|
||||
class="h-12 rounded-full px-2 text-xs"
|
||||
@click="emblemPart = part"
|
||||
>
|
||||
{{ part }}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-2 md:grid-cols-[7rem_1fr] md:items-center">
|
||||
<div class="text-muted-foreground">筛选</div>
|
||||
<div class="flex items-center gap-2">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { FileInput, PanelRight, WandSparkles } from "lucide-vue-next";
|
||||
import { FileInput, WandSparkles } from "lucide-vue-next";
|
||||
import api from "@/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -22,7 +22,6 @@ import { useConfigStore, useInfoStore } from "@/stores";
|
||||
import { useDnfHelperStore } from "@/stores/dnfhelper";
|
||||
import type { IConfigEmblem, IConfigEquip, IConfigOath } from "@/stores/config";
|
||||
import type { IHelperInfo } from "@/api/info/type";
|
||||
import ResultPanel from "@/pages/Dcalc/Character/Result/Index.vue";
|
||||
const props = defineProps<{
|
||||
alter: string;
|
||||
}>();
|
||||
@@ -35,8 +34,6 @@ const helperStore = useDnfHelperStore();
|
||||
const loading = ref(false);
|
||||
const loadError = ref("");
|
||||
const isExecuting = ref(false);
|
||||
const isMobileLayout = ref(false);
|
||||
const isResultSheetOpen = ref(false);
|
||||
const helperImportOpen = ref(false);
|
||||
const skillImportOpen = ref(false);
|
||||
const helperRoleImportOpen = ref(false);
|
||||
@@ -48,7 +45,8 @@ const importStatus = ref("");
|
||||
const importError = ref("");
|
||||
const helperRole = ref<IHelperInfo["roleList"][number]>();
|
||||
|
||||
let mediaQuery: MediaQueryList | null = null;
|
||||
let calcDebounceTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let calcQueued = false;
|
||||
|
||||
type AvatarConfig = Record<
|
||||
string,
|
||||
@@ -75,13 +73,6 @@ interface SkillImportPayload {
|
||||
skills?: typeof configStore.config.skills;
|
||||
}
|
||||
|
||||
const updateMobileLayout = () => {
|
||||
isMobileLayout.value = mediaQuery?.matches ?? false;
|
||||
if (!isMobileLayout.value) {
|
||||
isResultSheetOpen.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const tabs = [
|
||||
{ label: "装备", key: "equipment" },
|
||||
{ label: "技能", key: "skill" },
|
||||
@@ -97,14 +88,12 @@ const activeTab = computed(() => {
|
||||
return "equipment";
|
||||
});
|
||||
|
||||
// Result panel is now embedded inside Equipment/Skill/Forge pages to avoid duplicate rendering.
|
||||
const showGlobalResultPanel = computed(() => false);
|
||||
|
||||
const loadCharacter = async () => {
|
||||
loading.value = true;
|
||||
loadError.value = "";
|
||||
try {
|
||||
await infoStore.createCharacter(props.alter);
|
||||
const loaded = await infoStore.createCharacter(props.alter);
|
||||
if (!loaded) return;
|
||||
prepareHelperRoleImport();
|
||||
} catch (error) {
|
||||
loadError.value = error instanceof Error ? error.message : "角色数据加载失败";
|
||||
@@ -217,9 +206,6 @@ const prepareHelperRoleImport = () => {
|
||||
onMounted(() => {
|
||||
loadCharacter();
|
||||
window.addEventListener("beforeunload", configStore.saveConfig);
|
||||
mediaQuery = window.matchMedia("(max-width: 1279px)");
|
||||
updateMobileLayout();
|
||||
mediaQuery.addEventListener("change", updateMobileLayout);
|
||||
});
|
||||
|
||||
watch(
|
||||
@@ -236,30 +222,42 @@ watch(
|
||||
},
|
||||
);
|
||||
|
||||
watch(showGlobalResultPanel, (show) => {
|
||||
if (!show) {
|
||||
isResultSheetOpen.value = false;
|
||||
const executeCalculation = async () => {
|
||||
if (isExecuting.value) {
|
||||
calcQueued = true;
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
const stopConfigWatch = watch(
|
||||
() => JSON.stringify(configStore.config),
|
||||
async () => {
|
||||
if (isExecuting.value) return;
|
||||
isExecuting.value = true;
|
||||
try {
|
||||
await configStore.result.execute();
|
||||
configStore.saveConfig();
|
||||
} finally {
|
||||
configStore.saveConfig();
|
||||
isExecuting.value = false;
|
||||
if (calcQueued) {
|
||||
calcQueued = false;
|
||||
scheduleCalculation();
|
||||
}
|
||||
},
|
||||
{ deep: true },
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleCalculation = () => {
|
||||
if (calcDebounceTimer) clearTimeout(calcDebounceTimer);
|
||||
calcDebounceTimer = setTimeout(() => {
|
||||
calcDebounceTimer = undefined;
|
||||
void executeCalculation();
|
||||
}, 120);
|
||||
};
|
||||
|
||||
const stopConfigWatch = watch(
|
||||
() => JSON.stringify(configStore.config),
|
||||
scheduleCalculation,
|
||||
{ deep: true, flush: "post" },
|
||||
);
|
||||
|
||||
onUnmounted(() => {
|
||||
stopConfigWatch();
|
||||
mediaQuery?.removeEventListener("change", updateMobileLayout);
|
||||
if (calcDebounceTimer) clearTimeout(calcDebounceTimer);
|
||||
window.removeEventListener("beforeunload", configStore.saveConfig);
|
||||
configStore.saveConfig();
|
||||
});
|
||||
@@ -353,35 +351,10 @@ onUnmounted(() => {
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<div
|
||||
:class="[
|
||||
'grid min-h-0 flex-1 grid-cols-1 gap-4',
|
||||
showGlobalResultPanel ? 'xl:grid-cols-[minmax(0,1fr)_24rem]' : 'xl:grid-cols-1',
|
||||
]"
|
||||
>
|
||||
<div class="grid min-h-0 flex-1 grid-cols-1 gap-4">
|
||||
<div class="min-w-0 overflow-hidden">
|
||||
<router-view />
|
||||
</div>
|
||||
<aside v-if="!isMobileLayout && showGlobalResultPanel" class="min-h-0">
|
||||
<ResultPanel />
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<Sheet v-if="isMobileLayout && showGlobalResultPanel" v-model:open="isResultSheetOpen">
|
||||
<SheetTrigger as-child>
|
||||
<Button class="fixed bottom-5 right-4 z-30 shadow-lg" size="sm">
|
||||
<PanelRight class="size-4" />
|
||||
查看结果
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="bottom" class="h-[78vh] overflow-hidden px-3 pb-3">
|
||||
<SheetHeader class="pb-2">
|
||||
<SheetTitle>实时计算结果</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div class="h-[calc(78vh-4.5rem)] overflow-hidden">
|
||||
<ResultPanel />
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="1" y="1" width="16" height="16" fill="url(#bg)"/><rect x="0.5" y="0.5" width="17" height="17" stroke="#5D4F30"/><rect x="8" y="4" width="2" height="10" fill="#B7975F"/><rect x="4" y="8" width="10" height="2" fill="#B7975F"/><defs><linearGradient id="bg" x1="9" y1="1" x2="9" y2="17"><stop stop-color="#451A1F"/><stop offset="1" stop-color="#271111"/></linearGradient></defs></svg>
|
||||
|
After Width: | Height: | Size: 485 B |
@@ -0,0 +1 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="1" y="1" width="16" height="16" fill="url(#bg)"/><rect x="0.5" y="0.5" width="17" height="17" stroke="#5D4F30"/><path d="M9 9L3.75 14.1962V3.80385L9 9Z" fill="#B7975F"/><path d="M15 9L9.75 14.1962V3.80385L15 9Z" fill="#B7975F"/><defs><linearGradient id="bg" x1="9" y1="1" x2="9" y2="17"><stop stop-color="#451A1F"/><stop offset="1" stop-color="#271111"/></linearGradient></defs></svg>
|
||||
|
After Width: | Height: | Size: 489 B |
@@ -0,0 +1 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="1" y="1" width="16" height="16" fill="url(#bg)"/><rect x="0.5" y="0.5" width="17" height="17" stroke="#5D4F30"/><path d="M9 9L14.25 3.80385V14.1962L9 9Z" fill="#B7975F"/><path d="M3 9L8.25 3.80385V14.1962L3 9Z" fill="#B7975F"/><defs><linearGradient id="bg" x1="9" y1="1" x2="9" y2="17"><stop stop-color="#103562"/><stop offset="1" stop-color="#092343"/></linearGradient></defs></svg>
|
||||
|
After Width: | Height: | Size: 488 B |
@@ -0,0 +1 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="1" y="1" width="16" height="16" fill="url(#bg)"/><rect x="0.5" y="0.5" width="17" height="17" stroke="#5D4F30"/><rect x="4" y="8" width="10" height="2" fill="#B7975F"/><defs><linearGradient id="bg" x1="9" y1="1" x2="9" y2="17"><stop stop-color="#103562"/><stop offset="1" stop-color="#092343"/></linearGradient></defs></svg>
|
||||
|
After Width: | Height: | Size: 429 B |
@@ -13,6 +13,10 @@ import vp1Img from "./img/vp_1.png";
|
||||
import vp2Img from "./img/vp_2.png";
|
||||
import up1Img from "./img/up_1.png";
|
||||
import up2Img from "./img/up_2.png";
|
||||
import minImg from "./img/min.svg";
|
||||
import subImg from "./img/sub.svg";
|
||||
import addImg from "./img/add.svg";
|
||||
import maxImg from "./img/max.svg";
|
||||
|
||||
const vpImg = (mode: number) => (mode === 1 ? vp1Img : vp2Img);
|
||||
const upImg = (mode: number) => (mode === 1 ? up1Img : up2Img);
|
||||
@@ -27,11 +31,7 @@ const notice = ref("");
|
||||
const noticeType = ref<"error" | "info">("info");
|
||||
const openUpPickerSlot = ref<number | null>(null);
|
||||
const rightPanelTab = ref<"detail" | "result">("result");
|
||||
const leftPanelWidthClass = computed(() =>
|
||||
rightPanelTab.value === "result"
|
||||
? "xl:w-[460px] xl:min-w-[460px]"
|
||||
: "xl:w-[550px] xl:min-w-[460px]",
|
||||
);
|
||||
const leftPanelWidthClass = "xl:w-[550px] xl:min-w-[550px]";
|
||||
|
||||
/* ─── Constants ─── */
|
||||
const VP_LIMIT = 5;
|
||||
@@ -101,7 +101,7 @@ const lvColor = (id: string, maxLearnLv: number): string => {
|
||||
const lv = skillCfg(id).lv;
|
||||
if (lv < maxLearnLv) return "#32E432";
|
||||
if (lv > maxLearnLv) return "#58E0FC";
|
||||
return "currentColor";
|
||||
return "#F5F5F5";
|
||||
};
|
||||
|
||||
/* ─── Active / detail ─── */
|
||||
@@ -311,7 +311,7 @@ const resetAllSkills = () => {
|
||||
>
|
||||
<div class="min-w-max py-1" @click.self="clearActive">
|
||||
<template v-for="row in treeRows" :key="row.lv">
|
||||
<div class="flex items-center px-1 gap-[2px] mb-px">
|
||||
<div class="skill-tree-row flex items-center gap-[2px] px-1">
|
||||
<!-- Level label: 34×34 (matches original) -->
|
||||
<div
|
||||
class="w-[34px] h-[34px] shrink-0 flex items-center justify-center text-[11px] text-muted-foreground leading-[1.2] text-center"
|
||||
@@ -325,7 +325,8 @@ const resetAllSkills = () => {
|
||||
<div
|
||||
v-for="skill in row.extraSkills"
|
||||
:key="skill.id"
|
||||
class="skill-cell"
|
||||
class="skill-cell has-skill"
|
||||
:class="{ 'is-active': activeSkillId === skill.id.toString() }"
|
||||
:title="skill.name"
|
||||
@click.stop="chooseSkill(skill.id.toString())"
|
||||
>
|
||||
@@ -361,15 +362,18 @@ const resetAllSkills = () => {
|
||||
v-if="firstAwakeSkill"
|
||||
class="awake-bind-btn"
|
||||
:class="{ active: configStore.config.bindAwake === 50 }"
|
||||
:aria-pressed="configStore.config.bindAwake === 50"
|
||||
title="绑定一觉"
|
||||
@click.stop="setBindAwake(50)"
|
||||
>
|
||||
<img :src="getImageURL(firstAwakeSkill.icon)" :alt="firstAwakeSkill.name" />
|
||||
<span>一觉</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="secondAwakeSkill"
|
||||
class="awake-bind-btn"
|
||||
:class="{ active: configStore.config.bindAwake === 85 }"
|
||||
:aria-pressed="configStore.config.bindAwake === 85"
|
||||
title="绑定二觉"
|
||||
@click.stop="setBindAwake(85)"
|
||||
>
|
||||
@@ -377,6 +381,7 @@ const resetAllSkills = () => {
|
||||
:src="getImageURL(secondAwakeSkill.icon)"
|
||||
:alt="secondAwakeSkill.name"
|
||||
/>
|
||||
<span>二觉</span>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
@@ -384,18 +389,10 @@ const resetAllSkills = () => {
|
||||
class="action-row"
|
||||
@click.stop
|
||||
>
|
||||
<button class="skill-btn" @click.stop="resetLv(skill.id.toString())">
|
||||
«
|
||||
</button>
|
||||
<button class="skill-btn" @click.stop="stepLv(skill.id.toString(), -1)">
|
||||
‹
|
||||
</button>
|
||||
<button class="skill-btn" @click.stop="stepLv(skill.id.toString(), 1)">
|
||||
›
|
||||
</button>
|
||||
<button class="skill-btn" @click.stop="setMaxLv(skill.id.toString())">
|
||||
»
|
||||
</button>
|
||||
<button class="skill-btn" title="等级归零" @click.stop="resetLv(skill.id.toString())"><img :src="minImg" alt="等级归零" /></button>
|
||||
<button class="skill-btn" title="等级减一" @click.stop="stepLv(skill.id.toString(), -1)"><img :src="subImg" alt="等级减一" /></button>
|
||||
<button class="skill-btn" title="等级加一" @click.stop="stepLv(skill.id.toString(), 1)"><img :src="addImg" alt="等级加一" /></button>
|
||||
<button class="skill-btn" title="加至上限" @click.stop="setMaxLv(skill.id.toString())"><img :src="maxImg" alt="加至上限" /></button>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
@@ -413,7 +410,10 @@ const resetAllSkills = () => {
|
||||
v-for="(cell, colIdx) in row.cells"
|
||||
:key="colIdx"
|
||||
class="skill-cell"
|
||||
:class="cell ? 'cursor-pointer' : 'pointer-events-none'"
|
||||
:class="[
|
||||
cell ? 'cursor-pointer has-skill' : 'pointer-events-none',
|
||||
cell && activeSkillId === cell.id.toString() ? 'is-active' : '',
|
||||
]"
|
||||
:title="cell?.name"
|
||||
@click.stop="cell && chooseSkill(cell.id.toString())"
|
||||
>
|
||||
@@ -450,6 +450,7 @@ const resetAllSkills = () => {
|
||||
v-if="firstAwakeSkill"
|
||||
class="awake-bind-btn"
|
||||
:class="{ active: configStore.config.bindAwake === 50 }"
|
||||
:aria-pressed="configStore.config.bindAwake === 50"
|
||||
title="绑定一觉"
|
||||
@click.stop="setBindAwake(50)"
|
||||
>
|
||||
@@ -457,11 +458,13 @@ const resetAllSkills = () => {
|
||||
:src="getImageURL(firstAwakeSkill.icon)"
|
||||
:alt="firstAwakeSkill.name"
|
||||
/>
|
||||
<span>一觉</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="secondAwakeSkill"
|
||||
class="awake-bind-btn"
|
||||
:class="{ active: configStore.config.bindAwake === 85 }"
|
||||
:aria-pressed="configStore.config.bindAwake === 85"
|
||||
title="绑定二觉"
|
||||
@click.stop="setBindAwake(85)"
|
||||
>
|
||||
@@ -469,6 +472,7 @@ const resetAllSkills = () => {
|
||||
:src="getImageURL(secondAwakeSkill.icon)"
|
||||
:alt="secondAwakeSkill.name"
|
||||
/>
|
||||
<span>二觉</span>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
@@ -476,18 +480,10 @@ const resetAllSkills = () => {
|
||||
class="action-row"
|
||||
@click.stop
|
||||
>
|
||||
<button class="skill-btn" @click.stop="resetLv(cell.id.toString())">
|
||||
«
|
||||
</button>
|
||||
<button class="skill-btn" @click.stop="stepLv(cell.id.toString(), -1)">
|
||||
‹
|
||||
</button>
|
||||
<button class="skill-btn" @click.stop="stepLv(cell.id.toString(), 1)">
|
||||
›
|
||||
</button>
|
||||
<button class="skill-btn" @click.stop="setMaxLv(cell.id.toString())">
|
||||
»
|
||||
</button>
|
||||
<button class="skill-btn" title="等级归零" @click.stop="resetLv(cell.id.toString())"><img :src="minImg" alt="等级归零" /></button>
|
||||
<button class="skill-btn" title="等级减一" @click.stop="stepLv(cell.id.toString(), -1)"><img :src="subImg" alt="等级减一" /></button>
|
||||
<button class="skill-btn" title="等级加一" @click.stop="stepLv(cell.id.toString(), 1)"><img :src="addImg" alt="等级加一" /></button>
|
||||
<button class="skill-btn" title="加至上限" @click.stop="setMaxLv(cell.id.toString())"><img :src="maxImg" alt="加至上限" /></button>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
@@ -878,16 +874,33 @@ const resetAllSkills = () => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 35×55px skill cell — matches original dimensions */
|
||||
.skill-tree-row {
|
||||
min-height: 80px;
|
||||
border-bottom: 1px solid color-mix(in oklab, var(--border) 55%, transparent);
|
||||
background: color-mix(in oklab, var(--background) 96%, var(--muted));
|
||||
}
|
||||
.skill-tree-row:nth-child(odd) {
|
||||
background: color-mix(in oklab, var(--muted) 42%, var(--background));
|
||||
}
|
||||
|
||||
.skill-cell {
|
||||
position: relative;
|
||||
width: 35px;
|
||||
height: 55px;
|
||||
width: 42px;
|
||||
height: 76px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
.skill-cell.has-skill {
|
||||
background: url("./img/skill.png") center 8px / 34px 54px no-repeat;
|
||||
}
|
||||
.skill-cell.is-active {
|
||||
z-index: 6;
|
||||
background-image: url("./img/skillActive.png");
|
||||
background-position: center top;
|
||||
background-size: 88px 78px;
|
||||
}
|
||||
|
||||
/* Top 14px: badge row */
|
||||
.badge-row {
|
||||
@@ -909,7 +922,7 @@ const resetAllSkills = () => {
|
||||
/* 28×28px icon at y=14px */
|
||||
.skill-icon {
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
top: 13px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
object-fit: cover;
|
||||
@@ -918,54 +931,68 @@ const resetAllSkills = () => {
|
||||
|
||||
.awake-bind-panel {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
top: 7px;
|
||||
left: 50%;
|
||||
z-index: 8;
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
width: 148px;
|
||||
height: 58px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
transform: translateX(-50%);
|
||||
border-radius: 4px;
|
||||
background: color-mix(in oklab, var(--background) 88%, transparent);
|
||||
padding: 2px;
|
||||
box-shadow: 0 0 0 1px color-mix(in oklab, var(--border) 80%, transparent);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--popover);
|
||||
padding: 4px;
|
||||
color: var(--popover-foreground);
|
||||
box-shadow: 0 8px 24px color-mix(in oklab, var(--foreground) 18%, transparent);
|
||||
}
|
||||
|
||||
.awake-bind-btn {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: 1px solid color-mix(in oklab, var(--border) 70%, transparent);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
opacity: 0.62;
|
||||
display: flex;
|
||||
width: 66px;
|
||||
height: 48px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--background);
|
||||
color: var(--muted-foreground);
|
||||
font-size: 10px;
|
||||
opacity: 0.78;
|
||||
}
|
||||
|
||||
.awake-bind-btn.active {
|
||||
border-color: var(--primary);
|
||||
background: var(--accent);
|
||||
color: var(--accent-foreground);
|
||||
opacity: 1;
|
||||
box-shadow: 0 0 0 1px var(--primary);
|
||||
box-shadow: 0 0 0 1px color-mix(in oklab, var(--primary) 55%, transparent);
|
||||
}
|
||||
|
||||
.awake-bind-btn img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
flex: 0 0 auto;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* 4 action buttons at the bottom */
|
||||
.action-row {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
bottom: 3px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1px;
|
||||
}
|
||||
.skill-btn {
|
||||
width: 19px;
|
||||
height: 15px;
|
||||
font-size: 11px;
|
||||
background-color: color-mix(in oklab, var(--background) 90%, transparent);
|
||||
border: 1px solid color-mix(in oklab, var(--border) 70%, transparent);
|
||||
border-radius: 2px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -973,6 +1000,11 @@ const resetAllSkills = () => {
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
.skill-btn img {
|
||||
display: block;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
.skill-btn:hover {
|
||||
background-color: var(--muted);
|
||||
}
|
||||
@@ -980,7 +1012,7 @@ const resetAllSkills = () => {
|
||||
/* Level number */
|
||||
.lv-num {
|
||||
position: absolute;
|
||||
bottom: 2px;
|
||||
bottom: 18px;
|
||||
font-size: 10px;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
|
||||
@@ -13,11 +13,20 @@ import {
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { getImageURL } from "@/utils";
|
||||
const router = useRouter();
|
||||
const adventures = ref<IAdventureInfo[]>([]);
|
||||
const loading = ref(false);
|
||||
const helperId = ref("");
|
||||
const versionPickerOpen = ref(false);
|
||||
const selectedAlter = ref<IAlterInfo>();
|
||||
|
||||
const loadAdventures = async () => {
|
||||
loading.value = true;
|
||||
@@ -42,10 +51,22 @@ const openAlter = (alter: IAlterInfo) => {
|
||||
if (!alter.open) {
|
||||
return;
|
||||
}
|
||||
if (alter.options?.length) {
|
||||
selectedAlter.value = alter;
|
||||
versionPickerOpen.value = true;
|
||||
return;
|
||||
}
|
||||
const target = alter.value || alter.name;
|
||||
router.push(`/dcalc/character/${target}/equipment`);
|
||||
};
|
||||
|
||||
const openAlterOption = (value?: string, name?: string) => {
|
||||
const target = value || name;
|
||||
if (!target) return;
|
||||
versionPickerOpen.value = false;
|
||||
router.push(`/dcalc/character/${target}/equipment`);
|
||||
};
|
||||
|
||||
const gotoHelper = () => {
|
||||
if (!helperId.value.trim()) {
|
||||
router.push("/dcalc/dnfhelper");
|
||||
@@ -126,5 +147,28 @@ onMounted(() => {
|
||||
赣ICP备2023003492号
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<Sheet v-model:open="versionPickerOpen">
|
||||
<SheetContent class="w-full sm:max-w-sm">
|
||||
<SheetHeader>
|
||||
<SheetTitle>选择职业版本</SheetTitle>
|
||||
<SheetDescription>
|
||||
{{ selectedAlter?.title }} 提供多个计算入口,请选择需要使用的版本。
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div class="flex flex-col gap-2 px-4">
|
||||
<Button
|
||||
v-for="option in selectedAlter?.options || []"
|
||||
:key="option.value || option.name"
|
||||
variant="outline"
|
||||
class="h-auto justify-between py-3"
|
||||
@click="openAlterOption(option.value, option.name)"
|
||||
>
|
||||
<span>{{ option.title }}</span>
|
||||
<ArrowRight data-icon="inline-end" />
|
||||
</Button>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
@@ -34,6 +34,7 @@ export interface IConfigEquip {
|
||||
export interface IConfigOath {
|
||||
id: string;
|
||||
adaptation: number;
|
||||
upgrade: number[];
|
||||
}
|
||||
|
||||
export interface IConfigEmblem {
|
||||
@@ -107,6 +108,7 @@ const defaultAvatarConfig = {
|
||||
const createDefaultOathConfig = (): IConfigOath => ({
|
||||
id: "",
|
||||
adaptation: 0,
|
||||
upgrade: [0, 0, 0, 0],
|
||||
});
|
||||
|
||||
const defaultOathConfig = createDefaultOathConfig();
|
||||
@@ -118,8 +120,7 @@ const createDefaultEmblemConfig = (): IConfigEmblem => ({
|
||||
|
||||
const defaultEmblemConfig = createDefaultEmblemConfig();
|
||||
|
||||
export const useConfigStore = defineStore("configStore", () => {
|
||||
const config = ref<IConfig>({
|
||||
const createDefaultConfig = (): IConfig => ({
|
||||
skills: {},
|
||||
equips: {},
|
||||
oaths: {},
|
||||
@@ -133,7 +134,10 @@ export const useConfigStore = defineStore("configStore", () => {
|
||||
options: {},
|
||||
avatarLink: "",
|
||||
emblems: {},
|
||||
});
|
||||
});
|
||||
|
||||
export const useConfigStore = defineStore("configStore", () => {
|
||||
const config = ref<IConfig>(createDefaultConfig());
|
||||
|
||||
const skillCountConfig = ref<IResultSkillCount[]>([]);
|
||||
const changeSourceLogs = ref<IChangeSourceLog[]>([]);
|
||||
@@ -142,32 +146,22 @@ export const useConfigStore = defineStore("configStore", () => {
|
||||
const infoStore = useInfoStore();
|
||||
const alter = infoStore.infos?.alter;
|
||||
|
||||
// A character with no persisted data must start clean instead of inheriting the last role's
|
||||
// in-memory configuration or skill-count overrides.
|
||||
config.value = createDefaultConfig();
|
||||
skillCountConfig.value = [];
|
||||
|
||||
if (alter) {
|
||||
const localConfig = localStorage.getItem(`dcalc/${alter}/config`);
|
||||
const localSkillConfig = localStorage.getItem(`dcalc/${alter}/config/skillCount`);
|
||||
if (localConfig) {
|
||||
config.value = JSON.parse(localConfig);
|
||||
try {
|
||||
if (localConfig) config.value = JSON.parse(localConfig) as IConfig;
|
||||
if (localSkillConfig) skillCountConfig.value = JSON.parse(localSkillConfig) as IResultSkillCount[];
|
||||
} catch {
|
||||
// Ignore malformed legacy browser data and continue with the current schema defaults.
|
||||
config.value = createDefaultConfig();
|
||||
skillCountConfig.value = [];
|
||||
}
|
||||
if (localSkillConfig) {
|
||||
skillCountConfig.value = JSON.parse(localSkillConfig);
|
||||
}
|
||||
}
|
||||
if (!config.value) {
|
||||
config.value = {
|
||||
skills: {},
|
||||
equips: {},
|
||||
oaths: {},
|
||||
oathSkill: "1",
|
||||
jades: {},
|
||||
avatar: {},
|
||||
sundry: {},
|
||||
bindAwake: 50,
|
||||
DSB: false,
|
||||
BUFF: false,
|
||||
options: {},
|
||||
avatarLink: "",
|
||||
emblems: {},
|
||||
};
|
||||
}
|
||||
if (!config.value?.equips) config.value.equips = {};
|
||||
if (!config.value?.skills) config.value.skills = {};
|
||||
@@ -205,6 +199,8 @@ export const useConfigStore = defineStore("configStore", () => {
|
||||
config.value &&
|
||||
!config.value?.oaths.hasOwnProperty(p) &&
|
||||
(config.value.oaths[p.toString()] = createDefaultOathConfig());
|
||||
const oath = config.value.oaths[p.toString()];
|
||||
if (oath && !Array.isArray(oath.upgrade)) oath.upgrade = [0, 0, 0, 0];
|
||||
});
|
||||
|
||||
infoStore.options.forEach((option) => {
|
||||
@@ -310,6 +306,7 @@ export const useConfigStore = defineStore("configStore", () => {
|
||||
chooseEqu,
|
||||
skillCountConfig,
|
||||
defaultEqusConfig,
|
||||
createDefaultConfig,
|
||||
defaultAvatarConfig,
|
||||
defaultOathConfig,
|
||||
createDefaultOathConfig,
|
||||
|
||||
@@ -163,9 +163,17 @@ export const useInfoStore = defineStore("infoStore", () => {
|
||||
|
||||
const createCharacter = async (alter: string, equVersion?: string) => {
|
||||
const time = new Date().getTime();
|
||||
alter_token.value = btoa(JSON.stringify({ alter, equVersion, time }));
|
||||
infos.value = await api.characterInfo();
|
||||
const token = btoa(JSON.stringify({ alter, equVersion, time }));
|
||||
alter_token.value = token;
|
||||
const nextInfos = await api.characterInfo();
|
||||
|
||||
// Navigation can start another character request before this one returns. In that case the
|
||||
// latest Alter-Token owns the store and this response must not overwrite it.
|
||||
if (alter_token.value !== token) return false;
|
||||
|
||||
infos.value = nextInfos;
|
||||
useConfigStore().loadConfig();
|
||||
return true;
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
+163
-23
@@ -2,36 +2,176 @@ const http = require('http');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const hostname = '127.0.0.1';
|
||||
const port = 3000;
|
||||
const hostname = process.env.IMAGE_HOST || '127.0.0.1';
|
||||
const port = Number.parseInt(process.env.IMAGE_PORT || '3000', 10);
|
||||
const directoryPath = path.resolve(__dirname, '../images');
|
||||
const maxCacheBytes = Number.parseInt(process.env.IMAGE_CACHE_MAX_MB || '128', 10) * 1024 * 1024;
|
||||
const maxCacheItemBytes =
|
||||
Number.parseInt(process.env.IMAGE_CACHE_ITEM_MAX_MB || '4', 10) * 1024 * 1024;
|
||||
const browserCacheSeconds = Number.parseInt(
|
||||
process.env.IMAGE_BROWSER_CACHE_SECONDS || '3600',
|
||||
10,
|
||||
);
|
||||
|
||||
const ImageServer = http.createServer((req, res) => {
|
||||
// 图片存储的文件夹路径
|
||||
const directoryPath = path.join(__dirname, '../images');
|
||||
// 请求的图片文件名
|
||||
const filePath = path.join(directoryPath, decodeURIComponent(req.url));
|
||||
// 确保文件存在且是文件,不是目录
|
||||
fs.access(filePath, fs.constants.F_OK | fs.constants.R_OK, (err) => {
|
||||
if (err) {
|
||||
res.writeHead(404, 'Not Found');
|
||||
res.end('Oops. Image not found!');
|
||||
} else {
|
||||
// 读取图片文件并发送响应
|
||||
fs.readFile(filePath, (err, data) => {
|
||||
if (err) {
|
||||
res.writeHead(500, 'Server Error');
|
||||
res.end('Oops. There was a server error!');
|
||||
} else {
|
||||
res.writeHead(200, { 'Content-Type': 'image/jpeg' });
|
||||
res.end(data);
|
||||
const mimeTypes = {
|
||||
'.avif': 'image/avif',
|
||||
'.bmp': 'image/bmp',
|
||||
'.gif': 'image/gif',
|
||||
'.ico': 'image/x-icon',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.png': 'image/png',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.webp': 'image/webp',
|
||||
};
|
||||
|
||||
/** @type {Map<string, { data: Buffer, contentType: string, etag: string, modified: string }>} */
|
||||
const memoryCache = new Map();
|
||||
/** @type {Map<string, Promise<{ data: Buffer, contentType: string, etag: string, modified: string }>>} */
|
||||
const pendingReads = new Map();
|
||||
let cachedBytes = 0;
|
||||
|
||||
function removeCacheEntry(filePath) {
|
||||
const entry = memoryCache.get(filePath);
|
||||
if (!entry) return;
|
||||
cachedBytes -= entry.data.byteLength;
|
||||
memoryCache.delete(filePath);
|
||||
}
|
||||
|
||||
function cacheEntry(filePath, entry) {
|
||||
if (entry.data.byteLength > maxCacheItemBytes || maxCacheBytes <= 0) return;
|
||||
|
||||
removeCacheEntry(filePath);
|
||||
while (cachedBytes + entry.data.byteLength > maxCacheBytes && memoryCache.size) {
|
||||
const oldestPath = memoryCache.keys().next().value;
|
||||
if (!oldestPath) break;
|
||||
removeCacheEntry(oldestPath);
|
||||
}
|
||||
});
|
||||
|
||||
memoryCache.set(filePath, entry);
|
||||
cachedBytes += entry.data.byteLength;
|
||||
}
|
||||
|
||||
function getCachedEntry(filePath) {
|
||||
const entry = memoryCache.get(filePath);
|
||||
if (!entry) return undefined;
|
||||
memoryCache.delete(filePath);
|
||||
memoryCache.set(filePath, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
async function readImage(filePath) {
|
||||
const cached = getCachedEntry(filePath);
|
||||
if (cached) return cached;
|
||||
|
||||
const pending = pendingReads.get(filePath);
|
||||
if (pending) return pending;
|
||||
|
||||
const readPromise = (async () => {
|
||||
const handle = await fs.promises.open(filePath, 'r');
|
||||
try {
|
||||
const stat = await handle.stat();
|
||||
if (!stat.isFile()) {
|
||||
const error = new Error('Not a file');
|
||||
error.code = 'ENOENT';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const data = await handle.readFile();
|
||||
const entry = {
|
||||
data,
|
||||
contentType: mimeTypes[path.extname(filePath).toLowerCase()] || 'application/octet-stream',
|
||||
etag: `W/"${stat.size.toString(16)}-${Math.trunc(stat.mtimeMs).toString(16)}"`,
|
||||
modified: stat.mtime.toUTCString(),
|
||||
};
|
||||
cacheEntry(filePath, entry);
|
||||
return entry;
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
})();
|
||||
|
||||
pendingReads.set(filePath, readPromise);
|
||||
try {
|
||||
return await readPromise;
|
||||
} finally {
|
||||
pendingReads.delete(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveImagePath(requestUrl) {
|
||||
let pathname;
|
||||
try {
|
||||
pathname = decodeURIComponent(new URL(requestUrl, 'http://localhost').pathname);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const relativePath = pathname.replace(/^[/\\]+/, '');
|
||||
const filePath = path.resolve(directoryPath, relativePath);
|
||||
const relativeToRoot = path.relative(directoryPath, filePath);
|
||||
if (relativeToRoot.startsWith('..') || path.isAbsolute(relativeToRoot)) return undefined;
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function sendText(res, statusCode, message) {
|
||||
res.writeHead(statusCode, {
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
'Content-Length': Buffer.byteLength(message),
|
||||
});
|
||||
res.end(message);
|
||||
}
|
||||
|
||||
const ImageServer = http.createServer(async (req, res) => {
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
||||
res.setHeader('Allow', 'GET, HEAD');
|
||||
sendText(res, 405, 'Method not allowed');
|
||||
return;
|
||||
}
|
||||
|
||||
const filePath = resolveImagePath(req.url || '/');
|
||||
if (!filePath) {
|
||||
sendText(res, 400, 'Invalid image path');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const entry = await readImage(filePath);
|
||||
const headers = {
|
||||
'Content-Type': entry.contentType,
|
||||
'Content-Length': entry.data.byteLength,
|
||||
'Cache-Control': `public, max-age=${browserCacheSeconds}`,
|
||||
ETag: entry.etag,
|
||||
'Last-Modified': entry.modified,
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
};
|
||||
|
||||
if (req.headers['if-none-match'] === entry.etag) {
|
||||
res.writeHead(304, headers);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(200, headers);
|
||||
res.end(req.method === 'HEAD' ? undefined : entry.data);
|
||||
} catch (error) {
|
||||
if (error && (error.code === 'ENOENT' || error.code === 'EISDIR')) {
|
||||
sendText(res, 404, 'Image not found');
|
||||
return;
|
||||
}
|
||||
console.error(`Failed to serve ${filePath}:`, error);
|
||||
sendText(res, 500, 'Image server error');
|
||||
}
|
||||
});
|
||||
|
||||
ImageServer.keepAliveTimeout = 65_000;
|
||||
ImageServer.headersTimeout = 66_000;
|
||||
|
||||
ImageServer.listen(port, hostname, () => {
|
||||
console.log(`Server running at http://${hostname}:${port}/`);
|
||||
console.log(`Image server running at http://${hostname}:${port}/`);
|
||||
console.log(
|
||||
`Memory cache: ${Math.round(maxCacheBytes / 1024 / 1024)} MB, browser cache: ${browserCacheSeconds}s`,
|
||||
);
|
||||
console.log('Press CTRL+C to stop the server');
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user