图片加载提示,info半透明

This commit is contained in:
feie9454 2025-11-18 20:00:39 +08:00
parent 5a97bc2a8d
commit b5929613ba
3 changed files with 2337 additions and 226 deletions

392
README.md
View File

@ -1,188 +1,278 @@
# Winupdate Neo # Winupdate Neo
这是使用 Next.js + Prisma + PostgreSQL 重写的 Winupdate 项目的 API 部分。 一个基于 Next.js + Prisma + MinIO 的“主机截图与版本分发平台”。用于接收客户端上报的屏幕截图与窗口信息,保存到对象存储;配套 Web UI 浏览、检索与收藏记录,并提供将一段时间内的截图压制为视频的能力。同时支持客户端版本文件的上传与分发,以及主机浏览器凭据(含历史密码)入库与查询。
## 功能一览
- 主机与记录
- 接收主机上报的多张截图与窗口信息,自动建档并存储
- 按主机、时间范围检索记录;星标记录管理与分页
- 小时维度“活跃度”时间分布统计
- 媒体处理与下载
- 截图以 AV1.avif压缩后存入 MinIO
- 将一段时间内的截图转码为 MP4SVT-AV1 编码)供下载
- 文件直链下载(版本文件/截图等)
- 凭据采集
- 上报主机浏览器凭据(含历史密码版本),去重合并保存
- 版本分发
- 上传客户端新版本(.exe自动生成校验和与下载地址并标记最新
- 查询最新版本信息与下载链接
- 计划任务与通知
- node-cron 定时任务管理(示例:整点任务、每日清理)与前端管理页 /tasks
- 可选 QQ 机器人 WebHook 推送“主机上线/离线”等通知
- 安全与访问控制
- 页面侧(非 API支持 Basic AuthAPI 默认允许跨域withCors
## 技术栈 ## 技术栈
- **前端框架**: Next.js 15 - Next.js 15 + React 19 + TypeScript
- **数据库**: PostgreSQL + Prisma ORM - Prisma 6PostgreSQL
- **包管理器**: Bun - MinIOS3 兼容对象存储)
- **文件存储**: MinIO替代 GridFS - node-cron 定时任务
- **语言**: TypeScript - Tailwind CSS v4
- 运行与进程管理Bun、PM2
- 媒体处理FFmpeglibsvtav1
## 项目结构 ## 目结构(节选)
``` ```
app/ app/
├── api/ # API 路由 hosts/
│ ├── hosts/ route.ts # 主机列表 APIGET
│ │ ├── route.ts # 主机列表 API [hostname]/
│ │ └── [hostname]/ page.tsx # 主机详情页UI
│ │ ├── screenshots/ credentials/route.ts # 凭据GET/POST
│ │ │ └── route.ts # 截图上传/获取 API screenshots/route.ts # 截图GET查询/POST上传
│ │ ├── credentials/ starred/route.ts # 星标记录GET分页/POST批量标记
│ │ │ └── route.ts # 凭据管理 API time-distribution/route.ts# 小时分布统计
│ │ └── time-distribution/ downloads/[fileId]/route.ts # 文件下载版本、nssm
│ │ └── route.ts # 时间分布统计 API screenshots/[fileId]/route.ts # 截图文件回源
│ ├── screenshots/ api/
│ │ └── [fileId]/ tasks/route.ts # 计划任务状态/控制
│ │ └── route.ts # 截图文件服务 API generate/video/route.ts # 按时间段合成视频MP4
│ ├── downloads/ version/route.ts # 获取最新版本API 变体)
│ │ └── [fileId]/ version/route.ts # 获取最新版本App 路由变体)
│ │ └── route.ts # 文件下载 API
│ ├── version/
│ │ └── route.ts # 版本信息 API
│ └── upload/
│ └── version/
│ └── route.ts # 版本上传 API
├── api-test/ # API 测试页面
└── ... # 其他 Next.js 文件
lib/ lib/
├── prisma.ts # Prisma 客户端配置 prisma.ts # PrismaClient 单例
├── config.ts # 应用配置 config.ts # 端口与 Basic Auth 配置
├── fileStorage.ts # 文件存储工具 middleware.ts # withAuth / withCors 辅助
├── middleware.ts # 中间件工具 fileStorage.ts # MinIO 存储封装put/get/delete/stat
└── push.ts # 推送通知工具 minioClient.ts # MinIO 客户端与初始化
encodeVideo.ts # 图片压制为 AV1 视频
scheduler.ts / init-scheduler.ts # 定时任务注册
push/qq.ts # QQ Bot 推送
prisma/ prisma/
├── schema.prisma # 数据库模式 schema.prisma # 数据模型
└── migrations/ # 数据库迁移文件 pm2.config.js # PM2 生产运行配置
middleware.ts # Next 中间件:页面 Basic Auth
``` ```
## 快速开始 ## 前置依赖
### 1. 安装依赖 - Node.js 18+(推荐 20+
- Bun推荐仓库包含 bun.lock
- PostgreSQL 数据库
- MinIO或任意 S3 兼容对象存储)
- FFmpeg需包含 libsvtav1 编码器)
## 环境变量
在项目根目录创建 .env生产环境同样需要
```
# Server
PORT=3000
NODE_ENV=development
# Basic Auth仅页面侧使用API 默认不校验)
AUTH_USERNAME=admin
AUTH_PASSWORD=password
# DatabasePostgreSQL
DATABASE_URL="postgresql://user:pass@localhost:5432/winupdate_neo?schema=public"
# MinIO / S3
MINIO_ENDPOINT=127.0.0.1
MINIO_PORT=9000
MINIO_USE_SSL=false
MINIO_ACCESS_KEY=your_minio_access_key
MINIO_SECRET_KEY=your_minio_secret_key
MINIO_BUCKET_NAME=winupdate
# 可选QQ Bot 推送
QQ_BOT_URL=
QQ_BOT_TARGET_ID=
```
提示PM2 配置会读取当前目录下的 .envpm2.config.js 内部使用 dotenv 载入),生产环境注意设置强口令与私密变量。
## 安装与运行
- 安装依赖
```bash ```bash
bun install bun install
``` ```
### 2. 配置环境变量 - 生成 Prisma Client 并迁移数据库(开发)
复制 `.env.example` 文件为 `.env` 并配置相应的设置:
```bash ```bash
cp .env.example .env
```
配置文件内容示例:
```env
# Database
DATABASE_URL="postgresql://username:password@localhost:5432/winupdate_neo?schema=public"
# Auth
AUTH_USERNAME=admin
AUTH_PASSWORD=password
# Port
PORT=3000
# MinIO Configuration
MINIO_ENDPOINT=192.168.5.13
MINIO_PORT=9000
MINIO_USE_SSL=false
MINIO_ACCESS_KEY=your_access_key
MINIO_SECRET_KEY=your_secret_key
MINIO_BUCKET_NAME=winupdate
```
### 3. 初始化数据库
```bash
# 生成 Prisma 客户端
bun run db:generate bun run db:generate
# 运行数据库迁移
bun run db:migrate bun run db:migrate
``` ```
### 4. 启动开发服务器 - 开发运行
```bash ```bash
bun run dev bun run dev
``` ```
## API 端点 - 构建与启动(本地/容器)
### 主机管理
- `GET /api/hosts` - 获取主机列表
- `POST /api/hosts/{hostname}/screenshots` - 上传截图
- `GET /api/hosts/{hostname}/screenshots` - 获取截图记录
- `GET /api/hosts/{hostname}/time-distribution` - 获取时间分布统计
### 凭据管理
- `POST /api/hosts/{hostname}/credentials` - 上传凭据
- `GET /api/hosts/{hostname}/credentials` - 获取凭据
### 文件服务
- `GET /api/screenshots/{fileId}` - 获取截图文件
- `GET /api/downloads/{fileId}` - 下载文件
### 版本管理
- `GET /api/version` - 获取最新版本信息
- `POST /api/upload/version` - 上传新版本
## 数据库模型
### Host
- 主机基本信息hostname, lastUpdate
### Record
- 截图记录timestamp, windows, screenshots
### Window
- 窗口信息title, path, memory
### Screenshot
- 截图文件信息fileId, filename, monitorName
### Credential
- 凭据信息hostname, username, browser, url, login
### Password
- 密码历史value, timestamp
### Version
- 版本信息version, fileId, checksum, isLatest
## 与原项目的区别
1. **数据库**: 从 MongoDB 迁移到 PostgreSQL
2. **ORM**: 使用 Prisma 替代 Mongoose
3. **文件存储**: 使用本地文件系统替代 GridFS
4. **框架**: 从 Express 迁移到 Next.js API Routes
5. **包管理**: 使用 Bun 替代 npm/yarn
## 开发工具
```bash ```bash
# 数据库相关 bun run build
bun run db:generate # 生成 Prisma 客户端 bun run start
bun run db:migrate # 运行数据库迁移
bun run db:reset # 重置数据库
bun run db:studio # 打开 Prisma Studio
# 开发
bun run dev # 启动开发服务器
bun run build # 构建生产版本
bun run start # 启动生产服务器
bun run lint # 代码检查
``` ```
## 注意事项 - 使用 PM2 生产部署(推荐)
1. 文件存储在 MinIO 对象存储中,按分层结构组织 ```bash
2. 需要确保 PostgreSQL 数据库和 MinIO 服务正在运行 # 首次
3. 所有配置通过环境变量管理,包括数据库、认证和 MinIO 设置 pm2 start pm2.config.js
4. MinIO 需要预先创建对应的 bucket # 查看状态
5. 推送通知功能目前只是控制台日志,可以根据需要集成真实的推送服务 pm2 status
# 查看日志
pm2 logs winupdate-neo --lines 200
# 更新版本后重启
pm2 restart winupdate-neo
```
## API 兼容性 日志默认输出至 logs/,可在 pm2.config.js 中调整。
这个重写版本保持了与原始 Express 应用相同的 API 接口,确保客户端代码无需修改即可使用。 ## 数据模型Prisma
- Host主机按 hostname 唯一
- Record一次上报记录关联若干 Window 与 Screenshot支持 isStarred
- Window窗口信息title/path/memorymemory 为 BigInt
- Screenshot截图文件元信息核心为 objectNameMinIO 对象名)
- Credential主机-用户-浏览器-URL-Login 唯一,含 lastSyncTime
- Password凭据的历史密码值时间序列
- Version版本文件元信息fileId/objectName/checksum/isLatest
- Nssm辅助可下载文件的元信息
## MinIO 存储约定
- 桶名MINIO_BUCKET_NAME默认 winupdate
- 对象路径:
- 截图screenshots/YYYY/MM/DD/{hostname}/{uuid}.avif
- 版本versions/YYYY/MM/{uuid}.exe
- 其他files/YYYY/MM/DD/{uuid}
- 常用元数据:
- Content-Type、X-Original-Filename、X-File-ID、X-Upload-Time、X-File-Type、X-Hostname
## 核心 API节选
- 主机列表
- GET /hosts
- 截图上传/查询(按主机)
- POST /hosts/{hostname}/screenshotsmultipart/form-data
- 字段windows_infoJSON 字符串screenshot_0..n文件
- GET /hosts/{hostname}/screenshots?startTime=...&endTime=...
- 支持 Unix 秒或 ISO 时间,返回 records + windows + screenshotswindows.memory 已转 string
- 星标记录(按主机)
- GET /hosts/{hostname}/starred?page=1&limit=50
- POST /hosts/{hostname}/starred
- JSON{ "action": "star"|"unstar", "recordIds": ["..."] }
- 切换单条记录星标
- PATCH /api/records/{recordId}/star
- 凭据
- POST /hosts/{hostname}/credentialsBody 为特殊数组结构,首项形如 ["User", "username"],其后为浏览器项)
- GET /hosts/{hostname}/credentials包含密码历史降序
- 时间分布统计(小时)
- GET /hosts/{hostname}/time-distribution?from=ISO&to=ISO
- 返回:[{ timestamp: 秒, count }...]
- 截图文件
- GET /screenshots/{fileId}
- 版本文件下载
- GET /downloads/{fileId}
- 最新版本查询
- GET /versionApp 路由)或 GET /api/versionAPI 路由),均返回 { version, download_url, checksum }
- 截图合成视频MP4
- GET /api/generate/video?hostname=xxx&startTime=unixSec&endTime=unixSec
跨域:大多数 API 通过 withCors 允许跨域Access-Control-Allow-Origin: *)。
### 示例:上传截图
```bash
curl -X POST "http://localhost:3000/hosts/TEST-PC/screenshots" \
-F "windows_info=[{\"title\":\"Explorer\",\"path\":\"C:/Windows/explorer.exe\",\"memory\":12345}]" \
-F "screenshot_0=@/path/to/a.png" \
-F "screenshot_1=@/path/to/b.png"
```
### 示例:批量星标
```bash
curl -X POST "http://localhost:3000/hosts/TEST-PC/starred" \
-H "Content-Type: application/json" \
-d '{"action":"star","recordIds":["rec_xxx","rec_yyy"]}'
```
### 示例:生成时间段视频
```bash
curl -L "http://localhost:3000/api/generate/video?hostname=TEST-PC&startTime=1751104800&endTime=1751108400" -o out.mp4
```
## 认证与安全
- 页面 Basic Auth根中间件对大多数页面启用基本认证用户名/密码来自 AUTH_USERNAME/AUTH_PASSWORD。以下路径跳过认证
- /api/、/screenshots/、/downloads/、/_next/、/favicon.ico以及所有 POST 请求等
- API 跨域:默认允许任意来源(可按需收紧)
- 建议:
- 生产开启 HTTPS
- 使用强口令并限制来源 IP
- 为数据库与对象存储设置最小权限账号
## 定时任务
- 由 lib/scheduler.ts 定义并在服务端初始化lib/init-scheduler.ts
- 已内置:
- 每小时第 30 分执行的示例任务
- 每日 02:00 清理任务(示例)
- 管理界面:/tasks可查看状态并启动/停止
- 通过 /api/tasks 提供状态与控制 API
## 部署要点PM2
- 确保 .env、数据库与 MinIO 可用
- FFmpeg 必须包含 libsvtav1否则截图转码/视频合成会失败)
- 启动pm2 start pm2.config.js脚本使用 bun run start端口默认 12398可由 .env PORT 覆盖)
- 日志logs/ 目录
## 常见问题FAQ
- MinIO 连接失败
- 检查 MINIO_ENDPOINT/MINIO_PORT/MINIO_ACCESS_KEY/MINIO_SECRET_KEY
- 确认桶 MINIO_BUCKET_NAME 已存在
- 截图上传 500/视频生失败
- 确认 FFmpeg 安装且包含 libsvtav1服务器有足够的 CPU 与临时磁盘
- JSON 序列化 BigInt 报错
- API 层已处理 window.memory 的 BigInt->string前端请按字符串消费
- 版本下载 404
- /downloads/{fileId} 会在 versions 与 nssm 两表中查找,请确认 fileId 与库内记录一致
## 开发提示
- 新增模型后:更新 prisma/schema.prisma -> bun run db:generate -> bun run db:migrate
- 新增静态/下载接口时:优先只暴露 fileId后端内部解析为 objectName 再从 MinIO 取文件
- encodeVideo.ts 的 concat+SVT-AV1 管道对输入图片顺序敏感,注意生成 list.txt 的顺序
## 许可
未设置许可License。如需开源或分发请在提交前添加合适的 LICENSE 文件。
---
如需更多部署细节,可参考仓库中的 DEPLOYMENT.md。

