This commit is contained in:
2026-06-16 14:34:09 +08:00
parent 1236865f4b
commit 4c56c7cb04
126 changed files with 14802 additions and 0 deletions

10
.cursor/rules/agents.mdc Normal file
View File

@@ -0,0 +1,10 @@
---
description: 项目 Agent 规则(由 AGENTS.md 转化Cursor 始终应用)
alwaysApply: true
---
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->

View File

@@ -0,0 +1,27 @@
---
description: 写样式与 UI 时必须遵循 DESIGN.md 与项目样式架构
globs: src/**/*.{css,tsx}
alwaysApply: false
---
# 设计系统(浅色高级编辑风)
## 单一事实来源
`DESIGN.md` · `src/styles/tokens.css` · `typography.css` · `layout.css` · `components.css` · `locale-zh.css` · `globals.css`
## 必须遵守
1. **全站浅色**:仅用 `band-light` / `band-muted` / `bg-canvas-page`**禁止** `band-dark`、深色页背景。
2. **字体**:标题 `type-display-*`Noto Serif SC正文/导航 Inter代码 `code-panel`。
3. **主 CTA**`btn-primary`(墨灰 `#141414`,非大面积黑底区块)。
4. **层次**hairline 边框 + 白/米灰底交替,不用渐变与霓虹。
5. **中文**`locale-zh.css` 行高与字距;眉标 `type-eyebrow`,导语 `type-lead`。
## 禁止
深色 band、渐变装饰、荧光色、`zinc-*` 随意色
## 变更后
运行 `pnpm test`

15
.dockerignore Normal file
View File

@@ -0,0 +1,15 @@
node_modules
.next
out
.git
.gitignore
.cursor
test-results
playwright-report
blob-report
playwright/.cache
coverage
*.md
!README.md
.env*
!.env.example

5
.env.example Normal file
View File

@@ -0,0 +1,5 @@
# 站点对外 URL百度/搜狗提交 sitemap、canonical、OG 绝对地址均依赖此项)
NEXT_PUBLIC_SITE_URL=https://www.haiyushuke.com
# 百度站长平台 → 站点管理 → 验证站点 → HTML 标签中的 content 值(可选)
# BAIDU_SITE_VERIFICATION=codeva-xxxxxxxx

49
.gitignore vendored Normal file
View File

@@ -0,0 +1,49 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
# next.js
/.next/
/out/
/public/_pagefind/
# production
/build
# misc
.DS_Store
scripts/docker/haiyushuke-website-latest.tar
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
!.env.example
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

7
AGENTS.md Normal file
View File

@@ -0,0 +1,7 @@
<!-- Cursor 使用 `.cursor/rules/agents.mdc`(内容与下文相同,改规则时请同步两处)。 -->
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->

1
CLAUDE.md Normal file
View File

@@ -0,0 +1 @@
@AGENTS.md

176
DESIGN.md Normal file
View File

@@ -0,0 +1,176 @@
---
version: 1.0
name: Haiyu AI — Sci-Fi Intelligence
description: 海宇数科大模型官网视觉规范。深空底色 + 青紫霓虹渐变 + 微弱网格/辉光,传达「智能基础设施」而非传统企业站。
colors:
primary: "#0b5fff"
on-primary: "#f8fbff"
ink: "#0a1224"
body: "#5c6b8a"
hairline: "#d8e2f2"
canvas: "#f4f8ff"
canvas-elevated: "#ffffff"
canvas-dark: "#050816"
canvas-dark-mid: "#0c1228"
surface-dark-soft: "#151d3a"
surface-dark-glass: "rgb(12 20 48 / 0.55)"
on-dark: "#eef6ff"
on-dark-muted: "#9eb4d8"
accent-cyan: "#00e8ff"
accent-violet: "#8b5cf6"
accent-magenta: "#e879f9"
accent-electric: "#22d3ee"
accent-mint: "#a5f3fc"
glow-cyan: "rgb(0 232 255 / 0.45)"
glow-violet: "rgb(139 92 246 / 0.4)"
typography:
display-xxl:
fontFamily: Inter, system-ui, sans-serif
fontSize: 64px
fontWeight: 500
lineHeight: 1.15
display-xl:
fontSize: 40px
fontWeight: 500
lineHeight: 1.2
display-lg:
fontSize: 28px
fontWeight: 500
lineHeight: 1.3
display-md:
fontSize: 22px
fontWeight: 500
lineHeight: 1.35
body-lg:
fontSize: 18px
fontWeight: 400
lineHeight: 1.65
body-md:
fontSize: 16px
fontWeight: 400
lineHeight: 1.7
caption:
fontSize: 14px
fontWeight: 400
lineHeight: 1.55
caption-strong:
fontSize: 14px
fontWeight: 500
lineHeight: 1.55
mono-caps-button:
fontFamily: JetBrains Mono, ui-monospace, monospace
fontSize: 14px
fontWeight: 500
mono-caps-eyebrow:
fontSize: 11px
fontWeight: 500
letterSpacing: 0.12em
mono-caption:
fontSize: 11px
fontWeight: 400
rounded:
xs: 3.25px
sm: 6px
md: 10px
full: 9999px
spacing:
section: 88px
container-gutter-desktop: 32px
container-gutter-mobile: 16px
components:
nav-bar:
backgroundColor: "{colors.canvas-dark}"
borderColor: "rgb(0 232 255 / 0.15)"
button-primary:
background: "linear-gradient(135deg, {colors.accent-cyan}, {colors.accent-violet})"
textColor: "{colors.on-primary}"
glow: "{colors.glow-cyan}"
button-ghost-on-dark:
backgroundColor: "{colors.surface-dark-glass}"
borderColor: "rgb(0 232 255 / 0.25)"
hero-band-dark:
background: "{colors.canvas-dark} + mesh grid + radial violet glow"
card-on-dark:
background: "{colors.surface-dark-glass}"
borderColor: "rgb(0 232 255 / 0.2)"
card-on-light:
background: "{colors.canvas-elevated}"
borderColor: "{colors.hairline}"
stats-card-tinted:
background: "linear-gradient(145deg, rgb(0 232 255 / 0.12), rgb(139 92 246 / 0.1))"
---
## Overview
海宇数科官网面向**大模型服务与智能报告**的官方展示(非控制台)。视觉关键词:**深空、霓虹、网格、辉光、数据流**。
- **深色 band**`canvas-dark`Hero、智能体、企业方案、联系。叠加 `band-mesh` 径向光晕与细网格,像「控制台外的指挥舱」。
- **浅色 band**`canvas`):产品能力、模型表、资讯。冷白底 + 淡蓝 hairline卡片微抬。
- **品牌渐变**(固定三停):`accent-cyan``accent-violet``accent-magenta`。用于 Hero 装饰、主按钮、标题 `text-gradient`。**禁止**拆成单色图标或再加第四荧光色。
- **主 CTA**:青紫渐变底 + 外发光(`shadow-glow-cyan`),不是纯黑块。
- **中文排版**`locale-zh.css` 放宽行高、取消西文负字距;眉标可用 `type-eyebrow``type-mono-caps-eyebrow`(短标签)。
## Colors
| 令牌 | 用途 |
| -------------------------------------------------- | ---------------------------- |
| `primary` | 深色填充按钮、强调条(备用) |
| `accent-cyan` / `accent-violet` / `accent-magenta` | 渐变与科技眉标 |
| `canvas-dark` | 深空主背景 |
| `canvas` | 浅色 section |
| `hairline` | 浅色分割与卡片边 |
| `border-glow` | 深色玻璃卡边框CSS 变量) |
| `on-dark` / `on-dark-muted` | 深色上的正文层级 |
## Typography
- 展示Inter 500标题可用 `text-gradient`(仅 h1/h2 主标题)。
- MonoJetBrains Mono**仅**代码块、可选短眉标;不写长段落。
## Layout
- `container-site` max 1280px。
- `band-section` 仅垂直 `section` 间距;水平只由 container 控制。
- `section-stack` 统一块间距。
## Components实现见 `src/styles/components.css` + `effects.css`
- `btn-primary` — 渐变 + 辉光
- `btn-ghost-on-dark` — 玻璃描边
- `btn-secondary-glow` — 浅色 band 上的描边 CTA
- `card-on-dark` / `card-glass` — 深色玻璃
- `card-on-light` — 白卡 + hairline
- `badge-glow` — 深色上的小标签
- `gradient-brand` / `text-gradient` — 品牌渐变
## Do's
- 深色区域加 `band-mesh``band-grid`(二选一或叠加,勿整页花屏)。
- 统计/高亮块用 `stats-tile`(淡青紫渐变底)。
- 模型表头用 `type-caption-strong` + `text-body`
## Don'ts
- 不用 `zinc-*` / 随意 hex。
- 不用 `prefers-color-scheme` 自动主题。
- 浅色卡片不要大面积彩色阴影;辉光留给按钮与 Hero 装饰。
- 主标题不要全大写 mono。
## 样式文件架构
```
src/app/globals.css # 入口tailwind + tokens + typography + layout + components + effects + locale-zh
src/styles/tokens.css # @theme 色板、渐变、阴影
src/styles/typography.css
src/styles/layout.css # container、band、section-stack
src/styles/components.css
src/styles/effects.css # mesh、grid、text-gradient、glow、stats-tile
src/styles/locale-zh.css # 中文排版与按钮字体
```
变更令牌时同步 `tokens.css``effects.css`,运行 `pnpm test`

41
Dockerfile Normal file
View File

@@ -0,0 +1,41 @@
# 构建时可覆盖: --build-arg NODE_IMAGE=node:20-alpine
ARG NODE_IMAGE=node:20-alpine
FROM ${NODE_IMAGE} AS base
RUN apk add --no-cache libc6-compat \
&& corepack enable \
&& corepack prepare pnpm@10.11.0 --activate
ENV CI=true \
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
ARG NPM_REGISTRY=https://registry.npmmirror.com
ENV NPM_CONFIG_REGISTRY=${NPM_REGISTRY}
FROM base AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
ARG NEXT_PUBLIC_SITE_URL=https://www.haiyushuke.com
ARG BAIDU_SITE_VERIFICATION=
ENV NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL}
ENV BAIDU_SITE_VERIFICATION=${BAIDU_SITE_VERIFICATION}
RUN pnpm build
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
CMD ["node", "server.js"]

7
content/_meta.js Normal file
View File

@@ -0,0 +1,7 @@
export default {
index: "文档中心",
"getting-started": "快速开始",
models: "AI 模型接入",
guides: "使用指南",
faq: "常见问题",
};

32
content/faq.mdx Normal file
View File

