更新下载次数记录和更新次数记录等

This commit is contained in:
rucky
2026-04-07 18:30:49 +08:00
parent f459cc9ad0
commit 9dc6c0dcce
18 changed files with 665 additions and 591 deletions

689
API.md
View File

@@ -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` | 🆕 新增 | 上报在线状态 |

View File

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

View File

@@ -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,
}: {

View File

@@ -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<string, string> = {
general: "通用",
gameplay: "游戏玩法",

View File

@@ -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,
}: {

View File

@@ -8,7 +8,7 @@ export const metadata = {
description: "Nanami 最新动态、更新公告与使用教程",
};
export const revalidate = 30;
export const dynamic = "force-dynamic";
const PAGE_SIZE = 10;

View File

@@ -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 启动器版本更新日志",

View File

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

View File

@@ -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<OnlineData | null>(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 (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold">线</h1>
<p className="mt-1 text-muted-foreground">
线 15
</p>
</div>
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
线
</CardTitle>
<Users className="h-5 w-5 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">
{data?.total ?? "—"}
</div>
</CardContent>
</Card>
{data?.osList
.filter((o) => o.os)
.slice(0, 2)
.map((o) => (
<Card key={o.os}>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{o.os}
</CardTitle>
<Monitor className="h-5 w-5 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{o.count}</div>
</CardContent>
</Card>
))}
</div>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>线</CardTitle>
<CardDescription>
3
</CardDescription>
</div>
<Button
variant="outline"
size="sm"
onClick={() => {
setLoading(true);
fetchData();
}}
disabled={loading}
>
<RefreshCw
className={`mr-2 h-4 w-4 ${loading ? "animate-spin" : ""}`}
/>
</Button>
</div>
<div className="flex gap-3 pt-2">
<Input
placeholder="搜索 IP / 设备ID / 版本号…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="max-w-xs"
/>
<Select value={osFilter} onValueChange={(v) => setOsFilter(v ?? "")}>
<SelectTrigger className="w-40">
<SelectValue placeholder="全部系统" />
</SelectTrigger>
<SelectContent>
<SelectItem value=""></SelectItem>
{data?.osList
.filter((o) => o.os)
.map((o) => (
<SelectItem key={o.os} value={o.os}>
{o.os} ({o.count})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>IP </TableHead>
<TableHead></TableHead>
<TableHead> ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{!data || data.users.length === 0 ? (
<TableRow>
<TableCell
colSpan={7}
className="h-24 text-center text-muted-foreground"
>
{loading ? "加载中…" : "暂无在线用户"}
</TableCell>
</TableRow>
) : (
data.users.map((user) => (
<TableRow key={user.id}>
<TableCell className="font-mono text-sm">
{user.ip || "—"}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{user.location || "—"}
</TableCell>
<TableCell
className="max-w-[140px] truncate font-mono text-xs text-muted-foreground"
title={user.deviceId}
>
{user.deviceId}
</TableCell>
<TableCell>
<Badge variant={getOsBadgeVariant(user.os)}>
{user.os || "Unknown"}
</Badge>
</TableCell>
<TableCell className="text-sm">
{user.osVersion || "—"}
</TableCell>
<TableCell>
<Badge variant="outline">v{user.appVersion}</Badge>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{formatLastSeen(user.lastSeen)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}

View File

@@ -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() {
<div className="space-y-8">
<h1 className="text-3xl font-bold"></h1>
<div className="grid gap-4 md:grid-cols-3">
<div className="grid gap-4 sm:grid-cols-2 md:grid-cols-3">
{stats.map((stat) => (
<Card key={stat.title}>
<CardHeader className="flex flex-row items-center justify-between pb-2">

View File

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

View File

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

View File

@@ -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<string, { location: string; expiry: number }>();
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<Map<string, string>> {
const result = new Map<string, string>();
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<string, unknown> = {
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 })),
});
}

View File

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

View File

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

View File

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

View File

@@ -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<MetadataRoute.Sitemap> {
const baseUrl = process.env.NEXTAUTH_URL || "https://nanami.rucky.cn";

View File

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