Initial Commit

This commit is contained in:
ldy 2025-06-09 16:19:14 +08:00
parent 5cae6fec49
commit 6a032196e7
12 changed files with 569 additions and 0 deletions

View File

@ -1,2 +1,59 @@
# 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
View 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
View 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
View File

@ -0,0 +1 @@
username:password

21
frontend/package.json Normal file
View 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"
}
}

View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
}
}

221
frontend/src/App.jsx Normal file
View 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
View 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
View 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
View 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>
);

View 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
View 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: './'
});