@@ -0,0 +1,32 @@
---
title: 常见问题
description: 接入与使用中的常见问题解答
---
# 常见问题
## 返回 401 Unauthorized
- 检查 `Authorization: Bearer` 后是否有多余空格
- 确认 Key 未被禁用、未过期
- 确认请求发往正确的 Base URL
## 返回 429 Too Many Requests
表示触发 **限流**。请降低并发、实现指数退避重试,或联系商务提升配额。
## 模型不存在 / model_not_found
模型 ID 拼写错误或账号未开通该模型。请在 **模型广场** 复制准确 ID。
## 如何对账?
控制台 **账单** 模块可按日查看调用量与费用;企业客户可申请导出明细与发票。
## 是否支持私有化部署?
企业方案支持专有云与私有化,请通过官网 [联系我们](/#contact) 获取方案与 POC 安排。
## 文档有错误怎么办?
文档随产品迭代更新;若发现过时信息,请通过控制台反馈或邮件联系技术支持。

View File

@@ -0,0 +1,44 @@
---
title: 快速开始
description: 五分钟完成 API Key 创建与首次调用
---
# 快速开始
按以下步骤即可在海宇数科平台完成首次模型调用。
## 1. 注册并登录控制台
访问 [控制台](https://console.haiyushuke.com),使用企业邮箱或手机号完成注册。首次登录建议完成实名/企业认证(若你方环境要求)。
## 2. 创建 API Key
1. 进入 **设置 → API Key**
2. 点击 **创建密钥**,填写名称(如 `prod-backend`
3. 复制密钥并妥善保存(仅展示一次)
## 3. 选择模型与端点
在 **模型广场** 或 **接入文档** 中确认:
- 模型 ID如 `gpt-4o-mini`、`deepseek-chat`
- 兼容协议(一般为 OpenAI Chat Completions
## 4. 发起测试请求
```bash
curl https://api.haiyushuke.com/v1/chat/completions \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "gpt-4o-mini",
"messages": [{"role": "user", "content": "你好,请用一句话介绍海宇数科"}]
}'
```
成功时响应 JSON 中包含 `choices[0].message.content`。
## 下一步
- 按厂商阅读 [OpenAI 接入](/docs/models/openai)、[Claude 接入](/docs/models/claude)
- 了解 [流式调用](/docs/guides/streaming) 与 [API Key 管理](/docs/guides/api_keys)

5
content/guides/_meta.js Normal file
View File

@@ -0,0 +1,5 @@
export default {
console: "???",
api_keys: "API Key",
streaming: "????",
};

View File

@@ -0,0 +1,26 @@
---
title: API Key 管理
sidebarTitle: API Key
description: 创建、轮换与权限最佳实践
---
# API Key 管理
## 创建
控制台 → **设置 → API Key** → **创建**。建议命名包含环境与用途,例如 `prod-report-agent`。
## 存储
- 使用环境变量:`HAIYUSHUKE_API_KEY`
- 密钥管理系统KMS / Vault中保存勿提交至 Git
## 轮换流程
1. 创建新 Key
2. 在应用配置中灰度切换
3. 确认流量稳定后禁用旧 Key
## 泄露应急
若 Key 已泄露:立即 **禁用** 该 Key → 新建 → 排查仓库与 CI 日志是否残留。

View File

@@ -0,0 +1,30 @@
---
title: 控制台使用
sidebarTitle: 控制台
description: 控制台主要功能与导航说明
---
# 控制台使用指南
控制台地址:[https://console.haiyushuke.com](https://console.haiyushuke.com)
## 主要模块
1. **概览**:用量、余额与近期调用趋势
2. **模型广场**:浏览可用模型、定价与能力说明
3. **API Key**:创建、轮换、禁用密钥
4. **调用日志**(若已开通):按时间筛选请求与错误
5. **账单**:充值、发票与明细导出
## 推荐工作流
1. 创建 API Key
2. 在模型广场选择目标模型
3. 使用 SDK 或 cURL 完成联调
4. 在概览与调用日志中核对用量
## 权限与安全
- 为不同环境(开发 / 预发 / 生产)使用不同 Key
- 定期轮换 Key泄露后立即 **禁用** 并新建
- 生产环境勿将 Key 写入前端 bundle

View File

@@ -0,0 +1,38 @@
---
title: 流式调用
sidebarTitle: 流式调用
description: SSE 流式输出接入说明
---
# 流式调用
在请求体中设置 `"stream": true`,服务端以 SSE 形式返回增量内容。
## cURL
```bash
curl https://api.haiyushuke.com/v1/chat/completions \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"model":"gpt-4o-mini","stream":true,"messages":[{"role":"user","content":"写一首四句诗"}]}'
```
## OpenAI SDKNode
```ts
const stream = await client.chat.completions.create({
model: "gpt-4o-mini",
stream: true,
messages: [{ role: "user", content: "你好" }],
});
for await (const chunk of stream) {
const text = chunk.choices[0]?.delta?.content;
if (text) process.stdout.write(text);
}
```
## 注意
- 客户端需正确处理连接中断与超时重试
- 流式场景下计费通常按 **输出 token** 累计,以账单规则为准

1126
content/index.mdx Normal file

File diff suppressed because it is too large Load Diff

5
content/models/_meta.js Normal file
View File

@@ -0,0 +1,5 @@
export default {
openai: "OpenAI",
claude: "Claude (Anthropic)",
deepseek: "DeepSeek",
};

42
content/models/claude.mdx Normal file
View File

@@ -0,0 +1,42 @@
---
title: Claude 接入
sidebarTitle: Claude
description: Anthropic Claude 系列模型接入说明
---
# Claude (Anthropic) 接入
Claude 模型可通过 **OpenAI 兼容接口** 或 **Anthropic Messages API** 接入(以控制台开通能力为准)。
## OpenAI 兼容方式(推荐迁移)
```bash
curl https://api.haiyushuke.com/v1/chat/completions \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "claude-sonnet-4-20250514",
"messages": [{"role": "user", "content": "总结以下三点:安全、成本、延迟"}]
}'
```
将 `model` 替换为控制台提供的 Claude 模型 ID。
## 系统提示与多轮对话
与 OpenAI 相同,使用 `messages` 数组:
```json
{
"model": "claude-sonnet-4-20250514",
"messages": [
{ "role": "system", "content": "你是企业知识库助手,回答需简洁。" },
{ "role": "user", "content": "什么是 RAG" }
]
}
```
## 注意事项
- 部分 Claude 特性(如扩展思考、工具调用)需使用对应 API 形态,请参考控制台该模型的「能力标签」。
- 长上下文模型请留意单次请求的 `max_tokens` 与账单中的输入 token 统计。

View File

@@ -0,0 +1,32 @@
---
title: DeepSeek 接入
sidebarTitle: DeepSeek
description: DeepSeek 系列模型接入说明
---
# DeepSeek 接入
DeepSeek 模型通过统一网关暴露,协议为 **OpenAI Chat Completions 兼容**。
## 示例
```bash
curl https://api.haiyushuke.com/v1/chat/completions \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "deepseek-chat",
"messages": [{"role": "user", "content": "用列表说明大模型网关的三项优势"}]
}'
```
## 模型选择建议
| 场景 | 建议 |
| --- | --- |
| 通用对话 / 代码 | `deepseek-chat` |
| 复杂推理(若已开通) | 控制台标注的 Reasoner 类模型 ID |
## 与 OpenAI SDK 共用配置
仅需修改 `model` 字段,无需更换 SDK`baseURL` 与 API Key 与 [OpenAI 接入](/docs/models/openai) 相同。

62
content/models/openai.mdx Normal file
View File

@@ -0,0 +1,62 @@
---
title: OpenAI 接入
sidebarTitle: OpenAI
description: 通过 OpenAI 兼容协议接入 GPT 系列模型
---
# OpenAI 兼容接入
海宇数科对 OpenAI 官方 SDK 及 HTTP API 提供兼容层,便于现有应用零改造迁移。
## 端点
| 项目 | 值 |
| --- | --- |
| Base URL | `https://api.haiyushuke.com/v1` |
| Chat Completions | `POST /chat/completions` |
| 鉴权 | `Authorization: Bearer <API_KEY>` |
## Node.js官方 SDK
```ts
import OpenAI from "openai";
const client = new OpenAI({
apiKey: process.env.HAIYUSHUKE_API_KEY,
baseURL: "https://api.haiyushuke.com/v1",
});
const completion = await client.chat.completions.create({
model: "gpt-4o-mini",
messages: [{ role: "user", content: "Hello" }],
});
console.log(completion.choices[0]?.message?.content);
```
## Python
```python
from openai import OpenAI
client = OpenAI(
api_key="YOUR_API_KEY",
base_url="https://api.haiyushuke.com/v1",
)
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "Hello"}],
)
print(resp.choices[0].message.content)
```
## 常用参数
- `temperature`02控制随机性
- `max_tokens`:最大生成 token
- `stream: true`:开启流式,见 [流式调用](/docs/guides/streaming)
## 模型 ID
请在控制台 **模型广场** 查看当前可用 ID名称可能随供应商更新以控制台为准。

100
e2e/smoke.spec.ts Normal file
View File

@@ -0,0 +1,100 @@
import { test, expect } from "@playwright/test";
test.describe("站点冒烟", () => {
test("首页可访问且无致命前端错误", async ({ page }) => {
const pageErrors: string[] = [];
page.on("pageerror", (err) => {
pageErrors.push(err.message);
});
const response = await page.goto("/", { waitUntil: "networkidle" });
expect(response?.ok()).toBeTruthy();
await expect(page.locator("main")).toBeVisible();
await expect(page).toHaveTitle(/海宇数科/);
await expect(page.getByRole("heading", { level: 1 })).toContainText(
/智能报告/,
);
await expect(page.locator("#reports")).toBeVisible();
await expect(page.getByText("KYB 企业核验")).toBeVisible();
const exampleBtn = page
.locator("#reports")
.getByRole("button", { name: "查看智能体报告示例" })
.first();
await exampleBtn.scrollIntoViewIfNeeded();
await exampleBtn.click();
await expect(page.getByRole("dialog")).toBeVisible();
await expect(page.getByRole("dialog")).toContainText("核验结论");
await expect(page.locator("#models")).toBeVisible();
const squareLink = page.getByRole("link", { name: "模型广场" }).first();
await squareLink.click();
await expect(page).toHaveURL(/\/models$/);
await expect(page.getByRole("heading", { level: 1 })).toContainText(
"模型广场",
);
await expect(page.getByText("minimax-m2.7-highspeed")).toBeVisible();
await expect(page.getByText("glm-5.1")).toBeVisible();
await page.goto("/news", { waitUntil: "domcontentloaded" });
await expect(page.getByRole("heading", { level: 1 })).toContainText(
"AI 新闻资讯",
);
await page
.getByRole("link", { name: "智能报告模板库 2.0 发布" })
.first()
.click();
await expect(page).toHaveURL(/\/news\/report-templates-2/);
await expect(page.getByRole("heading", { level: 1 })).toContainText(
"智能报告模板库 2.0 发布",
);
expect(
pageErrors,
pageErrors.length ? pageErrors.join("\n") : undefined,
).toEqual([]);
});
test("窄屏显示移动菜单并可展开导航", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/", { waitUntil: "domcontentloaded" });
await expect(page.getByRole("button", { name: "菜单" })).toBeVisible();
await page.getByRole("button", { name: "菜单" }).click();
await expect(page.getByRole("link", { name: "智能报告" }).first()).toBeVisible();
await page.getByRole("link", { name: "模型能力" }).first().click();
await expect(page).toHaveURL(/\/models\/?$/);
});
test("文档中心可访问并展示侧栏与示例正文", async ({ page }) => {
const response = await page.goto("/docs", {
waitUntil: "domcontentloaded",
});
expect(response?.ok()).toBeTruthy();
await expect(page).toHaveTitle(/文档中心/);
await expect(
page.getByRole("region", { name: "文档导航" }),
).toBeVisible();
await expect(
page.getByRole("navigation", { name: "面包屑" }),
).toContainText("文档中心");
await expect(
page.getByRole("complementary", { name: "文档目录" }).or(
page.getByRole("navigation", { name: "文档目录(移动端)" }),
),
).toBeVisible({ timeout: 15_000 });
await expect(page.getByRole("link", { name: "快速开始" }).first()).toBeVisible();
await page.getByRole("link", { name: "OpenAI" }).first().click();
await expect(page).toHaveURL(/\/docs\/models\/openai/);
await expect(page.locator(".docs-prose h1, .docs-prose h2").first()).toContainText(
"OpenAI",
);
await expect(page.getByText("chat/completions")).toBeVisible();
});
test("全局样式编译通过(无 Turbopack CSS 报错页)", async ({ page }) => {
await page.goto("/", { waitUntil: "domcontentloaded" });
const bodyText = await page.locator("body").innerText();
expect(bodyText).not.toContain("Cannot apply unknown utility class");
expect(bodyText).not.toContain("CssSyntaxError");
});
});

14
next.config.mjs Normal file
View File

@@ -0,0 +1,14 @@
import nextra from "nextra";
const withNextra = nextra({
contentDirBasePath: "/docs",
search: { codeblocks: true },
});
/** @type {import('next').NextConfig} */
const nextConfig = {
reactCompiler: true,
output: "standalone",
};
export default withNextra(nextConfig);

38
package.json Normal file
View File

@@ -0,0 +1,38 @@
{
"name": "haiyushuke-website",
"version": "0.1.0",
"private": true,
"packageManager": "pnpm@10.11.0",
"scripts": {
"dev": "next dev",
"build": "next build --webpack",
"postbuild": "pagefind --site .next/server/app --output-path public/_pagefind",
"start": "next start",
"test": "pnpm build && playwright test",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui"
},
"dependencies": {
"@headlessui/react": "2.1.2",
"@tsparticles/engine": "^4.0.5",
"@tsparticles/react": "^4.0.5",
"@tsparticles/slim": "^4.0.5",
"motion": "^12.40.0",
"next": "16.2.6",
"nextra": "^4.6.1",
"nextra-theme-docs": "^4.6.1",
"react": "19.2.4",
"react-dom": "19.2.4"
},
"devDependencies": {
"@playwright/test": "^1.60.0",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"babel-plugin-react-compiler": "1.0.0",
"pagefind": "^1.5.2",
"tailwindcss": "^4",
"typescript": "^5"
}
}

31
playwright.config.ts Normal file
View File

@@ -0,0 +1,31 @@
import { defineConfig, devices } from "@playwright/test";
const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? "http://127.0.0.1:3000";
export default defineConfig({
testDir: "./e2e",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: [["list"], ["html", { open: "never" }]],
use: {
baseURL,
trace: "on-first-retry",
},
projects: [
{
name: "chromium",
use: {
...devices["Desktop Chrome"],
channel: "chrome",
},
},
],
webServer: {
command: "pnpm dev --webpack --port 3000",
url: baseURL,
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
});

5307
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

3
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,3 @@
ignoredBuiltDependencies:
- sharp
- unrs-resolver

7
postcss.config.mjs Normal file
View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

BIN
public/banner.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

BIN
public/doubao_logo.image Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

1
public/file.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

1
public/globe.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

BIN
public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

BIN
public/models/deepseek.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

BIN
public/models/minimax.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

32
public/models/zhipu.ico Normal file
View File

@@ -0,0 +1,32 @@
<!DOCTYPE html><html lang="zh-cn"><head><meta charset="utf-8"><meta name="author" content="北京智谱华章科技股份有限公司"><meta name="copyright" content="大模型开放平台"><meta name="robots" content="all"><meta name="renderer" content="webkit"><meta http-equiv="cache-control" content="no-cache"><meta http-equiv="Content-Security-Policy" content="upgrade-insecure-requests"><meta name="baidu-site-verification" content="codeva-TxacHBUilJ"><meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1"><meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,minimum-scale=1,user-scalable=0,viewport-fit=cover"><meta name="keywords" content="国内大模型,大模型网站,LLM,视觉大模型,大模型平台,多模态,智谱开放平台"><meta name="description" content="智谱大模型开放平台-新一代国产自主通用AI大模型开放平台是国内大模型排名前列的大模型网站研发了多款LLM模型多模态视觉模型产品致力于将AI产品技术与行业场景双轮驱动的中国先进的认知智能技术和千行百业应用相结合构建更高精度、高效率、通用化的AI开发新模式和企业级解决方案实现智谱大模型的产业化将AI的好处带给每个人。"><script src="//g.alicdn.com/frontend-lib/frontend-lib/2.3.74/interfaceacting.js"></script><script src="//g.alicdn.com/frontend-lib/frontend-lib/2.3.74/antidom.js"></script><script src="//o.alicdn.com/frontend-lib/common-lib/jquery.min.js"></script><!--[if IE]><link rel="icon" href="/favicon.ico" /><![endif]--><link rel="stylesheet" href="https://at.alicdn.com/t/c/font_4623769_by4l8c4sf6d.css"><link rel="stylesheet" href="https://at.alicdn.com/t/c/font_3037642_ztvuic2lt6.css"><title>智谱AI开放平台</title><script>// 百度访问分析代码
var projectEnv = 'production'
if (projectEnv === 'production') {
var _hmt = _hmt || [];
(function() {
var hm = document.createElement("script");
hm.src = "https://hm.baidu.com/hm.js?a1b1a5545a8f11fdd72d54f10971c4ea";
var s = document.getElementsByTagName("script")[0];
s.parentNode.insertBefore(hm, s);
})();
}</script><style>#loader-wrapper {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 999999;
display: -webkit-box;
display: -webkit-flex;
display: -moz-box;
display: flex;
-webkit-justify-content: center;
-moz-justify-content: center;
justify-content: center;
-webkit-align-items: center;
-moz-align-items: center;
align-items: center;
background-color: #fafafa;
}
.logo-loading {
width: 120px;
}</style><!--[if lte IE 11]><script>window.location.href='/html/ie.html';</script><![endif]--><script src="https://at.alicdn.com/t/c/font_4623769_by4l8c4sf6d.js"></script><script src="https://at.alicdn.com/t/c/font_3037642_ztvuic2lt6.js"></script><script async src="https://www.googletagmanager.com/gtag/js?id=G-SF8X67RPF9"></script><link href="/css/chunk-libs.08f92503.css" rel="stylesheet"><link href="/css/app.82a75497.css" rel="stylesheet"><link rel="icon" type="image/png" sizes="32x32" href="/img/icons/favicon-32x32.png"><link rel="icon" type="image/png" sizes="16x16" href="/img/icons/favicon-16x16.png"><link rel="manifest" href="/manifest.json"><meta name="theme-color" content="#ffffff"><meta name="apple-mobile-web-app-capable" content="no"><meta name="apple-mobile-web-app-status-bar-style" content="default"><meta name="apple-mobile-web-app-title" content="智谱AI开放平台"><link rel="apple-touch-icon" href="/img/icons/apple-touch-icon-152x152.png"><link rel="mask-icon" href="/img/icons/safari-pinned-tab.svg" color="#ffffff"><meta name="msapplication-TileImage" content="/img/icons/msapplication-icon-144x144.png"><meta name="msapplication-TileColor" content="#ffffff"></head><body><noscript><strong>We're sorry but 智谱AI开放平台 doesn't work properly without JavaScript enabled. Please enable it to continue.</strong></noscript><div id="app"><div id="loader-wrapper"><img class="logo-loading" src="/static/images/loading.gif" alt="智谱AI"></div></div><script src="/js/runtime.4b95d3c3.js"></script><script src="/js/chunk-vue.9f0021ad.js"></script><script src="/js/chunk-elementUI.24c710f5.js"></script><script src="/js/chunk-libs.068b51df.js"></script><script src="/js/app.2adb808e.js"></script></body><script src="https://res.wx.qq.com/connect/zh_CN/htmledition/js/wxLogin.js" onerror="window.wxLoginScriptFailed = true"></script></html>

BIN
public/news/1.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

BIN
public/news/2.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
public/news/3.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

BIN
public/news/4.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

BIN
public/news/5.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

BIN
public/news/6.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

BIN
public/news/7.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

BIN
public/news/8.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

1
public/next.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

21
public/site.webmanifest Normal file
View File

@@ -0,0 +1,21 @@
{
"name": "海宇数科",
"short_name": "海宇数科",
"description": "企业级大模型服务与智能报告平台",
"background_color": "#ffffff",
"theme_color": "#2670e8",
"display": "standalone",
"icons": [
{
"src": "/logo.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/favicon.ico",
"sizes": "48x48",
"type": "image/x-icon"
}
]
}

1
public/vercel.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

1
public/window.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

View File

@@ -0,0 +1,76 @@
#Requires -Version 5.1
<#
.SYNOPSIS
构建官网镜像并推送到私有 Registry对齐 bd-server/scripts/push-main-image.ps1
.DESCRIPTION
使用前: docker login docker-registry.tianyuanapi.com
默认镜像: docker-registry.tianyuanapi.com/haiyushuke/website:latest
.EXAMPLE
.\scripts\docker\build-push.ps1
.\scripts\docker\build-push.ps1 -ExportTar
#>
param(
[string]$Registry = "docker-registry.tianyuanapi.com",
[string]$ImagePath = "haiyushuke/website",
[string]$SiteUrl = "https://www.haiyushuke.com",
[string]$BaiduVerification = "",
[string]$NodeImage = "docker.m.daocloud.io/library/node:20-alpine",
[switch]$ExportTar
)
$ErrorActionPreference = "Stop"
$Tag = "latest"
$ProjectRoot = Resolve-Path (Join-Path $PSScriptRoot "../..")
$FullImage = "${Registry}/${ImagePath}:${Tag}"
$LocalName = "haiyushuke-website:build"
$TarPath = Join-Path $PSScriptRoot "haiyushuke-website-latest.tar"
Set-Location $ProjectRoot
Write-Host "==> Pre-pull base image: $NodeImage"
docker pull $NodeImage
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
Write-Host "==> build $LocalName"
$buildArgs = @(
"build",
"-t", $LocalName,
"--build-arg", "NODE_IMAGE=$NodeImage",
"--build-arg", "NEXT_PUBLIC_SITE_URL=$SiteUrl"
)
if ($BaiduVerification) {
$buildArgs += @("--build-arg", "BAIDU_SITE_VERIFICATION=$BaiduVerification")
}
$buildArgs += "."
docker @buildArgs
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
if ($ExportTar) {
Write-Host "==> tag $FullImage (for save)"
docker tag $LocalName $FullImage
Write-Host "==> save $TarPath"
docker save -o $TarPath $FullImage
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
Write-Host "Done (tar): $TarPath"
exit 0
}
Write-Host "==> tag $FullImage"
docker tag $LocalName $FullImage
Write-Host "==> push $FullImage"
docker push $FullImage
if ($LASTEXITCODE -ne 0) {
Write-Host ""
Write-Host "Push failed. Check:" -ForegroundColor Yellow
Write-Host " docker login $Registry" -ForegroundColor Yellow
Write-Host (' curl -sI "https://{0}/v2/{1}/manifests/latest"' -f $Registry, $ImagePath) -ForegroundColor Yellow
Write-Host "Registry host must be docker-registry.tianyuanapi.com (not docker-register...)." -ForegroundColor Yellow
exit $LASTEXITCODE
}
Write-Host ""
Write-Host "Done. Pushed: $FullImage"
Write-Host "Deploy host: cd scripts/docker && ./deploy-pull.sh"

View File

@@ -0,0 +1,15 @@
#!/usr/bin/env bash
# 无法 pull 时:本机 build-push.ps1 -ExportTar拷 tar 到服务器后执行
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TAR_FILE="${1:-${SCRIPT_DIR}/haiyushuke-website-latest.tar}"
ENV_FILE="${ENV_FILE:-${SCRIPT_DIR}/deploy.env}"
COMPOSE_FILE="${SCRIPT_DIR}/docker-compose.yml"
[[ -f "${ENV_FILE}" ]] || { echo "missing ${ENV_FILE}" >&2; exit 1; }
[[ -f "${TAR_FILE}" ]] || { echo "missing tar: ${TAR_FILE}" >&2; exit 1; }
cd "${SCRIPT_DIR}"
docker load -i "${TAR_FILE}"
docker compose --env-file "${ENV_FILE}" -f "${COMPOSE_FILE}" up -d

View File

@@ -0,0 +1,42 @@
#!/usr/bin/env bash
# 部署机拉取 latest 并启动(对齐 bd-server/scripts/pull-main-on-deploy.sh
# 自测: curl -sI "https://docker-registry.tianyuanapi.com/v2/haiyushuke/website/manifests/latest"
#
# 用法:
# cp deploy.env.example deploy.env
# ./deploy-pull.sh
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
COMPOSE_FILE="${SCRIPT_DIR}/docker-compose.yml"
ENV_FILE="${ENV_FILE:-${SCRIPT_DIR}/deploy.env}"
if [[ ! -f "${ENV_FILE}" ]]; then
echo "missing ${ENV_FILE} (cp deploy.env.example deploy.env)" >&2
exit 1
fi
cd "${SCRIPT_DIR}"
echo "==> pull website (latest)"
docker compose --env-file "${ENV_FILE}" -f "${COMPOSE_FILE}" pull
echo "==> up -d website"
docker compose --env-file "${ENV_FILE}" -f "${COMPOSE_FILE}" up -d
# shellcheck disable=SC1090
set -a
source "${ENV_FILE}"
set +a
HOST_PORT="${HOST_PORT:-3000}"
echo "==> health http://127.0.0.1:${HOST_PORT}/"
sleep 2
if curl -fsS -o /dev/null "http://127.0.0.1:${HOST_PORT}/"; then
echo "OK"
else
echo "WARN: curl failed; docker compose logs -f website"
fi
echo "Done: ${REGISTRY}/${IMAGE_PATH}:latest"

View File

@@ -0,0 +1,6 @@
REGISTRY=docker-registry.tianyuanapi.com
IMAGE_PATH=haiyushuke/website
CONTAINER_NAME=haiyushuke-website
HOST_PORT=3000
NEXT_PUBLIC_SITE_URL=https://www.haiyushuke.com
BAIDU_SITE_VERIFICATION=

View File

@@ -0,0 +1,9 @@
# cp deploy.env.example deploy.env
# 与 bd-server 相同 Registry 主机名;镜像路径为命名空间/名称
REGISTRY=docker-registry.tianyuanapi.com
IMAGE_PATH=haiyushuke/website
CONTAINER_NAME=haiyushuke-website
HOST_PORT=3000
NEXT_PUBLIC_SITE_URL=https://www.haiyushuke.com
BAIDU_SITE_VERIFICATION=

View File

@@ -0,0 +1,14 @@
# 服务器部署docker compose pull && up镜像由 build-push.ps1 推送)
# 自测: curl -sI "https://docker-registry.tianyuanapi.com/v2/haiyushuke/website/manifests/latest"
services:
website:
image: ${REGISTRY}/${IMAGE_PATH}:latest
container_name: ${CONTAINER_NAME}
restart: unless-stopped
ports:
- "${HOST_PORT}:3000"
environment:
NODE_ENV: production
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL}
BAIDU_SITE_VERIFICATION: ${BAIDU_SITE_VERIFICATION:-}

View File

@@ -0,0 +1,3 @@
{
"insecure-registries": []
}

View File

@@ -0,0 +1,24 @@
import type { Metadata } from "next";
import { SubpageShell } from "@/components/subpage-shell";
import { PageHero } from "@/components/page-hero";
import { SectionReportAgent } from "@/components/section-report-agent";
import { site } from "@/content/site";
import { pageMetadata } from "@/lib/site-metadata";
const s = site.reportAgent;
export const metadata: Metadata = pageMetadata({
title: s.title,
description: s.lead,
path: "/agents",
keywords: ["Agent", "Skill", "智能体", "报告智能体", "海宇数科"],
});
export default function AgentsPage() {
return (
<SubpageShell>
<PageHero eyebrow={s.eyebrow} title={s.title} lead={s.lead} />
<SectionReportAgent />
</SubpageShell>
);
}

26
src/app/(site)/layout.tsx Normal file
View File

@@ -0,0 +1,26 @@
import { ParticlesShell } from "@/components/particles-shell";
import { JsonLd } from "@/components/json-ld";
import {
organizationJsonLd,
rootMetadata,
webSiteJsonLd,
} from "@/lib/site-metadata";
export const metadata = rootMetadata();
export default function SiteLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<>
<JsonLd data={[organizationJsonLd(), webSiteJsonLd()]} />
<ParticlesShell>
<div className="site-content relative z-[1] flex min-h-full flex-1 flex-col">
{children}
</div>
</ParticlesShell>
</>
);
}

View File

@@ -0,0 +1,29 @@
import type { Metadata } from "next";
import { SubpageShell } from "@/components/subpage-shell";
import { PageHero } from "@/components/page-hero";
import { ModelsSquareClient } from "@/components/models-square-client";
import { modelSquarePage } from "@/content/model-catalog";
import { loadModelCatalog } from "@/lib/public-models";
import { pageMetadata } from "@/lib/site-metadata";
export const metadata: Metadata = pageMetadata({
title: modelSquarePage.title,
description: modelSquarePage.lead,
path: "/models",
keywords: ["模型广场", "大模型 API", "模型选型", "海宇数科"],
});
export default async function ModelsSquarePage() {
const p = modelSquarePage;
const initialModels = await loadModelCatalog();
return (
<SubpageShell>
<PageHero eyebrow={p.eyebrow} title={p.title} lead={p.lead} />
<section className="band-muted band-section">
<div className="container-site">
<ModelsSquareClient initialModels={initialModels} />
</div>
</section>
</SubpageShell>
);
}

View File

@@ -0,0 +1,26 @@
import type { Metadata } from "next";
import Link from "next/link";
import { SubpageShell } from "@/components/subpage-shell";
export const metadata: Metadata = {
title: "资讯未找到",
robots: { index: false, follow: true },
};
export default function NewsNotFound() {
return (
<SubpageShell>
<section className="band-light band-section">
<div className="container-site section-stack max-w-xl">
<h1 className="type-display-md text-ink"></h1>
<p className="type-body-md text-body">
</p>
<Link href="/news" className="btn-outline no-underline hover:no-underline w-fit">
AI
</Link>
</div>
</section>
</SubpageShell>
);
}

View File

@@ -0,0 +1,100 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { SubpageShell } from "@/components/subpage-shell";
import { JsonLd } from "@/components/json-ld";
import { NewsCoverImage } from "@/components/news-cover-image";
import {
getAllNewsSlugs,
getNewsBySlug,
} from "@/content/news-articles";
import {
articleMetadata,
newsArticleJsonLd,
} from "@/lib/site-metadata";
type PageProps = {
params: Promise<{ slug: string }>;
};
export function generateStaticParams() {
return getAllNewsSlugs().map((slug) => ({ slug }));
}
export async function generateMetadata({
params,
}: PageProps): Promise<Metadata> {
const { slug } = await params;
const article = getNewsBySlug(slug);
if (!article) {
return { title: "文章未找到", robots: { index: false, follow: false } };
}
return articleMetadata({
title: article.title,
description: article.summary,
path: `/news/${slug}`,
publishedTime: `${article.date}T08:00:00+08:00`,
imagePath: article.image,
imageAlt: article.imageAlt,
});
}
export default async function NewsDetailPage({ params }: PageProps) {
const { slug } = await params;
const article = getNewsBySlug(slug);
if (!article) {
notFound();
}
return (
<SubpageShell>
<JsonLd data={newsArticleJsonLd(article)} />
<article className="band-light band-section">
<div className="container-site news-detail-layout section-stack gap-6">
<Link
href="/news"
className="page-hero__back type-caption-strong text-body no-underline hover:no-underline w-fit"
>
</Link>
<div className="news-detail-cover">
<NewsCoverImage
src={article.image}
alt={article.imageAlt}
priority
sizes="(max-width: 1280px) 100vw, 1280px"
/>
</div>
<header className="page-hero__copy">
<p className="section-eyebrow page-hero__eyebrow">{article.tag}</p>
<h1 className="page-hero__title type-display-lg text-ink">
{article.title}
</h1>
<p className="page-hero__lead type-lead text-body">{article.summary}</p>
<p className="type-caption text-body">
{article.date}
<span className="mx-2" aria-hidden>
·
</span>
{article.author}
<span className="mx-2" aria-hidden>
·
</span>
{article.readMinutes}
</p>
</header>
<div className="article-prose flex flex-col gap-5">
{article.paragraphs.map((para, index) => (
<p key={index} className="type-body-md text-body">
{para}
</p>
))}
</div>
</div>
</article>
</SubpageShell>
);
}

View File

@@ -0,0 +1,35 @@
import type { Metadata } from "next";
import { SubpageShell } from "@/components/subpage-shell";
import { PageHero } from "@/components/page-hero";
import { NewsListCard } from "@/components/news-list-card";
import { newsArticles, newsListPage } from "@/content/news-articles";
import { pageMetadata } from "@/lib/site-metadata";
export const metadata: Metadata = pageMetadata({
title: newsListPage.title,
description: newsListPage.lead,
path: "/news",
keywords: ["AI 资讯", "大模型动态", "海宇数科", "智能报告"],
});
export default function NewsListPage() {
const p = newsListPage;
return (
<SubpageShell>
<PageHero eyebrow={p.eyebrow} title={p.title} lead={p.lead} />
<section className="band-light band-section">
<div className="container-site">
<div className="grid gap-6 sm:grid-cols-2">
{newsArticles.map((article, index) => (
<NewsListCard
key={article.slug}
article={article}
priorityImage={index < 2}
/>
))}
</div>
</div>
</section>
</SubpageShell>
);
}

56
src/app/(site)/page.tsx Normal file
View File

@@ -0,0 +1,56 @@
import type { Metadata } from "next";
import { SiteHeader } from "@/components/site-header";
import { SiteFooter } from "@/components/site-footer";
import { HeroSection } from "@/components/hero-section";
import { SectionIntelligentReport } from "@/components/section-intelligent-report";
import { SectionReportAgent } from "@/components/section-report-agent";
import { SectionModels } from "@/components/section-models";
import { SectionEnterprise } from "@/components/section-enterprise";
import { SectionNews } from "@/components/section-news";
import { SectionContact } from "@/components/section-contact";
import { ScrollReveal, ScrollRevealHero } from "@/components/scroll-reveal";
import { canonicalAbsoluteUrl } from "@/lib/site-url";
/** 首页 SEOcanonical 带尾部 `/`renderer 适配国产双核浏览器 */
export const metadata: Metadata = {
alternates: {
canonical: canonicalAbsoluteUrl("/"),
},
other: {
renderer: "webkit",
},
};
export default function Home() {
return (
<>
<SiteHeader />
<main className="flex flex-1 flex-col">
<ScrollRevealHero>
<HeroSection />
</ScrollRevealHero>
<ScrollReveal>
<SectionIntelligentReport />
</ScrollReveal>
<ScrollReveal delay={0.05}>
<SectionReportAgent />
</ScrollReveal>
<ScrollReveal>
<SectionModels />
</ScrollReveal>
<ScrollReveal delay={0.05}>
<SectionEnterprise />
</ScrollReveal>
<ScrollReveal>
<SectionNews />
</ScrollReveal>
<ScrollReveal delay={0.05}>
<SectionContact />
</ScrollReveal>
</main>
<ScrollReveal>
<SiteFooter />
</ScrollReveal>
</>
);
}

View File

@@ -0,0 +1,24 @@
import type { Metadata } from "next";
import { SubpageShell } from "@/components/subpage-shell";
import { PageHero } from "@/components/page-hero";
import { SectionIntelligentReport } from "@/components/section-intelligent-report";
import { site } from "@/content/site";
import { pageMetadata } from "@/lib/site-metadata";
const s = site.intelligentReport;
export const metadata: Metadata = pageMetadata({
title: s.title,
description: s.lead,
path: "/reports",
keywords: ["智能报告", "KYB", "企业核验", "尽调", "海宇数科"],
});
export default function ReportsPage() {
return (
<SubpageShell>
<PageHero eyebrow={s.eyebrow} title={s.title} lead={s.lead} />
<SectionIntelligentReport />
</SubpageShell>
);
}

View File

@@ -0,0 +1,24 @@
import type { Metadata } from "next";
import { SubpageShell } from "@/components/subpage-shell";
import { PageHero } from "@/components/page-hero";
import { SectionEnterprise } from "@/components/section-enterprise";
import { site } from "@/content/site";
import { pageMetadata } from "@/lib/site-metadata";
const s = site.enterprise;
export const metadata: Metadata = pageMetadata({
title: s.title,
description: s.lead,
path: "/solutions",
keywords: ["企业数字化", "解决方案", "大模型", "海宇数科"],
});
export default function SolutionsPage() {
return (
<SubpageShell>
<PageHero eyebrow={s.eyebrow} title={s.title} lead={s.lead} />
<SectionEnterprise />
</SubpageShell>
);
}

View File

@@ -0,0 +1,20 @@
import { NextResponse } from "next/server";
import { proxyPublicModelsResponse } from "@/lib/public-models";
export async function GET() {
const res = await proxyPublicModelsResponse();
if (!res.ok) {
if (res.status === 502) {
return NextResponse.json(
{ code: "INTERNAL_ERROR", message: "服务暂时不可用" },
{ status: 500 },
);
}
return NextResponse.json(
{ code: "UPSTREAM_ERROR", message: "获取模型列表失败" },
{ status: res.status || 502 },
);
}
const envelope = await res.json();
return NextResponse.json(envelope);
}

View File

@@ -0,0 +1,31 @@
import { generateStaticParamsFor, importPage } from "nextra/pages";
import { useMDXComponents } from "@/mdx-components";
import { DocsArticle } from "@/components/docs-article";
import type { DocsTocItem } from "@/components/docs-on-this-page";
export const generateStaticParams = generateStaticParamsFor("mdxPath");
export async function generateMetadata(props: {
params: Promise<{ mdxPath?: string[] }>;
}) {
const params = await props.params;
const { metadata } = await importPage(params.mdxPath);
return metadata;
}
export default async function DocsPage(props: {
params: Promise<{ mdxPath?: string[] }>;
}) {
const params = await props.params;
const {
default: MDXContent,
toc,
} = await importPage(params.mdxPath);
const components = useMDXComponents();
return (
<DocsArticle toc={toc as DocsTocItem[]}>
<MDXContent components={components} {...props} params={params} />
</DocsArticle>
);
}

16
src/app/docs/layout.tsx Normal file
View File

@@ -0,0 +1,16 @@
import { DocsShell } from "@/components/docs-shell";
export const metadata = {
title: {
default: "文档中心",
template: "%s · 海宇数科文档",
},
};
export default function DocsLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return <DocsShell>{children}</DocsShell>;
}

