From 9dc6c0dccea5f6c85b735fd095ad10c536c64d52 Mon Sep 17 00:00:00 2001 From: rucky Date: Tue, 7 Apr 2026 18:30:49 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E4=B8=8B=E8=BD=BD=E6=AC=A1?= =?UTF-8?q?=E6=95=B0=E8=AE=B0=E5=BD=95=E5=92=8C=E6=9B=B4=E6=96=B0=E6=AC=A1?= =?UTF-8?q?=E6=95=B0=E8=AE=B0=E5=BD=95=E7=AD=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- API.md | 689 ++++-------------- prisma/schema.prisma | 44 +- src/app/(public)/addons/[slug]/page.tsx | 2 + src/app/(public)/addons/page.tsx | 2 + src/app/(public)/articles/[slug]/page.tsx | 2 + src/app/(public)/articles/page.tsx | 2 +- src/app/(public)/changelog/page.tsx | 2 + src/app/(public)/page.tsx | 2 +- .../(dashboard)/launcher-online/page.tsx | 246 +++++++ src/app/admin/(dashboard)/page.tsx | 21 +- src/app/api/addons/route.ts | 3 +- src/app/api/launcher/heartbeat/route.ts | 48 ++ src/app/api/launcher/online/route.ts | 122 ++++ src/app/api/software/changelog/route.ts | 53 ++ src/app/api/software/check-update/route.ts | 5 +- src/app/api/software/download/[id]/route.ts | 9 +- src/app/sitemap.ts | 2 + src/components/admin/Sidebar.tsx | 2 + 18 files changed, 665 insertions(+), 591 deletions(-) create mode 100644 src/app/admin/(dashboard)/launcher-online/page.tsx create mode 100644 src/app/api/launcher/heartbeat/route.ts create mode 100644 src/app/api/launcher/online/route.ts create mode 100644 src/app/api/software/changelog/route.ts diff --git a/API.md b/API.md index 049d674..025ab26 100644 --- a/API.md +++ b/API.md @@ -1,75 +1,23 @@ -# Nanami Web API 文档 +# Nanami 启动器 API 文档 -Base URL: `https://nui.rucky.cn` +> Base URL: `https://nanami.rucky.cn`(或对应部署地址) +> +> 标注说明:🆕 新增 | ✏️ 有变动 | 无标注为原有不变 --- -## 目录 +## 1. 检查更新 ✏️ -- [公开接口](#公开接口) - - [服务器时间](#服务器时间) - - [检查更新](#检查更新) - - [启动器最新版本](#启动器最新版本) - - [软件版本下载](#软件版本下载) - - [插件列表](#插件列表) - - [插件详情](#插件详情) - - [插件版本下载](#插件版本下载) - - [Banner 列表](#banner-列表) - - [画廊图片列表](#画廊图片列表) -- [管理接口(需认证)](#管理接口需认证) - - [软件管理](#软件管理) - - [软件版本管理](#软件版本管理) - - [插件管理](#插件管理) - - [插件版本发布](#插件版本发布) - - [Banner 管理](#banner-管理) - - [画廊管理](#画廊管理) - - [文件上传](#文件上传) - - [修改密码](#修改密码) - ---- - -## 公开接口 - -### 服务器时间 - -获取服务器当前时间。 - -``` -GET /api/server-time -``` - -**响应示例:** - -```json -{ - "timestamp": 1710748800000, - "iso": "2026-03-18T08:00:00.000Z", - "timezone": "Asia/Shanghai" -} -``` - -| 字段 | 类型 | 说明 | -|------|------|------| -| `timestamp` | `number` | Unix 毫秒时间戳 | -| `iso` | `string` | ISO 8601 格式时间 | -| `timezone` | `string` | 服务器时区 | - ---- - -### 检查更新 - -客户端检查软件是否有新版本可用。 +检查指定软件是否有新版本。 ``` GET /api/software/check-update?slug={slug}&versionCode={versionCode} ``` -**查询参数:** - -| 参数 | 必填 | 说明 | -|------|------|------| -| `slug` | 是 | 软件标识符,如 `nanami-launcher` | -| `versionCode` | 否 | 当前客户端版本号(整数),默认 `0` | +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| slug | string | 是 | 软件标识,如 `nanami-launcher` 或 `nanami-launcher-patch` | +| versionCode | number | 否 | 当前客户端版本号(整数),不传默认为 0 | **响应示例:** @@ -78,564 +26,177 @@ GET /api/software/check-update?slug={slug}&versionCode={versionCode} "hasUpdate": true, "forceUpdate": false, "latest": { - "version": "0.8.15", - "versionCode": 815, - "changelog": "修复了若干问题...", - "downloadUrl": "https://cdn.example.com/launcher-setup.exe", + "version": "1.3.0", + "versionCode": 130, + "changelog": "- 修复xxx\n- 新增xxx", + "downloadUrl": "https://nanami.rucky.cn/api/software/download/clxxx?source=launcher", "fileSize": 52428800, - "minVersion": "0.7.0", - "createdAt": "2026-03-15T10:00:00.000Z" + "minVersion": "1.0.0", + "createdAt": "2026-03-25T10:00:00.000Z" } } ``` -| 字段 | 类型 | 说明 | -|------|------|------| -| `hasUpdate` | `boolean` | 是否有新版本 | -| `forceUpdate` | `boolean` | 是否需要强制更新 | -| `latest.version` | `string` | 最新版本号 | -| `latest.versionCode` | `number` | 最新版本编码 | -| `latest.changelog` | `string` | 更新日志 | -| `latest.downloadUrl` | `string` | 下载地址 | -| `latest.fileSize` | `number` | 文件大小(字节) | -| `latest.minVersion` | `string\|null` | 最低兼容版本 | -| `latest.createdAt` | `string` | 发布时间 | +**✏️ 变动说明**:返回的 `downloadUrl` 现在自动附带 `?source=launcher` 参数,启动器直接使用该 URL 下载即可,下载量会被单独统计为「客户端更新下载」,与网页端下载区分。启动器无需做任何额外处理。 --- -### 启动器最新版本 +## 2. 下载文件 ✏️ -获取启动器最新版本信息或直接下载。 - -#### 获取信息 +根据版本 ID 下载文件。通常不需要手动拼接,直接使用 check-update 返回的 `downloadUrl` 即可。 ``` -GET /api/software/latest?info=1 +GET /api/software/download/{versionId}?source={source} ``` -添加 `&track=1` 可同时记录一次下载计数。 +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| versionId | string | 是 | 路径参数,版本记录 ID | +| source | string | 否 | 下载来源标识,传 `launcher` 表示客户端更新下载 | -**响应示例:** +**响应**: + +- 本地文件:直接返回二进制流(`Content-Type: application/octet-stream`) +- 外链文件:302 重定向到外部 URL + +**✏️ 变动说明**:新增 `source` 查询参数。当 `source=launcher` 时,下载量会同时计入总下载量和启动器更新下载量两个计数器。check-update 返回的 URL 已自动包含此参数,启动器无需手动处理。 + +--- + +## 3. 获取最新版本信息(网页用) + +获取 nanami-launcher 的最新版本信息或直接下载,主要给网页端使用。 + +``` +GET /api/software/latest?info=1&track=1 +``` + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| info | string | 否 | 传 `1` 返回 JSON 元数据;不传则直接下载文件 | +| track | string | 否 | 仅在 `info=1` 时生效,传 `1` 同时计数下载量 | + +**响应示例(info=1):** ```json { "available": true, - "version": "0.8.15", - "versionCode": 815, - "changelog": "更新内容...", + "version": "1.3.0", + "versionCode": 130, + "changelog": "...", "fileSize": 52428800, - "createdAt": "2026-03-15T10:00:00.000Z", - "downloadUrl": "https://cdn.example.com/launcher-setup.exe", - "downloadType": "url" + "createdAt": "2026-03-25T10:00:00.000Z", + "downloadUrl": "/api/software/download/clxxx", + "downloadType": "local" } ``` -当无可用版本时返回: - -```json -{ "available": false } -``` - -#### 直接下载 - -``` -GET /api/software/latest -``` - -- 外部链接类型:302 重定向到外部 URL -- 本地文件类型:返回文件流 -- 自动记录下载次数 +> 此接口固定查询 slug 为 `nanami-launcher` 的软件,启动器通常使用 check-update 接口而非此接口。 --- -### 软件版本下载 +## 4. 上报心跳(在线状态) 🆕 -按版本 ID 下载特定版本。 +启动器定期调用此接口上报在线状态,建议每 **60 秒** 调用一次。超过 3 分钟无心跳视为离线。 ``` -GET /api/software/download/{id} -``` - -**路径参数:** - -| 参数 | 说明 | -|------|------| -| `id` | 软件版本 ID | - -- 外部链接类型:302 重定向 -- 本地文件类型:返回文件流(`application/octet-stream`) -- 自动记录下载次数 - -**错误响应:** - -| 状态码 | 说明 | -|--------|------| -| `404` | 版本不存在或文件不存在 | - ---- - -### 插件列表 - -获取已发布的插件列表。 - -``` -GET /api/addons -``` - -**查询参数:** - -| 参数 | 必填 | 说明 | -|------|------|------| -| `category` | 否 | 按分类筛选 | -| `search` | 否 | 按名称/简介搜索(模糊匹配) | -| `published` | 否 | 默认 `true`,设为 `false` 查询所有 | - -**响应示例:** - -```json -[ - { - "id": "clxx...", - "name": "插件名", - "slug": "addon-slug", - "summary": "插件简介", - "description": "详细描述...", - "iconUrl": "/uploads/icon.png", - "category": "ui", - "published": true, - "totalDownloads": 1024, - "createdAt": "2026-01-01T00:00:00.000Z", - "updatedAt": "2026-03-01T00:00:00.000Z", - "releases": [{ "...最新版本..." }], - "_count": { "releases": 5 } - } -] -``` - ---- - -### 插件详情 - -获取单个插件详细信息,包含所有版本和截图。 - -``` -GET /api/addons/{id} -``` - -**路径参数:** `id` 可以是插件 ID 或 slug。 - -**响应:** 插件完整信息,含 `releases`(按时间倒序)和 `screenshots`(按排序序号)。 - ---- - -### 插件版本下载 - -``` -GET /api/download/{id} -``` - -**路径参数:** - -| 参数 | 说明 | -|------|------| -| `id` | Release 版本 ID | - -- 自动增加 Release 和 Addon 的下载计数 -- 外部链接类型:302 重定向 -- 本地文件类型:返回文件流 - ---- - -### Banner 列表 - -``` -GET /api/banners -``` - -**查询参数:** - -| 参数 | 说明 | -|------|------| -| `enabled` | 设为 `1` 仅返回已启用的 Banner | - -**响应示例:** - -```json -[ - { - "id": "clxx...", - "imageUrl": "/banners/banner_1.png", - "sortOrder": 0, - "enabled": true, - "createdAt": "2026-01-01T00:00:00.000Z" - } -] -``` - ---- - -### 画廊图片列表 - -``` -GET /api/gallery -``` - -**查询参数:** - -| 参数 | 说明 | -|------|------| -| `enabled` | 设为 `1` 仅返回已启用的图片 | - -**响应示例:** - -```json -[ - { - "id": "clxx...", - "imageUrl": "/views/view_1.png", - "title": "主界面", - "sortOrder": 0, - "enabled": true, - "createdAt": "2026-01-01T00:00:00.000Z" - } -] -``` - ---- - -## 管理接口(需认证) - -以下接口需要管理员登录后的 Session 认证。未认证请求返回 `401 Unauthorized`。 - -### 软件管理 - -#### 获取软件列表 - -``` -GET /api/software -``` - -返回所有软件及其最新版本和版本数量。 - -#### 创建软件 - -``` -POST /api/software +POST /api/launcher/heartbeat Content-Type: application/json +``` +**请求体:** + +```json { - "name": "Nanami Launcher", - "slug": "nanami-launcher", - "description": "启动器描述" + "deviceId": "unique-machine-id", + "os": "Windows", + "osVersion": "10.0.19045", + "appVersion": "1.3.0" } ``` -| 字段 | 必填 | 说明 | -|------|------|------| -| `name` | 是 | 软件名称 | -| `slug` | 是 | 唯一标识符 | -| `description` | 否 | 描述 | - -#### 获取软件详情 - -``` -GET /api/software/{id} -``` - -`id` 可以是软件 ID 或 slug,返回软件信息及所有版本。 - -#### 更新软件 - -``` -PUT /api/software/{id} -Content-Type: application/json - -{ - "name": "新名称", - "slug": "new-slug", - "description": "新描述" -} -``` - -#### 删除软件 - -``` -DELETE /api/software/{id} -``` - ---- - -### 软件版本管理 - -#### 创建新版本 - -``` -POST /api/software/{id}/versions -Content-Type: application/json - -{ - "version": "1.0.0", - "versionCode": 100, - "changelog": "首次发布", - "downloadType": "url", - "externalUrl": "https://cdn.example.com/file.exe", - "fileSize": 52428800, - "forceUpdate": false, - "minVersion": null -} -``` - -| 字段 | 必填 | 类型 | 说明 | +| 字段 | 类型 | 必填 | 说明 | |------|------|------|------| -| `version` | 是 | `string` | 版本号 | -| `versionCode` | 是 | `number` | 版本编码(用于比较) | -| `changelog` | 否 | `string` | 更新日志 | -| `downloadType` | 否 | `string` | `"local"` 或 `"url"`,默认 `"local"` | -| `filePath` | 否 | `string` | 本地文件路径 | -| `externalUrl` | 否 | `string` | 外部下载链接 | -| `fileSize` | 否 | `number` | 文件大小(字节) | -| `forceUpdate` | 否 | `boolean` | 是否强制更新 | -| `minVersion` | 否 | `string` | 最低兼容版本 | +| deviceId | string | **是** | 设备唯一标识,建议使用机器码或 UUID 持久化存储 | +| os | string | 否 | 操作系统名称,如 `Windows`、`macOS`、`Linux` | +| osVersion | string | 否 | 系统版本号,如 `10.0.19045`、`14.3` | +| appVersion | string | 否 | 启动器版本号,如 `1.3.0` | -新版本自动设为 `isLatest: true`,之前的最新版本会被取消。 +**响应:** -#### 更新版本 - -``` -PUT /api/software/versions/{versionId} -Content-Type: application/json - -{ - "version": "1.0.1", - "changelog": "修复问题", - "isLatest": true -} +```json +{ "ok": true } ``` -所有字段均为可选,仅更新传入的字段。设置 `isLatest: true` 时自动取消其他版本的最新标记。 - -#### 删除版本 - -``` -DELETE /api/software/versions/{versionId} -``` +**实现建议**: +- 启动器启动时立即发送一次心跳,之后每 60 秒发送一次 +- `deviceId` 需要在客户端持久化保存,确保同一台机器始终使用相同 ID +- IP 地址由服务端自动获取,无需客户端上报 +- 网络失败时静默忽略即可,不影响启动器正常功能 --- -### 插件管理 +## 5. 历史更新日志 🆕 -#### 创建插件 +查询指定软件的所有版本历史和更新日志,按版本号倒序排列。 ``` -POST /api/addons -Content-Type: application/json - -{ - "name": "插件名", - "slug": "addon-slug", - "summary": "简介", - "description": "详细描述", - "iconUrl": "/uploads/icon.png", - "category": "ui" -} +GET /api/software/changelog?slug={slug} ``` -| 字段 | 必填 | 说明 | -|------|------|------| -| `name` | 是 | 插件名称 | -| `slug` | 是 | 唯一标识符 | -| `summary` | 是 | 简短描述 | -| `description` | 否 | 详细描述 | -| `iconUrl` | 否 | 图标 URL | -| `category` | 否 | 分类,默认 `"general"` | - -#### 更新插件 - -``` -PUT /api/addons/{id} -Content-Type: application/json - -{ - "name": "新名称", - "published": true -} -``` - -#### 删除插件 - -``` -DELETE /api/addons/{id} -``` - ---- - -### 插件版本发布 - -``` -POST /api/releases -Content-Type: application/json - -{ - "addonId": "插件ID", - "version": "1.0.0", - "changelog": "首次发布", - "downloadType": "url", - "externalUrl": "https://cdn.example.com/addon.zip", - "gameVersion": "11.1.0" -} -``` - -| 字段 | 必填 | 说明 | -|------|------|------| -| `addonId` | 是 | 所属插件 ID | -| `version` | 是 | 版本号 | -| `changelog` | 否 | 更新日志 | -| `downloadType` | 否 | `"local"` 或 `"url"` | -| `filePath` | 否 | 本地文件路径 | -| `externalUrl` | 否 | 外部链接(`downloadType` 为 `"url"` 时必填) | -| `gameVersion` | 否 | 适配的游戏版本 | - -#### 获取版本列表 - -``` -GET /api/releases?addonId={addonId} -``` - ---- - -### Banner 管理 - -#### 创建 Banner - -``` -POST /api/banners -Content-Type: application/json - -{ - "imageUrl": "/uploads/banner.png", - "sortOrder": 0, - "enabled": true -} -``` - -#### 更新 Banner - -``` -PUT /api/banners/{id} -Content-Type: application/json - -{ - "sortOrder": 1, - "enabled": false -} -``` - -#### 删除 Banner - -``` -DELETE /api/banners/{id} -``` - ---- - -### 画廊管理 - -#### 创建画廊图片 - -``` -POST /api/gallery -Content-Type: application/json - -{ - "imageUrl": "/uploads/screenshot.png", - "title": "主界面截图", - "sortOrder": 0, - "enabled": true -} -``` - -#### 更新画廊图片 - -``` -PUT /api/gallery/{id} -Content-Type: application/json - -{ - "title": "新标题", - "sortOrder": 2, - "enabled": true -} -``` - -#### 删除画廊图片 - -``` -DELETE /api/gallery/{id} -``` - ---- - -### 文件上传 - -上传文件到服务器。 - -``` -POST /api/upload -Content-Type: multipart/form-data - -file: (二进制文件) -``` +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| slug | string | 是 | 软件标识,如 `nanami-launcher` 或 `nanami-launcher-patch` | **响应示例:** ```json { - "filePath": "/uploads/1710748800000-image.png", - "originalName": "image.png", - "size": 204800 + "name": "Nanami 启动器(全量包)", + "slug": "nanami-launcher", + "versions": [ + { + "version": "1.3.0", + "versionCode": 130, + "changelog": "- 新增xxx\n- 修复xxx", + "fileSize": 52428800, + "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" + } + ] } ``` -返回的 `filePath` 可用于其他接口的 `imageUrl`、`filePath` 等字段。 +**响应字段说明:** + +| 字段 | 类型 | 说明 | +|------|------|------| +| versions[].version | string | 版本号 | +| versions[].versionCode | number | 版本号(整数),用于比较大小 | +| versions[].changelog | string | 更新日志内容 | +| versions[].fileSize | number | 文件大小(字节) | +| versions[].isLatest | boolean | 是否为当前最新版本 | +| versions[].forceUpdate | boolean | 是否为强制更新版本 | +| versions[].createdAt | string | 发布时间(ISO 8601) | --- -### 修改密码 +## 接口一览 -``` -POST /api/admin/change-password -Content-Type: application/json - -{ - "currentPassword": "当前密码", - "newPassword": "新密码(≥6位)" -} -``` - -**错误响应:** - -| 状态码 | 说明 | -|--------|------| -| `400` | 参数缺失或新密码过短 | -| `403` | 当前密码错误 | -| `404` | 用户不存在 | - ---- - -## 通用错误格式 - -所有 API 错误均以 JSON 格式返回: - -```json -{ - "error": "错误描述信息" -} -``` - -| 状态码 | 说明 | -|--------|------| -| `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` | 🆕 新增 | 上报在线状态 | diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 54f990b..d47c99b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -71,21 +71,22 @@ model Software { } model SoftwareVersion { - id String @id @default(cuid()) - softwareId String - version String - versionCode Int - changelog String @db.Text - downloadType String @default("local") - filePath String? - externalUrl String? - fileSize Int @default(0) - downloadCount Int @default(0) - isLatest Boolean @default(false) - forceUpdate Boolean @default(false) - minVersion String? - createdAt DateTime @default(now()) - software Software @relation(fields: [softwareId], references: [id], onDelete: Cascade) + id String @id @default(cuid()) + softwareId String + version String + versionCode Int + changelog String @db.Text + downloadType String @default("local") + filePath String? + externalUrl String? + fileSize Int @default(0) + downloadCount Int @default(0) + launcherDownloadCount Int @default(0) + isLatest Boolean @default(false) + forceUpdate Boolean @default(false) + minVersion String? + createdAt DateTime @default(now()) + software Software @relation(fields: [softwareId], references: [id], onDelete: Cascade) @@unique([softwareId, version]) @@index([softwareId]) @@ -134,3 +135,16 @@ model PageView { @@index([date]) @@index([path]) } + +model LauncherOnline { + id String @id @default(cuid()) + deviceId String @unique + ip String @default("") + os String @default("") + osVersion String @default("") + appVersion String @default("") + lastSeen DateTime @default(now()) + createdAt DateTime @default(now()) + + @@index([lastSeen]) +} diff --git a/src/app/(public)/addons/[slug]/page.tsx b/src/app/(public)/addons/[slug]/page.tsx index a01516e..8aa8092 100644 --- a/src/app/(public)/addons/[slug]/page.tsx +++ b/src/app/(public)/addons/[slug]/page.tsx @@ -6,6 +6,8 @@ import { Download, Package, Calendar, Tag, ArrowLeft } from "lucide-react"; import { DownloadButton } from "@/components/public/DownloadButton"; import { MarkdownContent } from "@/components/public/MarkdownContent"; +export const dynamic = "force-dynamic"; + export async function generateMetadata({ params, }: { diff --git a/src/app/(public)/addons/page.tsx b/src/app/(public)/addons/page.tsx index f301788..8c4ba74 100644 --- a/src/app/(public)/addons/page.tsx +++ b/src/app/(public)/addons/page.tsx @@ -3,6 +3,8 @@ import { AddonCard } from "@/components/public/AddonCard"; import Link from "next/link"; import { Package, Search, ChevronLeft, ChevronRight } from "lucide-react"; +export const dynamic = "force-dynamic"; + const categoryLabels: Record = { general: "通用", gameplay: "游戏玩法", diff --git a/src/app/(public)/articles/[slug]/page.tsx b/src/app/(public)/articles/[slug]/page.tsx index 3e5c0cf..326f39d 100644 --- a/src/app/(public)/articles/[slug]/page.tsx +++ b/src/app/(public)/articles/[slug]/page.tsx @@ -5,6 +5,8 @@ import { prisma } from "@/lib/db"; import { Calendar, ArrowLeft } from "lucide-react"; import { MarkdownContent } from "@/components/public/MarkdownContent"; +export const dynamic = "force-dynamic"; + export async function generateMetadata({ params, }: { diff --git a/src/app/(public)/articles/page.tsx b/src/app/(public)/articles/page.tsx index f3a4199..b346ae1 100644 --- a/src/app/(public)/articles/page.tsx +++ b/src/app/(public)/articles/page.tsx @@ -8,7 +8,7 @@ export const metadata = { description: "Nanami 最新动态、更新公告与使用教程", }; -export const revalidate = 30; +export const dynamic = "force-dynamic"; const PAGE_SIZE = 10; diff --git a/src/app/(public)/changelog/page.tsx b/src/app/(public)/changelog/page.tsx index 17c0e1a..805d292 100644 --- a/src/app/(public)/changelog/page.tsx +++ b/src/app/(public)/changelog/page.tsx @@ -1,6 +1,8 @@ import { prisma } from "@/lib/db"; import { Download, Calendar, Tag } from "lucide-react"; +export const dynamic = "force-dynamic"; + export const metadata = { title: "版本历史", description: "Nanami 启动器版本更新日志", diff --git a/src/app/(public)/page.tsx b/src/app/(public)/page.tsx index ddf1ce5..fa51241 100644 --- a/src/app/(public)/page.tsx +++ b/src/app/(public)/page.tsx @@ -7,7 +7,7 @@ import { HeroBanner } from "@/components/public/HeroBanner"; import { GameGallery } from "@/components/public/GameGallery"; import { Sparkles, Shield, Zap, Calendar } from "lucide-react"; -export const revalidate = 60; +export const dynamic = "force-dynamic"; export default async function HomePage() { const [featuredAddons, launcher, launcherDownloads, banners, galleryImages, latestArticles] = diff --git a/src/app/admin/(dashboard)/launcher-online/page.tsx b/src/app/admin/(dashboard)/launcher-online/page.tsx new file mode 100644 index 0000000..ee87748 --- /dev/null +++ b/src/app/admin/(dashboard)/launcher-online/page.tsx @@ -0,0 +1,246 @@ +"use client"; + +import { useEffect, useState, useCallback } from "react"; +import { + Card, + CardContent, + CardHeader, + CardTitle, + CardDescription, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Users, Monitor, RefreshCw } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +interface OnlineUser { + id: string; + deviceId: string; + ip: string; + location: string; + os: string; + osVersion: string; + appVersion: string; + lastSeen: string; + createdAt: string; +} + +interface OsCount { + os: string; + count: number; +} + +interface OnlineData { + users: OnlineUser[]; + total: number; + osList: OsCount[]; +} + +const REFRESH_INTERVAL = 15_000; + +export default function LauncherOnlinePage() { + const [data, setData] = useState(null); + const [search, setSearch] = useState(""); + const [osFilter, setOsFilter] = useState(""); + const [loading, setLoading] = useState(true); + + const fetchData = useCallback(async () => { + const params = new URLSearchParams(); + if (search) params.set("search", search); + if (osFilter) params.set("os", osFilter); + const res = await fetch(`/api/launcher/online?${params}`); + if (res.ok) { + setData(await res.json()); + } + setLoading(false); + }, [search, osFilter]); + + useEffect(() => { + fetchData(); + const timer = setInterval(fetchData, REFRESH_INTERVAL); + return () => clearInterval(timer); + }, [fetchData]); + + function formatLastSeen(iso: string) { + const diff = Date.now() - new Date(iso).getTime(); + const sec = Math.floor(diff / 1000); + if (sec < 60) return `${sec} 秒前`; + return `${Math.floor(sec / 60)} 分钟前`; + } + + function getOsBadgeVariant(os: string) { + const lower = os.toLowerCase(); + if (lower.includes("windows")) return "default" as const; + if (lower.includes("mac") || lower.includes("darwin")) + return "secondary" as const; + if (lower.includes("linux")) return "outline" as const; + return "secondary" as const; + } + + return ( +
+
+