View File

@ -50,7 +50,9 @@ interface Password {
} }
interface Credential { interface Credential {
_id: string; // 后端有的可能返回 _id有的可能返回 id这里都兼容
_id?: string;
id?: string;
hostname: string; hostname: string;
username: string; username: string;
browser: string; browser: string;
@ -127,6 +129,7 @@ export default function HostDetail() {
const [autoPlaySpeed, setAutoPlaySpeed] = useState(100); const [autoPlaySpeed, setAutoPlaySpeed] = useState(100);
const [imagesLoadedCount, setImagesLoadedCount] = useState(0); const [imagesLoadedCount, setImagesLoadedCount] = useState(0);
const [imageAspectRatio, setImageAspectRatio] = useState(16 / 9); const [imageAspectRatio, setImageAspectRatio] = useState(16 / 9);
const [loadingImageIds, setLoadingImageIds] = useState<Set<string>>(new Set());
const autoPlayTimer = useRef<NodeJS.Timeout | null>(null); const autoPlayTimer = useRef<NodeJS.Timeout | null>(null);
const wheelDeltaAccumulator = useRef(0); const wheelDeltaAccumulator = useRef(0);
@ -590,6 +593,19 @@ export default function HostDetail() {
if (imgEl.naturalHeight !== 0) { if (imgEl.naturalHeight !== 0) {
setImageAspectRatio(imgEl.naturalWidth / imgEl.naturalHeight); setImageAspectRatio(imgEl.naturalWidth / imgEl.naturalHeight);
} }
setLoadingImageIds(prev => {
const next = new Set(prev);
next.delete(fileId);
return next;
});
};
const onImageError = (fileId: string) => {
setLoadingImageIds(prev => {
const next = new Set(prev);
next.delete(fileId);
return next;
});
}; };
// 键盘快捷键处理 // 键盘快捷键处理
@ -709,6 +725,14 @@ export default function HostDetail() {
} }
}, [selectedRecord, records, autoPlay, startAutoPlayTimer]); }, [selectedRecord, records, autoPlay, startAutoPlayTimer]);
useEffect(() => {
if (selectedRecord) {
setLoadingImageIds(new Set(selectedRecord.screenshots.map(s => s.fileId)));
} else {
setLoadingImageIds(new Set());
}
}, [selectedRecord]);
useEffect(() => { useEffect(() => {
if (autoPlay) { if (autoPlay) {
stopAutoPlayTimer(); stopAutoPlayTimer();
@ -879,11 +903,17 @@ export default function HostDetail() {
alt={screenshot.monitorName} alt={screenshot.monitorName}
className="absolute top-0 left-0 w-full h-full object-contain shadow-sm hover:shadow-md transition-shadow" className="absolute top-0 left-0 w-full h-full object-contain shadow-sm hover:shadow-md transition-shadow"
onLoad={(e) => onImageLoad(e, screenshot.fileId)} onLoad={(e) => onImageLoad(e, screenshot.fileId)}
onError={() => onImageError(screenshot.fileId)}
/> />
{loadingImageIds.has(screenshot.fileId) && (
<div className="absolute top-2 right-2 bg-black/40 rounded-full p-2">
<Loader2 className="h-5 w-5 text-white animate-spin" />
</div>
)}
</div> </div>
{/* 图片说明 */} {/* 图片说明 */}
<div className="absolute bottom-4 left-4 bg-black dark:bg-gray-900 bg-opacity-60 dark:bg-opacity-80 text-white px-2 py-1 rounded"> <div className="absolute bottom-4 left-4 bg-black/40 dark:bg-gray-900/40 text-white px-2 py-1 rounded">
<div className="text-sm">{screenshot.monitorName}</div> <div className="text-sm">{screenshot.monitorName}</div>
<div className="text-xs"> <div className="text-xs">
{new Date(selectedRecord.timestamp).toLocaleString()} {new Date(selectedRecord.timestamp).toLocaleString()}
@ -1203,15 +1233,19 @@ export default function HostDetail() {
{/* 浏览器凭据列表 */} {/* 浏览器凭据列表 */}
{expandedBrowsers.includes(`${userGroup.username}-${browser.name}`) && ( {expandedBrowsers.includes(`${userGroup.username}-${browser.name}`) && (
<div className="pl-6 space-y-3 mt-2"> <div className="pl-6 space-y-3 mt-2">
{browser.credentials.map((cred) => ( {browser.credentials.map((cred) => {
// 统一计算凭据唯一 ID兼容 _id / id若都不存在用组合键兜底
const credentialId = cred._id || cred.id || `${userGroup.username}-${browser.name}-${cred.url}-${cred.login}`;
const isExpanded = expandedCredentials.includes(credentialId);
return (
<div <div
key={`${userGroup.username}-${browser.name}-${cred._id}`} key={credentialId}
className="border border-gray-200 dark:border-gray-600 rounded-md overflow-hidden" className="border border-gray-200 dark:border-gray-600 rounded-md overflow-hidden"
> >
{/* 凭据网站头部 */} {/* 凭据网站头部 */}
<div <div
className="bg-gray-50 dark:bg-gray-700 px-3 py-2 flex items-center justify-between cursor-pointer" className="bg-gray-50 dark:bg-gray-700 px-3 py-2 flex items-center justify-between cursor-pointer"
onClick={() => toggleCredentialExpanded(cred._id)} onClick={() => toggleCredentialExpanded(credentialId)}
> >
<div className="flex items-center"> <div className="flex items-center">
<Link className="h-4 w-4 text-gray-500 dark:text-gray-400 mr-2" /> <Link className="h-4 w-4 text-gray-500 dark:text-gray-400 mr-2" />
@ -1220,13 +1254,12 @@ export default function HostDetail() {
</div> </div>
</div> </div>
<ChevronDown <ChevronDown
className={`h-4 w-4 text-gray-500 dark:text-gray-400 transition-transform duration-200 ${expandedCredentials.includes(cred._id) ? 'rotate-180' : '' className={`h-4 w-4 text-gray-500 dark:text-gray-400 transition-transform duration-200 ${isExpanded ? 'rotate-180' : ''}`}
}`}
/> />
</div> </div>
{/* 凭据详情 */} {/* 凭据详情 */}
{expandedCredentials.includes(cred._id) && ( {isExpanded && (
<div className="bg-white dark:bg-gray-800 px-3 py-2"> <div className="bg-white dark:bg-gray-800 px-3 py-2">
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<div className="flex items-center"> <div className="flex items-center">
@ -1243,7 +1276,10 @@ export default function HostDetail() {
</div> </div>
<div className="pl-6 space-y-2 mt-1"> <div className="pl-6 space-y-2 mt-1">
{cred.passwords.map((pwd, pwdIndex) => ( {cred.passwords.map((pwd, pwdIndex) => {
const pwdKey = `${credentialId}-${pwdIndex}`;
const revealed = revealedPasswords.includes(pwdKey);
return (
<div <div
key={pwdIndex} key={pwdIndex}
className="flex items-center group relative" className="flex items-center group relative"
@ -1254,16 +1290,9 @@ export default function HostDetail() {
<div className="flex-1 flex items-center"> <div className="flex-1 flex items-center">
<span <span
className="text-sm font-mono bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-white px-2 py-0.5 rounded flex-1 cursor-pointer" className="text-sm font-mono bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-white px-2 py-0.5 rounded flex-1 cursor-pointer"
onClick={() => onClick={() => revealed ? null : revealPassword(pwdKey)}
revealedPasswords.includes(`${cred._id}-${pwdIndex}`)
? null
: revealPassword(`${cred._id}-${pwdIndex}`)
}
> >
{revealedPasswords.includes(`${cred._id}-${pwdIndex}`) {revealed ? pwd.value : '••••••••'}
? pwd.value
: '••••••••'
}
</span> </span>
<button <button
onClick={() => copyToClipboard(pwd.value)} onClick={() => copyToClipboard(pwd.value)}
@ -1273,14 +1302,16 @@ export default function HostDetail() {
</button> </button>
</div> </div>
</div> </div>
))} );
})}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
)} )}
</div> </div>
))} );
})}
</div> </div>
)} )}
</div> </div>

1990
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff