관리자부분 추가
This commit is contained in:
parent
700e1ce666
commit
3abdfbfb95
@ -4,6 +4,8 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-2283573567825482"
|
||||
crossorigin="anonymous"></script>
|
||||
<title>숙제노기</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
14
src/App.tsx
14
src/App.tsx
@ -1,6 +1,6 @@
|
||||
// src/App.tsx
|
||||
import { CssBaseline, ThemeProvider, createTheme } from '@mui/material'
|
||||
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { useAuth } from './contexts/AuthContext'
|
||||
import Layout from './components/Layout'
|
||||
import Home from './pages/Home'
|
||||
@ -19,6 +19,12 @@ import GuidePage from './pages/Guide'
|
||||
import FriendListPage from './pages/FriendListPage'
|
||||
import FriendRequestsPage from './pages/FriendRequestsPage'
|
||||
import FriendCharacterDashboard from './pages/FriendCharacterDashboard'
|
||||
import { ADMIN_BASE } from './lib/config';
|
||||
import RequireAdmin from './pages/admin/RequireAdmin';
|
||||
import AdminLayout from './pages/admin/AdminLayout';
|
||||
import AdminLogin from './pages/admin/Login';
|
||||
import AdminBoards from './pages/admin/Boards';
|
||||
import AdminUsers from './pages/admin/Users';
|
||||
|
||||
const darkTheme = createTheme({
|
||||
palette: {
|
||||
@ -52,6 +58,12 @@ function App() {
|
||||
<Route path="/friends" element={<FriendListPage />} />
|
||||
<Route path="/friends/:friend_id/characters" element={<FriendCharacterDashboard />} />
|
||||
<Route path="/friends/requests" element={<FriendRequestsPage />} />
|
||||
<Route path={`${ADMIN_BASE}/login`} element={<AdminLogin />} />
|
||||
<Route path={ADMIN_BASE} element={<RequireAdmin><AdminLayout /></RequireAdmin>}>
|
||||
<Route index element={<Navigate to="boards" replace />} />
|
||||
<Route path="boards" element={<AdminBoards />} />
|
||||
<Route path="users" element={<AdminUsers />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</Layout>
|
||||
</Router>
|
||||
|
||||
28
src/lib/adminApi.ts
Normal file
28
src/lib/adminApi.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { API_BASE, ADMIN_API_PREFIX } from "./config";
|
||||
|
||||
export async function adminLogin(username: string, password: string) {
|
||||
const body = new URLSearchParams({ username, password });
|
||||
const res = await fetch(`${API_BASE}${ADMIN_API_PREFIX}/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body,
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text().catch(()=>res.statusText));
|
||||
return res.json() as Promise<{ access_token: string }>;
|
||||
}
|
||||
|
||||
export async function adminListBoards(token: string) {
|
||||
const res = await fetch(`${API_BASE}${ADMIN_API_PREFIX}/boards`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text().catch(()=>res.statusText));
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function adminListUsers(token: string) {
|
||||
const res = await fetch(`${API_BASE}${ADMIN_API_PREFIX}/members`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text().catch(()=>res.statusText));
|
||||
return res.json();
|
||||
}
|
||||
5
src/lib/adminAuth.ts
Normal file
5
src/lib/adminAuth.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export const getAdminToken = () => localStorage.getItem("admin_token");
|
||||
export const setAdminToken = (t: string | null) => {
|
||||
if (t) localStorage.setItem("admin_token", t);
|
||||
else localStorage.removeItem("admin_token");
|
||||
};
|
||||
@ -1,8 +1,8 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: 'https://api.biryu2000.kr',
|
||||
// baseURL: 'http://localhost:8000',
|
||||
// baseURL: 'https://api.biryu2000.kr',
|
||||
baseURL: 'http://localhost:8000',
|
||||
})
|
||||
|
||||
// 요청 시 토큰 자동 추가
|
||||
|
||||
14
src/lib/config.ts
Normal file
14
src/lib/config.ts
Normal file
@ -0,0 +1,14 @@
|
||||
// 환경변수는 '있으면' 쓰고, 없으면 기본값으로 동작합니다.
|
||||
const env = (k: string) => (import.meta as any)?.env?.[k];
|
||||
|
||||
export const API_BASE =
|
||||
env("VITE_API_BASE_URL") ||
|
||||
(location.hostname === "localhost"
|
||||
? "http://localhost:8000" // 로컬 기본: FastAPI
|
||||
: `${location.origin}`); // 배포 기본: 동일 오리진 프록시 가정
|
||||
|
||||
const rawAdminBase = env("VITE_ADMIN_BASE_PATH") ?? "/siteManage";
|
||||
export const ADMIN_BASE = rawAdminBase.startsWith("/") ? rawAdminBase : `/${rawAdminBase}`;
|
||||
|
||||
export const ADMIN_API_PREFIX =
|
||||
env("VITE_ADMIN_API_PREFIX") || "/admin"; // 백엔드 관리자 API 프리픽스
|
||||
@ -6,6 +6,7 @@ export default function Home() {
|
||||
const { isLoggedIn } = useAuth()
|
||||
|
||||
const updates = [
|
||||
{ date: '2025-09-19', version: 'v2.0', content: '구글 애드센스를 붙였습니다. \'돈 좀 벌어보고싶다\'는 아니고. 그냥 휌해서...광고라도...' },
|
||||
{ date: '2025-06-11', version: 'v2.01', content: '회원가입 bug fix' },
|
||||
{ date: '2025-06-11', version: 'v2.0', content: '친구 기능 추가: 친구목록/요청 관리 및 캐릭터 공개 보기 지원' },
|
||||
{ date: '2025-06-06', version: 'v1.1', content: '피드백 및 수정요청 및 기능개선은 nightbug@naver.com 으로 부탁드립니다.' },
|
||||
@ -58,16 +59,16 @@ export default function Home() {
|
||||
<Divider sx={{ mt: 2 }} />
|
||||
</Box>
|
||||
|
||||
<Typography variant="h5" gutterBottom sx={{ mt: 6, textAlign: 'center' }}>
|
||||
라사서버 길드 '노인정'과 함께합니다. 누구마음대로?
|
||||
</Typography>
|
||||
<Box sx={{ mt: 4, textAlign: 'center' }}>
|
||||
<img
|
||||
src="/guild.png"
|
||||
alt="노인정길드"
|
||||
style={{ maxWidth: '100%', height: 'auto', borderRadius: '16px' }}
|
||||
/>
|
||||
</Box>
|
||||
{/*<Typography variant="h5" gutterBottom sx={{ mt: 6, textAlign: 'center' }}>*/}
|
||||
{/* 라사서버 길드 '노인정'과 함께합니다. 누구마음대로?*/}
|
||||
{/*</Typography>*/}
|
||||
{/*<Box sx={{ mt: 4, textAlign: 'center' }}>*/}
|
||||
{/* <img*/}
|
||||
{/* src="/guild.png"*/}
|
||||
{/* alt="노인정길드"*/}
|
||||
{/* style={{ maxWidth: '100%', height: 'auto', borderRadius: '16px' }}*/}
|
||||
{/* />*/}
|
||||
{/*</Box>*/}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
22
src/pages/admin/AdminLayout.tsx
Normal file
22
src/pages/admin/AdminLayout.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import { Outlet, Link as RouterLink, useLocation } from "react-router-dom";
|
||||
import { Box, Stack, Button } from "@mui/material";
|
||||
import { setAdminToken } from "../../lib/adminAuth";
|
||||
import { ADMIN_BASE } from "../../lib/config";
|
||||
|
||||
export default function AdminLayout() {
|
||||
const loc = useLocation();
|
||||
const is = (s: string) => loc.pathname.endsWith(s);
|
||||
return (
|
||||
<Box p={2}>
|
||||
<Stack direction="row" spacing={1} mb={2}>
|
||||
<Button component={RouterLink} to="boards" variant={is(`${ADMIN_BASE}/boards`) ? "contained" : "outlined"}>게시판관리</Button>
|
||||
<Button component={RouterLink} to="users" variant={is(`${ADMIN_BASE}/users`) ? "contained" : "outlined"}>회원관리</Button>
|
||||
<Box flex={1} />
|
||||
<Button color="error" onClick={()=>{ setAdminToken(null); location.href = location.pathname.replace(/\/[^\/]*$/, `${ADMIN_BASE}/login`); }}>
|
||||
로그아웃
|
||||
</Button>
|
||||
</Stack>
|
||||
<Outlet />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
22
src/pages/admin/Boards.tsx
Normal file
22
src/pages/admin/Boards.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { adminListBoards } from "../../lib/adminApi";
|
||||
import { getAdminToken } from "../../lib/adminAuth";
|
||||
import { Box, Typography, List, ListItem, ListItemText } from "@mui/material";
|
||||
|
||||
export default function AdminBoards() {
|
||||
const [items, setItems] = useState<any[]>([]);
|
||||
useEffect(()=>{
|
||||
const t = getAdminToken()!;
|
||||
adminListBoards(t).then(setItems).catch(console.error);
|
||||
}, []);
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h6" mb={1}>게시판 관리</Typography>
|
||||
<List dense>
|
||||
{items.map(b=>(
|
||||
<ListItem key={b.id}><ListItemText primary={`${b.code} — ${b.name}`} secondary={b.description} /></ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
35
src/pages/admin/Login.tsx
Normal file
35
src/pages/admin/Login.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Box, Stack, TextField, Button, Typography, Alert } from "@mui/material";
|
||||
import { adminLogin } from "../../lib/adminApi";
|
||||
import { setAdminToken } from "../../lib/adminAuth";
|
||||
import { ADMIN_BASE } from "../../lib/config";
|
||||
|
||||
export default function AdminLogin() {
|
||||
const nav = useNavigate();
|
||||
const [username, setU] = useState(""); const [password, setP] = useState("");
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
async function onSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const { access_token } = await adminLogin(username, password);
|
||||
setAdminToken(access_token);
|
||||
nav(`${ADMIN_BASE}/users`, { replace: true });
|
||||
} catch (e:any) { setErr(e?.message || "로그인 실패"); }
|
||||
}
|
||||
|
||||
return (
|
||||
<Box maxWidth={360} m="64px auto">
|
||||
<Typography variant="h5" mb={2}>관리자 로그인</Typography>
|
||||
<Box component="form" onSubmit={onSubmit}>
|
||||
<Stack spacing={2}>
|
||||
<TextField label="아이디" value={username} onChange={e=>setU(e.target.value)} required />
|
||||
<TextField label="비밀번호" type="password" value={password} onChange={e=>setP(e.target.value)} required />
|
||||
<Button type="submit" variant="contained">로그인</Button>
|
||||
{err && <Alert severity="error">{err}</Alert>}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
7
src/pages/admin/RequireAdmin.tsx
Normal file
7
src/pages/admin/RequireAdmin.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
import { Navigate } from "react-router-dom";
|
||||
import { getAdminToken } from "../../lib/adminAuth";
|
||||
import { ADMIN_BASE } from "../../lib/config";
|
||||
|
||||
export default function RequireAdmin({ children }: { children: React.ReactNode }) {
|
||||
return getAdminToken() ? <>{children}</> : <Navigate to={`${ADMIN_BASE}/login`} replace />;
|
||||
}
|
||||
47
src/pages/admin/Users.tsx
Normal file
47
src/pages/admin/Users.tsx
Normal file
@ -0,0 +1,47 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { adminListUsers } from "../../lib/adminApi";
|
||||
import { getAdminToken } from "../../lib/adminAuth";
|
||||
import { Box, Typography, Table, TableHead, TableRow, TableCell, TableBody } from "@mui/material";
|
||||
|
||||
type Row = {
|
||||
id: number;
|
||||
username: string;
|
||||
email?: string | null;
|
||||
created_at?: string;
|
||||
is_active?: boolean | null;
|
||||
};
|
||||
|
||||
export default function AdminUsers() {
|
||||
const [rows, setRows] = useState<Row[]>([]);
|
||||
useEffect(()=>{
|
||||
const t = getAdminToken()!;
|
||||
adminListUsers(t).then(setRows).catch(console.error);
|
||||
}, []);
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h6" mb={1}>회원관리</Typography>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>ID</TableCell>
|
||||
<TableCell>아이디</TableCell>
|
||||
<TableCell>이메일</TableCell>
|
||||
<TableCell>가입일</TableCell>
|
||||
<TableCell>활성</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{rows.map(r=>(
|
||||
<TableRow key={r.id}>
|
||||
<TableCell>{r.id}</TableCell>
|
||||
<TableCell>{r.username}</TableCell>
|
||||
<TableCell>{r.email ?? "-"}</TableCell>
|
||||
<TableCell>{r.created_at ? new Date(r.created_at).toLocaleString() : "-"}</TableCell>
|
||||
<TableCell>{r.is_active === false ? "N" : "Y"}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user