1459
This commit is contained in:
1 parent
4ab47a3e47
commit
ba016efbe8
21 files changed
+921
-72
No files matched your search
@@ -10,4 +10,5 @@ node_modules
|
||||
!package-lock.json
|
||||
/nginx
|
||||
/systemd
|
||||
/scripts
|
||||
/scripts/*
|
||||
!/scripts/hash-users.js
|
||||
@@ -65,6 +65,9 @@ COPY --from=builder /app/package.json ./
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
# Copy password hash script
|
||||
COPY scripts/hash-users.js /app/scripts/hash-users.js
|
||||
|
||||
# Create necessary directories
|
||||
RUN mkdir -p /app/logs /app/backups /app/recycle/posts /app/recycle/meta
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ services:
|
||||
- ./backups:/app/backups
|
||||
# 环境变量文件
|
||||
- ./.env:/app/.env:ro
|
||||
# 用户配置
|
||||
- ./users.json:/app/users.json:ro
|
||||
# SSH 密钥(用于 git push)
|
||||
- /root/.ssh:/home/nextjs/.ssh
|
||||
environment:
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
#!/bin/sh
|
||||
# Docker entrypoint script
|
||||
# 修复挂载目录权限后以 nextjs 用户启动应用
|
||||
|
||||
# 创建必要的子目录(以 root 身份)
|
||||
mkdir -p /app/recycle/posts /app/recycle/meta /app/logs /app/backups
|
||||
@@ -8,13 +7,21 @@ mkdir -p /app/recycle/posts /app/recycle/meta /app/logs /app/backups
|
||||
# 修复 recycle 目录权限
|
||||
chown -R nextjs:nodejs /app/recycle /app/logs /app/backups
|
||||
|
||||
# 修复博客目录权限(让 nextjs 用户可写)
|
||||
# 自动哈希明文密码(users.json 中没有 salt 字段时)
|
||||
if [ -f "/app/users.json" ]; then
|
||||
if ! grep -q '"salt"' /app/users.json 2>/dev/null; then
|
||||
echo "[entrypoint] 检测到明文密码,自动哈希..."
|
||||
node /app/scripts/hash-users.js
|
||||
echo "[entrypoint] 密码哈希完成"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 修复博客目录权限
|
||||
if [ -d "/blog" ]; then
|
||||
# 博客根目录及其所有文件
|
||||
chown -R nextjs:nodejs /blog
|
||||
fi
|
||||
|
||||
# 配置 git(容器内 nextjs 用户)
|
||||
# 配置 git
|
||||
su-exec nextjs git config --global user.name "Write Server" 2>/dev/null
|
||||
su-exec nextjs git config --global user.email "write@usj.cc" 2>/dev/null
|
||||
su-exec nextjs git config --global --add safe.directory /blog 2>/dev/null
|
||||
|
||||
@@ -29,29 +29,15 @@ server {
|
||||
access_log /var/log/nginx/write-server-access.log;
|
||||
error_log /var/log/nginx/write-server-error.log;
|
||||
|
||||
# 静态文件(登录页、Service Worker、htpasswd 验证)
|
||||
# 静态文件(Service Worker)
|
||||
location /sw.js {
|
||||
alias /etc/nginx/sw.js;
|
||||
add_header Content-Type application/javascript;
|
||||
add_header Cache-Control "no-cache, no-store";
|
||||
}
|
||||
|
||||
location /login.html {
|
||||
alias /etc/nginx/login.html;
|
||||
add_header Cache-Control "no-cache, no-store";
|
||||
}
|
||||
|
||||
location /auth/check {
|
||||
auth_basic "Write Server";
|
||||
auth_basic_user_file /etc/nginx/.htpasswd;
|
||||
return 200 'ok';
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
|
||||
# 所有其他请求通过 auth_request 验证
|
||||
# 所有请求代理到 write-server
|
||||
location / {
|
||||
auth_request /auth/internal;
|
||||
|
||||
proxy_pass http://write-server:8016;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
@@ -66,22 +52,6 @@ server {
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 120s;
|
||||
client_max_body_size 50m;
|
||||
|
||||
error_page 401 = @login;
|
||||
}
|
||||
|
||||
location = /auth/internal {
|
||||
internal;
|
||||
proxy_pass http://write-server:8016/api/stats;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
proxy_pass_request_body off;
|
||||
proxy_set_header Content-Length "";
|
||||
}
|
||||
|
||||
location @login {
|
||||
internal;
|
||||
add_header WWW-Authenticate "";
|
||||
return 302 /login.html;
|
||||
}
|
||||
|
||||
location ~ /\. {
|
||||
|
||||
+12
-11
@@ -1,14 +1,18 @@
|
||||
const CACHE = 'v1';
|
||||
const LOGIN = '/login.html';
|
||||
const EXPIRE_MS = 3 * 60 * 60 * 1000; // 3小时
|
||||
const LOGIN = '/login';
|
||||
const EXPIRE_MS = 3 * 60 * 60 * 1000;
|
||||
|
||||
self.addEventListener('install', () => self.skipWaiting());
|
||||
|
||||
self.addEventListener('activate', e => e.waitUntil(self.clients.claim()));
|
||||
|
||||
self.addEventListener('fetch', e => {
|
||||
const req = e.request;
|
||||
if (req.url.includes('/login.html') || req.url.includes('/auth/')) {
|
||||
const url = new URL(req.url);
|
||||
|
||||
// 登录页、静态资源不拦截
|
||||
if (url.pathname === '/login' || url.pathname.startsWith('/_next/') ||
|
||||
url.pathname.endsWith('.js') || url.pathname.endsWith('.css') ||
|
||||
url.pathname.endsWith('.ico') || url.pathname.endsWith('.woff2')) {
|
||||
e.respondWith(fetch(req));
|
||||
return;
|
||||
}
|
||||
@@ -18,16 +22,13 @@ self.addEventListener('fetch', e => {
|
||||
const tokenTime = self.__auth_time || 0;
|
||||
|
||||
if (!token || (Date.now() - tokenTime > EXPIRE_MS)) {
|
||||
// token 过期或不存在,重定向到登录页
|
||||
if (token) {
|
||||
self.__auth_token = null;
|
||||
self.__auth_time = 0;
|
||||
}
|
||||
self.__auth_token = null;
|
||||
self.__auth_time = 0;
|
||||
return Response.redirect(LOGIN, 302);
|
||||
}
|
||||
|
||||
const headers = new Headers(req.headers);
|
||||
headers.set('Authorization', 'Basic ' + token);
|
||||
headers.set('X-Auth-User', token);
|
||||
const res = await fetch(new Request(req, { headers }));
|
||||
|
||||
if (res.status === 401) {
|
||||
@@ -35,11 +36,11 @@ self.addEventListener('fetch', e => {
|
||||
self.__auth_time = 0;
|
||||
return Response.redirect(LOGIN, 302);
|
||||
}
|
||||
|
||||
return res;
|
||||
})());
|
||||
});
|
||||
|
||||
// 接收页面发来的 token
|
||||
self.addEventListener('message', e => {
|
||||
if (e.data && e.data.type === 'SET_TOKEN') {
|
||||
self.__auth_token = e.data.token;
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
// 用法: node scripts/hash-users.js
|
||||
// 生成哈希后的 users.json
|
||||
|
||||
const { createHash, randomBytes } = require("crypto");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
function hashPassword(password, salt) {
|
||||
return createHash("sha256").update(salt + password).digest("hex");
|
||||
}
|
||||
|
||||
// 在这里修改用户信息
|
||||
const users = [
|
||||
{ username: "admin", password: "admin123", name: "小赵", role: "admin" },
|
||||
{ username: "xiaomei", password: "xiaomei123", name: "小美", role: "user" },
|
||||
];
|
||||
|
||||
const hashedUsers = users.map(u => {
|
||||
const salt = randomBytes(16).toString("hex");
|
||||
const passwordHash = hashPassword(u.password, salt);
|
||||
return {
|
||||
username: u.username,
|
||||
passwordHash,
|
||||
salt,
|
||||
name: u.name,
|
||||
role: u.role,
|
||||
};
|
||||
});
|
||||
|
||||
const output = { users: hashedUsers };
|
||||
const outPath = path.join(__dirname, "..", "users.json");
|
||||
fs.writeFileSync(outPath, JSON.stringify(output, null, 2) + "\n");
|
||||
|
||||
console.log("已生成 users.json(密码已哈希)");
|
||||
console.log("用户名列表:", hashedUsers.map(u => u.username).join(", "));
|
||||
@@ -0,0 +1,41 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { authenticateUser, getAuthFromRequest } from "@/lib/auth";
|
||||
|
||||
// POST /api/auth/login - 登录
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { username, password } = body;
|
||||
|
||||
if (!username || !password) {
|
||||
return NextResponse.json({ error: "用户名和密码不能为空" }, { status: 400 });
|
||||
}
|
||||
|
||||
const user = authenticateUser(username, password);
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "用户名或密码错误" }, { status: 401 });
|
||||
}
|
||||
|
||||
const token = Buffer.from(`${username}:${Date.now()}`).toString("base64");
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
token,
|
||||
user: { username: user.username, name: user.name, role: user.role },
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : "Unknown error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/auth/login - 验证当前用户
|
||||
export async function GET(request: NextRequest) {
|
||||
const user = getAuthFromRequest(request);
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
}
|
||||
return NextResponse.json({ ok: true, user: { username: user.username, name: user.name, role: user.role } });
|
||||
}
|
||||
@@ -1,18 +1,23 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { listPosts, createPost, getPostCount } from "@/lib/posts";
|
||||
import { cleanExpired } from "@/lib/recycle";
|
||||
import { getAuthFromRequest } from "@/lib/auth";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
cleanExpired(); // housekeeping on each request
|
||||
cleanExpired();
|
||||
|
||||
const user = getAuthFromRequest(request);
|
||||
const { searchParams } = request.nextUrl;
|
||||
const search = searchParams.get("search") || "";
|
||||
const page = parseInt(searchParams.get("page") || "1", 10);
|
||||
const perPage = parseInt(searchParams.get("perPage") || "20", 10);
|
||||
const includeDrafts = searchParams.get("includeDrafts") !== "false";
|
||||
|
||||
const result = listPosts({ search, page, perPage, includeDrafts });
|
||||
// 管理员看所有文章,普通用户只看自己的
|
||||
const author = user?.role === "admin" ? undefined : user?.name;
|
||||
|
||||
const result = listPosts({ search, page, perPage, includeDrafts, author });
|
||||
const totalCount = getPostCount();
|
||||
|
||||
return NextResponse.json({ ...result, totalCount });
|
||||
@@ -26,6 +31,7 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const user = getAuthFromRequest(request);
|
||||
const body = await request.json();
|
||||
const { frontMatter, content } = body;
|
||||
|
||||
@@ -36,6 +42,11 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// 自动设置作者为当前登录用户
|
||||
if (user) {
|
||||
frontMatter.author = user.name;
|
||||
}
|
||||
|
||||
const dirPath = createPost(frontMatter, content || "");
|
||||
return NextResponse.json({ success: true, dirPath }, { status: 201 });
|
||||
} catch (error: unknown) {
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { getAuthFromRequest, hashPassword, generatePasswordHash } from "@/lib/auth";
|
||||
|
||||
interface StoredUser {
|
||||
username: string;
|
||||
passwordHash: string;
|
||||
salt: string;
|
||||
name: string;
|
||||
role: "admin" | "user";
|
||||
}
|
||||
|
||||
function getUsersPath(): string {
|
||||
return path.join(process.cwd(), "users.json");
|
||||
}
|
||||
|
||||
function loadUsers(): StoredUser[] {
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(getUsersPath(), "utf-8"));
|
||||
return data.users || [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveUsers(users: StoredUser[]): void {
|
||||
fs.writeFileSync(getUsersPath(), JSON.stringify({ users }, null, 2) + "\n");
|
||||
}
|
||||
|
||||
// GET - 获取用户列表(admin 可看所有,普通用户只看自己)
|
||||
export async function GET(request: NextRequest) {
|
||||
const currentUser = getAuthFromRequest(request);
|
||||
if (!currentUser) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const users = loadUsers();
|
||||
const safeUsers = users.map(u => ({
|
||||
username: u.username,
|
||||
name: u.name,
|
||||
role: u.role,
|
||||
}));
|
||||
|
||||
if (currentUser.role === "admin") {
|
||||
return NextResponse.json({ users: safeUsers });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
users: safeUsers.filter(u => u.username === currentUser.username),
|
||||
});
|
||||
}
|
||||
|
||||
// PUT - 修改密码或用户信息
|
||||
export async function PUT(request: NextRequest) {
|
||||
const currentUser = getAuthFromRequest(request);
|
||||
if (!currentUser) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { targetUsername, oldPassword, newPassword, newName } = body;
|
||||
|
||||
const users = loadUsers();
|
||||
const targetUser = users.find(u => u.username === (targetUsername || currentUser.username));
|
||||
|
||||
if (!targetUser) {
|
||||
return NextResponse.json({ error: "用户不存在" }, { status: 404 });
|
||||
}
|
||||
|
||||
// 普通用户只能改自己
|
||||
if (currentUser.role !== "admin" && targetUser.username !== currentUser.username) {
|
||||
return NextResponse.json({ error: "无权修改其他用户" }, { status: 403 });
|
||||
}
|
||||
|
||||
// 修改密码
|
||||
if (newPassword) {
|
||||
// 非 admin 修改自己的密码需要验证旧密码
|
||||
if (currentUser.role !== "admin" || targetUser.username === currentUser.username) {
|
||||
if (!oldPassword) {
|
||||
return NextResponse.json({ error: "请输入旧密码" }, { status: 400 });
|
||||
}
|
||||
const oldHash = hashPassword(oldPassword, targetUser.salt);
|
||||
if (oldHash !== targetUser.passwordHash) {
|
||||
return NextResponse.json({ error: "旧密码错误" }, { status: 400 });
|
||||
}
|
||||
}
|
||||
const { hash, salt } = generatePasswordHash(newPassword);
|
||||
targetUser.passwordHash = hash;
|
||||
targetUser.salt = salt;
|
||||
}
|
||||
|
||||
// 修改昵称
|
||||
if (newName) {
|
||||
targetUser.name = newName;
|
||||
}
|
||||
|
||||
saveUsers(users);
|
||||
return NextResponse.json({ ok: true, message: "修改成功" });
|
||||
}
|
||||
|
||||
// POST - 添加用户(仅 admin)
|
||||
export async function POST(request: NextRequest) {
|
||||
const currentUser = getAuthFromRequest(request);
|
||||
if (!currentUser || currentUser.role !== "admin") {
|
||||
return NextResponse.json({ error: "仅管理员可添加用户" }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { username, password, name, role } = body;
|
||||
|
||||
if (!username || !password || !name) {
|
||||
return NextResponse.json({ error: "用户名、密码、昵称不能为空" }, { status: 400 });
|
||||
}
|
||||
|
||||
const users = loadUsers();
|
||||
if (users.find(u => u.username === username)) {
|
||||
return NextResponse.json({ error: "用户名已存在" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { hash, salt } = generatePasswordHash(password);
|
||||
users.push({
|
||||
username,
|
||||
passwordHash: hash,
|
||||
salt,
|
||||
name,
|
||||
role: role || "user",
|
||||
});
|
||||
|
||||
saveUsers(users);
|
||||
return NextResponse.json({ ok: true, message: "用户添加成功" });
|
||||
}
|
||||
|
||||
// DELETE - 删除用户(仅 admin)
|
||||
export async function DELETE(request: NextRequest) {
|
||||
const currentUser = getAuthFromRequest(request);
|
||||
if (!currentUser || currentUser.role !== "admin") {
|
||||
return NextResponse.json({ error: "仅管理员可删除用户" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { searchParams } = request.nextUrl;
|
||||
const targetUsername = searchParams.get("username");
|
||||
|
||||
if (!targetUsername) {
|
||||
return NextResponse.json({ error: "缺少用户名" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (targetUsername === currentUser.username) {
|
||||
return NextResponse.json({ error: "不能删除自己" }, { status: 400 });
|
||||
}
|
||||
|
||||
const users = loadUsers();
|
||||
const filtered = users.filter(u => u.username !== targetUsername);
|
||||
|
||||
if (filtered.length === users.length) {
|
||||
return NextResponse.json({ error: "用户不存在" }, { status: 404 });
|
||||
}
|
||||
|
||||
saveUsers(filtered);
|
||||
return NextResponse.json({ ok: true, message: "用户已删除" });
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
import Sidebar from "@/components/Sidebar";
|
||||
import AppShell from "@/components/AppShell";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "写作后台",
|
||||
@@ -26,10 +26,7 @@ export default function RootLayout({
|
||||
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
|
||||
</head>
|
||||
<body className="h-full md:flex bg-bg text-text">
|
||||
<Sidebar />
|
||||
<main className="flex-1 overflow-auto pt-12 md:pt-0 h-full">
|
||||
{children}
|
||||
</main>
|
||||
<AppShell>{children}</AppShell>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// 已登录则跳转
|
||||
const token = localStorage.getItem("wauth_token");
|
||||
if (token) {
|
||||
fetch("/api/auth/login", {
|
||||
headers: { "X-Auth-User": token },
|
||||
}).then(r => {
|
||||
if (r.ok) router.replace("/");
|
||||
else localStorage.removeItem("wauth_token");
|
||||
}).catch(() => localStorage.removeItem("wauth_token"));
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
setError(data.error || "登录失败");
|
||||
return;
|
||||
}
|
||||
|
||||
// 存储用户信息
|
||||
localStorage.setItem("wauth_token", data.user.username);
|
||||
localStorage.setItem("wauth_user", JSON.stringify(data.user));
|
||||
|
||||
// 注册 Service Worker
|
||||
if ("serviceWorker" in navigator) {
|
||||
const reg = await navigator.serviceWorker.register("/sw.js");
|
||||
if (reg.installing) {
|
||||
await new Promise<void>(resolve => {
|
||||
reg.installing!.addEventListener("statechange", function () {
|
||||
if (this.state === "activated") resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
const sw = reg.active || reg.waiting || reg.installing;
|
||||
if (sw) {
|
||||
sw.postMessage({ type: "SET_TOKEN", token: data.user.username });
|
||||
}
|
||||
if (navigator.serviceWorker.controller) {
|
||||
navigator.serviceWorker.controller.postMessage({ type: "SET_TOKEN", token: data.user.username });
|
||||
}
|
||||
}
|
||||
|
||||
router.replace("/");
|
||||
} catch {
|
||||
setError("网络错误,请重试");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-bg">
|
||||
<div className="bg-surface rounded-2xl shadow-lg p-10 w-full max-w-sm">
|
||||
<h1 className="text-xl font-bold text-accent mb-1">写作后台</h1>
|
||||
<p className="text-[13px] text-muted mb-8">优世界 · Blog Admin</p>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 text-red-600 text-[13px] px-4 py-2.5 rounded-lg mb-5">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleLogin} className="space-y-5">
|
||||
<div>
|
||||
<label className="block text-[13px] text-text font-medium mb-1.5">用户名</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
className="w-full px-3.5 py-2.5 rounded-lg border border-line bg-bg text-text text-sm outline-none focus:border-accent transition-colors"
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[13px] text-text font-medium mb-1.5">密码</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
className="w-full px-3.5 py-2.5 rounded-lg border border-line bg-bg text-text text-sm outline-none focus:border-accent transition-colors"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-2.5 bg-accent text-white rounded-lg text-sm font-semibold hover:opacity-90 disabled:opacity-50 transition-opacity"
|
||||
>
|
||||
{loading ? "登录中..." : "登 录"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ArrowLeft, Plus, Trash2, Save, Key, User } from "lucide-react";
|
||||
|
||||
interface UserInfo {
|
||||
username: string;
|
||||
name: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export default function UsersPage() {
|
||||
const router = useRouter();
|
||||
const [users, setUsers] = useState<UserInfo[]>([]);
|
||||
const [currentUser, setCurrentUser] = useState<UserInfo | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [message, setMessage] = useState<{ type: "ok" | "err"; text: string } | null>(null);
|
||||
|
||||
// 修改密码
|
||||
const [showPasswordForm, setShowPasswordForm] = useState(false);
|
||||
const [passwordTarget, setPasswordTarget] = useState("");
|
||||
const [oldPassword, setOldPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
|
||||
// 添加用户
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [newUsername, setNewUsername] = useState("");
|
||||
const [addPassword, setAddPassword] = useState("");
|
||||
const [newName, setNewName] = useState("");
|
||||
const [newRole, setNewRole] = useState("user");
|
||||
|
||||
const isAdmin = currentUser?.role === "admin";
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const u = JSON.parse(localStorage.getItem("wauth_user") || "{}");
|
||||
setCurrentUser(u);
|
||||
} catch {}
|
||||
fetchUsers();
|
||||
}, []);
|
||||
|
||||
async function fetchUsers() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const token = localStorage.getItem("wauth_token");
|
||||
const res = await fetch("/api/users", {
|
||||
headers: { "X-Auth-User": token || "" },
|
||||
});
|
||||
const data = await res.json();
|
||||
setUsers(data.users || []);
|
||||
} catch {} finally { setLoading(false); }
|
||||
}
|
||||
|
||||
async function handleChangePassword(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setMessage(null);
|
||||
try {
|
||||
const token = localStorage.getItem("wauth_token");
|
||||
const res = await fetch("/api/users", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json", "X-Auth-User": token || "" },
|
||||
body: JSON.stringify({
|
||||
targetUsername: passwordTarget || currentUser?.username,
|
||||
oldPassword,
|
||||
newPassword,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error);
|
||||
setMessage({ type: "ok", text: "密码修改成功" });
|
||||
setShowPasswordForm(false);
|
||||
setOldPassword("");
|
||||
setNewPassword("");
|
||||
} catch (e) {
|
||||
setMessage({ type: "err", text: e instanceof Error ? e.message : "修改失败" });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddUser(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setMessage(null);
|
||||
try {
|
||||
const token = localStorage.getItem("wauth_token");
|
||||
const res = await fetch("/api/users", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "X-Auth-User": token || "" },
|
||||
body: JSON.stringify({ username: newUsername, password: addPassword, name: newName, role: newRole }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error);
|
||||
setMessage({ type: "ok", text: "用户添加成功" });
|
||||
setShowAddForm(false);
|
||||
setNewUsername("");
|
||||
setAddPassword("");
|
||||
setNewName("");
|
||||
fetchUsers();
|
||||
} catch (e) {
|
||||
setMessage({ type: "err", text: e instanceof Error ? e.message : "添加失败" });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteUser(username: string) {
|
||||
if (!confirm(`确定删除用户 ${username}?`)) return;
|
||||
setMessage(null);
|
||||
try {
|
||||
const token = localStorage.getItem("wauth_token");
|
||||
const res = await fetch(`/api/users?username=${username}`, {
|
||||
method: "DELETE",
|
||||
headers: { "X-Auth-User": token || "" },
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error);
|
||||
setMessage({ type: "ok", text: "用户已删除" });
|
||||
fetchUsers();
|
||||
} catch (e) {
|
||||
setMessage({ type: "err", text: e instanceof Error ? e.message : "删除失败" });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col px-4 py-4 md:px-8 md:py-6">
|
||||
<div className="flex items-center gap-3 mb-6 shrink-0">
|
||||
<button type="button" onClick={() => router.back()} className="p-1 -ml-1 rounded hover:bg-surface text-muted hover:text-text transition-colors">
|
||||
<ArrowLeft size={16} />
|
||||
</button>
|
||||
<h2 className="text-lg font-bold text-text">用户管理</h2>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className={`mb-4 px-4 py-2.5 rounded-lg text-[13px] ${message.type === "ok" ? "bg-green-50 text-green-700" : "bg-red-50 text-red-600"}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 用户列表 */}
|
||||
<div className="rounded-lg border border-line overflow-hidden mb-6">
|
||||
<table className="w-full text-[13px]">
|
||||
<thead className="bg-surface">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-2.5 font-medium text-muted">用户名</th>
|
||||
<th className="text-left px-4 py-2.5 font-medium text-muted">昵称</th>
|
||||
<th className="text-left px-4 py-2.5 font-medium text-muted">角色</th>
|
||||
<th className="text-right px-4 py-2.5 font-medium text-muted">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map(u => (
|
||||
<tr key={u.username} className="border-t border-line">
|
||||
<td className="px-4 py-2.5">{u.username}</td>
|
||||
<td className="px-4 py-2.5">{u.name}</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<span className={`px-2 py-0.5 rounded text-[11px] ${u.role === "admin" ? "bg-accent/10 text-accent" : "bg-surface text-muted"}`}>
|
||||
{u.role === "admin" ? "管理员" : "用户"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right">
|
||||
<button
|
||||
onClick={() => { setPasswordTarget(u.username); setShowPasswordForm(true); setMessage(null); }}
|
||||
className="p-1.5 rounded hover:bg-surface text-muted hover:text-accent transition-colors"
|
||||
title="修改密码"
|
||||
>
|
||||
<Key size={14} />
|
||||
</button>
|
||||
{isAdmin && u.username !== currentUser?.username && (
|
||||
<button
|
||||
onClick={() => handleDeleteUser(u.username)}
|
||||
className="p-1.5 rounded hover:bg-danger/8 text-muted hover:text-danger transition-colors ml-1"
|
||||
title="删除用户"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 添加用户按钮(仅 admin) */}
|
||||
{isAdmin && (
|
||||
<button
|
||||
onClick={() => { setShowAddForm(true); setMessage(null); }}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg border border-line text-[13px] text-muted hover:text-accent hover:border-accent/30 transition-colors mb-6"
|
||||
>
|
||||
<Plus size={14} /> 添加用户
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 修改密码表单 */}
|
||||
{showPasswordForm && (
|
||||
<div className="rounded-lg border border-line p-5 mb-6">
|
||||
<h3 className="font-medium text-sm mb-4">修改密码 — {passwordTarget}</h3>
|
||||
<form onSubmit={handleChangePassword} className="space-y-4">
|
||||
{(!isAdmin || passwordTarget === currentUser?.username) && (
|
||||
<div>
|
||||
<label className="block text-[13px] text-muted mb-1">旧密码</label>
|
||||
<input type="password" value={oldPassword} onChange={e => setOldPassword(e.target.value)}
|
||||
className="w-full max-w-xs px-3 py-2 rounded-lg border border-line bg-bg text-sm outline-none focus:border-accent" required />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-[13px] text-muted mb-1">新密码</label>
|
||||
<input type="password" value={newPassword} onChange={e => setNewPassword(e.target.value)}
|
||||
className="w-full max-w-xs px-3 py-2 rounded-lg border border-line bg-bg text-sm outline-none focus:border-accent" required />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button type="submit" className="flex items-center gap-1.5 px-4 py-2 rounded-lg bg-accent text-white text-[13px] font-medium hover:opacity-90">
|
||||
<Save size={13} /> 保存
|
||||
</button>
|
||||
<button type="button" onClick={() => setShowPasswordForm(false)} className="px-4 py-2 rounded-lg border border-line text-[13px] text-muted hover:text-text transition-colors">
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 添加用户表单 */}
|
||||
{showAddForm && (
|
||||
<div className="rounded-lg border border-line p-5">
|
||||
<h3 className="font-medium text-sm mb-4">添加用户</h3>
|
||||
<form onSubmit={handleAddUser} className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-[13px] text-muted mb-1">用户名</label>
|
||||
<input type="text" value={newUsername} onChange={e => setNewUsername(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-lg border border-line bg-bg text-sm outline-none focus:border-accent" required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[13px] text-muted mb-1">昵称</label>
|
||||
<input type="text" value={newName} onChange={e => setNewName(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-lg border border-line bg-bg text-sm outline-none focus:border-accent" required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[13px] text-muted mb-1">密码</label>
|
||||
<input type="password" value={addPassword} onChange={e => setAddPassword(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-lg border border-line bg-bg text-sm outline-none focus:border-accent" required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[13px] text-muted mb-1">角色</label>
|
||||
<select value={newRole} onChange={e => setNewRole(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-lg border border-line bg-bg text-sm outline-none focus:border-accent">
|
||||
<option value="user">用户</option>
|
||||
<option value="admin">管理员</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button type="submit" className="flex items-center gap-1.5 px-4 py-2 rounded-lg bg-accent text-white text-[13px] font-medium hover:opacity-90">
|
||||
<User size={13} /> 添加
|
||||
</button>
|
||||
<button type="button" onClick={() => setShowAddForm(false)} className="px-4 py-2 rounded-lg border border-line text-[13px] text-muted hover:text-text transition-colors">
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
import Sidebar from "@/components/Sidebar";
|
||||
import AuthGuard from "@/components/AuthGuard";
|
||||
|
||||
export default function AppShell({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const isLoginPage = pathname === "/login";
|
||||
|
||||
if (isLoginPage) {
|
||||
return <AuthGuard>{children}</AuthGuard>;
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthGuard>
|
||||
<Sidebar />
|
||||
<main className="flex-1 overflow-auto pt-12 md:pt-0 h-full">
|
||||
{children}
|
||||
</main>
|
||||
</AuthGuard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
|
||||
export default function AuthGuard({ children }: { children: React.ReactNode }) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const [checked, setChecked] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// 登录页不需要检查
|
||||
if (pathname === "/login") {
|
||||
setChecked(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const token = localStorage.getItem("wauth_token");
|
||||
if (!token) {
|
||||
router.replace("/login");
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证 token 是否有效
|
||||
fetch("/api/auth/login", {
|
||||
headers: { "X-Auth-User": token },
|
||||
})
|
||||
.then(r => {
|
||||
if (!r.ok) {
|
||||
localStorage.removeItem("wauth_token");
|
||||
localStorage.removeItem("wauth_user");
|
||||
router.replace("/login");
|
||||
} else {
|
||||
setChecked(true);
|
||||
// 注册 Service Worker
|
||||
if ("serviceWorker" in navigator) {
|
||||
navigator.serviceWorker.register("/sw.js").then(reg => {
|
||||
const sw = reg.active || reg.waiting || reg.installing;
|
||||
if (sw) sw.postMessage({ type: "SET_TOKEN", token });
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setChecked(true); // 网络错误时也显示页面
|
||||
});
|
||||
}, [pathname, router]);
|
||||
|
||||
if (!checked) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="text-muted text-sm">加载中...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
export default function LayoutWrapper({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const isLoginPage = pathname === "/login";
|
||||
|
||||
if (isLoginPage) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// 动态导入 Sidebar 避免在登录页加载
|
||||
const Sidebar = require("@/components/Sidebar").default;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Sidebar />
|
||||
<main className="flex-1 overflow-auto pt-12 md:pt-0 h-full">
|
||||
{children}
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -3,26 +3,36 @@
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { FileText, PenSquare, MessageSquare, Trash2, Rss, Link2, MessageCircle, Image, Menu, X, Bot } from "lucide-react";
|
||||
import { FileText, PenSquare, MessageSquare, Trash2, Rss, Link2, MessageCircle, Image, Menu, X, Bot, LogOut, Users } from "lucide-react";
|
||||
import ThemeToggle from "./ThemeToggle";
|
||||
|
||||
const navItems = [
|
||||
{ href: "/", label: "文章", icon: FileText },
|
||||
{ href: "/edit", label: "撰写", icon: PenSquare },
|
||||
{ href: "/comments", label: "评论", icon: MessageSquare },
|
||||
{ href: "/feeds", label: "订阅源", icon: Rss },
|
||||
{ href: "/links", label: "友链", icon: Link2 },
|
||||
{ href: "/images", label: "图片", icon: Image },
|
||||
{ href: "/wechat", label: "公众号", icon: MessageCircle },
|
||||
{ href: "/recycle", label: "回收站", icon: Trash2 },
|
||||
{ href: "/bot", label: "Bot", icon: Bot },
|
||||
{ href: "/", label: "文章", icon: FileText, roles: ["admin", "user"] },
|
||||
{ href: "/edit", label: "撰写", icon: PenSquare, roles: ["admin", "user"] },
|
||||
{ href: "/comments", label: "评论", icon: MessageSquare, roles: ["admin", "user"] },
|
||||
{ href: "/feeds", label: "订阅源", icon: Rss, roles: ["admin"] },
|
||||
{ href: "/links", label: "友链", icon: Link2, roles: ["admin"] },
|
||||
{ href: "/images", label: "图片", icon: Image, roles: ["admin", "user"] },
|
||||
{ href: "/wechat", label: "公众号", icon: MessageCircle, roles: ["admin"] },
|
||||
{ href: "/recycle", label: "回收站", icon: Trash2, roles: ["admin", "user"] },
|
||||
{ href: "/bot", label: "Bot", icon: Bot, roles: ["admin"] },
|
||||
];
|
||||
|
||||
export default function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
|
||||
const [userRole, setUserRole] = useState("user");
|
||||
|
||||
useEffect(() => { setOpen(false); }, [pathname]);
|
||||
useEffect(() => {
|
||||
try {
|
||||
const u = JSON.parse(localStorage.getItem("wauth_user") || "{}");
|
||||
setIsAdmin(u.role === "admin");
|
||||
setUserRole(u.role || "user");
|
||||
} catch {}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -63,7 +73,7 @@ export default function Sidebar() {
|
||||
|
||||
{/* Nav */}
|
||||
<nav className="flex-1 px-2.5 py-3 space-y-0.5">
|
||||
{navItems.map((item) => {
|
||||
{navItems.filter(item => item.roles.includes(userRole)).map((item) => {
|
||||
const active = isActive(item.href);
|
||||
return (
|
||||
<Link
|
||||
@@ -80,12 +90,44 @@ export default function Sidebar() {
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
{isAdmin && (
|
||||
<Link
|
||||
href="/users"
|
||||
className={`flex items-center gap-2.5 px-3 py-2 rounded-md text-[13px] transition-colors ${
|
||||
isActive("/users")
|
||||
? "bg-accent text-white font-medium"
|
||||
: "text-text/80 hover:bg-bg hover:text-accent"
|
||||
}`}
|
||||
>
|
||||
<Users size={15} strokeWidth={isActive("/users") ? 2.5 : 1.8} />
|
||||
用户管理
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-4 py-3 border-t border-line">
|
||||
<ThemeToggle />
|
||||
<p className="text-[10px] text-muted/50">localhost:8016</p>
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<p className="text-[11px] text-muted/70">
|
||||
{(() => { try { return JSON.parse(localStorage.getItem("wauth_user") || "{}").name || "未登录"; } catch { return "未登录"; } })()}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
localStorage.removeItem("wauth_token");
|
||||
localStorage.removeItem("wauth_user");
|
||||
if (navigator.serviceWorker?.controller) {
|
||||
navigator.serviceWorker.controller.postMessage({ type: "CLEAR_TOKEN" });
|
||||
}
|
||||
location.href = "/login";
|
||||
}}
|
||||
className="p-1 rounded hover:bg-bg text-muted hover:text-text transition-colors"
|
||||
title="退出登录"
|
||||
>
|
||||
<LogOut size={13} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { createHash, randomBytes } from "crypto";
|
||||
|
||||
interface StoredUser {
|
||||
username: string;
|
||||
passwordHash: string;
|
||||
salt: string;
|
||||
name: string;
|
||||
role: "admin" | "user";
|
||||
}
|
||||
|
||||
interface User {
|
||||
username: string;
|
||||
name: string;
|
||||
role: "admin" | "user";
|
||||
}
|
||||
|
||||
// 密码哈希
|
||||
export function hashPassword(password: string, salt: string): string {
|
||||
return createHash("sha256").update(salt + password).digest("hex");
|
||||
}
|
||||
|
||||
// 生成密码哈希(用于创建用户)
|
||||
export function generatePasswordHash(password: string): { hash: string; salt: string } {
|
||||
const salt = randomBytes(16).toString("hex");
|
||||
const hash = hashPassword(password, salt);
|
||||
return { hash, salt };
|
||||
}
|
||||
|
||||
// 从请求头获取用户信息
|
||||
export function getAuthFromRequest(request: Request): User | null {
|
||||
const authHeader = request.headers.get("X-Auth-User");
|
||||
if (!authHeader) return null;
|
||||
const stored = findStoredUser(authHeader);
|
||||
if (!stored) return null;
|
||||
return { username: stored.username, name: stored.name, role: stored.role };
|
||||
}
|
||||
|
||||
// 查找用户
|
||||
function findStoredUser(username: string): StoredUser | null {
|
||||
const users = loadStoredUsers();
|
||||
return users.find(u => u.username === username) || null;
|
||||
}
|
||||
|
||||
// 认证用户
|
||||
export function authenticateUser(username: string, password: string): User | null {
|
||||
const stored = findStoredUser(username);
|
||||
if (!stored) return null;
|
||||
const hash = hashPassword(password, stored.salt);
|
||||
if (hash !== stored.passwordHash) return null;
|
||||
return { username: stored.username, name: stored.name, role: stored.role };
|
||||
}
|
||||
|
||||
// 加载用户列表
|
||||
function loadStoredUsers(): StoredUser[] {
|
||||
try {
|
||||
const usersPath = path.join(process.cwd(), "users.json");
|
||||
if (!fs.existsSync(usersPath)) return [];
|
||||
const data = JSON.parse(fs.readFileSync(usersPath, "utf-8"));
|
||||
return data.users || [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// 是否是管理员
|
||||
export function isAdmin(request: Request): boolean {
|
||||
const user = getAuthFromRequest(request);
|
||||
return user?.role === "admin";
|
||||
}
|
||||
@@ -76,8 +76,9 @@ export function listPosts(options?: {
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
includeDrafts?: boolean;
|
||||
author?: string;
|
||||
}): { posts: PostListItem[]; total: number; totalPages: number } {
|
||||
const { search = "", page = 1, perPage = ITEMS_PER_PAGE, includeDrafts = true } = options || {};
|
||||
const { search = "", page = 1, perPage = ITEMS_PER_PAGE, includeDrafts = true, author } = options || {};
|
||||
const dirs = findPostDirectories();
|
||||
|
||||
const allPosts: PostListItem[] = [];
|
||||
@@ -102,6 +103,9 @@ export function listPosts(options?: {
|
||||
|
||||
if (!includeDrafts && post.draft) continue;
|
||||
|
||||
// 按作者过滤
|
||||
if (author && post.author !== author) continue;
|
||||
|
||||
if (search) {
|
||||
const q = search.toLowerCase();
|
||||
const match =
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { DEEPSEEK_API_KEY, DEEPSEEK_BASE_URL } from "@/lib/config";
|
||||
|
||||
// 语音转文字:使用 OpenAI Whisper API 或其他兼容服务
|
||||
const WHISPER_API_KEY = process.env.WHISPER_API_KEY || DEEPSEEK_API_KEY || "";
|
||||
const WHISPER_API_BASE = process.env.WHISPER_API_BASE || (DEEPSEEK_API_KEY ? DEEPSEEK_BASE_URL : "https://api.openai.com/v1");
|
||||
const WHISPER_API_KEY = process.env.WHISPER_API_KEY || process.env.DEEPSEEK_API_KEY || "";
|
||||
const WHISPER_API_BASE = process.env.WHISPER_API_BASE || process.env.DEEPSEEK_BASE_URL || "https://api.openai.com/v1";
|
||||
const WHISPER_MODEL = process.env.WHISPER_MODEL || "whisper-1";
|
||||
|
||||
export async function transcribeAudio(audioBuffer: ArrayBuffer, filename: string): Promise<string> {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"users": [
|
||||
{
|
||||
"username": "admin",
|
||||
"password": "admin123",
|
||||
"name": "小赵",
|
||||
"role": "admin"
|
||||
},
|
||||
{
|
||||
"username": "girlfriend",
|
||||
"password": "girlfriend123",
|
||||
"name": "Girlfriend",
|
||||
"role": "user"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user