feat(web): 前端逻辑骨架——http/router/stores/queries 与薄壳路由
This commit is contained in:
@@ -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,38 @@
|
||||
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;
|
||||
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,7 @@
|
||||
<script setup lang="ts">
|
||||
// 路由出口——逻辑骨架阶段无装饰
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
@@ -0,0 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useAdminSessionStore } from "@/stores/admin-session.ts";
|
||||
import { useLogout, useSession } from "@/queries/session.ts";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const adminSession = useAdminSessionStore();
|
||||
// 刷新后恢复侧边栏用户名(契约 GET /session)
|
||||
const sessionQuery = useSession();
|
||||
const logout = useLogout();
|
||||
|
||||
const username = computed(() => adminSession.username || sessionQuery.data.value?.username || "");
|
||||
|
||||
const menus = [
|
||||
{ key: "/admin/overview", label: "总览" },
|
||||
{ key: "/admin/keys", label: "Key 管理" },
|
||||
{ key: "/admin/payments", label: "支付记录" },
|
||||
{ key: "/admin/members", label: "成员管理" },
|
||||
{ key: "/admin/audit", label: "审计日志" },
|
||||
{ key: "/admin/settings", label: "账号设置" },
|
||||
];
|
||||
|
||||
const selected = computed(() => route.path);
|
||||
|
||||
async function onLogout() {
|
||||
await logout.mutateAsync().catch(() => undefined);
|
||||
void router.push({ name: "admin-login" });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-layout">
|
||||
<aside class="admin-layout__sider">
|
||||
<div class="admin-layout__brand">new-api-key-usage</div>
|
||||
<nav>
|
||||
<router-link
|
||||
v-for="item in menus"
|
||||
:key="item.key"
|
||||
:to="item.key"
|
||||
class="admin-layout__link"
|
||||
:class="{ 'is-active': selected === item.key }"
|
||||
>
|
||||
{{ item.label }}
|
||||
</router-link>
|
||||
</nav>
|
||||
<footer class="admin-layout__footer">
|
||||
<span>{{ username || "…" }}</span>
|
||||
<button type="button" @click="onLogout">登出</button>
|
||||
</footer>
|
||||
</aside>
|
||||
<main class="admin-layout__main">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-layout {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.admin-layout__sider {
|
||||
width: 200px;
|
||||
padding: 16px;
|
||||
border-right: 1px solid #eee;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.admin-layout__main {
|
||||
flex: 1;
|
||||
padding: 16px;
|
||||
}
|
||||
.admin-layout__link {
|
||||
display: block;
|
||||
padding: 6px 8px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
.admin-layout__link.is-active {
|
||||
font-weight: 600;
|
||||
}
|
||||
.admin-layout__footer {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,39 @@
|
||||
<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">
|
||||
<strong>成员入口</strong>
|
||||
<span>{{ memberName }}</span>
|
||||
</header>
|
||||
<main class="member-layout__main">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.member-layout {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.member-layout__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
.member-layout__main {
|
||||
padding: 16px;
|
||||
}
|
||||
</style>
|
||||
+17
-1
@@ -1,9 +1,25 @@
|
||||
import { createApp } from "vue";
|
||||
import { createPinia } from "pinia";
|
||||
import { QueryClient, VueQueryPlugin } from "@tanstack/vue-query";
|
||||
import Antd from "antdv-next";
|
||||
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";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 1,
|
||||
staleTime: 30_000,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const app = createApp(App);
|
||||
app.use(createPinia());
|
||||
bindAuthRedirectToRouter();
|
||||
app.use(router);
|
||||
app.use(VueQueryPlugin, { queryClient });
|
||||
app.use(Antd);
|
||||
app.mount("#app");
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
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()),
|
||||
});
|
||||
}
|
||||
|
||||
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,47 @@
|
||||
/**
|
||||
* 时间展示: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();
|
||||
}
|
||||
|
||||
/** 快捷项:最近 N 天(含今日),返回 [from, to) 的 ISO UTC */
|
||||
export function recentDaysRange(days: number): { from: string; to: string } {
|
||||
const to = dayjs().tz(DISPLAY_TZ).add(1, "day").startOf("day");
|
||||
const from = to.subtract(days, "day");
|
||||
return { from: from.toISOString(), to: to.toISOString() };
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
/** 审计日志占位——管理操作留痕(交互设计-审计日志) */
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<h1>审计日志</h1>
|
||||
<p>管理操作留痕时间线(占位,待 UI 设计)。</p>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
/** Key 管理占位——登记 / 启停 / 归属(交互设计-key管理) */
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<h1>Key 管理</h1>
|
||||
<p>登记、启停、归属(占位,待 UI 设计)。</p>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,89 @@
|
||||
<script setup lang="ts">
|
||||
/** 登录 + 空库三态分流(交互登录篇);视觉细节待与 antdv-next 工具链一并设计 */
|
||||
import { computed, ref } 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 username = ref("");
|
||||
const password = ref("");
|
||||
const passwordConfirm = ref("");
|
||||
const error = ref("");
|
||||
|
||||
const isSetup = computed(() => setupState.data.value?.initialized === false);
|
||||
|
||||
async function onSubmit() {
|
||||
error.value = "";
|
||||
try {
|
||||
if (isSetup.value) {
|
||||
if (password.value !== passwordConfirm.value) {
|
||||
error.value = "两次输入的密码不一致";
|
||||
return;
|
||||
}
|
||||
await setup.mutateAsync({
|
||||
username: username.value,
|
||||
password: password.value,
|
||||
});
|
||||
} else {
|
||||
await login.mutateAsync({
|
||||
username: username.value,
|
||||
password: password.value,
|
||||
});
|
||||
}
|
||||
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-view">
|
||||
<h1>{{ isSetup ? "初始化管理员" : "管理面登录" }}</h1>
|
||||
<form @submit.prevent="onSubmit">
|
||||
<label>
|
||||
用户名
|
||||
<input v-model="username" name="username" autocomplete="username" required />
|
||||
</label>
|
||||
<label>
|
||||
密码
|
||||
<input
|
||||
v-model="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label v-if="isSetup">
|
||||
确认密码
|
||||
<input v-model="passwordConfirm" name="password_confirm" type="password" required />
|
||||
</label>
|
||||
<button type="submit" :disabled="login.isPending.value || setup.isPending.value">
|
||||
{{ isSetup ? "完成设置" : "登录" }}
|
||||
</button>
|
||||
</form>
|
||||
<p v-if="error" role="alert">{{ error }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login-view {
|
||||
max-width: 360px;
|
||||
margin: 10vh auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.login-view label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
/** 成员管理占位——开户 / 令牌 / 停用(交互设计-成员管理) */
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<h1>成员管理</h1>
|
||||
<p>开户、令牌签发、停用(占位,待 UI 设计)。</p>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
/** 全员总览占位——信息结构见交互设计-全员总览;UI 待设计阶段 */
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<h1>全员总览</h1>
|
||||
<p>健康带 + 按成员 / key 汇总(占位,待 UI 设计)。</p>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
/** 支付记录占位——订阅/充值同构表单(交互设计-支付记录) */
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<h1>支付记录</h1>
|
||||
<p>订阅 / 充值登记与核对(占位,待 UI 设计)。</p>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
/** 账号设置(改密)占位——交互设计-登录与会话 */
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<h1>账号设置</h1>
|
||||
<p>修改密码(占位,待 UI 设计)。</p>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
/** 成员单页占位——聚合 + 来源徽章 + 明细(交互设计-成员视图) */
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<h1>我的用量</h1>
|
||||
<p>总用量、来源分解、key 窄卡、支付参数与明细(占位,待 UI 设计)。</p>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
/** 成员令牌登录壳——明文仅存 sessionStorage(交互登录篇 §4) */
|
||||
import { ref } 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 token = ref("");
|
||||
const error = ref("");
|
||||
const pending = ref(false);
|
||||
|
||||
async function onSubmit() {
|
||||
error.value = "";
|
||||
pending.value = true;
|
||||
try {
|
||||
tokenStore.setToken(token.value.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">
|
||||
<h1>成员入口</h1>
|
||||
<form @submit.prevent="onSubmit">
|
||||
<label>
|
||||
令牌
|
||||
<input v-model="token" name="token" type="password" required />
|
||||
</label>
|
||||
<button type="submit" :disabled="pending">进入</button>
|
||||
</form>
|
||||
<p v-if="error" role="alert">{{ error }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.member-login {
|
||||
max-width: 360px;
|
||||
margin: 12vh auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.member-login label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user