BIN
src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

50
src/app/globals.css Normal file
View File

@@ -0,0 +1,50 @@
@import "tailwindcss";
@import "../styles/tokens.css";
@import "../styles/typography.css";
@import "../styles/layout.css";
@import "../styles/components.css";
@import "../styles/effects.css";
@import "../styles/locale-zh.css";
@import "../styles/responsive.css";
@import "../styles/docs.css";
@layer base {
html {
color-scheme: light;
scroll-behavior: smooth;
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
}
body {
@apply font-display type-body-md antialiased;
background-color: var(--color-canvas-page);
color: var(--color-ink);
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
}
html[lang="zh-CN"] body {
font-feature-settings: normal;
}
a {
color: inherit;
text-decoration: none;
}
a:hover {
text-decoration: underline;
text-underline-offset: 0.25em;
text-decoration-thickness: 1px;
}
:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
}

42
src/app/layout.tsx Normal file
View File

@@ -0,0 +1,42 @@
import { Inter, Noto_Serif_SC, JetBrains_Mono } from "next/font/google";
import "./globals.css";
import { rootMetadata } from "@/lib/site-metadata";
const inter = Inter({
variable: "--font-inter",
subsets: ["latin"],
weight: ["400", "500"],
display: "swap",
});
const notoSerif = Noto_Serif_SC({
variable: "--font-noto-serif",
subsets: ["latin"],
weight: ["400", "500", "600"],
display: "swap",
});
const jetbrainsMono = JetBrains_Mono({
variable: "--font-jetbrains-mono",
subsets: ["latin"],
weight: ["400", "500"],
display: "swap",
});
export const metadata = rootMetadata();
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html
lang="zh-CN"
className={`${inter.variable} ${notoSerif.variable} ${jetbrainsMono.variable} h-full`}
suppressHydrationWarning
>
<body className="min-h-full antialiased">{children}</body>
</html>
);
}

