feat(config): inject frontend runtime config

This commit is contained in:
2026-07-20 12:06:57 +08:00
parent 0d559f6130
commit 7a3677ee9f
10 changed files with 127 additions and 71 deletions
+3 -1
View File
@@ -3,8 +3,10 @@ node_modules
out out
build build
.git .git
.env
.env.*
.env*.local .env*.local
README.md README.md
docker-compose.yml docker-compose.yml
Dockerfile Dockerfile
.dockerignore .dockerignore
-9
View File
@@ -105,15 +105,6 @@ jobs:
-t "${IMAGE_NAME}:${IMAGE_TAG}" \ -t "${IMAGE_NAME}:${IMAGE_TAG}" \
-t "${IMAGE_NAME}:latest" \ -t "${IMAGE_NAME}:latest" \
--build-arg NPM_CONFIG_REGISTRY="https://registry.npmmirror.com" \ --build-arg NPM_CONFIG_REGISTRY="https://registry.npmmirror.com" \
--build-arg NEXT_PUBLIC_BACKEND_URL="${{ vars.NEXT_PUBLIC_BACKEND_URL }}" \
--build-arg NEXT_PUBLIC_AGENT_URL="${{ vars.NEXT_PUBLIC_AGENT_URL }}" \
--build-arg NEXT_PUBLIC_AUDIO_SERVICE_URL="${{ vars.NEXT_PUBLIC_AUDIO_SERVICE_URL }}" \
--build-arg NEXT_PUBLIC_MAP_URL="${{ vars.NEXT_PUBLIC_MAP_URL }}" \
--build-arg NEXT_PUBLIC_MAP_WORKSPACE="${{ vars.NEXT_PUBLIC_MAP_WORKSPACE }}" \
--build-arg NEXT_PUBLIC_MAP_EXTENT="${{ vars.NEXT_PUBLIC_MAP_EXTENT }}" \
--build-arg NEXT_PUBLIC_NETWORK_NAME="${{ vars.NEXT_PUBLIC_NETWORK_NAME }}" \
--build-arg NEXT_PUBLIC_MAPBOX_TOKEN="${{ secrets.NEXT_PUBLIC_MAPBOX_TOKEN }}" \
--build-arg NEXT_PUBLIC_TIANDITU_TOKEN="${{ secrets.NEXT_PUBLIC_TIANDITU_TOKEN }}" \
. .
push_with_retry "${IMAGE_NAME}:${IMAGE_TAG}" push_with_retry "${IMAGE_NAME}:${IMAGE_TAG}"
push_with_retry "${IMAGE_NAME}:latest" push_with_retry "${IMAGE_NAME}:latest"
+4 -13
View File
@@ -18,18 +18,6 @@ RUN \
FROM base AS builder FROM base AS builder
# 只定义 ARG 接收来自构建命令或 docker-compose.yaml 的参数
# Next.js 在 build 时会自动读取同名的 ARG 作为环境变量
ARG NEXT_PUBLIC_BACKEND_URL
ARG NEXT_PUBLIC_AGENT_URL
ARG NEXT_PUBLIC_AUDIO_SERVICE_URL
ARG NEXT_PUBLIC_MAP_URL
ARG NEXT_PUBLIC_MAP_WORKSPACE
ARG NEXT_PUBLIC_MAP_EXTENT
ARG NEXT_PUBLIC_NETWORK_NAME
ARG NEXT_PUBLIC_MAPBOX_TOKEN
ARG NEXT_PUBLIC_TIANDITU_TOKEN
COPY --from=deps /app/refine/node_modules ./node_modules COPY --from=deps /app/refine/node_modules ./node_modules
COPY . . COPY . .
@@ -40,7 +28,9 @@ FROM base AS runner
ENV NODE_ENV=production ENV NODE_ENV=production
COPY --from=builder /app/refine/public ./public COPY --from=builder --chown=node:node /app/refine/public ./public
COPY --chown=node:node docker/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
RUN mkdir .next RUN mkdir .next
RUN chown node:node .next RUN chown node:node .next
@@ -55,4 +45,5 @@ EXPOSE 3000
ENV PORT=3000 ENV PORT=3000
ENV HOSTNAME="0.0.0.0" ENV HOSTNAME="0.0.0.0"
ENTRYPOINT ["/entrypoint.sh"]
CMD ["node", "server.js"] CMD ["node", "server.js"]
+8 -12
View File
@@ -6,19 +6,15 @@ services:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
args:
NEXT_PUBLIC_BACKEND_URL: ${NEXT_PUBLIC_BACKEND_URL}
NEXT_PUBLIC_AGENT_URL: ${NEXT_PUBLIC_AGENT_URL}
NEXT_PUBLIC_AUDIO_SERVICE_URL: ${NEXT_PUBLIC_AUDIO_SERVICE_URL}
NEXT_PUBLIC_MAP_URL: ${NEXT_PUBLIC_MAP_URL}
NEXT_PUBLIC_MAP_WORKSPACE: ${NEXT_PUBLIC_MAP_WORKSPACE}
NEXT_PUBLIC_MAP_EXTENT: ${NEXT_PUBLIC_MAP_EXTENT}
NEXT_PUBLIC_NETWORK_NAME: ${NEXT_PUBLIC_NETWORK_NAME}
NEXT_PUBLIC_MAPBOX_TOKEN: ${NEXT_PUBLIC_MAPBOX_TOKEN}
NEXT_PUBLIC_TIANDITU_TOKEN: ${NEXT_PUBLIC_TIANDITU_TOKEN}
env_file:
- .env
environment: environment:
BACKEND_URL: ${BACKEND_URL}
AGENT_URL: ${AGENT_URL}
MAP_URL: ${MAP_URL}
MAP_WORKSPACE: ${MAP_WORKSPACE}
MAP_EXTENT: ${MAP_EXTENT}
NETWORK_NAME: ${NETWORK_NAME}
MAPBOX_TOKEN: ${MAPBOX_TOKEN}
TIANDITU_TOKEN: ${TIANDITU_TOKEN}
KEYCLOAK_CLIENT_ID: ${KEYCLOAK_CLIENT_ID} KEYCLOAK_CLIENT_ID: ${KEYCLOAK_CLIENT_ID}
KEYCLOAK_CLIENT_SECRET: ${KEYCLOAK_CLIENT_SECRET} KEYCLOAK_CLIENT_SECRET: ${KEYCLOAK_CLIENT_SECRET}
KEYCLOAK_ISSUER: ${KEYCLOAK_ISSUER} KEYCLOAK_ISSUER: ${KEYCLOAK_ISSUER}
+35
View File
@@ -0,0 +1,35 @@
#!/bin/sh
set -eu
node <<'NODE'
const fs = require("fs");
const parseExtent = (value) => {
if (!value) {
return [13508849, 3608036, 13555781, 3633813];
}
const extent = value.split(",").map(Number);
return extent.length === 4 && extent.every(Number.isFinite)
? extent
: [13508849, 3608036, 13555781, 3633813];
};
const config = {
BACKEND_URL: process.env.BACKEND_URL || "http://127.0.0.1:8000",
AGENT_URL: process.env.AGENT_URL || "http://127.0.0.1:8788",
MAP_URL: process.env.MAP_URL || "http://127.0.0.1:8080/geoserver",
MAP_WORKSPACE: process.env.MAP_WORKSPACE || "tjwater",
MAP_EXTENT: parseExtent(process.env.MAP_EXTENT),
NETWORK_NAME: process.env.NETWORK_NAME || "tjwater",
MAPBOX_TOKEN: process.env.MAPBOX_TOKEN || "",
TIANDITU_TOKEN: process.env.TIANDITU_TOKEN || "",
};
fs.writeFileSync(
"/app/refine/public/runtime-config.js",
`window.__TJWATER_RUNTIME_CONFIG__ = ${JSON.stringify(config)};\n`,
);
NODE
exec "$@"
+17 -7
View File
@@ -9,16 +9,26 @@ type KeycloakTokenResponse = {
refresh_token?: string; refresh_token?: string;
}; };
const keycloakIssuer = process.env.KEYCLOAK_ISSUER!; const getKeycloakTokenEndpoint = () => {
const keycloakClientId = process.env.KEYCLOAK_CLIENT_ID!; const issuer = process.env.KEYCLOAK_ISSUER;
const keycloakClientSecret = process.env.KEYCLOAK_CLIENT_SECRET!; return issuer
const keycloakTokenEndpoint = `${keycloakIssuer.replace(/\/$/, "")}/protocol/openid-connect/token`; ? `${issuer.replace(/\/$/, "")}/protocol/openid-connect/token`
: undefined;
};
const refreshAccessToken = async (token: JWT): Promise<JWT> => { const refreshAccessToken = async (token: JWT): Promise<JWT> => {
if (!token.refreshToken) { if (!token.refreshToken) {
return { ...token, error: "RefreshAccessTokenError" }; return { ...token, error: "RefreshAccessTokenError" };
} }
const keycloakClientId = process.env.KEYCLOAK_CLIENT_ID;
const keycloakClientSecret = process.env.KEYCLOAK_CLIENT_SECRET;
const keycloakTokenEndpoint = getKeycloakTokenEndpoint();
if (!keycloakClientId || !keycloakClientSecret || !keycloakTokenEndpoint) {
return { ...token, error: "RefreshAccessTokenError" };
}
const body = new URLSearchParams({ const body = new URLSearchParams({
grant_type: "refresh_token", grant_type: "refresh_token",
client_id: keycloakClientId, client_id: keycloakClientId,
@@ -50,9 +60,9 @@ const authOptions: NextAuthOptions = {
// Configure one or more authentication providers // Configure one or more authentication providers
providers: [ providers: [
KeycloakProvider({ KeycloakProvider({
clientId: keycloakClientId, clientId: process.env.KEYCLOAK_CLIENT_ID ?? "",
clientSecret: keycloakClientSecret, clientSecret: process.env.KEYCLOAK_CLIENT_SECRET ?? "",
issuer: keycloakIssuer, issuer: process.env.KEYCLOAK_ISSUER ?? "",
profile(profile) { profile(profile) {
return { return {
id: profile.sub, id: profile.sub,
+2
View File
@@ -1,5 +1,6 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import Script from "next/script";
import React, { Suspense } from "react"; import React, { Suspense } from "react";
import { RefineContext } from "./RefineContext"; import { RefineContext } from "./RefineContext";
import { META_DATA } from "@config/config"; import { META_DATA } from "@config/config";
@@ -18,6 +19,7 @@ export default async function RootLayout({
return ( return (
<html lang="en"> <html lang="en">
<body> <body>
<Script src="/runtime-config.js" strategy="beforeInteractive" />
<Suspense> <Suspense>
<RefineContext defaultMode={defaultMode}>{children}</RefineContext> <RefineContext defaultMode={defaultMode}>{children}</RefineContext>
</Suspense> </Suspense>
+7 -3
View File
@@ -27,6 +27,10 @@ import { GlobalChatbox } from "@components/chat/GlobalChatbox";
import { setMapExtent, setMapWorkspace, setNetworkName } from "@config/config"; import { setMapExtent, setMapWorkspace, setNetworkName } from "@config/config";
import { useProjectStore } from "@/store/projectStore"; import { useProjectStore } from "@/store/projectStore";
const MAP_WORKSPACE_STORAGE_KEY = "MAP_WORKSPACE";
const NETWORK_NAME_STORAGE_KEY = "NETWORK_NAME";
const MAP_EXTENT_STORAGE_KEY = "MAP_EXTENT";
type IUser = { type IUser = {
id?: string; id?: string;
name?: string; name?: string;
@@ -70,9 +74,9 @@ export const Header: React.FC<RefineThemedLayoutHeaderProps> = ({
setMapWorkspace(workspace); setMapWorkspace(workspace);
setNetworkName(networkName); setNetworkName(networkName);
setMapExtent(extent); setMapExtent(extent);
localStorage.setItem("NEXT_PUBLIC_MAP_WORKSPACE", workspace); localStorage.setItem(MAP_WORKSPACE_STORAGE_KEY, workspace);
localStorage.setItem("NEXT_PUBLIC_NETWORK_NAME", networkName); localStorage.setItem(NETWORK_NAME_STORAGE_KEY, networkName);
localStorage.setItem("NEXT_PUBLIC_MAP_EXTENT", extent.join(",")); localStorage.setItem(MAP_EXTENT_STORAGE_KEY, extent.join(","));
localStorage.removeItem(`${workspace}_map_view`); localStorage.removeItem(`${workspace}_map_view`);
setCurrentProjectId(projectId || networkName || workspace); setCurrentProjectId(projectId || networkName || workspace);
setShowProjectSelector(false); setShowProjectSelector(false);
+40 -15
View File
@@ -1,13 +1,41 @@
type RuntimeConfig = {
BACKEND_URL?: string;
AGENT_URL?: string;
MAP_URL?: string;
MAP_WORKSPACE?: string;
MAP_EXTENT?: number[] | string;
NETWORK_NAME?: string;
MAPBOX_TOKEN?: string;
TIANDITU_TOKEN?: string;
};
declare global {
interface Window {
__TJWATER_RUNTIME_CONFIG__?: RuntimeConfig;
}
}
const runtimeConfig =
typeof window === "undefined" ? {} : window.__TJWATER_RUNTIME_CONFIG__ ?? {};
const parseMapExtent = (value: RuntimeConfig["MAP_EXTENT"]): number[] => {
if (Array.isArray(value)) {
return value;
}
if (typeof value === "string" && value.trim()) {
return value.split(",").map(Number);
}
return [13508849, 3608036, 13555781, 3633813];
};
export const config = { export const config = {
BACKEND_URL: process.env.NEXT_PUBLIC_BACKEND_URL || "http://127.0.0.1:8000", BACKEND_URL: runtimeConfig.BACKEND_URL || "http://127.0.0.1:8000",
AGENT_URL: process.env.NEXT_PUBLIC_AGENT_URL || "http://127.0.0.1:8788", AGENT_URL: runtimeConfig.AGENT_URL || "http://127.0.0.1:8788",
AUDIO_SERVICE_URL: MAP_URL: runtimeConfig.MAP_URL || "http://127.0.0.1:8080/geoserver",
process.env.NEXT_PUBLIC_AUDIO_SERVICE_URL || "http://127.0.0.1:18083", MAP_WORKSPACE: runtimeConfig.MAP_WORKSPACE || "tjwater",
MAP_URL: process.env.NEXT_PUBLIC_MAP_URL || "http://127.0.0.1:8080/geoserver", MAP_EXTENT: parseMapExtent(runtimeConfig.MAP_EXTENT),
MAP_WORKSPACE: process.env.NEXT_PUBLIC_MAP_WORKSPACE || "tjwater",
MAP_EXTENT: process.env.NEXT_PUBLIC_MAP_EXTENT
? process.env.NEXT_PUBLIC_MAP_EXTENT.split(",").map(Number)
: [13508849, 3608036, 13555781, 3633813],
MAP_DEFAULT_STYLE: { MAP_DEFAULT_STYLE: {
"stroke-width": 3, "stroke-width": 3,
"stroke-color": "rgba(51, 153, 204, 0.9)", "stroke-color": "rgba(51, 153, 204, 0.9)",
@@ -33,7 +61,7 @@ export const config = {
"scada", "scada",
], ],
}; };
export let NETWORK_NAME = process.env.NEXT_PUBLIC_NETWORK_NAME || "tjwater"; export let NETWORK_NAME = runtimeConfig.NETWORK_NAME || "tjwater";
export const setNetworkName = (name: string) => { export const setNetworkName = (name: string) => {
NETWORK_NAME = name; NETWORK_NAME = name;
@@ -47,11 +75,8 @@ export const setMapExtent = (extent: number[]) => {
config.MAP_EXTENT = extent; config.MAP_EXTENT = extent;
}; };
export const MAPBOX_TOKEN = export const MAPBOX_TOKEN = runtimeConfig.MAPBOX_TOKEN || "";
process.env.NEXT_PUBLIC_MAPBOX_TOKEN || export const TIANDITU_TOKEN = runtimeConfig.TIANDITU_TOKEN || "";
"pk.eyJ1IjoiemhpZnUiLCJhIjoiY205azNyNGY1MGkyZDJxcTJleDUwaHV1ZCJ9.wOmSdOnDDdre-mB1Lpy6Fg";
export const TIANDITU_TOKEN =
process.env.NEXT_PUBLIC_TIANDITU_TOKEN || "e3e8ad95ee911741fa71ed7bff2717ec";
export const PROJECT_TITLE = process.env.PROJECT_TITLE || "TJWater Project"; export const PROJECT_TITLE = process.env.PROJECT_TITLE || "TJWater Project";
export const META_DATA = { export const META_DATA = {
title: PROJECT_TITLE, title: PROJECT_TITLE,
+11 -11
View File
@@ -13,6 +13,9 @@ interface ProjectContextType {
} }
const ProjectContext = createContext<ProjectContextType | undefined>(undefined); const ProjectContext = createContext<ProjectContextType | undefined>(undefined);
const MAP_WORKSPACE_STORAGE_KEY = "MAP_WORKSPACE";
const NETWORK_NAME_STORAGE_KEY = "NETWORK_NAME";
const MAP_EXTENT_STORAGE_KEY = "MAP_EXTENT";
export const ProjectProvider: React.FC<{ children: React.ReactNode }> = ({ export const ProjectProvider: React.FC<{ children: React.ReactNode }> = ({
children, children,
@@ -38,15 +41,15 @@ export const ProjectProvider: React.FC<{ children: React.ReactNode }> = ({
setMapWorkspace(ws); setMapWorkspace(ws);
setNetworkName(net); setNetworkName(net);
setMapExtent(extent); setMapExtent(extent);
localStorage.setItem("NEXT_PUBLIC_MAP_EXTENT", extent.join(",")); localStorage.setItem(MAP_EXTENT_STORAGE_KEY, extent.join(","));
// Reset extent cache // Reset extent cache
localStorage.removeItem(`${ws}_map_view`); localStorage.removeItem(`${ws}_map_view`);
setCurrentProject({ workspace: ws, networkName: net, extent: extent }); setCurrentProject({ workspace: ws, networkName: net, extent: extent });
setCurrentProjectId(resolvedProjectId); setCurrentProjectId(resolvedProjectId);
// Save to localStorage // Save to localStorage
localStorage.setItem("NEXT_PUBLIC_MAP_WORKSPACE", ws); localStorage.setItem(MAP_WORKSPACE_STORAGE_KEY, ws);
localStorage.setItem("NEXT_PUBLIC_NETWORK_NAME", net); localStorage.setItem(NETWORK_NAME_STORAGE_KEY, net);
setIsConfigured(true); setIsConfigured(true);
@@ -76,10 +79,7 @@ export const ProjectProvider: React.FC<{ children: React.ReactNode }> = ({
// Update workspace if different // Update workspace if different
if (data?.gs_workspace && data.gs_workspace !== ws) { if (data?.gs_workspace && data.gs_workspace !== ws) {
setMapWorkspace(data.gs_workspace); setMapWorkspace(data.gs_workspace);
localStorage.setItem( localStorage.setItem(MAP_WORKSPACE_STORAGE_KEY, data.gs_workspace);
"NEXT_PUBLIC_MAP_WORKSPACE",
data.gs_workspace,
);
setCurrentProject((prev) => ({ setCurrentProject((prev) => ({
...prev, ...prev,
workspace: data.gs_workspace, workspace: data.gs_workspace,
@@ -92,7 +92,7 @@ export const ProjectProvider: React.FC<{ children: React.ReactNode }> = ({
: null; : null;
if (bbox && bbox.length === 4) { if (bbox && bbox.length === 4) {
setMapExtent(bbox); setMapExtent(bbox);
localStorage.setItem("NEXT_PUBLIC_MAP_EXTENT", bbox.join(",")); localStorage.setItem(MAP_EXTENT_STORAGE_KEY, bbox.join(","));
localStorage.removeItem(`${ws}_map_view`); localStorage.removeItem(`${ws}_map_view`);
setCurrentProject((prev) => ({ ...prev, extent: bbox })); setCurrentProject((prev) => ({ ...prev, extent: bbox }));
} }
@@ -104,9 +104,9 @@ export const ProjectProvider: React.FC<{ children: React.ReactNode }> = ({
useEffect(() => { useEffect(() => {
// Check localStorage // Check localStorage
const savedWorkspace = localStorage.getItem("NEXT_PUBLIC_MAP_WORKSPACE"); const savedWorkspace = localStorage.getItem(MAP_WORKSPACE_STORAGE_KEY);
const savedNetwork = localStorage.getItem("NEXT_PUBLIC_NETWORK_NAME"); const savedNetwork = localStorage.getItem(NETWORK_NAME_STORAGE_KEY);
const savedExtent = localStorage.getItem("NEXT_PUBLIC_MAP_EXTENT"); const savedExtent = localStorage.getItem(MAP_EXTENT_STORAGE_KEY);
const savedProjectId = localStorage.getItem("active_project"); const savedProjectId = localStorage.getItem("active_project");
// If we have saved config, use it. // If we have saved config, use it.