chore: 初始化仓库
中华文明全图鉴——文物全图系统(PC Web 地图 + NestJS API + 管理后台)。 含三大 IP(文物南迁北归 / 国宝海外回归 / 博物馆手艺人)、AI 文物对话、 文物地图与详情、以及 demo-video-kit 演示视频生成工具。
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
import { Routes, Route, Navigate } from "react-router-dom";
|
||||
import { ConfigProvider, theme } from "antd";
|
||||
import zhCN from "antd/locale/zh_CN";
|
||||
import Login from "./pages/Login";
|
||||
import AdminLayout from "./components/AdminLayout";
|
||||
import Dashboard from "./pages/Dashboard";
|
||||
import ArtifactList from "./pages/artifacts/ArtifactList";
|
||||
import { getStoredUser } from "./store/auth";
|
||||
|
||||
function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
const user = getStoredUser();
|
||||
return user ? <>{children}</> : <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<ConfigProvider
|
||||
locale={zhCN}
|
||||
theme={{
|
||||
algorithm: theme.darkAlgorithm,
|
||||
token: {
|
||||
colorPrimary: "#c9a84c",
|
||||
colorBgBase: "#14121e",
|
||||
colorBgContainer: "#1a1825",
|
||||
colorBgLayout: "#0f0d18",
|
||||
fontFamily: "\"Noto Sans SC\", \"PingFang SC\", sans-serif",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<AdminLayout />
|
||||
</RequireAuth>
|
||||
}
|
||||
>
|
||||
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="dashboard" element={<Dashboard />} />
|
||||
<Route path="artifacts" element={<ArtifactList />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||
</Routes>
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
const BASE_URL = import.meta.env.VITE_API_URL ?? "http://localhost:3002";
|
||||
|
||||
function getToken() {
|
||||
return localStorage.getItem("access_token");
|
||||
}
|
||||
|
||||
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const token = getToken();
|
||||
const res = await fetch(`${BASE_URL}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...(options.headers ?? {}),
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error((err as { message?: string }).message ?? `HTTP ${res.status}`);
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string) => request<T>(path),
|
||||
post: <T>(path: string, body: unknown) =>
|
||||
request<T>(path, { method: "POST", body: JSON.stringify(body) }),
|
||||
patch: <T>(path: string, body: unknown) =>
|
||||
request<T>(path, { method: "PATCH", body: JSON.stringify(body) }),
|
||||
delete: <T>(path: string) => request<T>(path, { method: "DELETE" }),
|
||||
};
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useState } from "react";
|
||||
import { Layout, Menu, Avatar, Dropdown, theme, Typography } from "antd";
|
||||
import {
|
||||
DashboardOutlined,
|
||||
PicLeftOutlined,
|
||||
BankOutlined,
|
||||
TagsOutlined,
|
||||
LogoutOutlined,
|
||||
UserOutlined,
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { Outlet, useNavigate, useLocation } from "react-router-dom";
|
||||
import { useAuth } from "../store/auth";
|
||||
|
||||
const { Header, Sider, Content } = Layout;
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ key: "/dashboard", icon: <DashboardOutlined />, label: "概览" },
|
||||
{ key: "/artifacts", icon: <PicLeftOutlined />, label: "文物管理" },
|
||||
{ key: "/institutions", icon: <BankOutlined />, label: "机构管理" },
|
||||
{ key: "/tags", icon: <TagsOutlined />, label: "标签管理" },
|
||||
];
|
||||
|
||||
export default function AdminLayout() {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { user, logout } = useAuth();
|
||||
const { token } = theme.useToken();
|
||||
|
||||
const userMenuItems = [
|
||||
{
|
||||
key: "logout",
|
||||
icon: <LogoutOutlined />,
|
||||
label: "退出登录",
|
||||
onClick: () => {
|
||||
logout();
|
||||
navigate("/login");
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Layout style={{ minHeight: "100vh" }}>
|
||||
<Sider
|
||||
collapsible
|
||||
collapsed={collapsed}
|
||||
onCollapse={setCollapsed}
|
||||
trigger={null}
|
||||
style={{
|
||||
background: token.colorBgContainer,
|
||||
borderRight: `1px solid ${token.colorBorderSecondary}`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: 56,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: collapsed ? "center" : "flex-start",
|
||||
padding: collapsed ? 0 : "0 20px",
|
||||
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
||||
}}
|
||||
>
|
||||
{collapsed ? (
|
||||
<span style={{ fontSize: 18 }}>🏛️</span>
|
||||
) : (
|
||||
<Typography.Text strong style={{ color: token.colorPrimary, fontSize: 13, letterSpacing: 1 }}>
|
||||
文明全图鉴
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
<Menu
|
||||
mode="inline"
|
||||
selectedKeys={[location.pathname]}
|
||||
items={NAV_ITEMS}
|
||||
onClick={({ key }) => navigate(key)}
|
||||
style={{ border: "none", marginTop: 8 }}
|
||||
/>
|
||||
</Sider>
|
||||
|
||||
<Layout>
|
||||
<Header
|
||||
style={{
|
||||
background: token.colorBgContainer,
|
||||
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
||||
height: 56,
|
||||
padding: "0 20px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
color: token.colorText,
|
||||
fontSize: 16,
|
||||
}}
|
||||
>
|
||||
{collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
||||
</button>
|
||||
<div style={{ flex: 1 }} />
|
||||
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight">
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, cursor: "pointer" }}>
|
||||
<Avatar size={28} icon={<UserOutlined />} style={{ background: token.colorPrimary }} />
|
||||
<Typography.Text style={{ fontSize: 13 }}>
|
||||
{user?.nickname ?? user?.username}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</Dropdown>
|
||||
</Header>
|
||||
|
||||
<Content style={{ padding: 24, background: token.colorBgLayout }}>
|
||||
<Outlet />
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/* 全站隐藏滚动条但仍可滚动 */
|
||||
* {
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none; /* 旧版 Edge / IE */
|
||||
}
|
||||
*::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
display: none; /* Chrome / Safari / 新版 Edge */
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import App from "./App";
|
||||
import "./index.css";
|
||||
|
||||
const root = document.getElementById("root");
|
||||
if (!root) throw new Error("找不到 #root 挂载点");
|
||||
|
||||
ReactDOM.createRoot(root).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Card, Statistic, Row, Col, Typography, Spin } from "antd";
|
||||
import { PicLeftOutlined, BankOutlined, EnvironmentOutlined } from "@ant-design/icons";
|
||||
import { api } from "../api/client";
|
||||
|
||||
interface Stats {
|
||||
total_artifacts: number;
|
||||
total_institutions: number;
|
||||
total_locations: number;
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const [stats, setStats] = useState<Stats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.get<Stats>("/api/v1/map/stats")
|
||||
.then(setStats)
|
||||
.catch(() => null)
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={4} style={{ marginBottom: 24 }}>
|
||||
概览
|
||||
</Typography.Title>
|
||||
{loading ? (
|
||||
<Spin />
|
||||
) : (
|
||||
<Row gutter={16}>
|
||||
<Col span={8}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="已发布文物"
|
||||
value={stats?.total_artifacts ?? 0}
|
||||
prefix={<PicLeftOutlined />}
|
||||
valueStyle={{ color: "#c9a84c" }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="合作机构"
|
||||
value={stats?.total_institutions ?? 0}
|
||||
prefix={<BankOutlined />}
|
||||
valueStyle={{ color: "#c9a84c" }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="地图点位"
|
||||
value={stats?.total_locations ?? 0}
|
||||
prefix={<EnvironmentOutlined />}
|
||||
valueStyle={{ color: "#c9a84c" }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useState } from "react";
|
||||
import { Form, Input, Button, message, Typography } from "antd";
|
||||
import { UserOutlined, LockOutlined } from "@ant-design/icons";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { api } from "../api/client";
|
||||
import { saveAuth, AuthUser } from "../store/auth";
|
||||
|
||||
interface LoginResponse {
|
||||
access_token: string;
|
||||
user: AuthUser;
|
||||
}
|
||||
|
||||
export default function Login() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const onFinish = async (values: { username: string; password: string }) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.post<LoginResponse>("/api/v1/auth/login", values);
|
||||
saveAuth(res.access_token, res.user);
|
||||
message.success(`欢迎回来,${res.user.nickname ?? res.user.username}`);
|
||||
navigate("/dashboard");
|
||||
} catch (err) {
|
||||
message.error((err as Error).message || "登录失败,请检查账号密码");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
minHeight: "100vh",
|
||||
background: "linear-gradient(135deg, #0f0c1a 0%, #1a1225 50%, #0d1117 100%)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 380,
|
||||
background: "rgba(255,255,255,0.04)",
|
||||
border: "1px solid rgba(201,168,76,0.2)",
|
||||
borderRadius: 12,
|
||||
padding: "40px 36px",
|
||||
boxShadow: "0 8px 40px rgba(0,0,0,0.5)",
|
||||
}}
|
||||
>
|
||||
<div style={{ textAlign: "center", marginBottom: 32 }}>
|
||||
<div style={{ fontSize: 28, marginBottom: 8 }}>🏛️</div>
|
||||
<Typography.Title
|
||||
level={3}
|
||||
style={{ color: "#c9a84c", margin: 0, fontWeight: 700, letterSpacing: 2 }}
|
||||
>
|
||||
中华文明全图鉴
|
||||
</Typography.Title>
|
||||
<Typography.Text style={{ color: "rgba(255,255,255,0.4)", fontSize: 13 }}>
|
||||
管理后台
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
<Form form={form} onFinish={onFinish} size="large" requiredMark={false}>
|
||||
<Form.Item
|
||||
name="username"
|
||||
rules={[{ required: true, message: "请输入邮箱或用户名" }]}
|
||||
>
|
||||
<Input
|
||||
prefix={<UserOutlined style={{ color: "rgba(255,255,255,0.3)" }} />}
|
||||
placeholder="邮箱 / 用户名"
|
||||
autoComplete="username"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="password"
|
||||
rules={[{ required: true, message: "请输入密码" }]}
|
||||
>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined style={{ color: "rgba(255,255,255,0.3)" }} />}
|
||||
placeholder="密码"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item style={{ marginBottom: 0 }}>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
block
|
||||
loading={loading}
|
||||
style={{ height: 44, fontWeight: 600, letterSpacing: 1 }}
|
||||
>
|
||||
登 录
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
Table,
|
||||
Input,
|
||||
Select,
|
||||
Space,
|
||||
Tag,
|
||||
Typography,
|
||||
Button,
|
||||
message,
|
||||
Tooltip,
|
||||
} from "antd";
|
||||
import { SearchOutlined, ReloadOutlined } from "@ant-design/icons";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import { api } from "../../api/client";
|
||||
|
||||
interface Artifact {
|
||||
id: string;
|
||||
name: string;
|
||||
category: string;
|
||||
level: string;
|
||||
dynasty: string;
|
||||
story_hook: string;
|
||||
current_institution: string;
|
||||
}
|
||||
|
||||
interface PageMeta {
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
interface ArtifactListResponse {
|
||||
data: Artifact[];
|
||||
meta: PageMeta;
|
||||
}
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
bronze: "青铜器",
|
||||
painting_calligraphy: "书画",
|
||||
porcelain: "陶瓷",
|
||||
jade: "玉器",
|
||||
gold_silver: "金银器",
|
||||
lacquer: "漆木器",
|
||||
textile: "织绣",
|
||||
stone_carving: "石刻造像",
|
||||
wood_carving: "木雕",
|
||||
dunhuang: "敦煌遗珍",
|
||||
ancient_book: "古籍文献",
|
||||
other: "其他",
|
||||
};
|
||||
|
||||
const LEVEL_COLORS: Record<string, string> = {
|
||||
level_1: "gold",
|
||||
level_2: "blue",
|
||||
level_3: "default",
|
||||
unknown: "default",
|
||||
};
|
||||
const LEVEL_LABELS: Record<string, string> = {
|
||||
level_1: "国家一级",
|
||||
level_2: "国家二级",
|
||||
level_3: "国家三级",
|
||||
unknown: "未定级",
|
||||
};
|
||||
|
||||
export default function ArtifactList() {
|
||||
const [data, setData] = useState<Artifact[]>([]);
|
||||
const [meta, setMeta] = useState<PageMeta>({ total: 0, page: 1, limit: 20, total_pages: 1 });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [q, setQ] = useState("");
|
||||
const [category, setCategory] = useState<string | undefined>();
|
||||
const [dynasty, setDynasty] = useState<string | undefined>();
|
||||
|
||||
const fetchData = useCallback(
|
||||
async (page = 1) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams({ page: String(page), limit: "20" });
|
||||
if (q) params.set("q", q);
|
||||
if (category) params.set("category", category);
|
||||
if (dynasty) params.set("dynasty", dynasty);
|
||||
const res = await api.get<ArtifactListResponse>(
|
||||
`/api/v1/artifacts?${params}`
|
||||
);
|
||||
setData(res.data);
|
||||
setMeta(res.meta);
|
||||
} catch (err) {
|
||||
message.error((err as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[q, category, dynasty]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData(1);
|
||||
}, [fetchData]);
|
||||
|
||||
const columns: ColumnsType<Artifact> = [
|
||||
{
|
||||
title: "文物名称",
|
||||
dataIndex: "name",
|
||||
key: "name",
|
||||
render: (text: string, record) => (
|
||||
<div>
|
||||
<Typography.Text strong>{text}</Typography.Text>
|
||||
{record.story_hook && (
|
||||
<Tooltip title={record.story_hook}>
|
||||
<Typography.Text
|
||||
type="secondary"
|
||||
style={{ display: "block", fontSize: 12, maxWidth: 200 }}
|
||||
ellipsis
|
||||
>
|
||||
{record.story_hook}
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "门类",
|
||||
dataIndex: "category",
|
||||
key: "category",
|
||||
width: 80,
|
||||
render: (cat: string) => CATEGORY_LABELS[cat] ?? cat,
|
||||
},
|
||||
{
|
||||
title: "级别",
|
||||
dataIndex: "level",
|
||||
key: "level",
|
||||
width: 100,
|
||||
render: (lv: string) => (
|
||||
<Tag color={LEVEL_COLORS[lv] ?? "default"}>{LEVEL_LABELS[lv] ?? lv}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "朝代",
|
||||
dataIndex: "dynasty",
|
||||
key: "dynasty",
|
||||
width: 90,
|
||||
},
|
||||
{
|
||||
title: "所在机构",
|
||||
dataIndex: "current_institution",
|
||||
key: "current_institution",
|
||||
render: (v: string) => v ?? <Typography.Text type="secondary">—</Typography.Text>,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 16 }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
文物管理
|
||||
</Typography.Title>
|
||||
</div>
|
||||
|
||||
<Space style={{ marginBottom: 16 }} wrap>
|
||||
<Input
|
||||
placeholder="搜索文物名称"
|
||||
prefix={<SearchOutlined />}
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
onPressEnter={() => fetchData(1)}
|
||||
style={{ width: 200 }}
|
||||
allowClear
|
||||
onClear={() => setQ("")}
|
||||
/>
|
||||
<Select
|
||||
placeholder="门类"
|
||||
style={{ width: 100 }}
|
||||
allowClear
|
||||
value={category}
|
||||
onChange={setCategory}
|
||||
options={Object.entries(CATEGORY_LABELS).map(([value, label]) => ({
|
||||
value,
|
||||
label,
|
||||
}))}
|
||||
/>
|
||||
<Select
|
||||
placeholder="朝代"
|
||||
style={{ width: 140 }}
|
||||
allowClear
|
||||
value={dynasty}
|
||||
onChange={setDynasty}
|
||||
options={["先秦", "秦汉", "魏晋南北朝", "隋唐", "宋元", "明清"].map((d) => ({
|
||||
value: d,
|
||||
label: d,
|
||||
}))}
|
||||
/>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={() => fetchData(1)}
|
||||
loading={loading}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
loading={loading}
|
||||
pagination={{
|
||||
total: meta.total,
|
||||
current: meta.page,
|
||||
pageSize: meta.limit,
|
||||
showTotal: (t) => `共 ${t} 件`,
|
||||
onChange: (page) => fetchData(page),
|
||||
showSizeChanger: false,
|
||||
}}
|
||||
size="middle"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
username: string;
|
||||
nickname: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
const USER_KEY = "admin_user";
|
||||
const TOKEN_KEY = "access_token";
|
||||
|
||||
export function saveAuth(token: string, user: AuthUser) {
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(user));
|
||||
}
|
||||
|
||||
export function clearAuth() {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(USER_KEY);
|
||||
}
|
||||
|
||||
export function getStoredUser(): AuthUser | null {
|
||||
try {
|
||||
const s = localStorage.getItem(USER_KEY);
|
||||
return s ? (JSON.parse(s) as AuthUser) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const [user, setUser] = useState<AuthUser | null>(getStoredUser);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => setUser(getStoredUser());
|
||||
window.addEventListener("storage", handler);
|
||||
return () => window.removeEventListener("storage", handler);
|
||||
}, []);
|
||||
|
||||
const logout = () => {
|
||||
clearAuth();
|
||||
setUser(null);
|
||||
};
|
||||
|
||||
return { user, setUser, logout };
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user