From 5baf9515ea464df921267cd3295235541beaac12 Mon Sep 17 00:00:00 2001 From: ShukeBta Date: Thu, 28 May 2026 08:35:59 +0800 Subject: [PATCH] =?UTF-8?q?TG=20Bot=20=E5=91=BD=E4=BB=A4=E4=BA=A4=E4=BA=92?= =?UTF-8?q?=20+=20UI=20=E5=9C=86=E8=A7=92=E8=89=B2=E5=B7=AE=E6=B7=B1?= =?UTF-8?q?=E5=BA=A6=E4=BC=98=E5=8C=96=20+=20=E9=80=9A=E7=9F=A5=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 表格溢出截断 --- cmd/server/main.go | 2 + internal/handler/handler.go | 9 + internal/handler/telegram_webhook.go | 99 ++++ internal/repository/notify_channel_repo.go | 7 + internal/service/notify_channels.go | 2 +- internal/service/service.go | 3 + internal/service/telegram_bot.go | 552 +++++++++++++++++++++ web/src/api/notify_channels.ts | 2 +- web/src/components/APIConfigsPanel.tsx | 16 +- web/src/components/AppFooter.tsx | 2 +- web/src/components/Layout.tsx | 28 +- web/src/components/MediaCard.tsx | 10 +- web/src/index.css | 12 +- web/src/pages/AIAssistantPage.tsx | 8 +- web/src/pages/APIConfigsPage.tsx | 2 +- web/src/pages/AdminPage.tsx | 12 +- web/src/pages/AssistantChatPage.tsx | 8 +- web/src/pages/DiscoverPage.tsx | 10 +- web/src/pages/DownloadClientsPage.tsx | 14 +- web/src/pages/DownloadsPage.tsx | 8 +- web/src/pages/DuplicatesPage.tsx | 4 +- web/src/pages/FileManagerPage.tsx | 4 +- web/src/pages/HomePage.tsx | 24 +- web/src/pages/LibraryPage.tsx | 12 +- web/src/pages/LicensePage.tsx | 16 +- web/src/pages/LoginPage.tsx | 14 +- web/src/pages/MediaDetailPage.tsx | 22 +- web/src/pages/NotifyChannelsPage.tsx | 30 +- web/src/pages/PlaylistsPage.tsx | 4 +- web/src/pages/PosterWallPage.tsx | 2 +- web/src/pages/ProfileManagementPage.tsx | 16 +- web/src/pages/RecycleBinPage.tsx | 6 +- web/src/pages/SchedulerPage.tsx | 4 +- web/src/pages/SearchPage.tsx | 2 +- web/src/pages/SettingsPage.tsx | 6 +- web/src/pages/SiteSearchPage.tsx | 8 +- web/src/pages/SitesPage.tsx | 36 +- web/src/pages/StatsPage.tsx | 4 +- web/src/pages/StorageConfigPage.tsx | 6 +- web/src/pages/StoragePage.tsx | 6 +- web/src/pages/StrmPage.tsx | 10 +- web/src/pages/SubscriptionsPage.tsx | 6 +- web/src/pages/TasksPage.tsx | 10 +- web/src/pages/ToolsPage.tsx | 8 +- web/src/pages/WatchHistoryPage.tsx | 6 +- web/src/types/index.ts | 1 - 46 files changed, 872 insertions(+), 201 deletions(-) create mode 100644 internal/handler/telegram_webhook.go create mode 100644 internal/service/telegram_bot.go diff --git a/cmd/server/main.go b/cmd/server/main.go index f46a5f6..ec1bff2 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -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)) } diff --git a/internal/handler/handler.go b/internal/handler/handler.go index dc541d6..777debf 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -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)) diff --git a/internal/handler/telegram_webhook.go b/internal/handler/telegram_webhook.go new file mode 100644 index 0000000..2762384 --- /dev/null +++ b/internal/handler/telegram_webhook.go @@ -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"}) + } +} diff --git a/internal/repository/notify_channel_repo.go b/internal/repository/notify_channel_repo.go index 8d03155..f3aa4d4 100644 --- a/internal/repository/notify_channel_repo.go +++ b/internal/repository/notify_channel_repo.go @@ -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 diff --git a/internal/service/notify_channels.go b/internal/service/notify_channels.go index 55cc9d5..ce3195d 100644 --- a/internal/service/notify_channels.go +++ b/internal/service/notify_channels.go @@ -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) } diff --git a/internal/service/service.go b/internal/service/service.go index 7ff8f63..f599e9d 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -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, diff --git a/internal/service/telegram_bot.go b/internal/service/telegram_bot.go new file mode 100644 index 0000000..e01ae01 --- /dev/null +++ b/internal/service/telegram_bot.go @@ -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( + "欢迎使用 MediaStationGo\n\n"+ + "你好 %s!你已成功连接到媒体中心。\n\n"+ + "可用命令:\n"+ + "📊 /status — 系统运行状态\n"+ + "🔍 /search 关键词 — 搜索媒体\n"+ + "📥 /downloads — 下载进度\n"+ + "📈 /stats — 媒体库统计\n"+ + "❓ /help — 帮助信息", + name, + ) +} + +// cmdHelp 处理 /help 命令。 +func (s *TelegramBotService) cmdHelp() string { + return "MediaStationGo 命令列表\n\n" + + "/start — 开始使用\n" + + "/help — 帮助信息\n" + + "/status — 系统运行状态\n" + + "/search 关键词 — 搜索媒体库\n" + + "/downloads — 下载列表\n" + + "/stats — 媒体库统计\n\n" + + "自动推送事件:\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( + "系统运行状态\n\n"+ + "🎬 媒体总数: %d\n"+ + "💾 存储占用: %.1f GB", + mediaCount, totalSizeGB, + ), nil +} + +// cmdSearch 处理 /search 命令。 +func (s *TelegramBotService) cmdSearch(ctx context.Context, args []string) (string, error) { + if len(args) == 0 { + return "请提供搜索关键词\n例: /search 哥斯拉", 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("未找到与 %s 相关的媒体", keyword), nil + } + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("搜索: %s\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. %s%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("下载任务 (%d)\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("媒体库统计\n\n") + sb.WriteString(fmt.Sprintf("📚 总数: %d\n", totalMedia)) + sb.WriteString(fmt.Sprintf("💾 大小: %s\n", formatSize(totalSize))) + + if len(libs) > 0 { + sb.WriteString("\n各库分布:\n") + for _, l := range libs { + icon := "🎬" + switch l.Type { + case "tv": + icon = "📺" + case "anime": + icon = "🍥" + case "music": + icon = "🎵" + } + sb.WriteString(fmt.Sprintf("%s %s: %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]) +} diff --git a/web/src/api/notify_channels.ts b/web/src/api/notify_channels.ts index 503b2da..5359cb9 100644 --- a/web/src/api/notify_channels.ts +++ b/web/src/api/notify_channels.ts @@ -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 events?: string[] enabled?: boolean diff --git a/web/src/components/APIConfigsPanel.tsx b/web/src/components/APIConfigsPanel.tsx index 4feffad..37de385 100644 --- a/web/src/components/APIConfigsPanel.tsx +++ b/web/src/components/APIConfigsPanel.tsx @@ -40,7 +40,7 @@ export function APIConfigsPanel() { {!loading && (
- + @@ -63,7 +63,7 @@ export function APIConfigsPanel() { ) : ( + {libs.map((l) => ( - + {users.map((u) => ( - + {torrents.map((t) => ( - + {tasks.map((t) => ( - + enter(e)} title={e.path} > diff --git a/web/src/pages/HomePage.tsx b/web/src/pages/HomePage.tsx index baab792..78b4fec 100644 --- a/web/src/pages/HomePage.tsx +++ b/web/src/pages/HomePage.tsx @@ -82,7 +82,7 @@ export function HomePage() {
- 站点舱准备中… + 站点舱准备中… ) @@ -92,7 +92,7 @@ export function HomePage() { return (
- +

您的家庭影视站暂无内容

@@ -142,7 +142,7 @@ export function HomePage() { {featuredItem.overview}

) : ( -

+

家庭私人媒体中心收藏。极高视听品质,支持多端原生无损解码及HLS转码播放。

)} @@ -150,15 +150,15 @@ export function HomePage() { {/* Metadata Badges */}
{featuredItem.year > 0 && ( - {featuredItem.year} 年 + {featuredItem.year} 年 )} {featuredItem.video_codec && ( - + {featuredItem.video_codec} )} {featuredItem.container && ( - + {featuredItem.container} )} @@ -183,11 +183,11 @@ export function HomePage() { {history.length > 0 && (
- +

继续观看

- {history.length} 个记录 + {history.length} 个记录
@@ -206,7 +206,7 @@ export function HomePage() {
- + {TYPE_ICONS[row.library.type] ?? }

@@ -240,7 +240,7 @@ export function HomePage() {
- +

最近添加

@@ -272,7 +272,7 @@ function ContinueCard({ media, progress }: { media: Media; progress: number }) { referrerPolicy="no-referrer" /> ) : ( -
+
)} @@ -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" />
-

+

已观看到 {Math.round(progress * 100)}%

diff --git a/web/src/pages/LibraryPage.tsx b/web/src/pages/LibraryPage.tsx index 6d6171e..dd513c2 100644 --- a/web/src/pages/LibraryPage.tsx +++ b/web/src/pages/LibraryPage.tsx @@ -126,7 +126,7 @@ export function LibraryPage() { {library &&

{library.type} · {library.path}

}
{role === 'admin' && ( -
+
@@ -144,7 +144,7 @@ export function LibraryPage() { {!isSeries && items.length === 0 && (
- +

该媒体库暂无内容,触发一次扫描后再来看看

)} @@ -190,7 +190,7 @@ export function LibraryPage() { {selectedSeries.rep.poster_url ? ( {selectedSeries.rep.title} ) : ( -
+
)}
@@ -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" > -
+
{ep.episode_num || '—'}
@@ -239,7 +239,7 @@ export function LibraryPage() { : formatSize(ep.size_bytes)}

- + ))}
@@ -253,7 +253,7 @@ export function LibraryPage() { {/* 剧集为空 */} {isSeries && seriesCards.length === 0 && !loading && (
- +

该库尚未发现任何剧集,触发一次扫描后再来看看

)} diff --git a/web/src/pages/LicensePage.tsx b/web/src/pages/LicensePage.tsx index c7f3294..942e5fa 100644 --- a/web/src/pages/LicensePage.tsx +++ b/web/src/pages/LicensePage.tsx @@ -147,10 +147,10 @@ export function LicensePage() { )}

当前状态

-
+
@@ -167,7 +167,7 @@ export function LicensePage() {
{!active && ( -
+

尚未绑定许可证。请在上方输入密钥完成激活。

@@ -202,7 +202,7 @@ export function LicensePage() { )} {isExpired && ( -
+
此许可证已过期,部分功能可能受限。请获取新的许可证密钥。
)} @@ -211,11 +211,11 @@ export function LicensePage() { {/* ── Pro tip ── */} {!active && !loadingStatus && ( -
+

需要获取许可证?请联系管理员获取 MediaStationGo 授权密钥。

-

+

授权服务器地址可在系统设置中配置

@@ -238,7 +238,7 @@ function StatusBadge({ className?: string }) { return ( -
+

{label}

{value} diff --git a/web/src/pages/LoginPage.tsx b/web/src/pages/LoginPage.tsx index da1e79a..dc4aa26 100644 --- a/web/src/pages/LoginPage.tsx +++ b/web/src/pages/LoginPage.tsx @@ -90,12 +90,12 @@ export function LoginPage() { 用户名

- + setUsername(e.target.value)} autoComplete="username" @@ -116,12 +116,12 @@ export function LoginPage() { 安全密码
- + setPassword(e.target.value)} autoComplete="current-password" @@ -131,7 +131,7 @@ export function LoginPage() {

{media.year > 0 && ( - + {media.year} 年 )} {media.width > 0 && ( - + {media.width} × {media.height} )} - + {fmtSize(media.size_bytes)} - + {fmtDuration(media.duration_sec)} {media.container && ( - + {media.container} )} @@ -227,7 +227,7 @@ export function MediaDetailPage() { {/* Genres */} {parseCSV(media.genres).length > 0 && (
- 类型流派 + 类型流派
{parseCSV(media.genres).map((g) => ( @@ -241,10 +241,10 @@ export function MediaDetailPage() { {/* Languages */} {parseCSV(media.languages).length > 0 && (
- 语言 + 语言
{parseCSV(media.languages).map((l) => ( - + {l} ))} @@ -255,10 +255,10 @@ export function MediaDetailPage() { {/* Countries */} {parseCSV(media.countries).length > 0 && (
- 国家/地区 + 国家/地区
{parseCSV(media.countries).map((c) => ( - + {c} ))} diff --git a/web/src/pages/NotifyChannelsPage.tsx b/web/src/pages/NotifyChannelsPage.tsx index 81721ee..abaace4 100644 --- a/web/src/pages/NotifyChannelsPage.tsx +++ b/web/src/pages/NotifyChannelsPage.tsx @@ -120,7 +120,7 @@ export function NotifyChannelsPage() { ) } -const TYPE_LABELS: Record = { +const TYPE_LABELS: Record = { telegram: 'Telegram', wechat: '企业微信', bark: 'Bark', @@ -145,11 +145,11 @@ function ChannelCard({
{channel.name} - - {TYPE_LABELS[channel.channel_type] ?? channel.channel_type} + + {TYPE_LABELS[channel.type] ?? channel.type} {!channel.enabled && ( - 已禁用 + 已禁用 )}
{summary}
@@ -157,19 +157,19 @@ function ChannelCard({
@@ -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> = { +const EMPTY_CONFIG: Record> = { telegram: { bot_token: '', chat_id: '' }, wechat: { sendkey: '' }, bark: { device_key: '', server: '' }, @@ -216,8 +216,8 @@ function ChannelFormModal({ onSaved: () => void | Promise }) { const [name, setName] = useState(editing?.name ?? '') - const [type, setType] = useState( - editing?.channel_type ?? 'telegram', + const [type, setType] = useState( + editing?.type ?? 'telegram', ) const [config, setConfig] = useState>( 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({
{items.map((m) => ( - + {jobs.map((j) => ( - + {libraries.map((lib) => ( - + {results.map((item, idx) => ( - + + {items.map((s) => ( - + {snap.transcodes.map((t) => ( - + {torrents.map((t) => ( - +
服务 密钥

{item.provider}

@@ -75,7 +75,7 @@ export function APIConfigsPanel() { {item.has_key ? ( {item.masked_key} ) : ( - 未配置 + 未配置 )}
@@ -93,7 +93,7 @@ export function APIConfigsPanel() {
{item.provider} @@ -207,7 +207,7 @@ function EditingRow({ diff --git a/web/src/components/AppFooter.tsx b/web/src/components/AppFooter.tsx index 1289817..c5d1954 100644 --- a/web/src/components/AppFooter.tsx +++ b/web/src/components/AppFooter.tsx @@ -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} > diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx index ebde851..64312d3 100644 --- a/web/src/components/Layout.tsx +++ b/web/src/components/Layout.tsx @@ -78,7 +78,7 @@ export function Layout() { {/* Toggle Collapse Button for Large Screen */} @@ -86,7 +86,7 @@ export function Layout() { {/* Mobile Drawer Close Button */} @@ -114,7 +114,7 @@ export function Layout() {
{libraries.length === 0 && (isSidebarOpen || isMobileDrawerOpen) && ( -
暂无媒体片库
+
暂无媒体片库
)} {libraries.map((lib) => ( { 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() { @@ -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" />
- + Enter
@@ -274,7 +274,7 @@ export function Layout() { {/* Quick search button shown ONLY on Mobile screens */} @@ -289,7 +289,7 @@ export function Layout() { {/* Notification alert bubble */} - @@ -310,7 +310,7 @@ export function Layout() {

{user?.username}

{user?.role}

- + @@ -389,7 +389,7 @@ export function Layout() { {/* Absolute Footer Frame */} - + ) @@ -399,7 +399,7 @@ function SectionHeader({ label, visible }: { label: string; visible: boolean }) if (!visible) return
; return (
- + {label}
@@ -432,7 +432,7 @@ function SidebarLink({ to, icon, label, end, collapsed }: SidebarLinkProps) { <> {icon} @@ -446,7 +446,7 @@ function SidebarLink({ to, icon, label, end, collapsed }: SidebarLinkProps) { )} {collapsed && ( -
+
{label}
)} diff --git a/web/src/components/MediaCard.tsx b/web/src/components/MediaCard.tsx index e14047f..c0227f2 100644 --- a/web/src/components/MediaCard.tsx +++ b/web/src/components/MediaCard.tsx @@ -32,7 +32,7 @@ export const MediaCard = ({ referrerPolicy="no-referrer" /> ) : ( -
+
No Poster
@@ -40,7 +40,7 @@ export const MediaCard = ({ {/* Episode count badge */} {count !== undefined && count > 1 && ( - + {count} 集 @@ -48,7 +48,7 @@ export const MediaCard = ({ {/* Rating Badge */} {(rating || (media as any).rating) && ( - + {(rating || (media as any).rating).toFixed(1)} @@ -66,7 +66,7 @@ export const MediaCard = ({ 立即观影 -

+

{media.overview || "暂无简介内容"}

@@ -91,7 +91,7 @@ export const MediaCard = ({
{media.year > 0 ? media.year : "未知年份"} {media.video_codec && ( - + {media.video_codec} )} diff --git a/web/src/index.css b/web/src/index.css index 8ecb3c9..a37c674 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -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 ── */ diff --git a/web/src/pages/AIAssistantPage.tsx b/web/src/pages/AIAssistantPage.tsx index 7be933b..6753189 100644 --- a/web/src/pages/AIAssistantPage.tsx +++ b/web/src/pages/AIAssistantPage.tsx @@ -113,7 +113,7 @@ export function AIAssistantPage() { {/* Smart search */}

智能搜索

- + { 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} @@ -145,7 +145,7 @@ export function AIAssistantPage() {
{intent && ( -
+
解析结果
@@ -212,7 +212,7 @@ export function AIAssistantPage() {
  • {t} diff --git a/web/src/pages/APIConfigsPage.tsx b/web/src/pages/APIConfigsPage.tsx index 3e7703c..2e1f974 100644 --- a/web/src/pages/APIConfigsPage.tsx +++ b/web/src/pages/APIConfigsPage.tsx @@ -126,7 +126,7 @@ function ProviderCard({ item, onUpdated }: { item: APIConfig; onUpdated: () => v /> 启用 -
    +
    diff --git a/web/src/pages/AdminPage.tsx b/web/src/pages/AdminPage.tsx index 48048a2..3f784d2 100644 --- a/web/src/pages/AdminPage.tsx +++ b/web/src/pages/AdminPage.tsx @@ -17,7 +17,7 @@ export function AdminPage() { return (

    管理后台

    -
    +
    {tabs.map((k) => (
  • {l.name} {l.path} {l.type}
    {u.username} {u.role} @@ -171,7 +171,7 @@ function UsersPanel() { @@ -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" > 编辑 @@ -301,7 +301,7 @@ function ClientFormModal({ diff --git a/web/src/pages/DownloadsPage.tsx b/web/src/pages/DownloadsPage.tsx index 2e8aae6..1436f0e 100644 --- a/web/src/pages/DownloadsPage.tsx +++ b/web/src/pages/DownloadsPage.tsx @@ -107,14 +107,14 @@ export function DownloadsPage() {
    {t.name} {t.state}
    -
    +
    {t.source} {t.url} diff --git a/web/src/pages/DuplicatesPage.tsx b/web/src/pages/DuplicatesPage.tsx index eee6153..6ebf063 100644 --- a/web/src/pages/DuplicatesPage.tsx +++ b/web/src/pages/DuplicatesPage.tsx @@ -93,7 +93,7 @@ export function DuplicatesPage() {

    {g.hash}

    - + 主条目
    @@ -101,7 +101,7 @@ export function DuplicatesPage() {

    {g.primary.path} · {fmtBytes(g.primary.size_bytes)}

    -
    +

    重复 ({g.duplicates.length})

    diff --git a/web/src/pages/FileManagerPage.tsx b/web/src/pages/FileManagerPage.tsx index 07f3db1..a1255a1 100644 --- a/web/src/pages/FileManagerPage.tsx +++ b/web/src/pages/FileManagerPage.tsx @@ -72,7 +72,7 @@ export function FileManagerPage() { )} {data?.path && ( - + {data.path} )} @@ -113,7 +113,7 @@ export function FileManagerPage() { {data.entries.map((e) => (
    {m.title} {m.path}
    {j.name} {j.interval} @@ -69,7 +69,7 @@ export function SchedulerPage() { diff --git a/web/src/pages/SearchPage.tsx b/web/src/pages/SearchPage.tsx index 6857c51..51c44b1 100644 --- a/web/src/pages/SearchPage.tsx +++ b/web/src/pages/SearchPage.tsx @@ -99,7 +99,7 @@ export function SearchPage() { {aiOn ? ( - + -
    +
    {GROUPS.map((g) => (
    {lib.name} {lib.type === 'movie' ? '电影' : lib.type === 'tv' ? '电视剧' : lib.type === 'anime' ? '动漫' : '音乐'} @@ -355,7 +355,7 @@ function SettingRow({
    {def.type === 'text' && ( diff --git a/web/src/pages/SiteSearchPage.tsx b/web/src/pages/SiteSearchPage.tsx index 6e94456..e66175f 100644 --- a/web/src/pages/SiteSearchPage.tsx +++ b/web/src/pages/SiteSearchPage.tsx @@ -75,7 +75,7 @@ export function SiteSearchPage() {
    - +
    {item.site_name} {item.leechers || '—'} {item.free && ( - + Free )} @@ -144,7 +144,7 @@ export function SiteSearchPage() { {advancedOpen && ( -
    +
    {/* 新增高级字段 */}
    @@ -646,7 +646,7 @@ export function SitesPage() {
    {l.name} {l.type} {l.media_count} {fmtBytes(l.total_bytes)}
    -
    +
    -
    +
    {icon}
    diff --git a/web/src/pages/StrmPage.tsx b/web/src/pages/StrmPage.tsx index 0afcbca..89188ce 100644 --- a/web/src/pages/StrmPage.tsx +++ b/web/src/pages/StrmPage.tsx @@ -173,7 +173,7 @@ export function StrmPage() { {/* Attach / detach STRM URL on existing media. */}

    附加 STRM URL 到已有媒体

    - +
    @@ -202,7 +202,7 @@ export function StrmPage() { {m.container || '本地文件'}
    {isStrm && ( -
    +
    已设置 STRM
    )} @@ -210,13 +210,13 @@ export function StrmPage() { {isStrm && ( )}
    -
    +
    {s.name} {s.feed_url} @@ -99,7 +99,7 @@ export function SubscriptionsPage() {
    {t.media_id} {t.encoder || 'libx264'} {new Date(t.started_at).toLocaleTimeString()} {t.playlist_ok ? ( - + ready ) : ( - + starting )} @@ -97,14 +97,14 @@ export function TasksPage() {
    {t.name} {t.state}
    -
    +
    整理 & 重命名

    - 将媒体文件按 媒体库/年份/标题 (年份)/标题.ext{' '} + 将媒体文件按 媒体库/年份/标题 (年份)/标题.ext{' '} 的规范布局移动并重命名,确保已先完成刮削。

    @@ -160,8 +160,8 @@ function OrganizePanel() { -
    -
    +
    + (
  • {m.title}
    diff --git a/web/src/pages/WatchHistoryPage.tsx b/web/src/pages/WatchHistoryPage.tsx index 428f507..128586c 100644 --- a/web/src/pages/WatchHistoryPage.tsx +++ b/web/src/pages/WatchHistoryPage.tsx @@ -49,7 +49,7 @@ export function WatchHistoryPage() { key={h.id} className="glass-panel flex items-center gap-4 !p-3" > -
    +
    {m.poster_url ? ( {fmtDuration(h.position_ms)} / {fmtDuration(h.duration_ms)} {new Date(h.watched_at).toLocaleString()} {h.completed && ( - + 已看完 )}
    -
    +