Initial Commit
This commit is contained in:
parent
5cae6fec49
commit
6a032196e7
57
README.md
57
README.md
@ -1,2 +1,59 @@
|
|||||||
# ComicViewer
|
# ComicViewer
|
||||||
|
|
||||||
|
## I. Project Description
|
||||||
|
|
||||||
|
- Automatically read comic directories, support jpg/png/webp/gif/mp4/html files
|
||||||
|
- Front-end responsive, compatible with all device
|
||||||
|
- Thumbnails, quick page switching, folder display
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## II. Project Structure
|
||||||
|
|
||||||
|
- backend/ Node.js+Viewers API
|
||||||
|
- frontend/ React+Vite+Tailwind Frontend
|
||||||
|
- README.md
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## III. Deployment
|
||||||
|
|
||||||
|
### 1. Backend
|
||||||
|
|
||||||
|
1. Install Node.js、PM2
|
||||||
|
2. Upload backend folder, execute following codes via ssh:
|
||||||
|
`npm install`
|
||||||
|
3. Modify `comicsRoot` in **server.js**
|
||||||
|
4. Set up at least one username and password in **users.txt**
|
||||||
|
5. Start PM2 service:
|
||||||
|
`pm2 start server.js --name comic-backend`,
|
||||||
|
`pm2 save`,
|
||||||
|
`pm2 startup`
|
||||||
|
|
||||||
|
### 2. Frontend
|
||||||
|
|
||||||
|
1. Redirect to the **frontend/** folder and execute:
|
||||||
|
`npm install`,
|
||||||
|
`npm run build`
|
||||||
|
2. Copy contents inside **dist/** to the root folder of your website
|
||||||
|
3. Set up Reverse Proxy:
|
||||||
|
>Nginx Example:
|
||||||
|
```
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://127.0.0.1:3001;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
root /www/wwwroot/你的站点目录;
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Miscellaneous
|
||||||
|
|
||||||
|
- Default port is 3001, It is recommended to open only locally and use a reverse proxy for the external network
|
||||||
|
- Suggested to put each comic in a separate folder
|
||||||
|
|
||||||
|
|||||||
15
backend/package.json
Normal file
15
backend/package.json
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "comicviewer-backend",
|
||||||
|
"version": "1.1.0",
|
||||||
|
"main": "server.js",
|
||||||
|
"type": "commonjs",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"express": "^4.19.2",
|
||||||
|
"express-session": "^1.18.0",
|
||||||
|
"sharp": "^0.33.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
153
backend/server.js
Normal file
153
backend/server.js
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const sharp = require('sharp');
|
||||||
|
const session = require('express-session');
|
||||||
|
const app = express();
|
||||||
|
const port = 3001;
|
||||||
|
|
||||||
|
const comicsRoot = '/www/comics'; // 修改为你的漫画主目录
|
||||||
|
|
||||||
|
app.use(require('cors')({
|
||||||
|
origin: true,
|
||||||
|
credentials: true // 允许跨域携带cookie(前端用 credentials: 'include')
|
||||||
|
}));
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(session({
|
||||||
|
secret: 'comic-viewer-2024', // 建议改成强随机字符串
|
||||||
|
resave: false,
|
||||||
|
saveUninitialized: false,
|
||||||
|
cookie: { httpOnly: true, maxAge: 30 * 24 * 3600 * 1000 }
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ==========================
|
||||||
|
// 用户管理
|
||||||
|
// ==========================
|
||||||
|
|
||||||
|
// users.txt 格式: 每行 username:password
|
||||||
|
function loadUsers() {
|
||||||
|
const file = path.join(__dirname, 'users.txt');
|
||||||
|
if (!fs.existsSync(file)) return {};
|
||||||
|
const users = {};
|
||||||
|
fs.readFileSync(file, 'utf8').split(/\r?\n/).forEach(line => {
|
||||||
|
if (!line.trim() || line.trim().startsWith('#')) return;
|
||||||
|
const [u, p] = line.split(':');
|
||||||
|
if (u && p) users[u.trim()] = p.trim();
|
||||||
|
});
|
||||||
|
return users;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 登录
|
||||||
|
app.post('/api/login', (req, res) => {
|
||||||
|
const { username, password } = req.body;
|
||||||
|
const users = loadUsers();
|
||||||
|
if (users[username] && users[username] === password) {
|
||||||
|
req.session.login = true;
|
||||||
|
req.session.username = username;
|
||||||
|
res.json({ ok: true });
|
||||||
|
} else {
|
||||||
|
res.status(401).json({ ok: false, msg: '用户名或密码错误' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// 登出
|
||||||
|
app.post('/api/logout', (req, res) => {
|
||||||
|
req.session.destroy(() => res.json({ ok: true }));
|
||||||
|
});
|
||||||
|
// 检查登录状态
|
||||||
|
app.get('/api/check', (req, res) => {
|
||||||
|
res.json({ login: !!req.session.login, username: req.session.username });
|
||||||
|
});
|
||||||
|
|
||||||
|
// 认证中间件
|
||||||
|
function auth(req, res, next) {
|
||||||
|
if (req.session.login) return next();
|
||||||
|
res.status(401).json({ ok: false, msg: '请先登录' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================
|
||||||
|
// 业务API(所有加auth保护)
|
||||||
|
// ==========================
|
||||||
|
|
||||||
|
// 文件类型判定
|
||||||
|
function isImage(fn) { return /\.(jpe?g|png|webp|gif)$/i.test(fn); }
|
||||||
|
function isHtml(fn) { return /\.html?$/i.test(fn); }
|
||||||
|
function isVideo(fn) { return /\.(mp4|webm|mov|avi|mkv|ogg)$/i.test(fn); }
|
||||||
|
|
||||||
|
// 只列出非空文件夹
|
||||||
|
app.get('/api/folders', auth, (req, res) => {
|
||||||
|
fs.readdir(comicsRoot, { withFileTypes: true }, (err, files) => {
|
||||||
|
if (err) return res.status(500).json([]);
|
||||||
|
const dirs = files.filter(f => f.isDirectory()).map(f => f.name);
|
||||||
|
Promise.all(
|
||||||
|
dirs.map(d =>
|
||||||
|
new Promise(resolve => {
|
||||||
|
fs.readdir(path.join(comicsRoot, d), (e, fls) => {
|
||||||
|
const nonHidden = (fls || []).filter(f => !f.startsWith('.'));
|
||||||
|
resolve(nonHidden.length > 0 ? d : null);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
)
|
||||||
|
).then(results => {
|
||||||
|
res.json(results.filter(Boolean));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 文件夹下所有文件(图片/html/视频/其它)
|
||||||
|
app.get('/api/list/:folder', auth, (req, res) => {
|
||||||
|
const folder = path.basename(req.params.folder);
|
||||||
|
const dir = path.join(comicsRoot, folder);
|
||||||
|
fs.readdir(dir, (err, files) => {
|
||||||
|
if (err) return res.status(404).json([]);
|
||||||
|
const filtered = files.filter(f => !f.startsWith('.'));
|
||||||
|
res.json(filtered.sort((a, b) => a.localeCompare(b, 'zh', { numeric: true })));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 文件夹下所有图片(按文件名排序)
|
||||||
|
app.get('/api/images/:folder', auth, (req, res) => {
|
||||||
|
const folder = path.basename(req.params.folder);
|
||||||
|
const dir = path.join(comicsRoot, folder);
|
||||||
|
fs.readdir(dir, (err, files) => {
|
||||||
|
if (err) return res.status(404).json([]);
|
||||||
|
const imgs = files.filter(isImage).sort((a, b) => a.localeCompare(b, 'zh', { numeric: true }));
|
||||||
|
res.json(imgs);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 图片/视频/其它原始文件
|
||||||
|
app.get('/api/image/:folder/:img', auth, (req, res) => {
|
||||||
|
const folder = path.basename(req.params.folder);
|
||||||
|
const img = path.basename(req.params.img);
|
||||||
|
const file = path.join(comicsRoot, folder, img);
|
||||||
|
fs.access(file, fs.constants.R_OK, (err) => {
|
||||||
|
if (err) return res.status(404).end();
|
||||||
|
res.sendFile(file);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 图片缩略图(只对图片调用)
|
||||||
|
app.get('/api/image-thumb/:folder/:img', auth, (req, res) => {
|
||||||
|
const folder = path.basename(req.params.folder);
|
||||||
|
const img = path.basename(req.params.img);
|
||||||
|
const file = path.join(comicsRoot, folder, img);
|
||||||
|
fs.access(file, fs.constants.R_OK, (err) => {
|
||||||
|
if (err) return res.status(404).end();
|
||||||
|
sharp(file).resize({ height: 128 }).toBuffer()
|
||||||
|
.then(buf => res.type('image/jpeg').send(buf))
|
||||||
|
.catch(() => res.status(404).end());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// HTML 文件(iframe方式)
|
||||||
|
app.get('/api/html/:folder/:file', auth, (req, res) => {
|
||||||
|
const folder = path.basename(req.params.folder);
|
||||||
|
const file = path.basename(req.params.file);
|
||||||
|
const filePath = path.join(comicsRoot, folder, file);
|
||||||
|
fs.access(filePath, fs.constants.R_OK, (err) => {
|
||||||
|
if (err) return res.status(404).end();
|
||||||
|
res.sendFile(filePath);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(port, () => console.log('Comic backend running at port', port));
|
||||||
1
backend/users.txt
Normal file
1
backend/users.txt
Normal file
@ -0,0 +1 @@
|
|||||||
|
username:password
|
||||||
21
frontend/package.json
Normal file
21
frontend/package.json
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "comicviewer-frontend",
|
||||||
|
"version": "1.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-react": "^4.0.4",
|
||||||
|
"autoprefixer": "^10.4.17",
|
||||||
|
"postcss": "^8.4.35",
|
||||||
|
"tailwindcss": "^3.4.3",
|
||||||
|
"vite": "^4.4.9"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
}
|
||||||
|
}
|
||||||
221
frontend/src/App.jsx
Normal file
221
frontend/src/App.jsx
Normal file
@ -0,0 +1,221 @@
|
|||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import Login from "./Login";
|
||||||
|
|
||||||
|
const apiBase = "/api";
|
||||||
|
const isImage = (name) => /\.(jpe?g|png|webp|gif)$/i.test(name);
|
||||||
|
const isHtml = (name) => /\.html?$/i.test(name);
|
||||||
|
const isVideo = (name) => /\.(mp4|webm|mov|avi|mkv|ogg)$/i.test(name);
|
||||||
|
|
||||||
|
function FolderList({ onOpen }) {
|
||||||
|
const [folders, setFolders] = useState([]);
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`${apiBase}/folders`).then((r) => r.json()).then(setFolders);
|
||||||
|
}, []);
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-br from-indigo-100 to-white p-8">
|
||||||
|
<h1 className="text-3xl font-bold mb-8">Comics Library</h1>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-6 w-full max-w-4xl">
|
||||||
|
{folders.length === 0 && <div className="text-gray-400 col-span-full">暂无可用漫画</div>}
|
||||||
|
{folders.map((name) => (
|
||||||
|
<button
|
||||||
|
key={name}
|
||||||
|
className="bg-white hover:bg-indigo-50 shadow-xl rounded-2xl p-8 text-xl font-semibold transition border border-indigo-100"
|
||||||
|
onClick={() => onOpen(name)}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FolderContent({ folder, onBack }) {
|
||||||
|
const [files, setFiles] = useState([]);
|
||||||
|
const [view, setView] = useState(null); // {type: 'image'|'html'|'video', file: string, index?: number}
|
||||||
|
const [allImages, setAllImages] = useState([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`${apiBase}/list/${encodeURIComponent(folder)}`)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(fls => {
|
||||||
|
setFiles(fls);
|
||||||
|
const imgs = fls.filter(isImage);
|
||||||
|
setAllImages(imgs);
|
||||||
|
if (imgs.length > 0 && imgs.length === fls.length) {
|
||||||
|
setView({ type: 'image', file: imgs[0], index: 0 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [folder]);
|
||||||
|
|
||||||
|
// 三种文件类型的预览模式
|
||||||
|
if (view && view.type === "image") {
|
||||||
|
return (
|
||||||
|
<ComicViewer
|
||||||
|
files={allImages}
|
||||||
|
folder={folder}
|
||||||
|
index={view.index}
|
||||||
|
onBack={() => setView(null)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (view && view.type === "html") {
|
||||||
|
return (
|
||||||
|
<div className="w-full min-h-screen flex flex-col items-center">
|
||||||
|
<div className="flex w-full max-w-3xl justify-between items-center p-2">
|
||||||
|
<button className="p-2 m-2" onClick={() => setView(null)}>⬅ 返回</button>
|
||||||
|
<span className="font-semibold text-lg">{view.file}</span>
|
||||||
|
</div>
|
||||||
|
<iframe
|
||||||
|
src={`${apiBase}/html/${encodeURIComponent(folder)}/${encodeURIComponent(view.file)}`}
|
||||||
|
className="w-full max-w-3xl min-h-[80vh] border rounded-lg shadow-lg"
|
||||||
|
title="Comic HTML"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (view && view.type === "video") {
|
||||||
|
return (
|
||||||
|
<div className="w-full min-h-screen flex flex-col items-center bg-neutral-100">
|
||||||
|
<div className="flex w-full max-w-3xl justify-between items-center p-2">
|
||||||
|
<button className="p-2 m-2" onClick={() => setView(null)}>⬅ 返回</button>
|
||||||
|
<span className="font-semibold text-lg">{view.file}</span>
|
||||||
|
</div>
|
||||||
|
<video
|
||||||
|
controls
|
||||||
|
autoPlay
|
||||||
|
style={{ width: '100%', maxWidth: '900px', maxHeight: '70vh', background: '#000', borderRadius: '1rem', boxShadow: '0 2px 16px rgba(0,0,0,0.18)' }}
|
||||||
|
src={`${apiBase}/image/${encodeURIComponent(folder)}/${encodeURIComponent(view.file)}`}
|
||||||
|
poster=""
|
||||||
|
>
|
||||||
|
抱歉,您的浏览器不支持 video 标签。
|
||||||
|
</video>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 列表展示
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col items-center bg-gradient-to-br from-indigo-50 to-white p-8">
|
||||||
|
<div className="flex w-full max-w-3xl justify-between items-center p-2 mb-4">
|
||||||
|
<button className="p-2" onClick={onBack}>⬅ 返回</button>
|
||||||
|
<span className="font-semibold text-lg">{folder}</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full max-w-3xl grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
{files.length === 0 && <div className="text-gray-400 col-span-full">空文件夹</div>}
|
||||||
|
{files.map((f, i) => (
|
||||||
|
<div key={f} className="bg-white shadow-md rounded-xl flex flex-col items-center p-3">
|
||||||
|
{isImage(f) ? (
|
||||||
|
<img
|
||||||
|
src={`${apiBase}/image-thumb/${encodeURIComponent(folder)}/${encodeURIComponent(f)}`}
|
||||||
|
alt={f}
|
||||||
|
className="h-24 w-auto rounded mb-2 cursor-pointer hover:scale-105 transition"
|
||||||
|
onClick={() => setView({ type: 'image', file: f, index: allImages.indexOf(f) })}
|
||||||
|
/>
|
||||||
|
) : isVideo(f) ? (
|
||||||
|
<button
|
||||||
|
className="flex flex-col items-center text-indigo-700 cursor-pointer"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
onClick={() => setView({ type: 'video', file: f })}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center h-20">
|
||||||
|
<svg width="48" height="48" fill="none"><circle cx="24" cy="24" r="20" fill="#6366f1" opacity="0.15"/><polygon points="19,16 36,24 19,32" fill="#6366f1"/></svg>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1">{f}</div>
|
||||||
|
</button>
|
||||||
|
) : isHtml(f) ? (
|
||||||
|
<button
|
||||||
|
className="text-indigo-700 underline cursor-pointer"
|
||||||
|
onClick={() => setView({ type: 'html', file: f })}
|
||||||
|
>
|
||||||
|
{f}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-500">{f}</span>
|
||||||
|
)}
|
||||||
|
<div className="text-sm text-gray-600 truncate">{f}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComicViewer({ files, folder, index = 0, onBack }) {
|
||||||
|
const [curIdx, setCurIdx] = useState(index);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCurIdx(index);
|
||||||
|
}, [index]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e) => {
|
||||||
|
if (e.key === "ArrowLeft" || e.key === "ArrowUp") setCurIdx(i => Math.max(i - 1, 0));
|
||||||
|
if (e.key === "ArrowRight" || e.key === "ArrowDown") setCurIdx(i => Math.min(i + 1, files.length - 1));
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", handler);
|
||||||
|
return () => window.removeEventListener("keydown", handler);
|
||||||
|
}, [files.length]);
|
||||||
|
|
||||||
|
if (!files.length) return <div className="text-center p-16">无图片</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-neutral-100 flex flex-col items-center">
|
||||||
|
<div className="flex w-full items-center justify-between max-w-3xl p-2">
|
||||||
|
<button
|
||||||
|
className="p-2 text-lg hover:bg-indigo-100 rounded-xl"
|
||||||
|
onClick={onBack}
|
||||||
|
>⬅ 返回</button>
|
||||||
|
<span className="text-xl font-semibold">{folder}</span>
|
||||||
|
<span className="text-gray-500">
|
||||||
|
{curIdx + 1} / {files.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-center w-full max-w-3xl grow">
|
||||||
|
<button onClick={() => setCurIdx(i => Math.max(i - 1, 0))} disabled={curIdx === 0} className="p-3 text-2xl hover:bg-indigo-200 rounded-xl disabled:opacity-30">◀</button>
|
||||||
|
<div className="flex-grow flex flex-col items-center">
|
||||||
|
<img
|
||||||
|
src={`${apiBase}/image/${encodeURIComponent(folder)}/${encodeURIComponent(files[curIdx])}`}
|
||||||
|
alt={files[curIdx]}
|
||||||
|
className="max-h-[70vh] w-auto max-w-full rounded-2xl shadow-lg object-contain transition-all duration-300"
|
||||||
|
draggable="false"
|
||||||
|
/>
|
||||||
|
<div className="text-base mt-2 text-gray-500">{files[curIdx]}</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setCurIdx(i => Math.min(i + 1, files.length - 1))} disabled={curIdx === files.length - 1} className="p-3 text-2xl hover:bg-indigo-200 rounded-xl disabled:opacity-30">▶</button>
|
||||||
|
</div>
|
||||||
|
<div className="w-full max-w-3xl flex gap-2 mt-4 flex-wrap justify-center">
|
||||||
|
{files.map((f, i) => (
|
||||||
|
<img
|
||||||
|
key={f}
|
||||||
|
src={`${apiBase}/image-thumb/${encodeURIComponent(folder)}/${encodeURIComponent(f)}`}
|
||||||
|
alt={f}
|
||||||
|
onClick={() => setCurIdx(i)}
|
||||||
|
className={`h-16 w-auto rounded border-2 ${i === curIdx ? "border-indigo-500" : "border-gray-200"} cursor-pointer hover:scale-105 transition`}
|
||||||
|
draggable="false"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [login, setLogin] = useState(null);
|
||||||
|
|
||||||
|
function checkLogin() {
|
||||||
|
fetch("/api/check", { credentials: "include" })
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((d) => setLogin(!!d.login));
|
||||||
|
}
|
||||||
|
useEffect(() => { checkLogin(); }, []);
|
||||||
|
|
||||||
|
if (login === null) return <div>加载中...</div>;
|
||||||
|
if (!login) return <Login onLogin={checkLogin} />;
|
||||||
|
|
||||||
|
// 已登录,显示主界面
|
||||||
|
const [folder, setFolder] = useState(null);
|
||||||
|
return folder
|
||||||
|
? <FolderContent folder={folder} onBack={() => setFolder(null)} />
|
||||||
|
: <FolderList onOpen={setFolder} />;
|
||||||
|
}
|
||||||
56
frontend/src/Login.jsx
Normal file
56
frontend/src/Login.jsx
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
|
||||||
|
export default function Login({ onLogin }) {
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [err, setErr] = useState("");
|
||||||
|
|
||||||
|
function handleSubmit(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
fetch("/api/login", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
})
|
||||||
|
.then((r) => {
|
||||||
|
if (!r.ok) throw new Error("用户名或密码错误");
|
||||||
|
return r.json();
|
||||||
|
})
|
||||||
|
.then((d) => {
|
||||||
|
if (d.ok) onLogin();
|
||||||
|
else setErr("用户名或密码错误");
|
||||||
|
})
|
||||||
|
.catch(() => setErr("用户名或密码错误"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col justify-center items-center bg-gradient-to-br from-indigo-100 to-white">
|
||||||
|
<form
|
||||||
|
className="w-80 bg-white shadow-xl rounded-2xl px-8 py-10 flex flex-col gap-6"
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
>
|
||||||
|
<h2 className="text-2xl font-bold text-center mb-4">登录</h2>
|
||||||
|
<input
|
||||||
|
className="border px-3 py-2 rounded"
|
||||||
|
placeholder="用户名"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="border px-3 py-2 rounded"
|
||||||
|
type="password"
|
||||||
|
placeholder="密码"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{err && <div className="text-red-500 text-center">{err}</div>}
|
||||||
|
<button className="bg-indigo-600 hover:bg-indigo-700 text-white py-2 rounded-xl font-semibold text-lg transition" type="submit">
|
||||||
|
登录
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
frontend/src/index.css
Normal file
7
frontend/src/index.css
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: linear-gradient(135deg, #eef2ff, #fff);
|
||||||
|
}
|
||||||
10
frontend/src/main.jsx
Normal file
10
frontend/src/main.jsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import App from './App.jsx';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
10
frontend/tailwind.config.js
Normal file
10
frontend/tailwind.config.js
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
module.exports = {
|
||||||
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./src/**/*.{js,jsx,ts,tsx}"
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
12
frontend/vite.config.js
Normal file
12
frontend/vite.config.js
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://localhost:3001'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
base: './'
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user