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