20
src/app/robots.ts Normal file
View File

@@ -0,0 +1,20 @@
import type { MetadataRoute } from "next";
import { getSiteUrl } from "@/lib/site-url";
export default function robots(): MetadataRoute.Robots {
const base = getSiteUrl();
const allowAll = { userAgent: "*", allow: "/" };
return {
rules: [
allowAll,
{ userAgent: "Baiduspider", allow: "/" },
{ userAgent: "Baiduspider-image", allow: "/" },
{ userAgent: "Sogou web spider", allow: "/" },
{ userAgent: "360Spider", allow: "/" },
{ userAgent: "YisouSpider", allow: "/" },
],
sitemap: new URL("/sitemap.xml", base).toString(),
host: base.host,
};
}

41
src/app/sitemap.ts Normal file
View File

@@ -0,0 +1,41 @@
import type { MetadataRoute } from "next";
import { getAllNewsSlugs, getNewsBySlug } from "@/content/news-articles";
import { canonicalAbsoluteUrl } from "@/lib/site-url";
export default function sitemap(): MetadataRoute.Sitemap {
const now = new Date();
const staticPaths = [
"/",
"/reports",
"/agents",
"/models",
"/solutions",
"/news",
"/docs",
"/docs/getting-started",
"/docs/faq",
] as const;
const staticEntries: MetadataRoute.Sitemap = staticPaths.map((path) => ({
url: canonicalAbsoluteUrl(path),
lastModified: now,
changeFrequency: path === "/news" ? ("daily" as const) : ("weekly" as const),
priority: path === "/" ? 1 : 0.9,
}));
const newsEntries: MetadataRoute.Sitemap = getAllNewsSlugs().map((slug) => {
const article = getNewsBySlug(slug);
const lastModified = article?.date
? new Date(`${article.date}T08:00:00+08:00`)
: now;
return {
url: canonicalAbsoluteUrl(`/news/${slug}`),
lastModified,
changeFrequency: "monthly" as const,
priority: 0.7,
};
});
return [...staticEntries, ...newsEntries];
}

