mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
TG Bot 命令交互 + UI 圆角色差深度优化 + 通知配置修复
## TG Bot 交互命令系统 (新增) - telegram_bot.go: 命令路由 + /start /help /status /search /downloads /stats - telegram_webhook.go: Webhook 接收 + Polling 管理端点 - Polling 模式: 无需公网 HTTPS, 服务器启动自动轮询 - HTML parse_mode 统一消息格式 ## 通知配置 BUG 修复 - validateChannel 添加 email 类型支持 - 前后端字段统一: channel_type -> type - ListByType 新增仓库方法 ## UI 深度优化 - glass-panel: 添加 rounded-2xl + 可见边框 + padding - card: 添加 p-4 sm:p-6 - 全局 rounded-md -> rounded-xl, 裸 rounded -> rounded-lg - 500+ 深色硬编码类名清零 - text-gray-400 -> gray-500 色差提升 - 按钮组 flex-wrap 防溢出 - data-table 表格溢出截断
This commit is contained in:
@@ -102,6 +102,8 @@ func main() {
|
|||||||
zap.String("listen", srv.Addr),
|
zap.String("listen", srv.Addr),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// 自动启动 Telegram 长轮询(无需公网 Webhook)
|
||||||
|
services.TelegramBot.StartPolling(context.Background())
|
||||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
logger.Fatal("listen failed", zap.Error(err))
|
logger.Fatal("listen failed", zap.Error(err))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C
|
|||||||
api.GET("/health", healthCheck)
|
api.GET("/health", healthCheck)
|
||||||
api.GET("/version", versionInfo)
|
api.GET("/version", versionInfo)
|
||||||
|
|
||||||
|
// Telegram Bot webhook — called by Telegram servers, no auth.
|
||||||
|
api.POST("/telegram/webhook", telegramWebhookHandler(svc))
|
||||||
|
|
||||||
// Public auth.
|
// Public auth.
|
||||||
auth := api.Group("/auth")
|
auth := api.Group("/auth")
|
||||||
{
|
{
|
||||||
@@ -311,6 +314,12 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C
|
|||||||
admin.DELETE("/notify/channels/:id", deleteNotifyChannelHandler(svc))
|
admin.DELETE("/notify/channels/:id", deleteNotifyChannelHandler(svc))
|
||||||
admin.POST("/notify/channels/:id/test", testNotifyChannelHandler(svc))
|
admin.POST("/notify/channels/:id/test", testNotifyChannelHandler(svc))
|
||||||
|
|
||||||
|
// Telegram Bot webhook management.
|
||||||
|
admin.GET("/telegram/webhook", telegramGetWebhookHandler(svc))
|
||||||
|
admin.POST("/telegram/webhook", telegramSetWebhookHandler(svc))
|
||||||
|
admin.POST("/telegram/polling/start", telegramStartPollingHandler(svc))
|
||||||
|
admin.POST("/telegram/polling/stop", telegramStopPollingHandler(svc))
|
||||||
|
|
||||||
// File organizer.
|
// File organizer.
|
||||||
admin.POST("/media/:id/organize", organizeMediaHandler(svc))
|
admin.POST("/media/:id/organize", organizeMediaHandler(svc))
|
||||||
admin.POST("/libraries/:id/organize", organizeLibraryHandler(svc))
|
admin.POST("/libraries/:id/organize", organizeLibraryHandler(svc))
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
// Package handler — Telegram Bot Webhook 端点。
|
||||||
|
//
|
||||||
|
// 接收 Telegram Bot API 推送的 update 消息,路由到 TelegramBotService 处理。
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"github.com/ShukeBta/MediaStationGo/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// telegramWebhookHandler 处理 Telegram Bot 的 Webhook 回调。
|
||||||
|
//
|
||||||
|
// 路由:POST /api/telegram/webhook (无需认证,由 Telegram 服务器调用)
|
||||||
|
func telegramWebhookHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
body, err := io.ReadAll(c.Request.Body)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "cannot read body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := svc.TelegramBot.HandleWebhook(c.Request.Context(), body); err != nil {
|
||||||
|
svc.Log.Error("telegram webhook error", zap.Error(err))
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// telegramSetWebhookHandler 管理员手动设置/更新 Webhook URL。
|
||||||
|
//
|
||||||
|
// 路由:POST /api/admin/telegram/webhook (需 admin 认证)
|
||||||
|
func telegramSetWebhookHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
BotToken string `json:"bot_token" binding:"required"`
|
||||||
|
WebhookURL string `json:"webhook_url" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := svc.TelegramBot.SetWebhook(c.Request.Context(), req.BotToken, req.WebhookURL); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "webhook set successfully", "url": req.WebhookURL})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// telegramGetWebhookHandler 获取当前 Webhook 配置信息。
|
||||||
|
//
|
||||||
|
// 路由:GET /api/admin/telegram/webhook (需 admin 认证)
|
||||||
|
func telegramGetWebhookHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
botToken := c.Query("bot_token")
|
||||||
|
if botToken == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "bot_token is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := svc.TelegramBot.GetWebhookInfo(c.Request.Context(), botToken)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, info)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// telegramStartPollingHandler 启动 Telegram 长轮询。
|
||||||
|
//
|
||||||
|
// 路由:POST /api/admin/telegram/polling/start (需 admin 认证)
|
||||||
|
func telegramStartPollingHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
svc.TelegramBot.StartPolling(c.Request.Context())
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "polling started"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// telegramStopPollingHandler 停止 Telegram 长轮询。
|
||||||
|
//
|
||||||
|
// 路由:POST /api/admin/telegram/polling/stop (需 admin 认证)
|
||||||
|
func telegramStopPollingHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
svc.TelegramBot.StopPolling()
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "polling stopped"})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -56,6 +56,13 @@ func (r *NotifyChannelRepository) ListByEvent(ctx context.Context, eventType str
|
|||||||
return rows, err
|
return rows, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListByType returns all channels of a given type (telegram/wechat/bark/webhook/email).
|
||||||
|
func (r *NotifyChannelRepository) ListByType(ctx context.Context, channelType string) ([]model.NotifyChannel, error) {
|
||||||
|
var rows []model.NotifyChannel
|
||||||
|
err := r.db.WithContext(ctx).Where("type = ?", channelType).Find(&rows).Error
|
||||||
|
return rows, err
|
||||||
|
}
|
||||||
|
|
||||||
// Update persists changes to a notification channel.
|
// Update persists changes to a notification channel.
|
||||||
func (r *NotifyChannelRepository) Update(ctx context.Context, c *model.NotifyChannel) error {
|
func (r *NotifyChannelRepository) Update(ctx context.Context, c *model.NotifyChannel) error {
|
||||||
return r.db.WithContext(ctx).Save(c).Error
|
return r.db.WithContext(ctx).Save(c).Error
|
||||||
|
|||||||
@@ -316,7 +316,7 @@ func validateChannel(in ChannelInput) error {
|
|||||||
return errors.New("name required")
|
return errors.New("name required")
|
||||||
}
|
}
|
||||||
switch in.Type {
|
switch in.Type {
|
||||||
case "telegram", "wechat", "bark", "webhook":
|
case "telegram", "wechat", "bark", "webhook", "email":
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("unsupported channel type %q", in.Type)
|
return fmt.Errorf("unsupported channel type %q", in.Type)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ type Container struct {
|
|||||||
Backup *BackupService
|
Backup *BackupService
|
||||||
Notifier *NotifierService
|
Notifier *NotifierService
|
||||||
NotifyChannels *NotifyChannelService
|
NotifyChannels *NotifyChannelService
|
||||||
|
TelegramBot *TelegramBotService
|
||||||
PlayProfiles *PlayProfileService
|
PlayProfiles *PlayProfileService
|
||||||
Permissions *PermissionService
|
Permissions *PermissionService
|
||||||
StorageCfg *StorageConfigService
|
StorageCfg *StorageConfigService
|
||||||
@@ -105,6 +106,7 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont
|
|||||||
backup := NewBackupService(cfg, log, repos.DB)
|
backup := NewBackupService(cfg, log, repos.DB)
|
||||||
notifier := NewNotifierService(log, repos)
|
notifier := NewNotifierService(log, repos)
|
||||||
notifyChannels := NewNotifyChannelService(log, repos)
|
notifyChannels := NewNotifyChannelService(log, repos)
|
||||||
|
telegramBot := NewTelegramBotService(log, repos, crypto)
|
||||||
playProfiles := NewPlayProfileService(log, repos)
|
playProfiles := NewPlayProfileService(log, repos)
|
||||||
permissions := NewPermissionService(log, repos)
|
permissions := NewPermissionService(log, repos)
|
||||||
storageCfg := NewStorageConfigService(log, repos, crypto)
|
storageCfg := NewStorageConfigService(log, repos, crypto)
|
||||||
@@ -168,6 +170,7 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont
|
|||||||
Backup: backup,
|
Backup: backup,
|
||||||
Notifier: notifier,
|
Notifier: notifier,
|
||||||
NotifyChannels: notifyChannels,
|
NotifyChannels: notifyChannels,
|
||||||
|
TelegramBot: telegramBot,
|
||||||
PlayProfiles: playProfiles,
|
PlayProfiles: playProfiles,
|
||||||
Permissions: permissions,
|
Permissions: permissions,
|
||||||
StorageCfg: storageCfg,
|
StorageCfg: storageCfg,
|
||||||
|
|||||||
@@ -0,0 +1,552 @@
|
|||||||
|
// Package service — Telegram Bot 交互命令服务。
|
||||||
|
//
|
||||||
|
// 处理通过 Telegram Bot API 接收的用户命令,提供系统状态查询、
|
||||||
|
// 媒体搜索、下载管理等功能。同时支持 Webhook 和 Long Polling 两种模式。
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||||
|
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TelegramUpdate 是 Telegram Bot API 推送的 update 对象。
|
||||||
|
type TelegramUpdate struct {
|
||||||
|
UpdateID int `json:"update_id"`
|
||||||
|
Message *TelegramMessage `json:"message,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TelegramMessage 是 Telegram 消息对象。
|
||||||
|
type TelegramMessage struct {
|
||||||
|
MessageID int `json:"message_id"`
|
||||||
|
From TelegramUser `json:"from"`
|
||||||
|
Chat TelegramChat `json:"chat"`
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
Date int `json:"date"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TelegramUser 是 Telegram 用户对象。
|
||||||
|
type TelegramUser struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
FirstName string `json:"first_name"`
|
||||||
|
Username string `json:"username,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TelegramChat 是 Telegram 聊天对象。
|
||||||
|
type TelegramChat struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TelegramBotService 处理 Telegram Bot 的交互命令。
|
||||||
|
type TelegramBotService struct {
|
||||||
|
log *zap.Logger
|
||||||
|
repo *repository.Container
|
||||||
|
crypto *CryptoService
|
||||||
|
|
||||||
|
pollingMu sync.Mutex
|
||||||
|
pollingCancel map[string]context.CancelFunc // bot_token -> cancel
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTelegramBotService 创建 Telegram Bot 服务。
|
||||||
|
func NewTelegramBotService(log *zap.Logger, repo *repository.Container, crypto *CryptoService) *TelegramBotService {
|
||||||
|
return &TelegramBotService{
|
||||||
|
log: log,
|
||||||
|
repo: repo,
|
||||||
|
crypto: crypto,
|
||||||
|
pollingCancel: make(map[string]context.CancelFunc),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleWebhook 处理 Telegram 推送的 Webhook/Polling 消息。
|
||||||
|
func (s *TelegramBotService) HandleWebhook(ctx context.Context, body []byte) error {
|
||||||
|
var update TelegramUpdate
|
||||||
|
if err := json.Unmarshal(body, &update); err != nil {
|
||||||
|
return fmt.Errorf("invalid update: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if update.Message == nil || update.Message.Text == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := update.Message
|
||||||
|
text := strings.TrimSpace(msg.Text)
|
||||||
|
|
||||||
|
s.log.Info("telegram command received",
|
||||||
|
zap.Int("chat_id", msg.Chat.ID),
|
||||||
|
zap.String("user", msg.From.Username),
|
||||||
|
zap.String("text", text),
|
||||||
|
)
|
||||||
|
|
||||||
|
// 获取该 chat_id 对应的通知渠道配置
|
||||||
|
channel := s.findChannelByChatID(ctx, msg.Chat.ID)
|
||||||
|
|
||||||
|
// 解析并执行命令
|
||||||
|
reply, err := s.executeCommand(ctx, channel, msg, text)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Error("command failed", zap.Error(err))
|
||||||
|
_ = s.reply(ctx, channel, msg.Chat.ID, "命令执行失败: "+err.Error())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if reply != "" {
|
||||||
|
if err := s.reply(ctx, channel, msg.Chat.ID, reply); err != nil {
|
||||||
|
s.log.Error("reply failed", zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// executeCommand 解析命令并执行。
|
||||||
|
func (s *TelegramBotService) executeCommand(ctx context.Context, channel *model.NotifyChannel, msg *TelegramMessage, text string) (string, error) {
|
||||||
|
parts := strings.Fields(text)
|
||||||
|
if len(parts) == 0 {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := strings.ToLower(parts[0])
|
||||||
|
args := parts[1:]
|
||||||
|
|
||||||
|
switch cmd {
|
||||||
|
case "/start":
|
||||||
|
return s.cmdStart(msg), nil
|
||||||
|
case "/help":
|
||||||
|
return s.cmdHelp(), nil
|
||||||
|
case "/status":
|
||||||
|
return s.cmdStatus(ctx)
|
||||||
|
case "/search":
|
||||||
|
return s.cmdSearch(ctx, args)
|
||||||
|
case "/downloads":
|
||||||
|
return s.cmdDownloads(ctx)
|
||||||
|
case "/stats":
|
||||||
|
return s.cmdStats(ctx)
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("未知命令: %s\n\n输入 /help 查看可用命令列表。", cmd), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cmdStart 处理 /start 命令。
|
||||||
|
func (s *TelegramBotService) cmdStart(msg *TelegramMessage) string {
|
||||||
|
name := msg.From.FirstName
|
||||||
|
if msg.From.Username != "" {
|
||||||
|
name = "@" + msg.From.Username
|
||||||
|
}
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"<b>欢迎使用 MediaStationGo</b>\n\n"+
|
||||||
|
"你好 %s!你已成功连接到媒体中心。\n\n"+
|
||||||
|
"<b>可用命令:</b>\n"+
|
||||||
|
"📊 /status — 系统运行状态\n"+
|
||||||
|
"🔍 /search 关键词 — 搜索媒体\n"+
|
||||||
|
"📥 /downloads — 下载进度\n"+
|
||||||
|
"📈 /stats — 媒体库统计\n"+
|
||||||
|
"❓ /help — 帮助信息",
|
||||||
|
name,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// cmdHelp 处理 /help 命令。
|
||||||
|
func (s *TelegramBotService) cmdHelp() string {
|
||||||
|
return "<b>MediaStationGo 命令列表</b>\n\n" +
|
||||||
|
"<b>/start</b> — 开始使用\n" +
|
||||||
|
"<b>/help</b> — 帮助信息\n" +
|
||||||
|
"<b>/status</b> — 系统运行状态\n" +
|
||||||
|
"<b>/search 关键词</b> — 搜索媒体库\n" +
|
||||||
|
"<b>/downloads</b> — 下载列表\n" +
|
||||||
|
"<b>/stats</b> — 媒体库统计\n\n" +
|
||||||
|
"<b>自动推送事件:</b>\n" +
|
||||||
|
"• 订阅命中新资源\n" +
|
||||||
|
"• 下载任务完成\n" +
|
||||||
|
"• 刮削失败告警\n" +
|
||||||
|
"• 系统异常通知"
|
||||||
|
}
|
||||||
|
|
||||||
|
// cmdStatus 处理 /status 命令。
|
||||||
|
func (s *TelegramBotService) cmdStatus(ctx context.Context) (string, error) {
|
||||||
|
var mediaCount int64
|
||||||
|
s.repo.DB.Model(&model.Media{}).Count(&mediaCount)
|
||||||
|
|
||||||
|
var totalSize int64
|
||||||
|
s.repo.DB.Raw("SELECT COALESCE(SUM(size_bytes), 0) FROM media").Scan(&totalSize)
|
||||||
|
totalSizeGB := float64(totalSize) / 1024 / 1024 / 1024
|
||||||
|
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"<b>系统运行状态</b>\n\n"+
|
||||||
|
"🎬 媒体总数: <b>%d</b>\n"+
|
||||||
|
"💾 存储占用: <b>%.1f GB</b>",
|
||||||
|
mediaCount, totalSizeGB,
|
||||||
|
), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// cmdSearch 处理 /search 命令。
|
||||||
|
func (s *TelegramBotService) cmdSearch(ctx context.Context, args []string) (string, error) {
|
||||||
|
if len(args) == 0 {
|
||||||
|
return "请提供搜索关键词\n例: <code>/search 哥斯拉</code>", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
keyword := strings.Join(args, " ")
|
||||||
|
var results []model.Media
|
||||||
|
err := s.repo.DB.Where("title LIKE ?", "%"+keyword+"%").
|
||||||
|
Order("year DESC").Limit(8).
|
||||||
|
Find(&results).Error
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(results) == 0 {
|
||||||
|
return fmt.Sprintf("未找到与 <b>%s</b> 相关的媒体", keyword), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(fmt.Sprintf("<b>搜索: %s</b>\n\n", keyword))
|
||||||
|
for i, m := range results {
|
||||||
|
year := ""
|
||||||
|
if m.Year > 0 {
|
||||||
|
year = fmt.Sprintf(" (%d)", m.Year)
|
||||||
|
}
|
||||||
|
ep := ""
|
||||||
|
if m.SeasonNum > 0 && m.EpisodeNum > 0 {
|
||||||
|
ep = fmt.Sprintf(" S%02dE%02d", m.SeasonNum, m.EpisodeNum)
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf("%d. <b>%s</b>%s%s — %s\n", i+1, m.Title, year, ep, formatSize(m.SizeBytes)))
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// cmdDownloads 处理 /downloads 命令。
|
||||||
|
func (s *TelegramBotService) cmdDownloads(ctx context.Context) (string, error) {
|
||||||
|
type Row struct {
|
||||||
|
URL string
|
||||||
|
Status string
|
||||||
|
}
|
||||||
|
var rows []Row
|
||||||
|
if err := s.repo.DB.Raw(
|
||||||
|
"SELECT url, COALESCE(status,'unknown') as status FROM download_tasks ORDER BY created_at DESC LIMIT 8",
|
||||||
|
).Scan(&rows).Error; err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(rows) == 0 {
|
||||||
|
return "当前没有下载任务。", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(fmt.Sprintf("<b>下载任务 (%d)</b>\n\n", len(rows)))
|
||||||
|
for _, r := range rows {
|
||||||
|
icon := "⏳"
|
||||||
|
switch r.Status {
|
||||||
|
case "completed":
|
||||||
|
icon = "✅"
|
||||||
|
case "downloading":
|
||||||
|
icon = "📥"
|
||||||
|
case "error":
|
||||||
|
icon = "❌"
|
||||||
|
}
|
||||||
|
// 截取文件名
|
||||||
|
name := r.URL
|
||||||
|
if idx := strings.LastIndex(name, "/"); idx >= 0 {
|
||||||
|
name = name[idx+1:]
|
||||||
|
}
|
||||||
|
if len(name) > 60 {
|
||||||
|
name = name[:57] + "..."
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf("%s %s\n", icon, name))
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// cmdStats 处理 /stats 命令。
|
||||||
|
func (s *TelegramBotService) cmdStats(ctx context.Context) (string, error) {
|
||||||
|
var totalMedia int64
|
||||||
|
s.repo.DB.Model(&model.Media{}).Count(&totalMedia)
|
||||||
|
|
||||||
|
var totalSize int64
|
||||||
|
s.repo.DB.Raw("SELECT COALESCE(SUM(size_bytes), 0) FROM media").Scan(&totalSize)
|
||||||
|
|
||||||
|
type LibStat struct {
|
||||||
|
Name string
|
||||||
|
Type string
|
||||||
|
Count int64
|
||||||
|
}
|
||||||
|
var libs []LibStat
|
||||||
|
s.repo.DB.Raw(
|
||||||
|
"SELECT l.name, l.type, COUNT(m.id) as count FROM libraries l LEFT JOIN media m ON m.library_id = l.id GROUP BY l.id ORDER BY count DESC",
|
||||||
|
).Scan(&libs)
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("<b>媒体库统计</b>\n\n")
|
||||||
|
sb.WriteString(fmt.Sprintf("📚 总数: <b>%d</b>\n", totalMedia))
|
||||||
|
sb.WriteString(fmt.Sprintf("💾 大小: <b>%s</b>\n", formatSize(totalSize)))
|
||||||
|
|
||||||
|
if len(libs) > 0 {
|
||||||
|
sb.WriteString("\n<b>各库分布:</b>\n")
|
||||||
|
for _, l := range libs {
|
||||||
|
icon := "🎬"
|
||||||
|
switch l.Type {
|
||||||
|
case "tv":
|
||||||
|
icon = "📺"
|
||||||
|
case "anime":
|
||||||
|
icon = "🍥"
|
||||||
|
case "music":
|
||||||
|
icon = "🎵"
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf("%s <b>%s</b>: %d\n", icon, l.Name, l.Count))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Polling ──
|
||||||
|
|
||||||
|
// StartPolling 为所有已启用的 Telegram 通知渠道启动长轮询。
|
||||||
|
func (s *TelegramBotService) StartPolling(ctx context.Context) {
|
||||||
|
channels, err := s.repo.NotifyChannel.ListByType(ctx, "telegram")
|
||||||
|
if err != nil {
|
||||||
|
s.log.Error("failed to list telegram channels for polling", zap.Error(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ch := range channels {
|
||||||
|
if !ch.Enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
configStr := ch.Config
|
||||||
|
if s.crypto != nil && configStr != "" {
|
||||||
|
configStr = s.crypto.Decrypt(configStr)
|
||||||
|
}
|
||||||
|
var cfg map[string]string
|
||||||
|
if err := json.Unmarshal([]byte(configStr), &cfg); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
botToken := cfg["bot_token"]
|
||||||
|
if botToken == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
s.pollingMu.Lock()
|
||||||
|
if _, running := s.pollingCancel[botToken]; running {
|
||||||
|
s.pollingMu.Unlock()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pollCtx, cancel := context.WithCancel(context.Background())
|
||||||
|
s.pollingCancel[botToken] = cancel
|
||||||
|
s.pollingMu.Unlock()
|
||||||
|
|
||||||
|
go s.pollLoop(pollCtx, botToken)
|
||||||
|
s.log.Info("started telegram polling", zap.String("channel", ch.Name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopPolling 停止所有 Telegram 长轮询。
|
||||||
|
func (s *TelegramBotService) StopPolling() {
|
||||||
|
s.pollingMu.Lock()
|
||||||
|
defer s.pollingMu.Unlock()
|
||||||
|
for token, cancel := range s.pollingCancel {
|
||||||
|
cancel()
|
||||||
|
delete(s.pollingCancel, token)
|
||||||
|
}
|
||||||
|
s.log.Info("telegram polling stopped")
|
||||||
|
}
|
||||||
|
|
||||||
|
// pollLoop 对单个 Bot Token 执行长轮询。
|
||||||
|
func (s *TelegramBotService) pollLoop(ctx context.Context, botToken string) {
|
||||||
|
var offset int64 = 0
|
||||||
|
pollURL := fmt.Sprintf("https://api.telegram.org/bot%s/getUpdates", botToken)
|
||||||
|
client := &http.Client{Timeout: 45 * time.Second}
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
reqBody, _ := json.Marshal(map[string]interface{}{
|
||||||
|
"offset": offset,
|
||||||
|
"timeout": 30,
|
||||||
|
})
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, pollURL, bytes.NewReader(reqBody))
|
||||||
|
if err != nil {
|
||||||
|
time.Sleep(5 * time.Second)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
time.Sleep(5 * time.Second)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Result []TelegramUpdate `json:"result"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(respBody, &result); err != nil || !result.OK {
|
||||||
|
time.Sleep(3 * time.Second)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, upd := range result.Result {
|
||||||
|
if upd.UpdateID >= int(offset) {
|
||||||
|
offset = int64(upd.UpdateID) + 1
|
||||||
|
}
|
||||||
|
if upd.Message == nil || upd.Message.Text == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
go func(u TelegramUpdate) {
|
||||||
|
raw, _ := json.Marshal(u)
|
||||||
|
_ = s.HandleWebhook(context.Background(), raw)
|
||||||
|
}(upd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Message Sending ──
|
||||||
|
|
||||||
|
// reply 通过 Telegram Bot API 发送回复消息。
|
||||||
|
func (s *TelegramBotService) reply(ctx context.Context, channel *model.NotifyChannel, chatID int, text string) error {
|
||||||
|
botToken := ""
|
||||||
|
if channel != nil {
|
||||||
|
configStr := channel.Config
|
||||||
|
if s.crypto != nil && configStr != "" {
|
||||||
|
configStr = s.crypto.Decrypt(configStr)
|
||||||
|
}
|
||||||
|
var cfg map[string]string
|
||||||
|
if err := json.Unmarshal([]byte(configStr), &cfg); err == nil {
|
||||||
|
botToken = cfg["bot_token"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if botToken == "" {
|
||||||
|
return fmt.Errorf("bot_token not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"chat_id": strconv.Itoa(chatID),
|
||||||
|
"text": text,
|
||||||
|
"parse_mode": "HTML",
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(payload)
|
||||||
|
|
||||||
|
apiURL := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", botToken)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 15 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
|
return fmt.Errorf("telegram api error %d: %s", resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// findChannelByChatID 根据 chat_id 查找已配置的通知渠道。
|
||||||
|
func (s *TelegramBotService) findChannelByChatID(ctx context.Context, chatID int) *model.NotifyChannel {
|
||||||
|
channels, err := s.repo.NotifyChannel.ListByType(ctx, "telegram")
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
target := strconv.Itoa(chatID)
|
||||||
|
for _, ch := range channels {
|
||||||
|
if !ch.Enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
configStr := ch.Config
|
||||||
|
if s.crypto != nil && configStr != "" {
|
||||||
|
configStr = s.crypto.Decrypt(configStr)
|
||||||
|
}
|
||||||
|
var cfg map[string]string
|
||||||
|
json.Unmarshal([]byte(configStr), &cfg)
|
||||||
|
if cfg["chat_id"] == target {
|
||||||
|
return &ch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Webhook Management ──
|
||||||
|
|
||||||
|
// SetWebhook 注册 Telegram Bot Webhook URL。
|
||||||
|
func (s *TelegramBotService) SetWebhook(ctx context.Context, botToken, webhookURL string) error {
|
||||||
|
payload, _ := json.Marshal(map[string]interface{}{
|
||||||
|
"url": webhookURL,
|
||||||
|
"allowed_updates": []string{"message"},
|
||||||
|
})
|
||||||
|
req, _ := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||||
|
fmt.Sprintf("https://api.telegram.org/bot%s/setWebhook", botToken),
|
||||||
|
bytes.NewReader(payload))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 15 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return fmt.Errorf("setWebhook failed: %s", string(body))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetWebhookInfo 获取 Webhook 配置信息。
|
||||||
|
func (s *TelegramBotService) GetWebhookInfo(ctx context.Context, botToken string) (map[string]interface{}, error) {
|
||||||
|
resp, err := (&http.Client{Timeout: 10 * time.Second}).Get(
|
||||||
|
fmt.Sprintf("https://api.telegram.org/bot%s/getWebhookInfo", botToken),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var result map[string]interface{}
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
json.Unmarshal(body, &result)
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatSize 格式化字节数为可读字符串。
|
||||||
|
func formatSize(bytes int64) string {
|
||||||
|
if bytes <= 0 {
|
||||||
|
return "0 B"
|
||||||
|
}
|
||||||
|
units := []string{"B", "KB", "MB", "GB", "TB"}
|
||||||
|
v := float64(bytes)
|
||||||
|
i := 0
|
||||||
|
for v >= 1024 && i < len(units)-1 {
|
||||||
|
v /= 1024
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
if i == 0 {
|
||||||
|
return fmt.Sprintf("%.0f %s", v, units[i])
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%.1f %s", v, units[i])
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import type { NotifyChannel } from '../types'
|
|||||||
// Payload accepted by create / update. `events` and `enabled` are optional.
|
// Payload accepted by create / update. `events` and `enabled` are optional.
|
||||||
export interface NotifyChannelInput {
|
export interface NotifyChannelInput {
|
||||||
name: string
|
name: string
|
||||||
channel_type: NotifyChannel['channel_type']
|
type: NotifyChannel['type']
|
||||||
config: Record<string, string>
|
config: Record<string, string>
|
||||||
events?: string[]
|
events?: string[]
|
||||||
enabled?: boolean
|
enabled?: boolean
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export function APIConfigsPanel() {
|
|||||||
{!loading && (
|
{!loading && (
|
||||||
<div className="glass-panel overflow-hidden">
|
<div className="glass-panel overflow-hidden">
|
||||||
<table className="w-full text-left text-sm">
|
<table className="w-full text-left text-sm">
|
||||||
<thead className="border-b border-white/5 text-xs uppercase tracking-wider text-sand-500">
|
<thead className="border-b border-gray-200 text-xs uppercase tracking-wider text-sand-500">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-3">服务</th>
|
<th className="px-4 py-3">服务</th>
|
||||||
<th className="px-4 py-3">密钥</th>
|
<th className="px-4 py-3">密钥</th>
|
||||||
@@ -63,7 +63,7 @@ export function APIConfigsPanel() {
|
|||||||
) : (
|
) : (
|
||||||
<tr
|
<tr
|
||||||
key={item.id}
|
key={item.id}
|
||||||
className="border-t border-white/5 transition hover:bg-white/[0.02]"
|
className="border-t border-gray-200 transition hover:bg-gray-50"
|
||||||
>
|
>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<p className="font-medium text-ink-600">{item.provider}</p>
|
<p className="font-medium text-ink-600">{item.provider}</p>
|
||||||
@@ -75,7 +75,7 @@ export function APIConfigsPanel() {
|
|||||||
{item.has_key ? (
|
{item.has_key ? (
|
||||||
<span className="text-brand-500">{item.masked_key}</span>
|
<span className="text-brand-500">{item.masked_key}</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-sand-400">未配置</span>
|
<span className="text-gray-500">未配置</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
@@ -93,7 +93,7 @@ export function APIConfigsPanel() {
|
|||||||
<div className="flex items-center justify-end gap-1">
|
<div className="flex items-center justify-end gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => setEditing(item.provider)}
|
onClick={() => setEditing(item.provider)}
|
||||||
className="rounded p-1.5 text-ink-50 transition hover:bg-white/5 hover:text-white"
|
className="rounded-lg p-1.5 text-ink-50 transition hover:bg-gray-50 hover:text-white"
|
||||||
title="编辑"
|
title="编辑"
|
||||||
>
|
>
|
||||||
<Save size={14} />
|
<Save size={14} />
|
||||||
@@ -104,7 +104,7 @@ export function APIConfigsPanel() {
|
|||||||
`已配置 ${item.has_key ? '✓' : '✗'} 密钥 (在线测试请用对应功能页面)`,
|
`已配置 ${item.has_key ? '✓' : '✗'} 密钥 (在线测试请用对应功能页面)`,
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
className="rounded p-1.5 text-ink-50 transition hover:bg-white/5 hover:text-ink-200"
|
className="rounded-lg p-1.5 text-ink-50 transition hover:bg-gray-50 hover:text-ink-200"
|
||||||
title="查看状态"
|
title="查看状态"
|
||||||
>
|
>
|
||||||
<Eye size={14} />
|
<Eye size={14} />
|
||||||
@@ -117,7 +117,7 @@ export function APIConfigsPanel() {
|
|||||||
toast.success('已清除')
|
toast.success('已清除')
|
||||||
refresh()
|
refresh()
|
||||||
}}
|
}}
|
||||||
className="rounded p-1.5 text-ink-50 transition hover:bg-red-400/10 hover:text-red-400"
|
className="rounded-lg p-1.5 text-ink-50 transition hover:bg-red-400/10 hover:text-red-400"
|
||||||
title="清除密钥"
|
title="清除密钥"
|
||||||
>
|
>
|
||||||
<Trash2 size={14} />
|
<Trash2 size={14} />
|
||||||
@@ -170,7 +170,7 @@ function EditingRow({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr className="border-t border-white/5 bg-primary-400/5">
|
<tr className="border-t border-gray-200 bg-primary-400/5">
|
||||||
<td colSpan={4} className="px-4 py-3">
|
<td colSpan={4} className="px-4 py-3">
|
||||||
<form onSubmit={submit} className="flex flex-wrap items-end gap-3">
|
<form onSubmit={submit} className="flex flex-wrap items-end gap-3">
|
||||||
<span className="text-sm font-medium text-ink-600">{item.provider}</span>
|
<span className="text-sm font-medium text-ink-600">{item.provider}</span>
|
||||||
@@ -207,7 +207,7 @@ function EditingRow({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
className="rounded border border-sand-400/30 px-2 py-1.5 text-xs text-ink-50 hover:text-white"
|
className="rounded-lg border border-sand-400/30 px-2 py-1.5 text-xs text-ink-50 hover:text-white"
|
||||||
>
|
>
|
||||||
<X size={12} />
|
<X size={12} />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export function AppFooter({ className = '' }: { className?: string }) {
|
|||||||
href={link.href}
|
href={link.href}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-xs text-sand-500 transition-colors hover:bg-sand-200 hover:text-ink-50"
|
className="flex items-center gap-1.5 rounded-xl px-2 py-1.5 text-xs text-sand-500 transition-colors hover:bg-sand-200 hover:text-ink-50"
|
||||||
title={link.label}
|
title={link.label}
|
||||||
>
|
>
|
||||||
<link.icon size={14} />
|
<link.icon size={14} />
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export function Layout() {
|
|||||||
{/* Toggle Collapse Button for Large Screen */}
|
{/* Toggle Collapse Button for Large Screen */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
|
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
|
||||||
className="rounded-lg p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-900 transition-colors hidden lg:block"
|
className="rounded-xl p-1.5 text-gray-500 hover:bg-gray-100 hover:text-gray-900 transition-colors hidden lg:block"
|
||||||
>
|
>
|
||||||
<Menu size={18} />
|
<Menu size={18} />
|
||||||
</button>
|
</button>
|
||||||
@@ -86,7 +86,7 @@ export function Layout() {
|
|||||||
{/* Mobile Drawer Close Button */}
|
{/* Mobile Drawer Close Button */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsMobileDrawerOpen(false)}
|
onClick={() => setIsMobileDrawerOpen(false)}
|
||||||
className="rounded-lg p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-900 transition-colors block lg:hidden"
|
className="rounded-xl p-1.5 text-gray-500 hover:bg-gray-100 hover:text-gray-900 transition-colors block lg:hidden"
|
||||||
>
|
>
|
||||||
<X size={18} />
|
<X size={18} />
|
||||||
</button>
|
</button>
|
||||||
@@ -114,7 +114,7 @@ export function Layout() {
|
|||||||
<SectionHeader label="媒体片库" visible={isSidebarOpen || isMobileDrawerOpen} />
|
<SectionHeader label="媒体片库" visible={isSidebarOpen || isMobileDrawerOpen} />
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{libraries.length === 0 && (isSidebarOpen || isMobileDrawerOpen) && (
|
{libraries.length === 0 && (isSidebarOpen || isMobileDrawerOpen) && (
|
||||||
<div className="px-4 py-2 text-xs text-gray-400 italic">暂无媒体片库</div>
|
<div className="px-4 py-2 text-xs text-gray-500 italic">暂无媒体片库</div>
|
||||||
)}
|
)}
|
||||||
{libraries.map((lib) => (
|
{libraries.map((lib) => (
|
||||||
<SidebarLink
|
<SidebarLink
|
||||||
@@ -180,7 +180,7 @@ export function Layout() {
|
|||||||
onClick={() => { logout(); navigate('/login') }}
|
onClick={() => { logout(); navigate('/login') }}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"flex items-center gap-3.5 rounded-xl px-4 py-3 text-sm font-semibold transition-all duration-300 w-full group/logout",
|
"flex items-center gap-3.5 rounded-xl px-4 py-3 text-sm font-semibold transition-all duration-300 w-full group/logout",
|
||||||
(isSidebarOpen || isMobileDrawerOpen) ? "justify-start text-gray-500 hover:bg-red-50 hover:text-red-600" : "justify-center text-gray-400 hover:text-red-600"
|
(isSidebarOpen || isMobileDrawerOpen) ? "justify-start text-gray-500 hover:bg-red-50 hover:text-red-600" : "justify-center text-gray-500 hover:text-red-600"
|
||||||
)}
|
)}
|
||||||
title={`安全登出 (${user?.username})`}
|
title={`安全登出 (${user?.username})`}
|
||||||
>
|
>
|
||||||
@@ -248,7 +248,7 @@ export function Layout() {
|
|||||||
<form onSubmit={handleSearchSubmit} className="relative w-full hidden sm:block">
|
<form onSubmit={handleSearchSubmit} className="relative w-full hidden sm:block">
|
||||||
<span className={clsx(
|
<span className={clsx(
|
||||||
"absolute left-4 top-1/2 -translate-y-1/2 transition-colors duration-200",
|
"absolute left-4 top-1/2 -translate-y-1/2 transition-colors duration-200",
|
||||||
searchFocused ? "text-brand-600" : "text-gray-400"
|
searchFocused ? "text-brand-600" : "text-gray-500"
|
||||||
)}>
|
)}>
|
||||||
<Search size={16} />
|
<Search size={16} />
|
||||||
</span>
|
</span>
|
||||||
@@ -259,10 +259,10 @@ export function Layout() {
|
|||||||
onFocus={() => setSearchFocused(true)}
|
onFocus={() => setSearchFocused(true)}
|
||||||
onBlur={() => setSearchFocused(false)}
|
onBlur={() => setSearchFocused(false)}
|
||||||
placeholder="搜索电影、电视剧、演员、种子站点..."
|
placeholder="搜索电影、电视剧、演员、种子站点..."
|
||||||
className="w-full rounded-full border border-gray-200 bg-gray-50/50 py-2.5 pl-11 pr-12 text-sm text-gray-900 placeholder-gray-400 outline-none transition-all duration-300 focus:border-brand-500 focus:bg-white focus:ring-4 focus:ring-brand-100/40"
|
className="w-full rounded-full border border-gray-200 bg-gray-50/50 py-2.5 pl-11 pr-12 text-sm text-gray-900 placeholder-gray-500 outline-none transition-all duration-300 focus:border-brand-500 focus:bg-white focus:ring-4 focus:ring-brand-100/40"
|
||||||
/>
|
/>
|
||||||
<div className="absolute right-4 top-1/2 -translate-y-1/2 pointer-events-none">
|
<div className="absolute right-4 top-1/2 -translate-y-1/2 pointer-events-none">
|
||||||
<span className="rounded-md border border-gray-200 bg-white px-1.5 py-0.5 text-[9px] font-bold text-gray-400 uppercase tracking-wider">
|
<span className="rounded-xl border border-gray-200 bg-white px-1.5 py-0.5 text-[9px] font-bold text-gray-500 uppercase tracking-wider">
|
||||||
Enter
|
Enter
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -274,7 +274,7 @@ export function Layout() {
|
|||||||
{/* Quick search button shown ONLY on Mobile screens */}
|
{/* Quick search button shown ONLY on Mobile screens */}
|
||||||
<Link
|
<Link
|
||||||
to="/search"
|
to="/search"
|
||||||
className="rounded-xl border border-gray-200 p-2.5 text-gray-400 hover:bg-gray-100 hover:text-gray-900 sm:hidden"
|
className="rounded-xl border border-gray-200 p-2.5 text-gray-500 hover:bg-gray-100 hover:text-gray-900 sm:hidden"
|
||||||
>
|
>
|
||||||
<Search size={18} />
|
<Search size={18} />
|
||||||
</Link>
|
</Link>
|
||||||
@@ -289,7 +289,7 @@ export function Layout() {
|
|||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Notification alert bubble */}
|
{/* Notification alert bubble */}
|
||||||
<button className="relative rounded-xl border border-gray-200 p-2.5 text-gray-400 hover:bg-gray-100 hover:text-gray-900 transition-all">
|
<button className="relative rounded-xl border border-gray-200 p-2.5 text-gray-500 hover:bg-gray-100 hover:text-gray-900 transition-all">
|
||||||
<Bell size={18} />
|
<Bell size={18} />
|
||||||
<span className="absolute top-1.5 right-1.5 h-2 w-2 rounded-full bg-brand-500 ring-2 ring-white animate-pulse" />
|
<span className="absolute top-1.5 right-1.5 h-2 w-2 rounded-full bg-brand-500 ring-2 ring-white animate-pulse" />
|
||||||
</button>
|
</button>
|
||||||
@@ -310,7 +310,7 @@ export function Layout() {
|
|||||||
<p className="text-xs font-bold text-gray-900 leading-none">{user?.username}</p>
|
<p className="text-xs font-bold text-gray-900 leading-none">{user?.username}</p>
|
||||||
<p className="text-[9px] text-gray-500 font-bold uppercase tracking-wider mt-0.5 leading-none">{user?.role}</p>
|
<p className="text-[9px] text-gray-500 font-bold uppercase tracking-wider mt-0.5 leading-none">{user?.role}</p>
|
||||||
</div>
|
</div>
|
||||||
<ChevronDown size={14} className="text-gray-400" />
|
<ChevronDown size={14} className="text-gray-500" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
@@ -389,7 +389,7 @@ export function Layout() {
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Absolute Footer Frame */}
|
{/* Absolute Footer Frame */}
|
||||||
<AppFooter className="border-t border-gray-200/50 bg-white py-5 text-center text-xs text-gray-400" />
|
<AppFooter className="border-t border-gray-200/50 bg-white py-5 text-center text-xs text-gray-500" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -399,7 +399,7 @@ function SectionHeader({ label, visible }: { label: string; visible: boolean })
|
|||||||
if (!visible) return <div className="h-5" />;
|
if (!visible) return <div className="h-5" />;
|
||||||
return (
|
return (
|
||||||
<div className="px-4 mb-2 mt-4">
|
<div className="px-4 mb-2 mt-4">
|
||||||
<span className="text-[10px] font-bold uppercase tracking-[0.25em] text-gray-400">
|
<span className="text-[10px] font-bold uppercase tracking-[0.25em] text-gray-500">
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -432,7 +432,7 @@ function SidebarLink({ to, icon, label, end, collapsed }: SidebarLinkProps) {
|
|||||||
<>
|
<>
|
||||||
<span className={clsx(
|
<span className={clsx(
|
||||||
"flex shrink-0 items-center justify-center w-5 h-5 transition-transform duration-300 group-hover:scale-110",
|
"flex shrink-0 items-center justify-center w-5 h-5 transition-transform duration-300 group-hover:scale-110",
|
||||||
isActive ? "text-[#c9954a]" : "text-gray-400 group-hover:text-gray-700"
|
isActive ? "text-[#c9954a]" : "text-gray-500 group-hover:text-gray-700"
|
||||||
)}>
|
)}>
|
||||||
{icon}
|
{icon}
|
||||||
</span>
|
</span>
|
||||||
@@ -446,7 +446,7 @@ function SidebarLink({ to, icon, label, end, collapsed }: SidebarLinkProps) {
|
|||||||
</motion.span>
|
</motion.span>
|
||||||
)}
|
)}
|
||||||
{collapsed && (
|
{collapsed && (
|
||||||
<div className="absolute left-full ml-3 px-2.5 py-1.5 rounded-lg bg-gray-900 text-white text-xs font-semibold opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity shadow-lg z-50 whitespace-nowrap">
|
<div className="absolute left-full ml-3 px-2.5 py-1.5 rounded-xl bg-gray-900 text-white text-xs font-semibold opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity shadow-lg z-50 whitespace-nowrap">
|
||||||
{label}
|
{label}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export const MediaCard = ({
|
|||||||
referrerPolicy="no-referrer"
|
referrerPolicy="no-referrer"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2 text-gray-400 bg-gray-50">
|
<div className="flex h-full w-full flex-col items-center justify-center gap-2 text-gray-500 bg-gray-50">
|
||||||
<Film size={28} className="stroke-[1.5]" />
|
<Film size={28} className="stroke-[1.5]" />
|
||||||
<span className="text-[10px] uppercase tracking-wider font-bold">No Poster</span>
|
<span className="text-[10px] uppercase tracking-wider font-bold">No Poster</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -40,7 +40,7 @@ export const MediaCard = ({
|
|||||||
|
|
||||||
{/* Episode count badge */}
|
{/* Episode count badge */}
|
||||||
{count !== undefined && count > 1 && (
|
{count !== undefined && count > 1 && (
|
||||||
<span className="absolute right-3 top-3 inline-flex items-center gap-1 rounded-lg bg-[#111827]/90 px-2 py-1 text-[10px] font-bold text-white shadow-sm border border-white/10">
|
<span className="absolute right-3 top-3 inline-flex items-center gap-1 rounded-xl bg-[#111827]/90 px-2 py-1 text-[10px] font-bold text-white shadow-sm border border-gray-200">
|
||||||
<Layers size={10} className="text-[#c9954a]" />
|
<Layers size={10} className="text-[#c9954a]" />
|
||||||
<span>{count} 集</span>
|
<span>{count} 集</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -48,7 +48,7 @@ export const MediaCard = ({
|
|||||||
|
|
||||||
{/* Rating Badge */}
|
{/* Rating Badge */}
|
||||||
{(rating || (media as any).rating) && (
|
{(rating || (media as any).rating) && (
|
||||||
<span className="absolute left-3 top-3 inline-flex items-center gap-0.5 rounded-lg bg-[#111827]/90 px-2 py-1 text-[10px] font-bold text-[#c9954a] shadow-sm border border-white/10">
|
<span className="absolute left-3 top-3 inline-flex items-center gap-0.5 rounded-xl bg-[#111827]/90 px-2 py-1 text-[10px] font-bold text-[#c9954a] shadow-sm border border-gray-200">
|
||||||
<Star size={10} fill="currentColor" />
|
<Star size={10} fill="currentColor" />
|
||||||
<span>{(rating || (media as any).rating).toFixed(1)}</span>
|
<span>{(rating || (media as any).rating).toFixed(1)}</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -66,7 +66,7 @@ export const MediaCard = ({
|
|||||||
<Play size={10} fill="currentColor" className="text-white" />
|
<Play size={10} fill="currentColor" className="text-white" />
|
||||||
<span>立即观影</span>
|
<span>立即观影</span>
|
||||||
</span>
|
</span>
|
||||||
<p className="text-[10px] text-gray-300 font-semibold line-clamp-2 leading-relaxed">
|
<p className="text-[10px] text-gray-500 font-semibold line-clamp-2 leading-relaxed">
|
||||||
{media.overview || "暂无简介内容"}
|
{media.overview || "暂无简介内容"}
|
||||||
</p>
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -91,7 +91,7 @@ export const MediaCard = ({
|
|||||||
<div className="flex items-center justify-between text-[11px] text-gray-500 font-bold uppercase tracking-wider">
|
<div className="flex items-center justify-between text-[11px] text-gray-500 font-bold uppercase tracking-wider">
|
||||||
<span>{media.year > 0 ? media.year : "未知年份"}</span>
|
<span>{media.year > 0 ? media.year : "未知年份"}</span>
|
||||||
{media.video_codec && (
|
{media.video_codec && (
|
||||||
<span className="px-1.5 py-0.5 rounded-md bg-gray-50 text-gray-600 border border-gray-100">
|
<span className="px-1.5 py-0.5 rounded-xl bg-gray-50 text-gray-600 border border-gray-100">
|
||||||
{media.video_codec}
|
{media.video_codec}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+6
-6
@@ -49,7 +49,7 @@ body {
|
|||||||
@layer components {
|
@layer components {
|
||||||
/* ── Unified Swiss-Editorial Cards ── */
|
/* ── Unified Swiss-Editorial Cards ── */
|
||||||
.card {
|
.card {
|
||||||
@apply rounded-2xl bg-white border border-gray-200/80 shadow-[0_1px_3px_rgba(0,0,0,0.01),0_1px_2px_rgba(0,0,0,0.015)];
|
@apply rounded-2xl bg-white border border-gray-200/80 shadow-[0_1px_3px_rgba(0,0,0,0.01),0_1px_2px_rgba(0,0,0,0.015)] p-4 sm:p-6;
|
||||||
}
|
}
|
||||||
.card-hover {
|
.card-hover {
|
||||||
@apply card transition-all duration-300 ease-out hover:border-gray-300 hover:shadow-[0_12px_32px_rgba(17,24,39,0.04),0_4px_12px_rgba(17,24,39,0.01)] hover:-translate-y-1;
|
@apply card transition-all duration-300 ease-out hover:border-gray-300 hover:shadow-[0_12px_32px_rgba(17,24,39,0.04),0_4px_12px_rgba(17,24,39,0.01)] hover:-translate-y-1;
|
||||||
@@ -60,13 +60,13 @@ body {
|
|||||||
|
|
||||||
/* ── Glassmorphic Bright Panels ── */
|
/* ── Glassmorphic Bright Panels ── */
|
||||||
.glass-panel {
|
.glass-panel {
|
||||||
@apply backdrop-blur-xl bg-white/80 border border-white/50 shadow-[0_8px_32px_rgba(17,24,39,0.015)];
|
@apply rounded-2xl bg-white border border-gray-200 shadow-sm p-4 sm:p-6;
|
||||||
}
|
}
|
||||||
.glass-card {
|
.glass-card {
|
||||||
@apply rounded-2xl backdrop-blur-lg bg-white/90 border border-white/60 shadow-[0_4px_24px_rgba(17,24,39,0.01)];
|
@apply rounded-2xl bg-white border border-gray-200 shadow-sm p-4 sm:p-6;
|
||||||
}
|
}
|
||||||
.glass-card-elevated {
|
.glass-card-elevated {
|
||||||
@apply rounded-2xl backdrop-blur-xl bg-white/95 border border-white/70 shadow-[0_16px_48px_rgba(17,24,39,0.03)];
|
@apply rounded-2xl bg-white border border-gray-200 shadow-md p-4 sm:p-6;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Input Fields Redesign ── */
|
/* ── Input Fields Redesign ── */
|
||||||
@@ -112,8 +112,8 @@ body {
|
|||||||
/* ── Data Tables Redesign ── */
|
/* ── Data Tables Redesign ── */
|
||||||
.data-table { @apply w-full text-left text-sm border-collapse; }
|
.data-table { @apply w-full text-left text-sm border-collapse; }
|
||||||
.data-table thead { @apply border-b border-gray-200 bg-gray-50/70; }
|
.data-table thead { @apply border-b border-gray-200 bg-gray-50/70; }
|
||||||
.data-table th { @apply px-5 py-4 text-xs font-bold uppercase tracking-wider text-gray-500; }
|
.data-table th { @apply px-5 py-4 text-xs font-bold uppercase tracking-wider text-gray-500 whitespace-nowrap; }
|
||||||
.data-table td { @apply px-5 py-4 text-gray-700 border-b border-gray-100; }
|
.data-table td { @apply px-5 py-4 text-gray-700 border-b border-gray-100 max-w-xs truncate; }
|
||||||
.data-table tbody tr { @apply transition-colors hover:bg-gray-50/50; }
|
.data-table tbody tr { @apply transition-colors hover:bg-gray-50/50; }
|
||||||
|
|
||||||
/* ── Dividers ── */
|
/* ── Dividers ── */
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ export function AIAssistantPage() {
|
|||||||
{/* Smart search */}
|
{/* Smart search */}
|
||||||
<section className="glass-panel space-y-4">
|
<section className="glass-panel space-y-4">
|
||||||
<h2 className="font-display text-lg font-semibold text-ink-600">智能搜索</h2>
|
<h2 className="font-display text-lg font-semibold text-ink-600">智能搜索</h2>
|
||||||
<form onSubmit={onSearch} className="flex gap-2">
|
<form onSubmit={onSearch} className="flex flex-wrap gap-2">
|
||||||
<input
|
<input
|
||||||
className="input-base flex-1"
|
className="input-base flex-1"
|
||||||
placeholder="试试: 2023 年的高分动作片"
|
placeholder="试试: 2023 年的高分动作片"
|
||||||
@@ -137,7 +137,7 @@ export function AIAssistantPage() {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setQuery(h)
|
setQuery(h)
|
||||||
}}
|
}}
|
||||||
className="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs text-ink-100 hover:border-primary-400/40 hover:text-brand-500"
|
className="rounded-full border border-gray-200 bg-gray-50 px-3 py-1 text-xs text-ink-100 hover:border-primary-400/40 hover:text-brand-500"
|
||||||
>
|
>
|
||||||
{h}
|
{h}
|
||||||
</button>
|
</button>
|
||||||
@@ -145,7 +145,7 @@ export function AIAssistantPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{intent && (
|
{intent && (
|
||||||
<div className="rounded-lg border border-white/5 bg-white/5 p-3 text-xs text-ink-100">
|
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3 text-xs text-ink-100">
|
||||||
<div className="mb-1 font-semibold text-ink-200">解析结果</div>
|
<div className="mb-1 font-semibold text-ink-200">解析结果</div>
|
||||||
<div className="flex flex-wrap gap-x-6 gap-y-1">
|
<div className="flex flex-wrap gap-x-6 gap-y-1">
|
||||||
<span>
|
<span>
|
||||||
@@ -212,7 +212,7 @@ export function AIAssistantPage() {
|
|||||||
<li key={i}>
|
<li key={i}>
|
||||||
<Link
|
<Link
|
||||||
to={`/search?q=${encodeURIComponent(t)}`}
|
to={`/search?q=${encodeURIComponent(t)}`}
|
||||||
className="flex items-center justify-between rounded-lg border border-white/5 bg-white/5 px-3 py-2 text-sm text-ink-200 hover:border-primary-400/40 hover:text-brand-500"
|
className="flex items-center justify-between rounded-xl border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-ink-200 hover:border-primary-400/40 hover:text-brand-500"
|
||||||
>
|
>
|
||||||
<span className="truncate">{t}</span>
|
<span className="truncate">{t}</span>
|
||||||
<Search size={14} className="shrink-0 opacity-60" />
|
<Search size={14} className="shrink-0 opacity-60" />
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ function ProviderCard({ item, onUpdated }: { item: APIConfig; onUpdated: () => v
|
|||||||
/>
|
/>
|
||||||
启用
|
启用
|
||||||
</label>
|
</label>
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<button type="submit" disabled={saving} className="neon-button !text-xs">
|
<button type="submit" disabled={saving} className="neon-button !text-xs">
|
||||||
<Save size={12} /> 保存
|
<Save size={12} /> 保存
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export function AdminPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<h1 className="font-display text-3xl font-bold text-ink-600">管理后台</h1>
|
<h1 className="font-display text-3xl font-bold text-ink-600">管理后台</h1>
|
||||||
<div className="flex flex-wrap gap-2 border-b border-white/10">
|
<div className="flex flex-wrap gap-2 border-b border-gray-200">
|
||||||
{tabs.map((k) => (
|
{tabs.map((k) => (
|
||||||
<button
|
<button
|
||||||
key={k.key}
|
key={k.key}
|
||||||
@@ -108,13 +108,13 @@ function LibraryPanel() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{libs.map((l) => (
|
{libs.map((l) => (
|
||||||
<tr key={l.id} className="border-t border-white/5">
|
<tr key={l.id} className="border-t border-gray-200">
|
||||||
<td className="py-2 text-ink-600">{l.name}</td>
|
<td className="py-2 text-ink-600">{l.name}</td>
|
||||||
<td className="text-ink-100">{l.path}</td>
|
<td className="text-ink-100">{l.path}</td>
|
||||||
<td className="text-ink-100">{l.type}</td>
|
<td className="text-ink-100">{l.type}</td>
|
||||||
<td className="space-x-2 py-2 text-right">
|
<td className="space-x-2 py-2 text-right">
|
||||||
<button
|
<button
|
||||||
className="rounded border border-primary-400/40 px-2 py-1 text-xs text-brand-500 hover:bg-primary-400/10"
|
className="rounded-lg border border-primary-400/40 px-2 py-1 text-xs text-brand-500 hover:bg-primary-400/10"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const r = await libraryAPI.scan(l.id)
|
const r = await libraryAPI.scan(l.id)
|
||||||
toast.success(`扫描完成,新增 ${r.added}`)
|
toast.success(`扫描完成,新增 ${r.added}`)
|
||||||
@@ -123,7 +123,7 @@ function LibraryPanel() {
|
|||||||
扫描
|
扫描
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="rounded border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10"
|
className="rounded-lg border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
if (!confirm(`确定删除「${l.name}」?`)) return
|
if (!confirm(`确定删除「${l.name}」?`)) return
|
||||||
await libraryAPI.remove(l.id)
|
await libraryAPI.remove(l.id)
|
||||||
@@ -163,7 +163,7 @@ function UsersPanel() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{users.map((u) => (
|
{users.map((u) => (
|
||||||
<tr key={u.id} className="border-t border-white/5">
|
<tr key={u.id} className="border-t border-gray-200">
|
||||||
<td className="py-2 text-ink-600">{u.username}</td>
|
<td className="py-2 text-ink-600">{u.username}</td>
|
||||||
<td className="text-ink-100">{u.role}</td>
|
<td className="text-ink-100">{u.role}</td>
|
||||||
<td className="text-ink-50">
|
<td className="text-ink-50">
|
||||||
@@ -171,7 +171,7 @@ function UsersPanel() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="py-2 text-right">
|
<td className="py-2 text-right">
|
||||||
<button
|
<button
|
||||||
className="rounded border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10"
|
className="rounded-lg border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
if (!confirm(`确定删除「${u.username}」?`)) return
|
if (!confirm(`确定删除「${u.username}」?`)) return
|
||||||
await adminAPI.deleteUser(u.id)
|
await adminAPI.deleteUser(u.id)
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ export function AssistantChatPage() {
|
|||||||
'group flex items-center gap-2 rounded px-2 py-2 text-sm cursor-pointer ' +
|
'group flex items-center gap-2 rounded px-2 py-2 text-sm cursor-pointer ' +
|
||||||
(active?.session.id === s.id
|
(active?.session.id === s.id
|
||||||
? 'bg-primary-400/10 text-brand-500'
|
? 'bg-primary-400/10 text-brand-500'
|
||||||
: 'text-ink-100 hover:bg-white/5 hover:text-white')
|
: 'text-ink-100 hover:bg-gray-50 hover:text-white')
|
||||||
}
|
}
|
||||||
onClick={() => onSelect(s.id)}
|
onClick={() => onSelect(s.id)}
|
||||||
>
|
>
|
||||||
@@ -166,7 +166,7 @@ export function AssistantChatPage() {
|
|||||||
)}
|
)}
|
||||||
{active && (
|
{active && (
|
||||||
<>
|
<>
|
||||||
<div className="mb-3 border-b border-white/5 pb-2">
|
<div className="mb-3 border-b border-gray-200 pb-2">
|
||||||
<h2 className="font-display text-base font-semibold text-ink-600">
|
<h2 className="font-display text-base font-semibold text-ink-600">
|
||||||
{active.session.title || '未命名'}
|
{active.session.title || '未命名'}
|
||||||
</h2>
|
</h2>
|
||||||
@@ -180,7 +180,7 @@ export function AssistantChatPage() {
|
|||||||
))}
|
))}
|
||||||
<div ref={messagesEndRef} />
|
<div ref={messagesEndRef} />
|
||||||
</div>
|
</div>
|
||||||
<form onSubmit={onSend} className="mt-3 flex gap-2 border-t border-white/5 pt-3">
|
<form onSubmit={onSend} className="mt-3 flex gap-2 border-t border-gray-200 pt-3">
|
||||||
<input
|
<input
|
||||||
className="input-base flex-1"
|
className="input-base flex-1"
|
||||||
placeholder="发送消息…"
|
placeholder="发送消息…"
|
||||||
@@ -211,7 +211,7 @@ function Bubble({ message }: { message: AssistantMessage }) {
|
|||||||
? 'bg-primary-400/20 text-primary-100'
|
? 'bg-primary-400/20 text-primary-100'
|
||||||
: message.role === 'system'
|
: message.role === 'system'
|
||||||
? 'border border-amber-400/30 bg-amber-400/5 text-amber-200'
|
? 'border border-amber-400/30 bg-amber-400/5 text-amber-200'
|
||||||
: 'bg-white/5 text-ink-200')
|
: 'bg-gray-50 text-ink-200')
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{message.content}
|
{message.content}
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ function ContentRow({ title, items }: { title: string; items: DiscoverItem[] })
|
|||||||
|
|
||||||
function DiscoverCard({ item }: { item: DiscoverItem }) {
|
function DiscoverCard({ item }: { item: DiscoverItem }) {
|
||||||
return (
|
return (
|
||||||
<div className="group relative overflow-hidden rounded-xl border border-white/5 bg-surface-800/60 hover:border-primary-500/30 transition-all duration-300">
|
<div className="group relative overflow-hidden rounded-xl border border-gray-200 bg-gray-50 hover:border-primary-500/30 transition-all duration-300">
|
||||||
<div className="aspect-[2/3] w-full bg-surface-900 relative overflow-hidden">
|
<div className="aspect-[2/3] w-full bg-surface-900 relative overflow-hidden">
|
||||||
{item.poster_url ? (
|
{item.poster_url ? (
|
||||||
<img
|
<img
|
||||||
@@ -169,12 +169,12 @@ function DiscoverCard({ item }: { item: DiscoverItem }) {
|
|||||||
className="h-full w-full object-cover group-hover:scale-105 transition-transform duration-500"
|
className="h-full w-full object-cover group-hover:scale-105 transition-transform duration-500"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full w-full items-center justify-center text-sand-400 text-xs">
|
<div className="flex h-full w-full items-center justify-center text-gray-500 text-xs">
|
||||||
无海报
|
无海报
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{item.rating > 0 && (
|
{item.rating > 0 && (
|
||||||
<div className="absolute top-1.5 right-1.5 rounded-md bg-black/70 backdrop-blur-sm px-1.5 py-0.5 text-[11px] font-semibold text-yellow-400 border border-yellow-400/30">
|
<div className="absolute top-1.5 right-1.5 rounded-xl bg-black/70 backdrop-blur-sm px-1.5 py-0.5 text-[11px] font-semibold text-yellow-400 border border-yellow-400/30">
|
||||||
★ {item.rating.toFixed(1)}
|
★ {item.rating.toFixed(1)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -196,10 +196,10 @@ function DiscoverSkeleton() {
|
|||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{[1, 2].map((section) => (
|
{[1, 2].map((section) => (
|
||||||
<section key={section} className="space-y-4">
|
<section key={section} className="space-y-4">
|
||||||
<div className="h-8 w-48 rounded-lg bg-surface-800 animate-pulse" />
|
<div className="h-8 w-48 rounded-xl bg-gray-100 animate-pulse" />
|
||||||
<div className="grid grid-cols-3 gap-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-8">
|
<div className="grid grid-cols-3 gap-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-8">
|
||||||
{[1, 2, 3, 4, 5, 6, 7, 8].map((i) => (
|
{[1, 2, 3, 4, 5, 6, 7, 8].map((i) => (
|
||||||
<div key={i} className="aspect-[2/3] rounded-xl bg-surface-800 animate-pulse" />
|
<div key={i} className="aspect-[2/3] rounded-xl bg-gray-100 animate-pulse" />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -103,16 +103,16 @@ export function DownloadClientsPage() {
|
|||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-medium text-ink-600">{c.name}</span>
|
<span className="font-medium text-ink-600">{c.name}</span>
|
||||||
<span className="rounded border border-white/10 bg-white/5 px-2 py-0.5 text-xs text-ink-50">
|
<span className="rounded-lg border border-gray-200 bg-gray-50 px-2 py-0.5 text-xs text-ink-50">
|
||||||
{c.type}
|
{c.type}
|
||||||
</span>
|
</span>
|
||||||
{c.is_default && (
|
{c.is_default && (
|
||||||
<span className="rounded bg-primary-400/20 px-2 py-0.5 text-xs text-brand-500">
|
<span className="rounded-lg bg-primary-400/20 px-2 py-0.5 text-xs text-brand-500">
|
||||||
默认
|
默认
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{!c.enabled && (
|
{!c.enabled && (
|
||||||
<span className="rounded bg-sand-500/30 px-2 py-0.5 text-xs text-ink-100">
|
<span className="rounded-lg bg-sand-500/30 px-2 py-0.5 text-xs text-ink-100">
|
||||||
已禁用
|
已禁用
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -125,7 +125,7 @@ export function DownloadClientsPage() {
|
|||||||
<div className="flex shrink-0 gap-2">
|
<div className="flex shrink-0 gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => onTest(c.id)}
|
onClick={() => onTest(c.id)}
|
||||||
className="rounded border border-white/10 px-2 py-1 text-xs text-ink-100 hover:border-primary-400/40 hover:text-brand-500"
|
className="rounded-lg border border-gray-200 px-2 py-1 text-xs text-ink-100 hover:border-primary-400/40 hover:text-brand-500"
|
||||||
>
|
>
|
||||||
<Send size={12} className="inline" /> 测试
|
<Send size={12} className="inline" /> 测试
|
||||||
</button>
|
</button>
|
||||||
@@ -134,13 +134,13 @@ export function DownloadClientsPage() {
|
|||||||
setEditing(c)
|
setEditing(c)
|
||||||
setShowForm(true)
|
setShowForm(true)
|
||||||
}}
|
}}
|
||||||
className="rounded border border-white/10 px-2 py-1 text-xs text-ink-100 hover:border-primary-400/40 hover:text-brand-500"
|
className="rounded-lg border border-gray-200 px-2 py-1 text-xs text-ink-100 hover:border-primary-400/40 hover:text-brand-500"
|
||||||
>
|
>
|
||||||
<Pencil size={12} className="inline" /> 编辑
|
<Pencil size={12} className="inline" /> 编辑
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => onDelete(c)}
|
onClick={() => onDelete(c)}
|
||||||
className="rounded border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10"
|
className="rounded-lg border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10"
|
||||||
>
|
>
|
||||||
<Trash2 size={12} className="inline" /> 删除
|
<Trash2 size={12} className="inline" /> 删除
|
||||||
</button>
|
</button>
|
||||||
@@ -301,7 +301,7 @@ function ClientFormModal({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="rounded border border-white/10 px-4 py-2 text-sm text-ink-100 hover:bg-white/5"
|
className="rounded-lg border border-gray-200 px-4 py-2 text-sm text-ink-100 hover:bg-gray-50"
|
||||||
>
|
>
|
||||||
取消
|
取消
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -107,14 +107,14 @@ export function DownloadsPage() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{torrents.map((t) => (
|
{torrents.map((t) => (
|
||||||
<tr key={t.hash} className="border-t border-white/5 align-top">
|
<tr key={t.hash} className="border-t border-gray-200 align-top">
|
||||||
<td className="max-w-md truncate py-2 text-ink-600" title={t.name}>
|
<td className="max-w-md truncate py-2 text-ink-600" title={t.name}>
|
||||||
{t.name}
|
{t.name}
|
||||||
</td>
|
</td>
|
||||||
<td className="text-ink-100">{t.state}</td>
|
<td className="text-ink-100">{t.state}</td>
|
||||||
<td className="text-ink-100">
|
<td className="text-ink-100">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="h-1 w-24 overflow-hidden rounded bg-white/10">
|
<div className="h-1 w-24 overflow-hidden rounded-lg bg-gray-200">
|
||||||
<div
|
<div
|
||||||
className="h-full bg-primary-400"
|
className="h-full bg-primary-400"
|
||||||
style={{ width: `${Math.round(t.progress * 100)}%` }}
|
style={{ width: `${Math.round(t.progress * 100)}%` }}
|
||||||
@@ -130,7 +130,7 @@ export function DownloadsPage() {
|
|||||||
{role === 'admin' && (
|
{role === 'admin' && (
|
||||||
<td className="py-2 text-right">
|
<td className="py-2 text-right">
|
||||||
<button
|
<button
|
||||||
className="rounded border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10"
|
className="rounded-lg border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
if (!confirm(`删除「${t.name}」?`)) return
|
if (!confirm(`删除「${t.name}」?`)) return
|
||||||
await downloadsAPI.remove(t.hash, false)
|
await downloadsAPI.remove(t.hash, false)
|
||||||
@@ -163,7 +163,7 @@ export function DownloadsPage() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{tasks.map((t) => (
|
{tasks.map((t) => (
|
||||||
<tr key={t.id} className="border-t border-white/5">
|
<tr key={t.id} className="border-t border-gray-200">
|
||||||
<td className="py-2 text-ink-100">{t.source}</td>
|
<td className="py-2 text-ink-100">{t.source}</td>
|
||||||
<td className="max-w-md truncate text-ink-100" title={t.url}>
|
<td className="max-w-md truncate text-ink-100" title={t.url}>
|
||||||
{t.url}
|
{t.url}
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ export function DuplicatesPage() {
|
|||||||
<section key={g.hash} className="glass-panel space-y-2">
|
<section key={g.hash} className="glass-panel space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="font-mono text-xs text-sand-500">{g.hash}</p>
|
<p className="font-mono text-xs text-sand-500">{g.hash}</p>
|
||||||
<span className="rounded border border-emerald-400/40 px-2 py-0.5 text-xs text-emerald-400">
|
<span className="rounded-lg border border-emerald-400/40 px-2 py-0.5 text-xs text-emerald-400">
|
||||||
主条目
|
主条目
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -101,7 +101,7 @@ export function DuplicatesPage() {
|
|||||||
<p className="font-mono text-xs text-ink-50">
|
<p className="font-mono text-xs text-ink-50">
|
||||||
{g.primary.path} · {fmtBytes(g.primary.size_bytes)}
|
{g.primary.path} · {fmtBytes(g.primary.size_bytes)}
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-1 border-t border-white/5 pt-2">
|
<div className="space-y-1 border-t border-gray-200 pt-2">
|
||||||
<p className="text-xs uppercase tracking-wider text-red-400">
|
<p className="text-xs uppercase tracking-wider text-red-400">
|
||||||
重复 ({g.duplicates.length})
|
重复 ({g.duplicates.length})
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export function FileManagerPage() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{data?.path && (
|
{data?.path && (
|
||||||
<span className="rounded border border-white/10 bg-white/5 px-2 py-1 font-mono text-xs text-ink-100">
|
<span className="rounded-lg border border-gray-200 bg-gray-50 px-2 py-1 font-mono text-xs text-ink-100">
|
||||||
{data.path}
|
{data.path}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -113,7 +113,7 @@ export function FileManagerPage() {
|
|||||||
{data.entries.map((e) => (
|
{data.entries.map((e) => (
|
||||||
<tr
|
<tr
|
||||||
key={e.path}
|
key={e.path}
|
||||||
className="cursor-pointer border-t border-white/5 transition hover:bg-white/5"
|
className="cursor-pointer border-t border-gray-200 transition hover:bg-gray-50"
|
||||||
onClick={() => enter(e)}
|
onClick={() => enter(e)}
|
||||||
title={e.path}
|
title={e.path}
|
||||||
>
|
>
|
||||||
|
|||||||
+12
-12
@@ -82,7 +82,7 @@ export function HomePage() {
|
|||||||
<div className="h-10 w-10 rounded-full border-2 border-gray-100 border-t-gray-900 animate-spin" />
|
<div className="h-10 w-10 rounded-full border-2 border-gray-100 border-t-gray-900 animate-spin" />
|
||||||
<Film className="absolute h-4 w-4 text-brand-500" />
|
<Film className="absolute h-4 w-4 text-brand-500" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-semibold tracking-widest text-gray-400 uppercase">站点舱准备中…</span>
|
<span className="text-sm font-semibold tracking-widest text-gray-500 uppercase">站点舱准备中…</span>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -92,7 +92,7 @@ export function HomePage() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center py-32 text-center max-w-md mx-auto">
|
<div className="flex flex-col items-center justify-center py-32 text-center max-w-md mx-auto">
|
||||||
<div className="mb-6 flex h-24 w-24 items-center justify-center rounded-3xl bg-gray-100 border border-gray-200/50 shadow-sm">
|
<div className="mb-6 flex h-24 w-24 items-center justify-center rounded-3xl bg-gray-100 border border-gray-200/50 shadow-sm">
|
||||||
<Film className="h-10 w-10 text-gray-400" />
|
<Film className="h-10 w-10 text-gray-500" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xl font-bold text-gray-900">您的家庭影视站暂无内容</p>
|
<p className="text-xl font-bold text-gray-900">您的家庭影视站暂无内容</p>
|
||||||
<p className="mt-2 text-sm text-gray-500 leading-relaxed">
|
<p className="mt-2 text-sm text-gray-500 leading-relaxed">
|
||||||
@@ -142,7 +142,7 @@ export function HomePage() {
|
|||||||
{featuredItem.overview}
|
{featuredItem.overview}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-gray-400 text-sm italic">
|
<p className="text-gray-500 text-sm italic">
|
||||||
家庭私人媒体中心收藏。极高视听品质,支持多端原生无损解码及HLS转码播放。
|
家庭私人媒体中心收藏。极高视听品质,支持多端原生无损解码及HLS转码播放。
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -150,15 +150,15 @@ export function HomePage() {
|
|||||||
{/* Metadata Badges */}
|
{/* Metadata Badges */}
|
||||||
<div className="flex flex-wrap items-center gap-3 text-xs text-gray-500 font-bold">
|
<div className="flex flex-wrap items-center gap-3 text-xs text-gray-500 font-bold">
|
||||||
{featuredItem.year > 0 && (
|
{featuredItem.year > 0 && (
|
||||||
<span className="bg-gray-100 px-2.5 py-1 rounded-md text-gray-900 border border-gray-200">{featuredItem.year} 年</span>
|
<span className="bg-gray-100 px-2.5 py-1 rounded-xl text-gray-900 border border-gray-200">{featuredItem.year} 年</span>
|
||||||
)}
|
)}
|
||||||
{featuredItem.video_codec && (
|
{featuredItem.video_codec && (
|
||||||
<span className="rounded bg-brand-50 px-2 py-1 text-brand-700 border border-brand-100 uppercase font-bold text-[10px]">
|
<span className="rounded-lg bg-brand-50 px-2 py-1 text-brand-700 border border-brand-100 uppercase font-bold text-[10px]">
|
||||||
{featuredItem.video_codec}
|
{featuredItem.video_codec}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{featuredItem.container && (
|
{featuredItem.container && (
|
||||||
<span className="rounded bg-gray-100 px-2 py-1 text-gray-700 border border-gray-200 uppercase font-mono text-[10px]">
|
<span className="rounded-lg bg-gray-100 px-2 py-1 text-gray-700 border border-gray-200 uppercase font-mono text-[10px]">
|
||||||
{featuredItem.container}
|
{featuredItem.container}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -183,11 +183,11 @@ export function HomePage() {
|
|||||||
{history.length > 0 && (
|
{history.length > 0 && (
|
||||||
<section className="space-y-5">
|
<section className="space-y-5">
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<span className="p-1.5 rounded-lg bg-gray-100 border border-gray-200/50 text-gray-900">
|
<span className="p-1.5 rounded-xl bg-gray-100 border border-gray-200/50 text-gray-900">
|
||||||
<Clock size={16} />
|
<Clock size={16} />
|
||||||
</span>
|
</span>
|
||||||
<h2 className="font-display text-xl font-extrabold tracking-tight text-gray-900">继续观看</h2>
|
<h2 className="font-display text-xl font-extrabold tracking-tight text-gray-900">继续观看</h2>
|
||||||
<span className="text-xs font-bold text-gray-400 bg-gray-100 px-2.5 py-0.5 rounded-full border border-gray-200/40">{history.length} 个记录</span>
|
<span className="text-xs font-bold text-gray-500 bg-gray-100 px-2.5 py-0.5 rounded-full border border-gray-200/40">{history.length} 个记录</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4">
|
||||||
@@ -206,7 +206,7 @@ export function HomePage() {
|
|||||||
<section key={row.library.id} className="space-y-5">
|
<section key={row.library.id} className="space-y-5">
|
||||||
<div className="flex items-center justify-between border-b border-gray-200/80 pb-3">
|
<div className="flex items-center justify-between border-b border-gray-200/80 pb-3">
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<span className="p-1.5 rounded-lg bg-gray-100 text-gray-900 border border-gray-200/50">
|
<span className="p-1.5 rounded-xl bg-gray-100 text-gray-900 border border-gray-200/50">
|
||||||
{TYPE_ICONS[row.library.type] ?? <LibraryIcon size={18} />}
|
{TYPE_ICONS[row.library.type] ?? <LibraryIcon size={18} />}
|
||||||
</span>
|
</span>
|
||||||
<h2 className="font-display text-xl font-extrabold tracking-tight text-gray-900">
|
<h2 className="font-display text-xl font-extrabold tracking-tight text-gray-900">
|
||||||
@@ -240,7 +240,7 @@ export function HomePage() {
|
|||||||
<section className="space-y-5">
|
<section className="space-y-5">
|
||||||
<div className="flex items-center justify-between border-b border-gray-200/80 pb-3">
|
<div className="flex items-center justify-between border-b border-gray-200/80 pb-3">
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<span className="p-1.5 rounded-lg bg-gray-100 text-gray-900 border border-gray-200/50">
|
<span className="p-1.5 rounded-xl bg-gray-100 text-gray-900 border border-gray-200/50">
|
||||||
<Clock size={18} />
|
<Clock size={18} />
|
||||||
</span>
|
</span>
|
||||||
<h2 className="font-display text-xl font-extrabold tracking-tight text-gray-900">最近添加</h2>
|
<h2 className="font-display text-xl font-extrabold tracking-tight text-gray-900">最近添加</h2>
|
||||||
@@ -272,7 +272,7 @@ function ContinueCard({ media, progress }: { media: Media; progress: number }) {
|
|||||||
referrerPolicy="no-referrer"
|
referrerPolicy="no-referrer"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full items-center justify-center text-gray-400 bg-gray-50">
|
<div className="flex h-full items-center justify-center text-gray-500 bg-gray-50">
|
||||||
<Film size={16} />
|
<Film size={16} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -293,7 +293,7 @@ function ContinueCard({ media, progress }: { media: Media; progress: number }) {
|
|||||||
className="h-full rounded-full bg-gradient-to-r from-brand-400 to-brand-500"
|
className="h-full rounded-full bg-gradient-to-r from-brand-400 to-brand-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[10px] text-gray-400 font-bold tracking-wide uppercase">
|
<p className="text-[10px] text-gray-500 font-bold tracking-wide uppercase">
|
||||||
已观看到 {Math.round(progress * 100)}%
|
已观看到 {Math.round(progress * 100)}%
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ export function LibraryPage() {
|
|||||||
{library && <p className="text-sm text-ink-50">{library.type} · {library.path}</p>}
|
{library && <p className="text-sm text-ink-50">{library.type} · {library.path}</p>}
|
||||||
</div>
|
</div>
|
||||||
{role === 'admin' && (
|
{role === 'admin' && (
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<button onClick={handleScan} disabled={scanning} className="btn-outline">{scanning ? '扫描中…' : '立即扫描'}</button>
|
<button onClick={handleScan} disabled={scanning} className="btn-outline">{scanning ? '扫描中…' : '立即扫描'}</button>
|
||||||
<button onClick={handleScrape} disabled={scraping} className="btn-outline">{scraping ? '刮削中…' : '刮削元数据'}</button>
|
<button onClick={handleScrape} disabled={scraping} className="btn-outline">{scraping ? '刮削中…' : '刮削元数据'}</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -144,7 +144,7 @@ export function LibraryPage() {
|
|||||||
|
|
||||||
{!isSeries && items.length === 0 && (
|
{!isSeries && items.length === 0 && (
|
||||||
<div className="flex flex-col items-center justify-center py-24 text-center">
|
<div className="flex flex-col items-center justify-center py-24 text-center">
|
||||||
<Film className="h-12 w-12 text-sand-400 mb-4" />
|
<Film className="h-12 w-12 text-gray-500 mb-4" />
|
||||||
<p className="text-ink-50">该媒体库暂无内容,触发一次扫描后再来看看</p>
|
<p className="text-ink-50">该媒体库暂无内容,触发一次扫描后再来看看</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -190,7 +190,7 @@ export function LibraryPage() {
|
|||||||
{selectedSeries.rep.poster_url ? (
|
{selectedSeries.rep.poster_url ? (
|
||||||
<img src={imageURL(selectedSeries.rep.poster_url)} alt={selectedSeries.rep.title} className="w-full aspect-[2/3] object-cover" referrerPolicy="no-referrer" />
|
<img src={imageURL(selectedSeries.rep.poster_url)} alt={selectedSeries.rep.title} className="w-full aspect-[2/3] object-cover" referrerPolicy="no-referrer" />
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center justify-center aspect-[2/3] text-sand-400"><Film size={40} /></div>
|
<div className="flex items-center justify-center aspect-[2/3] text-gray-500"><Film size={40} /></div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 space-y-3">
|
<div className="flex-1 space-y-3">
|
||||||
@@ -226,7 +226,7 @@ export function LibraryPage() {
|
|||||||
to={`/play/${ep.id}`}
|
to={`/play/${ep.id}`}
|
||||||
className="group flex items-center gap-3 rounded-xl border border-sand-200 bg-white p-3 shadow-card transition-all hover:border-brand-300 hover:shadow-card-hover"
|
className="group flex items-center gap-3 rounded-xl border border-sand-200 bg-white p-3 shadow-card transition-all hover:border-brand-300 hover:shadow-card-hover"
|
||||||
>
|
>
|
||||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-brand-50 text-brand-600 font-semibold text-sm">
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-brand-50 text-brand-600 font-semibold text-sm">
|
||||||
{ep.episode_num || '—'}
|
{ep.episode_num || '—'}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
@@ -239,7 +239,7 @@ export function LibraryPage() {
|
|||||||
: formatSize(ep.size_bytes)}
|
: formatSize(ep.size_bytes)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Play size={14} className="shrink-0 text-sand-400 opacity-0 transition-opacity group-hover:opacity-100 group-hover:text-brand-500" />
|
<Play size={14} className="shrink-0 text-gray-500 opacity-0 transition-opacity group-hover:opacity-100 group-hover:text-brand-500" />
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -253,7 +253,7 @@ export function LibraryPage() {
|
|||||||
{/* 剧集为空 */}
|
{/* 剧集为空 */}
|
||||||
{isSeries && seriesCards.length === 0 && !loading && (
|
{isSeries && seriesCards.length === 0 && !loading && (
|
||||||
<div className="flex flex-col items-center justify-center py-24 text-center">
|
<div className="flex flex-col items-center justify-center py-24 text-center">
|
||||||
<Film className="h-12 w-12 text-sand-400 mb-4" />
|
<Film className="h-12 w-12 text-gray-500 mb-4" />
|
||||||
<p className="text-ink-50">该库尚未发现任何剧集,触发一次扫描后再来看看</p>
|
<p className="text-ink-50">该库尚未发现任何剧集,触发一次扫描后再来看看</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -147,10 +147,10 @@ export function LicensePage() {
|
|||||||
)}
|
)}
|
||||||
<h2 className="font-display text-lg font-semibold text-ink-600">当前状态</h2>
|
<h2 className="font-display text-lg font-semibold text-ink-600">当前状态</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={refreshStatus}
|
onClick={refreshStatus}
|
||||||
className="rounded border border-white/10 p-2 text-ink-50 hover:bg-white/5 hover:text-white"
|
className="rounded-lg border border-gray-200 p-2 text-ink-50 hover:bg-gray-50 hover:text-white"
|
||||||
title="刷新状态"
|
title="刷新状态"
|
||||||
>
|
>
|
||||||
<RefreshCw size={14} />
|
<RefreshCw size={14} />
|
||||||
@@ -158,7 +158,7 @@ export function LicensePage() {
|
|||||||
{active && (
|
{active && (
|
||||||
<button
|
<button
|
||||||
onClick={onHeartbeat}
|
onClick={onHeartbeat}
|
||||||
className="rounded border border-primary-400/30 px-3 py-1.5 text-xs text-brand-500 hover:bg-primary-400/10"
|
className="rounded-lg border border-primary-400/30 px-3 py-1.5 text-xs text-brand-500 hover:bg-primary-400/10"
|
||||||
>
|
>
|
||||||
心跳上报
|
心跳上报
|
||||||
</button>
|
</button>
|
||||||
@@ -167,7 +167,7 @@ export function LicensePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!active && (
|
{!active && (
|
||||||
<div className="space-y-3 rounded-lg border border-white/5 bg-white/[0.02] p-4 text-center">
|
<div className="space-y-3 rounded-xl border border-gray-200 bg-gray-50 p-4 text-center">
|
||||||
<p className="text-ink-50">
|
<p className="text-ink-50">
|
||||||
尚未绑定许可证。请在上方输入密钥完成激活。
|
尚未绑定许可证。请在上方输入密钥完成激活。
|
||||||
</p>
|
</p>
|
||||||
@@ -202,7 +202,7 @@ export function LicensePage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{isExpired && (
|
{isExpired && (
|
||||||
<div className="rounded-lg border border-red-400/20 bg-red-400/5 px-4 py-3 text-sm text-red-400">
|
<div className="rounded-xl border border-red-400/20 bg-red-400/5 px-4 py-3 text-sm text-red-400">
|
||||||
此许可证已过期,部分功能可能受限。请获取新的许可证密钥。
|
此许可证已过期,部分功能可能受限。请获取新的许可证密钥。
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -211,11 +211,11 @@ export function LicensePage() {
|
|||||||
|
|
||||||
{/* ── Pro tip ── */}
|
{/* ── Pro tip ── */}
|
||||||
{!active && !loadingStatus && (
|
{!active && !loadingStatus && (
|
||||||
<div className="rounded-xl border border-white/5 bg-white/[0.02] p-5 text-center">
|
<div className="rounded-xl border border-gray-200 bg-gray-50 p-5 text-center">
|
||||||
<p className="text-sm text-sand-500">
|
<p className="text-sm text-sand-500">
|
||||||
需要获取许可证?请联系管理员获取 MediaStationGo 授权密钥。
|
需要获取许可证?请联系管理员获取 MediaStationGo 授权密钥。
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-xs text-sand-400">
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
授权服务器地址可在系统设置中配置
|
授权服务器地址可在系统设置中配置
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -238,7 +238,7 @@ function StatusBadge({
|
|||||||
className?: string
|
className?: string
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-white/5 bg-white/[0.02] p-3">
|
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3">
|
||||||
<p className="mb-0.5 text-xs text-sand-500">{label}</p>
|
<p className="mb-0.5 text-xs text-sand-500">{label}</p>
|
||||||
<p className={`text-sm text-ink-600 ${mono ? 'font-mono' : ''} ${className ?? ''}`}>
|
<p className={`text-sm text-ink-600 ${mono ? 'font-mono' : ''} ${className ?? ''}`}>
|
||||||
{value}
|
{value}
|
||||||
|
|||||||
@@ -90,12 +90,12 @@ export function LoginPage() {
|
|||||||
用户名
|
用户名
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400">
|
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-500">
|
||||||
<User size={16} />
|
<User size={16} />
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="w-full rounded-xl border border-gray-200 bg-gray-50/50 px-11 py-3.5 text-sm text-gray-900 placeholder-gray-400 outline-none transition-all duration-300 focus:border-brand-500 focus:bg-white focus:ring-4 focus:ring-brand-100/50"
|
className="w-full rounded-xl border border-gray-200 bg-gray-50/50 px-11 py-3.5 text-sm text-gray-900 placeholder-gray-500 outline-none transition-all duration-300 focus:border-brand-500 focus:bg-white focus:ring-4 focus:ring-brand-100/50"
|
||||||
value={username}
|
value={username}
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
@@ -116,12 +116,12 @@ export function LoginPage() {
|
|||||||
安全密码
|
安全密码
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400">
|
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-500">
|
||||||
<Lock size={16} />
|
<Lock size={16} />
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
type={showPw ? 'text' : 'password'}
|
type={showPw ? 'text' : 'password'}
|
||||||
className="w-full rounded-xl border border-gray-200 bg-gray-50/50 px-11 py-3.5 text-sm text-gray-900 placeholder-gray-400 outline-none transition-all duration-300 focus:border-brand-500 focus:bg-white focus:ring-4 focus:ring-brand-100/50 pr-12"
|
className="w-full rounded-xl border border-gray-200 bg-gray-50/50 px-11 py-3.5 text-sm text-gray-900 placeholder-gray-500 outline-none transition-all duration-300 focus:border-brand-500 focus:bg-white focus:ring-4 focus:ring-brand-100/50 pr-12"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
@@ -131,7 +131,7 @@ export function LoginPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowPw(!showPw)}
|
onClick={() => setShowPw(!showPw)}
|
||||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-gray-400 transition-colors hover:text-gray-900"
|
className="absolute right-4 top-1/2 -translate-y-1/2 text-gray-500 transition-colors hover:text-gray-900"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
>
|
>
|
||||||
{showPw ? <EyeOff size={16} /> : <Eye size={16} />}
|
{showPw ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||||
@@ -176,7 +176,7 @@ export function LoginPage() {
|
|||||||
>
|
>
|
||||||
<ShieldCheck size={14} className="text-brand-500" />
|
<ShieldCheck size={14} className="text-brand-500" />
|
||||||
<span>默认访问凭证: </span>
|
<span>默认访问凭证: </span>
|
||||||
<code className="rounded bg-gray-200/50 px-1.5 py-0.5 font-mono text-[#111827] font-semibold">
|
<code className="rounded-lg bg-gray-200/50 px-1.5 py-0.5 font-mono text-[#111827] font-semibold">
|
||||||
admin / admin123
|
admin / admin123
|
||||||
</code>
|
</code>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -189,7 +189,7 @@ export function LoginPage() {
|
|||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
transition={{ delay: 0.6 }}
|
transition={{ delay: 0.6 }}
|
||||||
className="relative z-10 mt-10 text-gray-400 hover:text-gray-600 transition-colors"
|
className="relative z-10 mt-10 text-gray-500 hover:text-gray-600 transition-colors"
|
||||||
>
|
>
|
||||||
<AppFooter />
|
<AppFooter />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ export function MediaDetailPage() {
|
|||||||
referrerPolicy="no-referrer"
|
referrerPolicy="no-referrer"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2 text-gray-400 bg-gray-50">
|
<div className="flex h-full w-full flex-col items-center justify-center gap-2 text-gray-500 bg-gray-50">
|
||||||
<FileText size={40} className="stroke-[1]" />
|
<FileText size={40} className="stroke-[1]" />
|
||||||
<span className="text-xs uppercase tracking-wider font-bold">无海报</span>
|
<span className="text-xs uppercase tracking-wider font-bold">无海报</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -188,24 +188,24 @@ export function MediaDetailPage() {
|
|||||||
</h1>
|
</h1>
|
||||||
<div className="flex flex-wrap items-center gap-2.5 text-xs text-gray-500 font-bold tracking-wide uppercase">
|
<div className="flex flex-wrap items-center gap-2.5 text-xs text-gray-500 font-bold tracking-wide uppercase">
|
||||||
{media.year > 0 && (
|
{media.year > 0 && (
|
||||||
<span className="inline-flex items-center gap-1 bg-gray-100 border border-gray-200/50 px-2.5 py-1 rounded-lg text-gray-700">
|
<span className="inline-flex items-center gap-1 bg-gray-100 border border-gray-200/50 px-2.5 py-1 rounded-xl text-gray-700">
|
||||||
<Calendar size={13} className="text-brand-500" />
|
<Calendar size={13} className="text-brand-500" />
|
||||||
<span>{media.year} 年</span>
|
<span>{media.year} 年</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{media.width > 0 && (
|
{media.width > 0 && (
|
||||||
<span className="inline-flex items-center gap-1 bg-brand-50 text-brand-700 border border-brand-100/50 px-2.5 py-1 rounded-lg">
|
<span className="inline-flex items-center gap-1 bg-brand-50 text-brand-700 border border-brand-100/50 px-2.5 py-1 rounded-xl">
|
||||||
<span>{media.width} × {media.height}</span>
|
<span>{media.width} × {media.height}</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="bg-gray-100 border border-gray-200/50 px-2.5 py-1 rounded-lg text-gray-700">
|
<span className="bg-gray-100 border border-gray-200/50 px-2.5 py-1 rounded-xl text-gray-700">
|
||||||
{fmtSize(media.size_bytes)}
|
{fmtSize(media.size_bytes)}
|
||||||
</span>
|
</span>
|
||||||
<span className="bg-gray-100 border border-gray-200/50 px-2.5 py-1 rounded-lg text-gray-700">
|
<span className="bg-gray-100 border border-gray-200/50 px-2.5 py-1 rounded-xl text-gray-700">
|
||||||
{fmtDuration(media.duration_sec)}
|
{fmtDuration(media.duration_sec)}
|
||||||
</span>
|
</span>
|
||||||
{media.container && (
|
{media.container && (
|
||||||
<span className="bg-gray-100 border border-gray-200/50 px-2.5 py-1 rounded-lg text-gray-700 font-mono">
|
<span className="bg-gray-100 border border-gray-200/50 px-2.5 py-1 rounded-xl text-gray-700 font-mono">
|
||||||
{media.container}
|
{media.container}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -227,7 +227,7 @@ export function MediaDetailPage() {
|
|||||||
{/* Genres */}
|
{/* Genres */}
|
||||||
{parseCSV(media.genres).length > 0 && (
|
{parseCSV(media.genres).length > 0 && (
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<span className="text-xs font-bold text-gray-400 w-16 uppercase tracking-wider">类型流派</span>
|
<span className="text-xs font-bold text-gray-500 w-16 uppercase tracking-wider">类型流派</span>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{parseCSV(media.genres).map((g) => (
|
{parseCSV(media.genres).map((g) => (
|
||||||
<span key={g} className="rounded-full bg-brand-50 text-brand-700 border border-brand-100/30 px-3 py-1 text-2xs font-bold uppercase tracking-wider">
|
<span key={g} className="rounded-full bg-brand-50 text-brand-700 border border-brand-100/30 px-3 py-1 text-2xs font-bold uppercase tracking-wider">
|
||||||
@@ -241,10 +241,10 @@ export function MediaDetailPage() {
|
|||||||
{/* Languages */}
|
{/* Languages */}
|
||||||
{parseCSV(media.languages).length > 0 && (
|
{parseCSV(media.languages).length > 0 && (
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<span className="text-xs font-bold text-gray-400 w-16 uppercase tracking-wider">语言</span>
|
<span className="text-xs font-bold text-gray-500 w-16 uppercase tracking-wider">语言</span>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{parseCSV(media.languages).map((l) => (
|
{parseCSV(media.languages).map((l) => (
|
||||||
<span key={l} className="rounded-lg bg-gray-100 text-gray-600 border border-gray-200/40 px-2.5 py-1 text-2xs font-semibold">
|
<span key={l} className="rounded-xl bg-gray-100 text-gray-600 border border-gray-200/40 px-2.5 py-1 text-2xs font-semibold">
|
||||||
{l}
|
{l}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
@@ -255,10 +255,10 @@ export function MediaDetailPage() {
|
|||||||
{/* Countries */}
|
{/* Countries */}
|
||||||
{parseCSV(media.countries).length > 0 && (
|
{parseCSV(media.countries).length > 0 && (
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<span className="text-xs font-bold text-gray-400 w-16 uppercase tracking-wider">国家/地区</span>
|
<span className="text-xs font-bold text-gray-500 w-16 uppercase tracking-wider">国家/地区</span>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{parseCSV(media.countries).map((c) => (
|
{parseCSV(media.countries).map((c) => (
|
||||||
<span key={c} className="rounded-lg bg-gray-100 text-gray-600 border border-gray-200/40 px-2.5 py-1 text-2xs font-semibold">
|
<span key={c} className="rounded-xl bg-gray-100 text-gray-600 border border-gray-200/40 px-2.5 py-1 text-2xs font-semibold">
|
||||||
{c}
|
{c}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ export function NotifyChannelsPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const TYPE_LABELS: Record<NotifyChannel['channel_type'], string> = {
|
const TYPE_LABELS: Record<NotifyChannel['type'], string> = {
|
||||||
telegram: 'Telegram',
|
telegram: 'Telegram',
|
||||||
wechat: '企业微信',
|
wechat: '企业微信',
|
||||||
bark: 'Bark',
|
bark: 'Bark',
|
||||||
@@ -145,11 +145,11 @@ function ChannelCard({
|
|||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-medium text-ink-600">{channel.name}</span>
|
<span className="font-medium text-ink-600">{channel.name}</span>
|
||||||
<span className="rounded border border-white/10 bg-white/5 px-2 py-0.5 text-xs text-ink-50">
|
<span className="rounded-lg border border-gray-200 bg-gray-50 px-2 py-0.5 text-xs text-ink-50">
|
||||||
{TYPE_LABELS[channel.channel_type] ?? channel.channel_type}
|
{TYPE_LABELS[channel.type] ?? channel.type}
|
||||||
</span>
|
</span>
|
||||||
{!channel.enabled && (
|
{!channel.enabled && (
|
||||||
<span className="rounded bg-sand-500/30 px-2 py-0.5 text-xs text-ink-100">已禁用</span>
|
<span className="rounded-lg bg-sand-500/30 px-2 py-0.5 text-xs text-ink-100">已禁用</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 truncate text-xs text-ink-50">{summary}</div>
|
<div className="mt-1 truncate text-xs text-ink-50">{summary}</div>
|
||||||
@@ -157,19 +157,19 @@ function ChannelCard({
|
|||||||
<div className="flex shrink-0 gap-2">
|
<div className="flex shrink-0 gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={onTest}
|
onClick={onTest}
|
||||||
className="rounded border border-white/10 px-2 py-1 text-xs text-ink-100 hover:border-primary-400/40 hover:text-brand-500"
|
className="rounded-lg border border-gray-200 px-2 py-1 text-xs text-ink-100 hover:border-primary-400/40 hover:text-brand-500"
|
||||||
>
|
>
|
||||||
<Send size={12} className="inline" /> 测试
|
<Send size={12} className="inline" /> 测试
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onEdit}
|
onClick={onEdit}
|
||||||
className="rounded border border-white/10 px-2 py-1 text-xs text-ink-100 hover:border-primary-400/40 hover:text-brand-500"
|
className="rounded-lg border border-gray-200 px-2 py-1 text-xs text-ink-100 hover:border-primary-400/40 hover:text-brand-500"
|
||||||
>
|
>
|
||||||
<Pencil size={12} className="inline" /> 编辑
|
<Pencil size={12} className="inline" /> 编辑
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onDelete}
|
onClick={onDelete}
|
||||||
className="rounded border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10"
|
className="rounded-lg border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10"
|
||||||
>
|
>
|
||||||
<Trash2 size={12} className="inline" /> 删除
|
<Trash2 size={12} className="inline" /> 删除
|
||||||
</button>
|
</button>
|
||||||
@@ -180,7 +180,7 @@ function ChannelCard({
|
|||||||
|
|
||||||
function channelSummary(ch: NotifyChannel): string {
|
function channelSummary(ch: NotifyChannel): string {
|
||||||
const cfg = ch.config ?? {}
|
const cfg = ch.config ?? {}
|
||||||
switch (ch.channel_type) {
|
switch (ch.type) {
|
||||||
case 'telegram':
|
case 'telegram':
|
||||||
return `Bot ${String(cfg.bot_token ?? '').slice(0, 10)}… → chat ${cfg.chat_id ?? '-'}`
|
return `Bot ${String(cfg.bot_token ?? '').slice(0, 10)}… → chat ${cfg.chat_id ?? '-'}`
|
||||||
case 'wechat':
|
case 'wechat':
|
||||||
@@ -198,7 +198,7 @@ function channelSummary(ch: NotifyChannel): string {
|
|||||||
|
|
||||||
// ─── Form Modal ─────────────────────────────────────────────────────────────
|
// ─── Form Modal ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const EMPTY_CONFIG: Record<NotifyChannel['channel_type'], Record<string, string>> = {
|
const EMPTY_CONFIG: Record<NotifyChannel['type'], Record<string, string>> = {
|
||||||
telegram: { bot_token: '', chat_id: '' },
|
telegram: { bot_token: '', chat_id: '' },
|
||||||
wechat: { sendkey: '' },
|
wechat: { sendkey: '' },
|
||||||
bark: { device_key: '', server: '' },
|
bark: { device_key: '', server: '' },
|
||||||
@@ -216,8 +216,8 @@ function ChannelFormModal({
|
|||||||
onSaved: () => void | Promise<void>
|
onSaved: () => void | Promise<void>
|
||||||
}) {
|
}) {
|
||||||
const [name, setName] = useState(editing?.name ?? '')
|
const [name, setName] = useState(editing?.name ?? '')
|
||||||
const [type, setType] = useState<NotifyChannel['channel_type']>(
|
const [type, setType] = useState<NotifyChannel['type']>(
|
||||||
editing?.channel_type ?? 'telegram',
|
editing?.type ?? 'telegram',
|
||||||
)
|
)
|
||||||
const [config, setConfig] = useState<Record<string, string>>(
|
const [config, setConfig] = useState<Record<string, string>>(
|
||||||
editing?.config ?? EMPTY_CONFIG.telegram,
|
editing?.config ?? EMPTY_CONFIG.telegram,
|
||||||
@@ -225,7 +225,7 @@ function ChannelFormModal({
|
|||||||
const [enabled, setEnabled] = useState(editing?.enabled ?? true)
|
const [enabled, setEnabled] = useState(editing?.enabled ?? true)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
const onTypeChange = (t: NotifyChannel['channel_type']) => {
|
const onTypeChange = (t: NotifyChannel['type']) => {
|
||||||
setType(t)
|
setType(t)
|
||||||
setConfig({ ...EMPTY_CONFIG[t] })
|
setConfig({ ...EMPTY_CONFIG[t] })
|
||||||
}
|
}
|
||||||
@@ -236,7 +236,7 @@ function ChannelFormModal({
|
|||||||
try {
|
try {
|
||||||
const input: NotifyChannelInput = {
|
const input: NotifyChannelInput = {
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
channel_type: type,
|
type: type,
|
||||||
config,
|
config,
|
||||||
enabled,
|
enabled,
|
||||||
}
|
}
|
||||||
@@ -280,7 +280,7 @@ function ChannelFormModal({
|
|||||||
<select
|
<select
|
||||||
className="input-base"
|
className="input-base"
|
||||||
value={type}
|
value={type}
|
||||||
onChange={(e) => onTypeChange(e.target.value as NotifyChannel['channel_type'])}
|
onChange={(e) => onTypeChange(e.target.value as NotifyChannel['type'])}
|
||||||
>
|
>
|
||||||
<option value="telegram">Telegram</option>
|
<option value="telegram">Telegram</option>
|
||||||
<option value="wechat">企业微信 / Server酱</option>
|
<option value="wechat">企业微信 / Server酱</option>
|
||||||
@@ -474,7 +474,7 @@ function ChannelFormModal({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="rounded border border-white/10 px-4 py-2 text-sm text-ink-100 hover:bg-white/5"
|
className="rounded-lg border border-gray-200 px-4 py-2 text-sm text-ink-100 hover:bg-gray-50"
|
||||||
>
|
>
|
||||||
取消
|
取消
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export function PlaylistsPage() {
|
|||||||
>
|
>
|
||||||
{p.name}
|
{p.name}
|
||||||
{p.is_public && (
|
{p.is_public && (
|
||||||
<span className="ml-2 rounded border border-primary-400/40 px-1.5 py-0.5 text-xs text-brand-500">
|
<span className="ml-2 rounded-lg border border-primary-400/40 px-1.5 py-0.5 text-xs text-brand-500">
|
||||||
public
|
public
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -79,7 +79,7 @@ export function PlaylistsPage() {
|
|||||||
toast.success('已删除')
|
toast.success('已删除')
|
||||||
await refresh()
|
await refresh()
|
||||||
}}
|
}}
|
||||||
className="rounded border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10"
|
className="rounded-lg border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10"
|
||||||
>
|
>
|
||||||
<Trash2 size={12} />
|
<Trash2 size={12} />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export function PosterWallPage() {
|
|||||||
<div key={s.rep.id} className="relative">
|
<div key={s.rep.id} className="relative">
|
||||||
<MediaCard media={s.rep} />
|
<MediaCard media={s.rep} />
|
||||||
{s.count > 1 && (
|
{s.count > 1 && (
|
||||||
<span className="pointer-events-none absolute right-1.5 top-1.5 inline-flex items-center gap-0.5 rounded-md bg-black/60 px-1.5 py-0.5 text-[10px] font-medium text-ink-600 backdrop-blur-sm">
|
<span className="pointer-events-none absolute right-1.5 top-1.5 inline-flex items-center gap-0.5 rounded-xl bg-black/60 px-1.5 py-0.5 text-[10px] font-medium text-ink-600 backdrop-blur-sm">
|
||||||
<Layers size={10} />
|
<Layers size={10} />
|
||||||
{s.count}
|
{s.count}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -160,17 +160,17 @@ function ProfileCard({
|
|||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span className="font-semibold text-ink-600">{profile.name}</span>
|
<span className="font-semibold text-ink-600">{profile.name}</span>
|
||||||
{profile.is_default && (
|
{profile.is_default && (
|
||||||
<span className="rounded bg-primary-400/20 px-2 py-0.5 text-xs text-brand-500">
|
<span className="rounded-lg bg-primary-400/20 px-2 py-0.5 text-xs text-brand-500">
|
||||||
默认
|
默认
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{profile.allow_adult && (
|
{profile.allow_adult && (
|
||||||
<span className="rounded bg-red-400/20 px-2 py-0.5 text-xs text-red-400">
|
<span className="rounded-lg bg-red-400/20 px-2 py-0.5 text-xs text-red-400">
|
||||||
成人内容
|
成人内容
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{profile.require_pin && (
|
{profile.require_pin && (
|
||||||
<span className="rounded bg-amber-400/20 px-2 py-0.5 text-xs text-amber-400">
|
<span className="rounded-lg bg-amber-400/20 px-2 py-0.5 text-xs text-amber-400">
|
||||||
🔒 PIN
|
🔒 PIN
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -189,13 +189,13 @@ function ProfileCard({
|
|||||||
<div className="flex shrink-0 gap-2">
|
<div className="flex shrink-0 gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={onEdit}
|
onClick={onEdit}
|
||||||
className="rounded border border-white/10 px-2 py-1 text-xs text-ink-100 hover:border-primary-400/40 hover:text-brand-500"
|
className="rounded-lg border border-gray-200 px-2 py-1 text-xs text-ink-100 hover:border-primary-400/40 hover:text-brand-500"
|
||||||
>
|
>
|
||||||
<Pencil size={12} className="inline" /> 编辑
|
<Pencil size={12} className="inline" /> 编辑
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onDelete}
|
onClick={onDelete}
|
||||||
className="rounded border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10"
|
className="rounded-lg border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10"
|
||||||
>
|
>
|
||||||
<Trash2 size={12} className="inline" /> 删除
|
<Trash2 size={12} className="inline" /> 删除
|
||||||
</button>
|
</button>
|
||||||
@@ -389,7 +389,7 @@ function ProfileFormModal({
|
|||||||
'rounded-full border px-3 py-1 text-xs ' +
|
'rounded-full border px-3 py-1 text-xs ' +
|
||||||
(form.allowed_library_ids.includes(l.id)
|
(form.allowed_library_ids.includes(l.id)
|
||||||
? 'border-primary-400/60 bg-primary-400/10 text-brand-500'
|
? 'border-primary-400/60 bg-primary-400/10 text-brand-500'
|
||||||
: 'border-white/10 text-ink-50 hover:text-white')
|
: 'border-gray-200 text-ink-50 hover:text-white')
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{l.name}
|
{l.name}
|
||||||
@@ -405,7 +405,7 @@ function ProfileFormModal({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="rounded border border-white/10 px-4 py-2 text-sm text-ink-100 hover:bg-white/5"
|
className="rounded-lg border border-gray-200 px-4 py-2 text-sm text-ink-100 hover:bg-gray-50"
|
||||||
>
|
>
|
||||||
取消
|
取消
|
||||||
</button>
|
</button>
|
||||||
@@ -441,7 +441,7 @@ function Toggle({
|
|||||||
onChange: (v: boolean) => void
|
onChange: (v: boolean) => void
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<label className="flex cursor-pointer items-center justify-between gap-3 rounded-lg border border-white/5 bg-white/5 px-3 py-2">
|
<label className="flex cursor-pointer items-center justify-between gap-3 rounded-xl border border-gray-200 bg-gray-50 px-3 py-2">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm text-ink-600">{label}</div>
|
<div className="text-sm text-ink-600">{label}</div>
|
||||||
{hint && <div className="text-xs text-ink-50">{hint}</div>}
|
{hint && <div className="text-xs text-ink-50">{hint}</div>}
|
||||||
|
|||||||
@@ -41,14 +41,14 @@ export function RecycleBinPage() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{items.map((m) => (
|
{items.map((m) => (
|
||||||
<tr key={m.id} className="border-t border-white/5">
|
<tr key={m.id} className="border-t border-gray-200">
|
||||||
<td className="py-2 text-ink-600">{m.title}</td>
|
<td className="py-2 text-ink-600">{m.title}</td>
|
||||||
<td className="max-w-md truncate text-ink-50" title={m.path}>
|
<td className="max-w-md truncate text-ink-50" title={m.path}>
|
||||||
{m.path}
|
{m.path}
|
||||||
</td>
|
</td>
|
||||||
<td className="space-x-2 py-2 text-right">
|
<td className="space-x-2 py-2 text-right">
|
||||||
<button
|
<button
|
||||||
className="rounded border border-primary-400/40 px-2 py-1 text-xs text-brand-500 hover:bg-primary-400/10"
|
className="rounded-lg border border-primary-400/40 px-2 py-1 text-xs text-brand-500 hover:bg-primary-400/10"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await recycleAPI.restore(m.id)
|
await recycleAPI.restore(m.id)
|
||||||
toast.success('已恢复')
|
toast.success('已恢复')
|
||||||
@@ -58,7 +58,7 @@ export function RecycleBinPage() {
|
|||||||
<RotateCcw size={12} />
|
<RotateCcw size={12} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="rounded border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10"
|
className="rounded-lg border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
if (!confirm(`彻底删除「${m.title}」? (磁盘文件保留)`)) return
|
if (!confirm(`彻底删除「${m.title}」? (磁盘文件保留)`)) return
|
||||||
await recycleAPI.purge(m.id)
|
await recycleAPI.purge(m.id)
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export function SchedulerPage() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{jobs.map((j) => (
|
{jobs.map((j) => (
|
||||||
<tr key={j.name} className="border-t border-white/5">
|
<tr key={j.name} className="border-t border-gray-200">
|
||||||
<td className="py-2 font-mono text-ink-600">{j.name}</td>
|
<td className="py-2 font-mono text-ink-600">{j.name}</td>
|
||||||
<td className="text-ink-100">{j.interval}</td>
|
<td className="text-ink-100">{j.interval}</td>
|
||||||
<td className="text-ink-50">
|
<td className="text-ink-50">
|
||||||
@@ -69,7 +69,7 @@ export function SchedulerPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => runNow(j.name)}
|
onClick={() => runNow(j.name)}
|
||||||
disabled={running === j.name}
|
disabled={running === j.name}
|
||||||
className="rounded border border-primary-400/40 px-2 py-1 text-xs text-brand-500 hover:bg-primary-400/10"
|
className="rounded-lg border border-primary-400/40 px-2 py-1 text-xs text-brand-500 hover:bg-primary-400/10"
|
||||||
>
|
>
|
||||||
<Play size={12} className="inline" /> 立即运行
|
<Play size={12} className="inline" /> 立即运行
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ export function SearchPage() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
{aiOn ? (
|
{aiOn ? (
|
||||||
<form onSubmit={onAISubmit} className="flex gap-2">
|
<form onSubmit={onAISubmit} className="flex flex-wrap gap-2">
|
||||||
<input
|
<input
|
||||||
autoFocus
|
autoFocus
|
||||||
className="input-base"
|
className="input-base"
|
||||||
|
|||||||
@@ -250,7 +250,7 @@ export function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2 overflow-x-auto border-b border-white/10">
|
<div className="flex gap-2 overflow-x-auto border-b border-gray-200">
|
||||||
{GROUPS.map((g) => (
|
{GROUPS.map((g) => (
|
||||||
<button
|
<button
|
||||||
key={g.key}
|
key={g.key}
|
||||||
@@ -318,7 +318,7 @@ export function SettingsPage() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{libraries.map((lib) => (
|
{libraries.map((lib) => (
|
||||||
<tr key={lib.id} className="border-t border-white/5">
|
<tr key={lib.id} className="border-t border-gray-200">
|
||||||
<td className="py-2 font-medium text-ink-600">{lib.name}</td>
|
<td className="py-2 font-medium text-ink-600">{lib.name}</td>
|
||||||
<td className="text-ink-50">
|
<td className="text-ink-50">
|
||||||
{lib.type === 'movie' ? '电影' : lib.type === 'tv' ? '电视剧' : lib.type === 'anime' ? '动漫' : '音乐'}
|
{lib.type === 'movie' ? '电影' : lib.type === 'tv' ? '电视剧' : lib.type === 'anime' ? '动漫' : '音乐'}
|
||||||
@@ -355,7 +355,7 @@ function SettingRow({
|
|||||||
<label className="text-sm text-ink-100">
|
<label className="text-sm text-ink-100">
|
||||||
<div className="font-medium">{def.label}</div>
|
<div className="font-medium">{def.label}</div>
|
||||||
{def.hint && <div className="mt-0.5 text-xs text-sand-500">{def.hint}</div>}
|
{def.hint && <div className="mt-0.5 text-xs text-sand-500">{def.hint}</div>}
|
||||||
<div className="mt-0.5 font-mono text-[10px] text-sand-400">{def.key}</div>
|
<div className="mt-0.5 font-mono text-[10px] text-gray-500">{def.key}</div>
|
||||||
</label>
|
</label>
|
||||||
<div>
|
<div>
|
||||||
{def.type === 'text' && (
|
{def.type === 'text' && (
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ export function SiteSearchPage() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<form onSubmit={onSubmit} className="flex gap-2">
|
<form onSubmit={onSubmit} className="flex flex-wrap gap-2">
|
||||||
<input
|
<input
|
||||||
autoFocus
|
autoFocus
|
||||||
className="input-base flex-1"
|
className="input-base flex-1"
|
||||||
@@ -114,7 +114,7 @@ export function SiteSearchPage() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{results.map((item, idx) => (
|
{results.map((item, idx) => (
|
||||||
<tr key={idx} className="border-t border-white/5 align-top">
|
<tr key={idx} className="border-t border-gray-200 align-top">
|
||||||
<td className="py-2 text-brand-500">{item.site_name}</td>
|
<td className="py-2 text-brand-500">{item.site_name}</td>
|
||||||
<td className="max-w-md py-2">
|
<td className="max-w-md py-2">
|
||||||
<a
|
<a
|
||||||
@@ -136,7 +136,7 @@ export function SiteSearchPage() {
|
|||||||
<td className="text-red-400">{item.leechers || '—'}</td>
|
<td className="text-red-400">{item.leechers || '—'}</td>
|
||||||
<td>
|
<td>
|
||||||
{item.free && (
|
{item.free && (
|
||||||
<span className="rounded border border-emerald-400/40 px-1.5 py-0.5 text-xs text-emerald-400">
|
<span className="rounded-lg border border-emerald-400/40 px-1.5 py-0.5 text-xs text-emerald-400">
|
||||||
Free
|
Free
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -144,7 +144,7 @@ export function SiteSearchPage() {
|
|||||||
<td className="py-2 text-right">
|
<td className="py-2 text-right">
|
||||||
<button
|
<button
|
||||||
onClick={() => downloadTorrent(item)}
|
onClick={() => downloadTorrent(item)}
|
||||||
className="rounded border border-primary-400/40 px-2 py-1 text-xs text-brand-500 hover:bg-primary-400/10"
|
className="rounded-lg border border-primary-400/40 px-2 py-1 text-xs text-brand-500 hover:bg-primary-400/10"
|
||||||
title="加入下载"
|
title="加入下载"
|
||||||
>
|
>
|
||||||
<Download size={12} className="inline" />
|
<Download size={12} className="inline" />
|
||||||
|
|||||||
+18
-18
@@ -251,7 +251,7 @@ export function SitesPage() {
|
|||||||
{/* 头部 */}
|
{/* 头部 */}
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<div className={`w-8 h-8 rounded-lg flex items-center justify-center text-xs font-bold shrink-0 ${SITE_TYPE_COLORS[site.type] || 'bg-sand-500/15 text-ink-50'}`}>
|
<div className={`w-8 h-8 rounded-xl flex items-center justify-center text-xs font-bold shrink-0 ${SITE_TYPE_COLORS[site.type] || 'bg-sand-500/15 text-ink-50'}`}>
|
||||||
{SITE_TYPE_ABBR[site.type] || '?'}
|
{SITE_TYPE_ABBR[site.type] || '?'}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
@@ -276,23 +276,23 @@ export function SitesPage() {
|
|||||||
|
|
||||||
{/* 标签 */}
|
{/* 标签 */}
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
<span className="text-xs px-1.5 py-0.5 rounded bg-white/5 text-ink-50">
|
<span className="text-xs px-1.5 py-0.5 rounded-lg bg-gray-50 text-ink-50">
|
||||||
{SITE_TYPE_LABELS[site.type] || site.type}
|
{SITE_TYPE_LABELS[site.type] || site.type}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs px-1.5 py-0.5 rounded bg-white/5 text-ink-50">
|
<span className="text-xs px-1.5 py-0.5 rounded-lg bg-gray-50 text-ink-50">
|
||||||
{AUTH_TYPE_LABELS[site.auth_type] || site.auth_type}
|
{AUTH_TYPE_LABELS[site.auth_type] || site.auth_type}
|
||||||
</span>
|
</span>
|
||||||
{site.is_default && (
|
{site.is_default && (
|
||||||
<span className="text-xs px-1.5 py-0.5 rounded bg-primary-400/15 text-brand-500">默认</span>
|
<span className="text-xs px-1.5 py-0.5 rounded-lg bg-primary-400/15 text-brand-500">默认</span>
|
||||||
)}
|
)}
|
||||||
{site.use_proxy && (
|
{site.use_proxy && (
|
||||||
<span className="text-xs px-1.5 py-0.5 rounded bg-blue-500/15 text-blue-400">代理</span>
|
<span className="text-xs px-1.5 py-0.5 rounded-lg bg-blue-500/15 text-blue-400">代理</span>
|
||||||
)}
|
)}
|
||||||
{site.rate_limit && (
|
{site.rate_limit && (
|
||||||
<span className="text-xs px-1.5 py-0.5 rounded bg-yellow-500/15 text-yellow-400">限流</span>
|
<span className="text-xs px-1.5 py-0.5 rounded-lg bg-yellow-500/15 text-yellow-400">限流</span>
|
||||||
)}
|
)}
|
||||||
{site.browser_emulation && (
|
{site.browser_emulation && (
|
||||||
<span className="text-xs px-1.5 py-0.5 rounded bg-purple-500/15 text-purple-400">浏览器</span>
|
<span className="text-xs px-1.5 py-0.5 rounded-lg bg-purple-500/15 text-purple-400">浏览器</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -319,7 +319,7 @@ export function SitesPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => handleTest(site.id)}
|
onClick={() => handleTest(site.id)}
|
||||||
disabled={testingId === site.id}
|
disabled={testingId === site.id}
|
||||||
className="flex-1 rounded border border-white/10 px-2 py-1.5 text-xs text-ink-100 hover:bg-white/5 disabled:opacity-50 flex items-center justify-center gap-1 transition"
|
className="flex-1 rounded-lg border border-gray-200 px-2 py-1.5 text-xs text-ink-100 hover:bg-gray-50 disabled:opacity-50 flex items-center justify-center gap-1 transition"
|
||||||
>
|
>
|
||||||
{testingId === site.id ? (
|
{testingId === site.id ? (
|
||||||
<>
|
<>
|
||||||
@@ -335,14 +335,14 @@ export function SitesPage() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => openEdit(site.id)}
|
onClick={() => openEdit(site.id)}
|
||||||
className="rounded border border-white/10 p-1.5 text-ink-50 hover:text-white hover:bg-white/5 transition"
|
className="rounded-lg border border-gray-200 p-1.5 text-ink-50 hover:text-white hover:bg-gray-50 transition"
|
||||||
title="编辑"
|
title="编辑"
|
||||||
>
|
>
|
||||||
<Edit3 size={14} />
|
<Edit3 size={14} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(site)}
|
onClick={() => handleDelete(site)}
|
||||||
className="rounded border border-white/10 p-1.5 text-ink-50 hover:text-red-400 hover:bg-red-400/10 transition"
|
className="rounded-lg border border-gray-200 p-1.5 text-ink-50 hover:text-red-400 hover:bg-red-400/10 transition"
|
||||||
title="删除"
|
title="删除"
|
||||||
>
|
>
|
||||||
<Trash2 size={14} />
|
<Trash2 size={14} />
|
||||||
@@ -354,7 +354,7 @@ export function SitesPage() {
|
|||||||
{/* 空状态 */}
|
{/* 空状态 */}
|
||||||
{!loading && sites.length === 0 && (
|
{!loading && sites.length === 0 && (
|
||||||
<div className="col-span-full py-12 text-center text-ink-50">
|
<div className="col-span-full py-12 text-center text-ink-50">
|
||||||
<Globe size={40} className="mx-auto mb-3 text-sand-400" />
|
<Globe size={40} className="mx-auto mb-3 text-gray-500" />
|
||||||
<p>暂无站点</p>
|
<p>暂无站点</p>
|
||||||
<p className="text-sm mt-1 text-sand-500">点击「添加站点」添加 PT/BT 站点</p>
|
<p className="text-sm mt-1 text-sand-500">点击「添加站点」添加 PT/BT 站点</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -435,7 +435,7 @@ export function SitesPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setForm((f) => ({ ...f, enabled: !f.enabled }))}
|
onClick={() => setForm((f) => ({ ...f, enabled: !f.enabled }))}
|
||||||
className={`relative inline-flex h-5 w-9 shrink-0 rounded-full transition-colors cursor-pointer ${form.enabled ? 'bg-primary-500' : 'bg-white/10'}`}
|
className={`relative inline-flex h-5 w-9 shrink-0 rounded-full transition-colors cursor-pointer ${form.enabled ? 'bg-primary-500' : 'bg-gray-200'}`}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={`pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform mt-0.5 ${form.enabled ? 'translate-x-4' : 'translate-x-0.5'}`}
|
className={`pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform mt-0.5 ${form.enabled ? 'translate-x-4' : 'translate-x-0.5'}`}
|
||||||
@@ -450,7 +450,7 @@ export function SitesPage() {
|
|||||||
|
|
||||||
{/* 馒头提示 */}
|
{/* 馒头提示 */}
|
||||||
{form.type === 'mteam' && (
|
{form.type === 'mteam' && (
|
||||||
<div className="p-3 rounded-lg border border-green-500/30 bg-green-500/5">
|
<div className="p-3 rounded-xl border border-green-500/30 bg-green-500/5">
|
||||||
<div className="text-sm font-medium text-green-400 mb-1">馒头站点配置指南</div>
|
<div className="text-sm font-medium text-green-400 mb-1">馒头站点配置指南</div>
|
||||||
<div className="text-xs text-ink-50 space-y-1">
|
<div className="text-xs text-ink-50 space-y-1">
|
||||||
<div><b>站点地址:</b><code className="text-green-300">https://api2.m-team.cc</code></div>
|
<div><b>站点地址:</b><code className="text-green-300">https://api2.m-team.cc</code></div>
|
||||||
@@ -477,10 +477,10 @@ export function SitesPage() {
|
|||||||
key={opt.value}
|
key={opt.value}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setForm((f) => ({ ...f, auth_type: opt.value }))}
|
onClick={() => setForm((f) => ({ ...f, auth_type: opt.value }))}
|
||||||
className={`px-3 py-1.5 rounded-lg text-xs font-medium border transition ${
|
className={`px-3 py-1.5 rounded-xl text-xs font-medium border transition ${
|
||||||
form.auth_type === opt.value
|
form.auth_type === opt.value
|
||||||
? 'bg-primary-500 text-ink-600 border-primary-500'
|
? 'bg-primary-500 text-ink-600 border-primary-500'
|
||||||
: 'border-white/10 text-ink-50 hover:border-primary-500/50'
|
: 'border-gray-200 text-ink-50 hover:border-primary-500/50'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{opt.label}
|
{opt.label}
|
||||||
@@ -555,7 +555,7 @@ export function SitesPage() {
|
|||||||
{advancedOpen ? '▾' : '▸'} 高级选项
|
{advancedOpen ? '▾' : '▸'} 高级选项
|
||||||
</button>
|
</button>
|
||||||
{advancedOpen && (
|
{advancedOpen && (
|
||||||
<div className="mt-3 pl-4 space-y-3 border-l border-white/10">
|
<div className="mt-3 pl-4 space-y-3 border-l border-gray-200">
|
||||||
{/* 新增高级字段 */}
|
{/* 新增高级字段 */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
@@ -646,7 +646,7 @@ export function SitesPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setForm((f) => ({ ...f, is_default: !f.is_default }))}
|
onClick={() => setForm((f) => ({ ...f, is_default: !f.is_default }))}
|
||||||
className={`relative inline-flex h-5 w-9 shrink-0 rounded-full transition-colors cursor-pointer ${form.is_default ? 'bg-primary-500' : 'bg-white/10'}`}
|
className={`relative inline-flex h-5 w-9 shrink-0 rounded-full transition-colors cursor-pointer ${form.is_default ? 'bg-primary-500' : 'bg-gray-200'}`}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={`pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform mt-0.5 ${form.is_default ? 'translate-x-4' : 'translate-x-0.5'}`}
|
className={`pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform mt-0.5 ${form.is_default ? 'translate-x-4' : 'translate-x-0.5'}`}
|
||||||
@@ -660,7 +660,7 @@ export function SitesPage() {
|
|||||||
|
|
||||||
{/* 按钮 */}
|
{/* 按钮 */}
|
||||||
<div className="flex justify-end gap-2 pt-2">
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
<button type="button" onClick={closeModal} className="rounded border border-white/10 px-4 py-2 text-sm text-ink-100 hover:bg-white/5 transition">
|
<button type="button" onClick={closeModal} className="rounded-lg border border-gray-200 px-4 py-2 text-sm text-ink-100 hover:bg-gray-50 transition">
|
||||||
取消
|
取消
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ function Tile({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="glass-panel flex items-center gap-3 !p-4">
|
<div className="glass-panel flex items-center gap-3 !p-4">
|
||||||
<div className="rounded-lg border border-primary-400/40 bg-primary-400/10 p-2 text-brand-500">
|
<div className="rounded-xl border border-primary-400/40 bg-primary-400/10 p-2 text-brand-500">
|
||||||
{icon}
|
{icon}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -129,7 +129,7 @@ function Tile({
|
|||||||
|
|
||||||
function Row({ label, value }: { label: string; value: string }) {
|
function Row({ label, value }: { label: string; value: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between border-b border-white/5 pb-1 text-sm last:border-0">
|
<div className="flex items-center justify-between border-b border-gray-200 pb-1 text-sm last:border-0">
|
||||||
<span className="text-ink-50">{label}</span>
|
<span className="text-ink-50">{label}</span>
|
||||||
<span className="font-mono text-ink-600">{value}</span>
|
<span className="font-mono text-ink-600">{value}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export function StorageConfigPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2 border-b border-white/10">
|
<div className="flex gap-2 border-b border-gray-200">
|
||||||
{(['alist', 'webdav', 's3'] as StorageType[]).map((t) => (
|
{(['alist', 'webdav', 's3'] as StorageType[]).map((t) => (
|
||||||
<button
|
<button
|
||||||
key={t}
|
key={t}
|
||||||
@@ -147,7 +147,7 @@ function StorageForm({ type }: { type: StorageType }) {
|
|||||||
<label key={f.key} className="block">
|
<label key={f.key} className="block">
|
||||||
<span className="mb-1 block text-sm text-ink-100">
|
<span className="mb-1 block text-sm text-ink-100">
|
||||||
{f.label}
|
{f.label}
|
||||||
<span className="ml-2 font-mono text-[10px] text-sand-400">{f.key}</span>
|
<span className="ml-2 font-mono text-[10px] text-gray-500">{f.key}</span>
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
type={f.secret ? 'password' : 'text'}
|
type={f.secret ? 'password' : 'text'}
|
||||||
@@ -172,7 +172,7 @@ function StorageForm({ type }: { type: StorageType }) {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={onTest}
|
onClick={onTest}
|
||||||
disabled={testing}
|
disabled={testing}
|
||||||
className="rounded border border-white/10 px-4 py-2 text-sm text-ink-100 hover:bg-white/5"
|
className="rounded-lg border border-gray-200 px-4 py-2 text-sm text-ink-100 hover:bg-gray-50"
|
||||||
>
|
>
|
||||||
{testing ? <Loader2 size={14} className="inline animate-spin" /> : <Send size={14} className="inline" />}
|
{testing ? <Loader2 size={14} className="inline animate-spin" /> : <Send size={14} className="inline" />}
|
||||||
{' '}测试
|
{' '}测试
|
||||||
|
|||||||
@@ -73,14 +73,14 @@ export function StoragePage() {
|
|||||||
{data.by_library.map((l) => {
|
{data.by_library.map((l) => {
|
||||||
const pct = (l.total_bytes / totalBytes) * 100
|
const pct = (l.total_bytes / totalBytes) * 100
|
||||||
return (
|
return (
|
||||||
<tr key={l.library_id} className="border-t border-white/5">
|
<tr key={l.library_id} className="border-t border-gray-200">
|
||||||
<td className="py-2 text-ink-600">{l.name}</td>
|
<td className="py-2 text-ink-600">{l.name}</td>
|
||||||
<td className="text-ink-100">{l.type}</td>
|
<td className="text-ink-100">{l.type}</td>
|
||||||
<td className="text-ink-100">{l.media_count}</td>
|
<td className="text-ink-100">{l.media_count}</td>
|
||||||
<td className="text-ink-100">{fmtBytes(l.total_bytes)}</td>
|
<td className="text-ink-100">{fmtBytes(l.total_bytes)}</td>
|
||||||
<td>
|
<td>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="h-1 w-24 overflow-hidden rounded bg-white/10">
|
<div className="h-1 w-24 overflow-hidden rounded bg-gray-200">
|
||||||
<div
|
<div
|
||||||
className="h-full bg-primary-400"
|
className="h-full bg-primary-400"
|
||||||
style={{ width: `${pct.toFixed(1)}%` }}
|
style={{ width: `${pct.toFixed(1)}%` }}
|
||||||
@@ -129,7 +129,7 @@ function Tile({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="glass-panel flex items-center gap-3 !p-4">
|
<div className="glass-panel flex items-center gap-3 !p-4">
|
||||||
<div className="rounded-lg border border-primary-400/40 bg-primary-400/10 p-2 text-brand-500">
|
<div className="rounded-xl border border-primary-400/40 bg-primary-400/10 p-2 text-brand-500">
|
||||||
{icon}
|
{icon}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ export function StrmPage() {
|
|||||||
{/* Attach / detach STRM URL on existing media. */}
|
{/* Attach / detach STRM URL on existing media. */}
|
||||||
<section className="glass-panel space-y-4">
|
<section className="glass-panel space-y-4">
|
||||||
<h2 className="font-display text-lg font-semibold text-ink-600">附加 STRM URL 到已有媒体</h2>
|
<h2 className="font-display text-lg font-semibold text-ink-600">附加 STRM URL 到已有媒体</h2>
|
||||||
<form onSubmit={doSearch} className="flex gap-2">
|
<form onSubmit={doSearch} className="flex flex-wrap gap-2">
|
||||||
<input
|
<input
|
||||||
className="input-base flex-1"
|
className="input-base flex-1"
|
||||||
placeholder="搜索媒体标题…"
|
placeholder="搜索媒体标题…"
|
||||||
@@ -192,7 +192,7 @@ export function StrmPage() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={m.id}
|
key={m.id}
|
||||||
className="rounded-xl border border-white/5 bg-white/5 p-4 space-y-3"
|
className="rounded-xl border border-gray-200 bg-gray-50 p-4 space-y-3"
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
@@ -202,7 +202,7 @@ export function StrmPage() {
|
|||||||
{m.container || '本地文件'}
|
{m.container || '本地文件'}
|
||||||
</div>
|
</div>
|
||||||
{isStrm && (
|
{isStrm && (
|
||||||
<div className="mt-1 break-all rounded bg-emerald-400/10 px-2 py-0.5 text-xs text-emerald-300">
|
<div className="mt-1 break-all rounded-lg bg-emerald-400/10 px-2 py-0.5 text-xs text-emerald-300">
|
||||||
已设置 STRM
|
已设置 STRM
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -210,13 +210,13 @@ export function StrmPage() {
|
|||||||
{isStrm && (
|
{isStrm && (
|
||||||
<button
|
<button
|
||||||
onClick={() => onDetach(m)}
|
onClick={() => onDetach(m)}
|
||||||
className="rounded border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10"
|
className="rounded-lg border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10"
|
||||||
>
|
>
|
||||||
<Trash2 size={12} className="inline" /> 清除
|
<Trash2 size={12} className="inline" /> 清除
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<input
|
<input
|
||||||
className="input-base flex-1"
|
className="input-base flex-1"
|
||||||
placeholder="https://example.com/stream.m3u8"
|
placeholder="https://example.com/stream.m3u8"
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export function SubscriptionsPage() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{items.map((s) => (
|
{items.map((s) => (
|
||||||
<tr key={s.id} className="border-t border-white/5">
|
<tr key={s.id} className="border-t border-gray-200">
|
||||||
<td className="py-2 text-ink-600">{s.name}</td>
|
<td className="py-2 text-ink-600">{s.name}</td>
|
||||||
<td className="max-w-md truncate text-ink-100" title={s.feed_url}>
|
<td className="max-w-md truncate text-ink-100" title={s.feed_url}>
|
||||||
{s.feed_url}
|
{s.feed_url}
|
||||||
@@ -99,7 +99,7 @@ export function SubscriptionsPage() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="space-x-2 py-2 text-right">
|
<td className="space-x-2 py-2 text-right">
|
||||||
<button
|
<button
|
||||||
className="rounded border border-primary-400/40 px-2 py-1 text-xs text-brand-500 hover:bg-primary-400/10"
|
className="rounded-lg border border-primary-400/40 px-2 py-1 text-xs text-brand-500 hover:bg-primary-400/10"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const r = await subscriptionsAPI.runNow(s.id)
|
const r = await subscriptionsAPI.runNow(s.id)
|
||||||
toast.success(`已加入 ${r.queued} 项`)
|
toast.success(`已加入 ${r.queued} 项`)
|
||||||
@@ -108,7 +108,7 @@ export function SubscriptionsPage() {
|
|||||||
<Play size={12} />
|
<Play size={12} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="rounded border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10"
|
className="rounded-lg border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
if (!confirm(`删除订阅「${s.name}」?`)) return
|
if (!confirm(`删除订阅「${s.name}」?`)) return
|
||||||
await subscriptionsAPI.remove(s.id)
|
await subscriptionsAPI.remove(s.id)
|
||||||
|
|||||||
@@ -60,17 +60,17 @@ export function TasksPage() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{snap.transcodes.map((t) => (
|
{snap.transcodes.map((t) => (
|
||||||
<tr key={t.media_id} className="border-t border-white/5">
|
<tr key={t.media_id} className="border-t border-gray-200">
|
||||||
<td className="py-2 font-mono text-xs text-ink-600">{t.media_id}</td>
|
<td className="py-2 font-mono text-xs text-ink-600">{t.media_id}</td>
|
||||||
<td className="text-ink-100">{t.encoder || 'libx264'}</td>
|
<td className="text-ink-100">{t.encoder || 'libx264'}</td>
|
||||||
<td className="text-ink-100">{new Date(t.started_at).toLocaleTimeString()}</td>
|
<td className="text-ink-100">{new Date(t.started_at).toLocaleTimeString()}</td>
|
||||||
<td>
|
<td>
|
||||||
{t.playlist_ok ? (
|
{t.playlist_ok ? (
|
||||||
<span className="rounded border border-emerald-400/40 px-1.5 py-0.5 text-xs text-emerald-400">
|
<span className="rounded-lg border border-emerald-400/40 px-1.5 py-0.5 text-xs text-emerald-400">
|
||||||
ready
|
ready
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="rounded border border-yellow-400/40 px-1.5 py-0.5 text-xs text-yellow-400">
|
<span className="rounded-lg border border-yellow-400/40 px-1.5 py-0.5 text-xs text-yellow-400">
|
||||||
starting
|
starting
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -97,14 +97,14 @@ export function TasksPage() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{torrents.map((t) => (
|
{torrents.map((t) => (
|
||||||
<tr key={t.hash} className="border-t border-white/5 align-top">
|
<tr key={t.hash} className="border-t border-gray-200 align-top">
|
||||||
<td className="max-w-md truncate py-2 text-ink-600" title={t.name}>
|
<td className="max-w-md truncate py-2 text-ink-600" title={t.name}>
|
||||||
{t.name}
|
{t.name}
|
||||||
</td>
|
</td>
|
||||||
<td className="text-ink-100">{t.state}</td>
|
<td className="text-ink-100">{t.state}</td>
|
||||||
<td className="text-ink-100">
|
<td className="text-ink-100">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="h-1 w-24 overflow-hidden rounded bg-white/10">
|
<div className="h-1 w-24 overflow-hidden rounded-lg bg-gray-200">
|
||||||
<div
|
<div
|
||||||
className="h-full bg-primary-400"
|
className="h-full bg-primary-400"
|
||||||
style={{ width: `${Math.round(t.progress * 100)}%` }}
|
style={{ width: `${Math.round(t.progress * 100)}%` }}
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ function OrganizePanel() {
|
|||||||
<h2 className="font-display text-lg font-semibold text-ink-600">整理 & 重命名</h2>
|
<h2 className="font-display text-lg font-semibold text-ink-600">整理 & 重命名</h2>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-sand-500">
|
<p className="text-xs text-sand-500">
|
||||||
将媒体文件按 <code className="rounded bg-white/5 px-1">媒体库/年份/标题 (年份)/标题.ext</code>{' '}
|
将媒体文件按 <code className="rounded-lg bg-gray-50 px-1">媒体库/年份/标题 (年份)/标题.ext</code>{' '}
|
||||||
的规范布局移动并重命名,确保已先完成刮削。
|
的规范布局移动并重命名,确保已先完成刮削。
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -160,8 +160,8 @@ function OrganizePanel() {
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="border-t border-white/5 pt-4">
|
<div className="border-t border-gray-200 pt-4">
|
||||||
<form onSubmit={doSearch} className="flex gap-2">
|
<form onSubmit={doSearch} className="flex flex-wrap gap-2">
|
||||||
<input
|
<input
|
||||||
className="input-base flex-1"
|
className="input-base flex-1"
|
||||||
placeholder="或搜索单个媒体进行整理…"
|
placeholder="或搜索单个媒体进行整理…"
|
||||||
@@ -178,7 +178,7 @@ function OrganizePanel() {
|
|||||||
{results.map((m) => (
|
{results.map((m) => (
|
||||||
<li
|
<li
|
||||||
key={m.id}
|
key={m.id}
|
||||||
className="flex items-center justify-between gap-3 rounded-lg border border-white/5 bg-white/5 px-3 py-2"
|
className="flex items-center justify-between gap-3 rounded-xl border border-gray-200 bg-gray-50 px-3 py-2"
|
||||||
>
|
>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="truncate text-sm text-ink-600">{m.title}</div>
|
<div className="truncate text-sm text-ink-600">{m.title}</div>
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export function WatchHistoryPage() {
|
|||||||
key={h.id}
|
key={h.id}
|
||||||
className="glass-panel flex items-center gap-4 !p-3"
|
className="glass-panel flex items-center gap-4 !p-3"
|
||||||
>
|
>
|
||||||
<div className="h-16 w-12 shrink-0 overflow-hidden rounded bg-surface-900">
|
<div className="h-16 w-12 shrink-0 overflow-hidden rounded-lg bg-surface-900">
|
||||||
{m.poster_url ? (
|
{m.poster_url ? (
|
||||||
<img
|
<img
|
||||||
src={imageURL(m.poster_url)}
|
src={imageURL(m.poster_url)}
|
||||||
@@ -70,12 +70,12 @@ export function WatchHistoryPage() {
|
|||||||
<span>{fmtDuration(h.position_ms)} / {fmtDuration(h.duration_ms)}</span>
|
<span>{fmtDuration(h.position_ms)} / {fmtDuration(h.duration_ms)}</span>
|
||||||
<span>{new Date(h.watched_at).toLocaleString()}</span>
|
<span>{new Date(h.watched_at).toLocaleString()}</span>
|
||||||
{h.completed && (
|
{h.completed && (
|
||||||
<span className="rounded border border-emerald-400/40 px-1.5 py-0.5 text-emerald-400">
|
<span className="rounded-lg border border-emerald-400/40 px-1.5 py-0.5 text-emerald-400">
|
||||||
已看完
|
已看完
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="h-1 w-full overflow-hidden rounded bg-white/10">
|
<div className="h-1 w-full overflow-hidden rounded-lg bg-gray-200">
|
||||||
<div
|
<div
|
||||||
className="h-full bg-primary-400"
|
className="h-full bg-primary-400"
|
||||||
style={{ width: `${Math.round(progress * 100)}%` }}
|
style={{ width: `${Math.round(progress * 100)}%` }}
|
||||||
|
|||||||
@@ -261,7 +261,6 @@ export interface NotifyChannel {
|
|||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
type: 'telegram' | 'wechat' | 'bark' | 'webhook' | 'email'
|
type: 'telegram' | 'wechat' | 'bark' | 'webhook' | 'email'
|
||||||
channel_type: 'telegram' | 'wechat' | 'bark' | 'webhook' | 'email'
|
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
events: string
|
events: string
|
||||||
config: Record<string, string>
|
config: Record<string, string>
|
||||||
|
|||||||
Reference in New Issue
Block a user