feat(web): support /fl base path deployment

This commit is contained in:
chengkml
2026-05-16 14:08:18 +08:00
parent c4e3d06072
commit b649fac7b9
15 changed files with 109 additions and 9 deletions
+1
View File
@@ -1,4 +1,5 @@
NEXT_PUBLIC_API_BASE_URL=http://127.0.0.1:8000
NEXT_PUBLIC_APP_BASE_PATH=/fl
API_HOST=0.0.0.0
API_PORT=8000
API_CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
+2
View File
@@ -70,6 +70,7 @@ jobs:
push: true
build-args: |
NEXT_PUBLIC_API_BASE_URL=${{ vars.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:8000' }}
NEXT_PUBLIC_APP_BASE_PATH=${{ vars.NEXT_PUBLIC_APP_BASE_PATH || '/fl' }}
tags: |
${{ steps.vars.outputs.web_image }}:${{ steps.vars.outputs.image_tag }}
${{ steps.vars.outputs.web_image }}:latest
@@ -171,6 +172,7 @@ jobs:
API_IMAGE=${API_IMAGE}:${IMAGE_TAG}
WEB_IMAGE=${WEB_IMAGE}:${IMAGE_TAG}
NEXT_PUBLIC_API_BASE_URL=${NEXT_PUBLIC_API_BASE_URL}
NEXT_PUBLIC_APP_BASE_PATH=/fl
FLOWER_BASIC_AUTH=${FLOWER_BASIC_AUTH}
ENV
+2
View File
@@ -52,5 +52,7 @@ POSTGRES_DB=fquiz
POSTGRES_USER=fquiz
POSTGRES_PASSWORD=replace_me
NEXT_PUBLIC_APP_BASE_PATH=/fl
API_CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
API_CORS_ORIGIN_REGEX=
+2
View File
@@ -197,11 +197,13 @@ services:
args:
NODE_BASE_IMAGE: ${NODE_BASE_IMAGE}
NEXT_PUBLIC_API_BASE_URL: ${NEXT_PUBLIC_API_BASE_URL}
NEXT_PUBLIC_APP_BASE_PATH: ${NEXT_PUBLIC_APP_BASE_PATH:-/fl}
container_name: ${COMPOSE_PROJECT_NAME}-web
depends_on:
- api
environment:
NEXT_PUBLIC_API_BASE_URL: ${NEXT_PUBLIC_API_BASE_URL}
NEXT_PUBLIC_APP_BASE_PATH: ${NEXT_PUBLIC_APP_BASE_PATH:-/fl}
NODE_ENV: production
ports:
- "3000:3000"
+1
View File
@@ -16,6 +16,7 @@ API_PORT=8000
FLOWER_PORT=5555
NEXT_PUBLIC_API_BASE_URL=https://quiz.example.com/api
NEXT_PUBLIC_APP_BASE_PATH=/fl
CELERY_LOG_LEVEL=INFO
CELERY_WORKER_CONCURRENCY=4
CELERY_WORKER_QUEUES=default,celery
+2
View File
@@ -52,5 +52,7 @@ POSTGRES_DB=fquiz
POSTGRES_USER=fquiz
POSTGRES_PASSWORD=replace_strong_password
NEXT_PUBLIC_APP_BASE_PATH=/fl
API_CORS_ORIGINS=https://quiz.example.com
API_CORS_ORIGIN_REGEX=
+1
View File
@@ -160,6 +160,7 @@ services:
- api
environment:
NEXT_PUBLIC_API_BASE_URL: ${NEXT_PUBLIC_API_BASE_URL}
NEXT_PUBLIC_APP_BASE_PATH: ${NEXT_PUBLIC_APP_BASE_PATH:-/fl}
NODE_ENV: production
ports:
- "3000:3000"
+4
View File
@@ -12,7 +12,9 @@ FROM ${NODE_BASE_IMAGE} AS builder
WORKDIR /app
ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
ARG NEXT_PUBLIC_APP_BASE_PATH=/fl
ENV NEXT_PUBLIC_API_BASE_URL=${NEXT_PUBLIC_API_BASE_URL}
ENV NEXT_PUBLIC_APP_BASE_PATH=${NEXT_PUBLIC_APP_BASE_PATH}
COPY --from=deps /app/node_modules ./node_modules
COPY . .
@@ -23,6 +25,8 @@ WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
ARG NEXT_PUBLIC_APP_BASE_PATH=/fl
ENV NEXT_PUBLIC_APP_BASE_PATH=${NEXT_PUBLIC_APP_BASE_PATH}
RUN addgroup -S nodejs && adduser -S nextjs -G nodejs
+13
View File
@@ -1,8 +1,21 @@
import type { NextConfig } from "next";
import path from "node:path";
function normalizeBasePath(value: string | undefined): string | undefined {
const trimmed = value?.trim();
if (!trimmed || trimmed === "/") {
return undefined;
}
const normalized = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
return normalized.replace(/\/+$/, "");
}
const basePath = normalizeBasePath(process.env.NEXT_PUBLIC_APP_BASE_PATH);
const nextConfig: NextConfig = {
output: "standalone",
basePath,
webpack(config) {
config.resolve = config.resolve ?? {};
config.resolve.alias = {
+2 -1
View File
@@ -39,6 +39,7 @@ import { useTopicSubscription } from "@/hooks/use-topic-subscription";
import { readApiError } from "@/lib/api";
import type { MenuTreeItem } from "@/types/auth";
import { useThemeAppearance } from "@/components/ui-antd";
import { withBasePath } from "@/lib/base-path";
const { Header, Sider, Content } = AntLayout;
const AntResult = Result as unknown as ComponentType<ResultProps>;
@@ -364,7 +365,7 @@ export default function AdminLayout({ children }: { children: ReactNode }) {
className="ml-2 flex items-center gap-2 text-inherit no-underline md:ml-0"
>
<img
src="/favicon.ico"
src={withBasePath("/favicon.ico")}
alt="高压电塔图标"
width={22}
height={22}
@@ -23,6 +23,7 @@ import {
import type { ColumnsType } from "antd/es/table";
import { useAuth } from "@/components/auth-provider";
import { withBasePath } from "@/lib/base-path";
import { AtpX6Viewer } from "@/components/atp-x6-viewer";
import { Card } from "@/components/ui-antd";
import { useTopicSubscription } from "@/hooks/use-topic-subscription";
@@ -1000,7 +1001,7 @@ export default function PowerLinesAtpViewerPage() {
title="ATP模型台账"
extra={(
<Space size={8} wrap>
<Button href="/power-lines">线</Button>
<Button href={withBasePath("/power-lines")}>线</Button>
{canManage && (
<Button type="primary" onClick={openCreateModelModal}>
+2 -1
View File
@@ -7,6 +7,7 @@ import { Alert, Button, Checkbox, Input, Space, Typography } from "antd";
import { useAuth } from "@/components/auth-provider";
import { Card } from "@/components/ui-antd";
import { withBasePath } from "@/lib/base-path";
const LOGIN_REMEMBER_KEY = "login.remember";
const LOGIN_USER_ID_KEY = "login.user_id";
@@ -95,7 +96,7 @@ export default function Home() {
<Space direction="vertical" size={20} className="w-full">
<div className="flex justify-center">
<img
src="/favicon.ico"
src={withBasePath("/favicon.ico")}
alt="高压电塔图标"
width={72}
height={72}
+2 -1
View File
@@ -11,6 +11,7 @@ import {
} from "react";
import { getApiBaseUrl, readApiError } from "@/lib/api";
import { withBasePath } from "@/lib/base-path";
import type { AuthTokenResponse, UserPublic } from "@/types/auth";
type AuthContextValue = {
@@ -182,7 +183,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
});
} finally {
if (typeof window !== "undefined") {
window.location.replace("/");
window.location.replace(withBasePath("/"));
return;
}
clearAuth();
+29
View File
@@ -0,0 +1,29 @@
function normalizeBasePath(value: string | undefined): string {
const trimmed = value?.trim() ?? "";
if (!trimmed || trimmed === "/") {
return "";
}
return `/${trimmed.replace(/^\/+|\/+$/g, "")}`;
}
export const APP_BASE_PATH = normalizeBasePath(
process.env.NEXT_PUBLIC_APP_BASE_PATH,
);
export function withBasePath(path: string): string {
if (path.startsWith("http://") || path.startsWith("https://") || path.startsWith("//")) {
return path;
}
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
if (!APP_BASE_PATH) {
return normalizedPath;
}
if (normalizedPath === "/") {
return APP_BASE_PATH;
}
return `${APP_BASE_PATH}${normalizedPath}`;
}
+44 -5
View File
@@ -1,5 +1,16 @@
import { NextRequest, NextResponse } from "next/server";
function normalizeBasePath(value: string | undefined): string {
const trimmed = value?.trim() ?? "";
if (!trimmed || trimmed === "/") {
return "";
}
return `/${trimmed.replace(/^\/+|\/+$/g, "")}`;
}
const APP_BASE_PATH = normalizeBasePath(process.env.NEXT_PUBLIC_APP_BASE_PATH);
const RESERVED_PREFIXES = [
"/api",
"/_next",
@@ -10,6 +21,34 @@ const RESERVED_PREFIXES = [
const PUBLIC_FILE = /\.[^/]+$/;
function stripBasePath(pathname: string): string {
if (!APP_BASE_PATH) {
return pathname;
}
if (pathname === APP_BASE_PATH) {
return "/";
}
if (pathname.startsWith(`${APP_BASE_PATH}/`)) {
return pathname.slice(APP_BASE_PATH.length) || "/";
}
return pathname;
}
function withBasePath(pathname: string): string {
if (!APP_BASE_PATH) {
return pathname;
}
if (pathname === "/") {
return APP_BASE_PATH;
}
return `${APP_BASE_PATH}${pathname}`;
}
function isBypassedPath(pathname: string): boolean {
if (pathname === "/") {
return true;
@@ -23,7 +62,7 @@ function isBypassedPath(pathname: string): boolean {
}
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
const pathname = stripBasePath(request.nextUrl.pathname);
if (isBypassedPath(pathname)) {
return NextResponse.next();
@@ -32,23 +71,23 @@ export function middleware(request: NextRequest) {
// Keep backward compatibility for existing /admin links.
if (pathname === "/admin" || pathname === "/admin/") {
const url = request.nextUrl.clone();
url.pathname = "/users";
url.pathname = withBasePath("/users");
return NextResponse.redirect(url);
}
if (pathname === "/dashboard" || pathname === "/dashboard/") {
const url = request.nextUrl.clone();
url.pathname = "/users";
url.pathname = withBasePath("/users");
return NextResponse.redirect(url);
}
if (pathname.startsWith("/admin/")) {
const url = request.nextUrl.clone();
url.pathname = pathname.slice("/admin".length) || "/users";
url.pathname = withBasePath(pathname.slice("/admin".length) || "/users");
return NextResponse.redirect(url);
}
// New public URLs without /admin are internally rewritten to existing routes.
const url = request.nextUrl.clone();
url.pathname = `/admin${pathname}`;
url.pathname = withBasePath(`/admin${pathname}`);
return NextResponse.rewrite(url);
}