View File

@@ -0,0 +1,214 @@
"use client";
import { motion, useReducedMotion } from "motion/react";
/** 与站点墨蓝 / nav-axis 一致的 AI 冷色光晕,避免高饱和「彩虹」感 */
const AI_GLOW_PALETTE = {
axis: "rgb(158 178 212 / 0.48)",
blue: "rgb(91 142 196 / 0.44)",
slate: "rgb(74 101 130 / 0.4)",
cyan: "rgb(86 168 198 / 0.38)",
violet: "rgb(124 140 200 / 0.42)",
periwinkle: "rgb(108 128 178 / 0.36)",
ice: "rgb(176 198 228 / 0.32)",
deep: "rgb(46 72 108 / 0.28)",
} as const;
type GlowOrb = {
id: string;
width: number;
height: number;
top: string;
left: string;
radius: string;
gradient: string;
duration: number;
path: { x: number[]; y: number[]; rotate?: number[] };
};
const ORBS: GlowOrb[] = [
{
id: "axis-tr",
width: 680,
height: 420,
top: "-18%",
left: "58%",
radius: "58% 42% 45% 55% / 52% 48% 62% 38%",
gradient: `radial-gradient(ellipse 90% 70% at 35% 45%, ${AI_GLOW_PALETTE.axis}, transparent 72%)`,
duration: 11,
path: {
x: [0, 180, -120, 80, 0],
y: [0, -140, 100, -50, 0],
rotate: [0, 22, -18, 8, 0],
},
},
{
id: "blue-wide",
width: 520,
height: 780,
top: "12%",
left: "-22%",
radius: "48% 52% 40% 60% / 55% 45% 50% 50%",
gradient: `radial-gradient(ellipse 75% 85% at 60% 50%, ${AI_GLOW_PALETTE.blue}, transparent 70%)`,
duration: 13,
path: {
x: [0, -140, 170, -90, 0],
y: [0, 120, -150, 70, 0],
rotate: [0, -28, 24, -12, 0],
},
},
{
id: "violet-mid",
width: 440,
height: 360,
top: "38%",
left: "42%",
radius: "62% 38% 55% 45% / 48% 52% 42% 58%",
gradient: `radial-gradient(ellipse 80% 65% at 50% 40%, ${AI_GLOW_PALETTE.violet}, transparent 68%)`,
duration: 9,
path: {
x: [0, -160, 130, -70, 0],
y: [0, 95, -130, 75, 0],
rotate: [0, 16, -20, 0],
},
},
{
id: "cyan-ribbon",
width: 900,
height: 280,
top: "62%",
left: "-12%",
radius: "40% 60% 50% 50% / 45% 55% 60% 40%",
gradient: `radial-gradient(ellipse 120% 55% at 50% 50%, ${AI_GLOW_PALETTE.cyan}, transparent 75%)`,
duration: 10,
path: {
x: [0, 220, -180, 100, 0],
y: [0, -70, 95, -40, 0],
rotate: [0, 14, -12, 0],
},
},
{
id: "slate-br",
width: 380,
height: 520,
top: "72%",
left: "72%",
radius: "55% 45% 48% 52% / 60% 40% 55% 45%",
gradient: `radial-gradient(ellipse 70% 90% at 40% 60%, ${AI_GLOW_PALETTE.slate}, transparent 72%)`,
duration: 12,
path: {
x: [0, -110, 150, -60, 0],
y: [0, -120, 85, 50, 0],
rotate: [0, -18, 14, 0],
},
},
{
id: "periwinkle-sm",
width: 260,
height: 340,
top: "18%",
left: "78%",
radius: "63% 37% 42% 58% / 50% 50% 58% 42%",
gradient: `radial-gradient(ellipse 65% 80% at 50% 50%, ${AI_GLOW_PALETTE.periwinkle}, transparent 70%)`,
duration: 8,
path: {
x: [0, 100, -85, 55, 0],
y: [0, -80, 65, -35, 0],
rotate: [0, 35, -28, 12, 0],
},
},
{
id: "ice-float",
width: 320,
height: 200,
top: "48%",
left: "88%",
radius: "50% 50% 45% 55% / 42% 58% 52% 48%",
gradient: `radial-gradient(ellipse 100% 60% at 30% 70%, ${AI_GLOW_PALETTE.ice}, transparent 68%)`,
duration: 7,
path: {
x: [0, -90, 130, -50, 0],
y: [0, 55, -70, 40, 0],
rotate: [0, 20, -16, 0],
},
},
{
id: "deep-anchor",
width: 560,
height: 640,
top: "85%",
left: "-8%",
radius: "42% 58% 60% 40% / 55% 45% 48% 52%",
gradient: `radial-gradient(ellipse 85% 75% at 55% 35%, ${AI_GLOW_PALETTE.deep}, transparent 78%)`,
duration: 14,
path: {
x: [0, 120, -150, 70, 0],
y: [0, -95, 130, -55, 0],
rotate: [0, -14, 18, 0],
},
},
{
id: "blue-cap",
width: 340,
height: 280,
top: "-8%",
left: "8%",
radius: "57% 43% 52% 48% / 46% 54% 50% 50%",
gradient: `radial-gradient(ellipse 70% 75% at 50% 50%, ${AI_GLOW_PALETTE.blue}, transparent 65%)`,
duration: 9,
path: {
x: [0, 85, -110, 45, 0],
y: [0, 70, -55, 30, 0],
rotate: [0, 24, -20, 0],
},
},
];
export function AmbientMotionGlow() {
const reduceMotion = useReducedMotion();
return (
<div className="ambient-motion-glow" aria-hidden>
{ORBS.map((orb) => (
<motion.div
key={orb.id}
className="ambient-motion-glow__orb"
style={{
width: orb.width,
height: orb.height,
top: orb.top,
left: orb.left,
borderRadius: orb.radius,
background: orb.gradient,
}}
animate={
reduceMotion
? undefined
: {
x: orb.path.x,
y: orb.path.y,
rotate: orb.path.rotate ?? 0,
scale: [1, 1.14, 0.88, 1.1, 1],
borderRadius: [
orb.radius,
"42% 58% 55% 45% / 48% 52% 58% 42%",
"58% 42% 48% 52% / 55% 45% 50% 50%",
orb.radius,
],
}
}
transition={
reduceMotion
? undefined
: {
duration: orb.duration,
repeat: Infinity,
ease: "linear",
repeatType: "loop",
}
}
/>
))}
</div>
);
}

View File