在线用户

+

+ 实时监控启动器在线状态,每 15 秒自动刷新 +

+
+ +
+ + + + 当前在线 + + + + +
+ {data?.total ?? "—"} +
+
+
+ + {data?.osList + .filter((o) => o.os) + .slice(0, 2) + .map((o) => ( + + + + {o.os} + + + + +
{o.count}
+
+
+ ))} +
+ + + +
+
+ 在线用户列表 + + 显示最近 3 分钟内有心跳的客户端 + +
+ +
+
+ setSearch(e.target.value)} + className="max-w-xs" + /> + +
+
+ + + + + IP 地址 + 地理位置 + 设备 ID + 操作系统 + 系统版本 + 启动器版本 + 最后心跳 + + + + {!data || data.users.length === 0 ? ( + + + {loading ? "加载中…" : "暂无在线用户"} + + + ) : ( + data.users.map((user) => ( + + + {user.ip || "—"} + + + {user.location || "—"} + + + {user.deviceId} + + + + {user.os || "Unknown"} + + + + {user.osVersion || "—"} + + + v{user.appVersion} + + + {formatLastSeen(user.lastSeen)} + + + )) + )} + +
+
+
+
+ ); +} diff --git a/src/app/admin/(dashboard)/page.tsx b/src/app/admin/(dashboard)/page.tsx index e677620..e9575eb 100644 --- a/src/app/admin/(dashboard)/page.tsx +++ b/src/app/admin/(dashboard)/page.tsx @@ -6,7 +6,7 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; -import { Package, Download, FileUp, Eye, Users, TrendingUp } from "lucide-react"; +import { Package, Download, FileUp, Eye, Users, TrendingUp, Monitor, Wifi } from "lucide-react"; export const dynamic = "force-dynamic"; @@ -24,6 +24,8 @@ export default async function DashboardPage() { const today = new Date().toISOString().slice(0, 10); const days = getLast7Days(); + const onlineThreshold = new Date(Date.now() - 3 * 60 * 1000); + const [ addonCount, totalDownloads, @@ -33,6 +35,9 @@ export default async function DashboardPage() { totalPV, todayUV, pvByDay, + launcherDownloads, + launcherUpdateDownloads, + onlineCount, ] = await Promise.all([ prisma.addon.count(), prisma.addon.aggregate({ _sum: { totalDownloads: true } }), @@ -54,6 +59,9 @@ export default async function DashboardPage() { _count: true, orderBy: { date: "asc" }, }), + prisma.softwareVersion.aggregate({ _sum: { downloadCount: true } }), + prisma.softwareVersion.aggregate({ _sum: { launcherDownloadCount: true } }), + prisma.launcherOnline.count({ where: { lastSeen: { gte: onlineThreshold } } }), ]); const pvMap = new Map(pvByDay.map((d) => [d.date, d._count])); @@ -63,10 +71,17 @@ export default async function DashboardPage() { })); const maxPV = Math.max(...chartData.map((d) => d.pv), 1); + const totalSwDownloads = launcherDownloads._sum.downloadCount || 0; + const totalLauncherUpdates = launcherUpdateDownloads._sum.launcherDownloadCount || 0; + const webDownloads = totalSwDownloads - totalLauncherUpdates; + const stats = [ { title: "插件总数", value: addonCount, icon: Package }, - { title: "总下载量", value: totalDownloads._sum.totalDownloads || 0, icon: Download }, + { title: "插件总下载量", value: totalDownloads._sum.totalDownloads || 0, icon: Download }, { title: "版本发布数", value: releaseCount, icon: FileUp }, + { title: "启动器下载量 (网页)", value: webDownloads, icon: Monitor }, + { title: "启动器更新量 (客户端)", value: totalLauncherUpdates, icon: Download }, + { title: "启动器在线人数", value: onlineCount, icon: Wifi }, { title: "今日访问 (PV)", value: todayPV, icon: Eye }, { title: "今日独立访客 (UV)", value: todayUV, icon: Users }, { title: "累计访问量", value: totalPV, icon: TrendingUp }, @@ -76,7 +91,7 @@ export default async function DashboardPage() {

仪表盘

-
+
{stats.map((stat) => ( diff --git a/src/app/api/addons/route.ts b/src/app/api/addons/route.ts index 5b19ed6..391c060 100644 --- a/src/app/api/addons/route.ts +++ b/src/app/api/addons/route.ts @@ -40,7 +40,7 @@ export async function POST(request: NextRequest) { } const body = await request.json(); - const { name, slug, summary, description, iconUrl, category } = body; + const { name, slug, summary, description, iconUrl, category, published } = body; if (!name || !slug || !summary) { return NextResponse.json( @@ -65,6 +65,7 @@ export async function POST(request: NextRequest) { description: description || "", iconUrl: iconUrl || null, category: category || "general", + published: published === true, }, }); diff --git a/src/app/api/launcher/heartbeat/route.ts b/src/app/api/launcher/heartbeat/route.ts new file mode 100644 index 0000000..38e5e84 --- /dev/null +++ b/src/app/api/launcher/heartbeat/route.ts @@ -0,0 +1,48 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { deviceId, os, osVersion, appVersion } = body as { + deviceId?: string; + os?: string; + osVersion?: string; + appVersion?: string; + }; + + if (!deviceId) { + return NextResponse.json( + { error: "deviceId is required" }, + { status: 400 } + ); + } + + const ip = + request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || + request.headers.get("x-real-ip") || + ""; + + await prisma.launcherOnline.upsert({ + where: { deviceId }, + update: { + ip, + os: os || "", + osVersion: osVersion || "", + appVersion: appVersion || "", + lastSeen: new Date(), + }, + create: { + deviceId, + ip, + os: os || "", + osVersion: osVersion || "", + appVersion: appVersion || "", + }, + }); + + return NextResponse.json({ ok: true }); + } catch { + return NextResponse.json({ ok: false }, { status: 500 }); + } +} diff --git a/src/app/api/launcher/online/route.ts b/src/app/api/launcher/online/route.ts new file mode 100644 index 0000000..dd53c84 --- /dev/null +++ b/src/app/api/launcher/online/route.ts @@ -0,0 +1,122 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; + +const ONLINE_THRESHOLD_MS = 3 * 60 * 1000; + +// IP geolocation cache: ip -> { location, expiry } +const ipCache = new Map(); +const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour + +interface IpApiResult { + query: string; + status: string; + country?: string; + regionName?: string; + city?: string; + isp?: string; +} + +async function resolveIpLocations(ips: string[]): Promise> { + const result = new Map(); + const now = Date.now(); + const toQuery: string[] = []; + + for (const ip of ips) { + const cached = ipCache.get(ip); + if (cached && cached.expiry > now) { + result.set(ip, cached.location); + } else { + toQuery.push(ip); + } + } + + if (toQuery.length === 0) return result; + + try { + const res = await fetch("http://ip-api.com/batch?lang=zh-CN", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify( + toQuery.map((ip) => ({ query: ip, fields: "query,status,country,regionName,city,isp" })) + ), + }); + + if (res.ok) { + const data: IpApiResult[] = await res.json(); + for (const item of data) { + let location = ""; + if (item.status === "success") { + const parts = [item.country, item.regionName, item.city].filter(Boolean); + const unique = [...new Set(parts)]; + location = unique.join(" ") + (item.isp ? ` (${item.isp})` : ""); + } + result.set(item.query, location); + ipCache.set(item.query, { location, expiry: now + CACHE_TTL_MS }); + } + } + } catch { + // IP lookup failure is non-critical + } + + return result; +} + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const search = searchParams.get("search") || ""; + const osFilter = searchParams.get("os") || ""; + + const threshold = new Date(Date.now() - ONLINE_THRESHOLD_MS); + + const where: Record = { + lastSeen: { gte: threshold }, + }; + + if (osFilter) { + where.os = osFilter; + } + + if (search) { + where.OR = [ + { ip: { contains: search } }, + { deviceId: { contains: search } }, + { appVersion: { contains: search } }, + ]; + } + + const [users, total] = await Promise.all([ + prisma.launcherOnline.findMany({ + where, + orderBy: { lastSeen: "desc" }, + }), + prisma.launcherOnline.count({ + where: { lastSeen: { gte: threshold } }, + }), + ]); + + const osList = await prisma.launcherOnline.groupBy({ + by: ["os"], + where: { lastSeen: { gte: threshold } }, + _count: true, + orderBy: { _count: { os: "desc" } }, + }); + + const uniqueIps = [...new Set(users.map((u) => u.ip).filter(Boolean))]; + const ipLocations = await resolveIpLocations(uniqueIps); + + return NextResponse.json({ + users: users.map((u) => ({ + id: u.id, + deviceId: u.deviceId, + ip: u.ip, + location: ipLocations.get(u.ip) || "", + os: u.os, + osVersion: u.osVersion, + appVersion: u.appVersion, + lastSeen: u.lastSeen.toISOString(), + createdAt: u.createdAt.toISOString(), + })), + total, + osList: osList.map((o) => ({ os: o.os, count: o._count })), + }); +} diff --git a/src/app/api/software/changelog/route.ts b/src/app/api/software/changelog/route.ts new file mode 100644 index 0000000..e75823b --- /dev/null +++ b/src/app/api/software/changelog/route.ts @@ -0,0 +1,53 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const slug = searchParams.get("slug"); + + if (!slug) { + return NextResponse.json( + { error: "slug parameter is required" }, + { status: 400 } + ); + } + + const software = await prisma.software.findUnique({ + where: { slug }, + include: { + versions: { + orderBy: { versionCode: "desc" }, + select: { + version: true, + versionCode: true, + changelog: true, + fileSize: true, + isLatest: true, + forceUpdate: true, + createdAt: true, + }, + }, + }, + }); + + if (!software) { + return NextResponse.json( + { error: "Software not found" }, + { status: 404 } + ); + } + + return NextResponse.json({ + name: software.name, + slug: software.slug, + versions: software.versions.map((v) => ({ + version: v.version, + versionCode: v.versionCode, + changelog: v.changelog, + fileSize: v.fileSize, + isLatest: v.isLatest, + forceUpdate: v.forceUpdate, + createdAt: v.createdAt.toISOString(), + })), + }); +} diff --git a/src/app/api/software/check-update/route.ts b/src/app/api/software/check-update/route.ts index 5a824c8..81fa9e9 100644 --- a/src/app/api/software/check-update/route.ts +++ b/src/app/api/software/check-update/route.ts @@ -39,10 +39,7 @@ export async function GET(request: NextRequest) { const origin = process.env.API_BASE_URL || process.env.NEXTAUTH_URL || request.nextUrl.origin; - const downloadUrl = - latest.downloadType === "url" && latest.externalUrl - ? latest.externalUrl - : `${origin.replace(/\/$/, "")}/api/software/download/${latest.id}`; + const downloadUrl = `${origin.replace(/\/$/, "")}/api/software/download/${latest.id}?source=launcher`; return NextResponse.json({ hasUpdate, diff --git a/src/app/api/software/download/[id]/route.ts b/src/app/api/software/download/[id]/route.ts index c0ae4d4..3949639 100644 --- a/src/app/api/software/download/[id]/route.ts +++ b/src/app/api/software/download/[id]/route.ts @@ -4,10 +4,11 @@ import { readFile, stat } from "fs/promises"; import path from "path"; export async function GET( - _request: NextRequest, + request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { const { id } = await params; + const source = new URL(request.url).searchParams.get("source"); const sv = await prisma.softwareVersion.findUnique({ where: { id }, @@ -18,9 +19,13 @@ export async function GET( return NextResponse.json({ error: "Version not found" }, { status: 404 }); } + const isLauncher = source === "launcher"; await prisma.softwareVersion.update({ where: { id }, - data: { downloadCount: { increment: 1 } }, + data: { + downloadCount: { increment: 1 }, + ...(isLauncher && { launcherDownloadCount: { increment: 1 } }), + }, }); if (sv.downloadType === "url" && sv.externalUrl) { diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts index 7eee68e..c11276b 100644 --- a/src/app/sitemap.ts +++ b/src/app/sitemap.ts @@ -1,6 +1,8 @@ import type { MetadataRoute } from "next"; import { prisma } from "@/lib/db"; +export const dynamic = "force-dynamic"; + export default async function sitemap(): Promise { const baseUrl = process.env.NEXTAUTH_URL || "https://nanami.rucky.cn"; diff --git a/src/components/admin/Sidebar.tsx b/src/components/admin/Sidebar.tsx index 013ee15..af8e321 100644 --- a/src/components/admin/Sidebar.tsx +++ b/src/components/admin/Sidebar.tsx @@ -13,6 +13,7 @@ import { Settings, LogOut, ChevronLeft, + Users, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { ThemeToggle } from "@/components/ThemeToggle"; @@ -23,6 +24,7 @@ const navItems = [ { href: "/admin/addons", label: "插件管理", icon: Package }, { href: "/admin/releases", label: "插件版本", icon: Upload }, { href: "/admin/software", label: "软件管理", icon: Monitor }, + { href: "/admin/launcher-online", label: "在线用户", icon: Users }, { href: "/admin/media", label: "媒体管理", icon: ImageIcon }, { href: "/admin/articles", label: "文章管理", icon: FileText }, { href: "/admin/settings", label: "系统设置", icon: Settings },