feat(web): support /fl base path deployment
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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}>
|
||||
新建模型
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user