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
+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>