Finalize docs static view component (#253)
This commit is contained in:
parent
6e423d5f66
commit
7bd2ce088e
41
.github/workflows/static-export.yml
vendored
Normal file
41
.github/workflows/static-export.yml
vendored
Normal file
@ -0,0 +1,41 @@
|
||||
name: Static Export
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ui/homepage
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'yarn'
|
||||
cache-dependency-path: ui/homepage/yarn.lock
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --frozen-lockfile
|
||||
|
||||
- name: Run export scripts
|
||||
run: yarn prebuild
|
||||
|
||||
- name: Build static bundle
|
||||
run: yarn build:static
|
||||
|
||||
- name: Sync to S3
|
||||
run: aws s3 sync ./out s3://$S3_BUCKET --delete
|
||||
env:
|
||||
S3_BUCKET: ${{ secrets.STATIC_EXPORT_BUCKET }}
|
||||
|
||||
- name: Invalidate CloudFront cache
|
||||
run: aws cloudfront create-invalidation --distribution-id ${{ secrets.CLOUDFRONT_DISTRIBUTION_ID }} --paths "/*"
|
||||
@ -4,17 +4,102 @@ Guide for local development setup.
|
||||
|
||||
## Nginx 配置
|
||||
|
||||
在 `cn-homepage.svc.plus` 和 `artifact.svc.plus` 之间部署静态站点与下载服务时,可使用如下 Nginx 配置:
|
||||
Compose 环境同样支持动态代理和静态托管两种部署方式:
|
||||
|
||||
### 动态渲染(Node.js 代理)
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name www.svc.plus cn-homepage.svc.plus;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name www.svc.plus cn-homepage.svc.plus;
|
||||
|
||||
ssl_certificate /etc/ssl/svc.plus.pem;
|
||||
ssl_certificate_key /etc/ssl/svc.plus.rsa.key;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location = /api/askai {
|
||||
access_by_lua_block {
|
||||
local redis = require "resty.redis"
|
||||
local r = redis:new()
|
||||
r:set_timeout(200)
|
||||
local ok, err = r:connect("127.0.0.1", 6379)
|
||||
if not ok then
|
||||
ngx.log(ngx.ERR, "Redis connect error: ", err)
|
||||
return ngx.exit(500)
|
||||
end
|
||||
|
||||
local user = ngx.var.arg_user or ngx.var.remote_addr
|
||||
local today = os.date("%Y%m%d")
|
||||
local key = "limit:user:" .. user .. ":" .. today
|
||||
|
||||
local count, err = r:incr(key)
|
||||
if count == 1 then r:expire(key, 86400) end
|
||||
if count > 200 then
|
||||
ngx.status = 429
|
||||
ngx.header["Content-Type"] = "text/plain; charset=utf-8"
|
||||
ngx.say("Too Many Requests: daily limit reached")
|
||||
return ngx.exit(429)
|
||||
end
|
||||
}
|
||||
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location ^~ /_next/ {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
location /favicon.ico {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location ~ /\. {
|
||||
deny all;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 静态导出(无 Node 依赖)
|
||||
|
||||
```nginx
|
||||
# 1. HTTP 自动跳转到 HTTPS
|
||||
server {
|
||||
listen 80;
|
||||
server_name cn-homepage.svc.plus;
|
||||
return 301 https://cn-homepage.svc.plus$request_uri;
|
||||
}
|
||||
|
||||
# 2. HTTPS 静态站部署 svc.plus
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name cn-homepage.svc.plus;
|
||||
@ -24,62 +109,54 @@ server {
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
|
||||
# 3. 指向静态构建输出目录
|
||||
root /var/www/XControl/ui/homepage/out;
|
||||
index index.html;
|
||||
|
||||
# 4. 页面访问(含 SPA fallback)
|
||||
error_page 404 /404/index.html;
|
||||
error_page 500 502 503 504 /500/index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# 5. 静态资源缓存优化
|
||||
location ~* \.(?:ico|css|js|gif|jpe?g|png|woff2?)$ {
|
||||
expires 30d;
|
||||
access_log off;
|
||||
add_header Cache-Control "public";
|
||||
}
|
||||
location ~* \.(?:ico|css|js|gif|jpe?g|png|woff2?)$ {
|
||||
expires 30d;
|
||||
access_log off;
|
||||
add_header Cache-Control "public";
|
||||
}
|
||||
|
||||
# 6. 转发后端 API
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:8080/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:8080/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
# 7. 隐藏 . 文件(如 .DS_Store)
|
||||
location ~ /\. {
|
||||
deny all;
|
||||
}
|
||||
location ~ /\. {
|
||||
deny all;
|
||||
}
|
||||
}
|
||||
|
||||
# 7. HTTPS 独立下载服务 artifact.svc.plus
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name artifact.svc.plus;
|
||||
|
||||
# SSL 配置
|
||||
ssl_certificate /etc/ssl/svc.plus.pem;
|
||||
ssl_certificate_key /etc/ssl/svc.plus.rsa.key;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
|
||||
# 映射统一目录
|
||||
root /data/update-server;
|
||||
index index.html;
|
||||
|
||||
# 显示目录索引,方便预览或手动下载
|
||||
autoindex on;
|
||||
autoindex_exact_size off;
|
||||
autoindex_localtime on;
|
||||
|
||||
# 允许所有子路径访问(包括你预留的)
|
||||
location / {
|
||||
add_header Accept-Ranges bytes;
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
|
||||
# 静态构建产物缓存优化
|
||||
location ~* \.(dmg|zip|tar\.gz|deb|rpm|exe|pkg|AppImage|apk|ipa)$ {
|
||||
expires 7d;
|
||||
access_log off;
|
||||
@ -88,7 +165,6 @@ server {
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# 隐藏 . 文件
|
||||
location ~ /\. {
|
||||
deny all;
|
||||
}
|
||||
|
||||
@ -4,17 +4,106 @@ How to deploy XControl via Helm.
|
||||
|
||||
## Nginx 配置
|
||||
|
||||
在使用 Helm 部署后,若需要通过 `cn-homepage.svc.plus` 和 `artifact.svc.plus` 提供静态页面与下载服务,可参考以下 Nginx 配置:
|
||||
Helm 部署既可以运行 Next.js Node 进程,也可以直接托管静态导出的页面。以下分别给出两种配置示例,便于在不同集群环境复用。
|
||||
|
||||
### 动态渲染(Node.js 代理)
|
||||
|
||||
如果希望在 Pod 内运行 Next.js 服务器,请将以下内容写入 `/usr/local/openresty/nginx/conf/sites-available/cn-homepage.svc.plus.conf`:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name www.svc.plus cn-homepage.svc.plus;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name www.svc.plus cn-homepage.svc.plus;
|
||||
|
||||
ssl_certificate /etc/ssl/svc.plus.pem;
|
||||
ssl_certificate_key /etc/ssl/svc.plus.rsa.key;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location = /api/askai {
|
||||
access_by_lua_block {
|
||||
local redis = require "resty.redis"
|
||||
local r = redis:new()
|
||||
r:set_timeout(200)
|
||||
local ok, err = r:connect("127.0.0.1", 6379)
|
||||
if not ok then
|
||||
ngx.log(ngx.ERR, "Redis connect error: ", err)
|
||||
return ngx.exit(500)
|
||||
end
|
||||
|
||||
local user = ngx.var.arg_user or ngx.var.remote_addr
|
||||
local today = os.date("%Y%m%d")
|
||||
local key = "limit:user:" .. user .. ":" .. today
|
||||
|
||||
local count, err = r:incr(key)
|
||||
if count == 1 then r:expire(key, 86400) end
|
||||
if count > 200 then
|
||||
ngx.status = 429
|
||||
ngx.header["Content-Type"] = "text/plain; charset=utf-8"
|
||||
ngx.say("Too Many Requests: daily limit reached")
|
||||
return ngx.exit(429)
|
||||
end
|
||||
}
|
||||
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location ^~ /_next/ {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
location /favicon.ico {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location ~ /\. {
|
||||
deny all;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 静态导出(无 Node 依赖)
|
||||
|
||||
完成 `yarn build:static` 后,可直接托管 `ui/homepage/out` 目录:
|
||||
|
||||
```nginx
|
||||
# 1. HTTP 自动跳转到 HTTPS
|
||||
server {
|
||||
listen 80;
|
||||
server_name cn-homepage.svc.plus;
|
||||
return 301 https://cn-homepage.svc.plus$request_uri;
|
||||
}
|
||||
|
||||
# 2. HTTPS 静态站部署 svc.plus
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name cn-homepage.svc.plus;
|
||||
@ -24,62 +113,54 @@ server {
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
|
||||
# 3. 指向静态构建输出目录
|
||||
root /var/www/XControl/ui/homepage/out;
|
||||
index index.html;
|
||||
|
||||
# 4. 页面访问(含 SPA fallback)
|
||||
error_page 404 /404/index.html;
|
||||
error_page 500 502 503 504 /500/index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# 5. 静态资源缓存优化
|
||||
location ~* \.(?:ico|css|js|gif|jpe?g|png|woff2?)$ {
|
||||
expires 30d;
|
||||
access_log off;
|
||||
add_header Cache-Control "public";
|
||||
}
|
||||
location ~* \.(?:ico|css|js|gif|jpe?g|png|woff2?)$ {
|
||||
expires 30d;
|
||||
access_log off;
|
||||
add_header Cache-Control "public";
|
||||
}
|
||||
|
||||
# 6. 转发后端 API
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:8080/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:8080/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
# 7. 隐藏 . 文件(如 .DS_Store)
|
||||
location ~ /\. {
|
||||
deny all;
|
||||
}
|
||||
location ~ /\. {
|
||||
deny all;
|
||||
}
|
||||
}
|
||||
|
||||
# 7. HTTPS 独立下载服务 artifact.svc.plus
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name artifact.svc.plus;
|
||||
|
||||
# SSL 配置
|
||||
ssl_certificate /etc/ssl/svc.plus.pem;
|
||||
ssl_certificate_key /etc/ssl/svc.plus.rsa.key;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
|
||||
# 映射统一目录
|
||||
root /data/update-server;
|
||||
index index.html;
|
||||
|
||||
# 显示目录索引,方便预览或手动下载
|
||||
autoindex on;
|
||||
autoindex_exact_size off;
|
||||
autoindex_localtime on;
|
||||
|
||||
# 允许所有子路径访问(包括你预留的)
|
||||
location / {
|
||||
add_header Accept-Ranges bytes;
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
|
||||
# 静态构建产物缓存优化
|
||||
location ~* \.(dmg|zip|tar\.gz|deb|rpm|exe|pkg|AppImage|apk|ipa)$ {
|
||||
expires 7d;
|
||||
access_log off;
|
||||
@ -88,7 +169,6 @@ server {
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# 隐藏 . 文件
|
||||
location ~ /\. {
|
||||
deny all;
|
||||
}
|
||||
|
||||
@ -13,17 +13,102 @@ helm K8s 生产部署(节点 + PG + 控制器
|
||||
|
||||
## Nginx 配置
|
||||
|
||||
以下示例展示了在 `cn-homepage.svc.plus` 上提供静态首页以及在 `artifact.svc.plus` 上提供下载服务的参考 Nginx 配置:
|
||||
Systemd 部署通常只有一台主机,可按需选择动态或静态策略:
|
||||
|
||||
### 动态渲染(Node.js 代理)
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name www.svc.plus cn-homepage.svc.plus;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name www.svc.plus cn-homepage.svc.plus;
|
||||
|
||||
ssl_certificate /etc/ssl/svc.plus.pem;
|
||||
ssl_certificate_key /etc/ssl/svc.plus.rsa.key;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location = /api/askai {
|
||||
access_by_lua_block {
|
||||
local redis = require "resty.redis"
|
||||
local r = redis:new()
|
||||
r:set_timeout(200)
|
||||
local ok, err = r:connect("127.0.0.1", 6379)
|
||||
if not ok then
|
||||
ngx.log(ngx.ERR, "Redis connect error: ", err)
|
||||
return ngx.exit(500)
|
||||
end
|
||||
|
||||
local user = ngx.var.arg_user or ngx.var.remote_addr
|
||||
local today = os.date("%Y%m%d")
|
||||
local key = "limit:user:" .. user .. ":" .. today
|
||||
|
||||
local count, err = r:incr(key)
|
||||
if count == 1 then r:expire(key, 86400) end
|
||||
if count > 200 then
|
||||
ngx.status = 429
|
||||
ngx.header["Content-Type"] = "text/plain; charset=utf-8"
|
||||
ngx.say("Too Many Requests: daily limit reached")
|
||||
return ngx.exit(429)
|
||||
end
|
||||
}
|
||||
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location ^~ /_next/ {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
location /favicon.ico {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location ~ /\. {
|
||||
deny all;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 静态导出(无 Node 依赖)
|
||||
|
||||
```nginx
|
||||
# 1. HTTP 自动跳转到 HTTPS
|
||||
server {
|
||||
listen 80;
|
||||
server_name cn-homepage.svc.plus;
|
||||
return 301 https://cn-homepage.svc.plus$request_uri;
|
||||
}
|
||||
|
||||
# 2. HTTPS 静态站部署 svc.plus
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name cn-homepage.svc.plus;
|
||||
@ -33,62 +118,54 @@ server {
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
|
||||
# 3. 指向静态构建输出目录
|
||||
root /var/www/XControl/ui/homepage/out;
|
||||
index index.html;
|
||||
|
||||
# 4. 页面访问(含 SPA fallback)
|
||||
error_page 404 /404/index.html;
|
||||
error_page 500 502 503 504 /500/index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# 5. 静态资源缓存优化
|
||||
location ~* \.(?:ico|css|js|gif|jpe?g|png|woff2?)$ {
|
||||
expires 30d;
|
||||
access_log off;
|
||||
add_header Cache-Control "public";
|
||||
}
|
||||
|
||||
# 6. 转发后端 API
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:8080/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
# 7. 隐藏 . 文件(如 .DS_Store)
|
||||
location ~ /\. {
|
||||
deny all;
|
||||
}
|
||||
}
|
||||
|
||||
# 7. HTTPS 独立下载服务 artifact.svc.plus
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name artifact.svc.plus;
|
||||
|
||||
# SSL 配置
|
||||
ssl_certificate /etc/ssl/svc.plus.pem;
|
||||
ssl_certificate_key /etc/ssl/svc.plus.rsa.key;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
|
||||
# 映射统一目录
|
||||
root /data/update-server;
|
||||
index index.html;
|
||||
|
||||
# 显示目录索引,方便预览或手动下载
|
||||
autoindex on;
|
||||
autoindex_exact_size off;
|
||||
autoindex_localtime on;
|
||||
|
||||
# 允许所有子路径访问(包括你预留的)
|
||||
location / {
|
||||
add_header Accept-Ranges bytes;
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
|
||||
# 静态构建产物缓存优化
|
||||
location ~* \.(dmg|zip|tar\.gz|deb|rpm|exe|pkg|AppImage|apk|ipa)$ {
|
||||
expires 7d;
|
||||
access_log off;
|
||||
@ -97,7 +174,6 @@ server {
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# 隐藏 . 文件
|
||||
location ~ /\. {
|
||||
deny all;
|
||||
}
|
||||
|
||||
21
docs/ui/static-export/cloud_iac.md
Normal file
21
docs/ui/static-export/cloud_iac.md
Normal file
@ -0,0 +1,21 @@
|
||||
# Cloud IaC 静态化设计
|
||||
|
||||
## 页面范围
|
||||
- `/cloud_iac`
|
||||
- `/cloud_iac/[provider]`
|
||||
- `/cloud_iac/[provider]/[service]`
|
||||
|
||||
## 数据来源
|
||||
- `ui/homepage/lib/iac/catalog.ts` 提供所有 Provider 与服务分类。
|
||||
- `public/_build/cloud_iac_index.json` 在构建前由 `scripts/export-slugs.ts` 导出 Provider / Service 组合。
|
||||
|
||||
## 静态导出策略
|
||||
1. 在三个页面的模块顶端声明 `export const dynamic = 'error'`,禁止运行时回退到动态渲染。
|
||||
2. 在 `/cloud_iac/[provider]` 与 `/cloud_iac/[provider]/[service]` 中实现 `generateStaticParams()`,从 `cloud_iac_index.json` 读取静态参数并导出 `dynamicParams = false`。
|
||||
3. 使用 `cloud_iac_index.json` 与 `catalog.ts` 的静态常量渲染页面内容,避免请求数据库或外部 API。
|
||||
4. 将任何 `generateMetadata` 的逻辑提前到模块作用域,生成一次性常量 `metadata`。
|
||||
|
||||
## 子任务拆分
|
||||
- **数据导出**:在 `scripts/export-slugs.ts` 中读取 `catalog.ts`,生成 Provider 和 Service 对应的 JSON 列表。
|
||||
- **页面改造**:为三个页面补充 `dynamic = 'error'` 与静态参数生成逻辑,更新到使用 JSON 数据。
|
||||
- **校验**:在 `scripts/check-build.js` 中增加对 `cloud_iac_index.json` 的完整性检查,确保 Provider 与 Service 数量均大于 0。
|
||||
17
docs/ui/static-export/demo.md
Normal file
17
docs/ui/static-export/demo.md
Normal file
@ -0,0 +1,17 @@
|
||||
# Demo 页面静态化设计
|
||||
|
||||
## 页面范围
|
||||
- `/demo`
|
||||
|
||||
## 数据来源
|
||||
- `app/demo/feature.config.ts` 控制功能开关。
|
||||
- `public/_build/cloud_iac_index.json` 不直接使用,但构建流程需要确保检查通过。
|
||||
|
||||
## 静态导出策略
|
||||
1. 在 `app/demo/page.tsx` 开头声明 `export const dynamic = 'error'`,确保页面只能在构建期生成。
|
||||
2. 页面仍通过 `feature.config` 判断开关,关闭时在构建期直接产出 404。
|
||||
3. 保持现有 `DemoContent` 客户端组件逻辑不变。
|
||||
|
||||
## 子任务拆分
|
||||
- **页面改造**:添加 `dynamic = 'error'` 常量。
|
||||
- **构建校验**:在 `scripts/check-build.js` 中确认 Demo 所需的构建产物存在,避免缺失时继续构建。
|
||||
22
docs/ui/static-export/docs.md
Normal file
22
docs/ui/static-export/docs.md
Normal file
@ -0,0 +1,22 @@
|
||||
# Docs 页面静态化设计
|
||||
|
||||
## 页面范围
|
||||
- `/docs`
|
||||
- `/docs/[name]`
|
||||
|
||||
## 数据来源
|
||||
- Markdown 内容位于 `ui/homepage/content/`,由 `scripts/scan-md.ts` 解析生成 `public/_build/docs_index.json`。
|
||||
- 下载路径映射由 `public/_build/docs_paths.json` 提供。
|
||||
|
||||
## 静态导出策略
|
||||
1. 将 `app/docs/page.tsx` 与 `app/docs/[name]/page.tsx` 顶部声明 `export const dynamic = 'error'`。
|
||||
2. 使用 `generateStaticParams()` 从 `docs_index.json` 读取所有 `slug`,并导出 `dynamicParams = false`。
|
||||
3. 在 `app/docs/resources.ts` 中移除运行时 `fetch`,改为读取静态 JSON 常量,确保构建期即可拿到完整数据。
|
||||
4. 将原有 `generateMetadata` 动态逻辑转换为静态 `metadata` 常量。
|
||||
5. 所有时间展示改用 `app/components/ClientTime.tsx`,并在父级标记 `suppressHydrationWarning`。
|
||||
|
||||
## 子任务拆分
|
||||
- **数据脚本**:编写 `scripts/scan-md.ts` 解析 Markdown 并输出 `docs_index.json`、更新 `docs_paths.json`。
|
||||
- **页面更新**:改造两个页面以消费静态 JSON,同时添加静态参数逻辑。
|
||||
- **组件调整**:把 `formatDate` 等服务端时间处理迁移至客户端组件。
|
||||
- **构建校验**:在 `scripts/check-build.js` 中验证 `docs_index.json` 至少包含一个条目,且字段完整。
|
||||
21
docs/ui/static-export/download.md
Normal file
21
docs/ui/static-export/download.md
Normal file
@ -0,0 +1,21 @@
|
||||
# Download 页面静态化设计
|
||||
|
||||
## 页面范围
|
||||
- `/download`
|
||||
- `/download/[...segments]`
|
||||
|
||||
## 数据来源
|
||||
- `public/dl-index/all.json` 与 `manifest.json` 描述下载目录。
|
||||
- `public/_build/docs_paths.json`(由 `scripts/export-slugs.ts` 写入)提供所有需要静态生成的路径。
|
||||
|
||||
## 静态导出策略
|
||||
1. 在两个页面模块顶端添加 `export const dynamic = 'error'`,禁止运行时回退。
|
||||
2. `/download/[...segments]` 使用 `generateStaticParams()` 从 `docs_paths.json` 读取路径列表并拆分为数组,导出 `dynamicParams = false`。
|
||||
3. 在服务端组件内不直接调用 `new Date()`,改用字符串比较或 `Date.parse`;时间展示统一下沉到 `ClientTime` 客户端组件。
|
||||
4. 404 分支保持不变,依旧在构建期根据静态数据输出。
|
||||
|
||||
## 子任务拆分
|
||||
- **数据脚本**:在 `scripts/export-slugs.ts` 中收集下载目录路径并输出 `docs_paths.json`。
|
||||
- **页面改造**:更新 `generateStaticParams()`、添加 `dynamicParams = false` 和时间处理逻辑。
|
||||
- **组件调整**:在需要展示更新时间的位置引入 `ClientTime` 并加上 `suppressHydrationWarning`。
|
||||
- **校验**:`scripts/check-build.js` 校验 `docs_paths.json` 非空且至少包含一级路径。
|
||||
16
docs/ui/static-export/login.md
Normal file
16
docs/ui/static-export/login.md
Normal file
@ -0,0 +1,16 @@
|
||||
# Login 页面静态化设计
|
||||
|
||||
## 页面范围
|
||||
- `/login`
|
||||
|
||||
## 数据来源
|
||||
- `app/login/feature.config.ts` 控制是否启用登录重定向。
|
||||
|
||||
## 静态导出策略
|
||||
1. 在页面顶部加入 `export const dynamic = 'error'`,强制构建期生成。
|
||||
2. 保持 `redirect('/panel/ldp')` 行为,构建时生成对 `/panel/ldp` 的静态跳转。
|
||||
3. 若功能开关关闭,构建时产出 404。
|
||||
|
||||
## 子任务拆分
|
||||
- **页面改造**:添加 `dynamic = 'error'`。
|
||||
- **校验**:在 `scripts/check-build.js` 中验证登录功能开关开启时需要的静态页面全部存在,确保部署时不会缺失。
|
||||
22
docs/ui/static-export/panel.md
Normal file
22
docs/ui/static-export/panel.md
Normal file
@ -0,0 +1,22 @@
|
||||
# Panel 区域静态化设计
|
||||
|
||||
## 页面范围
|
||||
- `/panel`
|
||||
- `/panel/account`
|
||||
- `/panel/agent`
|
||||
- `/panel/xray`
|
||||
- `/panel/subscription`
|
||||
- `/panel/api`
|
||||
- `/panel/ldp`
|
||||
|
||||
## 数据来源
|
||||
- 静态展示内容,依赖组件位于 `app/panel/components/`。
|
||||
|
||||
## 静态导出策略
|
||||
1. 在所有页面文件开头增加 `export const dynamic = 'error'`,替换现有 `force-static` 配置。
|
||||
2. 所有文案均为静态字符串,无需额外数据源。
|
||||
3. 保留现有的卡片组件布局,确保与动态版本一致。
|
||||
|
||||
## 子任务拆分
|
||||
- **页面改造**:为 7 个页面文件添加/替换 `dynamic = 'error'`。
|
||||
- **脚本校验**:`scripts/check-build.js` 校验输出目录中存在 `/panel` 相关静态文件。
|
||||
15
docs/ui/static-export/register.md
Normal file
15
docs/ui/static-export/register.md
Normal file
@ -0,0 +1,15 @@
|
||||
# Register 页面静态化设计
|
||||
|
||||
## 页面范围
|
||||
- `/register`
|
||||
|
||||
## 数据来源
|
||||
- `app/register/feature.config.ts` 用于控制是否开放注册入口。
|
||||
|
||||
## 静态导出策略
|
||||
1. 在页面顶部添加 `export const dynamic = 'error'`。
|
||||
2. 页面内容为静态卡片,保持 JSX 不变;关闭开关时在构建期直接产出 404。
|
||||
|
||||
## 子任务拆分
|
||||
- **页面改造**:新增 `dynamic = 'error'`。
|
||||
- **校验**:`scripts/check-build.js` 检测注册页面在开关开启时是否生成。
|
||||
54
scripts/check-build.js
Normal file
54
scripts/check-build.js
Normal file
@ -0,0 +1,54 @@
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const HOMEPAGE_ROOT = path.resolve(__dirname, '..', 'ui', 'homepage')
|
||||
|
||||
function readJson(relativePath) {
|
||||
const fullPath = path.join(HOMEPAGE_ROOT, 'public', '_build', relativePath)
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
throw new Error(`Missing build artifact: ${relativePath}`)
|
||||
}
|
||||
const raw = fs.readFileSync(fullPath, 'utf8')
|
||||
return JSON.parse(raw)
|
||||
}
|
||||
|
||||
function ensureArray(value, name) {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new Error(`${name} must be an array`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function main() {
|
||||
const docsIndex = ensureArray(readJson('docs_index.json'), 'docs_index.json')
|
||||
if (docsIndex.length === 0) {
|
||||
throw new Error('docs_index.json is empty')
|
||||
}
|
||||
if (docsIndex.some((doc) => typeof doc.slug !== 'string' || !doc.slug)) {
|
||||
throw new Error('docs_index.json contains entries without slug')
|
||||
}
|
||||
|
||||
const cloudIac = readJson('cloud_iac_index.json')
|
||||
const providers = ensureArray(cloudIac.providers, 'cloud_iac_index.providers')
|
||||
const services = ensureArray(cloudIac.services, 'cloud_iac_index.services')
|
||||
if (providers.length === 0) {
|
||||
throw new Error('cloud_iac_index.providers is empty')
|
||||
}
|
||||
if (services.length === 0) {
|
||||
throw new Error('cloud_iac_index.services is empty')
|
||||
}
|
||||
|
||||
const docsPaths = ensureArray(readJson('docs_paths.json'), 'docs_paths.json')
|
||||
if (docsPaths.length === 0) {
|
||||
throw new Error('docs_paths.json is empty')
|
||||
}
|
||||
|
||||
console.log('[check-build] All build artifacts look good.')
|
||||
}
|
||||
|
||||
try {
|
||||
main()
|
||||
} catch (error) {
|
||||
console.error('[check-build] validation failed:', error.message)
|
||||
process.exit(1)
|
||||
}
|
||||
92
scripts/export-slugs.ts
Normal file
92
scripts/export-slugs.ts
Normal file
@ -0,0 +1,92 @@
|
||||
import fs from 'fs/promises'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
import { CATALOG, PROVIDERS } from '../ui/homepage/lib/iac/catalog'
|
||||
import type { ProviderKey } from '../ui/homepage/lib/iac/types'
|
||||
import type { DirListing } from '../ui/homepage/types/download'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const PROJECT_ROOT = path.resolve(__dirname, '..')
|
||||
const HOMEPAGE_ROOT = path.join(PROJECT_ROOT, 'ui', 'homepage')
|
||||
const OUTPUT_DIR = path.join(HOMEPAGE_ROOT, 'public', '_build')
|
||||
|
||||
function unique<T>(items: Iterable<T>): T[] {
|
||||
return Array.from(new Set(items))
|
||||
}
|
||||
|
||||
function buildCloudIacIndex() {
|
||||
const providers = PROVIDERS.map((provider) => ({ key: provider.key, label: provider.label }))
|
||||
|
||||
const services: { provider: ProviderKey; service: string; category: string }[] = []
|
||||
for (const category of CATALOG) {
|
||||
if (!category.iac) continue
|
||||
for (const [provider, integration] of Object.entries(category.iac)) {
|
||||
if (!integration || typeof integration.detailSlug !== 'string') continue
|
||||
services.push({
|
||||
provider: provider as ProviderKey,
|
||||
service: integration.detailSlug,
|
||||
category: category.key,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
services.sort((a, b) => {
|
||||
if (a.provider === b.provider) return a.service.localeCompare(b.service)
|
||||
return a.provider.localeCompare(b.provider)
|
||||
})
|
||||
|
||||
return { providers, services }
|
||||
}
|
||||
|
||||
async function loadDownloadManifest(): Promise<DirListing[]> {
|
||||
const manifestPath = path.join(HOMEPAGE_ROOT, 'public', 'dl-index', 'all.json')
|
||||
try {
|
||||
const raw = await fs.readFile(manifestPath, 'utf8')
|
||||
const parsed = JSON.parse(raw) as DirListing[]
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed
|
||||
}
|
||||
return []
|
||||
} catch (error) {
|
||||
console.warn('[export-slugs] Unable to read download manifest:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function extractDownloadPaths(listings: DirListing[]): string[] {
|
||||
const paths: string[] = []
|
||||
for (const listing of listings) {
|
||||
if (!listing || typeof listing.path !== 'string') continue
|
||||
const trimmed = listing.path.replace(/\/+$/g, '')
|
||||
if (trimmed.length > 0) {
|
||||
paths.push(trimmed)
|
||||
}
|
||||
}
|
||||
return unique(paths).sort()
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await fs.mkdir(OUTPUT_DIR, { recursive: true })
|
||||
|
||||
const cloudIacIndex = buildCloudIacIndex()
|
||||
const downloadListings = await loadDownloadManifest()
|
||||
const downloadPaths = extractDownloadPaths(downloadListings)
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(OUTPUT_DIR, 'cloud_iac_index.json'),
|
||||
JSON.stringify(cloudIacIndex, null, 2),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(OUTPUT_DIR, 'docs_paths.json'),
|
||||
JSON.stringify(downloadPaths, null, 2),
|
||||
'utf8',
|
||||
)
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('[export-slugs] failed:', error)
|
||||
process.exit(1)
|
||||
})
|
||||
@ -39,6 +39,5 @@ async function main() {
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
console.warn('[fetch-dl-index] skipped due to error:', err)
|
||||
})
|
||||
|
||||
82
scripts/scan-md.ts
Normal file
82
scripts/scan-md.ts
Normal file
@ -0,0 +1,82 @@
|
||||
import fs from 'fs/promises'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const PROJECT_ROOT = path.resolve(__dirname, '..')
|
||||
const HOMEPAGE_ROOT = path.join(PROJECT_ROOT, 'ui', 'homepage')
|
||||
|
||||
const CONTENT_DIR = path.join(HOMEPAGE_ROOT, 'content')
|
||||
const OUTPUT_PATH = path.join(HOMEPAGE_ROOT, 'public', '_build', 'docs_index.json')
|
||||
|
||||
type DocEntry = {
|
||||
slug: string
|
||||
title: string
|
||||
description: string
|
||||
updatedAt: string
|
||||
pathSegments: string[]
|
||||
}
|
||||
|
||||
function extractTitle(lines: string[], fallback: string): string {
|
||||
const heading = lines.find((line) => /^#\s+/.test(line))
|
||||
if (!heading) return fallback
|
||||
return heading.replace(/^#\s+/, '').trim() || fallback
|
||||
}
|
||||
|
||||
function extractDescription(lines: string[], title: string): string {
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed || /^#\s+/.test(trimmed)) {
|
||||
continue
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
return `${title}.`
|
||||
}
|
||||
|
||||
async function readMarkdownFile(file: string): Promise<DocEntry | null> {
|
||||
if (!file.endsWith('.md')) return null
|
||||
const slug = file.replace(/\.md$/, '')
|
||||
const filePath = path.join(CONTENT_DIR, file)
|
||||
|
||||
const [source, stats] = await Promise.all([fs.readFile(filePath, 'utf8'), fs.stat(filePath)])
|
||||
const lines = source.split(/\r?\n/)
|
||||
const title = extractTitle(lines, slug)
|
||||
const description = extractDescription(lines, title)
|
||||
|
||||
return {
|
||||
slug,
|
||||
title,
|
||||
description,
|
||||
updatedAt: stats.mtime.toISOString(),
|
||||
pathSegments: slug.split('/').filter(Boolean),
|
||||
}
|
||||
}
|
||||
|
||||
async function collectDocs(): Promise<DocEntry[]> {
|
||||
try {
|
||||
const files = await fs.readdir(CONTENT_DIR)
|
||||
const entries: DocEntry[] = []
|
||||
for (const file of files) {
|
||||
const entry = await readMarkdownFile(file)
|
||||
if (entry) {
|
||||
entries.push(entry)
|
||||
}
|
||||
}
|
||||
return entries.sort((a, b) => a.slug.localeCompare(b.slug))
|
||||
} catch (error) {
|
||||
console.warn('[scan-md] Unable to scan markdown directory:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await fs.mkdir(path.dirname(OUTPUT_PATH), { recursive: true })
|
||||
const docs = await collectDocs()
|
||||
await fs.writeFile(OUTPUT_PATH, JSON.stringify(docs, null, 2), 'utf8')
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('[scan-md] failed:', error)
|
||||
process.exit(1)
|
||||
})
|
||||
1
ui/homepage/.yarnrc.yml
Normal file
1
ui/homepage/.yarnrc.yml
Normal file
@ -0,0 +1 @@
|
||||
nodeLinker: node-modules
|
||||
22
ui/homepage/app/404/page.tsx
Normal file
22
ui/homepage/app/404/page.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
export const dynamic = 'error'
|
||||
|
||||
import Link from 'next/link'
|
||||
|
||||
export default function NotFoundPage() {
|
||||
return (
|
||||
<main className="flex min-h-screen flex-col items-center justify-center bg-white px-4 py-24 text-center">
|
||||
<p className="text-sm font-semibold uppercase tracking-wide text-purple-600">404</p>
|
||||
<h1 className="mt-4 text-4xl font-bold text-gray-900">Page not found</h1>
|
||||
<p className="mt-3 max-w-md text-sm text-gray-600">
|
||||
The page you were looking for could not be generated during the static export. Please return to the homepage and try a
|
||||
different link.
|
||||
</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="mt-6 inline-flex items-center rounded-full bg-purple-600 px-5 py-2 text-sm font-semibold text-white shadow hover:bg-purple-700"
|
||||
>
|
||||
Back to homepage
|
||||
</Link>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@ -1,3 +1,5 @@
|
||||
export const dynamic = 'error'
|
||||
|
||||
import type { Metadata } from 'next'
|
||||
import { notFound } from 'next/navigation'
|
||||
|
||||
@ -6,33 +8,34 @@ import { CATALOG, PROVIDERS } from '@lib/iac/catalog'
|
||||
import type { CatalogItem, ProviderKey } from '@lib/iac/types'
|
||||
|
||||
import feature from '../../feature.config'
|
||||
import cloudIacIndex from '../../../../public/_build/cloud_iac_index.json'
|
||||
|
||||
type PageParams = {
|
||||
provider: string
|
||||
service: string
|
||||
}
|
||||
|
||||
type CloudIacIndex = {
|
||||
providers: { key: ProviderKey; label: string }[]
|
||||
services: { provider: ProviderKey; service: string }[]
|
||||
}
|
||||
|
||||
const CLOUD_IAC_INDEX = cloudIacIndex as CloudIacIndex
|
||||
|
||||
const PROVIDER_MAP = new Map(PROVIDERS.map((provider) => [provider.key, provider.label] as const))
|
||||
|
||||
function findCategoryBySlug(provider: ProviderKey, slug: string): CatalogItem | undefined {
|
||||
return CATALOG.find((item) => item.iac?.[provider]?.detailSlug === slug)
|
||||
}
|
||||
|
||||
export function generateMetadata({ params }: { params: PageParams }): Metadata {
|
||||
const providerKey = params.provider as ProviderKey
|
||||
const providerLabel = PROVIDER_MAP.get(providerKey)
|
||||
const category = providerLabel ? findCategoryBySlug(providerKey, params.service) : undefined
|
||||
if (!providerLabel || !category) {
|
||||
return {
|
||||
title: 'Cloud IaC Catalog',
|
||||
}
|
||||
}
|
||||
export function generateStaticParams() {
|
||||
return CLOUD_IAC_INDEX.services.map((item) => ({ provider: item.provider, service: item.service }))
|
||||
}
|
||||
|
||||
const productName = category.products[providerKey] ?? category.title
|
||||
return {
|
||||
title: `${providerLabel} · ${productName} · Cloud IaC`,
|
||||
description: `${providerLabel} 的 ${productName} IaC 详情页,提供 GitOps 配置、资源预览与成本估算。`,
|
||||
}
|
||||
export const dynamicParams = false
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Cloud IaC Catalog',
|
||||
}
|
||||
|
||||
export default function CloudIacServicePage({ params }: { params: PageParams }) {
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
export const dynamic = 'error'
|
||||
|
||||
import type { Metadata } from 'next'
|
||||
import { notFound } from 'next/navigation'
|
||||
|
||||
@ -6,26 +8,28 @@ import { CATALOG, PROVIDERS } from '@lib/iac/catalog'
|
||||
import type { ProviderKey } from '@lib/iac/types'
|
||||
|
||||
import feature from '../feature.config'
|
||||
import cloudIacIndex from '../../../public/_build/cloud_iac_index.json'
|
||||
|
||||
type PageParams = {
|
||||
provider: string
|
||||
}
|
||||
|
||||
type CloudIacIndex = {
|
||||
providers: { key: ProviderKey; label: string }[]
|
||||
}
|
||||
|
||||
const CLOUD_IAC_INDEX = cloudIacIndex as CloudIacIndex
|
||||
|
||||
const PROVIDER_MAP = new Map(PROVIDERS.map((provider) => [provider.key, provider.label] as const))
|
||||
|
||||
export function generateMetadata({ params }: { params: PageParams }): Metadata {
|
||||
const providerKey = params.provider as ProviderKey
|
||||
const providerLabel = PROVIDER_MAP.get(providerKey)
|
||||
if (!providerLabel) {
|
||||
return {
|
||||
title: 'Cloud IaC Catalog',
|
||||
}
|
||||
}
|
||||
export function generateStaticParams() {
|
||||
return CLOUD_IAC_INDEX.providers.map((provider) => ({ provider: provider.key }))
|
||||
}
|
||||
|
||||
return {
|
||||
title: `${providerLabel} · Cloud IaC Catalog`,
|
||||
description: `${providerLabel} 核心云服务的 IaC 编排目录,涵盖计算、网络、存储、数据库等常用能力。`,
|
||||
}
|
||||
export const dynamicParams = false
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Cloud IaC Catalog',
|
||||
}
|
||||
|
||||
export default function CloudIacProviderPage({ params }: { params: PageParams }) {
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
export const dynamic = 'error'
|
||||
|
||||
import type { Metadata } from 'next'
|
||||
import { notFound } from 'next/navigation'
|
||||
|
||||
|
||||
38
ui/homepage/app/components/ClientTime.tsx
Normal file
38
ui/homepage/app/components/ClientTime.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
|
||||
type ClientTimeProps = {
|
||||
isoString: string
|
||||
locale?: string
|
||||
options?: Intl.DateTimeFormatOptions
|
||||
fallback?: string
|
||||
}
|
||||
|
||||
function formatTimestamp(
|
||||
isoString: string,
|
||||
locale?: string,
|
||||
options?: Intl.DateTimeFormatOptions,
|
||||
fallback?: string,
|
||||
): { display: string; dateTime: string } {
|
||||
if (!isoString) {
|
||||
return { display: fallback ?? '--', dateTime: '' }
|
||||
}
|
||||
|
||||
const date = new Date(isoString)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return { display: fallback ?? isoString, dateTime: isoString }
|
||||
}
|
||||
|
||||
const formatter = new Intl.DateTimeFormat(locale ?? undefined, options)
|
||||
return { display: formatter.format(date), dateTime: date.toISOString() }
|
||||
}
|
||||
|
||||
export default function ClientTime({ isoString, locale, options, fallback }: ClientTimeProps) {
|
||||
const { display, dateTime } = useMemo(
|
||||
() => formatTimestamp(isoString, locale, options, fallback),
|
||||
[isoString, locale, options, fallback],
|
||||
)
|
||||
|
||||
return <time dateTime={dateTime || undefined}>{display}</time>
|
||||
}
|
||||
@ -1,3 +1,5 @@
|
||||
export const dynamic = 'error'
|
||||
|
||||
import { notFound } from 'next/navigation'
|
||||
|
||||
import feature from './feature.config'
|
||||
|
||||
107
ui/homepage/app/docs/[name]/DocViewSection.tsx
Normal file
107
ui/homepage/app/docs/[name]/DocViewSection.tsx
Normal file
@ -0,0 +1,107 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { ExternalLink, FileText, Monitor } from 'lucide-react'
|
||||
|
||||
export type ViewMode = 'pdf' | 'html'
|
||||
|
||||
export interface DocViewOption {
|
||||
id: ViewMode
|
||||
label: string
|
||||
description: string
|
||||
url: string
|
||||
icon: ViewMode
|
||||
}
|
||||
|
||||
const ICON_MAP: Record<ViewMode, typeof FileText> = {
|
||||
pdf: FileText,
|
||||
html: Monitor,
|
||||
}
|
||||
|
||||
interface DocViewSectionProps {
|
||||
docTitle: string
|
||||
options: DocViewOption[]
|
||||
}
|
||||
|
||||
export default function DocViewSection({ docTitle, options }: DocViewSectionProps) {
|
||||
const [activeId, setActiveId] = useState<ViewMode | undefined>(options[0]?.id)
|
||||
|
||||
const activeView = useMemo(() => {
|
||||
if (!options.length) return undefined
|
||||
return options.find((option) => option.id === activeId) ?? options[0]
|
||||
}, [options, activeId])
|
||||
|
||||
const ActiveIcon = activeView ? ICON_MAP[activeView.icon] : undefined
|
||||
|
||||
return (
|
||||
<section className="overflow-hidden rounded-3xl border border-gray-200 bg-white shadow-sm">
|
||||
<div className="p-6">
|
||||
{options.length > 0 && (
|
||||
<div className="mt-0 grid gap-3 sm:grid-cols-2">
|
||||
{options.map((view) => {
|
||||
const isActive = activeView?.id === view.id
|
||||
const Icon = ICON_MAP[view.icon]
|
||||
const optionClassName = [
|
||||
'flex items-start gap-3 rounded-2xl border p-4 text-left transition hover:border-purple-400 hover:shadow',
|
||||
isActive ? 'border-purple-500 bg-purple-50/60 shadow' : 'border-gray-200',
|
||||
].join(' ')
|
||||
return (
|
||||
<button
|
||||
key={view.id}
|
||||
type="button"
|
||||
onClick={() => setActiveId(view.id)}
|
||||
className={optionClassName}
|
||||
>
|
||||
<span
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-full ${
|
||||
isActive ? 'bg-purple-600 text-white' : 'bg-purple-100 text-purple-600'
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="space-y-1">
|
||||
<span className="flex items-center gap-2 text-sm font-semibold text-gray-900">
|
||||
{view.label}
|
||||
{isActive && <span className="text-xs font-medium text-purple-600">Selected</span>}
|
||||
</span>
|
||||
<span className="text-xs text-gray-600">{view.description}</span>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeView && (
|
||||
<div className="mt-6 flex flex-wrap items-center gap-3 text-sm">
|
||||
<span className="inline-flex items-center gap-2 rounded-full bg-purple-100 px-4 py-2 font-medium text-purple-700">
|
||||
{ActiveIcon && <ActiveIcon className="h-4 w-4 text-purple-600" />}
|
||||
<span className="text-purple-700">{activeView.label} view selected</span>
|
||||
</span>
|
||||
<a
|
||||
href={activeView.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-2 rounded-full border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:border-purple-400 hover:text-purple-600"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" /> Open in new tab
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{activeView ? (
|
||||
<iframe
|
||||
key={activeView.id}
|
||||
src={activeView.url}
|
||||
className="h-[80vh] w-full"
|
||||
title={`${docTitle} (${activeView.label})`}
|
||||
/>
|
||||
) : (
|
||||
<div className="p-10 text-center text-gray-500">
|
||||
This resource does not provide an embeddable format yet. Use the download buttons above instead.
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@ -1,29 +1,15 @@
|
||||
import Link from 'next/link'
|
||||
export const dynamic = 'error'
|
||||
|
||||
import { notFound } from 'next/navigation'
|
||||
import type { Metadata } from 'next'
|
||||
import { ExternalLink, FileText, Monitor, type LucideIcon } from 'lucide-react'
|
||||
|
||||
import Breadcrumbs, { Crumb } from '../../../components/download/Breadcrumbs'
|
||||
import { formatDate } from '../../../lib/format'
|
||||
import { getDocResource, getDocResources } from '../resources'
|
||||
import { getDocResource } from '../resources'
|
||||
import feature from '../feature.config'
|
||||
|
||||
type ViewMode = 'pdf' | 'html'
|
||||
|
||||
interface ViewOption {
|
||||
id: ViewMode
|
||||
label: string
|
||||
description: string
|
||||
url: string
|
||||
icon: LucideIcon
|
||||
}
|
||||
|
||||
function normalizeViewParam(viewParam: string | string[] | undefined): ViewMode | undefined {
|
||||
if (!viewParam) return undefined
|
||||
const value = Array.isArray(viewParam) ? viewParam[0] : viewParam
|
||||
if (value === 'pdf' || value === 'html') return value
|
||||
return undefined
|
||||
}
|
||||
import ClientTime from '../../components/ClientTime'
|
||||
import docsIndex from '../../../public/_build/docs_index.json'
|
||||
import type { DocResource } from '../resources'
|
||||
import DocViewSection, { type DocViewOption } from './DocViewSection'
|
||||
|
||||
function buildBreadcrumbs(slug: string, docTitle: string): Crumb[] {
|
||||
return [
|
||||
@ -32,36 +18,26 @@ function buildBreadcrumbs(slug: string, docTitle: string): Crumb[] {
|
||||
]
|
||||
}
|
||||
|
||||
export async function generateStaticParams() {
|
||||
const DOCS_INDEX = docsIndex as DocResource[]
|
||||
|
||||
export const generateStaticParams = () => {
|
||||
if (!feature.enabled) {
|
||||
return []
|
||||
}
|
||||
|
||||
const docs = await getDocResources()
|
||||
return docs.map((doc) => ({ name: doc.slug }))
|
||||
return DOCS_INDEX.map((doc) => ({ name: doc.slug }))
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: { params: { name: string } }): Promise<Metadata> {
|
||||
if (!feature.enabled) {
|
||||
return { title: 'Documentation' }
|
||||
}
|
||||
export const dynamicParams = false
|
||||
|
||||
const doc = await getDocResource(params.name)
|
||||
if (!doc) {
|
||||
return { title: 'Documentation' }
|
||||
}
|
||||
return {
|
||||
title: `${doc.title} | Documentation`,
|
||||
description: doc.description,
|
||||
}
|
||||
export const metadata: Metadata = {
|
||||
title: 'Documentation',
|
||||
}
|
||||
|
||||
export default async function DocPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: { name: string }
|
||||
searchParams?: { view?: string | string[] }
|
||||
}) {
|
||||
if (!feature.enabled) {
|
||||
notFound()
|
||||
@ -72,14 +48,14 @@ export default async function DocPage({
|
||||
notFound()
|
||||
}
|
||||
|
||||
const viewOptions: ViewOption[] = []
|
||||
const viewOptions: DocViewOption[] = []
|
||||
if (doc.pdfUrl) {
|
||||
viewOptions.push({
|
||||
id: 'pdf',
|
||||
label: 'PDF',
|
||||
description: 'Best for printing and full fidelity diagrams.',
|
||||
url: doc.pdfUrl,
|
||||
icon: FileText,
|
||||
icon: 'pdf',
|
||||
})
|
||||
}
|
||||
if (doc.htmlUrl) {
|
||||
@ -88,15 +64,11 @@ export default async function DocPage({
|
||||
label: 'HTML',
|
||||
description: 'Responsive reader mode optimised for browsers.',
|
||||
url: doc.htmlUrl,
|
||||
icon: Monitor,
|
||||
icon: 'html',
|
||||
})
|
||||
}
|
||||
|
||||
const normalizedView = normalizeViewParam(searchParams?.view)
|
||||
const activeView = viewOptions.find((view) => view.id === normalizedView) ?? viewOptions[0]
|
||||
|
||||
const breadcrumbs = buildBreadcrumbs(doc.slug, doc.title)
|
||||
const ActiveIcon = activeView?.icon
|
||||
|
||||
return (
|
||||
<main className="px-4 py-8 md:px-8">
|
||||
@ -129,78 +101,18 @@ export default async function DocPage({
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-2 text-sm text-gray-500 md:items-end">
|
||||
{doc.updatedAt && <span>Updated {formatDate(doc.updatedAt)}</span>}
|
||||
{doc.updatedAt && (
|
||||
<span suppressHydrationWarning>
|
||||
Updated <ClientTime isoString={doc.updatedAt} />
|
||||
</span>
|
||||
)}
|
||||
{doc.estimatedMinutes && <span>Approx. {doc.estimatedMinutes} minute read</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{viewOptions.length > 0 && (
|
||||
<div className="mt-6 grid gap-3 sm:grid-cols-2">
|
||||
{viewOptions.map((view) => {
|
||||
const isActive = activeView?.id === view.id
|
||||
const Icon = view.icon
|
||||
return (
|
||||
<Link
|
||||
key={view.id}
|
||||
href={`/docs/${doc.slug}?view=${view.id}`}
|
||||
className={`flex items-start gap-3 rounded-2xl border p-4 transition hover:border-purple-400 hover:shadow ${
|
||||
isActive ? 'border-purple-500 bg-purple-50/60 shadow' : 'border-gray-200'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-full ${
|
||||
isActive ? 'bg-purple-600 text-white' : 'bg-purple-100 text-purple-600'
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-gray-900">
|
||||
{view.label}
|
||||
{isActive && <span className="text-xs font-medium text-purple-600">Selected</span>}
|
||||
</div>
|
||||
<p className="text-xs text-gray-600">{view.description}</p>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeView && (
|
||||
<div className="mt-6 flex flex-wrap items-center gap-3 text-sm">
|
||||
{ActiveIcon && (
|
||||
<span className="inline-flex items-center gap-2 rounded-full bg-purple-100 px-4 py-2 font-medium text-purple-700">
|
||||
<ActiveIcon className="h-4 w-4 text-purple-600" />
|
||||
{activeView.label} view selected
|
||||
</span>
|
||||
)}
|
||||
<a
|
||||
href={activeView.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-2 rounded-full border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:border-purple-400 hover:text-purple-600"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" /> Open in new tab
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="overflow-hidden rounded-3xl border border-gray-200 bg-white shadow-sm">
|
||||
{activeView ? (
|
||||
<iframe
|
||||
key={activeView.id}
|
||||
src={activeView.url}
|
||||
className="h-[80vh] w-full"
|
||||
title={`${doc.title} (${activeView.label})`}
|
||||
/>
|
||||
) : (
|
||||
<div className="p-10 text-center text-gray-500">
|
||||
This resource does not provide an embeddable format yet. Use the download buttons above instead.
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
<DocViewSection docTitle={doc.title} options={viewOptions} />
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
|
||||
@ -5,7 +5,7 @@ const feature = createFeatureFlag({
|
||||
title: 'Documentation Library',
|
||||
description: 'Knowledge base resources sourced from dl.svc.plus',
|
||||
envVar: 'NEXT_PUBLIC_FEATURE_DOCS',
|
||||
defaultEnabled: false,
|
||||
defaultEnabled: true,
|
||||
})
|
||||
|
||||
export default feature
|
||||
|
||||
@ -1,10 +1,12 @@
|
||||
export const dynamic = 'error'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { ArrowUpRight } from 'lucide-react'
|
||||
|
||||
import { formatDate } from '../../lib/format'
|
||||
import { getDocResources } from './resources'
|
||||
import feature from './feature.config'
|
||||
import ClientTime from '../components/ClientTime'
|
||||
|
||||
function formatMeta({
|
||||
category,
|
||||
@ -29,8 +31,8 @@ export default async function DocsHome() {
|
||||
|
||||
const manifest = await getDocResources()
|
||||
const resources = [...manifest].sort((a, b) => {
|
||||
const aTime = a.updatedAt ? new Date(a.updatedAt).getTime() : 0
|
||||
const bTime = b.updatedAt ? new Date(b.updatedAt).getTime() : 0
|
||||
const aTime = a.updatedAt ? Date.parse(a.updatedAt) : 0
|
||||
const bTime = b.updatedAt ? Date.parse(b.updatedAt) : 0
|
||||
return bTime - aTime
|
||||
})
|
||||
|
||||
@ -71,7 +73,11 @@ export default async function DocsHome() {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs text-purple-500">
|
||||
{resource.updatedAt && <span>Updated {formatDate(resource.updatedAt)}</span>}
|
||||
{resource.updatedAt && (
|
||||
<span suppressHydrationWarning>
|
||||
Updated <ClientTime isoString={resource.updatedAt} />
|
||||
</span>
|
||||
)}
|
||||
{resource.estimatedMinutes && <span>{resource.estimatedMinutes} min read</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,8 +1,7 @@
|
||||
import 'server-only'
|
||||
|
||||
import { cache } from 'react'
|
||||
|
||||
import feature from './feature.config'
|
||||
import docsIndex from '../../public/_build/docs_index.json'
|
||||
|
||||
export interface DocResource {
|
||||
slug: string
|
||||
@ -38,38 +37,11 @@ interface RawDocResource {
|
||||
pathSegments?: unknown
|
||||
}
|
||||
|
||||
const FALLBACK_DOCS: DocResource[] = []
|
||||
const RAW_DOCS = Array.isArray(docsIndex) ? (docsIndex as RawDocResource[]) : []
|
||||
|
||||
const DOCS_MANIFEST_URL = process.env.DOCS_MANIFEST_URL || 'https://dl.svc.plus/docs/all.json'
|
||||
|
||||
const loadManifest = cache(async (): Promise<DocResource[]> => {
|
||||
try {
|
||||
const response = await fetch(DOCS_MANIFEST_URL, {
|
||||
cache: 'force-cache',
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch docs manifest: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as unknown
|
||||
if (!Array.isArray(payload)) {
|
||||
throw new Error('Docs manifest payload must be an array')
|
||||
}
|
||||
|
||||
const resources = payload
|
||||
.map((item) => normalizeResource(item as RawDocResource))
|
||||
.filter((item): item is DocResource => item !== null)
|
||||
|
||||
return resources
|
||||
} catch (error) {
|
||||
console.error('[docs] Unable to load manifest, using fallback dataset.', error)
|
||||
return FALLBACK_DOCS
|
||||
}
|
||||
})
|
||||
const DOCS_DATASET = RAW_DOCS.map((item) => normalizeResource(item as RawDocResource)).filter(
|
||||
(item): item is DocResource => item !== null,
|
||||
)
|
||||
|
||||
function normalizeResource(item: RawDocResource): DocResource | null {
|
||||
if (!item || typeof item !== 'object') {
|
||||
@ -156,7 +128,7 @@ export async function getDocResources(): Promise<DocResource[]> {
|
||||
return []
|
||||
}
|
||||
|
||||
return loadManifest()
|
||||
return DOCS_DATASET
|
||||
}
|
||||
|
||||
export async function getDocResource(slug: string): Promise<DocResource | undefined> {
|
||||
@ -164,6 +136,5 @@ export async function getDocResource(slug: string): Promise<DocResource | undefi
|
||||
return undefined
|
||||
}
|
||||
|
||||
const resources = await loadManifest()
|
||||
return resources.find((doc) => doc.slug === slug)
|
||||
return DOCS_DATASET.find((doc) => doc.slug === slug)
|
||||
}
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
export const dynamic = 'error'
|
||||
|
||||
import DownloadListingContent from '../../../components/download/DownloadListingContent'
|
||||
import DownloadNotFound from '../../../components/download/DownloadNotFound'
|
||||
import {
|
||||
@ -8,13 +10,15 @@ import {
|
||||
} from '../../../lib/download-data'
|
||||
import { getDownloadListings } from '../../../lib/download-manifest'
|
||||
import type { DirListing } from '../../../types/download'
|
||||
import docsPaths from '../../../public/_build/docs_paths.json'
|
||||
|
||||
const allListings = getDownloadListings()
|
||||
const DOWNLOAD_PATHS = (docsPaths as string[]).filter((path) => typeof path === 'string')
|
||||
|
||||
export async function generateStaticParams() {
|
||||
return allListings
|
||||
.filter((listing) => listing.path)
|
||||
.map((listing) => ({ segments: listing.path.split('/').filter(Boolean) }))
|
||||
export function generateStaticParams() {
|
||||
return DOWNLOAD_PATHS.filter((path) => path.trim().length > 0).map((path) => ({
|
||||
segments: path.split('/').filter(Boolean),
|
||||
}))
|
||||
}
|
||||
|
||||
export const dynamicParams = false
|
||||
@ -22,7 +26,7 @@ export const dynamicParams = false
|
||||
function getLatestModified(listing: DirListing): string | undefined {
|
||||
let latest: string | undefined
|
||||
for (const entry of listing.entries) {
|
||||
if (entry.lastModified && (!latest || new Date(entry.lastModified).getTime() > new Date(latest).getTime())) {
|
||||
if (entry.lastModified && (!latest || entry.lastModified > latest)) {
|
||||
latest = entry.lastModified
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
export const dynamic = 'error'
|
||||
|
||||
import DownloadBrowser from '../../components/download/DownloadBrowser'
|
||||
import DownloadSummary from '../../components/download/DownloadSummary'
|
||||
import { buildDownloadSections, countFiles, findListing } from '../../lib/download-data'
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
export const dynamic = 'error'
|
||||
|
||||
import { notFound, redirect } from 'next/navigation'
|
||||
|
||||
import feature from './feature.config'
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
export const dynamic = 'error'
|
||||
|
||||
import Hero from '@components/Hero'
|
||||
import Features from '@components/Features'
|
||||
import OpenSource from '@components/OpenSource'
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
export const dynamic = 'error'
|
||||
|
||||
import Card from '../components/Card'
|
||||
|
||||
export default function AccountPage() {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import Card from '../components/Card'
|
||||
export const dynamic = 'error'
|
||||
|
||||
export const dynamic = 'force-static'
|
||||
import Card from '../components/Card'
|
||||
|
||||
export default function AgentPage() {
|
||||
return (
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import Card from '../components/Card'
|
||||
export const dynamic = 'error'
|
||||
|
||||
export const dynamic = 'force-static'
|
||||
import Card from '../components/Card'
|
||||
|
||||
export default function APIPage() {
|
||||
return (
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import Link from 'next/link'
|
||||
|
||||
export const dynamic = 'error'
|
||||
|
||||
import Card from '../components/Card'
|
||||
|
||||
export default function LdpPage() {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import Card from './components/Card'
|
||||
export const dynamic = 'error'
|
||||
|
||||
export const dynamic = 'force-static'
|
||||
import Card from './components/Card'
|
||||
|
||||
export default function PanelHome() {
|
||||
return (
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import Card from '../components/Card'
|
||||
export const dynamic = 'error'
|
||||
|
||||
export const dynamic = 'force-static'
|
||||
import Card from '../components/Card'
|
||||
|
||||
export default function SubscriptionPage() {
|
||||
return (
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import Card from '../components/Card'
|
||||
export const dynamic = 'error'
|
||||
|
||||
export const dynamic = 'force-static'
|
||||
import Card from '../components/Card'
|
||||
|
||||
export default function XRayPage() {
|
||||
return (
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
export const dynamic = 'error'
|
||||
|
||||
import { notFound, redirect } from 'next/navigation'
|
||||
|
||||
import feature from './feature.config'
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
'use client'
|
||||
import Image from 'next/image'
|
||||
import { useState } from 'react'
|
||||
import demoFeature from '../app/demo/feature.config'
|
||||
import docsFeature from '../app/docs/feature.config'
|
||||
@ -117,7 +118,14 @@ export default function Navbar() {
|
||||
<nav className="fixed top-0 w-full z-50 bg-white/70 backdrop-blur border-b border-gray-200">
|
||||
<div className="max-w-7xl mx-auto px-4 py-4 flex justify-between items-center">
|
||||
<a href="#" className="text-xl font-bold text-gray-900 flex items-center gap-2">
|
||||
<img src="/icons/cloudnative_32.png" alt="logo" className="w-6 h-6" />
|
||||
<Image
|
||||
src="/icons/cloudnative_32.png"
|
||||
alt="logo"
|
||||
width={24}
|
||||
height={24}
|
||||
className="h-6 w-6"
|
||||
unoptimized
|
||||
/>
|
||||
CloudNative Suite
|
||||
</a>
|
||||
|
||||
|
||||
@ -8,7 +8,7 @@ import { useLanguage } from '@i18n/LanguageProvider'
|
||||
import { translations } from '@i18n/translations'
|
||||
import { formatDate } from '@lib/format'
|
||||
import { formatSegmentLabel, type DownloadSection } from '@lib/download-data'
|
||||
import type { DirListing } from '@types/download'
|
||||
import type { DirListing } from '../../types/download'
|
||||
|
||||
type DownloadListingContentProps = {
|
||||
segments: string[]
|
||||
|
||||
@ -35,7 +35,7 @@ type PreviewBuilder = (context: Context) => PreviewItem[]
|
||||
type CostBuilder = (context: Context) => CostItem[]
|
||||
type OutputBuilder = (context: Context) => OutputItem[]
|
||||
|
||||
const SPEC_PRESETS: Record<CategoryKey, SpecBuilder> = {
|
||||
const SPEC_PRESETS: Partial<Record<CategoryKey, SpecBuilder>> = {
|
||||
compute: () => [
|
||||
{ label: '实例规格', defaultValue: 't3.medium', description: '默认双核 4GB,适合通用业务与测试环境。' },
|
||||
{ label: '节点数量', defaultValue: '2', description: '可按需调整 Auto Scaling 期望容量。' },
|
||||
@ -93,7 +93,7 @@ const SPEC_PRESETS: Record<CategoryKey, SpecBuilder> = {
|
||||
],
|
||||
}
|
||||
|
||||
const RESOURCE_PRESETS: Record<CategoryKey, PreviewBuilder> = {
|
||||
const RESOURCE_PRESETS: Partial<Record<CategoryKey, PreviewBuilder>> = {
|
||||
compute: ({ productName }) => [
|
||||
{ title: 'Auto Scaling Group', description: '跨 2 个可用区部署,关联弹性伸缩策略与健康检查。' },
|
||||
{ title: productName, description: '默认创建 2 台计算节点,挂载业务安全组与监控代理。' },
|
||||
@ -151,7 +151,7 @@ const RESOURCE_PRESETS: Record<CategoryKey, PreviewBuilder> = {
|
||||
],
|
||||
}
|
||||
|
||||
const COST_PRESETS: Record<CategoryKey, CostBuilder> = {
|
||||
const COST_PRESETS: Partial<Record<CategoryKey, CostBuilder>> = {
|
||||
compute: () => [
|
||||
{ title: '计算实例', amount: '~$120', unit: '每月', description: '2 台按量付费 t3.medium 实例(含折扣)。' },
|
||||
{ title: '块存储', amount: '~$18', unit: '每月', description: '100 GiB 通用型 SSD 存储与快照。' },
|
||||
@ -209,7 +209,7 @@ const COST_PRESETS: Record<CategoryKey, CostBuilder> = {
|
||||
],
|
||||
}
|
||||
|
||||
const OUTPUT_PRESETS: Record<CategoryKey, OutputBuilder> = {
|
||||
const OUTPUT_PRESETS: Partial<Record<CategoryKey, OutputBuilder>> = {
|
||||
compute: () => [
|
||||
{ title: '实例私网 IP', value: '10.0.1.12 / 10.0.2.15', description: '可用于上游注册与服务发现。' },
|
||||
{ title: '弹性伸缩组 ARN', value: 'arn:aws:autoscaling:…:group/app-asg', description: '便于监控、告警或后续自动化引用。' },
|
||||
@ -267,18 +267,54 @@ const OUTPUT_PRESETS: Record<CategoryKey, OutputBuilder> = {
|
||||
],
|
||||
}
|
||||
|
||||
const DEFAULT_SPEC_BUILDER: SpecBuilder = () => [
|
||||
{
|
||||
label: '自定义参数',
|
||||
defaultValue: '按服务需求配置',
|
||||
description: '该服务类别暂未提供默认模板,请根据实际情况补充配置。',
|
||||
},
|
||||
]
|
||||
|
||||
const DEFAULT_PREVIEW_BUILDER: PreviewBuilder = () => [
|
||||
{
|
||||
title: '服务概览',
|
||||
description: '此类别尚未定义示例资源,请结合产品文档规划部署。',
|
||||
},
|
||||
]
|
||||
|
||||
const DEFAULT_COST_BUILDER: CostBuilder = () => [
|
||||
{
|
||||
title: '预估成本',
|
||||
amount: '按使用计费',
|
||||
unit: 'USD/月',
|
||||
description: '根据配置选择与使用时长,参考各云厂商最新报价。',
|
||||
},
|
||||
]
|
||||
|
||||
const DEFAULT_OUTPUT_BUILDER: OutputBuilder = () => [
|
||||
{
|
||||
title: '部署结果',
|
||||
value: '待填充',
|
||||
description: '可在 GitOps 或 IaC 管道中补充具体输出变量。',
|
||||
},
|
||||
]
|
||||
|
||||
export function getSpecRows(context: Context): SpecRow[] {
|
||||
return SPEC_PRESETS[context.category](context)
|
||||
const builder = SPEC_PRESETS[context.category] ?? DEFAULT_SPEC_BUILDER
|
||||
return builder(context)
|
||||
}
|
||||
|
||||
export function getResourcePreview(context: Context): PreviewItem[] {
|
||||
return RESOURCE_PRESETS[context.category](context)
|
||||
const builder = RESOURCE_PRESETS[context.category] ?? DEFAULT_PREVIEW_BUILDER
|
||||
return builder(context)
|
||||
}
|
||||
|
||||
export function getCostPreview(context: Context): CostItem[] {
|
||||
return COST_PRESETS[context.category](context)
|
||||
const builder = COST_PRESETS[context.category] ?? DEFAULT_COST_BUILDER
|
||||
return builder(context)
|
||||
}
|
||||
|
||||
export function getOutputPreview(context: Context): OutputItem[] {
|
||||
return OUTPUT_PRESETS[context.category](context)
|
||||
const builder = OUTPUT_PRESETS[context.category] ?? DEFAULT_OUTPUT_BUILDER
|
||||
return builder(context)
|
||||
}
|
||||
|
||||
@ -10,6 +10,10 @@ export type CategoryKey =
|
||||
| 'data_service'
|
||||
| 'security'
|
||||
| 'iam'
|
||||
| 'dns_cdn'
|
||||
| 'edge_iot'
|
||||
| 'observability'
|
||||
| 'api_integration'
|
||||
|
||||
export type ProviderKey = 'aws' | 'gcp' | 'azure' | 'aliyun'
|
||||
|
||||
|
||||
1
ui/homepage/next-env.d.ts
vendored
1
ui/homepage/next-env.d.ts
vendored
@ -1,5 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference types="next/navigation-types/compat/navigation" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: 'standalone', // 生成最小可运行产物(适合 1c1G)
|
||||
output: 'export', // 直接生成静态文件,便于部署到 S3 / Nginx
|
||||
trailingSlash: true,
|
||||
reactStrictMode: true,
|
||||
compress: false, // 压缩交给 Nginx,省 Node CPU
|
||||
images: { unoptimized: true },// 关闭服务端图片处理
|
||||
|
||||
@ -4,8 +4,9 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"prebuild": "tsx ../../scripts/fetch-dl-index.ts",
|
||||
"prebuild": "tsx ../../scripts/export-slugs.ts && tsx ../../scripts/scan-md.ts && tsx ../../scripts/fetch-dl-index.ts",
|
||||
"build": "next build",
|
||||
"build:static": "yarn prebuild && node ../../scripts/check-build.js && next build",
|
||||
"export": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
|
||||
19
ui/homepage/pages/500.tsx
Normal file
19
ui/homepage/pages/500.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import Link from 'next/link'
|
||||
|
||||
export default function ErrorPage() {
|
||||
return (
|
||||
<main className="flex min-h-screen flex-col items-center justify-center bg-white px-4 py-24 text-center">
|
||||
<p className="text-sm font-semibold uppercase tracking-wide text-purple-600">500</p>
|
||||
<h1 className="mt-4 text-4xl font-bold text-gray-900">Something went wrong</h1>
|
||||
<p className="mt-3 max-w-md text-sm text-gray-600">
|
||||
An unexpected error occurred while preparing this static page. The incident has been logged and will be investigated.
|
||||
</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="mt-6 inline-flex items-center rounded-full bg-purple-600 px-5 py-2 text-sm font-semibold text-white shadow hover:bg-purple-700"
|
||||
>
|
||||
Back to homepage
|
||||
</Link>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
322
ui/homepage/public/_build/cloud_iac_index.json
Normal file
322
ui/homepage/public/_build/cloud_iac_index.json
Normal file
@ -0,0 +1,322 @@
|
||||
{
|
||||
"providers": [
|
||||
{
|
||||
"key": "aws",
|
||||
"label": "AWS"
|
||||
},
|
||||
{
|
||||
"key": "gcp",
|
||||
"label": "GCP"
|
||||
},
|
||||
{
|
||||
"key": "azure",
|
||||
"label": "Azure"
|
||||
},
|
||||
{
|
||||
"key": "aliyun",
|
||||
"label": "阿里云"
|
||||
}
|
||||
],
|
||||
"services": [
|
||||
{
|
||||
"provider": "aliyun",
|
||||
"service": "ack",
|
||||
"category": "container"
|
||||
},
|
||||
{
|
||||
"provider": "aliyun",
|
||||
"service": "aliyun-emr",
|
||||
"category": "data_service"
|
||||
},
|
||||
{
|
||||
"provider": "aliyun",
|
||||
"service": "aliyun-iot",
|
||||
"category": "edge_iot"
|
||||
},
|
||||
{
|
||||
"provider": "aliyun",
|
||||
"service": "apigateway-dataworks",
|
||||
"category": "api_integration"
|
||||
},
|
||||
{
|
||||
"provider": "aliyun",
|
||||
"service": "apsaradb-rds",
|
||||
"category": "database"
|
||||
},
|
||||
{
|
||||
"provider": "aliyun",
|
||||
"service": "apsaradb-redis",
|
||||
"category": "cache"
|
||||
},
|
||||
{
|
||||
"provider": "aliyun",
|
||||
"service": "cloudmonitor-eventbridge",
|
||||
"category": "observability"
|
||||
},
|
||||
{
|
||||
"provider": "aliyun",
|
||||
"service": "dns-acceleration",
|
||||
"category": "dns_cdn"
|
||||
},
|
||||
{
|
||||
"provider": "aliyun",
|
||||
"service": "ecs",
|
||||
"category": "compute"
|
||||
},
|
||||
{
|
||||
"provider": "aliyun",
|
||||
"service": "mns",
|
||||
"category": "queue"
|
||||
},
|
||||
{
|
||||
"provider": "aliyun",
|
||||
"service": "oss",
|
||||
"category": "storage"
|
||||
},
|
||||
{
|
||||
"provider": "aliyun",
|
||||
"service": "ram",
|
||||
"category": "iam"
|
||||
},
|
||||
{
|
||||
"provider": "aliyun",
|
||||
"service": "security-group",
|
||||
"category": "security"
|
||||
},
|
||||
{
|
||||
"provider": "aliyun",
|
||||
"service": "slb",
|
||||
"category": "load_balancer"
|
||||
},
|
||||
{
|
||||
"provider": "aliyun",
|
||||
"service": "vpc",
|
||||
"category": "network"
|
||||
},
|
||||
{
|
||||
"provider": "aws",
|
||||
"service": "alb",
|
||||
"category": "load_balancer"
|
||||
},
|
||||
{
|
||||
"provider": "aws",
|
||||
"service": "api-gateway-appflow",
|
||||
"category": "api_integration"
|
||||
},
|
||||
{
|
||||
"provider": "aws",
|
||||
"service": "aws-iot",
|
||||
"category": "edge_iot"
|
||||
},
|
||||
{
|
||||
"provider": "aws",
|
||||
"service": "cloudwatch-eventbridge",
|
||||
"category": "observability"
|
||||
},
|
||||
{
|
||||
"provider": "aws",
|
||||
"service": "ec2",
|
||||
"category": "compute"
|
||||
},
|
||||
{
|
||||
"provider": "aws",
|
||||
"service": "eks",
|
||||
"category": "container"
|
||||
},
|
||||
{
|
||||
"provider": "aws",
|
||||
"service": "elasticache",
|
||||
"category": "cache"
|
||||
},
|
||||
{
|
||||
"provider": "aws",
|
||||
"service": "emr",
|
||||
"category": "data_service"
|
||||
},
|
||||
{
|
||||
"provider": "aws",
|
||||
"service": "iam",
|
||||
"category": "iam"
|
||||
},
|
||||
{
|
||||
"provider": "aws",
|
||||
"service": "rds",
|
||||
"category": "database"
|
||||
},
|
||||
{
|
||||
"provider": "aws",
|
||||
"service": "route53-cloudfront",
|
||||
"category": "dns_cdn"
|
||||
},
|
||||
{
|
||||
"provider": "aws",
|
||||
"service": "s3",
|
||||
"category": "storage"
|
||||
},
|
||||
{
|
||||
"provider": "aws",
|
||||
"service": "security-group",
|
||||
"category": "security"
|
||||
},
|
||||
{
|
||||
"provider": "aws",
|
||||
"service": "sqs",
|
||||
"category": "queue"
|
||||
},
|
||||
{
|
||||
"provider": "aws",
|
||||
"service": "vpc",
|
||||
"category": "network"
|
||||
},
|
||||
{
|
||||
"provider": "azure",
|
||||
"service": "aks",
|
||||
"category": "container"
|
||||
},
|
||||
{
|
||||
"provider": "azure",
|
||||
"service": "apim-data-factory",
|
||||
"category": "api_integration"
|
||||
},
|
||||
{
|
||||
"provider": "azure",
|
||||
"service": "azure-ad",
|
||||
"category": "iam"
|
||||
},
|
||||
{
|
||||
"provider": "azure",
|
||||
"service": "azure-database",
|
||||
"category": "database"
|
||||
},
|
||||
{
|
||||
"provider": "azure",
|
||||
"service": "azure-dns-front-door",
|
||||
"category": "dns_cdn"
|
||||
},
|
||||
{
|
||||
"provider": "azure",
|
||||
"service": "azure-iot",
|
||||
"category": "edge_iot"
|
||||
},
|
||||
{
|
||||
"provider": "azure",
|
||||
"service": "azure-load-balancer",
|
||||
"category": "load_balancer"
|
||||
},
|
||||
{
|
||||
"provider": "azure",
|
||||
"service": "blob-storage",
|
||||
"category": "storage"
|
||||
},
|
||||
{
|
||||
"provider": "azure",
|
||||
"service": "monitor-event-grid",
|
||||
"category": "observability"
|
||||
},
|
||||
{
|
||||
"provider": "azure",
|
||||
"service": "network-security-group",
|
||||
"category": "security"
|
||||
},
|
||||
{
|
||||
"provider": "azure",
|
||||
"service": "redis-cache",
|
||||
"category": "cache"
|
||||
},
|
||||
{
|
||||
"provider": "azure",
|
||||
"service": "service-bus",
|
||||
"category": "queue"
|
||||
},
|
||||
{
|
||||
"provider": "azure",
|
||||
"service": "synapse",
|
||||
"category": "data_service"
|
||||
},
|
||||
{
|
||||
"provider": "azure",
|
||||
"service": "virtual-machines",
|
||||
"category": "compute"
|
||||
},
|
||||
{
|
||||
"provider": "azure",
|
||||
"service": "virtual-network",
|
||||
"category": "network"
|
||||
},
|
||||
{
|
||||
"provider": "gcp",
|
||||
"service": "apigateway-integration",
|
||||
"category": "api_integration"
|
||||
},
|
||||
{
|
||||
"provider": "gcp",
|
||||
"service": "cloud-dns-cdn",
|
||||
"category": "dns_cdn"
|
||||
},
|
||||
{
|
||||
"provider": "gcp",
|
||||
"service": "cloud-iam",
|
||||
"category": "iam"
|
||||
},
|
||||
{
|
||||
"provider": "gcp",
|
||||
"service": "cloud-load-balancing",
|
||||
"category": "load_balancer"
|
||||
},
|
||||
{
|
||||
"provider": "gcp",
|
||||
"service": "cloud-sql",
|
||||
"category": "database"
|
||||
},
|
||||
{
|
||||
"provider": "gcp",
|
||||
"service": "cloud-storage",
|
||||
"category": "storage"
|
||||
},
|
||||
{
|
||||
"provider": "gcp",
|
||||
"service": "compute-engine",
|
||||
"category": "compute"
|
||||
},
|
||||
{
|
||||
"provider": "gcp",
|
||||
"service": "dataproc",
|
||||
"category": "data_service"
|
||||
},
|
||||
{
|
||||
"provider": "gcp",
|
||||
"service": "gcp-iot",
|
||||
"category": "edge_iot"
|
||||
},
|
||||
{
|
||||
"provider": "gcp",
|
||||
"service": "gke",
|
||||
"category": "container"
|
||||
},
|
||||
{
|
||||
"provider": "gcp",
|
||||
"service": "memorystore",
|
||||
"category": "cache"
|
||||
},
|
||||
{
|
||||
"provider": "gcp",
|
||||
"service": "operations-eventarc",
|
||||
"category": "observability"
|
||||
},
|
||||
{
|
||||
"provider": "gcp",
|
||||
"service": "pubsub",
|
||||
"category": "queue"
|
||||
},
|
||||
{
|
||||
"provider": "gcp",
|
||||
"service": "vpc",
|
||||
"category": "network"
|
||||
},
|
||||
{
|
||||
"provider": "gcp",
|
||||
"service": "vpc-firewall",
|
||||
"category": "security"
|
||||
}
|
||||
]
|
||||
}
|
||||
11
ui/homepage/public/_build/docs_index.json
Normal file
11
ui/homepage/public/_build/docs_index.json
Normal file
@ -0,0 +1,11 @@
|
||||
[
|
||||
{
|
||||
"slug": "getting-started",
|
||||
"title": "Getting Started",
|
||||
"description": "Welcome to the docs! This is a placeholder document.",
|
||||
"updatedAt": "2025-09-19T00:53:13.962Z",
|
||||
"pathSegments": [
|
||||
"getting-started"
|
||||
]
|
||||
}
|
||||
]
|
||||
35
ui/homepage/public/_build/docs_paths.json
Normal file
35
ui/homepage/public/_build/docs_paths.json
Normal file
@ -0,0 +1,35 @@
|
||||
[
|
||||
"deb",
|
||||
"docs",
|
||||
"offline-package",
|
||||
"offline-package/apisix-gateway",
|
||||
"offline-package/k3s",
|
||||
"offline-package/kong-gateway",
|
||||
"offline-package/nginx-ingress",
|
||||
"offline-package/sealos",
|
||||
"otel",
|
||||
"otel/OpenTelemetry",
|
||||
"otel/OpenTelemetry/v0.133.0",
|
||||
"rpm",
|
||||
"xray-core",
|
||||
"xray-core/v25.8.29",
|
||||
"xray-core/v25.8.3",
|
||||
"xray-core/v25.8.31",
|
||||
"xstream",
|
||||
"xstream/android",
|
||||
"xstream/android/latest",
|
||||
"xstream/ios",
|
||||
"xstream/ios/latest",
|
||||
"xstream/linux",
|
||||
"xstream/linux/latest",
|
||||
"xstream/linux/stable",
|
||||
"xstream/macos",
|
||||
"xstream/macos/docs",
|
||||
"xstream/macos/latest",
|
||||
"xstream/macos/stable",
|
||||
"xstream/windows",
|
||||
"xstream/windows/latest",
|
||||
"xstream/windows/stable",
|
||||
"xstream/windows/win10",
|
||||
"xstream/windows/win11"
|
||||
]
|
||||
Loading…
Reference in New Issue
Block a user