ff
10
.cursor/rules/agents.mdc
Normal 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 -->
|
||||
27
.cursor/rules/design-system.mdc
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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 -->
|
||||
176
DESIGN.md
Normal 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 主标题)。
|
||||
- Mono:JetBrains 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
@@ -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
@@ -0,0 +1,7 @@
|
||||
export default {
|
||||
index: "文档中心",
|
||||
"getting-started": "快速开始",
|
||||
models: "AI 模型接入",
|
||||
guides: "使用指南",
|
||||
faq: "常见问题",
|
||||
};
|
||||
32
content/faq.mdx
Normal 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 安排。
|
||||
|
||||
## 文档有错误怎么办?
|
||||
|
||||
文档随产品迭代更新;若发现过时信息,请通过控制台反馈或邮件联系技术支持。
|
||||
44
content/getting-started.mdx
Normal 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
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
console: "???",
|
||||
api_keys: "API Key",
|
||||
streaming: "????",
|
||||
};
|
||||
26
content/guides/api_keys.mdx
Normal 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 日志是否残留。
|
||||
30
content/guides/console.mdx
Normal 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
|
||||
38
content/guides/streaming.mdx
Normal 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 SDK(Node)
|
||||
|
||||
```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
5
content/models/_meta.js
Normal file
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
openai: "OpenAI",
|
||||
claude: "Claude (Anthropic)",
|
||||
deepseek: "DeepSeek",
|
||||
};
|
||||
42
content/models/claude.mdx
Normal 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 统计。
|
||||
32
content/models/deepseek.mdx
Normal 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
@@ -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`:0–2,控制随机性
|
||||
- `max_tokens`:最大生成 token
|
||||
- `stream: true`:开启流式,见 [流式调用](/docs/guides/streaming)
|
||||
|
||||
## 模型 ID
|
||||
|
||||
请在控制台 **模型广场** 查看当前可用 ID;名称可能随供应商更新,以控制台为准。
|
||||
100
e2e/smoke.spec.ts
Normal 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
@@ -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
@@ -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
@@ -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
3
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,3 @@
|
||||
ignoredBuiltDependencies:
|
||||
- sharp
|
||||
- unrs-resolver
|
||||
7
postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
BIN
public/banner.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
public/contact/business-wechat-qr.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
public/doubao_logo.image
Normal file
|
After Width: | Height: | Size: 7.1 KiB |
BIN
public/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
1
public/file.svg
Normal 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
@@ -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
|
After Width: | Height: | Size: 228 KiB |
BIN
public/models/deepseek.ico
Normal file
|
After Width: | Height: | Size: 205 KiB |
BIN
public/models/minimax.ico
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
32
public/models/zhipu.ico
Normal 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
|
After Width: | Height: | Size: 90 KiB |
BIN
public/news/2.webp
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
public/news/3.webp
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
public/news/4.webp
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
public/news/5.webp
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
public/news/6.webp
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
public/news/7.webp
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
public/news/8.webp
Normal file
|
After Width: | Height: | Size: 36 KiB |
1
public/next.svg
Normal 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
@@ -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
@@ -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
@@ -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 |
76
scripts/docker/build-push.ps1
Normal 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"
|
||||
15
scripts/docker/deploy-load-tar.sh
Normal 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
|
||||
42
scripts/docker/deploy-pull.sh
Normal 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"
|
||||
6
scripts/docker/deploy.env
Normal 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=
|
||||
9
scripts/docker/deploy.env.example
Normal 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=
|
||||
14
scripts/docker/docker-compose.yml
Normal 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:-}
|
||||
3
scripts/docker/docker-daemon.snippet.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"insecure-registries": []
|
||||
}
|
||||
24
src/app/(site)/agents/page.tsx
Normal 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
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
29
src/app/(site)/models/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
src/app/(site)/news/[slug]/not-found.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
100
src/app/(site)/news/[slug]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
src/app/(site)/news/page.tsx
Normal 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
@@ -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";
|
||||
|
||||
/** 首页 SEO:canonical 带尾部 `/`,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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
24
src/app/(site)/reports/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
src/app/(site)/solutions/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
src/app/api/models/route.ts
Normal 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);
|
||||
}
|
||||
31
src/app/docs/[[...mdxPath]]/page.tsx
Normal 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
@@ -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
|
After Width: | Height: | Size: 25 KiB |
50
src/app/globals.css
Normal 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
@@ -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
@@ -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
@@ -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];
|
||||
}
|
||||
214
src/components/ambient-motion-glow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
src/components/docs-article.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
src/components/docs-breadcrumb.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
src/components/docs-code-block.tsx
Normal 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 "";
|
||||
}
|
||||
48
src/components/docs-mobile-nav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
109
src/components/docs-on-this-page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
261
src/components/docs-search.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
src/components/docs-shell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
107
src/components/docs-sidebar-nav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
src/components/docs-subnav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
52
src/components/hero-section.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
122
src/components/intelligent-report-grid.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
13
src/components/json-ld.tsx
Normal 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) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
109
src/components/model-library-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
69
src/components/model-marquee-strip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
127
src/components/models-square-client.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
src/components/news-cover-image.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
85
src/components/news-list-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
src/components/page-hero.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
110
src/components/particles-shell.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
36
src/components/report-agent-flow-visual.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
98
src/components/scroll-reveal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
95
src/components/section-contact.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
src/components/section-enterprise.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
15
src/components/section-intelligent-report.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
15
src/components/section-intro.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||