Compare commits

...

17 Commits

Author SHA1 Message Date
魏风
ef93d4e5c2 feat: add location crud
All checks were successful
Deploy to Production / deploy (push) Successful in 1m2s
2026-03-12 19:47:41 +08:00
codex
f0a0667b84 deploy: support configurable backend host for frontend API URL
All checks were successful
Deploy to Production / deploy (push) Successful in 57s
2026-03-11 17:55:07 +08:00
codex
aeb7a8b678 ci: check backend/frontend via docker status in runner container mode
All checks were successful
Deploy to Production / deploy (push) Successful in 24s
2026-03-11 17:49:13 +08:00
codex
00469941dd deploy: move backend host port to 18000 to avoid 8000 conflict
Some checks failed
Deploy to Production / deploy (push) Failing after 5m10s
2026-03-11 17:41:22 +08:00
codex
2fe5a7f54e ci: default image names when secrets are empty
Some checks failed
Deploy to Production / deploy (push) Failing after 9s
2026-03-11 17:32:28 +08:00
codex
e860fd0607 ci: force compose to use .env.production in deploy workflow
Some checks failed
Deploy to Production / deploy (push) Failing after 5s
2026-03-11 17:15:02 +08:00
codex
6defbf92e0 ci: avoid external actions checkout by cloning local Gitea repo
Some checks failed
Deploy to Production / deploy (push) Failing after 8m42s
2026-03-11 16:44:56 +08:00
魏风
84aa601243 -----------tencent first conmit------------
Some checks failed
Deploy to Production / deploy (push) Failing after 6m19s
2026-03-11 15:56:11 +08:00
github-actions[bot]
4c63a663ac 📝 Update release notes
[skip ci]
2026-03-01 20:35:28 +00:00
github-actions[bot]
7005892795 📝 Update release notes
[skip ci]
2026-03-01 20:35:08 +00:00
dependabot[bot]
64455e5c7b ⬆ Bump actions/download-artifact from 7 to 8 (#2208)
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 7 to 8.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v7...v8)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: '8'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-01 21:34:47 +01:00
dependabot[bot]
2f5ceec867 ⬆ Bump actions/upload-artifact from 6 to 7 (#2207)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 6 to 7.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-01 21:34:42 +01:00
github-actions[bot]
16afa0d363 📝 Update release notes
[skip ci]
2026-03-01 20:34:34 +00:00
dependabot[bot]
40384c9deb ⬆ Bump @tanstack/react-router from 1.157.3 to 1.163.3 (#2215)
Bumps [@tanstack/react-router](https://github.com/TanStack/router/tree/HEAD/packages/react-router) from 1.157.3 to 1.163.3.
- [Release notes](https://github.com/TanStack/router/releases)
- [Commits](https://github.com/TanStack/router/commits/v1.163.3/packages/react-router)

---
updated-dependencies:
- dependency-name: "@tanstack/react-router"
  dependency-version: 1.163.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-01 21:34:16 +01:00
github-actions[bot]
aafa8ebfc9 📝 Update release notes
[skip ci]
2026-03-01 12:48:50 +00:00
github-actions[bot]
5e2e8a9e6a 📝 Update release notes
[skip ci]
2026-03-01 12:48:28 +00:00
github-actions[bot]
917c7c898c 📝 Update release notes
[skip ci]
2026-03-01 12:47:18 +00:00
32 changed files with 2099 additions and 22 deletions

47
.env.production.example Normal file
View File

@@ -0,0 +1,47 @@
# =============================================================================
# 生产环境配置 — 复制为 .env.production 并填入真实值
# =============================================================================
# Domain
DOMAIN=makefire.fun
# Frontend host (用于后端生成邮件链接等)
FRONTEND_HOST=https://makefire.fun
# Environment
ENVIRONMENT=production
PROJECT_NAME="Full Stack FastAPI Project"
STACK_NAME=full-stack-fastapi-project
# Backend
BACKEND_CORS_ORIGINS="https://makefire.fun,https://api.makefire.fun"
# ⚠️ 必须修改:运行 openssl rand -hex 32 生成
SECRET_KEY=changethis
FIRST_SUPERUSER=admin@makefire.fun
# ⚠️ 必须修改:设置强密码
FIRST_SUPERUSER_PASSWORD=changethis
# Emails (可选,如不需要发送邮件可留空)
SMTP_HOST=
SMTP_USER=
SMTP_PASSWORD=
EMAILS_FROM_EMAIL=info@makefire.fun
SMTP_TLS=True
SMTP_SSL=False
SMTP_PORT=587
# Postgres — 连接 1Panel 已有的 PostgreSQL 容器
# 在 Docker 网络 1panel-network 内,主机名为 postgresql
POSTGRES_SERVER=postgresql
POSTGRES_PORT=5432
POSTGRES_DB=app
# ⚠️ 使用已有 PG 的凭据,或为本项目创建专用用户
POSTGRES_USER=user_ZPKMQ6
POSTGRES_PASSWORD=password_CYmsGt
SENTRY_DSN=
# Docker images (本地构建,不需要远程 registry)
DOCKER_IMAGE_BACKEND=backend
DOCKER_IMAGE_FRONTEND=frontend

View File

@@ -0,0 +1,87 @@
name: Deploy to Production
on:
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
run: |
git clone "http://172.17.0.1:3000/${{ github.repository }}.git" .
git checkout "${{ github.sha }}"
- name: Create .env.production from secrets
run: |
cat > .env.production << 'ENVEOF'
DOMAIN=${{ secrets.DOMAIN }}
BACKEND_HOST=${{ secrets.BACKEND_HOST }}
FRONTEND_HOST=${{ secrets.FRONTEND_HOST }}
ENVIRONMENT=production
PROJECT_NAME=${{ secrets.PROJECT_NAME }}
STACK_NAME=${{ secrets.STACK_NAME }}
BACKEND_CORS_ORIGINS=${{ secrets.BACKEND_CORS_ORIGINS }}
SECRET_KEY=${{ secrets.SECRET_KEY }}
FIRST_SUPERUSER=${{ secrets.FIRST_SUPERUSER }}
FIRST_SUPERUSER_PASSWORD=${{ secrets.FIRST_SUPERUSER_PASSWORD }}
SMTP_HOST=${{ secrets.SMTP_HOST }}
SMTP_USER=${{ secrets.SMTP_USER }}
SMTP_PASSWORD=${{ secrets.SMTP_PASSWORD }}
EMAILS_FROM_EMAIL=${{ secrets.EMAILS_FROM_EMAIL }}
SMTP_TLS=${{ secrets.SMTP_TLS }}
SMTP_SSL=${{ secrets.SMTP_SSL }}
SMTP_PORT=${{ secrets.SMTP_PORT }}
POSTGRES_SERVER=${{ secrets.POSTGRES_SERVER }}
POSTGRES_PORT=${{ secrets.POSTGRES_PORT }}
POSTGRES_DB=${{ secrets.POSTGRES_DB }}
POSTGRES_USER=${{ secrets.POSTGRES_USER }}
POSTGRES_PASSWORD=${{ secrets.POSTGRES_PASSWORD }}
SENTRY_DSN=${{ secrets.SENTRY_DSN }}
DOCKER_IMAGE_BACKEND=${{ secrets.DOCKER_IMAGE_BACKEND }}
DOCKER_IMAGE_FRONTEND=${{ secrets.DOCKER_IMAGE_FRONTEND }}
ENVEOF
# Fallback defaults if image name secrets are empty
sed -i 's/^DOCKER_IMAGE_BACKEND=$/DOCKER_IMAGE_BACKEND=backend/' .env.production
sed -i 's/^DOCKER_IMAGE_FRONTEND=$/DOCKER_IMAGE_FRONTEND=frontend/' .env.production
- name: Build Docker images
run: docker compose --env-file .env.production -f compose.prod.yml build
- name: Stop existing services
run: docker compose --env-file .env.production -f compose.prod.yml down --remove-orphans || true
- name: Start services
run: docker compose --env-file .env.production -f compose.prod.yml up -d
- name: Wait for backend health check
run: |
echo "Waiting for backend container health=healthy..."
for i in $(seq 1 30); do
status=$(docker inspect -f '{{.State.Health.Status}}' full-stack-fastapi-backend-1 2>/dev/null || echo "unknown")
if [ "$status" = "healthy" ]; then
echo "✅ Backend is healthy!"
exit 0
fi
echo "Attempt $i/30 - status=$status, waiting 10s..."
sleep 10
done
echo "❌ Backend health check failed after 300s"
docker compose --env-file .env.production -f compose.prod.yml logs backend
exit 1
- name: Verify frontend
run: |
if docker compose --env-file .env.production -f compose.prod.yml ps frontend --status running | grep -q frontend; then
echo "✅ Frontend is accessible!"
else
echo "❌ Frontend is not accessible"
docker compose --env-file .env.production -f compose.prod.yml logs frontend
exit 1
fi
- name: Cleanup old Docker images
run: docker image prune -f || true

View File

@@ -71,7 +71,7 @@ jobs:
- run: docker compose down -v --remove-orphans
- name: Upload blob report to GitHub Actions Artifacts
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: blob-report-${{ matrix.shardIndex }}
path: frontend/blob-report
@@ -91,7 +91,7 @@ jobs:
- name: Install dependencies
run: bun ci
- name: Download blob reports from GitHub Actions Artifacts
uses: actions/download-artifact@v7
uses: actions/download-artifact@v8
with:
path: frontend/all-blob-reports
pattern: blob-report-*
@@ -100,7 +100,7 @@ jobs:
run: bunx playwright merge-reports --reporter html ./all-blob-reports
working-directory: frontend
- name: Upload HTML report
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: html-report--attempt-${{ github.run_attempt }}
path: frontend/playwright-report

View File

@@ -18,7 +18,7 @@ jobs:
with:
python-version: "3.13"
- run: pip install smokeshow
- uses: actions/download-artifact@v7
- uses: actions/download-artifact@v8
with:
name: coverage-html
path: backend/htmlcov

View File

@@ -31,7 +31,7 @@ jobs:
working-directory: backend
- run: docker compose down -v --remove-orphans
- name: Store coverage files
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: coverage-html
path: backend/htmlcov

3
.gitignore vendored
View File

@@ -5,3 +5,6 @@ node_modules/
/playwright-report/
/blob-report/
/playwright/.cache/
.DS_Store
.env.production
.venv/

View File

@@ -42,4 +42,4 @@ RUN --mount=type=cache,target=/root/.cache/uv \
WORKDIR /app/backend/
CMD ["fastapi", "run", "--workers", "4", "app/main.py"]
CMD ["fastapi", "run", "--workers", "2", "app/main.py"]

View File

@@ -0,0 +1,37 @@
"""add location table
Revision ID: 0b117fab4208
Revises: fe56fa70289e
Create Date: 2026-03-12 11:07:24.856505
"""
from alembic import op
import sqlalchemy as sa
import sqlmodel.sql.sqltypes
# revision identifiers, used by Alembic.
revision = '0b117fab4208'
down_revision = 'fe56fa70289e'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('location',
sa.Column('title', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False),
sa.Column('description', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('owner_id', sa.Uuid(), nullable=False),
sa.ForeignKeyConstraint(['owner_id'], ['user.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('location')
# ### end Alembic commands ###

View File

@@ -1,6 +1,6 @@
from fastapi import APIRouter
from app.api.routes import items, login, private, users, utils
from app.api.routes import items, locations, login, private, users, utils
from app.core.config import settings
api_router = APIRouter()
@@ -8,7 +8,9 @@ api_router.include_router(login.router)
api_router.include_router(users.router)
api_router.include_router(utils.router)
api_router.include_router(items.router)
api_router.include_router(locations.router)
if settings.ENVIRONMENT == "local":
api_router.include_router(private.router)

View File

@@ -0,0 +1,119 @@
import uuid
from typing import Any
from fastapi import APIRouter, HTTPException
from sqlmodel import col, func, select
from app.api.deps import CurrentUser, SessionDep
from app.models import (
Location,
LocationCreate,
LocationPublic,
LocationsPublic,
LocationUpdate,
Message,
)
router = APIRouter(prefix="/locations", tags=["locations"])
@router.get("/", response_model=LocationsPublic)
def read_locations(
session: SessionDep, current_user: CurrentUser, skip: int = 0, limit: int = 100
) -> Any:
"""
Retrieve locations.
"""
if current_user.is_superuser:
count_statement = select(func.count()).select_from(Location)
count = session.exec(count_statement).one()
statement = (
select(Location).order_by(col(Location.created_at).desc()).offset(skip).limit(limit)
)
locations = session.exec(statement).all()
else:
count_statement = (
select(func.count())
.select_from(Location)
.where(Location.owner_id == current_user.id)
)
count = session.exec(count_statement).one()
statement = (
select(Location)
.where(Location.owner_id == current_user.id)
.order_by(col(Location.created_at).desc())
.offset(skip)
.limit(limit)
)
locations = session.exec(statement).all()
return LocationsPublic(data=locations, count=count)
@router.get("/{id}", response_model=LocationPublic)
def read_location(session: SessionDep, current_user: CurrentUser, id: uuid.UUID) -> Any:
"""
Get location by ID.
"""
location = session.get(Location, id)
if not location:
raise HTTPException(status_code=404, detail="Location not found")
if not current_user.is_superuser and (location.owner_id != current_user.id):
raise HTTPException(status_code=403, detail="Not enough permissions")
return location
@router.post("/", response_model=LocationPublic)
def create_location(
*, session: SessionDep, current_user: CurrentUser, location_in: LocationCreate
) -> Any:
"""
Create new location.
"""
location = Location.model_validate(location_in, update={"owner_id": current_user.id})
session.add(location)
session.commit()
session.refresh(location)
return location
@router.put("/{id}", response_model=LocationPublic)
def update_location(
*,
session: SessionDep,
current_user: CurrentUser,
id: uuid.UUID,
location_in: LocationUpdate,
) -> Any:
"""
Update a location.
"""
location = session.get(Location, id)
if not location:
raise HTTPException(status_code=404, detail="Location not found")
if not current_user.is_superuser and (location.owner_id != current_user.id):
raise HTTPException(status_code=403, detail="Not enough permissions")
update_dict = location_in.model_dump(exclude_unset=True)
location.sqlmodel_update(update_dict)
session.add(location)
session.commit()
session.refresh(location)
return location
@router.delete("/{id}")
def delete_location(
session: SessionDep, current_user: CurrentUser, id: uuid.UUID
) -> Message:
"""
Delete a location.
"""
location = session.get(Location, id)
if not location:
raise HTTPException(status_code=404, detail="Location not found")
if not current_user.is_superuser and (location.owner_id != current_user.id):
raise HTTPException(status_code=403, detail="Not enough permissions")
session.delete(location)
session.commit()
return Message(message="Location deleted successfully")

View File

@@ -4,7 +4,15 @@ from typing import Any
from sqlmodel import Session, select
from app.core.security import get_password_hash, verify_password
from app.models import Item, ItemCreate, User, UserCreate, UserUpdate
from app.models import (
Item,
ItemCreate,
Location,
LocationCreate,
User,
UserCreate,
UserUpdate,
)
def create_user(*, session: Session, user_create: UserCreate) -> User:
@@ -66,3 +74,11 @@ def create_item(*, session: Session, item_in: ItemCreate, owner_id: uuid.UUID) -
session.commit()
session.refresh(db_item)
return db_item
def create_location(*, session: Session, location_in: LocationCreate, owner_id: uuid.UUID) -> Location:
db_location = Location.model_validate(location_in, update={"owner_id": owner_id})
session.add(db_location)
session.commit()
session.refresh(db_location)
return db_location

View File

@@ -54,6 +54,7 @@ class User(UserBase, table=True):
sa_type=DateTime(timezone=True), # type: ignore
)
items: list["Item"] = Relationship(back_populates="owner", cascade_delete=True)
locations: list["Location"] = Relationship(back_populates="owner", cascade_delete=True)
# Properties to return via API, id is always required
@@ -108,6 +109,47 @@ class ItemsPublic(SQLModel):
count: int
# Shared properties
class LocationBase(SQLModel):
title: str = Field(min_length=1, max_length=255)
description: str | None = Field(default=None, max_length=255)
# Properties to receive on location creation
class LocationCreate(LocationBase):
pass
# Properties to receive on location update
class LocationUpdate(LocationBase):
title: str | None = Field(default=None, min_length=1, max_length=255) # type: ignore
# Database model, database table inferred from class name
class Location(LocationBase, table=True):
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
created_at: datetime | None = Field(
default_factory=get_datetime_utc,
sa_type=DateTime(timezone=True), # type: ignore
)
owner_id: uuid.UUID = Field(
foreign_key="user.id", nullable=False, ondelete="CASCADE"
)
owner: User | None = Relationship(back_populates="locations")
# Properties to return via API, id is always required
class LocationPublic(LocationBase):
id: uuid.UUID
owner_id: uuid.UUID
created_at: datetime | None = None
class LocationsPublic(SQLModel):
data: list[LocationPublic]
count: int
# Generic message
class Message(SQLModel):
message: str

View File

@@ -0,0 +1,164 @@
import uuid
from fastapi.testclient import TestClient
from sqlmodel import Session
from app.core.config import settings
from tests.utils.location import create_random_location
def test_create_location(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
data = {"title": "Test Location", "description": "A test location"}
response = client.post(
f"{settings.API_V1_STR}/locations/",
headers=superuser_token_headers,
json=data,
)
assert response.status_code == 200
content = response.json()
assert content["title"] == data["title"]
assert content["description"] == data["description"]
assert "id" in content
assert "owner_id" in content
def test_read_location(
client: TestClient, superuser_token_headers: dict[str, str], db: Session
) -> None:
location = create_random_location(db)
response = client.get(
f"{settings.API_V1_STR}/locations/{location.id}",
headers=superuser_token_headers,
)
assert response.status_code == 200
content = response.json()
assert content["title"] == location.title
assert content["description"] == location.description
assert content["id"] == str(location.id)
assert content["owner_id"] == str(location.owner_id)
def test_read_location_not_found(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
response = client.get(
f"{settings.API_V1_STR}/locations/{uuid.uuid4()}",
headers=superuser_token_headers,
)
assert response.status_code == 404
content = response.json()
assert content["detail"] == "Location not found"
def test_read_location_not_enough_permissions(
client: TestClient, normal_user_token_headers: dict[str, str], db: Session
) -> None:
location = create_random_location(db)
response = client.get(
f"{settings.API_V1_STR}/locations/{location.id}",
headers=normal_user_token_headers,
)
assert response.status_code == 403
content = response.json()
assert content["detail"] == "Not enough permissions"
def test_read_locations(
client: TestClient, superuser_token_headers: dict[str, str], db: Session
) -> None:
create_random_location(db)
create_random_location(db)
response = client.get(
f"{settings.API_V1_STR}/locations/",
headers=superuser_token_headers,
)
assert response.status_code == 200
content = response.json()
assert len(content["data"]) >= 2
def test_update_location(
client: TestClient, superuser_token_headers: dict[str, str], db: Session
) -> None:
location = create_random_location(db)
data = {"title": "Updated title", "description": "Updated description"}
response = client.put(
f"{settings.API_V1_STR}/locations/{location.id}",
headers=superuser_token_headers,
json=data,
)
assert response.status_code == 200
content = response.json()
assert content["title"] == data["title"]
assert content["description"] == data["description"]
assert content["id"] == str(location.id)
assert content["owner_id"] == str(location.owner_id)
def test_update_location_not_found(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
data = {"title": "Updated title", "description": "Updated description"}
response = client.put(
f"{settings.API_V1_STR}/locations/{uuid.uuid4()}",
headers=superuser_token_headers,
json=data,
)
assert response.status_code == 404
content = response.json()
assert content["detail"] == "Location not found"
def test_update_location_not_enough_permissions(
client: TestClient, normal_user_token_headers: dict[str, str], db: Session
) -> None:
location = create_random_location(db)
data = {"title": "Updated title", "description": "Updated description"}
response = client.put(
f"{settings.API_V1_STR}/locations/{location.id}",
headers=normal_user_token_headers,
json=data,
)
assert response.status_code == 403
content = response.json()
assert content["detail"] == "Not enough permissions"
def test_delete_location(
client: TestClient, superuser_token_headers: dict[str, str], db: Session
) -> None:
location = create_random_location(db)
response = client.delete(
f"{settings.API_V1_STR}/locations/{location.id}",
headers=superuser_token_headers,
)
assert response.status_code == 200
content = response.json()
assert content["message"] == "Location deleted successfully"
def test_delete_location_not_found(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
response = client.delete(
f"{settings.API_V1_STR}/locations/{uuid.uuid4()}",
headers=superuser_token_headers,
)
assert response.status_code == 404
content = response.json()
assert content["detail"] == "Location not found"
def test_delete_location_not_enough_permissions(
client: TestClient, normal_user_token_headers: dict[str, str], db: Session
) -> None:
location = create_random_location(db)
response = client.delete(
f"{settings.API_V1_STR}/locations/{location.id}",
headers=normal_user_token_headers,
)
assert response.status_code == 403
content = response.json()
assert content["detail"] == "Not enough permissions"

View File

@@ -7,7 +7,7 @@ from sqlmodel import Session, delete
from app.core.config import settings
from app.core.db import engine, init_db
from app.main import app
from app.models import Item, User
from app.models import Item, Location, User
from tests.utils.user import authentication_token_from_email
from tests.utils.utils import get_superuser_token_headers
@@ -19,6 +19,8 @@ def db() -> Generator[Session, None, None]:
yield session
statement = delete(Item)
session.execute(statement)
statement = delete(Location)
session.execute(statement)
statement = delete(User)
session.execute(statement)
session.commit()

View File

@@ -0,0 +1,16 @@
from sqlmodel import Session
from app import crud
from app.models import Location, LocationCreate
from tests.utils.user import create_random_user
from tests.utils.utils import random_lower_string
def create_random_location(db: Session) -> Location:
user = create_random_user(db)
owner_id = user.id
assert owner_id is not None
title = random_lower_string()
description = random_lower_string()
location_in = LocationCreate(title=title, description=description)
return crud.create_location(session=db, location_in=location_in, owner_id=owner_id)

View File

@@ -25,7 +25,7 @@
"@tailwindcss/vite": "^4.1.18",
"@tanstack/react-query": "^5.90.21",
"@tanstack/react-query-devtools": "^5.91.1",
"@tanstack/react-router": "^1.157.3",
"@tanstack/react-router": "^1.163.3",
"@tanstack/react-router-devtools": "^1.163.3",
"@tanstack/react-table": "^8.21.3",
"axios": "1.13.5",
@@ -388,7 +388,7 @@
"@tailwindcss/vite": ["@tailwindcss/vite@4.1.18", "", { "dependencies": { "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "tailwindcss": "4.1.18" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA=="],
"@tanstack/history": ["@tanstack/history@1.154.14", "", {}, "sha512-xyIfof8eHBuub1CkBnbKNKQXeRZC4dClhmzePHVOEel4G7lk/dW+TQ16da7CFdeNLv6u6Owf5VoBQxoo6DFTSA=="],
"@tanstack/history": ["@tanstack/history@1.161.4", "", {}, "sha512-Kp/WSt411ZWYvgXy6uiv5RmhHrz9cAml05AQPrtdAp7eUqvIDbMGPnML25OKbzR3RJ1q4wgENxDTvlGPa9+Mww=="],
"@tanstack/query-core": ["@tanstack/query-core@5.90.20", "", {}, "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg=="],
@@ -398,11 +398,11 @@
"@tanstack/react-query-devtools": ["@tanstack/react-query-devtools@5.91.2", "", { "dependencies": { "@tanstack/query-devtools": "5.92.0" }, "peerDependencies": { "@tanstack/react-query": "^5.90.14", "react": "^18 || ^19" } }, "sha512-ZJ1503ay5fFeEYFUdo7LMNFzZryi6B0Cacrgr2h1JRkvikK1khgIq6Nq2EcblqEdIlgB/r7XDW8f8DQ89RuUgg=="],
"@tanstack/react-router": ["@tanstack/react-router@1.157.3", "", { "dependencies": { "@tanstack/history": "1.154.14", "@tanstack/react-store": "^0.8.0", "@tanstack/router-core": "1.157.3", "isbot": "^5.1.22", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-nrshpAAtYRWnvJeTwItA8WwDr5oX5zOvxxcFEWIdsscLHkKsK9ED9byV4d8VfCRey+W02blBxsCKpppJfq2rnQ=="],
"@tanstack/react-router": ["@tanstack/react-router@1.163.3", "", { "dependencies": { "@tanstack/history": "1.161.4", "@tanstack/react-store": "^0.9.1", "@tanstack/router-core": "1.163.3", "isbot": "^5.1.22", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-hheBbFVb+PbxtrWp8iy6+TTRTbhx3Pn6hKo8Tv/sWlG89ZMcD1xpQWzx8ukHN9K8YWbh5rdzt4kv6u8X4kB28Q=="],
"@tanstack/react-router-devtools": ["@tanstack/react-router-devtools@1.163.3", "", { "dependencies": { "@tanstack/router-devtools-core": "1.163.3" }, "peerDependencies": { "@tanstack/react-router": "^1.163.3", "@tanstack/router-core": "^1.163.3", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" }, "optionalPeers": ["@tanstack/router-core"] }, "sha512-42VMkV/2Z8ro7xzblPBRNZIEmCNXMzm2jD68G52p2qhjXm38wGpg46qneAESN9FtTQeVWk5aSXs47/jt7lkzmw=="],
"@tanstack/react-store": ["@tanstack/react-store@0.8.0", "", { "dependencies": { "@tanstack/store": "0.8.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-1vG9beLIuB7q69skxK9r5xiLN3ztzIPfSQSs0GfeqWGO2tGIyInZx0x1COhpx97RKaONSoAb8C3dxacWksm1ow=="],
"@tanstack/react-store": ["@tanstack/react-store@0.9.1", "", { "dependencies": { "@tanstack/store": "0.9.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-YzJLnRvy5lIEFTLWBAZmcOjK3+2AepnBv/sr6NZmiqJvq7zTQggyK99Gw8fqYdMdHPQWXjz0epFKJXC+9V2xDA=="],
"@tanstack/react-table": ["@tanstack/react-table@8.21.3", "", { "dependencies": { "@tanstack/table-core": "8.21.3" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww=="],
@@ -864,13 +864,17 @@
"@tailwindcss/vite/tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
"@tanstack/react-router/@tanstack/router-core": ["@tanstack/router-core@1.157.3", "", { "dependencies": { "@tanstack/history": "1.154.14", "@tanstack/store": "^0.8.0", "cookie-es": "^2.0.0", "seroval": "^1.4.2", "seroval-plugins": "^1.4.2", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-r2KY/UWC4Ocxx05G7b/tLNQ7ZGX7URvA5H5P1cNbkFmi77VbOgtbW0sfz9/+9Dyh6aqHVK/Bx5kuR5jojNvrHQ=="],
"@tanstack/react-router/@tanstack/router-core": ["@tanstack/router-core@1.163.3", "", { "dependencies": { "@tanstack/history": "1.161.4", "@tanstack/store": "^0.9.1", "cookie-es": "^2.0.0", "seroval": "^1.4.2", "seroval-plugins": "^1.4.2", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-jPptiGq/w3nuPzcMC7RNa79aU+b6OjaDzWJnBcV2UAwL4ThJamRS4h42TdhJE+oF5yH9IEnCOGQdfnbw45LbfA=="],
"@tanstack/react-router-devtools/@tanstack/router-core": ["@tanstack/router-core@1.163.3", "", { "dependencies": { "@tanstack/history": "1.161.4", "@tanstack/store": "^0.9.1", "cookie-es": "^2.0.0", "seroval": "^1.4.2", "seroval-plugins": "^1.4.2", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-jPptiGq/w3nuPzcMC7RNa79aU+b6OjaDzWJnBcV2UAwL4ThJamRS4h42TdhJE+oF5yH9IEnCOGQdfnbw45LbfA=="],
"@tanstack/react-store/@tanstack/store": ["@tanstack/store@0.9.1", "", {}, "sha512-+qcNkOy0N1qSGsP7omVCW0SDrXtaDcycPqBDE726yryiA5eTDFpjBReaYjghVJwNf1pcPMyzIwTGlYjCSQR0Fg=="],
"@tanstack/router-core/@tanstack/history": ["@tanstack/history@1.153.2", "", {}, "sha512-TVa0Wju5w6JZGq/S74Q7TQNtKXDatJaB4NYrhMZVU9ETlkgpr35NhDfOzsCJ93P0KCo1ZoDodlFp3c54/dLsyw=="],
"@tanstack/router-devtools/@tanstack/react-router-devtools": ["@tanstack/react-router-devtools@1.157.17", "", { "dependencies": { "@tanstack/router-devtools-core": "1.157.16" }, "peerDependencies": { "@tanstack/react-router": "^1.157.17", "@tanstack/router-core": "^1.157.16", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" }, "optionalPeers": ["@tanstack/router-core"] }, "sha512-ajhTEQMPK9XtgVN7KqLy9JobYbyjcbuZXc76kABA8HeUJqB98rvwdpVuB106LReeIKuTc5RLOgCrdkq2A19wpg=="],
"@tanstack/router-devtools-core/@tanstack/router-core": ["@tanstack/router-core@1.157.3", "", { "dependencies": { "@tanstack/history": "1.154.14", "@tanstack/store": "^0.8.0", "cookie-es": "^2.0.0", "seroval": "^1.4.2", "seroval-plugins": "^1.4.2", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-r2KY/UWC4Ocxx05G7b/tLNQ7ZGX7URvA5H5P1cNbkFmi77VbOgtbW0sfz9/+9Dyh6aqHVK/Bx5kuR5jojNvrHQ=="],
"@tanstack/router-devtools-core/@tanstack/router-core": ["@tanstack/router-core@1.163.3", "", { "dependencies": { "@tanstack/history": "1.161.4", "@tanstack/store": "^0.9.1", "cookie-es": "^2.0.0", "seroval": "^1.4.2", "seroval-plugins": "^1.4.2", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-jPptiGq/w3nuPzcMC7RNa79aU+b6OjaDzWJnBcV2UAwL4ThJamRS4h42TdhJE+oF5yH9IEnCOGQdfnbw45LbfA=="],
"@tanstack/router-generator/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
@@ -928,10 +932,18 @@
"@radix-ui/react-visually-hidden/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@tanstack/react-router-devtools/@tanstack/router-core/@tanstack/store": ["@tanstack/store@0.9.1", "", {}, "sha512-+qcNkOy0N1qSGsP7omVCW0SDrXtaDcycPqBDE726yryiA5eTDFpjBReaYjghVJwNf1pcPMyzIwTGlYjCSQR0Fg=="],
"@tanstack/react-router/@tanstack/router-core/@tanstack/store": ["@tanstack/store@0.9.1", "", {}, "sha512-+qcNkOy0N1qSGsP7omVCW0SDrXtaDcycPqBDE726yryiA5eTDFpjBReaYjghVJwNf1pcPMyzIwTGlYjCSQR0Fg=="],
"@tanstack/router-devtools-core/@tanstack/router-core/@tanstack/store": ["@tanstack/store@0.9.1", "", {}, "sha512-+qcNkOy0N1qSGsP7omVCW0SDrXtaDcycPqBDE726yryiA5eTDFpjBReaYjghVJwNf1pcPMyzIwTGlYjCSQR0Fg=="],
"@tanstack/router-devtools/@tanstack/react-router-devtools/@tanstack/router-core": ["@tanstack/router-core@1.163.3", "", { "dependencies": { "@tanstack/history": "1.161.4", "@tanstack/store": "^0.9.1", "cookie-es": "^2.0.0", "seroval": "^1.4.2", "seroval-plugins": "^1.4.2", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-jPptiGq/w3nuPzcMC7RNa79aU+b6OjaDzWJnBcV2UAwL4ThJamRS4h42TdhJE+oF5yH9IEnCOGQdfnbw45LbfA=="],
"@tanstack/router-devtools/@tanstack/react-router-devtools/@tanstack/router-devtools-core": ["@tanstack/router-devtools-core@1.157.16", "", { "dependencies": { "clsx": "^2.1.1", "goober": "^2.1.16", "tiny-invariant": "^1.3.3" }, "peerDependencies": { "@tanstack/router-core": "^1.157.16", "csstype": "^3.0.10" }, "optionalPeers": ["csstype"] }, "sha512-XBJTs/kMZYK6J2zhbGucHNuypwDB1t2vi8K5To+V6dUnLGBEyfQTf01fegiF4rpL1yXgomdGnP6aTiOFgldbVg=="],
"c12/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
"@tanstack/router-devtools/@tanstack/react-router-devtools/@tanstack/router-devtools-core/@tanstack/router-core": ["@tanstack/router-core@1.157.3", "", { "dependencies": { "@tanstack/history": "1.154.14", "@tanstack/store": "^0.8.0", "cookie-es": "^2.0.0", "seroval": "^1.4.2", "seroval-plugins": "^1.4.2", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-r2KY/UWC4Ocxx05G7b/tLNQ7ZGX7URvA5H5P1cNbkFmi77VbOgtbW0sfz9/+9Dyh6aqHVK/Bx5kuR5jojNvrHQ=="],
"@tanstack/router-devtools/@tanstack/react-router-devtools/@tanstack/router-core/@tanstack/store": ["@tanstack/store@0.9.1", "", {}, "sha512-+qcNkOy0N1qSGsP7omVCW0SDrXtaDcycPqBDE726yryiA5eTDFpjBReaYjghVJwNf1pcPMyzIwTGlYjCSQR0Fg=="],
}
}

89
compose.prod.yml Normal file
View File

@@ -0,0 +1,89 @@
services:
prestart:
image: '${DOCKER_IMAGE_BACKEND?Variable not set}:${TAG-latest}'
build:
context: .
dockerfile: backend/Dockerfile
networks:
- 1panel-network
command: bash scripts/prestart.sh
env_file:
- .env.production
environment:
- DOMAIN=${DOMAIN}
- FRONTEND_HOST=${FRONTEND_HOST?Variable not set}
- ENVIRONMENT=${ENVIRONMENT}
- BACKEND_CORS_ORIGINS=${BACKEND_CORS_ORIGINS}
- SECRET_KEY=${SECRET_KEY?Variable not set}
- FIRST_SUPERUSER=${FIRST_SUPERUSER?Variable not set}
- FIRST_SUPERUSER_PASSWORD=${FIRST_SUPERUSER_PASSWORD?Variable not set}
- SMTP_HOST=${SMTP_HOST}
- SMTP_USER=${SMTP_USER}
- SMTP_PASSWORD=${SMTP_PASSWORD}
- EMAILS_FROM_EMAIL=${EMAILS_FROM_EMAIL}
- POSTGRES_SERVER=${POSTGRES_SERVER}
- POSTGRES_PORT=${POSTGRES_PORT}
- POSTGRES_DB=${POSTGRES_DB}
- POSTGRES_USER=${POSTGRES_USER?Variable not set}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD?Variable not set}
- SENTRY_DSN=${SENTRY_DSN}
backend:
image: '${DOCKER_IMAGE_BACKEND?Variable not set}:${TAG-latest}'
restart: always
networks:
- 1panel-network
depends_on:
prestart:
condition: service_completed_successfully
ports:
- "127.0.0.1:18000:8000"
env_file:
- .env.production
environment:
- DOMAIN=${DOMAIN}
- FRONTEND_HOST=${FRONTEND_HOST?Variable not set}
- ENVIRONMENT=${ENVIRONMENT}
- BACKEND_CORS_ORIGINS=${BACKEND_CORS_ORIGINS}
- SECRET_KEY=${SECRET_KEY?Variable not set}
- FIRST_SUPERUSER=${FIRST_SUPERUSER?Variable not set}
- FIRST_SUPERUSER_PASSWORD=${FIRST_SUPERUSER_PASSWORD?Variable not set}
- SMTP_HOST=${SMTP_HOST}
- SMTP_USER=${SMTP_USER}
- SMTP_PASSWORD=${SMTP_PASSWORD}
- EMAILS_FROM_EMAIL=${EMAILS_FROM_EMAIL}
- POSTGRES_SERVER=${POSTGRES_SERVER}
- POSTGRES_PORT=${POSTGRES_PORT}
- POSTGRES_DB=${POSTGRES_DB}
- POSTGRES_USER=${POSTGRES_USER?Variable not set}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD?Variable not set}
- SENTRY_DSN=${SENTRY_DSN}
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/api/v1/utils/health-check/"]
interval: 10s
timeout: 5s
retries: 5
build:
context: .
dockerfile: backend/Dockerfile
frontend:
image: '${DOCKER_IMAGE_FRONTEND?Variable not set}:${TAG-latest}'
restart: always
networks:
- 1panel-network
ports:
- "127.0.0.1:3001:80"
build:
context: .
dockerfile: frontend/Dockerfile
args:
- VITE_API_URL=https://${BACKEND_HOST:-api.${DOMAIN?Variable not set}}
- NODE_ENV=production
networks:
1panel-network:
external: true

452
deploy-tencent.md Normal file
View File

@@ -0,0 +1,452 @@
# 部署指南:腾讯云 + 1Panel + OpenResty + Gitea CI/CD
## 目录
1. [架构概览](#1-架构概览)
2. [服务器准备](#2-服务器准备)
3. [配置环境变量](#3-配置环境变量)
4. [创建数据库](#4-创建数据库)
5. [手动首次部署](#5-手动首次部署)
6. [在 1Panel 中配置 OpenResty](#6-在-1panel-中配置-openresty)
7. [DNS 解析配置](#7-dns-解析配置)
8. [配置 Gitea Actions CI/CD](#8-配置-gitea-actions-cicd)
9. [验证部署](#9-验证部署)
10. [日常运维](#10-日常运维)
---
## 1. 架构概览
```
用户浏览器
OpenResty (1Panel 管理, SSL, 端口 80/443)
├── makefire.fun → 127.0.0.1:3001 (Frontend Nginx 容器)
└── api.makefire.fun → 127.0.0.1:8000 (Backend FastAPI 容器)
PostgreSQL (1Panel 已有容器, 1panel-network)
```
**关键设计决策:**
- 后端和前端容器只绑定 `127.0.0.1`,不对外暴露,由 OpenResty 统一反代
- 所有容器加入 `1panel-network`,可直接通过 `postgresql` 主机名访问已有数据库
- 不使用 Traefik用 1Panel 自带的 OpenResty 替代)
- 不启动独立 PostgreSQL 容器(复用已有的)
---
## 2. 服务器准备
### 2.1 创建部署目录
```bash
# SSH 登录服务器后执行
sudo mkdir -p /opt/fastapi-app
sudo chown $USER:$USER /opt/fastapi-app
```
### 2.2 初始化 Git 仓库(在 Gitea 上)
1. 登录 Gitea`http://your-server-ip:3000`
2. 创建新仓库,例如 `full-stack-fastapi`
3. 在本地开发机上添加 Gitea 远程仓库:
```bash
cd /Users/weifeng/Workspace/full-stack-fastapi-template
# 添加 Gitea 远程
git remote add gitea http://your-server-ip:3000/your-username/full-stack-fastapi.git
# 推送代码
git push gitea main
```
---
## 3. 配置环境变量
### 3.1 在服务器上创建生产环境文件
```bash
cd /opt/fastapi-app
# 复制示例文件(首次需要从代码仓库获取)
cp .env.production.example .env.production
```
### 3.2 修改关键配置
```bash
nano .env.production
```
**必须修改的项:**
```bash
# 生成强随机密钥
SECRET_KEY=$(openssl rand -hex 32)
echo "生成的 SECRET_KEY: $SECRET_KEY"
# 设置强管理员密码
FIRST_SUPERUSER_PASSWORD=你的强密码
```
> ⚠️ `SECRET_KEY` 和 `FIRST_SUPERUSER_PASSWORD` 不能使用默认值 `changethis`,否则生产环境会报错。
---
## 4. 创建数据库
在已有的 PostgreSQL 中为本项目创建专用数据库:
```bash
# 方法一:使用 docker exec
docker exec -it 1Panel-postgresql-bxrK psql -U user_ZPKMQ6 -c "CREATE DATABASE app;"
# 验证数据库是否创建成功
docker exec -it 1Panel-postgresql-bxrK psql -U user_ZPKMQ6 -c "\l" | grep app
```
如果你想创建专用用户(更安全,可选):
```bash
docker exec -it 1Panel-postgresql-bxrK psql -U user_ZPKMQ6 -c "
CREATE USER fastapi_user WITH PASSWORD 'your_strong_password';
GRANT ALL PRIVILEGES ON DATABASE app TO fastapi_user;
ALTER DATABASE app OWNER TO fastapi_user;
"
```
> 如果使用专用用户,记得更新 `.env.production` 中的 `POSTGRES_USER` 和 `POSTGRES_PASSWORD`。
---
## 5. 手动首次部署
### 5.1 克隆代码到服务器
```bash
cd /opt/fastapi-app
git clone http://localhost:3000/your-username/full-stack-fastapi.git .
# 或者如果已经有代码
git pull origin main
```
### 5.2 复制环境变量文件
确保 `.env.production``/opt/fastapi-app/` 目录下。
### 5.3 构建和启动
```bash
cd /opt/fastapi-app
# 构建镜像
docker compose -f compose.prod.yml build
# 启动服务
docker compose -f compose.prod.yml up -d
# 查看日志
docker compose -f compose.prod.yml logs -f
```
### 5.4 验证容器状态
```bash
# 查看运行状态
docker compose -f compose.prod.yml ps
# 测试后端
curl http://127.0.0.1:8000/api/v1/utils/health-check/
# 测试前端
curl -I http://127.0.0.1:3001
```
---
## 6. 在 1Panel 中配置 OpenResty
### 6.1 创建前端网站
1. 打开 1Panel → **网站****创建网站**
2. 选择 **反向代理**
3. 配置:
- **主域名**: `makefire.fun`
- **代理地址**: `http://127.0.0.1:3001`
4. 点击创建
### 6.2 创建后端 API 网站
1. **网站****创建网站****反向代理**
2. 配置:
- **主域名**: `api.makefire.fun`
- **代理地址**: `http://127.0.0.1:8000`
3. 点击创建
### 6.3 配置 SSL 证书
对每个网站:
1. 点击网站名称进入设置
2. 选择 **HTTPS** 标签
3. 选择 **申请证书****Let's Encrypt**
4. 勾选 **自动续签**
5. 勾选 **HTTP → HTTPS 强制跳转**
### 6.4 修改后端网站配置(可选优化)
进入 `api.makefire.fun` 网站设置 → **配置文件**,在 `location /` 块中添加:
```nginx
# WebSocket 支持
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# 请求体大小限制
client_max_body_size 10m;
# 超时
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
```
> 完整参考配置见项目根目录的 `openresty-example.conf`。
---
## 7. DNS 解析配置
在你的域名 DNS 管理处(腾讯云 DNS 或其他)添加:
| 记录类型 | 主机记录 | 记录值 | TTL |
|---------|---------|--------|-----|
| A | @ | 你的服务器 IP | 600 |
| A | api | 你的服务器 IP | 600 |
> 如果使用腾讯云域名,进入 **DNS 解析 DNSPod** 配置。
---
## 8. 配置 Gitea Actions CI/CD
### 8.1 安装 Gitea Actions Runner
```bash
# 1. 下载 Gitea Actions Runner
# 访问 https://gitea.com/gitea/act_runner/releases 获取最新版本
wget https://gitea.com/gitea/act_runner/releases/download/v0.2.11/act_runner-0.2.11-linux-amd64
chmod +x act_runner-0.2.11-linux-amd64
sudo mv act_runner-0.2.11-linux-amd64 /usr/local/bin/act_runner
# 2. 生成配置文件
cd /opt
act_runner generate-config > act_runner_config.yaml
```
### 8.2 修改 Runner 配置
编辑 `/opt/act_runner_config.yaml`,关键修改:
```yaml
runner:
# 标签,决定 workflow 中 runs-on 可以匹配的值
labels:
- "ubuntu-latest:host"
# ↑ 使用 host 模式,直接在服务器上运行(不在 Docker 中套 Docker
```
> **为什么用 `host` 模式?** 因为 workflow 需要执行 `docker compose` 命令来管理服务器上的容器。若在容器中运行,需要额外配置 Docker-in-Docker对低内存服务器更加不友好。
### 8.3 注册 Runner
```bash
# 1. 在 Gitea 中获取 Runner Token
# 进入仓库 → Settings → Actions → Runners → 点击 "Create new runner"
# 复制显示的 Token
# 2. 注册
act_runner register \
--instance http://localhost:3000 \
--token YOUR_RUNNER_TOKEN \
--name my-runner \
--labels "ubuntu-latest:host" \
--config /opt/act_runner_config.yaml \
--no-interactive
# 3. 启动(测试)
act_runner daemon --config /opt/act_runner_config.yaml
```
### 8.4 设置为系统服务(推荐)
```bash
sudo tee /etc/systemd/system/gitea-runner.service << 'EOF'
[Unit]
Description=Gitea Actions Runner
After=network.target docker.service
[Service]
Type=simple
User=root
WorkingDirectory=/opt/fastapi-app
ExecStart=/usr/local/bin/act_runner daemon --config /opt/act_runner_config.yaml
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable gitea-runner
sudo systemctl start gitea-runner
# 查看状态
sudo systemctl status gitea-runner
```
### 8.5 配置 Gitea Secrets
在 Gitea 仓库中配置环境变量密钥:
1. 进入仓库 → **Settings****Actions****Secrets**
2. 添加以下 Secrets
| Secret 名称 | 值 |
|-------------|-----|
| `DOMAIN` | `makefire.fun` |
| `FRONTEND_HOST` | `https://makefire.fun` |
| `PROJECT_NAME` | `Full Stack FastAPI Project` |
| `STACK_NAME` | `full-stack-fastapi-project` |
| `BACKEND_CORS_ORIGINS` | `https://makefire.fun,https://api.makefire.fun` |
| `SECRET_KEY` | *(用 `openssl rand -hex 32` 生成)* |
| `FIRST_SUPERUSER` | `admin@makefire.fun` |
| `FIRST_SUPERUSER_PASSWORD` | *(你的强密码)* |
| `SMTP_HOST` | *(留空或填写)* |
| `SMTP_USER` | *(留空或填写)* |
| `SMTP_PASSWORD` | *(留空或填写)* |
| `EMAILS_FROM_EMAIL` | `info@makefire.fun` |
| `SMTP_TLS` | `True` |
| `SMTP_SSL` | `False` |
| `SMTP_PORT` | `587` |
| `POSTGRES_SERVER` | `postgresql` |
| `POSTGRES_PORT` | `5432` |
| `POSTGRES_DB` | `app` |
| `POSTGRES_USER` | `user_ZPKMQ6` |
| `POSTGRES_PASSWORD` | `password_CYmsGt` |
| `SENTRY_DSN` | *(留空)* |
| `DOCKER_IMAGE_BACKEND` | `backend` |
| `DOCKER_IMAGE_FRONTEND` | `frontend` |
### 8.6 启用 Gitea Actions
1. 进入仓库 → **Settings****Repository**
2. 确保 **Actions** 功能已启用
3. 如果 Gitea 全局未启用 Actions需要在 Gitea 配置文件中添加:
```ini
; 在 /opt/1panel/apps/gitea/gitea/data/gitea/conf/app.ini 中
[actions]
ENABLED = true
```
修改后重启 Gitea 容器:
```bash
docker restart 1Panel-gitea-FSXv
```
---
## 9. 验证部署
### 9.1 本地访问测试
```bash
# 后端健康检查
curl http://127.0.0.1:8000/api/v1/utils/health-check/
# 前端页面
curl -I http://127.0.0.1:3001
```
### 9.2 域名访问测试
```bash
# 前端
curl -I https://makefire.fun
# 后端 API 文档
curl -I https://api.makefire.fun/docs
# 后端健康检查
curl https://api.makefire.fun/api/v1/utils/health-check/
```
### 9.3 CI/CD 测试
```bash
# 在本地开发机上
cd /Users/weifeng/Workspace/full-stack-fastapi-template
echo "# test" >> README.md
git add .
git commit -m "test: trigger CI/CD"
git push gitea main
```
然后在 Gitea 仓库的 **Actions** 标签中查看运行状态。
---
## 10. 日常运维
### 查看日志
```bash
cd /opt/fastapi-app
docker compose -f compose.prod.yml logs -f backend # 后端日志
docker compose -f compose.prod.yml logs -f frontend # 前端日志
docker compose -f compose.prod.yml logs -f # 全部日志
```
### 重启服务
```bash
docker compose -f compose.prod.yml restart backend
docker compose -f compose.prod.yml restart frontend
```
### 手动重新部署
```bash
cd /opt/fastapi-app
git pull origin main
docker compose -f compose.prod.yml build
docker compose -f compose.prod.yml down
docker compose -f compose.prod.yml up -d
```
### 清理 Docker 资源
```bash
# 清理无用镜像(释放磁盘空间)
docker image prune -f
docker system prune -f
```
### 数据库备份
```bash
# 备份
docker exec 1Panel-postgresql-bxrK pg_dump -U user_ZPKMQ6 app > backup_$(date +%Y%m%d).sql
# 恢复
docker exec -i 1Panel-postgresql-bxrK psql -U user_ZPKMQ6 app < backup_20260311.sql
```

View File

@@ -29,7 +29,7 @@
"@tailwindcss/vite": "^4.1.18",
"@tanstack/react-query": "^5.90.21",
"@tanstack/react-query-devtools": "^5.91.1",
"@tanstack/react-router": "^1.157.3",
"@tanstack/react-router": "^1.163.3",
"@tanstack/react-router-devtools": "^1.163.3",
"@tanstack/react-table": "^8.21.3",
"axios": "1.13.5",

View File

@@ -196,6 +196,131 @@ export const ItemsPublicSchema = {
title: 'ItemsPublic'
} as const;
export const LocationCreateSchema = {
properties: {
title: {
type: 'string',
maxLength: 255,
minLength: 1,
title: 'Title'
},
description: {
anyOf: [
{
type: 'string',
maxLength: 255
},
{
type: 'null'
}
],
title: 'Description'
}
},
type: 'object',
required: ['title'],
title: 'LocationCreate'
} as const;
export const LocationPublicSchema = {
properties: {
title: {
type: 'string',
maxLength: 255,
minLength: 1,
title: 'Title'
},
description: {
anyOf: [
{
type: 'string',
maxLength: 255
},
{
type: 'null'
}
],
title: 'Description'
},
id: {
type: 'string',
format: 'uuid',
title: 'Id'
},
owner_id: {
type: 'string',
format: 'uuid',
title: 'Owner Id'
},
created_at: {
anyOf: [
{
type: 'string',
format: 'date-time'
},
{
type: 'null'
}
],
title: 'Created At'
}
},
type: 'object',
required: ['title', 'id', 'owner_id'],
title: 'LocationPublic'
} as const;
export const LocationUpdateSchema = {
properties: {
title: {
anyOf: [
{
type: 'string',
maxLength: 255,
minLength: 1
},
{
type: 'null'
}
],
title: 'Title'
},
description: {
anyOf: [
{
type: 'string',
maxLength: 255
},
{
type: 'null'
}
],
title: 'Description'
}
},
type: 'object',
title: 'LocationUpdate'
} as const;
export const LocationsPublicSchema = {
properties: {
data: {
items: {
'$ref': '#/components/schemas/LocationPublic'
},
type: 'array',
title: 'Data'
},
count: {
type: 'integer',
title: 'Count'
}
},
type: 'object',
required: ['data', 'count'],
title: 'LocationsPublic'
} as const;
export const MessageSchema = {
properties: {
message: {

View File

@@ -3,7 +3,7 @@
import type { CancelablePromise } from './core/CancelablePromise';
import { OpenAPI } from './core/OpenAPI';
import { request as __request } from './core/request';
import type { ItemsReadItemsData, ItemsReadItemsResponse, ItemsCreateItemData, ItemsCreateItemResponse, ItemsReadItemData, ItemsReadItemResponse, ItemsUpdateItemData, ItemsUpdateItemResponse, ItemsDeleteItemData, ItemsDeleteItemResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, PrivateCreateUserData, PrivateCreateUserResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse } from './types.gen';
import type { ItemsReadItemsData, ItemsReadItemsResponse, ItemsCreateItemData, ItemsCreateItemResponse, ItemsReadItemData, ItemsReadItemResponse, ItemsUpdateItemData, ItemsUpdateItemResponse, ItemsDeleteItemData, ItemsDeleteItemResponse, LocationsReadLocationsData, LocationsReadLocationsResponse, LocationsCreateLocationData, LocationsCreateLocationResponse, LocationsReadLocationData, LocationsReadLocationResponse, LocationsUpdateLocationData, LocationsUpdateLocationResponse, LocationsDeleteLocationData, LocationsDeleteLocationResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, PrivateCreateUserData, PrivateCreateUserResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse } from './types.gen';
export class ItemsService {
/**
@@ -116,6 +116,117 @@ export class ItemsService {
}
}
export class LocationsService {
/**
* Read Locations
* Retrieve locations.
* @param data The data for the request.
* @param data.skip
* @param data.limit
* @returns LocationsPublic Successful Response
* @throws ApiError
*/
public static readLocations(data: LocationsReadLocationsData = {}): CancelablePromise<LocationsReadLocationsResponse> {
return __request(OpenAPI, {
method: 'GET',
url: '/api/v1/locations/',
query: {
skip: data.skip,
limit: data.limit
},
errors: {
422: 'Validation Error'
}
});
}
/**
* Create Location
* Create new location.
* @param data The data for the request.
* @param data.requestBody
* @returns LocationPublic Successful Response
* @throws ApiError
*/
public static createLocation(data: LocationsCreateLocationData): CancelablePromise<LocationsCreateLocationResponse> {
return __request(OpenAPI, {
method: 'POST',
url: '/api/v1/locations/',
body: data.requestBody,
mediaType: 'application/json',
errors: {
422: 'Validation Error'
}
});
}
/**
* Read Location
* Get location by ID.
* @param data The data for the request.
* @param data.id
* @returns LocationPublic Successful Response
* @throws ApiError
*/
public static readLocation(data: LocationsReadLocationData): CancelablePromise<LocationsReadLocationResponse> {
return __request(OpenAPI, {
method: 'GET',
url: '/api/v1/locations/{id}',
path: {
id: data.id
},
errors: {
422: 'Validation Error'
}
});
}
/**
* Update Location
* Update a location.
* @param data The data for the request.
* @param data.id
* @param data.requestBody
* @returns LocationPublic Successful Response
* @throws ApiError
*/
public static updateLocation(data: LocationsUpdateLocationData): CancelablePromise<LocationsUpdateLocationResponse> {
return __request(OpenAPI, {
method: 'PUT',
url: '/api/v1/locations/{id}',
path: {
id: data.id
},
body: data.requestBody,
mediaType: 'application/json',
errors: {
422: 'Validation Error'
}
});
}
/**
* Delete Location
* Delete a location.
* @param data The data for the request.
* @param data.id
* @returns Message Successful Response
* @throws ApiError
*/
public static deleteLocation(data: LocationsDeleteLocationData): CancelablePromise<LocationsDeleteLocationResponse> {
return __request(OpenAPI, {
method: 'DELETE',
url: '/api/v1/locations/{id}',
path: {
id: data.id
},
errors: {
422: 'Validation Error'
}
});
}
}
export class LoginService {
/**
* Login Access Token

View File

@@ -36,6 +36,29 @@ export type ItemUpdate = {
description?: (string | null);
};
export type LocationCreate = {
title: string;
description?: (string | null);
};
export type LocationPublic = {
title: string;
description?: (string | null);
id: string;
owner_id: string;
created_at?: (string | null);
};
export type LocationsPublic = {
data: Array<LocationPublic>;
count: number;
};
export type LocationUpdate = {
title?: (string | null);
description?: (string | null);
};
export type Message = {
message: string;
};
@@ -145,6 +168,38 @@ export type ItemsDeleteItemData = {
export type ItemsDeleteItemResponse = (Message);
export type LocationsReadLocationsData = {
limit?: number;
skip?: number;
};
export type LocationsReadLocationsResponse = (LocationsPublic);
export type LocationsCreateLocationData = {
requestBody: LocationCreate;
};
export type LocationsCreateLocationResponse = (LocationPublic);
export type LocationsReadLocationData = {
id: string;
};
export type LocationsReadLocationResponse = (LocationPublic);
export type LocationsUpdateLocationData = {
id: string;
requestBody: LocationUpdate;
};
export type LocationsUpdateLocationResponse = (LocationPublic);
export type LocationsDeleteLocationData = {
id: string;
};
export type LocationsDeleteLocationResponse = (Message);
export type LoginLoginAccessTokenData = {
formData: Body_login_login_access_token;
};

View File

@@ -0,0 +1,144 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { Plus } from "lucide-react"
import { useState } from "react"
import { useForm } from "react-hook-form"
import { z } from "zod"
import { type LocationCreate, LocationsService } from "@/client"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import { LoadingButton } from "@/components/ui/loading-button"
import useCustomToast from "@/hooks/useCustomToast"
import { handleError } from "@/utils"
const formSchema = z.object({
title: z.string().min(1, { message: "Title is required" }),
description: z.string().optional(),
})
type FormData = z.infer<typeof formSchema>
const AddLocation = () => {
const [isOpen, setIsOpen] = useState(false)
const queryClient = useQueryClient()
const { showSuccessToast, showErrorToast } = useCustomToast()
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
mode: "onBlur",
criteriaMode: "all",
defaultValues: {
title: "",
description: "",
},
})
const mutation = useMutation({
mutationFn: (data: LocationCreate) =>
LocationsService.createLocation({ requestBody: data }),
onSuccess: () => {
showSuccessToast("Location created successfully")
form.reset()
setIsOpen(false)
},
onError: handleError.bind(showErrorToast),
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["locations"] })
},
})
const onSubmit = (data: FormData) => {
mutation.mutate(data)
}
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button className="my-4">
<Plus className="mr-2" />
Add Location
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Add Location</DialogTitle>
<DialogDescription>
Fill in the details to add a new location.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="grid gap-4 py-4">
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>
Title <span className="text-destructive">*</span>
</FormLabel>
<FormControl>
<Input
placeholder="Title"
type="text"
{...field}
required
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Input placeholder="Description" type="text" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline" disabled={mutation.isPending}>
Cancel
</Button>
</DialogClose>
<LoadingButton type="submit" loading={mutation.isPending}>
Save
</LoadingButton>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
)
}
export default AddLocation

View File

@@ -0,0 +1,94 @@
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { Trash2 } from "lucide-react"
import { useState } from "react"
import { useForm } from "react-hook-form"
import { LocationsService } from "@/client"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { DropdownMenuItem } from "@/components/ui/dropdown-menu"
import { LoadingButton } from "@/components/ui/loading-button"
import useCustomToast from "@/hooks/useCustomToast"
import { handleError } from "@/utils"
interface DeleteLocationProps {
id: string
onSuccess: () => void
}
const DeleteLocation = ({ id, onSuccess }: DeleteLocationProps) => {
const [isOpen, setIsOpen] = useState(false)
const queryClient = useQueryClient()
const { showSuccessToast, showErrorToast } = useCustomToast()
const { handleSubmit } = useForm()
const deleteLocation = async (id: string) => {
await LocationsService.deleteLocation({ id: id })
}
const mutation = useMutation({
mutationFn: deleteLocation,
onSuccess: () => {
showSuccessToast("The location was deleted successfully")
setIsOpen(false)
onSuccess()
},
onError: handleError.bind(showErrorToast),
onSettled: () => {
queryClient.invalidateQueries()
},
})
const onSubmit = async () => {
mutation.mutate(id)
}
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DropdownMenuItem
variant="destructive"
onSelect={(e) => e.preventDefault()}
onClick={() => setIsOpen(true)}
>
<Trash2 />
Delete Location
</DropdownMenuItem>
<DialogContent className="sm:max-w-md">
<form onSubmit={handleSubmit(onSubmit)}>
<DialogHeader>
<DialogTitle>Delete Location</DialogTitle>
<DialogDescription>
This location will be permanently deleted. Are you sure? You will not
be able to undo this action.
</DialogDescription>
</DialogHeader>
<DialogFooter className="mt-4">
<DialogClose asChild>
<Button variant="outline" disabled={mutation.isPending}>
Cancel
</Button>
</DialogClose>
<LoadingButton
variant="destructive"
type="submit"
loading={mutation.isPending}
>
Delete
</LoadingButton>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
export default DeleteLocation

View File

@@ -0,0 +1,145 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { Pencil } from "lucide-react"
import { useState } from "react"
import { useForm } from "react-hook-form"
import { z } from "zod"
import { type LocationPublic, LocationsService } from "@/client"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { DropdownMenuItem } from "@/components/ui/dropdown-menu"
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import { LoadingButton } from "@/components/ui/loading-button"
import useCustomToast from "@/hooks/useCustomToast"
import { handleError } from "@/utils"
const formSchema = z.object({
title: z.string().min(1, { message: "Title is required" }),
description: z.string().optional(),
})
type FormData = z.infer<typeof formSchema>
interface EditLocationProps {
location: LocationPublic
onSuccess: () => void
}
const EditLocation = ({ location, onSuccess }: EditLocationProps) => {
const [isOpen, setIsOpen] = useState(false)
const queryClient = useQueryClient()
const { showSuccessToast, showErrorToast } = useCustomToast()
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
mode: "onBlur",
criteriaMode: "all",
defaultValues: {
title: location.title,
description: location.description ?? undefined,
},
})
const mutation = useMutation({
mutationFn: (data: FormData) =>
LocationsService.updateLocation({ id: location.id, requestBody: data }),
onSuccess: () => {
showSuccessToast("Location updated successfully")
setIsOpen(false)
onSuccess()
},
onError: handleError.bind(showErrorToast),
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["locations"] })
},
})
const onSubmit = (data: FormData) => {
mutation.mutate(data)
}
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DropdownMenuItem
onSelect={(e) => e.preventDefault()}
onClick={() => setIsOpen(true)}
>
<Pencil />
Edit Location
</DropdownMenuItem>
<DialogContent className="sm:max-w-md">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<DialogHeader>
<DialogTitle>Edit Location</DialogTitle>
<DialogDescription>
Update the location details below.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>
Title <span className="text-destructive">*</span>
</FormLabel>
<FormControl>
<Input placeholder="Title" type="text" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Input placeholder="Description" type="text" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline" disabled={mutation.isPending}>
Cancel
</Button>
</DialogClose>
<LoadingButton type="submit" loading={mutation.isPending}>
Save
</LoadingButton>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
)
}
export default EditLocation

View File

@@ -0,0 +1,34 @@
import { EllipsisVertical } from "lucide-react"
import { useState } from "react"
import type { LocationPublic } from "@/client"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import DeleteLocation from "../Locations/DeleteLocation"
import EditLocation from "../Locations/EditLocation"
interface LocationActionsMenuProps {
location: LocationPublic
}
export const LocationActionsMenu = ({ location }: LocationActionsMenuProps) => {
const [open, setOpen] = useState(false)
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<EllipsisVertical />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<EditLocation location={location} onSuccess={() => setOpen(false)} />
<DeleteLocation id={location.id} onSuccess={() => setOpen(false)} />
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -0,0 +1,73 @@
import type { ColumnDef } from "@tanstack/react-table"
import { Check, Copy } from "lucide-react"
import type { LocationPublic } from "@/client"
import { Button } from "@/components/ui/button"
import { useCopyToClipboard } from "@/hooks/useCopyToClipboard"
import { cn } from "@/lib/utils"
import { LocationActionsMenu } from "./LocationActionsMenu"
function CopyId({ id }: { id: string }) {
const [copiedText, copy] = useCopyToClipboard()
const isCopied = copiedText === id
return (
<div className="flex items-center gap-1.5 group">
<span className="font-mono text-xs text-muted-foreground">{id}</span>
<Button
variant="ghost"
size="icon"
className="size-6 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() => copy(id)}
>
{isCopied ? (
<Check className="size-3 text-green-500" />
) : (
<Copy className="size-3" />
)}
<span className="sr-only">Copy ID</span>
</Button>
</div>
)
}
export const columns: ColumnDef<LocationPublic>[] = [
{
accessorKey: "id",
header: "ID",
cell: ({ row }) => <CopyId id={row.original.id} />,
},
{
accessorKey: "title",
header: "Title",
cell: ({ row }) => (
<span className="font-medium">{row.original.title}</span>
),
},
{
accessorKey: "description",
header: "Description",
cell: ({ row }) => {
const description = row.original.description
return (
<span
className={cn(
"max-w-xs truncate block text-muted-foreground",
!description && "italic",
)}
>
{description || "No description"}
</span>
)
},
},
{
id: "actions",
header: () => <span className="sr-only">Actions</span>,
cell: ({ row }) => (
<div className="flex justify-end">
<LocationActionsMenu location={row.original} />
</div>
),
},
]

View File

@@ -1,4 +1,4 @@
import { Briefcase, Home, Users } from "lucide-react"
import { Briefcase, Home, MapPin, Users } from "lucide-react"
import { SidebarAppearance } from "@/components/Common/Appearance"
import { Logo } from "@/components/Common/Logo"
@@ -14,6 +14,7 @@ import { User } from "./User"
const baseItems: Item[] = [
{ icon: Home, title: "Dashboard", path: "/" },
{ icon: MapPin, title: "Locations", path: "/locations" },
{ icon: Briefcase, title: "Items", path: "/items" },
]

View File

@@ -16,6 +16,7 @@ import { Route as LoginRouteImport } from './routes/login'
import { Route as LayoutRouteImport } from './routes/_layout'
import { Route as LayoutIndexRouteImport } from './routes/_layout/index'
import { Route as LayoutSettingsRouteImport } from './routes/_layout/settings'
import { Route as LayoutLocationsRouteImport } from './routes/_layout/locations'
import { Route as LayoutItemsRouteImport } from './routes/_layout/items'
import { Route as LayoutAdminRouteImport } from './routes/_layout/admin'
@@ -53,6 +54,11 @@ const LayoutSettingsRoute = LayoutSettingsRouteImport.update({
path: '/settings',
getParentRoute: () => LayoutRoute,
} as any)
const LayoutLocationsRoute = LayoutLocationsRouteImport.update({
id: '/locations',
path: '/locations',
getParentRoute: () => LayoutRoute,
} as any)
const LayoutItemsRoute = LayoutItemsRouteImport.update({
id: '/items',
path: '/items',
@@ -65,14 +71,15 @@ const LayoutAdminRoute = LayoutAdminRouteImport.update({
} as any)
export interface FileRoutesByFullPath {
'/': typeof LayoutIndexRoute
'/login': typeof LoginRoute
'/recover-password': typeof RecoverPasswordRoute
'/reset-password': typeof ResetPasswordRoute
'/signup': typeof SignupRoute
'/admin': typeof LayoutAdminRoute
'/items': typeof LayoutItemsRoute
'/locations': typeof LayoutLocationsRoute
'/settings': typeof LayoutSettingsRoute
'/': typeof LayoutIndexRoute
}
export interface FileRoutesByTo {
'/login': typeof LoginRoute
@@ -81,6 +88,7 @@ export interface FileRoutesByTo {
'/signup': typeof SignupRoute
'/admin': typeof LayoutAdminRoute
'/items': typeof LayoutItemsRoute
'/locations': typeof LayoutLocationsRoute
'/settings': typeof LayoutSettingsRoute
'/': typeof LayoutIndexRoute
}
@@ -93,20 +101,22 @@ export interface FileRoutesById {
'/signup': typeof SignupRoute
'/_layout/admin': typeof LayoutAdminRoute
'/_layout/items': typeof LayoutItemsRoute
'/_layout/locations': typeof LayoutLocationsRoute
'/_layout/settings': typeof LayoutSettingsRoute
'/_layout/': typeof LayoutIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/login'
| '/recover-password'
| '/reset-password'
| '/signup'
| '/admin'
| '/items'
| '/locations'
| '/settings'
| '/'
fileRoutesByTo: FileRoutesByTo
to:
| '/login'
@@ -115,6 +125,7 @@ export interface FileRouteTypes {
| '/signup'
| '/admin'
| '/items'
| '/locations'
| '/settings'
| '/'
id:
@@ -126,6 +137,7 @@ export interface FileRouteTypes {
| '/signup'
| '/_layout/admin'
| '/_layout/items'
| '/_layout/locations'
| '/_layout/settings'
| '/_layout/'
fileRoutesById: FileRoutesById
@@ -171,7 +183,7 @@ declare module '@tanstack/react-router' {
'/_layout': {
id: '/_layout'
path: ''
fullPath: ''
fullPath: '/'
preLoaderRoute: typeof LayoutRouteImport
parentRoute: typeof rootRouteImport
}
@@ -189,6 +201,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof LayoutSettingsRouteImport
parentRoute: typeof LayoutRoute
}
'/_layout/locations': {
id: '/_layout/locations'
path: '/locations'
fullPath: '/locations'
preLoaderRoute: typeof LayoutLocationsRouteImport
parentRoute: typeof LayoutRoute
}
'/_layout/items': {
id: '/_layout/items'
path: '/items'
@@ -209,6 +228,7 @@ declare module '@tanstack/react-router' {
interface LayoutRouteChildren {
LayoutAdminRoute: typeof LayoutAdminRoute
LayoutItemsRoute: typeof LayoutItemsRoute
LayoutLocationsRoute: typeof LayoutLocationsRoute
LayoutSettingsRoute: typeof LayoutSettingsRoute
LayoutIndexRoute: typeof LayoutIndexRoute
}
@@ -216,6 +236,7 @@ interface LayoutRouteChildren {
const LayoutRouteChildren: LayoutRouteChildren = {
LayoutAdminRoute: LayoutAdminRoute,
LayoutItemsRoute: LayoutItemsRoute,
LayoutLocationsRoute: LayoutLocationsRoute,
LayoutSettingsRoute: LayoutSettingsRoute,
LayoutIndexRoute: LayoutIndexRoute,
}

View File

@@ -0,0 +1,69 @@
import { useSuspenseQuery } from "@tanstack/react-query"
import { createFileRoute } from "@tanstack/react-router"
import { Search } from "lucide-react"
import { Suspense } from "react"
import { LocationsService } from "@/client"
import { DataTable } from "@/components/Common/DataTable"
import AddLocation from "@/components/Locations/AddLocation"
import { columns } from "@/components/Locations/columns"
import PendingItems from "@/components/Pending/PendingItems"
function getLocationsQueryOptions() {
return {
queryFn: () => LocationsService.readLocations({ skip: 0, limit: 100 }),
queryKey: ["locations"],
}
}
export const Route = createFileRoute("/_layout/locations")({
component: Locations,
head: () => ({
meta: [
{
title: "Locations - FastAPI Template",
},
],
}),
})
function LocationsTableContent() {
const { data: locations } = useSuspenseQuery(getLocationsQueryOptions())
if (locations.data.length === 0) {
return (
<div className="flex flex-col items-center justify-center text-center py-12">
<div className="rounded-full bg-muted p-4 mb-4">
<Search className="h-8 w-8 text-muted-foreground" />
</div>
<h3 className="text-lg font-semibold">You don't have any locations yet</h3>
<p className="text-muted-foreground">Add a new location to get started</p>
</div>
)
}
return <DataTable columns={columns} data={locations.data} />
}
function LocationsTable() {
return (
<Suspense fallback={<PendingItems />}>
<LocationsTableContent />
</Suspense>
)
}
function Locations() {
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Locations</h1>
<p className="text-muted-foreground">Create and manage your locations</p>
</div>
<AddLocation />
</div>
<LocationsTable />
</div>
)
}

111
openresty-example.conf Normal file
View File

@@ -0,0 +1,111 @@
# =============================================================================
# OpenResty / Nginx 反向代理配置示例
# 此文件仅供参考,实际配置在 1Panel 网站管理中完成
# =============================================================================
#
# 在 1Panel 中需要创建 2 个网站:
# 1. makefire.fun → 前端
# 2. api.makefire.fun → 后端 API
#
# 两个网站都需要:
# - 开启 SSL (1Panel 可自动申请 Let's Encrypt 证书)
# - 开启 HTTP → HTTPS 强制跳转
#
# 以下是每个网站的反向代理配置内容:
# =============================================
# 网站 1: makefire.fun (前端)
# =============================================
# 在 1Panel 中: 网站 → 创建网站 → 反向代理
# 代理地址: http://127.0.0.1:3001
server {
listen 80;
listen 443 ssl http2;
server_name makefire.fun;
# SSL 证书 (由 1Panel 自动管理,以下路径仅为示例)
# ssl_certificate /path/to/cert.pem;
# ssl_certificate_key /path/to/key.pem;
# HTTP → HTTPS 跳转
if ($scheme = http) {
return 301 https://$host$request_uri;
}
# 前端反向代理
location / {
proxy_pass http://127.0.0.1:3001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# SPA 路由支持 — 当后端 nginx 返回 404 时,由前端处理
proxy_intercept_errors on;
error_page 404 = /index.html;
}
# 静态资源缓存
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
proxy_pass http://127.0.0.1:3001;
proxy_set_header Host $host;
expires 30d;
add_header Cache-Control "public, immutable";
}
# Gzip 压缩
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml;
}
# =============================================
# 网站 2: api.makefire.fun (后端 API)
# =============================================
# 在 1Panel 中: 网站 → 创建网站 → 反向代理
# 代理地址: http://127.0.0.1:8000
server {
listen 80;
listen 443 ssl http2;
server_name api.makefire.fun;
# SSL 证书 (由 1Panel 自动管理)
# ssl_certificate /path/to/cert.pem;
# ssl_certificate_key /path/to/key.pem;
# HTTP → HTTPS 跳转
if ($scheme = http) {
return 301 https://$host$request_uri;
}
# API 反向代理
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket 支持 (如后续需要)
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# 超时设置
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
# 请求体大小限制 (文件上传等)
client_max_body_size 10m;
}
# Gzip 压缩
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
}

View File

@@ -17,6 +17,12 @@
### Internal
* ⬆ Bump actions/download-artifact from 7 to 8. PR [#2208](https://github.com/fastapi/full-stack-fastapi-template/pull/2208) by [@dependabot[bot]](https://github.com/apps/dependabot).
* ⬆ Bump actions/upload-artifact from 6 to 7. PR [#2207](https://github.com/fastapi/full-stack-fastapi-template/pull/2207) by [@dependabot[bot]](https://github.com/apps/dependabot).
* ⬆ Bump @tanstack/react-router from 1.157.3 to 1.163.3. PR [#2215](https://github.com/fastapi/full-stack-fastapi-template/pull/2215) by [@dependabot[bot]](https://github.com/apps/dependabot).
* ⬆ Bump @tanstack/react-router-devtools from 1.159.10 to 1.163.3. PR [#2212](https://github.com/fastapi/full-stack-fastapi-template/pull/2212) by [@dependabot[bot]](https://github.com/apps/dependabot).
* ⬆ Bump @tanstack/react-query from 5.90.20 to 5.90.21. PR [#2213](https://github.com/fastapi/full-stack-fastapi-template/pull/2213) by [@dependabot[bot]](https://github.com/apps/dependabot).
* ⬆ Bump @types/node from 25.1.0 to 25.3.2. PR [#2214](https://github.com/fastapi/full-stack-fastapi-template/pull/2214) by [@dependabot[bot]](https://github.com/apps/dependabot).
* ⬆ Bump tailwindcss from 4.1.18 to 4.2.0. PR [#2198](https://github.com/fastapi/full-stack-fastapi-template/pull/2198) by [@dependabot[bot]](https://github.com/apps/dependabot).
* ⬆ Bump axios from 1.13.4 to 1.13.5. PR [#2199](https://github.com/fastapi/full-stack-fastapi-template/pull/2199) by [@dependabot[bot]](https://github.com/apps/dependabot).
* ⬆ Bump @vitejs/plugin-react-swc from 4.2.2 to 4.2.3. PR [#2200](https://github.com/fastapi/full-stack-fastapi-template/pull/2200) by [@dependabot[bot]](https://github.com/apps/dependabot).