全量提交: base_device子查询修复+SyncDevices父设备ParentDeviceId=0+KmsAdapter+前端多项修复

This commit is contained in:
2026-06-18 11:14:52 +08:00
parent 1fb746c548
commit a38f891197
236 changed files with 43274 additions and 424 deletions
File diff suppressed because one or more lines are too long
@@ -1,6 +1,8 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using System;
using System.IO;
namespace VolPro.WebApi.Controllers.Warehouse; namespace VolPro.WebApi.Controllers.Warehouse;
@@ -2,16 +2,26 @@
*设备管理扩展 — 区域树 + 点位设备列表 *设备管理扩展 — 区域树 + 点位设备列表
*所有改动在 Partial 目录,不破坏框架可升级性 *所有改动在 Partial 目录,不破坏框架可升级性
*/ */
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.AspNetCore.Http;
using VolPro.Entity.DomainModels;
using Warehouse.IServices;
using System.Linq; using System.Linq;
using Microsoft.EntityFrameworkCore; using System.Threading.Tasks;
using VolPro.Core.BaseProvider;
using VolPro.Core.Configuration;
using VolPro.Core.Enums;
using VolPro.Core.Extensions;
using VolPro.Core.Filters;
using VolPro.Core.ManageUser;
using VolPro.Entity.DomainModels;
using VolPro.Sys.IRepositories;
using VolPro.Sys.Repositories;
using Warehouse.IRepositories;
using Warehouse.IServices;
using Warehouse.Repositories;
namespace Warehouse.Controllers namespace Warehouse.Controllers
{ {
@@ -20,6 +30,7 @@ namespace Warehouse.Controllers
private readonly Ibase_deviceService _service;//访问业务代码 private readonly Ibase_deviceService _service;//访问业务代码
private readonly Iwarehouse_regionsService _regionsService; private readonly Iwarehouse_regionsService _regionsService;
private readonly Iwarehouse_devicepointService _pointService; private readonly Iwarehouse_devicepointService _pointService;
private readonly Ibase_deviceRepository _repository;
private readonly IHttpContextAccessor _httpContextAccessor; private readonly IHttpContextAccessor _httpContextAccessor;
[ActivatorUtilitiesConstructor] [ActivatorUtilitiesConstructor]
@@ -27,6 +38,7 @@ namespace Warehouse.Controllers
Ibase_deviceService service, Ibase_deviceService service,
Iwarehouse_regionsService regionsService, Iwarehouse_regionsService regionsService,
Iwarehouse_devicepointService pointService, Iwarehouse_devicepointService pointService,
Ibase_deviceRepository repository,
IHttpContextAccessor httpContextAccessor IHttpContextAccessor httpContextAccessor
) )
: base(service) : base(service)
@@ -34,6 +46,7 @@ namespace Warehouse.Controllers
_service = service; _service = service;
_regionsService = regionsService; _regionsService = regionsService;
_pointService = pointService; _pointService = pointService;
_repository = repository;
_httpContextAccessor = httpContextAccessor; _httpContextAccessor = httpContextAccessor;
} }
@@ -113,15 +126,137 @@ namespace Warehouse.Controllers
.OrderBy(x => x.DeviceId) .OrderBy(x => x.DeviceId)
.Select(x => new .Select(x => new
{ {
x.DeviceId, x.DeviceName, x.AdapterCode, x.SourceId, x.DeviceId,
x.DeviceCategory, x.DeviceGroup, x.IsParent, x.DeviceName,
x.ParentDeviceId, x.IsOnline, x.IpAddress, x.Port, x.AdapterCode,
x.Location, x.ExtraData, x.LastSyncTime, x.SourceId,
x.MapModelId, x.MapModelScale, x.MapModelRotation, x.Enable x.DeviceCategory,
x.DeviceGroup,
x.IsParent,
x.ParentDeviceId,
x.IsOnline,
x.IpAddress,
x.Port,
x.Location,
x.ExtraData,
x.LastSyncTime,
x.MapModelId,
x.MapModelScale,
x.MapModelRotation,
x.Enable
}) })
.ToListAsync(); .ToListAsync();
return Ok(new { items, total }); return Ok(new { items, total });
} }
/// <summary>
/// treetable 获取子节点数据(2021.05.02)
/// </summary>
/// <param name="loadData"></param>
/// <returns></returns>
[ApiActionPermission(ActionPermissionOptions.Search)]
[HttpPost, Route("GetPageData")]
public override ActionResult GetPageData([FromBody] PageDataOptions loadData)
{
return GetTreeTableRootData(loadData).Result;
}
/// <summary>
/// treetable 获取一级(根)节点数据
/// </summary>
/// <returns></returns>
[HttpPost, Route("getTreeTableRootData")]
[ApiActionPermission(ActionPermissionOptions.Search)]
public async Task<ActionResult> GetTreeTableRootData([FromBody] PageDataOptions options)
{
//页面加载根节点数据条件x => x.ParentId == 0,自己根据需要设置
var dbServiceId = UserContext.CurrentServiceId;
var query = _repository.FindAsIQueryable(x => x.ParentDeviceId == 0 || x.DeviceId == 1);
var queryChild = _repository.FindAsIQueryable(x => true);
// 先获取总数
var total = await query.CountAsync();
// 按需先排序,再 Skip/Take 分页
var rows = await query
.OrderBy(x => x.DeviceId)
.Skip((options.Page - 1) * options.Rows)
.Take(options.Rows)
.Select(s => new
{
s.DeviceId,
s.ParentDeviceId,
s.DeviceName,
s.AdapterCode,
s.SourceId,
s.DeviceCategory,
s.DeviceGroup,
s.IsParent,
s.IsOnline,
s.IpAddress,
s.Port,
s.Location,
s.ExtraData,
s.LastSyncTime,
s.MapModelId,
s.MapModelScale,
s.MapModelRotation,
s.Enable,
s.CreateDate,
s.Creator,
s.Modifier,
s.ModifyDate,
hasChildren = SqlSugar.SqlFunc.Subqueryable<base_device>().Where(x => x.ParentDeviceId == s.DeviceId).Any()
}).ToListAsync();
return JsonNormal(new { total, rows });
}
/// <summary>
///treetable 获取子节点数据
/// </summary>
/// <returns></returns>
[HttpPost, Route("getTreeTableChildrenData")]
[ApiActionPermission(ActionPermissionOptions.Search)]
public async Task<ActionResult> GetTreeTableChildrenData(int deviceId)
{
//点击节点时,加载子节点数据
var basedeviceRepository = base_deviceRepository.Instance.FindAsIQueryable(x => true);
var query = basedeviceRepository.Where(x => x.ParentDeviceId == deviceId);
//if (AppSetting.UseDynamicShareDB)
//{
// query = query.Where(x => x.DbServiceId == UserContext.CurrentServiceId);
//}
var rows = await query
.Select(s => new
{
s.DeviceId,
s.ParentDeviceId,
s.DeviceName,
s.AdapterCode,
s.SourceId,
s.DeviceCategory,
s.DeviceGroup,
s.IsParent,
s.IsOnline,
s.IpAddress,
s.Port,
s.Location,
s.ExtraData,
s.LastSyncTime,
s.MapModelId,
s.MapModelScale,
s.MapModelRotation,
s.Enable,
s.CreateDate,
s.Creator,
s.Modifier,
s.ModifyDate,
hasChildren = SqlSugar.SqlFunc.Subqueryable<base_device>().Where(x => x.ParentDeviceId == s.DeviceId).Any()
}).ToListAsync();
return JsonNormal(new { rows });
}
} }
} }
@@ -0,0 +1,9 @@
中文提示 : 连接数据库过程中发生错误,检查服务器是否正常连接字符串是否正确,错误信息:Connect Timeout expired.DbType="MySql";ConfigId="default".
English Message : Connection open error . Connect Timeout expired.DbType="MySql";ConfigId="default" at SqlSugar.Check.Exception(Boolean isException, String message, String[] args)
at SqlSugar.AdoProvider.CheckConnection()
at SqlSugar.AdoProvider.Open()
at SqlSugar.MySqlFastBuilder.ExecuteBulkCopyAsync(DataTable dt)
at SqlSugar.FastestProvider`1._BulkCopy(List`1 datas)
at SqlSugar.FastestProvider`1.BulkCopyAsync(List`1 datas)
at SqlSugar.FastestProvider`1.BulkCopy(List`1 datas)
at VolPro.Core.Services.Logger.Start() in D:\Code\SecMPS\api_sqlsugar\VolPro.Core\Services\Logger.cs:line 194SqlSugar
+138 -130
View File
@@ -1,140 +1,148 @@
{ {
"Logging": { "Logging": {
"LogLevel": { "LogLevel": {
"Default": "Information", "Default": "Information",
"Microsoft": "Warning", "Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information" "Microsoft.Hosting.Lifetime": "Information"
} }
}, },
"AllowedHosts": "*", "AllowedHosts": "*",
"VirtualPath": { "VirtualPath": {
"StaticFile": "E:\\Web\\Static", //配置的虚拟目录文件所在路径 "StaticFile": "E:\\Web\\Static", //配置的虚拟目录文件所在路径
"FolderName": "/Static" //访问时此路径时的别名 "FolderName": "/Static" //访问时此路径时的别名
}, },
"AppUrls": { "AppUrls": {
}, },
"Connection": { "Connection": {
//"DBType": "MsSql", //MySql/MsSql/PgSql/Oracle/Kdbndp //数据库类型,如果使用的是sqlserver此处应设置为MsSql //"DBType": "MsSql", //MySql/MsSql/PgSql/Oracle/Kdbndp //数据库类型,如果使用的是sqlserver此处应设置为MsSql
// sqlserver系统库 // sqlserver系统库
//"DbConnectionString": "Data Source=localhost;Initial Catalog=vol_pro_main;Persist Security Info=True;User ID=sa;Password=2w1q821130@W!Q;Connect Timeout=500;Encrypt=True;TrustServerCertificate=True;", //"DbConnectionString": "Data Source=localhost;Initial Catalog=vol_pro_main;Persist Security Info=True;User ID=sa;Password=2w1q821130@W!Q;Connect Timeout=500;Encrypt=True;TrustServerCertificate=True;",
////业务库1(与EFDBContext文件夹中一致) ////业务库1(与EFDBContext文件夹中一致)
//"ServiceDbContext": "Data Source=localhost;Initial Catalog=vol_pro_service;Persist Security Info=True;User ID=sa;Password=2w1q821130@W!Q;Connect Timeout=500;Encrypt=True;TrustServerCertificate=True;", //"ServiceDbContext": "Data Source=localhost;Initial Catalog=vol_pro_service;Persist Security Info=True;User ID=sa;Password=2w1q821130@W!Q;Connect Timeout=500;Encrypt=True;TrustServerCertificate=True;",
///业务库2(与EFDBContext文件夹中一致) ///业务库2(与EFDBContext文件夹中一致)
//"TestDbContext": "Data Source=localhost;Initial Catalog=vol_pro_test;Persist Security Info=True;User ID=sa;Password=123456;Connect Timeout=500;", //"TestDbContext": "Data Source=localhost;Initial Catalog=vol_pro_test;Persist Security Info=True;User ID=sa;Password=123456;Connect Timeout=500;",
//业务库3(与EFDBContext文件夹中一致) //业务库3(与EFDBContext文件夹中一致)
// "自定义DbContext": "Data Source=127.0.0.1;Initial Catalog=vol_pro_test;Persist Security Info=True;User ID=sa;Password=127.0.0.1;Connect Timeout=500;", // "自定义DbContext": "Data Source=127.0.0.1;Initial Catalog=vol_pro_test;Persist Security Info=True;User ID=sa;Password=127.0.0.1;Connect Timeout=500;",
////mysql系统库连接字符串======================================================= ////mysql系统库连接字符串=======================================================
"DBType": "MySql", //"DBType": "MySql",
"DbConnectionString": " Data Source=localhost;Database=gljs_main;User ID=gljs;Password=2w1q821130@W!Q;AllowLoadLocalInfile=true;", //"DbConnectionString": " Data Source=localhost;Database=gljs_main;User ID=gljs;Password=2w1q821130@W!Q;AllowLoadLocalInfile=true;",
//业务库1(与EFDBContext文件夹中一致) ////业务库1(与EFDBContext文件夹中一致)
"ServiceDbContext": " Data Source=localhost;Database=gljs_service;User ID=gljs;Password=2w1q821130@W!Q;AllowLoadLocalInfile=true;", //"ServiceDbContext": " Data Source=localhost;Database=gljs_service;User ID=gljs;Password=2w1q821130@W!Q;AllowLoadLocalInfile=true;",
//业务库2(与EFDBContext文件夹中一致) ////业务库2(与EFDBContext文件夹中一致)
"TestDbContext": " Data Source=localhost;Database=gljs_test;User ID=gljs;Password=2w1q821130@W!Q;AllowLoadLocalInfile=true;", //"TestDbContext": " Data Source=localhost;Database=gljs_test;User ID=gljs;Password=2w1q821130@W!Q;AllowLoadLocalInfile=true;",
////PgSql系统库连接字符串
// "DbConnectionString": "Host=127.0.0.1;Port=5432;User id=postgres;password=127.0.0.1;Database=vol_pro_main;", "DBType": "MySql",
////业务库1(与EFDBContext文件夹中一致) "DbConnectionString": "Data Source=192.168.3.108;Database=gljs_main;User ID=root;Password=2w1q821130@W!Q;AllowLoadLocalInfile=true;",
// "ServiceDbContext": "Host=127.0.0.1;Port=5432;User id=postgres;password=127.0.0.1;Database=vol_pro_service;", //业务库1(与EFDBContext文件夹中一致)
// //业务库2(与EFDBContext文件夹中一致) "ServiceDbContext": "Data Source=192.168.3.108;Database=gljs_service;User ID=root;Password=2w1q821130@W!Q;AllowLoadLocalInfile=true;",
// "TestDbContext": "Host=127.0.0.1;Port=5432;User id=postgres;password=127.0.0.1;Database=vol_pro_test;", //业务库2(与EFDBContext文件夹中一致)
"TestDbContext": "Data Source=192.168.3.108;Database=gljs_test;User ID=root;Password=2w1q821130@W!Q;AllowLoadLocalInfile=true;",
////PgSql系统库连接字符串
// "DbConnectionString": "Host=127.0.0.1;Port=5432;User id=postgres;password=127.0.0.1;Database=vol_pro_main;",
////业务库1(与EFDBContext文件夹中一致)
// "ServiceDbContext": "Host=127.0.0.1;Port=5432;User id=postgres;password=127.0.0.1;Database=vol_pro_service;",
// //业务库2(与EFDBContext文件夹中一致)
// "TestDbContext": "Host=127.0.0.1;Port=5432;User id=postgres;password=127.0.0.1;Database=vol_pro_test;",
////人大金仓系统库连接字符串================================================= ////人大金仓系统库连接字符串=================================================
// "DbConnectionString": "Host=127.0.0.1;Port=54321;User id=postgres;password=127.0.0.1;Database=vol_pro_main;", // "DbConnectionString": "Host=127.0.0.1;Port=54321;User id=postgres;password=127.0.0.1;Database=vol_pro_main;",
////业务库1(与EFDBContext文件夹中一致) ////业务库1(与EFDBContext文件夹中一致)
// "ServiceDbContext": "Host=127.0.0.1;Port=54321;User id=postgres;password=127.0.0.1;Database=vol_pro_service;", // "ServiceDbContext": "Host=127.0.0.1;Port=54321;User id=postgres;password=127.0.0.1;Database=vol_pro_service;",
// //业务库2(与EFDBContext文件夹中一致) // //业务库2(与EFDBContext文件夹中一致)
// "TestDbContext": "Host=127.0.0.1;Port=5432;User id=postgres;password=127.0.0.1;Database=vol_pro_test;", // "TestDbContext": "Host=127.0.0.1;Port=5432;User id=postgres;password=127.0.0.1;Database=vol_pro_test;",
//Oracle连接字符串 //Oracle连接字符串
//"DbConnectionString": "user id=C##VOL_PRO_MAIN;data source=127.0.0.1/ORCL;password=123456;", //"DbConnectionString": "user id=C##VOL_PRO_MAIN;data source=127.0.0.1/ORCL;password=123456;",
"RedisConnectionString": "127.0.0.1,Password=123456,SyncTimeout=15000", //redis连接字符串 "RedisConnectionString": "127.0.0.1,Password=123456,SyncTimeout=15000", //redis连接字符串
"UseRedis": "false", //是否使用redis,如果不使用,默认使用Memory内置缓存 "UseRedis": "false", //是否使用redis,如果不使用,默认使用Memory内置缓存
"UseSignalR": "true" //是否使用SignalR(2022.05.03),注意需要将端的地址配置到下面的CorsUrls属性中 "UseSignalR": "true" //是否使用SignalR(2022.05.03),注意需要将端的地址配置到下面的CorsUrls属性中
}, },
"Secret": { //秘钥配置 "Secret": { //秘钥配置
"JWT": "BB3647441FFA4B5DB4E64A29B53CE525", //JWT这里请一定要修改(随便换个值) "JWT": "BB3647441FFA4B5DB4E64A29B53CE525", //JWT这里请一定要修改(随便换个值)
"Audience": "vol.core", "Audience": "vol.core",
"Issuer": "VolPro.core.owner", "Issuer": "VolPro.core.owner",
"User": "C5ABA9E202D94C43A3CA66002BF77FAF", //用户表加密key,这里请一定要修改(随便换个值),修改后打开Sys_UserSerivce.cs中login方法,判断密码那行注掉,登录后修改密码再取消注释 "User": "C5ABA9E202D94C43A3CA66002BF77FAF", //用户表加密key,这里请一定要修改(随便换个值),修改后打开Sys_UserSerivce.cs中login方法,判断密码那行注掉,登录后修改密码再取消注释
"DB": "", "DB": "",
"Redis": "E6D90DDBC70C4F4EA3C312B6FCB473C8" "Redis": "E6D90DDBC70C4F4EA3C312B6FCB473C8"
},
//多个url用豆号隔开,url为vue站点的地址
"CorsUrls": "http://localhost:9000,http://127.0.0.1:9000,http://192.168.2.201:9000,http://192.168.2.200:9000",
"ExpMinutes": "120", //JWT有效期(分钟=默认120),
"CreateMember": { //对表插入数据时,需要记录创建人/创建时间/创建日期,配置UserIdField/UserNameField/DateField分别为对应数据库的创建人CreateID,创建人Creator,创建时间CreateDate字段(新建数据时,由框架默认完成给这几个字段赋值,字段区分大小写)或可手动调用T.SetCreateDefaultVal()完成设置创建人/创建时间/创建日期
//如果表的主键是GUID,界面查询时默认会用到DateField对应的实体(数据库)字段进行排序
"UserIdField": "CreateID",
"UserNameField": "Creator",
"DateField": "CreateDate"
},
"ModifyMember": { //修改同上
"UserIdField": "ModifyID",
"UserNameField": "Modifier",
"DateField": "ModifyDate"
}, //演示系统过滤Action,只有超级管理员才能操作,其他用户只有只读权限
"GlobalFilter": {
"Message": "演示环境,当前帐号没有开启此功能权限",
"Enable": "false", //开启Action过滤
"Actions": [ "Update", "Del", "Add", "SavePermission", "Save", "ExecSql", "CreatePage", "CreateVuePage", "CreateEntityModel", "SaveEidt", "CreateServices", "Import", "Upload", "Audit", "ModifyPwd" ]
},
"Kafka": {
//是否使用生产者
"UseProducer": false,
"ProducerSettings": {
"BootstrapServers": "192.168.20.241:9092", //confluent cloud bootstrap servers
"SaslMechanism": "Plain",
"SecurityProtocol": "SaslSsl",
"SaslUsername": "<confluent cloud key>",
"SaslPassword": "<confluent cloud secret>"
}, },
//多个url用豆号隔开,url为vue站点的地址 //是否使用消费者
"CorsUrls": "http://localhost:9000,http://127.0.0.1:9000,http://192.168.2.201:9000,http://192.168.2.200:9000", "UseConsumer": false,
"ExpMinutes": "120", //JWT有效期(分钟=默认120), //是否持续监听消费者订阅 用于while循环订阅
"CreateMember": { //对表插入数据时,需要记录创建人/创建时间/创建日期,配置UserIdField/UserNameField/DateField分别为对应数据库的创建人CreateID,创建人Creator,创建时间CreateDate字段(新建数据时,由框架默认完成给这几个字段赋值,字段区分大小写)或可手动调用T.SetCreateDefaultVal()完成设置创建人/创建时间/创建日期 "IsConsumerSubscribe": true,
//如果表的主键是GUID,界面查询时默认会用到DateField对应的实体(数据库)字段进行排序 "ConsumerSettings": {
"UserIdField": "CreateID", "BootstrapServers": "192.168.20.241:9092", //confluent cloud bootstrap servers
"UserNameField": "Creator", "GroupId": "amcl_group", //web-example-group
"DateField": "CreateDate" "SaslMechanism": "Plain",
"SecurityProtocol": "SaslSsl",
"SaslUsername": "<confluent cloud key>",
"SaslPassword": "<confluent cloud secret>"
}, },
"ModifyMember": { //修改同上 "Topics": {
"UserIdField": "ModifyID", "TestTopic": "alarm_topic"
"UserNameField": "Modifier", }
"DateField": "ModifyDate" },
}, //演示系统过滤Action,只有超级管理员才能操作,其他用户只有只读权限 "Mail": {
"GlobalFilter": { "Address": "code283591387@163.com", //发件的邮箱
"Message": "演示环境,当前帐号没有开启此功能权限", "Host": "smtp.163.com",
"Enable": "false", //开启Action过滤 "Name": "VOL", //发送人名称
"Actions": [ "Update", "Del", "Add", "SavePermission", "Save", "ExecSql", "CreatePage", "CreateVuePage", "CreateEntityModel", "SaveEidt", "CreateServices", "Import", "Upload", "Audit", "ModifyPwd" ] "Port": 25,
}, "EnableSsl": false,
"Kafka": { "AuthPwd": "授权密码" //授权密码(对应邮箱设置里面去开启)
//是否使用生产者 },
"UseProducer": false, "android": {
"ProducerSettings": { "version": "1.0.0",
"BootstrapServers": "192.168.20.241:9092", //confluent cloud bootstrap servers "url": "", //将打包后的安卓apk放在后台wwwroot的app文件夹下
"SaslMechanism": "Plain", "desc": ""
"SecurityProtocol": "SaslSsl", },
"SaslUsername": "<confluent cloud key>", "ios": {
"SaslPassword": "<confluent cloud secret>" "version": "1.0.2",
}, "url": "itms-apps://itunes.apple.com/cn/app/123456?mt=8",
//是否使用消费者 "desc": ""
"UseConsumer": false, },
//是否持续监听消费者订阅 用于while循环订阅 "UseSnow": "0", //是否使用雪花算法(表的主键字段为bigint类型时启用雪花算法生成唯一id; 1=是,0=否)
"IsConsumerSubscribe": true, "QuartzAccessKey": "65EC9387355E4717899C552963CE59X1", //定时任务的值,请自行修改
"ConsumerSettings": { "LogicDelField": "IsDel", //逻辑删除字段(对应表字段,逻辑删除只会将字段的值设置为1,默认是0)
"BootstrapServers": "192.168.20.241:9092", //confluent cloud bootstrap servers "TenancyField": "", //表的租户字段(用于不分库租户数据隔离,如表字段:TenancyId)(使用动态分库功能此字段用不上)
"GroupId": "amcl_group", //web-example-group "UseDynamicShareDB": "0", //使用动态分库(每个客户对应一个独立数据库)
"SaslMechanism": "Plain", "DBPath": "E:\\db\\", //数据库所在的磁盘目录(动态生成数据库时使用,注意:所有数据库都必须在同一个目录,并且把db文件夹下的Db_Empty库也要创建)
"SecurityProtocol": "SaslSsl", "DBBackPath": "E:\\db\\dbbak", //数据库备份磁盘所在目录(动态生成数据库时使用)
"SaslUsername": "<confluent cloud key>", "UserAuth": "0", //是否使用用户权限(限制只能看到指定用户创建的数据,用户管理页面的操作列可以看到此功能,设置为1后生效)
"SaslPassword": "<confluent cloud secret>" "ModelInService": "0", //表的model类是否生成到当前业务类库下(默认都在VolPro.Entity)
}, "FileAuth": "0", //2023.12.25所有静态文件访问授权
"Topics": { "montior": "0", //开启服务器性能监控(1=开启)
"TestTopic": "alarm_topic" "DbTable": "0" //开启界面上数据库表维护功能(1=开启)
}
},
"Mail": {
"Address": "code283591387@163.com", //发件的邮箱
"Host": "smtp.163.com",
"Name": "VOL", //发送人名称
"Port": 25,
"EnableSsl": false,
"AuthPwd": "授权密码" //授权密码(对应邮箱设置里面去开启)
},
"android": {
"version": "1.0.0",
"url": "", //将打包后的安卓apk放在后台wwwroot的app文件夹下
"desc": ""
},
"ios": {
"version": "1.0.2",
"url": "itms-apps://itunes.apple.com/cn/app/123456?mt=8",
"desc": ""
},
"UseSnow": "0", //是否使用雪花算法(表的主键字段为bigint类型时启用雪花算法生成唯一id; 1=是,0=否)
"QuartzAccessKey": "65EC9387355E4717899C552963CE59X1", //定时任务的值,请自行修改
"LogicDelField": "IsDel", //逻辑删除字段(对应表字段,逻辑删除只会将字段的值设置为1,默认是0)
"TenancyField": "", //表的租户字段(用于不分库租户数据隔离,如表字段:TenancyId)(使用动态分库功能此字段用不上)
"UseDynamicShareDB": "0", //使用动态分库(每个客户对应一个独立数据库)
"DBPath": "E:\\db\\", //数据库所在的磁盘目录(动态生成数据库时使用,注意:所有数据库都必须在同一个目录,并且把db文件夹下的Db_Empty库也要创建)
"DBBackPath": "E:\\db\\dbbak", //数据库备份磁盘所在目录(动态生成数据库时使用)
"UserAuth": "0", //是否使用用户权限(限制只能看到指定用户创建的数据,用户管理页面的操作列可以看到此功能,设置为1后生效)
"ModelInService": "0", //表的model类是否生成到当前业务类库下(默认都在VolPro.Entity)
"FileAuth": "0", //2023.12.25所有静态文件访问授权
"montior": "0", //开启服务器性能监控(1=开启)
"DbTable": "0" //开启界面上数据库表维护功能(1=开启)
} }
@@ -178,7 +178,7 @@ namespace Warehouse.Services
DeviceGroup = d.Group, DeviceGroup = d.Group,
NodeId = gatewayNodeId, NodeId = gatewayNodeId,
IsParent = d.IsParent ? "是" : "否", IsParent = d.IsParent ? "是" : "否",
ParentDeviceId = parentDeviceId, ParentDeviceId = d.IsParent ? 0 : parentDeviceId,
IsOnline = d.IsOnline ? "在线" : "离线", IsOnline = d.IsOnline ? "在线" : "离线",
IpAddress = d.IpAddress, IpAddress = d.IpAddress,
Port = d.Port, Port = d.Port,
@@ -194,13 +194,13 @@ namespace Warehouse.Services
} }
else else
{ {
var entity = db.Queryable<base_device>().InSingle(existingId); var entity = await db.Queryable<base_device>().FirstAsync(x => x.DeviceId == existingId);
if (entity != null) if (entity != null)
{ {
entity.NodeId = gatewayNodeId; // 重新归属到当前网关 entity.NodeId = gatewayNodeId; // 重新归属到当前网关
entity.IsOnline = d.IsOnline ? "在线" : "离线"; entity.IsOnline = d.IsOnline ? "在线" : "离线";
entity.IsParent = d.IsParent ? "是" : "否"; entity.IsParent = d.IsParent ? "是" : "否";
entity.ParentDeviceId = parentDeviceId ?? entity.ParentDeviceId; entity.ParentDeviceId = d.IsParent ? 0 : (parentDeviceId ?? entity.ParentDeviceId);
entity.IpAddress = d.IpAddress; entity.IpAddress = d.IpAddress;
entity.Port = d.Port; entity.Port = d.Port;
entity.ExtraData = d.ExtraDataJson ?? entity.ExtraData; entity.ExtraData = d.ExtraDataJson ?? entity.ExtraData;
@@ -397,7 +397,16 @@ OpenerIds = parameters.TryGetValue("lockholeSort", out var lh) ? new List<int> {
if (dataType != "staff") return new SyncResult { SuccessCount = 0, FailCount = 0, Message = $"不支持的数据类型: {dataType}" }; if (dataType != "staff") return new SyncResult { SuccessCount = 0, FailCount = 0, Message = $"不支持的数据类型: {dataType}" };
try try
{ {
var staffList = items.Cast<KmsStaff>().ToList(); var staffList = new List<KmsStaff>();
foreach (var item in items)
{
if (item is System.Text.Json.JsonElement je)
staffList.Add(System.Text.Json.JsonSerializer.Deserialize<KmsStaff>(je.GetRawText(), JsonOpts)!);
else if (item is KmsStaff ks)
staffList.Add(ks);
else
_logger.LogWarning("[{Code}] SyncData staff: 跳过未知类型 {Type}", AdapterCode, item?.GetType().Name);
}
await BatchSyncStaffAsync(staffList); await BatchSyncStaffAsync(staffList);
return new SyncResult { SuccessCount = staffList.Count }; return new SyncResult { SuccessCount = staffList.Count };
} }
@@ -410,6 +419,7 @@ OpenerIds = parameters.TryGetValue("lockholeSort", out var lh) ? new List<int> {
if (dataType != "staff") return new SyncResult { SuccessCount = 0, FailCount = 0, Message = $"不支持的数据类型: {dataType}" }; if (dataType != "staff") return new SyncResult { SuccessCount = 0, FailCount = 0, Message = $"不支持的数据类型: {dataType}" };
try try
{ {
// B13 DELETE: ids 是 string 数组, 无需反序列化
await BatchDeleteStaffAsync(ids); await BatchDeleteStaffAsync(ids);
return new SyncResult { SuccessCount = ids.Count }; return new SyncResult { SuccessCount = ids.Count };
} }
@@ -237,7 +237,7 @@ app.MapGet("/api/gateway/streams/{adapter}/{deviceId}/live", async (string adapt
var a = registry.FindByCode<IHasStreams>(adapter); var a = registry.FindByCode<IHasStreams>(adapter);
if (a == null) return Results.NotFound(new { error = "CAPABILITY_NOT_SUPPORTED", message = $"适配器 '{adapter}' 不支持视频取流" }); if (a == null) return Results.NotFound(new { error = "CAPABILITY_NOT_SUPPORTED", message = $"适配器 '{adapter}' 不支持视频取流" });
var result = await a.GetLiveUrlAsync(deviceId); var result = await a.GetLiveUrlAsync(deviceId);
return result.WsFlv == null && result.Hls == null return result.WsFlv == null && result.Hls == null && result.WebRtc == null
? Results.Problem("未获取到流地址", statusCode: 502) ? Results.Problem("未获取到流地址", statusCode: 502)
: Results.Ok(result); : Results.Ok(result);
}); });
@@ -44,7 +44,7 @@
"VolProBaseUrl": "http://192.168.3.108:9100", "VolProBaseUrl": "http://192.168.3.108:9100",
"NodeCode": "gw-test", "NodeCode": "gw-test",
"NodeToken": "changeme", "NodeToken": "changeme",
"SelfUrl": "http://192.168.3.108:5100", "SelfUrl": "http://192.168.3.110:5100",
"HeartbeatIntervalSec": 15, "HeartbeatIntervalSec": 15,
"AdapterInitTimeoutSec": 30, "AdapterInitTimeoutSec": 30,
"GatewayKey": null, "GatewayKey": null,
+4
View File
@@ -0,0 +1,4 @@
.react-router
build
node_modules
README.md
+4
View File
@@ -0,0 +1,4 @@
VITE_API_BASE_URL=/api
# 路由前缀
VITE_BASENAME=/web/
+4
View File
@@ -0,0 +1,4 @@
VITE_API_BASE_URL=/
# 路由前缀 - 必须与 react-router.config.ts 中的 basename 一致
VITE_BASENAME=/web/
+58
View File
@@ -0,0 +1,58 @@
name: Build and Release
on:
push:
branches:
- main
permissions:
contents: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Enable Corepack
run: corepack enable
- name: Install dependencies
run: yarn install --immutable
- name: Build
run: yarn build
- name: Rename dist to www and create zip
run: |
mv dist www
zip -r www.zip www
- name: Delete existing latest release
uses: dev-drprasad/delete-tag-and-release@v1.1
with:
tag_name: latest
github_token: ${{ secrets.GITHUB_TOKEN }}
delete_release: true
continue-on-error: true
- name: Create latest release
uses: softprops/action-gh-release@v2
with:
tag_name: latest
name: Latest Build
body: |
自动构建的最新版本
构建时间: ${{ github.event.head_commit.timestamp }}
提交: ${{ github.sha }}
files: www.zip
prerelease: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+10
View File
@@ -0,0 +1,10 @@
.DS_Store
/node_modules/
# React Router
/.react-router/
/build/
dist/
.cursor/
.remember/
stats.html
Binary file not shown.
File diff suppressed because one or more lines are too long
+3
View File
@@ -0,0 +1,3 @@
nodeLinker: node-modules
yarnPath: .yarn/releases/yarn-4.9.1.cjs
+22
View File
@@ -0,0 +1,22 @@
FROM node:20-alpine AS development-dependencies-env
COPY . /app
WORKDIR /app
RUN npm ci
FROM node:20-alpine AS production-dependencies-env
COPY ./package.json package-lock.json /app/
WORKDIR /app
RUN npm ci --omit=dev
FROM node:20-alpine AS build-env
COPY . /app/
COPY --from=development-dependencies-env /app/node_modules /app/node_modules
WORKDIR /app
RUN npm run build
FROM node:20-alpine
COPY ./package.json package-lock.json /app/
COPY --from=production-dependencies-env /app/node_modules /app/node_modules
COPY --from=build-env /app/build /app/build
WORKDIR /app
CMD ["npm", "run", "start"]
+25
View File
@@ -0,0 +1,25 @@
FROM oven/bun:1 AS dependencies-env
COPY . /app
FROM dependencies-env AS development-dependencies-env
COPY ./package.json bun.lockb /app/
WORKDIR /app
RUN bun i --frozen-lockfile
FROM dependencies-env AS production-dependencies-env
COPY ./package.json bun.lockb /app/
WORKDIR /app
RUN bun i --production
FROM dependencies-env AS build-env
COPY ./package.json bun.lockb /app/
COPY --from=development-dependencies-env /app/node_modules /app/node_modules
WORKDIR /app
RUN bun run build
FROM dependencies-env
COPY ./package.json bun.lockb /app/
COPY --from=production-dependencies-env /app/node_modules /app/node_modules
COPY --from=build-env /app/build /app/build
WORKDIR /app
CMD ["bun", "run", "start"]
+26
View File
@@ -0,0 +1,26 @@
FROM node:20-alpine AS dependencies-env
RUN npm i -g pnpm
COPY . /app
FROM dependencies-env AS development-dependencies-env
COPY ./package.json pnpm-lock.yaml /app/
WORKDIR /app
RUN pnpm i --frozen-lockfile
FROM dependencies-env AS production-dependencies-env
COPY ./package.json pnpm-lock.yaml /app/
WORKDIR /app
RUN pnpm i --prod --frozen-lockfile
FROM dependencies-env AS build-env
COPY ./package.json pnpm-lock.yaml /app/
COPY --from=development-dependencies-env /app/node_modules /app/node_modules
WORKDIR /app
RUN pnpm build
FROM dependencies-env
COPY ./package.json pnpm-lock.yaml /app/
COPY --from=production-dependencies-env /app/node_modules /app/node_modules
COPY --from=build-env /app/build /app/build
WORKDIR /app
CMD ["pnpm", "start"]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 gowvp
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+4
View File
@@ -0,0 +1,4 @@
.phony: build/cli
build/cli:
@yarn build
@rm -rf ../gb28181/www && mv dist ../gb28181/www
+214
View File
@@ -0,0 +1,214 @@
# PTZ 云台控制功能实现说明
## 概述
已成功为 GB28181 Web 前端项目添加了完整的 PTZ(云台)控制功能,支持 GB28181 协议的摄像头设备。
## 实现内容
### 1. API 接口层
**文件**: `app/service/api/channel/channel.ts`
添加了以下类型和函数:
```typescript
// PTZ 动作类型
type PTZAction = "continuous" | "stop" | "absolute" | "relative" | "preset";
// PTZ 方向
type PTZDirection =
| "up" | "down" | "left" | "right"
| "upleft" | "upright" | "downleft" | "downright"
| "zoomin" | "zoomout";
// PTZ 控制输入
interface PTZControlInput {
action: PTZAction;
direction?: PTZDirection;
speed?: number; // 0-1, 默认 0.5
x?: number; // -1 到 1 (绝对/相对移动)
y?: number; // -1 到 1 (绝对/相对移动)
zoom?: number; // 0 到 1 (绝对/相对移动)
preset_id?: string; // 预置位 ID
preset_op?: PresetOp; // 预置位操作
}
// PTZ 控制函数
async function PTZControl(channelId: string, data: PTZControlInput)
```
### 2. UI 组件
#### PTZ 控制面板
**文件**: `app/components/ptz-control/ptz-panel.tsx`
功能特性:
- ✅ 方向控制按钮(上、下、左、右)
- ✅ 对角线方向按钮(左上、右上、左下、右下)
- ✅ 变焦控制(放大、缩小)
- ✅ 停止按钮
- ✅ 速度调节滑块(10% - 100%)
- ✅ 按住移动,松开停止的交互方式
- ✅ 触摸设备支持
- ✅ 协议类型显示(GB28181)
- ✅ 不支持设备的友好提示
UI 设计:
- 采用十字方向键布局
- 直观的图标和文字提示
- 响应式设计,适配不同屏幕
- 加载状态显示
- 错误提示
#### Slider 组件
**文件**: `app/components/ui/slider.tsx`
基于 Radix UI 的 Slider 组件,用于速度控制。
### 3. 集成位置
**文件**: `app/pages/channels/device.tsx`
PTZ 控制面板已集成到设备详情视图的设备信息标签页中,当用户打开播放抽屉并查看设备详情时即可看到云台控制界面。
## 使用方法
### 基本操作
1. **打开播放抽屉**: 点击任意通道卡片
2. **查看设备详情**: 右侧会显示设备详细信息
3. **使用云台控制**: 在设备信息标签页底部找到 PTZ 控制面板
### 控制方式
#### 方向控制
- **点击并按住**方向按钮开始移动
- **松开按钮**自动停止
- 支持 8 个方向:上、下、左、右、左上、右上、左下、右下
#### 变焦控制
- **点击并按住**"放大"或"缩小"按钮
- **松开按钮**停止变焦
#### 速度调节
- 拖动滑块调整移动速度
- 范围:10% - 100%
- 默认值:50%
#### 紧急停止
- 点击红色停止按钮立即停止所有动作
### 请求流程
```
用户操作
PTZPanel 组件
PTZControl API 调用
POST /channels/{id}/ptz/control
后端 IPC Core
协议适配器(GB28181/ONVIF)
摄像头设备
```
### GB28181 实现
- 使用 SIP MESSAGE 方法发送控制命令
- XML 格式: `<Control><CmdType>DeviceControl</CmdType>...</Control>`
- 控制码格式: 8字节十六进制字符串
- 仅支持 continuous 和 stop 动作
### ONVIF 实现
- 使用 ONVIF PTZ 服务
- 支持 AbsoluteMove, RelativeMove, ContinuousMove
- 支持预设位管理(GotoPreset, SetPreset, RemovePreset)
- 完整的 PTZ 功能支持
## 注意事项
1. **设备必须在线**: 离线设备无法进行云台控制
2. **设备必须支持 PTZ**: 不是所有摄像头都支持云台功能
3. **GB28181 限制**: 仅支持连续移动和停止,不支持精确定位
4. **网络延迟**: 云台控制可能有轻微延迟,属正常现象
5. **权限要求**: 需要有效的认证 Token
## 故障排除
### 常见问题
**Q: 点击按钮没有反应?**
A: 检查以下几点:
- 设备是否在线
- 设备类型是否为 GB28181 或 ONVIF
- 浏览器控制台是否有错误信息
- 网络连接是否正常
**Q: 移动速度太快/太慢?**
A: 调整速度滑块,建议从 50% 开始尝试
**Q: 控制后不停止?**
A: 点击红色的停止按钮,或松开当前按住的按钮
**Q: 提示"云台控制失败"?**
A: 可能的原因:
- 设备不支持 PTZ 功能
- 设备配置问题
- 后端服务异常
- 查看浏览器控制台和网络请求获取详细错误
## 后续扩展
可以考虑添加的功能:
1. **预置位管理**: 保存和调用常用位置
2. **巡航路径**: 自动巡视多个位置
3. **键盘快捷键**: 使用方向键控制
4. **鼠标拖拽**: 在视频上直接拖拽控制
5. **手势控制**: 移动端滑动手势
6. **控制历史记录**: 查看最近的控制操作
7. **批量控制**: 同时控制多个摄像头
## 文件清单
新增文件:
- `app/service/api/channel/channel.ts` (修改,添加 PTZ API)
- `app/components/ptz-control/ptz-panel.tsx` (新增)
- `app/components/ui/slider.tsx` (新增)
- `app/pages/channels/device.tsx` (修改,集成 PTZ 面板)
- `package.json` (修改,添加 @radix-ui/react-slider 依赖)
## 测试建议
1. **功能测试**:
- 测试所有方向的移动
- 测试变焦功能
- 测试速度调节
- 测试停止功能
2. **兼容性测试**:
- Chrome/Edge/Firefox/Safari
- 桌面端和移动端
- 鼠标和触摸操作
3. **性能测试**:
- 快速连续点击
- 长时间按住
- 多设备同时控制
4. **边界测试**:
- 离线设备
- 不支持 PTZ 的设备
- 网络异常情况
## 总结
PTZ 云台控制功能已完整实现并集成到前端项目中,用户可以通过直观的界面控制支持 GB28181 协议的摄像头设备。界面简洁易用,支持多种控制方式,提供了良好的用户体验。
+158
View File
@@ -0,0 +1,158 @@
<p align="center">
<img src="./docs/logo.avif" alt="GoWVP Logo" width="550"/>
</p>
<p align="center">
<a href="https://github.com/gowvp/gb28181/releases"><img src="https://img.shields.io/github/v/release/ixugo/goweb?include_prereleases" alt="Version"/></a>
<a href="https://github.com/ixugo/goweb/blob/master/LICENSE.txt"><img src="https://img.shields.io/dub/l/vibe-d.svg" alt="License"/></a>
</p>
# 开箱即用的 GB/T28181 协议视频平台
go wvp 是 Go 语言实现的开源 GB28181 解决方案,基于GB28181-2022标准实现的网络视频平台,支持 rtmp/rtsp,客户端支持网页版本和安卓 App。支持rtsp/rtmp等视频流转发到国标平台,支持rtsp/rtmp等推流转发到国标平台。
## Golang 服务端实现 [gb28181](github.com/gowvp/gb28181)
当前项目是由 React 实现的 web 管理平台
## 页面缩略图
![首页概览](./docs/home.webp)
![首页概览](./docs/play.webp)
## 在线演示平台
+ [在线演示平台 :)](http://gowvp.golang.space:15123/)
## 技术栈
前置要求:
node.js > 20.x
+ [React 19](https://react.dev/)
+ [TanStack Router](https://tanstack.com/router/latest)
+ [shadcn/ui](https://ui.shadcn.com/)
+ [Vite 7](https://cn.vitejs.dev/)
+ [Tailwind CSS 4](https://tailwindcss.com/) - 使用 @tailwindcss/vite 插件
+ [React Query](https://tanstack.com/query/latest/docs/framework/react/overview)
+ [TypeScript](https://www.typescriptlang.org/)
+ [Biome](https://biomejs.dev/) - 代码格式化和 lint
## 使用帮助
路由跳转
```tsx
import { useNavigate } from "@tanstack/react-router";
const navigate = useNavigate();
const handleClick = () => {
navigate({ to: '/about' });
}
// 带搜索参数的导航
navigate({ to: '/zones', search: { cid: '123' } });
```
vite 配置代理
```ts
server: {
proxy: {
"/api": {
target: "http://localhost:18081",
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ""),
},
},
},
```
## 生产模式公共前缀
开发模式下,Vite 默认的开发服务器是以 / 为根路径运行的,而生产环境会以设置的 base 作为根路径。为了兼容开发模式和生产模式,可以按以下方式进行配置。
值为 `./` 会导致开发模式下出错
1. 更新 vite.config.ts
```ts
base: mode === "development" ? "/" : "/web/",
```
2. 其它静态文件
```tsx
<img src={`${import.meta.env.BASE_URL}assets/logo.avif`} alt="Logo" />
```
## 开发与生成环境区分
`yarn dev` 加载 `.env.development` 环境变量
`yarn build` 加载 `.env.production` 环境变量
### 其它
**drawer 背景动画**
需要使用 DrawerCSSProvider 包裹父组件,动画才生效
**react-resizable-panels**
导入 shadcn-ui 的 resizable ,需要额外执行
`yarn add react-resizable-panels`
vite.config.ts 需要增加以下配置,否则模块加载会出问题
```ts
ssr: {
// 外部化会导致问题的依赖项
noExternal: ["react-resizable-panels"],
},
```
### 部署
Start the development server with HMR:
```bash
npm run dev
```
Your application will be available at `http://localhost:5173`.
## Building for Production
Create a production build:
```bash
npm run build
```
## Deployment
### Docker Deployment
This template includes three Dockerfiles optimized for different package managers:
- `Dockerfile` - for npm
- `Dockerfile.pnpm` - for pnpm
- `Dockerfile.bun` - for bun
To build and run using Docker:
```bash
# For npm
docker build -t my-app .
# For pnpm
docker build -f Dockerfile.pnpm -t my-app .
# For bun
docker build -f Dockerfile.bun -t my-app .
# Run the container
docker run -p 3000:3000 my-app
```
The containerized application can be deployed to any platform that supports Docker,
```
+203
View File
@@ -0,0 +1,203 @@
@import "tailwindcss";
/* 自定义主题配置 - Tailwind v4 使用 @theme 指令 */
@theme {
/* 颜色 */
--color-background: hsl(var(--background));
--color-foreground: hsl(var(--foreground));
--color-card: hsl(var(--card));
--color-card-foreground: hsl(var(--card-foreground));
--color-popover: hsl(var(--popover));
--color-popover-foreground: hsl(var(--popover-foreground));
--color-primary: hsl(var(--primary));
--color-primary-foreground: hsl(var(--primary-foreground));
--color-secondary: hsl(var(--secondary));
--color-secondary-foreground: hsl(var(--secondary-foreground));
--color-muted: hsl(var(--muted));
--color-muted-foreground: hsl(var(--muted-foreground));
--color-accent: hsl(var(--accent));
--color-accent-foreground: hsl(var(--accent-foreground));
--color-destructive: hsl(var(--destructive));
--color-destructive-foreground: hsl(var(--destructive-foreground));
--color-border: hsl(var(--border));
--color-input: hsl(var(--input));
--color-ring: hsl(var(--ring));
--color-chart-1: hsl(var(--chart-1));
--color-chart-2: hsl(var(--chart-2));
--color-chart-3: hsl(var(--chart-3));
--color-chart-4: hsl(var(--chart-4));
--color-chart-5: hsl(var(--chart-5));
--color-sidebar: hsl(var(--sidebar-background));
--color-sidebar-foreground: hsl(var(--sidebar-foreground));
--color-sidebar-primary: hsl(var(--sidebar-primary));
--color-sidebar-primary-foreground: hsl(var(--sidebar-primary-foreground));
--color-sidebar-accent: hsl(var(--sidebar-accent));
--color-sidebar-accent-foreground: hsl(var(--sidebar-accent-foreground));
--color-sidebar-border: hsl(var(--sidebar-border));
--color-sidebar-ring: hsl(var(--sidebar-ring));
/* 圆角 */
--radius-lg: var(--radius);
--radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) - 4px);
/* 动画 */
--animate-ripple: ripple 1.2s linear infinite;
}
html,
body {
background-color: #f4f4f4;
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", "Helvetica Neue", sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
@media (prefers-color-scheme: dark) {
html,
body {
color-scheme: dark;
}
}
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 240 10% 3.9%;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--radius: 0.5rem;
--sidebar-background: 0 0% 98%;
--sidebar-foreground: 240 5.3% 26.1%;
--sidebar-primary: 240 5.9% 10%;
--sidebar-primary-foreground: 0 0% 98%;
--sidebar-accent: 240 4.8% 95.9%;
--sidebar-accent-foreground: 240 5.9% 10%;
--sidebar-border: 220 13% 91%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
.dark {
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--ring: 240 4.9% 83.9%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
--sidebar-background: 240 5.9% 10%;
--sidebar-foreground: 240 4.8% 95.9%;
--sidebar-primary: 224.3 76.3% 48%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 240 3.7% 15.9%;
--sidebar-accent-foreground: 240 4.8% 95.9%;
--sidebar-border: 240 3.7% 15.9%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
* {
border-color: hsl(var(--border));
}
body {
background-color: hsl(var(--background));
color: hsl(var(--foreground));
}
}
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-thumb {
background-color: rgba(0, 0, 0, 0.2);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background-color: rgba(0, 0, 0, 0.35);
}
::-webkit-scrollbar-track {
background: transparent;
}
* {
scrollbar-width: thin;
scrollbar-color: rgba(0, 0, 0, 0.2) transparent;
}
/* Antd Modal 圆角加大,贴近 Apple 弹窗风格 */
.ant-modal-content {
border-radius: 16px !important;
}
@keyframes ripple {
0% {
transform: scale(1);
opacity: 0.6;
}
100% {
transform: scale(3);
opacity: 0;
}
}
/* 表格分页组件右侧内边距,不影响表格本身 */
.ant-table-pagination.ant-pagination {
margin-right: 12px !important;
}
@keyframes livePulse {
0% { transform: scale(1); opacity: 0.6; }
100% { transform: scale(2.5); opacity: 0; }
}
@keyframes fadeInUp {
0% {
opacity: 0;
transform: translateY(20px) scale(0.95);
}
100% {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@@ -0,0 +1,26 @@
import { Handle, type HandleProps } from "@xyflow/react";
import { forwardRef } from "react";
import { cn } from "~/lib/utils";
export type BaseHandleProps = HandleProps;
export const BaseHandle = forwardRef<HTMLDivElement, BaseHandleProps>(
({ className, children, ...props }, ref) => {
return (
<Handle
ref={ref}
{...props}
className={cn(
"h-[11px] w-[11px] rounded-full border border-slate-300 bg-slate-100 transition dark:border-secondary dark:bg-secondary",
className,
)}
{...props}
>
{children}
</Handle>
);
},
);
BaseHandle.displayName = "BaseHandle";
@@ -0,0 +1,21 @@
import { forwardRef } from "react";
import { cn } from "~/lib/utils";
export const BaseNode = forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & { selected?: boolean }
>(({ className, selected, ...props }, ref) => (
<div
ref={ref}
className={cn(
"relative rounded-md border bg-card p-5 text-card-foreground",
className,
selected ? "border-muted-foreground shadow-lg" : "",
"hover:ring-1",
)}
{...props}
/>
));
BaseNode.displayName = "BaseNode";
@@ -0,0 +1,225 @@
import { useMemo, useState, useCallback } from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { Button } from "~/components/ui/button";
import { cn } from "~/lib/utils";
interface RecordingCalendarProps {
/** 有录像的日期列表,格式为 "YYYY-MM-DD" */
recordingDates: string[];
/** 当前选中的日期 */
selectedDate: Date;
/** 日期选择回调 */
onDateSelect: (date: Date) => void;
/** 月份变化回调(用于加载该月的录像统计) */
onMonthChange?: (year: number, month: number) => void;
/** 是否加载中 */
isLoading?: boolean;
}
const WEEKDAYS = ["日", "一", "二", "三", "四", "五", "六"];
/**
*
*
*/
export function RecordingCalendar({
recordingDates,
selectedDate,
onDateSelect,
onMonthChange,
isLoading = false,
}: RecordingCalendarProps) {
const [viewDate, setViewDate] = useState(() => {
return new Date(selectedDate.getFullYear(), selectedDate.getMonth(), 1);
});
// 切换到上个月
const goToPrevMonth = useCallback(() => {
setViewDate((prev) => {
const newDate = new Date(prev.getFullYear(), prev.getMonth() - 1, 1);
onMonthChange?.(newDate.getFullYear(), newDate.getMonth() + 1);
return newDate;
});
}, [onMonthChange]);
// 切换到下个月
const goToNextMonth = useCallback(() => {
setViewDate((prev) => {
const newDate = new Date(prev.getFullYear(), prev.getMonth() + 1, 1);
onMonthChange?.(newDate.getFullYear(), newDate.getMonth() + 1);
return newDate;
});
}, [onMonthChange]);
// 回到今天
const goToToday = useCallback(() => {
const today = new Date();
setViewDate(new Date(today.getFullYear(), today.getMonth(), 1));
onDateSelect(today);
onMonthChange?.(today.getFullYear(), today.getMonth() + 1);
}, [onDateSelect, onMonthChange]);
// 生成日历网格
const calendarDays = useMemo(() => {
const year = viewDate.getFullYear();
const month = viewDate.getMonth();
// 当月第一天是星期几
const firstDayOfMonth = new Date(year, month, 1).getDay();
// 当月有多少天
const daysInMonth = new Date(year, month + 1, 0).getDate();
// 上个月有多少天
const daysInPrevMonth = new Date(year, month, 0).getDate();
const days: {
date: Date;
day: number;
isCurrentMonth: boolean;
isToday: boolean;
isSelected: boolean;
hasRecording: boolean;
}[] = [];
// 填充上个月的日期
for (let i = firstDayOfMonth - 1; i >= 0; i--) {
const day = daysInPrevMonth - i;
const date = new Date(year, month - 1, day);
days.push({
date,
day,
isCurrentMonth: false,
isToday: false,
isSelected: false,
hasRecording: false,
});
}
// 填充当月日期
const today = new Date();
const todayStr = formatDate(today);
const selectedStr = formatDate(selectedDate);
for (let day = 1; day <= daysInMonth; day++) {
const date = new Date(year, month, day);
const dateStr = formatDate(date);
days.push({
date,
day,
isCurrentMonth: true,
isToday: dateStr === todayStr,
isSelected: dateStr === selectedStr,
hasRecording: recordingDates.includes(dateStr),
});
}
// 填充下个月的日期(补满 6 行)
const remainingDays = 42 - days.length;
for (let day = 1; day <= remainingDays; day++) {
const date = new Date(year, month + 1, day);
days.push({
date,
day,
isCurrentMonth: false,
isToday: false,
isSelected: false,
hasRecording: false,
});
}
return days;
}, [viewDate, selectedDate, recordingDates]);
// 格式化日期为 YYYY-MM-DD
function formatDate(date: Date): string {
const year = date.getFullYear();
const month = (date.getMonth() + 1).toString().padStart(2, "0");
const day = date.getDate().toString().padStart(2, "0");
return `${year}-${month}-${day}`;
}
return (
<div className="bg-white rounded-lg shadow-sm border p-4">
{/* 头部:月份导航 */}
<div className="flex items-center justify-between mb-4">
<Button
variant="ghost"
size="sm"
onClick={goToPrevMonth}
disabled={isLoading}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<div className="flex items-center gap-2">
<span className="text-lg font-semibold">
{viewDate.getFullYear()}{viewDate.getMonth() + 1}
</span>
<Button variant="outline" size="sm" onClick={goToToday}>
</Button>
</div>
<Button
variant="ghost"
size="sm"
onClick={goToNextMonth}
disabled={isLoading}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
{/* 星期标题 */}
<div className="grid grid-cols-7 gap-1 mb-2">
{WEEKDAYS.map((day) => (
<div
key={day}
className="text-center text-xs font-medium text-gray-500 py-1"
>
{day}
</div>
))}
</div>
{/* 日期网格 */}
<div className="grid grid-cols-7 gap-1">
{calendarDays.map((dayInfo, index) => (
<button
key={index}
type="button"
className={cn(
"relative aspect-square flex items-center justify-center rounded-lg text-sm transition-colors",
dayInfo.isCurrentMonth
? "text-gray-900 hover:bg-gray-100"
: "text-gray-300",
dayInfo.isToday && "ring-2 ring-blue-400",
dayInfo.isSelected && "bg-blue-500 text-white hover:bg-blue-600",
!dayInfo.isCurrentMonth && "pointer-events-none",
)}
onClick={() => {
if (dayInfo.isCurrentMonth) {
onDateSelect(dayInfo.date);
}
}}
disabled={!dayInfo.isCurrentMonth}
>
{dayInfo.day}
{/* 有录像的标记(蓝色圆点) */}
{dayInfo.hasRecording && !dayInfo.isSelected && (
<span className="absolute bottom-1 left-1/2 -translate-x-1/2 w-1.5 h-1.5 bg-blue-500 rounded-full" />
)}
</button>
))}
</div>
{/* 加载状态 */}
{isLoading && (
<div className="absolute inset-0 bg-white/50 flex items-center justify-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500" />
</div>
)}
</div>
);
}
export default RecordingCalendar;
@@ -0,0 +1,154 @@
import type { Edge, EdgeProps, Node } from "@xyflow/react";
import {
BaseEdge,
EdgeLabelRenderer,
getBezierPath,
getSmoothStepPath,
getStraightPath,
type Position,
useStore,
} from "@xyflow/react";
import { useMemo } from "react";
export type DataEdge<T extends Node = Node> = Edge<{
/**
* The key to lookup in the source node's `data` object. For additional safety,
* you can parameterize the `DataEdge` over the type of one of your nodes to
* constrain the possible values of this key.
*
* If no key is provided this edge behaves identically to React Flow's default
* edge component.
*/
key?: keyof T["data"];
/**
* Which of React Flow's path algorithms to use. Each value corresponds to one
* of React Flow's built-in edge types.
*
* If not provided, this defaults to `"bezier"`.
*/
path?: "bezier" | "smoothstep" | "step" | "straight";
}>;
export function DataEdge({
data = { path: "bezier" },
id,
markerEnd,
source,
sourcePosition,
sourceX,
sourceY,
style,
targetPosition,
targetX,
targetY,
}: EdgeProps<DataEdge>) {
const nodeData = useStore((state) => state.nodeLookup.get(source)?.data);
const [edgePath, labelX, labelY] = getPath({
type: data.path ?? "bezier",
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
});
const label = useMemo(() => {
if (data.key && nodeData) {
const value = nodeData[data.key];
switch (typeof value) {
case "string":
case "number":
return value;
case "object":
return JSON.stringify(value);
default:
return "";
}
}
}, [data, nodeData]);
const transform = `translate(${labelX}px,${labelY}px) translate(-50%, -50%)`;
return (
<>
<BaseEdge id={id} path={edgePath} markerEnd={markerEnd} style={style} />
{data.key && (
<EdgeLabelRenderer>
<div
className="absolute rounded border bg-background px-1 text-foreground"
style={{ transform }}
>
<pre className="text-xs">{label}</pre>
</div>
</EdgeLabelRenderer>
)}
</>
);
}
/**
* Chooses which of React Flow's edge path algorithms to use based on the provided
* `type`.
*/
function getPath({
type,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
}: {
type: "bezier" | "smoothstep" | "step" | "straight";
sourceX: number;
sourceY: number;
targetX: number;
targetY: number;
sourcePosition: Position;
targetPosition: Position;
}) {
switch (type) {
case "bezier":
return getBezierPath({
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
});
case "smoothstep":
return getSmoothStepPath({
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
});
case "step":
return getSmoothStepPath({
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
borderRadius: 0,
});
case "straight":
return getStraightPath({
sourceX,
sourceY,
targetX,
targetY,
});
}
}
@@ -0,0 +1,410 @@
import { Button, Input, Select, Slider, Spin } from "antd";
import {
Bell,
ChevronLeft,
ChevronRight,
ExternalLink,
RefreshCw,
ScanSearch,
Trash2,
} from "lucide-react";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import type {
CameraMarker,
LatestCameraEvent,
} from "~/pages/desktop/floor_plan.types";
import type { FlatDeviceChannelOption } from "~/service/api/device/device";
import type { FloorPlanInteractionMode } from "~/pages/desktop/floor_plan.storage";
import { formatEventTimeAbsolute } from "~/pages/desktop/floor_plan.relative-time";
/**
* device_id
* 12 ID
*
*/
function buildGroupedChannelOptions(channelOptions: FlatDeviceChannelOption[]) {
const grouped = new Map<
string,
{ label: string; options: Array<{ value: string; label: string }> }
>();
for (const item of channelOptions) {
const groupKey = item.deviceName;
const group = grouped.get(groupKey) ?? {
label: item.deviceName,
options: [],
};
group.options.push({
value: item.value,
label: `${item.deviceName} / ${item.channelName}`,
});
grouped.set(groupKey, group);
}
return Array.from(grouped.values())
.map((group) => ({
...group,
options: group.options.sort((left, right) =>
left.label.localeCompare(right.label, "zh-CN"),
),
}))
.sort((left, right) => left.label.localeCompare(right.label, "zh-CN"));
}
/**
*
*
*
*/
export function CameraBindingPanel({
camera,
channelOptions,
channelsLoading,
channelsError,
onBindChannel,
onAngleChange,
onFovChange,
onRangeChange,
onDelete,
interactionMode = "edit",
channelFilter = "",
onChannelFilterChange,
selectedLatestEvent = null,
selectedEventLoading = false,
channelOnline = null,
playbackTo = null,
alertsTo = null,
eventOccurredAgo = "",
dataFetchedAgo = "",
onRefreshEvent,
filterMatchCount = 0,
filterMatchActiveIndex = 0,
onFilterPrev,
onFilterNext,
onFilterFrameAll,
}: {
camera: CameraMarker | null;
channelOptions: FlatDeviceChannelOption[];
channelsLoading: boolean;
channelsError: string | null;
onBindChannel: (value: string | null) => void;
onAngleChange: (value: number) => void;
onFovChange: (value: number) => void;
onRangeChange: (value: number) => void;
onDelete: () => void;
interactionMode?: FloorPlanInteractionMode;
channelFilter?: string;
onChannelFilterChange?: (value: string) => void;
selectedLatestEvent?: LatestCameraEvent | null;
selectedEventLoading?: boolean;
channelOnline?: boolean | null;
playbackTo?: { pathname: string; search: string } | null;
alertsTo?: { pathname: string; search: string } | null;
eventOccurredAgo?: string;
dataFetchedAgo?: string;
onRefreshEvent?: () => void;
filterMatchCount?: number;
filterMatchActiveIndex?: number;
onFilterPrev?: () => void;
onFilterNext?: () => void;
onFilterFrameAll?: () => void;
}) {
const { t } = useTranslation("desktop");
const groupedChannelOptions = useMemo(
() => buildGroupedChannelOptions(channelOptions),
[channelOptions],
);
const editLocked = interactionMode === "browse";
const filterTrimmed = channelFilter.trim();
const showFilterNav =
Boolean(onChannelFilterChange) &&
Boolean(filterTrimmed) &&
Boolean(onFilterPrev && onFilterNext && onFilterFrameAll);
return (
<div className="space-y-4">
{onChannelFilterChange ? (
<div className="rounded-xl border border-gray-200 bg-white p-4 shadow-sm">
<div className="mb-2 text-sm font-medium text-gray-900">
{t("filter_cameras")}
</div>
<Input
allowClear
value={channelFilter}
placeholder={t("filter_cameras_placeholder")}
onChange={(e) => onChannelFilterChange(e.target.value)}
/>
<div className="mt-2 text-xs text-gray-500">
{t("filter_cameras_hint")}
</div>
{showFilterNav ? (
<div className="mt-3 flex flex-wrap items-center gap-2 border-t border-gray-100 pt-3">
{filterMatchCount > 0 ? (
<>
<span className="text-xs text-gray-600">
{t("filter_match_position", {
current: filterMatchActiveIndex + 1,
total: filterMatchCount,
})}
</span>
<Button
type="default"
size="small"
icon={<ChevronLeft className="h-3.5 w-3.5" />}
onClick={onFilterPrev}
title={t("filter_prev_match")}
/>
<Button
type="default"
size="small"
icon={<ChevronRight className="h-3.5 w-3.5" />}
onClick={onFilterNext}
title={t("filter_next_match")}
/>
<Button
type="default"
size="small"
icon={<ScanSearch className="h-3.5 w-3.5" />}
onClick={onFilterFrameAll}
>
{t("filter_frame_all")}
</Button>
<span className="w-full text-[11px] text-gray-400">
{t("filter_keyboard_hint")}
</span>
</>
) : (
<span className="text-xs text-amber-700">
{t("filter_no_matches")}
</span>
)}
</div>
) : null}
</div>
) : null}
{camera ? (
<div className="rounded-xl border border-gray-200 bg-white p-4 shadow-sm">
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<div className="text-sm font-semibold text-gray-900">
{t("panel_latest_ai_event")}
</div>
<div className="flex flex-wrap items-center justify-end gap-1">
{camera.channelId && onRefreshEvent ? (
<Button
type="text"
size="small"
icon={<RefreshCw className="h-3.5 w-3.5" />}
loading={selectedEventLoading}
onClick={onRefreshEvent}
title={t("refresh_event_tooltip")}
/>
) : null}
{camera.channelId && alertsTo ? (
<Link
to={alertsTo}
className="inline-flex items-center gap-1 rounded-lg bg-amber-600 px-2 py-1 text-xs font-medium text-white hover:bg-amber-700"
>
<Bell className="h-3.5 w-3.5" />
{t("open_alerts")}
</Link>
) : null}
{camera.channelId && playbackTo ? (
<Link
to={playbackTo}
className="inline-flex items-center gap-1 rounded-lg bg-gray-900 px-2 py-1 text-xs font-medium text-white hover:bg-gray-800"
>
<ExternalLink className="h-3.5 w-3.5" />
{t("open_playback")}
</Link>
) : null}
</div>
</div>
{selectedEventLoading ? (
<div className="flex min-h-20 items-center justify-center">
<Spin size="small" />
</div>
) : selectedLatestEvent ? (
<div className="space-y-2 text-xs text-gray-600">
{selectedLatestEvent.imageSrc ? (
<img
src={selectedLatestEvent.imageSrc}
alt={selectedLatestEvent.label}
className="h-32 w-full rounded-lg border border-gray-200 object-cover"
onError={() => {
console.warn("[floor-plan] panel event image failed", {
channelId: selectedLatestEvent.channelId,
});
}}
/>
) : null}
<div>
<span className="font-medium text-gray-900">
{t("latest_ai_event")}:{" "}
</span>
{selectedLatestEvent.label}
</div>
<div>
<span className="font-medium text-gray-900">
{t("event_time")}:{" "}
</span>
{formatEventTimeAbsolute(selectedLatestEvent.startedAt)}
</div>
<div>
<span className="font-medium text-gray-900">
{t("score")}:{" "}
</span>
{(selectedLatestEvent.score * 100).toFixed(1)}%
</div>
{eventOccurredAgo ? (
<div className="text-[11px] text-gray-500">
{t("event_occurred_ago", { ago: eventOccurredAgo })}
</div>
) : null}
{dataFetchedAgo ? (
<div className="text-[11px] text-gray-400">
{t("data_fetched_ago", { ago: dataFetchedAgo })}
</div>
) : null}
</div>
) : (
<div className="space-y-2">
<div className="rounded-lg bg-gray-50 px-3 py-3 text-center text-sm text-gray-500">
{camera.channelId ? t("no_ai_event") : t("camera_unbound")}
</div>
{camera.channelId && dataFetchedAgo ? (
<div className="text-center text-[11px] text-gray-400">
{t("data_fetched_ago", { ago: dataFetchedAgo })}
</div>
) : null}
</div>
)}
</div>
) : null}
{!camera ? (
<div className="rounded-xl border border-dashed border-gray-300 bg-gray-50 p-4 text-sm text-gray-500">
{t("no_camera_selected")}
</div>
) : (
<>
<div className="rounded-xl border border-gray-200 bg-white p-4 shadow-sm">
<div className="mb-1 text-sm font-semibold text-gray-900">
{t("camera_settings")}
</div>
<div className="text-xs text-gray-500">
{t("position")}: {Math.round(camera.x)}, {Math.round(camera.y)}
</div>
{camera.channelId != null ? (
<div className="mt-1 text-xs text-gray-600">
<span className="font-medium text-gray-900">
{t("channel_status")}:{" "}
</span>
{channelOnline === null
? t("channel_online_unknown")
: channelOnline
? t("channel_online")
: t("channel_offline")}
</div>
) : null}
{editLocked ? (
<div className="mt-2 text-xs leading-5 text-gray-500">
{t("browse_camera_panel_edit_hidden_hint")}
</div>
) : null}
</div>
{editLocked ? null : (
<>
<div className="rounded-xl border border-gray-200 bg-white p-4 shadow-sm">
<div className="mb-2 text-sm font-medium text-gray-900">
{t("bind_channel")}
</div>
{channelsLoading ? (
<div className="flex min-h-14 items-center justify-center">
<Spin size="small" />
</div>
) : (
<>
<Select
className="w-full"
showSearch
allowClear
placeholder={t("bind_channel_placeholder")}
optionFilterProp="label"
value={camera.channelId ?? undefined}
options={groupedChannelOptions}
onChange={(value) => onBindChannel(value ?? null)}
/>
<div className="mt-2 text-xs text-gray-500">
{t("channel_count_loaded", {
count: channelOptions.length,
})}
</div>
{channelsError ? (
<div className="mt-1 text-xs text-amber-600">
{t("channel_load_warning")}
</div>
) : null}
</>
)}
<div className="mt-2 text-xs text-gray-500">
{camera.channelName || t("camera_unbound")}
</div>
</div>
<div className="rounded-xl border border-gray-200 bg-white p-4 shadow-sm">
<div className="mb-3 text-sm font-medium text-gray-900">
{t("direction")}
</div>
<Slider
min={0}
max={359}
value={camera.angle}
onChange={(value) => onAngleChange(Number(value))}
/>
<div className="mb-3 mt-4 text-sm font-medium text-gray-900">
{t("fov")}
</div>
<Slider
min={20}
max={160}
value={camera.fov}
onChange={(value) => onFovChange(Number(value))}
/>
<div className="mb-3 mt-4 text-sm font-medium text-gray-900">
{t("range")}
</div>
<Slider
min={80}
max={800}
step={10}
value={camera.range}
onChange={(value) => onRangeChange(Number(value))}
/>
</div>
<button
type="button"
onClick={onDelete}
className="inline-flex w-full items-center justify-center gap-2 rounded-lg bg-red-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-red-600"
>
<Trash2 className="h-4 w-4" />
{t("delete_camera")}
</button>
</>
)}
</>
)}
</div>
);
}
@@ -0,0 +1,206 @@
import { Spin } from "antd";
import { Bell, ExternalLink } from "lucide-react";
import type { RefObject } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import type { CameraMarker, LatestCameraEvent } from "~/pages/desktop/floor_plan.types";
const CARD_WIDTH = 288;
const CARD_HEIGHT_ESTIMATE = 320;
/**
* try
* UI
*/
function formatEventTime(timestamp: number | null | undefined) {
if (!timestamp) {
return "-";
}
try {
return new Date(timestamp).toLocaleString();
} catch {
return String(timestamp);
}
}
/**
*
* fixed window
*/
function clampCardPositionViewport(left: number, top: number) {
const pad = 8;
const vw = typeof window !== "undefined" ? window.innerWidth : 1200;
const vh = typeof window !== "undefined" ? window.innerHeight : 800;
let x = left;
let y = top;
if (x + CARD_WIDTH > vw - pad) {
x = vw - CARD_WIDTH - pad;
}
if (y + CARD_HEIGHT_ESTIMATE > vh - pad) {
y = vh - CARD_HEIGHT_ESTIMATE - pad;
}
if (x < pad) {
x = pad;
}
if (y < pad) {
y = pad;
}
return { x, y };
}
/**
* Portal + fixed + FAB z-index
* absolute fixed z-50 FAB body z-[100]
* getBoundingClientRect +
* Konva fixed
*/
export function CameraHoverCard({
camera,
latestEvent,
loading,
anchorX,
anchorY,
canvasContainerRef,
channelOnline = null,
playbackTo = null,
alertsTo = null,
onCardPointerEnter,
onCardPointerLeave,
eventOccurredAgo = "",
dataFetchedAgo = "",
}: {
camera: CameraMarker;
latestEvent: LatestCameraEvent | null;
loading: boolean;
/** 相对画布容器左上角的屏幕对齐坐标(与 Stage 内 worldToScreen 一致) */
anchorX: number;
anchorY: number;
canvasContainerRef: RefObject<HTMLDivElement | null>;
channelOnline?: boolean | null | undefined;
playbackTo?: { pathname: string; search: string } | null;
alertsTo?: { pathname: string; search: string } | null;
/** 鼠标移入卡片时取消「离开摄像头」的延时清除,否则移向按钮途中卡片会消失 */
onCardPointerEnter?: () => void;
onCardPointerLeave?: () => void;
/** 由父组件用当前时间与事件 startedAt 算出,避免卡片内再挂定时器 */
eventOccurredAgo?: string;
/** 本次卡片数据完成请求的时间,用于提示「缓存可能滞后」 */
dataFetchedAgo?: string;
}) {
const { t } = useTranslation("desktop");
let left = 0;
let top = 0;
const el = canvasContainerRef.current;
if (el && typeof window !== "undefined") {
const rect = el.getBoundingClientRect();
const clamped = clampCardPositionViewport(rect.left + anchorX + 18, rect.top + anchorY + 18);
left = clamped.x;
top = clamped.y;
}
const canPlayback = Boolean(camera.channelId && playbackTo);
const canAlerts = Boolean(camera.channelId && alertsTo);
const node = (
<div
className="pointer-events-none fixed z-[100] w-72 rounded-xl border border-gray-200 bg-white/95 p-3 shadow-xl backdrop-blur"
style={{ left, top }}
onMouseEnter={onCardPointerEnter}
onMouseLeave={onCardPointerLeave}
>
<div className="mb-2 flex items-start justify-between gap-2">
<div className="min-w-0 flex-1 text-sm font-semibold text-gray-900">
{camera.channelName || t("camera_unbound")}
</div>
<div className="flex shrink-0 flex-wrap justify-end gap-1">
{canAlerts && alertsTo ? (
<Link
to={alertsTo}
onClick={(e) => e.stopPropagation()}
className="pointer-events-auto inline-flex items-center gap-1 rounded-lg bg-amber-600 px-2 py-1 text-xs font-medium text-white hover:bg-amber-700"
>
<Bell className="h-3.5 w-3.5" />
{t("open_alerts")}
</Link>
) : null}
{canPlayback && playbackTo ? (
<Link
to={playbackTo}
onClick={(e) => e.stopPropagation()}
className="pointer-events-auto inline-flex items-center gap-1 rounded-lg bg-gray-900 px-2 py-1 text-xs font-medium text-white hover:bg-gray-800"
>
<ExternalLink className="h-3.5 w-3.5" />
{t("open_playback")}
</Link>
) : null}
</div>
</div>
{camera.channelId != null ? (
<div className="mb-2 text-xs text-gray-600">
<span className="font-medium text-gray-900">{t("channel_status")}: </span>
{channelOnline === undefined
? t("channel_online_unknown")
: channelOnline
? t("channel_online")
: t("channel_offline")}
</div>
) : null}
{loading ? (
<div className="flex min-h-24 items-center justify-center">
<Spin size="small" />
</div>
) : latestEvent ? (
<>
{latestEvent.imageSrc ? (
<img
src={latestEvent.imageSrc}
alt={latestEvent.label}
className="mb-3 h-36 w-full rounded-lg border border-gray-200 object-cover"
onError={() => {
console.warn("[floor-plan] failed to load hover event image", {
channelId: latestEvent.channelId,
imageSrc: latestEvent.imageSrc,
});
}}
/>
) : null}
<div className="space-y-1 text-xs text-gray-600">
<div>
<span className="font-medium text-gray-900">{t("latest_ai_event")}: </span>
{latestEvent.label}
</div>
<div>
<span className="font-medium text-gray-900">{t("score")}: </span>
{(latestEvent.score * 100).toFixed(1)}%
</div>
<div>
<span className="font-medium text-gray-900">{t("event_time")}: </span>
{formatEventTime(latestEvent.startedAt)}
</div>
{eventOccurredAgo ? (
<div className="text-[11px] text-gray-500">{t("event_occurred_ago", { ago: eventOccurredAgo })}</div>
) : null}
</div>
</>
) : (
<div className="rounded-lg bg-gray-50 px-3 py-4 text-center text-sm text-gray-500">
{t("no_ai_event")}
</div>
)}
{!loading && dataFetchedAgo ? (
<div
className={`text-[11px] text-gray-400 ${latestEvent || !camera.channelId ? "mt-2 border-t border-gray-100 pt-2" : "mt-2 text-center"}`}
>
{t("data_fetched_ago", { ago: dataFetchedAgo })}
</div>
) : null}
</div>
);
if (typeof document === "undefined") {
return null;
}
return createPortal(node, document.body);
}
@@ -0,0 +1,202 @@
import { useCallback, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import type {
CameraMarker,
FloorWall,
PlannerView,
} from "~/pages/desktop/floor_plan.types";
import {
FLOOR_PLAN_WORLD_HEIGHT,
FLOOR_PLAN_WORLD_WIDTH,
} from "~/pages/desktop/floor_plan.storage";
const MAP_W_DEFAULT = 168;
const MAP_H_DEFAULT = 105;
const MAP_W_COMPACT = 128;
const MAP_H_COMPACT = 80;
const DRAG_THRESHOLD_PX = 5;
type FloorPlanMinimapProps = {
walls: FloorWall[];
cameras: CameraMarker[];
view: PlannerView;
viewportWidth: number;
viewportHeight: number;
onCenterWorld: (worldX: number, worldY: number) => void;
onPanViewByScreenDelta: (dx: number, dy: number) => void;
/** 小屏缩小尺寸并上移,避免与底部提示条、安全区重叠 */
compact?: boolean;
};
/**
* SVG + viewBox
* 线线SVG Konva 便/
*
*
*/
export function FloorPlanMinimap({
walls,
cameras,
view,
viewportWidth,
viewportHeight,
onCenterWorld,
onPanViewByScreenDelta,
compact = false,
}: FloorPlanMinimapProps) {
const { t } = useTranslation("desktop");
const [isDragging, setIsDragging] = useState(false);
const MAP_W = compact ? MAP_W_COMPACT : MAP_W_DEFAULT;
const MAP_H = compact ? MAP_H_COMPACT : MAP_H_DEFAULT;
const worldW = FLOOR_PLAN_WORLD_WIDTH;
const worldH = FLOOR_PLAN_WORLD_HEIGHT;
const vpLeft = -view.x / view.scale;
const vpTop = -view.y / view.scale;
const vpW = viewportWidth / view.scale;
const vpH = viewportHeight / view.scale;
const sessionRef = useRef<{
pointerId: number;
lastClientX: number;
lastClientY: number;
startClientX: number;
startClientY: number;
startedDrag: boolean;
} | null>(null);
const clientToWorld = useCallback(
(clientX: number, clientY: number, svg: SVGSVGElement) => {
const rect = svg.getBoundingClientRect();
const nx = (clientX - rect.left) / rect.width;
const ny = (clientY - rect.top) / rect.height;
return { wx: nx * worldW, wy: ny * worldH };
},
[worldW, worldH],
);
const endSession = useCallback((svg: SVGSVGElement, pointerId: number) => {
try {
svg.releasePointerCapture(pointerId);
} catch {
/* noop */
}
sessionRef.current = null;
setIsDragging(false);
}, []);
const onPointerDown = (event: React.PointerEvent<SVGSVGElement>) => {
event.preventDefault();
const svg = event.currentTarget;
svg.setPointerCapture(event.pointerId);
sessionRef.current = {
pointerId: event.pointerId,
lastClientX: event.clientX,
lastClientY: event.clientY,
startClientX: event.clientX,
startClientY: event.clientY,
startedDrag: false,
};
};
const onPointerMove = (event: React.PointerEvent<SVGSVGElement>) => {
const session = sessionRef.current;
if (!session || event.pointerId !== session.pointerId) {
return;
}
const dx = event.clientX - session.lastClientX;
const dy = event.clientY - session.lastClientY;
const distFromStart = Math.hypot(
event.clientX - session.startClientX,
event.clientY - session.startClientY,
);
if (distFromStart > DRAG_THRESHOLD_PX) {
if (!session.startedDrag) {
session.startedDrag = true;
setIsDragging(true);
}
onPanViewByScreenDelta(dx, dy);
session.lastClientX = event.clientX;
session.lastClientY = event.clientY;
}
};
const onPointerUp = (event: React.PointerEvent<SVGSVGElement>) => {
const session = sessionRef.current;
const svg = event.currentTarget;
if (!session || event.pointerId !== session.pointerId) {
return;
}
if (!session.startedDrag) {
const { wx, wy } = clientToWorld(
session.startClientX,
session.startClientY,
svg,
);
onCenterWorld(wx, wy);
}
endSession(svg, event.pointerId);
};
return (
<div
className={`pointer-events-auto absolute left-4 z-30 flex flex-col gap-1 rounded-lg border border-gray-200 bg-white/95 p-1.5 shadow-md backdrop-blur ${
compact
? "bottom-[max(6.5rem,env(safe-area-inset-bottom))]"
: "bottom-24"
}`}
title={t("minimap_hint")}
>
<svg
role="img"
width={MAP_W}
height={MAP_H}
viewBox={`0 0 ${worldW} ${worldH}`}
className={`rounded border border-gray-200 bg-slate-50 touch-none ${isDragging ? "cursor-grabbing" : "cursor-crosshair"}`}
preserveAspectRatio="none"
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
>
{walls.map((wall) => (
<line
key={wall.id}
x1={wall.x1}
y1={wall.y1}
x2={wall.x2}
y2={wall.y2}
stroke="#94a3b8"
strokeWidth={worldW / 200}
vectorEffect="non-scaling-stroke"
/>
))}
{cameras.map((camera) => (
<circle
key={camera.id}
cx={camera.x}
cy={camera.y}
r={worldW / 160}
fill="#3b82f6"
stroke="#fff"
strokeWidth={worldW / 400}
/>
))}
{/* 视口框:non-scaling + 约 1px,避免 world 坐标 strokeWidth 在缩放后变成十几像素粗边 */}
<rect
x={Math.max(0, vpLeft)}
y={Math.max(0, vpTop)}
width={Math.min(worldW, vpW)}
height={Math.min(worldH, vpH)}
fill="rgba(59,130,246,0.12)"
stroke="#2563eb"
strokeWidth={1.25}
vectorEffect="non-scaling-stroke"
pointerEvents="none"
/>
</svg>
</div>
);
}
@@ -0,0 +1,419 @@
/**
* HLS
*
* hls.js
* - HLS (m3u8)
* - MP4
* - (0.5x - 3x)
* -
* -
*
* 使
* hls.js
*
* @example
* ```tsx
* import HlsPlayer, { type HlsPlayerRef } from '@/components/hls-player';
*
* const playerRef = useRef<HlsPlayerRef>(null);
*
* <HlsPlayer
* ref={playerRef}
* onTimeUpdate={(time) => setCurrentTime(time)}
* onDurationChange={(duration) => setDuration(duration)}
* />
*
* // 播放 HLS
* playerRef.current?.play('http://example.com/playlist.m3u8');
*
* // 设置倍速
* playerRef.current?.setPlaybackRate(2);
*
* // 跳转到指定时间
* playerRef.current?.seek(30); // 跳转到30秒
* ```
*/
import Hls from "hls.js";
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react";
// ==================== 类型定义 ====================
export interface HlsPlayerRef {
/** 播放指定 URL(支持 m3u8 和 mp4 */
play: (url: string) => void;
/** 暂停播放 */
pause: () => void;
/** 继续播放 */
resume: () => void;
/** 停止播放并释放资源 */
stop: () => void;
/** 跳转到指定时间(秒) */
seek: (time: number) => void;
/** 设置播放速率 */
setPlaybackRate: (rate: number) => void;
/** 获取当前播放时间(秒) */
getCurrentTime: () => number;
/** 获取总时长(秒) */
getDuration: () => number;
/** 是否正在播放 */
isPlaying: () => boolean;
/** 设置音量 (0-1) */
setVolume: (volume: number) => void;
/** 静音/取消静音 */
setMuted: (muted: boolean) => void;
/** 快进指定秒数 */
forward: (seconds: number) => void;
/** 快退指定秒数 */
backward: (seconds: number) => void;
}
export interface HlsPlayerProps {
/** 时间更新回调(毫秒) */
onTimeUpdate?: (timeMs: number) => void;
/** 总时长变化回调(毫秒) */
onDurationChange?: (durationMs: number) => void;
/** 播放状态变化回调 */
onPlayStateChange?: (playing: boolean) => void;
/** 加载状态变化回调 */
onLoadingChange?: (loading: boolean) => void;
/** 错误回调 */
onError?: (error: Error) => void;
/** 播放结束回调 */
onEnded?: () => void;
/** 自定义样式类名 */
className?: string;
/** 是否自动播放 */
autoPlay?: boolean;
/** 是否静音 */
muted?: boolean;
/** 是否显示原生控件 */
controls?: boolean;
}
// ==================== 组件实现 ====================
const HlsPlayer = forwardRef<HlsPlayerRef, HlsPlayerProps>(
(
{
onTimeUpdate,
onDurationChange,
onPlayStateChange,
onLoadingChange,
onError,
onEnded,
className = "",
autoPlay = true,
muted = true,
controls = false,
},
ref
) => {
const videoRef = useRef<HTMLVideoElement>(null);
const hlsRef = useRef<Hls | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
const currentUrlRef = useRef<string>("");
// 清理 HLS 实例
const cleanup = useCallback(() => {
if (hlsRef.current) {
hlsRef.current.destroy();
hlsRef.current = null;
}
currentUrlRef.current = "";
}, []);
// 组件卸载时清理
useEffect(() => {
return () => {
cleanup();
};
}, [cleanup]);
// 播放指定 URL
const play = useCallback(
(url: string) => {
if (!videoRef.current) return;
// 如果是同一个 URL,直接播放
if (url === currentUrlRef.current) {
videoRef.current.play().catch(console.error);
return;
}
// 清理之前的实例
cleanup();
currentUrlRef.current = url;
onLoadingChange?.(true);
const video = videoRef.current;
// 判断是否为 HLS 格式
const isHls = url.includes(".m3u8");
if (isHls && Hls.isSupported()) {
// 使用 hls.js 播放
const hls = new Hls({
enableWorker: true,
lowLatencyMode: false,
// 针对 VOD 优化的配置
maxBufferLength: 30,
maxMaxBufferLength: 60,
maxBufferSize: 60 * 1000 * 1000, // 60MB
maxBufferHole: 0.5,
});
hls.loadSource(url);
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, () => {
onLoadingChange?.(false);
if (autoPlay) {
video.play().catch(console.error);
}
});
hls.on(Hls.Events.ERROR, (_, data) => {
if (data.fatal) {
onLoadingChange?.(false);
onError?.(new Error(`HLS Error: ${data.type} - ${data.details}`));
// 尝试恢复
if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {
hls.startLoad();
} else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) {
hls.recoverMediaError();
} else {
cleanup();
}
}
});
hlsRef.current = hls;
} else if (
isHls &&
video.canPlayType("application/vnd.apple.mpegurl")
) {
// Safari 原生支持 HLS
video.src = url;
video.addEventListener(
"loadedmetadata",
() => {
onLoadingChange?.(false);
if (autoPlay) {
video.play().catch(console.error);
}
},
{ once: true }
);
} else {
// 直接播放 MP4
video.src = url;
video.addEventListener(
"loadedmetadata",
() => {
onLoadingChange?.(false);
if (autoPlay) {
video.play().catch(console.error);
}
},
{ once: true }
);
}
},
[autoPlay, cleanup, onError, onLoadingChange]
);
// 暂停播放
const pause = useCallback(() => {
videoRef.current?.pause();
}, []);
// 继续播放
const resume = useCallback(() => {
videoRef.current?.play().catch(console.error);
}, []);
// 停止播放
const stop = useCallback(() => {
if (videoRef.current) {
videoRef.current.pause();
videoRef.current.currentTime = 0;
}
cleanup();
}, [cleanup]);
// 跳转到指定时间
const seek = useCallback((time: number) => {
if (videoRef.current) {
videoRef.current.currentTime = time;
}
}, []);
// 设置播放速率
const setPlaybackRate = useCallback((rate: number) => {
if (videoRef.current) {
videoRef.current.playbackRate = rate;
}
}, []);
// 获取当前时间
const getCurrentTime = useCallback(() => {
return videoRef.current?.currentTime ?? 0;
}, []);
// 获取总时长
const getDuration = useCallback(() => {
return videoRef.current?.duration ?? 0;
}, []);
// 是否正在播放
const getIsPlaying = useCallback(() => {
return isPlaying;
}, [isPlaying]);
// 设置音量
const setVolume = useCallback((volume: number) => {
if (videoRef.current) {
videoRef.current.volume = Math.max(0, Math.min(1, volume));
}
}, []);
// 设置静音
const setMuted = useCallback((mutedValue: boolean) => {
if (videoRef.current) {
videoRef.current.muted = mutedValue;
}
}, []);
// 快进
const forward = useCallback((seconds: number) => {
if (videoRef.current) {
const duration = videoRef.current.duration || 0;
videoRef.current.currentTime = Math.min(
duration,
videoRef.current.currentTime + seconds
);
}
}, []);
// 快退
const backward = useCallback((seconds: number) => {
if (videoRef.current) {
videoRef.current.currentTime = Math.max(
0,
videoRef.current.currentTime - seconds
);
}
}, []);
// 暴露方法给父组件
useImperativeHandle(
ref,
() => ({
play,
pause,
resume,
stop,
seek,
setPlaybackRate,
getCurrentTime,
getDuration,
isPlaying: getIsPlaying,
setVolume,
setMuted,
forward,
backward,
}),
[
play,
pause,
resume,
stop,
seek,
setPlaybackRate,
getCurrentTime,
getDuration,
getIsPlaying,
setVolume,
setMuted,
forward,
backward,
]
);
// 视频事件处理
const handleTimeUpdate = useCallback(() => {
if (videoRef.current) {
onTimeUpdate?.(videoRef.current.currentTime * 1000);
}
}, [onTimeUpdate]);
const handleDurationChange = useCallback(() => {
if (videoRef.current && !Number.isNaN(videoRef.current.duration)) {
onDurationChange?.(videoRef.current.duration * 1000);
}
}, [onDurationChange]);
const handlePlay = useCallback(() => {
setIsPlaying(true);
onPlayStateChange?.(true);
}, [onPlayStateChange]);
const handlePause = useCallback(() => {
setIsPlaying(false);
onPlayStateChange?.(false);
}, [onPlayStateChange]);
const handleEnded = useCallback(() => {
setIsPlaying(false);
onPlayStateChange?.(false);
onEnded?.();
}, [onPlayStateChange, onEnded]);
const handleError = useCallback(() => {
onLoadingChange?.(false);
if (videoRef.current?.error) {
onError?.(new Error(videoRef.current.error.message || "Video error"));
}
}, [onError, onLoadingChange]);
const handleWaiting = useCallback(() => {
onLoadingChange?.(true);
}, [onLoadingChange]);
const handlePlaying = useCallback(() => {
onLoadingChange?.(false);
}, [onLoadingChange]);
return (
<video
ref={videoRef}
className={`hls-player ${className}`}
style={{ width: "100%", height: "100%", backgroundColor: "#000" }}
muted={muted}
controls={controls}
playsInline
onTimeUpdate={handleTimeUpdate}
onDurationChange={handleDurationChange}
onPlay={handlePlay}
onPause={handlePause}
onEnded={handleEnded}
onError={handleError}
onWaiting={handleWaiting}
onPlaying={handlePlaying}
/>
);
}
);
HlsPlayer.displayName = "HlsPlayer";
export default HlsPlayer;
@@ -0,0 +1,44 @@
import type { HandleProps } from "@xyflow/react";
import type React from "react";
import { forwardRef } from "react";
import { BaseHandle } from "~/components/base-handle";
import { cn } from "~/lib/utils";
const flexDirections = {
top: "flex-col",
right: "flex-row-reverse justify-end",
bottom: "flex-col-reverse justify-end",
left: "flex-row",
};
export const LabeledHandle = forwardRef<
HTMLDivElement,
HandleProps &
React.HTMLAttributes<HTMLDivElement> & {
title: string;
handleClassName?: string;
labelClassName?: string;
}
>(
(
{ className, labelClassName, handleClassName, title, position, ...props },
ref,
) => (
<div
ref={ref}
title={title}
className={cn(
"relative flex items-center",
flexDirections[position],
className,
)}
>
<BaseHandle position={position} className={handleClassName} {...props} />
<label className={cn("px-3 text-sm text-foreground", labelClassName)}>
{title}
</label>
</div>
),
);
LabeledHandle.displayName = "LabeledHandle";
@@ -0,0 +1,55 @@
import { Languages } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "~/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
export function LanguageSwitcher() {
const { i18n, t } = useTranslation();
const changeLanguage = (lng: string) => {
console.log("Changing language from", i18n.language, "to", lng);
i18n.changeLanguage(lng).then(() => {
console.log("Language changed to:", i18n.language);
console.log("localStorage:", localStorage.getItem("i18nextLng"));
});
};
const currentLanguage = i18n.language || "zh";
console.log("Current language in switcher:", currentLanguage);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-9 w-9 rounded-full"
title={t("common:language")}
>
<Languages className="h-5 w-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-32">
<DropdownMenuItem
onClick={() => changeLanguage("zh")}
className={currentLanguage === "zh" ? "bg-accent" : ""}
>
<span className="mr-2">🇨🇳</span>
{t("common:chinese")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => changeLanguage("en")}
className={currentLanguage === "en" ? "bg-accent" : ""}
>
<span className="mr-2">🇺🇸</span>
{t("common:english")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -0,0 +1,473 @@
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from "react";
export interface VideoSegment {
id: number | string;
url: string;
duration: number;
startTime: number;
endTime?: number;
}
export interface Mp4PlayerRef {
play: () => void;
pause: () => void;
resume: () => void;
stop: () => void;
seek: (time: number, autoPlay?: boolean) => void;
setPlaybackRate: (rate: number) => void;
getCurrentTime: () => number;
getDuration: () => number;
isPlaying: () => boolean;
setMuted: (muted: boolean) => void;
setVolume: (volume: number) => void;
getVolume: () => number;
}
export interface Mp4PlayerProps {
segments: VideoSegment[];
onTimeUpdate?: (timeSeconds: number) => void;
onDurationChange?: (durationMs: number) => void;
onPlayStateChange?: (playing: boolean) => void;
onError?: (error: Error) => void;
onSegmentError?: (segment: VideoSegment, error: Error) => void;
onEnded?: () => void;
className?: string;
autoPlay?: boolean;
controls?: boolean;
}
type PendingSeek = {
offsetSeconds: number;
autoPlay: boolean;
};
const Mp4Player = forwardRef<Mp4PlayerRef, Mp4PlayerProps>(
(
{
segments,
onTimeUpdate,
onDurationChange,
onPlayStateChange,
onError,
onSegmentError,
onEnded,
className = "",
autoPlay = false,
controls = false,
},
ref,
) => {
const videoRef = useRef<HTMLVideoElement>(null);
const currentIndexRef = useRef(0);
const accumulatedTimeRef = useRef(0);
const pendingSeekRef = useRef<PendingSeek | null>(null);
const isPlayingRef = useRef(false);
const playbackRateRef = useRef(1);
const mutedRef = useRef(false);
const volumeRef = useRef(1);
const onTimeUpdateRef = useRef(onTimeUpdate);
const onDurationChangeRef = useRef(onDurationChange);
const onPlayStateChangeRef = useRef(onPlayStateChange);
const onErrorRef = useRef(onError);
const onSegmentErrorRef = useRef(onSegmentError);
const onEndedRef = useRef(onEnded);
const [currentIndex, setCurrentIndex] = useState(0);
const [isPlayingState, setIsPlayingState] = useState(false);
useEffect(() => {
onTimeUpdateRef.current = onTimeUpdate;
}, [onTimeUpdate]);
useEffect(() => {
onDurationChangeRef.current = onDurationChange;
}, [onDurationChange]);
useEffect(() => {
onPlayStateChangeRef.current = onPlayStateChange;
}, [onPlayStateChange]);
useEffect(() => {
onErrorRef.current = onError;
}, [onError]);
useEffect(() => {
onSegmentErrorRef.current = onSegmentError;
}, [onSegmentError]);
useEffect(() => {
onEndedRef.current = onEnded;
}, [onEnded]);
const emitPlayState = useCallback((playing: boolean) => {
onPlayStateChangeRef.current?.(playing);
}, []);
const emitError = useCallback((error: Error) => {
onErrorRef.current?.(error);
}, []);
const emitSegmentError = useCallback((segment: VideoSegment, error: Error) => {
onSegmentErrorRef.current?.(segment, error);
}, []);
const emitEnded = useCallback(() => {
onEndedRef.current?.();
}, []);
const totalDuration = useMemo(
() => segments.reduce((sum, segment) => sum + Math.max(segment.duration, 0), 0),
[segments],
);
const segmentsSignature = useMemo(
() =>
segments
.map((segment) => `${segment.id}:${segment.url}:${segment.duration}:${segment.startTime}:${segment.endTime ?? ""}`)
.join("|"),
[segments],
);
useEffect(() => {
onDurationChangeRef.current?.(totalDuration * 1000);
}, [totalDuration]);
const getAccumulatedTime = useCallback(
(index: number) => {
let sum = 0;
for (let i = 0; i < index && i < segments.length; i += 1) {
sum += Math.max(segments[i].duration, 0);
}
return sum;
},
[segments],
);
const syncMediaState = useCallback(() => {
const video = videoRef.current;
if (!video) return;
video.playbackRate = playbackRateRef.current;
video.muted = mutedRef.current;
video.volume = volumeRef.current;
}, []);
const applyPendingSeek = useCallback(() => {
const video = videoRef.current;
const pending = pendingSeekRef.current;
const segment = segments[currentIndexRef.current];
if (!video || !pending || !segment || video.readyState < 1) {
return;
}
syncMediaState();
const mediaDuration = Number.isFinite(video.duration) && video.duration > 0
? video.duration
: Math.max(segment.duration, 0);
const clampedOffset = Math.min(Math.max(pending.offsetSeconds, 0), mediaDuration || 0);
try {
video.currentTime = Number.isFinite(clampedOffset) ? clampedOffset : 0;
} catch {
video.currentTime = 0;
}
pendingSeekRef.current = null;
if (pending.autoPlay) {
video
.play()
.then(() => {
isPlayingRef.current = true;
setIsPlayingState(true);
emitPlayState(true);
})
.catch((error: unknown) => {
emitError(toError(error));
});
}
}, [emitError, emitPlayState, segments, syncMediaState]);
const loadSegment = useCallback(
(index: number, offsetSeconds = 0, autoPlayNext = false) => {
const video = videoRef.current;
const segment = segments[index];
if (!video || !segment) return;
currentIndexRef.current = index;
setCurrentIndex(index);
accumulatedTimeRef.current = getAccumulatedTime(index);
pendingSeekRef.current = {
offsetSeconds,
autoPlay: autoPlayNext,
};
syncMediaState();
if (video.src !== resolveUrl(segment.url)) {
video.src = segment.url;
video.load();
return;
}
applyPendingSeek();
},
[applyPendingSeek, getAccumulatedTime, segments, syncMediaState],
);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
if (segments.length === 0) {
video.pause();
video.removeAttribute("src");
video.load();
currentIndexRef.current = 0;
accumulatedTimeRef.current = 0;
pendingSeekRef.current = null;
isPlayingRef.current = false;
setCurrentIndex(0);
setIsPlayingState(false);
emitPlayState(false);
return;
}
const previousIndex = Math.min(currentIndexRef.current, segments.length - 1);
loadSegment(previousIndex, 0, autoPlay && previousIndex === 0);
}, [autoPlay, emitPlayState, loadSegment, segments.length, segmentsSignature]);
const play = useCallback(() => {
const video = videoRef.current;
if (!video || segments.length === 0) return;
syncMediaState();
if (!video.src) {
loadSegment(0, 0, true);
return;
}
video
.play()
.then(() => {
isPlayingRef.current = true;
setIsPlayingState(true);
emitPlayState(true);
})
.catch((error: unknown) => {
emitError(toError(error));
});
}, [emitError, emitPlayState, loadSegment, segments.length, syncMediaState]);
const pause = useCallback(() => {
videoRef.current?.pause();
isPlayingRef.current = false;
setIsPlayingState(false);
emitPlayState(false);
}, [emitPlayState]);
const resume = useCallback(() => {
const video = videoRef.current;
if (!video || segments.length === 0) return;
syncMediaState();
if (!video.src) {
loadSegment(currentIndexRef.current, 0, true);
return;
}
video
.play()
.then(() => {
isPlayingRef.current = true;
setIsPlayingState(true);
emitPlayState(true);
})
.catch((error: unknown) => {
emitError(toError(error));
});
}, [emitError, emitPlayState, loadSegment, segments.length, syncMediaState]);
const stop = useCallback(() => {
const video = videoRef.current;
if (!video) return;
video.pause();
isPlayingRef.current = false;
setIsPlayingState(false);
emitPlayState(false);
if (segments.length > 0) {
loadSegment(0, 0, false);
} else {
video.removeAttribute("src");
video.load();
}
}, [emitPlayState, loadSegment, segments.length]);
const seek = useCallback(
(time: number, autoPlayNext = false) => {
if (segments.length === 0) return;
const safeTime = Math.min(Math.max(time, 0), totalDuration);
let accumulated = 0;
for (let index = 0; index < segments.length; index += 1) {
const segment = segments[index];
const nextAccumulated = accumulated + Math.max(segment.duration, 0);
const isLast = index === segments.length - 1;
if (safeTime < nextAccumulated || isLast) {
const offsetSeconds = Math.max(safeTime - accumulated, 0);
loadSegment(index, offsetSeconds, autoPlayNext);
if (index === currentIndexRef.current && videoRef.current?.readyState) {
applyPendingSeek();
}
return;
}
accumulated = nextAccumulated;
}
},
[applyPendingSeek, loadSegment, segments, totalDuration],
);
const setPlaybackRate = useCallback((rate: number) => {
playbackRateRef.current = Number.isFinite(rate) && rate > 0 ? rate : 1;
if (videoRef.current) {
videoRef.current.playbackRate = playbackRateRef.current;
}
}, []);
const getCurrentTime = useCallback(() => {
return accumulatedTimeRef.current + (videoRef.current?.currentTime ?? 0);
}, []);
const getDuration = useCallback(() => totalDuration, [totalDuration]);
const isPlaying = useCallback(() => isPlayingRef.current, []);
const setMuted = useCallback((muted: boolean) => {
mutedRef.current = muted;
if (videoRef.current) {
videoRef.current.muted = muted;
}
}, []);
const setVolume = useCallback((volume: number) => {
volumeRef.current = Math.min(Math.max(volume, 0), 1);
if (videoRef.current) {
videoRef.current.volume = volumeRef.current;
}
}, []);
const getVolume = useCallback(() => volumeRef.current, []);
useImperativeHandle(
ref,
() => ({
play,
pause,
resume,
stop,
seek,
setPlaybackRate,
getCurrentTime,
getDuration,
isPlaying,
setMuted,
setVolume,
getVolume,
}),
[getCurrentTime, getDuration, getVolume, isPlaying, pause, play, resume, seek, setMuted, setPlaybackRate, setVolume, stop],
);
const handleLoadedMetadata = useCallback(() => {
applyPendingSeek();
}, [applyPendingSeek]);
const handleTimeUpdate = useCallback(() => {
onTimeUpdateRef.current?.(getCurrentTime());
}, [getCurrentTime]);
const handleEnded = useCallback(() => {
if (currentIndexRef.current < segments.length - 1) {
loadSegment(currentIndexRef.current + 1, 0, true);
return;
}
isPlayingRef.current = false;
setIsPlayingState(false);
emitPlayState(false);
emitEnded();
}, [emitEnded, emitPlayState, loadSegment, segments.length]);
const handleError = useCallback(() => {
const video = videoRef.current;
const currentSegment = segments[currentIndexRef.current];
const baseMessage = video?.error?.message || "Video error";
const error = new Error(
currentSegment
? `片段播放失败: ${currentSegment.url} - ${baseMessage}`
: baseMessage,
);
if (currentSegment) {
emitSegmentError(currentSegment, error);
}
if (currentIndexRef.current < segments.length - 1) {
loadSegment(currentIndexRef.current + 1, 0, isPlayingRef.current);
}
emitError(error);
}, [emitError, emitSegmentError, loadSegment, segments]);
return (
<video
ref={videoRef}
className={className}
style={{ width: "100%", height: "100%", backgroundColor: "#000" }}
playsInline
controls={controls}
preload="metadata"
onLoadedMetadata={handleLoadedMetadata}
onCanPlay={applyPendingSeek}
onTimeUpdate={handleTimeUpdate}
onEnded={handleEnded}
onError={handleError}
onPlay={() => {
isPlayingRef.current = true;
setIsPlayingState(true);
emitPlayState(true);
}}
onPause={() => {
if (!videoRef.current?.ended) {
isPlayingRef.current = false;
setIsPlayingState(false);
emitPlayState(false);
}
}}
data-playing={isPlayingState ? "true" : "false"}
/>
);
},
);
Mp4Player.displayName = "Mp4Player";
function resolveUrl(url: string): string {
if (typeof window === "undefined") return url;
return new URL(url, window.location.href).href;
}
function toError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error));
}
export default Mp4Player;
@@ -0,0 +1,197 @@
import { Slot } from "@radix-ui/react-slot";
import { useNodeId, useReactFlow } from "@xyflow/react";
import { EllipsisVertical, Trash } from "lucide-react";
import {
forwardRef,
type HTMLAttributes,
type ReactNode,
useCallback,
} from "react";
import { Button, type ButtonProps } from "~/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
import { cn } from "~/lib/utils";
/* NODE HEADER -------------------------------------------------------------- */
export type NodeHeaderProps = HTMLAttributes<HTMLElement>;
/**
* A container for a consistent header layout intended to be used inside the
* `<BaseNode />` component.
*/
export const NodeHeader = forwardRef<HTMLElement, NodeHeaderProps>(
({ className, ...props }, ref) => {
return (
<header
ref={ref}
{...props}
className={cn(
"flex items-center justify-between gap-2 px-3 py-2",
// Remove or modify these classes if you modify the padding in the
// `<BaseNode />` component.
className,
)}
/>
);
},
);
NodeHeader.displayName = "NodeHeader";
/* NODE HEADER TITLE -------------------------------------------------------- */
export type NodeHeaderTitleProps = HTMLAttributes<HTMLHeadingElement> & {
asChild?: boolean;
};
/**
* The title text for the node. To maintain a native application feel, the title
* text is not selectable.
*/
export const NodeHeaderTitle = forwardRef<
HTMLHeadingElement,
NodeHeaderTitleProps
>(({ className, asChild, ...props }, ref) => {
const Comp = asChild ? Slot : "h3";
return (
<Comp
ref={ref}
{...props}
className={cn(className, "user-select-none flex-1 font-semibold")}
/>
);
});
NodeHeaderTitle.displayName = "NodeHeaderTitle";
/* NODE HEADER ICON --------------------------------------------------------- */
export type NodeHeaderIconProps = HTMLAttributes<HTMLSpanElement>;
export const NodeHeaderIcon = forwardRef<HTMLSpanElement, NodeHeaderIconProps>(
({ className, ...props }, ref) => {
return (
<span ref={ref} {...props} className={cn(className, "[&>*]:size-5")} />
);
},
);
NodeHeaderIcon.displayName = "NodeHeaderIcon";
/* NODE HEADER ACTIONS ------------------------------------------------------ */
export type NodeHeaderActionsProps = HTMLAttributes<HTMLDivElement>;
/**
* A container for right-aligned action buttons in the node header.
*/
export const NodeHeaderActions = forwardRef<
HTMLDivElement,
NodeHeaderActionsProps
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
{...props}
className={cn(
"ml-auto flex items-center gap-1 justify-self-end",
className,
)}
/>
);
});
NodeHeaderActions.displayName = "NodeHeaderActions";
/* NODE HEADER ACTION ------------------------------------------------------- */
export type NodeHeaderActionProps = ButtonProps & {
label: string;
};
/**
* A thin wrapper around the `<Button />` component with a fixed sized suitable
* for icons.
*
* Because the `<NodeHeaderAction />` component is intended to render icons, it's
* important to provide a meaningful and accessible `label` prop that describes
* the action.
*/
export const NodeHeaderAction = forwardRef<
HTMLButtonElement,
NodeHeaderActionProps
>(({ className, label, title, ...props }, ref) => {
return (
<Button
ref={ref}
variant="ghost"
aria-label={label}
title={title ?? label}
className={cn(className, "nodrag size-6 p-1")}
{...props}
/>
);
});
NodeHeaderAction.displayName = "NodeHeaderAction";
//
export type NodeHeaderMenuActionProps = Omit<
NodeHeaderActionProps,
"onClick"
> & {
trigger?: ReactNode;
};
/**
* Renders a header action that opens a dropdown menu when clicked. The dropdown
* trigger is a button with an ellipsis icon. The trigger's content can be changed
* by using the `trigger` prop.
*
* Any children passed to the `<NodeHeaderMenuAction />` component will be rendered
* inside the dropdown menu. You can read the docs for the shadcn dropdown menu
* here: https://ui.shadcn.com/docs/components/dropdown-menu
*
*/
export const NodeHeaderMenuAction = forwardRef<
HTMLButtonElement,
NodeHeaderMenuActionProps
>(({ trigger, children, ...props }, ref) => {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<NodeHeaderAction ref={ref} {...props}>
{trigger ?? <EllipsisVertical />}
</NodeHeaderAction>
</DropdownMenuTrigger>
<DropdownMenuContent>{children}</DropdownMenuContent>
</DropdownMenu>
);
});
NodeHeaderMenuAction.displayName = "NodeHeaderMenuAction";
/* NODE HEADER DELETE ACTION --------------------------------------- */
export const NodeHeaderDeleteAction = () => {
const id = useNodeId();
const { setNodes } = useReactFlow();
const handleClick = useCallback(() => {
setNodes((prevNodes) => prevNodes.filter((node) => node.id !== id));
}, [id, setNodes]);
return (
<NodeHeaderAction onClick={handleClick} variant="ghost" label="Delete node">
<Trash />
</NodeHeaderAction>
);
};
NodeHeaderDeleteAction.displayName = "NodeHeaderDeleteAction";
@@ -0,0 +1,15 @@
import type React from "react";
import { Card, CardContent } from "~/components/ui/card";
interface BaseNodeProps {
children: React.ReactNode;
className?: string;
}
export function BaseNode({ children, className }: BaseNodeProps) {
return (
<Card className={`bg-white/90 shadow-lg ${className}`}>
<CardContent className="p-0">{children}</CardContent>
</Card>
);
}
@@ -0,0 +1,26 @@
import { Handle, type Position } from "@xyflow/react";
interface LabeledHandleProps {
id: string;
type: "source" | "target";
position: Position;
title?: string;
}
export function LabeledHandle({
id,
type,
position,
title,
}: LabeledHandleProps) {
return (
<div className="relative">
<Handle
id={id}
type={type}
position={position}
style={{ background: "#666666" }}
/>
</div>
);
}
@@ -0,0 +1,30 @@
import type { Node, NodeProps } from "@xyflow/react";
import { Position } from "@xyflow/react";
import { CardHeader, CardTitle } from "~/components/ui/card";
import { BaseNode } from "./base-node";
import { LabeledHandle } from "./labeled-handle";
export type ZlmNodeData = {
label: string;
ip?: string;
};
export type ZlmNode = Node<ZlmNodeData>;
export function ZlmNode({ data }: NodeProps<ZlmNode>) {
return (
<BaseNode className="w-32">
<CardHeader className="p-3">
<CardTitle className="text-sm font-medium">{data.label}</CardTitle>
</CardHeader>
<div className="px-3 pb-3">
<div className="text-xs text-muted-foreground">{data.ip}</div>
</div>
<div className="absolute bottom-0 left-0 right-0 flex justify-between p-2 bg-gray-50">
<LabeledHandle id="x" type="target" position={Position.Left} />
<LabeledHandle id="y" type="target" position={Position.Left} />
<LabeledHandle id="out" type="source" position={Position.Right} />
</div>
</BaseNode>
);
}
@@ -0,0 +1,299 @@
import { useMutation } from "@tanstack/react-query";
import { useNavigate } from "react-router";
import { ChevronDown, Copy } from "lucide-react";
import * as React from "react";
import { useCallback, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import Player, { type PlayerRef } from "~/components/player/player";
import { AspectRatio } from "~/components/ui/aspect-ratio";
import { Button } from "~/components/ui/button";
import { Drawer, DrawerContent } from "~/components/ui/drawer";
import { Input } from "~/components/ui/input";
import { copy2Clipboard } from "~/components/util/copy";
import ToolTips from "~/components/xui/tips";
import { PTZPanel } from "~/components/ptz-control/ptz-panel";
import { usePlayerLayout } from "~/hooks/use-player-layout";
import DeviceDetailView, {
type DeviceDetailViewRef,
} from "~/pages/channels/device";
import { Play } from "~/service/api/channel/channel";
import { ErrorHandle } from "~/service/config/error";
export interface PlayDrawerRef {
open: (item: any, options?: { hideSidebar?: boolean }) => void;
}
const PROTOCOLS_EXPANDED_KEY = "player_protocols_expanded";
export default function PlayDrawer({
ref,
}: {
ref: React.RefObject<PlayDrawerRef | null>;
}) {
const { t } = useTranslation("common");
const navigate = useNavigate();
const deviceDetailRef = useRef<DeviceDetailViewRef>(null);
const [showSidebar, setShowSidebar] = useState(true);
const [currentChannelId, setCurrentChannelId] = useState<string>("");
const [currentChannelExt, setCurrentChannelExt] = useState<any>(undefined);
const [currentChannelType, setCurrentChannelType] = useState<string>("");
const [currentChannelPtztype, setCurrentChannelPtztype] = useState<number>(0);
// 协议选择器收缩/展开状态 - 从 localStorage 读取,默认收缩
const [protocolsExpanded, setProtocolsExpanded] = useState(() => {
if (typeof window !== "undefined") {
return localStorage.getItem(PROTOCOLS_EXPANDED_KEY) === "true";
}
return false;
});
// 切换协议展开状态并保存到 localStorage
const toggleProtocolsExpanded = () => {
const newValue = !protocolsExpanded;
setProtocolsExpanded(newValue);
localStorage.setItem(PROTOCOLS_EXPANDED_KEY, String(newValue));
};
// 使用布局计算 Hook(使用固定 footer 高度避免展开/收缩时视频位置变动)
const layout = usePlayerLayout({
headerHeight: 40,
fixedFooterHeight: 120, // 固定高度,无论展开收缩都保持视频位置一致
sidebarWidth:
showSidebar && typeof window !== "undefined" && window.innerWidth >= 640
? 290
: 0,
});
// 播放功能
// 为什么: WebRTC 端到端延迟最低(300~500ms), H.265 兼容浏览器优先走 WebRTC;
// 不兼容的浏览器 WebRTCPlayer 内部会弹窗提示, 用户可手动切 HTTP_FLV 兜底。
const { mutate: playMutate, data: playData } = useMutation({
mutationFn: Play,
onSuccess(data) {
const item = data.data.items[0];
const preferred = item?.webrtc || item?.http_flv || "";
setLink(preferred);
playRef.current?.play(preferred);
},
onError: (error) => {
ErrorHandle(error);
},
});
React.useImperativeHandle(ref, () => ({
open(item: any, options?: { hideSidebar?: boolean }) {
console.log("打开播放详情,ID:", item.id);
setCurrentChannelId(item.id);
setCurrentChannelExt(item.ext);
setCurrentChannelType(item.type || "");
setCurrentChannelPtztype(item.ptztype ?? 0);
if (options?.hideSidebar !== undefined) {
setShowSidebar(!options.hideSidebar);
} else {
setShowSidebar(true);
}
// RTSP 类型通道需要触发播放请求才能启动拉流代理,无论 is_online 状态
// 其他类型仅在线时才触发播放
if (item.type === "RTSP" || item.is_online !== false) {
playMutate(item.id);
}
setOpen(true);
if (item.did && !options?.hideSidebar) {
setTimeout(() => {
deviceDetailRef.current?.showDetail(item.did);
}, 100);
}
},
}));
const [open, setOpen] = React.useState(false);
const playRef = useRef<PlayerRef>(null);
const [link, setLink] = useState("");
const [selected] = useState(0);
// 关闭弹窗,并销毁播放器
const onOpenChange = (v: boolean) => {
setOpen(v);
if (!v) {
playRef.current?.destroy();
}
};
const getStream = () => {
if (!playData) {
return null;
}
if (playData && playData.data?.items.length <= selected) {
return null;
}
return playData.data.items[selected];
};
/** 通道列表卡片点击:就地切换播放,不重新打开窗口 */
const handleChannelSwitch = useCallback((channel: any) => {
setCurrentChannelId(channel.id);
setCurrentChannelExt(channel.ext);
setCurrentChannelType(channel.type || "");
setCurrentChannelPtztype(channel.ptztype ?? 0);
if (channel.type === "RTSP" || channel.is_online !== false) {
playMutate(channel.id);
}
}, [playMutate]);
return (
<Drawer open={open} onOpenChange={onOpenChange}>
<DrawerContent className="h-[85vh] sm:h-[95vh]">
<div className="flex flex-col sm:flex-row h-full overflow-hidden">
{/* 播放器内容区域 - 背景色改为白色,移动端允许滚动以容纳 PTZ */}
<div className="flex-1 bg-white overflow-y-auto sm:overflow-visible" style={layout.containerStyle}>
{/* 播放器容器 */}
<div style={layout.contentStyle}>
<AspectRatio ratio={16 / 9}>
<Player ref={playRef} link={link} />
</AspectRatio>
</div>
{/* 底部信息 - 固定高度容器,通过 visibility 控制显隐避免视频位置变动 */}
<div
className="w-full mt-2"
style={{ ...layout.contentStyle, height: "120px" }}
>
{/* ZLM 标签 - 点击展开/收缩整个底部区域 */}
<div className="flex items-center my-2">
<Button
size="sm"
variant="outline"
className="shrink-0 font-medium transition-transform duration-200 hover:scale-105"
onClick={toggleProtocolsExpanded}
>
{playData?.data?.items?.[selected]?.label || "ZLM"}
<span
className={`ml-1 transition-transform duration-300 ${
protocolsExpanded ? "rotate-180" : "rotate-0"
}`}
>
<ChevronDown className="w-4 h-4" />
</span>
</Button>
</div>
{/* 地址输入框和协议按钮 - 固定高度,通过 opacity 和 visibility 控制显隐 */}
<div
className={`transition-all duration-300 ease-in-out ${
protocolsExpanded
? "opacity-100 visible"
: "opacity-0 invisible"
}`}
>
<Input
className="bg-gray-50 w-full my-2"
disabled
value={link}
/>
<div className="flex flex-wrap gap-1.5 sm:gap-2.5 my-2">
{[
{
name: "WebRTC",
addr: getStream()?.webrtc ?? "",
copy: false,
},
{
name: "HTTP_FLV",
addr: getStream()?.http_flv ?? "",
copy: true,
},
{
name: "WS_FLV",
addr: getStream()?.ws_flv ?? "",
copy: true,
},
{
name: "HLS",
addr: getStream()?.hls ?? "",
copy: true,
},
{
name: "RTMP",
addr: getStream()?.rtmp ?? "",
copy: true,
},
{
name: "RTSP",
addr: getStream()?.rtsp ?? "",
copy: true,
},
].map((item, i) => (
<ToolTips tips={item.addr || t("no_address")} key={i}>
<Button
size="sm"
variant="outline"
className={`text-[10px] h-6 px-1.5 sm:text-sm sm:h-9 sm:px-3 transition-all duration-200 ${
item.addr === link ? "border-gray-800" : ""
}`}
disabled={!item.addr}
onClick={() => {
if (!item.addr) return;
if (item.copy === true) {
copy2Clipboard(item.addr, {
title: t("stream_address_copied"),
description: item.addr,
});
return;
}
playRef.current?.play(item.addr);
setLink(item.addr);
}}
>
{item.copy && <Copy className="hidden sm:inline w-4 h-4 mr-1" />}
{item.name}
</Button>
</ToolTips>
))}
</div>
</div>
</div>
{/* 移动端 PTZ 云台控制 - z-index 最高确保不被遮挡 */}
{currentChannelId && (
<div className="sm:hidden pb-4 mt-2 relative z-50">
<PTZPanel
channelId={currentChannelId}
deviceType={currentChannelType || undefined}
ptztype={currentChannelPtztype}
/>
</div>
)}
</div>
{/* 设备详情/介绍 - 小屏幕时隐藏 */}
{showSidebar && (
<div className="hidden sm:block w-72 lg:w-[360px] bg-white overflow-y-auto">
<DeviceDetailView
ref={deviceDetailRef}
channelId={currentChannelId}
channelExt={currentChannelExt}
channelType={currentChannelType}
channelPtztype={currentChannelPtztype}
onZoneSettings={() => {
if (!currentChannelId) return;
onOpenChange(false);
navigate(`/zones?cid=${encodeURIComponent(currentChannelId)}`);
}}
onChannelSwitch={handleChannelSwitch}
/>
</div>
)}
</div>
</DrawerContent>
</Drawer>
);
}
@@ -0,0 +1,100 @@
import type React from "react";
import { useCallback, useEffect, useImperativeHandle, useRef, useState } from "react";
import { Loader2 } from "lucide-react";
import logger from "~/lib/logger";
import { toastError } from "../xui/toast";
import WebRTCPlayer, { type WebRTCPlayerRef } from "./webrtc-player";
export type PlayerRef = {
play: (link: string) => void;
destroy: () => void;
};
interface PlayerProps {
ref: React.RefObject<PlayerRef | null>;
link?: string;
}
// 为什么: WebRTC 是项目唯一保留的播放通道(低延迟+浏览器原生硬解),
// 其他协议仅作地址复制用, 不再内嵌播放器逻辑, 保持组件薄。
function isWebRTCLink(link: string): boolean {
return /^webrtc:/i.test(link);
}
function Player({ ref }: PlayerProps) {
const webrtcRef = useRef<WebRTCPlayerRef>(null);
const currentLinkRef = useRef<string | null>(null);
const [loading, setLoading] = useState(false);
// 延迟显示加载动画,快速连接时避免闪烁
const [showSpinner, setShowSpinner] = useState(false);
const spinnerTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (loading) {
spinnerTimerRef.current = setTimeout(() => setShowSpinner(true), 1000);
} else {
if (spinnerTimerRef.current) {
clearTimeout(spinnerTimerRef.current);
spinnerTimerRef.current = null;
}
setShowSpinner(false);
}
return () => {
if (spinnerTimerRef.current) {
clearTimeout(spinnerTimerRef.current);
}
};
}, [loading]);
const play = useCallback((link: string) => {
logger.info("Player ~ play ~ link:", link);
if (!isWebRTCLink(link)) {
toastError("当前仅支持 WebRTC 播放", {
description: "其他协议请点击按钮复制地址, 用外部播放器观看",
});
return;
}
setLoading(true);
currentLinkRef.current = link;
webrtcRef.current?.play(link).catch((e) => {
logger.error("Player ~ play failed:", e);
});
}, []);
const destroy = useCallback(() => {
logger.info("Player ~ destroy");
currentLinkRef.current = null;
setLoading(false);
webrtcRef.current?.destroy();
}, []);
/** WebRTC track 到达后关闭加载动画 */
const handleTrackReady = useCallback(() => {
setLoading(false);
}, []);
/** WebRTC 协商失败时也关闭加载动画,避免遮挡 warning */
const handlePlayFailed = useCallback(() => {
setLoading(false);
}, []);
useImperativeHandle(ref, () => ({ play, destroy }), [play, destroy]);
return (
<div className="min-w-full min-h-full rounded-lg bg-black relative">
<div className="absolute inset-0">
<WebRTCPlayer ref={webrtcRef} onTrackReady={handleTrackReady} onPlayFailed={handlePlayFailed} />
</div>
{showSpinner && (
<div className="absolute inset-0 flex items-center justify-center bg-black/60 rounded-lg z-10">
<div className="flex flex-col items-center gap-2">
<Loader2 className="w-8 h-8 text-white animate-spin" />
<span className="text-white/80 text-sm">...</span>
</div>
</div>
)}
</div>
);
}
export default Player;
@@ -0,0 +1,281 @@
import { AlertTriangle } from "lucide-react";
import React, { useCallback, useEffect, useImperativeHandle, useRef, useState } from "react";
import logger from "~/lib/logger";
const WARN_MSG =
"WebRTC 协商失败! 请检查您的 Chrome 浏览器是否为 105 及以上版本。\n作为替代方案,您可以复制 HTTP_FLV 流地址到 VLC 播放器中打开。";
// 为什么: 流媒体服务端可能在信令成功后才开始推流,首次连接时 track 延迟到达是正常现象,
// 单次超时判定为故障会造成误报,所以用多次重试来容忍这种延迟。
const MAX_RETRIES = 3;
const TRACK_TIMEOUT_MS = 3000;
const LAST_ATTEMPT_TIMEOUT_MS = 6000;
export type WebRTCPlayerRef = {
play: (link: string) => Promise<void>;
destroy: () => void;
};
interface WebRTCPlayerProps {
ref: React.RefObject<WebRTCPlayerRef | null>;
onTrackReady?: () => void;
onPlayFailed?: () => void;
}
// 为什么: ZLM 返回的 URL scheme 是 webrtc://, 浏览器无法直接识别, 需要按当前页面协议改写
// 成 http/https 做 HTTP 信令请求, 避免 mixed-content 被拦截。
function toSignalingURL(webrtcURL: string): string {
if (!webrtcURL) return "";
const scheme = typeof window !== "undefined" ? window.location.protocol : "http:";
return webrtcURL.replace(/^webrtc:/i, scheme);
}
// 为什么: ZLM HTTP 信令是单次 offer/answer (非 trickle), 必须把带完整 a=candidate 的 SDP 一次性发过去。
// setLocalDescription 后 ICE gathering 是异步的, 不等 complete 就发送会导致 offer 里无 candidate,
// 浏览器建不起 ICE pair → 黑屏。这里等 gathering 完成或 2s 兜底超时。
function waitIceGatheringComplete(pc: RTCPeerConnection, timeoutMs = 2000): Promise<void> {
if (pc.iceGatheringState === "complete") return Promise.resolve();
return new Promise((resolve) => {
const done = () => {
pc.removeEventListener("icegatheringstatechange", onChange);
clearTimeout(timer);
resolve();
};
const onChange = () => {
if (pc.iceGatheringState === "complete") done();
};
pc.addEventListener("icegatheringstatechange", onChange);
const timer = setTimeout(done, timeoutMs);
});
}
function WebRTCPlayer({ ref, onTrackReady, onPlayFailed }: WebRTCPlayerProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const pcRef = useRef<RTCPeerConnection | null>(null);
const abortRef = useRef<AbortController | null>(null);
const trackTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [warning, setWarning] = useState<string | null>(null);
useEffect(() => {
if (warning) onPlayFailed?.();
}, [warning]);
const destroy = useCallback(() => {
if (trackTimerRef.current) {
clearTimeout(trackTimerRef.current);
trackTimerRef.current = null;
}
abortRef.current?.abort();
abortRef.current = null;
if (pcRef.current) {
try {
pcRef.current.getSenders().forEach((s) => s.track?.stop());
pcRef.current.close();
} catch (e) {
logger.warn("WebRTCPlayer ~ close pc failed:", e);
}
pcRef.current = null;
}
if (videoRef.current) {
videoRef.current.srcObject = null;
}
}, []);
// 为什么: 单次连接可能因信令成功但流延迟到达而超时,提取为独立函数以支持外层重试。
// 返回 true 表示 track 已到达,false 表示超时。抛异常表示信令/ICE 层面失败。
const attemptConnect = useCallback(
(signaling: string, attempt: number, timeoutMs: number): Promise<boolean> => {
// 为什么: 新的重试必须中断前一次的信令请求和 PeerConnection
// 防止旧请求的响应干扰新连接状态。
abortRef.current?.abort();
abortRef.current = null;
if (pcRef.current) {
try { pcRef.current.close(); } catch (_) {}
pcRef.current = null;
}
return new Promise((resolve, reject) => {
const pc = new RTCPeerConnection();
pcRef.current = pc;
let settled = false;
const cleanup = () => {
if (trackTimerRef.current) {
clearTimeout(trackTimerRef.current);
trackTimerRef.current = null;
}
};
const settle = (result: boolean | Error) => {
if (settled) return;
settled = true;
cleanup();
if (result instanceof Error) {
reject(result);
} else {
resolve(result);
}
};
pc.addTransceiver("video", { direction: "recvonly" });
pc.addTransceiver("audio", { direction: "recvonly" });
trackTimerRef.current = setTimeout(() => {
if (pcRef.current === pc && !videoRef.current?.srcObject) {
logger.warn(`WebRTCPlayer ~ attempt ${attempt}/${MAX_RETRIES} no track within ${timeoutMs}ms`);
try { pc.close(); } catch (_) {}
if (pcRef.current === pc) pcRef.current = null;
settle(false);
}
}, timeoutMs);
pc.ontrack = (ev) => {
logger.info(`WebRTCPlayer ~ attempt ${attempt} ontrack:`, ev.track.kind);
const v = videoRef.current;
const stream = ev.streams[0];
if (!v || !stream) return;
if (v.srcObject !== stream) {
v.srcObject = stream;
onTrackReady?.();
v.play().catch((err) => {
logger.warn("WebRTCPlayer ~ video.play rejected:", err);
});
}
settle(true);
};
pc.oniceconnectionstatechange = () => {
logger.info(`WebRTCPlayer ~ attempt ${attempt} ice state:`, pc.iceConnectionState);
if (pc.iceConnectionState === "failed") {
try { pc.close(); } catch (_) {}
if (pcRef.current === pc) pcRef.current = null;
settle(new Error("ICE connection failed"));
}
};
pc.onconnectionstatechange = () => {
logger.info(`WebRTCPlayer ~ attempt ${attempt} pc state:`, pc.connectionState);
};
(async () => {
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
await waitIceGatheringComplete(pc);
const localSDP = pc.localDescription?.sdp || offer.sdp || "";
logger.info(`WebRTCPlayer ~ attempt ${attempt} local candidates:`,
(localSDP.match(/^a=candidate:/gm) || []).length);
const ac = new AbortController();
abortRef.current = ac;
const signalingTimeout = setTimeout(() => ac.abort(), timeoutMs);
let resp: Response;
try {
resp = await fetch(signaling, {
method: "POST",
headers: { "Content-Type": "application/sdp" },
body: localSDP,
signal: ac.signal,
});
} finally {
clearTimeout(signalingTimeout);
}
if (!resp.ok) throw new Error(`signaling http ${resp.status}`);
const text = await resp.text();
let answerSDP = "";
try {
const obj = JSON.parse(text);
if (obj.code !== 0) {
throw new Error(`signaling code=${obj.code} msg=${obj.msg || "unknown"}`);
}
answerSDP = obj.sdp;
} catch (_) {
if (text.startsWith("v=")) {
answerSDP = text;
} else {
throw new Error(`signaling bad response: ${text.slice(0, 120)}`);
}
}
logger.info(`WebRTCPlayer ~ attempt ${attempt} remote candidates:`,
(answerSDP.match(/^a=candidate:.*$/gm) || []).length);
await pc.setRemoteDescription({ type: "answer", sdp: answerSDP });
logger.info(`WebRTCPlayer ~ attempt ${attempt} setRemoteDescription ok`);
})().catch((e) => {
settle(e);
});
});
},
[onTrackReady],
);
const play = useCallback(async (link: string) => {
logger.info("WebRTCPlayer ~ play ~ link:", link);
destroy();
setWarning(null);
const signaling = toSignalingURL(link);
if (!signaling) {
setWarning(WARN_MSG);
return;
}
for (let i = 1; i <= MAX_RETRIES; i++) {
const timeout = i === MAX_RETRIES ? LAST_ATTEMPT_TIMEOUT_MS : TRACK_TIMEOUT_MS;
try {
const gotTrack = await attemptConnect(signaling, i, timeout);
if (gotTrack) {
logger.info(`WebRTCPlayer ~ attempt ${i} succeeded`);
return;
}
logger.warn(`WebRTCPlayer ~ attempt ${i}/${MAX_RETRIES} timed out, ${i < MAX_RETRIES ? "retrying..." : "giving up"}`);
} catch (e) {
logger.error(`WebRTCPlayer ~ attempt ${i}/${MAX_RETRIES} error:`, e);
if (i >= MAX_RETRIES) break;
logger.info(`WebRTCPlayer ~ retrying after error...`);
}
}
logger.error(`WebRTCPlayer ~ all ${MAX_RETRIES} attempts failed`);
destroy();
setWarning(WARN_MSG);
}, [destroy, attemptConnect]);
useImperativeHandle(ref, () => ({ play, destroy }), [play, destroy]);
useEffect(() => {
return () => destroy();
}, [destroy]);
return (
<div className="relative w-full h-full">
<video
ref={videoRef}
className="min-w-full min-h-full rounded-lg bg-black"
autoPlay
playsInline
muted
controls={false}
/>
{warning && (
<div
className="absolute top-0 left-0 right-0 z-10 bg-amber-500/90 text-white text-xs md:text-sm px-3 py-1.5 flex items-start gap-2 rounded-t-lg shadow"
role="alert"
>
<AlertTriangle className="shrink-0 w-4 h-4 mt-0.5" aria-hidden="true" />
<span className="flex-1 break-words whitespace-pre-line leading-snug">{warning}</span>
<button
type="button"
onClick={() => setWarning(null)}
className="shrink-0 text-white/90 hover:text-white cursor-pointer leading-none px-1"
aria-label="关闭警告"
>
×
</button>
</div>
)}
</div>
);
}
export default WebRTCPlayer;
@@ -0,0 +1,260 @@
import { useMutation } from "@tanstack/react-query";
import {
ArrowDown,
ArrowDownLeft,
ArrowDownRight,
ArrowLeft,
ArrowRight,
ArrowUp,
ArrowUpLeft,
ArrowUpRight,
Minus,
Plus,
Square,
} from "lucide-react";
import { memo, useCallback, useRef, useState } from "react";
import { toast } from "sonner";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Slider } from "~/components/ui/slider";
import { PTZControl, type PTZDirection } from "~/service/api/channel/channel";
interface PTZPanelProps {
channelId: string;
deviceType?: string;
/** 云台类型 (0=无云台/未知, >0=有云台) - 来自后端通道 ptztype 字段 */
ptztype?: number;
}
// 为什么: 开发/联调期 ptztype 尚未稳定返回, 允许面板始终显示, 生产期关闭即可。
const TEST_MODE = true;
type PtrEvt = React.MouseEvent | React.TouchEvent;
interface DirectionButtonProps {
direction: PTZDirection;
activeDirection: PTZDirection | null;
onStart: (d: PTZDirection, e?: PtrEvt) => void;
onStop: (e?: PtrEvt) => void;
icon: React.ReactNode;
ariaLabel: string;
}
// 为什么: 用 memo + 稳定回调引用避免速度滑块变动时全盘重渲染, 保障拖动手感。
const DirectionButton = memo(function DirectionButton({
direction,
activeDirection,
onStart,
onStop,
icon,
ariaLabel,
}: DirectionButtonProps) {
const isActive = activeDirection === direction;
return (
<button
type="button"
aria-label={ariaLabel}
onMouseDown={(e) => onStart(direction, e)}
onMouseUp={onStop}
onMouseLeave={onStop}
onTouchStart={(e) => onStart(direction, e)}
onTouchEnd={onStop}
className={`
relative flex items-center justify-center
h-11 w-11 sm:h-12 sm:w-12 rounded-xl select-none
transition-all duration-150 active:scale-95
border shadow-sm
${
isActive
? "bg-primary text-primary-foreground border-primary shadow-md shadow-primary/30"
: "bg-background text-foreground/80 border-border hover:bg-accent hover:text-foreground hover:-translate-y-[1px]"
}
`}
>
{icon}
</button>
);
});
interface ZoomButtonProps {
direction: "zoomin" | "zoomout";
activeDirection: PTZDirection | null;
onStart: (d: PTZDirection, e?: PtrEvt) => void;
onStop: (e?: PtrEvt) => void;
icon: React.ReactNode;
}
const ZoomButton = memo(function ZoomButton({
direction,
activeDirection,
onStart,
onStop,
icon,
}: ZoomButtonProps) {
const isActive = activeDirection === direction;
return (
<button
type="button"
onMouseDown={(e) => onStart(direction, e)}
onMouseUp={onStop}
onMouseLeave={onStop}
onTouchStart={(e) => onStart(direction, e)}
onTouchEnd={onStop}
className={`
flex items-center justify-center
h-9 rounded-lg border text-xs font-medium
transition-all duration-150 active:scale-95 select-none
${
isActive
? "bg-primary text-primary-foreground border-primary shadow-sm"
: "bg-background text-foreground/80 border-border hover:bg-accent"
}
`}
>
{icon}
</button>
);
});
export function PTZPanel({ channelId, deviceType, ptztype }: PTZPanelProps) {
const [speed, setSpeed] = useState(0.5);
const [activeDirection, setActiveDirection] = useState<PTZDirection | null>(
null,
);
const ptzMutation = useMutation({
mutationFn: (data: Parameters<typeof PTZControl>[1]) =>
PTZControl(channelId, data),
onError: (error: any) => {
toast.error(error?.message || "云台控制失败");
},
});
// 为什么: mousedown 立即发起 continuous, mouseup/leave/touchend 发 stop,
// 同方向去重避免连发; speedRef 让回调引用保持稳定, 防止速度滑块变动时子组件重渲染。
const speedRef = useRef(speed);
speedRef.current = speed;
const handleStart = useCallback(
(direction: PTZDirection, e?: PtrEvt) => {
e?.preventDefault?.();
e?.stopPropagation?.();
setActiveDirection((prev) => {
if (prev === direction) return prev;
ptzMutation.mutate({
action: "continuous",
direction,
speed: speedRef.current,
});
return direction;
});
},
[ptzMutation],
);
const handleStop = useCallback(
(e?: PtrEvt) => {
e?.preventDefault?.();
e?.stopPropagation?.();
setActiveDirection((prev) => {
if (prev) {
ptzMutation.mutate({ action: "stop" });
}
return null;
});
},
[ptzMutation],
);
const isSupportedProtocol =
deviceType === "GB28181" || deviceType === "ONVIF";
const supportsPTZ = TEST_MODE
? isSupportedProtocol
: isSupportedProtocol && (ptztype ?? 0) > 0;
if (!supportsPTZ) {
return null;
}
const directionPad = (
<div className="grid grid-cols-3 gap-1.5 w-fit mx-auto">
<DirectionButton direction="upleft" activeDirection={activeDirection} onStart={handleStart} onStop={handleStop} icon={<ArrowUpLeft className="h-4 w-4" />} ariaLabel="左上" />
<DirectionButton direction="up" activeDirection={activeDirection} onStart={handleStart} onStop={handleStop} icon={<ArrowUp className="h-4 w-4" />} ariaLabel="上" />
<DirectionButton direction="upright" activeDirection={activeDirection} onStart={handleStart} onStop={handleStop} icon={<ArrowUpRight className="h-4 w-4" />} ariaLabel="右上" />
<DirectionButton direction="left" activeDirection={activeDirection} onStart={handleStart} onStop={handleStop} icon={<ArrowLeft className="h-4 w-4" />} ariaLabel="左" />
<button
type="button"
onClick={(e) => handleStop(e)}
aria-label="停止"
className="flex items-center justify-center h-11 w-11 sm:h-12 sm:w-12 rounded-xl select-none bg-destructive/10 text-destructive border border-destructive/30 hover:bg-destructive hover:text-destructive-foreground transition-all duration-150 active:scale-95"
>
<Square className="h-3.5 w-3.5 fill-current" />
</button>
<DirectionButton direction="right" activeDirection={activeDirection} onStart={handleStart} onStop={handleStop} icon={<ArrowRight className="h-4 w-4" />} ariaLabel="右" />
<DirectionButton direction="downleft" activeDirection={activeDirection} onStart={handleStart} onStop={handleStop} icon={<ArrowDownLeft className="h-4 w-4" />} ariaLabel="左下" />
<DirectionButton direction="down" activeDirection={activeDirection} onStart={handleStart} onStop={handleStop} icon={<ArrowDown className="h-4 w-4" />} ariaLabel="下" />
<DirectionButton direction="downright" activeDirection={activeDirection} onStart={handleStart} onStop={handleStop} icon={<ArrowDownRight className="h-4 w-4" />} ariaLabel="右下" />
</div>
);
const controlPanel = (
<div className="space-y-3 flex-1 min-w-0">
{/* 速度控制 */}
<div className="space-y-1.5">
<div className="flex items-center justify-between text-[11px]">
<span className="text-muted-foreground"></span>
<span className="font-mono tabular-nums text-foreground/80">
{Math.round(speed * 100)}%
</span>
</div>
<Slider
value={[speed]}
onValueChange={(v) => setSpeed(v[0])}
min={0.1}
max={1}
step={0.1}
disabled={activeDirection !== null}
className="cursor-pointer"
/>
</div>
{/* 变焦控制 */}
<div className="grid grid-cols-2 gap-1.5">
<ZoomButton direction="zoomin" activeDirection={activeDirection} onStart={handleStart} onStop={handleStop} icon={<Plus className="h-4 w-4" />} />
<ZoomButton direction="zoomout" activeDirection={activeDirection} onStart={handleStart} onStop={handleStop} icon={<Minus className="h-4 w-4" />} />
</div>
<div className="text-[10px] text-muted-foreground/70 text-center leading-relaxed">
·
</div>
</div>
);
return (
<Card className="border-primary/15 sm:border bg-gradient-to-b from-background to-muted/30 shadow-sm sm:shadow-sm border-0 sm:border-primary/15">
<CardHeader className="pb-2 pt-2 sm:pt-3 px-1 sm:px-3">
<CardTitle className="text-xs font-semibold flex items-center justify-between text-foreground/70">
<span></span>
<Badge
variant="outline"
className="text-[10px] px-1.5 py-0 h-5 font-normal"
>
{deviceType || "PTZ"}
</Badge>
</CardTitle>
</CardHeader>
<CardContent className="px-1 sm:px-3 pb-2 sm:pb-3">
{/* 移动端左右布局,PC端上下布局 */}
<div className="flex gap-3 sm:hidden mx-auto max-w-[350px]">
<div className="shrink-0">{directionPad}</div>
{controlPanel}
</div>
<div className="hidden sm:block space-y-3">
{directionPad}
{controlPanel}
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,521 @@
/**
* SeamlessPlayer - MP4
*
* 使 mp4box.js + MSE MP4
* MP4 SourceBuffer
* timestampOffset
*/
import {
createFile,
type ISOFile,
type MP4BoxBuffer,
type Track,
} from "mp4box";
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react";
/** 视频片段信息 */
export interface VideoSegment {
/** 唯一标识 */
id: number;
/** 视频 URL */
url: string;
/** 时长(秒) */
duration: number;
/** 开始时间戳(毫秒),用于时间轴定位 */
startTime?: number;
}
/** 播放器暴露的方法 */
export interface SeamlessPlayerRef {
/** 开始播放 */
play: () => void;
/** 暂停 */
pause: () => void;
/** 跳转到指定时间(秒) */
seek: (time: number) => void;
/** 设置倍速 */
setPlaybackRate: (rate: number) => void;
/** 获取当前播放时间 */
getCurrentTime: () => number;
/** 获取总时长 */
getDuration: () => number;
/** 销毁播放器 */
destroy: () => void;
}
/** 播放器属性 */
export interface SeamlessPlayerProps {
/** 视频片段列表 */
segments: VideoSegment[];
/** 自动播放 */
autoPlay?: boolean;
/** 播放状态变化回调 */
onPlayStateChange?: (playing: boolean) => void;
/** 时间更新回调 */
onTimeUpdate?: (currentTime: number, duration: number) => void;
/** 加载进度回调 */
onLoadProgress?: (loaded: number, total: number) => void;
/** 错误回调 */
onError?: (error: Error) => void;
/** 播放结束回调 */
onEnded?: () => void;
/** 自定义样式 */
className?: string;
}
/** 内部状态 */
interface PlayerState {
isPlaying: boolean;
currentTime: number;
duration: number;
buffered: number;
loadedSegments: number;
totalSegments: number;
}
/**
* MP4
* 使 mp4box.js MP4 MSE
*/
const SeamlessPlayer = forwardRef<SeamlessPlayerRef, SeamlessPlayerProps>(
(
{
segments,
autoPlay = false,
onPlayStateChange,
onTimeUpdate,
onLoadProgress,
onError,
onEnded,
className,
},
ref
) => {
const videoRef = useRef<HTMLVideoElement>(null);
const mediaSourceRef = useRef<MediaSource | null>(null);
const sourceBufferRef = useRef<SourceBuffer | null>(null);
const mp4boxFileRef = useRef<ISOFile | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
// 播放状态
const [state, setState] = useState<PlayerState>({
isPlaying: false,
currentTime: 0,
duration: 0,
buffered: 0,
loadedSegments: 0,
totalSegments: segments.length,
});
// 累计时间偏移量,用于拼接多个文件
const timestampOffsetRef = useRef(0);
// 当前正在加载的片段索引
const currentLoadingIndexRef = useRef(0);
// 是否已初始化
const initializedRef = useRef(false);
// 待追加的 buffer 队列
const pendingBuffersRef = useRef<ArrayBuffer[]>([]);
// 是否正在追加
const isAppendingRef = useRef(false);
// codec 字符串
const codecRef = useRef<string>("");
/**
* buffer SourceBuffer
* MSE appendBuffer
*/
const appendBuffer = useCallback((buffer: ArrayBuffer) => {
const sourceBuffer = sourceBufferRef.current;
if (!sourceBuffer || sourceBuffer.updating) {
pendingBuffersRef.current.push(buffer);
return;
}
try {
isAppendingRef.current = true;
sourceBuffer.appendBuffer(buffer);
} catch (e) {
console.error("appendBuffer error:", e);
isAppendingRef.current = false;
}
}, []);
/**
* buffer
*/
const processBufferQueue = useCallback(() => {
const sourceBuffer = sourceBufferRef.current;
if (
!sourceBuffer ||
sourceBuffer.updating ||
pendingBuffersRef.current.length === 0
) {
isAppendingRef.current = false;
return;
}
const buffer = pendingBuffersRef.current.shift();
if (buffer) {
try {
sourceBuffer.appendBuffer(buffer);
} catch (e) {
console.error("processBufferQueue error:", e);
isAppendingRef.current = false;
}
}
}, []);
/**
* MP4
*/
const loadSegment = useCallback(
async (segment: VideoSegment, index: number) => {
const controller = abortControllerRef.current;
if (!controller) return;
try {
const response = await fetch(segment.url, {
signal: controller.signal,
});
if (!response.ok) {
throw new Error(
`Failed to fetch ${segment.url}: ${response.status}`
);
}
const arrayBuffer = await response.arrayBuffer();
// 创建 mp4box 文件实例处理此片段
const mp4boxFile = createFile();
// 存储解析出的 segments
const mediaSegments: ArrayBuffer[] = [];
let initSegment: ArrayBuffer | null = null;
mp4boxFile.onError = (e: string) => {
console.error(`MP4Box error for segment ${index}:`, e);
};
mp4boxFile.onReady = (info: { tracks: Track[] }) => {
// 找到视频轨道
const videoTrack = info.tracks.find(
(t: Track) => t.type === "video"
);
if (!videoTrack) {
console.error("No video track found");
return;
}
// 保存 codec 信息(仅第一个文件)
if (index === 0) {
codecRef.current = `video/mp4; codecs="${videoTrack.codec}"`;
}
// 设置分片参数
mp4boxFile.setSegmentOptions(videoTrack.id, null, {
nbSamples: 100,
});
// 获取初始化段
const initSegs = mp4boxFile.initializeSegmentation() as unknown as
| { buffer: ArrayBuffer }[]
| undefined;
if (initSegs && initSegs.length > 0) {
initSegment = initSegs[0].buffer;
}
// 开始生成媒体段
mp4boxFile.start();
};
mp4boxFile.onSegment = (
_id: number,
_user: unknown,
buffer: ArrayBuffer
) => {
mediaSegments.push(buffer);
};
// 输入数据
const mp4Buffer = arrayBuffer as MP4BoxBuffer;
mp4Buffer.fileStart = 0;
mp4boxFile.appendBuffer(mp4Buffer);
mp4boxFile.flush();
// 等待解析完成
await new Promise((resolve) => setTimeout(resolve, 100));
// 第一个文件时初始化 SourceBuffer
if (index === 0 && initSegment && codecRef.current) {
const mediaSource = mediaSourceRef.current;
if (mediaSource && mediaSource.readyState === "open") {
const sourceBuffer = mediaSource.addSourceBuffer(
codecRef.current
);
sourceBufferRef.current = sourceBuffer;
sourceBuffer.mode = "segments";
// 监听 updateend 事件处理队列
sourceBuffer.addEventListener("updateend", () => {
processBufferQueue();
// 检查是否所有片段都已加载完成
if (
currentLoadingIndexRef.current >= segments.length &&
pendingBuffersRef.current.length === 0 &&
!sourceBuffer.updating
) {
if (mediaSource.readyState === "open") {
try {
mediaSource.endOfStream();
} catch (e) {
// ignore
}
}
}
});
// 追加初始化段
appendBuffer(initSegment);
}
}
// 设置时间偏移量(从第二个文件开始)
if (index > 0) {
const sourceBuffer = sourceBufferRef.current;
if (sourceBuffer && !sourceBuffer.updating) {
// 等待之前的操作完成
await new Promise<void>((resolve) => {
const check = () => {
if (!sourceBuffer.updating) {
resolve();
} else {
setTimeout(check, 10);
}
};
check();
});
sourceBuffer.timestampOffset = timestampOffsetRef.current;
}
}
// 追加媒体段
for (const seg of mediaSegments) {
appendBuffer(seg);
}
// 更新时间偏移量
timestampOffsetRef.current += segment.duration;
// 更新加载进度
setState((prev) => ({
...prev,
loadedSegments: index + 1,
duration: timestampOffsetRef.current,
}));
onLoadProgress?.(index + 1, segments.length);
// 清理
mp4boxFile.stop();
} catch (e) {
if ((e as Error).name !== "AbortError") {
console.error(`Error loading segment ${index}:`, e);
onError?.(e as Error);
}
}
},
[segments, appendBuffer, processBufferQueue, onLoadProgress, onError]
);
/**
*
*/
const initialize = useCallback(async () => {
if (initializedRef.current || segments.length === 0) return;
initializedRef.current = true;
const video = videoRef.current;
if (!video) return;
// 创建 AbortController
abortControllerRef.current = new AbortController();
// 创建 MediaSource
const mediaSource = new MediaSource();
mediaSourceRef.current = mediaSource;
video.src = URL.createObjectURL(mediaSource);
// 等待 MediaSource 打开
await new Promise<void>((resolve) => {
mediaSource.addEventListener("sourceopen", () => resolve(), {
once: true,
});
});
// 依次加载所有片段
for (let i = 0; i < segments.length; i++) {
currentLoadingIndexRef.current = i + 1;
await loadSegment(segments[i], i);
}
// 自动播放
if (autoPlay) {
video.play().catch(() => {});
}
}, [segments, loadSegment, autoPlay]);
/**
*
*/
const destroy = useCallback(() => {
// 取消正在进行的请求
abortControllerRef.current?.abort();
abortControllerRef.current = null;
// 停止 mp4box
mp4boxFileRef.current?.stop();
mp4boxFileRef.current = null;
// 清理 MediaSource
const video = videoRef.current;
if (video) {
video.pause();
video.src = "";
video.load();
}
if (mediaSourceRef.current?.readyState === "open") {
try {
mediaSourceRef.current.endOfStream();
} catch (e) {
// ignore
}
}
mediaSourceRef.current = null;
sourceBufferRef.current = null;
pendingBuffersRef.current = [];
timestampOffsetRef.current = 0;
currentLoadingIndexRef.current = 0;
initializedRef.current = false;
isAppendingRef.current = false;
}, []);
// 暴露方法给父组件
useImperativeHandle(
ref,
() => ({
play: () => {
videoRef.current?.play().catch(() => {});
},
pause: () => {
videoRef.current?.pause();
},
seek: (time: number) => {
if (videoRef.current) {
videoRef.current.currentTime = time;
}
},
setPlaybackRate: (rate: number) => {
if (videoRef.current) {
videoRef.current.playbackRate = rate;
}
},
getCurrentTime: () => videoRef.current?.currentTime ?? 0,
getDuration: () => videoRef.current?.duration ?? 0,
destroy,
}),
[destroy]
);
// 初始化
useEffect(() => {
initialize();
return () => {
destroy();
};
}, [initialize, destroy]);
// 监听视频事件
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const handlePlay = () => {
setState((prev) => ({ ...prev, isPlaying: true }));
onPlayStateChange?.(true);
};
const handlePause = () => {
setState((prev) => ({ ...prev, isPlaying: false }));
onPlayStateChange?.(false);
};
const handleTimeUpdate = () => {
const currentTime = video.currentTime;
const duration = video.duration || 0;
setState((prev) => ({ ...prev, currentTime, duration }));
onTimeUpdate?.(currentTime, duration);
};
const handleEnded = () => {
setState((prev) => ({ ...prev, isPlaying: false }));
onPlayStateChange?.(false);
onEnded?.();
};
const handleError = () => {
const error = video.error;
if (error) {
onError?.(new Error(`Video error: ${error.message}`));
}
};
video.addEventListener("play", handlePlay);
video.addEventListener("pause", handlePause);
video.addEventListener("timeupdate", handleTimeUpdate);
video.addEventListener("ended", handleEnded);
video.addEventListener("error", handleError);
return () => {
video.removeEventListener("play", handlePlay);
video.removeEventListener("pause", handlePause);
video.removeEventListener("timeupdate", handleTimeUpdate);
video.removeEventListener("ended", handleEnded);
video.removeEventListener("error", handleError);
};
}, [onPlayStateChange, onTimeUpdate, onEnded, onError]);
return (
<div className={className}>
<video
ref={videoRef}
className="w-full h-full bg-black"
playsInline
controls
/>
{/* 加载状态指示 */}
{state.loadedSegments < state.totalSegments && (
<div className="absolute bottom-2 left-2 bg-black/70 text-white text-xs px-2 py-1 rounded">
: {state.loadedSegments}/{state.totalSegments}
</div>
)}
</div>
);
}
);
SeamlessPlayer.displayName = "SeamlessPlayer";
export default SeamlessPlayer;
@@ -0,0 +1,136 @@
import { useMutation } from "@tanstack/react-query";
import { Button, Form, Input, Popconfirm } from "antd";
import { useNavigate } from "react-router";
import { toastSuccess } from "~/components/xui/toast";
import { PUT } from "~/service/config/http";
import { ErrorHandle } from "~/service/config/error";
import { getPublicKey } from "~/service/api/user/user";
interface UpdateCredentialsResponse {
msg: string;
}
/** 使用动态导入加载 node-forge 进行 RSA-OAEP 加密 */
async function encryptWithRSA(
publicKeyPem: string,
data: string,
): Promise<string> {
const forge = (await import("node-forge")).default;
const publicKey = forge.pki.publicKeyFromPem(publicKeyPem);
const encrypted = publicKey.encrypt(data, "RSA-OAEP", {
md: forge.md.sha256.create(),
mgf1: { md: forge.md.sha256.create() },
});
return forge.util.encode64(encrypted);
}
/** 修改账户凭据,旧密码+新账号+新密码一起加密传输 */
async function updateCredentials(data: {
username: string;
old_password: string;
password: string;
}): Promise<UpdateCredentialsResponse> {
const { key: base64PemKey } = await getPublicKey();
const pemKey = atob(base64PemKey);
const encrypted = await encryptWithRSA(pemKey, JSON.stringify(data));
const res = await PUT<UpdateCredentialsResponse>("/users", { data: encrypted });
return res.data;
}
/**
*
* token JWT
*
*/
export default function AccountSettings({ onClose }: { onClose: () => void }) {
const [form] = Form.useForm();
const navigate = useNavigate();
const { mutateAsync, isPending } = useMutation({
mutationFn: updateCredentials,
onError: ErrorHandle,
onSuccess: () => {
toastSuccess("凭据更新成功");
localStorage.removeItem("GOWVP_TOKEN");
localStorage.removeItem("user");
onClose();
navigate("/");
},
});
const handleSubmit = async () => {
try {
const values = await form.validateFields();
const { confirmPassword: _, ...payload } = values;
await mutateAsync(payload);
} catch {
// 表单验证未通过
}
};
return (
<div className="max-w-sm">
<h3 className="text-base font-medium mb-4"></h3>
<Form form={form} layout="vertical" size="large">
<Form.Item
label="账号"
name="username"
rules={[{ required: true, message: "请输入账号" }]}
>
<Input placeholder="请输入新账号" />
</Form.Item>
<Form.Item
label="旧密码"
name="old_password"
rules={[{ required: true, message: "请输入旧密码" }]}
>
<Input.Password placeholder="请输入当前密码" />
</Form.Item>
<Form.Item
label="新密码"
name="password"
rules={[{ required: true, message: "请输入新密码" }]}
>
<Input.Password placeholder="请输入新密码" />
</Form.Item>
<Form.Item
label="确认密码"
name="confirmPassword"
dependencies={["password"]}
rules={[
{ required: true, message: "请确认密码" },
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue("password") === value) {
return Promise.resolve();
}
return Promise.reject(new Error("两次输入的密码不一致"));
},
}),
]}
>
<Input.Password placeholder="请再次输入密码" />
</Form.Item>
<Popconfirm
title="确认修改"
description="修改账户信息后将自动退出登录,需要使用新凭据重新登录。"
okText="确认"
cancelText="取消"
onConfirm={handleSubmit}
>
<Button
type="primary"
loading={isPending}
className="mt-2 w-1/2"
>
</Button>
</Popconfirm>
</Form>
</div>
);
}
@@ -0,0 +1,61 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Switch } from "antd";
import {
GetMetadata,
getMetadataKey,
SaveMetadata,
} from "~/service/api/metadata/metadata";
import { ErrorHandle } from "~/service/config/error";
import logger from "~/lib/logger";
export const COVER_BLUR_KEY = "cover_blur";
export const COVER_BLUR_STORAGE_KEY = "gowvp_cover_blur";
/**
*
* metadata + localStoragemetadata
* localStorage metadata localStorage
*/
export default function GeneralSettings() {
const queryClient = useQueryClient();
const { data } = useQuery({
queryKey: [getMetadataKey, COVER_BLUR_KEY],
queryFn: () => GetMetadata(COVER_BLUR_KEY),
retry: false,
});
const blurEnabled = data?.data?.ext === "true";
const { mutate, isPending } = useMutation({
mutationFn: (enabled: boolean) =>
SaveMetadata(COVER_BLUR_KEY, String(enabled)),
onSuccess: (_, enabled) => {
localStorage.setItem(COVER_BLUR_STORAGE_KEY, String(enabled));
queryClient.invalidateQueries({
queryKey: [getMetadataKey, COVER_BLUR_KEY],
});
// logger.debug("cover blur toggled", enabled);
},
onError: ErrorHandle,
});
return (
<div>
<h3 className="text-base font-medium mb-4"></h3>
<div className="flex items-center justify-between py-3">
<div>
<div className="text-sm font-medium text-gray-900"></div>
<div className="text-xs text-gray-500 mt-0.5">
</div>
</div>
<Switch
checked={blurEnabled}
loading={isPending}
onChange={(checked) => mutate(checked)}
/>
</div>
</div>
);
}
@@ -0,0 +1,67 @@
import { Modal } from "antd";
import { KeyRound, SlidersHorizontal } from "lucide-react";
import { useState } from "react";
import AccountSettings from "./account_settings";
import GeneralSettings from "./general_settings";
/** 左侧菜单项定义 */
const menuItems = [
{ key: "account", label: "账户设置", icon: KeyRound },
{ key: "general", label: "基本设置", icon: SlidersHorizontal },
] as const;
type MenuKey = (typeof menuItems)[number]["key"];
/**
*
* Modal
* ZLM
*/
export default function SettingsModal({
open,
onClose,
}: {
open: boolean;
onClose: () => void;
}) {
const [activeKey, setActiveKey] = useState<MenuKey>("account");
return (
<Modal
open={open}
onCancel={onClose}
footer={null}
width={768}
destroyOnClose
title="设置"
styles={{ body: { padding: 0 } }}
>
<div className="flex min-h-[400px]">
{/* 左侧菜单 */}
<nav className="w-40 border-r border-gray-200 py-3 shrink-0">
{menuItems.map((item) => (
<button
key={item.key}
type="button"
onClick={() => setActiveKey(item.key)}
className={`flex items-center gap-2 w-full px-4 py-2.5 text-sm transition-colors ${
activeKey === item.key
? "bg-gray-100 text-gray-900 font-medium border-l-2 border-gray-900"
: "text-gray-600 hover:bg-gray-50 hover:text-gray-900 border-l-2 border-transparent"
}`}
>
<item.icon className="w-4 h-4" />
{item.label}
</button>
))}
</nav>
{/* 右侧内容 */}
<div className="flex-1 p-6">
{activeKey === "account" && <AccountSettings onClose={onClose} />}
{activeKey === "general" && <GeneralSettings />}
</div>
</div>
</Modal>
);
}
@@ -0,0 +1,487 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { cn } from "~/lib/utils";
import type { PlaybackTimeRange } from "~/pages/recordings/time-mapping";
const MIN_WINDOW_MS = 60 * 1000;
export interface TimelineEventMarker {
id: string;
absoluteMs: number;
imageSrc?: string | null;
label: string;
title?: string;
subtitle?: string;
count?: number;
score?: number | null;
}
export interface DayPlaybackTimelineProps {
dayStartMs: number;
dayEndMs: number;
ranges: PlaybackTimeRange[];
errorRanges?: PlaybackTimeRange[];
eventMarkers?: TimelineEventMarker[];
eventRanges?: PlaybackTimeRange[];
currentTimeMs?: number | null;
onSeek: (absoluteMs: number) => void;
className?: string;
compact?: boolean;
}
export default function DayPlaybackTimeline({
dayStartMs,
dayEndMs,
ranges,
errorRanges = [],
eventMarkers = [],
eventRanges = [],
currentTimeMs,
onSeek,
className,
compact = false,
}: DayPlaybackTimelineProps) {
const overviewRef = useRef<HTMLDivElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [hoverMs, setHoverMs] = useState<number | null>(null);
const [activeMarkerId, setActiveMarkerId] = useState<string | null>(null);
const [activeEventRangeKey, setActiveEventRangeKey] = useState<string | null>(null);
const [viewStartMs, setViewStartMs] = useState(dayStartMs);
const [viewEndMs, setViewEndMs] = useState(dayEndMs);
const totalDurationMs = Math.max(dayEndMs - dayStartMs, 1);
const viewDurationMs = Math.max(viewEndMs - viewStartMs, MIN_WINDOW_MS);
useEffect(() => {
setViewStartMs(dayStartMs);
setViewEndMs(dayEndMs);
}, [dayStartMs, dayEndMs]);
const clampWindow = useCallback(
(nextStart: number, nextDuration: number) => {
const duration = Math.min(Math.max(nextDuration, MIN_WINDOW_MS), totalDurationMs);
const maxStart = dayEndMs - duration;
const start = Math.min(Math.max(nextStart, dayStartMs), maxStart);
return {
start,
end: start + duration,
};
},
[dayEndMs, dayStartMs, totalDurationMs],
);
const getAbsoluteMsFromClientX = useCallback(
(clientX: number, element: HTMLDivElement | null, useFullDay = false) => {
if (!element) return dayStartMs;
const rect = element.getBoundingClientRect();
if (rect.width <= 0) return dayStartMs;
const ratio = Math.min(Math.max((clientX - rect.left) / rect.width, 0), 1);
if (useFullDay) {
return dayStartMs + ratio * totalDurationMs;
}
return viewStartMs + ratio * viewDurationMs;
},
[dayStartMs, totalDurationMs, viewDurationMs, viewStartMs],
);
const visibleEventMarkers = useMemo(
() =>
eventMarkers.filter(
(marker) => marker.absoluteMs >= viewStartMs && marker.absoluteMs <= viewEndMs,
),
[eventMarkers, viewEndMs, viewStartMs],
);
const visibleEventRanges = useMemo(
() => clipRangesToWindow(eventRanges, viewStartMs, viewEndMs),
[eventRanges, viewEndMs, viewStartMs],
);
const findHoverEventState = useCallback(
(clientX: number, element: HTMLDivElement | null, useFullDay = false) => {
const markers = useFullDay ? eventMarkers : visibleEventMarkers;
const rangesForHit = useFullDay ? eventRanges : visibleEventRanges;
if (!element || markers.length === 0 || rangesForHit.length === 0) {
return { marker: null, rangeKey: null };
}
const rect = element.getBoundingClientRect();
if (rect.width <= 0) {
return { marker: null, rangeKey: null };
}
const absoluteMs = getAbsoluteMsFromClientX(clientX, element, useFullDay);
const duration = useFullDay ? totalDurationMs : viewDurationMs;
const baseStart = useFullDay ? dayStartMs : viewStartMs;
const msPerPx = duration / rect.width;
const toleranceMs = Math.max(msPerPx * 10, 1200);
const matchedRange = rangesForHit.find(
(range) => absoluteMs >= range.startMs - toleranceMs && absoluteMs <= range.endMs + toleranceMs,
);
if (!matchedRange) {
return { marker: null, rangeKey: null };
}
let marker: TimelineEventMarker | null = null;
let bestDistance = Number.POSITIVE_INFINITY;
for (const item of markers) {
if (item.absoluteMs < matchedRange.startMs - toleranceMs || item.absoluteMs > matchedRange.endMs + toleranceMs) {
continue;
}
const distance = Math.abs(item.absoluteMs - absoluteMs);
if (distance < bestDistance) {
bestDistance = distance;
marker = item;
}
}
if (!marker) {
return { marker: null, rangeKey: null };
}
return {
marker,
rangeKey: `${baseStart}-${matchedRange.startMs}-${matchedRange.endMs}`,
};
},
[dayStartMs, eventMarkers, eventRanges, getAbsoluteMsFromClientX, totalDurationMs, viewDurationMs, viewStartMs, visibleEventMarkers, visibleEventRanges],
);
const updatePointerState = useCallback(
(clientX: number) => {
const absoluteMs = getAbsoluteMsFromClientX(clientX, trackRef.current);
const hoverState = findHoverEventState(clientX, trackRef.current);
setHoverMs(absoluteMs);
setActiveMarkerId(hoverState.marker?.id ?? null);
setActiveEventRangeKey(hoverState.rangeKey);
return absoluteMs;
},
[findHoverEventState, getAbsoluteMsFromClientX],
);
const handleOverviewClick = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
const absoluteMs = getAbsoluteMsFromClientX(event.clientX, overviewRef.current, true);
const centeredStart = absoluteMs - viewDurationMs / 2;
const nextWindow = clampWindow(centeredStart, viewDurationMs);
setViewStartMs(nextWindow.start);
setViewEndMs(nextWindow.end);
const hoverState = findHoverEventState(event.clientX, overviewRef.current, true);
setActiveMarkerId(hoverState.marker?.id ?? null);
setActiveEventRangeKey(hoverState.rangeKey);
onSeek(absoluteMs);
},
[clampWindow, findHoverEventState, getAbsoluteMsFromClientX, onSeek, viewDurationMs],
);
const handlePointerDown = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
const absoluteMs = updatePointerState(event.clientX);
setIsDragging(true);
event.currentTarget.setPointerCapture(event.pointerId);
onSeek(absoluteMs);
},
[onSeek, updatePointerState],
);
const handlePointerMove = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
const absoluteMs = updatePointerState(event.clientX);
if (isDragging) {
onSeek(absoluteMs);
}
},
[isDragging, onSeek, updatePointerState],
);
const handlePointerUp = useCallback(() => {
setIsDragging(false);
}, []);
useEffect(() => {
const element = trackRef.current;
if (!element) return;
const handleWheel = (event: WheelEvent) => {
event.preventDefault();
const rect = element.getBoundingClientRect();
const ratio = rect.width > 0 ? (event.clientX - rect.left) / rect.width : 0.5;
const cursorRatio = Math.min(Math.max(ratio, 0), 1);
const anchorMs = viewStartMs + cursorRatio * viewDurationMs;
if (event.shiftKey) {
const delta = Math.sign(event.deltaY || event.deltaX || 0) * viewDurationMs * 0.15;
const nextWindow = clampWindow(viewStartMs + delta, viewDurationMs);
setViewStartMs(nextWindow.start);
setViewEndMs(nextWindow.end);
return;
}
const zoomFactor = event.deltaY > 0 ? 1.15 : 0.85;
const nextDuration = viewDurationMs * zoomFactor;
const nextStart = anchorMs - cursorRatio * nextDuration;
const nextWindow = clampWindow(nextStart, nextDuration);
setViewStartMs(nextWindow.start);
setViewEndMs(nextWindow.end);
};
element.addEventListener("wheel", handleWheel, { passive: false });
return () => {
element.removeEventListener("wheel", handleWheel);
};
}, [clampWindow, viewDurationMs, viewStartMs]);
const ticks = useMemo(() => {
const candidateSteps = [
60 * 1000,
5 * 60 * 1000,
10 * 60 * 1000,
15 * 60 * 1000,
30 * 60 * 1000,
60 * 60 * 1000,
2 * 60 * 60 * 1000,
3 * 60 * 60 * 1000,
6 * 60 * 60 * 1000,
];
const desiredTickCount = 8;
const targetStep = viewDurationMs / desiredTickCount;
const step = candidateSteps.find((item) => item >= targetStep) ?? candidateSteps[candidateSteps.length - 1];
const first = Math.ceil(viewStartMs / step) * step;
const result: number[] = [];
for (let tick = first; tick <= viewEndMs; tick += step) {
result.push(tick);
}
return result;
}, [viewDurationMs, viewEndMs, viewStartMs]);
const visibleRanges = useMemo(
() => clipRangesToWindow(ranges, viewStartMs, viewEndMs),
[ranges, viewEndMs, viewStartMs],
);
const visibleErrorRanges = useMemo(
() => clipRangesToWindow(errorRanges, viewStartMs, viewEndMs),
[errorRanges, viewEndMs, viewStartMs],
);
const activeMarker = useMemo(
() => eventMarkers.find((marker) => marker.id === activeMarkerId) ?? null,
[activeMarkerId, eventMarkers],
);
const currentRatio = currentTimeMs
? (currentTimeMs - viewStartMs) / viewDurationMs
: null;
const overviewCurrentRatio = currentTimeMs
? (currentTimeMs - dayStartMs) / totalDurationMs
: null;
const overviewWindowLeft = ((viewStartMs - dayStartMs) / totalDurationMs) * 100;
const overviewWindowWidth = (viewDurationMs / totalDurationMs) * 100;
const hoverRatio = hoverMs !== null ? (hoverMs - viewStartMs) / viewDurationMs : null;
const activeMarkerRatio = activeMarker
? (activeMarker.absoluteMs - viewStartMs) / viewDurationMs
: null;
const showHoverPreview = !compact && activeMarker && activeMarkerRatio !== null && activeMarkerRatio >= 0 && activeMarkerRatio <= 1;
return (
<div className={cn(compact ? "space-y-3" : "space-y-4", className)}>
<div>
<div className="mb-2 flex items-center justify-between text-xs text-gray-500">
<span>{compact ? "概览" : "全天概览"}</span>
<span>{formatDateTime(viewStartMs)} - {formatDateTime(viewEndMs)}</span>
</div>
<div
ref={overviewRef}
className={cn("relative cursor-pointer overflow-hidden rounded-xl border border-gray-200 bg-gray-50", compact ? "h-9" : "h-10")}
onPointerDown={handleOverviewClick}
>
{ranges.map((range, index) => renderRangeBlock(range, index, dayStartMs, totalDurationMs, "overview-normal"))}
{errorRanges.map((range, index) => renderRangeBlock(range, index, dayStartMs, totalDurationMs, "overview-error"))}
{eventRanges.map((range, index) => renderRangeBlock(range, index, dayStartMs, totalDurationMs, "overview-event", false, `${dayStartMs}-${range.startMs}-${range.endMs}` === activeEventRangeKey))}
<div
className="absolute top-0 bottom-0 rounded-xl border-2 border-blue-500 bg-blue-100/35"
style={{
left: `${overviewWindowLeft}%`,
width: `${Math.max(overviewWindowWidth, 2)}%`,
}}
/>
{overviewCurrentRatio !== null && overviewCurrentRatio >= 0 && overviewCurrentRatio <= 1 && (
<div
className="absolute top-0 bottom-0 w-0.5 bg-red-500"
style={{ left: `${overviewCurrentRatio * 100}%` }}
/>
)}
</div>
</div>
<div>
<div className="mb-2 flex items-center justify-between gap-3 text-xs text-gray-500">
<span>{compact ? "拖动定位" : "点击、拖动定位,悬停橙色区间预览,滚轮缩放,Shift + 滚轮平移"}</span>
<span>
{activeMarker
? activeMarker.title ?? formatDateTime(activeMarker.absoluteMs)
: hoverMs
? formatDateTime(hoverMs)
: currentTimeMs
? formatDateTime(currentTimeMs)
: "--:--:--"}
</span>
</div>
<div className="relative overflow-visible">
{showHoverPreview && activeMarker && activeMarkerRatio !== null && (
<div
className="pointer-events-none absolute bottom-full z-30 mb-3 w-56 -translate-x-1/2 overflow-hidden rounded-2xl border border-amber-200 bg-white shadow-xl"
style={{
left: `clamp(7rem, ${activeMarkerRatio * 100}%, calc(100% - 7rem))`,
}}
>
{activeMarker.imageSrc ? (
<img src={activeMarker.imageSrc} alt={activeMarker.title ?? "告警快照"} className="aspect-video w-full bg-black object-cover" />
) : (
<div className="flex aspect-video w-full items-center justify-center bg-gray-900 px-3 text-center text-xs text-white/80">
</div>
)}
<div className="space-y-1 border-t border-amber-100 px-3 py-2 text-xs text-gray-600">
<div className="flex items-center justify-between gap-2">
<span className="font-medium text-gray-800">{activeMarker.title ?? "告警快照"}</span>
{typeof activeMarker.count === "number" && activeMarker.count > 1 && (
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-medium text-amber-700">{activeMarker.count} </span>
)}
</div>
<div className="text-gray-500">{activeMarker.subtitle ?? activeMarker.label}</div>
</div>
</div>
)}
<div
ref={trackRef}
className={cn("relative cursor-pointer overflow-hidden rounded-2xl border border-gray-200 bg-white touch-none select-none", compact ? "h-24" : "h-28")}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerLeave={() => {
handlePointerUp();
setHoverMs(null);
setActiveMarkerId(null);
setActiveEventRangeKey(null);
}}
onPointerCancel={() => {
handlePointerUp();
setHoverMs(null);
setActiveMarkerId(null);
setActiveEventRangeKey(null);
}}
onDoubleClick={(event) => onSeek(getAbsoluteMsFromClientX(event.clientX, trackRef.current))}
>
<div className={cn("absolute inset-x-0 bg-gray-50", compact ? "top-7 bottom-7" : "top-8 bottom-8")} />
{ticks.map((tick) => {
const left = ((tick - viewStartMs) / viewDurationMs) * 100;
return (
<div key={tick} className="absolute top-0 bottom-0" style={{ left: `${left}%` }}>
<div className={cn("-translate-x-1/2 text-gray-500", compact ? "h-5 text-[10px]" : "h-6 text-[11px]")}>{formatAxisTime(tick, viewDurationMs)}</div>
<div className="absolute top-6 bottom-0 border-l border-dashed border-gray-200" />
</div>
);
})}
{visibleRanges.map((range, index) => renderRangeBlock(range, index, viewStartMs, viewDurationMs, "detail-normal", compact))}
{visibleErrorRanges.map((range, index) => renderRangeBlock(range, index, viewStartMs, viewDurationMs, "detail-error", compact))}
{visibleEventRanges.map((range, index) => renderRangeBlock(range, index, viewStartMs, viewDurationMs, "detail-event", compact, `${viewStartMs}-${range.startMs}-${range.endMs}` === activeEventRangeKey))}
{hoverRatio !== null && hoverRatio >= 0 && hoverRatio <= 1 && (
<div
className="absolute top-0 bottom-0 z-10 w-px bg-blue-500/80"
style={{ left: `${hoverRatio * 100}%` }}
/>
)}
{currentRatio !== null && currentRatio >= 0 && currentRatio <= 1 && (
<div
className="absolute top-0 bottom-0 z-20 w-0.5 bg-red-500"
style={{ left: `${currentRatio * 100}%` }}
>
<div className={cn("absolute top-0 rounded-full border-2 border-white bg-red-500 shadow", compact ? "-left-1 h-2.5 w-2.5" : "-left-1.5 h-3 w-3")} />
</div>
)}
</div>
</div>
</div>
</div>
);
}
function clipRangesToWindow(ranges: PlaybackTimeRange[], viewStartMs: number, viewEndMs: number) {
return ranges
.filter((range) => range.endMs >= viewStartMs && range.startMs <= viewEndMs)
.map((range) => ({
startMs: Math.max(range.startMs, viewStartMs),
endMs: Math.min(range.endMs, viewEndMs),
}));
}
function renderRangeBlock(
range: PlaybackTimeRange,
index: number,
baseStartMs: number,
durationMs: number,
variant:
| "overview-normal"
| "overview-error"
| "overview-event"
| "detail-normal"
| "detail-error"
| "detail-event",
compact = false,
active = false,
) {
const left = ((range.startMs - baseStartMs) / durationMs) * 100;
const width = ((range.endMs - range.startMs) / durationMs) * 100;
const className = {
"overview-normal": "absolute top-1.5 bottom-1.5 rounded-lg bg-blue-300",
"overview-error": "absolute top-1.5 bottom-1.5 z-10 rounded-lg bg-red-400",
"overview-event": cn("absolute top-1.5 bottom-1.5 z-20 rounded-lg bg-amber-400/90", active && "ring-2 ring-amber-200"),
"detail-normal": cn("absolute rounded-lg border border-blue-300 bg-blue-200", compact ? "top-8 bottom-8" : "top-10 bottom-10"),
"detail-error": cn("absolute z-10 rounded-lg border border-red-400 bg-red-300/90", compact ? "top-8 bottom-8" : "top-10 bottom-10"),
"detail-event": cn(
"absolute z-20 rounded-lg border border-amber-500/80 bg-amber-300/90 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.25)]",
compact ? "top-8 bottom-8" : "top-10 bottom-10",
active && "bg-amber-400 ring-2 ring-amber-200",
),
}[variant];
const minWidth = variant.startsWith("overview") ? 0.2 : variant === "detail-event" ? 0.65 : 0.5;
return (
<div
key={`${variant}-${range.startMs}-${range.endMs}-${index}`}
className={className}
style={{ left: `${left}%`, width: `${Math.max(width, minWidth)}%` }}
/>
);
}
function formatAxisTime(timestampMs: number, viewDurationMs: number) {
const date = new Date(timestampMs);
const hour = String(date.getHours()).padStart(2, "0");
const minute = String(date.getMinutes()).padStart(2, "0");
const second = String(date.getSeconds()).padStart(2, "0");
return viewDurationMs <= 10 * 60 * 1000 ? `${hour}:${minute}:${second}` : `${hour}:${minute}`;
}
function formatDateTime(timestampMs: number) {
const date = new Date(timestampMs);
const hour = String(date.getHours()).padStart(2, "0");
const minute = String(date.getMinutes()).padStart(2, "0");
const second = String(date.getSeconds()).padStart(2, "0");
return `${hour}:${minute}:${second}`;
}
@@ -0,0 +1,269 @@
import { useCallback, useRef, useState, useEffect, useMemo } from "react";
import type { TimeRange } from "~/service/api/recording/state";
import type { Event } from "~/service/api/event/state";
import { cn } from "~/lib/utils";
interface ReviewTimelineProps {
/** 录像时间段列表 */
timeRanges: TimeRange[];
/** 事件列表 */
events: Event[];
/** 当前播放时间(毫秒时间戳) */
currentTime: number;
/** 时间范围开始(毫秒时间戳) */
startTime: number;
/** 时间范围结束(毫秒时间戳) */
endTime: number;
/** 时间变化回调 */
onTimeChange: (time: number) => void;
/** 是否加载中 */
isLoading?: boolean;
}
/**
* Frigate
*
* /
*/
export function ReviewTimeline({
timeRanges,
events,
currentTime,
startTime,
endTime,
onTimeChange,
isLoading = false,
}: ReviewTimelineProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [isDragging, setIsDragging] = useState(false);
const totalDuration = endTime - startTime;
// 生成时间刻度(每小时一个大刻度,每15分钟一个小刻度)
const timeMarkers = useMemo(() => {
const markers: { time: number; label: string; isHour: boolean }[] = [];
// 从结束时间向开始时间生成
const hourMs = 60 * 60 * 1000;
const quarterMs = 15 * 60 * 1000;
// 找到第一个整点
const firstHour = new Date(endTime);
firstHour.setMinutes(0, 0, 0);
let currentMarker = firstHour.getTime();
while (currentMarker >= startTime) {
const date = new Date(currentMarker);
const isHour = date.getMinutes() === 0;
if (isHour) {
markers.push({
time: currentMarker,
label: formatTimeLabel(date),
isHour: true,
});
}
currentMarker -= quarterMs;
}
return markers;
}, [startTime, endTime]);
// 计算时间对应的位置百分比(从顶部开始,顶部是最新时间)
const getPositionPercent = useCallback(
(time: number) => {
return ((endTime - time) / totalDuration) * 100;
},
[endTime, totalDuration],
);
// 计算位置对应的时间
const getTimeFromPosition = useCallback(
(y: number, containerHeight: number) => {
const percent = y / containerHeight;
return endTime - percent * totalDuration;
},
[endTime, totalDuration],
);
// 处理指针事件
const handlePointerDown = useCallback(
(e: React.PointerEvent) => {
if (!containerRef.current) return;
setIsDragging(true);
const rect = containerRef.current.getBoundingClientRect();
const y = e.clientY - rect.top;
const time = getTimeFromPosition(y, rect.height);
onTimeChange(Math.max(startTime, Math.min(endTime, time)));
e.currentTarget.setPointerCapture(e.pointerId);
},
[getTimeFromPosition, onTimeChange, startTime, endTime],
);
const handlePointerMove = useCallback(
(e: React.PointerEvent) => {
if (!isDragging || !containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const y = e.clientY - rect.top;
const time = getTimeFromPosition(y, rect.height);
onTimeChange(Math.max(startTime, Math.min(endTime, time)));
},
[isDragging, getTimeFromPosition, onTimeChange, startTime, endTime],
);
const handlePointerUp = useCallback(() => {
setIsDragging(false);
}, []);
// 计算事件密度(用于绘制波形)
const eventDensity = useMemo(() => {
const bucketCount = 100;
const bucketDuration = totalDuration / bucketCount;
const density: number[] = new Array(bucketCount).fill(0);
for (const event of events) {
const bucketIndex = Math.floor(
(endTime - event.started_at) / bucketDuration,
);
if (bucketIndex >= 0 && bucketIndex < bucketCount) {
density[bucketIndex]++;
}
}
// 归一化
const maxDensity = Math.max(...density, 1);
return density.map((d) => d / maxDensity);
}, [events, endTime, totalDuration]);
// 当前时间指示器位置
const currentTimePercent = getPositionPercent(currentTime);
if (isLoading) {
return (
<div className="h-full flex items-center justify-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500" />
</div>
);
}
return (
<div className="h-full flex flex-col">
{/* 当前时间显示 */}
<div className="h-10 flex items-center justify-center border-b border-gray-700">
<span className="text-xs font-mono text-red-400">
{formatTimeDisplay(new Date(currentTime))}
</span>
</div>
{/* 时间轴主体 */}
<div
ref={containerRef}
className="flex-1 relative cursor-pointer select-none overflow-hidden"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerLeave={handlePointerUp}
>
{/* 背景 */}
<div className="absolute inset-0 bg-gray-900" />
{/* 录像时段(蓝色背景) */}
{timeRanges.map((range, index) => {
const topPercent = getPositionPercent(range.end_ms);
const bottomPercent = getPositionPercent(range.start_ms);
const heightPercent = bottomPercent - topPercent;
return (
<div
key={`range-${index}`}
className="absolute left-8 right-0 bg-blue-500/30"
style={{
top: `${topPercent}%`,
height: `${heightPercent}%`,
}}
/>
);
})}
{/* 事件密度波形(橙色) */}
<div className="absolute left-8 right-0 top-0 bottom-0 flex flex-col">
{eventDensity.map((density, index) => (
<div
key={`density-${index}`}
className="flex-1 flex items-center justify-end"
>
{density > 0 && (
<div
className="h-full bg-orange-400"
style={{
width: `${Math.max(density * 100, 10)}%`,
opacity: 0.3 + density * 0.7,
}}
/>
)}
</div>
))}
</div>
{/* 时间刻度 */}
{timeMarkers.map((marker) => {
const topPercent = getPositionPercent(marker.time);
if (topPercent < 0 || topPercent > 100) return null;
return (
<div
key={marker.time}
className="absolute left-0 right-0 flex items-center pointer-events-none"
style={{ top: `${topPercent}%` }}
>
<div className="w-8 flex items-center justify-end pr-1">
<span className="text-[10px] text-gray-400">
{marker.label}
</span>
</div>
<div className="flex-1 h-px bg-gray-600" />
</div>
);
})}
{/* 当前时间指示器(红色横线 + 左侧三角形) */}
<div
className="absolute left-0 right-0 z-20 pointer-events-none"
style={{ top: `${currentTimePercent}%` }}
>
<div className="relative flex items-center -translate-y-1/2">
{/* 红色横线 */}
<div className="absolute left-0 right-0 h-0.5 bg-red-500" />
{/* 左侧三角形手柄 */}
<div
className="absolute left-0 w-0 h-0"
style={{
borderTop: "6px solid transparent",
borderBottom: "6px solid transparent",
borderLeft: "8px solid #ef4444",
}}
/>
{/* 右侧小圆点 */}
<div className="absolute right-0 w-2 h-2 bg-red-500 rounded-full -translate-x-1" />
</div>
</div>
</div>
</div>
);
}
// 格式化时间标签(如 "12 PM"
function formatTimeLabel(date: Date): string {
const hours = date.getHours();
const ampm = hours >= 12 ? "PM" : "AM";
const hour12 = hours % 12 || 12;
return `${hour12} ${ampm}`;
}
// 格式化时间显示(如 "12:42:39 PM"
function formatTimeDisplay(date: Date): string {
return date.toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: true,
});
}
export default ReviewTimeline;
@@ -0,0 +1,102 @@
import {
type NodeProps,
NodeToolbar,
type NodeToolbarProps,
} from "@xyflow/react";
import React, {
createContext,
forwardRef,
type ReactNode,
useCallback,
useContext,
useState,
} from "react";
import { BaseNode } from "~/components/base-node";
/* TOOLTIP CONTEXT ---------------------------------------------------------- */
const TooltipContext = createContext(false);
/* TOOLTIP NODE ------------------------------------------------------------- */
export type TooltipNodeProps = Partial<NodeProps> & {
children?: ReactNode;
};
/**
* A component that wraps a node and provides tooltip visibility context.
*/
export const TooltipNode = forwardRef<HTMLDivElement, TooltipNodeProps>(
({ selected, children }, ref) => {
const [isTooltipVisible, setTooltipVisible] = useState(false);
const showTooltip = useCallback(() => setTooltipVisible(true), []);
const hideTooltip = useCallback(() => setTooltipVisible(false), []);
return (
<TooltipContext.Provider value={isTooltipVisible}>
<BaseNode
ref={ref}
onMouseEnter={showTooltip}
onMouseLeave={hideTooltip}
onFocus={showTooltip}
onBlur={hideTooltip}
tabIndex={0}
selected={selected}
>
{children}
</BaseNode>
</TooltipContext.Provider>
);
},
);
TooltipNode.displayName = "TooltipNode";
/* TOOLTIP CONTENT ---------------------------------------------------------- */
export type TooltipContentProps = NodeToolbarProps;
/**
* A component that displays the tooltip content based on visibility context.
*/
export const TooltipContent = forwardRef<HTMLDivElement, TooltipContentProps>(
({ position, children }, ref) => {
const isTooltipVisible = useContext(TooltipContext);
return (
<div ref={ref}>
<NodeToolbar
isVisible={isTooltipVisible}
className="rounded-sm bg-primary p-2 text-primary-foreground"
tabIndex={0}
position={position}
>
{children}
</NodeToolbar>
</div>
);
},
);
TooltipContent.displayName = "TooltipContent";
/* TOOLTIP TRIGGER ---------------------------------------------------------- */
export type TooltipTriggerProps = React.HTMLAttributes<HTMLParagraphElement>;
/**
* A component that triggers the tooltip visibility.
*/
export const TooltipTrigger = forwardRef<
HTMLParagraphElement,
TooltipTriggerProps
>(({ children, ...props }, ref) => {
return (
<div ref={ref} {...props}>
{children}
</div>
);
});
TooltipTrigger.displayName = "TooltipTrigger";
@@ -0,0 +1,160 @@
import { Tour } from "antd";
import type { TourStepProps } from "antd";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { GetMetadata, SaveMetadata } from "~/service/api/metadata/metadata";
import logger from "~/lib/logger";
const TOUR_METADATA_KEY = "app_tour_completed";
const TOUR_STORAGE_KEY = "app_tour_completed";
interface AppTourProps {
/** 引导开始前触发,用于准备 UI(如展开 FAB 菜单) */
onBeforeStep?: (step: number) => void;
/** 引导结束 */
onFinish?: () => void;
}
/** 查询 DOM 元素作为 Tour target */
function queryTarget(selector: string): HTMLElement | null {
return document.querySelector(selector);
}
/**
* metadata + localStorage
* localStorage metadata /
*/
export default function AppTour({ onBeforeStep, onFinish }: AppTourProps) {
const { t } = useTranslation("common");
const [open, setOpen] = useState(false);
const [current, setCurrent] = useState(0);
useEffect(() => {
const localFlag = localStorage.getItem(TOUR_STORAGE_KEY);
if (localFlag === "true") {
logger.info("AppTour: 本地已标记引导完成,跳过");
return;
}
// 延迟检查,等 ReactFlow 渲染完
const timer = setTimeout(() => {
setOpen(true);
}, 1500);
return () => clearTimeout(timer);
}, []);
/** 标记引导已完成,同步到 localStorage 和 metadata */
const markCompleted = useCallback(async () => {
localStorage.setItem(TOUR_STORAGE_KEY, "true");
try {
await SaveMetadata(TOUR_METADATA_KEY, "true");
logger.info("AppTour: 引导完成标记已同步到服务端");
} catch (e) {
logger.warn("AppTour: 同步引导标记到服务端失败", e);
}
}, []);
/** 临时关闭(遮罩点击/ESC),不持久化,下次刷新仍显示 */
const handleDismiss = useCallback(() => {
setOpen(false);
onFinish?.();
}, [onFinish]);
/** 永久结束引导("跳过" 或 "开始自行探索" 按钮),写入 metadata */
const handleComplete = useCallback(() => {
setOpen(false);
markCompleted();
onFinish?.();
}, [markCompleted, onFinish]);
const handleStepChange = useCallback((step: number) => {
setCurrent(step);
onBeforeStep?.(step);
}, [onBeforeStep]);
const steps: TourStepProps[] = useMemo(() => [
{
title: t("tour_dataflow_title"),
description: t("tour_dataflow_desc"),
target: () => queryTarget('[data-tour-id="dataflow"]')!,
placement: "rightBottom",
},
{
title: t("tour_floor_plan_title"),
description: t("tour_floor_plan_desc"),
target: () => queryTarget('[data-tour-id="floor-plan"]')!,
placement: "rightBottom",
},
{
title: "GB/T28181",
description: t("tour_gb28181_desc"),
target: () => queryTarget('[data-tour-id="gb28181"]')!,
placement: "right",
},
{
title: t("tour_zlm_settings_title"),
description: t("tour_zlm_settings_desc"),
target: () => queryTarget('[data-tour-id="zlm-settings"]')!,
placement: "left",
},
{
title: t("tour_fab_menu_title"),
description: t("tour_fab_menu_desc"),
target: () => queryTarget('[data-tour-id="fab-menu"]')!,
placement: "leftBottom",
},
{
title: t("tour_language_title"),
description: t("tour_language_desc"),
target: () => queryTarget('[data-tour-id="fab-language"]')!,
placement: "left",
},
], [t]);
if (!open) return null;
return (
<Tour
open={open}
current={current}
onChange={handleStepChange}
onClose={handleDismiss}
steps={steps}
indicatorsRender={(cur, total) => (
<span className="text-xs text-gray-400">
{cur + 1} / {total}
</span>
)}
actionsRender={(_, info) => {
const isLast = info.current === info.total - 1;
return (
<div className="flex gap-2">
{!isLast && (
<button
type="button"
onClick={handleComplete}
className="px-3 py-1 text-sm text-gray-500 hover:text-gray-700 transition-colors"
>
{t("tour_skip")}
</button>
)}
<button
type="button"
onClick={() => {
if (isLast) {
handleComplete();
} else {
handleStepChange(info.current + 1);
}
}}
className="px-4 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
{isLast ? t("tour_start_exploring") : t("tour_next")}
</button>
</div>
);
}}
/>
);
}
@@ -0,0 +1,138 @@
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import * as React from "react";
import { buttonVariants } from "~/components/ui/button";
import { cn } from "~/lib/utils";
const AlertDialog = AlertDialogPrimitive.Root;
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
const AlertDialogPortal = AlertDialogPrimitive.Portal;
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
ref={ref}
/>
));
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 translate-x-[-50%] translate-y-[-50%] bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className,
)}
{...props}
/>
</AlertDialogPortal>
));
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className,
)}
{...props}
/>
);
AlertDialogHeader.displayName = "AlertDialogHeader";
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className,
)}
{...props}
/>
);
AlertDialogFooter.displayName = "AlertDialogFooter";
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
));
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName;
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
));
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
className,
)}
{...props}
/>
));
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
};
@@ -0,0 +1,5 @@
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio";
const AspectRatio = AspectRatioPrimitive.Root;
export { AspectRatio };
@@ -0,0 +1,48 @@
import * as AvatarPrimitive from "@radix-ui/react-avatar";
import * as React from "react";
import { cn } from "~/lib/utils";
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className,
)}
{...props}
/>
));
Avatar.displayName = AvatarPrimitive.Root.displayName;
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
));
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
className,
)}
{...props}
/>
));
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
export { Avatar, AvatarImage, AvatarFallback };
@@ -0,0 +1,36 @@
import { cva, type VariantProps } from "class-variance-authority";
import type * as React from "react";
import { cn } from "~/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
export { Badge, badgeVariants };
@@ -0,0 +1,115 @@
import { Slot } from "@radix-ui/react-slot";
import { ChevronRight, MoreHorizontal } from "lucide-react";
import * as React from "react";
import { cn } from "~/lib/utils";
const Breadcrumb = React.forwardRef<
HTMLElement,
React.ComponentPropsWithoutRef<"nav"> & {
separator?: React.ReactNode;
}
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />);
Breadcrumb.displayName = "Breadcrumb";
const BreadcrumbList = React.forwardRef<
HTMLOListElement,
React.ComponentPropsWithoutRef<"ol">
>(({ className, ...props }, ref) => (
<ol
ref={ref}
className={cn(
"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
className,
)}
{...props}
/>
));
BreadcrumbList.displayName = "BreadcrumbList";
const BreadcrumbItem = React.forwardRef<
HTMLLIElement,
React.ComponentPropsWithoutRef<"li">
>(({ className, ...props }, ref) => (
<li
ref={ref}
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
));
BreadcrumbItem.displayName = "BreadcrumbItem";
const BreadcrumbLink = React.forwardRef<
HTMLAnchorElement,
React.ComponentPropsWithoutRef<"a"> & {
asChild?: boolean;
}
>(({ asChild, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a";
return (
<Comp
ref={ref}
className={cn("transition-colors hover:text-foreground", className)}
{...props}
/>
);
});
BreadcrumbLink.displayName = "BreadcrumbLink";
const BreadcrumbPage = React.forwardRef<
HTMLSpanElement,
React.ComponentPropsWithoutRef<"span">
>(({ className, ...props }, ref) => (
<span
ref={ref}
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props}
/>
));
BreadcrumbPage.displayName = "BreadcrumbPage";
const BreadcrumbSeparator = ({
children,
className,
...props
}: React.ComponentProps<"li">) => (
<li
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:w-3.5 [&>svg]:h-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
);
BreadcrumbSeparator.displayName = "BreadcrumbSeparator";
const BreadcrumbEllipsis = ({
className,
...props
}: React.ComponentProps<"span">) => (
<span
role="presentation"
aria-hidden="true"
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More</span>
</span>
);
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis";
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
};
@@ -0,0 +1,84 @@
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { Loader2 } from "lucide-react";
import * as React from "react";
import { cn } from "~/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
isLoading?: boolean;
isFull?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
(
{
className,
variant,
size,
asChild = false,
isLoading: loading,
isFull = false,
...props
},
ref,
) => {
const Comp = asChild ? Slot : "button";
return (
<div
className={cn("relative inline-block select-none", isFull && "w-full")}
>
<Comp
className={cn(buttonVariants({ variant, size, className }))}
style={{
height: "32px",
}}
// disabled={loading}
ref={ref}
{...props}
>
{props.children}
{loading && (
// <div className="absolute inset-0 flex items-center justify-center bg-white bg-opacity-80 z-10">
<Loader2 className="animate-spin" />
// </div>
)}
</Comp>
</div>
);
},
);
Button.displayName = "Button";
export { Button, buttonVariants };
@@ -0,0 +1,83 @@
import * as React from "react";
import { cn } from "~/lib/utils";
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-xl border-none bg-card text-card-foreground shadow-none",
className,
)}
{...props}
/>
));
Card.displayName = "Card";
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-4", className)}
{...props}
/>
));
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props}
/>
));
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-4 pt-0", className)} {...props} />
));
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
));
CardFooter.displayName = "CardFooter";
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardDescription,
CardContent,
};
@@ -0,0 +1,363 @@
import * as React from "react";
import * as RechartsPrimitive from "recharts";
import { cn } from "~/lib/utils";
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const;
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode;
icon?: React.ComponentType;
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
);
};
type ChartContextProps = {
config: ChartConfig;
};
const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() {
const context = React.useContext(ChartContext);
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />");
}
return context;
}
const ChartContainer = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
config: ChartConfig;
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"];
}
>(({ id, className, children, config, ...props }, ref) => {
const uniqueId = React.useId();
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
return (
<ChartContext.Provider value={{ config }}>
<div
data-chart={chartId}
ref={ref}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
className,
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
);
});
ChartContainer.displayName = "Chart";
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color,
);
if (!colorConfig.length) {
return null;
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join("\n")}
}
`,
)
.join("\n"),
}}
/>
);
};
const ChartTooltip = RechartsPrimitive.Tooltip;
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: "line" | "dot" | "dashed";
nameKey?: string;
labelKey?: string;
}
>(
(
{
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
},
ref,
) => {
const { config } = useChart();
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null;
}
const [item] = payload;
const key = `${labelKey || item.dataKey || item.name || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label;
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
);
}
if (!value) {
return null;
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
]);
if (!active || !payload?.length) {
return null;
}
const nestLabel = payload.length === 1 && indicator !== "dot";
return (
<div
ref={ref}
className={cn(
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
className,
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color || item.payload.fill || item.color;
return (
<div
key={item.dataKey}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center",
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
},
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center",
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="font-mono font-medium tabular-nums text-foreground">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
);
})}
</div>
</div>
);
},
);
ChartTooltipContent.displayName = "ChartTooltip";
const ChartLegend = RechartsPrimitive.Legend;
const ChartLegendContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean;
nameKey?: string;
}
>(
(
{ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey },
ref,
) => {
const { config } = useChart();
if (!payload?.length) {
return null;
}
return (
<div
ref={ref}
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className,
)}
>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
return (
<div
key={item.value}
className={cn(
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground",
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
);
})}
</div>
);
},
);
ChartLegendContent.displayName = "ChartLegend";
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string,
) {
if (typeof payload !== "object" || payload === null) {
return undefined;
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined;
let configLabelKey: string = key;
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string;
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string;
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config];
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
};
@@ -0,0 +1,28 @@
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { Check } from "lucide-react";
import * as React from "react";
import { cn } from "~/lib/utils";
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox };
@@ -0,0 +1,9 @@
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
const Collapsible = CollapsiblePrimitive.Root;
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
@@ -0,0 +1,116 @@
import * as React from "react";
import { Drawer as DrawerPrimitive } from "vaul";
import { cn } from "~/lib/utils";
const Drawer = ({
shouldScaleBackground = true,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
<DrawerPrimitive.Root
shouldScaleBackground={shouldScaleBackground}
{...props}
/>
);
Drawer.displayName = "Drawer";
const DrawerTrigger = DrawerPrimitive.Trigger;
const DrawerPortal = DrawerPrimitive.Portal;
const DrawerClose = DrawerPrimitive.Close;
const DrawerOverlay = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Overlay
ref={ref}
className={cn("fixed inset-0 z-50 bg-black/80", className)}
{...props}
/>
));
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName;
const DrawerContent = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DrawerPortal>
<DrawerOverlay />
<DrawerPrimitive.Content
ref={ref}
className={cn(
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
className,
)}
{...props}
>
<div className="mx-auto mt-3 h-1 rounded-full bg-muted" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
));
DrawerContent.displayName = "DrawerContent";
const DrawerHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
{...props}
/>
);
DrawerHeader.displayName = "DrawerHeader";
const DrawerFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
);
DrawerFooter.displayName = "DrawerFooter";
const DrawerTitle = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className,
)}
{...props}
/>
));
DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
const DrawerDescription = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
DrawerDescription.displayName = DrawerPrimitive.Description.displayName;
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
};
@@ -0,0 +1,199 @@
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { Check, ChevronRight, Circle } from "lucide-react";
import * as React from "react";
import { cn } from "~/lib/utils";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
));
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
inset && "pl-8",
className,
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className,
)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
);
};
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
};
@@ -0,0 +1,22 @@
import * as React from "react";
import { cn } from "~/lib/utils";
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className,
)}
ref={ref}
{...props}
/>
);
},
);
Input.displayName = "Input";
export { Input };
@@ -0,0 +1,24 @@
import * as LabelPrimitive from "@radix-ui/react-label";
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
import { cn } from "~/lib/utils";
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
);
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };
@@ -0,0 +1,128 @@
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu";
import { cva } from "class-variance-authority";
import { ChevronDown } from "lucide-react";
import * as React from "react";
import { cn } from "~/lib/utils";
const NavigationMenu = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Root
ref={ref}
className={cn(
"relative z-10 flex max-w-max flex-1 items-center justify-center",
className,
)}
{...props}
>
{children}
<NavigationMenuViewport />
</NavigationMenuPrimitive.Root>
));
NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName;
const NavigationMenuList = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.List>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.List>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.List
ref={ref}
className={cn(
"group flex flex-1 list-none items-center justify-center space-x-1",
className,
)}
{...props}
/>
));
NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName;
const NavigationMenuItem = NavigationMenuPrimitive.Item;
const navigationMenuTriggerStyle = cva(
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50",
);
const NavigationMenuTrigger = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Trigger
ref={ref}
className={cn(navigationMenuTriggerStyle(), "group", className)}
{...props}
>
{children}{" "}
<ChevronDown
className="relative top-[1px] ml-1 h-3 w-3 transition duration-300 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
));
NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName;
const NavigationMenuContent = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Content
ref={ref}
className={cn(
"left-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto ",
className,
)}
{...props}
/>
));
NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName;
const NavigationMenuLink = NavigationMenuPrimitive.Link;
const NavigationMenuViewport = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Viewport>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport>
>(({ className, ...props }, ref) => (
<div className={cn("absolute left-0 top-full flex justify-center")}>
<NavigationMenuPrimitive.Viewport
className={cn(
"origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]",
className,
)}
ref={ref}
{...props}
/>
</div>
));
NavigationMenuViewport.displayName =
NavigationMenuPrimitive.Viewport.displayName;
const NavigationMenuIndicator = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Indicator>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Indicator>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Indicator
ref={ref}
className={cn(
"top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in",
className,
)}
{...props}
>
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
</NavigationMenuPrimitive.Indicator>
));
NavigationMenuIndicator.displayName =
NavigationMenuPrimitive.Indicator.displayName;
export {
navigationMenuTriggerStyle,
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
};
@@ -0,0 +1,113 @@
import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react";
import * as React from "react";
import { type ButtonProps, buttonVariants } from "~/components/ui/button";
import { cn } from "~/lib/utils";
const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
<nav
aria-label="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
);
Pagination.displayName = "Pagination";
const PaginationContent = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
>(({ className, ...props }, ref) => (
<ul
ref={ref}
className={cn("flex flex-row items-center gap-1", className)}
{...props}
/>
));
PaginationContent.displayName = "PaginationContent";
const PaginationItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
>(({ className, ...props }, ref) => (
<li ref={ref} className={cn("", className)} {...props} />
));
PaginationItem.displayName = "PaginationItem";
type PaginationLinkProps = {
isActive?: boolean;
} & Pick<ButtonProps, "size"> &
React.ComponentProps<"a">;
const PaginationLink = ({
className,
isActive,
size = "icon",
...props
}: PaginationLinkProps) => (
<a
aria-current={isActive ? "page" : undefined}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
className,
)}
{...props}
/>
);
PaginationLink.displayName = "PaginationLink";
const PaginationPrevious = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("gap-1 pl-2.5", className)}
{...props}
>
<ChevronLeft className="h-4 w-4" />
</PaginationLink>
);
PaginationPrevious.displayName = "PaginationPrevious";
const PaginationNext = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("gap-1 pr-2.5", className)}
{...props}
>
<ChevronRight className="h-4 w-4" />
</PaginationLink>
);
PaginationNext.displayName = "PaginationNext";
const PaginationEllipsis = ({
className,
...props
}: React.ComponentProps<"span">) => (
<span
aria-hidden
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More pages</span>
</span>
);
PaginationEllipsis.displayName = "PaginationEllipsis";
export {
Pagination,
PaginationContent,
PaginationLink,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationEllipsis,
};
@@ -0,0 +1,31 @@
import * as PopoverPrimitive from "@radix-ui/react-popover";
import * as React from "react";
import { cn } from "~/lib/utils";
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverAnchor = PopoverPrimitive.Anchor;
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
@@ -0,0 +1,43 @@
"use client";
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
import * as React from "react";
import { cn } from "~/lib/utils";
const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Root
className={cn("grid gap-2", className)}
{...props}
ref={ref}
/>
);
});
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
"peer aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className,
)}
{...props}
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<div className="h-2 w-2 rounded-full bg-current" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
);
});
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
export { RadioGroup, RadioGroupItem };
@@ -0,0 +1,43 @@
import { GripVertical } from "lucide-react";
import * as ResizablePrimitive from "react-resizable-panels";
import { cn } from "~/lib/utils";
const ResizablePanelGroup = ({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
<ResizablePrimitive.PanelGroup
className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
className,
)}
{...props}
/>
);
const ResizablePanel = ResizablePrimitive.Panel;
const ResizableHandle = ({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean;
}) => (
<ResizablePrimitive.PanelResizeHandle
className={cn(
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className,
)}
{...props}
>
{withHandle && (
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
<GripVertical className="h-2.5 w-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
);
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };
@@ -0,0 +1,157 @@
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown, ChevronUp } from "lucide-react";
import * as React from "react";
import { cn } from "~/lib/utils";
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
{...props}
/>
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
};
@@ -0,0 +1,29 @@
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import * as React from "react";
import { cn } from "~/lib/utils";
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref,
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className,
)}
{...props}
/>
),
);
Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator };
@@ -0,0 +1,138 @@
import * as SheetPrimitive from "@radix-ui/react-dialog";
import { cva, type VariantProps } from "class-variance-authority";
import { X } from "lucide-react";
import * as React from "react";
import { cn } from "~/lib/utils";
const Sheet = SheetPrimitive.Root;
const SheetTrigger = SheetPrimitive.Trigger;
const SheetClose = SheetPrimitive.Close;
const SheetPortal = SheetPrimitive.Portal;
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
ref={ref}
/>
));
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
},
},
defaultVariants: {
side: "right",
},
},
);
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
{...props}
>
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
{children}
</SheetPrimitive.Content>
</SheetPortal>
));
SheetContent.displayName = SheetPrimitive.Content.displayName;
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className,
)}
{...props}
/>
);
SheetHeader.displayName = "SheetHeader";
const SheetFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className,
)}
{...props}
/>
);
SheetFooter.displayName = "SheetFooter";
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold text-foreground", className)}
{...props}
/>
));
SheetTitle.displayName = SheetPrimitive.Title.displayName;
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
SheetDescription.displayName = SheetPrimitive.Description.displayName;
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
};
@@ -0,0 +1,760 @@
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { PanelLeft } from "lucide-react";
import * as React from "react";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Separator } from "~/components/ui/separator";
import { Sheet, SheetContent } from "~/components/ui/sheet";
import { Skeleton } from "~/components/ui/skeleton";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "~/components/ui/tooltip";
import { useIsMobile } from "~/hooks/use-mobile";
import { cn } from "~/lib/utils";
const SIDEBAR_COOKIE_NAME = "sidebar_state";
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = "16rem";
const SIDEBAR_WIDTH_MOBILE = "18rem";
const SIDEBAR_WIDTH_ICON = "3rem";
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
type SidebarContext = {
state: "expanded" | "collapsed";
open: boolean;
setOpen: (open: boolean) => void;
openMobile: boolean;
setOpenMobile: (open: boolean) => void;
isMobile: boolean;
toggleSidebar: () => void;
};
const SidebarContext = React.createContext<SidebarContext | null>(null);
function useSidebar() {
const context = React.useContext(SidebarContext);
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.");
}
return context;
}
const SidebarProvider = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
>(
(
{
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
},
ref,
) => {
const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false);
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen);
const open = openProp ?? _open;
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value;
if (setOpenProp) {
setOpenProp(openState);
} else {
_setOpen(openState);
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
},
[setOpenProp, open],
);
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile
? setOpenMobile((open) => !open)
: setOpen((open) => !open);
}, [isMobile, setOpen]);
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault();
toggleSidebar();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [toggleSidebar]);
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed";
const contextValue = React.useMemo<SidebarContext>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, toggleSidebar],
);
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar",
className,
)}
ref={ref}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
);
},
);
SidebarProvider.displayName = "SidebarProvider";
const Sidebar = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
side?: "left" | "right";
variant?: "sidebar" | "floating" | "inset";
collapsible?: "offcanvas" | "icon" | "none";
}
>(
(
{
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
},
ref,
) => {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
if (collapsible === "none") {
return (
<div
className={cn(
"flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground",
className,
)}
ref={ref}
{...props}
>
{children}
</div>
);
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-mobile="true"
className="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
);
}
return (
<div
ref={ref}
className="group peer hidden text-sidebar-foreground md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
>
{/* This is what handles the sidebar gap on desktop */}
<div
className={cn(
"relative h-svh w-[--sidebar-width] bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon]",
)}
/>
<div
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l",
className,
)}
{...props}
>
<div
data-sidebar="sidebar"
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow"
>
{children}
</div>
</div>
</div>
);
},
);
Sidebar.displayName = "Sidebar";
const SidebarTrigger = React.forwardRef<
React.ElementRef<typeof Button>,
React.ComponentProps<typeof Button>
>(({ className, onClick, ...props }, ref) => {
const { toggleSidebar } = useSidebar();
return (
<Button
ref={ref}
data-sidebar="trigger"
variant="ghost"
size="icon"
className={cn("h-7 w-7", className)}
onClick={(event) => {
onClick?.(event);
toggleSidebar();
}}
{...props}
>
<PanelLeft />
<span className="sr-only">Toggle Sidebar</span>
</Button>
);
});
SidebarTrigger.displayName = "SidebarTrigger";
const SidebarRail = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button">
>(({ className, ...props }, ref) => {
const { toggleSidebar } = useSidebar();
return (
<button
ref={ref}
data-sidebar="rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex",
"[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className,
)}
{...props}
/>
);
});
SidebarRail.displayName = "SidebarRail";
const SidebarInset = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"main">
>(({ className, ...props }, ref) => {
return (
<main
ref={ref}
className={cn(
"relative flex min-h-svh flex-1 flex-col bg-background",
"peer-data-[variant=inset]:min-h-[calc(100svh-theme(spacing.4))] md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow",
className,
)}
{...props}
/>
);
});
SidebarInset.displayName = "SidebarInset";
const SidebarInput = React.forwardRef<
React.ElementRef<typeof Input>,
React.ComponentProps<typeof Input>
>(({ className, ...props }, ref) => {
return (
<Input
ref={ref}
data-sidebar="input"
className={cn(
"h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
className,
)}
{...props}
/>
);
});
SidebarInput.displayName = "SidebarInput";
const SidebarHeader = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
);
});
SidebarHeader.displayName = "SidebarHeader";
const SidebarFooter = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
);
});
SidebarFooter.displayName = "SidebarFooter";
const SidebarSeparator = React.forwardRef<
React.ElementRef<typeof Separator>,
React.ComponentProps<typeof Separator>
>(({ className, ...props }, ref) => {
return (
<Separator
ref={ref}
data-sidebar="separator"
className={cn("mx-2 w-auto bg-sidebar-border", className)}
{...props}
/>
);
});
SidebarSeparator.displayName = "SidebarSeparator";
const SidebarContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className,
)}
{...props}
/>
);
});
SidebarContent.displayName = "SidebarContent";
const SidebarGroup = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
);
});
SidebarGroup.displayName = "SidebarGroup";
const SidebarGroupLabel = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & { asChild?: boolean }
>(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "div";
return (
<Comp
ref={ref}
data-sidebar="group-label"
className={cn(
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opa] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className,
)}
{...props}
/>
);
});
SidebarGroupLabel.displayName = "SidebarGroupLabel";
const SidebarGroupAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & { asChild?: boolean }
>(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
ref={ref}
data-sidebar="group-action"
className={cn(
"absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
});
SidebarGroupAction.displayName = "SidebarGroupAction";
const SidebarGroupContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
));
SidebarGroupContent.displayName = "SidebarGroupContent";
const SidebarMenu = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
>(({ className, ...props }, ref) => (
<ul
ref={ref}
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
));
SidebarMenu.displayName = "SidebarMenu";
const SidebarMenuItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
>(({ className, ...props }, ref) => (
<li
ref={ref}
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
));
SidebarMenuItem.displayName = "SidebarMenuItem";
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-5 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
const SidebarMenuButton = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean;
isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants>
>(
(
{
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
},
ref,
) => {
const Comp = asChild ? Slot : "button";
const { isMobile, state } = useSidebar();
const button = (
<Comp
ref={ref}
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
);
if (!tooltip) {
return button;
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
};
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
);
},
);
SidebarMenuButton.displayName = "SidebarMenuButton";
const SidebarMenuAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean;
showOnHover?: boolean;
}
>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
ref={ref}
data-sidebar="menu-action"
className={cn(
"absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0",
className,
)}
{...props}
/>
);
});
SidebarMenuAction.displayName = "SidebarMenuAction";
const SidebarMenuBadge = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="menu-badge"
className={cn(
"pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
));
SidebarMenuBadge.displayName = "SidebarMenuBadge";
const SidebarMenuSkeleton = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
showIcon?: boolean;
}
>(({ className, showIcon = false, ...props }, ref) => {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`;
}, []);
return (
<div
ref={ref}
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-[--skeleton-width] flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
);
});
SidebarMenuSkeleton.displayName = "SidebarMenuSkeleton";
const SidebarMenuSub = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
>(({ className, ...props }, ref) => (
<ul
ref={ref}
data-sidebar="menu-sub"
className={cn(
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
));
SidebarMenuSub.displayName = "SidebarMenuSub";
const SidebarMenuSubItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
>(({ ...props }, ref) => <li ref={ref} {...props} />);
SidebarMenuSubItem.displayName = "SidebarMenuSubItem";
const SidebarMenuSubButton = React.forwardRef<
HTMLAnchorElement,
React.ComponentProps<"a"> & {
asChild?: boolean;
size?: "sm" | "md";
isActive?: boolean;
}
>(({ asChild = false, size = "md", isActive, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a";
return (
<Comp
ref={ref}
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
});
SidebarMenuSubButton.displayName = "SidebarMenuSubButton";
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
};
@@ -0,0 +1,15 @@
import { cn } from "~/lib/utils";
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-primary/10", className)}
{...props}
/>
);
}
export { Skeleton };
@@ -0,0 +1,26 @@
import * as React from "react"
import * as SliderPrimitive from "@radix-ui/react-slider"
import { cn } from "~/lib/utils"
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn(
"relative flex w-full touch-none select-none items-center",
className
)}
{...props}
>
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root>
))
Slider.displayName = SliderPrimitive.Root.displayName
export { Slider }
@@ -0,0 +1,30 @@
import { useTheme } from "next-themes";
import type React from "react";
import { Toaster as Sonner } from "sonner";
type ToasterProps = React.ComponentProps<typeof Sonner>;
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme();
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton:
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
},
}}
{...props}
/>
);
};
export { Toaster };
@@ -0,0 +1,120 @@
import * as React from "react";
import { cn } from "~/lib/utils";
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
));
Table.displayName = "Table";
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
));
TableHeader.displayName = "TableHeader";
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
));
TableBody.displayName = "TableBody";
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className,
)}
{...props}
/>
));
TableFooter.displayName = "TableFooter";
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className,
)}
{...props}
/>
));
TableRow.displayName = "TableRow";
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className,
)}
{...props}
/>
));
TableHead.displayName = "TableHead";
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn(
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className,
)}
{...props}
/>
));
TableCell.displayName = "TableCell";
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
));
TableCaption.displayName = "TableCaption";
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
};
@@ -0,0 +1,53 @@
import * as TabsPrimitive from "@radix-ui/react-tabs";
import * as React from "react";
import { cn } from "~/lib/utils";
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
className,
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
className,
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className,
)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };
@@ -0,0 +1,58 @@
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group";
import type { VariantProps } from "class-variance-authority";
import * as React from "react";
import { toggleVariants } from "~/components/ui/toggle";
import { cn } from "~/lib/utils";
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants>
>({
size: "default",
variant: "default",
});
const ToggleGroup = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants>
>(({ className, variant, size, children, ...props }, ref) => (
<ToggleGroupPrimitive.Root
ref={ref}
className={cn("flex items-center justify-center gap-1", className)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
));
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName;
const ToggleGroupItem = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>
>(({ className, children, variant, size, ...props }, ref) => {
const context = React.useContext(ToggleGroupContext);
return (
<ToggleGroupPrimitive.Item
ref={ref}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
className,
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
);
});
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName;
export { ToggleGroup, ToggleGroupItem };
@@ -0,0 +1,43 @@
import * as TogglePrimitive from "@radix-ui/react-toggle";
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
import { cn } from "~/lib/utils";
const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-9 px-2 min-w-9",
sm: "h-8 px-1.5 min-w-8",
lg: "h-10 px-2.5 min-w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
const Toggle = React.forwardRef<
React.ElementRef<typeof TogglePrimitive.Root>,
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>
>(({ className, variant, size, ...props }, ref) => (
<TogglePrimitive.Root
ref={ref}
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
));
Toggle.displayName = TogglePrimitive.Root.displayName;
export { Toggle, toggleVariants };
@@ -0,0 +1,30 @@
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import * as React from "react";
import { cn } from "~/lib/utils";
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
</TooltipPrimitive.Portal>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
@@ -0,0 +1,13 @@
import copy from "copy-to-clipboard";
import { toastSuccess } from "../xui/toast";
// copy2Clipboard 拷贝到粘贴板
export function copy2Clipboard(
s: string,
toast?: { title?: string; description?: string },
) {
copy(s);
if (toast?.title) {
toastSuccess(toast.title, { description: toast.description });
}
}
@@ -0,0 +1,33 @@
export function formatDate(dateString: string) {
if (dateString.startsWith("1970")) {
return "-";
}
if (!dateString || dateString.length < "2025-01-01 01:01:01".length) {
return dateString ?? "";
}
// 获取当前日期
const currentDate = new Date();
const currentYear = currentDate.getFullYear();
const currentMonth = currentDate.getMonth() + 1; // 月份从 0 开始,需要加 1
const currentDay = currentDate.getDate();
// 解析输入的日期字符串
const inputDate = new Date(dateString);
const inputYear = inputDate.getFullYear();
const inputMonth = inputDate.getMonth() + 1;
const inputDay = inputDate.getDate();
const inputTime = dateString.split(" ")[1]; // 提取时间部分
let result = "";
if (inputYear !== currentYear) {
return dateString;
}
// 是今年,去掉年份
result = `${inputMonth}-${inputDay} ${inputTime}`;
if (inputMonth === currentMonth && inputDay === currentDay) {
result = `Today ${inputTime}`;
}
return result;
}
@@ -0,0 +1,31 @@
import { useCallback, useRef } from "react";
/**
* Hook
* @param callback
* @param delay
* @returns
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function useDebounce<T extends (...args: any[]) => any>(
callback: T,
delay: number,
): (...args: Parameters<T>) => void {
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const debouncedFunction = useCallback(
(...args: Parameters<T>) => {
if (timerRef.current) {
clearTimeout(timerRef.current);
}
timerRef.current = setTimeout(() => {
callback(...args);
}, delay);
},
[callback, delay],
);
return debouncedFunction;
}
export default useDebounce;
@@ -0,0 +1,232 @@
import { Modal } from "antd";
import { Button } from "~/components/ui/button";
import type { CheckVersionResponse } from "~/service/api/version/state";
const IGNORED_VERSIONS_KEY = "GOWVP_IGNORED_VERSIONS";
const MAX_IGNORED_VERSIONS = 3;
interface VersionUpdateModalProps {
versionInfo: CheckVersionResponse | null;
onClose: () => void;
}
// 获取已忽略的版本列表
function getIgnoredVersions(): string[] {
try {
const stored = localStorage.getItem(IGNORED_VERSIONS_KEY);
if (stored) {
return JSON.parse(stored);
}
} catch {
// ignore
}
return [];
}
// 添加忽略的版本
function addIgnoredVersion(version: string): void {
const versions = getIgnoredVersions();
if (!versions.includes(version)) {
versions.push(version);
// 保持最多 3 个
while (versions.length > MAX_IGNORED_VERSIONS) {
versions.shift();
}
localStorage.setItem(IGNORED_VERSIONS_KEY, JSON.stringify(versions));
}
}
// 检查版本是否被忽略
export function isVersionIgnored(version: string): boolean {
return getIgnoredVersions().includes(version);
}
// 简单的 Markdown 渲染(支持基本格式)
function renderMarkdown(text: string): React.ReactNode {
if (!text) return null;
const lines = text.split("\n");
const elements: React.ReactNode[] = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// 处理标题
if (line.startsWith("### ")) {
elements.push(
<h3 key={i} className="text-base font-semibold mt-3 mb-1">
{line.slice(4)}
</h3>,
);
continue;
}
if (line.startsWith("## ")) {
elements.push(
<h2 key={i} className="text-lg font-semibold mt-3 mb-1">
{line.slice(3)}
</h2>,
);
continue;
}
if (line.startsWith("# ")) {
elements.push(
<h1 key={i} className="text-xl font-bold mt-3 mb-1">
{line.slice(2)}
</h1>,
);
continue;
}
// 处理分隔线
if (line.match(/^-{3,}$/)) {
elements.push(<hr key={i} className="my-3 border-gray-200" />);
continue;
}
// 处理列表项
if (line.match(/^\d+\.\s/)) {
const content = line.replace(/^\d+\.\s/, "");
elements.push(
<div key={i} className="flex gap-2 py-0.5">
<span className="text-gray-400"></span>
<span>{renderInlineMarkdown(content)}</span>
</div>,
);
continue;
}
if (line.startsWith("- ")) {
elements.push(
<div key={i} className="flex gap-2 py-0.5">
<span className="text-gray-400"></span>
<span>{renderInlineMarkdown(line.slice(2))}</span>
</div>,
);
continue;
}
// 空行
if (line.trim() === "") {
elements.push(<div key={i} className="h-2" />);
continue;
}
// 普通段落
elements.push(
<p key={i} className="py-0.5">
{renderInlineMarkdown(line)}
</p>,
);
}
return <div className="text-sm text-gray-600">{elements}</div>;
}
// 处理行内 Markdown(粗体、斜体、代码)
function renderInlineMarkdown(text: string): React.ReactNode {
// 简单处理:粗体 **text** 和代码 `code`
const parts: React.ReactNode[] = [];
let remaining = text;
let key = 0;
while (remaining.length > 0) {
// 处理粗体
const boldMatch = remaining.match(/\*\*(.+?)\*\*/);
// 处理代码
const codeMatch = remaining.match(/`(.+?)`/);
if (boldMatch && (!codeMatch || boldMatch.index! <= codeMatch.index!)) {
if (boldMatch.index! > 0) {
parts.push(remaining.slice(0, boldMatch.index));
}
parts.push(
<strong key={key++} className="font-semibold">
{boldMatch[1]}
</strong>,
);
remaining = remaining.slice(boldMatch.index! + boldMatch[0].length);
} else if (codeMatch) {
if (codeMatch.index! > 0) {
parts.push(remaining.slice(0, codeMatch.index));
}
parts.push(
<code
key={key++}
className="px-1 py-0.5 bg-gray-100 rounded text-xs font-mono"
>
{codeMatch[1]}
</code>,
);
remaining = remaining.slice(codeMatch.index! + codeMatch[0].length);
} else {
parts.push(remaining);
break;
}
}
return parts.length === 1 ? parts[0] : <>{parts}</>;
}
export default function VersionUpdateModal({
versionInfo,
onClose,
}: VersionUpdateModalProps) {
if (!versionInfo) return null;
const handleIgnore = () => {
addIgnoredVersion(versionInfo.new_version);
onClose();
};
const handleConfirm = (): void => {
// 直接关闭弹窗,不触发任何请求
onClose();
};
return (
<Modal
open={true}
onCancel={onClose}
footer={null}
title={
<div className="flex items-center gap-2">
<span className="text-lg">🎉 </span>
</div>
}
width={520}
>
<div className="py-4">
{/* 版本信息 */}
<div className="flex items-center gap-4 mb-4">
<div className="flex items-center gap-2">
<span className="text-gray-500">:</span>
<span className="font-mono text-sm bg-gray-100 px-2 py-0.5 rounded">
{versionInfo.current_version}
</span>
</div>
<span className="text-gray-400"></span>
<div className="flex items-center gap-2">
<span className="text-gray-500">:</span>
<span className="font-mono text-sm bg-green-100 text-green-700 px-2 py-0.5 rounded">
{versionInfo.new_version}
</span>
</div>
</div>
{/* 更新说明 */}
<div className="max-h-64 overflow-y-auto border border-gray-100 rounded-lg p-4 bg-gray-50/50">
<h4 className="text-sm font-medium text-gray-700 mb-2"></h4>
{renderMarkdown(versionInfo.description)}
</div>
{/* 操作按钮 */}
<div className="flex justify-end gap-3 mt-6">
<Button variant="outline" onClick={handleIgnore}>
</Button>
<Button onClick={handleConfirm}> Docker </Button>
</div>
</div>
</Modal>
);
}
@@ -0,0 +1,59 @@
import { PopoverClose } from "@radix-ui/react-popover";
import { CircleAlert, Trash } from "lucide-react";
import { Button } from "../ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
// XButtonDelete 删除按钮,提供二次确认功能
export function XButtonDelete({
onConfirm,
isLoading,
}: {
onConfirm: () => void;
isLoading?: boolean;
}) {
return (
<Popover>
<PopoverTrigger asChild>
<Button
isLoading={isLoading ?? false}
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
>
<Trash className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-36">
<div className="grid gap-4">
<div className="space-y-2">
<p className="text-sm text-muted-foreground flex items-center">
<CircleAlert
className="inline-block pr-2"
fill="orange"
color="white"
size={28}
/>
?
</p>
</div>
<div className="grid gap-2">
<div className="grid grid-cols-2 items-center gap-4">
<PopoverClose>
<Button size={"sm"} className="w-full" variant="outline">
</Button>
</PopoverClose>
<PopoverClose>
<Button onClick={onConfirm} size={"sm"}>
</Button>
</PopoverClose>
</div>
</div>
</div>
</PopoverContent>
</Popover>
);
}
@@ -0,0 +1,25 @@
import type React from "react";
import { cn } from "~/lib/utils";
import { Button } from "../ui/button";
export default function XButton({
children,
title,
icon,
className,
}: {
children?: React.ReactNode;
title: string;
icon?: React.ReactNode;
className?: string;
}) {
return (
<Button className={cn(className)}>
{children ?? (
<>
{icon} {title}
</>
)}
</Button>
);
}
@@ -0,0 +1,11 @@
import type { ReactNode } from "react";
export const DrawerCSSProvider = ({ children }: { children: ReactNode }) => {
return (
<div vaul-drawer-wrapper="">
<div className="relative flex min-h-screen flex-col bg-background">
{children}
</div>
</div>
);
};
@@ -0,0 +1,374 @@
import { useMutation } from "@tanstack/react-query";
import type { FormInstance } from "antd";
import { Button, Form, Modal } from "antd";
import { SquarePlus } from "lucide-react";
import React, {
Children,
isValidElement,
useImperativeHandle,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { Button as ShadcnButton } from "~/components/ui/button";
import { ErrorHandle } from "~/service/config/error";
export interface PFormProps {
onAddSuccess?: () => void; // 添加成功回调
onEditSuccess?: (data: any) => void; // 编辑成功回调
ref: React.RefObject<EditSheetImpl | null>; // 控制反转
}
// 步骤配置
export interface StepConfig {
title: string; // 步骤标题
fields: string[]; // 该步骤包含的字段名
}
interface EditSheetProps {
title: string; // 标题
description?: string; // 描述
children: React.ReactNode; // 表单内容
trigger?: React.ReactNode; // 触发器按钮
mutation: {
// api 请求
add: (values: any) => Promise<any>;
edit: (id: string, values: any) => Promise<any>;
};
onSuccess?: {
// 成功回调
add?: () => void;
edit?: (data: any) => void;
};
ref?: React.Ref<EditSheetImpl>;
form: FormInstance; // Ant Design Form 实例
width?: number | string; // Modal 宽度,默认 520
steps?: StepConfig[]; // 步骤配置,如果不提供则自动分组
fieldsPerStep?: number; // 每步字段数,默认 2
}
export interface EditSheetImpl {
edit: (values: any) => void; // 编辑时传入表单的值,打开弹窗
}
/**
*
* form id
* 3
*/
export function EditSheet({
title,
description,
children,
trigger,
mutation,
onSuccess,
ref,
form,
width = 520,
steps: customSteps,
fieldsPerStep = 2,
}: EditSheetProps) {
const { t } = useTranslation("common");
const [open, setOpen] = useState(false);
const [currentStep, setCurrentStep] = useState(0);
// 解析 children 中的 Form.Item,提取字段信息
const parseFormItems = () => {
const items: { name: string; element: React.ReactNode; hidden: boolean }[] =
[];
const traverse = (node: React.ReactNode) => {
Children.forEach(node, (child) => {
if (isValidElement(child)) {
// 检查是否是 Form.Item
if (
child.type === Form.Item ||
(child.type as any)?.displayName === "FormItem"
) {
const props = child.props as any;
const name = props.name;
const hidden = props.hidden === true;
if (name) {
items.push({ name, element: child, hidden });
}
}
// 递归处理子元素
if ((child.props as { children?: React.ReactNode })?.children) {
traverse((child.props as { children?: React.ReactNode }).children);
}
}
});
};
traverse(children);
return items;
};
const formItems = parseFormItems();
// 过滤出可见的表单项(用于分步)
const visibleItems = formItems.filter((item) => !item.hidden);
// 隐藏的表单项(始终渲染)
const hiddenItems = formItems.filter((item) => item.hidden);
// 自动生成步骤配置(每步 fieldsPerStep 个字段,最多 3 步)
const generateSteps = (): StepConfig[] => {
if (customSteps) return customSteps;
const stepsConfig: StepConfig[] = [];
const totalItems = visibleItems.length;
// 按 fieldsPerStep 分组,最多 3 步
for (
let i = 0;
i < totalItems && stepsConfig.length < 3;
i += fieldsPerStep
) {
const stepItems = visibleItems.slice(i, i + fieldsPerStep);
stepsConfig.push({
title: `${t("step")} ${stepsConfig.length + 1}`,
fields: stepItems.map((item) => item.name),
});
}
return stepsConfig;
};
const stepsConfig = generateSteps();
const totalSteps = stepsConfig.length;
const isMultiStep = totalSteps > 1;
// 获取当前步骤应该显示的字段
const getCurrentStepFields = (): string[] => {
if (!isMultiStep) return visibleItems.map((item) => item.name);
return stepsConfig[currentStep]?.fields || [];
};
// 判断当前是编辑模式还是新增模式
const isEditMode = () => {
const values = form.getFieldsValue();
return !!values.id;
};
useImperativeHandle(ref, () => ({
edit(values: any) {
console.log("🚀 ~ edit ~ values:", values);
form.setFieldsValue(values);
setCurrentStep(0);
setOpen(true);
},
}));
const { mutateAsync, isPending } = useMutation({
mutationFn: async (values: any) => {
if (values.id) {
return await mutation.edit(values.id, values);
}
return await mutation.add(values);
},
onSuccess(data, variables) {
if (variables.id) {
onSuccess?.edit?.(data.data);
} else {
onSuccess?.add?.();
}
handleClose();
},
onError: ErrorHandle,
});
// 验证当前步骤的字段
const validateCurrentStep = async (): Promise<boolean> => {
const currentFields = getCurrentStepFields();
try {
await form.validateFields(currentFields);
return true;
} catch {
return false;
}
};
// 下一步
const handleNext = async () => {
const isValid = await validateCurrentStep();
if (isValid && currentStep < totalSteps - 1) {
setCurrentStep(currentStep + 1);
}
};
// 上一步
const handlePrev = () => {
if (currentStep > 0) {
setCurrentStep(currentStep - 1);
}
};
const handleSubmit = async () => {
try {
// 最后一步时验证所有字段
const values = await form.validateFields();
await mutateAsync(values);
} catch (error) {
console.log("表单验证失败:", error);
}
};
const handleClose = () => {
setOpen(false);
setCurrentStep(0);
// 延迟重置表单,避免关闭动画时内容闪烁
setTimeout(() => {
form.resetFields();
}, 200);
};
const handleCancel = () => {
handleClose();
};
// 打开弹窗(用于新增模式)
const handleOpen = () => {
form.resetFields();
setCurrentStep(0);
setOpen(true);
};
// 渲染触发按钮
const renderTrigger = () => {
if (trigger === null) {
return null;
}
const defaultTrigger = (
<ShadcnButton onClick={handleOpen}>
<SquarePlus className="mr-2 h-4 w-4" />
{t("add")}
</ShadcnButton>
);
if (trigger) {
if (React.isValidElement(trigger)) {
return React.cloneElement(trigger as React.ReactElement<any>, {
onClick: (e: React.MouseEvent) => {
const originalOnClick = (trigger as React.ReactElement<any>).props
?.onClick;
if (originalOnClick) {
originalOnClick(e);
}
handleOpen();
},
});
}
return (
<div
onClick={handleOpen}
style={{ display: "inline-block", cursor: "pointer" }}
>
{trigger}
</div>
);
}
return defaultTrigger;
};
// 渲染表单内容
const renderFormContent = () => {
const currentFields = getCurrentStepFields();
return (
<>
{/* 隐藏字段始终渲染 */}
{hiddenItems.map((item) => (
<div key={item.name} style={{ display: "none" }}>
{item.element}
</div>
))}
{/* 可见字段根据当前步骤显示/隐藏 */}
{visibleItems.map((item) => {
const isCurrentStep = currentFields.includes(item.name);
return (
<div
key={item.name}
style={{ display: isCurrentStep ? "block" : "none" }}
>
{item.element}
</div>
);
})}
</>
);
};
// 渲染底部按钮
const renderFooter = () => {
if (!isMultiStep) {
// 单步骤模式
return [
<Button key="cancel" onClick={handleCancel}>
{t("cancel")}
</Button>,
<Button
key="submit"
type="primary"
loading={isPending}
onClick={handleSubmit}
>
{isEditMode() ? t("save") : t("add")}
</Button>,
];
}
// 多步骤模式(无取消按钮)
const isFirstStep = currentStep === 0;
const isLastStep = currentStep === totalSteps - 1;
return [
!isFirstStep && (
<Button key="prev" onClick={handlePrev}>
{t("prev_step")}
</Button>
),
isLastStep ? (
<Button
key="submit"
type="primary"
loading={isPending}
onClick={handleSubmit}
>
{isEditMode() ? t("save") : t("add")}
</Button>
) : (
<Button key="next" type="primary" onClick={handleNext}>
{t("next_step")}
</Button>
),
].filter(Boolean);
};
return (
<>
{renderTrigger()}
<Modal
title={title}
open={open}
onCancel={handleCancel}
width={width}
footer={renderFooter()}
destroyOnHidden={false}
maskClosable={false}
>
{description && (
<p className="text-gray-500 text-sm mb-4">{description}</p>
)}
<Form form={form} layout="vertical" size="large">
{renderFormContent()}
</Form>
</Modal>
</>
);
}
@@ -0,0 +1,45 @@
import { Link } from "react-router";
import React from "react";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "../ui/breadcrumb";
export default function XHeader({
items = [],
}: {
items: { title: string; url?: string }[];
}) {
return (
<header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-[[data-collapsible=icon]]/sidebar-wrapper:h-12">
<div className="flex items-center gap-2 px-4">
{/* <SidebarTrigger className="-ml-1" /> */}
{/* <Separator orientation="vertical" className="mr-2 h-4" /> */}
<Breadcrumb>
<BreadcrumbList>
{items.map((item, index) => (
<React.Fragment key={index}>
<BreadcrumbItem className="hidden md:block">
{(item.url?.length ?? 0) === 0 ? (
<BreadcrumbPage>{item.title}</BreadcrumbPage>
) : (
<Link to={item.url ?? ""}>{item.title}</Link>
// <BreadcrumbLink href={item.url}>
// </BreadcrumbLink>
)}
</BreadcrumbItem>
{index < items.length - 1 && (
<BreadcrumbSeparator className="hidden md:block" />
)}
</React.Fragment>
))}
</BreadcrumbList>
</Breadcrumb>
</div>
</header>
);
}
@@ -0,0 +1,100 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
interface TableQueryOptions<_T> {
queryKey: string;
fetchFn: (params: any) => Promise<any>;
deleteFn: (id: string) => Promise<any>;
defaultFilters?: {
page: number;
size: number;
key?: string;
};
}
export function useTableQuery<T>({
queryKey,
fetchFn,
deleteFn,
defaultFilters = { page: 1, size: 10, key: "" },
}: TableQueryOptions<T>) {
const [filters, setFilters] = useState(defaultFilters);
const queryClient = useQueryClient();
// 查询数据
const { data, isLoading } = useQuery({
queryKey: [queryKey, filters],
queryFn: () => fetchFn(filters),
});
// 删除功能
const { mutate: delMutate, isPending: delIsPending } = useMutation({
mutationFn: deleteFn,
onSuccess: (data) => {
queryClient.setQueryData([queryKey, filters], (old: any) => {
const newItems = old.data.items.filter(
(item: any) => item.id !== data.data.id,
);
// 如果当前页数据为空且不是第一页,回退一页
if (newItems.length === 0 && filters.page > 1) {
setTimeout(() => {
setFilters((prev) => ({ ...prev, page: prev.page - 1 }));
}, 370);
}
// 如果是第一页且数据为空,刷新当前页
else if (newItems.length === 0 && filters.page === 1) {
setTimeout(() => {
queryClient.invalidateQueries({
queryKey: [queryKey, filters],
});
}, 370);
}
return {
...old,
data: {
...old.data,
items: newItems,
},
};
});
},
});
// 添加成功处理
const handleAddSuccess = () => {
if (filters.page !== 1) {
setFilters({ ...filters, page: 1 });
return;
}
queryClient.invalidateQueries({
queryKey: [queryKey],
});
};
// 编辑成功处理
const handleEditSuccess = (updatedItem: T) => {
queryClient.setQueryData([queryKey, filters], (old: any) => ({
...old,
data: {
...old.data,
items: old.data.items.map((item: any) =>
item.id === (updatedItem as any).id ? updatedItem : item,
),
},
}));
};
return {
data: data?.data,
isLoading,
filters,
setFilters,
delMutate,
delIsPending,
queryClient,
handleAddSuccess,
handleEditSuccess,
};
}
@@ -0,0 +1,132 @@
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "../ui/pagination";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../ui/select";
// TODO: 这个组件是配置 shadcn table 的分页组件
// 未使用
type PaginationBoxProps = {
page: number;
size: number;
total: number;
setPagination: (page: number, size: number) => void;
};
export default function PaginationBox({
page,
size,
total,
setPagination,
}: PaginationBoxProps) {
const setData = (page: number, size: number) => {
setTimeout(() => {
setPagination(page, size);
}, 50);
};
return (
<div className="flex items-center justify-end space-x-2 py-4 select-none">
<div className="flex">
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
onClick={() => {
if (page > 1) {
setData(page - 1, size);
}
}}
/>
</PaginationItem>
{getPaginationArray(page, size, total).map((v) => {
return (
<PaginationItem key={v}>
<PaginationLink
isActive={v === page}
onClick={() => {
if (page <= 0) page = 1;
if (page !== v) {
setData(v, size);
}
}}
>
{v}
</PaginationLink>
</PaginationItem>
);
})}
{/* <PaginationItem>
<PaginationEllipsis />
</PaginationItem> */}
<PaginationItem>
<PaginationNext
onClick={() => {
if (page <= 0) page = 1;
if (page < Math.ceil(total / size)) {
setData(page + 1, size);
}
}}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
<Select
defaultValue={size.toString()}
onValueChange={(v) => setData(1, Number(v))}
>
<SelectTrigger className="w-[120px]">
<SelectValue />
</SelectTrigger>
<SelectContent className="min-w-[3rem]">
<SelectItem value="12">12 / </SelectItem>
<SelectItem value="24">24 / </SelectItem>
<SelectItem value="36">36 / </SelectItem>
<SelectItem value="50">50 / </SelectItem>
</SelectContent>
</Select>
</div>
</div>
);
}
function getPaginationArray(page: number, size: number, total: number) {
const totalPages = Math.ceil(total / size);
const paginationArray = [];
if (totalPages <= 4) {
for (let i = 1; i <= totalPages; i++) {
paginationArray.push(i);
}
return paginationArray;
}
if (page <= 2) {
for (let i = 1; i <= 4; i++) {
paginationArray.push(i);
}
} else if (page >= totalPages - 1) {
for (let i = totalPages - 3; i <= totalPages; i++) {
paginationArray.push(i);
}
} else {
paginationArray.push(page - 1);
paginationArray.push(page);
paginationArray.push(page + 1);
paginationArray.push(page + 2);
}
return paginationArray;
}
@@ -0,0 +1,148 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { ColumnsType } from "antd/es/table";
import { forwardRef, useImperativeHandle, useState } from "react";
import { ErrorHandle } from "~/service/config/error";
import { XTable } from "./table";
interface TableQueryProps<T> {
queryKey: string; // 查询key
fetchFn: (params: any) => Promise<any>; // 查询函数
deleteFn?: (id: string) => Promise<any>; // 删除函数
columns: ColumnsType<T>; // 列配置
// 过滤条件/分页
defaultFilters?: {
page: number;
size: number;
[property: string]: any;
};
}
export interface TableQueryRef<T> {
setFilters: (filters: any) => void; // 设置过滤条件
handleAddSuccess: () => void; // 添加成功处理
handleEditSuccess: (item: T) => void; // 编辑成功处理
delMutate: (id: string) => void; // 删除
delIsPending: boolean; // 删除状态
}
export const TableQuery = forwardRef<TableQueryRef<any>, TableQueryProps<any>>(
function TableQuery(
{
queryKey,
fetchFn,
deleteFn,
columns,
defaultFilters = { page: 1, size: 10 },
},
ref,
) {
const [filters, setFilters] = useState(defaultFilters);
const queryClient = useQueryClient();
// 查询数据
const { data, isLoading } = useQuery({
queryKey: [queryKey, filters],
queryFn: () => fetchFn(filters),
refetchInterval: 5000,
throwOnError: (err) => {
ErrorHandle(err);
return true;
},
});
// 删除功能
const { mutate: delMutate, isPending: delIsPending } = useMutation({
mutationFn: deleteFn,
onError: ErrorHandle,
onSuccess: (data) => {
queryClient.setQueryData([queryKey, filters], (old: any) => {
const newItems = old.data.items.filter(
(item: any) => item.id !== data.data.id,
);
// 如果当前页数据为空且不是第一页,回退一页
if (newItems.length === 0 && filters.page > 1) {
setTimeout(() => {
setFilters((prev) => ({ ...prev, page: prev.page - 1 }));
}, 370);
}
// 如果是第一页且数据为空,刷新当前页
else if (newItems.length === 0 && filters.page === 1) {
setTimeout(() => {
queryClient.invalidateQueries({
queryKey: [queryKey, filters],
});
}, 370);
}
return {
...old,
data: {
...old.data,
items: newItems,
},
};
});
},
});
// 添加成功处理
const handleAddSuccess = () => {
if (filters.page !== 1) {
setFilters({ page: 1, size: filters.size });
return;
}
queryClient.invalidateQueries({
queryKey: [queryKey],
});
};
// 编辑成功处理
const handleEditSuccess = (updatedItem: any) => {
queryClient.setQueryData([queryKey, filters], (old: any) => {
if (!old?.data) {
console.log("🚀 ~ queryClient.setQueryData ~ old:", old);
queryClient.invalidateQueries({
queryKey: [queryKey],
});
return old;
}
return {
...old,
data: {
...old.data,
items: old.data.items.map((item: any) =>
item.id === updatedItem.id ? updatedItem : item,
),
},
};
});
};
// 暴露内部方法
useImperativeHandle(ref, () => ({
setFilters,
handleAddSuccess,
handleEditSuccess,
delMutate,
delIsPending,
}));
return (
<XTable
columns={columns}
dataSource={data?.data.items}
loading={isLoading}
rowKey="id"
pagination={{
current: filters.page,
pageSize: filters.size,
total: data?.data.total,
onChange: (page, size) => {
setFilters({ ...filters, page, size });
},
}}
/>
);
},
);
@@ -0,0 +1,38 @@
import type { TableProps } from "antd";
import { Table } from "antd";
interface XTableProps<T> extends TableProps<T> {
className?: string;
}
// TODO: antd table 组件相比 shadcn/ui 开发效率更高
// 等未来有时间,可以将 xtable 实现替换成 shadcn/ui table
export function XTable<T extends object>({
className,
...props
}: XTableProps<T>) {
return (
<Table<T>
tableLayout="fixed"
{...props}
className={className}
scroll={{ x: "max-content" }}
// size="middle"
pagination={{
showSizeChanger: true,
showQuickJumper: false,
position: ["bottomRight"],
showTotal: (total) => `${total}`,
size: "small",
...props.pagination,
pageSizeOptions: ["10", "20", "30", "50"],
}}
style={{
width: "100%",
overflowX: "auto",
paddingBottom: 8,
...props.style,
}}
/>
);
}
@@ -0,0 +1,183 @@
// import * as React from "react";
// import {
// Table,
// TableBody,
// TableCell,
// TableHead,
// TableHeader,
// TableRow,
// } from "~/components/ui/table";
// import { motion, AnimatePresence } from "framer-motion";
// import PaginationBox from "./pagination";
// import { useRef, useEffect, useState } from "react";
// interface Column<T> {
// key: string;
// title: React.ReactNode;
// render?: (value: any, record: T) => React.ReactNode;
// }
// interface XTable2Props<T> {
// columns: Column<T>[];
// dataSource?: T[];
// rowKey: string;
// className?: string;
// loading?: boolean;
// pagination?: {
// page: number;
// size: number;
// total: number;
// setPagination: (page: number, size: number) => void;
// };
// }
// type OperationType = "add" | "delete" | "none";
// export function XTable2<T extends object>({
// columns,
// dataSource = [],
// rowKey,
// className,
// loading,
// pagination,
// }: XTable2Props<T>) {
// const prevDataRef = useRef<T[]>([]);
// const [operation, setOperation] = useState<{
// type: OperationType;
// id?: string;
// index?: number;
// }>({ type: "none" });
// useEffect(() => {
// const prevData = prevDataRef.current;
// // 如果是首次加载,不执行动画
// if (prevData.length === 0) {
// prevDataRef.current = dataSource;
// return;
// }
// // 避免不必要的状态更新
// if (prevData === dataSource) {
// return;
// }
// let newOperation = { type: "none" as OperationType };
// if (dataSource.length === prevData.length + 1) {
// // 添加操作
// const newRecord = dataSource.find(
// (curr: any) =>
// !prevData.some((prev: any) => prev[rowKey] === curr[rowKey])
// );
// if (newRecord) {
// const index = dataSource.findIndex(
// (item: any) => item[rowKey] === (newRecord as any)[rowKey]
// );
// newOperation = {
// type: "add",
// id: (newRecord as any)[rowKey],
// index,
// };
// }
// } else if (dataSource.length < prevData.length) {
// // 删除操作
// const deletedRecord = prevData.find(
// (prev: any) =>
// !dataSource.some((curr: any) => curr[rowKey] === prev[rowKey])
// );
// if (deletedRecord) {
// const index = prevData.findIndex(
// (item: any) => item[rowKey] === (deletedRecord as any)[rowKey]
// );
// newOperation = {
// type: "delete",
// id: (deletedRecord as any)[rowKey],
// index,
// };
// }
// }
// setOperation(newOperation);
// prevDataRef.current = dataSource;
// }, [dataSource, rowKey]);
// const renderCell = (record: T, column: Column<T>) => {
// const value = (record as any)[column.key];
// return column.render ? column.render(value, record) : value;
// };
// return (
// <div className={className}>
// <div className="overflow-hidden rounded-md border">
// <Table>
// <TableHeader>
// <TableRow className="hover:bg-transparent">
// {columns.map((column) => (
// <TableHead key={column.key} className="font-medium">
// {column.title}
// </TableHead>
// ))}
// </TableRow>
// </TableHeader>
// <TableBody>
// <AnimatePresence initial={false} mode="popLayout">
// {dataSource.map((record: T, index) => {
// const recordId = (record as any)[rowKey];
// const isNewRecord =
// operation.type === "add" && recordId === operation.id;
// const shouldShift =
// operation.type === "add" && index >= (operation.index ?? 0);
// return (
// <motion.tr
// key={recordId}
// className="hover:bg-muted/50"
// initial={
// isNewRecord
// ? { opacity: 0, x: 20 }
// : shouldShift
// ? { y: -40 }
// : { opacity: 1 }
// }
// animate={
// isNewRecord
// ? { opacity: 1, x: 0 }
// : shouldShift
// ? { y: 0 }
// : { opacity: 1 }
// }
// exit={
// operation.type === "delete" && recordId === operation.id
// ? { opacity: 0, y: -20 }
// : undefined
// }
// transition={{
// duration: 0.2,
// ease: "easeOut",
// delay: isNewRecord ? 0.15 : 0,
// }}
// >
// {columns.map((column) => (
// <TableCell key={column.key} className="font-normal">
// {renderCell(record, column)}
// </TableCell>
// ))}
// </motion.tr>
// );
// })}
// </AnimatePresence>
// </TableBody>
// </Table>
// </div>
// <div>
// <PaginationBox
// page={pagination?.page ?? 1}
// size={pagination?.size ?? 10}
// total={pagination?.total ?? 0}
// setPagination={pagination?.setPagination ?? (() => {})}
// />
// </div>
// </div>
// );
// }
@@ -0,0 +1,30 @@
import type React from "react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "../ui/tooltip";
export default function ToolTips({
children,
tips,
disabled,
}: {
children: React.ReactNode;
tips: string;
disabled?: boolean;
}) {
if (disabled) {
return <>{children}</>;
}
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent>{tips}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
@@ -0,0 +1,64 @@
import { CircleCheckBig, OctagonAlert } from "lucide-react";
import { type ExternalToast, toast } from "sonner";
// toastErrorMore 用于 api 统一错误处理
export function toastErrorMore(
message: string,
details: string[] | null,
props?: ExternalToast,
) {
toast.error(message, {
...props,
position: "top-right",
style: {
pointerEvents: "auto",
},
duration: 2000,
icon: <OctagonAlert color="red" size={22} />,
action: (details ?? []).length > 0 && {
label: <div className="z-100">😲</div>,
actionButtonStyle: {
zIndex: 100,
},
onClick: (e) => {
e.stopPropagation();
if (!details) return;
for (let i = 0; i < details.length; i++) {
toastError(details[i], {
duration: 1000,
});
}
},
},
});
}
// toastError 错误提示
export function toastError(message: string, props?: ExternalToast) {
toast.error(message, {
position: "top-right",
icon: <OctagonAlert color="red" size={22} />,
duration: 2000,
...props,
});
}
// toastSuccess 操作成功提示
export function toastSuccess(message: string, props?: ExternalToast) {
toast.success(message, {
position: "top-right",
icon: <CircleCheckBig color="green" size={22} />,
duration: 2000,
...props,
});
}
// toastWarn 警告提示
export function toastWarn(message: string, props?: ExternalToast) {
toast.warning(message, {
position: "top-right",
// icon: <CircleExclamation color="yellow" size={22} />,
duration: 2000,
...props,
});
}
@@ -0,0 +1,11 @@
// 区域编辑器预设颜色池
export const COLOR_POOL = [
"#3B82F6", // blue
"#EF4444", // red
"#10B981", // green
"#F59E0B", // amber
"#8B5CF6", // violet
"#EC4899", // pink
"#06B6D4", // cyan
"#F97316", // orange
];

Some files were not shown because too many files have changed in this diff Show More