Compare commits
10
Commits
9ecf817df2
...
311643065c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
311643065c | ||
|
|
2446d3bf2c | ||
|
|
3fb75ec699 | ||
|
|
bd5a9bf99f | ||
|
|
7ff87888a3 | ||
|
|
293141ea0a | ||
|
|
d6d7d9411f | ||
|
|
716ff96b07 | ||
|
|
129ce7e380 | ||
|
|
fbb989c3fb |
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
- **Git**:仅当项目作者明确说"提交 / commit"时才执行提交;`git push` 永远由项目作者手工执行,AI 绝不推送
|
- **Git**:仅当项目作者明确说"提交 / commit"时才执行提交;`git push` 永远由项目作者手工执行,AI 绝不推送
|
||||||
- **命名**:需求 / 设计 / 交互 / 技术 / 计划等**文档名**用中文(`README` / `AGENTS` / `LICENSE` 等约定俗成除外)、**目录**用英文;新文档按性质放入 `docs/design/`(我们定的方案与决策)或 `docs/references/`(平台可用接口参考——实证事实,不做设计决策),并同步更新 README 的「文档地图」;`docs/deprecated/` 只归档已废弃文档——**勿引用、勿作依据**(本分支暂无此目录,需要时再建)
|
- **命名**:需求 / 设计 / 交互 / 技术 / 计划等**文档名**用中文(`README` / `AGENTS` / `LICENSE` 等约定俗成除外)、**目录**用英文;新文档按性质放入 `docs/design/`(我们定的方案与决策)或 `docs/references/`(平台可用接口参考——实证事实,不做设计决策),并同步更新 README 的「文档地图」;`docs/deprecated/` 只归档已废弃文档——**勿引用、勿作依据**(本分支暂无此目录,需要时再建)
|
||||||
|
- **代码文件命名**:所有**目录名与文件名**一律 kebab-case(如 `time-range-picker.vue` · `admin-session.ts`);**组件在模板中引用时用大驼峰**(`<TimeRangePicker />`)——文件名 kebab、模板标签 PascalCase
|
||||||
- **文档状态**:头部表格标「状态」(草案 → 讨论中 → 待审阅 → 定稿(日期) → 已实现);设计文档定稿后不搬家、只改状态;被取代时删除(git 历史即考古)
|
- **文档状态**:头部表格标「状态」(草案 → 讨论中 → 待审阅 → 定稿(日期) → 已实现);设计文档定稿后不搬家、只改状态;被取代时删除(git 历史即考古)
|
||||||
- **部署**:交付物必须保证「一条命令链」可部署;**双形态并行已裁决**——完整前后端(运营方服务器)先行,Cloudflare Workers 抽取版(作者自部署)后续(见 `docs/design/需求清单.md` R13)
|
- **部署**:交付物必须保证「一条命令链」可部署;**双形态并行已裁决**——完整前后端(运营方服务器)先行,Cloudflare Workers 抽取版(作者自部署)后续(见 `docs/design/需求清单.md` R13)
|
||||||
- **不引入版本叙事**:不使用 v1/v2/v3 之类版本编号,不以「MVP」等标签或指称显式描述任何历史阶段;文档内容必须**自立**——不得引用、描述任何当前分支之外、可能被删除的分支内容
|
- **不引入版本叙事**:不使用 v1/v2/v3 之类版本编号,不以「MVP」等标签或指称显式描述任何历史阶段;文档内容必须**自立**——不得引用、描述任何当前分支之外、可能被删除的分支内容
|
||||||
|
|||||||
@@ -51,6 +51,14 @@ const DUMMY_PASSWORD_HASH =
|
|||||||
export function adminSessionRoutes() {
|
export function adminSessionRoutes() {
|
||||||
const r = new OpenAPIHono<{ Variables: AppVars }>();
|
const r = new OpenAPIHono<{ Variables: AppVars }>();
|
||||||
|
|
||||||
|
// 只保护需要会话的端点:GET/DELETE /session、PUT /password
|
||||||
|
// POST /session 是登录、setup* 是引导——不能套鉴权
|
||||||
|
r.use("/session", async (c, next) => {
|
||||||
|
if (c.req.method === "POST") return next();
|
||||||
|
return requireAdminSession(c, next);
|
||||||
|
});
|
||||||
|
r.use("/password", requireAdminSession);
|
||||||
|
|
||||||
r.openapi(
|
r.openapi(
|
||||||
createRoute({
|
createRoute({
|
||||||
method: "get",
|
method: "get",
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ const overviewSchema = z.object({
|
|||||||
key_id: z.number().int(),
|
key_id: z.number().int(),
|
||||||
token_name: z.string(),
|
token_name: z.string(),
|
||||||
member_id: z.number().int().nullable(),
|
member_id: z.number().int().nullable(),
|
||||||
|
member_name: z.string().nullable(),
|
||||||
status: z.string(),
|
status: z.string(),
|
||||||
data_cutoff: z.number().int().nullable(),
|
data_cutoff: z.number().int().nullable(),
|
||||||
usage: usageTotals,
|
usage: usageTotals,
|
||||||
@@ -184,11 +185,15 @@ export function adminUsageRoutes() {
|
|||||||
}
|
}
|
||||||
>();
|
>();
|
||||||
const keyMeta = await repos.keysExtra.listIdsWithCutoff();
|
const keyMeta = await repos.keysExtra.listIdsWithCutoff();
|
||||||
|
const memberNameById = new Map(
|
||||||
|
(await repos.members.listAll()).map((m) => [m.id, m.name]),
|
||||||
|
);
|
||||||
for (const k of keyMeta) {
|
for (const k of keyMeta) {
|
||||||
keyMap.set(k.id, {
|
keyMap.set(k.id, {
|
||||||
token_name: k.token_name,
|
token_name: k.token_name,
|
||||||
member_id: k.member_id,
|
member_id: k.member_id,
|
||||||
member_name: null,
|
member_name:
|
||||||
|
k.member_id == null ? null : (memberNameById.get(k.member_id) ?? null),
|
||||||
status: k.status,
|
status: k.status,
|
||||||
log_synced_at: k.log_synced_at,
|
log_synced_at: k.log_synced_at,
|
||||||
rows: [],
|
rows: [],
|
||||||
@@ -207,6 +212,7 @@ export function adminUsageRoutes() {
|
|||||||
key_id,
|
key_id,
|
||||||
token_name: v.token_name,
|
token_name: v.token_name,
|
||||||
member_id: v.member_id,
|
member_id: v.member_id,
|
||||||
|
member_name: v.member_name,
|
||||||
status: v.status,
|
status: v.status,
|
||||||
data_cutoff: toUnixSec(v.log_synced_at),
|
data_cutoff: toUnixSec(v.log_synced_at),
|
||||||
usage: sumUsage(v.rows, timeline),
|
usage: sumUsage(v.rows, timeline),
|
||||||
|
|||||||
+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,8 +16,13 @@
|
|||||||
"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:*",
|
||||||
"antdv-next": "^1.6.0",
|
"@tanstack/vue-query": "^5.104.1",
|
||||||
|
"@vueuse/core": "15.0.0",
|
||||||
|
"antdv-next": "1.6.0",
|
||||||
|
"axios": "^1.20.0",
|
||||||
|
"dayjs": "1.11.23",
|
||||||
"pinia": "^4.0.3",
|
"pinia": "^4.0.3",
|
||||||
"vue": "^3.5.42",
|
"vue": "^3.5.42",
|
||||||
"vue-router": "^5.3.1"
|
"vue-router": "^5.3.1"
|
||||||
|
|||||||
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 |
@@ -1,168 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
/**
|
|
||||||
* antdv-next §3.7 能力 spike——逐项覆盖《交互设计-总览与共性》清单。
|
|
||||||
* 任一项不覆盖即触发回退(Element Plus / ant-design-vue)并回报作者。
|
|
||||||
*/
|
|
||||||
import { h, ref } from "vue";
|
|
||||||
|
|
||||||
const collapsed = ref(false);
|
|
||||||
const drawerOpen = ref(false);
|
|
||||||
const modalOpen = ref(false);
|
|
||||||
const confirmLoading = ref(false);
|
|
||||||
const range = ref<[string, string] | null>(null);
|
|
||||||
const skKey = ref("");
|
|
||||||
const quotaMode = ref<"usd" | "quota">("usd");
|
|
||||||
const page = ref(1);
|
|
||||||
|
|
||||||
const dataSource = [
|
|
||||||
{
|
|
||||||
key: "1",
|
|
||||||
token_name: "小张-01",
|
|
||||||
billing_source: "subscription",
|
|
||||||
status: "active",
|
|
||||||
quota: 250000,
|
|
||||||
quota_usd: "0.50",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "2",
|
|
||||||
token_name: "小李-02",
|
|
||||||
billing_source: "wallet",
|
|
||||||
status: "disabled",
|
|
||||||
quota: 100000,
|
|
||||||
quota_usd: "0.20",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const columns = [
|
|
||||||
{ title: "名称", dataIndex: "token_name", key: "token_name" },
|
|
||||||
{
|
|
||||||
title: "来源",
|
|
||||||
key: "billing_source",
|
|
||||||
customRender: ({ record }: { record: (typeof dataSource)[0] }) =>
|
|
||||||
h(
|
|
||||||
"a-tag",
|
|
||||||
{ color: record.billing_source === "subscription" ? "blue" : "gold" },
|
|
||||||
() => (record.billing_source === "subscription" ? "订阅" : "充值"),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "状态",
|
|
||||||
key: "status",
|
|
||||||
customRender: ({ record }: { record: (typeof dataSource)[0] }) =>
|
|
||||||
h(
|
|
||||||
"a-badge",
|
|
||||||
{
|
|
||||||
status: record.status === "active" ? "success" : "default",
|
|
||||||
text: record.status === "active" ? "启用" : "停用",
|
|
||||||
},
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{ title: "quota", dataIndex: "quota", key: "quota" },
|
|
||||||
{ title: "额度美元", dataIndex: "quota_usd", key: "quota_usd" },
|
|
||||||
];
|
|
||||||
|
|
||||||
function onCopy() {
|
|
||||||
// toast:复制反馈
|
|
||||||
(window as unknown as { antd?: { message?: { success: (m: string) => void } } })
|
|
||||||
.antd?.message?.success?.("已复制");
|
|
||||||
}
|
|
||||||
|
|
||||||
function onRangeChange(vals: [string, string] | null) {
|
|
||||||
range.value = vals;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<a-layout style="min-height: 100vh">
|
|
||||||
<a-layout-sider v-model:collapsed="collapsed" collapsible>
|
|
||||||
<div style="color: #fff; padding: 16px; font-weight: 600">
|
|
||||||
new-api-key-usage
|
|
||||||
</div>
|
|
||||||
<a-menu theme="dark" mode="inline" default-selected-keys="['spike']">
|
|
||||||
<a-menu-item key="spike">能力 Spike</a-menu-item>
|
|
||||||
<a-menu-item key="overview" disabled>全员总览</a-menu-item>
|
|
||||||
<a-menu-item key="keys" disabled>Key 管理</a-menu-item>
|
|
||||||
</a-menu>
|
|
||||||
</a-layout-sider>
|
|
||||||
<a-layout>
|
|
||||||
<a-layout-content style="margin: 16px; background: #fff; padding: 16px">
|
|
||||||
<a-alert
|
|
||||||
type="info"
|
|
||||||
show-icon
|
|
||||||
closable
|
|
||||||
message="antdv-next §3.7 能力 spike"
|
|
||||||
description="可折叠侧边栏 / 抽屉 · 密集表格与展开行 · 对话框与二次确认 · message / Alert · DatePicker 范围 · 大号粘贴 · 徽章 / Segmented / Skeleton / Pagination"
|
|
||||||
style="margin-bottom: 16px"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<a-space direction="vertical" style="width: 100%" :size="16">
|
|
||||||
<a-space wrap>
|
|
||||||
<a-button type="primary" @click="drawerOpen = true">打开抽屉</a-button>
|
|
||||||
<a-button @click="modalOpen = true">打开对话框</a-button>
|
|
||||||
<a-button @click="onCopy">message.toast</a-button>
|
|
||||||
<a-popconfirm title="确认停用?" @confirm="onCopy">
|
|
||||||
<a-button danger>二次确认</a-button>
|
|
||||||
</a-popconfirm>
|
|
||||||
</a-space>
|
|
||||||
|
|
||||||
<a-space wrap>
|
|
||||||
<a-range-picker
|
|
||||||
style="width: 320px"
|
|
||||||
@change="(_: unknown, vals: [string, string]) => onRangeChange(vals)"
|
|
||||||
/>
|
|
||||||
<a-segmented
|
|
||||||
v-model:value="quotaMode"
|
|
||||||
:options="[
|
|
||||||
{ label: '美元', value: 'usd' },
|
|
||||||
{ label: 'quota', value: 'quota' },
|
|
||||||
]"
|
|
||||||
/>
|
|
||||||
</a-space>
|
|
||||||
|
|
||||||
<a-textarea
|
|
||||||
v-model:value="skKey"
|
|
||||||
:rows="4"
|
|
||||||
placeholder="大号粘贴输入框:sk-key 登记对话框用"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<a-table
|
|
||||||
:columns="columns"
|
|
||||||
:data-source="dataSource"
|
|
||||||
:pagination="false"
|
|
||||||
size="small"
|
|
||||||
:scroll="{ x: 800 }"
|
|
||||||
:expand-column-width="48"
|
|
||||||
>
|
|
||||||
<template #expandedRowRender="{ record }">
|
|
||||||
<div style="padding: 8px">
|
|
||||||
来源徽章展开行:{{ record.billing_source }} ·
|
|
||||||
<a-tag color="green">订阅 #3</a-tag>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</a-table>
|
|
||||||
|
|
||||||
<a-pagination v-model:current="page" :total="50" show-size-changer />
|
|
||||||
<a-skeleton active :paragraph="{ rows: 2 }" />
|
|
||||||
</a-space>
|
|
||||||
|
|
||||||
<a-drawer
|
|
||||||
v-model:open="drawerOpen"
|
|
||||||
title="可折叠侧边栏 / 抽屉"
|
|
||||||
placement="right"
|
|
||||||
:width="320"
|
|
||||||
>
|
|
||||||
<p>抽屉能力:筛选面板 / 详情。</p>
|
|
||||||
</a-drawer>
|
|
||||||
|
|
||||||
<a-modal
|
|
||||||
v-model:open="modalOpen"
|
|
||||||
title="对话框"
|
|
||||||
:confirm-loading="confirmLoading"
|
|
||||||
@ok="modalOpen = false"
|
|
||||||
>
|
|
||||||
<p>对话框 + 二次确认能力。</p>
|
|
||||||
</a-modal>
|
|
||||||
</a-layout-content>
|
|
||||||
</a-layout>
|
|
||||||
</a-layout>
|
|
||||||
</template>
|
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type { AuditLogItem, Paginated } from "@new-api-key-usage/core";
|
||||||
|
import { adminHttp } from "../http.ts";
|
||||||
|
|
||||||
|
export async function fetchAuditLogs(params: {
|
||||||
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
domain?: string;
|
||||||
|
}): Promise<Paginated<AuditLogItem>> {
|
||||||
|
const { data } = await adminHttp.get<Paginated<AuditLogItem>>("/audit-logs", {
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import type { EngineStatus, QpuLatest } from "@new-api-key-usage/core";
|
||||||
|
import { adminHttp } from "../http.ts";
|
||||||
|
|
||||||
|
export async function fetchEngine(): Promise<EngineStatus> {
|
||||||
|
const { data } = await adminHttp.get<EngineStatus>("/engine");
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchQpuLatest(): Promise<QpuLatest> {
|
||||||
|
const { data } = await adminHttp.get<QpuLatest>("/qpu/latest");
|
||||||
|
return data;
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import type { KeyItem } from "@new-api-key-usage/core";
|
||||||
|
import { adminHttp } from "../http.ts";
|
||||||
|
|
||||||
|
export type KeyListFilters = {
|
||||||
|
member_id?: number | "unassigned";
|
||||||
|
status?: "active" | "disabled";
|
||||||
|
recent_failed?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchKeys(filters: KeyListFilters = {}): Promise<KeyItem[]> {
|
||||||
|
const { data } = await adminHttp.get<KeyItem[]>("/keys", { params: filters });
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerKey(body: {
|
||||||
|
sk_key: string;
|
||||||
|
member_id?: number | null;
|
||||||
|
}): Promise<KeyItem> {
|
||||||
|
const { data } = await adminHttp.post<KeyItem>("/keys", body);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function patchKey(
|
||||||
|
id: number,
|
||||||
|
body: { status?: "active" | "disabled"; member_id?: number | null },
|
||||||
|
): Promise<KeyItem> {
|
||||||
|
const { data } = await adminHttp.patch<KeyItem>(`/keys/${id}`, body);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type { LogItem, Paginated } from "@new-api-key-usage/core";
|
||||||
|
import { adminHttp } from "../http.ts";
|
||||||
|
|
||||||
|
export async function fetchLogs(
|
||||||
|
range: { from: string; to: string; page: number; page_size: number },
|
||||||
|
): Promise<Paginated<LogItem>> {
|
||||||
|
const { data } = await adminHttp.get<Paginated<LogItem>>("/logs", {
|
||||||
|
params: range,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import type { MemberItem } from "@new-api-key-usage/core";
|
||||||
|
import { adminHttp } from "../http.ts";
|
||||||
|
|
||||||
|
export async function fetchMembers(): Promise<MemberItem[]> {
|
||||||
|
const { data } = await adminHttp.get<MemberItem[]>("/members");
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createMember(body: {
|
||||||
|
name: string;
|
||||||
|
remark?: string;
|
||||||
|
}): Promise<MemberItem> {
|
||||||
|
const { data } = await adminHttp.post<MemberItem>("/members", body);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function patchMember(
|
||||||
|
id: number,
|
||||||
|
body: { status: "active" | "disabled" },
|
||||||
|
): Promise<MemberItem> {
|
||||||
|
const { data } = await adminHttp.patch<MemberItem>(`/members/${id}`, body);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function issueMemberToken(id: number): Promise<{ token: string }> {
|
||||||
|
const { data } = await adminHttp.post<{ token: string }>(`/members/${id}/token`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import type { UsageTotals, UnixSec } from "@new-api-key-usage/core";
|
||||||
|
import { adminHttp } from "../http.ts";
|
||||||
|
|
||||||
|
export type AdminOverview = {
|
||||||
|
data_cutoff: UnixSec | null;
|
||||||
|
unassigned_subscription: UsageTotals;
|
||||||
|
by_source: Array<{
|
||||||
|
billing_source: string;
|
||||||
|
count: number;
|
||||||
|
quota: number;
|
||||||
|
quota_usd: string;
|
||||||
|
}>;
|
||||||
|
by_member: Array<{
|
||||||
|
member_id: number | null;
|
||||||
|
member_name: string | null;
|
||||||
|
key_count: number;
|
||||||
|
data_cutoff: UnixSec | null;
|
||||||
|
usage: UsageTotals;
|
||||||
|
}>;
|
||||||
|
by_key: Array<{
|
||||||
|
key_id: number;
|
||||||
|
token_name: string;
|
||||||
|
member_id: number | null;
|
||||||
|
member_name: string | null;
|
||||||
|
status: string;
|
||||||
|
data_cutoff: UnixSec | null;
|
||||||
|
usage: UsageTotals;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchOverview(range: {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
}): Promise<AdminOverview> {
|
||||||
|
const { data } = await adminHttp.get<AdminOverview>("/overview", {
|
||||||
|
params: range,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { PaymentItem } from "@new-api-key-usage/core";
|
||||||
|
import { adminHttp } from "../http.ts";
|
||||||
|
|
||||||
|
export type PaymentBody = Record<string, unknown>;
|
||||||
|
|
||||||
|
export async function fetchPayments(): Promise<PaymentItem[]> {
|
||||||
|
const { data } = await adminHttp.get<PaymentItem[]>("/payments");
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createPayment(body: PaymentBody): Promise<PaymentItem> {
|
||||||
|
const { data } = await adminHttp.post<PaymentItem>("/payments", body);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updatePayment(
|
||||||
|
id: number,
|
||||||
|
body: PaymentBody,
|
||||||
|
): Promise<PaymentItem> {
|
||||||
|
const { data } = await adminHttp.put<PaymentItem>(`/payments/${id}`, body);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deletePayment(id: number): Promise<void> {
|
||||||
|
await adminHttp.delete(`/payments/${id}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { adminHttp } from "../http.ts";
|
||||||
|
|
||||||
|
export type SetupState = { initialized: boolean };
|
||||||
|
export type UsernameBody = { username: string };
|
||||||
|
|
||||||
|
export async function fetchSetupState(): Promise<SetupState> {
|
||||||
|
const { data } = await adminHttp.get<SetupState>("/setup-state");
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setup(body: {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}): Promise<UsernameBody> {
|
||||||
|
const { data } = await adminHttp.post<UsernameBody>("/setup", body);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function login(body: {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}): Promise<UsernameBody> {
|
||||||
|
const { data } = await adminHttp.post<UsernameBody>("/session", body);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchSession(): Promise<UsernameBody> {
|
||||||
|
const { data } = await adminHttp.get<UsernameBody>("/session");
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout(): Promise<void> {
|
||||||
|
await adminHttp.delete("/session");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function changePassword(body: {
|
||||||
|
old_password: string;
|
||||||
|
new_password: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
await adminHttp.put("/password", body);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* 401 分流出口:由 main.ts 接到 router,避免 http ↔ router 循环依赖。
|
||||||
|
* 管理面任意 401 → /admin/login;成员视图任意 401 → /member/login(契约公共约定 §2)。
|
||||||
|
*/
|
||||||
|
export type AuthWorld = "admin" | "member";
|
||||||
|
|
||||||
|
type AuthRedirectHandler = (world: AuthWorld, reason: "expired" | "unauthorized") => void;
|
||||||
|
|
||||||
|
const fallback: AuthRedirectHandler = (world) => {
|
||||||
|
window.location.href = world === "admin" ? "/admin/login" : "/member/login";
|
||||||
|
};
|
||||||
|
|
||||||
|
let handler: AuthRedirectHandler = fallback;
|
||||||
|
|
||||||
|
export function setAuthRedirect(fn: AuthRedirectHandler): void {
|
||||||
|
handler = fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function redirectAuth(
|
||||||
|
world: AuthWorld,
|
||||||
|
reason: "expired" | "unauthorized" = "unauthorized",
|
||||||
|
): void {
|
||||||
|
handler(world, reason);
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import axios, {
|
||||||
|
AxiosError,
|
||||||
|
type AxiosInstance,
|
||||||
|
type InternalAxiosRequestConfig,
|
||||||
|
} from "axios";
|
||||||
|
import type { ErrorEnvelope } from "@new-api-key-usage/core";
|
||||||
|
import { redirectAuth, type AuthWorld } from "./auth-redirect.ts";
|
||||||
|
|
||||||
|
const MEMBER_TOKEN_KEY = "member_token";
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
readonly status: number;
|
||||||
|
readonly code: string;
|
||||||
|
readonly fields: Record<string, string> | undefined;
|
||||||
|
readonly keyId: number | undefined;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
status: number,
|
||||||
|
code: string,
|
||||||
|
message: string,
|
||||||
|
extra?: { fields?: Record<string, string>; key_id?: number },
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ApiError";
|
||||||
|
this.status = status;
|
||||||
|
this.code = code;
|
||||||
|
this.fields = extra?.fields;
|
||||||
|
this.keyId = extra?.key_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseErrorEnvelope(data: unknown): ErrorEnvelope | null {
|
||||||
|
if (!data || typeof data !== "object") return null;
|
||||||
|
const env = data as Partial<ErrorEnvelope>;
|
||||||
|
if (!env.error || typeof env.error !== "object") return null;
|
||||||
|
const { code, message } = env.error;
|
||||||
|
if (typeof code !== "string" || typeof message !== "string") return null;
|
||||||
|
return env as ErrorEnvelope;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toApiError(error: AxiosError, world: AuthWorld): ApiError {
|
||||||
|
const envelope = parseErrorEnvelope(error.response?.data);
|
||||||
|
if (envelope) {
|
||||||
|
return new ApiError(
|
||||||
|
error.response?.status ?? 0,
|
||||||
|
envelope.error.code,
|
||||||
|
envelope.error.message,
|
||||||
|
{
|
||||||
|
fields: envelope.error.fields,
|
||||||
|
key_id: envelope.error.key_id,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const status = error.response?.status ?? 0;
|
||||||
|
if (status === 401) {
|
||||||
|
return new ApiError(401, "unauthorized", "凭证无效或已过期");
|
||||||
|
}
|
||||||
|
if (status === 0 || !error.response) {
|
||||||
|
return new ApiError(0, "network", "网络异常,请稍后重试");
|
||||||
|
}
|
||||||
|
// 中间人非 JSON 错误体(代理 502 页等)回退通用文案
|
||||||
|
return new ApiError(status, "internal", "服务暂时不可用,请稍后重试");
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachInterceptors(client: AxiosInstance, world: AuthWorld): void {
|
||||||
|
if (world === "member") {
|
||||||
|
client.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
||||||
|
const token = sessionStorage.getItem(MEMBER_TOKEN_KEY);
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
client.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
(error: AxiosError) => {
|
||||||
|
const apiError = toApiError(error, world);
|
||||||
|
if (apiError.status === 401) {
|
||||||
|
redirectAuth(world, "expired");
|
||||||
|
}
|
||||||
|
return Promise.reject(apiError);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createClient(world: AuthWorld, baseURL: string): AxiosInstance {
|
||||||
|
const client = axios.create({
|
||||||
|
baseURL,
|
||||||
|
timeout: 30_000,
|
||||||
|
// 管理面会话 cookie 需跨端口(Vite 5173 → API 18456)携带
|
||||||
|
withCredentials: true,
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
});
|
||||||
|
attachInterceptors(client, world);
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 管理面:`/api/admin/*`,会话 cookie */
|
||||||
|
export const adminHttp = createClient("admin", "/api/admin");
|
||||||
|
|
||||||
|
/** 成员视图:`/api/member/*`,Bearer 令牌 */
|
||||||
|
export const memberHttp = createClient("member", "/api/member");
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import type {
|
||||||
|
LogItem,
|
||||||
|
Paginated,
|
||||||
|
PaymentItem,
|
||||||
|
UnixSec,
|
||||||
|
UsageTotals,
|
||||||
|
} from "@new-api-key-usage/core";
|
||||||
|
import { memberHttp } from "../http.ts";
|
||||||
|
|
||||||
|
export type MemberMe = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MemberOverview = {
|
||||||
|
member: { id: number | null; name: string | null };
|
||||||
|
usage: UsageTotals;
|
||||||
|
by_source: Array<{
|
||||||
|
billing_source: string;
|
||||||
|
count: number;
|
||||||
|
quota: number;
|
||||||
|
quota_usd: string;
|
||||||
|
}>;
|
||||||
|
keys: Array<{
|
||||||
|
key_id: number;
|
||||||
|
token_name: string;
|
||||||
|
log_synced_at: UnixSec | null;
|
||||||
|
usage: UsageTotals;
|
||||||
|
}>;
|
||||||
|
payments: PaymentItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchMe(): Promise<MemberMe> {
|
||||||
|
const { data } = await memberHttp.get<MemberMe>("/me");
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchOverview(range: {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
}): Promise<MemberOverview> {
|
||||||
|
const { data } = await memberHttp.get<MemberOverview>("/overview", {
|
||||||
|
params: range,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchLogs(
|
||||||
|
range: { from: string; to: string; page: number; page_size: number },
|
||||||
|
): Promise<Paginated<LogItem>> {
|
||||||
|
const { data } = await memberHttp.get<Paginated<LogItem>>("/logs", {
|
||||||
|
params: range,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* vue-query key 工厂——失效与缓存的唯一命名源。
|
||||||
|
* 约定:`[面, 端点, …参数]`,参数保持可 JSON 序列化。
|
||||||
|
*/
|
||||||
|
export type KeyFilters = {
|
||||||
|
member_id?: number | "unassigned";
|
||||||
|
status?: "active" | "disabled";
|
||||||
|
recent_failed?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PageQuery = {
|
||||||
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TimeRange = {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const qk = {
|
||||||
|
admin: {
|
||||||
|
setupState: () => ["admin", "setup-state"] as const,
|
||||||
|
session: () => ["admin", "session"] as const,
|
||||||
|
engine: () => ["admin", "engine"] as const,
|
||||||
|
overview: (range: TimeRange) =>
|
||||||
|
["admin", "overview", range.from, range.to] as const,
|
||||||
|
keys: (filters: KeyFilters) => ["admin", "keys", filters] as const,
|
||||||
|
logs: (range: TimeRange & PageQuery) =>
|
||||||
|
["admin", "logs", range.from, range.to, range.page, range.page_size] as const,
|
||||||
|
payments: () => ["admin", "payments"] as const,
|
||||||
|
qpuLatest: () => ["admin", "qpu-latest"] as const,
|
||||||
|
members: () => ["admin", "members"] as const,
|
||||||
|
auditLogs: (q: PageQuery & { domain?: string }) =>
|
||||||
|
["admin", "audit-logs", q.page, q.page_size, q.domain ?? ""] as const,
|
||||||
|
},
|
||||||
|
member: {
|
||||||
|
me: () => ["member", "me"] as const,
|
||||||
|
overview: (range: TimeRange) =>
|
||||||
|
["member", "overview", range.from, range.to] as const,
|
||||||
|
logs: (range: TimeRange & PageQuery) =>
|
||||||
|
["member", "logs", range.from, range.to, range.page, range.page_size] as const,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// 路由出口 + 全局中文(DatePicker 等默认 en-US,须用 ConfigProvider)
|
||||||
|
import zhCN from "antdv-next/locale/zh_CN";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import "dayjs/locale/zh-cn";
|
||||||
|
|
||||||
|
dayjs.locale("zh-cn");
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<a-config-provider :locale="zhCN">
|
||||||
|
<router-view />
|
||||||
|
</a-config-provider>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 健康带(交互-全员总览 §1.1):引擎轮次 · 全池数据截止 · 未关联订阅
|
||||||
|
* 文案偏完整说明,不追求极简电报体。
|
||||||
|
*/
|
||||||
|
import { computed } from "vue";
|
||||||
|
import type { EngineStatus } from "@new-api-key-usage/core";
|
||||||
|
import type { AdminOverview } from "@/api/admin/overview.ts";
|
||||||
|
import { formatMinute, hoursSince } from "@/utils/time.ts";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
engine: EngineStatus | undefined;
|
||||||
|
overview: AdminOverview | undefined;
|
||||||
|
loading?: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "go-failed-keys"): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const lastRound = computed(() => props.engine?.last_round ?? null);
|
||||||
|
const failedCount = computed(() => lastRound.value?.failed ?? 0);
|
||||||
|
const cutoff = computed(() => props.overview?.data_cutoff ?? null);
|
||||||
|
const cutoffHours = computed(() => hoursSince(cutoff.value));
|
||||||
|
const cutoffStale = computed(
|
||||||
|
() => cutoffHours.value != null && cutoffHours.value > 2,
|
||||||
|
);
|
||||||
|
const unassigned = computed(
|
||||||
|
() => props.overview?.unassigned_subscription ?? null,
|
||||||
|
);
|
||||||
|
const unassignedBad = computed(
|
||||||
|
() => (unassigned.value?.count ?? 0) > 0 || (unassigned.value?.quota ?? 0) > 0,
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="health-band">
|
||||||
|
<a-skeleton v-if="loading" active :paragraph="{ rows: 1 }" :title="false" />
|
||||||
|
<template v-else>
|
||||||
|
<div class="health-band__item">
|
||||||
|
<div class="health-band__title">同步引擎</div>
|
||||||
|
<div class="health-band__body">
|
||||||
|
<template v-if="lastRound">
|
||||||
|
最近一次同步轮次共处理
|
||||||
|
<strong>{{ lastRound.keys_total }}</strong>
|
||||||
|
枚 key,其中成功
|
||||||
|
<strong>{{ lastRound.succeeded }}</strong>
|
||||||
|
枚。
|
||||||
|
<template v-if="failedCount > 0">
|
||||||
|
有
|
||||||
|
<a-button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
class="health-band__fail-btn"
|
||||||
|
@click="emit('go-failed-keys')"
|
||||||
|
>
|
||||||
|
{{ failedCount }} 枚拉取失败
|
||||||
|
</a-button>
|
||||||
|
,可到 Key 管理筛选「最近轮次失败」查看是哪几枚。
|
||||||
|
</template>
|
||||||
|
<template v-else>没有拉取失败。</template>
|
||||||
|
</template>
|
||||||
|
<span v-else class="health-band__muted">
|
||||||
|
引擎尚未完成过同步轮次,或引擎进程未启动——此时用量数据可能还不完整。
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="health-band__item">
|
||||||
|
<div class="health-band__title">数据截止</div>
|
||||||
|
<div class="health-band__body">
|
||||||
|
<a-tooltip
|
||||||
|
title="取全部 key 的 log_synced_at 中最慢的那一个;某枚 key 同步失败会一直不写该字段,从而拖住全池截止"
|
||||||
|
>
|
||||||
|
<span>全池同步截止于 <strong>{{ formatMinute(cutoff) }}</strong>(北京时间)。</span>
|
||||||
|
</a-tooltip>
|
||||||
|
<template v-if="cutoffStale">
|
||||||
|
<span v-if="failedCount > 0" class="health-band__warn">
|
||||||
|
截止时间落后超过 2 小时——通常是
|
||||||
|
<a-button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
class="health-band__fail-btn"
|
||||||
|
@click="emit('go-failed-keys')"
|
||||||
|
>
|
||||||
|
同步失败的 key
|
||||||
|
</a-button>
|
||||||
|
一直未成功、拖住了全池最慢值;请到 Key 管理处理(必要时停用)。
|
||||||
|
</span>
|
||||||
|
<span v-else class="health-band__warn">
|
||||||
|
截止时间落后超过 2 小时,而最近轮次没有失败 key——同步链路可能停滞,请检查引擎日志。
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<span v-else-if="cutoff != null" class="health-band__ok">
|
||||||
|
目前在正常滞后范围内。
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="health-band__item">
|
||||||
|
<div class="health-band__title">未关联订阅</div>
|
||||||
|
<div class="health-band__body">
|
||||||
|
<a-tooltip
|
||||||
|
title="统计的是「来源为订阅、但日志上没有订阅编号」的用量;正常情况下应始终为 0"
|
||||||
|
>
|
||||||
|
<template v-if="unassignedBad">
|
||||||
|
<span class="health-band__danger">
|
||||||
|
本时段发现
|
||||||
|
{{ unassigned?.count }} 条 / ${{ unassigned?.quota_usd }}
|
||||||
|
订阅来源用量未关联到任何订阅编号
|
||||||
|
</span>
|
||||||
|
(说明支付记录缺登记,或上游计费行为发生了变化),需要人工核对。
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
本时段没有出现「订阅来源却关联不到订阅编号」的用量,属正常状态。
|
||||||
|
</template>
|
||||||
|
</a-tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.health-band {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
border: 1px solid #f0f0f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fafafa;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-band__item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 96px 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-band__title {
|
||||||
|
color: rgba(0, 0, 0, 0.45);
|
||||||
|
padding-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-band__body {
|
||||||
|
color: rgba(0, 0, 0, 0.88);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-band__body strong {
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-band__muted {
|
||||||
|
color: rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-band__ok {
|
||||||
|
color: rgba(0, 0, 0, 0.45);
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-band__warn {
|
||||||
|
color: #d48806;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-band__danger {
|
||||||
|
color: #cf1322;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-band__fail-btn {
|
||||||
|
padding: 0 4px;
|
||||||
|
height: auto;
|
||||||
|
line-height: inherit;
|
||||||
|
color: #cf1322;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 登记 Key:sk-key 明文在 web 的唯一露面处(交互-key管理 §2)
|
||||||
|
*/
|
||||||
|
import { computed, reactive, ref, watch } from "vue";
|
||||||
|
import type { MemberItem } from "@new-api-key-usage/core";
|
||||||
|
import type { ApiError } from "@/api/http.ts";
|
||||||
|
import { useCreateMember, useRegisterKey } from "@/queries/admin.ts";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
open: boolean;
|
||||||
|
members: MemberItem[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "update:open", v: boolean): void;
|
||||||
|
(e: "registered", keyId: number): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const register = useRegisterKey();
|
||||||
|
const createMember = useCreateMember();
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
sk_key: "",
|
||||||
|
member_id: null as number | "unassigned" | null,
|
||||||
|
});
|
||||||
|
const resultMsg = ref<{ type: "success" | "error" | "warning"; text: string } | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const showNewMember = ref(false);
|
||||||
|
const newMemberName = ref("");
|
||||||
|
const newMemberRemark = ref("");
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.open,
|
||||||
|
(v) => {
|
||||||
|
if (!v) {
|
||||||
|
form.sk_key = "";
|
||||||
|
form.member_id = null;
|
||||||
|
resultMsg.value = null;
|
||||||
|
showNewMember.value = false;
|
||||||
|
newMemberName.value = "";
|
||||||
|
newMemberRemark.value = "";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const memberOptions = computed(() => [
|
||||||
|
{ value: "unassigned", label: "未归属" },
|
||||||
|
...props.members.map((m) => ({ value: m.id, label: m.name })),
|
||||||
|
]);
|
||||||
|
|
||||||
|
async function onSubmit() {
|
||||||
|
resultMsg.value = null;
|
||||||
|
if (!form.sk_key.trim()) {
|
||||||
|
resultMsg.value = { type: "warning", text: "请粘贴 sk-key" };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const key = await register.mutateAsync({
|
||||||
|
sk_key: form.sk_key.trim(),
|
||||||
|
member_id:
|
||||||
|
form.member_id === "unassigned" || form.member_id == null
|
||||||
|
? null
|
||||||
|
: form.member_id,
|
||||||
|
});
|
||||||
|
resultMsg.value = {
|
||||||
|
type: "success",
|
||||||
|
text: "已登记,首轮同步进行中(约 5 分钟内完成)",
|
||||||
|
};
|
||||||
|
emit("registered", key.id);
|
||||||
|
} catch (e) {
|
||||||
|
const err = e as ApiError;
|
||||||
|
if (err.status === 409) {
|
||||||
|
resultMsg.value = {
|
||||||
|
type: "warning",
|
||||||
|
text: "该 key 已在清单中",
|
||||||
|
};
|
||||||
|
if (err.keyId != null) emit("registered", err.keyId);
|
||||||
|
} else if (err.status === 422) {
|
||||||
|
// 上游拒绝 vs 本地表单校验(如 sk- 前缀)文案不同
|
||||||
|
const fieldMsg = err.fields?.sk_key;
|
||||||
|
resultMsg.value = {
|
||||||
|
type: "error",
|
||||||
|
text:
|
||||||
|
fieldMsg ??
|
||||||
|
(err.code === "validation_failed"
|
||||||
|
? err.message || "请检查填写内容"
|
||||||
|
: "上游拒绝了这枚 key——可能不存在或已被禁用"),
|
||||||
|
};
|
||||||
|
} else if (err.status === 502 || err.status === 0) {
|
||||||
|
resultMsg.value = {
|
||||||
|
type: "error",
|
||||||
|
text: "上游站点暂时不可达,请稍后重试",
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
resultMsg.value = {
|
||||||
|
type: "error",
|
||||||
|
text: err.message || "登记失败,请稍后重试",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onCreateMember() {
|
||||||
|
const name = newMemberName.value.trim();
|
||||||
|
if (!name) return;
|
||||||
|
try {
|
||||||
|
const m = await createMember.mutateAsync({
|
||||||
|
name,
|
||||||
|
remark: newMemberRemark.value.trim(),
|
||||||
|
});
|
||||||
|
form.member_id = m.id;
|
||||||
|
showNewMember.value = false;
|
||||||
|
newMemberName.value = "";
|
||||||
|
newMemberRemark.value = "";
|
||||||
|
resultMsg.value = { type: "success", text: `已新建成员「${m.name}」并选为归属` };
|
||||||
|
} catch (e) {
|
||||||
|
const err = e as ApiError;
|
||||||
|
resultMsg.value = { type: "error", text: err.message || "新建成员失败" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<a-modal
|
||||||
|
:open="open"
|
||||||
|
title="登记 Key"
|
||||||
|
:confirm-loading="register.isPending.value"
|
||||||
|
ok-text="登记"
|
||||||
|
cancel-text="取消"
|
||||||
|
@update:open="emit('update:open', $event)"
|
||||||
|
@ok="onSubmit"
|
||||||
|
>
|
||||||
|
<a-form layout="vertical">
|
||||||
|
<a-form-item label="sk-key" required>
|
||||||
|
<a-textarea
|
||||||
|
v-model:value="form.sk_key"
|
||||||
|
:rows="4"
|
||||||
|
placeholder="粘贴你在 NewAPI 创建的 sk-…"
|
||||||
|
/>
|
||||||
|
<div class="register-key__note">
|
||||||
|
粘贴后立即加密存储,界面不再回显明文
|
||||||
|
</div>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="归属成员">
|
||||||
|
<a-select
|
||||||
|
v-model:value="form.member_id"
|
||||||
|
:options="memberOptions"
|
||||||
|
placeholder="未归属"
|
||||||
|
allow-clear
|
||||||
|
/>
|
||||||
|
<a-button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
style="padding-left: 0"
|
||||||
|
@click="showNewMember = !showNewMember"
|
||||||
|
>
|
||||||
|
+ 新建成员
|
||||||
|
</a-button>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item v-if="showNewMember" label="新成员名称">
|
||||||
|
<a-input
|
||||||
|
v-model:value="newMemberName"
|
||||||
|
placeholder="成员名称"
|
||||||
|
@press-enter="onCreateMember"
|
||||||
|
/>
|
||||||
|
<a-input
|
||||||
|
v-model:value="newMemberRemark"
|
||||||
|
placeholder="备注(可选)"
|
||||||
|
style="margin-top: 8px"
|
||||||
|
/>
|
||||||
|
<a-button
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
style="margin-top: 8px"
|
||||||
|
:loading="createMember.isPending.value"
|
||||||
|
@click="onCreateMember"
|
||||||
|
>
|
||||||
|
创建并选用
|
||||||
|
</a-button>
|
||||||
|
</a-form-item>
|
||||||
|
<a-alert
|
||||||
|
v-if="resultMsg"
|
||||||
|
:type="resultMsg.type"
|
||||||
|
show-icon
|
||||||
|
:message="resultMsg.text"
|
||||||
|
/>
|
||||||
|
</a-form>
|
||||||
|
</a-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.register-key__note {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 20px;
|
||||||
|
color: rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<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;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
/* 与标题块上缘对齐时,控件 32px 高与 label 竖向居中 */
|
||||||
|
min-height: 32px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 额度双单位:原始 quota + 美元(契约公共约定 §5;前端只呈现不换算)
|
||||||
|
*/
|
||||||
|
import { computed } from "vue";
|
||||||
|
import { formatInt } from "@/utils/format.ts";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
quota: number | null | undefined;
|
||||||
|
quotaUsd: string | null | undefined;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const quotaText = computed(() => formatInt(props.quota));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span class="quota-text">
|
||||||
|
<span class="quota-text__num">{{ quotaText }}</span>
|
||||||
|
<span class="quota-text__usd">(${{ quotaUsd ?? "—" }})</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.quota-text {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quota-text__num {
|
||||||
|
color: rgba(0, 0, 0, 0.88);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quota-text__usd {
|
||||||
|
margin-left: 6px;
|
||||||
|
color: rgba(0, 0, 0, 0.45);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 来源分解分段条(交互共性 §3.7:订阅 / 充值)——前端现算比例(公共约定 §5)
|
||||||
|
*/
|
||||||
|
import { computed } from "vue";
|
||||||
|
|
||||||
|
type SourceRow = {
|
||||||
|
billing_source: string;
|
||||||
|
count: number;
|
||||||
|
quota: number;
|
||||||
|
quota_usd: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
rows: SourceRow[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const segments = computed(() => {
|
||||||
|
const total = props.rows.reduce((s, r) => s + r.quota, 0);
|
||||||
|
return props.rows.map((r) => {
|
||||||
|
const pct = total > 0 ? (r.quota / total) * 100 : 0;
|
||||||
|
const isSub = r.billing_source === "subscription";
|
||||||
|
return {
|
||||||
|
key: r.billing_source,
|
||||||
|
label: isSub ? "订阅" : r.billing_source === "wallet" ? "充值" : r.billing_source,
|
||||||
|
pct,
|
||||||
|
count: r.count,
|
||||||
|
quota_usd: r.quota_usd,
|
||||||
|
color: isSub ? "#1677ff" : "#faad14",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasData = computed(() => segments.value.some((s) => s.pct > 0));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="source-bar">
|
||||||
|
<div v-if="!hasData" class="source-bar__empty">
|
||||||
|
本时段内没有任何消费调用,因此没有可分解的来源。
|
||||||
|
</div>
|
||||||
|
<template v-else>
|
||||||
|
<div class="source-bar__track">
|
||||||
|
<div
|
||||||
|
v-for="seg in segments"
|
||||||
|
:key="seg.key"
|
||||||
|
class="source-bar__seg"
|
||||||
|
:style="{ width: `${seg.pct}%`, background: seg.color }"
|
||||||
|
:title="`${seg.label}来源:占额度 ${seg.pct.toFixed(1)}%,调用 ${seg.count} 次,$${seg.quota_usd}`"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="source-bar__legend">
|
||||||
|
<span v-for="seg in segments" :key="seg.key" class="source-bar__item">
|
||||||
|
<i class="source-bar__dot" :style="{ background: seg.color }" />
|
||||||
|
<span class="source-bar__label">{{ seg.label }}</span>
|
||||||
|
<span class="source-bar__meta">
|
||||||
|
调用 {{ seg.count }} 次,消耗 ${{ seg.quota_usd }}
|
||||||
|
(占 {{ seg.pct.toFixed(1) }}%)
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.source-bar__track {
|
||||||
|
display: flex;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-bar__seg {
|
||||||
|
height: 100%;
|
||||||
|
min-width: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-bar__legend {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 22px;
|
||||||
|
color: rgba(0, 0, 0, 0.88);
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-bar__item {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-bar__dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
|
top: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-bar__label {
|
||||||
|
font-weight: 600;
|
||||||
|
min-width: 2.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-bar__meta {
|
||||||
|
color: rgba(0, 0, 0, 0.65);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-bar__empty {
|
||||||
|
font-size: 13px;
|
||||||
|
color: rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { Dayjs } from "dayjs";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import { computed } from "vue";
|
||||||
|
import { recentDaysRange, type RangeValue } from "@/utils/time.ts";
|
||||||
|
import type { RangeKind } from "@/composables/use-live-time-range.ts";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: RangeValue;
|
||||||
|
/** 外部预置(如「本订阅期间」);空则只保留内建快捷项 */
|
||||||
|
extraPresets?: Array<{ label: string; from: string; to: string }>;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "update:modelValue", value: RangeValue): void;
|
||||||
|
/** 相对区间可自动跟日历推进;fixed 为绝对窗口 */
|
||||||
|
(e: "change", value: RangeValue, kind: RangeKind): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const pickerValue = computed<[Dayjs, Dayjs] | null>(() => {
|
||||||
|
if (!props.modelValue.from || !props.modelValue.to) return null;
|
||||||
|
return [dayjs(props.modelValue.from), dayjs(props.modelValue.to)];
|
||||||
|
});
|
||||||
|
|
||||||
|
const presets = computed(() => {
|
||||||
|
const builtIn = [
|
||||||
|
{
|
||||||
|
label: "最近 7 天",
|
||||||
|
value: rangeToDayjs(recentDaysRange(7)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "最近 30 天",
|
||||||
|
value: rangeToDayjs(recentDaysRange(30)),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const extra = (props.extraPresets ?? []).map((p) => ({
|
||||||
|
label: p.label,
|
||||||
|
value: rangeToDayjs({ from: p.from, to: p.to }),
|
||||||
|
}));
|
||||||
|
return [...builtIn, ...extra];
|
||||||
|
});
|
||||||
|
|
||||||
|
function rangeToDayjs(range: RangeValue): [Dayjs, Dayjs] {
|
||||||
|
return [dayjs(range.from), dayjs(range.to)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectKind(value: RangeValue): RangeKind {
|
||||||
|
const fromD = dayjs(value.from).format("YYYY-MM-DD");
|
||||||
|
const toD = dayjs(value.to).format("YYYY-MM-DD");
|
||||||
|
for (const days of [7, 30] as const) {
|
||||||
|
const r = recentDaysRange(days);
|
||||||
|
if (
|
||||||
|
dayjs(r.from).format("YYYY-MM-DD") === fromD &&
|
||||||
|
dayjs(r.to).format("YYYY-MM-DD") === toD
|
||||||
|
) {
|
||||||
|
return days === 7 ? "recent-7" : "recent-30";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "fixed";
|
||||||
|
}
|
||||||
|
|
||||||
|
function onChange(dates: Dayjs[] | null) {
|
||||||
|
if (!dates || dates.length !== 2 || !dates[0] || !dates[1]) return;
|
||||||
|
const value = {
|
||||||
|
from: dates[0].toISOString(),
|
||||||
|
to: dates[1].toISOString(),
|
||||||
|
};
|
||||||
|
emit("update:modelValue", value);
|
||||||
|
emit("change", value, detectKind(value));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<a-range-picker
|
||||||
|
:value="pickerValue"
|
||||||
|
:presets="presets"
|
||||||
|
format="YYYY-MM-DD"
|
||||||
|
:placeholder="['开始日期', '结束日期']"
|
||||||
|
@change="onChange"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { computed, onUnmounted, ref } from "vue";
|
||||||
|
import { recentDaysRange } from "@/utils/time.ts";
|
||||||
|
|
||||||
|
export type RangeValue = { from: string; to: string };
|
||||||
|
export type RangeKind = "recent-7" | "recent-30" | "fixed";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 可跟随「今天」推进的时间范围:
|
||||||
|
* - 最近 7/30 天:定时重算窗口(页面挂过夜也不会陈旧)
|
||||||
|
* - fixed:自定义/订阅期间等绝对区间,窗口不挪
|
||||||
|
*/
|
||||||
|
export function useLiveTimeRange(initial: Exclude<RangeKind, "fixed"> = "recent-30") {
|
||||||
|
const kind = ref<RangeKind>(initial);
|
||||||
|
const fixed = ref<RangeValue>(recentDaysRange(30));
|
||||||
|
const nowTick = ref(Date.now());
|
||||||
|
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
nowTick.value = Date.now();
|
||||||
|
}, 60_000);
|
||||||
|
onUnmounted(() => clearInterval(timer));
|
||||||
|
|
||||||
|
const range = computed<RangeValue>(() => {
|
||||||
|
void nowTick.value;
|
||||||
|
if (kind.value === "recent-7") return recentDaysRange(7);
|
||||||
|
if (kind.value === "recent-30") return recentDaysRange(30);
|
||||||
|
return fixed.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
function setRange(value: RangeValue, nextKind: RangeKind) {
|
||||||
|
kind.value = nextKind;
|
||||||
|
if (nextKind === "fixed") fixed.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { range, setRange, kind };
|
||||||
|
}
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { MenuInfo, MenuItemType } from "antdv-next";
|
||||||
|
import { theme } from "antdv-next";
|
||||||
|
import {
|
||||||
|
AccountBookOutlined,
|
||||||
|
AuditOutlined,
|
||||||
|
DashboardOutlined,
|
||||||
|
DownOutlined,
|
||||||
|
KeyOutlined,
|
||||||
|
LogoutOutlined,
|
||||||
|
MenuFoldOutlined,
|
||||||
|
MenuUnfoldOutlined,
|
||||||
|
SettingOutlined,
|
||||||
|
TeamOutlined,
|
||||||
|
UserOutlined,
|
||||||
|
} from "@antdv-next/icons";
|
||||||
|
import { computed, ref, watch } from "vue";
|
||||||
|
import { useRoute, useRouter } from "vue-router";
|
||||||
|
import { useAdminSessionStore } from "@/stores/admin-session.ts";
|
||||||
|
import { useLogout, useSession } from "@/queries/session.ts";
|
||||||
|
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const adminSession = useAdminSessionStore();
|
||||||
|
// 必须解构:未解构时经 reactive 访问会解包 Ref,再取 .value 会得到 undefined
|
||||||
|
const {
|
||||||
|
data: sessionData,
|
||||||
|
isPending: sessionPending,
|
||||||
|
isFetching: sessionFetching,
|
||||||
|
} = useSession();
|
||||||
|
const logout = useLogout();
|
||||||
|
|
||||||
|
const collapsed = ref(false);
|
||||||
|
/** 品牌区两行:主标题 + 一句描述(与日志前缀 naku 同源) */
|
||||||
|
const brandTitle = "NAKU";
|
||||||
|
const brandDescription = "NewAPI 用量统计";
|
||||||
|
const username = computed(
|
||||||
|
() => adminSession.username || sessionData.value?.username || "",
|
||||||
|
);
|
||||||
|
|
||||||
|
/** 收起时仅剩图标——每项必须带 icon(交互共性侧边栏六项) */
|
||||||
|
const items: MenuItemType[] = [
|
||||||
|
{ key: "/admin/overview", icon: DashboardOutlined, label: "总览" },
|
||||||
|
{ key: "/admin/keys", icon: KeyOutlined, label: "Key 管理" },
|
||||||
|
{ key: "/admin/payments", icon: AccountBookOutlined, label: "支付记录" },
|
||||||
|
{ key: "/admin/members", icon: TeamOutlined, label: "成员管理" },
|
||||||
|
{ key: "/admin/audit", icon: AuditOutlined, label: "审计日志" },
|
||||||
|
{ key: "/admin/settings", icon: SettingOutlined, label: "账号设置" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const selectedKeys = ref<string[]>([route.path]);
|
||||||
|
watch(
|
||||||
|
() => route.path,
|
||||||
|
(path) => {
|
||||||
|
selectedKeys.value = [path];
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
function onMenuClick({ key }: MenuInfo) {
|
||||||
|
void router.push(String(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
const userMenuItems: MenuItemType[] = [
|
||||||
|
{ key: "settings", icon: UserOutlined, label: "账号设置" },
|
||||||
|
{ type: "divider", key: "d1" },
|
||||||
|
{ key: "logout", icon: LogoutOutlined, label: "退出", danger: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
async function onUserMenuClick({ key }: MenuInfo) {
|
||||||
|
if (key === "settings") {
|
||||||
|
void router.push({ name: "admin-settings" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key === "logout") {
|
||||||
|
await logout.mutateAsync().catch(() => undefined);
|
||||||
|
void router.push({ name: "admin-login" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<a-layout class="admin-layout">
|
||||||
|
<a-layout-sider
|
||||||
|
v-model:collapsed="collapsed"
|
||||||
|
collapsible
|
||||||
|
:width="220"
|
||||||
|
:collapsed-width="80"
|
||||||
|
>
|
||||||
|
<template #trigger>
|
||||||
|
<MenuUnfoldOutlined v-if="collapsed" />
|
||||||
|
<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 class="admin-layout__main">
|
||||||
|
<a-layout-header class="admin-layout__header">
|
||||||
|
<a-dropdown
|
||||||
|
:menu="{ items: userMenuItems }"
|
||||||
|
placement="bottomRight"
|
||||||
|
trigger="click"
|
||||||
|
@menu-click="onUserMenuClick"
|
||||||
|
>
|
||||||
|
<a class="admin-layout__account">
|
||||||
|
<UserOutlined class="admin-layout__account-icon" />
|
||||||
|
<span v-if="username" class="admin-layout__username">{{ username }}</span>
|
||||||
|
<span v-else class="admin-layout__username is-muted">
|
||||||
|
{{ sessionPending || sessionFetching ? "…" : "未登录" }}
|
||||||
|
</span>
|
||||||
|
<DownOutlined class="admin-layout__account-caret" />
|
||||||
|
</a>
|
||||||
|
</a-dropdown>
|
||||||
|
</a-layout-header>
|
||||||
|
<a-layout-content class="admin-layout__content">
|
||||||
|
<div
|
||||||
|
class="admin-layout__surface"
|
||||||
|
:style="{
|
||||||
|
background: token.colorBgContainer,
|
||||||
|
borderRadius: `${token.borderRadiusLG}px`,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<router-view />
|
||||||
|
</div>
|
||||||
|
</a-layout-content>
|
||||||
|
</a-layout>
|
||||||
|
</a-layout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 固定外壳:侧栏 + 顶栏不动,仅内容区滚动 */
|
||||||
|
.admin-layout {
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-layout__main {
|
||||||
|
height: 100vh;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 品牌区:恒高 64,不随收起跳动。
|
||||||
|
* 展开:徽标 + 两行文字(标题 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;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-layout__brand-title {
|
||||||
|
color: rgba(255, 255, 255, 0.92);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 22px;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 {
|
||||||
|
flex: 0 0 64px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
background: #fff;
|
||||||
|
padding: 0 24px;
|
||||||
|
height: 64px;
|
||||||
|
line-height: 64px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-layout__account {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0 8px;
|
||||||
|
height: 40px;
|
||||||
|
color: rgba(0, 0, 0, 0.88);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-layout__account:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-layout__account-icon {
|
||||||
|
font-size: 14px;
|
||||||
|
color: rgba(0, 0, 0, 0.65);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-layout__account-caret {
|
||||||
|
font-size: 10px;
|
||||||
|
color: rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-layout__username {
|
||||||
|
color: rgba(0, 0, 0, 0.88);
|
||||||
|
max-width: 160px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-layout__username.is-muted {
|
||||||
|
color: rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-layout__content {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
background: #f5f5f5;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-layout__surface {
|
||||||
|
padding: 24px;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
|
import { useMemberTokenStore } from "@/stores/member-token.ts";
|
||||||
|
import { useMemberMe } from "@/queries/member.ts";
|
||||||
|
|
||||||
|
const tokenStore = useMemberTokenStore();
|
||||||
|
const meQuery = useMemberMe();
|
||||||
|
|
||||||
|
const memberName = computed(
|
||||||
|
() => tokenStore.memberName || meQuery.data.value?.name || "",
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="member-layout">
|
||||||
|
<header class="member-layout__header">
|
||||||
|
<div class="member-layout__brand">成员入口</div>
|
||||||
|
<div class="member-layout__name">{{ memberName }}</div>
|
||||||
|
</header>
|
||||||
|
<main class="member-layout__main">
|
||||||
|
<div class="member-layout__surface">
|
||||||
|
<router-view />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* design.md:bg-layout 底 + bg-container 内容面;间距 24/16 对齐 spacing lg/md */
|
||||||
|
.member-layout {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-layout__header {
|
||||||
|
height: 64px;
|
||||||
|
padding: 0 24px;
|
||||||
|
background: #fff;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 {
|
||||||
|
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>
|
||||||
+17
-1
@@ -1,9 +1,25 @@
|
|||||||
import { createApp } from "vue";
|
import { createApp } from "vue";
|
||||||
|
import { createPinia } from "pinia";
|
||||||
|
import { QueryClient, VueQueryPlugin } from "@tanstack/vue-query";
|
||||||
import Antd from "antdv-next";
|
import Antd from "antdv-next";
|
||||||
import "antdv-next/dist/reset.css";
|
import "antdv-next/dist/reset.css";
|
||||||
import App from "./App.vue";
|
import App from "./app.vue";
|
||||||
|
import { bindAuthRedirectToRouter, router } from "./router/index.ts";
|
||||||
import "./assets/main.css";
|
import "./assets/main.css";
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: 1,
|
||||||
|
staleTime: 30_000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const app = createApp(App);
|
const app = createApp(App);
|
||||||
|
app.use(createPinia());
|
||||||
|
bindAuthRedirectToRouter();
|
||||||
|
app.use(router);
|
||||||
|
app.use(VueQueryPlugin, { queryClient });
|
||||||
app.use(Antd);
|
app.use(Antd);
|
||||||
app.mount("#app");
|
app.mount("#app");
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/vue-query";
|
||||||
|
import {
|
||||||
|
fetchKeys,
|
||||||
|
patchKey,
|
||||||
|
registerKey,
|
||||||
|
type KeyListFilters,
|
||||||
|
} from "@/api/admin/keys.ts";
|
||||||
|
import { fetchOverview } from "@/api/admin/overview.ts";
|
||||||
|
import { fetchEngine } from "@/api/admin/engine.ts";
|
||||||
|
import {
|
||||||
|
createPayment,
|
||||||
|
deletePayment,
|
||||||
|
fetchPayments,
|
||||||
|
updatePayment,
|
||||||
|
} from "@/api/admin/payments.ts";
|
||||||
|
import {
|
||||||
|
createMember,
|
||||||
|
fetchMembers,
|
||||||
|
issueMemberToken,
|
||||||
|
patchMember,
|
||||||
|
} from "@/api/admin/members.ts";
|
||||||
|
import { fetchAuditLogs } from "@/api/admin/audit.ts";
|
||||||
|
import { fetchLogs } from "@/api/admin/logs.ts";
|
||||||
|
import { qk, type PageQuery, type TimeRange } from "@/api/query-keys.ts";
|
||||||
|
|
||||||
|
export function useAdminOverview(range: () => TimeRange) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: () => qk.admin.overview(range()),
|
||||||
|
queryFn: () => fetchOverview(range()),
|
||||||
|
// 页面可长时间挂起:定期拉取引擎新写入的数据
|
||||||
|
refetchInterval: 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useEngine() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: qk.admin.engine(),
|
||||||
|
queryFn: fetchEngine,
|
||||||
|
refetchInterval: 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useKeys(filters: () => KeyListFilters) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: () => qk.admin.keys(filters()),
|
||||||
|
queryFn: () => fetchKeys(filters()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRegisterKey() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: registerKey,
|
||||||
|
onSuccess: () => {
|
||||||
|
void qc.invalidateQueries({ queryKey: ["admin", "keys"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePatchKey() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
id,
|
||||||
|
body,
|
||||||
|
}: {
|
||||||
|
id: number;
|
||||||
|
body: { status?: "active" | "disabled"; member_id?: number | null };
|
||||||
|
}) => patchKey(id, body),
|
||||||
|
onSuccess: () => {
|
||||||
|
void qc.invalidateQueries({ queryKey: ["admin", "keys"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAdminLogs(params: () => TimeRange & PageQuery) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: () => qk.admin.logs(params()),
|
||||||
|
queryFn: () => fetchLogs(params()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePayments() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: qk.admin.payments(),
|
||||||
|
queryFn: fetchPayments,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreatePayment() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: createPayment,
|
||||||
|
onSuccess: () => {
|
||||||
|
void qc.invalidateQueries({ queryKey: qk.admin.payments() });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdatePayment() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, body }: { id: number; body: Record<string, unknown> }) =>
|
||||||
|
updatePayment(id, body),
|
||||||
|
onSuccess: () => {
|
||||||
|
void qc.invalidateQueries({ queryKey: qk.admin.payments() });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeletePayment() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: deletePayment,
|
||||||
|
onSuccess: () => {
|
||||||
|
void qc.invalidateQueries({ queryKey: qk.admin.payments() });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMembers() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: qk.admin.members(),
|
||||||
|
queryFn: fetchMembers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateMember() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: createMember,
|
||||||
|
onSuccess: () => {
|
||||||
|
void qc.invalidateQueries({ queryKey: qk.admin.members() });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePatchMember() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
id,
|
||||||
|
body,
|
||||||
|
}: {
|
||||||
|
id: number;
|
||||||
|
body: { status: "active" | "disabled" };
|
||||||
|
}) => patchMember(id, body),
|
||||||
|
onSuccess: () => {
|
||||||
|
void qc.invalidateQueries({ queryKey: qk.admin.members() });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useIssueMemberToken() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: issueMemberToken,
|
||||||
|
onSuccess: () => {
|
||||||
|
void qc.invalidateQueries({ queryKey: qk.admin.members() });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuditLogs(
|
||||||
|
params: () => PageQuery & { domain?: string },
|
||||||
|
) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: () => qk.admin.auditLogs(params()),
|
||||||
|
queryFn: () => fetchAuditLogs(params()),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { useQuery } from "@tanstack/vue-query";
|
||||||
|
import {
|
||||||
|
fetchLogs,
|
||||||
|
fetchMe,
|
||||||
|
fetchOverview,
|
||||||
|
} from "@/api/member/me.ts";
|
||||||
|
import { qk, type PageQuery, type TimeRange } from "@/api/query-keys.ts";
|
||||||
|
|
||||||
|
export function useMemberMe() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: qk.member.me(),
|
||||||
|
queryFn: fetchMe,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMemberOverview(range: () => TimeRange) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: () => qk.member.overview(range()),
|
||||||
|
queryFn: () => fetchOverview(range()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMemberLogs(params: () => TimeRange & PageQuery) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: () => qk.member.logs(params()),
|
||||||
|
queryFn: () => fetchLogs(params()),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/vue-query";
|
||||||
|
import {
|
||||||
|
changePassword,
|
||||||
|
fetchSession,
|
||||||
|
fetchSetupState,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
setup,
|
||||||
|
} from "@/api/admin/session.ts";
|
||||||
|
import { qk } from "@/api/query-keys.ts";
|
||||||
|
import { useAdminSessionStore } from "@/stores/admin-session.ts";
|
||||||
|
|
||||||
|
export function useSetupState() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: qk.admin.setupState(),
|
||||||
|
queryFn: fetchSetupState,
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSession() {
|
||||||
|
const store = useAdminSessionStore();
|
||||||
|
return useQuery({
|
||||||
|
queryKey: qk.admin.session(),
|
||||||
|
queryFn: async () => {
|
||||||
|
const data = await fetchSession();
|
||||||
|
store.signIn(data.username);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
staleTime: 60_000,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLogin() {
|
||||||
|
const store = useAdminSessionStore();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: login,
|
||||||
|
onSuccess: (data) => {
|
||||||
|
store.signIn(data.username);
|
||||||
|
void qc.invalidateQueries({ queryKey: qk.admin.session() });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSetup() {
|
||||||
|
const store = useAdminSessionStore();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: setup,
|
||||||
|
onSuccess: (data) => {
|
||||||
|
store.signIn(data.username);
|
||||||
|
void qc.invalidateQueries({ queryKey: qk.admin.setupState() });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLogout() {
|
||||||
|
const store = useAdminSessionStore();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: logout,
|
||||||
|
onSuccess: () => {
|
||||||
|
store.signOut();
|
||||||
|
qc.clear();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useChangePassword() {
|
||||||
|
return useMutation({ mutationFn: changePassword });
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import type { RouteRecordRaw } from "vue-router";
|
||||||
|
import AdminLayout from "@/layouts/admin-layout.vue";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 管理面路由 `/admin/*`(契约:页面路由永不进入 `/api`)。
|
||||||
|
* 侧边栏六项 + 账号设置(交互共性 §2)。
|
||||||
|
*/
|
||||||
|
export const adminRoutes: RouteRecordRaw[] = [
|
||||||
|
{
|
||||||
|
path: "/admin/login",
|
||||||
|
name: "admin-login",
|
||||||
|
component: () => import("@/views/admin/login-view.vue"),
|
||||||
|
meta: { public: true, world: "admin" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/admin",
|
||||||
|
component: AdminLayout,
|
||||||
|
meta: { requiresAdmin: true, world: "admin" },
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: "",
|
||||||
|
redirect: { name: "admin-overview" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "overview",
|
||||||
|
name: "admin-overview",
|
||||||
|
component: () => import("@/views/admin/overview-view.vue"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "keys",
|
||||||
|
name: "admin-keys",
|
||||||
|
component: () => import("@/views/admin/keys-view.vue"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "payments",
|
||||||
|
name: "admin-payments",
|
||||||
|
component: () => import("@/views/admin/payments-view.vue"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "members",
|
||||||
|
name: "admin-members",
|
||||||
|
component: () => import("@/views/admin/members-view.vue"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "audit",
|
||||||
|
name: "admin-audit",
|
||||||
|
component: () => import("@/views/admin/audit-view.vue"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "settings",
|
||||||
|
name: "admin-settings",
|
||||||
|
component: () => import("@/views/admin/settings-view.vue"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { createRouter, createWebHistory, type Router } from "vue-router";
|
||||||
|
import { adminRoutes } from "./admin.ts";
|
||||||
|
import { memberRoutes } from "./member.ts";
|
||||||
|
import { setAuthRedirect } from "@/api/auth-redirect.ts";
|
||||||
|
import { useAdminSessionStore } from "@/stores/admin-session.ts";
|
||||||
|
import { useMemberTokenStore } from "@/stores/member-token.ts";
|
||||||
|
|
||||||
|
declare module "vue-router" {
|
||||||
|
interface RouteMeta {
|
||||||
|
public?: boolean;
|
||||||
|
requiresAdmin?: boolean;
|
||||||
|
requiresMember?: boolean;
|
||||||
|
world?: "admin" | "member";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const router: Router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes: [
|
||||||
|
{ path: "/", redirect: "/admin/overview" },
|
||||||
|
...adminRoutes,
|
||||||
|
...memberRoutes,
|
||||||
|
{ path: "/:pathMatch(.*)*", redirect: "/admin/overview" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
router.beforeEach((to) => {
|
||||||
|
if (to.meta.public) return true;
|
||||||
|
|
||||||
|
const memberToken = useMemberTokenStore();
|
||||||
|
|
||||||
|
// 管理面:硬刷新后 Pinia 为空但会话 cookie 仍在——交给 GET /session 与 401 分流裁决
|
||||||
|
if (to.meta.requiresMember && !memberToken.hasToken) {
|
||||||
|
return { name: "member-login" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 401 分流接入 router(由 main.ts 调用一次) */
|
||||||
|
export function bindAuthRedirectToRouter(): void {
|
||||||
|
setAuthRedirect((world, reason) => {
|
||||||
|
const adminSession = useAdminSessionStore();
|
||||||
|
const memberToken = useMemberTokenStore();
|
||||||
|
if (world === "admin") {
|
||||||
|
adminSession.signOut();
|
||||||
|
void router.push({
|
||||||
|
name: "admin-login",
|
||||||
|
query: reason === "expired" ? { reason: "expired" } : {},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
memberToken.clearToken();
|
||||||
|
void router.push({
|
||||||
|
name: "member-login",
|
||||||
|
query: reason === "expired" ? { reason: "expired" } : {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { RouteRecordRaw } from "vue-router";
|
||||||
|
import MemberLayout from "@/layouts/member-layout.vue";
|
||||||
|
|
||||||
|
/** 成员视图 `/member/*`:登录壳 → 单页(交互共性 §1) */
|
||||||
|
export const memberRoutes: RouteRecordRaw[] = [
|
||||||
|
{
|
||||||
|
path: "/member/login",
|
||||||
|
name: "member-login",
|
||||||
|
component: () => import("@/views/member/login-view.vue"),
|
||||||
|
meta: { public: true, world: "member" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/member",
|
||||||
|
component: MemberLayout,
|
||||||
|
meta: { requiresMember: true, world: "member" },
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: "",
|
||||||
|
name: "member-home",
|
||||||
|
component: () => import("@/views/member/home-view.vue"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { defineStore } from "pinia";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 管理面登录态(客户端镜像)。
|
||||||
|
* 服务端事实以 GET /api/admin/session 为准——刷新后由 queries/session 恢复。
|
||||||
|
*/
|
||||||
|
export const useAdminSessionStore = defineStore("admin-session", {
|
||||||
|
state: () => ({
|
||||||
|
username: "" as string,
|
||||||
|
}),
|
||||||
|
getters: {
|
||||||
|
isLoggedIn: (s) => s.username.length > 0,
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
signIn(username: string) {
|
||||||
|
this.username = username;
|
||||||
|
},
|
||||||
|
signOut() {
|
||||||
|
this.username = "";
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { defineStore } from "pinia";
|
||||||
|
|
||||||
|
const STORAGE_KEY = "member_token";
|
||||||
|
|
||||||
|
function readToken(): string {
|
||||||
|
try {
|
||||||
|
return sessionStorage.getItem(STORAGE_KEY) ?? "";
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeToken(token: string): void {
|
||||||
|
try {
|
||||||
|
if (token) sessionStorage.setItem(STORAGE_KEY, token);
|
||||||
|
else sessionStorage.removeItem(STORAGE_KEY);
|
||||||
|
} catch {
|
||||||
|
// 隐私模式等场景忽略
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 成员明文令牌——仅存在于登录壳输入与 sessionStorage(交互登录篇 §4)。
|
||||||
|
* 无登出端点:关标签即失效;作者重发令牌后旧令牌下一请求自然 401。
|
||||||
|
*/
|
||||||
|
export const useMemberTokenStore = defineStore("member-token", {
|
||||||
|
state: () => ({
|
||||||
|
token: readToken(),
|
||||||
|
memberName: "",
|
||||||
|
}),
|
||||||
|
getters: {
|
||||||
|
hasToken: (s) => s.token.length > 0,
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
setToken(token: string) {
|
||||||
|
this.token = token;
|
||||||
|
writeToken(token);
|
||||||
|
},
|
||||||
|
setMemberName(name: string) {
|
||||||
|
this.memberName = name;
|
||||||
|
},
|
||||||
|
clearToken() {
|
||||||
|
this.token = "";
|
||||||
|
this.memberName = "";
|
||||||
|
writeToken("");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/** 呈现层格式化(不做业务换算——换算责任在后端,契约公共约定 §5) */
|
||||||
|
|
||||||
|
/** 千分位整数;口径内精确显示,不四舍五入 */
|
||||||
|
export function formatInt(value: number | null | undefined): string {
|
||||||
|
if (value == null) return "—";
|
||||||
|
return value.toLocaleString("en-US");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** quota 原值 + 美元双单位展示片段 */
|
||||||
|
export function formatQuotaDual(
|
||||||
|
quota: number | null | undefined,
|
||||||
|
quotaUsd: string | null | undefined,
|
||||||
|
): string {
|
||||||
|
if (quota == null) return "—";
|
||||||
|
const usd = quotaUsd ?? "—";
|
||||||
|
return `${formatInt(quota)}($${usd})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 支付金额:纯数字、无货币符号(R9 币种无关) */
|
||||||
|
export function formatPaidAmount(value: number | null | undefined): string {
|
||||||
|
if (value == null) return "—";
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* 时间展示:API 响应时刻为 Unix 秒(UTC);界面一律北京时间(交互共性 §3.3)。
|
||||||
|
*/
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import utc from "dayjs/plugin/utc";
|
||||||
|
import timezone from "dayjs/plugin/timezone";
|
||||||
|
|
||||||
|
dayjs.extend(utc);
|
||||||
|
dayjs.extend(timezone);
|
||||||
|
|
||||||
|
/** 北京时区展示(固定 +08:00,不随主机时区变) */
|
||||||
|
const DISPLAY_TZ = "Asia/Shanghai";
|
||||||
|
|
||||||
|
/** Unix 秒 → 北京时间「YYYY-MM-DD HH:mm:ss」 */
|
||||||
|
export function formatDateTime(unixSec: number | null | undefined): string {
|
||||||
|
if (unixSec == null) return "—";
|
||||||
|
return dayjs.unix(unixSec).tz(DISPLAY_TZ).format("YYYY-MM-DD HH:mm:ss");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unix 秒 → 北京时间「YYYY-MM-DD」(统计口径到日) */
|
||||||
|
export function formatDate(unixSec: number | null | undefined): string {
|
||||||
|
if (unixSec == null) return "—";
|
||||||
|
return dayjs.unix(unixSec).tz(DISPLAY_TZ).format("YYYY-MM-DD");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unix 秒 → 北京时间「HH:mm:ss」(明细时刻) */
|
||||||
|
export function formatTimeOfDay(unixSec: number | null | undefined): string {
|
||||||
|
if (unixSec == null) return "—";
|
||||||
|
return dayjs.unix(unixSec).tz(DISPLAY_TZ).format("HH:mm:ss");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 北京时间日初 → 请求用 ISO 8601 UTC(契约 §4:from/to 收 ISO) */
|
||||||
|
export function beijingDayStartToIso(date: string): string {
|
||||||
|
return dayjs.tz(date, DISPLAY_TZ).startOf("day").toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 北京时间日界(次日零点,左闭右开的 to)→ ISO UTC */
|
||||||
|
export function beijingDayEndToIso(date: string): string {
|
||||||
|
return dayjs.tz(date, DISPLAY_TZ).add(1, "day").startOf("day").toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RangeValue = { from: string; to: string };
|
||||||
|
|
||||||
|
/** 快捷项:最近 N 天(含今日),返回 [from, to) 的 ISO UTC */
|
||||||
|
export function recentDaysRange(
|
||||||
|
days: number,
|
||||||
|
nowMs: number = Date.now(),
|
||||||
|
): { from: string; to: string } {
|
||||||
|
const to = dayjs(nowMs).tz(DISPLAY_TZ).add(1, "day").startOf("day");
|
||||||
|
const from = to.subtract(days, "day");
|
||||||
|
return { from: from.toISOString(), to: to.toISOString() };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 统计口径标注:「2026-09-02 ~ 10-01」(北京时间日) */
|
||||||
|
export function formatRangeCaption(fromIso: string, toIso: string): string {
|
||||||
|
if (!fromIso || !toIso) return "—";
|
||||||
|
const from = dayjs(fromIso).tz(DISPLAY_TZ).format("YYYY-MM-DD");
|
||||||
|
const to = dayjs(toIso).tz(DISPLAY_TZ).format("YYYY-MM-DD");
|
||||||
|
return `${from} ~ ${to}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 「数据截止」到分钟(交互共性 §3.3) */
|
||||||
|
export function formatMinute(unixSec: number | null | undefined): string {
|
||||||
|
if (unixSec == null) return "—";
|
||||||
|
return dayjs.unix(unixSec).tz(DISPLAY_TZ).format("YYYY-MM-DD HH:mm");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 距今小时数(用于数据截止停滞判定) */
|
||||||
|
export function hoursSince(unixSec: number | null | undefined): number | null {
|
||||||
|
if (unixSec == null) return null;
|
||||||
|
return (Date.now() / 1000 - unixSec) / 3600;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import PageHeader from "@/components/common/page-header.vue";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section>
|
||||||
|
<PageHeader
|
||||||
|
title="审计日志"
|
||||||
|
subtitle="管理操作留痕(信息结构见交互设计-审计日志)"
|
||||||
|
/>
|
||||||
|
<a-skeleton active :paragraph="{ rows: 4 }" />
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,368 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* Key 管理(交互设计-key管理):清单表 · 行内归属/启停 · 登记 · 停滞显形
|
||||||
|
*/
|
||||||
|
import { computed, h, ref, watch } from "vue";
|
||||||
|
import { Tooltip, message } from "antdv-next";
|
||||||
|
import { QuestionCircleOutlined } from "@antdv-next/icons";
|
||||||
|
import { useRoute, useRouter } from "vue-router";
|
||||||
|
import type { KeyItem } from "@new-api-key-usage/core";
|
||||||
|
import type { ApiError } from "@/api/http.ts";
|
||||||
|
import PageHeader from "@/components/common/page-header.vue";
|
||||||
|
import RegisterKeyModal from "@/components/admin/register-key-modal.vue";
|
||||||
|
import { useEngine, useKeys, useMembers, usePatchKey } from "@/queries/admin.ts";
|
||||||
|
import { formatInt } from "@/utils/format.ts";
|
||||||
|
import { formatMinute, hoursSince } from "@/utils/time.ts";
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const registerOpen = ref(false);
|
||||||
|
const highlightKeyId = ref<number | null>(null);
|
||||||
|
|
||||||
|
const filterMember = ref<number | "unassigned" | null>(null);
|
||||||
|
const filterStatus = ref<"active" | "disabled" | null>(null);
|
||||||
|
const filterRecentFailed = ref(false);
|
||||||
|
|
||||||
|
/** URL → 筛选(总览跳入) */
|
||||||
|
function syncFromRoute() {
|
||||||
|
const q = route.query;
|
||||||
|
const m = q.member_id;
|
||||||
|
if (m === "unassigned") filterMember.value = "unassigned";
|
||||||
|
else if (typeof m === "string" && m) filterMember.value = Number(m);
|
||||||
|
else filterMember.value = null;
|
||||||
|
|
||||||
|
if (q.status === "active" || q.status === "disabled") filterStatus.value = q.status;
|
||||||
|
else filterStatus.value = null;
|
||||||
|
|
||||||
|
filterRecentFailed.value = q.recent_failed === "1" || q.recent_failed === "true";
|
||||||
|
|
||||||
|
const kid = q.key_id;
|
||||||
|
highlightKeyId.value = typeof kid === "string" && kid ? Number(kid) : null;
|
||||||
|
}
|
||||||
|
syncFromRoute();
|
||||||
|
watch(
|
||||||
|
() => route.query,
|
||||||
|
() => syncFromRoute(),
|
||||||
|
);
|
||||||
|
|
||||||
|
function pushQuery() {
|
||||||
|
const query: Record<string, string> = {};
|
||||||
|
if (filterMember.value === "unassigned") query.member_id = "unassigned";
|
||||||
|
else if (filterMember.value != null) query.member_id = String(filterMember.value);
|
||||||
|
if (filterStatus.value) query.status = filterStatus.value;
|
||||||
|
if (filterRecentFailed.value) query.recent_failed = "1";
|
||||||
|
if (highlightKeyId.value != null) query.key_id = String(highlightKeyId.value);
|
||||||
|
void router.replace({ path: "/admin/keys", query });
|
||||||
|
}
|
||||||
|
|
||||||
|
watch([filterMember, filterStatus, filterRecentFailed], () => {
|
||||||
|
pushQuery();
|
||||||
|
});
|
||||||
|
|
||||||
|
const filters = computed(() => ({
|
||||||
|
member_id: filterMember.value ?? undefined,
|
||||||
|
status: filterStatus.value ?? undefined,
|
||||||
|
recent_failed: filterRecentFailed.value ? true : undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const keysQuery = useKeys(() => filters.value);
|
||||||
|
const engineQuery = useEngine();
|
||||||
|
const membersQuery = useMembers();
|
||||||
|
const patchKey = usePatchKey();
|
||||||
|
|
||||||
|
const rows = computed(() => {
|
||||||
|
const list = [...(keysQuery.data.value ?? [])];
|
||||||
|
// 异常在前:无截止/停滞 → 旧截止 → 新截止(交互:停滞预警)
|
||||||
|
list.sort((a, b) => {
|
||||||
|
const ta = a.log_synced_at ?? 0;
|
||||||
|
const tb = b.log_synced_at ?? 0;
|
||||||
|
return ta - tb;
|
||||||
|
});
|
||||||
|
return list;
|
||||||
|
});
|
||||||
|
|
||||||
|
const failedCount = computed(
|
||||||
|
() => engineQuery.data.value?.last_round?.failed ?? 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasActiveFilter = computed(
|
||||||
|
() =>
|
||||||
|
filterMember.value != null ||
|
||||||
|
filterStatus.value != null ||
|
||||||
|
filterRecentFailed.value,
|
||||||
|
);
|
||||||
|
|
||||||
|
const memberOptions = computed(() => [
|
||||||
|
{ value: "unassigned", label: "未归属" },
|
||||||
|
...(membersQuery.data.value ?? []).map((m) => ({ value: m.id, label: m.name })),
|
||||||
|
]);
|
||||||
|
|
||||||
|
function isStalled(logSyncedAt: number | null | undefined): boolean {
|
||||||
|
const h = hoursSince(logSyncedAt);
|
||||||
|
return h != null && h > 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tipTitle(label: string, tip: string) {
|
||||||
|
return h("span", { class: "overview-col-title" }, [
|
||||||
|
label,
|
||||||
|
h(
|
||||||
|
Tooltip,
|
||||||
|
{ title: tip },
|
||||||
|
() =>
|
||||||
|
h(QuestionCircleOutlined, {
|
||||||
|
class: "overview-col-title__icon",
|
||||||
|
"aria-hidden": "true",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ title: "名称", key: "token_name", width: 160 },
|
||||||
|
{ title: "归属", key: "member_name", width: 140 },
|
||||||
|
{ title: "状态", key: "status", width: 100 },
|
||||||
|
{
|
||||||
|
title: tipTitle(
|
||||||
|
"数据截止",
|
||||||
|
"该 key 最近一次日志同步成功的时刻;落后超过 2 小时标为停滞。检查引擎日志;若持续 401 请停用",
|
||||||
|
),
|
||||||
|
key: "data_cutoff",
|
||||||
|
width: 170,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: tipTitle("累计已用", "上游累计已消耗额度(quota)"),
|
||||||
|
key: "total_used",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: tipTitle("剩余", "上游剩余可用额度;不限额度显示「不限」"),
|
||||||
|
key: "total_available",
|
||||||
|
},
|
||||||
|
{ title: "登记时间", key: "registered_at", width: 160 },
|
||||||
|
{ title: "操作", key: "actions", width: 120 },
|
||||||
|
];
|
||||||
|
|
||||||
|
async function onMemberChange(
|
||||||
|
record: KeyItem,
|
||||||
|
memberId: number | "unassigned" | null,
|
||||||
|
) {
|
||||||
|
const next = memberId === "unassigned" || memberId == null ? null : memberId;
|
||||||
|
try {
|
||||||
|
await patchKey.mutateAsync({ id: record.id, body: { member_id: next } });
|
||||||
|
message.success("归属已更新");
|
||||||
|
} catch (e) {
|
||||||
|
message.error((e as ApiError).message || "归属更新失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onToggleStatus(record: KeyItem) {
|
||||||
|
const next = record.status === "active" ? "disabled" : "active";
|
||||||
|
try {
|
||||||
|
await patchKey.mutateAsync({ id: record.id, body: { status: next } });
|
||||||
|
if (next === "disabled") {
|
||||||
|
message.success("已停用——移出轮询清单,数据保留");
|
||||||
|
} else {
|
||||||
|
message.success("已启用——下一轮同步(≤5 分钟)将自动验证");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
message.error((e as ApiError).message || "状态更新失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRegistered(keyId: number) {
|
||||||
|
highlightKeyId.value = keyId;
|
||||||
|
pushQuery();
|
||||||
|
void keysQuery.refetch();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFailedClick() {
|
||||||
|
filterRecentFailed.value = !filterRecentFailed.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRowClass(record: KeyItem) {
|
||||||
|
return record.id === highlightKeyId.value ? "key-row--highlight" : "";
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section>
|
||||||
|
<PageHeader title="Key 管理" subtitle="登记、启停、归属与同步显形">
|
||||||
|
<template #actions>
|
||||||
|
<a-button type="primary" @click="registerOpen = true">登记 Key</a-button>
|
||||||
|
</template>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<div class="keys-toolbar">
|
||||||
|
<span class="keys-toolbar__summary">
|
||||||
|
<template v-if="failedCount > 0">
|
||||||
|
最近轮次失败
|
||||||
|
<a-button type="link" size="small" @click="onFailedClick">
|
||||||
|
{{ failedCount }} 枚
|
||||||
|
</a-button>
|
||||||
|
(点击筛选)
|
||||||
|
</template>
|
||||||
|
<template v-else>最近轮次无失败 key</template>
|
||||||
|
</span>
|
||||||
|
<a-space wrap>
|
||||||
|
<a-select
|
||||||
|
v-model:value="filterMember"
|
||||||
|
:options="[
|
||||||
|
{ value: null, label: '全部成员' },
|
||||||
|
{ value: 'unassigned', label: '未归属' },
|
||||||
|
...(membersQuery.data.value?.map((m) => ({ value: m.id, label: m.name })) ?? []),
|
||||||
|
]"
|
||||||
|
style="width: 140px"
|
||||||
|
placeholder="成员"
|
||||||
|
allow-clear
|
||||||
|
/>
|
||||||
|
<a-select
|
||||||
|
v-model:value="filterStatus"
|
||||||
|
:options="[
|
||||||
|
{ value: null, label: '全部状态' },
|
||||||
|
{ value: 'active', label: '启用中' },
|
||||||
|
{ value: 'disabled', label: '已停用' },
|
||||||
|
]"
|
||||||
|
style="width: 120px"
|
||||||
|
placeholder="状态"
|
||||||
|
allow-clear
|
||||||
|
/>
|
||||||
|
<a-checkbox v-model:checked="filterRecentFailed">最近轮次失败</a-checkbox>
|
||||||
|
</a-space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a-empty
|
||||||
|
v-if="!keysQuery.isPending.value && rows.length === 0"
|
||||||
|
:description="
|
||||||
|
hasActiveFilter
|
||||||
|
? '当前筛选条件下没有 key——可清除筛选,或登记新的 key'
|
||||||
|
: '还没有登记任何 key——把你在 NewAPI 创建的 sk-key 粘贴进来'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<a-button v-if="!hasActiveFilter" type="primary" @click="registerOpen = true">
|
||||||
|
登记 Key
|
||||||
|
</a-button>
|
||||||
|
</a-empty>
|
||||||
|
|
||||||
|
<a-table
|
||||||
|
v-else
|
||||||
|
:columns="columns"
|
||||||
|
:data-source="rows"
|
||||||
|
:row-key="(r: KeyItem) => r.id"
|
||||||
|
:loading="keysQuery.isPending.value || keysQuery.isFetching.value"
|
||||||
|
:pagination="false"
|
||||||
|
:scroll="{ x: 1100 }"
|
||||||
|
:row-class-name="onRowClass"
|
||||||
|
size="middle"
|
||||||
|
>
|
||||||
|
<template #bodyCell="{ column, record }">
|
||||||
|
<template v-if="column.key === 'token_name'">
|
||||||
|
{{ record.token_name }}
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'member_name'">
|
||||||
|
<a-select
|
||||||
|
:value="record.member_id == null ? 'unassigned' : record.member_id"
|
||||||
|
:options="memberOptions"
|
||||||
|
size="small"
|
||||||
|
style="width: 120px"
|
||||||
|
@change="(v: number | 'unassigned') => onMemberChange(record, v)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'status'">
|
||||||
|
<a-badge
|
||||||
|
:status="record.status === 'active' ? 'success' : 'default'"
|
||||||
|
:text="record.status === 'active' ? '启用中' : '已停用'"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'data_cutoff'">
|
||||||
|
<a-tooltip
|
||||||
|
v-if="isStalled(record.log_synced_at)"
|
||||||
|
title="检查引擎日志;若持续 401 请停用"
|
||||||
|
>
|
||||||
|
<span class="keys-stall">{{ formatMinute(record.log_synced_at) }} 停滞</span>
|
||||||
|
</a-tooltip>
|
||||||
|
<span v-else-if="record.log_synced_at == null" class="keys-syncing">
|
||||||
|
同步中…
|
||||||
|
</span>
|
||||||
|
<span v-else>{{ formatMinute(record.log_synced_at) }}</span>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'total_used'">
|
||||||
|
<span class="keys-num">{{ formatInt(record.total_used) }}</span>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'total_available'">
|
||||||
|
<span class="keys-num">
|
||||||
|
{{ record.unlimited_quota ? "不限" : formatInt(record.total_available) }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'registered_at'">
|
||||||
|
{{ formatMinute(record.registered_at) }}
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'actions'">
|
||||||
|
<a-popconfirm
|
||||||
|
v-if="record.status === 'active'"
|
||||||
|
title="停用后移出轮询清单(数据保留)。成员若仍在用它调用 API 不受影响——只有采集停止。"
|
||||||
|
ok-text="停用"
|
||||||
|
cancel-text="取消"
|
||||||
|
@confirm="() => onToggleStatus(record)"
|
||||||
|
>
|
||||||
|
<a-button size="small" danger>停用</a-button>
|
||||||
|
</a-popconfirm>
|
||||||
|
<a-button v-else size="small" @click="() => onToggleStatus(record)">
|
||||||
|
启用
|
||||||
|
</a-button>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</a-table>
|
||||||
|
|
||||||
|
<RegisterKeyModal
|
||||||
|
v-model:open="registerOpen"
|
||||||
|
:members="membersQuery.data.value ?? []"
|
||||||
|
@registered="onRegistered"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.keys-toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.keys-toolbar__summary {
|
||||||
|
font-size: 13px;
|
||||||
|
color: rgba(0, 0, 0, 0.65);
|
||||||
|
}
|
||||||
|
|
||||||
|
.keys-stall {
|
||||||
|
color: #d48806;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.keys-syncing {
|
||||||
|
color: rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.keys-num {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.key-row--highlight) {
|
||||||
|
background: #e6f4ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-col-title {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-col-title__icon {
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(0, 0, 0, 0.45);
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/** 登录 + 空库三态分流(交互登录篇) */
|
||||||
|
import type { FormInstance } from "antdv-next";
|
||||||
|
import { computed, reactive, ref, shallowRef } from "vue";
|
||||||
|
import { useRoute, useRouter } from "vue-router";
|
||||||
|
import { useLogin, useSetup, useSetupState } from "@/queries/session.ts";
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const setupState = useSetupState();
|
||||||
|
const login = useLogin();
|
||||||
|
const setup = useSetup();
|
||||||
|
const formRef = shallowRef<FormInstance>();
|
||||||
|
|
||||||
|
const model = reactive({
|
||||||
|
username: "",
|
||||||
|
password: "",
|
||||||
|
passwordConfirm: "",
|
||||||
|
});
|
||||||
|
const error = ref("");
|
||||||
|
|
||||||
|
const isSetup = computed(() => setupState.data.value?.initialized === false);
|
||||||
|
const loading = computed(() => login.isPending.value || setup.isPending.value);
|
||||||
|
|
||||||
|
async function onFinish() {
|
||||||
|
error.value = "";
|
||||||
|
try {
|
||||||
|
if (isSetup.value) {
|
||||||
|
if (model.password !== model.passwordConfirm) {
|
||||||
|
error.value = "两次输入的密码不一致";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await setup.mutateAsync({
|
||||||
|
username: model.username,
|
||||||
|
password: model.password,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await login.mutateAsync({
|
||||||
|
username: model.username,
|
||||||
|
password: model.password,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const redirect = route.query.redirect;
|
||||||
|
void router.replace(typeof redirect === "string" ? redirect : "/admin/overview");
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : "登录失败";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="login-shell">
|
||||||
|
<a-card class="login-shell__card" :title="isSetup ? '初始化管理员' : '管理面登录'">
|
||||||
|
<a-form
|
||||||
|
ref="formRef"
|
||||||
|
:model="model"
|
||||||
|
layout="vertical"
|
||||||
|
auto-complete="off"
|
||||||
|
@finish="onFinish"
|
||||||
|
>
|
||||||
|
<a-form-item
|
||||||
|
name="username"
|
||||||
|
label="用户名"
|
||||||
|
:rules="[{ required: true, message: '请输入用户名' }]"
|
||||||
|
>
|
||||||
|
<a-input v-model:value="model.username" autocomplete="username" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item
|
||||||
|
name="password"
|
||||||
|
label="密码"
|
||||||
|
:rules="[{ required: true, message: '请输入密码' }]"
|
||||||
|
>
|
||||||
|
<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"
|
||||||
|
/>
|
||||||
|
<a-button type="primary" html-type="submit" block :loading="loading">
|
||||||
|
{{ isSetup ? "完成设置" : "登录" }}
|
||||||
|
</a-button>
|
||||||
|
</a-form>
|
||||||
|
</a-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.login-shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #f0f2f5;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-shell__card {
|
||||||
|
width: 380px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import PageHeader from "@/components/common/page-header.vue";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section>
|
||||||
|
<PageHeader
|
||||||
|
title="成员管理"
|
||||||
|
subtitle="开户、令牌、停用(信息结构见交互设计-成员管理)"
|
||||||
|
/>
|
||||||
|
<a-skeleton active :paragraph="{ rows: 4 }" />
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 全员总览(交互设计-全员总览):健康带 · 时间范围 · 按成员 · 按 key
|
||||||
|
*/
|
||||||
|
import { computed, h, ref } from "vue";
|
||||||
|
import { Tooltip } from "antdv-next";
|
||||||
|
import { QuestionCircleOutlined } from "@antdv-next/icons";
|
||||||
|
import { useRouter } from "vue-router";
|
||||||
|
import PageHeader from "@/components/common/page-header.vue";
|
||||||
|
import TimeRangePicker from "@/components/common/time-range-picker.vue";
|
||||||
|
import QuotaText from "@/components/common/quota-text.vue";
|
||||||
|
import SourceBar from "@/components/common/source-bar.vue";
|
||||||
|
import HealthBand from "@/components/admin/health-band.vue";
|
||||||
|
import {
|
||||||
|
useLiveTimeRange,
|
||||||
|
type RangeKind,
|
||||||
|
type RangeValue,
|
||||||
|
} from "@/composables/use-live-time-range.ts";
|
||||||
|
import { useAdminOverview, useEngine, usePayments } from "@/queries/admin.ts";
|
||||||
|
import { formatMinute } from "@/utils/time.ts";
|
||||||
|
import { formatInt } from "@/utils/format.ts";
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const { range, setRange } = useLiveTimeRange("recent-30");
|
||||||
|
|
||||||
|
const overviewQuery = useAdminOverview(() => range.value);
|
||||||
|
const engineQuery = useEngine();
|
||||||
|
const paymentsQuery = usePayments();
|
||||||
|
|
||||||
|
const overview = computed(() => overviewQuery.data.value);
|
||||||
|
const engine = computed(() => engineQuery.data.value);
|
||||||
|
const loading = computed(
|
||||||
|
() => overviewQuery.isPending.value || engineQuery.isPending.value,
|
||||||
|
);
|
||||||
|
|
||||||
|
/** 「本订阅期间」等预置:有 period 的订阅记录才出现(纯用量模式自动隐藏) */
|
||||||
|
const extraPresets = computed(() => {
|
||||||
|
const list = paymentsQuery.data.value ?? [];
|
||||||
|
return list
|
||||||
|
.filter((p) => p.kind === "subscription" && p.period_start != null)
|
||||||
|
.map((p) => ({
|
||||||
|
label: `订阅 #${p.subscription_id ?? p.id} 期间`,
|
||||||
|
from: new Date((p.period_start ?? 0) * 1000).toISOString(),
|
||||||
|
to: new Date(
|
||||||
|
(p.period_end ?? Math.floor(Date.now() / 1000)) * 1000,
|
||||||
|
).toISOString(),
|
||||||
|
}))
|
||||||
|
.slice(0, 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
const byMember = computed(() => overview.value?.by_member ?? []);
|
||||||
|
const byKey = computed(() => overview.value?.by_key ?? []);
|
||||||
|
const bySource = computed(() => overview.value?.by_source ?? []);
|
||||||
|
|
||||||
|
const totalUsage = computed(() => {
|
||||||
|
const rows = byMember.value;
|
||||||
|
const count = rows.reduce((s, r) => s + r.usage.count, 0);
|
||||||
|
const quota = rows.reduce((s, r) => s + r.usage.quota, 0);
|
||||||
|
// 比例展示用 quota;美元仅示意合计(后端已逐行换算,这里加总展示仅供参考)
|
||||||
|
return { count, quota };
|
||||||
|
});
|
||||||
|
|
||||||
|
function memberPercent(quota: number): number {
|
||||||
|
const total = totalUsage.value.quota;
|
||||||
|
return total > 0 ? Math.round((quota / total) * 1000) / 10 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRangeChange(value: RangeValue, kind: RangeKind) {
|
||||||
|
setRange(value, kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMemberClick(memberId: number | null) {
|
||||||
|
void router.push({
|
||||||
|
path: "/admin/keys",
|
||||||
|
query: memberId == null ? { member_id: "unassigned" } : { member_id: String(memberId) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeyClick(keyId: number) {
|
||||||
|
void router.push({ path: "/admin/keys", query: { key_id: String(keyId) } });
|
||||||
|
}
|
||||||
|
|
||||||
|
function goFailedKeys() {
|
||||||
|
void router.push({ path: "/admin/keys", query: { recent_failed: "1" } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 表头:标题 + 问号说明 */
|
||||||
|
function tipTitle(label: string, tip: string) {
|
||||||
|
return h("span", { class: "overview-col-title" }, [
|
||||||
|
label,
|
||||||
|
h(
|
||||||
|
Tooltip,
|
||||||
|
{ title: tip },
|
||||||
|
() =>
|
||||||
|
h(QuestionCircleOutlined, {
|
||||||
|
class: "overview-col-title__icon",
|
||||||
|
"aria-hidden": "true",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const memberColumns = [
|
||||||
|
{ title: "成员", key: "member_name" },
|
||||||
|
{ title: "名下 Key 数", key: "key_count" },
|
||||||
|
{
|
||||||
|
title: tipTitle(
|
||||||
|
"调用次数",
|
||||||
|
"本时段内该成员名下的消费日志条数(type=2,一次调用一行)",
|
||||||
|
),
|
||||||
|
key: "count",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: tipTitle(
|
||||||
|
"额度消耗",
|
||||||
|
"本时段消耗的 quota 原值,括号内为按当时换算率折算的美元",
|
||||||
|
),
|
||||||
|
key: "usage",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: tipTitle(
|
||||||
|
"用量占比",
|
||||||
|
"该成员本时段 quota ÷ 全员同期 quota。只是用量事实占比,不是费用分摊",
|
||||||
|
),
|
||||||
|
key: "share",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: tipTitle(
|
||||||
|
"同步截止",
|
||||||
|
"该成员名下各 key 的 log_synced_at 里最慢的一刻(北京时间)",
|
||||||
|
),
|
||||||
|
key: "data_cutoff",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const keyColumns = [
|
||||||
|
{ title: "Key 名称", dataIndex: "token_name", key: "token_name" },
|
||||||
|
{ title: "归属成员", key: "member_name" },
|
||||||
|
{
|
||||||
|
title: tipTitle("当前状态", "启用中才会进入同步轮询;已停用仍可查历史"),
|
||||||
|
key: "status",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: tipTitle("同步截止", "该枚 key 最近一次成功写入日志的同步时刻"),
|
||||||
|
key: "data_cutoff",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: tipTitle("调用次数", "本时段消费日志条数(type=2)"),
|
||||||
|
key: "count",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: tipTitle("额度消耗", "本时段 quota 原值与按当时换算率折算的美元"),
|
||||||
|
key: "usage",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section>
|
||||||
|
<PageHeader
|
||||||
|
title="全员总览"
|
||||||
|
subtitle="健康与用量全景 · 仅统计消费行(type=2)"
|
||||||
|
class="overview-header"
|
||||||
|
>
|
||||||
|
<template #actions>
|
||||||
|
<span class="overview-range-label">时间范围</span>
|
||||||
|
<TimeRangePicker
|
||||||
|
:model-value="range"
|
||||||
|
:extra-presets="extraPresets"
|
||||||
|
@change="onRangeChange"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<HealthBand
|
||||||
|
:engine="engine"
|
||||||
|
:overview="overview"
|
||||||
|
:loading="loading"
|
||||||
|
@go-failed-keys="goFailedKeys"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<a-alert
|
||||||
|
v-if="overviewQuery.isError.value"
|
||||||
|
type="error"
|
||||||
|
show-icon
|
||||||
|
message="总览数据加载失败"
|
||||||
|
:description="overviewQuery.error.value?.message"
|
||||||
|
style="margin-bottom: 16px"
|
||||||
|
>
|
||||||
|
<template #action>
|
||||||
|
<a-button size="small" @click="() => overviewQuery.refetch()">重试</a-button>
|
||||||
|
</template>
|
||||||
|
</a-alert>
|
||||||
|
|
||||||
|
<!-- 来源分解 -->
|
||||||
|
<section class="overview-block">
|
||||||
|
<h2 class="overview-block__title">按来源分解</h2>
|
||||||
|
<SourceBar :rows="bySource" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 按成员 -->
|
||||||
|
<section class="overview-block">
|
||||||
|
<h2 class="overview-block__title">按成员汇总</h2>
|
||||||
|
<a-empty
|
||||||
|
v-if="!loading && byMember.length === 0"
|
||||||
|
description="本时段内没有任何消费调用,因此没有成员用量可汇总"
|
||||||
|
/>
|
||||||
|
<a-table
|
||||||
|
v-else
|
||||||
|
:columns="memberColumns"
|
||||||
|
:data-source="byMember"
|
||||||
|
:row-key="(r: { member_id: number | null }) => r.member_id ?? 'unassigned'"
|
||||||
|
size="middle"
|
||||||
|
:pagination="false"
|
||||||
|
:scroll="{ x: 900 }"
|
||||||
|
:loading="overviewQuery.isFetching.value"
|
||||||
|
>
|
||||||
|
<template #bodyCell="{ column, record }">
|
||||||
|
<template v-if="column.key === 'member_name'">
|
||||||
|
<a @click="onMemberClick(record.member_id)">
|
||||||
|
{{ record.member_name ?? "未归属" }}
|
||||||
|
</a>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'key_count'">
|
||||||
|
{{ record.key_count }}
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'count'">
|
||||||
|
{{ formatInt(record.usage.count) }}
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'usage'">
|
||||||
|
<QuotaText :quota="record.usage.quota" :quota-usd="record.usage.quota_usd" />
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'share'">
|
||||||
|
<span class="overview-share-cell__num">
|
||||||
|
{{ memberPercent(record.usage.quota) }}%
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'data_cutoff'">
|
||||||
|
{{ formatMinute(record.data_cutoff) }}
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</a-table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 按 key -->
|
||||||
|
<section class="overview-block">
|
||||||
|
<h2 class="overview-block__title">按 key 明细</h2>
|
||||||
|
<a-table
|
||||||
|
:columns="keyColumns"
|
||||||
|
:data-source="byKey"
|
||||||
|
:row-key="(r: { key_id: number }) => r.key_id"
|
||||||
|
size="middle"
|
||||||
|
:pagination="false"
|
||||||
|
:scroll="{ x: 900 }"
|
||||||
|
:loading="overviewQuery.isFetching.value"
|
||||||
|
>
|
||||||
|
<template #bodyCell="{ column, record }">
|
||||||
|
<template v-if="column.key === 'member_name'">
|
||||||
|
{{
|
||||||
|
record.member_name ??
|
||||||
|
(record.member_id == null
|
||||||
|
? "未归属"
|
||||||
|
: `成员 #${record.member_id}`)
|
||||||
|
}}
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'status'">
|
||||||
|
<a-badge
|
||||||
|
:status="record.status === 'active' ? 'success' : 'default'"
|
||||||
|
:text="record.status === 'active' ? '启用中' : '已停用'"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'data_cutoff'">
|
||||||
|
{{ formatMinute(record.data_cutoff) }}
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'count'">
|
||||||
|
{{ formatInt(record.usage.count) }}
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'usage'">
|
||||||
|
<QuotaText :quota="record.usage.quota" :quota-usd="record.usage.quota_usd" />
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'token_name'">
|
||||||
|
<a @click="onKeyClick(record.key_id)">{{ record.token_name }}</a>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</a-table>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.overview-range-label {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 32px;
|
||||||
|
color: rgba(0, 0, 0, 0.65);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-block {
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-block__title {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 22px;
|
||||||
|
color: rgba(0, 0, 0, 0.88);
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-col-title {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-col-title__icon {
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(0, 0, 0, 0.45);
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-share-cell__num {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: rgba(0, 0, 0, 0.88);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import PageHeader from "@/components/common/page-header.vue";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section>
|
||||||
|
<PageHeader
|
||||||
|
title="支付记录"
|
||||||
|
subtitle="订阅 / 充值登记与核对(信息结构见交互设计-支付记录)"
|
||||||
|
/>
|
||||||
|
<a-skeleton active :paragraph="{ rows: 4 }" />
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import PageHeader from "@/components/common/page-header.vue";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section>
|
||||||
|
<PageHeader title="账号设置" subtitle="修改密码" />
|
||||||
|
<a-skeleton active :paragraph="{ rows: 3 }" />
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import PageHeader from "@/components/common/page-header.vue";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section>
|
||||||
|
<PageHeader
|
||||||
|
title="我的用量"
|
||||||
|
subtitle="聚合 · 来源徽章 · 明细(信息结构见交互设计-成员视图)"
|
||||||
|
/>
|
||||||
|
<a-skeleton active :paragraph="{ rows: 4 }" />
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/** 成员令牌登录壳——明文仅存 sessionStorage(交互登录篇 §4) */
|
||||||
|
import type { FormInstance } from "antdv-next";
|
||||||
|
import { reactive, ref, shallowRef } from "vue";
|
||||||
|
import { useRouter } from "vue-router";
|
||||||
|
import { useMemberTokenStore } from "@/stores/member-token.ts";
|
||||||
|
import { fetchMe } from "@/api/member/me.ts";
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const tokenStore = useMemberTokenStore();
|
||||||
|
const formRef = shallowRef<FormInstance>();
|
||||||
|
const model = reactive({ token: "" });
|
||||||
|
const error = ref("");
|
||||||
|
const pending = ref(false);
|
||||||
|
|
||||||
|
async function onFinish() {
|
||||||
|
error.value = "";
|
||||||
|
pending.value = true;
|
||||||
|
try {
|
||||||
|
tokenStore.setToken(model.token.trim());
|
||||||
|
const me = await fetchMe();
|
||||||
|
tokenStore.setMemberName(me.name);
|
||||||
|
void router.replace({ name: "member-home" });
|
||||||
|
} catch {
|
||||||
|
tokenStore.clearToken();
|
||||||
|
error.value = "令牌无效或已停用";
|
||||||
|
} finally {
|
||||||
|
pending.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="member-login-shell">
|
||||||
|
<a-card class="member-login-shell__card" title="成员入口">
|
||||||
|
<a-form
|
||||||
|
ref="formRef"
|
||||||
|
:model="model"
|
||||||
|
layout="vertical"
|
||||||
|
auto-complete="off"
|
||||||
|
@finish="onFinish"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.member-login-shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #f0f2f5;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-login-shell__card {
|
||||||
|
width: 380px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+20
-12
@@ -1,20 +1,28 @@
|
|||||||
import { fileURLToPath, URL } from 'node:url'
|
import { fileURLToPath, URL } from "node:url";
|
||||||
|
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from "vite";
|
||||||
import vue from '@vitejs/plugin-vue'
|
import vue from "@vitejs/plugin-vue";
|
||||||
import vueJsx from '@vitejs/plugin-vue-jsx'
|
import vueJsx from "@vitejs/plugin-vue-jsx";
|
||||||
import vueDevTools from 'vite-plugin-vue-devtools'
|
import vueDevTools from "vite-plugin-vue-devtools";
|
||||||
|
|
||||||
|
/** 后端母体端口(与根 .env 的 PORT / AGENTS 约定一致) */
|
||||||
|
const API_TARGET = process.env.VITE_API_PROXY_TARGET ?? "http://127.0.0.1:18456";
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
plugins: [vue(), vueJsx(), vueDevTools()],
|
||||||
vue(),
|
|
||||||
vueJsx(),
|
|
||||||
vueDevTools(),
|
|
||||||
],
|
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
"@": fileURLToPath(new URL("./src", import.meta.url)),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
server: {
|
||||||
|
proxy: {
|
||||||
|
// 契约:/api/* 专属接口;SPA 路由不进 /api(公共约定 §2)
|
||||||
|
"/api": {
|
||||||
|
target: API_TARGET,
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -36,8 +36,13 @@
|
|||||||
"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:*",
|
||||||
"antdv-next": "^1.6.0",
|
"@tanstack/vue-query": "^5.104.1",
|
||||||
|
"@vueuse/core": "15.0.0",
|
||||||
|
"antdv-next": "1.6.0",
|
||||||
|
"axios": "^1.20.0",
|
||||||
|
"dayjs": "1.11.23",
|
||||||
"pinia": "^4.0.3",
|
"pinia": "^4.0.3",
|
||||||
"vue": "^3.5.42",
|
"vue": "^3.5.42",
|
||||||
"vue-router": "^5.3.1",
|
"vue-router": "^5.3.1",
|
||||||
@@ -91,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=="],
|
||||||
|
|
||||||
@@ -401,6 +406,12 @@
|
|||||||
|
|
||||||
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||||
|
|
||||||
|
"@tanstack/match-sorter-utils": ["@tanstack/match-sorter-utils@9.2.6", "", { "dependencies": { "remove-accents": "0.5.0" } }, "sha512-DIpUSOhPfQ7u/qfUi/Wgm0wBO56MyBYXGvnlI0NcauDRPqdBUlAZZjW2Dxm92m+k1UWVhKw8YJ4f47+75Aujyw=="],
|
||||||
|
|
||||||
|
"@tanstack/query-core": ["@tanstack/query-core@5.104.1", "", {}, "sha512-TiRghcrGkUTE+M4JBxvpPSpBhxvIzQairw+cksrHYGr+2uwC77yW1SY9nYxnYRYOppvucf8rEg6b5byoz4rTpw=="],
|
||||||
|
|
||||||
|
"@tanstack/vue-query": ["@tanstack/vue-query@5.104.1", "", { "dependencies": { "@tanstack/match-sorter-utils": "^9.1.2", "@tanstack/query-core": "5.104.1", "@vue/devtools-api": "^6.6.3", "vue-demi": "^0.14.10" }, "peerDependencies": { "@vue/composition-api": "^1.1.2", "vue": "^2.6.0 || ^3.3.0" }, "optionalPeers": ["@vue/composition-api"] }, "sha512-3858wOmIoIazR9mZ2g8/QsKNccQlzxIY+4/g55OHbhhMeBEi+ig/8vne2e5N+Xx9g5MXuB7Nscfb6B4REPDWlw=="],
|
||||||
|
|
||||||
"@tsconfig/node24": ["@tsconfig/node24@24.0.5", "", {}, "sha512-DWC54fGPuhZgg3Ighg4Y/A/bA3ZdB5hnzAuv/MkQ/X85sVsBsMQxu8t7khutdYeiNksBbFdDyeqv1HSW2Zl8aA=="],
|
"@tsconfig/node24": ["@tsconfig/node24@24.0.5", "", {}, "sha512-DWC54fGPuhZgg3Ighg4Y/A/bA3ZdB5hnzAuv/MkQ/X85sVsBsMQxu8t7khutdYeiNksBbFdDyeqv1HSW2Zl8aA=="],
|
||||||
|
|
||||||
"@types/bun": ["@types/bun@1.4.2", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="],
|
"@types/bun": ["@types/bun@1.4.2", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="],
|
||||||
@@ -611,7 +622,7 @@
|
|||||||
|
|
||||||
"@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.43", "", { "dependencies": { "@vue/compiler-dom": "3.5.43", "@vue/shared": "3.5.43" } }, "sha512-GF62orf7KiJX9RqrHNGrYBudsQGD0OhJ5nDs90O8UiDuS40+YMYomiXu6w6EuvtXuRDc3MSNis3EaaSKAVSWpg=="],
|
"@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.43", "", { "dependencies": { "@vue/compiler-dom": "3.5.43", "@vue/shared": "3.5.43" } }, "sha512-GF62orf7KiJX9RqrHNGrYBudsQGD0OhJ5nDs90O8UiDuS40+YMYomiXu6w6EuvtXuRDc3MSNis3EaaSKAVSWpg=="],
|
||||||
|
|
||||||
"@vue/devtools-api": ["@vue/devtools-api@8.2.1", "", { "dependencies": { "@vue/devtools-kit": "^8.2.1" } }, "sha512-6u4vXBlIBAC1wMplIZgpyPn7uh/s4Bf6F5bMzvLv+EdJ0aHs/+4B7Ygv864EStQSjRbsRzTko/kUG1A1IejQ3A=="],
|
"@vue/devtools-api": ["@vue/devtools-api@6.6.4", "", {}, "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g=="],
|
||||||
|
|
||||||
"@vue/devtools-core": ["@vue/devtools-core@8.2.1", "", { "dependencies": { "@vue/devtools-kit": "^8.2.1", "@vue/devtools-shared": "^8.2.1" }, "peerDependencies": { "vue": "^3.0.0" } }, "sha512-s/VfAY9oDTb/kFEWmy461jaFde2MIV1RO/gi1vwM+PAZBZ/Pc2Ndu3BNBdZUze8QDUuyYvElbEEGA83syjJfzA=="],
|
"@vue/devtools-core": ["@vue/devtools-core@8.2.1", "", { "dependencies": { "@vue/devtools-kit": "^8.2.1", "@vue/devtools-shared": "^8.2.1" }, "peerDependencies": { "vue": "^3.0.0" } }, "sha512-s/VfAY9oDTb/kFEWmy461jaFde2MIV1RO/gi1vwM+PAZBZ/Pc2Ndu3BNBdZUze8QDUuyYvElbEEGA83syjJfzA=="],
|
||||||
|
|
||||||
@@ -637,11 +648,11 @@
|
|||||||
|
|
||||||
"@vue/tsconfig": ["@vue/tsconfig@0.9.1", "", { "peerDependencies": { "typescript": ">= 5.8", "vue": "^3.4.0" }, "optionalPeers": ["typescript", "vue"] }, "sha512-buvjm+9NzLCJL29KY1j1991YYJ5e6275OiK+G4jtmfIb+z4POywbdm0wXusT9adVWqe0xqg70TbI7+mRx4uU9w=="],
|
"@vue/tsconfig": ["@vue/tsconfig@0.9.1", "", { "peerDependencies": { "typescript": ">= 5.8", "vue": "^3.4.0" }, "optionalPeers": ["typescript", "vue"] }, "sha512-buvjm+9NzLCJL29KY1j1991YYJ5e6275OiK+G4jtmfIb+z4POywbdm0wXusT9adVWqe0xqg70TbI7+mRx4uU9w=="],
|
||||||
|
|
||||||
"@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=="],
|
"@vueuse/core": ["@vueuse/core@15.0.0", "", { "dependencies": { "@types/web-bluetooth": "^0.0.21", "@vueuse/metadata": "15.0.0", "@vueuse/shared": "15.0.0" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-Zv67h5mrk7YHDJsRObx+DXaCBz6RKBUpxqLesJ+3xZNPG6yuQs7ja7kg3veFrrouYLWAqydQ7CwGEbLVxB43Ng=="],
|
||||||
|
|
||||||
"@vueuse/metadata": ["@vueuse/metadata@14.4.0", "", {}, "sha512-swx/255R6JyHZFJhx845iz5CRWDZdCfvkZOpACWc5+c5WHcG24mv8gUT1WIdFQaHt6dq79rvILd9QnCWiyVm9g=="],
|
"@vueuse/metadata": ["@vueuse/metadata@15.0.0", "", {}, "sha512-sfWE5LADAKJ0KnjtGliMdSN8Ij/L7uP6rXIZlgPloAI4zvvefbw6H5Lt7KJA6RqQOYgVqWF5Guj50HHMIzygBg=="],
|
||||||
|
|
||||||
"@vueuse/shared": ["@vueuse/shared@14.4.0", "", { "peerDependencies": { "vue": "^3.5.0" } }, "sha512-JRgY90Sz8DDtPMsaDflvPMp9xYk69JZAmbuDvAquUVXKr2gEjqtzGNTTthLfckH0BzBqvnu31gb4a8TGLRe79g=="],
|
"@vueuse/shared": ["@vueuse/shared@15.0.0", "", { "peerDependencies": { "vue": "^3.5.0" } }, "sha512-vbNXhGrbV53xPOMSyOzMHQRvhMgZf7oNDw2RChUetVxwTIEkInQ1joZ9JbT0G3z2n2AJNjvmCWeM9ySYrnvogA=="],
|
||||||
|
|
||||||
"abbrev": ["abbrev@5.0.0", "", {}, "sha512-/XrFJgzQQQHpti1raDJC6m4ws6aNktmjBlhk8Fdlk7LwCEuDoieEJJY9OFHjfiFJFFRM2tK+Ky/IsfbbmlMu1w=="],
|
"abbrev": ["abbrev@5.0.0", "", {}, "sha512-/XrFJgzQQQHpti1raDJC6m4ws6aNktmjBlhk8Fdlk7LwCEuDoieEJJY9OFHjfiFJFFRM2tK+Ky/IsfbbmlMu1w=="],
|
||||||
|
|
||||||
@@ -649,6 +660,8 @@
|
|||||||
|
|
||||||
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
|
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
|
||||||
|
|
||||||
|
"agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="],
|
||||||
|
|
||||||
"ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="],
|
"ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="],
|
||||||
|
|
||||||
"alien-signals": ["alien-signals@3.2.1", "", {}, "sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g=="],
|
"alien-signals": ["alien-signals@3.2.1", "", {}, "sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g=="],
|
||||||
@@ -665,6 +678,10 @@
|
|||||||
|
|
||||||
"ast-walker-scope": ["ast-walker-scope@0.9.0", "", { "dependencies": { "@babel/parser": "^7.29.2", "@babel/types": "^7.29.0", "ast-kit": "^2.2.0" } }, "sha512-IJdzo2vLiElBxKzwS36VsCue/62d6IdWjnPB2v3nuPKeWGynp6FF/CYoLa5i/3jXH/z97ZDdsXz6abpgM6w07A=="],
|
"ast-walker-scope": ["ast-walker-scope@0.9.0", "", { "dependencies": { "@babel/parser": "^7.29.2", "@babel/types": "^7.29.0", "ast-kit": "^2.2.0" } }, "sha512-IJdzo2vLiElBxKzwS36VsCue/62d6IdWjnPB2v3nuPKeWGynp6FF/CYoLa5i/3jXH/z97ZDdsXz6abpgM6w07A=="],
|
||||||
|
|
||||||
|
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
|
||||||
|
|
||||||
|
"axios": ["axios@1.20.0", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.6", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg=="],
|
||||||
|
|
||||||
"balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
"balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
||||||
|
|
||||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.11.27", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-ElY12DaROGuan+lMmZ8Cvo/ZUbXPe7Enc/9VU/b1T3Kp4dwytRcNdR8DoSJN5SNJT/CuvcCA0DHDVmMOCePdRQ=="],
|
"baseline-browser-mapping": ["baseline-browser-mapping@2.11.27", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-ElY12DaROGuan+lMmZ8Cvo/ZUbXPe7Enc/9VU/b1T3Kp4dwytRcNdR8DoSJN5SNJT/CuvcCA0DHDVmMOCePdRQ=="],
|
||||||
@@ -689,12 +706,16 @@
|
|||||||
|
|
||||||
"cacheable": ["cacheable@2.5.0", "", { "dependencies": { "@cacheable/memory": "^2.2.0", "@cacheable/utils": "^2.5.0", "hookified": "^1.15.0", "keyv": "^5.6.0", "qified": "^0.10.1" } }, "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g=="],
|
"cacheable": ["cacheable@2.5.0", "", { "dependencies": { "@cacheable/memory": "^2.2.0", "@cacheable/utils": "^2.5.0", "hookified": "^1.15.0", "keyv": "^5.6.0", "qified": "^0.10.1" } }, "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g=="],
|
||||||
|
|
||||||
|
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
||||||
|
|
||||||
"caniuse-lite": ["caniuse-lite@1.0.30001814", "", {}, "sha512-/Uaf1lAzr59XcMpW0o96WoEfr+VXK2OX4U9AgFoiSHsVJ4HppnIFUjtYzsyDH2+tgANaQb2/oxYGwCPapN1FpA=="],
|
"caniuse-lite": ["caniuse-lite@1.0.30001814", "", {}, "sha512-/Uaf1lAzr59XcMpW0o96WoEfr+VXK2OX4U9AgFoiSHsVJ4HppnIFUjtYzsyDH2+tgANaQb2/oxYGwCPapN1FpA=="],
|
||||||
|
|
||||||
"chai": ["chai@6.3.0", "", {}, "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg=="],
|
"chai": ["chai@6.3.0", "", {}, "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg=="],
|
||||||
|
|
||||||
"chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
|
"chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
|
||||||
|
|
||||||
|
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
|
||||||
|
|
||||||
"commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
|
"commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
|
||||||
|
|
||||||
"compute-scroll-into-view": ["compute-scroll-into-view@3.1.1", "", {}, "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw=="],
|
"compute-scroll-into-view": ["compute-scroll-into-view@3.1.1", "", {}, "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw=="],
|
||||||
@@ -729,12 +750,16 @@
|
|||||||
|
|
||||||
"define-lazy-prop": ["define-lazy-prop@3.0.0", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="],
|
"define-lazy-prop": ["define-lazy-prop@3.0.0", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="],
|
||||||
|
|
||||||
|
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
|
||||||
|
|
||||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||||
|
|
||||||
"drizzle-kit": ["drizzle-kit@0.31.11", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "tsx": "^4.21.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-YCYqxTLIB2OCqf6w9/Vef13baBVbwQIPcbT4Y56AfO6B9ajZhDhD8Jkveg/hJvT/iuMloKD/IYYkyMMNhr7Kqg=="],
|
"drizzle-kit": ["drizzle-kit@0.31.11", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "tsx": "^4.21.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-YCYqxTLIB2OCqf6w9/Vef13baBVbwQIPcbT4Y56AfO6B9ajZhDhD8Jkveg/hJvT/iuMloKD/IYYkyMMNhr7Kqg=="],
|
||||||
|
|
||||||
"drizzle-orm": ["drizzle-orm@0.45.3", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@netlify/db": ">=0.4.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "prisma": "*", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@netlify/db", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "prisma", "sql.js", "sqlite3"] }, "sha512-CAloER21cDdcgZ1OmjrZX82EeRNsdP+y0onX/eyoYDaYzki/adrAa91lzM5jWh7zZPK4qUbw/6LGv9J+A+uizA=="],
|
"drizzle-orm": ["drizzle-orm@0.45.3", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@netlify/db": ">=0.4.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "prisma": "*", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@netlify/db", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "prisma", "sql.js", "sqlite3"] }, "sha512-CAloER21cDdcgZ1OmjrZX82EeRNsdP+y0onX/eyoYDaYzki/adrAa91lzM5jWh7zZPK4qUbw/6LGv9J+A+uizA=="],
|
||||||
|
|
||||||
|
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||||
|
|
||||||
"editorconfig": ["editorconfig@3.0.2", "", { "dependencies": { "@one-ini/wasm": "0.2.1", "commander": "^14.0.3", "minimatch": "~10.2.4", "semver": "^7.7.4" }, "bin": { "editorconfig": "bin/editorconfig" } }, "sha512-T0ix8GhtxyKVfUFEcvdNDt3YGqlwkFHbD4/5bgFUDgFmxhI/cSRAeJ87/Sz//Cq8Eam6JX/e23RkoFO71P7aAA=="],
|
"editorconfig": ["editorconfig@3.0.2", "", { "dependencies": { "@one-ini/wasm": "0.2.1", "commander": "^14.0.3", "minimatch": "~10.2.4", "semver": "^7.7.4" }, "bin": { "editorconfig": "bin/editorconfig" } }, "sha512-T0ix8GhtxyKVfUFEcvdNDt3YGqlwkFHbD4/5bgFUDgFmxhI/cSRAeJ87/Sz//Cq8Eam6JX/e23RkoFO71P7aAA=="],
|
||||||
|
|
||||||
"electron-to-chromium": ["electron-to-chromium@1.5.444", "", {}, "sha512-5ss/uJfoDYDHT0lfJzT6FbcskIzROIOPf0BbbFkGcvDzoJU7i//9GDrwwIHQVmIsrAGiF3ihpADBRIsrEFt1rQ=="],
|
"electron-to-chromium": ["electron-to-chromium@1.5.444", "", {}, "sha512-5ss/uJfoDYDHT0lfJzT6FbcskIzROIOPf0BbbFkGcvDzoJU7i//9GDrwwIHQVmIsrAGiF3ihpADBRIsrEFt1rQ=="],
|
||||||
@@ -743,8 +768,16 @@
|
|||||||
|
|
||||||
"error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="],
|
"error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="],
|
||||||
|
|
||||||
|
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
||||||
|
|
||||||
|
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
||||||
|
|
||||||
"es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="],
|
"es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="],
|
||||||
|
|
||||||
|
"es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="],
|
||||||
|
|
||||||
|
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
|
||||||
|
|
||||||
"es-toolkit": ["es-toolkit@1.52.0", "", {}, "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA=="],
|
"es-toolkit": ["es-toolkit@1.52.0", "", {}, "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA=="],
|
||||||
|
|
||||||
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
|
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
|
||||||
@@ -803,18 +836,36 @@
|
|||||||
|
|
||||||
"flatted": ["flatted@3.4.4", "", {}, "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q=="],
|
"flatted": ["flatted@3.4.4", "", {}, "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q=="],
|
||||||
|
|
||||||
|
"follow-redirects": ["follow-redirects@1.16.1", "", { "peerDependencies": { "debug": "*" }, "optionalPeers": ["debug"] }, "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg=="],
|
||||||
|
|
||||||
|
"form-data": ["form-data@4.0.6", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.4", "mime-types": "^2.1.35" } }, "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ=="],
|
||||||
|
|
||||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||||
|
|
||||||
|
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||||
|
|
||||||
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
|
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
|
||||||
|
|
||||||
|
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
||||||
|
|
||||||
|
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
|
||||||
|
|
||||||
"get-tsconfig": ["get-tsconfig@4.14.3", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA=="],
|
"get-tsconfig": ["get-tsconfig@4.14.3", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA=="],
|
||||||
|
|
||||||
"glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="],
|
"glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="],
|
||||||
|
|
||||||
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
|
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
|
||||||
|
|
||||||
|
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
|
||||||
|
|
||||||
|
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
|
||||||
|
|
||||||
|
"has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
|
||||||
|
|
||||||
"hashery": ["hashery@1.5.1", "", { "dependencies": { "hookified": "^1.15.0" } }, "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ=="],
|
"hashery": ["hashery@1.5.1", "", { "dependencies": { "hookified": "^1.15.0" } }, "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ=="],
|
||||||
|
|
||||||
|
"hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="],
|
||||||
|
|
||||||
"hono": ["hono@4.13.13", "", {}, "sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA=="],
|
"hono": ["hono@4.13.13", "", {}, "sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA=="],
|
||||||
|
|
||||||
"hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="],
|
"hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="],
|
||||||
@@ -823,6 +874,8 @@
|
|||||||
|
|
||||||
"html-encoding-sniffer": ["html-encoding-sniffer@7.0.0", "", { "dependencies": { "@exodus/bytes": "^1.15.1" } }, "sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w=="],
|
"html-encoding-sniffer": ["html-encoding-sniffer@7.0.0", "", { "dependencies": { "@exodus/bytes": "^1.15.1" } }, "sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w=="],
|
||||||
|
|
||||||
|
"https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="],
|
||||||
|
|
||||||
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
||||||
|
|
||||||
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
|
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
|
||||||
@@ -909,6 +962,8 @@
|
|||||||
|
|
||||||
"magic-string-ast": ["magic-string-ast@1.0.3", "", { "dependencies": { "magic-string": "^0.30.19" } }, "sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA=="],
|
"magic-string-ast": ["magic-string-ast@1.0.3", "", { "dependencies": { "magic-string": "^0.30.19" } }, "sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA=="],
|
||||||
|
|
||||||
|
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||||
|
|
||||||
"mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="],
|
"mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="],
|
||||||
|
|
||||||
"memorystream": ["memorystream@0.3.1", "", {}, "sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw=="],
|
"memorystream": ["memorystream@0.3.1", "", {}, "sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw=="],
|
||||||
@@ -917,6 +972,10 @@
|
|||||||
|
|
||||||
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
|
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
|
||||||
|
|
||||||
|
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||||
|
|
||||||
|
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||||
|
|
||||||
"minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="],
|
"minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="],
|
||||||
|
|
||||||
"minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
|
"minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
|
||||||
@@ -1013,6 +1072,8 @@
|
|||||||
|
|
||||||
"proto-list": ["proto-list@1.2.4", "", {}, "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA=="],
|
"proto-list": ["proto-list@1.2.4", "", {}, "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA=="],
|
||||||
|
|
||||||
|
"proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="],
|
||||||
|
|
||||||
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||||
|
|
||||||
"qified": ["qified@0.10.1", "", { "dependencies": { "hookified": "^2.1.1" } }, "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA=="],
|
"qified": ["qified@0.10.1", "", { "dependencies": { "hookified": "^2.1.1" } }, "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA=="],
|
||||||
@@ -1025,6 +1086,8 @@
|
|||||||
|
|
||||||
"readdirp": ["readdirp@5.1.1", "", {}, "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA=="],
|
"readdirp": ["readdirp@5.1.1", "", {}, "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA=="],
|
||||||
|
|
||||||
|
"remove-accents": ["remove-accents@0.5.0", "", {}, "sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A=="],
|
||||||
|
|
||||||
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
|
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
|
||||||
|
|
||||||
"resize-observer-polyfill": ["resize-observer-polyfill@1.5.1", "", {}, "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg=="],
|
"resize-observer-polyfill": ["resize-observer-polyfill@1.5.1", "", {}, "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg=="],
|
||||||
@@ -1141,6 +1204,8 @@
|
|||||||
|
|
||||||
"vue-component-type-helpers": ["vue-component-type-helpers@3.3.12", "", {}, "sha512-HwCqmgTfLFskQc4P5TRnbduCMyPat2Ezu0elLWOGn0dD/6BKHP15B5bxce6aDi+E4kblPtru8amIBQzu0Di3qQ=="],
|
"vue-component-type-helpers": ["vue-component-type-helpers@3.3.12", "", {}, "sha512-HwCqmgTfLFskQc4P5TRnbduCMyPat2Ezu0elLWOGn0dD/6BKHP15B5bxce6aDi+E4kblPtru8amIBQzu0Di3qQ=="],
|
||||||
|
|
||||||
|
"vue-demi": ["vue-demi@0.14.10", "", { "peerDependencies": { "@vue/composition-api": "^1.0.0-rc.1", "vue": "^3.0.0-0 || ^2.6.0" }, "optionalPeers": ["@vue/composition-api"], "bin": { "vue-demi-fix": "bin/vue-demi-fix.js", "vue-demi-switch": "bin/vue-demi-switch.js" } }, "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg=="],
|
||||||
|
|
||||||
"vue-eslint-parser": ["vue-eslint-parser@10.4.1", "", { "dependencies": { "debug": "^4.4.0", "eslint-scope": "^8.2.0 || ^9.0.0", "eslint-visitor-keys": "^4.2.0 || ^5.0.0", "espree": "^10.3.0 || ^11.0.0", "esquery": "^1.6.0", "semver": "^7.6.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0" } }, "sha512-Gk6gRDj0n/fkRa3C3l0bBheoBckUq/Rs0F/TvMWIS6nzzx67amAViMe9CkNgsP2tXyQONvGiHQESHwFtZ3aYDA=="],
|
"vue-eslint-parser": ["vue-eslint-parser@10.4.1", "", { "dependencies": { "debug": "^4.4.0", "eslint-scope": "^8.2.0 || ^9.0.0", "eslint-visitor-keys": "^4.2.0 || ^5.0.0", "espree": "^10.3.0 || ^11.0.0", "esquery": "^1.6.0", "semver": "^7.6.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0" } }, "sha512-Gk6gRDj0n/fkRa3C3l0bBheoBckUq/Rs0F/TvMWIS6nzzx67amAViMe9CkNgsP2tXyQONvGiHQESHwFtZ3aYDA=="],
|
||||||
|
|
||||||
"vue-router": ["vue-router@5.3.1", "", { "dependencies": { "@vue-macros/common": "^3.1.3", "@vue/devtools-api": "^8.1.5", "ast-walker-scope": "^0.9.0", "chokidar": "^5.0.0", "confbox": "^0.2.4", "local-pkg": "^1.2.1", "magic-string": "^0.30.21", "mlly": "^1.8.2", "muggle-string": "^0.4.1", "nostics": "^1.1.4", "pathe": "^2.0.3", "picomatch": "^4.0.5", "scule": "^1.3.0", "tinyglobby": "^0.2.17", "unplugin": "^3.3.0", "unplugin-utils": "^0.3.2" }, "peerDependencies": { "@pinia/colada": ">=0.21.2", "@vue/compiler-sfc": "^3.5.34 || ^4.0.0", "pinia": "^3.0.4 || ^4.0.2", "vite": "^7.3.0 || ^8.0.0", "vue": "^3.5.34 || ^4.0.0" }, "optionalPeers": ["@pinia/colada", "@vue/compiler-sfc", "pinia", "vite"] }, "sha512-GDBZzgmILxA/kFnkFbJjQZdZ2QQbngnIMMuoUcjhZIfH1RGMaPjPwX5ASnV38qamuA9uhO0RDjSBHTDNG2uXyQ=="],
|
"vue-router": ["vue-router@5.3.1", "", { "dependencies": { "@vue-macros/common": "^3.1.3", "@vue/devtools-api": "^8.1.5", "ast-walker-scope": "^0.9.0", "chokidar": "^5.0.0", "confbox": "^0.2.4", "local-pkg": "^1.2.1", "magic-string": "^0.30.21", "mlly": "^1.8.2", "muggle-string": "^0.4.1", "nostics": "^1.1.4", "pathe": "^2.0.3", "picomatch": "^4.0.5", "scule": "^1.3.0", "tinyglobby": "^0.2.17", "unplugin": "^3.3.0", "unplugin-utils": "^0.3.2" }, "peerDependencies": { "@pinia/colada": ">=0.21.2", "@vue/compiler-sfc": "^3.5.34 || ^4.0.0", "pinia": "^3.0.4 || ^4.0.2", "vite": "^7.3.0 || ^8.0.0", "vue": "^3.5.34 || ^4.0.0" }, "optionalPeers": ["@pinia/colada", "@vue/compiler-sfc", "pinia", "vite"] }, "sha512-GDBZzgmILxA/kFnkFbJjQZdZ2QQbngnIMMuoUcjhZIfH1RGMaPjPwX5ASnV38qamuA9uhO0RDjSBHTDNG2uXyQ=="],
|
||||||
@@ -1179,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=="],
|
||||||
|
|
||||||
@@ -1211,6 +1276,10 @@
|
|||||||
|
|
||||||
"@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=="],
|
||||||
|
|
||||||
"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=="],
|
||||||
|
|
||||||
"cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
"cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||||
@@ -1221,6 +1290,8 @@
|
|||||||
|
|
||||||
"mlly/pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="],
|
"mlly/pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="],
|
||||||
|
|
||||||
|
"pinia/@vue/devtools-api": ["@vue/devtools-api@8.2.1", "", { "dependencies": { "@vue/devtools-kit": "^8.2.1" } }, "sha512-6u4vXBlIBAC1wMplIZgpyPn7uh/s4Bf6F5bMzvLv+EdJ0aHs/+4B7Ygv864EStQSjRbsRzTko/kUG1A1IejQ3A=="],
|
||||||
|
|
||||||
"pkg-types/confbox": ["confbox@0.3.1", "", {}, "sha512-cKUSoKa8YxFZZSmraVi7onONx3amu77ngK3kGpsYHDH7drPwCRkQE1RYMPlLRrMtnciRj274XNRxcHxnKmDSnA=="],
|
"pkg-types/confbox": ["confbox@0.3.1", "", {}, "sha512-cKUSoKa8YxFZZSmraVi7onONx3amu77ngK3kGpsYHDH7drPwCRkQE1RYMPlLRrMtnciRj274XNRxcHxnKmDSnA=="],
|
||||||
|
|
||||||
"qified/hookified": ["hookified@2.2.0", "", {}, "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA=="],
|
"qified/hookified": ["hookified@2.2.0", "", {}, "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA=="],
|
||||||
@@ -1231,7 +1302,7 @@
|
|||||||
|
|
||||||
"vite-plugin-vue-inspector/@vue/babel-plugin-jsx": ["@vue/babel-plugin-jsx@1.5.0", "", { "dependencies": { "@babel/helper-module-imports": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.2", "@vue/babel-helper-vue-transform-on": "1.5.0", "@vue/babel-plugin-resolve-type": "1.5.0", "@vue/shared": "^3.5.18" }, "peerDependencies": { "@babel/core": "^7.0.0-0" }, "optionalPeers": ["@babel/core"] }, "sha512-mneBhw1oOqCd2247O0Yw/mRwC9jIGACAJUlawkmMBiNmL4dGA2eMzuNZVNqOUfYTa6vqmND4CtOPzmEEEqLKFw=="],
|
"vite-plugin-vue-inspector/@vue/babel-plugin-jsx": ["@vue/babel-plugin-jsx@1.5.0", "", { "dependencies": { "@babel/helper-module-imports": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.2", "@vue/babel-helper-vue-transform-on": "1.5.0", "@vue/babel-plugin-resolve-type": "1.5.0", "@vue/shared": "^3.5.18" }, "peerDependencies": { "@babel/core": "^7.0.0-0" }, "optionalPeers": ["@babel/core"] }, "sha512-mneBhw1oOqCd2247O0Yw/mRwC9jIGACAJUlawkmMBiNmL4dGA2eMzuNZVNqOUfYTa6vqmND4CtOPzmEEEqLKFw=="],
|
||||||
|
|
||||||
"@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=="],
|
"vue-router/@vue/devtools-api": ["@vue/devtools-api@8.2.1", "", { "dependencies": { "@vue/devtools-kit": "^8.2.1" } }, "sha512-6u4vXBlIBAC1wMplIZgpyPn7uh/s4Bf6F5bMzvLv+EdJ0aHs/+4B7Ygv864EStQSjRbsRzTko/kUG1A1IejQ3A=="],
|
||||||
|
|
||||||
"@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=="],
|
||||||
|
|
||||||
@@ -1281,6 +1352,10 @@
|
|||||||
|
|
||||||
"@types/pg/@types/node/undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
|
"@types/pg/@types/node/undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
|
||||||
|
|
||||||
|
"antdv-next/@vueuse/core/@vueuse/metadata": ["@vueuse/metadata@14.4.0", "", {}, "sha512-swx/255R6JyHZFJhx845iz5CRWDZdCfvkZOpACWc5+c5WHcG24mv8gUT1WIdFQaHt6dq79rvILd9QnCWiyVm9g=="],
|
||||||
|
|
||||||
|
"antdv-next/@vueuse/core/@vueuse/shared": ["@vueuse/shared@14.4.0", "", { "peerDependencies": { "vue": "^3.5.0" } }, "sha512-JRgY90Sz8DDtPMsaDflvPMp9xYk69JZAmbuDvAquUVXKr2gEjqtzGNTTthLfckH0BzBqvnu31gb4a8TGLRe79g=="],
|
||||||
|
|
||||||
"bun-types/@types/node/undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
|
"bun-types/@types/node/undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
|
||||||
|
|
||||||
"cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
"cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||||
|
|||||||
Reference in New Issue
Block a user