图片加载提示,info半透明
This commit is contained in:
parent
5a97bc2a8d
commit
b5929613ba
392
README.md
392
README.md
@ -1,188 +1,278 @@
|
|||||||
# Winupdate Neo
|
# Winupdate Neo
|
||||||
|
|
||||||
这是使用 Next.js + Prisma + PostgreSQL 重写的 Winupdate 项目的 API 部分。
|
一个基于 Next.js + Prisma + MinIO 的“主机截图与版本分发平台”。用于接收客户端上报的屏幕截图与窗口信息,保存到对象存储;配套 Web UI 浏览、检索与收藏记录,并提供将一段时间内的截图压制为视频的能力。同时支持客户端版本文件的上传与分发,以及主机浏览器凭据(含历史密码)入库与查询。
|
||||||
|
|
||||||
|
## 功能一览
|
||||||
|
|
||||||
|
- 主机与记录
|
||||||
|
- 接收主机上报的多张截图与窗口信息,自动建档并存储
|
||||||
|
- 按主机、时间范围检索记录;星标记录管理与分页
|
||||||
|
- 小时维度“活跃度”时间分布统计
|
||||||
|
- 媒体处理与下载
|
||||||
|
- 截图以 AV1(.avif)压缩后存入 MinIO
|
||||||
|
- 将一段时间内的截图转码为 MP4(SVT-AV1 编码)供下载
|
||||||
|
- 文件直链下载(版本文件/截图等)
|
||||||
|
- 凭据采集
|
||||||
|
- 上报主机浏览器凭据(含历史密码版本),去重合并保存
|
||||||
|
- 版本分发
|
||||||
|
- 上传客户端新版本(.exe),自动生成校验和与下载地址,并标记最新
|
||||||
|
- 查询最新版本信息与下载链接
|
||||||
|
- 计划任务与通知
|
||||||
|
- node-cron 定时任务管理(示例:整点任务、每日清理)与前端管理页 /tasks
|
||||||
|
- 可选 QQ 机器人 WebHook 推送“主机上线/离线”等通知
|
||||||
|
- 安全与访问控制
|
||||||
|
- 页面侧(非 API)支持 Basic Auth;API 默认允许跨域(withCors)
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|
||||||
- **前端框架**: Next.js 15
|
- Next.js 15 + React 19 + TypeScript
|
||||||
- **数据库**: PostgreSQL + Prisma ORM
|
- Prisma 6(PostgreSQL)
|
||||||
- **包管理器**: Bun
|
- MinIO(S3 兼容对象存储)
|
||||||
- **文件存储**: MinIO(替代 GridFS)
|
- node-cron 定时任务
|
||||||
- **语言**: TypeScript
|
- Tailwind CSS v4
|
||||||
|
- 运行与进程管理:Bun、PM2
|
||||||
|
- 媒体处理:FFmpeg(libsvtav1)
|
||||||
|
|
||||||
## 项目结构
|
## 目录结构(节选)
|
||||||
|
|
||||||
```
|
```
|
||||||
app/
|
app/
|
||||||
├── api/ # API 路由
|
hosts/
|
||||||
│ ├── hosts/
|
route.ts # 主机列表 API(GET)
|
||||||
│ │ ├── 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
|
||||||
|
|
||||||
|
# Database(PostgreSQL)
|
||||||
|
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 配置会读取当前目录下的 .env(pm2.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/memory),memory 为 BigInt
|
||||||
|
- Screenshot:截图文件元信息,核心为 objectName(MinIO 对象名)
|
||||||
|
- 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}/screenshots(multipart/form-data)
|
||||||
|
- 字段:windows_info(JSON 字符串),screenshot_0..n(文件)
|
||||||
|
- GET /hosts/{hostname}/screenshots?startTime=...&endTime=...
|
||||||
|
- 支持 Unix 秒或 ISO 时间,返回 records + windows + screenshots(windows.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}/credentials(Body 为特殊数组结构,首项形如 ["User", "username"],其后为浏览器项)
|
||||||
|
- GET /hosts/{hostname}/credentials(包含密码历史,降序)
|
||||||
|
- 时间分布统计(小时)
|
||||||
|
- GET /hosts/{hostname}/time-distribution?from=ISO&to=ISO
|
||||||
|
- 返回:[{ timestamp: 秒, count }...]
|
||||||
|
- 截图文件
|
||||||
|
- GET /screenshots/{fileId}
|
||||||
|
- 版本文件下载
|
||||||
|
- GET /downloads/{fileId}
|
||||||
|
- 最新版本查询
|
||||||
|
- GET /version(App 路由)或 GET /api/version(API 路由),均返回 { 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。
|
||||||
|
|||||||
@ -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
1990
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user