chore: update repo content and deployment/docs assets

This commit is contained in:
Haitao Pan 2026-01-16 00:20:54 +08:00
parent f5f02330e8
commit 5e57fdeac2
480 changed files with 223 additions and 90744 deletions

View File

@ -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 验证配置

View File

@ -1,5 +0,0 @@
{
"extends": [
"next/core-web-vitals"
]
}

22
dashboard/.gitignore vendored
View File

@ -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/

View File

@ -1 +0,0 @@
20

View File

@ -1,7 +0,0 @@
compressionLevel: mixed
enableGlobalCache: false
nodeLinker: node-modules
npmRegistryServer: "https://registry.npmmirror.com"

View File

@ -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

View File

@ -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
# ---------------------------
# 额外瘦身(可减少 1540 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"]

View File

@ -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)"

View File

@ -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 版本)
**注意**: 保留两个版本以便比较和回滚

View File

@ -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. **性能提升**: 更好的模块加载

View File

@ -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.

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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

View File

@ -1,14 +0,0 @@
/**
* PostCSS 配置文件
* 使用 ES Module 格式 - 统一现代标准
*
* 参考: https://postcss.org/
*/
export default {
// 插件列表
plugins: {
autoprefixer: {},
tailwindcss: {},
},
}

View File

@ -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"
}
]
}
]

View File

@ -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"
}
]
}

View File

@ -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"
]
}
]

View File

@ -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"
]

View File

@ -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"
}
]
}
]

View File

@ -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

View File

@ -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*'],
}

View File

@ -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

View File

@ -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)
})

View File

@ -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"

View File

@ -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)
}
})

View File

@ -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

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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}
</>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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} />
}

View File

@ -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>
)
}

View File

@ -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} />
}

View File

@ -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} />
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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} />
}

View File

@ -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)
}

View File

@ -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 })
}

View File

@ -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 })
}

View File

@ -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)
}

View File

@ -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 })
}
}

View File

@ -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
}

View File

@ -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',
},
},
)
}

View File

@ -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',
},
},
)
}

View File

@ -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 })
}

View File

@ -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',
},
},
)
}

View File

@ -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',
},
},
)
}

View File

@ -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',
},
},
)
}

View File

@ -1 +0,0 @@
export { POST, GET } from '../../verify-email/route'

View File

@ -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
}

View File

@ -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 })
}

View File

@ -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)
}

View File

@ -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',
},
},
)
}

View File

@ -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',
},
},
)
}

View File

@ -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 })
}
}

View File

@ -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 })
}
}

View File

@ -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 })
}
}

View File

@ -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 })
}

View File

@ -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 })
}

View File

@ -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: '信息',
})
}

View File

@ -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,
})
}

View File

@ -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 })
}

View File

@ -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
}

View File

@ -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))
}

View File

@ -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 })
}

View File

@ -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)
}

View File

@ -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 })
}
}

View File

@ -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 })
}
}

View File

@ -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)
}

View File

@ -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 })
}

View File

@ -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()
}
}

View File

@ -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>
)
}

View File

@ -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`)即可在生产环境中按需关闭。

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
}

View File

@ -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'
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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)
}
}
}
}

View File

@ -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