mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 03:06:38 +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),
|
||||
)
|
||||
}
|
||||
// 自动启动 Telegram 长轮询(无需公网 Webhook)
|
||||
services.TelegramBot.StartPolling(context.Background())
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
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("/version", versionInfo)
|
||||
|
||||
// Telegram Bot webhook — called by Telegram servers, no auth.
|
||||
api.POST("/telegram/webhook", telegramWebhookHandler(svc))
|
||||
|
||||
// Public 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.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.
|
||||
admin.POST("/media/:id/organize", organizeMediaHandler(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
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (r *NotifyChannelRepository) Update(ctx context.Context, c *model.NotifyChannel) error {
|
||||
return r.db.WithContext(ctx).Save(c).Error
|
||||
|
||||
@@ -316,7 +316,7 @@ func validateChannel(in ChannelInput) error {
|
||||
return errors.New("name required")
|
||||
}
|
||||
switch in.Type {
|
||||
case "telegram", "wechat", "bark", "webhook":
|
||||
case "telegram", "wechat", "bark", "webhook", "email":
|
||||
default:
|
||||
return fmt.Errorf("unsupported channel type %q", in.Type)
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ type Container struct {
|
||||
Backup *BackupService
|
||||
Notifier *NotifierService
|
||||
NotifyChannels *NotifyChannelService
|
||||
TelegramBot *TelegramBotService
|
||||
PlayProfiles *PlayProfileService
|
||||
Permissions *PermissionService
|
||||
StorageCfg *StorageConfigService
|
||||
@@ -105,6 +106,7 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont
|
||||
backup := NewBackupService(cfg, log, repos.DB)
|
||||
notifier := NewNotifierService(log, repos)
|
||||
notifyChannels := NewNotifyChannelService(log, repos)
|
||||
telegramBot := NewTelegramBotService(log, repos, crypto)
|
||||
playProfiles := NewPlayProfileService(log, repos)
|
||||
permissions := NewPermissionService(log, repos)
|
||||
storageCfg := NewStorageConfigService(log, repos, crypto)
|
||||
@@ -168,6 +170,7 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont
|
||||
Backup: backup,
|
||||
Notifier: notifier,
|
||||
NotifyChannels: notifyChannels,
|
||||
TelegramBot: telegramBot,
|
||||
PlayProfiles: playProfiles,
|
||||
Permissions: permissions,
|
||||
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.
|
||||
export interface NotifyChannelInput {
|
||||
name: string
|
||||
channel_type: NotifyChannel['channel_type']
|
||||
type: NotifyChannel['type']
|
||||
config: Record<string, string>
|
||||
events?: string[]
|
||||
enabled?: boolean
|
||||
|
||||
@@ -40,7 +40,7 @@ export function APIConfigsPanel() {
|
||||
{!loading && (
|
||||
<div className="glass-panel overflow-hidden">
|
||||
<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>
|
||||
<th className="px-4 py-3">服务</th>
|
||||
<th className="px-4 py-3">密钥</th>
|
||||
@@ -63,7 +63,7 @@ export function APIConfigsPanel() {
|
||||
) : (
|
||||
<tr
|
||||
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">
|
||||
<p className="font-medium text-ink-600">{item.provider}</p>
|
||||
@@ -75,7 +75,7 @@ export function APIConfigsPanel() {
|
||||
{item.has_key ? (
|
||||
<span className="text-brand-500">{item.masked_key}</span>
|
||||
) : (
|
||||
<span className="text-sand-400">未配置</span>
|
||||
<span className="text-gray-500">未配置</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
@@ -93,7 +93,7 @@ export function APIConfigsPanel() {
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button
|
||||
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="编辑"
|
||||
>
|
||||
<Save size={14} />
|
||||
@@ -104,7 +104,7 @@ export function APIConfigsPanel() {
|
||||
`已配置 ${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="查看状态"
|
||||
>
|
||||
<Eye size={14} />
|
||||
@@ -117,7 +117,7 @@ export function APIConfigsPanel() {
|
||||
toast.success('已清除')
|
||||
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="清除密钥"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
@@ -170,7 +170,7 @@ function EditingRow({
|
||||
}
|
||||
|
||||
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">
|
||||
<form onSubmit={submit} className="flex flex-wrap items-end gap-3">
|
||||
<span className="text-sm font-medium text-ink-600">{item.provider}</span>
|
||||
@@ -207,7 +207,7 @@ function EditingRow({
|
||||
<button
|
||||
type="button"
|
||||
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} />
|
||||
</button>
|
||||
|
||||
@@ -16,7 +16,7 @@ export function AppFooter({ className = '' }: { className?: string }) {
|
||||
href={link.href}
|
||||
target="_blank"
|
||||
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}
|
||||
>
|
||||
<link.icon size={14} />
|
||||
|
||||
@@ -78,7 +78,7 @@ export function Layout() {
|
||||
{/* Toggle Collapse Button for Large Screen */}
|
||||
<button
|
||||
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} />
|
||||
</button>
|
||||
@@ -86,7 +86,7 @@ export function Layout() {
|
||||
{/* Mobile Drawer Close Button */}
|
||||
<button
|
||||
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} />
|
||||
</button>
|
||||
@@ -114,7 +114,7 @@ export function Layout() {
|
||||
<SectionHeader label="媒体片库" visible={isSidebarOpen || isMobileDrawerOpen} />
|
||||
<div className="space-y-1">
|
||||
{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) => (
|
||||
<SidebarLink
|
||||
@@ -180,7 +180,7 @@ export function Layout() {
|
||||
onClick={() => { logout(); navigate('/login') }}
|
||||
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",
|
||||
(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})`}
|
||||
>
|
||||
@@ -248,7 +248,7 @@ export function Layout() {
|
||||
<form onSubmit={handleSearchSubmit} className="relative w-full hidden sm:block">
|
||||
<span className={clsx(
|
||||
"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} />
|
||||
</span>
|
||||
@@ -259,10 +259,10 @@ export function Layout() {
|
||||
onFocus={() => setSearchFocused(true)}
|
||||
onBlur={() => setSearchFocused(false)}
|
||||
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">
|
||||
<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
|
||||
</span>
|
||||
</div>
|
||||
@@ -274,7 +274,7 @@ export function Layout() {
|
||||
{/* Quick search button shown ONLY on Mobile screens */}
|
||||
<Link
|
||||
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} />
|
||||
</Link>
|
||||
@@ -289,7 +289,7 @@ export function Layout() {
|
||||
</Link>
|
||||
|
||||
{/* 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} />
|
||||
<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>
|
||||
@@ -310,7 +310,7 @@ export function Layout() {
|
||||
<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>
|
||||
</div>
|
||||
<ChevronDown size={14} className="text-gray-400" />
|
||||
<ChevronDown size={14} className="text-gray-500" />
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
@@ -389,7 +389,7 @@ export function Layout() {
|
||||
</main>
|
||||
|
||||
{/* 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>
|
||||
)
|
||||
@@ -399,7 +399,7 @@ function SectionHeader({ label, visible }: { label: string; visible: boolean })
|
||||
if (!visible) return <div className="h-5" />;
|
||||
return (
|
||||
<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}
|
||||
</span>
|
||||
</div>
|
||||
@@ -432,7 +432,7 @@ function SidebarLink({ to, icon, label, end, collapsed }: SidebarLinkProps) {
|
||||
<>
|
||||
<span className={clsx(
|
||||
"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}
|
||||
</span>
|
||||
@@ -446,7 +446,7 @@ function SidebarLink({ to, icon, label, end, collapsed }: SidebarLinkProps) {
|
||||
</motion.span>
|
||||
)}
|
||||
{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}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -32,7 +32,7 @@ export const MediaCard = ({
|
||||
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]" />
|
||||
<span className="text-[10px] uppercase tracking-wider font-bold">No Poster</span>
|
||||
</div>
|
||||
@@ -40,7 +40,7 @@ export const MediaCard = ({
|
||||
|
||||
{/* Episode count badge */}
|
||||
{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]" />
|
||||
<span>{count} 集</span>
|
||||
</span>
|
||||
@@ -48,7 +48,7 @@ export const MediaCard = ({
|
||||
|
||||
{/* Rating Badge */}
|
||||
{(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" />
|
||||
<span>{(rating || (media as any).rating).toFixed(1)}</span>
|
||||
</span>
|
||||
@@ -66,7 +66,7 @@ export const MediaCard = ({
|
||||
<Play size={10} fill="currentColor" className="text-white" />
|
||||
<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 || "暂无简介内容"}
|
||||
</p>
|
||||
</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">
|
||||
<span>{media.year > 0 ? media.year : "未知年份"}</span>
|
||||
{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}
|
||||
</span>
|
||||
)}
|
||||
|
||||
+6
-6
@@ -49,7 +49,7 @@ body {
|
||||
@layer components {
|
||||
/* ── Unified Swiss-Editorial Cards ── */
|
||||
.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 {
|
||||
@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 ── */
|
||||
.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 {
|
||||
@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 {
|
||||
@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 ── */
|
||||
@@ -112,8 +112,8 @@ body {
|
||||
/* ── Data Tables Redesign ── */
|
||||
.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 th { @apply px-5 py-4 text-xs font-bold uppercase tracking-wider text-gray-500; }
|
||||
.data-table td { @apply px-5 py-4 text-gray-700 border-b border-gray-100; }
|
||||
.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 max-w-xs truncate; }
|
||||
.data-table tbody tr { @apply transition-colors hover:bg-gray-50/50; }
|
||||
|
||||
/* ── Dividers ── */
|
||||
|
||||
@@ -113,7 +113,7 @@ export function AIAssistantPage() {
|
||||
{/* Smart search */}
|
||||
<section className="glass-panel space-y-4">
|
||||
<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
|
||||
className="input-base flex-1"
|
||||
placeholder="试试: 2023 年的高分动作片"
|
||||
@@ -137,7 +137,7 @@ export function AIAssistantPage() {
|
||||
onClick={() => {
|
||||
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}
|
||||
</button>
|
||||
@@ -145,7 +145,7 @@ export function AIAssistantPage() {
|
||||
</div>
|
||||
|
||||
{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="flex flex-wrap gap-x-6 gap-y-1">
|
||||
<span>
|
||||
@@ -212,7 +212,7 @@ export function AIAssistantPage() {
|
||||
<li key={i}>
|
||||
<Link
|
||||
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>
|
||||
<Search size={14} className="shrink-0 opacity-60" />
|
||||
|
||||
@@ -126,7 +126,7 @@ function ProviderCard({ item, onUpdated }: { item: APIConfig; onUpdated: () => v
|
||||
/>
|
||||
启用
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button type="submit" disabled={saving} className="neon-button !text-xs">
|
||||
<Save size={12} /> 保存
|
||||
</button>
|
||||
|
||||
@@ -17,7 +17,7 @@ export function AdminPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<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) => (
|
||||
<button
|
||||
key={k.key}
|
||||
@@ -108,13 +108,13 @@ function LibraryPanel() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{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="text-ink-100">{l.path}</td>
|
||||
<td className="text-ink-100">{l.type}</td>
|
||||
<td className="space-x-2 py-2 text-right">
|
||||
<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 () => {
|
||||
const r = await libraryAPI.scan(l.id)
|
||||
toast.success(`扫描完成,新增 ${r.added}`)
|
||||
@@ -123,7 +123,7 @@ function LibraryPanel() {
|
||||
扫描
|
||||
</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 () => {
|
||||
if (!confirm(`确定删除「${l.name}」?`)) return
|
||||
await libraryAPI.remove(l.id)
|
||||
@@ -163,7 +163,7 @@ function UsersPanel() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{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="text-ink-100">{u.role}</td>
|
||||
<td className="text-ink-50">
|
||||
@@ -171,7 +171,7 @@ function UsersPanel() {
|
||||
</td>
|
||||
<td className="py-2 text-right">
|
||||
<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 () => {
|
||||
if (!confirm(`确定删除「${u.username}」?`)) return
|
||||
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 ' +
|
||||
(active?.session.id === s.id
|
||||
? '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)}
|
||||
>
|
||||
@@ -166,7 +166,7 @@ export function AssistantChatPage() {
|
||||
)}
|
||||
{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">
|
||||
{active.session.title || '未命名'}
|
||||
</h2>
|
||||
@@ -180,7 +180,7 @@ export function AssistantChatPage() {
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
</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
|
||||
className="input-base flex-1"
|
||||
placeholder="发送消息…"
|
||||
@@ -211,7 +211,7 @@ function Bubble({ message }: { message: AssistantMessage }) {
|
||||
? 'bg-primary-400/20 text-primary-100'
|
||||
: message.role === 'system'
|
||||
? '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}
|
||||
|
||||
@@ -158,7 +158,7 @@ function ContentRow({ title, items }: { title: string; items: DiscoverItem[] })
|
||||
|
||||
function DiscoverCard({ item }: { item: DiscoverItem }) {
|
||||
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">
|
||||
{item.poster_url ? (
|
||||
<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"
|
||||
/>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
{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)}
|
||||
</div>
|
||||
)}
|
||||
@@ -196,10 +196,10 @@ function DiscoverSkeleton() {
|
||||
<div className="space-y-8">
|
||||
{[1, 2].map((section) => (
|
||||
<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">
|
||||
{[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>
|
||||
</section>
|
||||
|
||||
@@ -103,16 +103,16 @@ export function DownloadClientsPage() {
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<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}
|
||||
</span>
|
||||
{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>
|
||||
)}
|
||||
{!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>
|
||||
)}
|
||||
@@ -125,7 +125,7 @@ export function DownloadClientsPage() {
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<button
|
||||
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" /> 测试
|
||||
</button>
|
||||
@@ -134,13 +134,13 @@ export function DownloadClientsPage() {
|
||||
setEditing(c)
|
||||
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" /> 编辑
|
||||
</button>
|
||||
<button
|
||||
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" /> 删除
|
||||
</button>
|
||||
@@ -301,7 +301,7 @@ function ClientFormModal({
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
|
||||
@@ -107,14 +107,14 @@ export function DownloadsPage() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{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}>
|
||||
{t.name}
|
||||
</td>
|
||||
<td className="text-ink-100">{t.state}</td>
|
||||
<td className="text-ink-100">
|
||||
<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
|
||||
className="h-full bg-primary-400"
|
||||
style={{ width: `${Math.round(t.progress * 100)}%` }}
|
||||
@@ -130,7 +130,7 @@ export function DownloadsPage() {
|
||||
{role === 'admin' && (
|
||||
<td className="py-2 text-right">
|
||||
<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 () => {
|
||||
if (!confirm(`删除「${t.name}」?`)) return
|
||||
await downloadsAPI.remove(t.hash, false)
|
||||
@@ -163,7 +163,7 @@ export function DownloadsPage() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{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="max-w-md truncate text-ink-100" title={t.url}>
|
||||
{t.url}
|
||||
|
||||
@@ -93,7 +93,7 @@ export function DuplicatesPage() {
|
||||
<section key={g.hash} className="glass-panel space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<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>
|
||||
</div>
|
||||
@@ -101,7 +101,7 @@ export function DuplicatesPage() {
|
||||
<p className="font-mono text-xs text-ink-50">
|
||||
{g.primary.path} · {fmtBytes(g.primary.size_bytes)}
|
||||
</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">
|
||||
重复 ({g.duplicates.length})
|
||||
</p>
|
||||
|
||||
@@ -72,7 +72,7 @@ export function FileManagerPage() {
|
||||
</button>
|
||||
)}
|
||||
{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}
|
||||
</span>
|
||||
)}
|
||||
@@ -113,7 +113,7 @@ export function FileManagerPage() {
|
||||
{data.entries.map((e) => (
|
||||
<tr
|
||||
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)}
|
||||
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" />
|
||||
<Film className="absolute h-4 w-4 text-brand-500" />
|
||||
</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>
|
||||
</div>
|
||||
)
|
||||
@@ -92,7 +92,7 @@ export function HomePage() {
|
||||
return (
|
||||
<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">
|
||||
<Film className="h-10 w-10 text-gray-400" />
|
||||
<Film className="h-10 w-10 text-gray-500" />
|
||||
</div>
|
||||
<p className="text-xl font-bold text-gray-900">您的家庭影视站暂无内容</p>
|
||||
<p className="mt-2 text-sm text-gray-500 leading-relaxed">
|
||||
@@ -142,7 +142,7 @@ export function HomePage() {
|
||||
{featuredItem.overview}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-gray-400 text-sm italic">
|
||||
<p className="text-gray-500 text-sm italic">
|
||||
家庭私人媒体中心收藏。极高视听品质,支持多端原生无损解码及HLS转码播放。
|
||||
</p>
|
||||
)}
|
||||
@@ -150,15 +150,15 @@ export function HomePage() {
|
||||
{/* Metadata Badges */}
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs text-gray-500 font-bold">
|
||||
{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 && (
|
||||
<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}
|
||||
</span>
|
||||
)}
|
||||
{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}
|
||||
</span>
|
||||
)}
|
||||
@@ -183,11 +183,11 @@ export function HomePage() {
|
||||
{history.length > 0 && (
|
||||
<section className="space-y-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} />
|
||||
</span>
|
||||
<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 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">
|
||||
<div className="flex items-center justify-between border-b border-gray-200/80 pb-3">
|
||||
<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} />}
|
||||
</span>
|
||||
<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">
|
||||
<div className="flex items-center justify-between border-b border-gray-200/80 pb-3">
|
||||
<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} />
|
||||
</span>
|
||||
<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"
|
||||
/>
|
||||
) : (
|
||||
<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} />
|
||||
</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"
|
||||
/>
|
||||
</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)}%
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -126,7 +126,7 @@ export function LibraryPage() {
|
||||
{library && <p className="text-sm text-ink-50">{library.type} · {library.path}</p>}
|
||||
</div>
|
||||
{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={handleScrape} disabled={scraping} className="btn-outline">{scraping ? '刮削中…' : '刮削元数据'}</button>
|
||||
</div>
|
||||
@@ -144,7 +144,7 @@ export function LibraryPage() {
|
||||
|
||||
{!isSeries && items.length === 0 && (
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
@@ -190,7 +190,7 @@ export function LibraryPage() {
|
||||
{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" />
|
||||
) : (
|
||||
<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 className="flex-1 space-y-3">
|
||||
@@ -226,7 +226,7 @@ export function LibraryPage() {
|
||||
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"
|
||||
>
|
||||
<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 || '—'}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -239,7 +239,7 @@ export function LibraryPage() {
|
||||
: formatSize(ep.size_bytes)}
|
||||
</p>
|
||||
</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>
|
||||
))}
|
||||
</div>
|
||||
@@ -253,7 +253,7 @@ export function LibraryPage() {
|
||||
{/* 剧集为空 */}
|
||||
{isSeries && seriesCards.length === 0 && !loading && (
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -147,10 +147,10 @@ export function LicensePage() {
|
||||
)}
|
||||
<h2 className="font-display text-lg font-semibold text-ink-600">当前状态</h2>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
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="刷新状态"
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
@@ -158,7 +158,7 @@ export function LicensePage() {
|
||||
{active && (
|
||||
<button
|
||||
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>
|
||||
@@ -167,7 +167,7 @@ export function LicensePage() {
|
||||
</div>
|
||||
|
||||
{!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>
|
||||
@@ -202,7 +202,7 @@ export function LicensePage() {
|
||||
)}
|
||||
|
||||
{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>
|
||||
)}
|
||||
@@ -211,11 +211,11 @@ export function LicensePage() {
|
||||
|
||||
{/* ── Pro tip ── */}
|
||||
{!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">
|
||||
需要获取许可证?请联系管理员获取 MediaStationGo 授权密钥。
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-sand-400">
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
授权服务器地址可在系统设置中配置
|
||||
</p>
|
||||
</div>
|
||||
@@ -238,7 +238,7 @@ function StatusBadge({
|
||||
className?: string
|
||||
}) {
|
||||
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={`text-sm text-ink-600 ${mono ? 'font-mono' : ''} ${className ?? ''}`}>
|
||||
{value}
|
||||
|
||||
@@ -90,12 +90,12 @@ export function LoginPage() {
|
||||
用户名
|
||||
</label>
|
||||
<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} />
|
||||
</span>
|
||||
<input
|
||||
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}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoComplete="username"
|
||||
@@ -116,12 +116,12 @@ export function LoginPage() {
|
||||
安全密码
|
||||
</label>
|
||||
<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} />
|
||||
</span>
|
||||
<input
|
||||
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}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
@@ -131,7 +131,7 @@ export function LoginPage() {
|
||||
<button
|
||||
type="button"
|
||||
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}
|
||||
>
|
||||
{showPw ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
@@ -176,7 +176,7 @@ export function LoginPage() {
|
||||
>
|
||||
<ShieldCheck size={14} className="text-brand-500" />
|
||||
<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
|
||||
</code>
|
||||
</motion.div>
|
||||
@@ -189,7 +189,7 @@ export function LoginPage() {
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
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 />
|
||||
</motion.div>
|
||||
|
||||
@@ -161,7 +161,7 @@ export function MediaDetailPage() {
|
||||
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]" />
|
||||
<span className="text-xs uppercase tracking-wider font-bold">无海报</span>
|
||||
</div>
|
||||
@@ -188,24 +188,24 @@ export function MediaDetailPage() {
|
||||
</h1>
|
||||
<div className="flex flex-wrap items-center gap-2.5 text-xs text-gray-500 font-bold tracking-wide uppercase">
|
||||
{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" />
|
||||
<span>{media.year} 年</span>
|
||||
</span>
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
<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)}
|
||||
</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)}
|
||||
</span>
|
||||
{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}
|
||||
</span>
|
||||
)}
|
||||
@@ -227,7 +227,7 @@ export function MediaDetailPage() {
|
||||
{/* Genres */}
|
||||
{parseCSV(media.genres).length > 0 && (
|
||||
<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">
|
||||
{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">
|
||||
@@ -241,10 +241,10 @@ export function MediaDetailPage() {
|
||||
{/* Languages */}
|
||||
{parseCSV(media.languages).length > 0 && (
|
||||
<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">
|
||||
{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}
|
||||
</span>
|
||||
))}
|
||||
@@ -255,10 +255,10 @@ export function MediaDetailPage() {
|
||||
{/* Countries */}
|
||||
{parseCSV(media.countries).length > 0 && (
|
||||
<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">
|
||||
{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}
|
||||
</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',
|
||||
wechat: '企业微信',
|
||||
bark: 'Bark',
|
||||
@@ -145,11 +145,11 @@ function ChannelCard({
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<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">
|
||||
{TYPE_LABELS[channel.channel_type] ?? channel.channel_type}
|
||||
<span className="rounded-lg border border-gray-200 bg-gray-50 px-2 py-0.5 text-xs text-ink-50">
|
||||
{TYPE_LABELS[channel.type] ?? channel.type}
|
||||
</span>
|
||||
{!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 className="mt-1 truncate text-xs text-ink-50">{summary}</div>
|
||||
@@ -157,19 +157,19 @@ function ChannelCard({
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<button
|
||||
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" /> 测试
|
||||
</button>
|
||||
<button
|
||||
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" /> 编辑
|
||||
</button>
|
||||
<button
|
||||
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" /> 删除
|
||||
</button>
|
||||
@@ -180,7 +180,7 @@ function ChannelCard({
|
||||
|
||||
function channelSummary(ch: NotifyChannel): string {
|
||||
const cfg = ch.config ?? {}
|
||||
switch (ch.channel_type) {
|
||||
switch (ch.type) {
|
||||
case 'telegram':
|
||||
return `Bot ${String(cfg.bot_token ?? '').slice(0, 10)}… → chat ${cfg.chat_id ?? '-'}`
|
||||
case 'wechat':
|
||||
@@ -198,7 +198,7 @@ function channelSummary(ch: NotifyChannel): string {
|
||||
|
||||
// ─── 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: '' },
|
||||
wechat: { sendkey: '' },
|
||||
bark: { device_key: '', server: '' },
|
||||
@@ -216,8 +216,8 @@ function ChannelFormModal({
|
||||
onSaved: () => void | Promise<void>
|
||||
}) {
|
||||
const [name, setName] = useState(editing?.name ?? '')
|
||||
const [type, setType] = useState<NotifyChannel['channel_type']>(
|
||||
editing?.channel_type ?? 'telegram',
|
||||
const [type, setType] = useState<NotifyChannel['type']>(
|
||||
editing?.type ?? 'telegram',
|
||||
)
|
||||
const [config, setConfig] = useState<Record<string, string>>(
|
||||
editing?.config ?? EMPTY_CONFIG.telegram,
|
||||
@@ -225,7 +225,7 @@ function ChannelFormModal({
|
||||
const [enabled, setEnabled] = useState(editing?.enabled ?? true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const onTypeChange = (t: NotifyChannel['channel_type']) => {
|
||||
const onTypeChange = (t: NotifyChannel['type']) => {
|
||||
setType(t)
|
||||
setConfig({ ...EMPTY_CONFIG[t] })
|
||||
}
|
||||
@@ -236,7 +236,7 @@ function ChannelFormModal({
|
||||
try {
|
||||
const input: NotifyChannelInput = {
|
||||
name: name.trim(),
|
||||
channel_type: type,
|
||||
type: type,
|
||||
config,
|
||||
enabled,
|
||||
}
|
||||
@@ -280,7 +280,7 @@ function ChannelFormModal({
|
||||
<select
|
||||
className="input-base"
|
||||
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="wechat">企业微信 / Server酱</option>
|
||||
@@ -474,7 +474,7 @@ function ChannelFormModal({
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
|
||||
@@ -67,7 +67,7 @@ export function PlaylistsPage() {
|
||||
>
|
||||
{p.name}
|
||||
{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
|
||||
</span>
|
||||
)}
|
||||
@@ -79,7 +79,7 @@ export function PlaylistsPage() {
|
||||
toast.success('已删除')
|
||||
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} />
|
||||
</button>
|
||||
|
||||
@@ -44,7 +44,7 @@ export function PosterWallPage() {
|
||||
<div key={s.rep.id} className="relative">
|
||||
<MediaCard media={s.rep} />
|
||||
{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} />
|
||||
{s.count}
|
||||
</span>
|
||||
|
||||
@@ -160,17 +160,17 @@ function ProfileCard({
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-semibold text-ink-600">{profile.name}</span>
|
||||
{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>
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
{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
|
||||
</span>
|
||||
)}
|
||||
@@ -189,13 +189,13 @@ function ProfileCard({
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<button
|
||||
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" /> 编辑
|
||||
</button>
|
||||
<button
|
||||
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" /> 删除
|
||||
</button>
|
||||
@@ -389,7 +389,7 @@ function ProfileFormModal({
|
||||
'rounded-full border px-3 py-1 text-xs ' +
|
||||
(form.allowed_library_ids.includes(l.id)
|
||||
? '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}
|
||||
@@ -405,7 +405,7 @@ function ProfileFormModal({
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
@@ -441,7 +441,7 @@ function Toggle({
|
||||
onChange: (v: boolean) => void
|
||||
}) {
|
||||
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 className="text-sm text-ink-600">{label}</div>
|
||||
{hint && <div className="text-xs text-ink-50">{hint}</div>}
|
||||
|
||||
@@ -41,14 +41,14 @@ export function RecycleBinPage() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{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="max-w-md truncate text-ink-50" title={m.path}>
|
||||
{m.path}
|
||||
</td>
|
||||
<td className="space-x-2 py-2 text-right">
|
||||
<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 () => {
|
||||
await recycleAPI.restore(m.id)
|
||||
toast.success('已恢复')
|
||||
@@ -58,7 +58,7 @@ export function RecycleBinPage() {
|
||||
<RotateCcw size={12} />
|
||||
</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 () => {
|
||||
if (!confirm(`彻底删除「${m.title}」? (磁盘文件保留)`)) return
|
||||
await recycleAPI.purge(m.id)
|
||||
|
||||
@@ -56,7 +56,7 @@ export function SchedulerPage() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{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="text-ink-100">{j.interval}</td>
|
||||
<td className="text-ink-50">
|
||||
@@ -69,7 +69,7 @@ export function SchedulerPage() {
|
||||
<button
|
||||
onClick={() => runNow(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" /> 立即运行
|
||||
</button>
|
||||
|
||||
@@ -99,7 +99,7 @@ export function SearchPage() {
|
||||
</header>
|
||||
|
||||
{aiOn ? (
|
||||
<form onSubmit={onAISubmit} className="flex gap-2">
|
||||
<form onSubmit={onAISubmit} className="flex flex-wrap gap-2">
|
||||
<input
|
||||
autoFocus
|
||||
className="input-base"
|
||||
|
||||
@@ -250,7 +250,7 @@ export function SettingsPage() {
|
||||
</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) => (
|
||||
<button
|
||||
key={g.key}
|
||||
@@ -318,7 +318,7 @@ export function SettingsPage() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{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="text-ink-50">
|
||||
{lib.type === 'movie' ? '电影' : lib.type === 'tv' ? '电视剧' : lib.type === 'anime' ? '动漫' : '音乐'}
|
||||
@@ -355,7 +355,7 @@ function SettingRow({
|
||||
<label className="text-sm text-ink-100">
|
||||
<div className="font-medium">{def.label}</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>
|
||||
<div>
|
||||
{def.type === 'text' && (
|
||||
|
||||
@@ -75,7 +75,7 @@ export function SiteSearchPage() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<form onSubmit={onSubmit} className="flex gap-2">
|
||||
<form onSubmit={onSubmit} className="flex flex-wrap gap-2">
|
||||
<input
|
||||
autoFocus
|
||||
className="input-base flex-1"
|
||||
@@ -114,7 +114,7 @@ export function SiteSearchPage() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{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="max-w-md py-2">
|
||||
<a
|
||||
@@ -136,7 +136,7 @@ export function SiteSearchPage() {
|
||||
<td className="text-red-400">{item.leechers || '—'}</td>
|
||||
<td>
|
||||
{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
|
||||
</span>
|
||||
)}
|
||||
@@ -144,7 +144,7 @@ export function SiteSearchPage() {
|
||||
<td className="py-2 text-right">
|
||||
<button
|
||||
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="加入下载"
|
||||
>
|
||||
<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-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] || '?'}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
@@ -276,23 +276,23 @@ export function SitesPage() {
|
||||
|
||||
{/* 标签 */}
|
||||
<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}
|
||||
</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}
|
||||
</span>
|
||||
{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 && (
|
||||
<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 && (
|
||||
<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 && (
|
||||
<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>
|
||||
|
||||
@@ -319,7 +319,7 @@ export function SitesPage() {
|
||||
<button
|
||||
onClick={() => handleTest(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 ? (
|
||||
<>
|
||||
@@ -335,14 +335,14 @@ export function SitesPage() {
|
||||
</button>
|
||||
<button
|
||||
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="编辑"
|
||||
>
|
||||
<Edit3 size={14} />
|
||||
</button>
|
||||
<button
|
||||
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="删除"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
@@ -354,7 +354,7 @@ export function SitesPage() {
|
||||
{/* 空状态 */}
|
||||
{!loading && sites.length === 0 && (
|
||||
<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 className="text-sm mt-1 text-sand-500">点击「添加站点」添加 PT/BT 站点</p>
|
||||
</div>
|
||||
@@ -435,7 +435,7 @@ export function SitesPage() {
|
||||
<button
|
||||
type="button"
|
||||
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
|
||||
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' && (
|
||||
<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-xs text-ink-50 space-y-1">
|
||||
<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}
|
||||
type="button"
|
||||
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
|
||||
? '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}
|
||||
@@ -555,7 +555,7 @@ export function SitesPage() {
|
||||
{advancedOpen ? '▾' : '▸'} 高级选项
|
||||
</button>
|
||||
{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>
|
||||
@@ -646,7 +646,7 @@ export function SitesPage() {
|
||||
<button
|
||||
type="button"
|
||||
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
|
||||
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">
|
||||
<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
|
||||
|
||||
@@ -116,7 +116,7 @@ function Tile({
|
||||
}) {
|
||||
return (
|
||||
<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}
|
||||
</div>
|
||||
<div>
|
||||
@@ -129,7 +129,7 @@ function Tile({
|
||||
|
||||
function Row({ label, value }: { label: string; value: string }) {
|
||||
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="font-mono text-ink-600">{value}</span>
|
||||
</div>
|
||||
|
||||
@@ -23,7 +23,7 @@ export function StorageConfigPage() {
|
||||
</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) => (
|
||||
<button
|
||||
key={t}
|
||||
@@ -147,7 +147,7 @@ function StorageForm({ type }: { type: StorageType }) {
|
||||
<label key={f.key} className="block">
|
||||
<span className="mb-1 block text-sm text-ink-100">
|
||||
{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>
|
||||
<input
|
||||
type={f.secret ? 'password' : 'text'}
|
||||
@@ -172,7 +172,7 @@ function StorageForm({ type }: { type: StorageType }) {
|
||||
type="button"
|
||||
onClick={onTest}
|
||||
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" />}
|
||||
{' '}测试
|
||||
|
||||
@@ -73,14 +73,14 @@ export function StoragePage() {
|
||||
{data.by_library.map((l) => {
|
||||
const pct = (l.total_bytes / totalBytes) * 100
|
||||
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="text-ink-100">{l.type}</td>
|
||||
<td className="text-ink-100">{l.media_count}</td>
|
||||
<td className="text-ink-100">{fmtBytes(l.total_bytes)}</td>
|
||||
<td>
|
||||
<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
|
||||
className="h-full bg-primary-400"
|
||||
style={{ width: `${pct.toFixed(1)}%` }}
|
||||
@@ -129,7 +129,7 @@ function Tile({
|
||||
}) {
|
||||
return (
|
||||
<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}
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -173,7 +173,7 @@ export function StrmPage() {
|
||||
{/* Attach / detach STRM URL on existing media. */}
|
||||
<section className="glass-panel space-y-4">
|
||||
<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
|
||||
className="input-base flex-1"
|
||||
placeholder="搜索媒体标题…"
|
||||
@@ -192,7 +192,7 @@ export function StrmPage() {
|
||||
return (
|
||||
<div
|
||||
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="min-w-0">
|
||||
@@ -202,7 +202,7 @@ export function StrmPage() {
|
||||
{m.container || '本地文件'}
|
||||
</div>
|
||||
{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
|
||||
</div>
|
||||
)}
|
||||
@@ -210,13 +210,13 @@ export function StrmPage() {
|
||||
{isStrm && (
|
||||
<button
|
||||
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" /> 清除
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<input
|
||||
className="input-base flex-1"
|
||||
placeholder="https://example.com/stream.m3u8"
|
||||
|
||||
@@ -88,7 +88,7 @@ export function SubscriptionsPage() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{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="max-w-md truncate text-ink-100" title={s.feed_url}>
|
||||
{s.feed_url}
|
||||
@@ -99,7 +99,7 @@ export function SubscriptionsPage() {
|
||||
</td>
|
||||
<td className="space-x-2 py-2 text-right">
|
||||
<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 () => {
|
||||
const r = await subscriptionsAPI.runNow(s.id)
|
||||
toast.success(`已加入 ${r.queued} 项`)
|
||||
@@ -108,7 +108,7 @@ export function SubscriptionsPage() {
|
||||
<Play size={12} />
|
||||
</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 () => {
|
||||
if (!confirm(`删除订阅「${s.name}」?`)) return
|
||||
await subscriptionsAPI.remove(s.id)
|
||||
|
||||
@@ -60,17 +60,17 @@ export function TasksPage() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{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="text-ink-100">{t.encoder || 'libx264'}</td>
|
||||
<td className="text-ink-100">{new Date(t.started_at).toLocaleTimeString()}</td>
|
||||
<td>
|
||||
{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
|
||||
</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
|
||||
</span>
|
||||
)}
|
||||
@@ -97,14 +97,14 @@ export function TasksPage() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{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}>
|
||||
{t.name}
|
||||
</td>
|
||||
<td className="text-ink-100">{t.state}</td>
|
||||
<td className="text-ink-100">
|
||||
<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
|
||||
className="h-full bg-primary-400"
|
||||
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>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
@@ -160,8 +160,8 @@ function OrganizePanel() {
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="border-t border-white/5 pt-4">
|
||||
<form onSubmit={doSearch} className="flex gap-2">
|
||||
<div className="border-t border-gray-200 pt-4">
|
||||
<form onSubmit={doSearch} className="flex flex-wrap gap-2">
|
||||
<input
|
||||
className="input-base flex-1"
|
||||
placeholder="或搜索单个媒体进行整理…"
|
||||
@@ -178,7 +178,7 @@ function OrganizePanel() {
|
||||
{results.map((m) => (
|
||||
<li
|
||||
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="truncate text-sm text-ink-600">{m.title}</div>
|
||||
|
||||
@@ -49,7 +49,7 @@ export function WatchHistoryPage() {
|
||||
key={h.id}
|
||||
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 ? (
|
||||
<img
|
||||
src={imageURL(m.poster_url)}
|
||||
@@ -70,12 +70,12 @@ export function WatchHistoryPage() {
|
||||
<span>{fmtDuration(h.position_ms)} / {fmtDuration(h.duration_ms)}</span>
|
||||
<span>{new Date(h.watched_at).toLocaleString()}</span>
|
||||
{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>
|
||||
)}
|
||||
</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
|
||||
className="h-full bg-primary-400"
|
||||
style={{ width: `${Math.round(progress * 100)}%` }}
|
||||
|
||||
@@ -261,7 +261,6 @@ export interface NotifyChannel {
|
||||
id: string
|
||||
name: string
|
||||
type: 'telegram' | 'wechat' | 'bark' | 'webhook' | 'email'
|
||||
channel_type: 'telegram' | 'wechat' | 'bark' | 'webhook' | 'email'
|
||||
enabled: boolean
|
||||
events: string
|
||||
config: Record<string, string>
|
||||
|
||||
Reference in New Issue
Block a user