@@ -0,0 +1,26 @@
import {
DocsOnThisPage,
type DocsTocItem,
} from "@/components/docs-on-this-page";
type DocsArticleProps = {
children: React.ReactNode;
toc?: DocsTocItem[];
};
export function DocsArticle({ children, toc = [] }: DocsArticleProps) {
return (
<div className="docs-article">
<div className="docs-article__body">
<article className="docs-prose" data-pagefind-body>
{children}
</article>
</div>
<div className="docs-toc-anchor">
<div className="docs-toc-sticky">
<DocsOnThisPage items={toc} />
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,53 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { docsNav } from "@/lib/docs-nav";
function findLabel(pathname: string): string {
if (pathname === "/docs" || pathname === "/docs/") return "文档中心";
for (const item of docsNav) {
if (pathname === item.href || pathname.startsWith(`${item.href}/`)) {
const child = item.children?.find(
(c) => pathname === c.href || pathname.startsWith(`${c.href}/`),
);
if (child) return child.label;
return item.label;
}
}
return "文档";
}
export function DocsBreadcrumb() {
const pathname = usePathname();
const current = findLabel(pathname);
const isHome = pathname === "/docs" || pathname === "/docs/";
return (
<nav className="docs-breadcrumb type-caption-strong" aria-label="面包屑">
<Link href="/" className="docs-breadcrumb__link">
</Link>
<span className="docs-breadcrumb__sep" aria-hidden>
/
</span>
{isHome ? (
<span className="docs-breadcrumb__current" aria-current="page">
</span>
) : (
<>
<Link href="/docs" className="docs-breadcrumb__link">
</Link>
<span className="docs-breadcrumb__sep" aria-hidden>
/
</span>
<span className="docs-breadcrumb__current" aria-current="page">
{current}
</span>
</>
)}
</nav>
);
}

View File

@@ -0,0 +1,46 @@
"use client";
import { useCallback, useState, type ComponentProps } from "react";
export function DocsCodeBlock(props: ComponentProps<"pre">) {
const [copied, setCopied] = useState(false);
const onCopy = useCallback(async () => {
const text = props.children
? extractText(props.children)
: "";
if (!text) return;
try {
await navigator.clipboard.writeText(text);
setCopied(true);
window.setTimeout(() => setCopied(false), 2000);
} catch {
/* ignore */
}
}, [props.children]);
return (
<div className="docs-code">
<button
type="button"
className="docs-code__copy"
onClick={onCopy}
aria-label={copied ? "已复制" : "复制代码"}
>
{copied ? "已复制" : "复制"}
</button>
<pre {...props} className={["docs-code__pre", props.className].filter(Boolean).join(" ")} />
</div>
);
}
function extractText(node: React.ReactNode): string {
if (typeof node === "string") return node;
if (typeof node === "number") return String(node);
if (Array.isArray(node)) return node.map(extractText).join("");
if (node && typeof node === "object" && "props" in node) {
const p = (node as { props?: { children?: React.ReactNode } }).props;
return extractText(p?.children ?? "");
}
return "";
}

View File

@@ -0,0 +1,48 @@
"use client";
import { useEffect, useId, useState } from "react";
import { DocsSidebarNav } from "@/components/docs-sidebar-nav";
export function DocsMobileNav() {
const [open, setOpen] = useState(false);
const panelId = useId();
useEffect(() => {
if (!open) return;
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = prev;
};
}, [open]);
return (
<div className="docs-mobile-nav">
<button
type="button"
className="docs-mobile-nav__trigger btn-outline"
aria-expanded={open}
aria-controls={panelId}
onClick={() => setOpen((v) => !v)}
>
{open ? "关闭目录" : "文档目录"}
</button>
{open ? (
<div
className="docs-mobile-nav__backdrop"
role="presentation"
onClick={() => setOpen(false)}
/>
) : null}
<div
id={panelId}
className="docs-mobile-nav__panel"
data-open={open}
hidden={!open}
>
<nav aria-label="文档目录(移动端)">
<DocsSidebarNav />
</nav>
</div>
</div>
);
}

View File

@@ -0,0 +1,109 @@
"use client";
import { useEffect, useState } from "react";
export type DocsTocItem = {
id?: string;
value: string;
depth: number;
};
export function DocsOnThisPage({ items }: { items: DocsTocItem[] }) {
const headings = items.filter((item) => item.depth >= 2 && item.depth <= 3);
const [activeId, setActiveId] = useState<string | null>(null);
useEffect(() => {
if (!headings.length) return;
const slugify = (text: string) =>
text
.toLowerCase()
.replace(/\s+/g, "-")
.replace(/[^\w\u4e00-\u9fff-]/g, "");
const elements = headings
.map((h) => {
const id =
h.id ||
slugify(
typeof h.value === "string"
? h.value
: String(h.value ?? ""),
);
const el = document.getElementById(id);
return el ? { id, el } : null;
})
.filter(Boolean) as { id: string; el: HTMLElement }[];
if (!elements.length) return;
const observer = new IntersectionObserver(
(entries) => {
const visible = entries
.filter((e) => e.isIntersecting)
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
if (visible[0]?.target.id) {
setActiveId(visible[0].target.id);
}
},
{ rootMargin: "-20% 0px -70% 0px", threshold: 0 },
);
for (const { el } of elements) observer.observe(el);
return () => observer.disconnect();
}, [headings]);
if (headings.length < 2) return null;
const slugify = (text: string) =>
text
.toLowerCase()
.replace(/\s+/g, "-")
.replace(/[^\w\u4e00-\u9fff-]/g, "");
return (
<nav className="docs-toc" aria-label="本页目录">
<p className="docs-toc__title type-caption-strong text-body"></p>
<ul className="docs-toc__list">
{headings.map((item, index) => {
const id =
item.id ||
slugify(
typeof item.value === "string"
? item.value
: String(item.value ?? `section-${index}`),
);
const depthClass =
item.depth >= 3 ? "docs-toc__link--h3" : "docs-toc__link--h2";
return (
<li key={`${id}-${index}`}>
<a
href={`#${id}`}
className={[
"docs-toc__link",
depthClass,
activeId === id ? "docs-toc__link--active" : "",
]
.filter(Boolean)
.join(" ")}
onClick={(e) => {
e.preventDefault();
document.getElementById(id)?.scrollIntoView({
behavior: "smooth",
block: "start",
});
history.replaceState(null, "", `#${id}`);
setActiveId(id);
}}
>
{typeof item.value === "string"
? item.value
: String(item.value)}
</a>
</li>
);
})}
</ul>
</nav>
);
}

View File

@@ -0,0 +1,261 @@
"use client";
import {
Combobox,
ComboboxInput,
ComboboxOption,
ComboboxOptions,
} from "@headlessui/react";
import { addBasePath } from "next/dist/client/add-base-path";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
type ChangeEvent,
type ReactNode,
useDeferredValue,
useEffect,
useRef,
useState,
} from "react";
declare global {
interface Window {
pagefind?: {
options: (opts: { baseUrl: string }) => Promise<void>;
debouncedSearch: (
query: string,
opts?: Record<string, unknown>,
) => Promise<
| {
results: Array<{
data: () => Promise<SearchResultData>;
}>;
}
| undefined
>;
};
}
}
type SearchResultData = {
url: string;
meta: { title: string };
sub_results: Array<{
url: string;
title: string;
excerpt: string;
}>;
};
const INPUT_TAGS = new Set(["INPUT", "SELECT", "BUTTON", "TEXTAREA"]);
const DEV_SEARCH_NOTICE = (
<>
<p>
<code>pnpm build</code> {" "}
<code>pnpm dev</code> 使 Nextra
</p>
</>
);
async function importPagefind() {
window.pagefind = await import(
/* webpackIgnore: true */
addBasePath("/_pagefind/pagefind.js")
);
await window.pagefind!.options({ baseUrl: "/" });
}
function cn(...parts: Array<string | false | undefined>) {
return parts.filter(Boolean).join(" ");
}
function SearchResultGroup({ data }: { data: SearchResultData }) {
return (
<>
<div className="docs-spotlight__group-title">{data.meta.title}</div>
{data.sub_results.map((sub) => (
<ComboboxOption
key={sub.url}
as={Link}
value={sub}
href={sub.url}
className={({ focus }: { focus: boolean }) =>
cn("docs-spotlight__hit", focus && "docs-spotlight__hit--focus")
}
>
<div className="docs-spotlight__hit-title">{sub.title}</div>
<div
className="docs-spotlight__hit-excerpt"
dangerouslySetInnerHTML={{ __html: sub.excerpt }}
/>
</ComboboxOption>
))}
</>
);
}
/** 与 Nextra 一致的 Pagefind 搜索(样式用本站 token不引入 nextra-theme 全局 CSS */
export function DocsSearch({ className }: { className?: string }) {
const router = useRouter();
const inputRef = useRef<HTMLInputElement>(null);
const [search, setSearch] = useState("");
const deferredSearch = useDeferredValue(search);
const [results, setResults] = useState<SearchResultData[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<ReactNode>("");
const [focused, setFocused] = useState(false);
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
const el = document.activeElement;
if (
!el ||
INPUT_TAGS.has(el.tagName) ||
(el instanceof HTMLElement && el.isContentEditable)
) {
return;
}
const mod = navigator.userAgent.includes("Mac")
? event.metaKey
: event.ctrlKey;
if (
event.key === "/" ||
(event.key === "k" && !event.shiftKey && mod)
) {
event.preventDefault();
inputRef.current?.focus({ preventScroll: true });
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, []);
useEffect(() => {
let cancelled = false;
async function run() {
if (!deferredSearch) {
setResults([]);
setError("");
return;
}
setIsLoading(true);
if (!window.pagefind) {
try {
await importPagefind();
} catch (err) {
const message =
err instanceof Error
? process.env.NODE_ENV !== "production" &&
err.message.includes("Failed to fetch")
? DEV_SEARCH_NOTICE
: `${err.constructor.name}: ${err.message}`
: String(err);
if (!cancelled) {
setError(message);
setIsLoading(false);
}
return;
}
}
const response = await window.pagefind!.debouncedSearch(deferredSearch);
if (!response || cancelled) return;
const data = await Promise.all(response.results.map((r) => r.data()));
const normalized = data.map((item) => ({
...item,
sub_results: item.sub_results.map((sub) => ({
...sub,
url: sub.url.replace(/\.html$/, "").replace(/\.html#/, "#"),
})),
}));
if (!cancelled) {
setResults(normalized);
setError("");
setIsLoading(false);
}
}
void run();
return () => {
cancelled = true;
};
}, [deferredSearch]);
const handleSelect = (hit: SearchResultData["sub_results"][number] | null) => {
if (!hit) return;
inputRef.current?.blur();
const [path, hash] = hit.url.split("#");
if (location.pathname === path) {
location.href = `#${hash}`;
} else {
router.push(hit.url);
}
setSearch("");
};
const showShortcut = mounted && !focused;
const isMac = mounted && navigator.userAgent.includes("Mac");
return (
<Combobox onChange={handleSelect}>
<div className={cn("docs-spotlight", className)}>
<ComboboxInput
ref={inputRef}
type="search"
spellCheck={false}
autoComplete="off"
className={({ focus }: { focus: boolean }) =>
cn("docs-spotlight__input", focus && "docs-spotlight__input--focus")
}
placeholder="搜索文档…"
value={search}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
setSearch(e.target.value)
}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
/>
<kbd
className={cn(
"docs-spotlight__kbd",
!showShortcut && "docs-spotlight__kbd--hidden",
)}
aria-hidden={!showShortcut}
>
{isMac ? (
<>
<span></span>K
</>
) : (
"CTRL K"
)}
</kbd>
</div>
<ComboboxOptions
anchor={{ to: "bottom end", gap: 10, padding: 16 }}
transition
className="docs-spotlight__panel"
>
{error ? (
<div className="docs-spotlight__state docs-spotlight__state--error">
<span className="docs-spotlight__state-title"></span>
<div>{error}</div>
</div>
) : isLoading ? (
<div className="docs-spotlight__state"></div>
) : results.length ? (
results.map((group) => (
<SearchResultGroup key={group.url} data={group} />
))
) : deferredSearch ? (
<div className="docs-spotlight__state"></div>
) : null}
</ComboboxOptions>
</Combobox>
);
}

View File

@@ -0,0 +1,33 @@
import { SiteHeader } from "@/components/site-header";
import { SiteFooter } from "@/components/site-footer";
import { ParticlesShell } from "@/components/particles-shell";
import { DocsSidebarNav } from "@/components/docs-sidebar-nav";
import { DocsMobileNav } from "@/components/docs-mobile-nav";
import { DocsSubnav } from "@/components/docs-subnav";
export function DocsShell({ children }: { children: React.ReactNode }) {
return (
<ParticlesShell>
<div className="site-content docs-page relative z-[1] flex min-h-full flex-1 flex-col">
<SiteHeader />
<DocsSubnav />
<div className="docs-shell band-muted">
<div className="container-site docs-shell__grid">
<DocsMobileNav />
<aside className="docs-shell__aside" aria-label="文档目录">
<div className="docs-shell__sticky-anchor">
<div className="docs-shell__sticky">
<nav aria-label="文档目录">
<DocsSidebarNav />
</nav>
</div>
</div>
</aside>
<div className="docs-shell__main">{children}</div>
</div>
</div>
<SiteFooter />
</div>
</ParticlesShell>
);
}

View File

@@ -0,0 +1,107 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useId, useState } from "react";
import { docsNav, type DocsNavItem } from "@/lib/docs-nav";
function isActive(pathname: string, href: string) {
if (href === "/docs") {
return pathname === "/docs" || pathname === "/docs/";
}
return pathname === href || pathname.startsWith(`${href}/`);
}
function isGroupActive(pathname: string, item: DocsNavItem) {
if (isActive(pathname, item.href)) return true;
return item.children?.some((c) => isActive(pathname, c.href)) ?? false;
}
function NavLink({
href,
label,
nested,
}: {
href: string;
label: string;
nested?: boolean;
}) {
const pathname = usePathname();
const active = isActive(pathname, href);
return (
<Link
href={href}
className={[
"docs-nav__link",
nested ? "docs-nav__link--nested" : "",
active ? "docs-nav__link--active" : "",
]
.filter(Boolean)
.join(" ")}
aria-current={active ? "page" : undefined}
>
{label}
</Link>
);
}
function NavGroup({ item }: { item: DocsNavItem }) {
const pathname = usePathname();
const groupActive = isGroupActive(pathname, item);
const [open, setOpen] = useState(groupActive);
const panelId = useId();
useEffect(() => {
if (groupActive) setOpen(true);
}, [groupActive]);
const hasChildren = Boolean(item.children?.length);
if (!hasChildren) {
return (
<li className="docs-nav__group">
<NavLink href={item.href} label={item.label} />
</li>
);
}
return (
<li className="docs-nav__group">
<div className="docs-nav__group-head">
<NavLink href={item.href} label={item.label} />
<button
type="button"
className="docs-nav__toggle"
aria-expanded={open}
aria-controls={panelId}
onClick={() => setOpen((v) => !v)}
>
<span className="sr-only">{open ? "收起" : "展开"}</span>
<span className="docs-nav__chevron" data-open={open} aria-hidden />
</button>
</div>
<ul
id={panelId}
className="docs-nav__sub"
data-open={open}
>
{item.children!.map((child) => (
<li key={child.href}>
<NavLink href={child.href} label={child.label} nested />
</li>
))}
</ul>
</li>
);
}
export function DocsSidebarNav() {
return (
<ul className="docs-nav">
{docsNav.map((item) => (
<NavGroup key={item.href} item={item} />
))}
</ul>
);
}

