This commit is contained in:
2026-05-11 00:32:03 +08:00
parent 2535bec2ed
commit 10022ae048
32 changed files with 2450 additions and 203 deletions
+22
View File
@@ -2,6 +2,28 @@
This template should help get you started developing with Vue 3 in Vite.
## Migration Status (frontend -> frontendNew)
- UI library baseline: shadcn-vue + Tailwind v4
- Routing migrated:
- `/home`
- `/dcalc/dnfhelper`
- `/character/:alter/(equipment|skill|forge)`
- Migrated pages:
- `src/pages/Home/Index.vue`
- `src/pages/Dcalc/DnfHelper/*`
- `src/pages/Character/Index.vue`
- `src/pages/Character/EquipmentDictionary/Index.vue`
- `src/pages/Character/Skill/index.vue`
- `src/pages/Character/Forge/index.vue`
## Next Migration Steps
- Migrate `frontend/src/pages/Character/Equipment/components/*` into shadcn-vue filter + picker panels.
- Migrate `frontend/src/pages/Character/Result/*` with Card/Item-based result layout and baseline compare blocks.
- Migrate `frontend/src/pages/Character/Config/import*.vue` into dialog/sheet flows in `frontendNew`.
- Replace placeholder summary blocks in Forge with interactive editors for avatar, emblem, jade, oath.
## Recommended IDE Setup
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
+5 -5
View File
@@ -1,10 +1,10 @@
<!DOCTYPE html>
<!doctype html>
<html lang="">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vite App</title>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>DNF Tools</title>
</head>
<body>
<div id="app"></div>
+2
View File
@@ -21,6 +21,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-vue-next": "^1.0.0",
"pako": "^2.1.0",
"pinia": "^3.0.4",
"reka-ui": "^2.9.6",
"shadcn-vue": "^2.6.2",
@@ -34,6 +35,7 @@
"devDependencies": {
"@tsconfig/node24": "^24.0.4",
"@types/node": "^24.12.2",
"@types/pako": "^2.0.4",
"@vitejs/plugin-vue": "^6.0.6",
"@vitejs/plugin-vue-jsx": "^5.1.5",
"@vue/eslint-config-typescript": "^14.7.0",
+16 -30
View File
@@ -26,6 +26,9 @@ importers:
lucide-vue-next:
specifier: ^1.0.0
version: 1.0.0(vue@3.5.33(typescript@6.0.3))
pako:
specifier: ^2.1.0
version: 2.1.0
pinia:
specifier: ^3.0.4
version: 3.0.4(typescript@6.0.3)(vue@3.5.33(typescript@6.0.3))
@@ -60,6 +63,9 @@ importers:
'@types/node':
specifier: ^24.12.2
version: 24.12.2
'@types/pako':
specifier: ^2.0.4
version: 2.0.4
'@vitejs/plugin-vue':
specifier: ^6.0.6
version: 6.0.6(vite@8.0.10(@types/node@24.12.2)(jiti@2.6.1)(stylus@0.57.0)(yaml@2.8.4))(vue@3.5.33(typescript@6.0.3))
@@ -474,56 +480,48 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-arm64-musl@0.45.0':
resolution: {integrity: sha512-XQKXZIKYJC3GQJ8FnD3iMntpw69Wd9kDDK/Xt79p6xnFYlGGxSNv2vIBvRTDg5CKByWFWWZLCRDOXoP/m6YN4g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxfmt/binding-linux-ppc64-gnu@0.45.0':
resolution: {integrity: sha512-+g5RiG+xOkdrCWkKodv407nTvMq4vYM18Uox2MhZBm/YoqFxxJpWKsloskFFG5NU13HGPw1wzYjjOVcyd9moCA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-riscv64-gnu@0.45.0':
resolution: {integrity: sha512-V7dXKoSyEbWAkkSF4JJNtF+NJZDmJoSarSoP30WCsB3X636Rehd3CvxBj49FIJxEBFWhvcUjGSHVeU8Erck1bQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-riscv64-musl@0.45.0':
resolution: {integrity: sha512-Vdelft1sAEYojVGgcODEFXSWYQYlIvoyIGWebKCuUibd1tvS1TjTx413xG2ZLuHpYj45CkN/ztMLMX6jrgqpgg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxfmt/binding-linux-s390x-gnu@0.45.0':
resolution: {integrity: sha512-RR7xKgNpqwENnK0aYCGYg0JycY2n93J0reNjHyes+I9Gq52dH95x+CBlnlAQHCPfz6FGnKA9HirgUl14WO6o7w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-x64-gnu@0.45.0':
resolution: {integrity: sha512-U/QQ0+BQNSHxjuXR/utvXnQ50Vu5kUuqEomZvQ1/3mhgbBiMc2WU9q5kZ5WwLp3gnFIx9ibkveoRSe2EZubkqg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-x64-musl@0.45.0':
resolution: {integrity: sha512-o5TLOUCF0RWQjsIS06yVC+kFgp092/yLe6qBGSUvtnmTVw9gxjpdQSXc3VN5Cnive4K11HNstEZF8ROKHfDFSw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxfmt/binding-openharmony-arm64@0.45.0':
resolution: {integrity: sha512-RnGcV3HgPuOjsGx/k9oyRNKmOp+NBLGzZTdPDYbc19r7NGeYPplnUU/BfU35bX2Y/O4ejvHxcfkvW2WoYL/gsg==}
@@ -596,56 +594,48 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-arm64-musl@1.60.0':
resolution: {integrity: sha512-eDYDXZGhQAXyn6GwtwiX/qcLS0HlOLPJ/+iiIY8RYr+3P8oKBmgKxADLlniL6FtWfE7pPk7IGN9/xvDEvDvFeg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxlint/binding-linux-ppc64-gnu@1.60.0':
resolution: {integrity: sha512-nxehly5XYBHUWI9VJX1bqCf9j/B43DaK/aS/T1fcxCpX3PA4Rm9BB54nPD1CKayT8xg6REN1ao+01hSRNgy8OA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-riscv64-gnu@1.60.0':
resolution: {integrity: sha512-j1qf/NaUfOWQutjeoooNG1Q0zsK0XGmSu1uDLq3cctquRF3j7t9Hxqf/76ehCc5GEUAanth2W4Fa+XT1RFg/nw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-riscv64-musl@1.60.0':
resolution: {integrity: sha512-YELKPRefQ/q/h3RUmeRfPCUhh2wBvgV1RyZ/F9M9u8cDyXsQW2ojv1DeWQTt466yczDITjZnIOg/s05pk7Ve2A==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxlint/binding-linux-s390x-gnu@1.60.0':
resolution: {integrity: sha512-JkO3C6Gki7Y6h/MiIkFKvHFOz98/YWvQ4WYbK9DLXACMP2rjULzkeGyAzorJE5S1dzLQGFgeqvN779kSFwoV1g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-x64-gnu@1.60.0':
resolution: {integrity: sha512-XjKHdFVCpZZZSWBCKyyqCq65s2AKXykMXkjLoKYODrD+f5toLhlwsMESscu8FbgnJQ4Y/dpR/zdazsahmgBJIA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-x64-musl@1.60.0':
resolution: {integrity: sha512-js29ZWIuPhNWzY8NC7KoffEMEeWG105vbmm+8EOJsC+T/jHBiKIJEUF78+F/IrgEWMMP9N0kRND4Pp75+xAhKg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxlint/binding-openharmony-arm64@1.60.0':
resolution: {integrity: sha512-H+PUITKHk04stFpWj3x3Kg08Afp/bcXSBi0EhasR5a0Vw7StXHTzdl655PUI0fB4qdh2Wsu6Dsi+3ACxPoyQnA==}
@@ -709,42 +699,36 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-arm64-musl@1.0.0-rc.17':
resolution: {integrity: sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17':
resolution: {integrity: sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17':
resolution: {integrity: sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-gnu@1.0.0-rc.17':
resolution: {integrity: sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-musl@1.0.0-rc.17':
resolution: {integrity: sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@rolldown/binding-openharmony-arm64@1.0.0-rc.17':
resolution: {integrity: sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==}
@@ -819,28 +803,24 @@ packages:
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.2.4':
resolution: {integrity: sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.2.4':
resolution: {integrity: sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-x64-musl@4.2.4':
resolution: {integrity: sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-wasm32-wasi@4.2.4':
resolution: {integrity: sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==}
@@ -904,6 +884,9 @@ packages:
'@types/node@24.12.2':
resolution: {integrity: sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==}
'@types/pako@2.0.4':
resolution: {integrity: sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==}
'@types/web-bluetooth@0.0.21':
resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==}
@@ -2051,28 +2034,24 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.32.0:
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.32.0:
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.32.0:
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.32.0:
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
@@ -2336,6 +2315,9 @@ packages:
package-json-from-dist@1.0.1:
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
pako@2.1.0:
resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==}
parseurl@1.3.3:
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
engines: {node: '>= 0.8'}
@@ -3702,6 +3684,8 @@ snapshots:
dependencies:
undici-types: 7.16.0
'@types/pako@2.0.4': {}
'@types/web-bluetooth@0.0.21': {}
'@typescript-eslint/eslint-plugin@8.59.1(@typescript-eslint/parser@8.59.1(eslint@10.3.0(jiti@2.6.1))(typescript@6.0.3))(eslint@10.3.0(jiti@2.6.1))(typescript@6.0.3)':
@@ -5188,6 +5172,8 @@ snapshots:
package-json-from-dist@1.0.1: {}
pako@2.1.0: {}
parseurl@1.3.3: {}
path-browserify@1.0.1: {}
Regular → Executable
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 89 KiB

+3
View File
@@ -0,0 +1,3 @@
<template>
<router-view />
</template>
+1 -1
View File
@@ -1,7 +1,7 @@
import type { AxiosInstance, AxiosResponse } from "axios";
import axios from "axios";
import { h } from "vue";
// import { gzip, ungzip } from 'pako'
import { ungzip } from "pako";
import { useInfoStore } from "@/stores";
// import { useDialog } from '@/components/hooks/dialog'
// import { useAppStore, useConfigStore } from '@/store'
+1
View File
@@ -33,6 +33,7 @@ export default defineRequest((request) => {
},
async getHelperList(id: string) {
const res = await request.get<IHelperInfo>(`/dnfhelper/${id}/list`);
await new Promise((resolve) => setTimeout(resolve, 10000));
return res.data;
},
};
+45
View File
@@ -0,0 +1,45 @@
<script setup lang="ts">
import { useRouter, useRoute } from "vue-router";
import { computed } from "vue";
import { Button } from "@/components/ui/button";
const navs = [
{ name: "首页", path: "/dcalc/home" },
{ name: "助手导入", path: "/dcalc/dnfhelper" },
{ name: "装备词典", path: "/dcalc/character/swordman_male/equipment" },
{ name: "技能面板", path: "/dcalc/character/swordman_male/skill" },
];
const router = useRouter();
const route = useRoute();
const activePath = computed(() => {
// 只匹配主路径
const found = navs.find((n) => route.path.startsWith(n.path));
return found ? found.path : "";
});
function go(path: string) {
if (route.path !== path) router.push(path);
}
</script>
<template>
<nav class="w-full flex items-center gap-2 px-4 py-2 border-b bg-background/80 sticky top-0 z-30">
<template v-for="nav in navs" :key="nav.path">
<Button
variant="ghost"
:class="activePath === nav.path ? 'text-primary font-bold' : 'text-muted-foreground'"
@click="go(nav.path)"
>
{{ nav.name }}
</Button>
</template>
</nav>
</template>
<style scoped>
nav {
backdrop-filter: blur(6px);
}
</style>
+9 -73
View File
@@ -5,7 +5,6 @@ import type { SidebarProps } from "@/components/ui/sidebar";
import {
BookOpen,
Bot,
Command,
// Frame,
LifeBuoy,
// Map,
@@ -58,78 +57,19 @@ const data = {
navMain: [
{
title: "计算器",
url: "#",
url: "/dcalc/dnfhelper",
icon: Bot,
isActive: true,
onClick: () => {
console.log("Playground clicked");
},
},
{
title: "名望数据",
url: "#",
icon: SquareTerminal,
// items: [
// {
// title: "Genesis",
// url: "#",
// },
// {
// title: "Explorer",
// url: "#",
// },
// {
// title: "Quantum",
// url: "#",
// },
// ],
},
{
title: "职业数据",
url: "#",
title: "技能数据",
url: "/dcalc/character/swordman_male/skill",
icon: BookOpen,
// items: [
// {
// title: "Introduction",
// url: "#",
// },
// {
// title: "Get Started",
// url: "#",
// },
// {
// title: "Tutorials",
// url: "#",
// },
// {
// title: "Changelog",
// url: "#",
// },
// ],
},
// {
// title: "Settings",
// url: "#",
// icon: Settings2,
// items: [
// {
// title: "General",
// url: "#",
// },
// {
// title: "Team",
// url: "#",
// },
// {
// title: "Billing",
// url: "#",
// },
// {
// title: "Limits",
// url: "#",
// },
// ],
// },
{
title: "装备词典",
url: "/dcalc/character/swordman_male/equipment",
icon: SquareTerminal,
},
],
navSecondary: [
{
@@ -171,11 +111,7 @@ const data = {
<div class="flex items-center gap-2 group-data-[collapsible=icon]:justify-center">
<SidebarMenuButton size="lg" as-child class="group-data-[collapsible=icon]:hidden">
<a href="#">
<div
class="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg"
>
<Command class="size-4" />
</div>
<img src="/favicon.ico" alt="DNF Tools" class="size-10 rounded-sm object-cover" />
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-medium">DNF Tools</span>
<span class="truncate text-xs">DNF工具集</span>
@@ -0,0 +1,114 @@
<script setup lang="ts">
import { useRoute, useRouter } from "vue-router";
import { computed, ref, watch } from "vue";
import Breadcrumb from "@/components/ui/breadcrumb/Breadcrumb.vue";
import BreadcrumbList from "@/components/ui/breadcrumb/BreadcrumbList.vue";
import BreadcrumbItem from "@/components/ui/breadcrumb/BreadcrumbItem.vue";
import BreadcrumbLink from "@/components/ui/breadcrumb/BreadcrumbLink.vue";
import BreadcrumbSeparator from "@/components/ui/breadcrumb/BreadcrumbSeparator.vue";
import BreadcrumbEllipsis from "@/components/ui/breadcrumb/BreadcrumbEllipsis.vue";
import BreadcrumbPage from "@/components/ui/breadcrumb/BreadcrumbPage.vue";
const router = useRouter();
const route = useRoute();
type PathCrumb = { label: string; to: string };
type EllipsisCrumb = { type: "ellipsis" };
type DisplayCrumb = PathCrumb | EllipsisCrumb;
const routeTrail = ref<PathCrumb[]>([]);
function labelFromPath(path: string): string {
if (path === "/dcalc" || path === "/dcalc/home") return "首页";
if (path.startsWith("/dcalc/dnfhelper")) return "助手导入";
if (path.endsWith("/equipment")) return "装备词典";
if (path.endsWith("/skill")) return "技能面板";
if (path.endsWith("/forge")) return "打造";
if (path.includes("/character/")) {
const alter = path.split("/")[3];
return alter ? `角色 ${alter}` : "角色";
}
return "页面";
}
watch(
() => route.fullPath,
(fullPath) => {
if (!fullPath.startsWith("/dcalc")) {
routeTrail.value = [];
return;
}
const existedIndex = routeTrail.value.findIndex((item) => item.to === fullPath);
if (existedIndex >= 0) {
routeTrail.value = routeTrail.value.slice(0, existedIndex + 1);
return;
}
routeTrail.value = [
...routeTrail.value,
{
label: labelFromPath(fullPath),
to: fullPath,
},
];
},
{ immediate: true },
);
const crumbs = computed(() => routeTrail.value);
const maxCrumbs = 4; // 超过则折叠
const showEllipsis = computed(() => crumbs.value.length > maxCrumbs);
const visibleCrumbs = computed(() => {
if (!showEllipsis.value) return crumbs.value;
// 只显示第一个、...、最后两个
return [crumbs.value[0], { type: "ellipsis" }, ...crumbs.value.slice(-2)] as DisplayCrumb[];
});
function go(to: string) {
if (to && typeof to === "string" && to !== route.fullPath) router.push(to);
}
</script>
<template>
<header
v-if="crumbs.length"
class="flex h-(--header-height) shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-(--header-height)"
>
<div class="max-w-7xl mx-auto px-6 py-3 flex items-center min-h-[56px]">
<Breadcrumb>
<BreadcrumbList>
<template
v-for="(crumb, idx) in visibleCrumbs"
:key="'type' in crumb ? 'ellipsis' + idx : crumb.to"
>
<BreadcrumbSeparator v-if="idx !== 0" />
<template v-if="'type' in crumb">
<BreadcrumbEllipsis />
</template>
<template v-else>
<BreadcrumbItem>
<template v-if="idx !== visibleCrumbs.length - 1">
<BreadcrumbLink
as="a"
@click.prevent="go(crumb.to)"
class="cursor-pointer font-normal text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 transition-colors"
>{{ crumb.label }}</BreadcrumbLink
>
</template>
<template v-else>
<BreadcrumbPage class="font-semibold text-gray-900 dark:text-gray-100">{{
crumb.label
}}</BreadcrumbPage>
</template>
</BreadcrumbItem>
</template>
</template>
</BreadcrumbList>
</Breadcrumb>
</div>
</header>
</template>
<style scoped></style>
+24 -2
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import type { LucideIcon } from "lucide-vue-next";
import { ChevronRight } from "lucide-vue-next";
import { RouterLink } from "vue-router";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
@@ -30,6 +31,8 @@ defineProps<{
}[];
}[];
}>();
const isExternal = (url: string) => /^https?:\/\//.test(url);
</script>
<template>
@@ -39,10 +42,20 @@ defineProps<{
<Collapsible v-for="item in items" :key="item.title" as-child :default-open="item.isActive">
<SidebarMenuItem>
<SidebarMenuButton as-child :tooltip="item.title">
<a :href="item.url" @click="item.onClick">
<a
v-if="isExternal(item.url)"
:href="item.url"
target="_blank"
rel="noopener noreferrer"
@click="item.onClick"
>
<component :is="item.icon" />
<span>{{ item.title }}</span>
</a>
<RouterLink v-else :to="item.url" @click="item.onClick">
<component :is="item.icon" />
<span>{{ item.title }}</span>
</RouterLink>
</SidebarMenuButton>
<template v-if="item.items?.length">
<CollapsibleTrigger as-child>
@@ -55,9 +68,18 @@ defineProps<{
<SidebarMenuSub>
<SidebarMenuSubItem v-for="subItem in item.items" :key="subItem.title">
<SidebarMenuSubButton as-child>
<a :href="subItem.url" @click="subItem.onClick">
<a
v-if="isExternal(subItem.url)"
:href="subItem.url"
target="_blank"
rel="noopener noreferrer"
@click="subItem.onClick"
>
<span>{{ subItem.title }}</span>
</a>
<RouterLink v-else :to="subItem.url" @click="subItem.onClick">
<span>{{ subItem.title }}</span>
</RouterLink>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
</SidebarMenuSub>
+1 -1
View File
@@ -19,7 +19,7 @@ const props = withDefaults(
:data-size="size"
:class="
cn(
'ring-foreground/10 bg-card text-card-foreground gap-4 overflow-hidden rounded-xl py-4 text-sm ring-1 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl group/card flex flex-col transition-all duration-200 hover:shadow-lg hover:ring-foreground/20',
'ring-foreground/10 bg-card text-card-foreground gap-4 overflow-hidden rounded-xl py-4 text-sm ring-1 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl group/card flex flex-col transition-all duration-200',
props.class,
)
"
@@ -0,0 +1,35 @@
<script lang="ts">
export const tabsContextKey = Symbol("tabs") as InjectionKey<TabsContextValue>;
</script>
<script setup lang="ts">
import { provide } from "vue";
import type { InjectionKey, Ref } from "vue";
import { cn } from "@/lib/utils";
export interface TabsContextValue {
modelValue: Ref<string>;
setValue: (value: string) => void;
}
const props = defineProps<{
class?: string;
}>();
const modelValue = defineModel<string>({ required: true });
const setValue = (value: string) => {
modelValue.value = value;
};
provide(tabsContextKey, {
modelValue,
setValue,
});
</script>
<template>
<div data-slot="tabs" :class="cn('flex flex-col gap-3', props.class)">
<slot />
</div>
</template>
@@ -0,0 +1,21 @@
<script setup lang="ts">
import { cn } from "@/lib/utils";
const props = defineProps<{
class?: string;
}>();
</script>
<template>
<div
data-slot="tabs-list"
:class="
cn(
'bg-muted text-muted-foreground inline-flex h-10 w-fit items-center rounded-lg p-1',
props.class,
)
"
>
<slot />
</div>
</template>
@@ -0,0 +1,39 @@
<script setup lang="ts">
import { computed, inject } from "vue";
import { cn } from "@/lib/utils";
import { tabsContextKey } from "./Tabs.vue";
const props = defineProps<{
value: string;
disabled?: boolean;
class?: string;
}>();
const tabs = inject(tabsContextKey);
if (!tabs) {
throw new Error("TabsTrigger must be used within Tabs");
}
const isActive = computed(() => tabs.modelValue.value === props.value);
</script>
<template>
<button
type="button"
data-slot="tabs-trigger"
:disabled="disabled"
:data-state="isActive ? 'active' : 'inactive'"
:class="
cn(
'inline-flex h-8 items-center justify-center rounded-md px-3 text-sm font-medium whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50',
'data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm',
'hover:text-foreground',
props.class,
)
"
@click="tabs.setValue(value)"
>
<slot />
</button>
</template>
+2 -2
View File
@@ -3,9 +3,9 @@ import { pinia } from "@/stores";
import { loadingPlugin } from "@/directives/loading";
import router from "./router";
import App from "@/pages/index.vue";
import App from "@/App.vue";
import "./style.css";
//
const appContainer = document.createElement("div");
appContainer.setAttribute("style", "text-rendering: geometricPrecision;letter-spacing: -0.1px;");
document.body.appendChild(appContainer);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,77 @@
<script setup lang="ts">
import { computed } from "vue";
import type { IEquipment } from "@/api/info/type";
import { getImageURL } from "@/utils";
import { cn } from "@/lib/utils";
const props = defineProps<{
equipment?: IEquipment;
inactive?: boolean;
selected?: boolean;
useDisplayUrl?: boolean;
}>();
defineEmits<{
click: [];
preview: [];
}>();
const rarityClass = computed(() => {
switch (props.equipment?.rarity) {
case "稀有":
return "border-violet-400";
case "神器":
return "border-fuchsia-500";
case "传说":
return "border-orange-500";
case "史诗":
return "border-amber-400";
case "太初":
return "border-emerald-400";
default:
return "border-muted";
}
});
const imageUrl = computed(() => {
if (!props.equipment) return "";
return getImageURL(
props.useDisplayUrl
? props.equipment.displayUrl || props.equipment.imageUrl
: props.equipment.imageUrl,
);
});
const cellStyle = {
width: "var(--equip-cell-size, 1.75rem)",
height: "var(--equip-cell-size, 1.75rem)",
};
</script>
<template>
<button
v-if="equipment"
type="button"
:class="
cn(
'relative shrink-0 overflow-hidden rounded-sm border bg-background p-0 leading-none transition-colors',
rarityClass,
selected
? 'after:pointer-events-none after:absolute after:inset-0 after:rounded-[3px] after:shadow-[inset_0_0_0_2px_hsl(var(--primary)),0_0_0_1px_hsl(var(--background))]'
: 'hover:border-primary/60',
inactive ? 'opacity-60' : '',
)
"
:style="cellStyle"
@click="$emit('click')"
@mouseenter="$emit('preview')"
>
<img :src="imageUrl" :alt="equipment.name" class="size-full object-contain" />
<div v-if="inactive" class="absolute inset-0 bg-black/45" />
</button>
<div
v-else
class="shrink-0 rounded-sm border border-dashed border-muted-foreground/30 bg-muted/15"
:style="cellStyle"
/>
</template>
@@ -0,0 +1,76 @@
<script setup lang="ts">
import { computed } from "vue";
import { Hammer, ShieldCheck, Sparkles } from "lucide-vue-next";
import { useConfigStore, useInfoStore } from "@/stores";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
const configStore = useConfigStore();
const infoStore = useInfoStore();
const summary = computed(() => {
const equipCount = Object.values(configStore.config.equips || {}).filter(
(item) => !!item.id,
).length;
const avatarCount = Object.values(configStore.config.avatar || {}).filter(
(item) => !!item.id,
).length;
const oathCount = Object.values(configStore.config.oaths || {}).filter(
(item) => !!item.id,
).length;
return {
equipCount,
avatarCount,
oathCount,
emblemSlots: infoStore.emblemParts.length,
};
});
</script>
<template>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-3">
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2">
<Hammer class="size-4" />
装备打造
</CardTitle>
<CardDescription>已迁移基础统计,后续补齐增幅/强化/锻造编辑器。</CardDescription>
</CardHeader>
<CardContent class="space-y-2 text-sm text-muted-foreground">
<div>已配置装备: {{ summary.equipCount }}</div>
<div>誓约槽位: {{ summary.oathCount }}</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>
<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>
</template>
@@ -0,0 +1,105 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { LoaderCircle, RefreshCcw } from "lucide-vue-next";
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";
import { useInfoStore } from "@/stores";
const props = defineProps<{
alter: string;
}>();
const route = useRoute();
const router = useRouter();
const infoStore = useInfoStore();
const loading = ref(false);
const loadError = ref("");
const tabs = [
{ label: "装备", key: "equipment" },
{ label: "技能", key: "skill" },
{ label: "打造", key: "forge" },
] as const;
const activeTab = computed(() => {
const path = route.path;
if (path.endsWith("/skill")) return "skill";
if (path.endsWith("/forge")) return "forge";
return "equipment";
});
const loadCharacter = async () => {
loading.value = true;
loadError.value = "";
try {
await infoStore.createCharacter(props.alter);
} catch (error) {
loadError.value = error instanceof Error ? error.message : "角色数据加载失败";
} finally {
loading.value = false;
}
};
const switchTab = (tab: (typeof tabs)[number]["key"]) => {
router.push(`/dcalc/character/${props.alter}/${tab}`);
};
onMounted(() => {
loadCharacter();
});
watch(
() => props.alter,
() => {
loadCharacter();
},
);
</script>
<template>
<section class="mx-auto flex min-h-full w-full max-w-7xl flex-col gap-4 px-4 py-6 md:px-8">
<Card class="flex-none">
<CardHeader>
<CardTitle class="flex items-center justify-between">
<span>角色配置 · {{ alter }}</span>
<Button variant="outline" size="sm" :disabled="loading" @click="loadCharacter">
<RefreshCcw :class="{ 'animate-spin': loading }" />
刷新
</Button>
</CardTitle>
<CardDescription
>此页面承接旧版 character 模块,逐步迁移装备/技能/打造功能。</CardDescription
>
</CardHeader>
<CardContent class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<Tabs :model-value="activeTab" class="gap-0">
<TabsList>
<TabsTrigger
v-for="tab in tabs"
:key="tab.key"
:value="tab.key"
@click="switchTab(tab.key)"
>
{{ tab.label }}
</TabsTrigger>
</TabsList>
</Tabs>
<div class="text-sm text-muted-foreground">
<span v-if="loading" class="inline-flex items-center gap-2">
<LoaderCircle class="size-4 animate-spin" />
正在同步角色数据...
</span>
<span v-else-if="loadError">{{ loadError }}</span>
<span v-else>数据源已就绪,可继续编辑配置。</span>
</div>
</CardContent>
</Card>
<div class="min-w-0 flex-1">
<router-view />
</div>
</section>
</template>
@@ -0,0 +1,54 @@
<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";
const infoStore = useInfoStore();
const keyword = ref("");
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);
});
});
</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>
<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>
</div>
</CardContent>
</Card>
</div>
<div v-if="skills.length === 0" class="py-10 text-center text-sm text-muted-foreground">
当前筛选条件没有匹配技能。
</div>
</CardContent>
</Card>
</template>
@@ -0,0 +1,105 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { LoaderCircle, RefreshCcw } from "lucide-vue-next";
import { Button } from "../../../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../../../components/ui/card";
import { useInfoStore } from "../../../../stores";
const props = defineProps<{
alter: string;
}>();
const route = useRoute();
const router = useRouter();
const infoStore = useInfoStore();
const loading = ref(false);
const loadError = ref("");
const tabs = [
{ label: "装备", key: "equipment" },
{ label: "技能", key: "skill" },
{ label: "打造", key: "forge" },
] as const;
const activeTab = computed(() => {
const path = route.path;
if (path.endsWith("/skill")) return "skill";
if (path.endsWith("/forge")) return "forge";
return "equipment";
});
const loadCharacter = async () => {
loading.value = true;
loadError.value = "";
try {
await infoStore.createCharacter(props.alter);
} catch (error) {
loadError.value = error instanceof Error ? error.message : "角色数据加载失败";
} finally {
loading.value = false;
}
};
const switchTab = (tab: (typeof tabs)[number]["key"]) => {
router.push(`/dcalc/character/${props.alter}/${tab}`);
};
onMounted(() => {
loadCharacter();
});
watch(
() => props.alter,
() => {
loadCharacter();
},
);
</script>
<template>
<section class="mx-auto flex w-full max-w-7xl flex-col gap-4 px-4 py-6 md:px-8">
<Card>
<CardHeader>
<CardTitle class="flex items-center justify-between">
<span>角色配置 · {{ alter }}</span>
<Button variant="outline" size="sm" :disabled="loading" @click="loadCharacter">
<RefreshCcw :class="{ 'animate-spin': loading }" />
刷新
</Button>
</CardTitle>
<CardDescription
>此页面承接旧版 character 模块,逐步迁移装备/技能/打造功能。</CardDescription
>
</CardHeader>
<CardContent class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div class="inline-flex gap-2">
<Button
v-for="tab in tabs"
:key="tab.key"
:variant="activeTab === tab.key ? 'default' : 'outline'"
size="sm"
@click="switchTab(tab.key)"
>
{{ tab.label }}
</Button>
</div>
<div class="text-sm text-muted-foreground">
<span v-if="loading" class="inline-flex items-center gap-2">
<LoaderCircle class="size-4 animate-spin" />
正在同步角色数据...
</span>
<span v-else-if="loadError">{{ loadError }}</span>
<span v-else>数据源已就绪,可继续编辑配置。</span>
</div>
</CardContent>
</Card>
<router-view />
</section>
</template>
+71 -44
View File
@@ -1,10 +1,19 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { ref } from "vue";
import { GalleryVerticalEnd, Shuffle, Grip } from "lucide-vue-next";
import { GalleryVerticalEnd, Shuffle } from "lucide-vue-next";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Field,
FieldDescription,
@@ -21,56 +30,74 @@ const props = defineProps<{
}>();
const helperId = ref("");
const helperStore = useDnfHelperStore();
const onSubmit = async (e: Event) => {
e.preventDefault();
await useDnfHelperStore().refleshHelperInfo(helperId.value);
await helperStore.refreshHelperInfo(helperId.value);
};
const onRandom = () => {
const seed = `${Math.floor(1000000 + Math.random() * 8999999)}`;
helperId.value = seed;
};
</script>
<template>
<div :class="cn('flex flex-col gap-6', props.class)">
<form @submit="onSubmit">
<FieldGroup>
<div class="flex flex-col items-center gap-2 text-center">
<a href="#" class="flex flex-col items-center gap-2 font-medium">
<div class="flex size-8 items-center justify-center rounded-md">
<GalleryVerticalEnd class="size-6" />
</div>
<span class="sr-only">DNF计算器</span>
</a>
<!-- <h1 class="text-xl font-bold">输入助手ID</h1> -->
<!-- <FieldDescription>请输入助手的社区ID </FieldDescription> -->
</div>
<Field class="flex flex-col items-center">
<FieldLabel for="helperId" class="sr-only"> 助手ID </FieldLabel>
<InputGroup>
<InputGroupInput
id="helperId"
type="number"
placeholder="助手社区ID"
required
v-model="helperId"
/>
<InputGroupAddon>
<Shuffle />
</InputGroupAddon>
<InputGroupAddon align="inline-end">
<Kbd>Enter</Kbd>
</InputGroupAddon>
</InputGroup>
<!-- <FieldDescription class="text-center">
Didn't receive the code? <a href="#">Resend</a>
</FieldDescription> -->
</Field>
<Field>
<Button class="self-center w-sm!" type="submit"> 确认 </Button>
</Field>
</FieldGroup>
</form>
<FieldSeparator class="w-sm self-center">没有助手ID</FieldSeparator>
<FieldDescription class="px-6 text-center">
<Button size="sm" variant="outline"> <Grip /> 选择职业 </Button>
<div :class="cn('flex flex-col gap-6 self-center', props.class)">
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2 text-lg">
<GalleryVerticalEnd class="size-5" />
绑定助手社区ID
</CardTitle>
<CardDescription>
输入助手社区ID后可同步角色列表与基础打造信息。该流程与旧版导入逻辑保持一致。
</CardDescription>
</CardHeader>
<CardContent>
<form class="space-y-4" @submit="onSubmit">
<FieldGroup>
<Field class="flex flex-col gap-2">
<FieldLabel for="helperId">助手ID</FieldLabel>
<InputGroup>
<InputGroupInput
id="helperId"
v-model="helperId"
type="text"
inputmode="numeric"
placeholder="例如: 12345678"
required
/>
<InputGroupAddon as-child>
<button type="button" class="inline-flex items-center" @click="onRandom">
<Shuffle class="size-4" />
</button>
</InputGroupAddon>
<InputGroupAddon align="inline-end">
<Kbd>Enter</Kbd>
</InputGroupAddon>
</InputGroup>
<FieldDescription>点击回车或确认按钮即可触发同步。</FieldDescription>
</Field>
</FieldGroup>
<CardFooter class="grid grid-cols-1 gap-2 px-0 sm:grid-cols-2">
<Button type="submit" :disabled="helperStore.loading">
{{ helperStore.loading ? "同步中..." : "确认并同步" }}
</Button>
<Button variant="outline" type="button" @click="$router.push('/dcalc/home')">
选择职业
</Button>
</CardFooter>
</form>
</CardContent>
</Card>
<FieldSeparator>提示</FieldSeparator>
<FieldDescription class="px-1 text-sm">
助手识别基于OCR,存在一定误差,导入后建议在“装备/打造”页复核关键词条。
</FieldDescription>
</div>
</template>
@@ -1,7 +1,9 @@
<script setup lang="ts">
import { useDnfHelperStore } from "@/stores/dnfhelper";
import { Card, CardContent } from "@/components/ui/card";
import Bind from "./bind.vue";
import List from "./list.vue";
import Processing from "./processing.vue";
const helperStore = useDnfHelperStore();
@@ -9,10 +11,17 @@ const listInfo = computed(() => helperStore.helperInfo?.roleList ?? []);
</script>
<template>
<template v-if="listInfo.length == 0">
<Bind class="self-center justify-center w-fit !max-w-sm h-full" />
</template>
<template v-else>
<List :list="listInfo" />
</template>
<div class="mx-auto flex h-full w-full flex-col gap-4 flex-1">
<Card v-if="helperStore.errorMessage" class="border-destructive/50 bg-destructive/5">
<CardContent class="py-3 text-sm text-destructive">{{
helperStore.errorMessage
}}</CardContent>
</Card>
<Processing v-if="helperStore.loading" />
<Bind v-else-if="listInfo.length === 0" class="mx-auto my-auto" />
<List v-else :list="listInfo" />
</div>
</template>
+41 -22
View File
@@ -1,15 +1,17 @@
<script setup lang="ts">
import Button from "@/components/ui/button/Button.vue";
import { computed } from "vue";
import { Button } from "@/components/ui/button";
// import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
Card,
CardContent,
// CardDescription,
CardFooter,
CardDescription,
// CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { useDnfHelperStore } from "@/stores/dnfhelper";
import { RefreshCcw, Grip } from "lucide-vue-next";
import { Grip, RefreshCcw } from "lucide-vue-next";
const props = defineProps<{
list: {
@@ -24,41 +26,58 @@ const props = defineProps<{
const { list } = toRefs(props);
const helperStore = useDnfHelperStore();
const sortedList = computed(() => {
return [...list.value].sort((left, right) => right.fame - left.fame);
});
const formatFame = (fame: number) => {
return new Intl.NumberFormat("zh-CN").format(fame);
};
</script>
<template>
<header
class="bg-background sticky top-0 flex h-16 shrink-0 items-center gap-2 border-b px-4 justify-between"
class="sticky top-0 z-10 flex shrink-0 flex-col gap-3 border-b bg-background/95 px-1 py-2 backdrop-blur md:flex-row md:items-center md:justify-between px-6"
>
<div>
<h1 class="text-lg font-semibold">角色列表</h1>
<p class="text-sm text-muted-foreground">共 {{ list.length }} 个角色</p>
<p class="text-sm text-muted-foreground">共 {{ sortedList.length }} 个角色</p>
</div>
<div class="flex gap-4">
<Button variant="outline"> <Grip /> 其他职业 </Button>
<div class="flex flex-wrap gap-2">
<Button variant="outline" @click="$router.push('/dcalc/home')"> <Grip /> 选择职业 </Button>
<Button
variant="outline"
size="icon"
aria-label="Submit"
@click="helperStore.refleshHelperInfo(helperStore.helperInfo?.id)"
:disabled="helperStore.loading"
@click="helperStore.refreshHelperInfo(helperStore.helperInfo?.id)"
>
<RefreshCcw />
<RefreshCcw :class="{ 'animate-spin': helperStore.loading }" />
刷新
</Button>
<Button variant="secondary" @click="helperStore.logout">取消绑定</Button>
</div>
</header>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4 p-5 box-border">
<template v-for="item in list" :key="item.roleId">
<div class="grid grid-cols-1 gap-4 py-4 px-6 sm:grid-cols-2 xl:grid-cols-4">
<template v-for="item in sortedList" :key="item.roleId">
<Card class="w-full">
<CardHeader>
<CardTitle>{{ item.roleJob }}</CardTitle>
<CardHeader class="pb-3">
<CardTitle class="text-base flex">
{{ item.roleName }}
<Button class="ml-auto" size="sm" variant="outline" @click="$router.push('/dcalc/home')"
>导入</Button
>
</CardTitle>
<CardDescription class="flex justify-between">
<span>
{{ item.roleJob }}
</span>
<span>名望: {{ formatFame(item.fame) }}</span>
</CardDescription>
</CardHeader>
<CardContent class="flex items-center justify-center h-sm">
<img :src="item.avatarLink" alt="avatar" class="w-auto h-sm rounded-full object-cover" />
<CardContent class="flex items-center justify-center">
<img :src="item.avatarLink" :alt="item.roleName" class="h-40 w-auto rounded" />
</CardContent>
<CardFooter class="flex justify-between">
<span>{{ item.roleName }}</span>
<span>{{ item.fame }} </span>
</CardFooter>
</Card>
</template>
</div>
+130
View File
@@ -0,0 +1,130 @@
<script setup lang="ts">
import { useRouter } from "vue-router";
import { ArrowRight, Search } from "lucide-vue-next";
import api from "@/api";
import type { IAdventureInfo, IAlterInfo } from "@/api/info/type";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
// CardDescription,
// CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { getImageURL } from "@/utils";
const router = useRouter();
const adventures = ref<IAdventureInfo[]>([]);
const loading = ref(false);
const helperId = ref("");
const loadAdventures = async () => {
loading.value = true;
try {
adventures.value = await api.adventures();
} finally {
loading.value = false;
}
};
const openAlter = (alter: IAlterInfo) => {
if (alter.comment === "首页") {
window.open("https://dnftools.com", "_blank", "noopener");
return;
}
if (alter.name === "sponsor") {
if (alter.url) {
window.open(alter.url, "_blank", "noopener");
}
return;
}
if (!alter.open) {
return;
}
const target = alter.value || alter.name;
router.push(`/dcalc/character/${target}/equipment`);
};
const gotoHelper = () => {
if (!helperId.value.trim()) {
router.push("/dcalc/dnfhelper");
return;
}
router.push("/dcalc/dnfhelper");
};
onMounted(() => {
loadAdventures();
});
</script>
<template>
<main class="mx-auto flex min-h-full w-full max-w-7xl flex-col gap-6 px-4 py-6 md:px-8">
<Card class="h-auto flex-none">
<CardContent class="flex min-h-14 flex-col gap-3 justify-center items-center">
<img src="/favicon.ico" alt="DNF Tools" class="size-30 rounded-sm object-cover" />
<div class="text-sm text-muted-foreground">
点击下方职业卡片进入配置,或使用助手导入快速同步角色。
</div>
<Button type="submit" variant="outline" @click="gotoHelper">
<Search />
助手导入
</Button>
</CardContent>
</Card>
<div v-if="loading" class="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
<Card v-for="idx in 6" :key="idx">
<CardHeader>
<Skeleton class="h-5 w-40" />
<Skeleton class="h-4 w-24" />
</CardHeader>
<CardContent class="space-y-2">
<Skeleton class="h-8 w-full" v-for="row in 4" :key="row" />
</CardContent>
</Card>
</div>
<div v-else class="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
<Card v-for="adventure in adventures" :key="adventure.name">
<CardHeader>
<CardTitle>{{ adventure.title }}</CardTitle>
<!-- <CardDescription>{{ adventure.children.length }} 个分支职业</CardDescription> -->
</CardHeader>
<CardContent class="grid grid-cols-2 gap-2">
<Button
v-for="child in adventure.children.filter((item) => item.title)"
:key="child.name"
variant="outline"
class="justify-between p-1.5 !h-auto"
:disabled="!child.open"
@click="openAlter(child)"
>
<item class="flex items-center gap-2">
<img
:src="
getImageURL(`characters/${adventure.name}/${child.name}/material/Seicing_3.png`)
"
:alt="child.title"
class="size-8 rounded-sm object-cover"
/>
<span class="truncate">{{ child.title }}</span>
</item>
<ArrowRight class="size-3" />
</Button>
</CardContent>
<!-- <CardFooter class="text-xs text-muted-foreground">
{{ adventure.name }}
</CardFooter> -->
</Card>
</div>
<div class="text-center text-xs text-muted-foreground">
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener noreferrer">
赣ICP备2023003492号
</a>
</div>
</main>
</template>
+15 -7
View File
@@ -1,16 +1,24 @@
<script setup lang="ts">
import AppSidebar from "@/components/AppSidebar.vue";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
// import DcalcBreadcrumb from "@/components/DcalcBreadcrumb.vue";
</script>
<template>
<SidebarProvider>
<div class="flex w-full">
<AppSidebar />
<SidebarInset>
<router-view />
<!-- router-view -->
</SidebarInset>
<div class="flex h-svh w-full overflow-hidden">
<div class="flex w-full min-h-0">
<AppSidebar />
<SidebarInset class="flex min-w-0 flex-1 min-h-0 flex-col overflow-hidden">
<!-- <DcalcBreadcrumb /> -->
<div class="flex items-center border-b px-2 py-2 md:hidden">
<SidebarTrigger class="h-8 w-8" />
</div>
<div class="flex flex-1 overflow-y-auto overflow-x-hidden">
<router-view />
</div>
</SidebarInset>
</div>
</div>
</SidebarProvider>
</template>
+44 -3
View File
@@ -5,20 +5,61 @@ const router = createRouter({
routes: [
{
path: "/",
name: "home",
component: () => import("@/pages/index.vue"),
},
{
path: "/dcalc",
name: "dcalc",
component: () => import("@/pages/index.vue"),
children: [
{
path: "/dcalc/dnfhelper",
path: "",
redirect: "/dcalc/home",
},
{
path: "home",
name: "home",
component: () => import("@/pages/Dcalc/Home/Index.vue"),
},
{
path: "dnfhelper",
name: "dnfhelper",
component: () => import("@/pages/Dcalc/DnfHelper/index.vue"),
},
{
path: "character/:alter",
name: "character",
props: true,
component: () => import("@/pages/Dcalc/Character/Index.vue"),
redirect: (to) => {
return {
name: "character-equipment",
params: { alter: to.params.alter },
};
},
children: [
{
path: "equipment",
name: "character-equipment",
component: () => import("@/pages/Dcalc/Character/EquipmentDictionary/Index.vue"),
},
{
path: "skill",
name: "character-skill",
component: () => import("@/pages/Dcalc/Character/Skill/index.vue"),
},
{
path: "forge",
name: "character-forge",
component: () => import("@/pages/Dcalc/Character/Forge/index.vue"),
},
],
},
],
},
{
path: "/:pathMatch(.*)*",
redirect: "/dcalc/home",
},
],
});
+24 -4
View File
@@ -10,22 +10,42 @@ export const useDnfHelperStore = defineStore("dnfHelperStore", () => {
},
});
const loading = ref(false);
const errorMessage = ref("");
const refleshHelperInfo = async (uid?: string) => {
if (!uid) return;
const data = await api.getHelperList(uid);
if (data) {
helperInfo.value = data;
console.log(helperInfo.value);
loading.value = true;
errorMessage.value = "";
try {
const data = await api.getHelperList(uid);
if (data) {
helperInfo.value = data;
}
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : "同步助手数据失败";
throw error;
} finally {
loading.value = false;
}
};
const refreshHelperInfo = async (uid?: string) => {
await refleshHelperInfo(uid);
};
const logout = () => {
helperInfo.value = null;
errorMessage.value = "";
};
return {
helperInfo,
loading,
errorMessage,
refleshHelperInfo,
refreshHelperInfo,
logout,
};
});
+19
View File
@@ -0,0 +1,19 @@
let baseURL = "https://image.dnftools.com";
// 避免远程开发的时候冲突
if (import.meta.env.MODE == "development") {
baseURL = "http://127.0.0.1:3000";
}
if (import.meta.env.MODE == "web" || import.meta.env.MODE == "show") {
baseURL = "https://image.dnftools.com";
}
export const getImageURL = (image: string) => {
image = image.startsWith("/") ? image : `/${image}`;
return `${baseURL}${image}`;
};
export const getLocalImageURL = (url: string) => {
return new URL(url, import.meta.url).href;
};