零 Node 部署、超级管理员,并完善本地构建发布文档。

- FastAPI 单进程托管 frontend/dist,systemd 替代 PM2

- 超级管理员 admin、注册开关与用户管理

- README/DEPLOY/USAGE 说明:改代码须本地构建 dist 后 push,服务器 update.sh

- 提交 frontend/dist 与 build-frontend 脚本
This commit is contained in:
dekun
2026-06-28 13:19:41 +08:00
parent a3d4875bde
commit f1ad4273f4
34 changed files with 1567 additions and 268 deletions
+4 -2
View File
@@ -2,8 +2,7 @@
# 部署目录默认:/opt/secondary-school-grade-archive # 部署目录默认:/opt/secondary-school-grade-archive
WEB_PORT=23566 WEB_PORT=23566
API_PORT=23568 FRONTEND_DIST=/opt/secondary-school-grade-archive/frontend/dist
API_TARGET=http://127.0.0.1:23568
SECRET_KEY=请替换为随机字符串 SECRET_KEY=请替换为随机字符串
POSTGRES_USER=gradeapp POSTGRES_USER=gradeapp
@@ -18,3 +17,6 @@ CORS_ORIGINS=http://127.0.0.1:23566,http://localhost:23566
OLLAMA_BASE_URL=http://127.0.0.1:11434 OLLAMA_BASE_URL=http://127.0.0.1:11434
OLLAMA_MODEL=qwen2.5:7b OLLAMA_MODEL=qwen2.5:7b
FLUCTUATION_THRESHOLD=0.08 FLUCTUATION_THRESHOLD=0.08
ADMIN_DEFAULT_USERNAME=admin
ADMIN_DEFAULT_PASSWORD=admin123
+2 -1
View File
@@ -5,4 +5,5 @@ __pycache__/
.env .env
!.env.example !.env.example
node_modules/ node_modules/
dist/ frontend/node_modules/
# frontend/dist 随仓库发布,服务器无需 npm 构建
+73 -14
View File
@@ -14,12 +14,65 @@ Secondary School Grade Archive — 多用户 Web 系统:成绩录入、占比
| 文档 | 说明 | | 文档 | 说明 |
|------|------| |------|------|
| [docs/DEPLOY.md](./docs/DEPLOY.md) | **Ubuntu PM2 一键部署** | | [docs/DEPLOY.md](./docs/DEPLOY.md) | **Ubuntu 零 Node 部署**systemd + FastAPI 单进程) |
| [docs/USAGE.md](./docs/USAGE.md) | 用户使用说明 | | [docs/USAGE.md](./docs/USAGE.md) | 用户使用说明 |
--- ---
## Ubuntu 一键部署(PM2 ## 修改代码后如何发布(必读
**生产服务器不安装 Node.js,也不在服务器上执行 `npm build`。**
凡涉及前端或全栈改动的发布,均按以下流程在**开发机**完成构建后再推送:
```
开发机改代码 → 本地构建 frontend/dist → git push 远端仓库 → 服务器 git pull + update.sh
```
### 仅改后端(`backend/`
```bash
git add backend/
git commit -m "你的说明"
git push
```
服务器:
```bash
bash /opt/secondary-school-grade-archive/deploy/update.sh
```
### 改前端或同时改前后端(`frontend/`
**必须先本地构建,再把 `frontend/dist` 一并提交推送:**
```powershell
# Windows
.\deploy\build-frontend.ps1
```
```bash
# Linux / macOS
bash deploy/build-frontend.sh
```
```bash
git add frontend/ frontend/dist
git commit -m "你的说明"
git push
```
服务器:
```bash
bash /opt/secondary-school-grade-archive/deploy/update.sh
```
> 若只推送源码而未推送 `frontend/dist`,服务器更新后页面不会变化。详见 [docs/DEPLOY.md §2](./docs/DEPLOY.md#2-代码修改与发布流程重要)。
---
## Ubuntu 一键部署(零 Node
```bash ```bash
git clone https://git.bz121.com/dekun/secondary-school-grade-archive.git /opt/secondary-school-grade-archive git clone https://git.bz121.com/dekun/secondary-school-grade-archive.git /opt/secondary-school-grade-archive
@@ -30,47 +83,53 @@ bash deploy/install.sh
- 安装目录:`/opt/secondary-school-grade-archive` - 安装目录:`/opt/secondary-school-grade-archive`
- 访问地址:`http://<服务器IP>:23566` - 访问地址:`http://<服务器IP>:23566`
- 进程管理:`pm2 status` / `pm2 logs` - 进程管理:`systemctl status grade-archive` / `journalctl -u grade-archive -f`
- 默认超级管理员:**admin / admin123**(登录后请在「系统设置」中修改)
详见 [docs/DEPLOY.md](./docs/DEPLOY.md)。**反向代理不包含在本项目中。** **前提:** 仓库中已包含 `frontend/dist/`(由开发机构建后推送)。详见 [docs/DEPLOY.md](./docs/DEPLOY.md)。**反向代理不包含在本项目中。**
--- ---
## 本地开发 ## 本地开发
### 方式一:前后端分离(推荐日常开发)
```bash ```bash
# PostgreSQL 本地安装后 # PostgreSQL 本地安装后
cp backend/.env.example backend/.env cp .env.example .env
cd backend cd backend
python3 -m venv venv python3 -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate source venv/bin/activate # Windows: venv\Scripts\activate
pip install -r requirements.txt pip install -r requirements.txt
uvicorn app.main:app --reload --port 23568 uvicorn app.main:app --reload --host 0.0.0.0 --port 23566
cd frontend cd frontend
npm install && npm run dev npm install && npm run dev
``` ```
开发前端http://localhost:5173(代理 `/api` 23568 - 前端开发:http://localhost:5173Vite 代理 `/api` 23566
- 后端 APIhttp://localhost:23566/api/health
生产网关本地模拟: ### 方式二:模拟生产(单进程 + 静态 dist)
```bash ```bash
cd deploy/pm2 && npm install cd frontend && npm run build
# 先 build 前端 cd ../backend
cd ../../frontend && npm run build source venv/bin/activate
WEB_PORT=23566 pm2 start ../deploy/pm2/ecosystem.config.cjs uvicorn app.main:app --reload --host 0.0.0.0 --port 23566
``` ```
访问 http://localhost:23566
--- ---
## 运维 ## 运维
```bash ```bash
bash deploy/update.sh # 更新 bash deploy/update.sh # 拉代码 + 更新依赖 + 重启服务
bash deploy/backup.sh # 备份 bash deploy/backup.sh # 备份
bash deploy/uninstall.sh # 停止 bash deploy/uninstall.sh # 停止并卸载
``` ```
--- ---
+5 -1
View File
@@ -12,7 +12,11 @@ class Settings(BaseSettings):
OLLAMA_BASE_URL: str = "http://127.0.0.1:11434" OLLAMA_BASE_URL: str = "http://127.0.0.1:11434"
OLLAMA_MODEL: str = "qwen2.5:7b" OLLAMA_MODEL: str = "qwen2.5:7b"
FLUCTUATION_THRESHOLD: float = 0.08 FLUCTUATION_THRESHOLD: float = 0.08
CORS_ORIGINS: str = "http://localhost:5173,http://localhost:3000,http://localhost" CORS_ORIGINS: str = "http://localhost:5173,http://localhost:23566,http://localhost"
WEB_PORT: int = 23566
FRONTEND_DIST: str = "../frontend/dist"
ADMIN_DEFAULT_USERNAME: str = "admin"
ADMIN_DEFAULT_PASSWORD: str = "admin123"
class Config: class Config:
env_file = ".env" env_file = ".env"
+6
View File
@@ -32,3 +32,9 @@ def get_current_user(
if user is None: if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户不存在") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户不存在")
return user return user
def get_superuser(current_user: User = Depends(get_current_user)) -> User:
if not current_user.is_superuser:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="需要超级管理员权限")
return current_user
+36 -3
View File
@@ -1,14 +1,26 @@
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
from fastapi import FastAPI from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from app.core.config import settings from app.core.config import settings
from app.core.database import Base, SessionLocal, engine from app.core.database import Base, SessionLocal, engine
from app.routers import auth, exams, export, students, subjects, wrong_questions from app.routers import admin, auth, exams, export, settings as settings_router, students, subjects, wrong_questions
from app.services.migrate import run_migrations from app.services.migrate import run_migrations
from app.services.seed import seed_subjects from app.services.seed import seed_admin_and_settings, seed_subjects
def resolve_frontend_dist() -> Path | None:
backend_dir = Path(__file__).resolve().parent.parent
dist = Path(settings.FRONTEND_DIST)
if not dist.is_absolute():
dist = (backend_dir / dist).resolve()
if dist.is_dir() and (dist / "index.html").is_file():
return dist
return None
@asynccontextmanager @asynccontextmanager
@@ -19,6 +31,7 @@ async def lifespan(app: FastAPI):
db = SessionLocal() db = SessionLocal()
try: try:
seed_subjects(db) seed_subjects(db)
seed_admin_and_settings(db)
finally: finally:
db.close() db.close()
yield yield
@@ -36,6 +49,8 @@ app.add_middleware(
) )
app.include_router(auth.router, prefix="/api") app.include_router(auth.router, prefix="/api")
app.include_router(settings_router.router, prefix="/api")
app.include_router(admin.router, prefix="/api")
app.include_router(students.router, prefix="/api") app.include_router(students.router, prefix="/api")
app.include_router(subjects.router, prefix="/api") app.include_router(subjects.router, prefix="/api")
app.include_router(exams.router, prefix="/api") app.include_router(exams.router, prefix="/api")
@@ -46,3 +61,21 @@ app.include_router(export.router, prefix="/api")
@app.get("/api/health") @app.get("/api/health")
def health(): def health():
return {"status": "ok"} return {"status": "ok"}
dist_dir = resolve_frontend_dist()
if dist_dir is not None:
assets_dir = dist_dir / "assets"
if assets_dir.is_dir():
app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")
@app.get("/{full_path:path}", include_in_schema=False)
async def serve_spa(full_path: str):
if full_path.startswith("api") or full_path.startswith("api/"):
raise HTTPException(status_code=404, detail="Not Found")
if full_path in ("", "index.html"):
return FileResponse(dist_dir / "index.html")
candidate = dist_dir / full_path
if candidate.is_file():
return FileResponse(candidate)
return FileResponse(dist_dir / "index.html")
+11
View File
@@ -33,6 +33,7 @@ class User(Base):
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
username: Mapped[str] = mapped_column(String(64), unique=True, index=True) username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
password_hash: Mapped[str] = mapped_column(String(255)) password_hash: Mapped[str] = mapped_column(String(255))
is_superuser: Mapped[bool] = mapped_column(default=False)
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
) )
@@ -128,3 +129,13 @@ class WrongQuestion(Base):
student: Mapped["Student"] = relationship(back_populates="wrong_questions") student: Mapped["Student"] = relationship(back_populates="wrong_questions")
subject: Mapped["Subject"] = relationship(back_populates="wrong_questions") subject: Mapped["Subject"] = relationship(back_populates="wrong_questions")
class SystemSettings(Base):
__tablename__ = "system_settings"
id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1)
registration_enabled: Mapped[bool] = mapped_column(default=True)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
+140
View File
@@ -0,0 +1,140 @@
import uuid
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.deps import get_superuser
from app.core.security import get_password_hash, verify_password
from app.models.user import SystemSettings, User
from app.schemas import (
AdminProfileUpdate,
AdminUserCreate,
AdminUserOut,
AdminUserPasswordUpdate,
PublicSettingsOut,
SystemSettingsOut,
SystemSettingsUpdate,
)
router = APIRouter(prefix="/admin", tags=["admin"])
def get_or_create_settings(db: Session) -> SystemSettings:
row = db.get(SystemSettings, 1)
if row is None:
row = SystemSettings(id=1, registration_enabled=True)
db.add(row)
db.commit()
db.refresh(row)
return row
@router.get("/settings", response_model=SystemSettingsOut)
def get_settings(
db: Session = Depends(get_db),
_: User = Depends(get_superuser),
):
return get_or_create_settings(db)
@router.patch("/settings", response_model=SystemSettingsOut)
def update_settings(
data: SystemSettingsUpdate,
db: Session = Depends(get_db),
_: User = Depends(get_superuser),
):
row = get_or_create_settings(db)
if data.registration_enabled is not None:
row.registration_enabled = data.registration_enabled
row.updated_at = datetime.now(timezone.utc)
db.commit()
db.refresh(row)
return row
@router.patch("/profile", response_model=AdminUserOut)
def update_profile(
data: AdminProfileUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_superuser),
):
if not data.username and not data.password:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请填写要修改的内容")
if data.password:
if not data.current_password:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="修改密码需提供当前密码")
if not verify_password(data.current_password, current_user.password_hash):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="当前密码错误")
if data.username and data.username != current_user.username:
if db.query(User).filter(User.username == data.username).first():
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="用户名已存在")
current_user.username = data.username
if data.password:
current_user.password_hash = get_password_hash(data.password)
db.commit()
db.refresh(current_user)
return current_user
@router.get("/users", response_model=list[AdminUserOut])
def list_users(
db: Session = Depends(get_db),
_: User = Depends(get_superuser),
):
return db.query(User).order_by(User.created_at).all()
@router.post("/users", response_model=AdminUserOut, status_code=status.HTTP_201_CREATED)
def create_user(
data: AdminUserCreate,
db: Session = Depends(get_db),
_: User = Depends(get_superuser),
):
if db.query(User).filter(User.username == data.username).first():
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="用户名已存在")
user = User(username=data.username, password_hash=get_password_hash(data.password))
db.add(user)
db.commit()
db.refresh(user)
return user
@router.patch("/users/{user_id}", response_model=AdminUserOut)
def reset_user_password(
user_id: uuid.UUID,
data: AdminUserPasswordUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_superuser),
):
user = db.get(User, user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
if user.is_superuser and user.id != current_user.id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不能修改其他超级管理员")
user.password_hash = get_password_hash(data.password)
db.commit()
db.refresh(user)
return user
@router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_user(
user_id: uuid.UUID,
db: Session = Depends(get_db),
current_user: User = Depends(get_superuser),
):
if user_id == current_user.id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不能删除当前登录账号")
user = db.get(User, user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
if user.is_superuser:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不能删除超级管理员")
db.delete(user)
db.commit()
+14 -1
View File
@@ -13,14 +13,27 @@ from app.core.security import (
get_password_hash, get_password_hash,
verify_password, verify_password,
) )
from app.models.user import User from app.models.user import SystemSettings, User
from app.schemas import RefreshRequest, TokenResponse, UserLogin, UserOut, UserRegister from app.schemas import RefreshRequest, TokenResponse, UserLogin, UserOut, UserRegister
router = APIRouter(prefix="/auth", tags=["auth"]) router = APIRouter(prefix="/auth", tags=["auth"])
def get_or_create_settings(db: Session) -> SystemSettings:
row = db.get(SystemSettings, 1)
if row is None:
row = SystemSettings(id=1, registration_enabled=True)
db.add(row)
db.commit()
db.refresh(row)
return row
@router.post("/register", response_model=UserOut, status_code=status.HTTP_201_CREATED) @router.post("/register", response_model=UserOut, status_code=status.HTTP_201_CREATED)
def register(data: UserRegister, db: Session = Depends(get_db)): def register(data: UserRegister, db: Session = Depends(get_db)):
settings_row = get_or_create_settings(db)
if not settings_row.registration_enabled:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="注册已关闭,请联系管理员")
if db.query(User).filter(User.username == data.username).first(): if db.query(User).filter(User.username == data.username).first():
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="用户名已存在") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="用户名已存在")
user = User(username=data.username, password_hash=get_password_hash(data.password)) user = User(username=data.username, password_hash=get_password_hash(data.password))
+24
View File
@@ -0,0 +1,24 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.models.user import SystemSettings
from app.schemas import PublicSettingsOut
router = APIRouter(prefix="/settings", tags=["settings"])
def get_or_create_settings(db: Session) -> SystemSettings:
row = db.get(SystemSettings, 1)
if row is None:
row = SystemSettings(id=1, registration_enabled=True)
db.add(row)
db.commit()
db.refresh(row)
return row
@router.get("/public", response_model=PublicSettingsOut)
def public_settings(db: Session = Depends(get_db)):
row = get_or_create_settings(db)
return PublicSettingsOut(registration_enabled=row.registration_enabled)
+40
View File
@@ -46,6 +46,46 @@ class RefreshRequest(BaseModel):
class UserOut(BaseModel): class UserOut(BaseModel):
id: UUID id: UUID
username: str username: str
is_superuser: bool = False
created_at: datetime
model_config = {"from_attributes": True}
class PublicSettingsOut(BaseModel):
registration_enabled: bool
class SystemSettingsOut(BaseModel):
registration_enabled: bool
updated_at: datetime
model_config = {"from_attributes": True}
class SystemSettingsUpdate(BaseModel):
registration_enabled: bool | None = None
class AdminProfileUpdate(BaseModel):
username: str | None = Field(default=None, min_length=3, max_length=64)
current_password: str | None = None
password: str | None = Field(default=None, min_length=6, max_length=128)
class AdminUserCreate(BaseModel):
username: str = Field(min_length=3, max_length=64)
password: str = Field(min_length=6, max_length=128)
class AdminUserPasswordUpdate(BaseModel):
password: str = Field(min_length=6, max_length=128)
class AdminUserOut(BaseModel):
id: UUID
username: str
is_superuser: bool
created_at: datetime created_at: datetime
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
+15
View File
@@ -1,5 +1,6 @@
from sqlalchemy import inspect, text from sqlalchemy import inspect, text
from app.core.config import settings
from app.core.database import engine from app.core.database import engine
@@ -18,3 +19,17 @@ def run_migrations() -> None:
"NOT NULL DEFAULT 'junior_high'" "NOT NULL DEFAULT 'junior_high'"
) )
) )
if "users" in inspector.get_table_names():
user_columns = {col["name"] for col in inspector.get_columns("users")}
if "is_superuser" not in user_columns:
with engine.begin() as conn:
conn.execute(
text("ALTER TABLE users ADD COLUMN is_superuser BOOLEAN NOT NULL DEFAULT FALSE")
)
conn.execute(
text(
f"UPDATE users SET is_superuser = TRUE "
f"WHERE username = '{settings.ADMIN_DEFAULT_USERNAME}'"
)
)
+22 -1
View File
@@ -1,6 +1,8 @@
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.models.user import Subject from app.core.config import settings
from app.core.security import get_password_hash
from app.models.user import Subject, SystemSettings, User
DEFAULT_SUBJECTS = [ DEFAULT_SUBJECTS = [
"语文", "语文",
@@ -21,3 +23,22 @@ def seed_subjects(db: Session) -> None:
if name not in existing: if name not in existing:
db.add(Subject(name=name)) db.add(Subject(name=name))
db.commit() db.commit()
def seed_admin_and_settings(db: Session) -> None:
if db.get(SystemSettings, 1) is None:
db.add(SystemSettings(id=1, registration_enabled=True))
admin = db.query(User).filter(User.username == settings.ADMIN_DEFAULT_USERNAME).first()
if admin is None:
db.add(
User(
username=settings.ADMIN_DEFAULT_USERNAME,
password_hash=get_password_hash(settings.ADMIN_DEFAULT_PASSWORD),
is_superuser=True,
)
)
elif not admin.is_superuser:
admin.is_superuser = True
db.commit()
+21
View File
@@ -0,0 +1,21 @@
# 本地构建前端并提示提交 dist(开发机执行,服务器不构建)
$ErrorActionPreference = "Stop"
$Root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
Set-Location (Join-Path $Root "frontend")
Write-Host "[INFO] 安装依赖并构建前端…"
npm ci
npm run build
if (-not (Test-Path "dist\index.html")) {
Write-Error "构建失败:未生成 dist\index.html"
}
Write-Host ""
Write-Host "构建完成。请提交并推送:"
Write-Host " git add frontend/dist"
Write-Host ' git commit -m "build: update frontend dist"'
Write-Host " git push"
Write-Host ""
Write-Host "服务器更新:"
Write-Host " bash /opt/secondary-school-grade-archive/deploy/update.sh"
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# 本地构建前端并提示提交 dist(开发机执行,服务器不构建)
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "${ROOT}/frontend"
echo "[INFO] 安装依赖并构建前端…"
npm ci
npm run build
if [[ ! -f dist/index.html ]]; then
echo "[ERROR] 构建失败:未生成 dist/index.html"
exit 1
fi
echo ""
echo "构建完成。请提交并推送:"
echo " git add frontend/dist"
echo " git commit -m \"build: update frontend dist\""
echo " git push"
echo ""
echo "服务器更新:"
echo " bash /opt/secondary-school-grade-archive/deploy/update.sh"
+15
View File
@@ -0,0 +1,15 @@
[Unit]
Description=Secondary School Grade Archive
After=network.target postgresql.service
Wants=postgresql.service
[Service]
Type=simple
WorkingDirectory=/opt/secondary-school-grade-archive
ExecStart=/opt/secondary-school-grade-archive/deploy/start.sh
Restart=always
RestartSec=5
EnvironmentFile=-/opt/secondary-school-grade-archive/.env
[Install]
WantedBy=multi-user.target
+42 -65
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# #
# 中学成绩档案系统 — Ubuntu PM2 一键部署 # 中学成绩档案系统 — Ubuntu 零 Node 一键部署(FastAPI 单进程 + systemd
# 版权所有 (c) 马建军 微信: dekun03 手机: 18364911125 # 版权所有 (c) 马建军 微信: dekun03 手机: 18364911125
# #
set -euo pipefail set -euo pipefail
@@ -8,11 +8,8 @@ set -euo pipefail
REPO_URL="${REPO_URL:-https://git.bz121.com/dekun/secondary-school-grade-archive.git}" REPO_URL="${REPO_URL:-https://git.bz121.com/dekun/secondary-school-grade-archive.git}"
INSTALL_DIR="${INSTALL_DIR:-/opt/secondary-school-grade-archive}" INSTALL_DIR="${INSTALL_DIR:-/opt/secondary-school-grade-archive}"
WEB_PORT="${WEB_PORT:-23566}" WEB_PORT="${WEB_PORT:-23566}"
API_PORT="${API_PORT:-23568}"
BRANCH="${BRANCH:-main}" BRANCH="${BRANCH:-main}"
NODE_MAJOR="${NODE_MAJOR:-20}"
PIP_MIRROR="${PIP_MIRROR:-https://pypi.tuna.tsinghua.edu.cn/simple}" PIP_MIRROR="${PIP_MIRROR:-https://pypi.tuna.tsinghua.edu.cn/simple}"
NPM_REGISTRY="${NPM_REGISTRY:-https://registry.npmmirror.com}"
RED='\033[0;31m' RED='\033[0;31m'
GREEN='\033[0;32m' GREEN='\033[0;32m'
@@ -59,21 +56,6 @@ install_base_packages() {
libgomp1 libglib2.0-0 libsm6 libxrender1 libxext6 libgomp1 libglib2.0-0 libsm6 libxrender1 libxext6
} }
install_node_pm2() {
if ! command -v node &>/dev/null || [[ "$(node -v | cut -d. -f1 | tr -d v)" -lt "${NODE_MAJOR}" ]]; then
log_info "安装 Node.js ${NODE_MAJOR}.x…"
curl -fsSL "https://deb.nodesource.com/setup_${NODE_MAJOR}.x" | bash -
apt-get install -y -qq nodejs
fi
log_info "Node: $(node -v) npm: $(npm -v)"
if ! command -v pm2 &>/dev/null; then
log_info "安装 PM2…"
npm install -g pm2
fi
log_info "PM2: $(pm2 -v)"
}
clone_or_update_repo() { clone_or_update_repo() {
if [[ -d "${INSTALL_DIR}/.git" ]]; then if [[ -d "${INSTALL_DIR}/.git" ]]; then
log_info "更新代码: ${INSTALL_DIR}" log_info "更新代码: ${INSTALL_DIR}"
@@ -86,6 +68,16 @@ clone_or_update_repo() {
git clone --branch "${BRANCH}" "${REPO_URL}" "${INSTALL_DIR}" || git clone "${REPO_URL}" "${INSTALL_DIR}" git clone --branch "${BRANCH}" "${REPO_URL}" "${INSTALL_DIR}" || git clone "${REPO_URL}" "${INSTALL_DIR}"
fi fi
find "${INSTALL_DIR}" -name "*.sh" -exec sed -i 's/\r$//' {} + find "${INSTALL_DIR}" -name "*.sh" -exec sed -i 's/\r$//' {} +
chmod +x "${INSTALL_DIR}/deploy/start.sh"
}
verify_frontend_dist() {
if [[ ! -f "${INSTALL_DIR}/frontend/dist/index.html" ]]; then
log_error "未找到 frontend/dist/index.html"
log_error "请先在开发机执行: cd frontend && npm run build,并将 dist 推送到仓库后再部署"
exit 1
fi
log_info "前端静态资源已就绪(无需在服务器构建)"
} }
generate_env() { generate_env() {
@@ -96,8 +88,6 @@ generate_env() {
if [[ -f "${env_file}" ]]; then if [[ -f "${env_file}" ]]; then
log_info "保留已有 .env" log_info "保留已有 .env"
# shellcheck disable=SC1090
set -a && source "${env_file}" && set +a
return return
fi fi
@@ -109,8 +99,7 @@ generate_env() {
cat > "${env_file}" <<EOF cat > "${env_file}" <<EOF
# generated by deploy/install.sh — $(date -Iseconds) # generated by deploy/install.sh — $(date -Iseconds)
WEB_PORT=${WEB_PORT} WEB_PORT=${WEB_PORT}
API_PORT=${API_PORT} FRONTEND_DIST=${INSTALL_DIR}/frontend/dist
API_TARGET=http://127.0.0.1:${API_PORT}
SECRET_KEY=${secret} SECRET_KEY=${secret}
POSTGRES_USER=${pg_user} POSTGRES_USER=${pg_user}
POSTGRES_PASSWORD=${pg_pass} POSTGRES_PASSWORD=${pg_pass}
@@ -121,9 +110,11 @@ CORS_ORIGINS=http://${server_ip}:${WEB_PORT},http://127.0.0.1:${WEB_PORT},http:/
OLLAMA_BASE_URL=http://127.0.0.1:11434 OLLAMA_BASE_URL=http://127.0.0.1:11434
OLLAMA_MODEL=qwen2.5:7b OLLAMA_MODEL=qwen2.5:7b
FLUCTUATION_THRESHOLD=0.08 FLUCTUATION_THRESHOLD=0.08
ADMIN_DEFAULT_USERNAME=admin
ADMIN_DEFAULT_PASSWORD=admin123
EOF EOF
chmod 600 "${env_file}" chmod 600 "${env_file}"
log_info ".env 已生成" log_info ".env 已生成(默认超级管理员 admin / admin123,请登录后修改)"
} }
setup_postgresql() { setup_postgresql() {
@@ -146,7 +137,7 @@ setup_postgresql() {
} }
setup_backend() { setup_backend() {
log_info "安装 Python 依赖(显示完整进度,Paddle 包较大,约 1030 分钟)…" log_info "安装 Python 依赖(Paddle 包较大,约 1030 分钟)…"
cd "${INSTALL_DIR}/backend" cd "${INSTALL_DIR}/backend"
python3 -m venv venv python3 -m venv venv
# shellcheck disable=SC1091 # shellcheck disable=SC1091
@@ -156,41 +147,27 @@ setup_backend() {
deactivate deactivate
} }
setup_frontend() { stop_legacy_pm2() {
log_info "构建前端…" if command -v pm2 &>/dev/null; then
cd "${INSTALL_DIR}/frontend" pm2 delete grade-api grade-web 2>/dev/null || true
npm config set registry "${NPM_REGISTRY}" pm2 save 2>/dev/null || true
npm ci log_info "已停止旧版 PM2 进程(grade-api / grade-web"
npm run build
}
setup_gateway() {
log_info "安装 Web 网关依赖…"
cd "${INSTALL_DIR}/deploy/pm2"
npm config set registry "${NPM_REGISTRY}"
npm ci
}
setup_pm2_startup() {
log_info "配置 PM2 开机自启…"
local startup_cmd
startup_cmd=$(pm2 startup systemd -u root --hp /root 2>&1 | grep -E '^sudo ' | tail -1 || true)
if [[ -n "${startup_cmd}" ]]; then
# shellcheck disable=SC2086
eval ${startup_cmd} || log_warn "PM2 开机自启配置失败,可稍后手动执行: pm2 startup"
else
log_warn "未获取 PM2 startup 命令,重启后需手动 pm2 resurrect"
fi fi
} }
start_pm2() { setup_systemd() {
log_info "启动 PM2 服务…" log_info "配置 systemd 服务…"
sed "s|/opt/secondary-school-grade-archive|${INSTALL_DIR}|g" \
"${INSTALL_DIR}/deploy/grade-archive.service" > /etc/systemd/system/grade-archive.service
systemctl daemon-reload
systemctl enable grade-archive
}
start_service() {
log_info "启动服务…"
cd "${INSTALL_DIR}" cd "${INSTALL_DIR}"
mkdir -p uploads backups mkdir -p uploads backups
pm2 delete grade-api grade-web 2>/dev/null || true systemctl restart grade-archive
pm2 start deploy/pm2/ecosystem.config.cjs
pm2 save
setup_pm2_startup
} }
wait_healthy() { wait_healthy() {
@@ -202,7 +179,7 @@ wait_healthy() {
fi fi
sleep 3 sleep 3
done done
log_warn "健康检查超时,请查看: pm2 logs" log_warn "健康检查超时,请查看: journalctl -u grade-archive -f"
} }
print_summary() { print_summary() {
@@ -211,36 +188,36 @@ print_summary() {
ip="${ip:-127.0.0.1}" ip="${ip:-127.0.0.1}"
echo "" echo ""
echo "==========================================" echo "=========================================="
echo " 中学成绩档案系统 PM2 部署完成" echo " 中学成绩档案系统部署完成(零 Node"
echo " 版权所有 (c) 马建军" echo " 版权所有 (c) 马建军"
echo "==========================================" echo "=========================================="
echo " 访问: http://${ip}:${WEB_PORT}" echo " 访问: http://${ip}:${WEB_PORT}"
echo " 目录: ${INSTALL_DIR}" echo " 目录: ${INSTALL_DIR}"
echo " 默认管理员: admin / admin123(请立即修改)"
echo "" echo ""
echo " pm2 status" echo " systemctl status grade-archive"
echo " pm2 logs" echo " journalctl -u grade-archive -f"
echo " bash ${INSTALL_DIR}/deploy/update.sh" echo " bash ${INSTALL_DIR}/deploy/update.sh"
echo " bash ${INSTALL_DIR}/deploy/backup.sh" echo " bash ${INSTALL_DIR}/deploy/backup.sh"
echo "" echo ""
echo " 反向代理请自行配置,本项目不包含"
echo " 微信 dekun03 手机 18364911125" echo " 微信 dekun03 手机 18364911125"
echo "==========================================" echo "=========================================="
} }
main() { main() {
log_info "PM2 一键部署开始" log_info "零 Node 一键部署开始"
require_root require_root
check_os check_os
check_port check_port
install_base_packages install_base_packages
install_node_pm2
clone_or_update_repo clone_or_update_repo
verify_frontend_dist
generate_env generate_env
setup_postgresql setup_postgresql
setup_backend setup_backend
setup_frontend stop_legacy_pm2
setup_gateway setup_systemd
start_pm2 start_service
wait_healthy wait_healthy
print_summary print_summary
} }
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "${ROOT}/backend"
# shellcheck disable=SC1091
source venv/bin/activate
if [[ -f "${ROOT}/.env" ]]; then
set -a
# shellcheck disable=SC1090
source "${ROOT}/.env"
set +a
fi
export FRONTEND_DIST="${FRONTEND_DIST:-${ROOT}/frontend/dist}"
export UPLOAD_DIR="${UPLOAD_DIR:-${ROOT}/uploads}"
exec python -m uvicorn app.main:app --host 0.0.0.0 --port "${WEB_PORT:-23566}"
+15 -15
View File
@@ -4,39 +4,39 @@ set -euo pipefail
INSTALL_DIR="${INSTALL_DIR:-/opt/secondary-school-grade-archive}" INSTALL_DIR="${INSTALL_DIR:-/opt/secondary-school-grade-archive}"
BRANCH="${BRANCH:-main}" BRANCH="${BRANCH:-main}"
PIP_MIRROR="${PIP_MIRROR:-https://pypi.tuna.tsinghua.edu.cn/simple}" PIP_MIRROR="${PIP_MIRROR:-https://pypi.tuna.tsinghua.edu.cn/simple}"
NPM_REGISTRY="${NPM_REGISTRY:-https://registry.npmmirror.com}"
GREEN='\033[0;32m' GREEN='\033[0;32m'
RED='\033[0;31m'
NC='\033[0m' NC='\033[0m'
log_info() { echo -e "${GREEN}[INFO]${NC} $*"; } log_info() { echo -e "${GREEN}[INFO]${NC} $*"; }
log_error() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
cd "${INSTALL_DIR}" || exit 1 cd "${INSTALL_DIR}" || exit 1
find "${INSTALL_DIR}" -name "*.sh" -exec sed -i 's/\r$//' {} + find "${INSTALL_DIR}" -name "*.sh" -exec sed -i 's/\r$//' {} +
chmod +x deploy/start.sh
log_info "拉取最新代码…" log_info "拉取最新代码…"
git fetch origin git fetch origin
git checkout "${BRANCH}" 2>/dev/null || true git checkout "${BRANCH}" 2>/dev/null || true
git pull origin "${BRANCH}" git pull origin "${BRANCH}"
if [[ ! -f "${INSTALL_DIR}/frontend/dist/index.html" ]]; then
log_error "未找到 frontend/dist/index.html"
log_error "请先在开发机构建前端并推送到仓库: cd frontend && npm run build && git add frontend/dist && git push"
exit 1
fi
log_info "更新后端依赖…" log_info "更新后端依赖…"
cd backend cd backend
source venv/bin/activate source venv/bin/activate
pip install -r requirements.txt --progress-bar on -i "${PIP_MIRROR}" pip install -r requirements.txt --progress-bar on -i "${PIP_MIRROR}"
deactivate deactivate
log_info "重建前端…" if command -v pm2 &>/dev/null; then
cd ../frontend pm2 delete grade-api grade-web 2>/dev/null || true
npm config set registry "${NPM_REGISTRY}" fi
npm ci
npm run build
log_info "更新网关…" log_info "重启 systemd 服务…"
cd ../deploy/pm2 systemctl restart grade-archive
npm ci
log_info "重启 PM2…" log_info "更新完成 — 访问端口见 .env 中 WEB_PORT(默认 23566"
cd "${INSTALL_DIR}"
pm2 reload deploy/pm2/ecosystem.config.cjs --update-env || pm2 start deploy/pm2/ecosystem.config.cjs
pm2 save
log_info "更新完成"
+122 -94
View File
@@ -1,4 +1,4 @@
# Ubuntu PM2 部署文档 # Ubuntu 零 Node 部署文档
> **中学成绩档案系统** · 版权所有 © 马建军 · 微信 **dekun03** · 手机 **18364911125** > **中学成绩档案系统** · 版权所有 © 马建军 · 微信 **dekun03** · 手机 **18364911125**
> 仓库:[https://git.bz121.com/dekun/secondary-school-grade-archive.git](https://git.bz121.com/dekun/secondary-school-grade-archive.git) > 仓库:[https://git.bz121.com/dekun/secondary-school-grade-archive.git](https://git.bz121.com/dekun/secondary-school-grade-archive.git)
@@ -9,41 +9,101 @@
| 项目 | 说明 | | 项目 | 说明 |
|------|------| |------|------|
| 方式 | **PM2**(非 Docker | | 方式 | **systemd + FastAPI 单进程**(服务器**无需** Node.js / npm |
| 系统 | Ubuntu 20.04 / 22.04 / 24.04 | | 系统 | Ubuntu 20.04 / 22.04 / 24.04 |
| 用户 | **root** | | 用户 | **root** |
| 目录 | `/opt/secondary-school-grade-archive` | | 目录 | `/opt/secondary-school-grade-archive` |
| 端口 | **23566** 对外 Web**23568** 内部 API(仅本机 | | 端口 | **23566**API + 前端静态资源同一端口 |
| 反向代理 | **不包含**,请自行配置 |
### 架构 ### 架构
``` ```
浏览器 → :23566 (PM2: grade-web, Express 静态 + /api 反代) 浏览器 → :23566 (systemd: grade-archive, Uvicorn)
──→ 127.0.0.1:23568 (PM2: grade-api, Uvicorn) ── /api/* → FastAPI 接口
└── /* → frontend/dist 静态文件
└──→ PostgreSQL (本机) └──→ PostgreSQL (本机)
└──→ uploads/ └──→ uploads/
└──→ Ollama (本机可选, :11434) └──→ Ollama (本机可选, :11434)
``` ```
PM2 进程: ### 为何不在服务器构建前端?
| 名称 | 说明 | 前端 `npm ci && npm run build` 会占用大量磁盘与内存,且需在服务器安装 Node.js。
|------|------| 因此采用 **开发机构建 → 推送 `frontend/dist` → 服务器只拉取** 的方式,服务器仅需 Python + PostgreSQL。
| `grade-api` | FastAPI / Uvicorn |
| `grade-web` | 前端静态资源 + `/api` 反向代理 |
--- ---
## 2. 环境要求 ## 2. 代码修改与发布流程(重要)
- CPU 2 核+,内存 4 GB+OCR 建议 8 GB 每次修改代码后,按改动范围在**开发机**操作,再推送到远端仓库,最后在服务器执行 `update.sh`
- 磁盘 15 GB+
- 可访问 Git 仓库与 npm / PyPI ### 流程总览
```
┌─────────────┐ ┌──────────────────┐ ┌─────────────┐ ┌─────────────────┐
│ 开发机改代码 │ → │ 本地构建(如需) │ → │ git push 远端 │ → │ 服务器 update.sh │
└─────────────┘ └──────────────────┘ └─────────────┘ └─────────────────┘
```
### 2.1 仅修改后端(`backend/`
不涉及前端页面时,**无需** `npm run build`
```bash
git add backend/
git commit -m "fix: 说明本次改动"
git push
```
服务器:
```bash
bash /opt/secondary-school-grade-archive/deploy/update.sh
```
`update.sh` 会:`git pull``pip install``systemctl restart grade-archive`
### 2.2 修改前端(`frontend/src` 等)或同时改前后端
**必须先在开发机构建**,并将构建产物 `frontend/dist/` 提交到仓库:
```powershell
# Windows
.\deploy\build-frontend.ps1
```
```bash
# Linux / macOS
bash deploy/build-frontend.sh
```
上述脚本等价于 `cd frontend && npm ci && npm run build`,并检查 `dist/index.html` 是否生成。
然后提交并推送(**务必包含 `frontend/dist`**):
```bash
git add frontend/ frontend/dist
git commit -m "feat: 说明本次改动"
git push
```
服务器:
```bash
bash /opt/secondary-school-grade-archive/deploy/update.sh
```
### 2.3 常见错误
| 现象 | 原因 | 处理 |
|------|------|------|
| 服务器页面没变化 | 只 push 了源码,未 push `frontend/dist` | 本地 `npm run build` 后重新提交 dist |
| `install.sh` 报错找不到 dist | 仓库里没有预构建的 dist | 开发机构建并 push 后再部署 |
| API 正常但页面 404 | `FRONTEND_DIST` 路径不对 | 检查 `.env``FRONTEND_DIST` |
--- ---
## 3. 一键部署 ## 3. 一键部署(新服务器)
```bash ```bash
git clone https://git.bz121.com/dekun/secondary-school-grade-archive.git /opt/secondary-school-grade-archive git clone https://git.bz121.com/dekun/secondary-school-grade-archive.git /opt/secondary-school-grade-archive
@@ -54,120 +114,88 @@ bash deploy/install.sh
脚本自动完成: 脚本自动完成:
1. 检测 root、Ubuntu、端口 23566 1. 安装 PostgreSQL、Python 依赖
2. 安装 PostgreSQL、Python3、Node.js 20、PM2 2. 检查 `frontend/dist/index.html` 是否存在
3. 克隆/更新代码 3. 生成 `.env`、创建数据库
4. 生成 `.env`(随机密钥、数据库密码) 4. 注册并启动 systemd 服务 `grade-archive`
5. 创建 PostgreSQL 用户与数据库
6. Python 虚拟环境 + `pip install` **前提:** 仓库中已包含 `frontend/dist/`(开发机构建后推送)。
7. 前端 `npm ci && npm run build`
8. `pm2 start` 并设置开机自启
部署成功后访问:**`http://<服务器IP>:23566`** 部署成功后访问:**`http://<服务器IP>:23566`**
默认超级管理员:**admin / admin123**(登录后请在「系统设置」中修改)
--- ---
## 4. 环境变量(`.env` ## 4. 环境变量(`.env`
| 变量 | 默认 | 说明 | | 变量 | 默认 | 说明 |
|------|------|------| |------|------|------|
| `WEB_PORT` | 23566 | 对外 Web 端口 | | `WEB_PORT` | 23566 | 对外端口 |
| `API_PORT` | 23568 | 内部 API 端口(仅本机,勿与 8000 等常用端口冲突 | | `FRONTEND_DIST` | `.../frontend/dist` | 前端静态目录(绝对路径 |
| `API_TARGET` | `http://127.0.0.1:23568` | Web 网关转发目标 |
| `DATABASE_URL` | 自动生成 | PostgreSQL 连接 | | `DATABASE_URL` | 自动生成 | PostgreSQL 连接 |
| `SECRET_KEY` | 自动生成 | JWT 密钥 | | `SECRET_KEY` | 自动生成 | JWT 密钥 |
| `UPLOAD_DIR` | `.../uploads` | 错题图片目录 | | `UPLOAD_DIR` | `.../uploads` | 错题图片目录 |
| `OLLAMA_BASE_URL` | `http://127.0.0.1:11434` | 本地 Ollama | | `ADMIN_DEFAULT_USERNAME` | admin | 首次安装默认管理员用户名 |
| `ADMIN_DEFAULT_PASSWORD` | admin123 | 首次安装默认管理员密码 |
修改后重启: 示例见仓库根目录 [.env.example](../.env.example)。
```bash
cd /opt/secondary-school-grade-archive
pm2 reload deploy/pm2/ecosystem.config.cjs --update-env
```
--- ---
## 5. 常用命令 ## 5. 常用命令
```bash ```bash
cd /opt/secondary-school-grade-archive # 服务状态
systemctl status grade-archive
pm2 status # 进程状态 # 实时日志
pm2 logs # 全部日志 journalctl -u grade-archive -f
pm2 logs grade-api # 后端日志
pm2 logs grade-web # 网关日志
bash deploy/update.sh # 拉代码 + 重建 + 重启 # 拉代码并重启(日常更新)
bash deploy/backup.sh # 备份数据库与 uploads bash /opt/secondary-school-grade-archive/deploy/update.sh
bash deploy/uninstall.sh # 停止 PM2 服务
# 备份数据库与 uploads
bash /opt/secondary-school-grade-archive/deploy/backup.sh
``` ```
--- ---
## 6. Ollama(可选) ## 6. 从旧版 PM2 迁移
```bash 若之前使用 `grade-api` + `grade-web`PM2 + Express),执行 `deploy/update.sh` 会:
curl -fsSL https://ollama.com/install.sh | sh
ollama pull qwen2.5:7b - 停止并删除 PM2 进程 `grade-api``grade-web`
``` - 重启 systemd 服务 `grade-archive`
`.env` 调整建议:
- 保留 `WEB_PORT=23566`
- 添加 `FRONTEND_DIST=/opt/secondary-school-grade-archive/frontend/dist`
- 可删除 `API_PORT``API_TARGET`(已不再使用)
--- ---
## 7. 反向代理(自行配置) ## 7. 超级管理员
将域名/HTTPS 流量转发到 **`http://127.0.0.1:23566`** 即可。 | 功能 | 说明 |
使用 HTTPS 后请更新 `.env``CORS_ORIGINS`
---
## 8. 防火墙
```bash
ufw allow 22/tcp
ufw allow 23566/tcp
ufw enable
```
---
## 9. 故障排查
| 现象 | 处理 |
|------|------| |------|------|
| 无法访问 | `pm2 status` · `ss -tlnp \| grep 23566` | | 默认账号 | **admin / admin123**(首次安装后请立即修改) |
| 502 / API 错误 | `pm2 logs grade-api` | | 系统设置 | 超级管理员可修改自己的用户名、密码 |
| 数据库连接失败 | `systemctl status postgresql` · 检查 `.env``DATABASE_URL` | | 注册开关 | 可开启/关闭登录页公开注册 |
| 前端空白 | 确认 `frontend/dist` 存在 · `pm2 logs grade-web` | | 用户管理 | 注册关闭时,由管理员添加用户并重置密码 |
| 普通用户 | **不能**自行修改用户名和密码 |
使用说明见 [USAGE.md](./USAGE.md)。
--- ---
## 10. 自定义参数 ## 8. 反向代理(用户自行配置)
```bash 本项目**不包含** Nginx / Caddy 等反向代理配置。若需 HTTPS 或域名访问,请在服务器上自行配置,将流量转发到 `127.0.0.1:23566`
WEB_PORT=23566 INSTALL_DIR=/opt/secondary-school-grade-archive bash deploy/install.sh
# 使用官方 PyPI(海外服务器可去掉国内镜像)
PIP_MIRROR=https://pypi.org/simple bash deploy/install.sh
```
### 网络代理(可选,脚本内不内置)
一键部署**不会**自动配置代理。无代理环境可直接运行,不会因代理报错。
若本机网络需走代理,请在**执行安装前**手动 export(仅当前终端生效):
```bash
export HTTP_PROXY=http://你的代理地址:端口
export HTTPS_PROXY=http://你的代理地址:端口
bash deploy/install.sh
```
不设置则不走代理。`pip install` 会**显示完整下载/安装进度**,不再静默。
--- ---
## 11. 版权 ## 9. 技术支持
见 [LICENSE](../LICENSE) · [COPYRIGHT.md](../COPYRIGHT.md) 微信 **dekun03** · 手机 **18364911125**
技术支持:微信 **dekun03** · 手机 **18364911125**
+61 -11
View File
@@ -16,7 +16,7 @@
- 错题库:拍照上传 → OCR 识别 → AI 生成解法(可编辑) - 错题库:拍照上传 → OCR 识别 → AI 生成解法(可编辑)
- 成绩 CSV 导出 - 成绩 CSV 导出
部署方式见 [DEPLOY.md](./DEPLOY.md)PM2,端口 23566)。 部署与代码发布见 [DEPLOY.md](./DEPLOY.md)systemd 零 Node,端口 **23566**)。
--- ---
@@ -25,12 +25,23 @@
### 2.1 登录与注册 ### 2.1 登录与注册
1. 浏览器打开 `http://<服务器IP>:23566` 1. 浏览器打开 `http://<服务器IP>:23566`
2. 首次使用点击 **注册**,设置用户名(≥3 字符)和密码(≥6 字符 2. **首次部署**默认超级管理员:**admin / admin123**(请登录后立即修改
3. 注册成功后自动登录 3. 若管理员已**开放注册**,可在登录页 **注册** 新账号(用户名 ≥3 字符,密码 ≥6 字符)
4. 若管理员已**关闭注册**,登录页不显示注册入口,需联系管理员在「系统设置 → 用户管理」中添加账号
> 系统无默认管理员账号,首个注册用户即为普通用户数据仅本人可见。 > 普通用户数据仅本人可见;普通用户**不能**自行修改用户名或密码
### 2.2 添加学生 ### 2.2 系统设置(超级管理员)
首页右上角 **系统设置**(仅超级管理员可见):
| 功能 | 说明 |
|------|------|
| 注册开关 | 开启后用户可自行注册;关闭后仅管理员添加用户 |
| 管理员账号 | 修改超级管理员的用户名、密码(修改密码需输入当前密码) |
| 用户管理 | 添加用户、重置密码、删除普通用户 |
### 2.3 添加学生
1. 首页点击 **添加学生** 1. 首页点击 **添加学生**
2. 填写: 2. 填写:
@@ -40,7 +51,7 @@
- **班级**:如「3班」(可选) - **班级**:如「3班」(可选)
3. 保存后在卡片上可看到学段标签 3. 保存后在卡片上可看到学段标签
### 2.3 录入成绩 ### 2.4 录入成绩
进入学生详情 → **成绩录入** 标签: 进入学生详情 → **成绩录入** 标签:
@@ -130,13 +141,49 @@
--- ---
## 7. 常见问题 ## 7. 维护人员:修改代码后如何更新线上
**服务器不在本地构建前端。** 维护或二次开发时,请严格按以下流程发布:
### 只改后端
```bash
git add backend/
git commit -m "说明"
git push
# 服务器
bash /opt/secondary-school-grade-archive/deploy/update.sh
```
### 改前端(或前后端都改)
```powershell
# Windows:先构建
.\deploy\build-frontend.ps1
```
```bash
# Linux / macOS:先构建
bash deploy/build-frontend.sh
git add frontend/ frontend/dist
git commit -m "说明"
git push
# 服务器
bash /opt/secondary-school-grade-archive/deploy/update.sh
```
详细说明见 [DEPLOY.md §2](./DEPLOY.md#2-代码修改与发布流程重要) 与 [README](../README.md#修改代码后如何发布必读)。
---
## 8. 常见问题
**Q:忘记密码怎么办?** **Q:忘记密码怎么办?**
A当前版本无找回密码功能,需管理员在数据库中重置或重新注册(生产环境建议后续增加找回流程) A普通用户请联系超级管理员,在「系统设置 → 用户管理」中重置密码。超级管理员忘记密码需通过数据库或 `.env` 中的 `ADMIN_DEFAULT_*` 配合运维处理
**Q:多人能否共用一台服务器?** **Q:多人能否共用一台服务器?**
A:可以。每人注册独立账号,数据互不可见。 A:可以。每人独立账号,数据互不可见。
**Q:能否同时管理初中和高中孩子?** **Q:能否同时管理初中和高中孩子?**
A:可以。添加学生时分别选择学段即可。 A:可以。添加学生时分别选择学段即可。
@@ -145,11 +192,14 @@ A:可以。添加学生时分别选择学段即可。
A:可以。使用同一服务器地址与账号登录即可。 A:可以。使用同一服务器地址与账号登录即可。
**QHTTPS 和域名怎么配置?** **QHTTPS 和域名怎么配置?**
A:本项目不包含反向代理配置,请参考 [DEPLOY.md 第 7 节](./DEPLOY.md#7-反向代理用户自行配置) 自行设置。 A:本项目不包含反向代理配置,请参考 [DEPLOY.md §8](./DEPLOY.md#8-反向代理用户自行配置) 自行设置。
**Q:改了代码但服务器页面没变化?**
A:很可能未在开发机执行 `npm run build` 或未将 `frontend/dist` 推送到仓库。见上文第 7 节。
--- ---
## 8. 技术支持与版权 ## 9. 技术支持与版权
| 项目 | 内容 | | 项目 | 内容 |
|------|------| |------|------|
+1 -1
View File
@@ -8,7 +8,7 @@ pnpm-debug.log*
lerna-debug.log* lerna-debug.log*
node_modules node_modules
dist # dist 由开发机构建后提交,供服务器零 Node 部署使用
dist-ssr dist-ssr
*.local *.local
+1
View File
@@ -0,0 +1 @@
*{box-sizing:border-box}body{-webkit-font-smoothing:antialiased;background:#f5f5f5;margin:0;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif}#root{min-height:100vh}a{color:inherit}@media (width<=576px){.ant-table{font-size:12px}}
File diff suppressed because one or more lines are too long
+1
View File
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="author" content="马建军" />
<meta name="copyright" content="Copyright (c) 马建军. All rights reserved." />
<title>中学成绩档案</title>
<script type="module" crossorigin src="/assets/index-Ds_4iUm1.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DHafbxey.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
+9
View File
@@ -1,6 +1,7 @@
import { Navigate, Route, Routes } from 'react-router-dom' import { Navigate, Route, Routes } from 'react-router-dom'
import { useAuth } from './context/AuthContext' import { useAuth } from './context/AuthContext'
import LoginPage from './pages/LoginPage' import LoginPage from './pages/LoginPage'
import SettingsPage from './pages/SettingsPage'
import StudentDetailPage from './pages/StudentDetailPage' import StudentDetailPage from './pages/StudentDetailPage'
import StudentsPage from './pages/StudentsPage' import StudentsPage from './pages/StudentsPage'
@@ -31,6 +32,14 @@ export default function App() {
</PrivateRoute> </PrivateRoute>
} }
/> />
<Route
path="/settings"
element={
<PrivateRoute>
<SettingsPage />
</PrivateRoute>
}
/>
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
) )
+24
View File
@@ -1,10 +1,13 @@
import axios from 'axios' import axios from 'axios'
import type { import type {
AdminUser,
Exam, Exam,
PublicSettings,
ScoreInput, ScoreInput,
SchoolLevel, SchoolLevel,
Student, Student,
Subject, Subject,
SystemSettings,
TokenResponse, TokenResponse,
TrendResponse, TrendResponse,
User, User,
@@ -59,6 +62,27 @@ export const authApi = {
me: () => api.get<User>('/auth/me'), me: () => api.get<User>('/auth/me'),
} }
export const settingsApi = {
public: () => api.get<PublicSettings>('/settings/public'),
}
export const adminApi = {
getSettings: () => api.get<SystemSettings>('/admin/settings'),
updateSettings: (data: { registration_enabled?: boolean }) =>
api.patch<SystemSettings>('/admin/settings', data),
updateProfile: (data: {
username?: string
current_password?: string
password?: string
}) => api.patch<AdminUser>('/admin/profile', data),
listUsers: () => api.get<AdminUser[]>('/admin/users'),
createUser: (data: { username: string; password: string }) =>
api.post<AdminUser>('/admin/users', data),
resetUserPassword: (id: string, password: string) =>
api.patch<AdminUser>(`/admin/users/${id}`, { password }),
deleteUser: (id: string) => api.delete(`/admin/users/${id}`),
}
export const studentApi = { export const studentApi = {
list: () => api.get<Student[]>('/students'), list: () => api.get<Student[]>('/students'),
create: (data: { create: (data: {
+45 -26
View File
@@ -1,7 +1,9 @@
import { LockOutlined, UserOutlined } from '@ant-design/icons' import { LockOutlined, UserOutlined } from '@ant-design/icons'
import { Button, Card, Form, Input, Tabs, Typography, message } from 'antd' import { Button, Card, Form, Input, Spin, Tabs, Typography, message } from 'antd'
import axios from 'axios' import axios from 'axios'
import { useEffect, useState } from 'react'
import { Navigate, useNavigate } from 'react-router-dom' import { Navigate, useNavigate } from 'react-router-dom'
import { settingsApi } from '../api/client'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
function apiErrorMessage(error: unknown, fallback: string) { function apiErrorMessage(error: unknown, fallback: string) {
@@ -18,6 +20,16 @@ export default function LoginPage() {
const navigate = useNavigate() const navigate = useNavigate()
const [loginForm] = Form.useForm() const [loginForm] = Form.useForm()
const [registerForm] = Form.useForm() const [registerForm] = Form.useForm()
const [registrationEnabled, setRegistrationEnabled] = useState(true)
const [settingsLoading, setSettingsLoading] = useState(true)
useEffect(() => {
settingsApi
.public()
.then((res) => setRegistrationEnabled(res.data.registration_enabled))
.catch(() => setRegistrationEnabled(true))
.finally(() => setSettingsLoading(false))
}, [])
if (!loading && user) return <Navigate to="/" replace /> if (!loading && user) return <Navigate to="/" replace />
@@ -45,23 +57,7 @@ export default function LoginPage() {
} }
} }
return ( const tabItems = [
<div
style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
padding: 16,
}}
>
<Card style={{ width: '100%', maxWidth: 420 }} title="中学生成绩档案">
<Typography.Paragraph type="secondary" style={{ textAlign: 'center' }}>
· ·
</Typography.Paragraph>
<Tabs
items={[
{ {
key: 'login', key: 'login',
label: '登录', label: '登录',
@@ -79,7 +75,10 @@ export default function LoginPage() {
</Form> </Form>
), ),
}, },
{ ]
if (registrationEnabled) {
tabItems.push({
key: 'register', key: 'register',
label: '注册', label: '注册',
children: ( children: (
@@ -102,10 +101,7 @@ export default function LoginPage() {
> >
<Input.Password prefix={<LockOutlined />} placeholder="密码" /> <Input.Password prefix={<LockOutlined />} placeholder="密码" />
</Form.Item> </Form.Item>
<Form.Item <Form.Item name="confirm" rules={[{ required: true, message: '请确认密码' }]}>
name="confirm"
rules={[{ required: true, message: '请确认密码' }]}
>
<Input.Password prefix={<LockOutlined />} placeholder="确认密码" /> <Input.Password prefix={<LockOutlined />} placeholder="确认密码" />
</Form.Item> </Form.Item>
<Button type="primary" htmlType="submit" block> <Button type="primary" htmlType="submit" block>
@@ -113,9 +109,32 @@ export default function LoginPage() {
</Button> </Button>
</Form> </Form>
), ),
}, })
]} }
/>
return (
<div
style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
padding: 16,
}}
>
<Card style={{ width: '100%', maxWidth: 420 }} title="中学生成绩档案">
<Typography.Paragraph type="secondary" style={{ textAlign: 'center' }}>
· ·
</Typography.Paragraph>
<Spin spinning={settingsLoading}>
{!settingsLoading && !registrationEnabled && (
<Typography.Paragraph type="secondary" style={{ textAlign: 'center', marginBottom: 16 }}>
</Typography.Paragraph>
)}
<Tabs items={tabItems} />
</Spin>
<Typography.Paragraph <Typography.Paragraph
type="secondary" type="secondary"
style={{ textAlign: 'center', fontSize: 12, marginTop: 16, marginBottom: 0 }} style={{ textAlign: 'center', fontSize: 12, marginTop: 16, marginBottom: 0 }}
+247
View File
@@ -0,0 +1,247 @@
import { LockOutlined, SettingOutlined, UserOutlined } from '@ant-design/icons'
import {
Button,
Card,
Form,
Input,
Modal,
Popconfirm,
Space,
Switch,
Table,
Tabs,
Typography,
message,
} from 'antd'
import { useEffect, useState } from 'react'
import { Link, Navigate } from 'react-router-dom'
import { adminApi } from '../api/client'
import { useAuth } from '../context/AuthContext'
import type { AdminUser, SystemSettings } from '../types'
export default function SettingsPage() {
const { user } = useAuth()
const [settings, setSettings] = useState<SystemSettings | null>(null)
const [users, setUsers] = useState<AdminUser[]>([])
const [loading, setLoading] = useState(true)
const [profileForm] = Form.useForm()
const [createForm] = Form.useForm()
const [createOpen, setCreateOpen] = useState(false)
const [resetUser, setResetUser] = useState<AdminUser | null>(null)
const [resetForm] = Form.useForm()
if (!user?.is_superuser) return <Navigate to="/" replace />
const load = async () => {
setLoading(true)
try {
const [settingsRes, usersRes] = await Promise.all([
adminApi.getSettings(),
adminApi.listUsers(),
])
setSettings(settingsRes.data)
setUsers(usersRes.data)
profileForm.setFieldsValue({ username: user.username })
} finally {
setLoading(false)
}
}
useEffect(() => {
load()
}, [])
const toggleRegistration = async (checked: boolean) => {
const { data } = await adminApi.updateSettings({ registration_enabled: checked })
setSettings(data)
message.success(checked ? '已开放注册' : '已关闭注册')
}
const saveProfile = async (values: {
username: string
current_password?: string
password?: string
confirm?: string
}) => {
if (values.password && values.password !== values.confirm) {
message.error('两次密码不一致')
return
}
await adminApi.updateProfile({
username: values.username !== user?.username ? values.username : undefined,
current_password: values.password ? values.current_password : undefined,
password: values.password || undefined,
})
message.success('账号信息已更新,若修改了用户名或密码请重新登录')
}
const createUser = async (values: { username: string; password: string }) => {
await adminApi.createUser(values)
message.success('用户已创建')
setCreateOpen(false)
createForm.resetFields()
load()
}
const resetPassword = async (values: { password: string }) => {
if (!resetUser) return
await adminApi.resetUserPassword(resetUser.id, values.password)
message.success('密码已重置')
setResetUser(null)
resetForm.resetFields()
}
const removeUser = async (id: string) => {
await adminApi.deleteUser(id)
message.success('用户已删除')
load()
}
return (
<div style={{ maxWidth: 960, margin: '0 auto', padding: '16px 16px 32px' }}>
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<div>
<Typography.Title level={3} style={{ margin: 0 }}>
</Typography.Title>
<Typography.Text type="secondary">
· <Link to="/"></Link>
</Typography.Text>
</div>
<Tabs
items={[
{
key: 'general',
label: '基本设置',
children: (
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<Card title="注册开关" loading={loading}>
<Space>
<Switch
checked={settings?.registration_enabled ?? true}
onChange={toggleRegistration}
/>
<Typography.Text>
{settings?.registration_enabled
? '开放注册:用户可在登录页自行注册'
: '关闭注册:仅超级管理员可添加用户'}
</Typography.Text>
</Space>
</Card>
<Card title="管理员账号">
<Form form={profileForm} layout="vertical" onFinish={saveProfile}>
<Form.Item
name="username"
label="用户名"
rules={[{ required: true, min: 3 }]}
>
<Input prefix={<UserOutlined />} />
</Form.Item>
<Form.Item name="current_password" label="当前密码(修改密码时必填)">
<Input.Password prefix={<LockOutlined />} />
</Form.Item>
<Form.Item name="password" label="新密码(留空则不修改)">
<Input.Password prefix={<LockOutlined />} />
</Form.Item>
<Form.Item name="confirm" label="确认新密码">
<Input.Password prefix={<LockOutlined />} />
</Form.Item>
<Button type="primary" htmlType="submit" icon={<SettingOutlined />}>
</Button>
</Form>
</Card>
</Space>
),
},
{
key: 'users',
label: '用户管理',
children: (
<Card
title="用户列表"
extra={
<Button type="primary" onClick={() => setCreateOpen(true)}>
</Button>
}
>
<Table
rowKey="id"
loading={loading}
dataSource={users}
pagination={false}
columns={[
{ title: '用户名', dataIndex: 'username' },
{
title: '角色',
dataIndex: 'is_superuser',
render: (v: boolean) => (v ? '超级管理员' : '普通用户'),
},
{
title: '创建时间',
dataIndex: 'created_at',
render: (v: string) => new Date(v).toLocaleString(),
},
{
title: '操作',
render: (_, record) =>
record.is_superuser ? (
<Typography.Text type="secondary"></Typography.Text>
) : (
<Space>
<Button size="small" onClick={() => setResetUser(record)}>
</Button>
<Popconfirm title="确定删除该用户?" onConfirm={() => removeUser(record.id)}>
<Button size="small" danger>
</Button>
</Popconfirm>
</Space>
),
},
]}
/>
</Card>
),
},
]}
/>
</Space>
<Modal
title="添加用户"
open={createOpen}
onCancel={() => setCreateOpen(false)}
onOk={() => createForm.submit()}
destroyOnHidden
>
<Form form={createForm} layout="vertical" onFinish={createUser}>
<Form.Item name="username" label="用户名" rules={[{ required: true, min: 3 }]}>
<Input />
</Form.Item>
<Form.Item name="password" label="密码" rules={[{ required: true, min: 6 }]}>
<Input.Password />
</Form.Item>
</Form>
</Modal>
<Modal
title={`重置密码 — ${resetUser?.username}`}
open={!!resetUser}
onCancel={() => setResetUser(null)}
onOk={() => resetForm.submit()}
destroyOnHidden
>
<Form form={resetForm} layout="vertical" onFinish={resetPassword}>
<Form.Item name="password" label="新密码" rules={[{ required: true, min: 6 }]}>
<Input.Password />
</Form.Item>
</Form>
</Modal>
</div>
)
}
+6 -1
View File
@@ -1,4 +1,4 @@
import { LogoutOutlined, PlusOutlined, UserOutlined } from '@ant-design/icons' import { LogoutOutlined, PlusOutlined, SettingOutlined, UserOutlined } from '@ant-design/icons'
import { Button, Card, Col, Form, Input, Modal, Row, Select, Space, Spin, Tag, Typography, message } from 'antd' import { Button, Card, Col, Form, Input, Modal, Row, Select, Space, Spin, Tag, Typography, message } from 'antd'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
@@ -62,6 +62,11 @@ export default function StudentsPage() {
<Typography.Text type="secondary">{user?.username}</Typography.Text> <Typography.Text type="secondary">{user?.username}</Typography.Text>
</div> </div>
<Space wrap> <Space wrap>
{user?.is_superuser && (
<Link to="/settings">
<Button icon={<SettingOutlined />}></Button>
</Link>
)}
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}> <Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
</Button> </Button>
+17
View File
@@ -7,6 +7,23 @@ export interface TokenResponse {
export interface User { export interface User {
id: string id: string
username: string username: string
is_superuser: boolean
created_at: string
}
export interface PublicSettings {
registration_enabled: boolean
}
export interface SystemSettings {
registration_enabled: boolean
updated_at: string
}
export interface AdminUser {
id: string
username: string
is_superuser: boolean
created_at: string created_at: string
} }
+1 -1
View File
@@ -7,7 +7,7 @@ export default defineConfig({
port: 5173, port: 5173,
proxy: { proxy: {
'/api': { '/api': {
target: 'http://localhost:23568', target: 'http://localhost:23566',
changeOrigin: true, changeOrigin: true,
}, },
}, },