View File

@@ -0,0 +1,18 @@
import { DocsBreadcrumb } from "@/components/docs-breadcrumb";
import { DocsSearch } from "@/components/docs-search";
/** 文档次顶栏:面包屑 + 搜索,滚动时固定在全站顶栏下方 */
export function DocsSubnav() {
return (
<div className="docs-subnav" role="region" aria-label="文档导航">
<div className="docs-subnav__rail">
<div className="container-site docs-subnav__inner">
<DocsBreadcrumb />
<div className="docs-subnav__search">
<DocsSearch />
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,52 @@
import Image from "next/image";
import { site } from "@/content/site";
export function HeroSection() {
const { hero } = site;
return (
<section
className="hero-section relative isolate z-0 overflow-hidden"
id="top"
>
<Image
src="/banner.png"
alt="海宇数科品牌视觉"
fill
priority
className="pointer-events-none object-cover object-center"
sizes="100vw"
/>
<div
className="hero-section__shade hero-section__shade--mobile pointer-events-none absolute inset-0"
aria-hidden
/>
<div className="hero-section__content container-site relative z-10 flex flex-col justify-center">
<div className="measure-title flex max-w-2xl flex-col gap-7">
<p className="type-eyebrow section-eyebrow">
{hero.eyebrow}
</p>
<h1 className="type-display-xxl text-balance text-ink">
{hero.title}
</h1>
<p className="type-lead measure-prose">{hero.lead}</p>
<div className="flex flex-wrap gap-3 pt-1">
<a
href={site.links.console}
className="btn-primary no-underline hover:no-underline"
target="_blank"
rel="noopener noreferrer"
>
{hero.primaryCta}
</a>
<a
href={hero.secondaryHref}
className="btn-outline no-underline hover:no-underline"
>
{hero.secondaryCta}
</a>
</div>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,122 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { site } from "@/content/site";
import { ScrollRevealItem } from "@/components/scroll-reveal";
type Report = (typeof site.intelligentReport.reports)[number];
export function IntelligentReportGrid() {
const { reports, viewExampleLabel } = site.intelligentReport;
const [active, setActive] = useState<Report | null>(null);
const openExample = useCallback((report: Report) => {
setActive(report);
}, []);
const closeExample = useCallback(() => {
setActive(null);
}, []);
useEffect(() => {
if (!active) return;
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = prev;
};
}, [active]);
return (
<>
<div className="grid gap-6 sm:grid-cols-2">
{reports.map((r, index) => (
<ScrollRevealItem key={r.id} index={index} className="h-full">
<article className="report-scenario-card h-full">
<div className="report-scenario-card__head">
<p className="type-eyebrow">{r.audience}</p>
<span className="report-scenario-card__index" aria-hidden>
{String(index + 1).padStart(2, "0")}
</span>
</div>
<h3 className="type-display-md text-balance text-ink">{r.title}</h3>
<p className="type-body-md measure-prose flex-1 text-body">
{r.body}
</p>
<div className="report-scenario-card__cta">
<button
type="button"
className="btn-outline w-fit"
onClick={() => openExample(r)}
>
{viewExampleLabel}
</button>
</div>
</article>
</ScrollRevealItem>
))}
</div>
{active ? (
<div
className="report-example-overlay"
role="dialog"
aria-modal="true"
aria-labelledby="report-example-title"
onClick={(e) => {
if (e.target === e.currentTarget) closeExample();
}}
onKeyDown={(e) => {
if (e.key === "Escape") closeExample();
}}
>
<div className="report-example-dialog">
<div className="report-example-dialog__inner">
<header className="report-example-dialog__header flex flex-col gap-3">
<p className="type-mono-caps-label text-body">{active.audience}</p>
<h3
id="report-example-title"
className="type-display-lg text-balance text-ink"
>
{active.example.title}
</h3>
<p className="type-mono-caption text-body">{active.example.meta}</p>
</header>
<div className="report-example-dialog__body">
{active.example.sections.map((sec) => (
<section
key={sec.heading}
className="report-example-section"
>
<h4 className="type-caption-strong text-ink">
{sec.heading}
</h4>
{"body" in sec && sec.body ? (
<p className="type-body-md text-body">{sec.body}</p>
) : null}
{"bullets" in sec && sec.bullets ? (
<ul className="report-example-list">
{sec.bullets.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
) : null}
</section>
))}
</div>
<footer className="report-example-dialog__footer">
<button
type="button"
className="btn-primary"
onClick={closeExample}
>
</button>
</footer>
</div>
</div>
</div>
) : null}
</>
);
}

View File

@@ -0,0 +1,13 @@
type JsonLdProps = {
data: Record<string, unknown> | Record<string, unknown>[];
};
export function JsonLd({ data }: JsonLdProps) {
const payload = Array.isArray(data) ? data : [data];
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(payload) }}
/>
);
}

View File

@@ -0,0 +1,109 @@
import Image from "next/image";
import type {
ModelCatalogItem,
ModelCornerBadge,
} from "@/content/model-catalog";
export type ModelCardData = Pick<
ModelCatalogItem,
| "id"
| "name"
| "description"
| "tags"
| "context"
| "priceIn"
| "priceOut"
| "priceLabel"
| "pricingNote"
| "contextLabel"
| "logo"
| "logoAlt"
> & {
cornerBadge?: ModelCornerBadge;
};
type ModelLibraryCardProps = {
model: ModelCardData;
};
function cornerBadgeClass(badge: ModelCornerBadge): string {
const base = "model-library-card__corner-badge";
return badge === "特惠"
? `${base} ${base}--deal`
: `${base} ${base}--tem`;
}
export function ModelLibraryCard({ model }: ModelLibraryCardProps) {
return (
<article className="model-library-card flex h-full flex-col">
{model.cornerBadge ? (
<span className={cornerBadgeClass(model.cornerBadge)}>
{model.cornerBadge}
</span>
) : null}
<header className="model-library-card__head">
<span className="model-library-card__icon" aria-hidden>
{model.logo ? (
<Image
src={model.logo}
alt={model.logoAlt ?? model.name}
width={28}
height={28}
className="model-library-card__logo"
unoptimized
/>
) : (
<span className="model-library-card__icon-mark" />
)}
</span>
<h3 className="model-library-card__title type-body-md-strong text-ink">
{model.name}
</h3>
</header>
<p className="model-library-card__desc type-body-md flex-1 text-body">
{model.description}
</p>
<ul className="model-library-card__tags">
{model.tags.map((tag, index) => (
<li key={`${tag}-${index}`}>
<span
className={
tag === "主推"
? "model-library-card__tag model-library-card__tag--featured"
: "model-library-card__tag"
}
>
{tag}
</span>
</li>
))}
</ul>
<footer className="model-library-card__meta">
<div className="model-library-card__meta-cell">
<span className="type-caption text-body">
{model.contextLabel ?? "上下文 / 规格"}
</span>
<p className="model-library-card__meta-value type-body-md-strong text-ink">
<span className="model-library-card__meta-bar" aria-hidden />
{model.context}
</p>
</div>
<div className="model-library-card__meta-cell">
<span className="type-caption text-body">
{model.priceLabel ?? "价格1M Tokens"}
</span>
{model.pricingNote ? (
<p className="type-body-md-strong text-ink">{model.pricingNote}</p>
) : (
<p className="type-body-md-strong text-ink">
{model.priceIn} / {model.priceOut}
</p>
)}
</div>
</footer>
</article>
);
}

View File

@@ -0,0 +1,69 @@
import Image from "next/image";
import type { ModelMarqueeItem } from "@/content/model-catalog";
type ModelMarqueeStripProps = {
items: ModelMarqueeItem[];
direction: "left" | "right";
/** 供无障碍朗读,如「平台已接入的部分模型」 */
ariaLabel: string;
};
function MarqueeChip({ item }: { item: ModelMarqueeItem }) {
return (
<li className="model-marquee__chip">
<span className="model-marquee__icon" aria-hidden>
{item.logo ? (
<Image
src={item.logo}
alt=""
width={24}
height={24}
className="model-marquee__logo"
unoptimized
/>
) : (
<span className="model-marquee__icon-fallback" />
)}
</span>
<span className="model-marquee__name type-caption-strong text-ink">
{item.name}
</span>
</li>
);
}
function MarqueeGroup({ items }: { items: ModelMarqueeItem[] }) {
return (
<ul className="model-marquee__group">
{items.map((item) => (
<MarqueeChip key={item.id} item={item} />
))}
</ul>
);
}
export function ModelMarqueeStrip({
items,
direction,
ariaLabel,
}: ModelMarqueeStripProps) {
if (items.length === 0) return null;
return (
<div
className={
direction === "left"
? "model-marquee model-marquee--left"
: "model-marquee model-marquee--right"
}
aria-label={ariaLabel}
>
<div className="model-marquee__viewport">
<div className="model-marquee__track">
<MarqueeGroup items={items} />
<MarqueeGroup items={items} />
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,127 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import {
modelCategoryLabels,
getCornerBadgeFromHighlight,
mapApiModelsToCatalog,
type ModelCatalogItem,
type ApiModelItem,
} from "@/content/model-catalog";
import { ModelLibraryCard } from "@/components/model-library-card";
type FilterKey = keyof typeof modelCategoryLabels;
type ModelsSquareClientProps = {
/** 服务端预取的目录 */
initialModels?: ModelCatalogItem[];
};
export function ModelsSquareClient({ initialModels }: ModelsSquareClientProps) {
const [filter, setFilter] = useState<FilterKey>("all");
const [models, setModels] = useState<ModelCatalogItem[]>(initialModels ?? []);
const [loading, setLoading] = useState(!initialModels?.length);
const [loadError, setLoadError] = useState(false);
useEffect(() => {
async function fetchModels() {
setLoadError(false);
try {
const res = await fetch("/api/models");
if (!res.ok) {
setLoadError(true);
return;
}
const envelope = await res.json();
const data: ApiModelItem[] = envelope.data ?? envelope;
if (Array.isArray(data)) {
setModels(mapApiModelsToCatalog(data));
}
} catch {
setLoadError(true);
} finally {
setLoading(false);
}
}
fetchModels();
}, []);
const filtered = useMemo(() => {
if (filter === "all") return models;
return models.filter((m) => m.category === filter);
}, [filter, models]);
const filters = Object.entries(modelCategoryLabels) as [
FilterKey,
string,
][];
return (
<div className="flex flex-col gap-8">
<div
className="models-square-filters flex flex-wrap gap-2"
role="tablist"
aria-label="模型分类"
>
{filters.map(([key, label]) => (
<button
key={key}
type="button"
role="tab"
aria-selected={filter === key}
className={
filter === key
? "models-square-filters__chip is-active"
: "models-square-filters__chip"
}
onClick={() => setFilter(key)}
>
{label}
</button>
))}
</div>
<div className="models-square-grid">
{filtered.map((model) => (
<ModelSquareItem key={model.id} model={model} />
))}
</div>
{loading && models.length === 0 ? (
<p className="type-body-md text-body"></p>
) : null}
{!loading && loadError && models.length === 0 ? (
<p className="type-body-md text-body">
</p>
) : null}
{!loading && !loadError && filtered.length === 0 ? (
<p className="type-body-md text-body">
{models.length === 0
? "暂无上线模型。"
: "该分类下暂无模型,请选择其他分类。"}
</p>
) : null}
</div>
);
}
function ModelSquareItem({ model }: { model: ModelCatalogItem }) {
const cornerBadge = getCornerBadgeFromHighlight(model.highlight);
return (
<div className="models-square-item">
<p className="models-square-item__meta type-caption text-body">
{model.provider}
<span className="mx-2 text-hairline" aria-hidden>
·
</span>
{modelCategoryLabels[model.category]}
</p>
<ModelLibraryCard
model={cornerBadge ? { ...model, cornerBadge } : model}
/>
</div>
);
}

View File

@@ -0,0 +1,28 @@
import Image from "next/image";
type NewsCoverImageProps = {
src: string;
alt: string;
priority?: boolean;
className?: string;
sizes?: string;
};
export function NewsCoverImage({
src,
alt,
priority = false,
className = "",
sizes = "(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 640px",
}: NewsCoverImageProps) {
return (
<Image
src={src}
alt={alt}
fill
sizes={sizes}
className={`object-cover object-center ${className}`.trim()}
priority={priority}
/>
);
}

View File

@@ -0,0 +1,85 @@
import Link from "next/link";
import type { NewsArticle } from "@/content/news-articles";
import { NewsCoverImage } from "@/components/news-cover-image";
export type NewsListCardLayout = "default" | "primary" | "secondary";
type NewsListCardProps = {
article: NewsArticle;
priorityImage?: boolean;
layout?: NewsListCardLayout;
};
export function NewsListCard({
article,
priorityImage = false,
layout = "default",
}: NewsListCardProps) {
const articleClass =
layout === "primary"
? "news-list-card news-list-card--primary card-on-light flex flex-col overflow-hidden"
: layout === "secondary"
? "news-list-card news-list-card--secondary card-on-light flex overflow-hidden"
: "news-list-card card-on-light flex flex-col overflow-hidden";
const titleClass =
layout === "secondary"
? "news-list-card__title news-list-card__title--compact type-body-md-strong text-balance"
: layout === "primary"
? "news-list-card__title type-display-md text-balance"
: "type-display-md text-balance";
const showSummary = layout === "default" || layout === "primary";
const summaryClamp =
layout === "primary" ? "news-list-card__summary news-list-card__summary--clamp" : "";
const mediaLinkClass =
layout === "default"
? "news-list-card__media-link relative block aspect-video w-full overflow-hidden bg-hairline no-underline hover:no-underline"
: layout === "secondary"
? "news-list-card__media-link relative block h-full min-h-full w-full overflow-hidden no-underline hover:no-underline"
: "news-list-card__media-link relative block w-full shrink-0 overflow-hidden no-underline hover:no-underline";
return (
<article className={articleClass}>
<Link href={`/news/${article.slug}`} className={mediaLinkClass}>
<NewsCoverImage
src={article.image}
alt={article.imageAlt}
priority={priorityImage}
/>
</Link>
<div className="news-list-card__body flex flex-col gap-2">
<span className="type-caption-strong text-body">{article.tag}</span>
<h2 className={titleClass}>
<Link
href={`/news/${article.slug}`}
className="text-ink no-underline hover:no-underline"
>
{article.title}
</Link>
</h2>
{showSummary ? (
<p
className={
summaryClamp
? `${summaryClamp} type-body-md text-body`
: "type-body-md flex-1 text-body"
}
>
{article.summary}
</p>
) : null}
<div className="news-list-card__foot flex flex-wrap items-center justify-between gap-2 pt-0.5">
<p className="type-caption text-body">{article.date}</p>
<Link
href={`/news/${article.slug}`}
className="type-caption-strong text-accent no-underline hover:no-underline"
>
</Link>
</div>
</div>
</article>
);
}

View File

@@ -0,0 +1,35 @@
import Link from "next/link";
type PageHeroProps = {
eyebrow: string;
title: string;
lead: string;
backHref?: string;
backLabel?: string;
};
export function PageHero({
eyebrow,
title,
lead,
backHref = "/",
backLabel = "返回首页",
}: PageHeroProps) {
return (
<header className="page-hero band-light band-section">
<div className="container-site section-stack gap-6">
<Link
href={backHref}
className="page-hero__back type-caption-strong text-body no-underline hover:no-underline w-fit"
>
{backLabel}
</Link>
<div className="page-hero__copy">
<p className="section-eyebrow page-hero__eyebrow">{eyebrow}</p>
<h1 className="page-hero__title type-display-lg text-ink">{title}</h1>
<p className="page-hero__lead type-lead text-body">{lead}</p>
</div>
</div>
</header>
);
}

View File

@@ -0,0 +1,110 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import Particles, { ParticlesProvider } from "@tsparticles/react";
import type { ISourceOptions } from "@tsparticles/engine";
import { loadSlim } from "@tsparticles/slim";
import { AmbientMotionGlow } from "@/components/ambient-motion-glow";
async function particlesInit(engine: import("@tsparticles/engine").Engine) {
await loadSlim(engine);
}
function buildOptions(animate: boolean): ISourceOptions {
return {
fullScreen: {
enable: false,
},
fpsLimit: 60,
detectRetina: true,
background: {
color: { value: "transparent" },
},
particles: {
number: { value: 10 },
color: {
value: [
"#9eb2d4",
"#5b8ec4",
"#4a6582",
"#56a8c6",
"#7c8ec8",
"#6c88b8",
"#b0c4dc",
"#3d5a7a",
],
},
shape: { type: "circle" },
opacity: {
value: { min: 0.18, max: 0.38 },
},
size: {
value: { min: 160, max: 720 },
},
move: {
enable: true,
speed: animate ? { min: 4.5, max: 9 } : { min: 0.15, max: 0.25 },
drift: animate ? { min: -2.5, max: 2.5 } : 0,
direction: "none",
random: true,
straight: false,
outModes: {
default: "bounce",
},
},
links: {
enable: false,
},
},
interactivity: {
events: {
onHover: { enable: false },
onClick: { enable: false },
},
},
pauseOnBlur: false,
autoPlay: true,
};
}
function AmbientParticlesCanvas() {
const [animate, setAnimate] = useState(true);
useEffect(() => {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
const update = () => setAnimate(!mq.matches);
update();
mq.addEventListener("change", update);
return () => mq.removeEventListener("change", update);
}, []);
const options = useMemo(() => buildOptions(animate), [animate]);
return (
<div className="ambient-bg-stack" aria-hidden>
<div className="ambient-particles-fallback" />
<AmbientMotionGlow />
<ParticlesProvider init={particlesInit}>
<Particles
id="ambient-tsparticles"
options={options}
className="ambient-particles-canvas"
/>
</ParticlesProvider>
</div>
);
}
type ParticlesShellProps = {
children: React.ReactNode;
};
/** 背景与页面内容分离,避免 Provider 加载前挡住整页 */
export function ParticlesShell({ children }: ParticlesShellProps) {
return (
<>
<AmbientParticlesCanvas />
{children}
</>
);
}

View File

@@ -0,0 +1,36 @@
import { site } from "@/content/site";
export function ReportAgentFlowVisual() {
const steps = site.reportAgent.installSteps;
return (
<ol className="agent-flow-visual" aria-label="智能体 Skill 安装三步示意">
{steps.map((step, index) => (
<li key={step.name} className="agent-flow-visual__step">
<div className="agent-mock-screen">
<div className="agent-mock-screen__chrome" aria-hidden>
<span className="agent-mock-screen__dot" />
<span className="agent-mock-screen__dot" />
<span className="agent-mock-screen__dot" />
<span className="agent-mock-screen__title type-mono-caption">
{step.screenTitle}
</span>
</div>
<div className="agent-mock-screen__body">
<p className="type-caption text-body">{step.screenHint}</p>
<div className="agent-mock-screen__lines" aria-hidden>
<span />
<span />
</div>
</div>
</div>
<div className="agent-flow-visual__caption">
<span className="type-mono-caps-label text-body">
{index + 1}
</span>
<p className="type-caption-strong text-ink">{step.name}</p>
</div>
</li>
))}
</ol>
);
}

View File

@@ -0,0 +1,98 @@
"use client";
import { motion, useReducedMotion } from "motion/react";
import type { ReactNode } from "react";
const easeOut = [0.22, 1, 0.36, 1] as const;
type ScrollRevealProps = {
children: ReactNode;
className?: string;
/** 同屏多块依次出现时递增,单位秒 */
delay?: number;
};
/** 进入视口时自下而上淡入(滚动展示) */
export function ScrollReveal({
children,
className,
delay = 0,
}: ScrollRevealProps) {
const reduceMotion = useReducedMotion();
if (reduceMotion) {
return <div className={className}>{children}</div>;
}
return (
<motion.div
className={className}
initial={{ opacity: 0, y: 32 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.15, margin: "0px 0px -40px 0px" }}
transition={{ duration: 0.6, delay, ease: easeOut }}
>
{children}
</motion.div>
);
}
type ScrollRevealHeroProps = {
children: ReactNode;
className?: string;
};
/** 首屏进入页面时播放一次 */
export function ScrollRevealHero({ children, className }: ScrollRevealHeroProps) {
const reduceMotion = useReducedMotion();
if (reduceMotion) {
return <div className={className}>{children}</div>;
}
return (
<motion.div
className={className}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.7, ease: easeOut }}
>
{children}
</motion.div>
);
}
type ScrollRevealItemProps = {
children: ReactNode;
className?: string;
index?: number;
};
/** 卡片/列表项:随滚动交错出现 */
export function ScrollRevealItem({
children,
className,
index = 0,
}: ScrollRevealItemProps) {
const reduceMotion = useReducedMotion();
if (reduceMotion) {
return <div className={className}>{children}</div>;
}
return (
<motion.div
className={className}
initial={{ opacity: 0, y: 24 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.2, margin: "0px 0px -32px 0px" }}
transition={{
duration: 0.5,
delay: Math.min(index * 0.08, 0.32),
ease: easeOut,
}}
>
{children}
</motion.div>
);
}

View File

@@ -0,0 +1,95 @@
import Image from "next/image";
import { site } from "@/content/site";
import { SectionIntro } from "@/components/section-intro";
export function SectionContact() {
const c = site.contact;
const b = c.business;
const { customerService } = site.links;
return (
<section className="band-muted band-section" id="contact">
<div className="container-site section-stack">
<SectionIntro
eyebrow={c.eyebrow}
title={c.title}
lead={c.lead}
/>
<div className="contact-layout">
<ul className="contact-channels">
<li className="contact-channels__item card-on-light">
<p className="type-caption-strong text-body">
</p>
<a
href={`mailto:${c.email}`}
className="type-body-md-strong text-ink no-underline hover:no-underline"
>
{c.email}
</a>
</li>
<li className="contact-channels__item card-on-light">
<p className="type-caption-strong text-body">
</p>
<a
href={`tel:${c.phoneTel}`}
className="type-body-md-strong text-ink no-underline hover:no-underline"
>
{c.phoneDisplay}
</a>
</li>
<li className="contact-channels__item card-on-light">
<p className="type-caption-strong text-body">
{c.customerServiceLabel}
</p>
<a
href={customerService}
className="btn-outline no-underline hover:no-underline w-fit"
target="_blank"
rel="noopener noreferrer"
>
</a>
<p className="type-caption text-body">
线
</p>
</li>
</ul>
{/* <aside className="contact-business card-on-light">
<p className="contact-business__eyebrow type-caption-strong">
商务洽谈
</p>
<div className="contact-business__panel">
<h3 className="contact-business__title type-body-md-strong">
{b.title}
</h3>
<p className="contact-business__lead type-caption">{b.lead}</p>
<div className="contact-business__qr-wrap">
<Image
src={b.qrImage}
alt={b.qrAlt}
width={220}
height={220}
className="contact-business__qr"
priority={false}
/>
</div>
<a
href={`tel:${c.phoneTel}`}
className="contact-business__phone type-body-md-strong no-underline hover:no-underline"
>
<span className="contact-business__phone-icon" aria-hidden>
</span>
{b.phoneLabel}{c.phoneDisplay}
</a>
</div>
</aside> */}
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,22 @@
import { site } from "@/content/site";
import { SectionIntro } from "@/components/section-intro";
export function SectionEnterprise() {
const s = site.enterprise;
return (
<section className="band-light band-section" id="solutions">
<div className="container-site section-stack">
<SectionIntro eyebrow={s.eyebrow} title={s.title} lead={s.lead} />
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{s.solutions.map((item) => (
<article key={item.title} className="card-on-light flex flex-col gap-4">
<h3 className="type-display-md text-balance text-ink">{item.title}</h3>
<p className="type-body-md text-body">{item.body}</p>
</article>
))}
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,15 @@
import { site } from "@/content/site";
import { SectionIntro } from "@/components/section-intro";
import { IntelligentReportGrid } from "@/components/intelligent-report-grid";
export function SectionIntelligentReport() {
const s = site.intelligentReport;
return (
<section className="band-muted band-section" id="reports">
<div className="container-site section-stack">
<SectionIntro eyebrow={s.eyebrow} title={s.title} lead={s.lead} />
<IntelligentReportGrid />
</div>
</section>
);
}

View File

@@ -0,0 +1,15 @@
type SectionIntroProps = {
eyebrow: string;
title: string;
lead: string;
};
export function SectionIntro({ eyebrow, title, lead }: SectionIntroProps) {
return (
<header className="measure-title flex flex-col gap-5">
<p className="type-eyebrow section-eyebrow">{eyebrow}</p>
<h2 className="type-display-xl text-balance text-ink">{title}</h2>
<p className="type-lead measure-prose">{lead}</p>
</header>
);
}

Some files were not shown because too many files have changed in this diff Show More