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:
ShukeBta
2026-05-28 08:35:59 +08:00
parent c094818594
commit 5baf9515ea
46 changed files with 872 additions and 201 deletions
+2
View File
@@ -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))
}
+9
View File
@@ -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))
+99
View File
@@ -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
+1 -1
View File
@@ -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)
}
+3
View File
@@ -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,
+552
View File
@@ -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])
}
+1 -1
View File
@@ -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
+8 -8
View File
@@ -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>
+1 -1
View File
@@ -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} />
+14 -14
View File
@@ -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>
)}
+5 -5
View File
@@ -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
View File
@@ -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 ── */
+4 -4
View File
@@ -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" />
+1 -1
View File
@@ -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>
+6 -6
View File
@@ -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)
+4 -4
View File
@@ -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}
+5 -5
View File
@@ -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>
+7 -7
View File
@@ -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>
+4 -4
View File
@@ -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}
+2 -2
View File
@@ -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>
+2 -2
View File
@@ -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
View File
@@ -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>
+6 -6
View File
@@ -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>
)}
+8 -8
View File
@@ -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}
+7 -7
View File
@@ -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>
+11 -11
View File
@@ -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>
))}
+15 -15
View File
@@ -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>
+2 -2
View File
@@ -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>
+1 -1
View File
@@ -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>
+8 -8
View File
@@ -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>}
+3 -3
View File
@@ -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)
+2 -2
View File
@@ -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>
+1 -1
View File
@@ -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"
+3 -3
View File
@@ -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' && (
+4 -4
View File
@@ -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
View File
@@ -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
+2 -2
View File
@@ -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>
+3 -3
View File
@@ -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" />}
{' '}测试
+3 -3
View File
@@ -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>
+5 -5
View File
@@ -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"
+3 -3
View File
@@ -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)
+5 -5
View File
@@ -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)}%` }}
+4 -4
View File
@@ -122,7 +122,7 @@ function OrganizePanel() {
<h2 className="font-display text-lg font-semibold text-ink-600">整理 &amp; 重命名</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>
+3 -3
View File
@@ -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)}%` }}
-1
View File
@@ -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>