From fa7aedb8e7e811d1f7681c777f31d5914e19a1c8 Mon Sep 17 00:00:00 2001 From: rucky Date: Tue, 12 May 2026 09:58:25 +0800 Subject: [PATCH] feat: add localization and site settings --- API.md | 577 +++++++++++++++--- deploy-remote.sh | 2 +- prisma/schema.prisma | 48 +- src/app/(public)/addons/[slug]/page.tsx | 204 ++----- src/app/(public)/addons/page.tsx | 97 ++- src/app/(public)/articles/[slug]/page.tsx | 62 +- src/app/(public)/articles/page.tsx | 55 +- src/app/(public)/changelog/page.tsx | 99 +-- src/app/(public)/layout.tsx | 30 +- src/app/(public)/page.tsx | 111 ++-- .../(dashboard)/addons/[id]/edit/page.tsx | 18 +- src/app/admin/(dashboard)/releases/page.tsx | 62 +- src/app/admin/(dashboard)/settings/page.tsx | 405 +++++++++++- src/app/admin/(dashboard)/software/page.tsx | 58 +- src/app/api/addons/[id]/route.ts | 53 +- .../[id]/screenshots/[screenshotId]/route.ts | 39 ++ src/app/api/addons/[id]/screenshots/route.ts | 42 ++ src/app/api/addons/route.ts | 60 +- src/app/api/admin/site-settings/route.ts | 55 ++ src/app/api/articles/[id]/route.ts | 21 +- src/app/api/articles/route.ts | 33 +- src/app/api/gallery/[id]/route.ts | 1 + src/app/api/gallery/route.ts | 12 +- src/app/api/releases/[id]/route.ts | 85 +++ src/app/api/releases/route.ts | 47 +- src/app/api/software/[id]/route.ts | 35 +- src/app/api/software/[id]/versions/route.ts | 11 +- src/app/api/software/changelog/route.ts | 20 +- src/app/api/software/check-update/route.ts | 27 +- src/app/api/software/latest/route.ts | 20 +- src/app/api/software/route.ts | 40 +- .../software/versions/[versionId]/route.ts | 20 +- src/app/download/launcher/route.ts | 70 +++ src/app/globals.css | 189 ++++++ src/app/layout.tsx | 24 +- src/components/admin/AddonForm.tsx | 170 +++++- src/components/admin/AddonScreenshots.tsx | 280 +++++++++ src/components/admin/ArticleEditor.tsx | 190 ++++-- src/components/admin/MediaManager.tsx | 50 +- src/components/admin/ReleaseForm.tsx | 90 ++- src/components/admin/ReleasesTable.tsx | 379 ++++++++++++ src/components/admin/SoftwareEditForm.tsx | 76 ++- src/components/admin/SoftwareVersionForm.tsx | 52 +- src/components/admin/SoftwareVersionTable.tsx | 92 ++- src/components/public/AddonCard.tsx | 48 +- src/components/public/AddonDetail.tsx | 247 ++++++++ .../public/AddonsCategoryFilter.tsx | 49 ++ src/components/public/ArticleCard.tsx | 73 +++ src/components/public/ArticleDetail.tsx | 80 +++ src/components/public/BgmPlayer.tsx | 159 +++++ src/components/public/ChangelogTimeline.tsx | 138 +++++ src/components/public/Footer.tsx | 42 +- src/components/public/GameGallery.tsx | 22 +- src/components/public/HeroBanner.tsx | 22 +- src/components/public/LanguageSwitcher.tsx | 43 ++ src/components/public/Navbar.tsx | 24 +- src/components/public/ShutdownBanner.tsx | 338 ++++++++++ src/components/public/T.tsx | 23 + src/components/public/WowVersionSwitcher.tsx | 62 ++ src/i18n/LocaleProvider.tsx | 86 +++ src/i18n/WowVersionProvider.tsx | 68 +++ src/i18n/getLocale.ts | 30 + src/i18n/messages.ts | 201 ++++++ src/lib/api-locale.ts | 50 ++ src/lib/get-server-wow.ts | 18 + src/lib/site-settings.ts | 105 ++++ src/lib/wow-versions.ts | 70 +++ 67 files changed, 5221 insertions(+), 888 deletions(-) create mode 100644 src/app/api/addons/[id]/screenshots/[screenshotId]/route.ts create mode 100644 src/app/api/addons/[id]/screenshots/route.ts create mode 100644 src/app/api/admin/site-settings/route.ts create mode 100644 src/app/api/releases/[id]/route.ts create mode 100644 src/app/download/launcher/route.ts create mode 100644 src/components/admin/AddonScreenshots.tsx create mode 100644 src/components/admin/ReleasesTable.tsx create mode 100644 src/components/public/AddonDetail.tsx create mode 100644 src/components/public/AddonsCategoryFilter.tsx create mode 100644 src/components/public/ArticleCard.tsx create mode 100644 src/components/public/ArticleDetail.tsx create mode 100644 src/components/public/BgmPlayer.tsx create mode 100644 src/components/public/ChangelogTimeline.tsx create mode 100644 src/components/public/LanguageSwitcher.tsx create mode 100644 src/components/public/ShutdownBanner.tsx create mode 100644 src/components/public/T.tsx create mode 100644 src/components/public/WowVersionSwitcher.tsx create mode 100644 src/i18n/LocaleProvider.tsx create mode 100644 src/i18n/WowVersionProvider.tsx create mode 100644 src/i18n/getLocale.ts create mode 100644 src/i18n/messages.ts create mode 100644 src/lib/api-locale.ts create mode 100644 src/lib/get-server-wow.ts create mode 100644 src/lib/site-settings.ts create mode 100644 src/lib/wow-versions.ts diff --git a/API.md b/API.md index 025ab26..0ddadce 100644 --- a/API.md +++ b/API.md @@ -1,103 +1,224 @@ -# Nanami 启动器 API 文档 +# Nanami Web · Public API -> Base URL: `https://nanami.rucky.cn`(或对应部署地址) -> -> 标注说明:🆕 新增 | ✏️ 有变动 | 无标注为原有不变 +Bilingual (zh / en) public REST API for the Nanami Turtle WoW platform. + +> 标注:🆕 新增 · ✏️ 有变动 · 🌐 支持多语言 + +- **Base URL(生产环境):** `https://nanami.rucky.cn` +- **Base URL(直连 IP):** `http://120.77.146.152:3000` +- **Content-Type:** `application/json` +- **认证:** 公共读接口无需认证;POST/PUT/DELETE 写接口需要管理员 session(不属于公共 API 范围) --- -## 1. 检查更新 ✏️ +## 🎮 WoW 客户端版本(多版本支持) -检查指定软件是否有新版本。 +平台为 Turtle WoW **1.18** 与 **1.17** 两套客户端独立维护启动器、插件版本与发布渠道。每个 `Release` / `SoftwareVersion` 都打了 `wowVersion` 标签,且 `isLatest` 在 `(addon|software, wowVersion)` 维度内唯一 —— 同一插件可以同时存在「1.18 最新版」和「1.17 最新版」两条 latest。 + +### 如何选择 wow 版本 + +不同接口的解析策略略有差异: + +#### 列表 / 浏览类接口(addons / releases / software / changelog) + +| 来源 | 示例 | 优先级 | +|------|------|--------| +| 查询参数 `wow` | `?wow=1.18` 或 `?wow=1.17` | 1(最高) | +| 查询参数 `wowVersion` | `?wowVersion=1.17` | 2(别名) | +| Cookie `wow` | 浏览器侧由前台切换设置 | 3 | +| 默认 | — | `1.18` | + +这些接口让浏览器在导航过程中保留用户选择的 wow 频道(cookie 持久化)。多数列表型接口还支持 `?wow=all` 表示不过滤、返回所有版本。 + +#### 下载 / 自更新类接口(latest / check-update / download/launcher) + +| 来源 | 示例 | 优先级 | +|------|------|--------| +| 查询参数 `wow` | `?wow=1.18` | 1(最高) | +| 查询参数 `wowVersion` | `?wowVersion=1.17` | 2(别名) | +| 默认 | — | `1.18` | + +⚠️ **下载类接口故意不读 Cookie**。这是一个强约定:**URL 自己完全决定下载到的二进制**。 + +为什么这样设计: +1. `https://nanami.rucky.cn/download/launcher` 这种第三方嵌入直链,必须永远稳定指向 1.18 版本,不能被访客之前在前台切换过的 cookie 污染。 +2. 启动器自更新(`/api/software/check-update`)的频道由启动器自身声明(`?wow=1.17`),避免 1.18 客户端因为 cookie 串台拿到 1.17 的更新。 +3. 不论中英文(`lang`)切换,下载到的二进制都是同一个,只跟 `?wow=` 相关。 + +合法值:`1.18`、`1.17`。 + +### 响应字段 + +- 单条对象上含 `wowVersion`,标识该 release / version 所属的客户端版本 +- 列表型响应顶层含 `wowVersion` 字段,回显本次过滤值(`"all"` 表示未过滤) + +### 启动器自更新建议 + +启动器调用 `/api/software/check-update` 时务必带上 `wow=1.18` 或 `wow=1.17`,否则会按默认 `1.18` 返回 —— 1.17 客户端拿到 1.18 的更新就出问题了。 + +```bash +# 1.17 启动器自检更新(已安装 versionCode=1010) +curl 'https://nanami.rucky.cn/api/software/check-update?slug=nanami-launcher&versionCode=1010&wow=1.17' + +# 1.18 启动器 +curl 'https://nanami.rucky.cn/api/software/check-update?slug=nanami-launcher&versionCode=1010&wow=1.18' +``` + +--- + +## 🌐 国际化(i18n) + +所有公共读接口都支持中英双语,通过查询参数选择语言。 + +### 语言协商 + +| 来源 | 示例 | 优先级 | +|------|------|--------| +| 查询参数 `lang` | `?lang=en` 或 `?lang=zh` | 1(最高) | +| 查询参数 `locale` | `?locale=en` | 2(`lang` 别名) | +| `Accept-Language` 请求头 | `Accept-Language: en-US,en;q=0.9` | 3 | +| 默认 | — | `zh` | + +合法值:`zh`、`en`。其他值会被忽略,回退到默认。 + +### 响应字段约定 + +每条可翻译的文本字段,会同时返回三个键: + +| 键名 | 含义 | +|------|------| +| `name` / `summary` / `description` / `changelog` / `title` / `content` | 「规范字段」,按当前 lang 解析后的值 | +| `*Zh`(如 `nameZh`) | 原始中文 | +| `*En`(如 `nameEn`) | 原始英文 | + +响应顶层附带 `lang` 字段,回显本次实际响应使用的语言,便于客户端确认协商结果。 + +### 回退规则 + +请求 `lang=en` 时若 `*En` 字段为空,则规范字段回退到中文(保证客户端永远拿到非空内容)。`*Zh` / `*En` 始终是数据库存储的原始值(其中之一可能为空)。 + +### 兼容性 + +- **未传 `lang` 的旧客户端不受影响**:`name`、`summary`、`changelog` 等字段仍是中文,行为与改造前一致 +- 新增的 `*Zh` / `*En` 字段是增量字段,旧客户端可以安全忽略 + +--- + +## 1. 检查更新 ✏️ 🌐 🎮 ``` -GET /api/software/check-update?slug={slug}&versionCode={versionCode} +GET /api/software/check-update?slug={slug}&versionCode={n}&wow={wow}&lang={lang} ``` | 参数 | 类型 | 必填 | 说明 | |------|------|------|------| -| slug | string | 是 | 软件标识,如 `nanami-launcher` 或 `nanami-launcher-patch` | -| versionCode | number | 否 | 当前客户端版本号(整数),不传默认为 0 | +| slug | string | ✅ | 软件标识,例如 `nanami-launcher`、`nanami-launcher-patch` | +| versionCode | number | ❌ | 当前客户端版本号(整数),缺省为 0 | +| wow | string | ❌ | `1.18` / `1.17`,缺省 `1.18` | +| lang | string | ❌ | `zh` / `en`,缺省 `zh` | -**响应示例:** +**响应示例(wow=1.18, lang=en):** ```json { "hasUpdate": true, "forceUpdate": false, + "lang": "en", + "wowVersion": "1.18", "latest": { - "version": "1.3.0", - "versionCode": 130, - "changelog": "- 修复xxx\n- 新增xxx", - "downloadUrl": "https://nanami.rucky.cn/api/software/download/clxxx?source=launcher", - "fileSize": 52428800, - "minVersion": "1.0.0", - "createdAt": "2026-03-25T10:00:00.000Z" + "version": "1.0.15", + "versionCode": 1015, + "changelog": "Added Memorial Box feature\n…", + "changelogZh": "添加骨灰盒功能\n…", + "changelogEn": "Added Memorial Box feature\n…", + "downloadUrl": "https://nanami.rucky.cn/api/software/download/abc123?source=launcher", + "fileSize": 0, + "minVersion": null, + "wowVersion": "1.18", + "createdAt": "2026-04-28T14:40:30.844Z" } } ``` -**✏️ 变动说明**:返回的 `downloadUrl` 现在自动附带 `?source=launcher` 参数,启动器直接使用该 URL 下载即可,下载量会被单独统计为「客户端更新下载」,与网页端下载区分。启动器无需做任何额外处理。 +`forceUpdate=true` 仅当存在更新且最新版被标记为强制更新。`isLatest` 是 `(software, wowVersion)` 维度的 —— 1.18 与 1.17 各自有独立的 latest,互不影响。下载 URL 自动带 `source=launcher`,启动器更新下载会单独计数。 --- ## 2. 下载文件 ✏️ -根据版本 ID 下载文件。通常不需要手动拼接,直接使用 check-update 返回的 `downloadUrl` 即可。 - ``` GET /api/software/download/{versionId}?source={source} ``` | 参数 | 类型 | 必填 | 说明 | |------|------|------|------| -| versionId | string | 是 | 路径参数,版本记录 ID | -| source | string | 否 | 下载来源标识,传 `launcher` 表示客户端更新下载 | +| versionId | string | ✅ | 路径参数,版本记录 ID | +| source | string | ❌ | 下载来源标记,`launcher` 表示客户端自更新 | -**响应**: +**响应:** -- 本地文件:直接返回二进制流(`Content-Type: application/octet-stream`) -- 外链文件:302 重定向到外部 URL +- 本地文件:`Content-Type: application/octet-stream`,二进制流 +- 外链文件:HTTP 302 跳转 -**✏️ 变动说明**:新增 `source` 查询参数。当 `source=launcher` 时,下载量会同时计入总下载量和启动器更新下载量两个计数器。check-update 返回的 URL 已自动包含此参数,启动器无需手动处理。 +`source=launcher` 时,下载量计入「启动器更新下载」单独计数器。该参数已由 `check-update` 自动附加,启动器无需关心。 --- -## 3. 获取最新版本信息(网页用) - -获取 nanami-launcher 的最新版本信息或直接下载,主要给网页端使用。 +## 3. 获取最新启动器(网页用) 🌐 ``` -GET /api/software/latest?info=1&track=1 +GET /api/software/latest?info=1&track=1&lang={lang} ``` | 参数 | 类型 | 必填 | 说明 | |------|------|------|------| -| info | string | 否 | 传 `1` 返回 JSON 元数据;不传则直接下载文件 | -| track | string | 否 | 仅在 `info=1` 时生效,传 `1` 同时计数下载量 | +| info | string | ❌ | 传 `1` 返回 JSON 元数据;不传则直接返回二进制文件 | +| track | string | ❌ | 仅 `info=1` 时生效,传 `1` 同时累加下载计数 | +| lang | string | ❌ | `zh` / `en`,仅 `info=1` 时影响 changelog 字段 | -**响应示例(info=1):** +**响应示例(info=1, lang=en):** ```json { "available": true, - "version": "1.3.0", - "versionCode": 130, - "changelog": "...", - "fileSize": 52428800, - "createdAt": "2026-03-25T10:00:00.000Z", - "downloadUrl": "/api/software/download/clxxx", - "downloadType": "local" + "version": "1.0.15", + "versionCode": 1015, + "changelog": "Added Memorial Box feature\n…", + "changelogZh": "添加骨灰盒功能\n…", + "changelogEn": "Added Memorial Box feature\n…", + "fileSize": 0, + "createdAt": "2026-04-28T14:40:30.844Z", + "downloadUrl": "/api/software/download/abc123", + "downloadType": "url", + "lang": "en" } ``` -> 此接口固定查询 slug 为 `nanami-launcher` 的软件,启动器通常使用 check-update 接口而非此接口。 +--- + +## 4. 启动器友好直链 🆕 🎮 + +``` +GET /download/launcher?wow={wow} +``` + +外部网页可直接 `` 引用这个地址下载最新版启动器,等价于 `GET /api/software/latest`(无 `info=1`):本地文件直返,外链 302 跳转。 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| wow | string | ❌ | `1.18` / `1.17`,缺省 `1.18` | + +⚠️ **第三方链接安全**:这条 URL 是公开嵌入用的,**只看显式 `?wow=` 参数**,**不读用户 cookie**。意思是:即使用户之前在前台切到 1.17,再点这条不带 `?wow=` 的链接仍然会下载到 1.18。这样可以让站外的「下载启动器」按钮稳定指向 1.18。 + +```html +下载 Nanami 启动器(默认 WoW 1.18) +下载 Nanami 启动器(WoW 1.18) +下载 Nanami 启动器(WoW 1.17) +``` --- -## 4. 上报心跳(在线状态) 🆕 - -启动器定期调用此接口上报在线状态,建议每 **60 秒** 调用一次。超过 3 分钟无心跳视为离线。 +## 5. 上报心跳(在线状态) 🆕 ``` POST /api/launcher/heartbeat @@ -117,86 +238,350 @@ Content-Type: application/json | 字段 | 类型 | 必填 | 说明 | |------|------|------|------| -| deviceId | string | **是** | 设备唯一标识,建议使用机器码或 UUID 持久化存储 | -| os | string | 否 | 操作系统名称,如 `Windows`、`macOS`、`Linux` | -| osVersion | string | 否 | 系统版本号,如 `10.0.19045`、`14.3` | -| appVersion | string | 否 | 启动器版本号,如 `1.3.0` | +| deviceId | string | ✅ | 设备唯一标识,建议机器码或 UUID 并持久化 | +| os | string | ❌ | 操作系统,例如 `Windows` | +| osVersion | string | ❌ | 系统版本号 | +| appVersion | string | ❌ | 启动器版本 | -**响应:** +**响应:** `{ "ok": true }` -```json -{ "ok": true } -``` - -**实现建议**: -- 启动器启动时立即发送一次心跳,之后每 60 秒发送一次 -- `deviceId` 需要在客户端持久化保存,确保同一台机器始终使用相同 ID -- IP 地址由服务端自动获取,无需客户端上报 -- 网络失败时静默忽略即可,不影响启动器正常功能 +实现建议:启动时发送一次心跳,之后每 60 秒一次;超过 3 分钟未心跳视为离线。 --- -## 5. 历史更新日志 🆕 - -查询指定软件的所有版本历史和更新日志,按版本号倒序排列。 +## 6. 历史更新日志 ✏️ 🌐 🎮 ``` -GET /api/software/changelog?slug={slug} +GET /api/software/changelog?slug={slug}&wow={wow}&lang={lang} ``` | 参数 | 类型 | 必填 | 说明 | |------|------|------|------| -| slug | string | 是 | 软件标识,如 `nanami-launcher` 或 `nanami-launcher-patch` | +| slug | string | ✅ | 软件标识 | +| wow | string | ❌ | `1.18` / `1.17`(缺省 `1.18`),传 `all` 不过滤 | +| lang | string | ❌ | `zh` / `en` | -**响应示例:** +**响应示例(wow=1.18, lang=en):** ```json { - "name": "Nanami 启动器(全量包)", + "name": "Nanami Launcher", + "nameZh": "Nanami 启动器", + "nameEn": "Nanami Launcher", "slug": "nanami-launcher", + "lang": "en", + "wowVersion": "1.18", "versions": [ { - "version": "1.3.0", - "versionCode": 130, - "changelog": "- 新增xxx\n- 修复xxx", - "fileSize": 52428800, + "version": "1.0.15", + "versionCode": 1015, + "changelog": "Added Memorial Box feature\n…", + "changelogZh": "添加骨灰盒功能\n…", + "changelogEn": "Added Memorial Box feature\n…", + "fileSize": 0, "isLatest": true, "forceUpdate": false, - "createdAt": "2026-03-25T10:00:00.000Z" - }, - { - "version": "1.2.0", - "versionCode": 120, - "changelog": "- 优化xxx", - "fileSize": 50331648, - "isLatest": false, - "forceUpdate": false, - "createdAt": "2026-03-10T08:00:00.000Z" + "wowVersion": "1.18", + "createdAt": "2026-04-28T14:40:30.844Z" } ] } ``` -**响应字段说明:** +--- -| 字段 | 类型 | 说明 | -|------|------|------| -| versions[].version | string | 版本号 | -| versions[].versionCode | number | 版本号(整数),用于比较大小 | -| versions[].changelog | string | 更新日志内容 | -| versions[].fileSize | number | 文件大小(字节) | -| versions[].isLatest | boolean | 是否为当前最新版本 | -| versions[].forceUpdate | boolean | 是否为强制更新版本 | -| versions[].createdAt | string | 发布时间(ISO 8601) | +## 7. 软件列表 / 详情 🆕 🌐 + +### `GET /api/software?lang={lang}` + +返回全部软件包及其当前最新版本。 + +**响应示例:** + +```json +[ + { + "id": "...", + "slug": "nanami-launcher", + "name": "Nanami Launcher", + "nameZh": "Nanami 启动器", + "nameEn": "Nanami Launcher", + "description": "Nanami addon launcher…", + "descriptionZh": "Nanami 插件启动器…", + "descriptionEn": "Nanami addon launcher…", + "createdAt": "...", + "updatedAt": "...", + "versions": [{ "version": "1.0.15", "isLatest": true, "...": "..." }], + "_count": { "versions": 48 }, + "lang": "en" + } +] +``` + +### `GET /api/software/{idOrSlug}?lang={lang}` + +返回单个软件包及其全部历史版本(versions 按 versionCode 倒序)。 + +--- + +## 8. 插件列表 / 详情 🆕 🌐 🎮 + +### `GET /api/addons` + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| lang | string | ❌ | `zh` / `en` | +| wow | string | ❌ | `1.18` / `1.17` —— 仅返回此 WoW 版本下的 latest release;传 `all` 表示不过滤 | +| published | string | ❌ | 默认 `true`;传 `false` 包括草稿 | +| category | string | ❌ | 按分类过滤 | +| search | string | ❌ | 名称 / 简介模糊匹配,中英都搜 | + +**响应示例:** + +```json +[ + { + "id": "...", + "slug": "nanami-ui", + "name": "Nanami-UI", + "nameZh": "Nanami-UI", + "nameEn": "Nanami-UI", + "summary": "All-in-one UI replacement…", + "summaryZh": "一款为乌龟服精心打造的全功能界面…", + "summaryEn": "All-in-one UI replacement…", + "description": "# Nanami-UI\n…", + "descriptionZh": "# Nanami-UI\n…", + "descriptionEn": "# Nanami-UI\n…", + "iconUrl": "/uploads/...", + "category": "ui", + "published": true, + "totalDownloads": 1234, + "releases": [ + { + "id": "...", + "version": "0.9.14", + "changelog": "See the launcher changelog for details.", + "changelogZh": "详见启动器更新日志", + "changelogEn": "See the launcher changelog for details.", + "downloadType": "local", + "filePath": "/uploads/...", + "isLatest": true + } + ], + "screenshots": [], + "_count": { "releases": 5 }, + "lang": "en" + } +] +``` + +### `GET /api/addons/{idOrSlug}?lang={lang}` + +返回单个插件及其所有 releases(按 createdAt 倒序)。 + +--- + +## 9. 插件版本(Releases) 🆕 🌐 🎮 + +### `GET /api/releases?addonId={id}&wow={wow}&lang={lang}` + +列出 release。 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| addonId | string | ❌ | 限定某个插件;缺省返回全平台所有插件的 release | +| wow | string | ❌ | `1.18` / `1.17`,缺省 `1.18`;传 `all` 不过滤 | +| lang | string | ❌ | `zh` / `en` | + +**响应示例:** + +```json +[ + { + "id": "...", + "version": "1.2.5", + "changelog": "2026.04.03 update:\n…", + "changelogZh": "2026.04.03更新:\n…", + "changelogEn": "2026.04.03 update:\n…", + "downloadType": "local", + "filePath": "/uploads/...", + "externalUrl": null, + "gameVersion": "1.18.1", + "downloadCount": 0, + "isLatest": true, + "createdAt": "...", + "addon": { + "id": "...", + "slug": "instancejournal", + "name": "InstanceJournal", + "nameZh": "InstanceJournal", + "nameEn": "InstanceJournal" + }, + "lang": "en" + } +] +``` + +### `GET /api/download/{releaseId}` + +直接下载 release 文件(本地文件返二进制,外链 302 跳转),同时累加 release 下载量。 + +--- + +## 10. 公告文章 🆕 🌐 + +### `GET /api/articles` + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| lang | string | ❌ | `zh` / `en` | +| published | string | ❌ | 默认 `true` | +| limit | number | ❌ | 限制条数 | + +**响应:** + +```json +[ + { + "id": "...", + "slug": "shutdown-announcement", + "title": "Server shutdown", + "titleZh": "关服公告", + "titleEn": "Server shutdown", + "summary": "...", + "summaryZh": "...", + "summaryEn": "...", + "content": "Markdown content…", + "contentZh": "Markdown 内容…", + "contentEn": "Markdown content…", + "coverImage": "/uploads/…", + "published": true, + "createdAt": "...", + "updatedAt": "...", + "lang": "en" + } +] +``` + +### `GET /api/articles/{idOrSlug}?lang={lang}` + +返回单篇文章。字段同上。 + +--- + +## 11. 站点资源 🌐 + +### `GET /api/banners?enabled=1` + +首页 Hero Banner 图。无可翻译文本,`lang` 参数不影响响应。 + +```json +[{ "id": "...", "imageUrl": "/uploads/...", "sortOrder": 0, "enabled": true }] +``` + +### `GET /api/gallery?enabled=1&lang={lang}` + +截图画廊。`title` 字段双语。 + +```json +[ + { + "id": "...", + "imageUrl": "/uploads/...", + "title": "Nameplate showcase", + "titleZh": "姓名版展示", + "titleEn": "Nameplate showcase", + "sortOrder": 0, + "enabled": true + } +] +``` + +--- + +## 12. 服务器时间 + +``` +GET /api/server-time +``` + +```json +{ "serverTime": "2026-04-29T08:00:00.000Z", "epochMs": 1798156800000 } +``` + +供客户端校时与首页倒计时使用。 + +--- + +## 错误处理 + +所有失败响应都返回统一结构: + +```json +{ "error": "Slug already exists" } +``` + +| HTTP | 含义 | +|------|------| +| 400 | 参数缺失或格式错误 | +| 401 | 未授权(写接口) | +| 404 | 资源不存在 | +| 409 | 冲突(如 slug 重复) | +| 500 | 服务端错误 | --- ## 接口一览 -| 方法 | 路径 | 状态 | 用途 | -|------|------|------|------| -| GET | `/api/software/check-update` | ✏️ 变动 | 检查更新,downloadUrl 已含 source 标记 | -| GET | `/api/software/download/{id}` | ✏️ 变动 | 下载文件,支持 source 参数区分来源 | -| GET | `/api/software/latest` | 无变动 | 获取最新版信息/下载(网页用) | -| GET | `/api/software/changelog` | 🆕 新增 | 查询历史更新日志 | -| POST | `/api/launcher/heartbeat` | 🆕 新增 | 上报在线状态 | +| 方法 | 路径 | 状态 | 多语言 | WoW 过滤 | 用途 | +|------|------|------|--------|----------|------| +| GET | `/api/software/check-update` | ✏️ | ✅ | ✅ | 启动器自更新检查 | +| GET | `/api/software/download/{id}` | ✏️ | ❌ | ❌ | 下载启动器文件(按 versionId 精确) | +| GET | `/api/software/latest` | ✏️ | ✅ | ✅ | 最新启动器信息 / 下载(网页用) | +| GET | `/download/launcher` | 🆕 | ❌ | ✅ | 友好直链:按 wow 始终下载最新 | +| POST | `/api/launcher/heartbeat` | — | ❌ | ❌ | 上报启动器在线状态 | +| GET | `/api/software/changelog` | ✏️ | ✅ | ✅ | 启动器历史 changelog | +| GET | `/api/software` | 🆕 | ✅ | ✅ | 软件列表 | +| GET | `/api/software/{idOrSlug}` | 🆕 | ✅ | ✅ | 单个软件 + 全部版本 | +| GET | `/api/addons` | 🆕 | ✅ | ✅ | 插件列表 | +| GET | `/api/addons/{idOrSlug}` | 🆕 | ✅ | ✅ | 单个插件 + 所有 release | +| GET | `/api/releases` | 🆕 | ✅ | ✅ | 插件版本列表 | +| GET | `/api/download/{id}` | — | ❌ | ❌ | 下载插件 release 文件(按 releaseId) | +| GET | `/api/articles` | 🆕 | ✅ | ❌ | 公告列表 | +| GET | `/api/articles/{idOrSlug}` | 🆕 | ✅ | ❌ | 单篇公告 | +| GET | `/api/banners` | 🆕 | ❌ | ❌ | Hero Banner 图 | +| GET | `/api/gallery` | 🆕 | ✅ | ❌ | 截图画廊 | +| GET | `/api/server-time` | — | — | ❌ | 服务器时间 | + +--- + +## 调用示例 + +```bash +# 1.18 启动器最新版(英文 changelog) +curl 'https://nanami.rucky.cn/api/software/latest?info=1&lang=en&wow=1.18' + +# 1.17 启动器最新版 +curl 'https://nanami.rucky.cn/api/software/latest?info=1&wow=1.17' + +# 1.18 启动器全部历史 changelog(中文) +curl 'https://nanami.rucky.cn/api/software/changelog?slug=nanami-launcher&wow=1.18&lang=zh' + +# 1.17 启动器自更新检查(已安装 versionCode=1010) +curl 'https://nanami.rucky.cn/api/software/check-update?slug=nanami-launcher&versionCode=1010&wow=1.17' + +# 1.17 客户端的 UI 类插件(英文) +curl 'https://nanami.rucky.cn/api/addons?lang=en&category=ui&wow=1.17' + +# 不限 WoW 版本,列出所有 release +curl 'https://nanami.rucky.cn/api/releases?wow=all' + +# 1.18 启动器友好直链 +curl -L -o nanami-launcher-1.18.exe 'https://nanami.rucky.cn/download/launcher?wow=1.18' + +# 1.17 启动器友好直链 +curl -L -o nanami-launcher-1.17.exe 'https://nanami.rucky.cn/download/launcher?wow=1.17' + +# 通过 Accept-Language 协商语言 +curl -H 'Accept-Language: en-US,en;q=0.9' 'https://nanami.rucky.cn/api/articles' +``` diff --git a/deploy-remote.sh b/deploy-remote.sh index be0f29d..441fa10 100755 --- a/deploy-remote.sh +++ b/deploy-remote.sh @@ -87,7 +87,7 @@ echo "=> [6/6] 服务器安装生产依赖 & 同步数据库 & 重启..." run_ssh "cd ${REMOTE_DIR} && \ npm install --omit=dev --registry=https://registry.npmjs.org && \ npx prisma generate && \ - npx prisma db push && \ + npx prisma db push --accept-data-loss && \ mkdir -p uploads && \ ln -sf ${REMOTE_DIR}/uploads ${REMOTE_DIR}/.next/standalone/uploads && \ ln -sf ${REMOTE_DIR}/.env ${REMOTE_DIR}/.next/standalone/.env && \ diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d47c99b..c54eae4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -17,9 +17,12 @@ model Admin { model Addon { id String @id @default(cuid()) name String + nameEn String @default("") slug String @unique summary String + summaryEn String @default("") description String @db.Text + descriptionEn String @default("") @db.Text iconUrl String? category String @default("general") published Boolean @default(false) @@ -38,16 +41,20 @@ model Release { addonId String version String changelog String @db.Text + changelogEn String @default("") @db.Text downloadType String @default("local") filePath String? externalUrl String? gameVersion String @default("") + // Turtle WoW client major version this build targets: "1.18" or "1.17" + wowVersion String @default("1.18") downloadCount Int @default(0) isLatest Boolean @default(false) createdAt DateTime @default(now()) addon Addon @relation(fields: [addonId], references: [id], onDelete: Cascade) @@index([addonId]) + @@index([addonId, wowVersion, isLatest]) } model Screenshot { @@ -61,13 +68,15 @@ model Screenshot { } model Software { - id String @id @default(cuid()) - name String - slug String @unique - description String @default("") - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - versions SoftwareVersion[] + id String @id @default(cuid()) + name String + nameEn String @default("") + slug String @unique + description String @default("") + descriptionEn String @default("") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + versions SoftwareVersion[] } model SoftwareVersion { @@ -76,6 +85,7 @@ model SoftwareVersion { version String versionCode Int changelog String @db.Text + changelogEn String @default("") @db.Text downloadType String @default("local") filePath String? externalUrl String? @@ -85,11 +95,14 @@ model SoftwareVersion { isLatest Boolean @default(false) forceUpdate Boolean @default(false) minVersion String? + // Turtle WoW client major version this build targets: "1.18" or "1.17" + wowVersion String @default("1.18") createdAt DateTime @default(now()) software Software @relation(fields: [softwareId], references: [id], onDelete: Cascade) - @@unique([softwareId, version]) + @@unique([softwareId, version, wowVersion]) @@index([softwareId]) + @@index([softwareId, wowVersion, isLatest]) } model BannerImage { @@ -104,6 +117,7 @@ model GalleryImage { id String @id @default(cuid()) imageUrl String title String @default("") + titleEn String @default("") sortOrder Int @default(0) enabled Boolean @default(true) createdAt DateTime @default(now()) @@ -112,9 +126,12 @@ model GalleryImage { model Article { id String @id @default(cuid()) title String + titleEn String @default("") slug String @unique summary String @default("") + summaryEn String @default("") content String @db.Text + contentEn String @default("") @db.Text coverImage String? published Boolean @default(false) createdAt DateTime @default(now()) @@ -148,3 +165,18 @@ model LauncherOnline { @@index([lastSeen]) } + +model SiteSetting { + id Int @id @default(1) + grayscale Boolean @default(false) + shutdownBannerEnabled Boolean @default(false) + shutdownTitle String @default("") + shutdownTitleEn String @default("") + shutdownSubtitle String @default("") + shutdownSubtitleEn String @default("") + shutdownAt DateTime? + bgmUrl String @default("") + bgmAutoplay Boolean @default(false) + bgmVolume Int @default(50) + updatedAt DateTime @updatedAt +} diff --git a/src/app/(public)/addons/[slug]/page.tsx b/src/app/(public)/addons/[slug]/page.tsx index 8aa8092..3a5d163 100644 --- a/src/app/(public)/addons/[slug]/page.tsx +++ b/src/app/(public)/addons/[slug]/page.tsx @@ -1,10 +1,7 @@ import { notFound } from "next/navigation"; -import Image from "next/image"; -import Link from "next/link"; import { prisma } from "@/lib/db"; -import { Download, Package, Calendar, Tag, ArrowLeft } from "lucide-react"; -import { DownloadButton } from "@/components/public/DownloadButton"; -import { MarkdownContent } from "@/components/public/MarkdownContent"; +import { AddonDetail } from "@/components/public/AddonDetail"; +import { getServerWowVersion } from "@/lib/get-server-wow"; export const dynamic = "force-dynamic"; @@ -33,177 +30,50 @@ export default async function AddonDetailPage({ params: Promise<{ slug: string }>; }) { const { slug } = await params; + const wowVersion = await getServerWowVersion(); const addon = await prisma.addon.findUnique({ where: { slug }, include: { - releases: { orderBy: { createdAt: "desc" } }, + releases: { + where: { wowVersion }, + orderBy: { createdAt: "desc" }, + }, screenshots: { orderBy: { sortOrder: "asc" } }, }, }); if (!addon || !addon.published) notFound(); - const latestRelease = addon.releases.find((r) => r.isLatest); - - const categoryLabels: Record = { - general: "通用", gameplay: "游戏玩法", ui: "界面增强", - combat: "战斗", raid: "团队副本", pvp: "PvP", - tradeskill: "专业技能", utility: "实用工具", - }; - return ( -
- - - 返回插件列表 - - - {/* Header */} -
- {addon.iconUrl ? ( -
- {addon.name} -
- ) : ( -
- -
- )} -
-

