feat(web): 布局壳与 NAKU 品牌——侧栏图标菜单、两行品牌区、favicon
This commit is contained in:
+4
-1
@@ -3,7 +3,10 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>antdv-next §3.7 能力 spike</title>
|
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
|
<title>NAKU · NewAPI 用量统计</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"format": "prettier --write --experimental-cli src/"
|
"format": "prettier --write --experimental-cli src/"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@antdv-next/icons": "^1.1.2",
|
||||||
"@new-api-key-usage/core": "workspace:*",
|
"@new-api-key-usage/core": "workspace:*",
|
||||||
"@tanstack/vue-query": "^5.104.1",
|
"@tanstack/vue-query": "^5.104.1",
|
||||||
"@vueuse/core": "15.0.0",
|
"@vueuse/core": "15.0.0",
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 5.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 636 B |
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||||
|
<rect width="32" height="32" rx="7" fill="#1677FF"/>
|
||||||
|
<text x="16" y="21.5" text-anchor="middle" dominant-baseline="middle"
|
||||||
|
font-family="Segoe UI, system-ui, sans-serif" font-size="18"
|
||||||
|
font-weight="600" fill="#FFFFFF">N</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 314 B |
@@ -0,0 +1,53 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 页面标题区——对齐 design.md 排版:
|
||||||
|
* 标题 headline-sm(20/600/28)· 说明 body-sm 三级字(rgba 0.45)
|
||||||
|
*/
|
||||||
|
defineProps<{
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<header class="page-header">
|
||||||
|
<div class="page-header__text">
|
||||||
|
<h1 class="page-header__title">{{ title }}</h1>
|
||||||
|
<p v-if="subtitle" class="page-header__subtitle">{{ subtitle }}</p>
|
||||||
|
</div>
|
||||||
|
<div v-if="$slots.actions" class="page-header__actions">
|
||||||
|
<slot name="actions" />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 28px;
|
||||||
|
color: rgba(0, 0, 0, 0.88);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header__subtitle {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 20px;
|
||||||
|
color: rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,28 +1,57 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from "vue";
|
import type { MenuInfo, MenuItemType } from "antdv-next";
|
||||||
|
import { theme } from "antdv-next";
|
||||||
|
import {
|
||||||
|
AccountBookOutlined,
|
||||||
|
AuditOutlined,
|
||||||
|
DashboardOutlined,
|
||||||
|
KeyOutlined,
|
||||||
|
MenuFoldOutlined,
|
||||||
|
MenuUnfoldOutlined,
|
||||||
|
SettingOutlined,
|
||||||
|
TeamOutlined,
|
||||||
|
} from "@antdv-next/icons";
|
||||||
|
import { computed, ref, watch } from "vue";
|
||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import { useAdminSessionStore } from "@/stores/admin-session.ts";
|
import { useAdminSessionStore } from "@/stores/admin-session.ts";
|
||||||
import { useLogout, useSession } from "@/queries/session.ts";
|
import { useLogout, useSession } from "@/queries/session.ts";
|
||||||
|
|
||||||
|
const { token } = theme.useToken();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const adminSession = useAdminSessionStore();
|
const adminSession = useAdminSessionStore();
|
||||||
// 刷新后恢复侧边栏用户名(契约 GET /session)
|
|
||||||
const sessionQuery = useSession();
|
const sessionQuery = useSession();
|
||||||
const logout = useLogout();
|
const logout = useLogout();
|
||||||
|
|
||||||
const username = computed(() => adminSession.username || sessionQuery.data.value?.username || "");
|
const collapsed = ref(false);
|
||||||
|
/** 品牌区两行:主标题 + 一句描述(与日志前缀 naku 同源) */
|
||||||
|
const brandTitle = "NAKU";
|
||||||
|
const brandDescription = "NewAPI 用量统计";
|
||||||
|
const username = computed(
|
||||||
|
() => adminSession.username || sessionQuery.data.value?.username || "",
|
||||||
|
);
|
||||||
|
|
||||||
const menus = [
|
/** 收起时仅剩图标——每项必须带 icon(交互共性侧边栏六项) */
|
||||||
{ key: "/admin/overview", label: "总览" },
|
const items: MenuItemType[] = [
|
||||||
{ key: "/admin/keys", label: "Key 管理" },
|
{ key: "/admin/overview", icon: DashboardOutlined, label: "总览" },
|
||||||
{ key: "/admin/payments", label: "支付记录" },
|
{ key: "/admin/keys", icon: KeyOutlined, label: "Key 管理" },
|
||||||
{ key: "/admin/members", label: "成员管理" },
|
{ key: "/admin/payments", icon: AccountBookOutlined, label: "支付记录" },
|
||||||
{ key: "/admin/audit", label: "审计日志" },
|
{ key: "/admin/members", icon: TeamOutlined, label: "成员管理" },
|
||||||
{ key: "/admin/settings", label: "账号设置" },
|
{ key: "/admin/audit", icon: AuditOutlined, label: "审计日志" },
|
||||||
|
{ key: "/admin/settings", icon: SettingOutlined, label: "账号设置" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const selected = computed(() => route.path);
|
const selectedKeys = ref<string[]>([route.path]);
|
||||||
|
watch(
|
||||||
|
() => route.path,
|
||||||
|
(path) => {
|
||||||
|
selectedKeys.value = [path];
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
function onMenuClick({ key }: MenuInfo) {
|
||||||
|
void router.push(String(key));
|
||||||
|
}
|
||||||
|
|
||||||
async function onLogout() {
|
async function onLogout() {
|
||||||
await logout.mutateAsync().catch(() => undefined);
|
await logout.mutateAsync().catch(() => undefined);
|
||||||
@@ -31,61 +60,164 @@ async function onLogout() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="admin-layout">
|
<a-layout class="admin-layout">
|
||||||
<aside class="admin-layout__sider">
|
<a-layout-sider
|
||||||
<div class="admin-layout__brand">new-api-key-usage</div>
|
v-model:collapsed="collapsed"
|
||||||
<nav>
|
collapsible
|
||||||
<router-link
|
:width="220"
|
||||||
v-for="item in menus"
|
:collapsed-width="80"
|
||||||
:key="item.key"
|
>
|
||||||
:to="item.key"
|
<template #trigger>
|
||||||
class="admin-layout__link"
|
<MenuUnfoldOutlined v-if="collapsed" />
|
||||||
:class="{ 'is-active': selected === item.key }"
|
<MenuFoldOutlined v-else />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 品牌区:展开「徽标 + 标题/描述」两行;收起只留徽标 -->
|
||||||
|
<div
|
||||||
|
class="admin-layout__brand"
|
||||||
|
:class="{ 'is-collapsed': collapsed }"
|
||||||
|
:title="collapsed ? `${brandTitle} · ${brandDescription}` : undefined"
|
||||||
|
>
|
||||||
|
<span class="admin-layout__mark" aria-hidden="true">N</span>
|
||||||
|
<div v-if="!collapsed" class="admin-layout__brand-text">
|
||||||
|
<div class="admin-layout__brand-title">{{ brandTitle }}</div>
|
||||||
|
<div class="admin-layout__brand-desc">{{ brandDescription }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a-menu
|
||||||
|
theme="dark"
|
||||||
|
mode="inline"
|
||||||
|
:inline-collapsed="collapsed"
|
||||||
|
:selected-keys="selectedKeys"
|
||||||
|
:items="items"
|
||||||
|
@click="onMenuClick"
|
||||||
|
/>
|
||||||
|
</a-layout-sider>
|
||||||
|
<a-layout>
|
||||||
|
<a-layout-header class="admin-layout__header">
|
||||||
|
<div class="admin-layout__account">
|
||||||
|
<span class="admin-layout__username">{{ username || "…" }}</span>
|
||||||
|
<a-button size="small" :loading="logout.isPending.value" @click="onLogout">
|
||||||
|
登出
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</a-layout-header>
|
||||||
|
<a-layout-content class="admin-layout__content">
|
||||||
|
<div
|
||||||
|
class="admin-layout__surface"
|
||||||
|
:style="{
|
||||||
|
background: token.colorBgContainer,
|
||||||
|
borderRadius: `${token.borderRadiusLG}px`,
|
||||||
|
}"
|
||||||
>
|
>
|
||||||
{{ item.label }}
|
<router-view />
|
||||||
</router-link>
|
</div>
|
||||||
</nav>
|
</a-layout-content>
|
||||||
<footer class="admin-layout__footer">
|
</a-layout>
|
||||||
<span>{{ username || "…" }}</span>
|
</a-layout>
|
||||||
<button type="button" @click="onLogout">登出</button>
|
|
||||||
</footer>
|
|
||||||
</aside>
|
|
||||||
<main class="admin-layout__main">
|
|
||||||
<router-view />
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.admin-layout {
|
.admin-layout {
|
||||||
display: flex;
|
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
.admin-layout__sider {
|
|
||||||
width: 200px;
|
/*
|
||||||
padding: 16px;
|
* 品牌区:恒高 64,不随收起跳动。
|
||||||
border-right: 1px solid #eee;
|
* 展开:徽标 + 两行文字(标题 title-md 级 14/600/22 · 描述 body-sm 12/400/20)——22+20=42,竖向居中。
|
||||||
|
* 收起:只居中徽标;悬停 title 为「标题 · 描述」。
|
||||||
|
*/
|
||||||
|
.admin-layout__brand {
|
||||||
|
height: 64px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 0 24px;
|
||||||
|
overflow: hidden;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-layout__brand.is-collapsed {
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-layout__mark {
|
||||||
|
flex: 0 0 28px;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #1677ff;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 28px;
|
||||||
|
text-align: center;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-layout__brand-text {
|
||||||
|
min-width: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
justify-content: center;
|
||||||
|
gap: 0;
|
||||||
}
|
}
|
||||||
.admin-layout__main {
|
|
||||||
flex: 1;
|
.admin-layout__brand-title {
|
||||||
padding: 16px;
|
color: rgba(255, 255, 255, 0.92);
|
||||||
}
|
font-size: 14px;
|
||||||
.admin-layout__link {
|
|
||||||
display: block;
|
|
||||||
padding: 6px 8px;
|
|
||||||
text-decoration: none;
|
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
.admin-layout__link.is-active {
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
line-height: 22px;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
.admin-layout__footer {
|
|
||||||
margin-top: auto;
|
.admin-layout__brand-desc {
|
||||||
|
color: rgba(255, 255, 255, 0.45);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 20px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-layout__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
align-items: center;
|
||||||
gap: 8px;
|
justify-content: flex-end;
|
||||||
|
background: #fff;
|
||||||
|
padding: 0 24px;
|
||||||
|
height: 64px;
|
||||||
|
line-height: 64px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-layout__account {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
line-height: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-layout__username {
|
||||||
|
color: rgba(0, 0, 0, 0.65);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-layout__content {
|
||||||
|
background: #f5f5f5;
|
||||||
|
padding: 24px;
|
||||||
|
min-height: calc(100vh - 64px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-layout__surface {
|
||||||
|
padding: 24px;
|
||||||
|
min-height: calc(100vh - 64px - 48px);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -6,34 +6,66 @@ import { useMemberMe } from "@/queries/member.ts";
|
|||||||
const tokenStore = useMemberTokenStore();
|
const tokenStore = useMemberTokenStore();
|
||||||
const meQuery = useMemberMe();
|
const meQuery = useMemberMe();
|
||||||
|
|
||||||
const memberName = computed(() => tokenStore.memberName || meQuery.data.value?.name || "");
|
const memberName = computed(
|
||||||
|
() => tokenStore.memberName || meQuery.data.value?.name || "",
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="member-layout">
|
<div class="member-layout">
|
||||||
<header class="member-layout__header">
|
<header class="member-layout__header">
|
||||||
<strong>成员入口</strong>
|
<div class="member-layout__brand">成员入口</div>
|
||||||
<span>{{ memberName }}</span>
|
<div class="member-layout__name">{{ memberName }}</div>
|
||||||
</header>
|
</header>
|
||||||
<main class="member-layout__main">
|
<main class="member-layout__main">
|
||||||
<router-view />
|
<div class="member-layout__surface">
|
||||||
|
<router-view />
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
/* design.md:bg-layout 底 + bg-container 内容面;间距 24/16 对齐 spacing lg/md */
|
||||||
.member-layout {
|
.member-layout {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
background: #f5f5f5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-layout__header {
|
.member-layout__header {
|
||||||
|
height: 64px;
|
||||||
|
padding: 0 24px;
|
||||||
|
background: #fff;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 12px 16px;
|
|
||||||
border-bottom: 1px solid #eee;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.member-layout__brand {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-layout__name {
|
||||||
|
color: rgba(0, 0, 0, 0.65);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
.member-layout__main {
|
.member-layout__main {
|
||||||
padding: 16px;
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 960px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px 16px 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-layout__surface {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 24px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/** 审计日志占位——管理操作留痕(交互设计-审计日志) */
|
import PageHeader from "@/components/common/page-header.vue";
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section>
|
<section>
|
||||||
<h1>审计日志</h1>
|
<PageHeader
|
||||||
<p>管理操作留痕时间线(占位,待 UI 设计)。</p>
|
title="审计日志"
|
||||||
|
subtitle="管理操作留痕(信息结构见交互设计-审计日志)"
|
||||||
|
/>
|
||||||
|
<a-skeleton active :paragraph="{ rows: 4 }" />
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/** Key 管理占位——登记 / 启停 / 归属(交互设计-key管理) */
|
import PageHeader from "@/components/common/page-header.vue";
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section>
|
<section>
|
||||||
<h1>Key 管理</h1>
|
<PageHeader
|
||||||
<p>登记、启停、归属(占位,待 UI 设计)。</p>
|
title="Key 管理"
|
||||||
|
subtitle="登记、启停、归属(信息结构见交互设计-key管理)"
|
||||||
|
/>
|
||||||
|
<a-skeleton active :paragraph="{ rows: 4 }" />
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/** 登录 + 空库三态分流(交互登录篇);视觉细节待与 antdv-next 工具链一并设计 */
|
/** 登录 + 空库三态分流(交互登录篇) */
|
||||||
import { computed, ref } from "vue";
|
import type { FormInstance } from "antdv-next";
|
||||||
|
import { computed, reactive, ref, shallowRef } from "vue";
|
||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import { useLogin, useSetup, useSetupState } from "@/queries/session.ts";
|
import { useLogin, useSetup, useSetupState } from "@/queries/session.ts";
|
||||||
|
|
||||||
@@ -9,30 +10,34 @@ const router = useRouter();
|
|||||||
const setupState = useSetupState();
|
const setupState = useSetupState();
|
||||||
const login = useLogin();
|
const login = useLogin();
|
||||||
const setup = useSetup();
|
const setup = useSetup();
|
||||||
|
const formRef = shallowRef<FormInstance>();
|
||||||
|
|
||||||
const username = ref("");
|
const model = reactive({
|
||||||
const password = ref("");
|
username: "",
|
||||||
const passwordConfirm = ref("");
|
password: "",
|
||||||
|
passwordConfirm: "",
|
||||||
|
});
|
||||||
const error = ref("");
|
const error = ref("");
|
||||||
|
|
||||||
const isSetup = computed(() => setupState.data.value?.initialized === false);
|
const isSetup = computed(() => setupState.data.value?.initialized === false);
|
||||||
|
const loading = computed(() => login.isPending.value || setup.isPending.value);
|
||||||
|
|
||||||
async function onSubmit() {
|
async function onFinish() {
|
||||||
error.value = "";
|
error.value = "";
|
||||||
try {
|
try {
|
||||||
if (isSetup.value) {
|
if (isSetup.value) {
|
||||||
if (password.value !== passwordConfirm.value) {
|
if (model.password !== model.passwordConfirm) {
|
||||||
error.value = "两次输入的密码不一致";
|
error.value = "两次输入的密码不一致";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await setup.mutateAsync({
|
await setup.mutateAsync({
|
||||||
username: username.value,
|
username: model.username,
|
||||||
password: password.value,
|
password: model.password,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await login.mutateAsync({
|
await login.mutateAsync({
|
||||||
username: username.value,
|
username: model.username,
|
||||||
password: password.value,
|
password: model.password,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const redirect = route.query.redirect;
|
const redirect = route.query.redirect;
|
||||||
@@ -44,46 +49,66 @@ async function onSubmit() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="login-view">
|
<div class="login-shell">
|
||||||
<h1>{{ isSetup ? "初始化管理员" : "管理面登录" }}</h1>
|
<a-card class="login-shell__card" :title="isSetup ? '初始化管理员' : '管理面登录'">
|
||||||
<form @submit.prevent="onSubmit">
|
<a-form
|
||||||
<label>
|
ref="formRef"
|
||||||
用户名
|
:model="model"
|
||||||
<input v-model="username" name="username" autocomplete="username" required />
|
layout="vertical"
|
||||||
</label>
|
auto-complete="off"
|
||||||
<label>
|
@finish="onFinish"
|
||||||
密码
|
>
|
||||||
<input
|
<a-form-item
|
||||||
v-model="password"
|
name="username"
|
||||||
|
label="用户名"
|
||||||
|
:rules="[{ required: true, message: '请输入用户名' }]"
|
||||||
|
>
|
||||||
|
<a-input v-model:value="model.username" autocomplete="username" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item
|
||||||
name="password"
|
name="password"
|
||||||
type="password"
|
label="密码"
|
||||||
autocomplete="current-password"
|
:rules="[{ required: true, message: '请输入密码' }]"
|
||||||
required
|
>
|
||||||
|
<a-input-password
|
||||||
|
v-model:value="model.password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item
|
||||||
|
v-if="isSetup"
|
||||||
|
name="passwordConfirm"
|
||||||
|
label="确认密码"
|
||||||
|
:rules="[{ required: true, message: '请再次输入密码' }]"
|
||||||
|
>
|
||||||
|
<a-input-password v-model:value="model.passwordConfirm" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-alert
|
||||||
|
v-if="error"
|
||||||
|
type="error"
|
||||||
|
show-icon
|
||||||
|
:message="error"
|
||||||
|
style="margin-bottom: 16px"
|
||||||
/>
|
/>
|
||||||
</label>
|
<a-button type="primary" html-type="submit" block :loading="loading">
|
||||||
<label v-if="isSetup">
|
{{ isSetup ? "完成设置" : "登录" }}
|
||||||
确认密码
|
</a-button>
|
||||||
<input v-model="passwordConfirm" name="password_confirm" type="password" required />
|
</a-form>
|
||||||
</label>
|
</a-card>
|
||||||
<button type="submit" :disabled="login.isPending.value || setup.isPending.value">
|
|
||||||
{{ isSetup ? "完成设置" : "登录" }}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
<p v-if="error" role="alert">{{ error }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.login-view {
|
.login-shell {
|
||||||
max-width: 360px;
|
min-height: 100vh;
|
||||||
margin: 10vh auto;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
align-items: center;
|
||||||
gap: 12px;
|
justify-content: center;
|
||||||
|
background: #f0f2f5;
|
||||||
|
padding: 24px;
|
||||||
}
|
}
|
||||||
.login-view label {
|
|
||||||
display: flex;
|
.login-shell__card {
|
||||||
flex-direction: column;
|
width: 380px;
|
||||||
gap: 4px;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/** 成员管理占位——开户 / 令牌 / 停用(交互设计-成员管理) */
|
import PageHeader from "@/components/common/page-header.vue";
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section>
|
<section>
|
||||||
<h1>成员管理</h1>
|
<PageHeader
|
||||||
<p>开户、令牌签发、停用(占位,待 UI 设计)。</p>
|
title="成员管理"
|
||||||
|
subtitle="开户、令牌、停用(信息结构见交互设计-成员管理)"
|
||||||
|
/>
|
||||||
|
<a-skeleton active :paragraph="{ rows: 4 }" />
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/** 全员总览占位——信息结构见交互设计-全员总览;UI 待设计阶段 */
|
import PageHeader from "@/components/common/page-header.vue";
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section>
|
<section>
|
||||||
<h1>全员总览</h1>
|
<PageHeader
|
||||||
<p>健康带 + 按成员 / key 汇总(占位,待 UI 设计)。</p>
|
title="全员总览"
|
||||||
|
subtitle="健康带 · 按成员 / key 汇总(信息结构见交互设计-全员总览)"
|
||||||
|
/>
|
||||||
|
<a-skeleton active :paragraph="{ rows: 4 }" />
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/** 支付记录占位——订阅/充值同构表单(交互设计-支付记录) */
|
import PageHeader from "@/components/common/page-header.vue";
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section>
|
<section>
|
||||||
<h1>支付记录</h1>
|
<PageHeader
|
||||||
<p>订阅 / 充值登记与核对(占位,待 UI 设计)。</p>
|
title="支付记录"
|
||||||
|
subtitle="订阅 / 充值登记与核对(信息结构见交互设计-支付记录)"
|
||||||
|
/>
|
||||||
|
<a-skeleton active :paragraph="{ rows: 4 }" />
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/** 账号设置(改密)占位——交互设计-登录与会话 */
|
import PageHeader from "@/components/common/page-header.vue";
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section>
|
<section>
|
||||||
<h1>账号设置</h1>
|
<PageHeader title="账号设置" subtitle="修改密码" />
|
||||||
<p>修改密码(占位,待 UI 设计)。</p>
|
<a-skeleton active :paragraph="{ rows: 3 }" />
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/** 成员单页占位——聚合 + 来源徽章 + 明细(交互设计-成员视图) */
|
import PageHeader from "@/components/common/page-header.vue";
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section>
|
<section>
|
||||||
<h1>我的用量</h1>
|
<PageHeader
|
||||||
<p>总用量、来源分解、key 窄卡、支付参数与明细(占位,待 UI 设计)。</p>
|
title="我的用量"
|
||||||
|
subtitle="聚合 · 来源徽章 · 明细(信息结构见交互设计-成员视图)"
|
||||||
|
/>
|
||||||
|
<a-skeleton active :paragraph="{ rows: 4 }" />
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,21 +1,23 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/** 成员令牌登录壳——明文仅存 sessionStorage(交互登录篇 §4) */
|
/** 成员令牌登录壳——明文仅存 sessionStorage(交互登录篇 §4) */
|
||||||
import { ref } from "vue";
|
import type { FormInstance } from "antdv-next";
|
||||||
|
import { reactive, ref, shallowRef } from "vue";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import { useMemberTokenStore } from "@/stores/member-token.ts";
|
import { useMemberTokenStore } from "@/stores/member-token.ts";
|
||||||
import { fetchMe } from "@/api/member/me.ts";
|
import { fetchMe } from "@/api/member/me.ts";
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const tokenStore = useMemberTokenStore();
|
const tokenStore = useMemberTokenStore();
|
||||||
const token = ref("");
|
const formRef = shallowRef<FormInstance>();
|
||||||
|
const model = reactive({ token: "" });
|
||||||
const error = ref("");
|
const error = ref("");
|
||||||
const pending = ref(false);
|
const pending = ref(false);
|
||||||
|
|
||||||
async function onSubmit() {
|
async function onFinish() {
|
||||||
error.value = "";
|
error.value = "";
|
||||||
pending.value = true;
|
pending.value = true;
|
||||||
try {
|
try {
|
||||||
tokenStore.setToken(token.value.trim());
|
tokenStore.setToken(model.token.trim());
|
||||||
const me = await fetchMe();
|
const me = await fetchMe();
|
||||||
tokenStore.setMemberName(me.name);
|
tokenStore.setMemberName(me.name);
|
||||||
void router.replace({ name: "member-home" });
|
void router.replace({ name: "member-home" });
|
||||||
@@ -29,30 +31,48 @@ async function onSubmit() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="member-login">
|
<div class="member-login-shell">
|
||||||
<h1>成员入口</h1>
|
<a-card class="member-login-shell__card" title="成员入口">
|
||||||
<form @submit.prevent="onSubmit">
|
<a-form
|
||||||
<label>
|
ref="formRef"
|
||||||
令牌
|
:model="model"
|
||||||
<input v-model="token" name="token" type="password" required />
|
layout="vertical"
|
||||||
</label>
|
auto-complete="off"
|
||||||
<button type="submit" :disabled="pending">进入</button>
|
@finish="onFinish"
|
||||||
</form>
|
>
|
||||||
<p v-if="error" role="alert">{{ error }}</p>
|
<a-form-item
|
||||||
|
name="token"
|
||||||
|
label="令牌"
|
||||||
|
:rules="[{ required: true, message: '请输入成员令牌' }]"
|
||||||
|
>
|
||||||
|
<a-input-password v-model:value="model.token" placeholder="粘贴成员令牌" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-alert
|
||||||
|
v-if="error"
|
||||||
|
type="error"
|
||||||
|
show-icon
|
||||||
|
:message="error"
|
||||||
|
style="margin-bottom: 16px"
|
||||||
|
/>
|
||||||
|
<a-button type="primary" html-type="submit" block :loading="pending">
|
||||||
|
进入
|
||||||
|
</a-button>
|
||||||
|
</a-form>
|
||||||
|
</a-card>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.member-login {
|
.member-login-shell {
|
||||||
max-width: 360px;
|
min-height: 100vh;
|
||||||
margin: 12vh auto;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
align-items: center;
|
||||||
gap: 12px;
|
justify-content: center;
|
||||||
|
background: #f0f2f5;
|
||||||
|
padding: 24px;
|
||||||
}
|
}
|
||||||
.member-login label {
|
|
||||||
display: flex;
|
.member-login-shell__card {
|
||||||
flex-direction: column;
|
width: 380px;
|
||||||
gap: 4px;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -36,6 +36,7 @@
|
|||||||
"name": "@new-api-key-usage/web",
|
"name": "@new-api-key-usage/web",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@antdv-next/icons": "^1.1.2",
|
||||||
"@new-api-key-usage/core": "workspace:*",
|
"@new-api-key-usage/core": "workspace:*",
|
||||||
"@tanstack/vue-query": "^5.104.1",
|
"@tanstack/vue-query": "^5.104.1",
|
||||||
"@vueuse/core": "15.0.0",
|
"@vueuse/core": "15.0.0",
|
||||||
@@ -95,7 +96,7 @@
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages": {
|
"packages": {
|
||||||
"@ant-design/colors": ["@ant-design/colors@8.0.1", "", { "dependencies": { "@ant-design/fast-color": "^3.0.0" } }, "sha512-foPVl0+SWIslGUtD/xBr1p9U4AKzPhNYEseXYRRo5QSzGACYZrQbe11AYJbYfAWnWSpGBx6JjBmSeugUsD9vqQ=="],
|
"@ant-design/colors": ["@ant-design/colors@7.2.1", "", { "dependencies": { "@ant-design/fast-color": "^2.0.6" } }, "sha512-lCHDcEzieu4GA3n8ELeZ5VQ8pKQAWcGGLRTQ50aQM2iqPpq2evTxER84jfdPvsPAtEcZ7m44NI45edFMo8oOYQ=="],
|
||||||
|
|
||||||
"@ant-design/fast-color": ["@ant-design/fast-color@3.0.1", "", {}, "sha512-esKJegpW4nckh0o6kV3Tkb7NPIZYbPnnFxmQDUmL08ukXZAvV85TZBr70eGuke/CIArLaP6aw8lt9KILjnWuOw=="],
|
"@ant-design/fast-color": ["@ant-design/fast-color@3.0.1", "", {}, "sha512-esKJegpW4nckh0o6kV3Tkb7NPIZYbPnnFxmQDUmL08ukXZAvV85TZBr70eGuke/CIArLaP6aw8lt9KILjnWuOw=="],
|
||||||
|
|
||||||
@@ -1243,7 +1244,7 @@
|
|||||||
|
|
||||||
"zod": ["zod@4.6.5", "", {}, "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q=="],
|
"zod": ["zod@4.6.5", "", {}, "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q=="],
|
||||||
|
|
||||||
"@antdv-next/icons/@ant-design/colors": ["@ant-design/colors@7.2.1", "", { "dependencies": { "@ant-design/fast-color": "^2.0.6" } }, "sha512-lCHDcEzieu4GA3n8ELeZ5VQ8pKQAWcGGLRTQ50aQM2iqPpq2evTxER84jfdPvsPAtEcZ7m44NI45edFMo8oOYQ=="],
|
"@ant-design/colors/@ant-design/fast-color": ["@ant-design/fast-color@2.0.6", "", { "dependencies": { "@babel/runtime": "^7.24.7" } }, "sha512-y2217gk4NqL35giHl72o6Zzqji9O7vHh9YmhUVkPtAOpoTCH4uWxo/pr4VE8t0+ChEPs0qo4eJRC5Q1eXWo3vA=="],
|
||||||
|
|
||||||
"@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
"@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||||
|
|
||||||
@@ -1275,6 +1276,8 @@
|
|||||||
|
|
||||||
"@vue/compiler-sfc/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
"@vue/compiler-sfc/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
||||||
|
|
||||||
|
"antdv-next/@ant-design/colors": ["@ant-design/colors@8.0.1", "", { "dependencies": { "@ant-design/fast-color": "^3.0.0" } }, "sha512-foPVl0+SWIslGUtD/xBr1p9U4AKzPhNYEseXYRRo5QSzGACYZrQbe11AYJbYfAWnWSpGBx6JjBmSeugUsD9vqQ=="],
|
||||||
|
|
||||||
"antdv-next/@vueuse/core": ["@vueuse/core@14.4.0", "", { "dependencies": { "@types/web-bluetooth": "^0.0.21", "@vueuse/metadata": "14.4.0", "@vueuse/shared": "14.4.0" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-X4WHz1HlCzCBoYXesUkifzzWBAcZgXG8Fi5iNPQg/epdzOB3gu8Fawj3hvuwYR1nGcXGnvxwYYcUC/71++svtQ=="],
|
"antdv-next/@vueuse/core": ["@vueuse/core@14.4.0", "", { "dependencies": { "@types/web-bluetooth": "^0.0.21", "@vueuse/metadata": "14.4.0", "@vueuse/shared": "14.4.0" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-X4WHz1HlCzCBoYXesUkifzzWBAcZgXG8Fi5iNPQg/epdzOB3gu8Fawj3hvuwYR1nGcXGnvxwYYcUC/71++svtQ=="],
|
||||||
|
|
||||||
"bun-types/@types/node": ["@types/node@26.6.4", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg=="],
|
"bun-types/@types/node": ["@types/node@26.6.4", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg=="],
|
||||||
@@ -1301,8 +1304,6 @@
|
|||||||
|
|
||||||
"vue-router/@vue/devtools-api": ["@vue/devtools-api@8.2.1", "", { "dependencies": { "@vue/devtools-kit": "^8.2.1" } }, "sha512-6u4vXBlIBAC1wMplIZgpyPn7uh/s4Bf6F5bMzvLv+EdJ0aHs/+4B7Ygv864EStQSjRbsRzTko/kUG1A1IejQ3A=="],
|
"vue-router/@vue/devtools-api": ["@vue/devtools-api@8.2.1", "", { "dependencies": { "@vue/devtools-kit": "^8.2.1" } }, "sha512-6u4vXBlIBAC1wMplIZgpyPn7uh/s4Bf6F5bMzvLv+EdJ0aHs/+4B7Ygv864EStQSjRbsRzTko/kUG1A1IejQ3A=="],
|
||||||
|
|
||||||
"@antdv-next/icons/@ant-design/colors/@ant-design/fast-color": ["@ant-design/fast-color@2.0.6", "", { "dependencies": { "@babel/runtime": "^7.24.7" } }, "sha512-y2217gk4NqL35giHl72o6Zzqji9O7vHh9YmhUVkPtAOpoTCH4uWxo/pr4VE8t0+ChEPs0qo4eJRC5Q1eXWo3vA=="],
|
|
||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="],
|
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="],
|
||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="],
|
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="],
|
||||||
|
|||||||
Reference in New Issue
Block a user