dev
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user