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_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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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}>
|
||||||
新建模型
|
新建模型
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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";
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user