관리자부분 추가

This commit is contained in:
김종호 2025-09-19 16:31:16 +09:00
parent 700e1ce666
commit 3abdfbfb95
12 changed files with 208 additions and 13 deletions

View File

@ -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>

View File

@ -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
View 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
View 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");
};

View File

@ -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
View 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 프리픽스

View File

@ -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>

View 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>
);
}

View 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
View 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>
);
}

View 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
View 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>
);
}