- {addon.name} -

-

- {addon.summary} -

-
- - {categoryLabels[addon.category] || addon.category} - - - - {addon.totalDownloads.toLocaleString()} 次下载 - - {latestRelease && ( - - - v{latestRelease.version} - - )} -
-
- {latestRelease && ( -
- -
- )} -
- -
- -
- {/* Description */} -
-
-

介绍

- -
- - {/* Screenshots */} - {addon.screenshots.length > 0 && ( -
-

截图

-
- {addon.screenshots.map((ss) => ( -
- Screenshot -
- ))} -
-
- )} -
- - {/* Sidebar - Releases */} -
-
-

- 版本历史 -

-

- 共 {addon.releases.length} 个版本 -

-
- {addon.releases.map((release) => ( -
-
-
- - v{release.version} - - {release.isLatest && ( - - 最新 - - )} -
- -
- {release.gameVersion && ( -

- WoW {release.gameVersion} -

- )} -
- - - {new Date(release.createdAt).toLocaleDateString("zh-CN")} - - - - {release.downloadCount} - -
- {release.changelog && ( -

- {release.changelog} -

- )} -
- ))} - {addon.releases.length === 0 && ( -

暂无版本发布

- )} -
-
-
-
-
+ ({ + id: r.id, + version: r.version, + changelog: r.changelog, + changelogEn: r.changelogEn, + gameVersion: r.gameVersion, + wowVersion: r.wowVersion, + downloadCount: r.downloadCount, + isLatest: r.isLatest, + createdAt: r.createdAt.toISOString(), + })), + screenshots: addon.screenshots.map((s) => ({ + id: s.id, + imageUrl: s.imageUrl, + })), + }} + /> ); } diff --git a/src/app/(public)/addons/page.tsx b/src/app/(public)/addons/page.tsx index 8c4ba74..a5b2aa3 100644 --- a/src/app/(public)/addons/page.tsx +++ b/src/app/(public)/addons/page.tsx @@ -1,21 +1,13 @@ import { prisma } from "@/lib/db"; import { AddonCard } from "@/components/public/AddonCard"; +import { AddonsCategoryFilter } from "@/components/public/AddonsCategoryFilter"; +import { T } from "@/components/public/T"; +import { getServerWowVersion } from "@/lib/get-server-wow"; import Link from "next/link"; import { Package, Search, ChevronLeft, ChevronRight } from "lucide-react"; export const dynamic = "force-dynamic"; -const categoryLabels: Record = { - general: "通用", - gameplay: "游戏玩法", - ui: "界面增强", - combat: "战斗", - raid: "团队副本", - pvp: "PvP", - tradeskill: "专业技能", - utility: "实用工具", -}; - export const metadata = { title: "插件列表 - Nanami", description: "浏览和下载 Turtle WoW 插件", @@ -32,13 +24,16 @@ export default async function AddonsPage({ }) { const { category, search, page: pageStr } = await searchParams; const page = Math.max(1, parseInt(pageStr || "1", 10) || 1); + const wowVersion = await getServerWowVersion(); const where: Record = { published: true }; if (category) where.category = category; if (search) { where.OR = [ { name: { contains: search, mode: "insensitive" } }, + { nameEn: { contains: search, mode: "insensitive" } }, { summary: { contains: search, mode: "insensitive" } }, + { summaryEn: { contains: search, mode: "insensitive" } }, ]; } @@ -47,8 +42,12 @@ export default async function AddonsPage({ where, include: { releases: { - where: { isLatest: true }, - select: { version: true }, + where: { isLatest: true, wowVersion }, + select: { version: true, wowVersion: true }, + }, + screenshots: { + orderBy: { sortOrder: "asc" }, + take: 1, }, }, orderBy: { totalDownloads: "desc" }, @@ -79,39 +78,21 @@ export default async function AddonsPage({

- 插件列表 +

- 浏览和下载 World of Warcraft 插件 +

{/* Category Filter */} -
- - 全部 - - {categories.map((cat) => ( - - {categoryLabels[cat.category] || cat.category} ({cat._count.id}) - - ))} -
+ ({ + slug: c.category, + count: c._count.id, + }))} + /> {/* Addon Grid */} {addons.length > 0 ? ( @@ -168,18 +149,32 @@ export default async function AddonsPage({ )} ) : ( -
-
- -
-

- {search ? `没有找到"${search}"相关的插件` : "暂无插件"} -

-

- {search ? "尝试更换关键词搜索" : "稍后再来查看吧"} -

-
+ )} ); } + +function AddonsEmpty({ search }: { search: string | null }) { + return ( +
+
+ +
+

+ {search ? ( + + ) : ( + + )} +

+

+ {search ? ( + + ) : ( + + )} +

+
+ ); +} diff --git a/src/app/(public)/articles/[slug]/page.tsx b/src/app/(public)/articles/[slug]/page.tsx index 326f39d..4a75b19 100644 --- a/src/app/(public)/articles/[slug]/page.tsx +++ b/src/app/(public)/articles/[slug]/page.tsx @@ -1,9 +1,6 @@ import { notFound } from "next/navigation"; -import Link from "next/link"; -import Image from "next/image"; import { prisma } from "@/lib/db"; -import { Calendar, ArrowLeft } from "lucide-react"; -import { MarkdownContent } from "@/components/public/MarkdownContent"; +import { ArticleDetail } from "@/components/public/ArticleDetail"; export const dynamic = "force-dynamic"; @@ -44,52 +41,15 @@ export default async function ArticleDetailPage({ if (!article) notFound(); return ( -
- - - 返回文章列表 - - -

- {article.title} -

- -
- - {new Date(article.createdAt).toLocaleDateString("zh-CN", { - year: "numeric", - month: "long", - day: "numeric", - })} -
- - {article.coverImage && ( -
- {article.title} -
- )} - - - -
- - - 返回文章列表 - -
-
+ ); } diff --git a/src/app/(public)/articles/page.tsx b/src/app/(public)/articles/page.tsx index b346ae1..4adbae8 100644 --- a/src/app/(public)/articles/page.tsx +++ b/src/app/(public)/articles/page.tsx @@ -1,7 +1,8 @@ import Link from "next/link"; -import Image from "next/image"; import { prisma } from "@/lib/db"; -import { Calendar, ChevronLeft, ChevronRight } from "lucide-react"; +import { ChevronLeft, ChevronRight } from "lucide-react"; +import { ArticleCard } from "@/components/public/ArticleCard"; +import { T } from "@/components/public/T"; export const metadata = { title: "公告与文章", @@ -35,49 +36,33 @@ export default async function ArticlesPage({ return (

- 公告与文章 +

- 最新动态与更新公告 +

{articles.length === 0 ? ( -

暂无文章

+

+ +

) : ( <>
{articles.map((article) => ( - - {article.coverImage && ( -
- {article.title} -
- )} -
-

- {article.title} -

- {article.summary && ( -

- {article.summary} -

- )} -
- - {new Date(article.createdAt).toLocaleDateString("zh-CN")} -
-
- + article={{ + id: article.id, + slug: article.slug, + title: article.title, + titleEn: article.titleEn, + summary: article.summary, + summaryEn: article.summaryEn, + coverImage: article.coverImage, + createdAt: article.createdAt.toISOString(), + }} + /> ))}
diff --git a/src/app/(public)/changelog/page.tsx b/src/app/(public)/changelog/page.tsx index 805d292..1694946 100644 --- a/src/app/(public)/changelog/page.tsx +++ b/src/app/(public)/changelog/page.tsx @@ -1,5 +1,6 @@ import { prisma } from "@/lib/db"; -import { Download, Calendar, Tag } from "lucide-react"; +import { ChangelogTimeline } from "@/components/public/ChangelogTimeline"; +import { getServerWowVersion } from "@/lib/get-server-wow"; export const dynamic = "force-dynamic"; @@ -11,94 +12,30 @@ export const metadata = { export const revalidate = 120; export default async function ChangelogPage() { + const wowVersion = await getServerWowVersion(); const software = await prisma.software.findUnique({ where: { slug: "nanami-launcher" }, include: { versions: { + where: { wowVersion }, orderBy: { versionCode: "desc" }, }, }, }); - const versions = software?.versions ?? []; + const versions = (software?.versions ?? []).map((v) => ({ + id: v.id, + version: v.version, + versionCode: v.versionCode, + changelog: v.changelog, + changelogEn: v.changelogEn, + fileSize: v.fileSize, + isLatest: v.isLatest, + forceUpdate: v.forceUpdate, + wowVersion: v.wowVersion, + downloadCount: v.downloadCount, + createdAt: v.createdAt.toISOString(), + })); - return ( -
-

- 版本历史 -

-

- Nanami 启动器更新日志 -

- - {versions.length === 0 ? ( -

暂无版本记录

- ) : ( -
- {/* Timeline line */} -
- -
- {versions.map((v, idx) => ( -
- {/* Timeline dot */} -
- -
- {/* Header */} -
-

- v{v.version} -

- {v.isLatest && ( - - 最新版本 - - )} - {v.forceUpdate && ( - - 强制更新 - - )} -
- - {/* Meta */} -
- - - {new Date(v.createdAt).toLocaleDateString("zh-CN")} - - - - Build {v.versionCode} - - - - {v.downloadCount.toLocaleString()} 次下载 - - {v.fileSize > 0 && ( - - {(v.fileSize / 1024 / 1024).toFixed(1)} MB - - )} -
- - {/* Changelog */} -
- {v.changelog || "无更新说明"} -
-
-
- ))} -
-
- )} -
- ); + return ; } diff --git a/src/app/(public)/layout.tsx b/src/app/(public)/layout.tsx index 2106199..69399e6 100644 --- a/src/app/(public)/layout.tsx +++ b/src/app/(public)/layout.tsx @@ -1,18 +1,34 @@ import { Navbar } from "@/components/public/Navbar"; import { Footer } from "@/components/public/Footer"; import { PageTracker } from "@/components/public/PageTracker"; +import { BgmPlayer } from "@/components/public/BgmPlayer"; +import { getSiteSettings } from "@/lib/site-settings"; -export default function PublicLayout({ +export default async function PublicLayout({ children, }: { children: React.ReactNode; }) { + const settings = await getSiteSettings(); return ( -
- -
{children}
-
- -
+ <> +
+ +
{children}
+
+ +
+ {settings.bgmUrl && ( + + )} + ); } diff --git a/src/app/(public)/page.tsx b/src/app/(public)/page.tsx index fa51241..ebc78a8 100644 --- a/src/app/(public)/page.tsx +++ b/src/app/(public)/page.tsx @@ -1,24 +1,35 @@ import Link from "next/link"; -import Image from "next/image"; import { prisma } from "@/lib/db"; import { Button } from "@/components/ui/button"; import { AddonCard } from "@/components/public/AddonCard"; import { HeroBanner } from "@/components/public/HeroBanner"; import { GameGallery } from "@/components/public/GameGallery"; -import { Sparkles, Shield, Zap, Calendar } from "lucide-react"; +import { ShutdownBanner } from "@/components/public/ShutdownBanner"; +import { T } from "@/components/public/T"; +import { ArticleCard } from "@/components/public/ArticleCard"; +import { getSiteSettings } from "@/lib/site-settings"; +import { getServerWowVersion } from "@/lib/get-server-wow"; +import { Sparkles, Shield, Zap } from "lucide-react"; export const dynamic = "force-dynamic"; export default async function HomePage() { + const siteSettings = await getSiteSettings(); + const wowVersion = await getServerWowVersion(); const [featuredAddons, launcher, launcherDownloads, banners, galleryImages, latestArticles] = await Promise.all([ prisma.addon.findMany({ where: { published: true }, include: { releases: { - where: { isLatest: true }, + where: { isLatest: true, wowVersion }, select: { version: true }, }, + screenshots: { + orderBy: { sortOrder: "asc" }, + take: 1, + select: { imageUrl: true }, + }, }, orderBy: { totalDownloads: "desc" }, take: 6, @@ -27,13 +38,13 @@ export default async function HomePage() { where: { slug: "nanami-launcher" }, include: { versions: { - where: { isLatest: true }, + where: { isLatest: true, wowVersion }, take: 1, }, }, }), prisma.softwareVersion.aggregate({ - where: { software: { slug: "nanami-launcher" } }, + where: { software: { slug: "nanami-launcher" }, wowVersion }, _sum: { downloadCount: true }, }), prisma.bannerImage.findMany({ @@ -44,7 +55,7 @@ export default async function HomePage() { prisma.galleryImage.findMany({ where: { enabled: true }, orderBy: { sortOrder: "asc" }, - select: { imageUrl: true, title: true }, + select: { imageUrl: true, title: true, titleEn: true }, }), prisma.article.findMany({ where: { published: true }, @@ -58,6 +69,25 @@ export default async function HomePage() { return ( <> + {siteSettings.shutdownBannerEnabled && siteSettings.shutdownAt && ( + + )} + -

深度适配

+

+ +

- 专为乌龟服 1.18.0 打造,兼容自定义内容与新种族,稳定流畅 +

@@ -85,10 +117,10 @@ export default async function HomePage() {

- 一键安装管理 +

- 通过 Nanami 启动器自动安装、更新,告别手动拖拽文件夹 +

@@ -96,10 +128,10 @@ export default async function HomePage() {

- 内置 AI 翻译 +

- 自带智能翻译引擎,轻松畅玩英文服务器,语言不再是障碍 +

@@ -111,48 +143,33 @@ export default async function HomePage() {
-

最新公告

+

+ +

{latestArticles.map((article) => ( - - {article.coverImage && ( -
- {article.title} -
- )} -
-

- {article.title} -

- {article.summary && ( -

- {article.summary} -

- )} -
- - {new Date(article.createdAt).toLocaleDateString("zh-CN")} -
-
- + variant="compact" + article={{ + id: article.id, + slug: article.slug, + title: article.title, + titleEn: article.titleEn, + summary: article.summary, + summaryEn: article.summaryEn, + coverImage: article.coverImage, + createdAt: article.createdAt.toISOString(), + }} + /> ))}
@@ -164,13 +181,15 @@ export default async function HomePage() {
-

热门插件

+

+ +

diff --git a/src/app/admin/(dashboard)/addons/[id]/edit/page.tsx b/src/app/admin/(dashboard)/addons/[id]/edit/page.tsx index 2178fa3..7c3c054 100644 --- a/src/app/admin/(dashboard)/addons/[id]/edit/page.tsx +++ b/src/app/admin/(dashboard)/addons/[id]/edit/page.tsx @@ -1,6 +1,8 @@ import { notFound } from "next/navigation"; import { prisma } from "@/lib/db"; import { AddonForm } from "@/components/admin/AddonForm"; +import { AddonScreenshots } from "@/components/admin/AddonScreenshots"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; export const dynamic = "force-dynamic"; @@ -10,7 +12,10 @@ export default async function EditAddonPage({ params: Promise<{ id: string }>; }) { const { id } = await params; - const addon = await prisma.addon.findUnique({ where: { id } }); + const addon = await prisma.addon.findUnique({ + where: { id }, + include: { screenshots: { orderBy: { sortOrder: "asc" } } }, + }); if (!addon) notFound(); @@ -18,6 +23,17 @@ export default async function EditAddonPage({

编辑插件

+ + + 说明截图 + + + + +
); } diff --git a/src/app/admin/(dashboard)/releases/page.tsx b/src/app/admin/(dashboard)/releases/page.tsx index 82bb6ec..01d2c75 100644 --- a/src/app/admin/(dashboard)/releases/page.tsx +++ b/src/app/admin/(dashboard)/releases/page.tsx @@ -1,16 +1,8 @@ import Link from "next/link"; import { prisma } from "@/lib/db"; import { Button } from "@/components/ui/button"; -import { Badge } from "@/components/ui/badge"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; import { Plus } from "lucide-react"; +import { ReleasesTable } from "@/components/admin/ReleasesTable"; export const dynamic = "force-dynamic"; @@ -31,57 +23,7 @@ export default async function AdminReleasesPage() {
- - - - 插件 - 版本 - 游戏版本 - 下载方式 - 下载量 - 发布时间 - 状态 - - - - {releases.length === 0 ? ( - - - 暂无版本发布 - - - ) : ( - releases.map((release) => ( - - - {release.addon.name} - - v{release.version} - - {release.gameVersion || "-"} - - - - {release.downloadType === "local" ? "本地文件" : "外部链接"} - - - {release.downloadCount} - - {new Date(release.createdAt).toLocaleDateString("zh-CN")} - - - {release.isLatest && ( - 最新 - )} - - - )) - )} - -
+
); diff --git a/src/app/admin/(dashboard)/settings/page.tsx b/src/app/admin/(dashboard)/settings/page.tsx index 9464f3c..6205d37 100644 --- a/src/app/admin/(dashboard)/settings/page.tsx +++ b/src/app/admin/(dashboard)/settings/page.tsx @@ -1,16 +1,154 @@ "use client"; -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Switch } from "@/components/ui/switch"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { toast } from "sonner"; +import { Trash2, Upload, Copy, ExternalLink } from "lucide-react"; + +type SiteSettings = { + grayscale: boolean; + shutdownBannerEnabled: boolean; + shutdownTitle: string; + shutdownTitleEn: string; + shutdownSubtitle: string; + shutdownSubtitleEn: string; + shutdownAt: string | null; + bgmUrl: string; + bgmAutoplay: boolean; + bgmVolume: number; +}; + +function toDatetimeLocal(iso: string | null): string { + if (!iso) return ""; + const d = new Date(iso); + if (isNaN(d.getTime())) return ""; + const pad = (n: number) => n.toString().padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad( + d.getHours() + )}:${pad(d.getMinutes())}`; +} export default function SettingsPage() { const [loading, setLoading] = useState(false); + const [launcherUrl, setLauncherUrl] = useState(""); - async function handleSubmit(e: React.FormEvent) { + useEffect(() => { + if (typeof window !== "undefined") { + setLauncherUrl(`${window.location.origin}/download/launcher`); + } + }, []); + + async function handleCopyLauncherUrl() { + try { + await navigator.clipboard.writeText(launcherUrl); + toast.success("已复制到剪贴板"); + } catch { + toast.error("复制失败"); + } + } + + const [siteLoading, setSiteLoading] = useState(true); + const [siteSaving, setSiteSaving] = useState(false); + const [grayscale, setGrayscale] = useState(false); + const [bannerEnabled, setBannerEnabled] = useState(false); + const [title, setTitle] = useState(""); + const [titleEn, setTitleEn] = useState(""); + const [subtitle, setSubtitle] = useState(""); + const [subtitleEn, setSubtitleEn] = useState(""); + const [shutdownAtLocal, setShutdownAtLocal] = useState(""); + const [bgmUrl, setBgmUrl] = useState(""); + const [bgmAutoplay, setBgmAutoplay] = useState(false); + const [bgmVolume, setBgmVolume] = useState(50); + const [bgmUploading, setBgmUploading] = useState(false); + const bgmFileRef = useRef(null); + + useEffect(() => { + (async () => { + try { + const res = await fetch("/api/admin/site-settings"); + if (!res.ok) throw new Error(); + const data: SiteSettings = await res.json(); + setGrayscale(data.grayscale); + setBannerEnabled(data.shutdownBannerEnabled); + setTitle(data.shutdownTitle); + setTitleEn(data.shutdownTitleEn ?? ""); + setSubtitle(data.shutdownSubtitle); + setSubtitleEn(data.shutdownSubtitleEn ?? ""); + setShutdownAtLocal(toDatetimeLocal(data.shutdownAt)); + setBgmUrl(data.bgmUrl || ""); + setBgmAutoplay(!!data.bgmAutoplay); + setBgmVolume( + typeof data.bgmVolume === "number" ? data.bgmVolume : 50 + ); + } catch { + toast.error("加载站点设置失败"); + } finally { + setSiteLoading(false); + } + })(); + }, []); + + async function handleSiteSave(e: React.FormEvent) { + e.preventDefault(); + setSiteSaving(true); + const payload = { + grayscale, + shutdownBannerEnabled: bannerEnabled, + shutdownTitle: title, + shutdownTitleEn: titleEn, + shutdownSubtitle: subtitle, + shutdownSubtitleEn: subtitleEn, + shutdownAt: shutdownAtLocal + ? new Date(shutdownAtLocal).toISOString() + : null, + bgmUrl, + bgmAutoplay, + bgmVolume, + }; + const res = await fetch("/api/admin/site-settings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + if (res.ok) { + toast.success("站点设置已保存"); + } else { + const data = await res.json().catch(() => ({})); + toast.error(data?.error || "保存失败"); + } + setSiteSaving(false); + } + + async function handleBgmUpload(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (!file) return; + if (!file.type.startsWith("audio/")) { + toast.error("请选择音频文件"); + return; + } + setBgmUploading(true); + try { + const form = new FormData(); + form.append("file", file); + const res = await fetch("/api/upload", { method: "POST", body: form }); + if (!res.ok) throw new Error(); + const data = await res.json(); + setBgmUrl(data.filePath as string); + toast.success("音频上传成功,请记得保存"); + } catch { + toast.error("音频上传失败"); + } finally { + setBgmUploading(false); + if (bgmFileRef.current) bgmFileRef.current.value = ""; + } + } + + async function handlePasswordSubmit(e: React.FormEvent) { e.preventDefault(); setLoading(true); @@ -45,14 +183,273 @@ export default function SettingsPage() { } return ( -
+

系统设置

+ + + + 站点外观 + + +
+
+
+ +

+ 启用后前台整站将置为黑白灰,适用于重大纪念或肃穆场合。 +

+
+ +
+ +
+
+
+ +

+ 在首页顶部显示倒计时横幅,整体为肃穆风格。 +

+
+ +
+ +
+ +