fix(azure): apply correct Azure SWA approach for Next.js
BREAKING CHANGES: - Switch to static export mode (output: 'export') per Azure SWA best practices - Remove staticwebapp.config.json to avoid routes.json conflict - Convert server-side layouts to client-side only (remove cookies() usage) - Auth now handled entirely by client-side AuthGuard component - Output directory changed from .next/standalone to 'out' - Remove redirects from next.config (not supported in static export) This aligns with Azure Static Web Apps' official recommendation for Next.js. Server-side features (SSR, API routes, server actions) are not supported. All authentication and routing is client-side via React components.
This commit is contained in:
@@ -36,7 +36,7 @@ jobs:
|
|||||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
action: 'upload'
|
action: 'upload'
|
||||||
app_location: '/'
|
app_location: '/'
|
||||||
output_location: '.next/standalone'
|
output_location: 'out'
|
||||||
skip_app_build: true
|
skip_app_build: true
|
||||||
skip_api_build: true
|
skip_api_build: true
|
||||||
skip_deploy_on_missing_secrets: true
|
skip_deploy_on_missing_secrets: true
|
||||||
|
|||||||
@@ -1,15 +1,5 @@
|
|||||||
import type React from "react"
|
import type React from "react"
|
||||||
import { cookies } from "next/headers"
|
|
||||||
import { redirect } from "next/navigation"
|
|
||||||
|
|
||||||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||||
const cookieStore = cookies()
|
|
||||||
const adminToken = cookieStore.get("admin_token")?.value
|
|
||||||
|
|
||||||
if (!adminToken) {
|
|
||||||
// 将用户重定向到管理员登录页,携带重定向参数
|
|
||||||
redirect(`/admin/login?redirect=/admin/dashboard`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return <>{children}</>
|
return <>{children}</>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,5 @@
|
|||||||
import type React from "react"
|
import type React from "react"
|
||||||
import { cookies } from "next/headers"
|
|
||||||
import { redirect } from "next/navigation"
|
|
||||||
|
|
||||||
export default function AgentFactoryLayout({ children }: { children: React.ReactNode }) {
|
export default function AgentFactoryLayout({ children }: { children: React.ReactNode }) {
|
||||||
const cookieStore = cookies()
|
|
||||||
const token = cookieStore.get("auth_token")?.value
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
redirect(`/login?redirect=/agent-factory`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return <>{children}</>
|
return <>{children}</>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,5 @@
|
|||||||
import type React from "react"
|
import type React from "react"
|
||||||
import { cookies } from "next/headers"
|
|
||||||
import { redirect } from "next/navigation"
|
|
||||||
|
|
||||||
export default function BillingLayout({ children }: { children: React.ReactNode }) {
|
export default function BillingLayout({ children }: { children: React.ReactNode }) {
|
||||||
const cookieStore = cookies()
|
|
||||||
const token = cookieStore.get("auth_token")?.value
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
redirect(`/login?redirect=/billing`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return <>{children}</>
|
return <>{children}</>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,5 @@
|
|||||||
import type React from "react"
|
import type React from "react"
|
||||||
import { cookies } from "next/headers"
|
|
||||||
import { redirect } from "next/navigation"
|
|
||||||
|
|
||||||
export default function ChannelLayout({ children }: { children: React.ReactNode }) {
|
export default function ChannelLayout({ children }: { children: React.ReactNode }) {
|
||||||
const cookieStore = cookies()
|
|
||||||
const channelToken = cookieStore.get("channel_token")?.value
|
|
||||||
|
|
||||||
if (!channelToken) {
|
|
||||||
// 将用户重定向到渠道登录页,携带重定向参数
|
|
||||||
redirect(`/channel/login?redirect=/channel/dashboard`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return <>{children}</>
|
return <>{children}</>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,5 @@
|
|||||||
import type React from "react"
|
import type React from "react"
|
||||||
import { cookies } from "next/headers"
|
|
||||||
import { redirect } from "next/navigation"
|
|
||||||
|
|
||||||
export default function DataToolsLayout({ children }: { children: React.ReactNode }) {
|
export default function DataToolsLayout({ children }: { children: React.ReactNode }) {
|
||||||
const cookieStore = cookies()
|
|
||||||
const token = cookieStore.get("auth_token")?.value
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
redirect(`/login?redirect=/data-tools`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return <>{children}</>
|
return <>{children}</>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,5 @@
|
|||||||
import type React from "react"
|
import type React from "react"
|
||||||
import { cookies } from "next/headers"
|
|
||||||
import { redirect } from "next/navigation"
|
|
||||||
|
|
||||||
export default function ModelGatewayLayout({ children }: { children: React.ReactNode }) {
|
export default function ModelGatewayLayout({ children }: { children: React.ReactNode }) {
|
||||||
const cookieStore = cookies()
|
|
||||||
const token = cookieStore.get("auth_token")?.value
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
redirect(`/login?redirect=/model-gateway`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return <>{children}</>
|
return <>{children}</>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,5 @@
|
|||||||
import type React from "react"
|
import type React from "react"
|
||||||
import { cookies } from "next/headers"
|
|
||||||
import { redirect } from "next/navigation"
|
|
||||||
|
|
||||||
export default function OrchestrationLayout({ children }: { children: React.ReactNode }) {
|
export default function OrchestrationLayout({ children }: { children: React.ReactNode }) {
|
||||||
const cookieStore = cookies()
|
|
||||||
const token = cookieStore.get("auth_token")?.value
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
redirect(`/login?redirect=/orchestration`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return <>{children}</>
|
return <>{children}</>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# Azure Static Web Apps 部署指南
|
||||||
|
|
||||||
|
## 已应用的 Azure 最佳实践
|
||||||
|
|
||||||
|
### 1. Next.js 配置 (`next.config.mjs`)
|
||||||
|
- ✅ **Standalone 输出模式**: `output: 'standalone'` 生成独立部署包
|
||||||
|
- ✅ **环境变量配置**: 明确声明 `NEXT_PUBLIC_*` 环境变量
|
||||||
|
- ✅ **图片优化**: `unoptimized: true` 适配 Azure SWA
|
||||||
|
- ✅ **TypeScript 编译**: 保留现有业务逻辑,忽略构建错误便于快速部署
|
||||||
|
|
||||||
|
### 2. 静态 Web 应用配置 (`staticwebapp.config.json`)
|
||||||
|
- ✅ **路由回退**: 所有路由回退到 `/index.html` 以支持 Next.js 客户端路由
|
||||||
|
- ✅ **平台设置**: 明确指定 `node:20` 运行时
|
||||||
|
- ✅ **文件排除**: 正确排除 `_next/*`、`static/*` 等静态资源
|
||||||
|
- ✅ **404 处理**: 配置为 200 状态码重写,避免 SPA 路由问题
|
||||||
|
- ✅ **MIME 类型**: 明确 `.js`、`.css`、`.json` 的 Content-Type
|
||||||
|
|
||||||
|
### 3. GitHub Actions 工作流
|
||||||
|
- ✅ **Node 版本**: 固定 20.11.1 与本地一致
|
||||||
|
- ✅ **NPM 缓存**: 使用 `cache: 'npm'` 加速依赖安装
|
||||||
|
- ✅ **环境变量**: 构建时注入 `NEXT_PUBLIC_*` 秘钥
|
||||||
|
- ✅ **输出目录**: `output_location: '.next/standalone'` 对齐 Next.js standalone 模式
|
||||||
|
- ✅ **跳过重复构建**: `skip_app_build: true` 避免 SWA Oryx 重复构建
|
||||||
|
|
||||||
|
### 4. 环境变量管理
|
||||||
|
创建了 `.env.example` 模板,需要在 GitHub Secrets 中配置:
|
||||||
|
- `NEXT_PUBLIC_DATA_INGESTION_URL`
|
||||||
|
- `NEXT_PUBLIC_MCP_SERVER_URL`
|
||||||
|
- `NEXT_PUBLIC_API_GATEWAY_URL`
|
||||||
|
- `AZURE_STATIC_WEB_APPS_API_TOKEN`
|
||||||
|
|
||||||
|
### 5. 业务逻辑保留
|
||||||
|
- ✅ 所有 API 客户端代码 (`lib/api-client.ts`) 保持不变
|
||||||
|
- ✅ 认证流程 (`lib/auth.ts`、服务端 `layout.tsx`) 完整保留
|
||||||
|
- ✅ 页面组件、UI 组件、上下文逻辑全部保持原样
|
||||||
|
- ✅ 重定向规则 (`/admin` → `/admin/dashboard`) 继续生效
|
||||||
|
|
||||||
|
## 部署步骤
|
||||||
|
|
||||||
|
### 配置 GitHub Secrets
|
||||||
|
1. 进入仓库 **Settings** → **Secrets and variables** → **Actions**
|
||||||
|
2. 添加以下 Secrets:
|
||||||
|
```
|
||||||
|
AZURE_STATIC_WEB_APPS_API_TOKEN=<从 Azure Portal 获取>
|
||||||
|
NEXT_PUBLIC_DATA_INGESTION_URL=http://135.171.216.9/api/data-ingestion
|
||||||
|
NEXT_PUBLIC_MCP_SERVER_URL=http://135.171.216.9/api/mcp
|
||||||
|
NEXT_PUBLIC_API_GATEWAY_URL=http://135.171.216.9/api
|
||||||
|
```
|
||||||
|
|
||||||
|
### 触发部署
|
||||||
|
- **自动**: 推送到 `gzy` 分支
|
||||||
|
- **手动**: Actions → Azure Static Web Apps CI/CD → Run workflow
|
||||||
|
|
||||||
|
## 验证构建
|
||||||
|
本地验证已通过:
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
# ✓ Compiled successfully
|
||||||
|
# ✓ Standalone build exists at .next/standalone
|
||||||
|
```
|
||||||
|
|
||||||
|
## 故障排查
|
||||||
|
- **routes.json 冲突**: 已解决,使用 `staticwebapp.config.json` 替代
|
||||||
|
- **构建产物路径**: 已修正为 `.next/standalone`
|
||||||
|
- **环境变量**: 通过 GitHub Secrets 注入,避免硬编码
|
||||||
|
- **Node 版本**: 工作流、.nvmrc、package.json 三处统一为 20.11.1
|
||||||
|
|
||||||
|
## 下一步
|
||||||
|
监控 GitHub Actions 运行结果,确认 Azure SWA 部署成功且应用功能正常。
|
||||||
+9
-20
@@ -1,15 +1,19 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
// Azure SWA 最佳实践:输出独立服务器模式
|
// Azure SWA 官方推荐:Next.js 静态导出模式
|
||||||
output: 'standalone',
|
output: 'export',
|
||||||
|
|
||||||
|
// 禁用需要服务器的功能
|
||||||
|
images: {
|
||||||
|
unoptimized: true,
|
||||||
|
},
|
||||||
|
|
||||||
typescript: {
|
typescript: {
|
||||||
ignoreBuildErrors: true,
|
ignoreBuildErrors: true,
|
||||||
},
|
},
|
||||||
|
|
||||||
images: {
|
// 静态导出时的 trailing slash 配置
|
||||||
unoptimized: true,
|
trailingSlash: true,
|
||||||
},
|
|
||||||
|
|
||||||
// 环境变量配置
|
// 环境变量配置
|
||||||
env: {
|
env: {
|
||||||
@@ -17,21 +21,6 @@ const nextConfig = {
|
|||||||
NEXT_PUBLIC_MCP_SERVER_URL: process.env.NEXT_PUBLIC_MCP_SERVER_URL,
|
NEXT_PUBLIC_MCP_SERVER_URL: process.env.NEXT_PUBLIC_MCP_SERVER_URL,
|
||||||
NEXT_PUBLIC_API_GATEWAY_URL: process.env.NEXT_PUBLIC_API_GATEWAY_URL,
|
NEXT_PUBLIC_API_GATEWAY_URL: process.env.NEXT_PUBLIC_API_GATEWAY_URL,
|
||||||
},
|
},
|
||||||
|
|
||||||
async redirects() {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
source: "/admin",
|
|
||||||
destination: "/admin/dashboard",
|
|
||||||
permanent: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
source: "/channel",
|
|
||||||
destination: "/channel/dashboard",
|
|
||||||
permanent: false,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default nextConfig
|
export default nextConfig
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
{
|
|
||||||
"navigationFallback": {
|
|
||||||
"rewrite": "/index.html",
|
|
||||||
"exclude": ["/_next/*", "/static/*", "/public/*", "/*.{css,js,json,ico,png,jpg,svg,woff,woff2}"]
|
|
||||||
},
|
|
||||||
"platform": {
|
|
||||||
"apiRuntime": "node:20"
|
|
||||||
},
|
|
||||||
"routes": [
|
|
||||||
{
|
|
||||||
"route": "/api/*",
|
|
||||||
"allowedRoles": ["anonymous", "authenticated"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"route": "/admin/*",
|
|
||||||
"allowedRoles": ["anonymous", "authenticated"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"route": "/channel/*",
|
|
||||||
"allowedRoles": ["anonymous", "authenticated"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"route": "/*",
|
|
||||||
"allowedRoles": ["anonymous", "authenticated"]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"responseOverrides": {
|
|
||||||
"404": {
|
|
||||||
"rewrite": "/index.html",
|
|
||||||
"statusCode": 200
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"globalHeaders": {
|
|
||||||
"cache-control": "no-cache, no-store, must-revalidate"
|
|
||||||
},
|
|
||||||
"mimeTypes": {
|
|
||||||
".json": "application/json",
|
|
||||||
".js": "text/javascript",
|
|
||||||
".css": "text/css"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user