chore: update repo content and deployment/docs assets
This commit is contained in:
parent
f5f02330e8
commit
5e57fdeac2
@ -1,27 +0,0 @@
|
||||
# 配置文件清理总结
|
||||
|
||||
## 删除的文件
|
||||
❌ package.new.json (144B) - 临时文件
|
||||
❌ tailwind.config.yaml (1.0K) - 备用方案
|
||||
❌ postcss.config.yaml (158B) - 备用方案
|
||||
|
||||
## 保留的文件
|
||||
✅ package.json (2.0K) - 项目配置
|
||||
✅ package-lock.json (369K) - 锁文件
|
||||
✅ tsconfig.json (1.3K) - TypeScript 配置
|
||||
✅ next.config.mjs (2.1K) - ES Module
|
||||
✅ tailwind.config.js (824B) - CommonJS 版本
|
||||
✅ tailwind.config.mjs (1.2K) - ES Module 版本
|
||||
✅ postcss.config.js (83B) - CommonJS 版本
|
||||
✅ postcss.config.mjs (215B) - ES Module 版本
|
||||
|
||||
## 当前状态
|
||||
- 总计: 8 个核心配置文件
|
||||
- ES Module: 3 个 (next.config.mjs, tailwind.config.mjs, postcss.config.mjs)
|
||||
- CommonJS: 2 个 (tailwind.config.js, postcss.config.js)
|
||||
- JSON: 3 个 (package.json, package-lock.json, tsconfig.json)
|
||||
|
||||
## 下一步行动
|
||||
1. 选择使用 CommonJS 版本 (.js) 还是 ES Module 版本 (.mjs)
|
||||
2. 如果使用 .mjs 版本,删除对应的 .js 文件
|
||||
3. 运行 npm run build 验证配置
|
||||
@ -1,5 +0,0 @@
|
||||
{
|
||||
"extends": [
|
||||
"next/core-web-vitals"
|
||||
]
|
||||
}
|
||||
22
dashboard/.gitignore
vendored
22
dashboard/.gitignore
vendored
@ -1,22 +0,0 @@
|
||||
|
||||
# Build output
|
||||
.next/
|
||||
out/
|
||||
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Playwright artifacts
|
||||
playwright-report/
|
||||
test-results/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# OS junk
|
||||
.DS_Store
|
||||
|
||||
# Editor junk
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
@ -1 +0,0 @@
|
||||
20
|
||||
@ -1,7 +0,0 @@
|
||||
compressionLevel: mixed
|
||||
|
||||
enableGlobalCache: false
|
||||
|
||||
nodeLinker: node-modules
|
||||
|
||||
npmRegistryServer: "https://registry.npmmirror.com"
|
||||
@ -1,205 +0,0 @@
|
||||
# Agent Guidelines for dashboard/
|
||||
|
||||
These instructions apply only to the dashboard/ subtree of XControl. They augment
|
||||
the root-level AGENTS.md with stricter rules specifically for the Next.js UI codebase.
|
||||
|
||||
The dashboard is a Next.js App Router application implemented in TypeScript with
|
||||
Tailwind CSS, Zustand state management, and Vitest/Playwright tests.
|
||||
|
||||
This document defines the architectural rules that all contributors — human or AI agents —
|
||||
must follow when modifying any code under dashboard/.
|
||||
|
||||
## 📌 1. State Management Rules (Zustand-Only Architecture)
|
||||
|
||||
Global state inconsistencies are the primary source of UI bugs and unpredictability.
|
||||
To eliminate this entire class of issues, the dashboard enforces:
|
||||
|
||||
✅ Zustand is the ONLY allowed global/shared state mechanism.
|
||||
❌ React Context Providers are disallowed for global state.
|
||||
|
||||
This includes:
|
||||
|
||||
No createContext, useContext, or <Context.Provider> for app-level data
|
||||
(auth/session/user/theme/language/insight/workbench/shared config).
|
||||
No “hybrid” patterns where Zustand data is mirrored inside a Provider.
|
||||
No component-level useState / useEffect holding cross-component state.
|
||||
|
||||
✔ All shared state MUST live inside Zustand slices
|
||||
|
||||
Each slice must:
|
||||
Export a useXStore(selector) function.
|
||||
Expose clear state + actions.
|
||||
Remain serializable for hydration when needed.
|
||||
Keep the shape stable and predictable.
|
||||
|
||||
✔ Recommended slice structure (pattern)
|
||||
/dashboard/src/state/
|
||||
user.ts → auth/session
|
||||
theme.ts → light/dark/system
|
||||
language.ts → i18n
|
||||
insight.ts → insight editor / workbench
|
||||
runtime.ts → runtime service config (hydrated from YAML)
|
||||
|
||||
|
||||
Slices should follow this format:
|
||||
|
||||
export const useUserStore = create<UserState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
user: null,
|
||||
isLoading: false,
|
||||
setUser: (u) => set({ user: u }),
|
||||
clearUser: () => set({ user: null }),
|
||||
}),
|
||||
{ name: 'user' }
|
||||
)
|
||||
)
|
||||
|
||||
📌 2. URL-Synchronized State Must Live in Zustand
|
||||
|
||||
Features such as:
|
||||
|
||||
insight editor / workbench state
|
||||
encoded shareable links
|
||||
URL → state hydration
|
||||
state → URL serialization
|
||||
MUST be handled inside the Zustand slice not in the component tree.
|
||||
|
||||
❌ Forbidden
|
||||
|
||||
Components containing URL parsing logic
|
||||
Components reading searchParams and storing them in local state
|
||||
Effects that attempt to “mirror” global data into component-local state
|
||||
✔ Mandatory
|
||||
|
||||
Zustand slices must expose helpers such as:
|
||||
|
||||
hydrateFromURL(searchParams: URLSearchParams)
|
||||
syncToURL(router: AppRouterInstance)
|
||||
serialize(): string
|
||||
|
||||
|
||||
This keeps the UI stateless and predictable.
|
||||
|
||||
📌 3. Component-level State Rules
|
||||
|
||||
Local UI state (modal open, hover, controlled inputs) is allowed:
|
||||
|
||||
Allowed:
|
||||
|
||||
useState for purely local visuals
|
||||
useEffect for browser-only side effects
|
||||
useRef for DOM details
|
||||
|
||||
Not allowed:
|
||||
|
||||
useState for data needed across pages/components
|
||||
useEffect that propagates shared state upward
|
||||
|
||||
When unsure:
|
||||
If two components could ever read it → it belongs in Zustand.
|
||||
|
||||
📌 4. File Structure & Code Conventions
|
||||
Directory structure
|
||||
|
||||
Maintain component, state, and utility layout:
|
||||
|
||||
src/
|
||||
app/ → routes (App Router)
|
||||
components/ → presentational components
|
||||
state/ → Zustand slices
|
||||
hooks/ → reusable UI hooks
|
||||
lib/ → shared utilities (non-state)
|
||||
config/ → runtime-service-config.yaml and loaders
|
||||
|
||||
Code style
|
||||
|
||||
ESLint + Prettier formatting
|
||||
|
||||
2-space indentation
|
||||
|
||||
Single quotes
|
||||
|
||||
No unused exports
|
||||
|
||||
No default exports for slices or large utilities
|
||||
(Easier for static analysis + tree shaking)
|
||||
|
||||
📌 5. Environment, Config & Runtime Rules
|
||||
Declarative configuration only
|
||||
|
||||
Do not add browser-only environment variables.
|
||||
|
||||
All new runtime config fields must go into:
|
||||
|
||||
dashboard/config/runtime-service-config.yaml
|
||||
|
||||
|
||||
And be hydrated by a Zustand slice (e.g., runtime.ts).
|
||||
|
||||
📌 6. AI Agent (Codex/GPT) Rules — Strict Mode
|
||||
|
||||
Because the dashboard often uses AI to refactor/upgrade code, the following constraints
|
||||
apply specifically to code generated by agents:
|
||||
|
||||
🚫 Agents MUST NOT:
|
||||
|
||||
Generate any form of React Provider for global state
|
||||
|
||||
Introduce hybrid Context+Zustand patterns
|
||||
|
||||
Use component-level state for shared logic
|
||||
|
||||
Use browser APIs (window, localStorage) in server-compatible modules
|
||||
|
||||
Change directory structure without explicit instruction
|
||||
|
||||
Generate environment variables not reflected in runtime-service-config.yaml
|
||||
|
||||
✅ Agents MUST:
|
||||
|
||||
Implement all shared logic as Zustand slices
|
||||
|
||||
Keep slices serializable and deterministic
|
||||
|
||||
Produce code compatible with Next.js App Router (SSR + CSR safe)
|
||||
|
||||
Follow ESLint and existing style conventions
|
||||
|
||||
Ensure newly generated slices include proper actions/selectors
|
||||
|
||||
Prefer pure functions and stable keys for Zustand persist middlewares
|
||||
|
||||
📌 7. Testing Requirements
|
||||
|
||||
Contributors must run:
|
||||
|
||||
yarn --cwd dashboard lint
|
||||
yarn --cwd dashboard test
|
||||
yarn --cwd dashboard test:e2e
|
||||
|
||||
|
||||
Slices that handle URL hydration must include unit tests verifying:
|
||||
|
||||
URL → state correctness
|
||||
|
||||
state → URL correctness
|
||||
|
||||
shareable link determinism
|
||||
|
||||
Insight-related state should always include at least minimal test coverage.
|
||||
|
||||
📌 8. Summary of Key Constraints (TL;DR)
|
||||
🚫 Forbidden
|
||||
|
||||
React Context for shared/global state
|
||||
|
||||
useState/useEffect for cross-component data
|
||||
|
||||
Ad-hoc URL parsing inside components
|
||||
|
||||
✔ Required
|
||||
Zustand-only global state
|
||||
URL hydration inside Zustand slices
|
||||
Declarative runtime config (YAML → slice)
|
||||
AI agents must follow deterministic slice architecture
|
||||
@ -1,75 +0,0 @@
|
||||
# =======================================================
|
||||
# Global ARGs — 必须在所有 FROM 之前声明
|
||||
# =======================================================
|
||||
ARG NODE_BUILDER_IMAGE=node:22-bookworm
|
||||
ARG NODE_RUNTIME_IMAGE=node:22-slim
|
||||
|
||||
# -------------------------------------------------------
|
||||
# Stage 1 — Builder (Turbopack + standalone)
|
||||
# -------------------------------------------------------
|
||||
FROM ${NODE_BUILDER_IMAGE} AS builder
|
||||
|
||||
WORKDIR /app/dashboard
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED=1 \
|
||||
NEXT_PRIVATE_TURBOPACK=1
|
||||
|
||||
# ---------------------------
|
||||
# 基础镜像升级到最新
|
||||
# ---------------------------
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends curl \
|
||||
&& apt-get upgrade -y \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& corepack enable \
|
||||
&& corepack prepare yarn@4.12.0 --activate
|
||||
|
||||
COPY . .
|
||||
RUN find . -name "package-lock.json" -delete
|
||||
RUN yarn install --immutable
|
||||
RUN yarn next build
|
||||
|
||||
# -------------------------------------------------------
|
||||
# Stage 2 — Runtime (极致瘦身)
|
||||
# -------------------------------------------------------
|
||||
FROM ${NODE_RUNTIME_IMAGE} AS runner
|
||||
|
||||
WORKDIR /app/dashboard/
|
||||
|
||||
ENV NODE_ENV=production \
|
||||
RUNTIME_ENV=prod \
|
||||
REGION=cn \
|
||||
PORT=3000
|
||||
|
||||
# ---------------------------
|
||||
# 安装 tini(修复子进程 + 更快退出)
|
||||
# 基础镜像升级到最新
|
||||
# ---------------------------
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends tini ca-certificates \
|
||||
&& apt-get dist-upgrade -y \
|
||||
&& apt-get install -y --no-install-recommends --only-upgrade libpam-modules libpam-modules-bin libpam-runtime libpam0g zlib1g \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# ---------------------------
|
||||
# 导入 standalone 的运行产物
|
||||
# ---------------------------
|
||||
COPY --from=builder /app/dashboard/.next/standalone ./
|
||||
COPY --from=builder /app/dashboard/.next/static ./static
|
||||
COPY --from=builder /app/dashboard/public ./public
|
||||
|
||||
# ---------------------------
|
||||
# 额外瘦身(可减少 15–40 MB)
|
||||
# ---------------------------
|
||||
#RUN rm -rf node_modules/next/dist/compiled/@vercel/og/* \
|
||||
# && rm -rf node_modules/sharp/vendor/* \
|
||||
# && find node_modules -name "*.md" -delete \
|
||||
# && find node_modules -name "*.map" -delete
|
||||
|
||||
# (如果 Edge Runtime 里用不到 font/SWC 也可移除,比现在更狠)
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENTRYPOINT ["/usr/bin/tini", "--"]
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
@ -1,112 +0,0 @@
|
||||
SHELL := /bin/bash
|
||||
NODE_VERSION := $(shell node -v 2>/dev/null || echo "Not Found")
|
||||
YARN := $(shell command -v yarn 2>/dev/null)
|
||||
MAGICK := $(shell command -v magick 2>/dev/null || command -v convert 2>/dev/null)
|
||||
OS := $(shell uname -s)
|
||||
YARN_VERSION ?= 4.12.0
|
||||
|
||||
.PHONY: init ensure-deps dev build export clean info icon start stop restart test sync-dl-index
|
||||
|
||||
icon:
|
||||
@echo "🎨 Generating favicon and icon images..."
|
||||
@if [ -z "$(MAGICK)" ]; then \
|
||||
echo "❌ ImageMagick not found."; \
|
||||
if [ "$(OS)" = "Darwin" ]; then \
|
||||
echo "👉 Try: brew install imagemagick"; \
|
||||
elif [ -f /etc/debian_version ]; then \
|
||||
echo "👉 Try: sudo apt install imagemagick"; \
|
||||
elif [ -f /etc/redhat-release ]; then \
|
||||
echo "👉 Try: sudo dnf install imagemagick"; \
|
||||
fi; \
|
||||
exit 1; \
|
||||
fi
|
||||
@mkdir -p public/icons
|
||||
@$(MAGICK) ../ui/logo.png -resize 32x32 public/icons/cloudnative_32.png
|
||||
@$(MAGICK) ../ui/logo.png -resize 64x64 -background none -define icon:auto-resize=64,48,32,16 public/favicon.ico
|
||||
@echo "✅ Icons generated successfully."
|
||||
|
||||
init:
|
||||
@echo "🔧 Installing dependencies for dashboard..."
|
||||
@corepack enable && corepack prepare yarn@$(YARN_VERSION) --activate
|
||||
@echo "🧹 Removing npm lockfiles to mirror Docker build..."
|
||||
@find . -name "package-lock.json" -delete
|
||||
@if [ -z "$(YARN)" ]; then \
|
||||
echo "⚠️ Yarn not found. Attempting to install..."; \
|
||||
if [ "$(OS)" = "Darwin" ]; then \
|
||||
if command -v brew >/dev/null 2>&1; then \
|
||||
brew install yarn; \
|
||||
else \
|
||||
echo "❌ Homebrew not found. Please install Yarn manually."; exit 1; \
|
||||
fi; \
|
||||
elif [ -f /etc/debian_version ]; then \
|
||||
curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | sudo apt-key add - && \
|
||||
echo "deb https://dl.yarnpkg.com/debian/ stable main" | sudo tee /etc/apt/sources.list.d/yarn.list && \
|
||||
sudo apt update && sudo apt install -y yarn; \
|
||||
elif [ -f /etc/redhat-release ]; then \
|
||||
curl --silent --location https://dl.yarnpkg.com/rpm/yarn.repo | sudo tee /etc/yum.repos.d/yarn.repo && \
|
||||
sudo yum install -y yarn; \
|
||||
else \
|
||||
echo "❌ Unsupported OS. Please install Yarn manually."; exit 1; \
|
||||
fi; \
|
||||
fi
|
||||
yarn config set npmRegistryServer https://registry.npmmirror.com
|
||||
yarn install --immutable
|
||||
|
||||
ensure-deps:
|
||||
@if [ ! -f .yarn/install-state.gz ] || [ ! -d node_modules ] || [ ! -d node_modules/sanitize-html ]; then \
|
||||
echo "📦 Installing dependencies..."; \
|
||||
yarn install --immutable; \
|
||||
fi
|
||||
|
||||
dev: ensure-deps
|
||||
@echo "🚀 Starting Next.js dev server (dashboard)..."
|
||||
yarn dev -p 3001
|
||||
|
||||
start:
|
||||
@echo "🚀 Starting Next.js dev server (dashboard) in background..."
|
||||
@nohup yarn dev -p 3001 >/tmp/dashboard.log 2>&1 & echo $$! > dashboard.pid
|
||||
|
||||
stop:
|
||||
@echo "🛑 Stopping Next.js dev server (dashboard)..."
|
||||
@if [ -f dashboard.pid ]; then \
|
||||
kill `cat dashboard.pid` >/dev/null 2>&1 || true; \
|
||||
rm dashboard.pid; \
|
||||
else \
|
||||
echo "No running server"; \
|
||||
fi
|
||||
|
||||
restart: stop start
|
||||
|
||||
test:
|
||||
@echo "🔍 Running tests..."
|
||||
@yarn test || echo "No tests configured"
|
||||
|
||||
build: init
|
||||
yarn config set npmRegistryServer https://registry.npmmirror.com
|
||||
@if [ -z "$(SKIP_SYNC)" ]; then \
|
||||
$(MAKE) sync-dl-index; \
|
||||
fi
|
||||
@echo "🔨 Building dashboard..."
|
||||
NEXT_TELEMETRY_DISABLED=1 NEXT_PRIVATE_TURBOPACK=1 yarn next build
|
||||
|
||||
sync-dl-index:
|
||||
@echo "📥 Fetching download & docs manifests..."
|
||||
@mkdir -p public/dl-index
|
||||
@if ! curl -fsSL https://dl.svc.plus/manifest.json -o public/dl-index/artifacts-manifest.json; then \
|
||||
echo "⚠️ Unable to download artifacts manifest. Using existing snapshot."; \
|
||||
fi
|
||||
@if ! curl -fsSL https://dl.svc.plus/docs/all.json -o public/dl-index/docs-manifest.json; then \
|
||||
echo "⚠️ Unable to download docs manifest. Using existing snapshot."; \
|
||||
fi
|
||||
|
||||
export:
|
||||
@echo "📦 Exporting dashboard static site to ./out ..."
|
||||
@NEXT_SHOULD_EXPORT=true yarn next export
|
||||
|
||||
clean:
|
||||
@echo "🧹 Cleaning .next and out directories..."
|
||||
rm -rf .next out
|
||||
|
||||
info:
|
||||
@echo "🧾 Node.js version: $(NODE_VERSION)"
|
||||
|
||||
@ -1,164 +0,0 @@
|
||||
# 📊 配置文件系统汇总报告
|
||||
|
||||
## 🏗️ 当前模块系统状态
|
||||
|
||||
### 模块类型统计
|
||||
```
|
||||
✅ ES Module (.mjs): 1 个文件
|
||||
⚠️ CommonJS (.js): 2 个文件
|
||||
❌ 混合状态: 1 个文件 (package.json)
|
||||
```
|
||||
|
||||
### 详细状态表
|
||||
| 配置文件 | 扩展名 | 当前格式 | 目标格式 | 大小 | 状态 |
|
||||
|----------|--------|----------|----------|------|------|
|
||||
| `package.json` | .json | JSON (无 type 字段) | JSON + type:module | 2.0K | ⚠️ 需配置 |
|
||||
| `next.config.mjs` | .mjs | ES Module | 保持 | 2.1K | ✅ 完成 |
|
||||
| `tailwind.config.js` | .js | CommonJS | ES Module | 824B | ⚠️ 已创建 .mjs 版本 |
|
||||
| `postcss.config.js` | .js | CommonJS | ES Module | 83B | ⚠️ 已创建 .mjs 版本 |
|
||||
| `tsconfig.json` | .json | JSON | 保持 | 1.3K | ✅ 不需修改 |
|
||||
| `package-lock.json` | .json | JSON (锁文件) | 保持 | 369K | ✅ 不需修改 |
|
||||
| `next-env.d.ts` | .ts | TypeScript (生成) | 忽略 | 251B | ❌ 自动生成 |
|
||||
|
||||
## 📁 当前配置文件列表
|
||||
|
||||
### 1. 核心配置文件
|
||||
```
|
||||
.
|
||||
├── package.json (2.0K) ⚠️ 需添加 "type": "module"
|
||||
├── package-lock.json (369K) ✅ 锁文件,无需修改
|
||||
├── tsconfig.json (1.3K) ✅ TypeScript 配置,标准 JSON
|
||||
├── next-env.d.ts (251B) ❌ 自动生成,忽略
|
||||
│
|
||||
├── next.config.mjs (2.1K) ✅ 已是 ES Module
|
||||
├── tailwind.config.js (824B) ⚠️ CommonJS
|
||||
│ ├── tailwind.config.mjs (1.2K) ✅ ES Module 版本 (已创建)
|
||||
│
|
||||
├── postcss.config.js (83B) ⚠️ CommonJS
|
||||
│ ├── postcss.config.mjs (215B) ✅ ES Module 版本 (已创建)
|
||||
```
|
||||
|
||||
### 2. 功能配置文件
|
||||
```
|
||||
其他配置文件:
|
||||
├── .eslintrc.json (linting)
|
||||
├── .prettierrc (code formatting)
|
||||
├── jest.config.js (testing)
|
||||
├── vitest.config.ts (testing)
|
||||
└── playwright.config.ts (e2e testing)
|
||||
```
|
||||
|
||||
## 🔄 转换进度表
|
||||
|
||||
| 配置文件 | 转换状态 | 行动 |
|
||||
|----------|----------|------|
|
||||
| `package.json` | ⏳ 等待 | 添加 "type": "module" |
|
||||
| `tailwind.config.js` | ✅ 完成 | 替换为 .mjs 版本 |
|
||||
| `postcss.config.js` | ✅ 完成 | 替换为 .mjs 版本 |
|
||||
| `next.config.mjs` | ✅ 完成 | 已是 ES Module |
|
||||
|
||||
## 🎯 模块系统对比
|
||||
|
||||
### CommonJS (当前)
|
||||
```javascript
|
||||
// tailwind.config.js
|
||||
module.exports = {
|
||||
content: ['./app/**/*.{ts,tsx}'],
|
||||
theme: { extend: {} },
|
||||
plugins: [require('@tailwindcss/typography')]
|
||||
}
|
||||
```
|
||||
|
||||
### ES Module (目标)
|
||||
```javascript
|
||||
// tailwind.config.mjs
|
||||
import typography from '@tailwindcss/typography'
|
||||
|
||||
export default {
|
||||
content: [
|
||||
'./src/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
],
|
||||
theme: { extend: {} },
|
||||
plugins: [typography]
|
||||
}
|
||||
```
|
||||
|
||||
## 📊 文件大小分析
|
||||
|
||||
### 转换前后对比
|
||||
```
|
||||
名称 转换前 转换后 变化
|
||||
────────────────────────────────────────────────
|
||||
tailwind.config.js 824B 1.2K +46% (增加注释)
|
||||
postcss.config.js 83B 215B +159% (增加注释)
|
||||
next.config.mjs 2.1K 2.1K 0% (保持)
|
||||
package.json 2.0K 2.0K 0% (仅添加 type 字段)
|
||||
```
|
||||
|
||||
### 原因分析
|
||||
- **.mjs 文件更大**: 因为增加了 JSDoc 注释和使用说明
|
||||
- **向后兼容**: .mjs 扩展名在 Node.js 12+ 原生支持
|
||||
- **更好的可维护性**: 注释提高代码可读性
|
||||
|
||||
## 🚀 推荐执行方案
|
||||
|
||||
### 方案 A: 最小风险 (推荐)
|
||||
```bash
|
||||
# 仅替换配置文件为 .mjs 版本
|
||||
mv tailwind.config.mjs tailwind.config.js
|
||||
mv postcss.config.mjs postcss.config.js
|
||||
npm run build
|
||||
```
|
||||
|
||||
**优点**:
|
||||
- ✅ 无需修改 package.json
|
||||
- ✅ 不影响其他 .js 文件
|
||||
- ✅ 风险最小
|
||||
- ✅ 立即可用
|
||||
|
||||
### 方案 B: 完全现代化
|
||||
```bash
|
||||
# 1. 添加 type: "module" 到 package.json
|
||||
# 2. 替换配置文件
|
||||
# 3. 可能需要修改其他 .js 文件
|
||||
```
|
||||
|
||||
**优点**:
|
||||
- ✅ 完全统一为 ES Module
|
||||
- ✅ 更好的 tree-shaking
|
||||
- ✅ 现代化标准
|
||||
|
||||
**缺点**:
|
||||
- ⚠️ 可能影响现有 .js 文件
|
||||
- ⚠️ 需要全面测试
|
||||
|
||||
## 💡 最佳实践建议
|
||||
|
||||
1. **保持简单**: 使用 .mjs 扩展名即可,无需修改 package.json
|
||||
2. **增加注释**: 为复杂配置添加说明
|
||||
3. **版本控制**: 将所有更改提交到 Git
|
||||
4. **测试验证**: 每次修改后运行 `npm run build`
|
||||
|
||||
## ✨ 关键结论
|
||||
|
||||
- **当前状态**: 50% ES Module (1/2 配置文件)
|
||||
- **目标状态**: 100% ES Module (所有配置文件)
|
||||
- **行动**: 替换 tailwind.config.js 和 postcss.config.js 为 .mjs 版本
|
||||
- **风险**: 低 (仅配置文件更改)
|
||||
- **收益**: 现代标准 + 更好可维护性
|
||||
|
||||
## 🧹 清理计划
|
||||
|
||||
### 需要删除的文件
|
||||
- ❌ `package.new.json` - 临时创建的文件
|
||||
- ❌ `tailwind.config.yaml` - 备用方案,不需保留
|
||||
- ❌ `postcss.config.yaml` - 备用方案,不需保留
|
||||
|
||||
### 需要保留的文件
|
||||
- ✅ `tailwind.config.js` (原版 CommonJS)
|
||||
- ✅ `tailwind.config.mjs` (ES Module 版本)
|
||||
- ✅ `postcss.config.js` (原版 CommonJS)
|
||||
- ✅ `postcss.config.mjs` (ES Module 版本)
|
||||
|
||||
**注意**: 保留两个版本以便比较和回滚
|
||||
@ -1,66 +0,0 @@
|
||||
# 🔄 CommonJS → ES Module 迁移指南
|
||||
|
||||
## 📊 迁移概览
|
||||
|
||||
### 转换前 (CommonJS)
|
||||
```javascript
|
||||
// tailwind.config.js
|
||||
module.exports = {
|
||||
content: ['./app/**/*.{ts,tsx}', './components/**/*.{ts,tsx}'],
|
||||
theme: { extend: {} },
|
||||
plugins: [require('@tailwindcss/typography')]
|
||||
}
|
||||
```
|
||||
|
||||
### 转换后 (ES Module)
|
||||
```javascript
|
||||
// tailwind.config.mjs
|
||||
import typography from '@tailwindcss/typography'
|
||||
|
||||
export default {
|
||||
content: [
|
||||
'./src/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
],
|
||||
theme: { extend: {} },
|
||||
plugins: [typography]
|
||||
}
|
||||
```
|
||||
|
||||
## ✅ 迁移文件清单
|
||||
|
||||
| 文件 | 原格式 → 新格式 | 状态 |
|
||||
|------|----------------|------|
|
||||
| `package.json` | 添加 `"type": "module"` | 📋 需手动 |
|
||||
| `tailwind.config.js` | → `tailwind.config.mjs` | ✅ 完成 |
|
||||
| `postcss.config.js` | → `postcss.config.mjs` | ✅ 完成 |
|
||||
| `next.config.mjs` | 已是 ES Module | ✅ 保持 |
|
||||
|
||||
## 🚀 执行迁移
|
||||
|
||||
```bash
|
||||
# 1. 备份原文件
|
||||
cp package.json package.json.bak
|
||||
cp tailwind.config.js tailwind.config.js.bak
|
||||
cp postcss.config.js postcss.config.js.bak
|
||||
|
||||
# 2. 添加 type: "module" 到 package.json
|
||||
# 在 "private": true 后添加
|
||||
|
||||
# 3. 替换配置文件
|
||||
mv tailwind.config.mjs tailwind.config.js
|
||||
mv postcss.config.mjs postcss.config.js
|
||||
|
||||
# 4. 验证
|
||||
npm run build
|
||||
```
|
||||
|
||||
## 🎯 ES Module 优势
|
||||
|
||||
1. **统一标准**: import/export 语法
|
||||
2. **更好 Tree Shaking**: 消除未使用代码
|
||||
3. **静态分析**: IDE 支持更好
|
||||
4. **未来兼容**: ECMAScript 标准
|
||||
5. **性能提升**: 更好的模块加载
|
||||
|
||||
6
dashboard/next-env.d.ts
vendored
6
dashboard/next-env.d.ts
vendored
@ -1,6 +0,0 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
@ -1,97 +0,0 @@
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const nextConfig = {
|
||||
// ===============================
|
||||
// 🚀 生产优化 —— 最关键的三行
|
||||
// ===============================
|
||||
output: "standalone", // 让 Next.js 生成可独立运行的最小产物(大幅减小 Docker 镜像)
|
||||
compress: true, // Gzip 压缩输出(确保小体积网络传输)
|
||||
|
||||
// 配置允许的外部图片域名
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'dl.svc.plus',
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'www.svc.plus',
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'images.unsplash.com',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
webpack: (config) => {
|
||||
// 添加 YAML 文件支持
|
||||
config.module.rules.push({
|
||||
test: /\.ya?ml$/i,
|
||||
type: 'asset/source',
|
||||
});
|
||||
|
||||
// 显式 alias,保证 Turbopack 也能解析
|
||||
config.resolve.alias = {
|
||||
...(config.resolve.alias ?? {}),
|
||||
"@components": path.join(__dirname, "src", "components"),
|
||||
"@i18n": path.join(__dirname, "src", "i18n"),
|
||||
"@lib": path.join(__dirname, "src", "lib"),
|
||||
"@types": path.join(__dirname, "types"),
|
||||
"@server": path.join(__dirname, "src", "server"),
|
||||
"@modules": path.join(__dirname, "src", "modules"),
|
||||
"@extensions": path.join(__dirname, "src", "modules", "extensions"),
|
||||
"@theme": path.join(__dirname, "src", "components", "theme"),
|
||||
"@templates": path.join(__dirname, "src", "modules", "templates"),
|
||||
"@src": path.join(__dirname, "src"),
|
||||
"@": path.join(__dirname, "src"),
|
||||
};
|
||||
|
||||
// 添加模块搜索路径
|
||||
config.resolve.modules = [
|
||||
...(config.resolve.modules || []),
|
||||
__dirname,
|
||||
path.join(__dirname, "src"),
|
||||
];
|
||||
|
||||
return config;
|
||||
},
|
||||
reactStrictMode: true,
|
||||
transpilePackages: ['@internal/neurapress'],
|
||||
typedRoutes: false,
|
||||
turbopack: {
|
||||
root: path.resolve(__dirname),
|
||||
},
|
||||
};
|
||||
|
||||
export async function redirects() {
|
||||
return [
|
||||
{
|
||||
source: '/XStream',
|
||||
destination: '/xstream',
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: '/Xstream',
|
||||
destination: '/xstream',
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: '/XScopeHub',
|
||||
destination: '/xscopehub',
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: '/XCloudFlow',
|
||||
destination: '/xcloudflow',
|
||||
permanent: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export default nextConfig;
|
||||
10473
dashboard/package-lock.json
generated
10473
dashboard/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -1,75 +0,0 @@
|
||||
{
|
||||
"name": "cloudnative-dashboard",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=18.17 <23"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "next dev --turbo",
|
||||
"prebuild": "tsx ../scripts/export-slugs.ts && tsx ../scripts/scan-md.ts && tsx ../scripts/fetch-dl-index.ts && node ../scripts/copy-manifests.js",
|
||||
"build": "next build",
|
||||
"build:static": "npm run prebuild && next build",
|
||||
"start": "node ./scripts/start.js",
|
||||
"lint": "next lint",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"format": "prettier --write .",
|
||||
"preview": "next build && next start",
|
||||
"test": "vitest run --config tests/unit/vitest.config.ts",
|
||||
"test:unit": "vitest run --config tests/unit/vitest.config.ts",
|
||||
"test:e2e": "playwright test --config tests/e2e/playwright.config.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@internal/neurapress": "file:../packages/neurapress",
|
||||
"dompurify": "^3.2.6",
|
||||
"gray-matter": "^4.0.3",
|
||||
"html2canvas": "^1.4.1",
|
||||
"js-yaml": "^4.1.0",
|
||||
"katex": "^0.16.27",
|
||||
"lucide-react": "^0.319.0",
|
||||
"marked": "^16.1.2",
|
||||
"next": "^16.0.9",
|
||||
"pdfjs-dist": "^4.2.67",
|
||||
"prismjs": "^1.30.0",
|
||||
"prop-types": "^15.8.1",
|
||||
"qr.js": "0.0.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-grid-layout": "^1.4.4",
|
||||
"react-pdf": "^9.1.0",
|
||||
"react-resizable": "^3.0.4",
|
||||
"sanitize-html": "^2.13.0",
|
||||
"swr": "^2.3.0",
|
||||
"zustand": "^4.5.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.49.1",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@testing-library/dom": "^9.3.1",
|
||||
"@testing-library/jest-dom": "^6.4.6",
|
||||
"@testing-library/react": "^14.3.1",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "24.0.3",
|
||||
"@types/react": "^18.3.26",
|
||||
"@types/react-grid-layout": "^1.3.5",
|
||||
"@types/sanitize-html": "^2.16.0",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"baseline-browser-mapping": "^2.8.32",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-config-next": "^15.5.3",
|
||||
"jsdom": "^24.0.0",
|
||||
"postcss": "^8.4.32",
|
||||
"prettier": "^3.3.3",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"tsconfig-paths-webpack-plugin": "^4.2.0",
|
||||
"tsx": "^4.7.1",
|
||||
"typescript": "^5.4.2",
|
||||
"vitest": "^4.0.7"
|
||||
},
|
||||
"resolutions": {
|
||||
"glob": "10.5.0"
|
||||
},
|
||||
"packageManager": "yarn@4.12.0"
|
||||
}
|
||||
@ -1 +0,0 @@
|
||||
Subproject commit 4ad02ef4b50c4aa19f82549ca4e408422043bd8b
|
||||
@ -1,14 +0,0 @@
|
||||
/**
|
||||
* PostCSS 配置文件
|
||||
* 使用 ES Module 格式 - 统一现代标准
|
||||
*
|
||||
* 参考: https://postcss.org/
|
||||
*/
|
||||
|
||||
export default {
|
||||
// 插件列表
|
||||
plugins: {
|
||||
autoprefixer: {},
|
||||
tailwindcss: {},
|
||||
},
|
||||
}
|
||||
@ -1,36 +0,0 @@
|
||||
[
|
||||
{
|
||||
"path": "",
|
||||
"entries": [
|
||||
{
|
||||
"name": "offline-package/",
|
||||
"href": "/offline-package/",
|
||||
"type": "dir",
|
||||
"lastModified": "2025-09-19T00:00:00.000Z"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "offline-package/",
|
||||
"entries": [
|
||||
{
|
||||
"name": "sample-package/",
|
||||
"href": "/offline-package/sample-package/",
|
||||
"type": "dir",
|
||||
"lastModified": "2025-09-19T00:00:00.000Z"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "offline-package/sample-package/",
|
||||
"entries": [
|
||||
{
|
||||
"name": "v1.0.0.tar.gz",
|
||||
"href": "/offline-package/sample-package/v1.0.0.tar.gz",
|
||||
"type": "file",
|
||||
"size": 1024,
|
||||
"lastModified": "2025-09-19T00:00:00.000Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@ -1,342 +0,0 @@
|
||||
{
|
||||
"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": "aliyun-landing-zone",
|
||||
"category": "landing_zone"
|
||||
},
|
||||
{
|
||||
"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": "control-tower-landing-zone",
|
||||
"category": "landing_zone"
|
||||
},
|
||||
{
|
||||
"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-landing-zone",
|
||||
"category": "landing_zone"
|
||||
},
|
||||
{
|
||||
"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": "gcp-landing-zone",
|
||||
"category": "landing_zone"
|
||||
},
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -1,11 +0,0 @@
|
||||
[
|
||||
{
|
||||
"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"
|
||||
]
|
||||
}
|
||||
]
|
||||
@ -1,35 +0,0 @@
|
||||
[
|
||||
"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"
|
||||
]
|
||||
@ -1,25 +0,0 @@
|
||||
[
|
||||
{
|
||||
"path": "offline-package/",
|
||||
"entries": [
|
||||
{
|
||||
"name": "sample-package/",
|
||||
"href": "/offline-package/sample-package/",
|
||||
"type": "dir",
|
||||
"lastModified": "2025-09-19T00:00:00.000Z"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "offline-package/sample-package/",
|
||||
"entries": [
|
||||
{
|
||||
"name": "v1.0.0.tar.gz",
|
||||
"href": "/offline-package/sample-package/v1.0.0.tar.gz",
|
||||
"type": "file",
|
||||
"size": 1024,
|
||||
"lastModified": "2025-09-19T00:00:00.000Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@ -1,652 +0,0 @@
|
||||
[
|
||||
{
|
||||
"path": "",
|
||||
"entries": [
|
||||
{
|
||||
"name": "deb/",
|
||||
"href": "/deb/",
|
||||
"type": "dir",
|
||||
"lastModified": "2025-09-15T10:21:39Z"
|
||||
},
|
||||
{
|
||||
"name": "docs/",
|
||||
"href": "/docs/",
|
||||
"type": "dir",
|
||||
"lastModified": "2025-09-15T12:13:17Z"
|
||||
},
|
||||
{
|
||||
"name": "offline-package/",
|
||||
"href": "/offline-package/",
|
||||
"type": "dir",
|
||||
"lastModified": "2025-09-15T10:21:44Z"
|
||||
},
|
||||
{
|
||||
"name": "otel/",
|
||||
"href": "/otel/",
|
||||
"type": "dir",
|
||||
"lastModified": "2025-09-15T10:21:46Z"
|
||||
},
|
||||
{
|
||||
"name": "rpm/",
|
||||
"href": "/rpm/",
|
||||
"type": "dir",
|
||||
"lastModified": "2025-09-15T10:21:47Z"
|
||||
},
|
||||
{
|
||||
"name": "xray-core/",
|
||||
"href": "/xray-core/",
|
||||
"type": "dir",
|
||||
"lastModified": "2025-09-15T10:21:51Z"
|
||||
},
|
||||
{
|
||||
"name": "xstream/",
|
||||
"href": "/xstream/",
|
||||
"type": "dir",
|
||||
"lastModified": "2025-09-15T10:22:03Z"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "deb/",
|
||||
"entries": [
|
||||
{
|
||||
"name": "example.deb",
|
||||
"href": "/deb/example.deb",
|
||||
"type": "file",
|
||||
"size": 0,
|
||||
"lastModified": "2025-07-29T05:17:40Z"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "docs/",
|
||||
"entries": []
|
||||
},
|
||||
{
|
||||
"path": "offline-package/",
|
||||
"entries": [
|
||||
{
|
||||
"name": "apisix-gateway/",
|
||||
"href": "/offline-package/apisix-gateway/",
|
||||
"type": "dir",
|
||||
"lastModified": "2025-09-15T10:21:40Z"
|
||||
},
|
||||
{
|
||||
"name": "k3s/",
|
||||
"href": "/offline-package/k3s/",
|
||||
"type": "dir",
|
||||
"lastModified": "2025-09-15T10:21:41Z"
|
||||
},
|
||||
{
|
||||
"name": "kong-gateway/",
|
||||
"href": "/offline-package/kong-gateway/",
|
||||
"type": "dir",
|
||||
"lastModified": "2025-09-15T10:21:42Z"
|
||||
},
|
||||
{
|
||||
"name": "nginx-ingress/",
|
||||
"href": "/offline-package/nginx-ingress/",
|
||||
"type": "dir",
|
||||
"lastModified": "2025-09-15T10:21:43Z"
|
||||
},
|
||||
{
|
||||
"name": "sealos/",
|
||||
"href": "/offline-package/sealos/",
|
||||
"type": "dir",
|
||||
"lastModified": "2025-09-15T10:21:44Z"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "offline-package/apisix-gateway/",
|
||||
"entries": [
|
||||
{
|
||||
"name": "SHA256SUMS",
|
||||
"href": "/offline-package/apisix-gateway/SHA256SUMS",
|
||||
"type": "file",
|
||||
"size": 216,
|
||||
"lastModified": "2025-09-14T12:49:17Z",
|
||||
"sha256": "/offline-package/apisix-gateway/SHA256SUMS"
|
||||
},
|
||||
{
|
||||
"name": "offline-setup-apisix-gateway-amd64.tar.gz",
|
||||
"href": "/offline-package/apisix-gateway/offline-setup-apisix-gateway-amd64.tar.gz",
|
||||
"type": "file",
|
||||
"size": 194146012,
|
||||
"lastModified": "2025-09-14T12:43:32Z",
|
||||
"sha256": "/offline-package/apisix-gateway/SHA256SUMS"
|
||||
},
|
||||
{
|
||||
"name": "offline-setup-apisix-gateway-arm64.tar.gz",
|
||||
"href": "/offline-package/apisix-gateway/offline-setup-apisix-gateway-arm64.tar.gz",
|
||||
"type": "file",
|
||||
"size": 194144032,
|
||||
"lastModified": "2025-09-14T12:43:33Z",
|
||||
"sha256": "/offline-package/apisix-gateway/SHA256SUMS"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "offline-package/k3s/",
|
||||
"entries": [
|
||||
{
|
||||
"name": "offline-package-k3s-installer-amd64.tar.gz",
|
||||
"href": "/offline-package/k3s/offline-package-k3s-installer-amd64.tar.gz",
|
||||
"type": "file",
|
||||
"size": 223183297,
|
||||
"lastModified": "2025-09-14T09:51:55Z",
|
||||
"sha256": "/offline-package/k3s/offline-package-k3s-installer-amd64.tar.gz.sha256sum"
|
||||
},
|
||||
{
|
||||
"name": "offline-package-k3s-installer-amd64.tar.gz.sha256sum",
|
||||
"href": "/offline-package/k3s/offline-package-k3s-installer-amd64.tar.gz.sha256sum",
|
||||
"type": "file",
|
||||
"size": 109,
|
||||
"lastModified": "2025-09-14T10:13:38Z"
|
||||
},
|
||||
{
|
||||
"name": "offline-package-k3s-installer-arm64.tar.gz",
|
||||
"href": "/offline-package/k3s/offline-package-k3s-installer-arm64.tar.gz",
|
||||
"type": "file",
|
||||
"size": 204874562,
|
||||
"lastModified": "2025-09-14T09:51:54Z",
|
||||
"sha256": "/offline-package/k3s/offline-package-k3s-installer-arm64.tar.gz.sha256sum"
|
||||
},
|
||||
{
|
||||
"name": "offline-package-k3s-installer-arm64.tar.gz.sha256sum",
|
||||
"href": "/offline-package/k3s/offline-package-k3s-installer-arm64.tar.gz.sha256sum",
|
||||
"type": "file",
|
||||
"size": 109,
|
||||
"lastModified": "2025-09-14T10:12:39Z"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "offline-package/kong-gateway/",
|
||||
"entries": [
|
||||
{
|
||||
"name": "SHA256SUMS",
|
||||
"href": "/offline-package/kong-gateway/SHA256SUMS",
|
||||
"type": "file",
|
||||
"size": 212,
|
||||
"lastModified": "2025-09-14T12:17:41Z",
|
||||
"sha256": "/offline-package/kong-gateway/SHA256SUMS"
|
||||
},
|
||||
{
|
||||
"name": "offline-setup-kong-gateway-amd64.tar.gz",
|
||||
"href": "/offline-package/kong-gateway/offline-setup-kong-gateway-amd64.tar.gz",
|
||||
"type": "file",
|
||||
"size": 260781308,
|
||||
"lastModified": "2025-09-14T12:10:37Z",
|
||||
"sha256": "/offline-package/kong-gateway/SHA256SUMS"
|
||||
},
|
||||
{
|
||||
"name": "offline-setup-kong-gateway-arm64.tar.gz",
|
||||
"href": "/offline-package/kong-gateway/offline-setup-kong-gateway-arm64.tar.gz",
|
||||
"type": "file",
|
||||
"size": 259941381,
|
||||
"lastModified": "2025-09-14T12:10:38Z",
|
||||
"sha256": "/offline-package/kong-gateway/SHA256SUMS"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "offline-package/nginx-ingress/",
|
||||
"entries": [
|
||||
{
|
||||
"name": "SHA256SUMS",
|
||||
"href": "/offline-package/nginx-ingress/SHA256SUMS",
|
||||
"type": "file",
|
||||
"size": 214,
|
||||
"lastModified": "2025-09-14T11:18:37Z",
|
||||
"sha256": "/offline-package/nginx-ingress/SHA256SUMS"
|
||||
},
|
||||
{
|
||||
"name": "offline-setup-nginx-ingress-amd64.tar.gz",
|
||||
"href": "/offline-package/nginx-ingress/offline-setup-nginx-ingress-amd64.tar.gz",
|
||||
"type": "file",
|
||||
"size": 156094656,
|
||||
"lastModified": "2025-09-14T11:05:40Z",
|
||||
"sha256": "/offline-package/nginx-ingress/SHA256SUMS"
|
||||
},
|
||||
{
|
||||
"name": "offline-setup-nginx-ingress-arm64.tar.gz",
|
||||
"href": "/offline-package/nginx-ingress/offline-setup-nginx-ingress-arm64.tar.gz",
|
||||
"type": "file",
|
||||
"size": 155252914,
|
||||
"lastModified": "2025-09-14T11:05:40Z",
|
||||
"sha256": "/offline-package/nginx-ingress/SHA256SUMS"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "offline-package/sealos/",
|
||||
"entries": [
|
||||
{
|
||||
"name": "SHA256SUMS",
|
||||
"href": "/offline-package/sealos/SHA256SUMS",
|
||||
"type": "file",
|
||||
"size": 204,
|
||||
"lastModified": "2025-09-14T11:03:05Z",
|
||||
"sha256": "/offline-package/sealos/SHA256SUMS"
|
||||
},
|
||||
{
|
||||
"name": "sealos-offline-package-amd64.tar.gz",
|
||||
"href": "/offline-package/sealos/sealos-offline-package-amd64.tar.gz",
|
||||
"type": "file",
|
||||
"size": 890301186,
|
||||
"lastModified": "2025-09-14T10:37:31Z",
|
||||
"sha256": "/offline-package/sealos/SHA256SUMS"
|
||||
},
|
||||
{
|
||||
"name": "sealos-offline-package-arm64.tar.gz",
|
||||
"href": "/offline-package/sealos/sealos-offline-package-arm64.tar.gz",
|
||||
"type": "file",
|
||||
"size": 834350941,
|
||||
"lastModified": "2025-09-14T10:45:39Z",
|
||||
"sha256": "/offline-package/sealos/SHA256SUMS"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "otel/",
|
||||
"entries": [
|
||||
{
|
||||
"name": "OpenTelemetry/",
|
||||
"href": "/otel/OpenTelemetry/",
|
||||
"type": "dir",
|
||||
"lastModified": "2025-09-15T10:21:46Z"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "otel/OpenTelemetry/",
|
||||
"entries": [
|
||||
{
|
||||
"name": "v0.133.0/",
|
||||
"href": "/otel/OpenTelemetry/v0.133.0/",
|
||||
"type": "dir",
|
||||
"lastModified": "2025-09-15T10:21:46Z"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "otel/OpenTelemetry/v0.133.0/",
|
||||
"entries": [
|
||||
{
|
||||
"name": "opampsupervisor_0.133.0_linux_amd64",
|
||||
"href": "/otel/OpenTelemetry/v0.133.0/opampsupervisor_0.133.0_linux_amd64",
|
||||
"type": "file",
|
||||
"size": 22896824,
|
||||
"lastModified": "2025-09-03T08:18:29Z"
|
||||
},
|
||||
{
|
||||
"name": "opampsupervisor_0.133.0_linux_arm64",
|
||||
"href": "/otel/OpenTelemetry/v0.133.0/opampsupervisor_0.133.0_linux_arm64",
|
||||
"type": "file",
|
||||
"size": 21758136,
|
||||
"lastModified": "2025-09-03T08:18:35Z"
|
||||
},
|
||||
{
|
||||
"name": "otelcol-contrib_0.133.0_linux_amd64.tar.gz",
|
||||
"href": "/otel/OpenTelemetry/v0.133.0/otelcol-contrib_0.133.0_linux_amd64.tar.gz",
|
||||
"type": "file",
|
||||
"size": 90264630,
|
||||
"lastModified": "2025-09-03T08:18:36Z"
|
||||
},
|
||||
{
|
||||
"name": "otelcol-contrib_0.133.0_linux_arm64.tar.gz",
|
||||
"href": "/otel/OpenTelemetry/v0.133.0/otelcol-contrib_0.133.0_linux_arm64.tar.gz",
|
||||
"type": "file",
|
||||
"size": 82333025,
|
||||
"lastModified": "2025-09-03T08:18:35Z"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "rpm/",
|
||||
"entries": []
|
||||
},
|
||||
{
|
||||
"path": "xray-core/",
|
||||
"entries": [
|
||||
{
|
||||
"name": "v25.8.29/",
|
||||
"href": "/xray-core/v25.8.29/",
|
||||
"type": "dir",
|
||||
"lastModified": "2025-09-15T10:21:49Z"
|
||||
},
|
||||
{
|
||||
"name": "v25.8.3/",
|
||||
"href": "/xray-core/v25.8.3/",
|
||||
"type": "dir",
|
||||
"lastModified": "2025-09-15T10:21:49Z"
|
||||
},
|
||||
{
|
||||
"name": "v25.8.31/",
|
||||
"href": "/xray-core/v25.8.31/",
|
||||
"type": "dir",
|
||||
"lastModified": "2025-09-15T10:21:51Z"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "xray-core/v25.8.29/",
|
||||
"entries": [
|
||||
{
|
||||
"name": "Xray-linux-64.zip",
|
||||
"href": "/xray-core/v25.8.29/Xray-linux-64.zip",
|
||||
"type": "file",
|
||||
"size": 20188542,
|
||||
"lastModified": "2025-08-30T02:27:13Z"
|
||||
},
|
||||
{
|
||||
"name": "Xray-macos-64.zip",
|
||||
"href": "/xray-core/v25.8.29/Xray-macos-64.zip",
|
||||
"type": "file",
|
||||
"size": 19866304,
|
||||
"lastModified": "2025-08-30T02:27:14Z"
|
||||
},
|
||||
{
|
||||
"name": "Xray-windows-64.zip",
|
||||
"href": "/xray-core/v25.8.29/Xray-windows-64.zip",
|
||||
"type": "file",
|
||||
"size": 19762272,
|
||||
"lastModified": "2025-08-30T02:27:18Z"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "xray-core/v25.8.3/",
|
||||
"entries": [
|
||||
{
|
||||
"name": "Xray-linux-64.zip",
|
||||
"href": "/xray-core/v25.8.3/Xray-linux-64.zip",
|
||||
"type": "file",
|
||||
"size": 18431780,
|
||||
"lastModified": "2025-08-15T05:26:49Z"
|
||||
},
|
||||
{
|
||||
"name": "Xray-macos-64.zip",
|
||||
"href": "/xray-core/v25.8.3/Xray-macos-64.zip",
|
||||
"type": "file",
|
||||
"size": 18191431,
|
||||
"lastModified": "2025-08-15T05:26:49Z"
|
||||
},
|
||||
{
|
||||
"name": "Xray-windows-64.zip",
|
||||
"href": "/xray-core/v25.8.3/Xray-windows-64.zip",
|
||||
"type": "file",
|
||||
"size": 18071359,
|
||||
"lastModified": "2025-08-15T05:26:51Z"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "xray-core/v25.8.31/",
|
||||
"entries": [
|
||||
{
|
||||
"name": "Xray-linux-64.zip",
|
||||
"href": "/xray-core/v25.8.31/Xray-linux-64.zip",
|
||||
"type": "file",
|
||||
"size": 20200865,
|
||||
"lastModified": "2025-09-01T02:36:57Z"
|
||||
},
|
||||
{
|
||||
"name": "Xray-macos-64.zip",
|
||||
"href": "/xray-core/v25.8.31/Xray-macos-64.zip",
|
||||
"type": "file",
|
||||
"size": 19875652,
|
||||
"lastModified": "2025-09-01T02:36:53Z"
|
||||
},
|
||||
{
|
||||
"name": "Xray-windows-64.zip",
|
||||
"href": "/xray-core/v25.8.31/Xray-windows-64.zip",
|
||||
"type": "file",
|
||||
"size": 19776769,
|
||||
"lastModified": "2025-09-01T02:36:56Z"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "xstream/",
|
||||
"entries": [
|
||||
{
|
||||
"name": "android/",
|
||||
"href": "/xstream/android/",
|
||||
"type": "dir",
|
||||
"lastModified": "2025-09-15T10:21:53Z"
|
||||
},
|
||||
{
|
||||
"name": "ios/",
|
||||
"href": "/xstream/ios/",
|
||||
"type": "dir",
|
||||
"lastModified": "2025-09-15T10:21:55Z"
|
||||
},
|
||||
{
|
||||
"name": "linux/",
|
||||
"href": "/xstream/linux/",
|
||||
"type": "dir",
|
||||
"lastModified": "2025-09-15T10:21:57Z"
|
||||
},
|
||||
{
|
||||
"name": "macos/",
|
||||
"href": "/xstream/macos/",
|
||||
"type": "dir",
|
||||
"lastModified": "2025-09-15T10:22:00Z"
|
||||
},
|
||||
{
|
||||
"name": "windows/",
|
||||
"href": "/xstream/windows/",
|
||||
"type": "dir",
|
||||
"lastModified": "2025-09-15T10:22:03Z"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "xstream/android/",
|
||||
"entries": [
|
||||
{
|
||||
"name": "latest/",
|
||||
"href": "/xstream/android/latest/",
|
||||
"type": "dir",
|
||||
"lastModified": "2025-09-15T10:21:53Z"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "xstream/android/latest/",
|
||||
"entries": [
|
||||
{
|
||||
"name": "xstream-android-latest.apk",
|
||||
"href": "/xstream/android/latest/xstream-android-latest.apk",
|
||||
"type": "file",
|
||||
"size": 23383658,
|
||||
"lastModified": "2025-08-04T06:26:15Z"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "xstream/ios/",
|
||||
"entries": [
|
||||
{
|
||||
"name": "latest/",
|
||||
"href": "/xstream/ios/latest/",
|
||||
"type": "dir",
|
||||
"lastModified": "2025-09-15T10:21:55Z"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "xstream/ios/latest/",
|
||||
"entries": [
|
||||
{
|
||||
"name": "xstream-ios-latest.ipa",
|
||||
"href": "/xstream/ios/latest/xstream-ios-latest.ipa",
|
||||
"type": "file",
|
||||
"size": 26579614,
|
||||
"lastModified": "2025-08-04T06:26:20Z"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "xstream/linux/",
|
||||
"entries": [
|
||||
{
|
||||
"name": "latest/",
|
||||
"href": "/xstream/linux/latest/",
|
||||
"type": "dir",
|
||||
"lastModified": "2025-09-15T10:21:56Z"
|
||||
},
|
||||
{
|
||||
"name": "stable/",
|
||||
"href": "/xstream/linux/stable/",
|
||||
"type": "dir",
|
||||
"lastModified": "2025-09-15T10:21:57Z"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "xstream/linux/latest/",
|
||||
"entries": [
|
||||
{
|
||||
"name": "xstream-linux-latest.zip",
|
||||
"href": "/xstream/linux/latest/xstream-linux-latest.zip",
|
||||
"type": "file",
|
||||
"size": 14247578,
|
||||
"lastModified": "2025-08-04T06:26:14Z"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "xstream/linux/stable/",
|
||||
"entries": [
|
||||
{
|
||||
"name": "xstream-linux-latest.zip",
|
||||
"href": "/xstream/linux/stable/xstream-linux-latest.zip",
|
||||
"type": "file",
|
||||
"size": 14058826,
|
||||
"lastModified": "2025-08-15T06:26:54Z"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "xstream/macos/",
|
||||
"entries": [
|
||||
{
|
||||
"name": "docs/",
|
||||
"href": "/xstream/macos/docs/",
|
||||
"type": "dir",
|
||||
"lastModified": "2025-09-15T10:21:58Z"
|
||||
},
|
||||
{
|
||||
"name": "latest/",
|
||||
"href": "/xstream/macos/latest/",
|
||||
"type": "dir",
|
||||
"lastModified": "2025-09-15T10:21:59Z"
|
||||
},
|
||||
{
|
||||
"name": "stable/",
|
||||
"href": "/xstream/macos/stable/",
|
||||
"type": "dir",
|
||||
"lastModified": "2025-09-15T10:22:00Z"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "xstream/macos/docs/",
|
||||
"entries": []
|
||||
},
|
||||
{
|
||||
"path": "xstream/macos/latest/",
|
||||
"entries": [
|
||||
{
|
||||
"name": "xstream-macos-latest.dmg",
|
||||
"href": "/xstream/macos/latest/xstream-macos-latest.dmg",
|
||||
"type": "file",
|
||||
"size": 39446900,
|
||||
"lastModified": "2025-08-04T06:26:18Z"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "xstream/macos/stable/",
|
||||
"entries": [
|
||||
{
|
||||
"name": "xstream-macos-latest.dmg",
|
||||
"href": "/xstream/macos/stable/xstream-macos-latest.dmg",
|
||||
"type": "file",
|
||||
"size": 42458674,
|
||||
"lastModified": "2025-08-15T06:26:57Z"
|
||||
},
|
||||
{
|
||||
"name": "xstream-release-v0.2.0.dmg",
|
||||
"href": "/xstream/macos/stable/xstream-release-v0.2.0.dmg",
|
||||
"type": "file",
|
||||
"size": 43614833,
|
||||
"lastModified": "2025-07-30T01:12:42Z"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "xstream/windows/",
|
||||
"entries": [
|
||||
{
|
||||
"name": "latest/",
|
||||
"href": "/xstream/windows/latest/",
|
||||
"type": "dir",
|
||||
"lastModified": "2025-09-15T10:22:02Z"
|
||||
},
|
||||
{
|
||||
"name": "stable/",
|
||||
"href": "/xstream/windows/stable/",
|
||||
"type": "dir",
|
||||
"lastModified": "2025-09-15T10:22:02Z"
|
||||
},
|
||||
{
|
||||
"name": "win10/",
|
||||
"href": "/xstream/windows/win10/",
|
||||
"type": "dir",
|
||||
"lastModified": "2025-09-15T10:22:03Z"
|
||||
},
|
||||
{
|
||||
"name": "win11/",
|
||||
"href": "/xstream/windows/win11/",
|
||||
"type": "dir",
|
||||
"lastModified": "2025-09-15T10:22:03Z"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "xstream/windows/latest/",
|
||||
"entries": [
|
||||
{
|
||||
"name": "xstream-windows-latest.zip",
|
||||
"href": "/xstream/windows/latest/xstream-windows-latest.zip",
|
||||
"type": "file",
|
||||
"size": 18510460,
|
||||
"lastModified": "2025-08-04T06:26:19Z"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "xstream/windows/stable/",
|
||||
"entries": [
|
||||
{
|
||||
"name": "xstream-windows-latest.zip",
|
||||
"href": "/xstream/windows/stable/xstream-windows-latest.zip",
|
||||
"type": "file",
|
||||
"size": 18312976,
|
||||
"lastModified": "2025-08-15T06:26:59Z"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "xstream/windows/win10/",
|
||||
"entries": []
|
||||
},
|
||||
{
|
||||
"path": "xstream/windows/win11/",
|
||||
"entries": []
|
||||
}
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
Before Width: | Height: | Size: 31 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.5 KiB |
@ -1,26 +0,0 @@
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
function stripTrailingSlashes(pathname: string) {
|
||||
if (pathname.length <= 1) return pathname
|
||||
return pathname.replace(/\/+$/u, '')
|
||||
}
|
||||
|
||||
export default async function proxy(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl
|
||||
const isApiRoute = pathname === '/api' || pathname.startsWith('/api/')
|
||||
const shouldStrip = isApiRoute && pathname.length > 1 && pathname.endsWith('/')
|
||||
|
||||
if (!shouldStrip) {
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
const url = request.nextUrl.clone()
|
||||
url.pathname = stripTrailingSlashes(pathname)
|
||||
|
||||
return NextResponse.rewrite(url)
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ['/api/:path*'],
|
||||
}
|
||||
@ -1,227 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
CONFIG_DIR="${ROOT_DIR}/src/config"
|
||||
BASE_FILE="${CONFIG_DIR}/runtime-service-config.base.yaml"
|
||||
SIT_FILE="${CONFIG_DIR}/runtime-service-config.sit.yaml"
|
||||
PROD_FILE="${CONFIG_DIR}/runtime-service-config.prod.yaml"
|
||||
OUTPUT_FILE="${CONFIG_DIR}/runtime-service-config.yaml"
|
||||
|
||||
HOSTNAME_OVERRIDE=""
|
||||
ENV_OVERRIDE=""
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage: scripts/gen-runtime-config.sh [--hostname <hostname>] [--env <env>] [--output <path>]
|
||||
|
||||
Options:
|
||||
--hostname Override the hostname used for environment detection.
|
||||
--env Explicitly set the environment (prod|sit). Overrides hostname detection.
|
||||
--output Output path for the merged runtime configuration. Defaults to src/config/runtime-service-config.yaml.
|
||||
-h, --help Show this help message.
|
||||
USAGE
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--hostname)
|
||||
HOSTNAME_OVERRIDE="$2"
|
||||
shift 2
|
||||
;;
|
||||
--env)
|
||||
ENV_OVERRIDE="$2"
|
||||
shift 2
|
||||
;;
|
||||
--output)
|
||||
OUTPUT_FILE="$2"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
HOSTNAME_OVERRIDE="$1"
|
||||
shift 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ ! -f "${BASE_FILE}" ]]; then
|
||||
echo "error: base configuration file not found at ${BASE_FILE}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v yq >/dev/null 2>&1; then
|
||||
echo 'error: yq is required to merge runtime configuration files' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sanitize_hostname() {
|
||||
local value="$1"
|
||||
value="${value%%/*}"
|
||||
value="${value##*://}"
|
||||
value="${value%%:*}"
|
||||
value="${value,,}"
|
||||
value="${value%.}"
|
||||
echo "$value"
|
||||
}
|
||||
|
||||
detect_hostname() {
|
||||
if [[ -n "${HOSTNAME_OVERRIDE}" ]]; then
|
||||
sanitize_hostname "${HOSTNAME_OVERRIDE}"
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ -n "${RUNTIME_HOSTNAME:-}" ]]; then
|
||||
sanitize_hostname "${RUNTIME_HOSTNAME}"
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ -n "${NEXT_RUNTIME_HOSTNAME:-}" ]]; then
|
||||
sanitize_hostname "${NEXT_RUNTIME_HOSTNAME}"
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ -n "${DEPLOYMENT_HOSTNAME:-}" ]]; then
|
||||
sanitize_hostname "${DEPLOYMENT_HOSTNAME}"
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ -n "${VERCEL_URL:-}" ]]; then
|
||||
sanitize_hostname "${VERCEL_URL}"
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ -n "${NEXT_PUBLIC_VERCEL_URL:-}" ]]; then
|
||||
sanitize_hostname "${NEXT_PUBLIC_VERCEL_URL}"
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ -n "${URL:-}" ]]; then
|
||||
sanitize_hostname "${URL}"
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ -n "${HOSTNAME:-}" ]]; then
|
||||
sanitize_hostname "${HOSTNAME}"
|
||||
return
|
||||
fi
|
||||
|
||||
echo ""
|
||||
}
|
||||
|
||||
normalize_env_key() {
|
||||
local value="$1"
|
||||
value="${value//[^A-Za-z0-9]/_}"
|
||||
value="${value##_}"
|
||||
value="${value%%_}"
|
||||
echo "${value,,}"
|
||||
}
|
||||
|
||||
detect_environment_from_hostname() {
|
||||
local hostname="$1"
|
||||
if [[ -z "${hostname}" ]]; then
|
||||
echo "prod"
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ "${hostname}" == dev.* || "${hostname}" == dev-* || "${hostname}" == *.dev.* ]]; then
|
||||
echo "sit"
|
||||
return
|
||||
fi
|
||||
|
||||
echo "prod"
|
||||
}
|
||||
|
||||
resolve_environment() {
|
||||
if [[ -n "${ENV_OVERRIDE}" ]]; then
|
||||
normalize_env_key "${ENV_OVERRIDE}"
|
||||
return
|
||||
fi
|
||||
|
||||
local env_candidates=(
|
||||
"${RUNTIME_ENV:-}"
|
||||
"${NEXT_RUNTIME_ENV:-}"
|
||||
"${APP_ENV:-}"
|
||||
"${NODE_ENV:-}"
|
||||
)
|
||||
|
||||
for candidate in "${env_candidates[@]}"; do
|
||||
if [[ -n "${candidate}" ]]; then
|
||||
local normalized
|
||||
normalized="$(normalize_env_key "${candidate}")"
|
||||
case "${normalized}" in
|
||||
prod|production|release|main)
|
||||
echo "prod"
|
||||
return
|
||||
;;
|
||||
sit|staging|test)
|
||||
echo "sit"
|
||||
return
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
done
|
||||
|
||||
local detected_hostname
|
||||
detected_hostname="$(detect_hostname)"
|
||||
detect_environment_from_hostname "${detected_hostname}"
|
||||
}
|
||||
|
||||
detect_region() {
|
||||
local hostname="$1"
|
||||
if [[ "${hostname}" == cn-* ]]; then
|
||||
echo "CN"
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ "${hostname}" == global-* ]]; then
|
||||
echo "Global"
|
||||
return
|
||||
fi
|
||||
|
||||
echo "Default"
|
||||
}
|
||||
|
||||
ENVIRONMENT="$(resolve_environment)"
|
||||
HOSTNAME_VALUE="$(detect_hostname)"
|
||||
REGION_LABEL="$(detect_region "${HOSTNAME_VALUE}")"
|
||||
|
||||
case "${ENVIRONMENT}" in
|
||||
sit)
|
||||
ENV_FILE="${SIT_FILE}"
|
||||
;;
|
||||
prod|production)
|
||||
ENV_FILE="${PROD_FILE}"
|
||||
ENVIRONMENT="prod"
|
||||
;;
|
||||
*)
|
||||
echo "warning: unknown environment '${ENVIRONMENT}', defaulting to production" >&2
|
||||
ENV_FILE="${PROD_FILE}"
|
||||
ENVIRONMENT="prod"
|
||||
;;
|
||||
endcase
|
||||
|
||||
if [[ ! -f "${ENV_FILE}" ]]; then
|
||||
echo "warning: environment configuration file '${ENV_FILE}' not found, using base configuration only" >&2
|
||||
ENV_FILE=""
|
||||
fi
|
||||
|
||||
OUTPUT_PATH="${OUTPUT_FILE}"
|
||||
if [[ "${OUTPUT_PATH}" != /* ]]; then
|
||||
OUTPUT_PATH="${ROOT_DIR}/${OUTPUT_PATH}"
|
||||
fi
|
||||
|
||||
tmp_file="${OUTPUT_PATH}.tmp"
|
||||
|
||||
if [[ -n "${ENV_FILE}" ]]; then
|
||||
yq eval-all 'select(fileIndex == 0) * select(fileIndex == 1)' "${BASE_FILE}" "${ENV_FILE}" >"${tmp_file}"
|
||||
else
|
||||
cp "${BASE_FILE}" "${tmp_file}"
|
||||
fi
|
||||
|
||||
mv "${tmp_file}" "${OUTPUT_PATH}"
|
||||
|
||||
echo "Generated runtime-service-config.yaml (${ENVIRONMENT^^}${REGION_LABEL:+ / ${REGION_LABEL}}) from hostname '${HOSTNAME_VALUE:-unknown}'" >&2
|
||||
@ -1,106 +0,0 @@
|
||||
import { promises as fs } from 'fs'
|
||||
import path from 'path'
|
||||
import yaml from 'js-yaml'
|
||||
|
||||
type Language = 'zh' | 'en'
|
||||
|
||||
type HeroContent = {
|
||||
eyebrow: string
|
||||
title: string
|
||||
tagline?: string
|
||||
description: string
|
||||
focusAreas?: string[]
|
||||
ctaLabel?: string
|
||||
products?: Array<{
|
||||
label: string
|
||||
headline: string
|
||||
description: string
|
||||
href: string
|
||||
}>
|
||||
}
|
||||
|
||||
const CONTENT_ROOT = path.join(process.cwd(), 'src', 'content')
|
||||
const OUTPUT_ROOT = path.join(process.cwd(), 'src', 'data', 'content')
|
||||
|
||||
function parseFrontMatter(raw: string): { metadata: Record<string, any> } {
|
||||
const frontMatterMatch = raw.match(/^---\s*\n([\s\S]*?)\n---\s*\n?([\s\S]*)$/)
|
||||
if (!frontMatterMatch) {
|
||||
return { metadata: {} }
|
||||
}
|
||||
|
||||
const [, frontMatter] = frontMatterMatch
|
||||
try {
|
||||
const metadata = yaml.load(frontMatter) as Record<string, any>
|
||||
return { metadata: metadata || {} }
|
||||
} catch (error) {
|
||||
console.error('Failed to parse YAML frontmatter:', error)
|
||||
return { metadata: {} }
|
||||
}
|
||||
}
|
||||
|
||||
async function generateHomepageContent() {
|
||||
const languages: Language[] = ['zh', 'en']
|
||||
const content: Record<Language, HeroContent> = {} as any
|
||||
|
||||
for (const lang of languages) {
|
||||
try {
|
||||
const filePath = path.join(CONTENT_ROOT, 'homepage', lang, 'hero.md')
|
||||
const raw = await fs.readFile(filePath, 'utf-8')
|
||||
const { metadata } = parseFrontMatter(raw)
|
||||
content[lang] = metadata as HeroContent
|
||||
} catch (error) {
|
||||
console.error(`Failed to read homepage content for ${lang}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
async function generateProductContent(product: 'xstream' | 'xcloudflow' | 'xscopehub') {
|
||||
const languages: Language[] = ['zh', 'en']
|
||||
const content: Record<Language, any> = {} as any
|
||||
|
||||
for (const lang of languages) {
|
||||
try {
|
||||
const heroPath = path.join(CONTENT_ROOT, 'product', product, lang, 'hero.md')
|
||||
const raw = await fs.readFile(heroPath, 'utf-8')
|
||||
const { metadata } = parseFrontMatter(raw)
|
||||
content[lang] = metadata
|
||||
} catch (error) {
|
||||
console.error(`Failed to read ${product} content for ${lang}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// Create output directory
|
||||
await fs.mkdir(OUTPUT_ROOT, { recursive: true })
|
||||
|
||||
// Generate homepage content
|
||||
console.log('Generating homepage content...')
|
||||
const homepageContent = await generateHomepageContent()
|
||||
await fs.writeFile(
|
||||
path.join(OUTPUT_ROOT, 'homepage.json'),
|
||||
JSON.stringify(homepageContent, null, 2)
|
||||
)
|
||||
|
||||
// Generate product content
|
||||
const products = ['xstream', 'xcloudflow', 'xscopehub'] as const
|
||||
for (const product of products) {
|
||||
console.log(`Generating ${product} content...`)
|
||||
const productContent = await generateProductContent(product)
|
||||
await fs.writeFile(
|
||||
path.join(OUTPUT_ROOT, `${product}.json`),
|
||||
JSON.stringify(productContent, null, 2)
|
||||
)
|
||||
}
|
||||
|
||||
console.log('Content generation complete!')
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('Content generation failed:', error)
|
||||
process.exit(1)
|
||||
})
|
||||
@ -1,113 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Logger Function
|
||||
log() {
|
||||
local message="$1"
|
||||
local type="$2"
|
||||
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
|
||||
local color
|
||||
local endcolor="\033[0m"
|
||||
|
||||
case "$type" in
|
||||
"info") color="\033[38;5;79m" ;;
|
||||
"success") color="\033[1;32m" ;;
|
||||
"error") color="\033[1;31m" ;;
|
||||
*) color="\033[1;34m" ;;
|
||||
esac
|
||||
|
||||
echo -e "${color}${timestamp} - ${message}${endcolor}"
|
||||
}
|
||||
|
||||
# Error handler function
|
||||
handle_error() {
|
||||
local exit_code=$1
|
||||
local error_message="$2"
|
||||
log "Error: $error_message (Exit Code: $exit_code)" "error"
|
||||
exit $exit_code
|
||||
}
|
||||
|
||||
# Function to check for command availability
|
||||
command_exists() {
|
||||
command -v "$1" &> /dev/null
|
||||
}
|
||||
|
||||
check_os() {
|
||||
if ! [ -f "/etc/debian_version" ]; then
|
||||
echo "Error: This script is only supported on Debian-based systems."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to Install the script pre-requisites
|
||||
install_pre_reqs() {
|
||||
log "Installing pre-requisites" "info"
|
||||
|
||||
# Run 'apt-get update'
|
||||
if ! apt-get update -y; then
|
||||
handle_error "$?" "Failed to run 'apt-get update'"
|
||||
fi
|
||||
|
||||
# Run 'apt-get install'
|
||||
if ! apt-get install -y apt-transport-https ca-certificates curl gnupg; then
|
||||
handle_error "$?" "Failed to install packages"
|
||||
fi
|
||||
|
||||
if ! mkdir -p /usr/share/keyrings; then
|
||||
handle_error "$?" "Makes sure the path /usr/share/keyrings exist or run ' mkdir -p /usr/share/keyrings' with sudo"
|
||||
fi
|
||||
|
||||
rm -f /usr/share/keyrings/nodesource.gpg || true
|
||||
rm -f /etc/apt/sources.list.d/nodesource.list || true
|
||||
|
||||
# Run 'curl' and 'gpg' to download and import the NodeSource signing key
|
||||
if ! curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /usr/share/keyrings/nodesource.gpg; then
|
||||
handle_error "$?" "Failed to download and import the NodeSource signing key"
|
||||
fi
|
||||
|
||||
# Explicitly set the permissions to ensure the file is readable by all
|
||||
if ! chmod 644 /usr/share/keyrings/nodesource.gpg; then
|
||||
handle_error "$?" "Failed to set correct permissions on /usr/share/keyrings/nodesource.gpg"
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to configure the Repo
|
||||
configure_repo() {
|
||||
local node_version=$1
|
||||
|
||||
arch=$(dpkg --print-architecture)
|
||||
if [ "$arch" != "amd64" ] && [ "$arch" != "arm64" ] && [ "$arch" != "armhf" ]; then
|
||||
handle_error "1" "Unsupported architecture: $arch. Only amd64, arm64, and armhf are supported."
|
||||
fi
|
||||
|
||||
echo "deb [arch=$arch signed-by=/usr/share/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$node_version nodistro main" | tee /etc/apt/sources.list.d/nodesource.list > /dev/null
|
||||
|
||||
# N|solid Config
|
||||
echo "Package: nsolid" | tee /etc/apt/preferences.d/nsolid > /dev/null
|
||||
echo "Pin: origin deb.nodesource.com" | tee -a /etc/apt/preferences.d/nsolid > /dev/null
|
||||
echo "Pin-Priority: 600" | tee -a /etc/apt/preferences.d/nsolid > /dev/null
|
||||
|
||||
# Nodejs Config
|
||||
echo "Package: nodejs" | tee /etc/apt/preferences.d/nodejs > /dev/null
|
||||
echo "Pin: origin deb.nodesource.com" | tee -a /etc/apt/preferences.d/nodejs > /dev/null
|
||||
echo "Pin-Priority: 600" | tee -a /etc/apt/preferences.d/nodejs > /dev/null
|
||||
|
||||
# Run 'apt-get update'
|
||||
if ! apt-get update -y; then
|
||||
handle_error "$?" "Failed to run 'apt-get update'"
|
||||
else
|
||||
log "Repository configured successfully."
|
||||
log "To install Node.js, run: apt-get install nodejs -y" "info"
|
||||
log "You can use N|solid Runtime as a node.js alternative" "info"
|
||||
log "To install N|solid Runtime, run: apt-get install nsolid -y \n" "success"
|
||||
fi
|
||||
}
|
||||
|
||||
# Define Node.js version
|
||||
NODE_VERSION="20.x"
|
||||
|
||||
# Check OS
|
||||
check_os
|
||||
|
||||
# Main execution
|
||||
install_pre_reqs || handle_error $? "Failed installing pre-requisites"
|
||||
configure_repo "$NODE_VERSION" || handle_error $? "Failed configuring repository"
|
||||
@ -1,23 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const { spawn } = require('node:child_process')
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
|
||||
const child = spawn('next', ['start', ...args], {
|
||||
stdio: 'inherit',
|
||||
shell: process.platform === 'win32',
|
||||
})
|
||||
|
||||
child.on('error', (error) => {
|
||||
console.error('Failed to launch Next.js:', error)
|
||||
process.exit(1)
|
||||
})
|
||||
|
||||
child.on('close', (code, signal) => {
|
||||
if (signal) {
|
||||
process.kill(process.pid, signal)
|
||||
} else {
|
||||
process.exit(code ?? 0)
|
||||
}
|
||||
})
|
||||
@ -1,88 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||
REPO_ROOT=$(cd "${SCRIPT_DIR}/.." && pwd)
|
||||
CONTENT_DIR="${REPO_ROOT}/src/content"
|
||||
REMOTE_REPO="${CONTENT_REMOTE_REPO:-}"
|
||||
REMOTE_BRANCH="${CONTENT_REMOTE_BRANCH:-main}"
|
||||
REMOTE_SUBDIR="${CONTENT_REMOTE_SUBDIR:-content}"
|
||||
|
||||
usage() {
|
||||
cat <<USAGE
|
||||
Usage: $(basename "$0") <push|pull>
|
||||
|
||||
Environment variables:
|
||||
CONTENT_REMOTE_REPO Git URL or local path of the external content repository (required)
|
||||
CONTENT_REMOTE_BRANCH Branch to sync (default: main)
|
||||
CONTENT_REMOTE_SUBDIR Subdirectory inside the external repo to mirror content/ (default: content)
|
||||
GIT_AUTHOR_NAME Author name used for commits when pushing (default: Content Sync Bot)
|
||||
GIT_AUTHOR_EMAIL Author email used for commits when pushing (default: content-sync@example.com)
|
||||
USAGE
|
||||
}
|
||||
|
||||
if [[ $# -ne 1 ]]; then
|
||||
usage
|
||||
exit 1
|
||||
fi
|
||||
|
||||
MODE="$1"
|
||||
|
||||
if [[ -z "${REMOTE_REPO}" ]]; then
|
||||
echo "CONTENT_REMOTE_REPO is required" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -d "${CONTENT_DIR}" ]]; then
|
||||
echo "Content directory not found: ${CONTENT_DIR}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TMP_DIR=$(mktemp -d)
|
||||
trap 'rm -rf "${TMP_DIR}"' EXIT
|
||||
|
||||
clone_repo() {
|
||||
git clone --depth=1 --branch "${REMOTE_BRANCH}" "${REMOTE_REPO}" "${TMP_DIR}/repo" >/dev/null 2>&1 || \
|
||||
git clone --depth=1 "${REMOTE_REPO}" "${TMP_DIR}/repo"
|
||||
(cd "${TMP_DIR}/repo" && git checkout "${REMOTE_BRANCH}" >/dev/null 2>&1 || git checkout -b "${REMOTE_BRANCH}")
|
||||
}
|
||||
|
||||
sync_push() {
|
||||
clone_repo
|
||||
mkdir -p "${TMP_DIR}/repo/${REMOTE_SUBDIR}"
|
||||
rsync -a --delete "${CONTENT_DIR}/" "${TMP_DIR}/repo/${REMOTE_SUBDIR}/"
|
||||
(
|
||||
cd "${TMP_DIR}/repo"
|
||||
if [[ -n "$(git status --porcelain)" ]]; then
|
||||
git config user.name "${GIT_AUTHOR_NAME:-Content Sync Bot}"
|
||||
git config user.email "${GIT_AUTHOR_EMAIL:-content-sync@example.com}"
|
||||
git add "${REMOTE_SUBDIR}"
|
||||
git commit -m "chore(content): sync from dashboard"
|
||||
git push origin "${REMOTE_BRANCH}"
|
||||
else
|
||||
echo "No changes to push"
|
||||
fi
|
||||
)
|
||||
}
|
||||
|
||||
sync_pull() {
|
||||
clone_repo
|
||||
if [[ ! -d "${TMP_DIR}/repo/${REMOTE_SUBDIR}" ]]; then
|
||||
echo "Remote repository does not contain ${REMOTE_SUBDIR}" >&2
|
||||
exit 1
|
||||
fi
|
||||
rsync -a --delete "${TMP_DIR}/repo/${REMOTE_SUBDIR}/" "${CONTENT_DIR}/"
|
||||
}
|
||||
|
||||
case "${MODE}" in
|
||||
push)
|
||||
sync_push
|
||||
;;
|
||||
pull)
|
||||
sync_pull
|
||||
;;
|
||||
*)
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@ -1,323 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
ChangeEvent,
|
||||
FormEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
|
||||
import { AuthLayout } from '@components/auth/AuthLayout'
|
||||
import { useLanguage } from '@i18n/LanguageProvider'
|
||||
import { translations } from '@i18n/translations'
|
||||
|
||||
const VERIFICATION_CODE_LENGTH = 6
|
||||
const RESEND_COOLDOWN_SECONDS = 60
|
||||
|
||||
const EMAIL_QUERY_KEYS = ['email', 'address', 'identifier', 'account'] as const
|
||||
|
||||
type AlertState = { type: 'error' | 'success' | 'info'; message: string }
|
||||
|
||||
export default function EmailVerificationContent() {
|
||||
const { language } = useLanguage()
|
||||
const t = translations[language].auth.emailVerification
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const redirectTimeoutRef = useRef<number | null>(null)
|
||||
|
||||
const email = useMemo(() => {
|
||||
for (const key of EMAIL_QUERY_KEYS) {
|
||||
const value = searchParams.get(key)
|
||||
if (typeof value === 'string' && value.trim().length > 0) {
|
||||
return value.trim().toLowerCase()
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}, [searchParams])
|
||||
|
||||
const statusParam = searchParams.get('status')
|
||||
const errorParam = searchParams.get('error')
|
||||
|
||||
const descriptionEmail = email || t.emailFallback || ''
|
||||
const description = useMemo(() => {
|
||||
if (!t.description.includes('{{email}}')) {
|
||||
return t.description
|
||||
}
|
||||
return t.description.replace('{{email}}', descriptionEmail)
|
||||
}, [descriptionEmail, t.description])
|
||||
|
||||
const initialAlert = useMemo<AlertState | null>(() => {
|
||||
if (statusParam === 'sent') {
|
||||
return { type: 'info', message: t.alerts.verificationSent }
|
||||
}
|
||||
if (statusParam === 'resent') {
|
||||
return {
|
||||
type: 'success',
|
||||
message: t.alerts.verificationResent ?? t.alerts.verificationSent,
|
||||
}
|
||||
}
|
||||
if (errorParam) {
|
||||
const normalized = errorParam
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '_')
|
||||
.replace(/^_+|_+$/g, '')
|
||||
const errorMap: Record<string, string> = {
|
||||
missing_verification: t.alerts.codeRequired,
|
||||
verification_failed: t.alerts.verificationFailed,
|
||||
invalid_code: t.alerts.verificationFailed,
|
||||
invalid_email: t.alerts.missingEmail,
|
||||
code_required: t.alerts.codeRequired,
|
||||
}
|
||||
const message = errorMap[normalized] ?? t.alerts.genericError
|
||||
return { type: normalized === 'already_verified' ? 'success' : 'error', message }
|
||||
}
|
||||
if (!email) {
|
||||
return { type: 'info', message: t.alerts.missingEmail }
|
||||
}
|
||||
return null
|
||||
}, [email, errorParam, statusParam, t.alerts])
|
||||
|
||||
const [alert, setAlert] = useState<AlertState | null>(initialAlert)
|
||||
const [code, setCode] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [isResending, setIsResending] = useState(false)
|
||||
const [resendCooldown, setResendCooldown] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
setAlert(initialAlert)
|
||||
}, [initialAlert])
|
||||
|
||||
useEffect(() => {
|
||||
if (resendCooldown <= 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setResendCooldown(previous => Math.max(previous - 1, 0))
|
||||
}, 1000)
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId)
|
||||
}
|
||||
}, [resendCooldown])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (redirectTimeoutRef.current !== null) {
|
||||
window.clearTimeout(redirectTimeoutRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleCodeChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
|
||||
const digitsOnly = event.target.value.replace(/\D/g, '').slice(0, VERIFICATION_CODE_LENGTH)
|
||||
setCode(digitsOnly)
|
||||
}, [])
|
||||
|
||||
const hasEmail = email.length > 0
|
||||
const isSubmitDisabled =
|
||||
isSubmitting || !hasEmail || code.length !== VERIFICATION_CODE_LENGTH
|
||||
const isResendDisabled = isResending || resendCooldown > 0 || !hasEmail
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
if (isSubmitting) {
|
||||
return
|
||||
}
|
||||
if (!hasEmail) {
|
||||
setAlert({ type: 'error', message: t.alerts.missingEmail })
|
||||
return
|
||||
}
|
||||
if (code.length !== VERIFICATION_CODE_LENGTH) {
|
||||
setAlert({ type: 'error', message: t.alerts.codeRequired })
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
setAlert(null)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/verify-email', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ email, code }),
|
||||
})
|
||||
|
||||
const payload = (await response.json().catch(() => ({}))) as {
|
||||
success?: boolean
|
||||
error?: string | null
|
||||
}
|
||||
|
||||
if (!response.ok || payload?.success !== true) {
|
||||
const errorCode = typeof payload?.error === 'string' ? payload.error : 'verification_failed'
|
||||
const normalized = errorCode
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '_')
|
||||
.replace(/^_+|_+$/g, '')
|
||||
|
||||
if (normalized === 'already_verified') {
|
||||
const message = t.alerts.verificationReady ?? t.alerts.verificationSent
|
||||
setAlert({ type: 'success', message })
|
||||
redirectTimeoutRef.current = window.setTimeout(() => {
|
||||
router.push('/login?registered=1')
|
||||
}, 1200)
|
||||
return
|
||||
}
|
||||
|
||||
const errorMap: Record<string, string> = {
|
||||
missing_verification: t.alerts.codeRequired,
|
||||
invalid_code: t.alerts.verificationFailed,
|
||||
verification_failed: t.alerts.verificationFailed,
|
||||
invalid_email: t.alerts.missingEmail,
|
||||
code_expired: t.alerts.verificationFailed,
|
||||
}
|
||||
const message = errorMap[normalized] ?? t.alerts.genericError
|
||||
setAlert({ type: 'error', message })
|
||||
return
|
||||
}
|
||||
|
||||
const successMessage = t.alerts.verificationReady ?? t.alerts.verificationSent
|
||||
setAlert({ type: 'success', message: successMessage })
|
||||
setCode('')
|
||||
redirectTimeoutRef.current = window.setTimeout(() => {
|
||||
router.push('/login?registered=1')
|
||||
}, 1200)
|
||||
} catch (error) {
|
||||
console.error('Email verification request failed', error)
|
||||
setAlert({ type: 'error', message: t.alerts.genericError })
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}, [code, email, hasEmail, isSubmitting, router, t.alerts])
|
||||
|
||||
const handleResend = useCallback(async () => {
|
||||
if (isResending || !hasEmail) {
|
||||
if (!hasEmail) {
|
||||
setAlert({ type: 'error', message: t.alerts.missingEmail })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
setIsResending(true)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/verify-email/send', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ email }),
|
||||
})
|
||||
|
||||
const payload = (await response.json().catch(() => ({}))) as {
|
||||
success?: boolean
|
||||
error?: string | null
|
||||
}
|
||||
|
||||
if (!response.ok || payload?.success !== true) {
|
||||
const errorCode = typeof payload?.error === 'string' ? payload.error : 'verification_failed'
|
||||
const normalized = errorCode
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '_')
|
||||
.replace(/^_+|_+$/g, '')
|
||||
|
||||
if (normalized === 'already_verified') {
|
||||
const message = t.alerts.verificationReady ?? t.alerts.verificationSent
|
||||
setAlert({ type: 'success', message })
|
||||
redirectTimeoutRef.current = window.setTimeout(() => {
|
||||
router.push('/login?registered=1')
|
||||
}, 1200)
|
||||
return
|
||||
}
|
||||
|
||||
const errorMap: Record<string, string> = {
|
||||
invalid_email: t.alerts.missingEmail,
|
||||
verification_failed: t.alerts.verificationFailed,
|
||||
rate_limited: t.alerts.genericError,
|
||||
}
|
||||
const message = errorMap[normalized] ?? t.alerts.genericError
|
||||
setAlert({ type: 'error', message })
|
||||
return
|
||||
}
|
||||
|
||||
const successMessage = t.alerts.verificationResent ?? t.alerts.verificationSent
|
||||
setAlert({ type: 'success', message: successMessage })
|
||||
setResendCooldown(RESEND_COOLDOWN_SECONDS)
|
||||
} catch (error) {
|
||||
console.error('Email verification resend failed', error)
|
||||
setAlert({ type: 'error', message: t.alerts.genericError })
|
||||
} finally {
|
||||
setIsResending(false)
|
||||
}
|
||||
}, [email, hasEmail, isResending, router, t.alerts])
|
||||
|
||||
const resendLabel = isResending
|
||||
? t.resend.resending ?? t.resend.label
|
||||
: resendCooldown > 0
|
||||
? `${t.resend.label} (${resendCooldown}s)`
|
||||
: t.resend.label
|
||||
|
||||
return (
|
||||
<AuthLayout
|
||||
mode="register"
|
||||
badge={t.badge}
|
||||
title={t.title}
|
||||
description={description}
|
||||
alert={alert}
|
||||
switchAction={{ text: t.switchAction.text, linkLabel: t.switchAction.link, href: '/login' }}
|
||||
footnote={t.footnote}
|
||||
bottomNote={t.bottomNote}
|
||||
>
|
||||
<form className="space-y-5" onSubmit={handleSubmit} noValidate>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="verification-code" className="text-sm font-medium text-slate-600">
|
||||
{t.form.codeLabel}
|
||||
</label>
|
||||
<input
|
||||
id="verification-code"
|
||||
name="code"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
placeholder={t.form.codePlaceholder}
|
||||
className="w-full rounded-2xl border border-slate-200 bg-white/90 px-4 py-2.5 text-slate-900 shadow-sm transition focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200 disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-400"
|
||||
value={code}
|
||||
onChange={handleCodeChange}
|
||||
disabled={isSubmitting || !hasEmail}
|
||||
aria-describedby="verification-code-help"
|
||||
/>
|
||||
{t.form.helper ? (
|
||||
<p id="verification-code-help" className="text-xs text-slate-500">
|
||||
{t.form.helper}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full rounded-2xl bg-gradient-to-r from-sky-500 to-blue-500 px-4 py-2.5 text-sm font-semibold text-white shadow-lg shadow-sky-500/20 transition hover:from-sky-500 hover:to-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-500 disabled:cursor-not-allowed disabled:opacity-70"
|
||||
disabled={isSubmitDisabled}
|
||||
>
|
||||
{isSubmitting ? t.form.submitting ?? t.form.submit : t.form.submit}
|
||||
</button>
|
||||
</form>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleResend}
|
||||
className="inline-flex w-full items-center justify-center rounded-2xl border border-slate-200 px-4 py-2 text-sm font-medium text-slate-600 transition hover:border-slate-300 hover:bg-slate-50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-slate-300 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={isResendDisabled}
|
||||
>
|
||||
{resendLabel}
|
||||
</button>
|
||||
</AuthLayout>
|
||||
)
|
||||
}
|
||||
@ -1,24 +0,0 @@
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
import { Suspense } from 'react'
|
||||
import { notFound } from 'next/navigation'
|
||||
|
||||
import { isFeatureEnabled } from '@lib/featureToggles'
|
||||
|
||||
import EmailVerificationContent from './EmailVerificationContent'
|
||||
|
||||
function EmailVerificationPageFallback() {
|
||||
return <div className="flex min-h-screen flex-col bg-slate-50" />
|
||||
}
|
||||
|
||||
export default function EmailVerificationPage() {
|
||||
if (!isFeatureEnabled('globalNavigation', '/email-verification')) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense fallback={<EmailVerificationPageFallback />}>
|
||||
<EmailVerificationContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@ -1,13 +0,0 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { AppShellBypass } from '@lib/appShellBypass'
|
||||
|
||||
export default function AuthPagesLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<AppShellBypass>
|
||||
<div className="flex min-h-screen flex-col bg-slate-50">
|
||||
{children}
|
||||
</div>
|
||||
</AppShellBypass>
|
||||
)
|
||||
}
|
||||
@ -1,347 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { FormEvent, ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { Github } from 'lucide-react'
|
||||
|
||||
import { AuthLayout, AuthLayoutSocialButton } from '@components/auth/AuthLayout'
|
||||
import { useLanguage } from '@i18n/LanguageProvider'
|
||||
import { translations } from '@i18n/translations'
|
||||
|
||||
import { WeChatIcon } from '../../components/icons/WeChatIcon'
|
||||
|
||||
type LoginContentProps = {
|
||||
accountServiceBaseUrl: string
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
export default function LoginContent({ accountServiceBaseUrl, children }: LoginContentProps) {
|
||||
const { language } = useLanguage()
|
||||
const t = translations[language].auth.login
|
||||
const alerts = t.alerts
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
const sensitiveKeys = ['username', 'password', 'email']
|
||||
const hasSensitiveParams = sensitiveKeys.some((key) => searchParams.has(key))
|
||||
|
||||
if (!hasSensitiveParams) {
|
||||
return
|
||||
}
|
||||
|
||||
const sanitized = new URLSearchParams(searchParams.toString())
|
||||
sensitiveKeys.forEach((key) => sanitized.delete(key))
|
||||
|
||||
const queryString = sanitized.toString()
|
||||
router.replace(queryString ? `/login?${queryString}` : '/login', { scroll: false })
|
||||
}, [router, searchParams])
|
||||
|
||||
const errorParam = searchParams.get('error')
|
||||
const registeredParam = searchParams.get('registered')
|
||||
const setupMfaParam = searchParams.get('setupMfa')
|
||||
|
||||
const normalize = useCallback(
|
||||
(value: string) =>
|
||||
value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '_')
|
||||
.replace(/^_+|_+$/g, ''),
|
||||
[],
|
||||
)
|
||||
|
||||
const githubAuthUrl = process.env.NEXT_PUBLIC_GITHUB_AUTH_URL || '/api/auth/github'
|
||||
const wechatAuthUrl = process.env.NEXT_PUBLIC_WECHAT_AUTH_URL || '/api/auth/wechat'
|
||||
const loginUrl = process.env.NEXT_PUBLIC_LOGIN_URL || `${accountServiceBaseUrl}/api/auth/login`
|
||||
|
||||
const loginUrlRef = useRef(loginUrl)
|
||||
|
||||
const deriveSameOriginLoginFallback = useCallback((url: string): string | undefined => {
|
||||
if (typeof window === 'undefined') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
try {
|
||||
const currentOrigin = window.location.origin
|
||||
const parsed = new URL(url, currentOrigin)
|
||||
|
||||
if (parsed.origin === currentOrigin) {
|
||||
const relative = `${parsed.pathname}${parsed.search}${parsed.hash}` || '/api/auth/login'
|
||||
return relative
|
||||
}
|
||||
|
||||
const localHostnames = new Set(['localhost', '127.0.0.1', '[::1]'])
|
||||
const parsedHostname = parsed.hostname.toLowerCase()
|
||||
const browserHostname = window.location.hostname.toLowerCase()
|
||||
|
||||
const parsedIsLocal = localHostnames.has(parsedHostname)
|
||||
const browserIsLocal = localHostnames.has(browserHostname)
|
||||
|
||||
if (!browserIsLocal && parsedIsLocal) {
|
||||
const relative = `${parsed.pathname}${parsed.search}${parsed.hash}` || '/api/auth/login'
|
||||
return relative
|
||||
}
|
||||
|
||||
if (
|
||||
window.location.protocol === 'https:' &&
|
||||
parsed.protocol === 'http:' &&
|
||||
parsedHostname === browserHostname
|
||||
) {
|
||||
parsed.protocol = 'https:'
|
||||
return parsed.toString()
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to derive same-origin login fallback', error)
|
||||
}
|
||||
|
||||
return undefined
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loginUrlRef.current = loginUrl
|
||||
}, [loginUrl])
|
||||
|
||||
const socialButtonsDisabled = true
|
||||
|
||||
const initialAlert = useMemo(() => {
|
||||
const successMessages: string[] = []
|
||||
if (registeredParam === '1') {
|
||||
successMessages.push(alerts.registered)
|
||||
}
|
||||
if (setupMfaParam === '1') {
|
||||
const setupRequiredMessage = alerts.mfa?.setupRequired ?? alerts.genericError
|
||||
if (setupRequiredMessage) {
|
||||
successMessages.push(setupRequiredMessage)
|
||||
}
|
||||
}
|
||||
|
||||
if (successMessages.length > 0) {
|
||||
return { type: 'success', message: successMessages.join(' ') } as const
|
||||
}
|
||||
|
||||
if (!errorParam) {
|
||||
return null
|
||||
}
|
||||
|
||||
const normalizedError = normalize(errorParam)
|
||||
const errorMap: Record<string, string> = {
|
||||
missing_credentials: alerts.missingCredentials,
|
||||
email_and_password_are_required: alerts.missingCredentials,
|
||||
invalid_credentials: alerts.invalidCredentials,
|
||||
user_not_found: alerts.userNotFound ?? alerts.genericError,
|
||||
credentials_in_query: alerts.genericError,
|
||||
invalid_request: alerts.genericError,
|
||||
|
||||
}
|
||||
const message = errorMap[normalizedError] ?? alerts.genericError
|
||||
return { type: 'error', message } as const
|
||||
}, [alerts, errorParam, normalize, registeredParam, setupMfaParam])
|
||||
|
||||
const [alert, setAlert] = useState(initialAlert)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setAlert(initialAlert)
|
||||
}, [initialAlert])
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
if (isSubmitting) {
|
||||
return
|
||||
}
|
||||
|
||||
const formData = new FormData(event.currentTarget)
|
||||
const username = String(formData.get('username') ?? '').trim()
|
||||
const password = String(formData.get('password') ?? '')
|
||||
const remember = formData.get('remember') === 'on'
|
||||
|
||||
if (!username || !password) {
|
||||
setAlert({ type: 'error', message: alerts.missingCredentials })
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
setAlert(null)
|
||||
|
||||
try {
|
||||
const requestPayload = {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username,
|
||||
password,
|
||||
remember,
|
||||
}),
|
||||
} as const
|
||||
|
||||
let response: Response
|
||||
let usedUrl = loginUrlRef.current
|
||||
|
||||
try {
|
||||
response = await fetch(usedUrl, requestPayload)
|
||||
} catch (primaryError) {
|
||||
const sameOriginFallback = deriveSameOriginLoginFallback(usedUrl)
|
||||
if (sameOriginFallback && sameOriginFallback !== usedUrl) {
|
||||
try {
|
||||
response = await fetch(sameOriginFallback, requestPayload)
|
||||
loginUrlRef.current = sameOriginFallback
|
||||
usedUrl = sameOriginFallback
|
||||
} catch (fallbackError) {
|
||||
console.error('Primary login request failed, same-origin fallback also failed', fallbackError)
|
||||
throw fallbackError
|
||||
}
|
||||
} else {
|
||||
const httpsPattern = /^https:/i
|
||||
if (httpsPattern.test(usedUrl)) {
|
||||
const insecureUrl = usedUrl.replace(httpsPattern, 'http:')
|
||||
|
||||
try {
|
||||
response = await fetch(insecureUrl, requestPayload)
|
||||
loginUrlRef.current = insecureUrl
|
||||
usedUrl = insecureUrl
|
||||
} catch (fallbackError) {
|
||||
console.error('Primary login request failed, insecure fallback also failed', fallbackError)
|
||||
throw fallbackError
|
||||
}
|
||||
} else {
|
||||
throw primaryError
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
let errorCode = 'invalid_credentials'
|
||||
try {
|
||||
const data = await response.json()
|
||||
if (typeof data?.error === 'string') {
|
||||
errorCode = data.error
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to parse login response', error)
|
||||
}
|
||||
|
||||
const errorMap: Record<string, string> = {
|
||||
invalid_credentials: alerts.invalidCredentials,
|
||||
missing_credentials: alerts.missingCredentials,
|
||||
user_not_found: alerts.userNotFound ?? alerts.genericError,
|
||||
invalid_request: alerts.genericError,
|
||||
credentials_in_query: alerts.genericError,
|
||||
}
|
||||
|
||||
setAlert({ type: 'error', message: errorMap[normalize(errorCode)] ?? alerts.genericError })
|
||||
return
|
||||
}
|
||||
|
||||
const data: { redirectTo?: string } = await response
|
||||
.json()
|
||||
.catch(() => ({}))
|
||||
router.push(data?.redirectTo || '/')
|
||||
router.refresh()
|
||||
} catch (error) {
|
||||
console.error('Failed to submit login request', error)
|
||||
setAlert({ type: 'error', message: alerts.genericError })
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
},
|
||||
[alerts, deriveSameOriginLoginFallback, isSubmitting, normalize, router],
|
||||
)
|
||||
|
||||
const socialButtons = useMemo<AuthLayoutSocialButton[]>(() => {
|
||||
return [
|
||||
{
|
||||
label: t.social.github,
|
||||
href: githubAuthUrl,
|
||||
icon: <Github className="h-5 w-5" aria-hidden />,
|
||||
disabled: socialButtonsDisabled,
|
||||
},
|
||||
{
|
||||
label: t.social.wechat,
|
||||
href: wechatAuthUrl,
|
||||
icon: <WeChatIcon className="h-5 w-5" aria-hidden />,
|
||||
disabled: socialButtonsDisabled,
|
||||
},
|
||||
]
|
||||
}, [githubAuthUrl, socialButtonsDisabled, t.social.github, t.social.wechat, wechatAuthUrl])
|
||||
|
||||
const formContent = useMemo(() => {
|
||||
if (children) {
|
||||
return children
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="space-y-5" method="post" onSubmit={handleSubmit} noValidate>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="login-username" className="text-sm font-medium text-slate-600">
|
||||
{t.form.email}
|
||||
</label>
|
||||
<input
|
||||
id="login-username"
|
||||
name="username"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
placeholder={t.form.emailPlaceholder}
|
||||
className="w-full rounded-2xl border border-slate-200 bg-white/90 px-4 py-2.5 text-slate-900 shadow-sm transition focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<label htmlFor="login-password" className="font-medium text-slate-600">
|
||||
{t.form.password}
|
||||
</label>
|
||||
<Link href="#" className="font-medium text-sky-600 hover:text-sky-500">
|
||||
{t.forgotPassword}
|
||||
</Link>
|
||||
</div>
|
||||
<input
|
||||
id="login-password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
placeholder={t.form.passwordPlaceholder}
|
||||
className="w-full rounded-2xl border border-slate-200 bg-white/90 px-4 py-2.5 text-slate-900 shadow-sm transition focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-3 text-sm text-slate-600">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="remember"
|
||||
className="h-4 w-4 rounded border-slate-300 text-sky-600 focus:ring-sky-500"
|
||||
/>
|
||||
{t.form.remember}
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
aria-busy={isSubmitting}
|
||||
className="w-full rounded-2xl bg-gradient-to-r from-sky-500 to-blue-500 px-4 py-2.5 text-sm font-semibold text-white shadow-lg shadow-sky-500/20 transition hover:from-sky-500 hover:to-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-500 disabled:cursor-not-allowed disabled:opacity-70"
|
||||
>
|
||||
{isSubmitting ? t.form.submitting ?? t.form.submit : t.form.submit}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}, [children, handleSubmit, isSubmitting, t])
|
||||
return (
|
||||
<AuthLayout
|
||||
mode="login"
|
||||
badge={t.badge}
|
||||
title={t.form.title}
|
||||
description={t.form.subtitle}
|
||||
alert={alert}
|
||||
socialHeading={t.social.title}
|
||||
socialButtons={socialButtons}
|
||||
switchAction={{ text: t.registerPrompt.text, linkLabel: t.registerPrompt.link, href: '/register' }}
|
||||
bottomNote={t.bottomNote}
|
||||
>
|
||||
{formContent}
|
||||
</AuthLayout>
|
||||
)
|
||||
}
|
||||
@ -1,364 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { FormEvent, useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
import { useLanguage } from '@i18n/LanguageProvider'
|
||||
import { translations } from '@i18n/translations'
|
||||
import { useUserStore } from '@lib/userStore'
|
||||
|
||||
export function LoginForm() {
|
||||
const router = useRouter()
|
||||
const { language } = useLanguage()
|
||||
const pageCopy = translations[language].login
|
||||
const authCopy = translations[language].auth.login
|
||||
const navCopy = translations[language].nav.account
|
||||
const user = useUserStore((state) => state.user)
|
||||
const login = useUserStore((state) => state.login)
|
||||
const userEmail = user?.email ?? ''
|
||||
const [identifier, setIdentifier] = useState(() => userEmail)
|
||||
const [password, setPassword] = useState('')
|
||||
const [totpCode, setTotpCode] = useState('')
|
||||
const [remember, setRemember] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [mfaRequirement, setMfaRequirement] = useState<'optional' | 'required'>(() =>
|
||||
user?.mfaEnabled ? 'required' : 'optional',
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (userEmail && identifier.trim().length === 0) {
|
||||
setIdentifier(userEmail)
|
||||
}
|
||||
}, [identifier, userEmail])
|
||||
|
||||
useEffect(() => {
|
||||
setTotpCode('')
|
||||
}, [identifier])
|
||||
|
||||
useEffect(() => {
|
||||
if (mfaRequirement !== 'required' && totpCode !== '') {
|
||||
setTotpCode('')
|
||||
}
|
||||
}, [mfaRequirement, totpCode])
|
||||
|
||||
useEffect(() => {
|
||||
let isActive = true
|
||||
const trimmedIdentifier = identifier.trim()
|
||||
|
||||
if (!trimmedIdentifier) {
|
||||
if (isActive) {
|
||||
setMfaRequirement('optional')
|
||||
}
|
||||
return () => {
|
||||
isActive = false
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedIdentifier = trimmedIdentifier.toLowerCase()
|
||||
|
||||
const controller = new AbortController()
|
||||
const signal = controller.signal
|
||||
|
||||
const timeoutId = window.setTimeout(async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/auth/mfa/status?identifier=${encodeURIComponent(normalizedIdentifier)}`,
|
||||
{
|
||||
method: 'GET',
|
||||
cache: 'no-store',
|
||||
signal,
|
||||
},
|
||||
)
|
||||
|
||||
if (!isActive || signal.aborted) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
setMfaRequirement('optional')
|
||||
return
|
||||
}
|
||||
|
||||
const payload = (await response.json().catch(() => ({}))) as {
|
||||
mfa?: { totpEnabled?: boolean }
|
||||
}
|
||||
|
||||
const requiresMfa = Boolean(payload?.mfa?.totpEnabled)
|
||||
setMfaRequirement(requiresMfa ? 'required' : 'optional')
|
||||
} catch (lookupError) {
|
||||
if ((lookupError as Error)?.name === 'AbortError' || signal.aborted) {
|
||||
return
|
||||
}
|
||||
setMfaRequirement('optional')
|
||||
}
|
||||
}, 300)
|
||||
|
||||
return () => {
|
||||
isActive = false
|
||||
controller.abort()
|
||||
window.clearTimeout(timeoutId)
|
||||
}
|
||||
}, [identifier])
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.mfaEnabled) {
|
||||
setMfaRequirement('required')
|
||||
}
|
||||
}, [user?.mfaEnabled])
|
||||
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
|
||||
const trimmedIdentifier = identifier.trim()
|
||||
if (!trimmedIdentifier) {
|
||||
setError(pageCopy.missingUsername)
|
||||
return
|
||||
}
|
||||
if (!password) {
|
||||
setError(pageCopy.missingPassword)
|
||||
return
|
||||
}
|
||||
const requiresTotp = mfaRequirement === 'required'
|
||||
const sanitizedTotp = totpCode.replace(/\D/g, '')
|
||||
|
||||
if (requiresTotp) {
|
||||
if (!sanitizedTotp) {
|
||||
setError(pageCopy.missingTotp ?? authCopy.alerts.mfa?.missing ?? authCopy.alerts.missingCredentials)
|
||||
return
|
||||
}
|
||||
|
||||
if (sanitizedTotp.length !== 6) {
|
||||
setError(
|
||||
authCopy.alerts.mfa?.invalidFormat ??
|
||||
authCopy.alerts.mfa?.invalid ??
|
||||
pageCopy.missingTotp ??
|
||||
authCopy.alerts.missingCredentials,
|
||||
)
|
||||
return
|
||||
}
|
||||
} else if (sanitizedTotp && sanitizedTotp.length !== 6) {
|
||||
setError(
|
||||
authCopy.alerts.mfa?.invalidFormat ??
|
||||
authCopy.alerts.mfa?.invalid ??
|
||||
pageCopy.missingTotp ??
|
||||
authCopy.alerts.missingCredentials,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
setError(null)
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const response = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email: trimmedIdentifier,
|
||||
password,
|
||||
totp: sanitizedTotp.length === 6 ? sanitizedTotp : undefined,
|
||||
remember,
|
||||
}),
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
const payload = (await response.json().catch(() => ({}))) as {
|
||||
success?: boolean
|
||||
error?: string | null
|
||||
needMfa?: boolean
|
||||
}
|
||||
|
||||
if (payload.needMfa) {
|
||||
setMfaRequirement('required')
|
||||
router.replace('/panel/account?setupMfa=1')
|
||||
router.refresh()
|
||||
return
|
||||
}
|
||||
|
||||
const isSuccessful = response.ok && (payload.success ?? true)
|
||||
|
||||
if (!isSuccessful) {
|
||||
const messageKey = payload.error ?? 'generic_error'
|
||||
if (
|
||||
messageKey === 'mfa_code_required' ||
|
||||
messageKey === 'invalid_mfa_code' ||
|
||||
messageKey === 'mfa_required' ||
|
||||
messageKey === 'mfa_setup_required' ||
|
||||
messageKey === 'mfa_challenge_failed'
|
||||
) {
|
||||
setMfaRequirement('required')
|
||||
}
|
||||
switch (messageKey) {
|
||||
case 'missing_credentials':
|
||||
setError(authCopy.alerts.missingCredentials)
|
||||
break
|
||||
case 'invalid_credentials':
|
||||
setError(pageCopy.invalidCredentials)
|
||||
break
|
||||
case 'user_not_found':
|
||||
setError(pageCopy.userNotFound)
|
||||
break
|
||||
case 'mfa_code_required':
|
||||
setError(authCopy.alerts.mfa?.missing ?? pageCopy.missingTotp ?? authCopy.alerts.missingCredentials)
|
||||
break
|
||||
case 'invalid_mfa_code':
|
||||
setError(authCopy.alerts.mfa?.invalid ?? pageCopy.genericError)
|
||||
break
|
||||
case 'mfa_challenge_failed':
|
||||
setError(authCopy.alerts.mfa?.challengeFailed ?? pageCopy.genericError)
|
||||
break
|
||||
case 'account_service_unreachable':
|
||||
setError(pageCopy.serviceUnavailable ?? pageCopy.genericError)
|
||||
break
|
||||
default:
|
||||
setError(pageCopy.genericError)
|
||||
break
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
await login()
|
||||
router.replace('/')
|
||||
router.refresh()
|
||||
} catch (submitError) {
|
||||
console.warn('Login failed', submitError)
|
||||
setError(pageCopy.genericError)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleGoHome = () => {
|
||||
router.replace('/')
|
||||
router.refresh()
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
router.push('/logout')
|
||||
}
|
||||
|
||||
const requiresTotpInput = mfaRequirement === 'required'
|
||||
const mfaModeLabel = requiresTotpInput
|
||||
? authCopy.form.mfa.passwordAndTotp
|
||||
: authCopy.form.mfa.passwordOnly
|
||||
|
||||
return (
|
||||
<>
|
||||
{user ? (
|
||||
<div className="space-y-4 rounded-2xl border border-sky-200 bg-sky-50/80 p-5 text-sm text-sky-700">
|
||||
<p className="text-base font-semibold">
|
||||
{pageCopy.success.replace('{username}', user.username)}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGoHome}
|
||||
className="inline-flex items-center justify-center rounded-2xl bg-gradient-to-r from-sky-500 to-blue-500 px-4 py-2 text-sm font-semibold text-white shadow-lg shadow-sky-500/20 transition hover:from-sky-500 hover:to-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-500"
|
||||
>
|
||||
{pageCopy.goHome}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
className="inline-flex items-center justify-center rounded-2xl border border-slate-200 px-4 py-2 text-sm font-medium text-slate-600 transition hover:border-slate-300 hover:bg-slate-50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-slate-300"
|
||||
>
|
||||
{navCopy.logout}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!user ? (
|
||||
<form method="post" onSubmit={handleSubmit} className="space-y-5" noValidate>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="login-identifier" className="text-sm font-medium text-slate-600">
|
||||
{authCopy.form.email}
|
||||
</label>
|
||||
<input
|
||||
id="login-identifier"
|
||||
name="identifier"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
value={identifier}
|
||||
onChange={(event) => setIdentifier(event.target.value)}
|
||||
placeholder={authCopy.form.emailPlaceholder}
|
||||
className="w-full rounded-2xl border border-slate-200 bg-white/90 px-4 py-2.5 text-slate-900 shadow-sm transition focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-slate-600">{authCopy.form.mfa.mode}</p>
|
||||
<div className="rounded-2xl border border-dashed border-sky-200 bg-sky-50/80 px-4 py-3 text-sm text-sky-700">
|
||||
{mfaModeLabel}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<label htmlFor="login-password" className="font-medium text-slate-600">
|
||||
{authCopy.form.password}
|
||||
</label>
|
||||
<Link href="#" className="font-medium text-sky-600 hover:text-sky-500">
|
||||
{authCopy.forgotPassword}
|
||||
</Link>
|
||||
</div>
|
||||
<input
|
||||
id="login-password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
placeholder={authCopy.form.passwordPlaceholder}
|
||||
className="w-full rounded-2xl border border-slate-200 bg-white/90 px-4 py-2.5 text-slate-900 shadow-sm transition focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
||||
/>
|
||||
</div>
|
||||
{requiresTotpInput ? (
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="login-totp" className="text-sm font-medium text-slate-600">
|
||||
{authCopy.form.mfa.codeLabel}
|
||||
</label>
|
||||
<input
|
||||
id="login-totp"
|
||||
name="totpCode"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={totpCode}
|
||||
onChange={(event) => {
|
||||
const digits = event.target.value.replace(/\D/g, '').slice(0, 6)
|
||||
setTotpCode(digits)
|
||||
}}
|
||||
placeholder={authCopy.form.mfa.codePlaceholder}
|
||||
className="w-full rounded-2xl border border-slate-200 bg-white/90 px-4 py-2.5 text-slate-900 shadow-sm transition focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<label className="flex items-center gap-3 text-sm text-slate-600">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="remember"
|
||||
className="h-4 w-4 rounded border-slate-300 text-sky-600 focus:ring-sky-500"
|
||||
checked={remember}
|
||||
onChange={(event) => setRemember(event.target.checked)}
|
||||
/>
|
||||
{authCopy.form.remember}
|
||||
</label>
|
||||
|
||||
{error ? <p className="text-sm text-red-600">{error}</p> : null}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full rounded-2xl bg-gradient-to-r from-sky-500 to-blue-500 px-4 py-2.5 text-sm font-semibold text-white shadow-lg shadow-sky-500/20 transition hover:from-sky-500 hover:to-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-500 disabled:cursor-not-allowed disabled:opacity-70"
|
||||
>
|
||||
{isSubmitting ? `${authCopy.form.submit}…` : authCopy.form.submit}
|
||||
</button>
|
||||
<p className="text-xs text-slate-500">* {pageCopy.disclaimer}</p>
|
||||
</form>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -1,27 +0,0 @@
|
||||
export const dynamic = 'error'
|
||||
|
||||
import { Suspense } from 'react'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { isFeatureEnabled } from '@lib/featureToggles'
|
||||
import { getAccountServiceBaseUrl } from '@server/serviceConfig'
|
||||
import { LoginForm } from './LoginForm'
|
||||
import LoginContent from './LoginContent'
|
||||
|
||||
function LoginPageFallback() {
|
||||
return <div className="flex min-h-screen flex-col bg-slate-50" />
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
if (!isFeatureEnabled('globalNavigation', '/login')) {
|
||||
notFound()
|
||||
}
|
||||
const accountServiceBaseUrl = getAccountServiceBaseUrl()
|
||||
// 统一返回:容器包裹表单,兼容两边改动
|
||||
return (
|
||||
<Suspense fallback={<LoginPageFallback />}>
|
||||
<LoginContent accountServiceBaseUrl={accountServiceBaseUrl}>
|
||||
<LoginForm />
|
||||
</LoginContent>
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@ -1,931 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { Github } from 'lucide-react'
|
||||
import {
|
||||
ChangeEvent,
|
||||
ClipboardEvent,
|
||||
FormEvent,
|
||||
KeyboardEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useId,
|
||||
} from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
|
||||
import { AuthLayout, AuthLayoutSocialButton } from '@components/auth/AuthLayout'
|
||||
import { useLanguage } from '@i18n/LanguageProvider'
|
||||
import { translations } from '@i18n/translations'
|
||||
|
||||
import { WeChatIcon } from '../../components/icons/WeChatIcon'
|
||||
|
||||
type AlertState = { type: 'error' | 'success' | 'info'; message: string }
|
||||
|
||||
const VERIFICATION_CODE_LENGTH = 6
|
||||
const RESEND_COOLDOWN_SECONDS = 60
|
||||
const EMAIL_PATTERN = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/
|
||||
const PASSWORD_STRENGTH_PATTERN = /^(?=.*[A-Za-z])(?=.*\d).{8,}$/
|
||||
|
||||
export default function RegisterContent() {
|
||||
const { language } = useLanguage()
|
||||
const t = translations[language].auth.register
|
||||
const alerts = t.alerts
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
|
||||
const githubAuthUrl = process.env.NEXT_PUBLIC_GITHUB_AUTH_URL || '/api/auth/github'
|
||||
const wechatAuthUrl = process.env.NEXT_PUBLIC_WECHAT_AUTH_URL || '/api/auth/wechat'
|
||||
const isSocialAuthVisible = false
|
||||
|
||||
const socialButtons = useMemo<AuthLayoutSocialButton[]>(() => {
|
||||
if (!isSocialAuthVisible) {
|
||||
return []
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
label: t.social.github,
|
||||
href: githubAuthUrl,
|
||||
icon: <Github className="h-5 w-5" aria-hidden />,
|
||||
},
|
||||
{
|
||||
label: t.social.wechat,
|
||||
href: wechatAuthUrl,
|
||||
icon: <WeChatIcon className="h-5 w-5" aria-hidden />,
|
||||
},
|
||||
]
|
||||
}, [githubAuthUrl, isSocialAuthVisible, t.social.github, t.social.wechat, wechatAuthUrl])
|
||||
|
||||
useEffect(() => {
|
||||
const sensitiveKeys = ['username', 'password', 'confirmPassword', 'email']
|
||||
const hasSensitiveParams = sensitiveKeys.some((key) => searchParams.has(key))
|
||||
|
||||
if (!hasSensitiveParams) {
|
||||
return
|
||||
}
|
||||
|
||||
const sanitized = new URLSearchParams(searchParams.toString())
|
||||
sensitiveKeys.forEach((key) => sanitized.delete(key))
|
||||
|
||||
const queryString = sanitized.toString()
|
||||
router.replace(queryString ? `/register?${queryString}` : '/register', { scroll: false })
|
||||
}, [router, searchParams])
|
||||
|
||||
const normalize = useCallback(
|
||||
(value: string) =>
|
||||
value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '_')
|
||||
.replace(/^_+|_+$/g, ''),
|
||||
[],
|
||||
)
|
||||
|
||||
const initialAlert = useMemo<AlertState | null>(() => {
|
||||
const errorParam = searchParams.get('error')
|
||||
const successParam = searchParams.get('success')
|
||||
|
||||
if (successParam === '1') {
|
||||
return { type: 'success', message: alerts.success }
|
||||
}
|
||||
|
||||
if (!errorParam) {
|
||||
return null
|
||||
}
|
||||
|
||||
const normalizedError = normalize(errorParam)
|
||||
const errorMap: Record<string, string> = {
|
||||
missing_fields: alerts.missingFields,
|
||||
email_and_password_are_required: alerts.missingFields,
|
||||
password_mismatch: alerts.passwordMismatch,
|
||||
user_already_exists: alerts.userExists,
|
||||
email_must_be_a_valid_address: alerts.invalidEmail,
|
||||
password_must_be_at_least_8_characters: alerts.weakPassword,
|
||||
email_already_exists: alerts.userExists,
|
||||
name_already_exists: alerts.usernameExists ?? alerts.userExists,
|
||||
invalid_email: alerts.invalidEmail,
|
||||
password_too_short: alerts.weakPassword,
|
||||
invalid_name: alerts.invalidName ?? alerts.genericError,
|
||||
name_required: alerts.invalidName ?? alerts.genericError,
|
||||
credentials_in_query: alerts.genericError,
|
||||
}
|
||||
const message = errorMap[normalizedError] ?? alerts.genericError
|
||||
return { type: 'error', message }
|
||||
}, [alerts, normalize, searchParams])
|
||||
|
||||
const [alert, setAlert] = useState<AlertState | null>(initialAlert)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [codeDigits, setCodeDigits] = useState<string[]>(() => Array(VERIFICATION_CODE_LENGTH).fill(''))
|
||||
const [hasRequestedCode, setHasRequestedCode] = useState(false)
|
||||
const [pendingEmail, setPendingEmail] = useState('')
|
||||
const [pendingPassword, setPendingPassword] = useState('')
|
||||
const [isResending, setIsResending] = useState(false)
|
||||
const [resendCooldown, setResendCooldown] = useState(0)
|
||||
const [isVerified, setIsVerified] = useState(false)
|
||||
const [formValues, setFormValues] = useState({
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
agreement: false,
|
||||
})
|
||||
const [isFormReady, setIsFormReady] = useState(false)
|
||||
const formRef = useRef<HTMLFormElement | null>(null)
|
||||
const codeInputRefs = useRef<(HTMLInputElement | null)[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
setAlert(initialAlert)
|
||||
}, [initialAlert])
|
||||
|
||||
useEffect(() => {
|
||||
setIsFormReady(true)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (resendCooldown <= 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const timer = window.setInterval(() => {
|
||||
setResendCooldown((current) => (current > 0 ? current - 1 : 0))
|
||||
}, 1000)
|
||||
|
||||
return () => window.clearInterval(timer)
|
||||
}, [resendCooldown])
|
||||
|
||||
const focusCodeInput = useCallback((index: number) => {
|
||||
const input = codeInputRefs.current[index]
|
||||
if (input) {
|
||||
input.focus()
|
||||
input.select()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const resetCodeDigits = useCallback(() => {
|
||||
setCodeDigits(Array(VERIFICATION_CODE_LENGTH).fill(''))
|
||||
}, [])
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
(field: 'email' | 'password' | 'confirmPassword') =>
|
||||
(event: ChangeEvent<HTMLInputElement>) => {
|
||||
const { value } = event.target
|
||||
setFormValues((previous) => ({ ...previous, [field]: value }))
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const handleAgreementChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
|
||||
setFormValues((previous) => ({ ...previous, agreement: event.target.checked }))
|
||||
}, [])
|
||||
|
||||
const handleCodeChange = useCallback(
|
||||
(index: number, value: string) => {
|
||||
const sanitized = value.replace(/\D/g, '')
|
||||
setCodeDigits((previous) => {
|
||||
const next = [...previous]
|
||||
next[index] = sanitized ? sanitized[sanitized.length - 1] ?? '' : ''
|
||||
return next
|
||||
})
|
||||
|
||||
if (sanitized && index < VERIFICATION_CODE_LENGTH - 1) {
|
||||
focusCodeInput(index + 1)
|
||||
}
|
||||
},
|
||||
[focusCodeInput],
|
||||
)
|
||||
|
||||
const handleCodeKeyDown = useCallback(
|
||||
(index: number, event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Backspace' && !codeDigits[index] && index > 0) {
|
||||
event.preventDefault()
|
||||
setCodeDigits((previous) => {
|
||||
const next = [...previous]
|
||||
next[index - 1] = ''
|
||||
return next
|
||||
})
|
||||
focusCodeInput(index - 1)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowLeft' && index > 0) {
|
||||
event.preventDefault()
|
||||
focusCodeInput(index - 1)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowRight' && index < VERIFICATION_CODE_LENGTH - 1) {
|
||||
event.preventDefault()
|
||||
focusCodeInput(index + 1)
|
||||
}
|
||||
},
|
||||
[codeDigits, focusCodeInput],
|
||||
)
|
||||
|
||||
const handleCodePaste = useCallback(
|
||||
(index: number, event: ClipboardEvent<HTMLInputElement>) => {
|
||||
event.preventDefault()
|
||||
const clipboardValue = event.clipboardData.getData('text').replace(/\D/g, '')
|
||||
if (!clipboardValue) {
|
||||
return
|
||||
}
|
||||
|
||||
const digits = clipboardValue.slice(0, VERIFICATION_CODE_LENGTH - index).split('')
|
||||
setCodeDigits((previous) => {
|
||||
const next = [...previous]
|
||||
digits.forEach((digit, offset) => {
|
||||
const targetIndex = index + offset
|
||||
if (targetIndex < VERIFICATION_CODE_LENGTH) {
|
||||
next[targetIndex] = digit
|
||||
}
|
||||
})
|
||||
return next
|
||||
})
|
||||
|
||||
const lastFilledIndex = Math.min(index + digits.length - 1, VERIFICATION_CODE_LENGTH - 1)
|
||||
focusCodeInput(lastFilledIndex)
|
||||
},
|
||||
[focusCodeInput],
|
||||
)
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
|
||||
if (isSubmitting) {
|
||||
return
|
||||
}
|
||||
|
||||
formRef.current = event.currentTarget
|
||||
|
||||
const formData = new FormData(event.currentTarget)
|
||||
const emailInput = String(formData.get('email') ?? '').trim()
|
||||
const normalizedEmail = emailInput.toLowerCase()
|
||||
const password = String(formData.get('password') ?? '')
|
||||
const confirmPassword = String(formData.get('confirmPassword') ?? '')
|
||||
const agreementAccepted = formData.get('agreement') === 'on'
|
||||
const verificationCode = codeDigits.join('')
|
||||
|
||||
setFormValues((previous) => ({
|
||||
...previous,
|
||||
email: emailInput,
|
||||
password,
|
||||
confirmPassword,
|
||||
agreement: agreementAccepted,
|
||||
}))
|
||||
|
||||
const showError = (message: string) => {
|
||||
setAlert({ type: 'error', message })
|
||||
}
|
||||
|
||||
const showStatus = (message: string) => {
|
||||
setAlert({ type: 'info', message })
|
||||
}
|
||||
|
||||
if (!hasRequestedCode) {
|
||||
if (!emailInput || !EMAIL_PATTERN.test(emailInput)) {
|
||||
showError(alerts.invalidEmail)
|
||||
return
|
||||
}
|
||||
|
||||
if (!password || !confirmPassword) {
|
||||
showError(alerts.missingFields)
|
||||
return
|
||||
}
|
||||
|
||||
if (!PASSWORD_STRENGTH_PATTERN.test(password)) {
|
||||
showError(alerts.weakPassword ?? alerts.genericError)
|
||||
return
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
showError(alerts.passwordMismatch)
|
||||
return
|
||||
}
|
||||
|
||||
if (!agreementAccepted) {
|
||||
showError(alerts.agreementRequired ?? alerts.missingFields)
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
showStatus(
|
||||
t.form.validation?.submitting ??
|
||||
t.form.submitting ??
|
||||
'Submitting registration request…',
|
||||
)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/register/send', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ email: emailInput }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
let errorCode = 'generic_error'
|
||||
try {
|
||||
const data = await response.json()
|
||||
if (typeof data?.error === 'string') {
|
||||
errorCode = data.error
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to parse verification send response', error)
|
||||
}
|
||||
|
||||
const errorMap: Record<string, string> = {
|
||||
invalid_request: alerts.genericError,
|
||||
invalid_email: alerts.invalidEmail,
|
||||
verification_failed: alerts.verificationFailed ?? alerts.genericError,
|
||||
email_already_exists: alerts.userExists,
|
||||
account_service_unreachable: alerts.genericError,
|
||||
}
|
||||
|
||||
showError(errorMap[normalize(errorCode)] ?? alerts.genericError)
|
||||
setIsSubmitting(false)
|
||||
return
|
||||
}
|
||||
|
||||
setPendingEmail(normalizedEmail)
|
||||
setPendingPassword(password)
|
||||
setHasRequestedCode(true)
|
||||
setIsVerified(false)
|
||||
resetCodeDigits()
|
||||
focusCodeInput(0)
|
||||
setResendCooldown(RESEND_COOLDOWN_SECONDS)
|
||||
|
||||
const successMessage = alerts.verificationSent ?? alerts.genericError
|
||||
setAlert({ type: 'success', message: successMessage })
|
||||
} catch (error) {
|
||||
console.error('Failed to request verification code', error)
|
||||
showError(alerts.genericError)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const emailForVerification = pendingEmail || normalizedEmail
|
||||
if (!emailForVerification) {
|
||||
showError(alerts.invalidEmail)
|
||||
return
|
||||
}
|
||||
|
||||
if (!isVerified) {
|
||||
if (verificationCode.length !== VERIFICATION_CODE_LENGTH) {
|
||||
showError(alerts.codeRequired ?? alerts.invalidCode ?? alerts.missingFields)
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
showStatus(
|
||||
t.form.validation?.verifying ??
|
||||
t.form.verifying ??
|
||||
t.form.verifySubmit ??
|
||||
t.form.submit,
|
||||
)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/register/verify', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ email: emailForVerification, code: verificationCode }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
let errorCode = 'generic_error'
|
||||
try {
|
||||
const data = await response.json()
|
||||
if (typeof data?.error === 'string') {
|
||||
errorCode = data.error
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to parse verification response', error)
|
||||
}
|
||||
|
||||
const errorMap: Record<string, string> = {
|
||||
invalid_request: alerts.genericError,
|
||||
missing_verification: alerts.codeRequired ?? alerts.missingFields,
|
||||
invalid_code:
|
||||
alerts.verificationFailed ?? alerts.invalidCode ?? alerts.genericError,
|
||||
verification_failed: alerts.verificationFailed ?? alerts.genericError,
|
||||
account_service_unreachable: alerts.genericError,
|
||||
}
|
||||
|
||||
showError(errorMap[normalize(errorCode)] ?? alerts.genericError)
|
||||
setIsSubmitting(false)
|
||||
return
|
||||
}
|
||||
|
||||
setIsVerified(true)
|
||||
const successMessage = alerts.verificationReady ?? alerts.success
|
||||
setAlert({ type: 'success', message: successMessage })
|
||||
} catch (error) {
|
||||
console.error('Failed to verify email', error)
|
||||
showError(alerts.genericError)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!pendingPassword) {
|
||||
showError(alerts.genericError)
|
||||
return
|
||||
}
|
||||
|
||||
if (verificationCode.length !== VERIFICATION_CODE_LENGTH) {
|
||||
showError(alerts.codeRequired ?? alerts.invalidCode ?? alerts.genericError)
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
showStatus(
|
||||
t.form.validation?.completing ??
|
||||
t.form.completing ??
|
||||
t.form.completeSubmit ??
|
||||
t.form.submit,
|
||||
)
|
||||
|
||||
try {
|
||||
const registerResponse = await fetch('/api/auth/register', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email: emailForVerification,
|
||||
password: pendingPassword,
|
||||
confirmPassword: pendingPassword,
|
||||
code: verificationCode,
|
||||
}),
|
||||
})
|
||||
|
||||
let registerData: { success?: boolean; error?: string } | null = null
|
||||
try {
|
||||
registerData = await registerResponse.json()
|
||||
} catch (error) {
|
||||
registerData = null
|
||||
}
|
||||
|
||||
if (!registerResponse.ok || registerData?.success === false) {
|
||||
const errorCode =
|
||||
typeof registerData?.error === 'string' ? registerData.error : 'registration_failed'
|
||||
const errorMap: Record<string, string> = {
|
||||
invalid_request: alerts.genericError,
|
||||
missing_credentials: alerts.missingFields,
|
||||
invalid_email: alerts.invalidEmail,
|
||||
password_too_short: alerts.weakPassword,
|
||||
email_already_exists: alerts.userExists,
|
||||
name_already_exists: alerts.usernameExists ?? alerts.userExists,
|
||||
invalid_name: alerts.invalidName ?? alerts.genericError,
|
||||
name_required: alerts.invalidName ?? alerts.genericError,
|
||||
hash_failure: alerts.genericError,
|
||||
user_creation_failed: alerts.genericError,
|
||||
credentials_in_query: alerts.genericError,
|
||||
verification_required: alerts.codeRequired ?? alerts.genericError,
|
||||
invalid_code:
|
||||
alerts.verificationFailed ?? alerts.invalidCode ?? alerts.genericError,
|
||||
account_service_unreachable: alerts.genericError,
|
||||
}
|
||||
|
||||
showError(errorMap[normalize(errorCode)] ?? alerts.genericError)
|
||||
setIsSubmitting(false)
|
||||
return
|
||||
}
|
||||
|
||||
const loginResponse = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email: emailForVerification,
|
||||
password: pendingPassword,
|
||||
}),
|
||||
})
|
||||
|
||||
let loginData:
|
||||
| { success?: boolean; needMfa?: boolean; error?: string; redirectTo?: string }
|
||||
| null = null
|
||||
try {
|
||||
loginData = await loginResponse.json()
|
||||
} catch (error) {
|
||||
loginData = null
|
||||
}
|
||||
|
||||
if (!loginResponse.ok || !loginData?.success) {
|
||||
const errorCode = typeof loginData?.error === 'string' ? loginData.error : 'generic_error'
|
||||
const errorMap: Record<string, string> = {
|
||||
invalid_credentials: alerts.genericError,
|
||||
missing_credentials: alerts.missingFields,
|
||||
account_service_unreachable: alerts.genericError,
|
||||
authentication_failed: alerts.genericError,
|
||||
}
|
||||
|
||||
if (loginData?.needMfa) {
|
||||
router.push('/login?needMfa=1')
|
||||
router.refresh()
|
||||
setIsSubmitting(false)
|
||||
return
|
||||
}
|
||||
|
||||
showError(errorMap[normalize(errorCode)] ?? alerts.genericError)
|
||||
setIsSubmitting(false)
|
||||
return
|
||||
}
|
||||
|
||||
const successMessage = alerts.registrationComplete ?? alerts.success
|
||||
setAlert({ type: 'success', message: successMessage })
|
||||
|
||||
router.push(loginData?.redirectTo || '/')
|
||||
router.refresh()
|
||||
} catch (error) {
|
||||
console.error('Failed to complete registration', error)
|
||||
showError(alerts.genericError)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
},
|
||||
[
|
||||
alerts,
|
||||
codeDigits,
|
||||
focusCodeInput,
|
||||
hasRequestedCode,
|
||||
isSubmitting,
|
||||
isVerified,
|
||||
normalize,
|
||||
pendingEmail,
|
||||
pendingPassword,
|
||||
resetCodeDigits,
|
||||
router,
|
||||
t.form,
|
||||
],
|
||||
)
|
||||
|
||||
const handleResend = useCallback(async () => {
|
||||
if (isResending || resendCooldown > 0 || isVerified) {
|
||||
return
|
||||
}
|
||||
|
||||
const emailFromFormRaw =
|
||||
pendingEmail ||
|
||||
(formRef.current ? String(new FormData(formRef.current).get('email') ?? '').trim() : '')
|
||||
|
||||
if (!emailFromFormRaw) {
|
||||
setAlert({ type: 'error', message: alerts.invalidEmail })
|
||||
return
|
||||
}
|
||||
|
||||
const emailFromForm = emailFromFormRaw.trim()
|
||||
|
||||
setIsResending(true)
|
||||
const resendStatusMessage =
|
||||
t.form.verificationCodeResending ??
|
||||
(t.form.verificationCodeResend ? `${t.form.verificationCodeResend}…` : 'Resending verification code…')
|
||||
setAlert({ type: 'info', message: resendStatusMessage })
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/register/send', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ email: emailFromForm }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
let errorCode = 'generic_error'
|
||||
try {
|
||||
const data = await response.json()
|
||||
if (typeof data?.error === 'string') {
|
||||
errorCode = data.error
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to parse resend response', error)
|
||||
}
|
||||
|
||||
const errorMap: Record<string, string> = {
|
||||
invalid_request: alerts.genericError,
|
||||
invalid_email: alerts.invalidEmail,
|
||||
verification_failed: alerts.verificationFailed ?? alerts.genericError,
|
||||
already_verified: alerts.verificationFailed ?? alerts.genericError,
|
||||
account_service_unreachable: alerts.genericError,
|
||||
email_already_exists: alerts.userExists,
|
||||
}
|
||||
|
||||
setAlert({ type: 'error', message: errorMap[normalize(errorCode)] ?? alerts.genericError })
|
||||
setIsResending(false)
|
||||
return
|
||||
}
|
||||
|
||||
setPendingEmail(emailFromForm.toLowerCase())
|
||||
setHasRequestedCode(true)
|
||||
setIsVerified(false)
|
||||
resetCodeDigits()
|
||||
focusCodeInput(0)
|
||||
setResendCooldown(RESEND_COOLDOWN_SECONDS)
|
||||
const message = alerts.verificationResent ?? alerts.verificationSent ?? 'Verification code resent.'
|
||||
setAlert({ type: 'success', message })
|
||||
setIsResending(false)
|
||||
} catch (error) {
|
||||
console.error('Failed to resend verification code', error)
|
||||
setAlert({ type: 'error', message: alerts.genericError })
|
||||
setIsResending(false)
|
||||
}
|
||||
}, [
|
||||
alerts,
|
||||
focusCodeInput,
|
||||
isResending,
|
||||
isVerified,
|
||||
normalize,
|
||||
pendingEmail,
|
||||
resetCodeDigits,
|
||||
resendCooldown,
|
||||
t.form.verificationCodeResend,
|
||||
t.form.verificationCodeResending,
|
||||
])
|
||||
|
||||
const aboveForm = t.uuidNote ? (
|
||||
<div className="rounded-2xl border border-dashed border-sky-200 bg-sky-50/80 px-4 py-3 text-sm text-sky-700">
|
||||
{t.uuidNote}
|
||||
</div>
|
||||
) : null
|
||||
|
||||
const isVerificationStep = hasRequestedCode && !isVerified
|
||||
const submitLabel = isVerified
|
||||
? isSubmitting
|
||||
? t.form.completing ?? t.form.completeSubmit ?? t.form.submit
|
||||
: t.form.completeSubmit ?? t.form.submit
|
||||
: isVerificationStep
|
||||
? isSubmitting
|
||||
? t.form.verifying ?? t.form.verifySubmit ?? t.form.submit
|
||||
: t.form.verifySubmit ?? t.form.submit
|
||||
: isSubmitting
|
||||
? t.form.submitting ?? t.form.submit
|
||||
: t.form.submit
|
||||
const resendLabel = isResending
|
||||
? t.form.verificationCodeResending ?? t.form.verificationCodeResend
|
||||
: resendCooldown > 0
|
||||
? `${t.form.verificationCodeResend} (${resendCooldown}s)`
|
||||
: t.form.verificationCodeResend
|
||||
const verificationDescriptionId = useId()
|
||||
const validationHints = t.form.validation
|
||||
const validationState = useMemo(() => {
|
||||
const messages: string[] = []
|
||||
|
||||
if (!isFormReady && validationHints?.initializing) {
|
||||
return { disabled: true, messages: [validationHints.initializing] }
|
||||
}
|
||||
|
||||
if (isSubmitting) {
|
||||
if (isVerified) {
|
||||
messages.push(
|
||||
validationHints?.completing ??
|
||||
t.form.completing ??
|
||||
t.form.completeSubmit ??
|
||||
t.form.submit,
|
||||
)
|
||||
} else if (isVerificationStep) {
|
||||
messages.push(
|
||||
validationHints?.verifying ??
|
||||
t.form.verifying ??
|
||||
t.form.verifySubmit ??
|
||||
t.form.submit,
|
||||
)
|
||||
} else {
|
||||
messages.push(validationHints?.submitting ?? t.form.submitting ?? t.form.submit)
|
||||
}
|
||||
|
||||
return { disabled: true, messages }
|
||||
}
|
||||
|
||||
if (!hasRequestedCode) {
|
||||
const emailValue = formValues.email.trim()
|
||||
|
||||
if (!emailValue) {
|
||||
messages.push(validationHints?.emailMissing ?? alerts.invalidEmail)
|
||||
} else if (!EMAIL_PATTERN.test(emailValue)) {
|
||||
messages.push(validationHints?.emailInvalid ?? alerts.invalidEmail)
|
||||
}
|
||||
|
||||
if (!formValues.password) {
|
||||
messages.push(validationHints?.passwordMissing ?? alerts.missingFields)
|
||||
}
|
||||
|
||||
if (!formValues.confirmPassword) {
|
||||
messages.push(validationHints?.confirmPasswordMissing ?? alerts.missingFields)
|
||||
}
|
||||
|
||||
if (formValues.password && !PASSWORD_STRENGTH_PATTERN.test(formValues.password)) {
|
||||
messages.push(validationHints?.passwordWeak ?? alerts.weakPassword ?? alerts.genericError)
|
||||
}
|
||||
|
||||
if (
|
||||
formValues.password &&
|
||||
formValues.confirmPassword &&
|
||||
formValues.password !== formValues.confirmPassword
|
||||
) {
|
||||
messages.push(validationHints?.passwordMismatch ?? alerts.passwordMismatch)
|
||||
}
|
||||
|
||||
if (!formValues.agreement) {
|
||||
messages.push(
|
||||
validationHints?.agreementRequired ?? alerts.agreementRequired ?? alerts.missingFields,
|
||||
)
|
||||
}
|
||||
|
||||
const uniqueMessages = Array.from(new Set(messages.filter(Boolean)))
|
||||
return { disabled: uniqueMessages.length > 0, messages: uniqueMessages }
|
||||
}
|
||||
|
||||
if (!isVerified) {
|
||||
if (codeDigits.some((digit) => !digit)) {
|
||||
messages.push(
|
||||
validationHints?.codeIncomplete ??
|
||||
alerts.codeRequired ??
|
||||
alerts.invalidCode ??
|
||||
alerts.missingFields,
|
||||
)
|
||||
}
|
||||
|
||||
const uniqueMessages = Array.from(new Set(messages.filter(Boolean)))
|
||||
return { disabled: uniqueMessages.length > 0, messages: uniqueMessages }
|
||||
}
|
||||
|
||||
if (codeDigits.some((digit) => !digit)) {
|
||||
messages.push(
|
||||
validationHints?.codeIncomplete ??
|
||||
alerts.codeRequired ??
|
||||
alerts.invalidCode ??
|
||||
alerts.missingFields,
|
||||
)
|
||||
}
|
||||
|
||||
if (!pendingPassword) {
|
||||
messages.push(validationHints?.passwordUnavailable ?? alerts.genericError)
|
||||
}
|
||||
|
||||
const uniqueMessages = Array.from(new Set(messages.filter(Boolean)))
|
||||
return { disabled: uniqueMessages.length > 0, messages: uniqueMessages }
|
||||
}, [
|
||||
alerts,
|
||||
codeDigits,
|
||||
formValues,
|
||||
hasRequestedCode,
|
||||
isFormReady,
|
||||
isSubmitting,
|
||||
isVerificationStep,
|
||||
isVerified,
|
||||
pendingPassword,
|
||||
t.form.completeSubmit,
|
||||
t.form.completing,
|
||||
t.form.submit,
|
||||
t.form.submitting,
|
||||
t.form.verifySubmit,
|
||||
t.form.verifying,
|
||||
validationHints,
|
||||
])
|
||||
const isSubmitDisabled = validationState.disabled
|
||||
const validationMessages = validationState.messages
|
||||
|
||||
return (
|
||||
<AuthLayout
|
||||
mode="register"
|
||||
badge={t.badge}
|
||||
title={t.form.title}
|
||||
description={t.form.subtitle}
|
||||
alert={alert}
|
||||
socialHeading={t.social.title}
|
||||
socialButtons={socialButtons}
|
||||
aboveForm={aboveForm}
|
||||
switchAction={{ text: t.loginPrompt.text, linkLabel: t.loginPrompt.link, href: '/login' }}
|
||||
bottomNote={t.bottomNote}
|
||||
>
|
||||
<form
|
||||
ref={formRef}
|
||||
className="space-y-5"
|
||||
method="post"
|
||||
onSubmit={handleSubmit}
|
||||
noValidate
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="email" className="text-sm font-medium text-slate-600">
|
||||
{t.form.email}
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
placeholder={t.form.emailPlaceholder}
|
||||
className="w-full rounded-2xl border border-slate-200 bg-white/90 px-4 py-2.5 text-slate-900 shadow-sm transition focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200 disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-400"
|
||||
required
|
||||
disabled={isVerificationStep}
|
||||
value={formValues.email}
|
||||
onChange={handleInputChange('email')}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-5 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="password" className="text-sm font-medium text-slate-600">
|
||||
{t.form.password}
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder={t.form.passwordPlaceholder}
|
||||
className="w-full rounded-2xl border border-slate-200 bg-white/90 px-4 py-2.5 text-slate-900 shadow-sm transition focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200 disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-400"
|
||||
required={!isVerificationStep}
|
||||
disabled={isVerificationStep}
|
||||
value={formValues.password}
|
||||
onChange={handleInputChange('password')}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="confirm-password" className="text-sm font-medium text-slate-600">
|
||||
{t.form.confirmPassword}
|
||||
</label>
|
||||
<input
|
||||
id="confirm-password"
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder={t.form.confirmPasswordPlaceholder}
|
||||
className="w-full rounded-2xl border border-slate-200 bg-white/90 px-4 py-2.5 text-slate-900 shadow-sm transition focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200 disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-400"
|
||||
required={!isVerificationStep}
|
||||
disabled={isVerificationStep}
|
||||
value={formValues.confirmPassword}
|
||||
onChange={handleInputChange('confirmPassword')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-600" htmlFor="verification-code-0">
|
||||
{t.form.verificationCodeLabel}
|
||||
</label>
|
||||
{t.form.verificationCodeDescription ? (
|
||||
<p
|
||||
id={verificationDescriptionId}
|
||||
className="text-xs text-slate-500"
|
||||
>
|
||||
{t.form.verificationCodeDescription}
|
||||
</p>
|
||||
) : null}
|
||||
{hasRequestedCode && !isVerified ? (
|
||||
<div className="rounded-2xl border border-dashed border-sky-200 bg-sky-50/80 px-4 py-3 text-sm text-sky-700">
|
||||
我们已向你的邮箱发送一封验证邮件,点击邮件中的链接即可完成注册。
|
||||
验证链接有效期 <strong>10 分钟</strong>。
|
||||
<br />
|
||||
若未收到邮件,请检查垃圾箱或稍后重试。
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<label className="flex items-start gap-3 text-sm text-slate-600">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="agreement"
|
||||
required={!isVerificationStep}
|
||||
disabled={isVerificationStep}
|
||||
className="mt-1 h-4 w-4 rounded border-slate-300 text-sky-600 focus:ring-sky-500 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
checked={formValues.agreement}
|
||||
onChange={handleAgreementChange}
|
||||
/>
|
||||
<span>
|
||||
{t.form.agreement}{' '}
|
||||
<Link href="/docs" className="font-semibold text-sky-600 hover:text-sky-500">
|
||||
{t.form.terms}
|
||||
</Link>
|
||||
</span>
|
||||
</label>
|
||||
{validationMessages.length > 0 ? (
|
||||
<div
|
||||
className="rounded-2xl border border-slate-200 bg-white/80 px-4 py-3 text-sm text-slate-600"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<ul className="list-disc space-y-1 pl-5">
|
||||
{validationMessages.map((message) => (
|
||||
<li key={message}>{message}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitDisabled}
|
||||
className="w-full rounded-2xl bg-gradient-to-r from-sky-500 to-blue-500 px-4 py-2.5 text-sm font-semibold text-white shadow-lg shadow-sky-500/20 transition hover:from-sky-500 hover:to-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-500 disabled:cursor-not-allowed disabled:opacity-70"
|
||||
>
|
||||
{submitLabel}
|
||||
</button>
|
||||
</form>
|
||||
</AuthLayout>
|
||||
)
|
||||
}
|
||||
@ -1,26 +0,0 @@
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export const revalidate = 0
|
||||
|
||||
import { Suspense } from 'react'
|
||||
import { notFound } from 'next/navigation'
|
||||
|
||||
import { isFeatureEnabled } from '@lib/featureToggles'
|
||||
|
||||
import RegisterContent from './RegisterContent'
|
||||
|
||||
function RegisterPageFallback() {
|
||||
return <div className="flex min-h-screen flex-col bg-slate-50" />
|
||||
}
|
||||
|
||||
export default function RegisterPage() {
|
||||
if (!isFeatureEnabled('globalNavigation', '/register')) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense fallback={<RegisterPageFallback />}>
|
||||
<RegisterContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@ -1,14 +0,0 @@
|
||||
export const dynamic = 'error'
|
||||
|
||||
import ComposeForm from '../../../../components/mail/ComposeForm'
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{
|
||||
slug: string
|
||||
}>
|
||||
}
|
||||
|
||||
export default async function ComposePage({ params }: PageProps) {
|
||||
const { slug: tenantId } = await params
|
||||
return <ComposeForm tenantId={tenantId} />
|
||||
}
|
||||
@ -1,20 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
import MessageView from '../../../../../components/mail/MessageView'
|
||||
|
||||
export default function MessageDetailPage({ params }: { params: { slug: string; id: string } }) {
|
||||
const router = useRouter()
|
||||
const tenantId = params.slug
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<MessageView
|
||||
tenantId={tenantId}
|
||||
messageId={params.id}
|
||||
showBackButton
|
||||
onBack={() => router.back()}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,14 +0,0 @@
|
||||
export const dynamic = 'error'
|
||||
|
||||
import MailDashboard from '../../../components/mail/MailDashboard'
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{
|
||||
slug: string
|
||||
}>
|
||||
}
|
||||
|
||||
export default async function TenantMailPage({ params }: PageProps) {
|
||||
const { slug: tenantId } = await params
|
||||
return <MailDashboard tenantId={tenantId} tenantName={tenantId} />
|
||||
}
|
||||
@ -1,14 +0,0 @@
|
||||
export const dynamic = 'error'
|
||||
|
||||
import MailSettings from '../../../../components/mail/MailSettings'
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{
|
||||
slug: string
|
||||
}>
|
||||
}
|
||||
|
||||
export default async function MailSettingsPage({ params }: PageProps) {
|
||||
const { slug: tenantId } = await params
|
||||
return <MailSettings tenantId={tenantId} />
|
||||
}
|
||||
@ -1,22 +0,0 @@
|
||||
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,19 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@ -1,14 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { ThemeProvider } from '@components/theme'
|
||||
import { LanguageProvider } from '@i18n/LanguageProvider'
|
||||
|
||||
export function AppProviders({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<LanguageProvider>{children}</LanguageProvider>
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
@ -1,205 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
|
||||
type Html2CanvasFn = typeof import('html2canvas')['default']
|
||||
let html2canvasLoader: Promise<Html2CanvasFn> | null = null
|
||||
|
||||
const loadHtml2Canvas = async (): Promise<Html2CanvasFn> => {
|
||||
if (typeof window === 'undefined') {
|
||||
throw new Error('html2canvas can only be loaded in the browser')
|
||||
}
|
||||
|
||||
if (!html2canvasLoader) {
|
||||
html2canvasLoader = import('html2canvas').then((module) => module.default)
|
||||
}
|
||||
|
||||
return html2canvasLoader
|
||||
}
|
||||
type QRCodeToCanvas = (
|
||||
canvas: HTMLCanvasElement,
|
||||
text: string,
|
||||
options?: Record<string, unknown>
|
||||
) => Promise<unknown>
|
||||
|
||||
import ProductCommunity from '@components/marketing/ProductCommunity'
|
||||
import ProductDocs from '@components/marketing/ProductDocs'
|
||||
import ProductDownload from '@components/marketing/ProductDownload'
|
||||
import ProductEditions from '@components/marketing/ProductEditions'
|
||||
import ProductFaq from '@components/marketing/ProductFaq'
|
||||
import ProductFeatures from '@components/marketing/ProductFeatures'
|
||||
import ProductHero from '@components/marketing/ProductHero'
|
||||
import ProductScenarios from '@components/marketing/ProductScenarios'
|
||||
import type { ProductConfig } from '@modules/products/registry'
|
||||
|
||||
export type Lang = 'zh' | 'en'
|
||||
|
||||
const drawQR = async (
|
||||
node: HTMLElement | null,
|
||||
url: string,
|
||||
size: number = 180
|
||||
) => {
|
||||
if (!node) {
|
||||
return
|
||||
}
|
||||
|
||||
node.innerHTML = ''
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = size
|
||||
canvas.height = size
|
||||
|
||||
try {
|
||||
const { toCanvas } = (await import('qrcode')) as unknown as {
|
||||
toCanvas: QRCodeToCanvas
|
||||
}
|
||||
|
||||
await toCanvas(canvas, url, {
|
||||
width: size,
|
||||
margin: 1,
|
||||
errorCorrectionLevel: 'H',
|
||||
})
|
||||
node.appendChild(canvas)
|
||||
} catch (error) {
|
||||
console.error('Failed to render QR code', error)
|
||||
}
|
||||
}
|
||||
|
||||
const exportPoster = async (node: HTMLElement | null, slug: string) => {
|
||||
if (!node || typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
const html2canvas = await loadHtml2Canvas()
|
||||
const canvas = await html2canvas(node, {
|
||||
backgroundColor: '#ffffff',
|
||||
scale: 2,
|
||||
useCORS: true,
|
||||
})
|
||||
|
||||
const link = document.createElement('a')
|
||||
const date = new Date().toISOString().slice(0, 10)
|
||||
link.download = `${slug}-poster-${date}.png`
|
||||
link.href = canvas.toDataURL('image/png')
|
||||
link.click()
|
||||
}
|
||||
|
||||
type ClientProps = {
|
||||
config: ProductConfig
|
||||
}
|
||||
|
||||
export default function Client({ config }: ClientProps) {
|
||||
const [lang, setLang] = useState<Lang>('zh')
|
||||
const defaultQrUrl = useMemo(
|
||||
() => `https://www.svc.plus/${config.slug}/`,
|
||||
[config.slug]
|
||||
)
|
||||
const [qrUrl, setQrUrl] = useState(defaultQrUrl)
|
||||
|
||||
const qrRef = useRef<HTMLDivElement>(null)
|
||||
const posterRef = useRef<HTMLDivElement>(null)
|
||||
const posterQrRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof navigator !== 'undefined') {
|
||||
const preferred = navigator.language?.toLowerCase().startsWith('en')
|
||||
? 'en'
|
||||
: 'zh'
|
||||
setLang(preferred)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
setQrUrl(defaultQrUrl)
|
||||
}, [defaultQrUrl])
|
||||
|
||||
useEffect(() => {
|
||||
drawQR(qrRef.current, qrUrl, 180)
|
||||
drawQR(posterQrRef.current, qrUrl, 220)
|
||||
}, [qrUrl])
|
||||
|
||||
const handleToggleLanguage = useCallback(() => {
|
||||
setLang((current) => (current === 'zh' ? 'en' : 'zh'))
|
||||
}, [])
|
||||
|
||||
const handleExportPoster = useCallback(() => {
|
||||
return exportPoster(posterRef.current, config.slug)
|
||||
}, [config.slug])
|
||||
|
||||
const handleQrUpdate = useCallback(
|
||||
(value: string) => {
|
||||
setQrUrl(value || defaultQrUrl)
|
||||
},
|
||||
[defaultQrUrl]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="bg-white text-slate-900">
|
||||
<header className="sticky top-0 z-30 border-b border-slate-200 bg-white/80 backdrop-blur">
|
||||
<div className="mx-auto flex h-16 max-w-6xl items-center justify-between px-4 sm:px-6 lg:px-8">
|
||||
<Link href="/" className="flex items-center gap-2 text-sm font-semibold">
|
||||
<span className="text-slate-500">SVC.plus</span>
|
||||
<span className="text-slate-400">/</span>
|
||||
<span className="text-brand-dark">{config.name}</span>
|
||||
</Link>
|
||||
<nav className="hidden gap-6 text-sm font-medium text-slate-600 md:flex">
|
||||
<Link href="#features" scroll>
|
||||
{lang === 'zh' ? '核心功能' : 'Features'}
|
||||
</Link>
|
||||
<Link href="#editions" scroll>
|
||||
{lang === 'zh' ? '版本与部署' : 'Editions'}
|
||||
</Link>
|
||||
<Link href="#scenarios" scroll>
|
||||
{lang === 'zh' ? '应用场景' : 'Scenarios'}
|
||||
</Link>
|
||||
<Link href="#download" scroll>
|
||||
{lang === 'zh' ? '下载' : 'Download'}
|
||||
</Link>
|
||||
<Link href="#docs" scroll>
|
||||
{lang === 'zh' ? '文档' : 'Docs'}
|
||||
</Link>
|
||||
<Link href="#faq" scroll>
|
||||
{lang === 'zh' ? 'FAQ' : 'FAQ'}
|
||||
</Link>
|
||||
</nav>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggleLanguage}
|
||||
className="rounded-full border border-slate-200 px-3 py-1.5 text-xs font-semibold text-slate-600 transition hover:bg-slate-50"
|
||||
aria-label={lang === 'zh' ? 'Switch to English' : '切换到中文'}
|
||||
>
|
||||
{lang === 'zh' ? 'EN' : '中文'}
|
||||
</button>
|
||||
<Link
|
||||
href="#download"
|
||||
scroll
|
||||
className="rounded-lg bg-brand px-4 py-2 text-xs font-semibold text-white shadow-sm transition hover:bg-brand-dark"
|
||||
>
|
||||
{lang === 'zh' ? '立即下载' : 'Download'}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
<ProductHero config={config} lang={lang} onExportPoster={handleExportPoster} />
|
||||
<ProductFeatures config={config} lang={lang} />
|
||||
<ProductEditions config={config} lang={lang} />
|
||||
<ProductScenarios lang={lang} />
|
||||
<ProductDownload config={config} lang={lang} />
|
||||
<ProductDocs config={config} lang={lang} />
|
||||
<ProductFaq lang={lang} />
|
||||
<ProductCommunity
|
||||
config={config}
|
||||
lang={lang}
|
||||
qrUrl={qrUrl}
|
||||
onQrUrlChange={handleQrUpdate}
|
||||
qrRef={qrRef}
|
||||
posterRef={posterRef}
|
||||
posterQrRef={posterQrRef}
|
||||
onExportPoster={handleExportPoster}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,58 +0,0 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { notFound } from 'next/navigation'
|
||||
|
||||
import Client from './Client'
|
||||
import { PRODUCT_MAP, getAllSlugs } from '@modules/products/registry'
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{
|
||||
slug: string
|
||||
}>
|
||||
}
|
||||
|
||||
export async function generateStaticParams() {
|
||||
return getAllSlugs().map((slug) => ({ slug }))
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { slug } = await params
|
||||
const config = PRODUCT_MAP.get(slug)
|
||||
|
||||
if (!config) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const description = `${config.name} — ${config.tagline_en}`
|
||||
const canonical = `https://www.svc.plus/${config.slug}`
|
||||
|
||||
return {
|
||||
title: config.title,
|
||||
description,
|
||||
alternates: {
|
||||
canonical,
|
||||
},
|
||||
openGraph: {
|
||||
title: config.title_en,
|
||||
description,
|
||||
images: [config.ogImage],
|
||||
url: canonical,
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: config.title_en,
|
||||
description,
|
||||
images: [config.ogImage],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default async function ProductPage({ params }: PageProps) {
|
||||
const { slug } = await params
|
||||
const config = PRODUCT_MAP.get(slug)
|
||||
|
||||
if (!config) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
return <Client config={config} />
|
||||
}
|
||||
@ -1,75 +0,0 @@
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
|
||||
import { getAccountSession, userHasRole } from '@server/account/session'
|
||||
import type { AccountUserRole } from '@server/account/session'
|
||||
|
||||
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
|
||||
|
||||
const READ_ROLES: AccountUserRole[] = ['admin', 'operator']
|
||||
const WRITE_ROLES: AccountUserRole[] = ['admin']
|
||||
|
||||
type ErrorPayload = {
|
||||
error: string
|
||||
}
|
||||
|
||||
async function proxyAccountRequest(request: NextRequest, endpoint: string, method: string, token: string) {
|
||||
const headers = new Headers({
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: 'application/json',
|
||||
})
|
||||
|
||||
let body: string | undefined
|
||||
if (method !== 'GET' && method !== 'HEAD') {
|
||||
body = await request.text()
|
||||
const contentType = request.headers.get('content-type') ?? 'application/json'
|
||||
headers.set('Content-Type', contentType)
|
||||
}
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method,
|
||||
headers,
|
||||
body,
|
||||
cache: 'no-store',
|
||||
})
|
||||
|
||||
const payload = await response.json().catch(() => null)
|
||||
if (payload === null) {
|
||||
return NextResponse.json<ErrorPayload>({ error: 'invalid_response' }, { status: 502 })
|
||||
}
|
||||
|
||||
return NextResponse.json(payload, { status: response.status })
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await getAccountSession(request)
|
||||
const user = session.user
|
||||
|
||||
if (!user || !session.token) {
|
||||
return NextResponse.json<ErrorPayload>({ error: 'unauthenticated' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!(await userHasRole(user, READ_ROLES))) {
|
||||
return NextResponse.json<ErrorPayload>({ error: 'forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
return proxyAccountRequest(request, `${ACCOUNT_API_BASE}/admin/settings`, 'GET', session.token)
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await getAccountSession(request)
|
||||
const user = session.user
|
||||
|
||||
if (!user || !session.token) {
|
||||
return NextResponse.json<ErrorPayload>({ error: 'unauthenticated' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!(await userHasRole(user, WRITE_ROLES))) {
|
||||
return NextResponse.json<ErrorPayload>({ error: 'forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
return proxyAccountRequest(request, `${ACCOUNT_API_BASE}/admin/settings`, 'POST', session.token)
|
||||
}
|
||||
|
||||
@ -1,70 +0,0 @@
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
|
||||
import { getAccountSession, userHasRole } from '@server/account/session'
|
||||
import type { AccountUserRole } from '@server/account/session'
|
||||
|
||||
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
|
||||
const REQUIRED_ROLES: AccountUserRole[] = ['admin']
|
||||
|
||||
type ErrorPayload = {
|
||||
error: string
|
||||
}
|
||||
|
||||
type RouteParams = {
|
||||
params: Promise<{
|
||||
userId: string
|
||||
}>
|
||||
}
|
||||
|
||||
function resolveUserId(param?: string): string | null {
|
||||
if (!param) {
|
||||
return null
|
||||
}
|
||||
const trimmed = param.trim()
|
||||
return trimmed.length > 0 ? trimmed : null
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
const session = await getAccountSession(request)
|
||||
const user = session.user
|
||||
|
||||
if (!user || !session.token) {
|
||||
return NextResponse.json<ErrorPayload>({ error: 'unauthenticated' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!(await userHasRole(user, REQUIRED_ROLES))) {
|
||||
return NextResponse.json<ErrorPayload>({ error: 'forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { userId: userIdParam } = await params
|
||||
const userId = resolveUserId(userIdParam)
|
||||
if (!userId) {
|
||||
return NextResponse.json<ErrorPayload>({ error: 'invalid_user' }, { status: 400 })
|
||||
}
|
||||
|
||||
const body = await request.text()
|
||||
const headers = new Headers({
|
||||
Authorization: `Bearer ${session.token}`,
|
||||
Accept: 'application/json',
|
||||
})
|
||||
const contentType = request.headers.get('content-type') ?? 'application/json'
|
||||
headers.set('Content-Type', contentType)
|
||||
|
||||
const response = await fetch(`${ACCOUNT_API_BASE}/admin/users/${encodeURIComponent(userId)}/role`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body,
|
||||
cache: 'no-store',
|
||||
})
|
||||
|
||||
const payload = await response.json().catch(() => null)
|
||||
if (payload === null) {
|
||||
return NextResponse.json<ErrorPayload>({ error: 'invalid_response' }, { status: 502 })
|
||||
}
|
||||
|
||||
return NextResponse.json(payload, { status: response.status })
|
||||
}
|
||||
|
||||
@ -1,45 +0,0 @@
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
|
||||
import { getAccountSession, userHasRole } from '@server/account/session'
|
||||
import type { AccountUserRole } from '@server/account/session'
|
||||
|
||||
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
|
||||
|
||||
const ALLOWED_ROLES: AccountUserRole[] = ['admin', 'operator']
|
||||
|
||||
type MetricsErrorPayload = {
|
||||
error: string
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await getAccountSession(request)
|
||||
const user = session.user
|
||||
|
||||
if (!user || !session.token) {
|
||||
return NextResponse.json<MetricsErrorPayload>({ error: 'unauthenticated' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!(await userHasRole(user, ALLOWED_ROLES))) {
|
||||
return NextResponse.json<MetricsErrorPayload>({ error: 'forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const response = await fetch(`${ACCOUNT_API_BASE}/admin/users/metrics`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.token}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
cache: 'no-store',
|
||||
})
|
||||
|
||||
const payload = await response.json().catch(() => null)
|
||||
if (payload === null) {
|
||||
return NextResponse.json<MetricsErrorPayload>({ error: 'invalid_response' }, { status: 502 })
|
||||
}
|
||||
|
||||
return NextResponse.json(payload, { status: response.status })
|
||||
}
|
||||
|
||||
@ -1,46 +0,0 @@
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
import type { NextRequest } from 'next/server'
|
||||
|
||||
import { createUpstreamProxyHandler } from '@lib/apiProxy'
|
||||
import { getInternalServerServiceBaseUrl } from '@server/serviceConfig'
|
||||
|
||||
const AGENT_PREFIX = '/api/agent'
|
||||
|
||||
function createHandler() {
|
||||
const upstreamBaseUrl = getInternalServerServiceBaseUrl()
|
||||
return createUpstreamProxyHandler({
|
||||
upstreamBaseUrl,
|
||||
upstreamPathPrefix: AGENT_PREFIX,
|
||||
})
|
||||
}
|
||||
|
||||
const handler = createHandler()
|
||||
|
||||
export function GET(request: NextRequest) {
|
||||
return handler(request)
|
||||
}
|
||||
|
||||
export function POST(request: NextRequest) {
|
||||
return handler(request)
|
||||
}
|
||||
|
||||
export function PUT(request: NextRequest) {
|
||||
return handler(request)
|
||||
}
|
||||
|
||||
export function PATCH(request: NextRequest) {
|
||||
return handler(request)
|
||||
}
|
||||
|
||||
export function DELETE(request: NextRequest) {
|
||||
return handler(request)
|
||||
}
|
||||
|
||||
export function HEAD(request: NextRequest) {
|
||||
return handler(request)
|
||||
}
|
||||
|
||||
export function OPTIONS(request: NextRequest) {
|
||||
return handler(request)
|
||||
}
|
||||
@ -1,42 +0,0 @@
|
||||
import { getInternalServerServiceBaseUrl } from '@server/serviceConfig'
|
||||
|
||||
const FORWARDED_HEADERS = ['authorization', 'cookie', 'x-account-session'] as const
|
||||
|
||||
function buildForwardHeaders(req: Request) {
|
||||
const headers = new Headers({ 'Content-Type': 'application/json' })
|
||||
|
||||
for (const name of FORWARDED_HEADERS) {
|
||||
const value = req.headers.get(name)
|
||||
if (value) {
|
||||
headers.set(name, value)
|
||||
}
|
||||
}
|
||||
|
||||
return headers
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const { question, history } = await req.json()
|
||||
const apiBase = getInternalServerServiceBaseUrl()
|
||||
const response = await fetch(`${apiBase}/api/askai`, {
|
||||
method: 'POST',
|
||||
headers: buildForwardHeaders(req),
|
||||
body: JSON.stringify({ question, history }),
|
||||
credentials: 'include'
|
||||
})
|
||||
|
||||
const data = await response.json().catch(() => null)
|
||||
if (data === null) {
|
||||
return Response.json({ error: 'Invalid response from server' }, {
|
||||
status: response.status
|
||||
})
|
||||
}
|
||||
|
||||
return Response.json(data, { status: response.status })
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
return Response.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,123 +0,0 @@
|
||||
import { cookies } from 'next/headers'
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { applyMfaCookie, applySessionCookie, clearMfaCookie, clearSessionCookie, deriveMaxAgeFromExpires, MFA_COOKIE_NAME } from '@lib/authGateway'
|
||||
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
|
||||
|
||||
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
|
||||
|
||||
type LoginPayload = {
|
||||
email?: string
|
||||
password?: string
|
||||
remember?: boolean
|
||||
totp?: string
|
||||
code?: string
|
||||
token?: string
|
||||
}
|
||||
|
||||
type AccountLoginResponse = {
|
||||
token?: string
|
||||
expiresAt?: string
|
||||
error?: string
|
||||
mfaToken?: string
|
||||
needMfa?: boolean
|
||||
mfaEnabled?: boolean
|
||||
}
|
||||
|
||||
function normalizeEmail(value: unknown) {
|
||||
return typeof value === 'string' ? value.trim().toLowerCase() : ''
|
||||
}
|
||||
|
||||
function normalizeCode(value: unknown) {
|
||||
return typeof value === 'string' ? value.replace(/\D/g, '').slice(0, 6) : ''
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
let payload: LoginPayload
|
||||
try {
|
||||
payload = (await request.json()) as LoginPayload
|
||||
} catch (error) {
|
||||
console.error('Failed to decode login payload', error)
|
||||
return NextResponse.json({ success: false, error: 'invalid_request', needMfa: false }, { status: 400 })
|
||||
}
|
||||
|
||||
const email = normalizeEmail(payload?.email)
|
||||
const password = typeof payload?.password === 'string' ? payload.password : ''
|
||||
const totpCode = normalizeCode(payload?.totp ?? payload?.code)
|
||||
const remember = Boolean(payload?.remember)
|
||||
|
||||
if (!email || !password) {
|
||||
return NextResponse.json({ success: false, error: 'missing_credentials', needMfa: false }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
const loginBody: Record<string, string> = { email, password }
|
||||
if (totpCode) {
|
||||
loginBody.totpCode = totpCode
|
||||
}
|
||||
|
||||
const response = await fetch(`${ACCOUNT_API_BASE}/login`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(loginBody),
|
||||
cache: 'no-store',
|
||||
})
|
||||
|
||||
const data = (await response.json().catch(() => ({}))) as AccountLoginResponse
|
||||
|
||||
if (response.ok && typeof data?.token === 'string' && data.token.length > 0) {
|
||||
const maxAgeFromBackend = deriveMaxAgeFromExpires(data?.expiresAt)
|
||||
const effectiveMaxAge = remember ? Math.max(maxAgeFromBackend, 60 * 60 * 24 * 30) : maxAgeFromBackend
|
||||
const result = NextResponse.json({ success: true, error: null, needMfa: false })
|
||||
applySessionCookie(result, data.token, effectiveMaxAge)
|
||||
clearMfaCookie(result)
|
||||
return result
|
||||
}
|
||||
|
||||
const errorCode = typeof data?.error === 'string' ? data.error : 'authentication_failed'
|
||||
const needsMfa = Boolean(data?.needMfa || errorCode === 'mfa_required' || errorCode === 'mfa_setup_required')
|
||||
|
||||
if ((response.status === 401 || response.status === 403 || needsMfa) && typeof data?.mfaToken === 'string') {
|
||||
const result = NextResponse.json({ success: false, error: errorCode, needMfa: true }, { status: 401 })
|
||||
applyMfaCookie(result, data.mfaToken)
|
||||
clearSessionCookie(result)
|
||||
return result
|
||||
}
|
||||
|
||||
const statusCode = response.status || 401
|
||||
const result = NextResponse.json({ success: false, error: errorCode, needMfa: false }, { status: statusCode })
|
||||
clearSessionCookie(result)
|
||||
clearMfaCookie(result)
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Account service login proxy failed', error)
|
||||
const result = NextResponse.json({ success: false, error: 'account_service_unreachable', needMfa: false }, { status: 502 })
|
||||
clearSessionCookie(result)
|
||||
clearMfaCookie(result)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
export function GET() {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'method_not_allowed', needMfa: false },
|
||||
{
|
||||
status: 405,
|
||||
headers: {
|
||||
Allow: 'POST',
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export async function DELETE() {
|
||||
const cookieStore = await cookies()
|
||||
const response = NextResponse.json({ success: true, error: null, needMfa: false })
|
||||
if (cookieStore.has(MFA_COOKIE_NAME)) {
|
||||
clearMfaCookie(response)
|
||||
}
|
||||
clearSessionCookie(response)
|
||||
return response
|
||||
}
|
||||
@ -1,54 +0,0 @@
|
||||
import { cookies } from 'next/headers'
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { SESSION_COOKIE_NAME, clearSessionCookie } from '@lib/authGateway'
|
||||
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
|
||||
|
||||
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
void request
|
||||
const token = (await cookies()).get(SESSION_COOKIE_NAME)?.value?.trim()
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json({ success: false, error: 'session_required' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${ACCOUNT_API_BASE}/mfa/disable`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
cache: 'no-store',
|
||||
})
|
||||
|
||||
const data = await response.json().catch(() => ({}))
|
||||
if (!response.ok) {
|
||||
const errorCode = typeof (data as { error?: string })?.error === 'string' ? data.error : 'mfa_disable_failed'
|
||||
if (response.status === 401) {
|
||||
const result = NextResponse.json({ success: false, error: errorCode })
|
||||
clearSessionCookie(result)
|
||||
return result
|
||||
}
|
||||
return NextResponse.json({ success: false, error: errorCode }, { status: response.status || 400 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, error: null, data })
|
||||
} catch (error) {
|
||||
console.error('Account service MFA disable proxy failed', error)
|
||||
return NextResponse.json({ success: false, error: 'account_service_unreachable' }, { status: 502 })
|
||||
}
|
||||
}
|
||||
|
||||
export function GET() {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'method_not_allowed' },
|
||||
{
|
||||
status: 405,
|
||||
headers: {
|
||||
Allow: 'POST',
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
@ -1,99 +0,0 @@
|
||||
import { cookies } from 'next/headers'
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { applyMfaCookie, MFA_COOKIE_NAME, SESSION_COOKIE_NAME } from '@lib/authGateway'
|
||||
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
|
||||
|
||||
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
|
||||
|
||||
// This Next.js route proxies MFA provisioning requests to the account service.
|
||||
// The UI calls /api/auth/mfa/setup, which in turn forwards to the Go backend
|
||||
// at /api/auth/mfa/totp/provision, keeping browser credentials opaque to the
|
||||
// external service and letting us manage cookies centrally.
|
||||
|
||||
type SetupPayload = {
|
||||
token?: string
|
||||
issuer?: string
|
||||
account?: string
|
||||
}
|
||||
|
||||
function normalizeString(value: unknown) {
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const cookieStore = await cookies()
|
||||
let payload: SetupPayload
|
||||
try {
|
||||
payload = (await request.json()) as SetupPayload
|
||||
} catch (error) {
|
||||
console.error('Failed to decode MFA setup payload', error)
|
||||
return NextResponse.json({ success: false, error: 'invalid_request', needMfa: true }, { status: 400 })
|
||||
}
|
||||
|
||||
const sessionToken = cookieStore.get(SESSION_COOKIE_NAME)?.value ?? ''
|
||||
const cookieToken = cookieStore.get(MFA_COOKIE_NAME)?.value ?? ''
|
||||
const token = normalizeString(payload?.token || cookieToken)
|
||||
|
||||
if (!token && !sessionToken) {
|
||||
return NextResponse.json({ success: false, error: 'mfa_token_required', needMfa: true }, { status: 400 })
|
||||
}
|
||||
|
||||
const issuer = normalizeString(payload?.issuer)
|
||||
const account = normalizeString(payload?.account)
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
if (sessionToken) {
|
||||
headers.Authorization = `Bearer ${sessionToken}`
|
||||
}
|
||||
|
||||
const body: Record<string, string> = {}
|
||||
if (token) {
|
||||
body.token = token
|
||||
}
|
||||
if (issuer) {
|
||||
body.issuer = issuer
|
||||
}
|
||||
if (account) {
|
||||
body.account = account
|
||||
}
|
||||
|
||||
const response = await fetch(`${ACCOUNT_API_BASE}/mfa/totp/provision`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
cache: 'no-store',
|
||||
})
|
||||
|
||||
const data = await response.json().catch(() => ({}))
|
||||
if (!response.ok) {
|
||||
const errorCode = typeof (data as { error?: string })?.error === 'string' ? data.error : 'mfa_setup_failed'
|
||||
return NextResponse.json({ success: false, error: errorCode, needMfa: true }, { status: response.status || 400 })
|
||||
}
|
||||
|
||||
const result = NextResponse.json({ success: true, error: null, needMfa: true, data })
|
||||
const nextToken = normalizeString((data as { mfaToken?: string })?.mfaToken || token || cookieToken)
|
||||
if (nextToken) {
|
||||
applyMfaCookie(result, nextToken)
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Account service MFA setup proxy failed', error)
|
||||
return NextResponse.json({ success: false, error: 'account_service_unreachable', needMfa: true }, { status: 502 })
|
||||
}
|
||||
}
|
||||
|
||||
export function GET() {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'method_not_allowed', needMfa: true },
|
||||
{
|
||||
status: 405,
|
||||
headers: {
|
||||
Allow: 'POST',
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
@ -1,49 +0,0 @@
|
||||
import { cookies } from 'next/headers'
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { MFA_COOKIE_NAME, SESSION_COOKIE_NAME } from '@lib/authGateway'
|
||||
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
|
||||
|
||||
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const cookieStore = await cookies()
|
||||
const sessionToken = cookieStore.get(SESSION_COOKIE_NAME)?.value ?? ''
|
||||
const storedMfaToken = cookieStore.get(MFA_COOKIE_NAME)?.value ?? ''
|
||||
|
||||
const url = new URL(request.url)
|
||||
const queryToken = String(url.searchParams.get('token') ?? '').trim()
|
||||
const token = queryToken || storedMfaToken
|
||||
const identifier = String(
|
||||
url.searchParams.get('identifier') ?? url.searchParams.get('email') ?? '',
|
||||
).trim()
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
Accept: 'application/json',
|
||||
}
|
||||
if (sessionToken) {
|
||||
headers.Authorization = `Bearer ${sessionToken}`
|
||||
}
|
||||
|
||||
const params = new URLSearchParams()
|
||||
if (token) {
|
||||
params.set('token', token)
|
||||
}
|
||||
if (identifier) {
|
||||
params.set('identifier', identifier.toLowerCase())
|
||||
}
|
||||
|
||||
const endpointParams = params.toString()
|
||||
const endpoint = endpointParams
|
||||
? `${ACCOUNT_API_BASE}/mfa/status?${endpointParams}`
|
||||
: `${ACCOUNT_API_BASE}/mfa/status`
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
cache: 'no-store',
|
||||
})
|
||||
|
||||
const payload = await response.json().catch(() => ({}))
|
||||
return NextResponse.json(payload, { status: response.status })
|
||||
}
|
||||
@ -1,114 +0,0 @@
|
||||
import { cookies } from 'next/headers'
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import {
|
||||
applyMfaCookie,
|
||||
applySessionCookie,
|
||||
clearMfaCookie,
|
||||
clearSessionCookie,
|
||||
deriveMaxAgeFromExpires,
|
||||
MFA_COOKIE_NAME,
|
||||
} from '@lib/authGateway'
|
||||
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
|
||||
|
||||
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
|
||||
|
||||
type VerifyPayload = {
|
||||
token?: string
|
||||
code?: string
|
||||
totp?: string
|
||||
}
|
||||
|
||||
type AccountVerifyResponse = {
|
||||
token?: string
|
||||
expiresAt?: string
|
||||
mfaToken?: string
|
||||
error?: string
|
||||
retryAt?: string
|
||||
user?: Record<string, unknown> | null
|
||||
mfa?: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
function normalizeString(value: unknown) {
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
|
||||
function normalizeCode(value: unknown) {
|
||||
return typeof value === 'string' ? value.replace(/\D/g, '').slice(0, 6) : ''
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const cookieStore = await cookies()
|
||||
let payload: VerifyPayload
|
||||
try {
|
||||
payload = (await request.json()) as VerifyPayload
|
||||
} catch (error) {
|
||||
console.error('Failed to decode MFA verification payload', error)
|
||||
return NextResponse.json({ success: false, error: 'invalid_request', needMfa: true }, { status: 400 })
|
||||
}
|
||||
|
||||
const cookieToken = cookieStore.get(MFA_COOKIE_NAME)?.value ?? ''
|
||||
const token = normalizeString(payload?.token || cookieToken)
|
||||
const code = normalizeCode(payload?.code ?? payload?.totp)
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json({ success: false, error: 'mfa_token_required', needMfa: true }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
return NextResponse.json({ success: false, error: 'mfa_code_required', needMfa: true }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${ACCOUNT_API_BASE}/mfa/totp/verify`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ token, code }),
|
||||
cache: 'no-store',
|
||||
})
|
||||
|
||||
const data = (await response.json().catch(() => ({}))) as AccountVerifyResponse
|
||||
|
||||
if (response.ok && typeof data?.token === 'string' && data.token.length > 0) {
|
||||
const result = NextResponse.json({ success: true, error: null, needMfa: false, data })
|
||||
applySessionCookie(result, data.token, deriveMaxAgeFromExpires(data?.expiresAt))
|
||||
clearMfaCookie(result)
|
||||
return result
|
||||
}
|
||||
|
||||
const errorCode = typeof data?.error === 'string' ? data.error : 'mfa_verification_failed'
|
||||
const result = NextResponse.json(
|
||||
{ success: false, error: errorCode, needMfa: true, data },
|
||||
{ status: response.status || 400 },
|
||||
)
|
||||
|
||||
if (typeof data?.mfaToken === 'string' && data.mfaToken.trim()) {
|
||||
applyMfaCookie(result, data.mfaToken)
|
||||
} else {
|
||||
applyMfaCookie(result, token)
|
||||
}
|
||||
|
||||
clearSessionCookie(result)
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Account service MFA verification proxy failed', error)
|
||||
const result = NextResponse.json({ success: false, error: 'account_service_unreachable', needMfa: true }, { status: 502 })
|
||||
applyMfaCookie(result, token)
|
||||
clearSessionCookie(result)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
export function GET() {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'method_not_allowed', needMfa: true },
|
||||
{
|
||||
status: 405,
|
||||
headers: {
|
||||
Allow: 'POST',
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
@ -1,94 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
|
||||
|
||||
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
|
||||
|
||||
type RegistrationPayload = {
|
||||
name?: string
|
||||
email?: string
|
||||
password?: string
|
||||
confirmPassword?: string
|
||||
code?: string
|
||||
}
|
||||
|
||||
function normalizeEmail(value: unknown) {
|
||||
return typeof value === 'string' ? value.trim().toLowerCase() : ''
|
||||
}
|
||||
|
||||
function normalizeString(value: unknown) {
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
let payload: RegistrationPayload
|
||||
try {
|
||||
payload = (await request.json()) as RegistrationPayload
|
||||
} catch (error) {
|
||||
console.error('Failed to decode registration payload', error)
|
||||
return NextResponse.json({ success: false, error: 'invalid_request', needMfa: false }, { status: 400 })
|
||||
}
|
||||
|
||||
const email = normalizeEmail(payload?.email)
|
||||
const password = typeof payload?.password === 'string' ? payload.password : ''
|
||||
const confirmPassword =
|
||||
typeof payload?.confirmPassword === 'string' ? payload.confirmPassword : payload?.password ?? ''
|
||||
const name = normalizeString(payload?.name)
|
||||
const code = normalizeString(payload?.code)
|
||||
|
||||
if (!email || !password) {
|
||||
return NextResponse.json({ success: false, error: 'missing_credentials', needMfa: false }, { status: 400 })
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
return NextResponse.json({ success: false, error: 'password_mismatch', needMfa: false }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
return NextResponse.json({ success: false, error: 'verification_required', needMfa: false }, { status: 400 })
|
||||
}
|
||||
|
||||
const body = {
|
||||
email,
|
||||
password,
|
||||
code,
|
||||
...(name ? { name } : {}),
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${ACCOUNT_API_BASE}/register`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
cache: 'no-store',
|
||||
})
|
||||
|
||||
const data = await response.json().catch(() => ({}))
|
||||
if (!response.ok) {
|
||||
const errorCode = typeof (data as { error?: string })?.error === 'string' ? data.error : 'registration_failed'
|
||||
return NextResponse.json(
|
||||
{ success: false, error: errorCode, needMfa: false },
|
||||
{ status: response.status || 400 },
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, error: null, needMfa: false })
|
||||
} catch (error) {
|
||||
console.error('Account service registration proxy failed', error)
|
||||
return NextResponse.json({ success: false, error: 'account_service_unreachable', needMfa: false }, { status: 502 })
|
||||
}
|
||||
}
|
||||
|
||||
export function GET() {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'method_not_allowed', needMfa: false },
|
||||
{
|
||||
status: 405,
|
||||
headers: {
|
||||
Allow: 'POST',
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
@ -1,65 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
|
||||
|
||||
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
|
||||
|
||||
type SendPayload = {
|
||||
email?: string
|
||||
}
|
||||
|
||||
function normalizeEmail(value: unknown) {
|
||||
return typeof value === 'string' ? value.trim().toLowerCase() : ''
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
let payload: SendPayload
|
||||
try {
|
||||
payload = (await request.json()) as SendPayload
|
||||
} catch (error) {
|
||||
console.error('Failed to decode registration send payload', error)
|
||||
return NextResponse.json({ success: false, error: 'invalid_request', needMfa: false }, { status: 400 })
|
||||
}
|
||||
|
||||
const email = normalizeEmail(payload?.email)
|
||||
if (!email) {
|
||||
return NextResponse.json({ success: false, error: 'invalid_email', needMfa: false }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${ACCOUNT_API_BASE}/register/send`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ email }),
|
||||
cache: 'no-store',
|
||||
})
|
||||
|
||||
const data = await response.json().catch(() => ({}))
|
||||
if (!response.ok) {
|
||||
const errorCode = typeof (data as { error?: string })?.error === 'string' ? data.error : 'verification_failed'
|
||||
return NextResponse.json({ success: false, error: errorCode, needMfa: false }, { status: response.status || 400 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, error: null, needMfa: false })
|
||||
} catch (error) {
|
||||
console.error('Account service registration send proxy failed', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'account_service_unreachable', needMfa: false },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function GET() {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'method_not_allowed', needMfa: false },
|
||||
{
|
||||
status: 405,
|
||||
headers: {
|
||||
Allow: 'POST',
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
@ -1 +0,0 @@
|
||||
export { POST, GET } from '../../verify-email/route'
|
||||
@ -1,184 +0,0 @@
|
||||
import { cookies } from 'next/headers'
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { SESSION_COOKIE_NAME, clearSessionCookie } from '@lib/authGateway'
|
||||
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
|
||||
|
||||
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
|
||||
|
||||
type AccountUser = {
|
||||
id?: string
|
||||
uuid?: string
|
||||
name?: string
|
||||
username?: string
|
||||
email: string
|
||||
mfaEnabled?: boolean
|
||||
mfaPending?: boolean
|
||||
mfa?: {
|
||||
totpEnabled?: boolean
|
||||
totpPending?: boolean
|
||||
totpSecretIssuedAt?: string
|
||||
totpConfirmedAt?: string
|
||||
totpLockedUntil?: string
|
||||
}
|
||||
role?: string
|
||||
groups?: string[]
|
||||
permissions?: string[]
|
||||
tenantId?: string
|
||||
tenants?: Array<{
|
||||
id?: string
|
||||
name?: string
|
||||
role?: string
|
||||
}>
|
||||
}
|
||||
|
||||
type SessionResponse = {
|
||||
user?: AccountUser | null
|
||||
error?: string
|
||||
}
|
||||
|
||||
async function fetchSession(token: string) {
|
||||
try {
|
||||
const response = await fetch(`${ACCOUNT_API_BASE}/session`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
cache: 'no-store',
|
||||
})
|
||||
|
||||
const data = (await response.json().catch(() => ({}))) as SessionResponse
|
||||
return { response, data }
|
||||
} catch (error) {
|
||||
console.error('Session lookup proxy failed', error)
|
||||
return { response: null, data: null }
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
void request
|
||||
const token = (await cookies()).get(SESSION_COOKIE_NAME)?.value
|
||||
if (!token) {
|
||||
return NextResponse.json({ user: null })
|
||||
}
|
||||
|
||||
const { response, data } = await fetchSession(token)
|
||||
if (!response || !response.ok || !data?.user) {
|
||||
const res = NextResponse.json({ user: null })
|
||||
clearSessionCookie(res)
|
||||
return res
|
||||
}
|
||||
|
||||
const rawUser = data.user as AccountUser
|
||||
const identifier =
|
||||
typeof rawUser.uuid === 'string' && rawUser.uuid.trim().length > 0
|
||||
? rawUser.uuid.trim()
|
||||
: typeof rawUser.id === 'string'
|
||||
? rawUser.id.trim()
|
||||
: undefined
|
||||
|
||||
const rawMfa = rawUser.mfa ?? {}
|
||||
const derivedMfaEnabled = Boolean(rawUser.mfaEnabled ?? rawMfa.totpEnabled)
|
||||
const derivedMfaPendingSource =
|
||||
typeof rawUser.mfaPending === 'boolean'
|
||||
? rawUser.mfaPending
|
||||
: typeof rawMfa.totpPending === 'boolean'
|
||||
? rawMfa.totpPending
|
||||
: false
|
||||
const derivedMfaPending = derivedMfaPendingSource && !derivedMfaEnabled
|
||||
|
||||
const normalizedRole =
|
||||
typeof rawUser.role === 'string' && rawUser.role.trim().length > 0
|
||||
? rawUser.role.trim().toLowerCase()
|
||||
: 'user'
|
||||
const normalizedGroups = Array.isArray(rawUser.groups)
|
||||
? rawUser.groups
|
||||
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
|
||||
.map((value) => value.trim())
|
||||
: []
|
||||
const normalizedPermissions = Array.isArray(rawUser.permissions)
|
||||
? rawUser.permissions
|
||||
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
|
||||
.map((value) => value.trim())
|
||||
: []
|
||||
const normalizedTenantId =
|
||||
typeof rawUser.tenantId === 'string' && rawUser.tenantId.trim().length > 0
|
||||
? rawUser.tenantId.trim()
|
||||
: undefined
|
||||
const normalizedTenants = Array.isArray(rawUser.tenants)
|
||||
? rawUser.tenants
|
||||
.map((tenant) => {
|
||||
if (!tenant || typeof tenant !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
const identifier =
|
||||
typeof tenant.id === 'string' && tenant.id.trim().length > 0
|
||||
? tenant.id.trim()
|
||||
: undefined
|
||||
if (!identifier) {
|
||||
return null
|
||||
}
|
||||
|
||||
const normalizedTenant: { id: string; name?: string; role?: string } = {
|
||||
id: identifier,
|
||||
}
|
||||
|
||||
if (typeof tenant.name === 'string' && tenant.name.trim().length > 0) {
|
||||
normalizedTenant.name = tenant.name.trim()
|
||||
}
|
||||
|
||||
if (typeof tenant.role === 'string' && tenant.role.trim().length > 0) {
|
||||
normalizedTenant.role = tenant.role.trim().toLowerCase()
|
||||
}
|
||||
|
||||
return normalizedTenant
|
||||
})
|
||||
.filter((tenant): tenant is { id: string; name?: string; role?: string } => Boolean(tenant))
|
||||
: undefined
|
||||
|
||||
const normalizedMfa = Object.keys(rawMfa).length
|
||||
? {
|
||||
...rawMfa,
|
||||
totpEnabled: Boolean(rawMfa.totpEnabled ?? derivedMfaEnabled),
|
||||
totpPending: Boolean(rawMfa.totpPending ?? derivedMfaPending),
|
||||
}
|
||||
: {
|
||||
totpEnabled: derivedMfaEnabled,
|
||||
totpPending: derivedMfaPending,
|
||||
}
|
||||
|
||||
const normalizedUser = identifier ? { ...rawUser, id: identifier, uuid: identifier } : rawUser
|
||||
|
||||
return NextResponse.json({
|
||||
user: {
|
||||
...normalizedUser,
|
||||
mfaEnabled: derivedMfaEnabled,
|
||||
mfaPending: derivedMfaPending,
|
||||
mfa: normalizedMfa,
|
||||
role: normalizedRole,
|
||||
groups: normalizedGroups,
|
||||
permissions: normalizedPermissions,
|
||||
tenantId: normalizedTenantId,
|
||||
tenants: normalizedTenants,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
void request
|
||||
const cookieStore = await cookies()
|
||||
const token = cookieStore.get(SESSION_COOKIE_NAME)?.value
|
||||
if (token) {
|
||||
await fetch(`${ACCOUNT_API_BASE}/session`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
cache: 'no-store',
|
||||
}).catch(() => null)
|
||||
}
|
||||
|
||||
const response = NextResponse.json({ success: true })
|
||||
clearSessionCookie(response)
|
||||
return response
|
||||
}
|
||||
@ -1,29 +0,0 @@
|
||||
import { cookies } from 'next/headers'
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { SESSION_COOKIE_NAME } from '@lib/authGateway'
|
||||
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
|
||||
|
||||
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const token = (await cookies()).get(SESSION_COOKIE_NAME)?.value
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: 'unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const payload = await request.json().catch(() => ({}))
|
||||
const response = await fetch(`${ACCOUNT_API_BASE}/subscriptions/cancel`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
cache: 'no-store',
|
||||
})
|
||||
|
||||
const data = await response.json().catch(() => ({}))
|
||||
return NextResponse.json(data, { status: response.status })
|
||||
}
|
||||
@ -1,39 +0,0 @@
|
||||
import { cookies } from 'next/headers'
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { SESSION_COOKIE_NAME } from '@lib/authGateway'
|
||||
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
|
||||
|
||||
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
|
||||
|
||||
async function proxyRequest(request: NextRequest, pathSuffix: string, body?: Record<string, unknown>) {
|
||||
const token = (await cookies()).get(SESSION_COOKIE_NAME)?.value
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: 'unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const target = `${ACCOUNT_API_BASE}/subscriptions${pathSuffix}`
|
||||
|
||||
const response = await fetch(target, {
|
||||
method: request.method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
cache: 'no-store',
|
||||
})
|
||||
|
||||
const data = await response.json().catch(() => ({}))
|
||||
return NextResponse.json(data, { status: response.status })
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return proxyRequest(request, '')
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const payload = await request.json().catch(() => ({}))
|
||||
return proxyRequest(request, '', payload)
|
||||
}
|
||||
@ -1,69 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
|
||||
|
||||
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
|
||||
|
||||
type VerifyPayload = {
|
||||
email?: string
|
||||
code?: string
|
||||
}
|
||||
|
||||
function normalizeEmail(value: unknown) {
|
||||
return typeof value === 'string' ? value.trim().toLowerCase() : ''
|
||||
}
|
||||
|
||||
function normalizeCode(value: unknown) {
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
let payload: VerifyPayload
|
||||
try {
|
||||
payload = (await request.json()) as VerifyPayload
|
||||
} catch (error) {
|
||||
console.error('Failed to decode verification payload', error)
|
||||
return NextResponse.json({ success: false, error: 'invalid_request', needMfa: false }, { status: 400 })
|
||||
}
|
||||
|
||||
const email = normalizeEmail(payload?.email)
|
||||
const code = normalizeCode(payload?.code)
|
||||
|
||||
if (!email || !code) {
|
||||
return NextResponse.json({ success: false, error: 'missing_verification', needMfa: false }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${ACCOUNT_API_BASE}/register/verify`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ email, code }),
|
||||
cache: 'no-store',
|
||||
})
|
||||
|
||||
const data = await response.json().catch(() => ({}))
|
||||
if (!response.ok) {
|
||||
const errorCode = typeof (data as { error?: string })?.error === 'string' ? data.error : 'verification_failed'
|
||||
return NextResponse.json({ success: false, error: errorCode, needMfa: false }, { status: response.status || 400 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, error: null, needMfa: false })
|
||||
} catch (error) {
|
||||
console.error('Account service verification proxy failed', error)
|
||||
return NextResponse.json({ success: false, error: 'account_service_unreachable', needMfa: false }, { status: 502 })
|
||||
}
|
||||
}
|
||||
|
||||
export function GET() {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'method_not_allowed', needMfa: false },
|
||||
{
|
||||
status: 405,
|
||||
headers: {
|
||||
Allow: 'POST',
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
@ -1,65 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
|
||||
|
||||
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
|
||||
|
||||
type SendPayload = {
|
||||
email?: string
|
||||
}
|
||||
|
||||
function normalizeEmail(value: unknown) {
|
||||
return typeof value === 'string' ? value.trim().toLowerCase() : ''
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
let payload: SendPayload
|
||||
try {
|
||||
payload = (await request.json()) as SendPayload
|
||||
} catch (error) {
|
||||
console.error('Failed to decode verification send payload', error)
|
||||
return NextResponse.json({ success: false, error: 'invalid_request', needMfa: false }, { status: 400 })
|
||||
}
|
||||
|
||||
const email = normalizeEmail(payload?.email)
|
||||
if (!email) {
|
||||
return NextResponse.json({ success: false, error: 'invalid_email', needMfa: false }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${ACCOUNT_API_BASE}/register/send`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ email }),
|
||||
cache: 'no-store',
|
||||
})
|
||||
|
||||
const data = await response.json().catch(() => ({}))
|
||||
if (!response.ok) {
|
||||
const errorCode = typeof (data as { error?: string })?.error === 'string' ? data.error : 'verification_failed'
|
||||
return NextResponse.json({ success: false, error: errorCode, needMfa: false }, { status: response.status || 400 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, error: null, needMfa: false })
|
||||
} catch (error) {
|
||||
console.error('Account service verification send proxy failed', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'account_service_unreachable', needMfa: false },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function GET() {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'method_not_allowed', needMfa: false },
|
||||
{
|
||||
status: 405,
|
||||
headers: {
|
||||
Allow: 'POST',
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
@ -1,23 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { ContentNotFoundError, getContentCommitMeta } from '@server/content-meta'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const path = request.nextUrl.searchParams.get('path')
|
||||
if (!path) {
|
||||
return NextResponse.json({ error: 'Missing path parameter' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await getContentCommitMeta(path)
|
||||
return NextResponse.json(result, { status: 200 })
|
||||
} catch (error) {
|
||||
if (error instanceof ContentNotFoundError) {
|
||||
return NextResponse.json({ error: 'Content file not found' }, { status: 404 })
|
||||
}
|
||||
console.error('Failed to load content metadata:', error)
|
||||
return NextResponse.json({ error: 'Failed to load metadata' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@ -1,21 +0,0 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
const ARTIFACTS_MANIFEST_URL = 'https://dl.svc.plus/dl-index/artifacts-manifest.json'
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const response = await fetch(ARTIFACTS_MANIFEST_URL, {
|
||||
cache: 'no-cache',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch artifacts manifest: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (error) {
|
||||
console.error('Error fetching artifacts manifest:', error)
|
||||
return NextResponse.json([], { status: 200 })
|
||||
}
|
||||
}
|
||||
@ -1,21 +0,0 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
const DOCS_MANIFEST_URL = 'https://dl.svc.plus/dl-index/docs-manifest.json'
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const response = await fetch(DOCS_MANIFEST_URL, {
|
||||
cache: 'no-cache',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch docs manifest: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (error) {
|
||||
console.error('Error fetching docs manifest:', error)
|
||||
return NextResponse.json([], { status: 200 })
|
||||
}
|
||||
}
|
||||
@ -1,15 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { getMessage, resolveTenantId } from '../../mockData'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const tenantId = resolveTenantId(request.headers.get('x-tenant-id'))
|
||||
const body = (await request.json()) as { messageId: string }
|
||||
const message = getMessage(tenantId, body.messageId)
|
||||
if (!message) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const labels = Array.from(new Set([...message.labels, 'AI-Reviewed']))
|
||||
return NextResponse.json({ labels })
|
||||
}
|
||||
@ -1,17 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { getMessage, resolveTenantId } from '../../mockData'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const tenantId = resolveTenantId(request.headers.get('x-tenant-id'))
|
||||
const body = (await request.json()) as { messageId: string; style?: string; language?: string }
|
||||
const message = body?.messageId ? getMessage(tenantId, body.messageId) : null
|
||||
|
||||
const base = message?.aiInsights?.suggestions ?? [
|
||||
'收到,我们将安排同事跟进。',
|
||||
'感谢提醒,我们将及时回复。',
|
||||
'请告知是否需要更多信息。',
|
||||
]
|
||||
|
||||
return NextResponse.json({ suggestions: base })
|
||||
}
|
||||
@ -1,28 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { getMessage, resolveTenantId } from '../../mockData'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const tenantId = resolveTenantId(request.headers.get('x-tenant-id'))
|
||||
const body = (await request.json()) as { messageId?: string; raw?: string }
|
||||
if (!body.messageId && !body.raw) {
|
||||
return NextResponse.json({ error: 'messageId or raw is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (body.messageId) {
|
||||
const message = getMessage(tenantId, body.messageId)
|
||||
if (!message) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
}
|
||||
if (message.aiInsights) {
|
||||
return NextResponse.json(message.aiInsights)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
summary: '示例摘要:邮件内容将提炼为关键句子。',
|
||||
bullets: ['示例要点一', '示例要点二'],
|
||||
actions: ['示例行动一'],
|
||||
tone: '信息',
|
||||
})
|
||||
}
|
||||
@ -1,35 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { getInbox, resolveTenantId } from '../mockData'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const tenantHeader = request.headers.get('x-tenant-id')
|
||||
const tenantQuery = request.nextUrl.searchParams.get('tenantId')
|
||||
const tenantId = resolveTenantId(tenantHeader ?? tenantQuery)
|
||||
|
||||
const inbox = getInbox(tenantId)
|
||||
|
||||
const label = request.nextUrl.searchParams.get('label')
|
||||
const query = request.nextUrl.searchParams.get('q')?.toLowerCase().trim()
|
||||
|
||||
let filtered = inbox.messages
|
||||
if (label === 'unread') {
|
||||
filtered = filtered.filter((item) => item.unread)
|
||||
} else if (label === 'starred') {
|
||||
filtered = filtered.filter((item) => item.starred)
|
||||
} else if (label && label !== 'important') {
|
||||
filtered = filtered.filter((item) => item.labels.includes(label))
|
||||
}
|
||||
if (query) {
|
||||
filtered = filtered.filter((item) =>
|
||||
[item.subject, item.snippet, item.from.email, item.from.name]
|
||||
.filter(Boolean)
|
||||
.some((field) => field!.toLowerCase().includes(query)),
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
...inbox,
|
||||
messages: filtered,
|
||||
})
|
||||
}
|
||||
@ -1,23 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { getMessage, resolveTenantId } from '../../mockData'
|
||||
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const tenantId = resolveTenantId(request.headers.get('x-tenant-id'))
|
||||
const { id } = await params
|
||||
const message = getMessage(tenantId, id)
|
||||
if (!message) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
}
|
||||
return NextResponse.json(message)
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const tenantId = resolveTenantId(request.headers.get('x-tenant-id'))
|
||||
const { id } = await params
|
||||
const message = getMessage(tenantId, id)
|
||||
if (!message) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
}
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
@ -1,208 +0,0 @@
|
||||
import type { MailInboxResponse, MailListMessage, MailMessageDetail, NamespacePolicy } from '@lib/mail/types'
|
||||
|
||||
type TenantMailData = {
|
||||
inbox: MailListMessage[]
|
||||
messages: Record<string, MailMessageDetail>
|
||||
namespace: NamespacePolicy
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
|
||||
const baseMessages: MailListMessage[] = [
|
||||
{
|
||||
id: 'msg-1001',
|
||||
subject: '【故障通报】核心链路延迟恢复通知',
|
||||
snippet: '生产集群延迟已恢复至正常指标,详见行动项。',
|
||||
from: { name: 'SRE 值班', email: 'sre@svc.plus' },
|
||||
to: [{ name: 'Ops 团队', email: 'ops@tenant.io' }],
|
||||
date: new Date(now - 5 * 60 * 1000).toISOString(),
|
||||
unread: true,
|
||||
starred: true,
|
||||
labels: ['Incident', 'Priority'],
|
||||
hasAttachments: true,
|
||||
aiSummary: {
|
||||
preview: '延迟恢复,需确认追踪指标。',
|
||||
tone: '紧急',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'msg-1002',
|
||||
subject: '月度账单与消耗对账单',
|
||||
snippet: '附件包含 5 月份资源使用与费用明细,请于本周内确认。',
|
||||
from: { name: 'Finance Robot', email: 'billing@svc.plus' },
|
||||
to: [{ name: 'Finance', email: 'finance@tenant.io' }],
|
||||
date: new Date(now - 2 * 60 * 60 * 1000).toISOString(),
|
||||
unread: false,
|
||||
labels: ['Billing'],
|
||||
hasAttachments: true,
|
||||
aiSummary: {
|
||||
preview: '账单结算提醒,需核对折扣。',
|
||||
tone: '正式',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'msg-1003',
|
||||
subject: 'AI 助手联调会议记录',
|
||||
snippet: '会议纪要包含下一步联调行动项与 SLA 讨论。',
|
||||
from: { name: '产品经理', email: 'pm@svc.plus' },
|
||||
to: [{ name: 'AI 团队', email: 'ai@tenant.io' }],
|
||||
date: new Date(now - 5 * 60 * 60 * 1000).toISOString(),
|
||||
unread: false,
|
||||
labels: ['Product'],
|
||||
aiSummary: {
|
||||
preview: '提炼三条关键任务。',
|
||||
tone: '合作',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'msg-1004',
|
||||
subject: '【提醒】IAM 权限矩阵变更审批',
|
||||
snippet: '审批单待确认,涉及新的只读角色授权,请于 24 小时内处理。',
|
||||
from: { name: 'Access Bot', email: 'iam@svc.plus' },
|
||||
to: [{ name: 'Security', email: 'sec@tenant.io' }],
|
||||
date: new Date(now - 12 * 60 * 60 * 1000).toISOString(),
|
||||
unread: true,
|
||||
labels: ['Security'],
|
||||
aiSummary: {
|
||||
preview: '审批截止前需确认。',
|
||||
tone: '提醒',
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const detailMap: Record<string, MailMessageDetail> = {
|
||||
'msg-1001': {
|
||||
...baseMessages[0],
|
||||
text: '生产链路延迟恢复。请确认后续监控指标与复盘会议安排。',
|
||||
html: '<p>生产链路延迟已恢复。</p><ul><li>核对 Prometheus 延迟指标</li><li>更新状态页面</li><li>准备 18:00 复盘会议</li></ul>',
|
||||
attachments: [
|
||||
{
|
||||
id: 'att-1',
|
||||
fileName: 'incident-report.pdf',
|
||||
contentType: 'application/pdf',
|
||||
size: 234567,
|
||||
downloadUrl: '#',
|
||||
},
|
||||
],
|
||||
aiInsights: {
|
||||
summary: '生产链路延迟恢复,需跟进指标及复盘会议。',
|
||||
bullets: ['Prometheus 延迟恢复', '状态页面需更新', '18:00 复盘会议'],
|
||||
actions: ['确认状态页', '同步客户邮件', '准备复盘材料'],
|
||||
tone: '紧急',
|
||||
suggestions: [
|
||||
'感谢通知,已安排团队核查 Prometheus 指标。',
|
||||
'收到,我们将于 18:00 准备复盘材料。',
|
||||
'请同步可能影响的客户列表,方便统一公告。',
|
||||
],
|
||||
},
|
||||
},
|
||||
'msg-1002': {
|
||||
...baseMessages[1],
|
||||
text: '随信附上 5 月份账单,包含折扣与超额费用明细,请在本周内完成对账。',
|
||||
attachments: [
|
||||
{
|
||||
id: 'att-2',
|
||||
fileName: 'may-usage.xlsx',
|
||||
contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
size: 54567,
|
||||
downloadUrl: '#',
|
||||
},
|
||||
],
|
||||
aiInsights: {
|
||||
summary: '账单需要财务团队在本周内确认。',
|
||||
bullets: ['包含折扣明细', '有部分资源超额', '需在周五前回复'],
|
||||
actions: ['核对折扣', '确认超额原因', '回邮确认'],
|
||||
tone: '正式',
|
||||
suggestions: ['已收悉,我们将在周四前完成对账并回复。'],
|
||||
},
|
||||
},
|
||||
'msg-1003': {
|
||||
...baseMessages[2],
|
||||
html: '<p>联调会议要点:</p><ol><li>六月上线 Beta,需补充监控指标</li><li>AI 模型回退策略需评审</li><li>下一次联调会议安排在周五上午</li></ol>',
|
||||
aiInsights: {
|
||||
summary: '会议聚焦上线计划、模型回退与下次会议时间。',
|
||||
bullets: ['六月 Beta 上线', '确认模型回退策略', '周五上午继续联调'],
|
||||
actions: ['同步监控指标清单', '准备回退方案文档', '发送会议邀请'],
|
||||
tone: '合作',
|
||||
},
|
||||
},
|
||||
'msg-1004': {
|
||||
...baseMessages[3],
|
||||
text: 'IAM 角色矩阵变更涉及新建只读角色,需要安全团队审批。',
|
||||
aiInsights: {
|
||||
summary: '安全团队需在 24 小时内确认新角色审批。',
|
||||
bullets: ['新增只读角色', '审批截止 24 小时内', '需评估权限边界'],
|
||||
actions: ['审阅角色权限', '评估风险', '确认审批或驳回'],
|
||||
tone: '提醒',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const TENANT_DATA: Record<string, TenantMailData> = {
|
||||
'tenant-alpha': {
|
||||
inbox: baseMessages,
|
||||
messages: detailMap,
|
||||
namespace: {
|
||||
model: 'gpt-4o-mini',
|
||||
temperature: 0.3,
|
||||
maxTokens: 2048,
|
||||
rateLimitPerMinute: 60,
|
||||
vectorIndex: 's3://tenant-alpha-mail',
|
||||
policy: '{"blockedKeywords": ["NDA", "秘密"]}',
|
||||
updatedAt: new Date(now - 3600 * 1000).toISOString(),
|
||||
},
|
||||
},
|
||||
default: {
|
||||
inbox: baseMessages,
|
||||
messages: detailMap,
|
||||
namespace: {
|
||||
model: 'gpt-4o-mini',
|
||||
temperature: 0.5,
|
||||
maxTokens: 2048,
|
||||
rateLimitPerMinute: 30,
|
||||
vectorIndex: 's3://default-mail',
|
||||
policy: '{"allowExternal": true}',
|
||||
updatedAt: new Date(now - 7200 * 1000).toISOString(),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export function resolveTenantId(raw: string | null | undefined) {
|
||||
if (!raw) {
|
||||
return 'default'
|
||||
}
|
||||
return TENANT_DATA[raw] ? raw : 'default'
|
||||
}
|
||||
|
||||
export function getInbox(tenantId: string): MailInboxResponse {
|
||||
const data = TENANT_DATA[tenantId] ?? TENANT_DATA.default
|
||||
return {
|
||||
messages: data.inbox,
|
||||
labels: [
|
||||
{ id: 'Incident', name: 'Incident', color: '#f97316', unread: data.inbox.filter((item) => item.unread && item.labels.includes('Incident')).length },
|
||||
{ id: 'Billing', name: 'Billing', color: '#2563eb', unread: data.inbox.filter((item) => item.unread && item.labels.includes('Billing')).length },
|
||||
{ id: 'Security', name: 'Security', color: '#7c3aed', unread: data.inbox.filter((item) => item.unread && item.labels.includes('Security')).length },
|
||||
{ id: 'Product', name: 'Product', color: '#0f766e', unread: data.inbox.filter((item) => item.unread && item.labels.includes('Product')).length },
|
||||
],
|
||||
unreadCount: data.inbox.filter((item) => item.unread).length,
|
||||
nextCursor: null,
|
||||
}
|
||||
}
|
||||
|
||||
export function getMessage(tenantId: string, id: string): MailMessageDetail | null {
|
||||
const data = TENANT_DATA[tenantId] ?? TENANT_DATA.default
|
||||
return data.messages[id] ?? null
|
||||
}
|
||||
|
||||
export function getNamespace(tenantId: string): NamespacePolicy {
|
||||
const data = TENANT_DATA[tenantId] ?? TENANT_DATA.default
|
||||
return data.namespace
|
||||
}
|
||||
|
||||
export function updateNamespace(tenantId: string, patch: Partial<NamespacePolicy>): NamespacePolicy {
|
||||
const key = TENANT_DATA[tenantId] ? tenantId : 'default'
|
||||
const current = TENANT_DATA[key].namespace
|
||||
const next = { ...current, ...patch, updatedAt: new Date().toISOString() }
|
||||
TENANT_DATA[key].namespace = next
|
||||
return next
|
||||
}
|
||||
@ -1,14 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { getNamespace, resolveTenantId, updateNamespace } from '../mockData'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const tenantId = resolveTenantId(request.headers.get('x-tenant-id'))
|
||||
return NextResponse.json(getNamespace(tenantId))
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
const tenantId = resolveTenantId(request.headers.get('x-tenant-id'))
|
||||
const patch = (await request.json()) as Record<string, unknown>
|
||||
return NextResponse.json(updateNamespace(tenantId, patch))
|
||||
}
|
||||
@ -1,9 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import type { ComposePayload } from '@lib/mail/types'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const payload = (await request.json()) as ComposePayload
|
||||
void payload
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
@ -1,22 +0,0 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
import { loadRuntimeConfig } from '@server/runtime-loader'
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const hostnameHeader = request.headers.get('host') ?? undefined
|
||||
const runtimeConfig = loadRuntimeConfig({ hostname: hostnameHeader })
|
||||
|
||||
const payload = {
|
||||
status: 'ok' as const,
|
||||
environment: runtimeConfig.environment,
|
||||
region: runtimeConfig.region,
|
||||
apiBaseUrl: runtimeConfig.apiBaseUrl,
|
||||
authUrl: runtimeConfig.authUrl,
|
||||
dashboardUrl: runtimeConfig.dashboardUrl,
|
||||
logLevel: runtimeConfig.logLevel,
|
||||
}
|
||||
|
||||
console.info('[runtime-config] /api/ping resolved config snippet', payload)
|
||||
|
||||
return NextResponse.json(payload)
|
||||
}
|
||||
@ -1,37 +0,0 @@
|
||||
import { getInternalServerServiceBaseUrl } from '@server/serviceConfig'
|
||||
|
||||
const FORWARDED_HEADERS = ['authorization', 'cookie', 'x-account-session'] as const
|
||||
|
||||
function buildForwardHeaders(req: Request) {
|
||||
const headers = new Headers({ 'Content-Type': 'application/json' })
|
||||
|
||||
for (const name of FORWARDED_HEADERS) {
|
||||
const value = req.headers.get(name)
|
||||
if (value) {
|
||||
headers.set(name, value)
|
||||
}
|
||||
}
|
||||
|
||||
return headers
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const { question, history } = await req.json()
|
||||
const apiBase = getInternalServerServiceBaseUrl()
|
||||
const response = await fetch(`${apiBase}/api/rag/query`, {
|
||||
method: 'POST',
|
||||
headers: buildForwardHeaders(req),
|
||||
body: JSON.stringify({ question, history }),
|
||||
credentials: 'include'
|
||||
})
|
||||
|
||||
const data = await response.json().catch(() => null)
|
||||
return Response.json(data ?? { error: 'Invalid response from server' }, {
|
||||
status: response.status
|
||||
})
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
return Response.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@ -1,23 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { ContentNotFoundError, renderMarkdownFile } from '@server/render-markdown'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const path = request.nextUrl.searchParams.get('path')
|
||||
if (!path) {
|
||||
return NextResponse.json({ error: 'Missing path parameter' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await renderMarkdownFile(path)
|
||||
return NextResponse.json(result, { status: 200 })
|
||||
} catch (error) {
|
||||
if (error instanceof ContentNotFoundError) {
|
||||
return NextResponse.json({ error: 'Markdown file not found' }, { status: 404 })
|
||||
}
|
||||
console.error('Failed to render markdown:', error)
|
||||
return NextResponse.json({ error: 'Failed to render markdown' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@ -1,46 +0,0 @@
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
import type { NextRequest } from 'next/server'
|
||||
|
||||
import { createUpstreamProxyHandler } from '@lib/apiProxy'
|
||||
import { getInternalServerServiceBaseUrl } from '@server/serviceConfig'
|
||||
|
||||
const TASK_PREFIX = '/api/task'
|
||||
|
||||
function createHandler() {
|
||||
const upstreamBaseUrl = getInternalServerServiceBaseUrl()
|
||||
return createUpstreamProxyHandler({
|
||||
upstreamBaseUrl,
|
||||
upstreamPathPrefix: TASK_PREFIX,
|
||||
})
|
||||
}
|
||||
|
||||
const handler = createHandler()
|
||||
|
||||
export function GET(request: NextRequest) {
|
||||
return handler(request)
|
||||
}
|
||||
|
||||
export function POST(request: NextRequest) {
|
||||
return handler(request)
|
||||
}
|
||||
|
||||
export function PUT(request: NextRequest) {
|
||||
return handler(request)
|
||||
}
|
||||
|
||||
export function PATCH(request: NextRequest) {
|
||||
return handler(request)
|
||||
}
|
||||
|
||||
export function DELETE(request: NextRequest) {
|
||||
return handler(request)
|
||||
}
|
||||
|
||||
export function HEAD(request: NextRequest) {
|
||||
return handler(request)
|
||||
}
|
||||
|
||||
export function OPTIONS(request: NextRequest) {
|
||||
return handler(request)
|
||||
}
|
||||
@ -1,63 +0,0 @@
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
import { getInternalServerServiceBaseUrl } from '@server/serviceConfig'
|
||||
import { getAccountSession, userHasRole } from '@server/account/session'
|
||||
import type { AccountUserRole } from '@server/account/session'
|
||||
|
||||
const SERVER_API_BASE = getInternalServerServiceBaseUrl()
|
||||
const SERVER_USERS_ENDPOINT = `${SERVER_API_BASE}/api/users`
|
||||
|
||||
const ALLOWED_ROLES: AccountUserRole[] = ['admin', 'operator']
|
||||
|
||||
type ErrorPayload = {
|
||||
error: string
|
||||
}
|
||||
|
||||
type PermissionAwareHeaders = {
|
||||
'X-User-Role': string
|
||||
'X-User-Permissions'?: string
|
||||
}
|
||||
|
||||
function buildForwardHeaders(role: string, permissions: string[]): PermissionAwareHeaders {
|
||||
const headers: PermissionAwareHeaders = {
|
||||
'X-User-Role': role,
|
||||
}
|
||||
if (permissions.length > 0) {
|
||||
headers['X-User-Permissions'] = permissions.join(',')
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const session = await getAccountSession()
|
||||
const user = session.user
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json<ErrorPayload>({ error: 'unauthenticated' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!(await userHasRole(user, ALLOWED_ROLES))) {
|
||||
return NextResponse.json<ErrorPayload>({ error: 'forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const headers = new Headers({
|
||||
Accept: 'application/json',
|
||||
...buildForwardHeaders(user.role, user.permissions),
|
||||
})
|
||||
|
||||
const response = await fetch(SERVER_USERS_ENDPOINT, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
cache: 'no-store',
|
||||
})
|
||||
|
||||
const payload = await response.json().catch(() => null)
|
||||
if (payload === null) {
|
||||
return NextResponse.json<ErrorPayload>({ error: 'invalid_response' }, { status: 502 })
|
||||
}
|
||||
|
||||
return NextResponse.json(payload, { status: response.status })
|
||||
}
|
||||
|
||||
@ -1,123 +0,0 @@
|
||||
import Link from 'next/link'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { readMarkdownFile } from '@lib/markdown'
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
type PageProps = {
|
||||
params: { slug: string }
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string, language: 'zh' | 'en'): string {
|
||||
const date = new Date(dateStr)
|
||||
|
||||
if (language === 'zh') {
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})
|
||||
} else {
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { slug } = await params
|
||||
try {
|
||||
const blogContentRoot = process.cwd() + '/src/content/blog'
|
||||
const file = await readMarkdownFile(`${slug}.md`, { baseDir: blogContentRoot })
|
||||
|
||||
const title = file.metadata.title as string
|
||||
const excerpt = (file.metadata.excerpt as string) || ''
|
||||
|
||||
return {
|
||||
title: `${title} | Cloud-Neutral Blog`,
|
||||
description: excerpt,
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
title: 'Blog Post | Cloud-Neutral',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default async function BlogPostPage({ params }: PageProps) {
|
||||
const { slug } = await params
|
||||
try {
|
||||
const blogContentRoot = process.cwd() + '/src/content/blog'
|
||||
const file = await readMarkdownFile(`${slug}.md`, { baseDir: blogContentRoot })
|
||||
|
||||
const title = (file.metadata.title as string) || slug
|
||||
const author = file.metadata.author as string | undefined
|
||||
const date = file.metadata.date as string | undefined
|
||||
const tags = file.metadata.tags as string[] | undefined
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen flex-col bg-slate-50">
|
||||
<div className="mx-auto w-full max-w-4xl px-4 py-16">
|
||||
{/* Back to blog link */}
|
||||
<Link
|
||||
href="/blog"
|
||||
className="mb-8 inline-flex items-center text-sm font-semibold text-brand transition hover:text-brand-dark"
|
||||
>
|
||||
← {date ? 'Back to Blog' : '返回博客'}
|
||||
</Link>
|
||||
|
||||
{/* Article header */}
|
||||
<header className="mb-12">
|
||||
<h1 className="mb-4 text-4xl font-bold text-slate-900 sm:text-5xl">
|
||||
{title}
|
||||
</h1>
|
||||
|
||||
{author && (
|
||||
<p className="mb-2 text-sm text-slate-600">
|
||||
{date ? 'By' : '作者'} {author}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{date && (
|
||||
<time className="text-sm text-slate-500">
|
||||
{formatDate(date, 'en')}
|
||||
</time>
|
||||
)}
|
||||
|
||||
{tags && tags.length > 0 && (
|
||||
<div className="mt-6 flex flex-wrap gap-2">
|
||||
{tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="rounded-full bg-slate-100 px-3 py-1 text-xs font-medium text-slate-700"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* Article content */}
|
||||
<article
|
||||
className="prose prose-slate max-w-none prose-headings:scroll-mt-24 prose-a:text-brand prose-a:no-underline hover:prose-a:underline"
|
||||
dangerouslySetInnerHTML={{ __html: file.html }}
|
||||
/>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="mt-16 border-t border-slate-200 pt-8">
|
||||
<Link
|
||||
href="/blog"
|
||||
className="inline-flex items-center text-sm font-semibold text-brand transition hover:text-brand-dark"
|
||||
>
|
||||
← Back to Blog
|
||||
</Link>
|
||||
</footer>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
} catch (error) {
|
||||
notFound()
|
||||
}
|
||||
}
|
||||
@ -1,175 +0,0 @@
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
import Link from 'next/link'
|
||||
import SearchComponent from '@components/search'
|
||||
import { getHomepagePosts } from '@lib/marketingContent'
|
||||
import type { Metadata } from 'next'
|
||||
import { notFound } from 'next/navigation'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Blog | Cloud-Neutral',
|
||||
description: 'Latest updates, releases, and insights from the Cloud-Neutral community.',
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string | undefined, language: 'zh' | 'en'): string {
|
||||
if (!dateStr) return ''
|
||||
|
||||
const date = new Date(dateStr)
|
||||
|
||||
if (language === 'zh') {
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})
|
||||
} else {
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type PageProps = {
|
||||
searchParams?: { page?: string } | Promise<{ page?: string }>
|
||||
}
|
||||
|
||||
export default async function BlogPage({ searchParams }: PageProps) {
|
||||
const posts = await getHomepagePosts()
|
||||
const resolvedSearchParams = await Promise.resolve(searchParams ?? {})
|
||||
const { page } = resolvedSearchParams ?? {}
|
||||
const postsPerPage = 10
|
||||
const currentPage = parseInt(page || '1', 10)
|
||||
const totalPages = Math.max(1, Math.ceil(posts.length / postsPerPage))
|
||||
|
||||
if ((posts.length > 0 && currentPage > totalPages) || currentPage < 1) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const startIndex = (currentPage - 1) * postsPerPage
|
||||
const endIndex = startIndex + postsPerPage
|
||||
const paginatedPosts = posts.slice(startIndex, endIndex)
|
||||
|
||||
return (
|
||||
<div className="bg-white text-slate-900">
|
||||
{/* Sticky Navigation Header */}
|
||||
<header className="sticky top-0 z-30 border-b border-slate-200 bg-white/80 backdrop-blur">
|
||||
<div className="mx-auto flex h-16 max-w-6xl items-center justify-between px-4 sm:px-6 lg:px-8">
|
||||
<Link href="/" className="flex items-center gap-2 text-sm font-semibold">
|
||||
<span className="text-slate-500">SVC.plus</span>
|
||||
<span className="text-slate-400">/</span>
|
||||
<span className="text-brand-dark">blog</span>
|
||||
</Link>
|
||||
<div className="flex items-center gap-3">
|
||||
<SearchComponent className="relative w-full max-w-xs" />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex min-h-screen flex-col bg-slate-50">
|
||||
<div className="mx-auto w-full max-w-6xl px-4 py-16">
|
||||
<div className="mb-12">
|
||||
<h1 className="text-4xl font-bold text-slate-900 mb-4">Blog</h1>
|
||||
<p className="text-lg text-slate-600">
|
||||
Latest updates, releases, and insights from the Cloud-Neutral community.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{posts.length === 0 ? (
|
||||
<div className="text-center py-20">
|
||||
<p className="text-slate-500">暂无博客文章</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-8">
|
||||
{paginatedPosts.map((post) => (
|
||||
<article
|
||||
key={post.slug}
|
||||
className="rounded-2xl border border-slate-200 bg-white p-8 shadow-sm transition hover:shadow-md"
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<span className="text-sm font-semibold text-brand">Blog</span>
|
||||
{post.date && (
|
||||
<time className="text-sm text-slate-500">
|
||||
{formatDate(post.date, 'en')}
|
||||
</time>
|
||||
)}
|
||||
</div>
|
||||
<h2 className="mb-4 text-2xl font-bold text-slate-900">{post.title}</h2>
|
||||
{post.author && (
|
||||
<p className="mb-4 text-sm text-slate-500">By {post.author}</p>
|
||||
)}
|
||||
<p className="mb-6 text-slate-600">{post.excerpt}</p>
|
||||
<div className="flex items-center gap-4">
|
||||
{post.tags && post.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{post.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="rounded-full bg-slate-100 px-3 py-1 text-xs font-medium text-slate-700"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Link
|
||||
href={`/blog/${post.slug}`}
|
||||
className="ml-auto text-sm font-semibold text-brand transition hover:text-brand-dark"
|
||||
>
|
||||
Read more →
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<nav className="mt-12 flex items-center justify-center gap-2">
|
||||
<Link
|
||||
href={`/blog?page=${currentPage - 1}`}
|
||||
className={`px-4 py-2 text-sm font-semibold rounded-lg transition ${
|
||||
currentPage === 1
|
||||
? 'cursor-not-allowed text-slate-400'
|
||||
: 'text-brand hover:bg-slate-100'
|
||||
}`}
|
||||
aria-disabled={currentPage === 1}
|
||||
>
|
||||
Previous
|
||||
</Link>
|
||||
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
|
||||
<Link
|
||||
key={page}
|
||||
href={`/blog?page=${page}`}
|
||||
className={`px-4 py-2 text-sm font-semibold rounded-lg transition ${
|
||||
page === currentPage
|
||||
? 'bg-brand text-white'
|
||||
: 'text-slate-700 hover:bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
{page}
|
||||
</Link>
|
||||
))}
|
||||
|
||||
<Link
|
||||
href={`/blog?page=${currentPage + 1}`}
|
||||
className={`px-4 py-2 text-sm font-semibold rounded-lg transition ${
|
||||
currentPage === totalPages
|
||||
? 'cursor-not-allowed text-slate-400'
|
||||
: 'text-brand hover:bg-slate-100'
|
||||
}`}
|
||||
aria-disabled={currentPage === totalPages}
|
||||
>
|
||||
Next
|
||||
</Link>
|
||||
</nav>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,32 +0,0 @@
|
||||
# Cloud IaC Catalog
|
||||
|
||||
Cloud IaC Catalog 提供跨云厂商(AWS、GCP、Azure、阿里云)的核心基础设施服务对照表,并预置 Terraform、Pulumi 以及 GitHub CI 的触发入口。页面基于 Next.js App Router 与 Tailwind CSS 构建,可快速扩展为企业内部的多云自动化控制台。
|
||||
|
||||
## 开发调试
|
||||
|
||||
```bash
|
||||
cd dashboard
|
||||
yarn install
|
||||
yarn dev
|
||||
```
|
||||
|
||||
开发服务器默认运行在 <http://localhost:3000>。访问 `/cloud_iac` 路由即可查看 IAC 编排面板。
|
||||
|
||||
## 数据与配置
|
||||
|
||||
- `lib/iac/catalog.ts`:维护十三大类别的产品对照表与 IaC 模块元数据。
|
||||
- `lib/iac/actions.ts`:封装触发 Terraform、Pulumi 与 GitHub Workflow 的占位实现,后续可替换为实际 API。
|
||||
- `components/iac/CloudIacCatalog.tsx`:渲染左侧筛选与右侧卡片网格,并处理弹窗交互。
|
||||
- `components/iac/RunModal.tsx`:运行确认弹窗,展示模块信息与 JSON 参数输入。
|
||||
|
||||
如需接入真实环境,可在 `catalog.ts` 中替换 Terraform 模块路径、Pulumi 组件名称或 GitHub Workflow 文件名,并在 `actions.ts` 中对接内部 API / 消息队列。
|
||||
|
||||
## 与 CI / IaC 系统集成
|
||||
|
||||
1. **Terraform / Pulumi**:在 `runTerraformModule` 与 `runPulumiProgram` 中调用内部 API(例如触发 Terraform Cloud、Atlantis、Pulumi Service 或自建执行引擎)。
|
||||
2. **GitHub CI**:在 `triggerGithubWorkflow` 中调用 GitHub REST / GraphQL API,或将请求转发至现有的 GitHub App 服务。
|
||||
3. **参数传递**:弹窗中的 JSON 参数会原样传入上述动作处理函数,可用于指定环境、凭证、变量文件等运行上下文。
|
||||
|
||||
## Feature Flag
|
||||
|
||||
`next.config.js` 会读取 `config/feature-toggles.json` 中的 `appModules.cloud_iac` 配置以决定入口是否展示与页面是否可访问。将该节点的 `enabled` 设置为 `false`(或为特定云厂商 / 服务节点设为 `false`)即可在生产环境中按需关闭。
|
||||
@ -1,76 +0,0 @@
|
||||
export const dynamic = 'error'
|
||||
|
||||
import type { Metadata } from 'next'
|
||||
import { notFound } from 'next/navigation'
|
||||
|
||||
import { CATALOG, PROVIDERS } from '@lib/iac/catalog'
|
||||
import type { CatalogItem, ProviderKey } from '@lib/iac/types'
|
||||
|
||||
import cloudIacIndex from '../../../../../public/_build/cloud_iac_index.json'
|
||||
import { isFeatureEnabled } from '@lib/featureToggles'
|
||||
|
||||
import ServiceDetailView from '@components/iac/ServiceDetailView'
|
||||
|
||||
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 generateStaticParams() {
|
||||
return CLOUD_IAC_INDEX.services.map((item) => ({ provider: item.provider, service: item.service }))
|
||||
}
|
||||
|
||||
export const dynamicParams = false
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Cloud IaC Catalog',
|
||||
}
|
||||
|
||||
export default function CloudIacServicePage({ params }: { params: PageParams }) {
|
||||
const providerKey = params.provider as ProviderKey
|
||||
const serviceSlug = params.service
|
||||
|
||||
if (!isFeatureEnabled('appModules', `/cloud_iac/${providerKey}/${serviceSlug}`)) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const providerLabel = PROVIDER_MAP.get(providerKey)
|
||||
if (!providerLabel) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const category = findCategoryBySlug(providerKey, serviceSlug)
|
||||
if (!category) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const integration = category.iac?.[providerKey]
|
||||
const productName = category.products[providerKey] ?? category.title
|
||||
|
||||
return (
|
||||
<main className="px-4 py-10 md:px-8">
|
||||
<div className="mx-auto max-w-7xl space-y-8">
|
||||
<ServiceDetailView
|
||||
providerKey={providerKey}
|
||||
providerLabel={providerLabel}
|
||||
category={category}
|
||||
productName={productName}
|
||||
integration={integration}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@ -1,62 +0,0 @@
|
||||
export const dynamic = 'error'
|
||||
|
||||
import type { Metadata } from 'next'
|
||||
import { notFound } from 'next/navigation'
|
||||
|
||||
import CloudIacCatalog from '@components/iac/CloudIacCatalog'
|
||||
import { CATALOG, PROVIDERS } from '@lib/iac/catalog'
|
||||
import type { ProviderKey } from '@lib/iac/types'
|
||||
|
||||
import cloudIacIndex from '../../../../public/_build/cloud_iac_index.json'
|
||||
import { isFeatureEnabled } from '@lib/featureToggles'
|
||||
|
||||
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 generateStaticParams() {
|
||||
return CLOUD_IAC_INDEX.providers.map((provider) => ({ provider: provider.key }))
|
||||
}
|
||||
|
||||
export const dynamicParams = false
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Cloud IaC Catalog',
|
||||
}
|
||||
|
||||
export default function CloudIacProviderPage({ params }: { params: PageParams }) {
|
||||
const providerKey = params.provider as ProviderKey
|
||||
|
||||
if (!isFeatureEnabled('appModules', `/cloud_iac/${providerKey}`)) {
|
||||
notFound()
|
||||
}
|
||||
if (!PROVIDER_MAP.has(providerKey)) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const providerLabel = PROVIDER_MAP.get(providerKey)!
|
||||
|
||||
return (
|
||||
<main className="px-4 py-10 md:px-8">
|
||||
<div className="mx-auto flex max-w-7xl flex-col gap-8">
|
||||
<header className="space-y-3">
|
||||
<p className="text-sm font-semibold uppercase tracking-wide text-purple-600">{providerLabel} Catalog</p>
|
||||
<h1 className="text-3xl font-bold text-gray-900 md:text-4xl">核心服务目录</h1>
|
||||
<p className="max-w-3xl text-sm text-gray-600 md:text-base">
|
||||
浏览 {providerLabel} 提供的计算、网络、负载均衡、存储、数据库、缓存、队列、容器服务、数据服务、安全防护以及身份与访问管理能力,点击卡片进入服务详情配置 IaC 与 GitOps。
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<CloudIacCatalog catalog={CATALOG} providers={PROVIDERS} mode="provider" activeProvider={providerKey} />
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@ -1,38 +0,0 @@
|
||||
export const dynamic = 'error'
|
||||
|
||||
import type { Metadata } from 'next'
|
||||
import { notFound } from 'next/navigation'
|
||||
|
||||
import CloudIacCatalog from '@components/iac/CloudIacCatalog'
|
||||
import { CATALOG, PROVIDERS } from '@lib/iac/catalog'
|
||||
|
||||
import { isFeatureEnabled } from '@lib/featureToggles'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Cloud IaC Catalog',
|
||||
description:
|
||||
'跨云厂商的计算、网络、存储等核心服务一站式对照表,可分层浏览厂商详情并调度 Terraform / Pulumi / GitOps 流程。',
|
||||
}
|
||||
|
||||
export default function CloudIacPage() {
|
||||
if (!isFeatureEnabled('appModules', '/cloud_iac')) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="px-4 py-10 md:px-8">
|
||||
<div className="mx-auto flex max-w-7xl flex-col gap-10">
|
||||
<header className="space-y-3">
|
||||
<p className="text-sm font-semibold uppercase tracking-wide text-purple-600">Cloud Automation</p>
|
||||
<h1 className="text-3xl font-bold text-gray-900 md:text-4xl">Cloud IaC Catalog</h1>
|
||||
<p className="max-w-3xl text-sm text-gray-600 md:text-base">
|
||||
跨云厂商(AWS / GCP / Azure / 阿里云)的计算、网络、DNS / CDN、负载均衡、存储、数据库、缓存、队列、容器服务、边缘计算 / IoT、
|
||||
数据服务、监控日志 / 事件总线、API 网关 / 数据集成、安全防护、身份与访问管理等核心能力汇总于此。先在概览页快速比对,再进入各云厂商目录与服务详情页配置 GitOps 同步与 IaC 执行流程。
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<CloudIacCatalog catalog={CATALOG} providers={PROVIDERS} />
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@ -1,38 +0,0 @@
|
||||
'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,28 +0,0 @@
|
||||
import type { SVGProps } from 'react'
|
||||
|
||||
export function WeChatIcon({ className, ...props }: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
role="img"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M14 2.75c-4.418 0-8 2.996-8 6.7 0 1.71.78 3.28 2.07 4.51L7 16.4l2.64-1.5c1.38.6 2.94.95 4.54.95 4.418 0 8-2.996 8-6.7s-3.582-6.4-8-6.4Z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
opacity="0.85"
|
||||
d="M6.25 10.5C3.35 10.5 1 12.61 1 15.17c0 1.26.56 2.43 1.52 3.33L1 22l3.36-1.9c.65.18 1.34.27 2.07.27 2.9 0 5.26-2.11 5.26-4.67S9.15 10.5 6.25 10.5Z"
|
||||
/>
|
||||
<circle cx="11.4" cy="9.4" r="0.9" fill="#fff" />
|
||||
<circle cx="15.6" cy="9.4" r="0.9" fill="#fff" />
|
||||
<circle cx="5.3" cy="15.1" r="0.75" fill="#fff" />
|
||||
<circle cx="7.8" cy="15.1" r="0.75" fill="#fff" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@ -1,199 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { InsightState } from '../../insight/store/urlState'
|
||||
|
||||
const quickActions = [
|
||||
{ id: 'explain', label: 'Explain anomaly', prompt: 'Explain the recent anomaly in my metrics.' },
|
||||
{ id: 'logs', label: 'Fetch related logs', prompt: 'Show me logs correlated with the current filters.' },
|
||||
{ id: 'rca', label: 'Root cause analysis', prompt: 'Run an RCA for the checkout service using metrics, logs and traces.' },
|
||||
{ id: 'alert', label: 'Draft alert', prompt: 'Generate an alert rule for p95 latency over 300ms.' },
|
||||
{ id: 'report', label: 'Incident report', prompt: 'Create a short incident report for the last hour.' }
|
||||
]
|
||||
|
||||
interface AssistantProps {
|
||||
state: InsightState
|
||||
}
|
||||
|
||||
export function AIAssistant({ state }: AssistantProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [isMinimized, setIsMinimized] = useState(false)
|
||||
const [isMaximized, setIsMaximized] = useState(false)
|
||||
const [message, setMessage] = useState('')
|
||||
const [history, setHistory] = useState<{ question: string; timestamp: number }[]>([])
|
||||
const [conversation, setConversation] = useState<
|
||||
{ author: 'user' | 'ai'; text: string; timestamp: number }[]
|
||||
>([])
|
||||
|
||||
const contextSummary = useMemo(
|
||||
() =>
|
||||
`Org=${state.org}, Project=${state.project}, Env=${state.env}, Region=${state.region}, Topology=${state.topologyMode}, Service=${state.service || 'all'}, Time=${state.timeRange}`,
|
||||
[state]
|
||||
)
|
||||
|
||||
function openPanel(prompt?: string) {
|
||||
setIsOpen(true)
|
||||
setIsMinimized(false)
|
||||
if (prompt) {
|
||||
appendMessage(prompt)
|
||||
}
|
||||
}
|
||||
|
||||
function appendMessage(prompt: string) {
|
||||
const timestamp = Date.now()
|
||||
const updatedHistory = [{ question: prompt, timestamp }, ...history].slice(0, 10)
|
||||
setHistory(updatedHistory)
|
||||
setConversation(prev => [
|
||||
...prev,
|
||||
{ author: 'user', text: prompt, timestamp },
|
||||
{
|
||||
author: 'ai',
|
||||
text: `Using context (${contextSummary}) I will draft insights for “${prompt}”. Connect me to your backend to replace this placeholder response.`,
|
||||
timestamp: timestamp + 1
|
||||
}
|
||||
])
|
||||
}
|
||||
|
||||
function handleSend() {
|
||||
if (!message.trim()) return
|
||||
appendMessage(message.trim())
|
||||
setMessage('')
|
||||
}
|
||||
|
||||
function toggleMinimize() {
|
||||
setIsMinimized(prev => !prev)
|
||||
}
|
||||
|
||||
function toggleMaximize() {
|
||||
setIsMaximized(prev => !prev)
|
||||
setIsMinimized(false)
|
||||
}
|
||||
|
||||
function closePanel() {
|
||||
setIsOpen(false)
|
||||
setIsMinimized(false)
|
||||
setIsMaximized(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 rounded-2xl border border-slate-800 bg-slate-900/70 p-5 shadow-lg shadow-slate-950/20">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-slate-100">AI Assistant</h3>
|
||||
<p className="text-xs text-slate-400">Bring AskAI insights into your observability workflow.</p>
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{quickActions.map(action => (
|
||||
<button
|
||||
key={action.id}
|
||||
onClick={() => openPanel(action.prompt)}
|
||||
className="rounded-2xl border border-slate-800 bg-slate-950/60 px-3 py-2 text-left text-sm text-slate-200 transition hover:border-emerald-500/60"
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-800 bg-slate-950/60 p-4 text-xs text-slate-300">
|
||||
<p className="text-[11px] uppercase tracking-wide text-slate-500">Current context</p>
|
||||
<p className="mt-2 text-[12px] leading-relaxed text-slate-400">{contextSummary}</p>
|
||||
</div>
|
||||
<div className="space-y-2 text-xs text-slate-300">
|
||||
<p className="text-[11px] uppercase tracking-wide text-slate-500">Recent questions</p>
|
||||
{history.length === 0 ? (
|
||||
<p className="text-xs text-slate-500">Run a quick action or open the assistant to get started.</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{history.map(item => (
|
||||
<li key={item.timestamp} className="flex items-center justify-between rounded-xl border border-slate-800 bg-slate-950/60 px-3 py-2">
|
||||
<span className="text-slate-200">{item.question}</span>
|
||||
<span className="text-slate-500">{new Date(item.timestamp).toLocaleTimeString()}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => openPanel('Help me explore the current observability context.')}
|
||||
className="w-full rounded-xl bg-emerald-500/80 px-4 py-2 text-sm font-semibold text-emerald-950"
|
||||
>
|
||||
Open assistant
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div
|
||||
className={`fixed right-6 z-40 flex flex-col rounded-3xl border border-emerald-500/30 bg-slate-950/95 shadow-2xl transition-all ${
|
||||
isMaximized ? 'top-6 bottom-6 w-[420px] lg:w-[460px]' : 'bottom-10 w-[360px] lg:w-[400px]'
|
||||
} ${isMinimized ? 'h-14 overflow-hidden' : 'max-h-[85vh]'}`}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-emerald-500/20 px-4 py-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-100">AI copilot</p>
|
||||
{!isMinimized && (
|
||||
<p className="text-[11px] text-slate-400">Context aware responses for the current workspace.</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-slate-300">
|
||||
<button onClick={toggleMinimize} className="rounded-lg border border-transparent px-2 py-1 hover:border-slate-700">
|
||||
{isMinimized ? 'Restore' : 'Minimize'}
|
||||
</button>
|
||||
<button onClick={toggleMaximize} className="rounded-lg border border-transparent px-2 py-1 hover:border-slate-700">
|
||||
{isMaximized ? 'Default size' : 'Maximize'}
|
||||
</button>
|
||||
<button onClick={closePanel} className="rounded-lg border border-transparent px-2 py-1 hover:border-slate-700">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isMinimized && (
|
||||
<div className="flex-1 overflow-y-auto px-4 py-3">
|
||||
{conversation.length === 0 ? (
|
||||
<p className="text-xs text-slate-500">
|
||||
Ask a question to start a conversation. The assistant replies with enriched placeholders until wired to
|
||||
your backend.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{conversation.map(entry => (
|
||||
<li
|
||||
key={entry.timestamp}
|
||||
className={`rounded-2xl px-3 py-2 text-sm ${
|
||||
entry.author === 'user'
|
||||
? 'bg-emerald-500/10 text-emerald-100'
|
||||
: 'bg-slate-900/80 text-slate-200'
|
||||
}`}
|
||||
>
|
||||
<p className="text-xs uppercase tracking-wide text-slate-500">
|
||||
{entry.author === 'user' ? 'You' : 'Assistant'} · {new Date(entry.timestamp).toLocaleTimeString()}
|
||||
</p>
|
||||
<p className="mt-1 text-sm leading-relaxed">{entry.text}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isMinimized && (
|
||||
<div className="border-t border-emerald-500/10 bg-slate-950/80 px-4 py-3">
|
||||
<textarea
|
||||
value={message}
|
||||
onChange={event => setMessage(event.target.value)}
|
||||
placeholder="Ask anything about this observability view…"
|
||||
className="h-20 w-full rounded-2xl border border-slate-800 bg-slate-900/80 p-3 text-sm text-slate-200"
|
||||
/>
|
||||
<div className="mt-2 flex items-center justify-between text-xs text-slate-400">
|
||||
<span>Responses include context automatically.</span>
|
||||
<button
|
||||
onClick={handleSend}
|
||||
className="rounded-xl bg-emerald-500/80 px-3 py-1.5 text-xs font-semibold text-emerald-950"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,341 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { fetchPromQL } from '../../insight/services/adapters/prometheus'
|
||||
import { fetchLogs } from '../../insight/services/adapters/logs'
|
||||
import { fetchTraces } from '../../insight/services/adapters/traces'
|
||||
import { DataSource, InsightState, QueryInputMode, QueryLanguage } from '../../insight/store/urlState'
|
||||
import { QueryChips } from '@components/common/QueryChips'
|
||||
import { QueryHistoryPanel } from '@components/common/QueryHistoryPanel'
|
||||
|
||||
interface ExploreBuilderProps {
|
||||
state: InsightState
|
||||
updateState: (partial: Partial<InsightState>) => void
|
||||
history: Record<QueryLanguage, string[]>
|
||||
setHistory: (language: QueryLanguage, next: string[]) => void
|
||||
onResults: (language: QueryLanguage, data: any) => void
|
||||
panelLanguages?: QueryLanguage[]
|
||||
}
|
||||
|
||||
export const languageMeta: Record<
|
||||
QueryLanguage,
|
||||
{ label: string; description: string; dataSource: DataSource; placeholder: string }
|
||||
> = {
|
||||
promql: {
|
||||
label: 'PromQL Explorer',
|
||||
description: 'Build metrics queries for service SLOs and alerts.',
|
||||
dataSource: 'metrics',
|
||||
placeholder: 'sum(rate(http_requests_total{job="api"}[5m]))'
|
||||
},
|
||||
logql: {
|
||||
label: 'LogQL Explorer',
|
||||
description: 'Stream and filter structured application logs.',
|
||||
dataSource: 'logs',
|
||||
placeholder: '{service="checkout"} |= "error"'
|
||||
},
|
||||
traceql: {
|
||||
label: 'TraceQL Explorer',
|
||||
description: 'Slice and dice distributed tracing data.',
|
||||
dataSource: 'traces',
|
||||
placeholder: 'traces{service="checkout"} | duration > 250ms'
|
||||
}
|
||||
}
|
||||
|
||||
const defaultRecord = <T,>(value: T): Record<QueryLanguage, T> => ({
|
||||
promql: value,
|
||||
logql: value,
|
||||
traceql: value
|
||||
})
|
||||
|
||||
export function ExploreBuilder({
|
||||
state,
|
||||
updateState,
|
||||
history,
|
||||
setHistory,
|
||||
onResults,
|
||||
panelLanguages
|
||||
}: ExploreBuilderProps) {
|
||||
const [chipsMap, setChipsMap] = useState<Record<QueryLanguage, string[]>>(defaultRecord<string[]>([]))
|
||||
const [runningMap, setRunningMap] = useState<Record<QueryLanguage, boolean>>(defaultRecord<boolean>(false))
|
||||
const [messageMap, setMessageMap] = useState<Record<QueryLanguage, string>>(defaultRecord<string>(''))
|
||||
const [collapsedMap, setCollapsedMap] = useState<Record<QueryLanguage, boolean>>(defaultRecord<boolean>(false))
|
||||
const [inputModeMap, setInputModeMap] = useState<Record<QueryLanguage, QueryInputMode>>(defaultRecord<QueryInputMode>('ql'))
|
||||
|
||||
const activePanels = useMemo(
|
||||
() => panelLanguages ?? state.activeLanguages,
|
||||
[panelLanguages, state.activeLanguages]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!state.service) return
|
||||
setChipsMap(prev => {
|
||||
const serviceChip = `service="${state.service}"`
|
||||
const next: Record<QueryLanguage, string[]> = { ...prev }
|
||||
activePanels.forEach(language => {
|
||||
if (!next[language]) {
|
||||
next[language] = []
|
||||
}
|
||||
if (!next[language].includes(serviceChip)) {
|
||||
next[language] = [...next[language], serviceChip]
|
||||
}
|
||||
})
|
||||
return next
|
||||
})
|
||||
}, [activePanels, state.service])
|
||||
|
||||
useEffect(() => {
|
||||
setInputModeMap(prev => {
|
||||
const next = { ...prev }
|
||||
activePanels.forEach(language => {
|
||||
if (!next[language]) {
|
||||
next[language] = 'ql'
|
||||
}
|
||||
})
|
||||
return next
|
||||
})
|
||||
}, [activePanels])
|
||||
|
||||
async function runQuery(language: QueryLanguage) {
|
||||
const query = state.queries[language] || ''
|
||||
if (!query) return
|
||||
const meta = languageMeta[language]
|
||||
setRunningMap(prev => ({ ...prev, [language]: true }))
|
||||
setMessageMap(prev => ({ ...prev, [language]: '' }))
|
||||
try {
|
||||
let result: any
|
||||
if (meta.dataSource === 'metrics') {
|
||||
result = await fetchPromQL(query)
|
||||
} else if (meta.dataSource === 'logs') {
|
||||
result = await fetchLogs(query)
|
||||
} else {
|
||||
result = await fetchTraces(query)
|
||||
}
|
||||
onResults(language, result)
|
||||
const nextHistory = [query, ...(history[language] || []).filter(item => item !== query)].slice(0, 15)
|
||||
setHistory(language, nextHistory)
|
||||
setMessageMap(prev => ({ ...prev, [language]: 'Query executed successfully' }))
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
setMessageMap(prev => ({ ...prev, [language]: 'Query failed. Please try again later.' }))
|
||||
} finally {
|
||||
setRunningMap(prev => ({ ...prev, [language]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
function removeChip(language: QueryLanguage, label: string) {
|
||||
setChipsMap(prev => ({
|
||||
...prev,
|
||||
[language]: (prev[language] || []).filter(item => item !== label)
|
||||
}))
|
||||
}
|
||||
|
||||
function toggleMode() {
|
||||
updateState({ builderMode: state.builderMode === 'visual' ? 'code' : 'visual' })
|
||||
}
|
||||
|
||||
function setInputMode(language: QueryLanguage, mode: QueryInputMode) {
|
||||
setInputModeMap(prev => ({ ...prev, [language]: mode }))
|
||||
}
|
||||
|
||||
function handleQueryChange(language: QueryLanguage, value: string) {
|
||||
const dataSource = languageMeta[language].dataSource
|
||||
setInputMode(language, 'ql')
|
||||
updateState({
|
||||
queries: { ...state.queries, [language]: value },
|
||||
queryLanguage: language,
|
||||
dataSource,
|
||||
activeLanguages: Array.from(new Set([...state.activeLanguages, language]))
|
||||
})
|
||||
}
|
||||
|
||||
function handleHistoryInsert(language: QueryLanguage, query: string) {
|
||||
handleQueryChange(language, query)
|
||||
}
|
||||
|
||||
function handleCollapse(language: QueryLanguage) {
|
||||
setCollapsedMap(prev => ({ ...prev, [language]: !prev[language] }))
|
||||
}
|
||||
|
||||
if (activePanels.length === 0) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-slate-800 bg-slate-900/70 p-6 text-sm text-slate-300">
|
||||
Select a query language from the navigation to start exploring data.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const panels = activePanels.map(language => {
|
||||
const meta = languageMeta[language]
|
||||
const chips = chipsMap[language] || []
|
||||
const historyItems = history[language] || []
|
||||
const isCollapsed = collapsedMap[language]
|
||||
const inputMode = inputModeMap[language] || 'ql'
|
||||
|
||||
return (
|
||||
<section
|
||||
key={language}
|
||||
className="flex h-full flex-col rounded-2xl border border-slate-800 bg-slate-900/70 p-5 shadow-lg shadow-slate-950/20"
|
||||
>
|
||||
<header className="panel-drag-handle flex flex-wrap items-start gap-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-slate-200">{meta.label}</h3>
|
||||
<p className="text-xs text-slate-400">{meta.description}</p>
|
||||
</div>
|
||||
<div className="ml-auto flex flex-wrap items-center gap-2 text-xs text-slate-400">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="hidden sm:inline">Mode:</span>
|
||||
<div className="flex overflow-hidden rounded-xl border border-slate-800">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setInputMode(language, 'ql')}
|
||||
className={`px-3 py-1 text-xs font-medium transition ${
|
||||
inputMode === 'ql'
|
||||
? 'bg-emerald-500/20 text-emerald-200'
|
||||
: 'bg-slate-900/70 text-slate-400 hover:bg-slate-800'
|
||||
}`}
|
||||
>
|
||||
QL input
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setInputMode(language, 'menu')}
|
||||
className={`px-3 py-1 text-xs font-medium transition ${
|
||||
inputMode === 'menu'
|
||||
? 'bg-emerald-500/20 text-emerald-200'
|
||||
: 'bg-slate-900/70 text-slate-400 hover:bg-slate-800'
|
||||
}`}
|
||||
>
|
||||
Menu select
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleCollapse(language)}
|
||||
className="rounded-xl border border-slate-700 px-3 py-1 hover:bg-slate-800"
|
||||
>
|
||||
{isCollapsed ? 'Expand' : 'Collapse'}
|
||||
</button>
|
||||
{inputMode === 'ql' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleMode}
|
||||
className="rounded-xl border border-slate-700 px-3 py-1 hover:bg-slate-800"
|
||||
>
|
||||
{state.builderMode === 'visual' ? 'Switch to code' : 'Switch to visual'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
{!isCollapsed && (
|
||||
<div className="mt-4 flex flex-1 flex-col gap-4">
|
||||
{inputMode === 'menu' ? (
|
||||
<div className="space-y-3 text-xs text-slate-200">
|
||||
<ContextSummary state={state} />
|
||||
<div>
|
||||
<p className="text-[11px] uppercase tracking-wide text-slate-500">Query preview</p>
|
||||
<pre className="mt-2 max-h-32 overflow-auto rounded-2xl border border-slate-800 bg-slate-950/60 p-3 text-xs text-emerald-200">
|
||||
{state.queries[language] || meta.placeholder}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
) : state.builderMode === 'visual' ? (
|
||||
<div className="space-y-3">
|
||||
<QueryChips labels={chips} onRemove={label => removeChip(language, label)} />
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
||||
Aggregation
|
||||
<select className="rounded-xl border border-slate-800 bg-slate-950/60 px-3 py-2 text-sm text-slate-200">
|
||||
<option>sum</option>
|
||||
<option>avg</option>
|
||||
<option>max</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
||||
Window
|
||||
<select className="rounded-xl border border-slate-800 bg-slate-950/60 px-3 py-2 text-sm text-slate-200">
|
||||
<option>5m</option>
|
||||
<option>15m</option>
|
||||
<option>1h</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<textarea
|
||||
value={state.queries[language] || ''}
|
||||
onChange={event => handleQueryChange(language, event.target.value)}
|
||||
placeholder={meta.placeholder}
|
||||
className="h-32 w-full rounded-2xl border border-slate-800 bg-slate-950/60 p-3 text-sm text-slate-200 shadow-inner"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<textarea
|
||||
value={state.queries[language] || ''}
|
||||
onChange={event => handleQueryChange(language, event.target.value)}
|
||||
placeholder={meta.placeholder}
|
||||
className="h-48 w-full rounded-2xl border border-slate-800 bg-slate-950/60 p-3 font-mono text-sm text-slate-200 shadow-inner"
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
onClick={() => runQuery(language)}
|
||||
disabled={runningMap[language]}
|
||||
className="rounded-xl bg-emerald-500/80 px-4 py-2 text-sm font-semibold text-emerald-950 shadow-lg disabled:opacity-50"
|
||||
>
|
||||
{runningMap[language] ? 'Running…' : 'Run query'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const query = state.queries[language] || meta.placeholder
|
||||
const unique = [query, ...historyItems.filter(item => item !== query)].slice(0, 15)
|
||||
setHistory(language, unique)
|
||||
}}
|
||||
className="rounded-xl border border-slate-700 px-3 py-2 text-xs text-slate-300 hover:bg-slate-800"
|
||||
>
|
||||
Save to history
|
||||
</button>
|
||||
{messageMap[language] && <span className="text-xs text-slate-400">{messageMap[language]}</span>}
|
||||
</div>
|
||||
<QueryHistoryPanel
|
||||
history={historyItems}
|
||||
onInsert={query => handleHistoryInsert(language, query)}
|
||||
onClear={() => setHistory(language, [])}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
})
|
||||
|
||||
if (panels.length === 1) {
|
||||
return panels[0]
|
||||
}
|
||||
|
||||
return <div className="space-y-4">{panels}</div>
|
||||
}
|
||||
|
||||
function ContextSummary({ state }: { state: InsightState }) {
|
||||
const context = [
|
||||
{ label: 'Org', value: state.org },
|
||||
{ label: 'Environment', value: state.env },
|
||||
{ label: 'Region', value: state.region },
|
||||
{ label: 'Project', value: state.project },
|
||||
{ label: 'Time range', value: state.timeRange }
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-slate-800 bg-slate-950/60 p-3">
|
||||
<p className="text-[11px] uppercase tracking-wide text-slate-500">Global context</p>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
{context.map(item => (
|
||||
<span
|
||||
key={item.label}
|
||||
className="flex items-center gap-2 rounded-full border border-slate-800 bg-slate-900/70 px-3 py-1 text-xs text-slate-200"
|
||||
>
|
||||
<span className="text-slate-500">{item.label}</span>
|
||||
<span className="font-medium text-slate-100">{item.value}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,91 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { InsightState } from '../../insight/store/urlState'
|
||||
import { TimeRangePicker } from './TimeRangePicker'
|
||||
|
||||
interface BreadcrumbBarProps {
|
||||
state: InsightState
|
||||
updateState: (partial: Partial<InsightState>) => void
|
||||
shareableLink: string
|
||||
}
|
||||
|
||||
const orgs = ['global-org', 'retail-hub', 'fintech-lab']
|
||||
const projects = ['observability', 'payments', 'edge']
|
||||
const envs = ['production', 'staging', 'dev']
|
||||
const regions = ['us-west-2', 'eu-central-1', 'ap-southeast-1']
|
||||
|
||||
export function BreadcrumbBar({ state, updateState, shareableLink }: BreadcrumbBarProps) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const canCopy = Boolean(shareableLink)
|
||||
|
||||
async function handleCopy() {
|
||||
if (!canCopy) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(shareableLink)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
} catch (err) {
|
||||
console.error('Copy failed', err)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-wrap items-center gap-3 text-sm text-slate-200">
|
||||
<Selector label="Org" value={state.org} options={orgs} onChange={org => updateState({ org })} />
|
||||
<Separator />
|
||||
<Selector label="Environment" value={state.env} options={envs} onChange={env => updateState({ env })} />
|
||||
<Separator />
|
||||
<Selector label="Region" value={state.region} options={regions} onChange={region => updateState({ region })} />
|
||||
<Separator />
|
||||
<Selector label="Project" value={state.project} options={projects} onChange={project => updateState({ project })} />
|
||||
<Separator />
|
||||
<TimeRangePicker state={state} updateState={updateState} />
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
disabled={!canCopy}
|
||||
className={`ml-auto flex items-center gap-2 rounded-xl border border-slate-700 px-3 py-1.5 text-xs font-medium text-slate-200 transition ${
|
||||
canCopy ? 'hover:bg-slate-800' : 'opacity-60 cursor-not-allowed'
|
||||
}`}
|
||||
aria-disabled={!canCopy}
|
||||
>
|
||||
{copied ? 'Link copied!' : canCopy ? 'Copy share link' : 'Generating share link...'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Selector({
|
||||
label,
|
||||
value,
|
||||
options,
|
||||
onChange
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
options: string[]
|
||||
onChange: (value: string) => void
|
||||
}) {
|
||||
return (
|
||||
<label className="flex min-w-[160px] flex-1 items-center gap-2 rounded-xl border border-slate-800 bg-slate-900/80 px-3 py-1.5 shadow-inner">
|
||||
<span className="text-xs uppercase tracking-wide text-slate-500">{label}</span>
|
||||
<select
|
||||
value={value}
|
||||
onChange={event => onChange(event.target.value)}
|
||||
className="w-full bg-transparent text-sm font-medium focus:outline-none"
|
||||
>
|
||||
{options.map(option => (
|
||||
<option key={option} value={option} className="bg-slate-900">
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function Separator() {
|
||||
return <span className="text-slate-600">/</span>
|
||||
}
|
||||
@ -1,225 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { BellRing, Compass, Layers, Sparkles, type LucideIcon, PanelLeftClose, PanelLeftOpen, EyeOff } from 'lucide-react'
|
||||
|
||||
import { QueryLanguage, TopologyMode } from '../../insight/store/urlState'
|
||||
|
||||
interface SidebarProps {
|
||||
topologyMode: TopologyMode
|
||||
activeLanguages: QueryLanguage[]
|
||||
onSelectSection: (section: string) => void
|
||||
onTopologyChange: (mode: TopologyMode) => void
|
||||
onToggleLanguage: (language: QueryLanguage) => void
|
||||
onToggleCollapse: () => void
|
||||
onHide: () => void
|
||||
activeSection: string
|
||||
collapsed: boolean
|
||||
}
|
||||
|
||||
const sections: { id: string; label: string; icon: LucideIcon }[] = [
|
||||
{ id: 'topology', label: 'Topology', icon: Layers },
|
||||
{ id: 'explore', label: 'Explore', icon: Compass },
|
||||
{ id: 'slo', label: 'SLO & Alerts', icon: BellRing },
|
||||
{ id: 'ai', label: 'AI Assistant', icon: Sparkles }
|
||||
]
|
||||
|
||||
const topologyOptions: { id: TopologyMode; label: string; hint: string }[] = [
|
||||
{ id: 'application', label: 'Application', hint: 'Services and dependencies' },
|
||||
{ id: 'network', label: 'Network', hint: 'Gateways, meshes and edges' },
|
||||
{ id: 'resource', label: 'Resource', hint: 'Clusters, nodes and workloads' }
|
||||
]
|
||||
|
||||
const languageOptions: { id: QueryLanguage; label: string; description: string }[] = [
|
||||
{ id: 'promql', label: 'PromQL', description: 'Metrics analytics' },
|
||||
{ id: 'logql', label: 'LogQL', description: 'Log navigation' },
|
||||
{ id: 'traceql', label: 'TraceQL', description: 'Trace exploration' }
|
||||
]
|
||||
|
||||
export function Sidebar({
|
||||
topologyMode,
|
||||
activeLanguages,
|
||||
activeSection,
|
||||
onSelectSection,
|
||||
onTopologyChange,
|
||||
onToggleLanguage,
|
||||
onToggleCollapse,
|
||||
onHide,
|
||||
collapsed
|
||||
}: SidebarProps) {
|
||||
return (
|
||||
<aside
|
||||
className={`border-r border-slate-800 bg-slate-900/70 px-3 py-6 backdrop-blur transition-all duration-200 ${
|
||||
collapsed ? 'w-20 space-y-6' : 'w-full space-y-7 lg:w-72 xl:w-80'
|
||||
}`}
|
||||
>
|
||||
<div className={`flex items-start justify-between ${collapsed ? 'flex-col items-center gap-4' : ''}`}>
|
||||
{!collapsed && (
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-lg font-semibold text-slate-100">Insight Workbench</h1>
|
||||
<p className="text-sm text-slate-400">
|
||||
Navigate topology, run cross-domain queries and keep SLOs on track.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className={`flex items-center gap-2 ${collapsed ? '' : 'ml-2'}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleCollapse}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-xl border border-slate-800 bg-slate-900/80 text-slate-300 transition hover:border-slate-700 hover:text-slate-100"
|
||||
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||
>
|
||||
{collapsed ? <PanelLeftOpen className="h-4 w-4" /> : <PanelLeftClose className="h-4 w-4" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onHide}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-xl border border-slate-800 bg-slate-900/80 text-slate-300 transition hover:border-red-500/60 hover:text-red-300"
|
||||
aria-label="Hide sidebar"
|
||||
>
|
||||
<EyeOff className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className={`space-y-1 ${collapsed ? 'flex flex-col items-center gap-2 space-y-0' : ''}`}>
|
||||
{sections.map(section => {
|
||||
const active = activeSection === section.id
|
||||
const Icon = section.icon
|
||||
return (
|
||||
<div key={section.id} className={`group relative ${collapsed ? 'w-full' : ''}`}>
|
||||
<button
|
||||
onClick={() => onSelectSection(section.id)}
|
||||
className={`w-full rounded-xl transition ${
|
||||
collapsed
|
||||
? 'flex flex-col items-center gap-2 px-2 py-3'
|
||||
: 'flex items-center gap-3 px-3 py-2 text-left'
|
||||
} ${
|
||||
active
|
||||
? 'bg-slate-800 text-slate-100 shadow-inner shadow-slate-800/60'
|
||||
: 'text-slate-300 hover:bg-slate-800/60'
|
||||
}`}
|
||||
title={section.label}
|
||||
>
|
||||
<span
|
||||
className={`flex h-10 w-10 items-center justify-center rounded-xl border ${
|
||||
active ? 'border-slate-700 bg-slate-800 text-slate-100' : 'border-slate-800 bg-slate-900 text-slate-400'
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
</span>
|
||||
{!collapsed && <span className="font-medium">{section.label}</span>}
|
||||
</button>
|
||||
|
||||
{section.id === 'topology' && (
|
||||
<div
|
||||
className={`pointer-events-none absolute z-20 hidden w-60 rounded-2xl border border-slate-800 bg-slate-950/90 p-3 text-left shadow-xl backdrop-blur transition group-hover:pointer-events-auto group-hover:flex group-focus-within:pointer-events-auto group-focus-within:flex ${
|
||||
collapsed ? 'left-full top-1/2 ml-3 -translate-y-1/2' : 'left-full top-1/2 ml-4 -translate-y-1/2'
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col gap-2 text-sm text-slate-200">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-slate-400">Topology mode</p>
|
||||
<p className="text-xs text-slate-500">Hover to select how the topology map is rendered.</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{topologyOptions.map(option => {
|
||||
const activeMode = topologyMode === option.id
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
className={`flex flex-col rounded-xl border px-3 py-2 text-left transition ${
|
||||
activeMode
|
||||
? 'border-emerald-500/70 bg-emerald-500/10 text-emerald-200'
|
||||
: 'border-slate-800 bg-slate-900/70 text-slate-200 hover:border-slate-700'
|
||||
}`}
|
||||
onClick={() => onTopologyChange(option.id)}
|
||||
type="button"
|
||||
>
|
||||
<span className="text-sm font-semibold">{option.label}</span>
|
||||
<span className="text-xs text-slate-400">{option.hint}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{section.id === 'explore' && (
|
||||
<div
|
||||
className={`pointer-events-none absolute z-20 hidden w-64 rounded-2xl border border-slate-800 bg-slate-950/90 p-3 text-left shadow-xl backdrop-blur transition group-hover:pointer-events-auto group-hover:flex group-focus-within:pointer-events-auto group-focus-within:flex ${
|
||||
collapsed ? 'left-full top-1/2 ml-3 -translate-y-1/2' : 'left-full top-1/2 ml-4 -translate-y-1/2'
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col gap-2 text-sm text-slate-200">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-slate-400">Query domains</p>
|
||||
<p className="text-xs text-slate-500">Toggle languages to open matching explorers.</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{languageOptions.map(option => {
|
||||
const activeLanguage = activeLanguages.includes(option.id)
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
className={`flex items-center justify-between rounded-xl border px-3 py-2 text-left text-sm transition ${
|
||||
activeLanguage
|
||||
? 'border-emerald-500/70 bg-emerald-500/10 text-emerald-200'
|
||||
: 'border-slate-800 bg-slate-900/70 text-slate-200 hover:border-slate-700'
|
||||
}`}
|
||||
onClick={() => onToggleLanguage(option.id)}
|
||||
type="button"
|
||||
>
|
||||
<span className="font-medium">{option.label}</span>
|
||||
<span className="text-xs text-slate-400">{option.description}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div
|
||||
className={`rounded-2xl border border-slate-800 bg-gradient-to-br from-slate-800/80 to-slate-900 shadow-inner ${
|
||||
collapsed ? 'p-3' : 'p-4'
|
||||
}`}
|
||||
>
|
||||
{!collapsed ? (
|
||||
<>
|
||||
<p className="mb-2 text-xs uppercase tracking-wide text-slate-400">Active explorers</p>
|
||||
<ul className="space-y-1 text-sm text-slate-300">
|
||||
{activeLanguages.map(language => (
|
||||
<li key={language} className="flex items-center justify-between">
|
||||
<span>{languageLabels[language]}</span>
|
||||
<span className="text-xs text-slate-500">QL</span>
|
||||
</li>
|
||||
))}
|
||||
{activeLanguages.length === 0 && <li className="text-xs text-slate-500">No languages selected.</li>}
|
||||
</ul>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<p className="text-xs uppercase tracking-wide text-slate-400">Active</p>
|
||||
<div className="flex flex-col items-center gap-1 text-[10px] text-slate-300">
|
||||
{activeLanguages.map(language => (
|
||||
<span key={language}>{languageLabels[language]}</span>
|
||||
))}
|
||||
{activeLanguages.length === 0 && <span className="text-slate-500">None</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
const languageLabels: Record<QueryLanguage, string> = {
|
||||
promql: 'Prometheus metrics',
|
||||
logql: 'Log stream',
|
||||
traceql: 'Distributed traces'
|
||||
}
|
||||
@ -1,33 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { InsightState } from '../../insight/store/urlState'
|
||||
import { formatDuration } from '@lib/format'
|
||||
|
||||
interface TimeRangePickerProps {
|
||||
state: InsightState
|
||||
updateState: (partial: Partial<InsightState>) => void
|
||||
}
|
||||
|
||||
const ranges = ['15m', '1h', '6h', '24h', '7d']
|
||||
|
||||
export function TimeRangePicker({ state, updateState }: TimeRangePickerProps) {
|
||||
return (
|
||||
<label className="flex min-w-[160px] flex-1 items-center gap-2 rounded-xl border border-slate-800 bg-slate-900/80 px-3 py-1.5 text-sm text-slate-200 shadow-inner">
|
||||
<span className="text-xs uppercase tracking-wide text-slate-500">Time range</span>
|
||||
<select
|
||||
value={state.timeRange}
|
||||
onChange={event => updateState({ timeRange: event.target.value })}
|
||||
className="w-full bg-transparent text-sm font-medium focus:outline-none"
|
||||
>
|
||||
{ranges.map(range => (
|
||||
<option key={range} value={range} className="bg-slate-900">
|
||||
{formatDuration(range)}
|
||||
</option>
|
||||
))}
|
||||
<option value="custom" className="bg-slate-900">
|
||||
Custom window
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
@ -1,85 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { ReactNode, useEffect, useMemo, useState } from 'react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import type { Layout, ReactGridLayoutProps } from 'react-grid-layout'
|
||||
|
||||
interface WorkspacePanel {
|
||||
id: string
|
||||
domId?: string
|
||||
content: ReactNode
|
||||
minW?: number
|
||||
minH?: number
|
||||
}
|
||||
|
||||
interface WorkspaceGridProps {
|
||||
layout: Layout[]
|
||||
defaultLayout: Layout[]
|
||||
panels: WorkspacePanel[]
|
||||
onLayoutChange: (layout: Layout[]) => void
|
||||
draggableHandle?: string
|
||||
}
|
||||
|
||||
const ReactGridLayout = dynamic<ReactGridLayoutProps>(
|
||||
() =>
|
||||
import('react-grid-layout').then(mod => {
|
||||
const baseComponent = mod.default
|
||||
const widthProvider = mod.WidthProvider ?? mod.default?.WidthProvider
|
||||
|
||||
if (!widthProvider) {
|
||||
throw new Error('Unable to load react-grid-layout WidthProvider')
|
||||
}
|
||||
|
||||
return widthProvider(baseComponent)
|
||||
}),
|
||||
{ ssr: false }
|
||||
)
|
||||
|
||||
export function WorkspaceGrid({
|
||||
layout,
|
||||
defaultLayout,
|
||||
panels,
|
||||
onLayoutChange,
|
||||
draggableHandle
|
||||
}: WorkspaceGridProps) {
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
const mergedLayout = useMemo(() => {
|
||||
const defaultsById = Object.fromEntries(defaultLayout.map(item => [item.i, item]))
|
||||
return layout.map(item => ({
|
||||
...defaultsById[item.i],
|
||||
...item
|
||||
}))
|
||||
}, [defaultLayout, layout])
|
||||
|
||||
if (!mounted) {
|
||||
return <div className="grid gap-4 md:grid-cols-2"><div className="h-64 animate-pulse rounded-2xl bg-slate-900/60" /><div className="h-64 animate-pulse rounded-2xl bg-slate-900/60" /></div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="insight-grid">
|
||||
<ReactGridLayout
|
||||
layout={mergedLayout}
|
||||
cols={12}
|
||||
rowHeight={36}
|
||||
margin={[16, 16]}
|
||||
containerPadding={[0, 0]}
|
||||
draggableHandle={draggableHandle}
|
||||
onLayoutChange={onLayoutChange}
|
||||
compactType={null}
|
||||
isBounded
|
||||
useCSSTransforms
|
||||
>
|
||||
{panels.map(panel => (
|
||||
<div key={panel.id} id={panel.domId}>
|
||||
<div className="h-full">{panel.content}</div>
|
||||
</div>
|
||||
))}
|
||||
</ReactGridLayout>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,44 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { InsightState } from '../../insight/store/urlState'
|
||||
import { BreadcrumbBar } from './BreadcrumbBar'
|
||||
|
||||
interface WorkspaceHeaderProps {
|
||||
state: InsightState
|
||||
updateState: (partial: Partial<InsightState>) => void
|
||||
shareableLink: string
|
||||
statusMessage?: string | null
|
||||
showBreadcrumb?: boolean
|
||||
}
|
||||
|
||||
export function WorkspaceHeader({
|
||||
state,
|
||||
updateState,
|
||||
shareableLink,
|
||||
statusMessage,
|
||||
showBreadcrumb = true
|
||||
}: WorkspaceHeaderProps) {
|
||||
return (
|
||||
<header className="rounded-2xl border border-slate-800 bg-slate-900/70 px-6 py-5 shadow-lg shadow-slate-950/20">
|
||||
<div className="space-y-2">
|
||||
{showBreadcrumb && (
|
||||
<BreadcrumbBar state={state} updateState={updateState} shareableLink={shareableLink} />
|
||||
)}
|
||||
<p className="text-xs text-slate-400">
|
||||
Drag any panel handle to reorganize the workspace. Saved layouts stay local to your browser.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap items-center justify-between gap-3 text-xs text-slate-400">
|
||||
<span>
|
||||
Shareable link:{' '}
|
||||
<span className="text-slate-200">{shareableLink || 'State syncs in the URL hash for collaboration.'}</span>
|
||||
</span>
|
||||
{statusMessage ? (
|
||||
<span className="text-emerald-300">{statusMessage}</span>
|
||||
) : (
|
||||
<span>Changes you save apply only to this device.</span>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@ -1,44 +0,0 @@
|
||||
import { createOpenObserveClient } from './openobserve'
|
||||
|
||||
export interface LogEntry {
|
||||
timestamp: number
|
||||
message: string
|
||||
level: string
|
||||
service: string
|
||||
fields?: Record<string, any>
|
||||
}
|
||||
|
||||
const mockLogs: LogEntry[] = Array.from({ length: 25 }).map((_, idx) => ({
|
||||
timestamp: Date.now() - idx * 15000,
|
||||
message: `Request handled with status ${idx % 5 === 0 ? 500 : 200}`,
|
||||
level: idx % 5 === 0 ? 'error' : idx % 3 === 0 ? 'warn' : 'info',
|
||||
service: idx % 2 === 0 ? 'checkout' : 'payments',
|
||||
fields: {
|
||||
traceId: `trace-${idx}`,
|
||||
spanId: `span-${idx}`
|
||||
}
|
||||
}))
|
||||
|
||||
export async function fetchLogs(query: string) {
|
||||
void query
|
||||
await new Promise(resolve => setTimeout(resolve, 200))
|
||||
return mockLogs
|
||||
}
|
||||
|
||||
export function createLogsAdapter(baseUrl?: string, token?: string) {
|
||||
const client = createOpenObserveClient({ baseUrl, token })
|
||||
return {
|
||||
async queryLogs(query: string, params?: Record<string, string>) {
|
||||
void params
|
||||
try {
|
||||
return await client.request<LogEntry[]>(`/logs/query`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ query })
|
||||
})
|
||||
} catch (err) {
|
||||
console.warn('Logs adapter fallback to mock', err)
|
||||
return fetchLogs(query)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,24 +0,0 @@
|
||||
export interface ClientOptions {
|
||||
baseUrl?: string
|
||||
token?: string
|
||||
}
|
||||
|
||||
export function createOpenObserveClient(options: ClientOptions = {}) {
|
||||
const { baseUrl = '/api', token } = options
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const headers = new Headers(init?.headers)
|
||||
if (!headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/json')
|
||||
}
|
||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||
const res = await fetch(`${baseUrl}${path}`, {
|
||||
...init,
|
||||
headers
|
||||
})
|
||||
if (!res.ok) throw new Error(`Request failed: ${res.status}`)
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
|
||||
return { request }
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user