init
This commit is contained in:
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>K12Study Admin</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
24
frontend/package.json
Normal file
24
frontend/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "k12study-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
"scripts": {
|
||||
"dev": "vite --host",
|
||||
"build": "tsc -p tsconfig.json && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.30.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react-swc": "^3.8.0",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.2.0"
|
||||
}
|
||||
}
|
||||
5
frontend/src/App.tsx
Normal file
5
frontend/src/App.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { AppRouter } from "./router/AppRouter";
|
||||
|
||||
export default function App() {
|
||||
return <AppRouter />;
|
||||
}
|
||||
14
frontend/src/api/auth.ts
Normal file
14
frontend/src/api/auth.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { ApiResponse } from "../types/api";
|
||||
import { http } from "../utils/http";
|
||||
|
||||
interface LoginInput {
|
||||
username: string;
|
||||
password: string;
|
||||
provinceCode: string;
|
||||
areaCode: string;
|
||||
tenantId: string;
|
||||
}
|
||||
|
||||
export async function login(input: LoginInput) {
|
||||
return http.post<ApiResponse<{ accessToken: string; refreshToken: string }>>("/auth/login", input);
|
||||
}
|
||||
13
frontend/src/api/upms.ts
Normal file
13
frontend/src/api/upms.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { ApiResponse } from "../types/api";
|
||||
import type { CurrentRouteUser, RouteNode } from "../types/route";
|
||||
import { http } from "../utils/http";
|
||||
|
||||
export async function fetchDynamicRoutes(): Promise<RouteNode[]> {
|
||||
const response = await http.get<ApiResponse<RouteNode[]>>("/upms/routes");
|
||||
return response.data as RouteNode[];
|
||||
}
|
||||
|
||||
export async function fetchCurrentUser(): Promise<CurrentRouteUser> {
|
||||
const response = await http.get<ApiResponse<CurrentRouteUser>>("/upms/current-user");
|
||||
return response.data as CurrentRouteUser;
|
||||
}
|
||||
18
frontend/src/components/AppCard.tsx
Normal file
18
frontend/src/components/AppCard.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { PropsWithChildren, ReactNode } from "react";
|
||||
|
||||
type AppCardProps = PropsWithChildren<{
|
||||
title: string;
|
||||
extra?: ReactNode;
|
||||
}>;
|
||||
|
||||
export function AppCard({ title, extra, children }: AppCardProps) {
|
||||
return (
|
||||
<section className="app-card">
|
||||
<header className="app-card__header">
|
||||
<h3>{title}</h3>
|
||||
{extra}
|
||||
</header>
|
||||
<div className="app-card__body">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
3
frontend/src/components/LoadingView.tsx
Normal file
3
frontend/src/components/LoadingView.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export function LoadingView({ message = "Loading..." }: { message?: string }) {
|
||||
return <div className="loading-view">{message}</div>;
|
||||
}
|
||||
9
frontend/src/layouts/DefaultLayout.tsx
Normal file
9
frontend/src/layouts/DefaultLayout.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Outlet } from "react-router-dom";
|
||||
|
||||
export function DefaultLayout() {
|
||||
return (
|
||||
<div className="default-layout">
|
||||
<Outlet />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
18
frontend/src/layouts/SidebarLayout.tsx
Normal file
18
frontend/src/layouts/SidebarLayout.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Outlet, useLocation } from "react-router-dom";
|
||||
|
||||
export function SidebarLayout() {
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<div className="shell">
|
||||
<aside className="shell__sidebar">
|
||||
<div className="shell__brand">K12Study</div>
|
||||
<div className="shell__hint">React 管理端基础壳</div>
|
||||
</aside>
|
||||
<main className="shell__content">
|
||||
<header className="shell__header">{location.pathname}</header>
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
13
frontend/src/main.tsx
Normal file
13
frontend/src/main.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import App from "./App";
|
||||
import "./styles/app.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
);
|
||||
9
frontend/src/pages/DashboardPage.tsx
Normal file
9
frontend/src/pages/DashboardPage.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { AppCard } from "../components/AppCard";
|
||||
|
||||
export function DashboardPage() {
|
||||
return (
|
||||
<AppCard title="项目骨架已就绪">
|
||||
<p>当前页面用于承接首版后台管理端骨架,后续可继续补充机构端、教师端等业务模块。</p>
|
||||
</AppCard>
|
||||
);
|
||||
}
|
||||
42
frontend/src/pages/LoginPage.tsx
Normal file
42
frontend/src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { type FormEvent, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { login } from "../api/auth";
|
||||
import { setAccessToken } from "../utils/storage";
|
||||
|
||||
export function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const [form, setForm] = useState({
|
||||
username: "admin",
|
||||
password: "admin123",
|
||||
provinceCode: "330000",
|
||||
areaCode: "330100",
|
||||
tenantId: "SCH-HQ"
|
||||
});
|
||||
|
||||
async function handleSubmit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
const response = await login(form);
|
||||
setAccessToken(response.data.accessToken);
|
||||
navigate("/");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="login-page">
|
||||
<form className="login-form" onSubmit={handleSubmit}>
|
||||
<h1>K12Study Admin</h1>
|
||||
<input
|
||||
value={form.username}
|
||||
onChange={(event) => setForm({ ...form, username: event.target.value })}
|
||||
placeholder="用户名"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={form.password}
|
||||
onChange={(event) => setForm({ ...form, password: event.target.value })}
|
||||
placeholder="密码"
|
||||
/>
|
||||
<button type="submit">登录</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
10
frontend/src/pages/NotFoundPage.tsx
Normal file
10
frontend/src/pages/NotFoundPage.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
export function NotFoundPage() {
|
||||
return (
|
||||
<div className="not-found">
|
||||
<h1>404</h1>
|
||||
<Link to="/">返回首页</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
13
frontend/src/pages/RoutePlaceholderPage.tsx
Normal file
13
frontend/src/pages/RoutePlaceholderPage.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { AppCard } from "../components/AppCard";
|
||||
|
||||
export function RoutePlaceholderPage() {
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<AppCard title="动态路由占位页">
|
||||
<p>当前路由:{location.pathname}</p>
|
||||
<p>这里先用于承接动态菜单和页面挂载,后续再补真实业务实现。</p>
|
||||
</AppCard>
|
||||
);
|
||||
}
|
||||
90
frontend/src/router/AppRouter.tsx
Normal file
90
frontend/src/router/AppRouter.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Navigate, type RouteObject, useRoutes } from "react-router-dom";
|
||||
import { fetchDynamicRoutes } from "../api/upms";
|
||||
import { LoadingView } from "../components/LoadingView";
|
||||
import { DefaultLayout } from "../layouts/DefaultLayout";
|
||||
import { SidebarLayout } from "../layouts/SidebarLayout";
|
||||
import { DashboardPage } from "../pages/DashboardPage";
|
||||
import { LoginPage } from "../pages/LoginPage";
|
||||
import { NotFoundPage } from "../pages/NotFoundPage";
|
||||
import { RoutePlaceholderPage } from "../pages/RoutePlaceholderPage";
|
||||
import type { RouteNode } from "../types/route";
|
||||
|
||||
const layoutRegistry = {
|
||||
DEFAULT: DefaultLayout,
|
||||
SIDEBAR: SidebarLayout
|
||||
};
|
||||
|
||||
function toChildRoute(route: RouteNode): RouteObject {
|
||||
const Component = route.component === "dashboard" ? DashboardPage : RoutePlaceholderPage;
|
||||
|
||||
return {
|
||||
path: route.path === "/" ? "" : route.path.replace(/^\//, ""),
|
||||
element: <Component />
|
||||
};
|
||||
}
|
||||
|
||||
function buildRoutes(dynamicRoutes: RouteNode[]): RouteObject[] {
|
||||
const grouped = dynamicRoutes.reduce<Record<string, RouteNode[]>>((acc, route) => {
|
||||
acc[route.layout] ??= [];
|
||||
acc[route.layout].push(route);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const layoutRoutes = Object.entries(grouped).map(([layout, routes]) => {
|
||||
const Layout = layoutRegistry[layout as keyof typeof layoutRegistry] ?? DefaultLayout;
|
||||
return {
|
||||
path: "/",
|
||||
element: <Layout />,
|
||||
children: routes.map(toChildRoute)
|
||||
} satisfies RouteObject;
|
||||
});
|
||||
|
||||
return [
|
||||
{ path: "/login", element: <LoginPage /> },
|
||||
...layoutRoutes,
|
||||
{ path: "*", element: <NotFoundPage /> }
|
||||
];
|
||||
}
|
||||
|
||||
export function AppRouter() {
|
||||
const [dynamicRoutes, setDynamicRoutes] = useState<RouteNode[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDynamicRoutes()
|
||||
.then((routes) => setDynamicRoutes(routes))
|
||||
.catch(() =>
|
||||
setDynamicRoutes([
|
||||
{
|
||||
id: "dashboard",
|
||||
path: "/",
|
||||
name: "dashboard",
|
||||
component: "dashboard",
|
||||
layout: "SIDEBAR",
|
||||
meta: {
|
||||
title: "控制台",
|
||||
hidden: false,
|
||||
permissionCodes: ["dashboard:view"]
|
||||
},
|
||||
children: []
|
||||
}
|
||||
])
|
||||
)
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const routes = useMemo(() => {
|
||||
if (loading) {
|
||||
return [{ path: "*", element: <LoadingView message="正在加载路由..." /> }];
|
||||
}
|
||||
|
||||
const nextRoutes = buildRoutes(dynamicRoutes);
|
||||
if (dynamicRoutes.length === 0) {
|
||||
nextRoutes.unshift({ path: "/", element: <Navigate to="/login" replace /> });
|
||||
}
|
||||
return nextRoutes;
|
||||
}, [dynamicRoutes, loading]);
|
||||
|
||||
return useRoutes(routes);
|
||||
}
|
||||
9
frontend/src/store/session.ts
Normal file
9
frontend/src/store/session.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { clearAccessToken, getAccessToken } from "../utils/storage";
|
||||
|
||||
export function isAuthenticated() {
|
||||
return Boolean(getAccessToken());
|
||||
}
|
||||
|
||||
export function signOut() {
|
||||
clearAccessToken();
|
||||
}
|
||||
120
frontend/src/styles/app.css
Normal file
120
frontend/src/styles/app.css
Normal file
@@ -0,0 +1,120 @@
|
||||
:root {
|
||||
color: #1a1f36;
|
||||
background: linear-gradient(180deg, #eef2ff 0%, #f8fafc 100%);
|
||||
font-family: "Segoe UI", "PingFang SC", sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.shell {
|
||||
display: grid;
|
||||
grid-template-columns: 240px 1fr;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.shell__sidebar {
|
||||
padding: 24px;
|
||||
background: #111827;
|
||||
color: #f9fafb;
|
||||
}
|
||||
|
||||
.shell__brand {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.shell__hint {
|
||||
margin-top: 12px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.shell__content {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.shell__header {
|
||||
margin-bottom: 16px;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.app-card {
|
||||
border-radius: 20px;
|
||||
background: #ffffff;
|
||||
padding: 24px;
|
||||
box-shadow: 0 20px 50px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.app-card__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.app-card__body {
|
||||
color: #334155;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.login-page {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
width: min(360px, calc(100vw - 32px));
|
||||
padding: 32px;
|
||||
border-radius: 24px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 24px 60px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
.login-form input,
|
||||
.login-form button {
|
||||
height: 44px;
|
||||
padding: 0 14px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #cbd5e1;
|
||||
}
|
||||
|
||||
.login-form button {
|
||||
border: none;
|
||||
background: #2563eb;
|
||||
color: #ffffff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.loading-view,
|
||||
.not-found {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 100vh;
|
||||
color: #5b6475;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.shell__sidebar {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.shell__content {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
6
frontend/src/types/api.ts
Normal file
6
frontend/src/types/api.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export interface ApiResponse<T> {
|
||||
code: number;
|
||||
message: string;
|
||||
data: T;
|
||||
traceId: string;
|
||||
}
|
||||
27
frontend/src/types/route.ts
Normal file
27
frontend/src/types/route.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export type LayoutType = "DEFAULT" | "SIDEBAR";
|
||||
|
||||
export interface RouteMeta {
|
||||
title: string;
|
||||
icon?: string;
|
||||
permissionCodes?: string[];
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
export interface RouteNode {
|
||||
id: string;
|
||||
path: string;
|
||||
name: string;
|
||||
component: string;
|
||||
layout: LayoutType;
|
||||
meta: RouteMeta;
|
||||
children: RouteNode[];
|
||||
}
|
||||
|
||||
export interface CurrentRouteUser {
|
||||
userId: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
tenantId: string;
|
||||
deptId: string;
|
||||
permissionCodes: string[];
|
||||
}
|
||||
83
frontend/src/utils/http.ts
Normal file
83
frontend/src/utils/http.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { getAccessToken } from "./storage";
|
||||
|
||||
const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "/api";
|
||||
const DEFAULT_TIMEOUT = 10000;
|
||||
|
||||
type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||
|
||||
interface RequestOptions {
|
||||
method?: HttpMethod;
|
||||
body?: unknown;
|
||||
headers?: Record<string, string>;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
function buildUrl(path: string) {
|
||||
if (/^https?:\/\//.test(path)) {
|
||||
return path;
|
||||
}
|
||||
return `${BASE_URL}${path.startsWith("/") ? path : `/${path}`}`;
|
||||
}
|
||||
|
||||
async function parseResponse(response: Response) {
|
||||
if (response.status === 204) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const contentType = response.headers.get("content-type") ?? "";
|
||||
if (contentType.includes("application/json")) {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
return response.text();
|
||||
}
|
||||
|
||||
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), options.timeout ?? DEFAULT_TIMEOUT);
|
||||
const token = getAccessToken();
|
||||
|
||||
try {
|
||||
const response = await fetch(buildUrl(path), {
|
||||
method: options.method ?? "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...options.headers
|
||||
},
|
||||
body: options.body === undefined ? undefined : JSON.stringify(options.body),
|
||||
signal: controller.signal
|
||||
});
|
||||
|
||||
const payload = await parseResponse(response);
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
typeof payload === "object" && payload !== null && "message" in payload
|
||||
? String(payload.message)
|
||||
: `Request failed with status ${response.status}`;
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return payload as T;
|
||||
} finally {
|
||||
window.clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
export const http = {
|
||||
get<T>(path: string, options?: Omit<RequestOptions, "method" | "body">) {
|
||||
return request<T>(path, { ...options, method: "GET" });
|
||||
},
|
||||
post<T>(path: string, body?: unknown, options?: Omit<RequestOptions, "method" | "body">) {
|
||||
return request<T>(path, { ...options, method: "POST", body });
|
||||
},
|
||||
put<T>(path: string, body?: unknown, options?: Omit<RequestOptions, "method" | "body">) {
|
||||
return request<T>(path, { ...options, method: "PUT", body });
|
||||
},
|
||||
patch<T>(path: string, body?: unknown, options?: Omit<RequestOptions, "method" | "body">) {
|
||||
return request<T>(path, { ...options, method: "PATCH", body });
|
||||
},
|
||||
delete<T>(path: string, options?: Omit<RequestOptions, "method" | "body">) {
|
||||
return request<T>(path, { ...options, method: "DELETE" });
|
||||
}
|
||||
};
|
||||
13
frontend/src/utils/storage.ts
Normal file
13
frontend/src/utils/storage.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
const ACCESS_TOKEN_KEY = "k12study.access-token";
|
||||
|
||||
export function getAccessToken() {
|
||||
return localStorage.getItem(ACCESS_TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function setAccessToken(token: string) {
|
||||
localStorage.setItem(ACCESS_TOKEN_KEY, token);
|
||||
}
|
||||
|
||||
export function clearAccessToken() {
|
||||
localStorage.removeItem(ACCESS_TOKEN_KEY);
|
||||
}
|
||||
22
frontend/tsconfig.json
Normal file
22
frontend/tsconfig.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"baseUrl": ".",
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
15
frontend/vite.config.ts
Normal file
15
frontend/vite.config.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react-swc";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://localhost:8088",
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user