feat(admin-portal): 完整实现 admin-portal 管理端微前端

包含 src 全部实现、Dockerfile、配置文件等
This commit is contained in:
SpecialX
2026-07-10 19:09:12 +08:00
parent 02d09c47fa
commit b3511910d1
69 changed files with 11955 additions and 693 deletions

View File

@@ -0,0 +1,107 @@
"use client";
import {
createContext,
useContext,
useState,
useEffect,
useCallback,
type ReactNode,
} from "react";
import {
getToken,
getUser,
login as loginApi,
logout as logoutApi,
} from "@/lib/auth";
import type { CurrentUser } from "@/types/view-models";
interface AuthContextValue {
user: CurrentUser | null;
isAuthenticated: boolean;
isLoading: boolean;
hasPermission: (perm: string) => boolean;
hasAnyPermission: (perms: string[]) => boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
}
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUserState] = useState<CurrentUser | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const token = getToken();
const stored = getUser();
if (token && stored) {
setUserState(stored);
}
setIsLoading(false);
}, []);
const login = useCallback(async (email: string, password: string) => {
const result = await loginApi(email, password);
setUserState(result.user);
}, []);
const logout = useCallback(() => {
setUserState(null);
logoutApi();
}, []);
const hasPermission = useCallback(
(perm: string) => {
if (!user) return false;
if (user.permissions.includes("*")) return true;
return user.permissions.includes(perm);
},
[user],
);
const hasAnyPermission = useCallback(
(perms: string[]) => {
if (!user) return false;
if (user.permissions.includes("*")) return true;
return perms.some((p) => user.permissions.includes(p));
},
[user],
);
return (
<AuthContext.Provider
value={{
user,
isAuthenticated: user !== null,
isLoading,
hasPermission,
hasAnyPermission,
login,
logout,
}}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) {
throw new Error("useAuth must be used within AuthProvider");
}
return ctx;
}
/** 权限 Hook复用 AuthProvider */
export function usePermission() {
const { user, hasPermission, hasAnyPermission } = useAuth();
return {
hasPermission,
hasAnyPermission,
permissions: user?.permissions ?? [],
dataScope: user?.dataScope ?? "SELF",
roles: user?.roles ?? [],
};
}

View File

@@ -0,0 +1,16 @@
"use client";
import { useMemo, type ReactNode } from "react";
import { Provider } from "urql";
import { createGraphQLClient } from "@/lib/graphql-client";
/**
* GraphQL Providerstandalone 模式自建 urql client
*
* 仲裁 ARB-002 / ISSUE-047 §5.4MF 模式复用 Shell 暴露的 GraphQL client 单例
* 开发期 standalone 模式:自建 urql client 指向 /api/admin/graphqlMSW 拦截)
*/
export function GraphQLProvider({ children }: { children: ReactNode }) {
const client = useMemo(() => createGraphQLClient(), []);
return <Provider value={client}>{children}</Provider>;
}

View File

@@ -0,0 +1,124 @@
"use client";
import {
createContext,
useContext,
useState,
useCallback,
type ReactNode,
} from "react";
type ToastType = "success" | "error" | "warning" | "info";
interface ToastItem {
id: string;
type: ToastType;
title: string;
description?: string;
}
interface ToastContextValue {
toasts: ToastItem[];
show: (type: ToastType, title: string, description?: string) => void;
dismiss: (id: string) => void;
}
const ToastContext = createContext<ToastContextValue | null>(null);
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<ToastItem[]>([]);
const dismiss = useCallback((id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);
const show = useCallback(
(type: ToastType, title: string, description?: string) => {
const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
setToasts((prev) => [...prev, { id, type, title, description }]);
setTimeout(() => dismiss(id), 5000);
},
[dismiss],
);
const colorMap: Record<ToastType, string> = {
success: "var(--color-success)",
error: "var(--color-danger)",
warning: "var(--color-warning)",
info: "var(--color-accent)",
};
return (
<ToastContext.Provider value={{ toasts, show, dismiss }}>
{children}
<div
role="region"
aria-label="通知"
aria-live="polite"
aria-atomic="true"
style={{
position: "fixed",
top: 16,
right: 16,
zIndex: 9999,
display: "flex",
flexDirection: "column",
gap: 8,
maxWidth: 400,
}}
>
{toasts.map((toast) => (
<button
key={toast.id}
type="button"
style={{
background: "var(--bg-paper)",
border: "none",
borderLeft: `4px solid ${colorMap[toast.type]}`,
borderRadius: 6,
padding: "12px 16px",
boxShadow: "0 2px 8px rgba(0,0,0,0.08)",
cursor: "pointer",
textAlign: "left",
width: "100%",
display: "block",
}}
onClick={() => dismiss(toast.id)}
aria-label={`${toast.title}${toast.description ? `: ${toast.description}` : ""}`}
>
<p
style={{
fontWeight: 600,
fontSize: 14,
color: "var(--color-ink)",
margin: 0,
}}
>
{toast.title}
</p>
{toast.description && (
<p
style={{
fontSize: 13,
color: "var(--color-ink-muted)",
marginTop: 4,
margin: 0,
}}
>
{toast.description}
</p>
)}
</button>
))}
</div>
</ToastContext.Provider>
);
}
export function useToast(): ToastContextValue {
const ctx = useContext(ToastContext);
if (!ctx) {
throw new Error("useToast must be used within ToastProvider");
}
return ctx;
}