更新 update 门户站点界面和后台功能
build-winui / winui (push) Waiting to run

This commit is contained in:
QWQLwToo
2026-06-27 18:09:11 +08:00
parent 2513eb2903
commit 962a2f2143
56 changed files with 4564 additions and 714 deletions
@@ -0,0 +1,48 @@
package config
import "strings"
type SafeBrandingConfig struct {
SiteIconURL string `json:"siteIconUrl"`
DeveloperAvatarURL string `json:"developerAvatarUrl"`
DeveloperName string `json:"developerName"`
FeedbackEmail string `json:"feedbackEmail"`
}
func SafeBranding(cfg BrandingConfig) SafeBrandingConfig {
return SafeBrandingConfig{
SiteIconURL: strings.TrimSpace(cfg.SiteIconURL),
DeveloperAvatarURL: strings.TrimSpace(cfg.DeveloperAvatarURL),
DeveloperName: strings.TrimSpace(firstNonEmpty(cfg.DeveloperName, "YMhut")),
FeedbackEmail: strings.TrimSpace(firstNonEmpty(cfg.FeedbackEmail, "support@ymhut.cn")),
}
}
func NormalizeBranding(current BrandingConfig, incoming BrandingConfig) BrandingConfig {
next := current
if value := strings.TrimSpace(incoming.SiteIconURL); value != "" {
next.SiteIconURL = value
}
if value := strings.TrimSpace(incoming.DeveloperAvatarURL); value != "" {
next.DeveloperAvatarURL = value
}
if value := strings.TrimSpace(incoming.DeveloperName); value != "" {
next.DeveloperName = value
}
if value := strings.TrimSpace(incoming.FeedbackEmail); value != "" {
next.FeedbackEmail = value
}
if next.SiteIconURL == "" {
next.SiteIconURL = "https://img.ymhut.cn/file/1782108850041_icon.webp"
}
if next.DeveloperAvatarURL == "" {
next.DeveloperAvatarURL = "https://img.ymhut.cn/file/1782108780690_b_3db45f3787f19192c8de8e06bc0987ef.webp"
}
if next.DeveloperName == "" {
next.DeveloperName = "YMhut"
}
if next.FeedbackEmail == "" {
next.FeedbackEmail = "support@ymhut.cn"
}
return next
}
@@ -36,6 +36,8 @@ type Config struct {
MaxRequestBytes int64 `json:"max_request_bytes"`
MaxPackageBytes int64 `json:"max_package_bytes"`
Database DatabaseConfig `json:"database"`
Mail MailConfig `json:"mail"`
Branding BrandingConfig `json:"branding"`
UploadGuard UploadGuardConfig `json:"upload_guard"`
SourceCheckSeconds int `json:"source_check_seconds"`
}
@@ -44,6 +46,11 @@ type DatabaseConfig struct {
Provider string `json:"provider"`
SQLitePath string `json:"sqlite_path"`
MySQLDSN string `json:"mysql_dsn"`
MySQLHost string `json:"mysql_host"`
MySQLPort int `json:"mysql_port"`
MySQLDatabase string `json:"mysql_database"`
MySQLUser string `json:"mysql_user"`
MySQLPassword string `json:"mysql_password"`
FailoverEnabled bool `json:"failover_enabled"`
HotSyncEnabled bool `json:"hot_sync_enabled"`
HealthIntervalSec int `json:"health_interval_sec"`
@@ -52,6 +59,25 @@ type DatabaseConfig struct {
ConnMaxLifetimeSeconds int `json:"conn_max_lifetime_seconds"`
}
type MailConfig struct {
Host string `json:"host"`
Port int `json:"port"`
Secure string `json:"secure"`
Username string `json:"username"`
Password string `json:"password"`
FromAddress string `json:"from_address"`
FromName string `json:"from_name"`
DeveloperAddress string `json:"developer_address"`
TimeoutSeconds int `json:"timeout_seconds"`
}
type BrandingConfig struct {
SiteIconURL string `json:"site_icon_url"`
DeveloperAvatarURL string `json:"developer_avatar_url"`
DeveloperName string `json:"developer_name"`
FeedbackEmail string `json:"feedback_email"`
}
type UploadGuardConfig struct {
MaxZipFiles int `json:"max_zip_files"`
MaxDecompressedBytes int64 `json:"max_decompressed_bytes"`
@@ -120,6 +146,8 @@ func defaults(root string) *Config {
Database: DatabaseConfig{
Provider: "sqlite",
SQLitePath: filepath.Join(root, "storage", "unified.sqlite"),
MySQLHost: "127.0.0.1",
MySQLPort: 3306,
FailoverEnabled: true,
HotSyncEnabled: true,
HealthIntervalSec: 30,
@@ -127,6 +155,19 @@ func defaults(root string) *Config {
MaxIdleConns: 4,
ConnMaxLifetimeSeconds: 300,
},
Mail: MailConfig{
Port: 465,
Secure: "ssl",
FromName: "YMhut Box Feedback",
DeveloperAddress: "support@ymhut.cn",
TimeoutSeconds: 20,
},
Branding: BrandingConfig{
SiteIconURL: "https://img.ymhut.cn/file/1782108850041_icon.webp",
DeveloperAvatarURL: "https://img.ymhut.cn/file/1782108780690_b_3db45f3787f19192c8de8e06bc0987ef.webp",
DeveloperName: "YMhut",
FeedbackEmail: "support@ymhut.cn",
},
UploadGuard: UploadGuardConfig{
MaxZipFiles: 80,
MaxDecompressedBytes: 30 * 1024 * 1024,
@@ -184,6 +225,66 @@ func applyEnv(cfg *Config) {
if value := os.Getenv("YMHUT_MYSQL_DSN"); value != "" {
cfg.Database.MySQLDSN = value
}
if value := os.Getenv("YMHUT_MYSQL_HOST"); value != "" {
cfg.Database.MySQLHost = value
}
if value := os.Getenv("YMHUT_MYSQL_PORT"); value != "" {
if parsed, err := strconv.Atoi(value); err == nil {
cfg.Database.MySQLPort = parsed
}
}
if value := os.Getenv("YMHUT_MYSQL_DATABASE"); value != "" {
cfg.Database.MySQLDatabase = value
}
if value := os.Getenv("YMHUT_MYSQL_USER"); value != "" {
cfg.Database.MySQLUser = value
}
if value := os.Getenv("YMHUT_MYSQL_PASSWORD"); value != "" {
cfg.Database.MySQLPassword = value
}
if value := os.Getenv("YMHUT_MAIL_HOST"); value != "" {
cfg.Mail.Host = value
}
if value := os.Getenv("YMHUT_MAIL_PORT"); value != "" {
if parsed, err := strconv.Atoi(value); err == nil {
cfg.Mail.Port = parsed
}
}
if value := os.Getenv("YMHUT_MAIL_SECURE"); value != "" {
cfg.Mail.Secure = value
}
if value := os.Getenv("YMHUT_MAIL_USERNAME"); value != "" {
cfg.Mail.Username = value
}
if value := os.Getenv("YMHUT_MAIL_PASSWORD"); value != "" {
cfg.Mail.Password = value
}
if value := os.Getenv("YMHUT_MAIL_FROM_ADDRESS"); value != "" {
cfg.Mail.FromAddress = value
}
if value := os.Getenv("YMHUT_MAIL_FROM_NAME"); value != "" {
cfg.Mail.FromName = value
}
if value := os.Getenv("YMHUT_MAIL_DEVELOPER_ADDRESS"); value != "" {
cfg.Mail.DeveloperAddress = value
}
if value := os.Getenv("YMHUT_MAIL_TIMEOUT_SECONDS"); value != "" {
if parsed, err := strconv.Atoi(value); err == nil {
cfg.Mail.TimeoutSeconds = parsed
}
}
if value := os.Getenv("YMHUT_BRAND_ICON_URL"); value != "" {
cfg.Branding.SiteIconURL = value
}
if value := os.Getenv("YMHUT_BRAND_DEVELOPER_AVATAR_URL"); value != "" {
cfg.Branding.DeveloperAvatarURL = value
}
if value := os.Getenv("YMHUT_BRAND_DEVELOPER_NAME"); value != "" {
cfg.Branding.DeveloperName = value
}
if value := os.Getenv("YMHUT_BRAND_FEEDBACK_EMAIL"); value != "" {
cfg.Branding.FeedbackEmail = value
}
if value := os.Getenv("YMHUT_CLIENT_SIGNATURE_KEY"); value != "" {
cfg.ClientSignatureKey = value
}
@@ -267,10 +368,30 @@ func normalize(root string, cfg *Config) {
if cfg.Database.Provider == "" {
cfg.Database.Provider = "sqlite"
}
cfg.Database.Provider = strings.ToLower(strings.TrimSpace(cfg.Database.Provider))
if cfg.Database.SQLitePath == "" {
cfg.Database.SQLitePath = filepath.Join(cfg.StorageDir, "unified.sqlite")
}
cfg.Database.SQLitePath = absPath(cfg.BaseDir, cfg.Database.SQLitePath)
if cfg.Database.MySQLHost == "" {
cfg.Database.MySQLHost = "127.0.0.1"
}
if cfg.Database.MySQLPort <= 0 {
cfg.Database.MySQLPort = 3306
}
if cfg.Database.Provider == "mysql" && cfg.Database.MySQLDSN == "" && cfg.Database.MySQLDatabase != "" && cfg.Database.MySQLUser != "" {
if dsn, err := BuildMySQLDSN(MySQLInput{
Host: cfg.Database.MySQLHost,
Port: cfg.Database.MySQLPort,
Database: cfg.Database.MySQLDatabase,
Username: cfg.Database.MySQLUser,
Password: cfg.Database.MySQLPassword,
Charset: "utf8mb4",
ParseTime: true,
}); err == nil {
cfg.Database.MySQLDSN = dsn
}
}
if cfg.Database.HealthIntervalSec <= 0 {
cfg.Database.HealthIntervalSec = 30
}
@@ -316,6 +437,37 @@ func normalize(root string, cfg *Config) {
if cfg.SourceCheckSeconds <= 0 {
cfg.SourceCheckSeconds = 300
}
if cfg.Mail.Port <= 0 {
cfg.Mail.Port = 465
}
cfg.Mail.Secure = strings.ToLower(strings.TrimSpace(cfg.Mail.Secure))
if cfg.Mail.Secure == "" {
cfg.Mail.Secure = "ssl"
}
if cfg.Mail.FromName == "" {
cfg.Mail.FromName = "YMhut Box Feedback"
}
if cfg.Mail.FromAddress == "" {
cfg.Mail.FromAddress = cfg.Mail.Username
}
if cfg.Mail.TimeoutSeconds <= 0 {
cfg.Mail.TimeoutSeconds = 20
}
if cfg.Branding.SiteIconURL == "" {
cfg.Branding.SiteIconURL = "https://img.ymhut.cn/file/1782108850041_icon.webp"
}
if cfg.Branding.DeveloperAvatarURL == "" {
cfg.Branding.DeveloperAvatarURL = "https://img.ymhut.cn/file/1782108780690_b_3db45f3787f19192c8de8e06bc0987ef.webp"
}
if cfg.Branding.DeveloperName == "" {
cfg.Branding.DeveloperName = "YMhut"
}
if cfg.Branding.FeedbackEmail == "" {
cfg.Branding.FeedbackEmail = "support@ymhut.cn"
}
if cfg.Mail.DeveloperAddress == "" {
cfg.Mail.DeveloperAddress = cfg.Branding.FeedbackEmail
}
}
func ResolveBaseDir() (string, error) {
@@ -0,0 +1,189 @@
package config
import (
"errors"
"fmt"
"net/url"
"path/filepath"
"strconv"
"strings"
)
type MySQLInput struct {
Host string `json:"host"`
Port int `json:"port"`
Database string `json:"database"`
Username string `json:"username"`
Password string `json:"password"`
Charset string `json:"charset"`
ParseTime bool `json:"parseTime"`
TLS string `json:"tls"`
}
type SafeDatabaseConfig struct {
Provider string `json:"provider"`
SQLitePath string `json:"sqlitePath"`
MySQLDSN string `json:"mysqlDsn"`
MySQLHost string `json:"mysqlHost"`
MySQLPort int `json:"mysqlPort"`
MySQLDatabase string `json:"mysqlDatabase"`
MySQLUser string `json:"mysqlUser"`
HasPassword bool `json:"hasPassword"`
}
func BuildMySQLDSN(input MySQLInput) (string, error) {
host := strings.TrimSpace(input.Host)
if host == "" {
host = "127.0.0.1"
}
port := input.Port
if port <= 0 {
port = 3306
}
database := strings.TrimSpace(input.Database)
username := strings.TrimSpace(input.Username)
if database == "" {
return "", errors.New("mysql database is required")
}
if username == "" {
return "", errors.New("mysql username is required")
}
params := url.Values{}
params.Set("charset", firstNonEmpty(strings.TrimSpace(input.Charset), "utf8mb4"))
params.Set("parseTime", strconv.FormatBool(input.ParseTime))
if tls := strings.TrimSpace(input.TLS); tls != "" {
params.Set("tls", tls)
}
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?%s", username, input.Password, host, port, database, params.Encode()), nil
}
func NormalizeDatabase(baseDir string, current DatabaseConfig, incoming DatabaseConfig, keepPassword bool) (DatabaseConfig, error) {
next := current
structuredChanged := false
if incoming.Provider != "" {
next.Provider = strings.ToLower(strings.TrimSpace(incoming.Provider))
}
if next.Provider == "" {
next.Provider = "sqlite"
}
if incoming.SQLitePath != "" {
next.SQLitePath = incoming.SQLitePath
}
if next.SQLitePath != "" && !filepath.IsAbs(next.SQLitePath) && !strings.HasPrefix(strings.ToLower(next.SQLitePath), "file:") {
next.SQLitePath = filepath.Join(baseDir, next.SQLitePath)
}
if incoming.MySQLHost != "" {
next.MySQLHost = strings.TrimSpace(incoming.MySQLHost)
structuredChanged = true
}
if incoming.MySQLPort > 0 {
next.MySQLPort = incoming.MySQLPort
structuredChanged = true
}
if incoming.MySQLDatabase != "" {
next.MySQLDatabase = strings.TrimSpace(incoming.MySQLDatabase)
structuredChanged = true
}
if incoming.MySQLUser != "" {
next.MySQLUser = strings.TrimSpace(incoming.MySQLUser)
structuredChanged = true
}
if incoming.MySQLPassword != "" || !keepPassword {
next.MySQLPassword = incoming.MySQLPassword
structuredChanged = true
}
if incoming.MySQLDSN != "" {
next.MySQLDSN = strings.TrimSpace(incoming.MySQLDSN)
}
if next.MySQLHost == "" {
next.MySQLHost = "127.0.0.1"
}
if next.MySQLPort <= 0 {
next.MySQLPort = 3306
}
if next.Provider == "sqlite" {
next.MySQLDSN = ""
} else if next.Provider == "mysql" {
if structuredChanged || next.MySQLDSN == "" {
dsn, err := BuildMySQLDSN(MySQLInput{
Host: next.MySQLHost,
Port: next.MySQLPort,
Database: next.MySQLDatabase,
Username: next.MySQLUser,
Password: next.MySQLPassword,
Charset: "utf8mb4",
ParseTime: true,
})
if err != nil {
return DatabaseConfig{}, err
}
next.MySQLDSN = dsn
}
if strings.TrimSpace(next.MySQLDSN) == "" {
return DatabaseConfig{}, errors.New("mysql connection is required")
}
} else {
return DatabaseConfig{}, errors.New("provider must be sqlite or mysql")
}
if strings.TrimSpace(next.SQLitePath) == "" {
return DatabaseConfig{}, errors.New("sqlite path is required")
}
if next.MaxOpenConns <= 0 {
next.MaxOpenConns = 10
}
if next.MaxIdleConns <= 0 {
next.MaxIdleConns = 4
}
if next.ConnMaxLifetimeSeconds <= 0 {
next.ConnMaxLifetimeSeconds = 300
}
if next.HealthIntervalSec <= 0 {
next.HealthIntervalSec = 30
}
return next, nil
}
func SafeDatabase(baseDir string, cfg DatabaseConfig) SafeDatabaseConfig {
return SafeDatabaseConfig{
Provider: firstNonEmpty(cfg.Provider, "sqlite"),
SQLitePath: relativeToBase(baseDir, cfg.SQLitePath),
MySQLDSN: MaskDSN(cfg.MySQLDSN),
MySQLHost: cfg.MySQLHost,
MySQLPort: cfg.MySQLPort,
MySQLDatabase: cfg.MySQLDatabase,
MySQLUser: cfg.MySQLUser,
HasPassword: strings.TrimSpace(cfg.MySQLPassword) != "" || dsnHasPassword(cfg.MySQLDSN),
}
}
func MaskDSN(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
at := strings.Index(value, "@")
colon := strings.Index(value, ":")
if at > -1 && colon > -1 && colon < at {
return value[:colon+1] + "******" + value[at:]
}
return value
}
func relativeToBase(base, value string) string {
if strings.TrimSpace(value) == "" {
return ""
}
if base != "" {
if rel, err := filepath.Rel(base, value); err == nil && !strings.HasPrefix(rel, "..") && rel != "." {
return filepath.ToSlash(rel)
}
}
return filepath.ToSlash(value)
}
func dsnHasPassword(value string) bool {
value = strings.TrimSpace(value)
at := strings.Index(value, "@")
colon := strings.Index(value, ":")
return at > -1 && colon > -1 && colon < at && colon+1 < at
}
@@ -2,6 +2,7 @@ package db
import (
"fmt"
"strings"
"time"
)
@@ -40,7 +41,7 @@ func (s *Store) DashboardOverview(limit int) (map[string]any, error) {
}
func (s *Store) RecentSourceChecks(limit int) ([]map[string]any, error) {
rows, err := s.query(`SELECT h.id, e.source_id, e.name, h.status, h.latency_ms, h.error, h.checked_at
rows, err := s.query(`SELECT h.id, h.source_db_id, COALESCE(e.source_id, ''), COALESCE(e.name, ''), h.status, h.latency_ms, h.error, h.checked_at
FROM endpoint_health_checks h LEFT JOIN source_endpoints e ON e.id = h.source_db_id
ORDER BY h.checked_at DESC, h.id DESC LIMIT ?`, limit)
if err != nil {
@@ -49,13 +50,19 @@ func (s *Store) RecentSourceChecks(limit int) ([]map[string]any, error) {
defer rows.Close()
items := []map[string]any{}
for rows.Next() {
var id int64
var id, sourceDBID int64
var sourceID, name, status, message, checkedAt string
var latency int
if err := rows.Scan(&id, &sourceID, &name, &status, &latency, &message, &checkedAt); err != nil {
if err := rows.Scan(&id, &sourceDBID, &sourceID, &name, &status, &latency, &message, &checkedAt); err != nil {
return nil, err
}
items = append(items, map[string]any{"id": id, "sourceId": sourceID, "name": name, "status": status, "latencyMs": latency, "error": message, "checkedAt": checkedAt})
if sourceID == "" {
sourceID = fmt.Sprintf("deleted-%d", sourceDBID)
}
if name == "" {
name = fmt.Sprintf("已删除接口 #%d", sourceDBID)
}
items = append(items, map[string]any{"id": id, "sourceDbId": sourceDBID, "sourceId": sourceID, "name": name, "status": status, "latencyMs": latency, "error": message, "checkedAt": checkedAt})
}
return items, rows.Err()
}
@@ -100,6 +107,59 @@ func (s *Store) ListAuditLogs(limit int) ([]AuditLog, error) {
return scanAuditRows(rows)
}
func (s *Store) ListAuditLogsPage(filters AuditFilters) (AuditPage, error) {
page := filters.Page
if page <= 0 {
page = 1
}
perPage := filters.PerPage
if perPage <= 0 {
perPage = 35
}
if perPage > 100 {
perPage = 100
}
where, args := auditWhere(filters)
var total int
if err := s.queryRow(`SELECT COUNT(*) FROM audit_logs`+where, args...).Scan(&total); err != nil {
return AuditPage{}, err
}
offset := (page - 1) * perPage
queryArgs := append(append([]any{}, args...), perPage, offset)
rows, err := s.query(`SELECT id, actor, type, target, message, ip, user_agent, created_at FROM audit_logs`+where+` ORDER BY id DESC LIMIT ? OFFSET ?`, queryArgs...)
if err != nil {
return AuditPage{}, err
}
defer rows.Close()
items, err := scanAuditRows(rows)
if err != nil {
return AuditPage{}, err
}
return AuditPage{Items: items, Total: total, Page: page, PerPage: perPage}, nil
}
func auditWhere(filters AuditFilters) (string, []any) {
clauses := []string{}
args := []any{}
if value := strings.TrimSpace(filters.Type); value != "" {
clauses = append(clauses, "type = ?")
args = append(args, sanitize(value))
}
if value := strings.TrimSpace(filters.Target); value != "" {
clauses = append(clauses, "target = ?")
args = append(args, sanitize(value))
}
if value := strings.TrimSpace(filters.Query); value != "" {
clauses = append(clauses, "(actor LIKE ? OR type LIKE ? OR target LIKE ? OR message LIKE ? OR ip LIKE ?)")
like := "%" + sanitize(value) + "%"
args = append(args, like, like, like, like, like)
}
if len(clauses) == 0 {
return "", args
}
return " WHERE " + strings.Join(clauses, " AND "), args
}
func (s *Store) ListAuditLogsForTarget(target string, limit int) ([]AuditLog, error) {
if limit <= 0 || limit > 200 {
limit = 100
@@ -21,6 +21,10 @@ func (s *Store) CopyRemoteToSQLite() (string, error) {
}
func (s *Store) ImportSQLiteToRemote() (SyncResult, error) {
if !s.trySyncLock() {
return SyncResult{Direction: "sqlite_to_remote", Status: "running", Skipped: true, Tables: map[string]int{}, FinishedAt: Now(), Warnings: []string{"database sync is already running"}}, errors.New("database sync is already running")
}
defer s.syncMu.Unlock()
s.mu.RLock()
remote := s.remoteDB
remoteDialect := s.remoteDialect
@@ -28,9 +32,9 @@ func (s *Store) ImportSQLiteToRemote() (SyncResult, error) {
localDialect := s.localDialect
s.mu.RUnlock()
if remote == nil {
err := errors.New("remote database is not configured")
s.setSyncStatus(SyncResult{Direction: "sqlite_to_remote", Tables: map[string]int{}, FinishedAt: Now()}, err)
return SyncResult{}, err
result := SyncResult{Direction: "sqlite_to_remote", Status: "skipped", Skipped: true, Tables: map[string]int{}, FinishedAt: Now(), Warnings: []string{"remote database is not configured"}}
s.setSyncStatus(result, nil)
return result, nil
}
result, err := copyAllTables(local, localDialect, remote, remoteDialect, "sqlite_to_remote")
s.setSyncStatus(result, err)
@@ -38,6 +42,10 @@ func (s *Store) ImportSQLiteToRemote() (SyncResult, error) {
}
func (s *Store) SyncNow() (SyncResult, error) {
if !s.trySyncLock() {
return SyncResult{Direction: "remote_to_sqlite", Status: "running", Skipped: true, Tables: map[string]int{}, FinishedAt: Now(), Warnings: []string{"database sync is already running"}}, errors.New("database sync is already running")
}
defer s.syncMu.Unlock()
s.mu.RLock()
remote := s.remoteDB
remoteDialect := s.remoteDialect
@@ -45,7 +53,7 @@ func (s *Store) SyncNow() (SyncResult, error) {
localDialect := s.localDialect
s.mu.RUnlock()
if remote == nil {
result := SyncResult{Direction: "remote_to_sqlite", Tables: map[string]int{}, FinishedAt: Now()}
result := SyncResult{Direction: "remote_to_sqlite", Status: "skipped", Skipped: true, Tables: map[string]int{}, FinishedAt: Now(), Warnings: []string{"remote database is not configured"}}
s.setSyncStatus(result, nil)
return result, nil
}
@@ -66,6 +74,10 @@ func (s *Store) setSyncStatus(result SyncResult, err error) {
s.status.LastSyncError = ""
}
func (s *Store) trySyncLock() bool {
return s.syncMu.TryLock()
}
type tableSpec struct {
Name string
Columns []string
@@ -88,6 +100,7 @@ var syncTables = []tableSpec{
{"source_endpoints", []string{"id", "category_id", "category_name", "source_id", "name", "description", "method", "api_url", "url_template", "thumbnail_url", "proxy_mode", "timeout_ms", "retry_count", "cache_seconds", "check_interval_sec", "enabled", "client_visible", "supported_formats", "last_status", "last_latency_ms", "last_checked_at", "last_error", "consecutive_failure", "created_at", "updated_at"}, []string{"id"}},
{"endpoint_health_checks", []string{"id", "source_db_id", "status", "latency_ms", "error", "checked_at"}, []string{"id"}},
{"endpoint_call_logs", []string{"id", "source_id", "status", "latency_ms", "error", "client", "created_at"}, []string{"id"}},
{"system_settings", []string{"key", "value", "updated_at"}, []string{"key"}},
{"audit_logs", []string{"id", "actor", "type", "target", "message", "ip", "user_agent", "created_at"}, []string{"id"}},
{"legacy_json_revisions", []string{"id", "name", "raw", "note", "created_by", "created_at"}, []string{"id"}},
{"webhook_deliveries", []string{"id", "webhook_name", "event", "status", "attempts", "response_code", "error_message", "payload_sha256", "created_at", "finished_at"}, []string{"id"}},
@@ -95,19 +108,22 @@ var syncTables = []tableSpec{
}
func copyAllTables(src *sql.DB, srcDialect dialect, dst *sql.DB, dstDialect dialect, direction string) (SyncResult, error) {
result := SyncResult{Direction: direction, Tables: map[string]int{}, FinishedAt: Now()}
result := SyncResult{Direction: direction, Status: "completed", Tables: map[string]int{}, FinishedAt: Now()}
for _, table := range syncTables {
count, err := copyTable(src, srcDialect, dst, dstDialect, table)
if err != nil {
result.Status = "failed"
result.FinishedAt = Now()
return result, err
}
result.Tables[table.Name] = count
}
result.FinishedAt = Now()
return result, nil
}
func copyTable(src *sql.DB, srcDialect dialect, dst *sql.DB, dstDialect dialect, spec tableSpec) (int, error) {
rows, err := src.Query(srcDialect.rebind("SELECT " + strings.Join(spec.Columns, ", ") + " FROM " + spec.Name))
rows, err := src.Query(srcDialect.rebind("SELECT " + srcDialect.columnList(spec.Columns) + " FROM " + spec.Name))
if err != nil {
return 0, err
}
@@ -42,6 +42,46 @@ func (d dialect) idType() string {
return "INTEGER PRIMARY KEY AUTOINCREMENT"
}
func (d dialect) keyTextType() string {
if d.name == "mysql" {
return "VARCHAR(191)"
}
return "TEXT"
}
func (d dialect) shortTextType() string {
if d.name == "mysql" {
return "VARCHAR(255)"
}
return "TEXT"
}
func (d dialect) mediumTextType() string {
if d.name == "mysql" {
return "VARCHAR(1024)"
}
return "TEXT"
}
func (d dialect) longTextType() string {
if d.name == "mysql" {
return "LONGTEXT"
}
return "TEXT"
}
func (d dialect) quoteIdent(identifier string) string {
return "`" + strings.ReplaceAll(identifier, "`", "``") + "`"
}
func (d dialect) columnList(columns []string) string {
quoted := make([]string, len(columns))
for index, column := range columns {
quoted[index] = d.quoteIdent(column)
}
return strings.Join(quoted, ", ")
}
func (d dialect) boolExpr(value bool) int {
if value {
return 1
@@ -54,7 +94,7 @@ func (d dialect) upsert(table string, columns, conflict []string) string {
for i := range placeholders {
placeholders[i] = "?"
}
base := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", table, strings.Join(columns, ", "), strings.Join(placeholders, ", "))
base := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", table, d.columnList(columns), strings.Join(placeholders, ", "))
conflictSet := map[string]bool{}
for _, column := range conflict {
conflictSet[column] = true
@@ -64,10 +104,11 @@ func (d dialect) upsert(table string, columns, conflict []string) string {
if conflictSet[column] {
continue
}
quoted := d.quoteIdent(column)
if d.name == "mysql" {
updates = append(updates, fmt.Sprintf("%s = VALUES(%s)", column, column))
updates = append(updates, fmt.Sprintf("%s = VALUES(%s)", quoted, quoted))
} else {
updates = append(updates, fmt.Sprintf("%s = excluded.%s", column, column))
updates = append(updates, fmt.Sprintf("%s = excluded.%s", quoted, quoted))
}
}
if len(updates) == 0 {
@@ -79,7 +120,7 @@ func (d dialect) upsert(table string, columns, conflict []string) string {
if d.name == "mysql" {
return base + " ON DUPLICATE KEY UPDATE " + strings.Join(updates, ", ")
}
return base + " ON CONFLICT (" + strings.Join(conflict, ", ") + ") DO UPDATE SET " + strings.Join(updates, ", ")
return base + " ON CONFLICT (" + d.columnList(conflict) + ") DO UPDATE SET " + strings.Join(updates, ", ")
}
func (d dialect) limitOffset(limit, offset int) string {
@@ -304,6 +304,36 @@ func (s *Store) UpsertMailRecord(item LegacyMailRecord) error {
return err
}
func (s *Store) InsertMailRecord(item LegacyMailRecord) (int64, error) {
if item.CreatedAt == "" {
item.CreatedAt = Now()
}
id, err := s.insertID(`INSERT INTO mail_records (
feedback_code, kind, status, to_address, subject, plain_body, html_body,
attachment_path, attachment_name, error_message, created_at, sent_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
sanitize(item.FeedbackCode), sanitize(firstNonEmpty(item.Kind, "feedback")), sanitize(firstNonEmpty(item.Status, "pending")),
sanitize(item.ToAddress), sanitizeLong(item.Subject, 1000), sanitizeLong(item.PlainBody, 12000), sanitizeLong(item.HTMLBody, 12000),
item.AttachmentPath, item.AttachmentName, sanitizeLong(item.ErrorMessage, 1000), item.CreatedAt, item.SentAt)
return id, err
}
func (s *Store) UpdateMailState(id int64, status, errorMessage string) error {
sentAt := ""
if status == "sent" {
sentAt = Now()
}
_, err := s.exec(`UPDATE mail_records SET status = ?, error_message = ?, sent_at = ? WHERE id = ?`,
sanitize(status), sanitizeLong(errorMessage, 1000), sentAt, id)
return err
}
func (s *Store) UpdateFeedbackMailState(code string, sent bool) error {
_, err := s.exec(`UPDATE feedback_tickets SET mail_sent = ?, updated_at = ?, last_activity_at = ? WHERE code = ?`,
boolInt(sent), Now(), Now(), code)
return err
}
func (s *Store) ListFeedbackEvents(code string, limit int) ([]LegacyFeedbackEvent, error) {
if limit <= 0 || limit > 200 {
limit = 100
@@ -34,6 +34,9 @@ type DatabaseStatus struct {
type SyncResult struct {
Direction string `json:"direction"`
Status string `json:"status"`
Skipped bool `json:"skipped"`
Warnings []string `json:"warnings,omitempty"`
Tables map[string]int `json:"tables"`
FinishedAt string `json:"finishedAt"`
}
@@ -121,6 +124,8 @@ type LegacyMailRecord struct {
Status string `json:"status"`
ToAddress string `json:"toAddress"`
Subject string `json:"subject"`
PlainBody string `json:"plainBody,omitempty"`
HTMLBody string `json:"htmlBody,omitempty"`
AttachmentPath string `json:"attachmentPath"`
AttachmentName string `json:"attachmentName"`
ErrorMessage string `json:"errorMessage"`
@@ -272,6 +277,21 @@ type AuditLog struct {
CreatedAt string `json:"createdAt"`
}
type AuditFilters struct {
Page int
PerPage int
Type string
Target string
Query string
}
type AuditPage struct {
Items []AuditLog `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
PerPage int `json:"perPage"`
}
type LegacyJsonRevision struct {
ID int64 `json:"id"`
Name string `json:"name"`
+195 -184
View File
@@ -26,252 +26,261 @@ func (s *Store) migrate(conn *sql.DB, d dialect) error {
}
func schemaStatements(d dialect) []string {
keyText := d.keyTextType()
shortText := d.shortTextType()
mediumText := d.mediumTextType()
longText := d.longTextType()
return []string{
`CREATE TABLE IF NOT EXISTS schema_migrations (
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS schema_migrations (
version VARCHAR(64) NOT NULL PRIMARY KEY,
applied_at TEXT NOT NULL,
applied_at %s NOT NULL,
description VARCHAR(255) NOT NULL DEFAULT ''
)`,
)`, shortText),
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS admin_users (
id %s,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
username %s NOT NULL UNIQUE,
password_hash %s NOT NULL,
password_changed INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`, d.idType()),
created_at %s NOT NULL,
updated_at %s NOT NULL
)`, d.idType(), keyText, shortText, shortText, shortText),
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS sessions (
id %s,
session_id TEXT NOT NULL UNIQUE,
username TEXT NOT NULL,
csrf TEXT NOT NULL,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL
)`, d.idType()),
session_id %s NOT NULL UNIQUE,
username %s NOT NULL,
csrf %s NOT NULL,
expires_at %s NOT NULL,
created_at %s NOT NULL
)`, d.idType(), keyText, keyText, shortText, shortText, shortText),
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS release_packages (
id %s,
product TEXT NOT NULL,
version TEXT NOT NULL,
platform TEXT NOT NULL,
arch TEXT NOT NULL,
file_name TEXT NOT NULL UNIQUE,
url TEXT NOT NULL,
sha256 TEXT NOT NULL,
product %s NOT NULL,
version %s NOT NULL,
platform %s NOT NULL,
arch %s NOT NULL,
file_name %s NOT NULL UNIQUE,
url %s NOT NULL,
sha256 %s NOT NULL,
size_bytes BIGINT NOT NULL DEFAULT 0,
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`, d.idType()),
created_at %s NOT NULL,
updated_at %s NOT NULL
)`, d.idType(), keyText, keyText, keyText, keyText, keyText, mediumText, shortText, shortText, shortText),
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS release_notices (
id %s,
version TEXT NOT NULL UNIQUE,
build TEXT NOT NULL DEFAULT '',
channel TEXT NOT NULL DEFAULT 'stable',
title TEXT NOT NULL DEFAULT '',
message TEXT NOT NULL DEFAULT '',
release_notes TEXT NOT NULL DEFAULT '',
message_md TEXT NOT NULL DEFAULT '',
release_notes_md TEXT NOT NULL DEFAULT '',
download_url TEXT NOT NULL DEFAULT '',
notice_file TEXT NOT NULL DEFAULT '',
raw_json TEXT NOT NULL,
published_at TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`, d.idType()),
version %s NOT NULL UNIQUE,
build %s NOT NULL DEFAULT '',
channel %s NOT NULL DEFAULT 'stable',
title %s NOT NULL DEFAULT '',
message %s NOT NULL,
release_notes %s NOT NULL,
message_md %s NOT NULL,
release_notes_md %s NOT NULL,
download_url %s NOT NULL DEFAULT '',
notice_file %s NOT NULL DEFAULT '',
raw_json %s NOT NULL,
published_at %s NOT NULL DEFAULT '',
created_at %s NOT NULL,
updated_at %s NOT NULL
)`, d.idType(), keyText, shortText, shortText, mediumText, longText, longText, longText, longText, mediumText, keyText, longText, shortText, shortText, shortText),
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS release_notice_revisions (
id %s,
version TEXT NOT NULL,
raw_json TEXT NOT NULL,
note TEXT NOT NULL DEFAULT '',
created_by TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
)`, d.idType()),
version %s NOT NULL,
raw_json %s NOT NULL,
note %s NOT NULL DEFAULT '',
created_by %s NOT NULL DEFAULT '',
created_at %s NOT NULL
)`, d.idType(), keyText, longText, mediumText, keyText, shortText),
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS feedback_tickets (
code TEXT PRIMARY KEY,
title TEXT NOT NULL,
type TEXT NOT NULL,
severity TEXT NOT NULL,
category TEXT NOT NULL DEFAULT '',
priority TEXT NOT NULL DEFAULT '',
contact TEXT NOT NULL DEFAULT '',
body TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL,
status_detail TEXT NOT NULL DEFAULT '',
public_reply TEXT NOT NULL DEFAULT '',
note TEXT NOT NULL DEFAULT '',
assignee TEXT NOT NULL DEFAULT '',
handled_by TEXT NOT NULL DEFAULT '',
due_at TEXT NOT NULL DEFAULT '',
resolved_at TEXT NOT NULL DEFAULT '',
archived_at TEXT NOT NULL DEFAULT '',
sla_level TEXT NOT NULL DEFAULT '',
source_channel TEXT NOT NULL DEFAULT '',
code %s PRIMARY KEY,
title %s NOT NULL,
type %s NOT NULL,
severity %s NOT NULL,
category %s NOT NULL DEFAULT '',
priority %s NOT NULL DEFAULT '',
contact %s NOT NULL DEFAULT '',
body %s NOT NULL,
status %s NOT NULL,
status_detail %s NOT NULL DEFAULT '',
public_reply %s NOT NULL,
note %s NOT NULL,
assignee %s NOT NULL DEFAULT '',
handled_by %s NOT NULL DEFAULT '',
due_at %s NOT NULL DEFAULT '',
resolved_at %s NOT NULL DEFAULT '',
archived_at %s NOT NULL DEFAULT '',
sla_level %s NOT NULL DEFAULT '',
source_channel %s NOT NULL DEFAULT '',
risk_score INTEGER NOT NULL DEFAULT 0,
resolution TEXT NOT NULL DEFAULT '',
attachment TEXT NOT NULL DEFAULT '',
package_path TEXT NOT NULL DEFAULT '',
encrypted_package_path TEXT NOT NULL DEFAULT '',
package_sha256 TEXT NOT NULL DEFAULT '',
plain_package_sha256 TEXT NOT NULL DEFAULT '',
summary_text TEXT NOT NULL DEFAULT '',
included_files TEXT NOT NULL DEFAULT '',
resolution %s NOT NULL,
attachment %s NOT NULL DEFAULT '',
package_path %s NOT NULL DEFAULT '',
encrypted_package_path %s NOT NULL DEFAULT '',
package_sha256 %s NOT NULL DEFAULT '',
plain_package_sha256 %s NOT NULL DEFAULT '',
summary_text %s NOT NULL,
included_files %s NOT NULL,
mail_sent INTEGER NOT NULL DEFAULT 0,
remote_addr TEXT NOT NULL DEFAULT '',
tags TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
last_activity_at TEXT NOT NULL
)`),
remote_addr %s NOT NULL DEFAULT '',
tags %s NOT NULL,
created_at %s NOT NULL,
updated_at %s NOT NULL,
last_activity_at %s NOT NULL
)`, keyText, mediumText, keyText, keyText, keyText, keyText, mediumText, longText, keyText, mediumText, longText, longText, keyText, keyText, shortText, shortText, shortText, keyText, keyText, longText, mediumText, mediumText, mediumText, shortText, shortText, longText, longText, shortText, longText, shortText, shortText, shortText),
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS feedback_comments (
id %s,
feedback_code TEXT NOT NULL,
author TEXT NOT NULL DEFAULT '',
body TEXT NOT NULL,
feedback_code %s NOT NULL,
author %s NOT NULL DEFAULT '',
body %s NOT NULL,
internal INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL
)`, d.idType()),
created_at %s NOT NULL
)`, d.idType(), keyText, keyText, longText, shortText),
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS feedback_attachments (
id %s,
feedback_code TEXT NOT NULL,
kind TEXT NOT NULL,
path TEXT NOT NULL,
file_name TEXT NOT NULL,
sha256 TEXT NOT NULL DEFAULT '',
feedback_code %s NOT NULL,
kind %s NOT NULL,
path %s NOT NULL,
file_name %s NOT NULL,
sha256 %s NOT NULL DEFAULT '',
size_bytes BIGINT NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
)`, d.idType()),
created_at %s NOT NULL
)`, d.idType(), keyText, keyText, mediumText, mediumText, shortText, shortText),
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS feedback_events (
id %s,
feedback_code TEXT NOT NULL,
event_type TEXT NOT NULL,
actor TEXT NOT NULL DEFAULT '',
from_value TEXT NOT NULL DEFAULT '',
to_value TEXT NOT NULL DEFAULT '',
message TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
)`, d.idType()),
`CREATE TABLE IF NOT EXISTS feedback_tags (
feedback_code TEXT NOT NULL,
tag TEXT NOT NULL,
created_at TEXT NOT NULL,
feedback_code %s NOT NULL,
event_type %s NOT NULL,
actor %s NOT NULL DEFAULT '',
from_value %s NOT NULL DEFAULT '',
to_value %s NOT NULL DEFAULT '',
message %s NOT NULL DEFAULT '',
created_at %s NOT NULL
)`, d.idType(), keyText, keyText, keyText, mediumText, mediumText, mediumText, shortText),
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS feedback_tags (
feedback_code %s NOT NULL,
tag %s NOT NULL,
created_at %s NOT NULL,
PRIMARY KEY (feedback_code, tag)
)`,
)`, keyText, keyText, shortText),
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS mail_records (
id %s,
feedback_code TEXT NOT NULL DEFAULT '',
kind TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT '',
to_address TEXT NOT NULL DEFAULT '',
subject TEXT NOT NULL DEFAULT '',
plain_body TEXT NOT NULL DEFAULT '',
html_body TEXT NOT NULL DEFAULT '',
attachment_path TEXT NOT NULL DEFAULT '',
attachment_name TEXT NOT NULL DEFAULT '',
error_message TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
sent_at TEXT NOT NULL DEFAULT ''
)`, d.idType()),
feedback_code %s NOT NULL DEFAULT '',
kind %s NOT NULL DEFAULT '',
status %s NOT NULL DEFAULT '',
to_address %s NOT NULL DEFAULT '',
subject %s NOT NULL DEFAULT '',
plain_body %s NOT NULL,
html_body %s NOT NULL,
attachment_path %s NOT NULL DEFAULT '',
attachment_name %s NOT NULL DEFAULT '',
error_message %s NOT NULL,
created_at %s NOT NULL,
sent_at %s NOT NULL DEFAULT ''
)`, d.idType(), keyText, keyText, keyText, mediumText, mediumText, longText, longText, mediumText, mediumText, longText, shortText, shortText),
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS source_categories (
id %s,
category_id TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
category_id %s NOT NULL UNIQUE,
name %s NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
ui_config TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`, d.idType()),
ui_config %s NOT NULL,
created_at %s NOT NULL,
updated_at %s NOT NULL
)`, d.idType(), keyText, shortText, longText, shortText, shortText),
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS source_endpoints (
id %s,
category_id TEXT NOT NULL,
category_name TEXT NOT NULL,
source_id TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
method TEXT NOT NULL DEFAULT 'GET',
api_url TEXT NOT NULL DEFAULT '',
url_template TEXT NOT NULL DEFAULT '',
thumbnail_url TEXT NOT NULL DEFAULT '',
proxy_mode TEXT NOT NULL DEFAULT 'client_direct',
category_id %s NOT NULL,
category_name %s NOT NULL,
source_id %s NOT NULL UNIQUE,
name %s NOT NULL,
description %s NOT NULL DEFAULT '',
method %s NOT NULL DEFAULT 'GET',
api_url %s NOT NULL DEFAULT '',
url_template %s NOT NULL DEFAULT '',
thumbnail_url %s NOT NULL DEFAULT '',
proxy_mode %s NOT NULL DEFAULT 'client_direct',
timeout_ms INTEGER NOT NULL DEFAULT 8000,
retry_count INTEGER NOT NULL DEFAULT 1,
cache_seconds INTEGER NOT NULL DEFAULT 300,
check_interval_sec INTEGER NOT NULL DEFAULT 300,
enabled INTEGER NOT NULL DEFAULT 1,
client_visible INTEGER NOT NULL DEFAULT 1,
supported_formats TEXT NOT NULL DEFAULT '[]',
last_status TEXT NOT NULL DEFAULT 'unknown',
supported_formats %s NOT NULL,
last_status %s NOT NULL DEFAULT 'unknown',
last_latency_ms INTEGER NOT NULL DEFAULT 0,
last_checked_at TEXT NOT NULL DEFAULT '',
last_error TEXT NOT NULL DEFAULT '',
last_checked_at %s NOT NULL DEFAULT '',
last_error %s NOT NULL,
consecutive_failure INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`, d.idType()),
created_at %s NOT NULL,
updated_at %s NOT NULL
)`, d.idType(), keyText, shortText, keyText, shortText, mediumText, keyText, mediumText, mediumText, mediumText, keyText, longText, keyText, shortText, longText, shortText, shortText),
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS endpoint_health_checks (
id %s,
source_db_id BIGINT NOT NULL,
status TEXT NOT NULL,
status %s NOT NULL,
latency_ms INTEGER NOT NULL DEFAULT 0,
error TEXT NOT NULL DEFAULT '',
checked_at TEXT NOT NULL
)`, d.idType()),
error %s NOT NULL,
checked_at %s NOT NULL
)`, d.idType(), keyText, longText, shortText),
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS endpoint_call_logs (
id %s,
source_id TEXT NOT NULL,
status TEXT NOT NULL,
source_id %s NOT NULL,
status %s NOT NULL,
latency_ms INTEGER NOT NULL DEFAULT 0,
error TEXT NOT NULL DEFAULT '',
client TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
)`, d.idType()),
error %s NOT NULL,
client %s NOT NULL DEFAULT '',
created_at %s NOT NULL
)`, d.idType(), keyText, keyText, longText, mediumText, shortText),
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS database_sync_jobs (
id %s,
direction TEXT NOT NULL,
status TEXT NOT NULL,
message TEXT NOT NULL DEFAULT '',
tables_json TEXT NOT NULL DEFAULT '{}',
started_at TEXT NOT NULL,
finished_at TEXT NOT NULL DEFAULT ''
)`, d.idType()),
direction %s NOT NULL,
status %s NOT NULL,
message %s NOT NULL,
tables_json %s NOT NULL,
started_at %s NOT NULL,
finished_at %s NOT NULL DEFAULT ''
)`, d.idType(), keyText, keyText, longText, longText, shortText, shortText),
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS system_settings (
%s %s NOT NULL PRIMARY KEY,
value %s NOT NULL,
updated_at %s NOT NULL
)`, d.quoteIdent("key"), keyText, longText, shortText),
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS legacy_sync_jobs (
id %s,
status TEXT NOT NULL,
summary TEXT NOT NULL DEFAULT '',
stats_json TEXT NOT NULL DEFAULT '{}',
started_at TEXT NOT NULL,
finished_at TEXT NOT NULL DEFAULT ''
)`, d.idType()),
status %s NOT NULL,
summary %s NOT NULL,
stats_json %s NOT NULL,
started_at %s NOT NULL,
finished_at %s NOT NULL DEFAULT ''
)`, d.idType(), keyText, longText, longText, shortText, shortText),
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS audit_logs (
id %s,
actor TEXT NOT NULL DEFAULT '',
type TEXT NOT NULL,
target TEXT NOT NULL DEFAULT '',
message TEXT NOT NULL DEFAULT '',
ip TEXT NOT NULL DEFAULT '',
user_agent TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
)`, d.idType()),
actor %s NOT NULL DEFAULT '',
type %s NOT NULL,
target %s NOT NULL DEFAULT '',
message %s NOT NULL,
ip %s NOT NULL DEFAULT '',
user_agent %s NOT NULL DEFAULT '',
created_at %s NOT NULL
)`, d.idType(), keyText, keyText, keyText, longText, keyText, mediumText, shortText),
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS legacy_json_revisions (
id %s,
name TEXT NOT NULL,
raw TEXT NOT NULL,
note TEXT NOT NULL DEFAULT '',
created_by TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
)`, d.idType()),
name %s NOT NULL,
raw %s NOT NULL,
note %s NOT NULL DEFAULT '',
created_by %s NOT NULL DEFAULT '',
created_at %s NOT NULL
)`, d.idType(), keyText, longText, mediumText, keyText, shortText),
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS webhook_deliveries (
id %s,
webhook_name TEXT NOT NULL DEFAULT '',
event TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT '',
webhook_name %s NOT NULL DEFAULT '',
event %s NOT NULL DEFAULT '',
status %s NOT NULL DEFAULT '',
attempts INTEGER NOT NULL DEFAULT 0,
response_code INTEGER NOT NULL DEFAULT 0,
error_message TEXT NOT NULL DEFAULT '',
payload_sha256 TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
finished_at TEXT NOT NULL DEFAULT ''
)`, d.idType()),
error_message %s NOT NULL,
payload_sha256 %s NOT NULL DEFAULT '',
created_at %s NOT NULL,
finished_at %s NOT NULL DEFAULT ''
)`, d.idType(), keyText, keyText, keyText, longText, shortText, shortText, shortText),
`CREATE INDEX IF NOT EXISTS idx_feedback_tickets_activity ON feedback_tickets(last_activity_at)`,
`CREATE INDEX IF NOT EXISTS idx_feedback_comments_code ON feedback_comments(feedback_code)`,
`CREATE INDEX IF NOT EXISTS idx_feedback_attachments_code ON feedback_attachments(feedback_code)`,
@@ -279,6 +288,8 @@ func schemaStatements(d dialect) []string {
`CREATE INDEX IF NOT EXISTS idx_mail_records_code ON mail_records(feedback_code)`,
`CREATE INDEX IF NOT EXISTS idx_endpoint_call_logs_source ON endpoint_call_logs(source_id)`,
`CREATE INDEX IF NOT EXISTS idx_audit_logs_created ON audit_logs(created_at)`,
`CREATE INDEX IF NOT EXISTS idx_audit_logs_type ON audit_logs(type)`,
`CREATE INDEX IF NOT EXISTS idx_audit_logs_target ON audit_logs(target)`,
`CREATE INDEX IF NOT EXISTS idx_legacy_json_revisions_name ON legacy_json_revisions(name, id)`,
`CREATE INDEX IF NOT EXISTS idx_release_notices_version ON release_notices(version)`,
`CREATE INDEX IF NOT EXISTS idx_release_notice_revisions_version ON release_notice_revisions(version, id)`,
@@ -0,0 +1,17 @@
package db
func (s *Store) GetSetting(key string) (string, error) {
var value string
err := s.queryRow("SELECT value FROM system_settings WHERE `key` = ?", sanitize(key)).Scan(&value)
return value, err
}
func (s *Store) UpsertSetting(key, value string) error {
columns := []string{"key", "value", "updated_at"}
conn, d := s.active()
_, err := conn.Exec(d.rebind(d.upsert("system_settings", columns, []string{"key"})), sanitize(key), value, Now())
if err != nil {
s.markFailover(err)
}
return err
}
@@ -110,8 +110,8 @@ func (s *Store) RecordSourceCheck(sourceDBID int64, status string, latency int,
return err
}
if status == "ok" {
_, err = s.exec(`UPDATE source_endpoints SET last_status = ?, last_latency_ms = ?, last_checked_at = ?, last_error = '', consecutive_failure = 0, updated_at = ? WHERE id = ?`,
status, latency, now, now, sourceDBID)
_, err = s.exec(`UPDATE source_endpoints SET last_status = ?, last_latency_ms = ?, last_checked_at = ?, last_error = ?, consecutive_failure = 0, updated_at = ? WHERE id = ?`,
status, latency, now, sanitize(message), now, sourceDBID)
} else if status == "redirected" {
_, err = s.exec(`UPDATE source_endpoints SET last_status = ?, last_latency_ms = ?, last_checked_at = ?, last_error = ?, consecutive_failure = 0, updated_at = ? WHERE id = ?`,
status, latency, now, sanitize(message), now, sourceDBID)
@@ -14,6 +14,7 @@ import (
type Store struct {
mu sync.RWMutex
syncMu sync.Mutex
cfg *config.Config
path string
db *sql.DB
@@ -114,6 +115,80 @@ func (s *Store) Path() string {
return s.path
}
func (s *Store) ReconfigureDatabase(cfg *config.Config) error {
if cfg == nil {
return nil
}
path := cfg.Database.SQLitePath
if strings.TrimSpace(path) == "" {
path = filepath.Join(cfg.StorageDir, "unified.sqlite")
}
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
return err
}
if err := os.MkdirAll(cfg.StorageDir, 0o750); err != nil {
return err
}
localCfg := cfg.Database
localCfg.Provider = "sqlite"
localCfg.SQLitePath = path
local, localDialect, err := openSQLDatabase(localCfg)
if err != nil {
return err
}
local.SetMaxOpenConns(1)
if err := s.migrate(local, localDialect); err != nil {
_ = local.Close()
return err
}
var remote *sql.DB
var remoteDialect dialect
if strings.EqualFold(cfg.Database.Provider, "mysql") {
remote, remoteDialect, err = openSQLDatabase(cfg.Database)
if err != nil {
_ = local.Close()
return err
}
if err := s.migrate(remote, remoteDialect); err != nil {
_ = remote.Close()
_ = local.Close()
return err
}
}
s.mu.Lock()
oldLocal := s.localDB
oldRemote := s.remoteDB
s.cfg.Database = cfg.Database
s.path = path
s.localDB = local
s.localDialect = localDialect
s.remoteDB = remote
s.remoteDialect = remoteDialect
s.status.ConfigProvider = cfg.Database.Provider
s.status.SQLiteReady = true
s.status.RemoteReady = remote != nil
s.status.LastError = ""
s.status.FailoverActive = false
if remote != nil {
s.db = remote
s.dialect = remoteDialect
s.status.ActiveProvider = "mysql"
} else {
s.db = local
s.dialect = localDialect
s.status.ActiveProvider = "sqlite"
}
s.status.LastRecoveredAt = Now()
s.mu.Unlock()
if oldRemote != nil && oldRemote != oldLocal && oldRemote != remote {
_ = oldRemote.Close()
}
if oldLocal != nil && oldLocal != local {
_ = oldLocal.Close()
}
return nil
}
func (s *Store) active() (*sql.DB, dialect) {
s.mu.RLock()
defer s.mu.RUnlock()
@@ -6,6 +6,7 @@ import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"ymhut-box/server/unified-management/internal/config"
@@ -273,3 +274,75 @@ func TestChangeAdminPasswordRejectsWeakPasswords(t *testing.T) {
}
}
}
func TestMySQLSchemaAvoidsTextKeys(t *testing.T) {
statements := strings.Join(schemaStatements(dialectFor("mysql")), "\n")
for _, forbidden := range []string{
"TEXT NOT NULL UNIQUE",
"TEXT PRIMARY KEY",
"TEXT NOT NULL PRIMARY KEY",
"key VARCHAR(191) NOT NULL PRIMARY KEY",
} {
if strings.Contains(statements, forbidden) {
t.Fatalf("mysql schema contains forbidden fragment %q:\n%s", forbidden, statements)
}
}
if !strings.Contains(statements, "`key` VARCHAR(191) NOT NULL PRIMARY KEY") {
t.Fatalf("system_settings.key must be quoted for MySQL:\n%s", statements)
}
}
func TestDashboardOverviewKeepsChecksForDeletedSources(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "unified.sqlite")
store, err := Open(&config.Config{
StorageDir: root,
Database: config.DatabaseConfig{
Provider: "sqlite",
SQLitePath: path,
FailoverEnabled: true,
HealthIntervalSec: 3600,
MaxOpenConns: 1,
MaxIdleConns: 1,
ConnMaxLifetimeSeconds: 60,
},
})
if err != nil {
t.Fatal(err)
}
defer store.Close()
source, err := store.UpsertSource(Source{
CategoryID: "video",
CategoryName: "视频",
SourceID: "video-demo",
Name: "演示接口",
APIURL: "https://example.com/video.json",
Enabled: true,
ClientVisible: true,
})
if err != nil {
t.Fatal(err)
}
if err := store.RecordSourceCheck(source.ID, "ok", 123, ""); err != nil {
t.Fatal(err)
}
if err := store.DeleteSource(source.SourceID); err != nil {
t.Fatal(err)
}
overview, err := store.DashboardOverview(10)
if err != nil {
t.Fatal(err)
}
checks, ok := overview["heartbeats"].([]map[string]any)
if !ok {
t.Fatalf("heartbeats has unexpected type %T", overview["heartbeats"])
}
if len(checks) != 1 {
t.Fatalf("expected deleted source check to remain visible, got %d", len(checks))
}
if checks[0]["sourceId"] == "" || checks[0]["name"] == "" {
t.Fatalf("deleted source check should have fallback sourceId/name: %#v", checks[0])
}
}
@@ -23,6 +23,7 @@ import (
"ymhut-box/server/unified-management/internal/config"
"ymhut-box/server/unified-management/internal/db"
feedbackmail "ymhut-box/server/unified-management/internal/mail"
)
const PackageMagic = "YMHUTFB1"
@@ -79,14 +80,70 @@ func NewService(cfg *config.Config, store *db.Store) *Service {
func (s *Service) Submit(r *http.Request) (db.Feedback, error) {
contentType := r.Header.Get("Content-Type")
var item db.Feedback
var err error
if strings.Contains(contentType, "multipart/form-data") {
if item, err := s.submitMultipart(r); err == nil {
if item, err = s.submitMultipart(r); err == nil {
if !DuplicateSubmission(r) && s.NotifyFeedback(item) == nil {
item.MailSent = true
}
return item, nil
} else if hasSignedFields(r) || !strings.Contains(strings.ToLower(err.Error()), "signed multipart fields are required") {
return db.Feedback{}, err
}
}
return s.submitSimple(r)
item, err = s.submitSimple(r)
if err == nil && s.NotifyFeedback(item) == nil {
item.MailSent = true
}
return item, err
}
func (s *Service) RetryMail(code string) error {
item, err := s.store.GetFeedback(NormalizeCode(code))
if err != nil {
return err
}
return s.NotifyFeedback(item)
}
func (s *Service) NotifyFeedback(item db.Feedback) error {
message, err := feedbackmail.BuildFeedbackMessage(s.cfg, item)
if err != nil {
_, _ = s.store.InsertMailRecord(db.LegacyMailRecord{
FeedbackCode: item.Code,
Kind: "feedback",
Status: "failed",
Subject: "反馈邮件未发送",
ErrorMessage: err.Error(),
CreatedAt: db.Now(),
})
_ = s.store.UpdateFeedbackMailState(item.Code, false)
return err
}
mailID, err := s.store.InsertMailRecord(db.LegacyMailRecord{
FeedbackCode: item.Code,
Kind: "feedback",
Status: "pending",
ToAddress: message.To,
Subject: message.Subject,
PlainBody: message.PlainBody,
HTMLBody: message.HTMLBody,
AttachmentPath: message.AttachmentPath,
AttachmentName: message.AttachmentName,
CreatedAt: db.Now(),
})
if err != nil {
return err
}
if err := feedbackmail.Send(s.cfg, message); err != nil {
_ = s.store.UpdateMailState(mailID, "failed", err.Error())
_ = s.store.UpdateFeedbackMailState(item.Code, false)
return err
}
_ = s.store.UpdateMailState(mailID, "sent", "")
_ = s.store.UpdateFeedbackMailState(item.Code, true)
return nil
}
func (s *Service) submitSimple(r *http.Request) (db.Feedback, error) {
@@ -230,12 +230,12 @@ func validate(name string, parsed map[string]any) error {
case "update-info":
if _, ok := parsed["app_version"]; !ok {
if _, ok := parsed["title"]; !ok {
return errors.New("update-info requires app_version or title")
return errors.New("更新 JSON 需要填写 app_version title")
}
}
case "media-types":
if _, ok := parsed["categories"].([]any); !ok {
return errors.New("media-types requires categories array")
return errors.New("媒体源 JSON 需要包含 categories 数组")
}
if _, ok := parsed["layout_version"]; !ok {
parsed["layout_version"] = "1.0.0"
@@ -0,0 +1,357 @@
package mail
import (
"crypto/tls"
"encoding/base64"
"errors"
"fmt"
"mime"
"net"
"net/smtp"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"ymhut-box/server/unified-management/internal/config"
"ymhut-box/server/unified-management/internal/db"
)
type Message struct {
From string
FromName string
To string
Subject string
PlainBody string
HTMLBody string
AttachmentPath string
AttachmentName string
}
func SafeConfig(cfg config.MailConfig) map[string]any {
return map[string]any{
"host": cfg.Host,
"port": cfg.Port,
"secure": cfg.Secure,
"username": cfg.Username,
"fromAddress": cfg.FromAddress,
"fromName": cfg.FromName,
"developerAddress": cfg.DeveloperAddress,
"timeoutSeconds": cfg.TimeoutSeconds,
"hasPassword": strings.TrimSpace(cfg.Password) != "",
"configured": IsConfigured(cfg),
}
}
func IsConfigured(cfg config.MailConfig) bool {
channel := normalize(cfg)
return channel.Host != "" && channel.FromAddress != "" && channel.DeveloperAddress != ""
}
func BuildFeedbackMessage(cfg *config.Config, record db.Feedback) (Message, error) {
channel, err := channel(cfg.Mail)
if err != nil {
return Message{}, err
}
attachment := record.PackagePath
name := ""
if attachment != "" {
name = record.Code + ".zip"
}
subject := "[" + record.Code + "] YMhut Box 反馈:" + truncate(record.Title, 80)
return Message{
From: channel.FromAddress,
FromName: channel.FromName,
To: channel.DeveloperAddress,
Subject: subject,
PlainBody: feedbackPlain(record),
HTMLBody: feedbackHTML(record),
AttachmentPath: attachment,
AttachmentName: name,
}, nil
}
func BuildTestMessage(cfg *config.Config) (Message, error) {
channel, err := channel(cfg.Mail)
if err != nil {
return Message{}, err
}
now := time.Now().UTC().Format(time.RFC3339)
return Message{
From: channel.FromAddress,
FromName: channel.FromName,
To: channel.DeveloperAddress,
Subject: "YMhut Box 反馈通知测试",
PlainBody: "这是一封来自 unified-management 的测试通知。\n时间:" + now,
HTMLBody: "<p>这是一封来自 unified-management 的测试通知。</p><p>时间:" + htmlEscape(now) + "</p>",
}, nil
}
func Send(cfg *config.Config, message Message) error {
channel, err := channel(cfg.Mail)
if err != nil {
return err
}
raw, err := BuildMIME(message)
if err != nil {
return err
}
return smtpSend(channel, message.From, message.To, raw)
}
func BuildMIME(message Message) (string, error) {
boundary := "ymhut_" + randomish()
altBoundary := "ymhut_alt_" + randomish()
headers := []string{
"Date: " + time.Now().UTC().Format("Mon, 02 Jan 2006 15:04:05") + " +0000",
"From: " + mimeAddress(message.From, message.FromName),
"To: " + message.To,
"Subject: " + mime.BEncoding.Encode("UTF-8", message.Subject),
"MIME-Version: 1.0",
`Content-Type: multipart/mixed; boundary="` + boundary + `"`,
}
body := []string{
"--" + boundary,
`Content-Type: multipart/alternative; boundary="` + altBoundary + `"`,
"",
"--" + altBoundary,
"Content-Type: text/plain; charset=UTF-8",
"Content-Transfer-Encoding: base64",
"",
wrapBase64([]byte(message.PlainBody)),
"--" + altBoundary,
"Content-Type: text/html; charset=UTF-8",
"Content-Transfer-Encoding: base64",
"",
wrapBase64([]byte(message.HTMLBody)),
"--" + altBoundary + "--",
}
if message.AttachmentPath != "" {
data, err := os.ReadFile(message.AttachmentPath)
if err == nil {
name := firstNonEmpty(message.AttachmentName, filepath.Base(message.AttachmentPath))
escaped := strings.ReplaceAll(strings.ReplaceAll(name, `\`, `\\`), `"`, `\"`)
body = append(body,
"--"+boundary,
`Content-Type: application/zip; name="`+escaped+`"`,
"Content-Transfer-Encoding: base64",
`Content-Disposition: attachment; filename="`+escaped+`"`,
"",
wrapBase64(data),
)
}
}
body = append(body, "--"+boundary+"--")
return strings.Join(headers, "\r\n") + "\r\n\r\n" + strings.Join(body, "\r\n"), nil
}
func channel(cfg config.MailConfig) (config.MailConfig, error) {
cfg = normalize(cfg)
if cfg.Host == "" || cfg.FromAddress == "" || cfg.DeveloperAddress == "" {
return cfg, errors.New("mail is not configured")
}
return cfg, nil
}
func normalize(cfg config.MailConfig) config.MailConfig {
cfg.Secure = strings.ToLower(strings.TrimSpace(cfg.Secure))
if cfg.Secure == "" {
cfg.Secure = "ssl"
}
if cfg.Port <= 0 {
cfg.Port = 465
}
if cfg.FromAddress == "" {
cfg.FromAddress = cfg.Username
}
if cfg.FromName == "" {
cfg.FromName = "YMhut Box Feedback"
}
if cfg.TimeoutSeconds <= 0 {
cfg.TimeoutSeconds = 20
}
return cfg
}
func smtpSend(channel config.MailConfig, from, to, rawMessage string) error {
address := net.JoinHostPort(channel.Host, fmt.Sprintf("%d", channel.Port))
timeout := time.Duration(channel.TimeoutSeconds) * time.Second
var client *smtp.Client
if channel.Secure == "ssl" || channel.Secure == "tls" {
conn, err := tls.DialWithDialer(&net.Dialer{Timeout: timeout}, "tcp", address, &tls.Config{ServerName: channel.Host})
if err != nil {
return fmt.Errorf("邮件服务器连接失败:%w", err)
}
var clientErr error
client, clientErr = smtp.NewClient(conn, channel.Host)
if clientErr != nil {
_ = conn.Close()
return clientErr
}
} else {
conn, err := net.DialTimeout("tcp", address, timeout)
if err != nil {
return fmt.Errorf("邮件服务器连接失败:%w", err)
}
var clientErr error
client, clientErr = smtp.NewClient(conn, channel.Host)
if clientErr != nil {
_ = conn.Close()
return clientErr
}
}
defer client.Close()
if channel.Secure == "starttls" {
if err := client.StartTLS(&tls.Config{ServerName: channel.Host}); err != nil {
return fmt.Errorf("邮件加密握手失败:%w", err)
}
}
if channel.Username != "" || channel.Password != "" {
if err := client.Auth(smtp.PlainAuth("", channel.Username, channel.Password, channel.Host)); err != nil {
return fmt.Errorf("邮件认证失败:%w", err)
}
}
if err := client.Mail(extractEmail(from)); err != nil {
return err
}
if err := client.Rcpt(extractEmail(to)); err != nil {
return err
}
writer, err := client.Data()
if err != nil {
return err
}
if _, err := writer.Write([]byte(rawMessage)); err != nil {
_ = writer.Close()
return err
}
if err := writer.Close(); err != nil {
return err
}
return client.Quit()
}
func feedbackPlain(record db.Feedback) string {
return strings.Join([]string{
"YMhut Box 反馈工单",
"反馈编号:" + record.Code,
"标题:" + record.Title,
"类型:" + typeLabel(record.Type),
"优先级:" + priorityLabel(record.Priority, record.Severity),
"联系方式:" + record.Contact,
"接收时间:" + record.CreatedAt,
"包含文件:" + record.IncludedFiles,
"反馈包 SHA256" + record.PlainPackageSha256,
"",
"正文:",
record.Body,
"",
"反馈包摘要:",
record.SummaryText,
}, "\n")
}
func feedbackHTML(record db.Feedback) string {
rows := [][2]string{
{"反馈编号", record.Code},
{"标题", record.Title},
{"类型", typeLabel(record.Type)},
{"优先级", priorityLabel(record.Priority, record.Severity)},
{"联系方式", record.Contact},
{"接收时间", record.CreatedAt},
{"包含文件", record.IncludedFiles},
{"反馈包 SHA256", record.PlainPackageSha256},
}
html := `<h2>YMhut Box 反馈工单</h2><table cellpadding="8" cellspacing="0" border="1" style="border-collapse:collapse">`
for _, row := range rows {
html += `<tr><th align="left">` + htmlEscape(row[0]) + "</th><td>" + strings.ReplaceAll(htmlEscape(row[1]), "\n", "<br>") + "</td></tr>"
}
html += "</table>"
html += `<h3>正文</h3><p style="white-space:pre-wrap">` + htmlEscape(record.Body) + "</p>"
html += `<h3>反馈包摘要</h3><pre style="white-space:pre-wrap">` + htmlEscape(record.SummaryText) + "</pre>"
return html
}
func typeLabel(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "suggestion":
return "建议"
case "ui":
return "界面反馈"
case "other":
return "其他"
default:
return "问题"
}
}
func priorityLabel(priority, severity string) string {
value := firstNonEmpty(priority, severity)
switch strings.ToLower(strings.TrimSpace(value)) {
case "urgent", "blocking":
return "紧急"
case "high", "major":
return "高"
case "low", "minor":
return "低"
default:
return "普通"
}
}
func htmlEscape(value string) string {
value = strings.ReplaceAll(value, "&", "&amp;")
value = strings.ReplaceAll(value, "<", "&lt;")
value = strings.ReplaceAll(value, ">", "&gt;")
value = strings.ReplaceAll(value, `"`, "&quot;")
return strings.ReplaceAll(value, "'", "&#39;")
}
func mimeAddress(address, name string) string {
if name == "" {
return address
}
return mime.BEncoding.Encode("UTF-8", name) + " <" + extractEmail(address) + ">"
}
func extractEmail(value string) string {
re := regexp.MustCompile(`<([^>]+)>`)
if match := re.FindStringSubmatch(value); len(match) == 2 {
return strings.TrimSpace(match[1])
}
return strings.TrimSpace(value)
}
func wrapBase64(data []byte) string {
encoded := base64.StdEncoding.EncodeToString(data)
var builder strings.Builder
for len(encoded) > 76 {
builder.WriteString(encoded[:76])
builder.WriteString("\r\n")
encoded = encoded[76:]
}
builder.WriteString(encoded)
return builder.String()
}
func randomish() string {
return strings.ReplaceAll(fmt.Sprintf("%d", time.Now().UnixNano()), "-", "")
}
func truncate(value string, max int) string {
runes := []rune(strings.TrimSpace(value))
if len(runes) <= max {
return string(runes)
}
return string(runes[:max])
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
@@ -139,6 +139,34 @@ func (s *Service) Save(ctx context.Context, version string, req SaveRequest, act
return s.Get(saved.Version)
}
func (s *Service) SyncFromLegacyUpdateInfo(ctx context.Context, raw string, actor string) error {
if strings.TrimSpace(raw) == "" {
return nil
}
item, parsed, formatted, err := parseNotice([]byte(raw), "", "")
if err != nil {
return err
}
item.RawJSON = formatted
current, err := s.store.GetReleaseNotice(item.Version)
if err == nil && current.RawJSON != "" && current.RawJSON != formatted {
_, _ = s.store.SaveReleaseNoticeRevision(item.Version, current.RawJSON, "auto backup before legacy update-info sync", actor)
}
saved, err := s.store.UpsertReleaseNotice(item)
if err != nil {
return err
}
_, _ = s.store.SaveReleaseNoticeRevision(saved.Version, formatted, "synced from update-info.json", actor)
if err := s.writeNoticeFile(saved, formatted); err != nil {
return err
}
if err := s.writeTotalIndex(saved, parsed); err != nil {
return err
}
_ = s.store.InsertAudit(db.AuditLog{Actor: actor, Type: "release_notice.synced", Target: saved.Version, Message: "版本日志已从兼容 update-info.json 同步"})
return nil
}
func (s *Service) Restore(ctx context.Context, version string, revisionID int64, actor string) (Document, error) {
revision, err := s.store.GetReleaseNoticeRevision(version, revisionID)
if err != nil {
@@ -227,10 +255,7 @@ func (s *Service) writeTotalIndex(item db.ReleaseNotice, parsed map[string]any)
func (s *Service) syncLegacyUpdateInfo(item db.ReleaseNotice, parsed map[string]any) error {
path := filepath.Join(s.cfg.UpdatePublicDir, "update-info.json")
payload := map[string]any{}
if data, err := os.ReadFile(path); err == nil {
_ = json.Unmarshal(data, &payload)
}
payload := s.legacyUpdateBase(path)
payload["app_version"] = item.Version
setNonEmpty(payload, "build", item.Build)
setNonEmpty(payload, "channel", item.Channel)
@@ -256,6 +281,36 @@ func (s *Service) syncLegacyUpdateInfo(item db.ReleaseNotice, parsed map[string]
return atomicWrite(path, append(data, '\n'))
}
func (s *Service) legacyUpdateBase(currentPath string) map[string]any {
payload := map[string]any{}
for _, path := range []string{
filepath.Join(s.cfg.LegacyUpdateDir, "public", "update-info.json"),
currentPath,
} {
if data, err := os.ReadFile(path); err == nil {
var doc map[string]any
if json.Unmarshal(data, &doc) == nil {
for key, value := range doc {
payload[key] = value
}
}
}
}
if payload["app_version"] == nil {
if value, ok := payload["appVersion"]; ok {
payload["app_version"] = value
} else if value, ok := payload["latestVersion"]; ok {
payload["app_version"] = value
}
}
if payload["manifest_version"] == nil {
if value, ok := payload["manifestVersion"]; ok {
payload["manifest_version"] = value
}
}
return payload
}
func parseAndFormat(data []byte, fallbackVersion, noticeFile string) (map[string]any, string, error) {
_, parsed, formatted, err := parseNotice(data, fallbackVersion, noticeFile)
return parsed, formatted, err
@@ -270,7 +325,7 @@ func parseNotice(data []byte, fallbackVersion, noticeFile string) (db.ReleaseNot
}
version := firstNonEmpty(stringValue(parsed, "app_version"), stringValue(parsed, "version"), fallbackVersion)
if version == "" {
return db.ReleaseNotice{}, nil, "", errors.New("version or app_version is required")
return db.ReleaseNotice{}, nil, "", errors.New("版本日志需要填写 version app_version")
}
if noticeFile == "" {
noticeFile = version + ".json"
@@ -69,6 +69,56 @@ func TestSaveNoticeSyncsFilesAndLegacyUpdateInfo(t *testing.T) {
}
}
func TestSyncFromLegacyUpdateInfoUpdatesNoticeIndex(t *testing.T) {
root := t.TempDir()
public := filepath.Join(root, "public")
noticeDir := filepath.Join(root, "update-notice")
if err := os.MkdirAll(public, 0o755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(noticeDir, 0o755); err != nil {
t.Fatal(err)
}
writeJSON(t, filepath.Join(noticeDir, "total.json"), map[string]any{"schema_version": 1, "versions": []any{}})
cfg := &config.Config{
StorageDir: filepath.Join(root, "storage"),
UpdatePublicDir: public,
UpdateNoticeDir: noticeDir,
Database: config.DatabaseConfig{
Provider: "sqlite",
SQLitePath: filepath.Join(root, "storage", "unified.sqlite"),
FailoverEnabled: true,
HealthIntervalSec: 3600,
MaxOpenConns: 1,
MaxIdleConns: 1,
ConnMaxLifetimeSeconds: 60,
},
}
store, err := db.Open(cfg)
if err != nil {
t.Fatal(err)
}
defer store.Close()
service := NewService(cfg, store)
raw := `{"app_version":"2.0.7.5","title":"YMhut Box 2.0.7.5","message":"随机放映室优化","release_notes":"修复图片源和全屏预览","download_url":"https://update.ymhut.cn/downloads/app.exe"}`
if err := service.SyncFromLegacyUpdateInfo(context.Background(), raw, "admin"); err != nil {
t.Fatal(err)
}
items, err := service.List(10)
if err != nil {
t.Fatal(err)
}
if len(items) != 1 || items[0].Version != "2.0.7.5" || items[0].Title != "YMhut Box 2.0.7.5" {
t.Fatalf("notice list not synced: %#v", items)
}
total := readJSONFile(t, filepath.Join(noticeDir, "total.json"))
if total["latest_version"] != "2.0.7.5" {
t.Fatalf("total index not updated: %#v", total)
}
}
func writeJSON(t *testing.T, path string, payload any) {
t.Helper()
data, err := json.Marshal(payload)
@@ -61,7 +61,7 @@ func NewService(cfg *config.Config, store *db.Store, noticeService ...*notices.S
}
func (s *Service) LegacyUpdateInfo(r *http.Request) map[string]any {
payload := readJSON(filepath.Join(s.cfg.UpdatePublicDir, "update-info.json"))
payload := s.legacyUpdateBase()
manifest := s.Manifest(r)
for _, key := range []string{"app_version", "download_url", "download_mirrors", "detected_product", "detected_packages", "packages", "modules", "manifest_version", "release_notes", "release_notes_md", "message", "message_md", "notices", "latest_notice"} {
if value, ok := manifest[key]; ok {
@@ -72,7 +72,7 @@ func (s *Service) LegacyUpdateInfo(r *http.Request) map[string]any {
}
func (s *Service) Manifest(r *http.Request) map[string]any {
payload := readJSON(filepath.Join(s.cfg.UpdatePublicDir, "update-info.json"))
payload := s.legacyUpdateBase()
packages := s.ScanPackages(r)
modules := readJSON(filepath.Join(s.cfg.UpdatePublicDir, "modules.json"))["modules"]
if modules == nil {
@@ -116,6 +116,20 @@ func (s *Service) Manifest(r *http.Request) map[string]any {
return payload
}
func (s *Service) PublishLegacyUpdateInfo(r *http.Request, actor string) error {
payload := s.LegacyUpdateInfo(r)
data, err := json.MarshalIndent(payload, "", " ")
if err != nil {
return err
}
path := filepath.Join(s.cfg.UpdatePublicDir, "update-info.json")
if err := atomicWrite(path, append(data, '\n')); err != nil {
return err
}
_, _ = s.store.SaveLegacyRevision("update-info", string(append(data, '\n')), "generated from release database", firstNonEmpty(actor, "system"))
return nil
}
func setIfMissing(payload map[string]any, key, value string) {
if strings.TrimSpace(value) == "" {
return
@@ -244,7 +258,7 @@ func (s *Service) SaveUploadedPackage(r *http.Request, reader io.Reader, opts Up
func (s *Service) updateLegacyManifest(pkg Package, opts UploadOptions) error {
path := filepath.Join(s.cfg.UpdatePublicDir, "update-info.json")
payload := readJSON(path)
payload := s.legacyUpdateBase()
payload["app_version"] = pkg.Version
payload["download_url"] = pkg.URL
payload["package_sha256"] = pkg.SHA256
@@ -265,10 +279,32 @@ func (s *Service) updateLegacyManifest(pkg Package, opts UploadOptions) error {
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
return err
return atomicWrite(path, append(data, '\n'))
}
func (s *Service) legacyUpdateBase() map[string]any {
payload := map[string]any{}
for _, path := range []string{
filepath.Join(s.cfg.LegacyUpdateDir, "public", "update-info.json"),
filepath.Join(s.cfg.UpdatePublicDir, "update-info.json"),
} {
for key, value := range readJSON(path) {
payload[key] = value
}
}
return os.WriteFile(path, append(data, '\n'), 0o640)
if payload["app_version"] == nil {
if value, ok := payload["appVersion"]; ok {
payload["app_version"] = value
} else if value, ok := payload["latestVersion"]; ok {
payload["app_version"] = value
}
}
if payload["manifest_version"] == nil {
if value, ok := payload["manifestVersion"]; ok {
payload["manifest_version"] = value
}
}
return payload
}
func readJSON(path string) map[string]any {
@@ -283,6 +319,30 @@ func readJSON(path string) map[string]any {
return payload
}
func atomicWrite(path string, data []byte) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o750); err != nil {
return err
}
tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".*.tmp")
if err != nil {
return err
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
if err := os.Chmod(tmpName, 0o640); err != nil {
return err
}
return os.Rename(tmpName, path)
}
func requestBaseURL(r *http.Request, fallback string) string {
if r != nil {
scheme := r.Header.Get("X-Forwarded-Proto")
@@ -5,10 +5,12 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"time"
@@ -44,10 +46,28 @@ type CheckJob struct {
LastError string `json:"lastError"`
}
type mediaResolution struct {
URL string
Key string
MediaType string
Direct bool
}
type mediaCandidate struct {
Resolution mediaResolution
Score int
Depth int
Order int
}
type legacyMedia struct {
Categories []legacyCategory `json:"categories"`
}
const maxSourceProbeBytes int64 = 2 * 1024 * 1024
var absoluteURLPattern = regexp.MustCompile(`https?://[^\s"'<>\\]+`)
type legacyCategory struct {
ID string `json:"id"`
Name string `json:"name"`
@@ -152,6 +172,42 @@ func (s *Service) ImportLegacyMediaTypes(ctx context.Context) error {
return nil
}
func (s *Service) DeleteSourceAndPublishCompatibility(ctx context.Context, sourceID, actor string) error {
sourceID = strings.TrimSpace(sourceID)
if sourceID == "" || strings.ContainsAny(sourceID, `/\`) || strings.Contains(sourceID, "..") {
return errors.New("invalid source id")
}
if _, err := s.store.GetSourceBySourceID(sourceID); err != nil {
return err
}
if err := s.store.DeleteSource(sourceID); err != nil {
return err
}
if err := s.PublishLegacyMediaTypes(ctx, actor); err != nil {
return err
}
_ = s.store.InsertAudit(db.AuditLog{Actor: firstNonEmpty(actor, "admin"), Type: "source.deleted", Target: sourceID, Message: "客户端接口已删除并同步兼容 media-types.json"})
return nil
}
func (s *Service) PublishLegacyMediaTypes(ctx context.Context, actor string) error {
catalog, err := s.Catalog(false)
if err != nil {
return err
}
data, err := json.MarshalIndent(catalog, "", " ")
if err != nil {
return err
}
formatted := append(data, '\n')
path := filepath.Join(s.cfg.UpdatePublicDir, "media-types.json")
if err := atomicWrite(path, formatted); err != nil {
return err
}
_, _ = s.store.SaveLegacyRevision("media-types", string(formatted), "generated from source database", firstNonEmpty(actor, "system"))
return nil
}
func (s *Service) Catalog(includeHidden bool) (map[string]any, error) {
items, err := s.store.ListSources(includeHidden)
if err != nil {
@@ -194,6 +250,7 @@ func (s *Service) Catalog(includeHidden bool) (map[string]any, error) {
"meta": parseHealthMeta(item.LastError),
},
}
applyResolvedFields(sub, item.LastError)
cat["subcategories"] = append(cat["subcategories"].([]map[string]any), sub)
}
out := []map[string]any{}
@@ -216,7 +273,7 @@ func (s *Service) Endpoints(includeHidden bool) ([]map[string]any, error) {
for _, item := range items {
var formats []string
_ = json.Unmarshal([]byte(item.SupportedFormats), &formats)
out = append(out, map[string]any{
endpoint := map[string]any{
"id": item.SourceID,
"category": item.CategoryID,
"name": item.Name,
@@ -235,7 +292,9 @@ func (s *Service) Endpoints(includeHidden bool) ([]map[string]any, error) {
"consecutiveFailure": item.ConsecutiveFailure,
"meta": parseHealthMeta(item.LastError),
},
})
}
applyResolvedFields(endpoint, item.LastError)
out = append(out, endpoint)
}
return out, nil
}
@@ -434,6 +493,18 @@ func (s *Service) CheckOneStatus(ctx context.Context, item db.Source) (string, e
"error": resp.Status,
})
}
if resp.StatusCode < 400 {
meta := parseHealthMeta(message)
meta["finalUrl"] = resp.Request.URL.String()
meta["finalStatus"] = resp.StatusCode
if resolution := resolveMediaFromResponse(resp); resolution.URL != "" {
meta["resolvedUrl"] = resolution.URL
meta["resolvedKey"] = resolution.Key
meta["mediaType"] = resolution.MediaType
meta["directMedia"] = resolution.Direct
}
message = healthMetaMessage(meta)
}
if err := s.store.RecordSourceCheck(item.ID, status, latency, message); err != nil {
return status, err
}
@@ -486,6 +557,259 @@ func isHTTPURL(value *url.URL) bool {
return scheme == "http" || scheme == "https"
}
func resolveMediaFromResponse(resp *http.Response) mediaResolution {
if resp == nil || resp.Request == nil || resp.Request.URL == nil {
return mediaResolution{}
}
finalURL := resp.Request.URL
contentType := strings.ToLower(strings.TrimSpace(strings.Split(resp.Header.Get("Content-Type"), ";")[0]))
if mediaType := mediaTypeFromContentType(contentType); mediaType != "" || looksLikeMediaURL(finalURL) {
return mediaResolution{URL: finalURL.String(), Key: "response", MediaType: firstNonEmpty(mediaType, mediaTypeFromURL(finalURL)), Direct: true}
}
if !canProbeText(contentType, resp.ContentLength) {
return mediaResolution{}
}
reader := io.LimitReader(resp.Body, maxSourceProbeBytes+1)
data, err := io.ReadAll(reader)
if err != nil || int64(len(data)) > maxSourceProbeBytes {
return mediaResolution{}
}
text := strings.TrimSpace(string(data))
if text == "" {
return mediaResolution{}
}
var decoded any
if json.Unmarshal(data, &decoded) == nil {
if candidate, ok := bestJSONMediaCandidate(decoded, finalURL); ok {
return candidate.Resolution
}
}
if candidate, ok := bestTextMediaCandidate(text, finalURL); ok {
return candidate.Resolution
}
return mediaResolution{}
}
func canProbeText(contentType string, length int64) bool {
if length > maxSourceProbeBytes {
return false
}
if contentType == "" || strings.Contains(contentType, "json") {
return true
}
return strings.HasPrefix(contentType, "text/") ||
strings.Contains(contentType, "javascript") ||
strings.Contains(contentType, "xml") ||
strings.Contains(contentType, "form")
}
func bestJSONMediaCandidate(value any, base *url.URL) (mediaCandidate, bool) {
candidates := []mediaCandidate{}
order := 0
collectJSONMediaCandidates(value, "", base, 0, &order, &candidates)
return bestCandidate(candidates)
}
func collectJSONMediaCandidates(value any, key string, base *url.URL, depth int, order *int, candidates *[]mediaCandidate) {
switch typed := value.(type) {
case map[string]any:
for childKey, childValue := range typed {
nextKey := childKey
if key != "" {
nextKey = key + "." + childKey
}
collectJSONMediaCandidates(childValue, nextKey, base, depth+1, order, candidates)
}
case []any:
for _, childValue := range typed {
collectJSONMediaCandidates(childValue, key, base, depth+1, order, candidates)
}
case string:
*order = *order + 1
if candidate, ok := candidateFromString(key, typed, base, depth, *order); ok {
*candidates = append(*candidates, candidate)
}
}
}
func bestTextMediaCandidate(text string, base *url.URL) (mediaCandidate, bool) {
candidates := []mediaCandidate{}
matches := absoluteURLPattern.FindAllString(text, 30)
for index, match := range matches {
if candidate, ok := candidateFromString("text", strings.TrimRight(match, ".,);]}'\""), base, 0, index+1); ok {
candidates = append(candidates, candidate)
}
}
return bestCandidate(candidates)
}
func candidateFromString(key, value string, base *url.URL, depth, order int) (mediaCandidate, bool) {
raw := strings.TrimSpace(value)
if raw == "" {
return mediaCandidate{}, false
}
urls := []string{raw}
if !strings.Contains(raw, "://") {
urls = append(urls, absoluteURLPattern.FindAllString(raw, 10)...)
}
for _, candidate := range urls {
resolved, ok := resolveCandidateURL(candidate, base)
if !ok {
continue
}
mediaType := mediaTypeFromURL(resolved)
if mediaType == "" {
continue
}
keyScore := mediaKeyScore(key)
score := 100 + keyScore - depth
return mediaCandidate{
Resolution: mediaResolution{
URL: resolved.String(),
Key: key,
MediaType: mediaType,
},
Score: score,
Depth: depth,
Order: order,
}, true
}
return mediaCandidate{}, false
}
func resolveCandidateURL(value string, base *url.URL) (*url.URL, bool) {
value = strings.TrimSpace(strings.Trim(value, `"'`))
if value == "" {
return nil, false
}
parsed, err := url.Parse(value)
if err != nil {
return nil, false
}
if !parsed.IsAbs() {
if base == nil {
return nil, false
}
parsed = base.ResolveReference(parsed)
}
if !isHTTPURL(parsed) {
return nil, false
}
return parsed, true
}
func bestCandidate(candidates []mediaCandidate) (mediaCandidate, bool) {
if len(candidates) == 0 {
return mediaCandidate{}, false
}
best := candidates[0]
for _, candidate := range candidates[1:] {
if candidate.Score > best.Score ||
(candidate.Score == best.Score && candidate.Depth < best.Depth) ||
(candidate.Score == best.Score && candidate.Depth == best.Depth && candidate.Order < best.Order) {
best = candidate
}
}
return best, true
}
func mediaKeyScore(key string) int {
last := key
if index := strings.LastIndex(last, "."); index >= 0 {
last = last[index+1:]
}
normalized := strings.ToLower(strings.TrimSpace(last))
switch normalized {
case "url", "src", "image", "img", "pic", "cover", "thumbnail", "video", "file", "media":
return 80
case "href", "poster", "preview", "download", "play", "audio":
return 60
}
for _, token := range []string{"url", "src", "image", "img", "pic", "cover", "thumb", "video", "file", "media"} {
if strings.Contains(normalized, token) {
return 40
}
}
return 0
}
func looksLikeMediaURL(value *url.URL) bool {
return mediaTypeFromURL(value) != ""
}
func mediaTypeFromURL(value *url.URL) string {
if value == nil {
return ""
}
extension := strings.ToLower(strings.TrimPrefix(filepath.Ext(value.Path), "."))
switch extension {
case "jpg", "jpeg", "png", "webp", "gif", "bmp", "tif", "tiff":
return "image"
case "mp4", "webm", "m3u8", "mkv", "mov", "m4v", "avi", "wmv":
return "video"
case "mp3", "wav", "flac", "aac", "m4a", "ogg", "wma":
return "audio"
default:
return ""
}
}
func mediaTypeFromContentType(value string) string {
switch {
case strings.HasPrefix(value, "image/"):
return "image"
case strings.HasPrefix(value, "video/") || value == "application/vnd.apple.mpegurl" || value == "application/x-mpegurl":
return "video"
case strings.HasPrefix(value, "audio/"):
return "audio"
default:
return ""
}
}
func applyResolvedFields(target map[string]any, message string) {
meta := parseHealthMeta(message)
resolvedURL, _ := meta["resolvedUrl"].(string)
resolvedKey, _ := meta["resolvedKey"].(string)
mediaType, _ := meta["mediaType"].(string)
if strings.TrimSpace(resolvedURL) != "" {
target["resolvedUrl"] = resolvedURL
target["resolved_url"] = resolvedURL
}
if strings.TrimSpace(resolvedKey) != "" {
target["resolvedKey"] = resolvedKey
target["resolved_key"] = resolvedKey
}
if strings.TrimSpace(mediaType) != "" {
target["mediaType"] = mediaType
target["media_type"] = mediaType
}
}
func atomicWrite(path string, data []byte) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o750); err != nil {
return err
}
tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".*.tmp")
if err != nil {
return err
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
if err := os.Chmod(tmpName, 0o640); err != nil {
return err
}
return os.Rename(tmpName, path)
}
func healthMetaMessage(meta map[string]any) string {
data, err := json.Marshal(meta)
if err != nil {
@@ -2,6 +2,7 @@ package sources
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
@@ -119,6 +120,115 @@ func TestSubscribeEventsBroadcastsToAllSubscribers(t *testing.T) {
assertEvent("subscriber B", eventsB)
}
func TestCheckOneResolvesNestedJSONMediaURL(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"ok": true,
"data": map[string]any{
"ignored": "https://example.test/readme.txt",
"items": []map[string]any{
{"name": "first"},
{"cover": "/media/poster.webp"},
},
},
})
}))
defer server.Close()
cfg, store := testStore(t)
service := NewService(cfg, store)
item, err := store.UpsertSource(db.Source{
CategoryID: "image",
CategoryName: "Image",
SourceID: "json-cover",
Name: "JSON Cover",
Method: "GET",
APIURL: server.URL + "/api/random",
TimeoutMS: 3000,
CheckIntervalSec: 300,
Enabled: true,
ClientVisible: true,
})
if err != nil {
t.Fatal(err)
}
if err := service.CheckOne(context.Background(), item); err != nil {
t.Fatal(err)
}
checked, err := store.GetSourceBySourceID("json-cover")
if err != nil {
t.Fatal(err)
}
meta := parseHealthMeta(checked.LastError)
if meta["resolvedUrl"] != server.URL+"/media/poster.webp" {
t.Fatalf("resolvedUrl = %#v, want relative media URL", meta["resolvedUrl"])
}
if meta["resolvedKey"] != "data.items.cover" {
t.Fatalf("resolvedKey = %#v", meta["resolvedKey"])
}
if meta["mediaType"] != "image" {
t.Fatalf("mediaType = %#v, want image", meta["mediaType"])
}
catalog, err := service.Catalog(false)
if err != nil {
t.Fatal(err)
}
categories := catalog["categories"].([]map[string]any)
sub := categories[0]["subcategories"].([]map[string]any)[0]
if sub["resolvedUrl"] != server.URL+"/media/poster.webp" {
t.Fatalf("catalog resolvedUrl = %#v", sub["resolvedUrl"])
}
endpoints, err := service.Endpoints(false)
if err != nil {
t.Fatal(err)
}
if endpoints[0]["resolvedUrl"] != server.URL+"/media/poster.webp" {
t.Fatalf("endpoint resolvedUrl = %#v", endpoints[0]["resolvedUrl"])
}
if endpoints[0]["urlTemplate"] != server.URL+"/api/random" {
t.Fatalf("urlTemplate changed: %#v", endpoints[0]["urlTemplate"])
}
}
func TestCheckOneResolvesTextMediaURL(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = w.Write([]byte(`play: https://cdn.example.test/video/sample.mp4`))
}))
defer server.Close()
cfg, store := testStore(t)
service := NewService(cfg, store)
item, err := store.UpsertSource(db.Source{
CategoryID: "video",
CategoryName: "Video",
SourceID: "text-video",
Name: "Text Video",
Method: "GET",
APIURL: server.URL,
TimeoutMS: 3000,
CheckIntervalSec: 300,
Enabled: true,
ClientVisible: true,
})
if err != nil {
t.Fatal(err)
}
if err := service.CheckOne(context.Background(), item); err != nil {
t.Fatal(err)
}
checked, err := store.GetSourceBySourceID("text-video")
if err != nil {
t.Fatal(err)
}
meta := parseHealthMeta(checked.LastError)
if meta["resolvedUrl"] != "https://cdn.example.test/video/sample.mp4" {
t.Fatalf("resolvedUrl = %#v", meta["resolvedUrl"])
}
if meta["mediaType"] != "video" {
t.Fatalf("mediaType = %#v, want video", meta["mediaType"])
}
}
func testStore(t *testing.T) (*config.Config, *db.Store) {
t.Helper()
dir := t.TempDir()
@@ -117,10 +117,21 @@ func (r *router) handleAdminFeedbacks(w http.ResponseWriter, req *http.Request)
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "comment": comment})
return
}
if req.Method == http.MethodPost && strings.HasPrefix(path, "/api/admin/feedbacks/") && strings.HasSuffix(path, "/mail/retry") {
code := strings.TrimSuffix(strings.TrimPrefix(path, "/api/admin/feedbacks/"), "/mail/retry")
if err := r.feedback.RetryMail(code); err != nil {
writeError(w, http.StatusBadGateway, "MAIL_RETRY_FAILED", err)
return
}
_ = r.store.InsertAudit(db.AuditLog{Actor: "admin", Type: "feedback.mail.retry", Target: code, Message: "反馈邮件已重试发送", IP: req.RemoteAddr, UserAgent: req.UserAgent()})
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
return
}
if req.Method == http.MethodPatch && strings.HasPrefix(path, "/api/admin/feedbacks/") {
code := strings.TrimPrefix(path, "/api/admin/feedbacks/")
var body struct {
Status string `json:"status"`
Priority string `json:"priority"`
StatusDetail string `json:"statusDetail"`
PublicReply string `json:"publicReply"`
}
@@ -128,7 +139,7 @@ func (r *router) handleAdminFeedbacks(w http.ResponseWriter, req *http.Request)
writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", err)
return
}
if err := r.store.UpdateFeedbackTicket(code, db.FeedbackUpdate{Status: firstNonEmpty(body.Status, "new"), StatusDetail: body.StatusDetail, PublicReply: body.PublicReply, Actor: "admin"}); err != nil {
if err := r.store.UpdateFeedbackTicket(code, db.FeedbackUpdate{Status: firstNonEmpty(body.Status, "new"), Priority: body.Priority, StatusDetail: body.StatusDetail, PublicReply: body.PublicReply, Actor: "admin"}); err != nil {
writeError(w, http.StatusInternalServerError, "FEEDBACK_UPDATE_FAILED", err)
return
}
@@ -50,6 +50,9 @@ func (r *router) handleAdminLegacy(w http.ResponseWriter, req *http.Request) {
if name == "media-types" {
_ = r.sources.ImportLegacyMediaTypes(req.Context())
}
if name == "update-info" {
r.syncNoticeFromLegacyUpdateInfo(req, doc.Raw)
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "document": doc})
return
}
@@ -83,8 +86,18 @@ func (r *router) handleAdminLegacy(w http.ResponseWriter, req *http.Request) {
if name == "media-types" {
_ = r.sources.ImportLegacyMediaTypes(req.Context())
}
if name == "update-info" {
r.syncNoticeFromLegacyUpdateInfo(req, doc.Raw)
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "document": doc})
return
}
http.NotFound(w, req)
}
func (r *router) syncNoticeFromLegacyUpdateInfo(req *http.Request, raw string) {
if r.notices == nil {
return
}
_ = r.notices.SyncFromLegacyUpdateInfo(req.Context(), raw, "admin")
}
@@ -56,13 +56,17 @@ func (r *router) handleAdminSources(w http.ResponseWriter, req *http.Request) {
writeError(w, http.StatusInternalServerError, "SOURCE_SAVE_FAILED", err)
return
}
_ = r.sources.PublishLegacyMediaTypes(req.Context(), "admin")
_ = r.releases.PublishLegacyUpdateInfo(req, "admin")
_ = r.store.InsertAudit(db.AuditLog{Actor: "admin", Type: "source.saved", Target: saved.SourceID, Message: "客户端接口已保存并同步兼容 media-types.json", IP: req.RemoteAddr, UserAgent: req.UserAgent()})
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "source": saved})
case req.Method == http.MethodDelete && strings.HasPrefix(path, "/api/admin/sources/"):
sourceID := strings.TrimPrefix(path, "/api/admin/sources/")
if err := r.store.DeleteSource(sourceID); err != nil {
if err := r.sources.DeleteSourceAndPublishCompatibility(req.Context(), sourceID, "admin"); err != nil {
writeError(w, http.StatusInternalServerError, "SOURCE_DELETE_FAILED", err)
return
}
_ = r.releases.PublishLegacyUpdateInfo(req, "admin")
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
default:
http.NotFound(w, req)
@@ -4,38 +4,57 @@ import (
"encoding/json"
"errors"
"net/http"
"path/filepath"
"strconv"
"time"
"ymhut-box/server/unified-management/internal/config"
"ymhut-box/server/unified-management/internal/db"
"ymhut-box/server/unified-management/internal/health"
feedbackmail "ymhut-box/server/unified-management/internal/mail"
)
func (r *router) handleAdminDatabase(w http.ResponseWriter, req *http.Request) {
path := cleanPath(req.URL.Path)
switch {
case req.Method == http.MethodGet && path == "/api/admin/database/config":
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "config": config.SafeDatabase(r.cfg.BaseDir, r.cfg.Database)})
case req.Method == http.MethodGet && path == "/api/admin/database/status":
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "database": r.store.Status()})
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "database": r.store.Status(), "config": config.SafeDatabase(r.cfg.BaseDir, r.cfg.Database)})
case req.Method == http.MethodPost && path == "/api/admin/database/test":
var body config.DatabaseConfig
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
body, err := decodeAdminDatabaseConfig(req, r.cfg.BaseDir, r.cfg.Database, true)
if err != nil {
writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", err)
return
}
if body.Provider == "" {
body.Provider = r.cfg.Database.Provider
}
if body.SQLitePath == "" {
body.SQLitePath = r.cfg.Database.SQLitePath
}
if body.MySQLDSN == "" {
body.MySQLDSN = r.cfg.Database.MySQLDSN
}
if err := db.TestDatabase(body); err != nil {
writeError(w, http.StatusBadGateway, "DATABASE_TEST_FAILED", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "config": config.SafeDatabase(r.cfg.BaseDir, body)})
case req.Method == http.MethodPost && path == "/api/admin/database/save":
body, err := decodeAdminDatabaseConfig(req, r.cfg.BaseDir, r.cfg.Database, true)
if err != nil {
writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", err)
return
}
if err := db.TestDatabase(body); err != nil {
writeError(w, http.StatusBadGateway, "DATABASE_TEST_FAILED", err)
return
}
next := *r.cfg
next.Database = body
if err := config.Save(&next); err != nil {
writeError(w, http.StatusInternalServerError, "DATABASE_SAVE_FAILED", err)
return
}
r.cfg.Database = next.Database
if err := r.store.ReconfigureDatabase(r.cfg); err != nil {
writeError(w, http.StatusInternalServerError, "DATABASE_SAVE_FAILED", err)
return
}
_ = r.store.InsertAudit(db.AuditLog{Actor: "admin", Type: "system.database.saved", Target: body.Provider, Message: "数据库配置已保存并热切换", IP: req.RemoteAddr, UserAgent: req.UserAgent()})
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "database": r.store.Status(), "config": config.SafeDatabase(r.cfg.BaseDir, r.cfg.Database)})
case req.Method == http.MethodPost && path == "/api/admin/database/import-sqlite":
result, err := r.store.ImportSQLiteToRemote()
if err != nil {
@@ -55,6 +74,52 @@ func (r *router) handleAdminDatabase(w http.ResponseWriter, req *http.Request) {
}
}
type adminDatabaseRequest struct {
Provider string `json:"provider"`
SQLitePath string `json:"sqlite_path"`
SQLitePathAlt string `json:"sqlitePath"`
MySQLDSN string `json:"mysql_dsn"`
MySQLDSNAlt string `json:"mysqlDsn"`
MySQLHost string `json:"mysql_host"`
MySQLHostAlt string `json:"mysqlHost"`
MySQLPort int `json:"mysql_port"`
MySQLPortAlt int `json:"mysqlPort"`
MySQLDatabase string `json:"mysql_database"`
MySQLDBAlt string `json:"mysqlDatabase"`
MySQLUser string `json:"mysql_user"`
MySQLUserAlt string `json:"mysqlUser"`
MySQLPassword string `json:"mysql_password"`
MySQLPassAlt string `json:"mysqlPassword"`
MySQL config.MySQLInput `json:"mysql"`
}
func decodeAdminDatabaseConfig(req *http.Request, baseDir string, current config.DatabaseConfig, keepPassword bool) (config.DatabaseConfig, error) {
var body adminDatabaseRequest
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
return config.DatabaseConfig{}, err
}
incoming := config.DatabaseConfig{
Provider: body.Provider,
SQLitePath: firstNonEmpty(body.SQLitePath, body.SQLitePathAlt),
MySQLDSN: firstNonEmpty(body.MySQLDSN, body.MySQLDSNAlt),
MySQLHost: firstNonEmpty(body.MySQLHost, body.MySQLHostAlt, body.MySQL.Host),
MySQLPort: firstPositive(body.MySQLPort, body.MySQLPortAlt, body.MySQL.Port),
MySQLDatabase: firstNonEmpty(body.MySQLDatabase, body.MySQLDBAlt, body.MySQL.Database),
MySQLUser: firstNonEmpty(body.MySQLUser, body.MySQLUserAlt, body.MySQL.Username),
MySQLPassword: firstNonEmpty(body.MySQLPassword, body.MySQLPassAlt, body.MySQL.Password),
}
return config.NormalizeDatabase(baseDir, current, incoming, keepPassword)
}
func firstPositive(values ...int) int {
for _, value := range values {
if value > 0 {
return value
}
}
return 0
}
func (r *router) handleAdminDashboard(w http.ResponseWriter, req *http.Request) {
path := cleanPath(req.URL.Path)
if req.Method != http.MethodGet || path != "/api/admin/dashboard/overview" {
@@ -141,12 +206,26 @@ func (r *router) handleAdminSystem(w http.ResponseWriter, req *http.Request) {
case "/api/admin/system/health":
writeJSON(w, http.StatusOK, health.Snapshot(r.cfg, r.store))
case "/api/admin/system/audit":
items, err := r.store.ListAuditLogs(100)
page, err := r.store.ListAuditLogsPage(db.AuditFilters{
Page: queryInt(req, "page", 1),
PerPage: queryInt(req, "perPage", 35),
Type: req.URL.Query().Get("type"),
Target: req.URL.Query().Get("target"),
Query: req.URL.Query().Get("q"),
})
if err != nil {
writeError(w, http.StatusInternalServerError, "AUDIT_FAILED", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "items": items})
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "items": page.Items, "page": page})
case "/api/admin/system/mail/config":
r.handleMailConfig(w, req)
case "/api/admin/system/mail/test":
r.handleMailTest(w, req)
case "/api/admin/system/branding":
r.handleBranding(w, req)
case "/api/admin/system/migration":
r.handleMigrationStatus(w, req)
case "/api/admin/system/database/sync":
if req.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", errors.New("POST required"))
@@ -162,3 +241,188 @@ func (r *router) handleAdminSystem(w http.ResponseWriter, req *http.Request) {
http.NotFound(w, req)
}
}
func queryInt(req *http.Request, key string, fallback int) int {
value, err := strconv.Atoi(req.URL.Query().Get(key))
if err != nil || value <= 0 {
return fallback
}
return value
}
func (r *router) handleMigrationStatus(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", errors.New("GET required"))
return
}
status := r.store.Status()
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"migration": map[string]any{
"strategy": "database_first_with_file_assets",
"databaseCovers": []string{
"系统设置与品牌",
"管理员与会话元数据",
"反馈工单、附件元数据与邮件记录",
"来源目录、客户端接口与健康记录",
"发布元数据、版本公告与兼容 JSON 修订",
"审计日志、旧项目同步记录与数据库同步状态",
},
"fileAssets": []map[string]string{
{"name": "downloads", "path": r.cfg.DownloadsDir, "description": "发布包和下载文件"},
{"name": "update public", "path": r.cfg.UpdatePublicDir, "description": "旧客户端兼容 JSON 生成物"},
{"name": "feedback packages", "path": filepath.Join(r.cfg.StorageDir, "feedback-packages"), "description": "反馈附件包"},
},
"sqlitePath": r.store.Path(),
"mysql": config.SafeDatabase(r.cfg.BaseDir, r.cfg.Database),
"lastSyncAt": status.LastSyncAt,
"lastSyncError": status.LastSyncError,
"activeProvider": status.ActiveProvider,
},
})
}
func (r *router) handleBranding(w http.ResponseWriter, req *http.Request) {
switch req.Method {
case http.MethodGet:
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "branding": config.SafeBranding(r.effectiveBranding())})
case http.MethodPost:
var body struct {
SiteIconURL string `json:"siteIconUrl"`
SiteIconURLSnake string `json:"site_icon_url"`
DeveloperAvatarURL string `json:"developerAvatarUrl"`
DeveloperAvatarAlt string `json:"developer_avatar_url"`
DeveloperName string `json:"developerName"`
DeveloperNameSnake string `json:"developer_name"`
FeedbackEmail string `json:"feedbackEmail"`
FeedbackEmailSnake string `json:"feedback_email"`
}
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", err)
return
}
next := config.BrandingConfig{
SiteIconURL: firstNonEmpty(body.SiteIconURL, body.SiteIconURLSnake),
DeveloperAvatarURL: firstNonEmpty(body.DeveloperAvatarURL, body.DeveloperAvatarAlt),
DeveloperName: firstNonEmpty(body.DeveloperName, body.DeveloperNameSnake),
FeedbackEmail: firstNonEmpty(body.FeedbackEmail, body.FeedbackEmailSnake),
}
if err := r.saveBranding(next); err != nil {
writeError(w, http.StatusInternalServerError, "BRANDING_SAVE_FAILED", err)
return
}
_ = r.store.InsertAudit(db.AuditLog{Actor: "admin", Type: "system.branding.saved", Target: r.cfg.Branding.DeveloperName, Message: "站点品牌信息已保存", IP: req.RemoteAddr, UserAgent: req.UserAgent()})
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "branding": config.SafeBranding(r.effectiveBranding())})
default:
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", errors.New("GET or POST required"))
}
}
func (r *router) handleMailConfig(w http.ResponseWriter, req *http.Request) {
switch req.Method {
case http.MethodGet:
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "config": feedbackmail.SafeConfig(r.cfg.Mail)})
case http.MethodPost:
nextMail, err := decodeMailConfig(req, r.cfg.Mail)
if err != nil {
writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", err)
return
}
next := *r.cfg
next.Mail = nextMail
if err := config.Save(&next); err != nil {
writeError(w, http.StatusInternalServerError, "MAIL_CONFIG_FAILED", err)
return
}
r.cfg.Mail = next.Mail
_ = r.store.InsertAudit(db.AuditLog{Actor: "admin", Type: "system.mail.saved", Target: nextMail.Host, Message: "邮件通知配置已保存", IP: req.RemoteAddr, UserAgent: req.UserAgent()})
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "config": feedbackmail.SafeConfig(r.cfg.Mail)})
default:
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", errors.New("GET or POST required"))
}
}
func (r *router) handleMailTest(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", errors.New("POST required"))
return
}
message, err := feedbackmail.BuildTestMessage(r.cfg)
if err != nil {
writeError(w, http.StatusBadRequest, "MAIL_TEST_FAILED", err)
return
}
if err := feedbackmail.Send(r.cfg, message); err != nil {
writeError(w, http.StatusBadGateway, "MAIL_TEST_FAILED", err)
return
}
_ = r.store.InsertAudit(db.AuditLog{Actor: "admin", Type: "system.mail.test", Target: message.To, Message: "测试邮件已发送", IP: req.RemoteAddr, UserAgent: req.UserAgent()})
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
type mailConfigRequest struct {
Host string `json:"host"`
Port int `json:"port"`
Secure string `json:"secure"`
Username string `json:"username"`
Password string `json:"password"`
FromAddress string `json:"from_address"`
FromAddressAlt string `json:"fromAddress"`
FromName string `json:"from_name"`
FromNameAlt string `json:"fromName"`
DeveloperAddress string `json:"developer_address"`
DeveloperAlt string `json:"developerAddress"`
TimeoutSeconds int `json:"timeout_seconds"`
TimeoutAlt int `json:"timeoutSeconds"`
}
func decodeMailConfig(req *http.Request, current config.MailConfig) (config.MailConfig, error) {
var body mailConfigRequest
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
return config.MailConfig{}, err
}
next := current
if body.Host != "" {
next.Host = body.Host
}
if body.Port > 0 {
next.Port = body.Port
}
if body.Secure != "" {
next.Secure = body.Secure
}
if body.Username != "" {
next.Username = body.Username
}
if body.Password != "" {
next.Password = body.Password
}
if value := firstNonEmpty(body.FromAddress, body.FromAddressAlt); value != "" {
next.FromAddress = value
}
if value := firstNonEmpty(body.FromName, body.FromNameAlt); value != "" {
next.FromName = value
}
if value := firstNonEmpty(body.DeveloperAddress, body.DeveloperAlt); value != "" {
next.DeveloperAddress = value
}
if timeout := firstPositive(body.TimeoutSeconds, body.TimeoutAlt); timeout > 0 {
next.TimeoutSeconds = timeout
}
if next.Port <= 0 {
next.Port = 465
}
if next.Secure == "" {
next.Secure = "ssl"
}
if next.FromName == "" {
next.FromName = "YMhut Box Feedback"
}
if next.FromAddress == "" {
next.FromAddress = next.Username
}
if next.TimeoutSeconds <= 0 {
next.TimeoutSeconds = 20
}
return next, nil
}
@@ -0,0 +1,42 @@
package web
import (
"encoding/json"
"ymhut-box/server/unified-management/internal/config"
)
const brandingSettingKey = "branding"
func (r *router) effectiveBranding() config.BrandingConfig {
branding := config.NormalizeBranding(config.BrandingConfig{}, r.cfg.Branding)
if r.store == nil {
return branding
}
raw, err := r.store.GetSetting(brandingSettingKey)
if err != nil || raw == "" {
return branding
}
var stored config.BrandingConfig
if json.Unmarshal([]byte(raw), &stored) != nil {
return branding
}
return config.NormalizeBranding(branding, stored)
}
func (r *router) saveBranding(branding config.BrandingConfig) error {
branding = config.NormalizeBranding(r.cfg.Branding, branding)
next := *r.cfg
next.Branding = branding
next.Mail.DeveloperAddress = firstNonEmpty(next.Mail.DeveloperAddress, branding.FeedbackEmail)
if err := config.Save(&next); err != nil {
return err
}
r.cfg.Branding = next.Branding
r.cfg.Mail = next.Mail
data, err := json.Marshal(r.cfg.Branding)
if err != nil {
return err
}
return r.store.UpsertSetting(brandingSettingKey, string(data))
}
@@ -42,6 +42,7 @@ func (r *router) handleClientBootstrap(w http.ResponseWriter, req *http.Request)
"release": release,
"sources": sourceCatalog,
"feedback": map[string]any{"submit": "/", "status": "/?api=status&code=:code"},
"branding": config.SafeBranding(r.effectiveBranding()),
"health": health.Snapshot(r.cfg, r.store),
})
}
@@ -60,10 +60,18 @@ func localizedErrorMessage(code, message string) string {
"database is not available": "数据库当前不可用",
"provider must be sqlite or mysql": "数据库类型必须是 SQLite 或 MySQL",
"mysql connection is required": "请填写 MySQL 连接信息",
"mysql database is required": "请填写 MySQL 数据库名",
"mysql username is required": "请填写 MySQL 数据库用户",
"sqlite path is required": "请填写 SQLite 路径",
"mysql_dsn is required": "请填写 MySQL DSN",
"remote database is not configured": "远端 MySQL 未配置",
"database sync is already running": "数据库同步正在执行,请稍后再试",
"mail is not configured": "邮件通知尚未配置完整",
"release notices are not configured": "版本日志功能尚未配置",
"legacy sync service is not configured": "旧项目同步服务尚未配置",
"update-info requires app_version or title": "更新 JSON 需要填写 app_version 或 title",
"media-types requires categories array": "媒体源 JSON 需要包含 categories 数组",
"version or app_version is required": "版本日志需要填写 version 或 app_version",
}
if translated, ok := exact[lower]; ok {
return translated
@@ -74,6 +82,7 @@ func localizedErrorMessage(code, message string) string {
"PASSWORD_CHANGE_FAILED": "密码修改失败",
"INVALID_PAYLOAD": "提交内容格式不正确",
"DATABASE_TEST_FAILED": "数据库连接测试失败",
"DATABASE_SAVE_FAILED": "数据库配置保存失败",
"DATABASE_IMPORT_FAILED": "SQLite 导入远端库失败",
"DATABASE_SYNC_FAILED": "远端库同步回本地失败",
"LEGACY_SAVE_FAILED": "兼容 JSON 保存失败",
@@ -97,6 +106,9 @@ func localizedErrorMessage(code, message string) string {
"AUDIT_FAILED": "审计日志加载失败",
"FEEDBACK_LIST_FAILED": "反馈列表加载失败",
"FEEDBACK_UPDATE_FAILED": "反馈工单更新失败",
"MAIL_CONFIG_FAILED": "邮件配置保存失败",
"MAIL_TEST_FAILED": "测试邮件发送失败",
"MAIL_RETRY_FAILED": "反馈邮件重试失败",
"NOTICE_NOT_FOUND": "未找到版本日志",
"NOTICES_FAILED": "版本日志加载失败",
"MEDIA_TYPES_FAILED": "媒体源 JSON 加载失败",
@@ -130,12 +130,111 @@ func TestClientBootstrapAndEndpointsShape(t *testing.T) {
}
continue
}
if path == "/api/client/bootstrap" {
branding, ok := payload["branding"].(map[string]any)
if !ok {
t.Fatalf("bootstrap missing branding: %#v", payload)
}
if branding["developerName"] != "YMhut" || branding["feedbackEmail"] != "support@ymhut.cn" {
t.Fatalf("unexpected branding defaults: %#v", branding)
}
}
if payload["ok"] != true {
t.Fatalf("%s missing ok=true: %#v", path, payload)
}
}
}
func TestAdminDeleteSourcePublishesCompatibilityJSON(t *testing.T) {
handler, cleanup := testRouter(t)
defer cleanup()
session, csrf, err := loginForTest(handler)
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodDelete, "/api/admin/sources/demo", nil)
req.AddCookie(&http.Cookie{Name: auth.SessionCookie, Value: session})
req.Header.Set("X-CSRF-Token", csrf)
res := httptest.NewRecorder()
handler.ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("delete source returned %d: %s", res.Code, res.Body.String())
}
mediaReq := httptest.NewRequest(http.MethodGet, "/media-types.json", nil)
mediaRes := httptest.NewRecorder()
handler.ServeHTTP(mediaRes, mediaReq)
if mediaRes.Code != http.StatusOK {
t.Fatalf("media-types returned %d: %s", mediaRes.Code, mediaRes.Body.String())
}
if strings.Contains(mediaRes.Body.String(), `"demo"`) {
t.Fatalf("deleted source leaked into media-types.json: %s", mediaRes.Body.String())
}
updateReq := httptest.NewRequest(http.MethodGet, "/update-info.json", nil)
updateRes := httptest.NewRecorder()
handler.ServeHTTP(updateRes, updateReq)
if updateRes.Code != http.StatusOK {
t.Fatalf("update-info returned %d: %s", updateRes.Code, updateRes.Body.String())
}
var updatePayload map[string]any
if err := json.Unmarshal(updateRes.Body.Bytes(), &updatePayload); err != nil {
t.Fatal(err)
}
assertJSONKeys(t, "update-info after source delete", updatePayload, []string{"app_version", "manifest_version", "packages", "modules"})
}
func TestAdminAuditPaginationAndBranding(t *testing.T) {
handler, cleanup := testRouter(t)
defer cleanup()
session, csrf, err := loginForTest(handler)
if err != nil {
t.Fatal(err)
}
for i := 0; i < 40; i++ {
body := strings.NewReader(`{"developerName":"YMhut","feedbackEmail":"support@ymhut.cn"}`)
req := httptest.NewRequest(http.MethodPost, "/api/admin/system/branding", body)
req.AddCookie(&http.Cookie{Name: auth.SessionCookie, Value: session})
req.Header.Set("X-CSRF-Token", csrf)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
handler.ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("branding save %d returned %d: %s", i, res.Code, res.Body.String())
}
}
req := httptest.NewRequest(http.MethodGet, "/api/admin/system/audit?page=1&perPage=35&type=system.branding.saved", nil)
req.AddCookie(&http.Cookie{Name: auth.SessionCookie, Value: session})
res := httptest.NewRecorder()
handler.ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("audit returned %d: %s", res.Code, res.Body.String())
}
var payload struct {
Items []any `json:"items"`
Page struct {
Total int `json:"total"`
Page int `json:"page"`
PerPage int `json:"perPage"`
} `json:"page"`
}
if err := json.Unmarshal(res.Body.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload.Page.Page != 1 || payload.Page.PerPage != 35 {
t.Fatalf("unexpected audit page metadata: %#v", payload.Page)
}
if payload.Page.Total < 40 {
t.Fatalf("expected at least 40 branding audit records, got %d", payload.Page.Total)
}
if len(payload.Items) > 35 {
t.Fatalf("expected at most 35 audit items, got %d", len(payload.Items))
}
}
func TestLegacyFeedbackStatusDTOContract(t *testing.T) {
payload := legacyFeedbackStatus(db.Feedback{
Code: "FB-20260626-ABCDEF",
@@ -419,6 +518,80 @@ func TestAdminLegacyRequiresAuth(t *testing.T) {
}
}
func TestAdminLegacyUpdateInfoSyncsReleaseNotice(t *testing.T) {
handler, cleanup := testRouter(t)
defer cleanup()
session, csrf, err := loginForTest(handler)
if err != nil {
t.Fatal(err)
}
body, _ := json.Marshal(map[string]string{
"raw": `{"app_version":"2.0.7.5","title":"YMhut Box 2.0.7.5","message":"随机放映室优化","release_notes":"修复图片源和全屏预览"}`,
})
req := httptest.NewRequest(http.MethodPut, "/api/admin/legacy/update-info", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-CSRF-Token", csrf)
req.AddCookie(&http.Cookie{Name: auth.SessionCookie, Value: session})
res := httptest.NewRecorder()
handler.ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("save update-info returned %d: %s", res.Code, res.Body.String())
}
listReq := httptest.NewRequest(http.MethodGet, "/api/admin/releases/notices", nil)
listReq.AddCookie(&http.Cookie{Name: auth.SessionCookie, Value: session})
listRes := httptest.NewRecorder()
handler.ServeHTTP(listRes, listReq)
if listRes.Code != http.StatusOK {
t.Fatalf("notice list returned %d: %s", listRes.Code, listRes.Body.String())
}
var payload struct {
Items []struct {
Version string `json:"version"`
Title string `json:"title"`
} `json:"items"`
}
if err := json.Unmarshal(listRes.Body.Bytes(), &payload); err != nil {
t.Fatal(err)
}
found := false
for _, item := range payload.Items {
if item.Version == "2.0.7.5" && item.Title == "YMhut Box 2.0.7.5" {
found = true
}
}
if !found {
t.Fatalf("synced release notice not found: %#v", payload.Items)
}
}
func TestAdminLegacyValidationErrorIsChinese(t *testing.T) {
handler, cleanup := testRouter(t)
defer cleanup()
session, csrf, err := loginForTest(handler)
if err != nil {
t.Fatal(err)
}
body, _ := json.Marshal(map[string]string{"raw": `{"message":"missing version and title"}`})
req := httptest.NewRequest(http.MethodPost, "/api/admin/legacy/update-info/validate", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-CSRF-Token", csrf)
req.AddCookie(&http.Cookie{Name: auth.SessionCookie, Value: session})
res := httptest.NewRecorder()
handler.ServeHTTP(res, req)
if res.Code != http.StatusBadRequest {
t.Fatalf("expected validation failure, got %d: %s", res.Code, res.Body.String())
}
if strings.Contains(res.Body.String(), "update-info requires app_version or title") {
t.Fatalf("english validation leaked: %s", res.Body.String())
}
if !strings.Contains(res.Body.String(), "更新 JSON 需要填写 app_version 或 title") {
t.Fatalf("missing chinese validation message: %s", res.Body.String())
}
}
func TestAdminWriteRequiresCSRF(t *testing.T) {
handler, cleanup := testRouter(t)
defer cleanup()
@@ -599,9 +772,12 @@ func testRouter(t *testing.T) (http.Handler, func()) {
})
mustWriteJSON(t, filepath.Join(noticeDir, "2.0.0.json"), map[string]any{"app_version": "2.0.0", "title": "YMhut Box 2.0.0", "release_notes": "Initial release", "release_notes_md": "## Initial"})
cfg := &config.Config{
BaseDir: root,
ConfigPath: filepath.Join(root, "config.json"),
Listen: ":0",
BaseURL: "https://update.ymhut.cn",
StorageDir: filepath.Join(root, "storage"),
DataDir: filepath.Join(root, "data"),
UpdatePublicDir: public,
UpdateNoticeDir: noticeDir,
DownloadsDir: filepath.Join(public, "downloads"),
+15 -94
View File
@@ -3,11 +3,8 @@ package web
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"path/filepath"
"strconv"
"strings"
"time"
@@ -24,18 +21,7 @@ type setupRequest struct {
BaseURL string `json:"baseUrl"`
SQLitePath string `json:"sqlitePath"`
MySQLDSN string `json:"mysqlDsn"`
MySQL setupMySQLConfig `json:"mysql"`
}
type setupMySQLConfig struct {
Host string `json:"host"`
Port int `json:"port"`
Database string `json:"database"`
Username string `json:"username"`
Password string `json:"password"`
Charset string `json:"charset"`
ParseTime bool `json:"parseTime"`
TLS string `json:"tls"`
MySQL config.MySQLInput `json:"mysql"`
}
func NewSetupRouter(cfg *config.Config) http.Handler {
@@ -73,7 +59,7 @@ func (r *setupRouter) status() map[string]any {
"defaults": map[string]any{
"provider": firstNonEmpty(r.cfg.Database.Provider, "sqlite"),
"sqlitePath": relativeToBase(r.cfg.BaseDir, r.cfg.Database.SQLitePath),
"mysqlDsn": maskDSN(r.cfg.Database.MySQLDSN),
"mysqlDsn": config.MaskDSN(r.cfg.Database.MySQLDSN),
"baseUrl": r.cfg.BaseURL,
},
}
@@ -103,7 +89,7 @@ func (r *setupRouter) handleDatabaseTest(w http.ResponseWriter, req *http.Reques
"provider": next.Provider,
"baseUrl": firstNonEmpty(body.BaseURL, r.cfg.BaseURL),
"sqlitePath": relativeToBase(r.cfg.BaseDir, next.SQLitePath),
"mysqlDsn": maskDSN(next.MySQLDSN),
"mysqlDsn": config.MaskDSN(next.MySQLDSN),
},
})
}
@@ -153,44 +139,18 @@ func (r *setupRouter) decodeSetupDatabase(req *http.Request) (config.DatabaseCon
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
return config.DatabaseConfig{}, body, err
}
next := r.cfg.Database
next.Provider = strings.ToLower(strings.TrimSpace(firstNonEmpty(body.Provider, next.Provider, "sqlite")))
if body.SQLitePath != "" {
next.SQLitePath = body.SQLitePath
incoming := config.DatabaseConfig{
Provider: body.Provider,
SQLitePath: body.SQLitePath,
MySQLDSN: body.MySQLDSN,
MySQLHost: body.MySQL.Host,
MySQLPort: body.MySQL.Port,
MySQLDatabase: body.MySQL.Database,
MySQLUser: body.MySQL.Username,
MySQLPassword: body.MySQL.Password,
}
if next.SQLitePath != "" && !filepath.IsAbs(next.SQLitePath) && !strings.HasPrefix(strings.ToLower(next.SQLitePath), "file:") {
next.SQLitePath = filepath.Join(r.cfg.BaseDir, next.SQLitePath)
}
if next.Provider == "sqlite" {
next.MySQLDSN = ""
} else if body.MySQLDSN != "" {
next.MySQLDSN = body.MySQLDSN
} else if body.MySQL.Host != "" || body.MySQL.Database != "" || body.MySQL.Username != "" {
dsn, err := buildMySQLDSN(body.MySQL)
if err != nil {
return config.DatabaseConfig{}, body, err
}
next.MySQLDSN = dsn
}
if next.Provider != "sqlite" && next.Provider != "mysql" {
return config.DatabaseConfig{}, body, errors.New("provider must be sqlite or mysql")
}
if next.Provider == "mysql" && strings.TrimSpace(next.MySQLDSN) == "" {
return config.DatabaseConfig{}, body, errors.New("mysql connection is required")
}
if next.MaxOpenConns <= 0 {
next.MaxOpenConns = 10
}
if next.MaxIdleConns <= 0 {
next.MaxIdleConns = 4
}
if next.ConnMaxLifetimeSeconds <= 0 {
next.ConnMaxLifetimeSeconds = 300
}
if next.HealthIntervalSec <= 0 {
next.HealthIntervalSec = 30
}
return next, body, nil
next, err := config.NormalizeDatabase(r.cfg.BaseDir, r.cfg.Database, incoming, false)
return next, body, err
}
func (r *setupRouter) serveSetup(w http.ResponseWriter, req *http.Request) {
@@ -205,48 +165,9 @@ func (r *setupRouter) serveSetup(w http.ResponseWriter, req *http.Request) {
_, _ = w.Write([]byte(`<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><title>YMhut Setup</title></head><body><main><h1>YMhut Setup</h1><p>Setup frontend is not built. Run npm install && npm run build in web/setup.</p><p>` + index + `</p></main></body></html>`))
}
func buildMySQLDSN(input setupMySQLConfig) (string, error) {
host := strings.TrimSpace(input.Host)
if host == "" {
host = "127.0.0.1"
}
port := input.Port
if port <= 0 {
port = 3306
}
database := strings.TrimSpace(input.Database)
username := strings.TrimSpace(input.Username)
if database == "" {
return "", errors.New("mysql database is required")
}
if username == "" {
return "", errors.New("mysql username is required")
}
params := url.Values{}
params.Set("charset", firstNonEmpty(strings.TrimSpace(input.Charset), "utf8mb4"))
params.Set("parseTime", strconv.FormatBool(input.ParseTime))
if tls := strings.TrimSpace(input.TLS); tls != "" {
params.Set("tls", tls)
}
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?%s", username, input.Password, host, port, database, params.Encode()), nil
}
func maskDSN(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
at := strings.Index(value, "@")
colon := strings.Index(value, ":")
if at > -1 && colon > -1 && colon < at {
return value[:colon+1] + "******" + value[at:]
}
return value
}
func maskedDatabaseTarget(base string, cfg config.DatabaseConfig) string {
if strings.EqualFold(cfg.Provider, "mysql") {
return maskDSN(cfg.MySQLDSN)
return config.MaskDSN(cfg.MySQLDSN)
}
return relativeToBase(base, cfg.SQLitePath)
}
+480 -30
View File
@@ -31,8 +31,11 @@ import { createSystemStore } from "./stores/system";
const DashboardView = defineAsyncComponent(() => import("./views/DashboardView.vue"));
type SystemTab = "database" | "sync" | "security" | "health" | "audit";
type SystemTab = "database" | "migration" | "sync" | "security" | "health" | "audit";
type ToastState = { message: string; type: "success" | "warn" | "error" };
type LoadSystemOptions = { preserveForms?: boolean };
type LoadDatabaseOptions = { previewLegacy?: boolean; preserveForm?: boolean };
type LoadMailOptions = { preserveForm?: boolean };
type Captcha = {
captchaId: string;
@@ -58,6 +61,8 @@ const currentPath = computed(() => normalizeAdminPath(route.path));
const loading = ref(false);
const toast = ref<ToastState | null>(null);
const autoRefreshPaused = ref(false);
const databaseFormEditing = ref(false);
const mailConfigEditing = ref(false);
let refreshTimer: number | undefined;
let toastTimer: number | undefined;
let events: EventSource | null = null;
@@ -74,9 +79,9 @@ const { csrf, captcha, bootstrap: authBootstrap, loginForm, passwordForm } = aut
const { dashboard, sourceCheckJobs } = dashboardStore;
const { page: feedbackPage, selected: selectedFeedback, filters: feedbackFilters, update: feedbackUpdate, commentDraft } = feedbackStore;
const { releases, notices: releaseNotices, selectedNotice, noticeDraft, uploadDraft } = releaseStore;
const { sync: legacySync, documents: legacyDocuments, drafts: legacyDrafts } = legacyStore;
const { sync: legacySync, documents: legacyDocuments, drafts: legacyDrafts, modal: legacyModal, activeMediaCategoryIndex } = legacyStore;
const { sources, endpoints, draft: sourceDraft } = sourceStore;
const { database, databaseLastSync, healthSnapshot, auditLogs, databaseForm, legacySyncMode } = systemStore;
const { database, databaseConfig, databaseLastSync, healthSnapshot, auditLogs, auditPage, migrationStatus, branding, databaseForm, databaseConfigCollapsed, mailConfig, legacySyncMode } = systemStore;
const routes: RouteItem[] = [
{ path: "/admin/dashboard", label: "仪表盘", description: "服务状态、接口心跳与运营指标", icon: LayoutDashboard },
@@ -114,25 +119,59 @@ const visibleEndpointCount = computed(() => endpoints.value.filter((item) => ite
const healthyEndpointCount = computed(() => endpoints.value.filter((item) => ["ok", "redirected"].includes(endpointStatus(item))).length);
const latestNotice = computed(() => releaseNotices.value[0] || null);
const activeLegacyLabel = computed(() => activeLegacyName.value === "media-types" ? "media-types.json" : "update-info.json");
const activeMediaCategory = computed(() => {
const categories = legacyDrafts["media-types"].form.categories || [];
return categories[activeMediaCategoryIndex.value] || null;
});
const systemTab = computed<SystemTab>(() => normalizeSystemTab(route.query.tab));
const heartbeatChartRows = computed(() => {
const rows = heartbeats.value
.slice()
.reverse()
.map((item: any) => ({
time: heartbeatTimeValue(item.checkedAt),
label: timeLabel(item.checkedAt),
latency: Number(item.latencyMs ?? item.latency_ms ?? 0),
name: item.name || item.sourceId || "未知接口",
status: labelStatus(item.status),
}))
.filter((item: any) => Number.isFinite(item.latency));
return rows.length ? rows : [{ time: Date.now(), label: "暂无", latency: 0, name: "暂无检测记录", status: "未检测" }];
});
const isHeartbeatChartEmpty = computed(() => heartbeats.value.length === 0);
const heartbeatOption = computed(() => ({
animation: true,
tooltip: { trigger: "axis" },
grid: { left: 44, right: 18, top: 28, bottom: 34 },
grid: { left: 48, right: 22, top: 28, bottom: 40, containLabel: true },
xAxis: {
type: "category",
data: heartbeats.value.slice().reverse().map((item: any) => timeLabel(item.checkedAt)),
boundaryGap: heartbeatChartRows.value.length <= 1,
data: heartbeatChartRows.value.map((item: any) => item.label),
axisLine: { lineStyle: { color: "#cbd5e1" } },
axisLabel: { color: "#64748b" },
},
yAxis: {
type: "value",
name: "ms",
min: 0,
axisLine: { lineStyle: { color: "#cbd5e1" } },
axisLabel: { color: "#64748b" },
splitLine: { lineStyle: { color: "#e5e7eb" } },
},
yAxis: { type: "value", name: "ms", axisLine: { lineStyle: { color: "#cbd5e1" } }, splitLine: { lineStyle: { color: "#e5e7eb" } } },
series: [
{
name: "接口延迟",
type: "line",
smooth: true,
showSymbol: true,
symbolSize: 7,
connectNulls: true,
areaStyle: { opacity: 0.18 },
data: heartbeats.value.slice().reverse().map((item: any) => item.latencyMs || 0),
data: heartbeatChartRows.value.map((item: any) => item.latency),
color: "#2563eb",
lineStyle: { width: 3 },
emphasis: { focus: "series" },
},
],
}));
@@ -199,19 +238,29 @@ const viewContext = computed(() => ({
addMediaCategory,
addMediaSubcategory,
addUpdateMirror,
applyLegacyModal,
auditPage,
auditLogs: auditLogs.value,
autoRefreshPaused: autoRefreshPaused.value,
availabilityOption: availabilityOption.value,
branding,
changePassword,
checkSources,
clientCalls: clientCalls.value,
commentDraft,
copyEndpointToSource,
database: database.value,
databaseConfig: databaseConfig.value,
databaseConfigCollapsed: databaseConfigCollapsed.value,
databaseFormEditing: databaseFormEditing.value,
databaseForm,
databaseLastSync: databaseLastSync.value,
databaseSyncStatusLabel,
databaseSyncDirectionLabel,
databaseSyncTableCount,
databaseConfigSummary,
deleteEndpoint,
editDatabaseConfig,
endpointStatus,
endpoints: endpoints.value,
feedbackFilters,
@@ -224,16 +273,36 @@ const viewContext = computed(() => ({
healthyEndpointCount: healthyEndpointCount.value,
heartbeatOption: heartbeatOption.value,
heartbeats: heartbeats.value,
isHeartbeatChartEmpty: isHeartbeatChartEmpty.value,
importNotices,
kpis: kpis.value,
labelStatus,
labelPriority,
latestNotice: latestNotice.value,
legacyDocuments,
legacyDrafts,
legacyModal,
activeMediaCategoryIndex: activeMediaCategoryIndex.value,
activeMediaCategory: activeMediaCategory.value,
legacySync: legacySync.value,
legacySyncMode: legacySyncMode.value,
loadAudit,
loadBranding,
loadFeedbacks,
loadMigrationStatus,
mailConfig,
mailConfigEditing: mailConfigEditing.value,
markDatabaseFormEditing,
markMailConfigEditing,
migrationStatus: migrationStatus.value,
loadMailConfig,
reloadDatabaseConfig,
reloadMailConfig,
saveDatabase,
saveBranding,
saveMailConfig,
testMail,
retryFeedbackMail,
navigate,
noticeDraft,
onPackageSelected,
@@ -262,8 +331,15 @@ const viewContext = computed(() => ({
syncDatabase,
systemTab: systemTab.value,
setSystemTab,
setAuditPage,
selectAuditLog,
testDatabase,
toggleAutoRefresh,
openMediaCategoryModal,
openMediaSubcategoryModal,
openUpdateMirrorModal,
selectMediaCategory,
closeLegacyModal,
updateLegacyRawFromForm,
uploadDraft,
uploadPackage,
@@ -291,7 +367,7 @@ function normalizeAdminPath(value: string) {
function normalizeSystemTab(value: unknown): SystemTab {
const tab = Array.isArray(value) ? value[0] : value;
if (tab === "sync" || tab === "security" || tab === "health" || tab === "audit") return tab;
if (tab === "migration" || tab === "sync" || tab === "security" || tab === "health" || tab === "audit") return tab;
return "database";
}
@@ -392,7 +468,7 @@ async function load() {
if (currentPath.value === "/admin/releases") await loadReleases();
if (currentPath.value === "/admin/sources") await loadSources();
if (currentPath.value === "/admin/endpoints") await loadEndpoints();
if (currentPath.value === "/admin/system") await loadSystem();
if (currentPath.value === "/admin/system") await loadSystem({ preserveForms: true });
const legacyName = activeLegacyName.value;
if (legacyName) await loadLegacy(legacyName);
connectAdminEvents();
@@ -403,14 +479,22 @@ async function loadDashboard() {
dashboard.value = await api("/api/admin/dashboard/overview?window=24h");
}
async function loadSystem() {
await Promise.all([loadDatabase(), loadHealth(), loadAudit()]);
async function loadSystem(options: LoadSystemOptions = {}) {
await Promise.all([
loadDatabase({ preserveForm: options.preserveForms }),
loadMailConfig({ preserveForm: options.preserveForms }),
loadHealth(),
loadAudit(),
loadMigrationStatus(),
loadBranding(),
]);
}
async function loadFeedbacks() {
const params = new URLSearchParams({ page: String(feedbackFilters.page), perPage: String(feedbackFilters.perPage) });
if (feedbackFilters.q) params.set("q", feedbackFilters.q);
if (feedbackFilters.status) params.set("status", feedbackFilters.status);
if (feedbackFilters.priority) params.set("priority", feedbackFilters.priority);
const data = await api<{ page: any }>(`/api/admin/feedbacks?${params}`);
feedbackPage.value = data.page || { items: [], total: 0, page: 1, perPage: 20 };
}
@@ -419,6 +503,7 @@ async function openFeedback(item: any) {
const data = await api<{ feedback: any }>(`/api/admin/feedbacks/${encodeURIComponent(item.code)}`);
selectedFeedback.value = data.feedback;
feedbackUpdate.status = data.feedback.status || "new";
feedbackUpdate.priority = data.feedback.priority || "normal";
feedbackUpdate.statusDetail = data.feedback.statusDetail || "";
feedbackUpdate.publicReply = data.feedback.publicReply || "";
}
@@ -445,14 +530,17 @@ async function addFeedbackComment() {
});
}
async function loadReleases() {
async function loadReleases(preferredVersion = noticeDraft.version) {
const [releaseData, noticeData] = await Promise.all([
api<{ manifest: any }>("/api/admin/releases"),
api<{ items: any[] }>("/api/admin/releases/notices"),
]);
releases.value = releaseData.manifest;
releaseNotices.value = noticeData.items || [];
if (releaseNotices.value.length && !noticeDraft.version) await openNotice(releaseNotices.value[0].version);
const target = preferredVersion && releaseNotices.value.some((item: any) => item.version === preferredVersion)
? preferredVersion
: releaseNotices.value[0]?.version;
if (target && noticeDraft.version !== target) await openNotice(target);
}
async function importNotices() {
@@ -492,7 +580,7 @@ async function saveNotice() {
selectedNotice.value = data.document;
noticeDraft.note = "";
setToast("版本日志已保存并同步兼容更新信息");
await loadReleases();
await loadReleases(data.document?.notice?.version || noticeDraft.version);
});
}
@@ -516,6 +604,7 @@ async function loadLegacy(name: LegacyName) {
legacyDrafts[name].raw = data.document.raw || "";
legacyDrafts[name].preview = data.document.parsed || null;
legacyDrafts[name].form = makeLegacyForm(name, data.document.parsed || {});
if (name === "media-types") clampMediaCategoryIndex();
}
function onPackageSelected(event: Event) {
@@ -597,6 +686,12 @@ async function saveLegacy(name: LegacyName) {
legacyDrafts[name].preview = data.document.parsed;
legacyDrafts[name].form = makeLegacyForm(name, data.document.parsed || {});
legacyDrafts[name].note = "";
if (name === "media-types") {
clampMediaCategoryIndex();
}
if (name === "update-info") {
await loadReleases(legacyDrafts[name].preview?.app_version || legacyDrafts[name].preview?.version || noticeDraft.version);
}
setToast("兼容 JSON 已保存并发布到旧路径");
});
}
@@ -611,6 +706,8 @@ async function restoreLegacy(name: LegacyName, revisionId: number) {
legacyDrafts[name].raw = data.document.raw;
legacyDrafts[name].preview = data.document.parsed;
legacyDrafts[name].form = makeLegacyForm(name, data.document.parsed || {});
if (name === "media-types") clampMediaCategoryIndex();
if (name === "update-info") await loadReleases(legacyDrafts[name].preview?.app_version || legacyDrafts[name].preview?.version || noticeDraft.version);
setToast("兼容 JSON 已恢复");
});
}
@@ -648,6 +745,7 @@ function makeLegacyForm(name: LegacyName, parsed: any) {
release_notes_md: parsed.release_notes_md || "",
update_notes: JSON.stringify(parsed.update_notes || {}, null, 2),
last_update_notes: JSON.stringify(parsed.last_update_notes || {}, null, 2),
download_mirrors: clone(parsed.download_mirrors || []),
package_sha256: parsed.package_sha256 || "",
package_size: parsed.package_size || "",
updated_at: parsed.updated_at || parsed.last_updated || "",
@@ -683,6 +781,7 @@ function updateLegacyRawFromForm(name: LegacyName) {
if (form[key] !== undefined) current[key] = form[key];
}
if (form.package_size !== "") current.package_size = Number(form.package_size || 0);
current.download_mirrors = clone(form.download_mirrors || current.download_mirrors || []);
current.update_notes = parseJSONSafe(form.update_notes, current.update_notes || {});
current.last_update_notes = parseJSONSafe(form.last_update_notes, current.last_update_notes || {});
}
@@ -710,8 +809,104 @@ function addMediaSubcategory(category: any) {
category.subcategories.push({ id: `source-${category.subcategories.length + 1}`, name: "新接口", api_url: "", refresh_interval: 300, supported_formats: "json", downloadable: true });
}
function openMediaCategoryModal(index = -1) {
const categories = legacyDrafts["media-types"].form.categories || [];
const existing = index >= 0 ? categories[index] : null;
Object.assign(legacyModal, {
open: true,
type: "media-category",
categoryIndex: index,
itemIndex: -1,
draft: clone(existing || { id: `category-${categories.length + 1}`, name: "新分类", enabled: true }),
});
}
function selectMediaCategory(index: number) {
activeMediaCategoryIndex.value = Math.max(0, index);
clampMediaCategoryIndex();
}
function clampMediaCategoryIndex() {
const categories = legacyDrafts["media-types"].form.categories || [];
if (categories.length === 0) {
activeMediaCategoryIndex.value = 0;
return;
}
activeMediaCategoryIndex.value = Math.min(Math.max(0, activeMediaCategoryIndex.value), categories.length - 1);
}
function openMediaSubcategoryModal(categoryIndex = activeMediaCategoryIndex.value, itemIndex = -1) {
const categories = legacyDrafts["media-types"].form.categories || [];
const category = categories[categoryIndex];
if (!category) return;
activeMediaCategoryIndex.value = categoryIndex;
const subcategories = category.subcategories || [];
const existing = itemIndex >= 0 ? subcategories[itemIndex] : null;
Object.assign(legacyModal, {
open: true,
type: "media-subcategory",
categoryIndex,
itemIndex,
draft: clone(existing || { id: `source-${subcategories.length + 1}`, name: "新接口", api_url: "", thumbnail_url: "", refresh_interval: 300, supported_formats: "json", downloadable: true, description: "" }),
});
}
function openUpdateMirrorModal(index = -1) {
const form = legacyDrafts["update-info"].form;
if (!Array.isArray(form.download_mirrors)) form.download_mirrors = [];
const mirrors = form.download_mirrors;
const existing = index >= 0 ? mirrors[index] : null;
Object.assign(legacyModal, {
open: true,
type: "update-mirror",
categoryIndex: -1,
itemIndex: index,
draft: clone(existing || { id: `mirror-${mirrors.length + 1}`, name: "备用镜像", url: "", type: "direct", enabled: true }),
});
}
function closeLegacyModal() {
Object.assign(legacyModal, { open: false, type: "", categoryIndex: -1, itemIndex: -1, draft: {} });
}
function applyLegacyModal() {
if (legacyModal.type === "media-category") {
const form = legacyDrafts["media-types"].form;
if (!Array.isArray(form.categories)) form.categories = [];
const next = { ...legacyModal.draft, enabled: legacyModal.draft.enabled !== false, subcategories: legacyModal.draft.subcategories || [] };
if (legacyModal.categoryIndex >= 0) {
next.subcategories = form.categories[legacyModal.categoryIndex]?.subcategories || [];
form.categories.splice(legacyModal.categoryIndex, 1, next);
activeMediaCategoryIndex.value = legacyModal.categoryIndex;
} else {
form.categories.push(next);
activeMediaCategoryIndex.value = form.categories.length - 1;
}
clampMediaCategoryIndex();
}
if (legacyModal.type === "media-subcategory") {
const category = legacyDrafts["media-types"].form.categories?.[legacyModal.categoryIndex];
if (!category) return;
if (!Array.isArray(category.subcategories)) category.subcategories = [];
const next = { ...legacyModal.draft, refresh_interval: Number(legacyModal.draft.refresh_interval || 300), downloadable: legacyModal.draft.downloadable !== false };
if (legacyModal.itemIndex >= 0) category.subcategories.splice(legacyModal.itemIndex, 1, next);
else category.subcategories.push(next);
}
if (legacyModal.type === "update-mirror") {
const form = legacyDrafts["update-info"].form;
if (!Array.isArray(form.download_mirrors)) form.download_mirrors = [];
const mirrors = form.download_mirrors;
const next = { ...legacyModal.draft, enabled: legacyModal.draft.enabled !== false };
if (legacyModal.itemIndex >= 0) mirrors.splice(legacyModal.itemIndex, 1, next);
else mirrors.push(next);
updateLegacyRawFromForm("update-info");
}
closeLegacyModal();
}
function removeItem(list: any[], index: number) {
list.splice(index, 1);
clampMediaCategoryIndex();
}
async function loadSources() {
@@ -731,10 +926,10 @@ async function checkSources() {
await guarded(async () => {
const data = await api<{ jobId: string; job: any }>("/api/admin/sources/check", { method: "POST", body: "{}" });
if (data.job) sourceCheckJobs.value = [data.job, ...sourceCheckJobs.value.filter((item) => item.id !== data.job.id)].slice(0, 5);
setToast(`接口心跳检测已进入队列:${data.jobId}`);
setToast(`服务端接口检测已进入队列:${data.jobId}`);
if (currentPath.value === "/admin/dashboard") await loadDashboard();
if (currentPath.value === "/admin/sources") await loadSources();
if (currentPath.value === "/admin/system") await loadSystem();
if (currentPath.value === "/admin/system") await loadSystem({ preserveForms: true });
});
}
@@ -767,32 +962,226 @@ function copyEndpointToSource(item: any) {
navigate("/admin/sources");
}
async function loadDatabase(options: { previewLegacy?: boolean } = {}) {
const data = await api<{ database: any }>("/api/admin/database/status");
async function deleteEndpoint(item: any) {
const sourceID = item.id || item.sourceId;
if (!sourceID) return;
if (!window.confirm(`确认删除客户端接口「${sourceID}」?删除后会同步兼容 media-types.json 和 update-info.json。`)) return;
await guarded(async () => {
await api(`/api/admin/sources/${encodeURIComponent(sourceID)}`, { method: "DELETE" });
setToast("客户端接口已删除,兼容 JSON 已同步");
await Promise.all([loadSources().catch(() => undefined), loadEndpoints()]);
});
}
async function loadDatabase(options: LoadDatabaseOptions = {}) {
const data = await api<{ database: any; config?: any }>("/api/admin/database/status");
database.value = data.database;
databaseForm.provider = data.database?.configProvider || "sqlite";
databaseConfig.value = data.config || null;
if (!options.preserveForm || !databaseFormEditing.value) {
applyDatabaseConfig(data.config || {}, data.database || {});
databaseFormEditing.value = false;
}
if (options.previewLegacy !== false) await previewLegacySync();
}
function applyDatabaseConfig(config: any, status: any = {}) {
databaseForm.provider = config.provider || status.configProvider || "sqlite";
databaseForm.sqlitePath = config.sqlitePath || "";
databaseForm.mysqlHost = config.mysqlHost || "127.0.0.1";
databaseForm.mysqlPort = Number(config.mysqlPort || 3306);
databaseForm.mysqlDatabase = config.mysqlDatabase || "";
databaseForm.mysqlUser = config.mysqlUser || "";
databaseForm.mysqlPassword = "";
databaseForm.mysqlDsn = config.mysqlDsn || "";
databaseConfigCollapsed.value = databaseForm.provider === "mysql" && Boolean(config.mysqlHost || config.mysqlDatabase || config.mysqlDsn);
}
function databasePayload() {
return {
provider: databaseForm.provider,
sqlite_path: databaseForm.sqlitePath,
mysql_host: databaseForm.mysqlHost,
mysql_port: Number(databaseForm.mysqlPort || 3306),
mysql_database: databaseForm.mysqlDatabase,
mysql_user: databaseForm.mysqlUser,
mysql_password: databaseForm.mysqlPassword,
};
}
async function testDatabase() {
await guarded(async () => {
await api("/api/admin/database/test", {
method: "POST",
body: JSON.stringify({ provider: databaseForm.provider, sqlite_path: databaseForm.sqlitePath, mysql_dsn: databaseForm.mysqlDsn }),
body: JSON.stringify(databasePayload()),
});
setToast("数据库连接测试通过");
});
}
async function saveDatabase() {
await guarded(async () => {
const data = await api<{ database: any; config: any }>("/api/admin/database/save", {
method: "POST",
body: JSON.stringify(databasePayload()),
});
database.value = data.database;
databaseConfig.value = data.config;
databaseFormEditing.value = false;
applyDatabaseConfig(data.config || {}, data.database || {});
databaseConfigCollapsed.value = true;
setToast("数据库配置已测试、保存并热切换");
await loadDatabase({ previewLegacy: false, preserveForm: true });
});
}
async function syncDatabase(direction: "import" | "sync") {
await guarded(async () => {
const data = await api<{ result?: any; finishedAt?: string }>(direction === "import" ? "/api/admin/database/import-sqlite" : "/api/admin/database/sync", { method: "POST", body: "{}" });
databaseLastSync.value = data.result || { direction: direction === "import" ? "sqlite_to_remote" : "remote_to_sqlite", finishedAt: data.finishedAt };
setToast(direction === "import" ? "SQLite 已导入远端库" : "远端库已同步回本地");
const result = databaseLastSync.value || {};
if (result.skipped) {
setToast(result.warnings?.[0] || "远端 MySQL 未配置,同步已跳过", "warn");
} else {
setToast(direction === "import" ? "SQLite 已导入远端库" : "远端库已同步回本地");
}
await loadDatabase({ previewLegacy: false });
});
}
function editDatabaseConfig() {
databaseFormEditing.value = true;
databaseConfigCollapsed.value = false;
}
function markDatabaseFormEditing() {
databaseFormEditing.value = true;
}
async function reloadDatabaseConfig() {
databaseFormEditing.value = false;
await guarded(async () => {
await loadDatabase({ previewLegacy: false });
setToast("数据库配置已从服务端重新读取");
});
}
function databaseConfigSummary() {
const config = databaseConfig.value || {};
if (databaseForm.provider === "mysql") {
const host = config.mysqlHost || databaseForm.mysqlHost || "127.0.0.1";
const port = config.mysqlPort || databaseForm.mysqlPort || 3306;
const databaseName = config.mysqlDatabase || databaseForm.mysqlDatabase || "-";
const user = config.mysqlUser || databaseForm.mysqlUser || "-";
return `${host}:${port} / ${databaseName} / ${user}${config.hasPassword ? " / 已保存密码" : ""}`;
}
return config.sqlitePath || databaseForm.sqlitePath || "使用默认 SQLite 路径";
}
async function loadMigrationStatus() {
const data = await api<{ migration: any }>("/api/admin/system/migration");
migrationStatus.value = data.migration || null;
}
async function loadBranding() {
const data = await api<{ branding: any }>("/api/admin/system/branding");
Object.assign(branding, {
siteIconUrl: data.branding?.siteIconUrl || branding.siteIconUrl,
developerAvatarUrl: data.branding?.developerAvatarUrl || branding.developerAvatarUrl,
developerName: data.branding?.developerName || "YMhut",
feedbackEmail: data.branding?.feedbackEmail || "support@ymhut.cn",
});
}
async function saveBranding() {
await guarded(async () => {
const data = await api<{ branding: any }>("/api/admin/system/branding", {
method: "POST",
body: JSON.stringify({
siteIconUrl: branding.siteIconUrl,
developerAvatarUrl: branding.developerAvatarUrl,
developerName: branding.developerName,
feedbackEmail: branding.feedbackEmail,
}),
});
Object.assign(branding, data.branding || {});
if (!mailConfig.developerAddress) mailConfig.developerAddress = branding.feedbackEmail;
setToast("站点品牌信息已保存");
});
}
async function loadMailConfig(options: LoadMailOptions = {}) {
const data = await api<{ config: any }>("/api/admin/system/mail/config");
if (!options.preserveForm || !mailConfigEditing.value) {
Object.assign(mailConfig, {
host: data.config?.host || "",
port: Number(data.config?.port || 465),
secure: data.config?.secure || "ssl",
username: data.config?.username || "",
password: "",
fromAddress: data.config?.fromAddress || "",
fromName: data.config?.fromName || "YMhut Box Feedback",
developerAddress: data.config?.developerAddress || "",
timeoutSeconds: Number(data.config?.timeoutSeconds || 20),
hasPassword: Boolean(data.config?.hasPassword),
configured: Boolean(data.config?.configured),
});
mailConfigEditing.value = false;
}
}
function mailPayload() {
return {
host: mailConfig.host,
port: Number(mailConfig.port || 465),
secure: mailConfig.secure,
username: mailConfig.username,
password: mailConfig.password,
from_address: mailConfig.fromAddress,
from_name: mailConfig.fromName,
developer_address: mailConfig.developerAddress,
timeout_seconds: Number(mailConfig.timeoutSeconds || 20),
};
}
async function saveMailConfig() {
await guarded(async () => {
const data = await api<{ config: any }>("/api/admin/system/mail/config", { method: "POST", body: JSON.stringify(mailPayload()) });
mailConfigEditing.value = false;
Object.assign(mailConfig, { ...data.config, password: "" });
setToast("邮件通知配置已保存");
});
}
async function testMail() {
await guarded(async () => {
await api("/api/admin/system/mail/test", { method: "POST", body: "{}" });
setToast("测试邮件已发送");
await loadMailConfig({ preserveForm: true });
});
}
function markMailConfigEditing() {
mailConfigEditing.value = true;
}
async function reloadMailConfig() {
mailConfigEditing.value = false;
await guarded(async () => {
await loadMailConfig();
setToast("邮件配置已从服务端重新读取");
});
}
async function retryFeedbackMail() {
if (!selectedFeedback.value) return;
await guarded(async () => {
await api(`/api/admin/feedbacks/${encodeURIComponent(selectedFeedback.value.code)}/mail/retry`, { method: "POST", body: "{}" });
setToast("反馈邮件已重新发送");
await openFeedback(selectedFeedback.value);
await loadFeedbacks();
});
}
async function previewLegacySync() {
legacySyncMode.value = "preview";
legacySync.value = await api("/api/admin/sync/legacy/preview").catch((error) => ({ ok: false, errors: [String(error)] }));
@@ -803,7 +1192,7 @@ async function runLegacySync() {
legacySyncMode.value = "run";
legacySync.value = await api("/api/admin/sync/legacy/run", { method: "POST", body: "{}" });
setToast("旧项目同步已完成");
await Promise.all([loadDatabase({ previewLegacy: false }), loadReleases().catch(() => undefined), loadSources().catch(() => undefined)]);
await Promise.all([loadDatabase({ previewLegacy: false, preserveForm: true }), loadReleases().catch(() => undefined), loadSources().catch(() => undefined)]);
});
}
@@ -812,8 +1201,31 @@ async function loadHealth() {
}
async function loadAudit() {
const data = await api<{ items: any[] }>("/api/admin/system/audit");
auditLogs.value = data.items || [];
const params = new URLSearchParams({
page: String(auditPage.page || 1),
perPage: String(auditPage.perPage || 35),
});
if (auditPage.q) params.set("q", auditPage.q);
if (auditPage.type) params.set("type", auditPage.type);
if (auditPage.target) params.set("target", auditPage.target);
const data = await api<{ items: any[]; page?: any }>(`/api/admin/system/audit?${params}`);
const page = data.page || { items: data.items || [], total: data.items?.length || 0, page: auditPage.page, perPage: auditPage.perPage };
auditLogs.value = page.items || [];
Object.assign(auditPage, {
items: page.items || [],
total: Number(page.total || 0),
page: Number(page.page || auditPage.page || 1),
perPage: Number(page.perPage || auditPage.perPage || 35),
});
}
function setAuditPage(page: number) {
auditPage.page = Math.max(1, page);
void loadAudit();
}
function selectAuditLog(item: any) {
auditPage.selected = item;
}
async function changePassword() {
@@ -832,9 +1244,9 @@ function endpointStatus(item: any) {
function statusTone(status: string) {
const value = String(status || "").toLowerCase();
if (["ok", "online", "new", "sqlite", "mysql", "sent", "ready"].includes(value)) return "good";
if (["redirected", "degraded", "pending", "processing", "queued", "missing"].includes(value)) return "warn";
if (["error", "failed", "closed", "offline"].includes(value)) return "bad";
if (["ok", "online", "new", "sqlite", "mysql", "sent", "ready", "completed"].includes(value)) return "good";
if (["redirected", "degraded", "pending", "processing", "queued", "missing", "skipped", "running", "normal"].includes(value)) return "warn";
if (["error", "failed", "closed", "offline", "urgent", "high", "blocking", "major"].includes(value)) return "bad";
return "neutral";
}
@@ -852,11 +1264,30 @@ function labelStatus(value: string) {
new: "新建",
processing: "处理中",
closed: "已关闭",
pending: "待发送",
sent: "已发送",
skipped: "已跳过",
running: "执行中",
completed: "已完成",
failed: "失败",
};
return labels[value] || value || "未知";
}
function labelPriority(value: string) {
const labels: Record<string, string> = {
low: "低",
minor: "低",
normal: "普通",
medium: "普通",
high: "高",
major: "高",
urgent: "紧急",
blocking: "紧急",
};
return labels[String(value || "").toLowerCase()] || value || "普通";
}
function auditTypeLabel(value: string) {
const labels: Record<string, string> = {
"auth.login": "管理员登录",
@@ -892,6 +1323,16 @@ function databaseSyncDirectionLabel(value: string) {
return value || "-";
}
function databaseSyncStatusLabel(value: string) {
const labels: Record<string, string> = {
completed: "已完成",
skipped: "已跳过",
running: "执行中",
failed: "失败",
};
return labels[String(value || "").toLowerCase()] || value || "-";
}
function databaseSyncTableCount(result: any) {
const tables = result?.tables || {};
return Object.values(tables).reduce((total: number, value: any) => total + Number(value || 0), 0);
@@ -914,6 +1355,12 @@ function timeLabel(value: string) {
return value.length > 10 ? value.slice(11, 19) : value;
}
function heartbeatTimeValue(value: string) {
if (!value) return Date.now();
const parsed = Date.parse(value);
return Number.isFinite(parsed) ? parsed : value;
}
function pretty(value: any) {
return JSON.stringify(value || {}, null, 2);
}
@@ -968,7 +1415,7 @@ function connectAdminEvents() {
if (currentPath.value === "/admin/dashboard") void Promise.all([loadDashboard(), loadSourceCheckJobs().catch(() => undefined)]);
if (currentPath.value === "/admin/sources") void Promise.all([loadSources(), loadSourceCheckJobs().catch(() => undefined)]);
if (currentPath.value === "/admin/endpoints") void loadEndpoints();
if (currentPath.value === "/admin/system") void loadSystem();
if (currentPath.value === "/admin/system") void loadSystem({ preserveForms: true });
};
for (const name of ["source_check.item", "source_check.progress", "source_check.completed", "heartbeat"]) {
events.addEventListener(name, refreshCurrent);
@@ -1017,8 +1464,11 @@ function connectAdminEvents() {
<main v-else class="app-shell">
<aside class="sidebar">
<div class="brand">
<span class="brand-mark"><ShieldCheck :size="22" /></span>
<div><strong>YMhut</strong><small>统一管理台</small></div>
<span class="brand-mark">
<img v-if="branding.siteIconUrl" :src="branding.siteIconUrl" alt="YMhut" />
<ShieldCheck v-else :size="22" />
</span>
<div><strong>{{ branding.developerName || "YMhut" }}</strong><small>统一管理台</small></div>
</div>
<nav class="nav-groups">
<section v-for="group in navGroups" :key="group.label" class="nav-group">
@@ -24,7 +24,7 @@ const exactMessages: Record<string, string> = {
"file is required": "请选择要上传的文件",
"invalid filename": "文件名不合法",
"path escape rejected": "文件路径不合法",
"check job not found": "未找到心跳检测任务",
"check job not found": "未找到服务端检测任务",
"streaming is not supported": "当前运行环境不支持实时事件流",
};
@@ -3,8 +3,8 @@ import { reactive, ref } from "vue";
export function createFeedbackStore() {
const page = ref<any>({ items: [], total: 0, page: 1, perPage: 20 });
const selected = ref<any | null>(null);
const filters = reactive({ q: "", status: "", page: 1, perPage: 20 });
const update = reactive({ status: "", statusDetail: "", publicReply: "" });
const filters = reactive({ q: "", status: "", priority: "", page: 1, perPage: 20 });
const update = reactive({ status: "", priority: "", statusDetail: "", publicReply: "" });
const commentDraft = reactive({ body: "", internal: true });
return { page, selected, filters, update, commentDraft };
@@ -5,10 +5,18 @@ export type LegacyName = "update-info" | "media-types";
export function createLegacyStore() {
const sync = ref<any>(null);
const documents = reactive<Record<LegacyName, any | null>>({ "update-info": null, "media-types": null });
const modal = reactive({
open: false,
type: "",
categoryIndex: -1,
itemIndex: -1,
draft: {} as any,
});
const activeMediaCategoryIndex = ref(0);
const drafts = reactive<Record<LegacyName, { raw: string; note: string; preview: any | null; tab: "form" | "raw" | "preview" | "history"; form: any }>>({
"update-info": { raw: "", note: "", preview: null, tab: "form", form: {} },
"media-types": { raw: "", note: "", preview: null, tab: "form", form: { categories: [] } },
});
return { sync, documents, drafts };
return { sync, documents, drafts, modal, activeMediaCategoryIndex };
}
@@ -2,11 +2,52 @@ import { reactive, ref } from "vue";
export function createSystemStore() {
const database = ref<any>(null);
const databaseConfig = ref<any>(null);
const databaseLastSync = ref<any>(null);
const healthSnapshot = ref<any>(null);
const auditLogs = ref<any[]>([]);
const databaseForm = reactive({ provider: "sqlite", sqlitePath: "", mysqlDsn: "" });
const auditPage = reactive({
items: [] as any[],
total: 0,
page: 1,
perPage: 35,
q: "",
type: "",
target: "",
selected: null as any | null,
});
const migrationStatus = ref<any>(null);
const branding = reactive({
siteIconUrl: "https://img.ymhut.cn/file/1782108850041_icon.webp",
developerAvatarUrl: "https://img.ymhut.cn/file/1782108780690_b_3db45f3787f19192c8de8e06bc0987ef.webp",
developerName: "YMhut",
feedbackEmail: "support@ymhut.cn",
});
const databaseForm = reactive({
provider: "sqlite",
sqlitePath: "",
mysqlHost: "127.0.0.1",
mysqlPort: 3306,
mysqlDatabase: "",
mysqlUser: "",
mysqlPassword: "",
mysqlDsn: "",
});
const databaseConfigCollapsed = ref(true);
const mailConfig = reactive({
host: "",
port: 465,
secure: "ssl",
username: "",
password: "",
fromAddress: "",
fromName: "YMhut Box Feedback",
developerAddress: "",
timeoutSeconds: 20,
hasPassword: false,
configured: false,
});
const legacySyncMode = ref<"preview" | "run">("preview");
return { database, databaseLastSync, healthSnapshot, auditLogs, databaseForm, legacySyncMode };
return { database, databaseConfig, databaseLastSync, healthSnapshot, auditLogs, auditPage, migrationStatus, branding, databaseForm, databaseConfigCollapsed, mailConfig, legacySyncMode };
}
@@ -115,6 +115,8 @@ input:focus, textarea:focus, select:focus {
.btn.ghost { background: transparent; }
.btn.compact { min-height: 30px; padding: 5px 8px; font-size: 12px; }
.btn.full { width: 100%; }
.btn.danger { color: var(--bad); border-color: #f0b8b1; }
.btn.danger:hover { background: var(--bad-bg); color: var(--bad); }
.button-row, .top-actions, .toolbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.alert-line, .notice {
@@ -172,6 +174,7 @@ input:focus, textarea:focus, select:focus {
}
.brand { display: flex; gap: 12px; align-items: center; min-width: 0; padding-bottom: 14px; border-bottom: 1px solid var(--line); }
.brand-mark { width: 38px; height: 38px; border-radius: 12px; display: grid; place-items: center; background: #111827; color: #fff; }
.brand-mark img { width: 100%; height: 100%; object-fit: cover; border-radius: inherit; display: block; }
.brand strong { display: block; }
.brand small { display: block; color: var(--muted); margin-top: 2px; }
.brand > div { min-width: 0; overflow: hidden; }
@@ -229,6 +232,13 @@ input:focus, textarea:focus, select:focus {
padding: 16px;
box-shadow: 0 1px 2px rgba(17, 24, 39, 0.04);
}
.panel-soft {
min-width: 0;
border: 1px solid var(--line);
border-radius: 12px;
background: var(--panel-soft);
padding: 14px;
}
.metric, .panel, .revision-list button, .nested-card {
transition: transform 0.2s var(--ease), border-color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease;
}
@@ -242,9 +252,26 @@ input:focus, textarea:focus, select:focus {
.chart-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
.chart-panel { min-height: 330px; display: flex; flex-direction: column; }
.chart-panel-relative { position: relative; }
.chart { min-height: 260px; width: 100%; flex: 1; }
.chart-empty {
position: absolute;
inset: 56px 16px 16px;
display: grid;
place-content: center;
gap: 6px;
border: 1px dashed var(--line);
border-radius: 10px;
background: rgba(248, 250, 252, 0.84);
color: var(--muted);
text-align: center;
pointer-events: none;
}
.chart-empty strong { color: var(--ink); }
.split { display: grid; grid-template-columns: minmax(0, 1fr) 390px; gap: 14px; align-items: start; }
.split.wide-split { grid-template-columns: minmax(380px, 0.95fr) minmax(0, 1.05fr); }
.legacy-media-editor { grid-template-columns: minmax(340px, 0.95fr) minmax(0, 1.05fr); }
.legacy-media-editor > * { min-width: 0; }
.search-box {
min-width: min(420px, 100%);
@@ -300,6 +327,48 @@ hr { border: 0; border-top: 1px solid var(--line); width: 100%; margin: 12px 0;
.empty-state.compact { min-height: 96px; border: 1px dashed var(--line); border-radius: 6px; }
.source-group { margin-top: 12px; }
.source-group h3 { display: flex; align-items: center; gap: 8px; }
.table-scroll {
width: 100%;
max-width: 100%;
overflow-x: auto;
scrollbar-gutter: stable;
}
.media-subcategory-panel { min-width: 0; overflow: hidden; }
.media-subcategory-table table { table-layout: fixed; min-width: 680px; }
.media-subcategory-table th:nth-child(1), .media-subcategory-table td:nth-child(1) { width: 170px; }
.media-subcategory-table th:nth-child(2), .media-subcategory-table td:nth-child(2) { width: 120px; }
.media-subcategory-table th:nth-child(3), .media-subcategory-table td:nth-child(3) { width: 72px; }
.media-subcategory-table th:nth-child(5), .media-subcategory-table td:nth-child(5) { width: 150px; }
.media-subcategory-table .button-row { flex-wrap: nowrap; }
.url-cell {
max-width: 1px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.category-list { display: flex; flex-direction: column; gap: 8px; }
.category-list button {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
color: var(--ink);
padding: 10px 12px;
text-align: left;
cursor: pointer;
transition: border-color 0.18s ease, background-color 0.18s ease, box-shadow 0.18s ease;
}
.category-list button:hover, .category-list button.active {
border-color: rgba(37, 99, 235, 0.35);
background: var(--primary-soft);
box-shadow: 0 8px 20px rgba(37, 99, 235, 0.08);
}
.category-list span:first-child { min-width: 0; display: grid; gap: 2px; }
.category-list strong, .category-list small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.code-editor { min-height: 56dvh; white-space: pre; overflow: auto; font-size: 13px; }
.compact-editor { min-height: 260px; }
details {
@@ -334,6 +403,7 @@ summary { cursor: pointer; font-weight: 900; margin-bottom: 10px; }
}
.revision-list button:hover, .revision-list button.active { border-color: var(--primary); background: #f8fbff; }
.revision-list small { display: block; color: var(--muted); margin-top: 3px; }
.compact-side { gap: 10px; }
.kv-grid { display: grid; grid-template-columns: 140px minmax(0, 1fr); gap: 11px 14px; }
.kv-grid span { color: var(--muted); }
.kv-grid strong { overflow-wrap: anywhere; }
@@ -375,6 +445,63 @@ summary { cursor: pointer; font-weight: 900; margin-bottom: 10px; }
line-height: 1.55;
}
.ops-note svg { flex: 0 0 auto; margin-top: 3px; }
.plain-list { margin: 0; padding-left: 18px; color: var(--muted); line-height: 1.8; }
.asset-row {
display: grid;
gap: 4px;
border: 1px solid var(--line);
border-radius: 10px;
background: #fff;
padding: 10px;
}
.asset-row span { color: var(--muted); }
.asset-row code { overflow-wrap: anywhere; font-size: 12px; color: var(--primary-dark); }
.brand-preview {
display: flex;
align-items: center;
gap: 10px;
border: 1px solid var(--line);
border-radius: 12px;
background: var(--panel-soft);
padding: 10px;
}
.brand-preview img {
width: 42px;
height: 42px;
border-radius: 10px;
object-fit: cover;
border: 1px solid var(--line);
}
.pager {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
color: var(--muted);
font-weight: 800;
}
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 900;
display: grid;
place-items: center;
padding: 20px;
background: rgba(15, 23, 42, 0.42);
}
.modal-panel {
width: min(720px, calc(100vw - 32px));
max-height: calc(100dvh - 40px);
overflow: auto;
display: flex;
flex-direction: column;
gap: 14px;
border: 1px solid var(--line);
border-radius: 14px;
background: #fff;
box-shadow: var(--shadow);
padding: 18px;
}
.tabs {
display: flex;
@@ -21,7 +21,7 @@ use([CanvasRenderer, LineChart, PieChart, BarChart, GaugeChart, GridComponent, T
</div>
<div class="toolbar">
<button class="btn primary" @click="ctx.checkSources"><Activity :size="16" />立即心跳检测</button>
<button class="btn primary" @click="ctx.checkSources"><Activity :size="16" />立即服务端检测</button>
<button class="btn ghost" @click="ctx.toggleAutoRefresh">
<component :is="ctx.autoRefreshPaused ? PlayCircle : PauseCircle" :size="16" />
{{ ctx.autoRefreshPaused ? "恢复自动刷新" : "暂停自动刷新" }}
@@ -30,7 +30,7 @@ use([CanvasRenderer, LineChart, PieChart, BarChart, GaugeChart, GridComponent, T
</div>
<section v-if="ctx.sourceCheckJobs.length" class="panel">
<div class="section-head"><h2>心跳检测任务</h2><span class="badge">{{ ctx.sourceCheckJobs[0].status }}</span></div>
<div class="section-head"><h2>服务端检测任务</h2><span class="badge">{{ ctx.sourceCheckJobs[0].status }}</span></div>
<table>
<thead><tr><th>任务</th><th>进度</th><th>正常</th><th>重定向</th><th>降级</th><th>错误</th><th>开始时间</th></tr></thead>
<tbody>
@@ -48,14 +48,21 @@ use([CanvasRenderer, LineChart, PieChart, BarChart, GaugeChart, GridComponent, T
</section>
<div class="chart-grid">
<section class="panel chart-panel"><h2>接口心跳延迟</h2><VChart class="chart" :option="ctx.heartbeatOption" autoresize /></section>
<section class="panel chart-panel chart-panel-relative">
<h2>服务端接口延迟</h2>
<VChart class="chart" :option="ctx.heartbeatOption" autoresize />
<div v-if="ctx.isHeartbeatChartEmpty" class="chart-empty">
<strong>暂无服务端检测记录</strong>
<span>点击立即服务端检测后会生成延迟曲线</span>
</div>
</section>
<section class="panel chart-panel"><h2>接口健康分布</h2><VChart class="chart" :option="ctx.healthOption" autoresize /></section>
<section class="panel chart-panel"><h2>反馈状态分布</h2><VChart class="chart" :option="ctx.feedbackOption" autoresize /></section>
<section class="panel chart-panel"><h2>服务可用率</h2><VChart class="chart" :option="ctx.availabilityOption" autoresize /></section>
</div>
<section class="panel">
<div class="section-head"><h2>最近接口心跳</h2><span class="badge">{{ ctx.heartbeats.length }} </span></div>
<div class="section-head"><h2>最近服务端检测</h2><span class="badge">{{ ctx.heartbeats.length }} </span></div>
<table>
<thead><tr><th>接口</th><th>状态</th><th>延迟</th><th>错误</th><th>时间</th></tr></thead>
<tbody>
@@ -66,7 +73,7 @@ use([CanvasRenderer, LineChart, PieChart, BarChart, GaugeChart, GridComponent, T
<td class="hash">{{ item.error || "-" }}</td>
<td>{{ item.checkedAt || "-" }}</td>
</tr>
<tr v-if="ctx.heartbeats.length === 0"><td colspan="5">暂无心跳记录点击立即心跳检测后会刷新</td></tr>
<tr v-if="ctx.heartbeats.length === 0"><td colspan="5">暂无检测记录点击立即服务端检测后会刷新</td></tr>
</tbody>
</table>
</section>
@@ -1,12 +1,20 @@
<script setup lang="ts">
import { Pencil, Trash2 } from "lucide-vue-next";
defineProps<{ ctx: any }>();
</script>
<template>
<section class="panel page-stack">
<div class="section-head"><h2>客户端动态接口</h2><span class="badge">{{ ctx.visibleEndpointCount }} 可见 / {{ ctx.healthyEndpointCount }} 健康</span></div>
<div class="section-head">
<div>
<h2>客户端动态接口</h2>
<p class="muted">删除接口后会由服务端重新生成兼容媒体源 JSON 和更新 JSON</p>
</div>
<span class="badge">{{ ctx.visibleEndpointCount }} 可见 / {{ ctx.healthyEndpointCount }} 健康</span>
</div>
<table>
<thead><tr><th>ID</th><th>分类</th><th>模式</th><th>健康</th><th>缓存</th><th>URL</th><th></th></tr></thead>
<thead><tr><th>ID</th><th>分类</th><th>模式</th><th>健康</th><th>缓存</th><th>URL</th><th>操作</th></tr></thead>
<tbody>
<tr v-for="item in ctx.endpoints" :key="item.id || item.sourceId">
<td class="mono">{{ item.id || item.sourceId }}</td>
@@ -17,8 +25,13 @@ defineProps<{ ctx: any }>();
<span v-if="ctx.endpointStatus(item) === 'redirected' || item.health?.meta?.redirected" class="badge warn">重定向接口</span>
</td>
<td>{{ item.cacheSeconds || 0 }}s</td>
<td class="hash">{{ item.urlTemplate || item.apiUrl }}</td>
<td><button class="btn ghost compact" @click="ctx.copyEndpointToSource(item)">编辑</button></td>
<td class="hash">{{ item.resolvedUrl || item.urlTemplate || item.apiUrl }}</td>
<td>
<div class="button-row">
<button class="btn ghost compact" @click="ctx.copyEndpointToSource(item)"><Pencil :size="14" />编辑</button>
<button class="btn ghost compact danger" @click="ctx.deleteEndpoint(item)"><Trash2 :size="14" />删除</button>
</div>
</td>
</tr>
<tr v-if="ctx.endpoints.length === 0"><td colspan="7">暂无客户端接口</td></tr>
</tbody>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { Save, Search, UploadCloud } from "lucide-vue-next";
import { Mail, Save, Search, UploadCloud } from "lucide-vue-next";
defineProps<{ ctx: any }>();
</script>
@@ -8,44 +8,107 @@ defineProps<{ ctx: any }>();
<section class="split">
<section class="panel page-stack">
<div class="toolbar">
<label class="search-box"><Search :size="16" /><input v-model="ctx.feedbackFilters.q" placeholder="搜索编号、标题、联系方式" @keyup.enter="ctx.loadFeedbacks" /></label>
<select v-model="ctx.feedbackFilters.status" @change="ctx.loadFeedbacks"><option value="">全部状态</option><option value="new">new</option><option value="processing">processing</option><option value="closed">closed</option></select>
<label class="search-box">
<Search :size="16" />
<input v-model="ctx.feedbackFilters.q" placeholder="搜索编号、标题、联系方式" @keyup.enter="ctx.loadFeedbacks" />
</label>
<select v-model="ctx.feedbackFilters.status" @change="ctx.loadFeedbacks">
<option value="">全部状态</option>
<option value="new">新建</option>
<option value="processing">处理中</option>
<option value="closed">已关闭</option>
</select>
<select v-model="ctx.feedbackFilters.priority" @change="ctx.loadFeedbacks">
<option value="">全部优先级</option>
<option value="low"></option>
<option value="normal">普通</option>
<option value="high"></option>
<option value="urgent">紧急</option>
</select>
<button class="btn ghost" @click="ctx.loadFeedbacks">查询</button>
<a class="btn ghost" href="/api/admin/feedbacks/export" target="_blank"><UploadCloud :size="16" />CSV</a>
</div>
<table>
<thead><tr><th>编号</th><th>标题</th><th>状态</th><th>优先级</th><th>最近活动</th></tr></thead>
<thead><tr><th>编号</th><th>标题</th><th>状态</th><th>优先级</th><th>邮件</th><th>最近活动</th></tr></thead>
<tbody>
<tr v-for="item in ctx.feedbackPage.items" :key="item.code" class="clickable" :class="{ selected: ctx.selectedFeedback?.code === item.code }" @click="ctx.openFeedback(item)">
<td class="mono">{{ item.code }}</td>
<td>{{ item.title || item.summaryText || "未命名反馈" }}</td>
<td><span :class="['badge', ctx.statusTone(item.status)]">{{ item.status }}</span></td>
<td>{{ item.priority || "-" }}</td>
<td><span :class="['badge', ctx.statusTone(item.status)]">{{ ctx.labelStatus(item.status) }}</span></td>
<td><span :class="['badge', ctx.statusTone(item.priority)]">{{ ctx.labelPriority(item.priority) }}</span></td>
<td><span :class="['badge', item.mailSent ? 'good' : 'warn']">{{ item.mailSent ? "已发送" : "未发送" }}</span></td>
<td>{{ item.lastActivityAt || item.createdAt }}</td>
</tr>
<tr v-if="!ctx.feedbackPage.items?.length"><td colspan="5">暂无反馈工单</td></tr>
<tr v-if="!ctx.feedbackPage.items?.length"><td colspan="6">暂无反馈工单</td></tr>
</tbody>
</table>
</section>
<aside class="panel detail-panel">
<template v-if="ctx.selectedFeedback">
<h2>{{ ctx.selectedFeedback.code }}</h2>
<div class="section-head">
<h2>{{ ctx.selectedFeedback.code }}</h2>
<span :class="['badge', ctx.selectedFeedback.mailSent ? 'good' : 'warn']">{{ ctx.selectedFeedback.mailSent ? "邮件已发送" : "邮件未发送" }}</span>
</div>
<p class="muted">{{ ctx.selectedFeedback.title || ctx.selectedFeedback.body }}</p>
<label>状态<select v-model="ctx.feedbackUpdate.status"><option>new</option><option>processing</option><option>closed</option></select></label>
<div class="kv-grid">
<span>联系方式</span><strong>{{ ctx.selectedFeedback.contact || "-" }}</strong>
<span>来源</span><strong>{{ ctx.selectedFeedback.sourceChannel || "-" }}</strong>
<span>接收时间</span><strong>{{ ctx.selectedFeedback.createdAt || "-" }}</strong>
</div>
<label>状态
<select v-model="ctx.feedbackUpdate.status">
<option value="new">新建</option>
<option value="processing">处理中</option>
<option value="closed">已关闭</option>
</select>
</label>
<label>优先级
<select v-model="ctx.feedbackUpdate.priority">
<option value="low"></option>
<option value="normal">普通</option>
<option value="high"></option>
<option value="urgent">紧急</option>
</select>
</label>
<label>状态说明<input v-model="ctx.feedbackUpdate.statusDetail" /></label>
<label>公开回复<textarea v-model="ctx.feedbackUpdate.publicReply" rows="3"></textarea></label>
<button class="btn primary" @click="ctx.saveFeedbackUpdate"><Save :size="16" />保存状态</button>
<div class="button-row">
<button class="btn primary" @click="ctx.saveFeedbackUpdate"><Save :size="16" />保存工单</button>
<button class="btn ghost" @click="ctx.retryFeedbackMail"><Mail :size="16" />重试邮件</button>
</div>
<hr />
<h3>评论</h3>
<div class="comment-list">
<div v-for="item in ctx.selectedFeedback.comments || []" :key="item.id" class="comment"><strong>{{ item.author }}</strong><p>{{ item.body }}</p></div>
<div v-for="item in ctx.selectedFeedback.comments || []" :key="item.id" class="comment">
<strong>{{ item.author }}</strong>
<p>{{ item.body }}</p>
</div>
</div>
<label>新增评论<textarea v-model="ctx.commentDraft.body" rows="3"></textarea></label>
<label class="checkbox"><input v-model="ctx.commentDraft.internal" type="checkbox" />内部备注</label>
<button class="btn ghost" @click="ctx.addFeedbackComment">添加评论</button>
<details>
<summary>旧反馈事件 / 邮件记录</summary>
<pre class="json-preview small">{{ ctx.pretty({ events: ctx.selectedFeedback.legacyEvents, mail: ctx.selectedFeedback.mailRecords }) }}</pre>
<summary>邮件记录</summary>
<table>
<thead><tr><th>状态</th><th>收件人</th><th>主题</th><th>错误</th></tr></thead>
<tbody>
<tr v-for="item in ctx.selectedFeedback.mailRecords || []" :key="item.id">
<td><span :class="['badge', ctx.statusTone(item.status)]">{{ ctx.labelStatus(item.status) }}</span></td>
<td>{{ item.toAddress || "-" }}</td>
<td>{{ item.subject || "-" }}</td>
<td>{{ item.errorMessage || "-" }}</td>
</tr>
<tr v-if="!(ctx.selectedFeedback.mailRecords || []).length"><td colspan="4">暂无邮件记录</td></tr>
</tbody>
</table>
</details>
<details>
<summary>旧反馈事件</summary>
<pre class="json-preview small">{{ ctx.pretty({ events: ctx.selectedFeedback.legacyEvents }) }}</pre>
</details>
</template>
<div v-else class="empty-state">选择一条工单查看详情</div>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { CheckCircle2, Plus, Save, Trash2 } from "lucide-vue-next";
import { CheckCircle2, Pencil, Plus, Save, Trash2 } from "lucide-vue-next";
defineProps<{ ctx: any }>();
</script>
@@ -9,7 +9,7 @@ defineProps<{ ctx: any }>();
<div class="section-head">
<div>
<h2>{{ ctx.activeLegacyLabel }}</h2>
<p class="muted">以当前兼容 JSON 为基板表单保存会合并进原 JSON知字段保留</p>
<p class="muted">可视化表单只维护常用字段保存会合并回当前 JSON识别字段继续保留</p>
</div>
<div class="button-row">
<button class="btn ghost" @click="ctx.validateLegacy(ctx.activeLegacyName)"><CheckCircle2 :size="16" />校验</button>
@@ -18,73 +18,126 @@ defineProps<{ ctx: any }>();
</div>
<div class="tabs">
<button :class="{ active: ctx.legacyDrafts[ctx.activeLegacyName].tab === 'form' }" @click="ctx.legacyDrafts[ctx.activeLegacyName].tab = 'form'">可视化表单</button>
<button :class="{ active: ctx.legacyDrafts[ctx.activeLegacyName].tab === 'form' }" @click="ctx.legacyDrafts[ctx.activeLegacyName].tab = 'form'">可视化</button>
<button :class="{ active: ctx.legacyDrafts[ctx.activeLegacyName].tab === 'raw' }" @click="ctx.legacyDrafts[ctx.activeLegacyName].tab = 'raw'">Raw JSON</button>
<button :class="{ active: ctx.legacyDrafts[ctx.activeLegacyName].tab === 'preview' }" @click="ctx.legacyDrafts[ctx.activeLegacyName].tab = 'preview'">预览</button>
<button :class="{ active: ctx.legacyDrafts[ctx.activeLegacyName].tab === 'history' }" @click="ctx.legacyDrafts[ctx.activeLegacyName].tab = 'history'">历史版本</button>
</div>
<p v-if="ctx.activeLegacyName === 'media-types'" class="notice">
生产环境不再自动依赖旧项目路径需要以 server/update/public/media-types.json 为基板时请切换到 Raw JSON 粘贴完整内容校验通过后保存发布
</p>
<section v-if="ctx.legacyDrafts[ctx.activeLegacyName].tab === 'form' && ctx.activeLegacyName === 'update-info'" class="page-stack">
<section class="form-grid">
<label>版本号<input v-model="ctx.legacyDrafts['update-info'].form.app_version" /></label>
<label>标题<input v-model="ctx.legacyDrafts['update-info'].form.title" /></label>
<label class="wide">公告<textarea v-model="ctx.legacyDrafts['update-info'].form.message" rows="3"></textarea></label>
<label class="wide">下载地址<input v-model="ctx.legacyDrafts['update-info'].form.download_url" /></label>
<label> SHA256<input v-model="ctx.legacyDrafts['update-info'].form.package_sha256" /></label>
<label>包大小<input v-model="ctx.legacyDrafts['update-info'].form.package_size" type="number" /></label>
<label class="wide">发布说明<textarea v-model="ctx.legacyDrafts['update-info'].form.release_notes" rows="4"></textarea></label>
<label class="wide">发布说明 Markdown<textarea v-model="ctx.legacyDrafts['update-info'].form.release_notes_md" rows="4"></textarea></label>
</section>
<section v-if="ctx.legacyDrafts[ctx.activeLegacyName].tab === 'form' && ctx.activeLegacyName === 'update-info'" class="form-grid">
<label>版本号<input v-model="ctx.legacyDrafts['update-info'].form.app_version" /></label>
<label>标题<input v-model="ctx.legacyDrafts['update-info'].form.title" /></label>
<label class="wide">公告<textarea v-model="ctx.legacyDrafts['update-info'].form.message" rows="3"></textarea></label>
<label class="wide">下载地址<input v-model="ctx.legacyDrafts['update-info'].form.download_url" /></label>
<label> SHA256<input v-model="ctx.legacyDrafts['update-info'].form.package_sha256" /></label>
<label>包大小<input v-model="ctx.legacyDrafts['update-info'].form.package_size" type="number" /></label>
<label class="wide">发布说明<textarea v-model="ctx.legacyDrafts['update-info'].form.release_notes" rows="4"></textarea></label>
<label class="wide">发布说明 Markdown<textarea v-model="ctx.legacyDrafts['update-info'].form.release_notes_md" rows="5"></textarea></label>
<label class="wide">更新说明 JSON<textarea v-model="ctx.legacyDrafts['update-info'].form.update_notes" class="code-editor mini-editor"></textarea></label>
<label class="wide">上次更新说明 JSON<textarea v-model="ctx.legacyDrafts['update-info'].form.last_update_notes" class="code-editor mini-editor"></textarea></label>
<div class="wide button-row">
<button class="btn ghost" @click="ctx.addUpdateMirror"><Plus :size="16" />新增镜像字段到底稿</button>
<button class="btn" @click="ctx.updateLegacyRawFromForm('update-info')">生成预览 JSON</button>
</div>
<section class="nested-card">
<div class="section-head">
<h3>下载镜像</h3>
<button class="btn ghost compact" @click="ctx.openUpdateMirrorModal()"><Plus :size="14" />新增镜像</button>
</div>
<table>
<thead><tr><th>ID</th><th>名称</th><th>类型</th><th>状态</th><th>URL</th><th>操作</th></tr></thead>
<tbody>
<tr v-for="(mirror, index) in ctx.legacyDrafts['update-info'].form.download_mirrors || []" :key="mirror.id || index">
<td class="mono">{{ mirror.id }}</td>
<td>{{ mirror.name }}</td>
<td>{{ mirror.type || "direct" }}</td>
<td><span :class="['badge', mirror.enabled === false ? 'neutral' : 'good']">{{ mirror.enabled === false ? "停用" : "启用" }}</span></td>
<td class="hash">{{ mirror.url }}</td>
<td>
<div class="button-row">
<button class="btn ghost compact" @click="ctx.openUpdateMirrorModal(index)"><Pencil :size="14" />编辑</button>
<button class="btn ghost compact danger" @click="ctx.removeItem(ctx.legacyDrafts['update-info'].form.download_mirrors, index)"><Trash2 :size="14" />删除</button>
</div>
</td>
</tr>
<tr v-if="!(ctx.legacyDrafts['update-info'].form.download_mirrors || []).length"><td colspan="6">暂无镜像</td></tr>
</tbody>
</table>
</section>
<details>
<summary>高级 JSON 字段</summary>
<div class="form-grid">
<label class="wide">更新说明 JSON<textarea v-model="ctx.legacyDrafts['update-info'].form.update_notes" class="code-editor mini-editor"></textarea></label>
<label class="wide">上次更新说明 JSON<textarea v-model="ctx.legacyDrafts['update-info'].form.last_update_notes" class="code-editor mini-editor"></textarea></label>
</div>
</details>
<div class="button-row"><button class="btn" @click="ctx.updateLegacyRawFromForm('update-info')">生成预览 JSON</button></div>
</section>
<section v-else-if="ctx.legacyDrafts[ctx.activeLegacyName].tab === 'form'" class="page-stack">
<div class="form-grid">
<label>布局版本<input v-model="ctx.legacyDrafts['media-types'].form.layout_version" /></label>
<label>更新时间<input v-model="ctx.legacyDrafts['media-types'].form.last_updated" /></label>
<label class="wide">UI 配置 JSON<textarea v-model="ctx.legacyDrafts['media-types'].form.ui_config" class="code-editor mini-editor"></textarea></label>
</div>
<div class="button-row">
<button class="btn ghost" @click="ctx.addMediaCategory('media-types')"><Plus :size="16" />新增分类</button>
<button class="btn" @click="ctx.updateLegacyRawFromForm('media-types')">生成预览 JSON</button>
</div>
<section v-for="(cat, cIndex) in ctx.legacyDrafts['media-types'].form.categories" :key="cIndex" class="nested-card">
<div class="section-head">
<h3>分类 {{ cIndex + 1 }}</h3>
<button class="btn ghost compact" @click="ctx.removeItem(ctx.legacyDrafts['media-types'].form.categories, cIndex)"><Trash2 :size="14" />删除</button>
</div>
<section v-else-if="ctx.legacyDrafts[ctx.activeLegacyName].tab === 'form'" class="split legacy-media-editor">
<section class="panel-soft page-stack">
<div class="form-grid">
<label>ID<input v-model="cat.id" /></label>
<label>名称<input v-model="cat.name" /></label>
<label class="checkbox"><input v-model="cat.enabled" type="checkbox" />启用分类</label>
<label>布局版本<input v-model="ctx.legacyDrafts['media-types'].form.layout_version" /></label>
<label>更新时间<input v-model="ctx.legacyDrafts['media-types'].form.last_updated" /></label>
<label class="wide">UI 配置 JSON<textarea v-model="ctx.legacyDrafts['media-types'].form.ui_config" class="code-editor mini-editor"></textarea></label>
</div>
<div class="button-row">
<button class="btn ghost compact" @click="ctx.addMediaSubcategory(cat)"><Plus :size="14" />新增子接口</button>
<div class="section-head">
<h3>分类</h3>
<button class="btn ghost compact" @click="ctx.openMediaCategoryModal()"><Plus :size="14" />新增分类</button>
</div>
<section v-for="(sub, sIndex) in cat.subcategories" :key="sIndex" class="nested-card inner">
<div class="section-head">
<h3>{{ sub.name || "子接口" }}</h3>
<button class="btn ghost compact" @click="ctx.removeItem(cat.subcategories, sIndex)"><Trash2 :size="14" />删除</button>
<div class="category-list" v-if="ctx.legacyDrafts['media-types'].form.categories.length">
<button
v-for="(cat, cIndex) in ctx.legacyDrafts['media-types'].form.categories"
:key="cat.id || cIndex"
type="button"
:class="{ active: ctx.activeMediaCategoryIndex === cIndex }"
@click="ctx.selectMediaCategory(cIndex)"
>
<span><strong>{{ cat.name || cat.id || `分类 ${cIndex + 1}` }}</strong><small class="mono">{{ cat.id || "-" }}</small></span>
<span class="badge">{{ cat.subcategories?.length || 0 }}</span>
</button>
</div>
<div v-else class="empty-state compact">暂无分类</div>
</section>
<aside class="panel-soft page-stack media-subcategory-panel">
<div class="section-head">
<div>
<h3>{{ ctx.activeMediaCategory?.name || ctx.activeMediaCategory?.id || "子接口" }}</h3>
<p class="muted">右侧仅显示当前选中分类下的子接口</p>
</div>
<div class="form-grid">
<label>ID<input v-model="sub.id" /></label>
<label>名称<input v-model="sub.name" /></label>
<label class="wide">接口 URL<input v-model="sub.api_url" /></label>
<label>缩略图<input v-model="sub.thumbnail_url" /></label>
<label>刷新间隔<input v-model.number="sub.refresh_interval" type="number" /></label>
<label>格式<input v-model="sub.supported_formats" placeholder="json, xml" /></label>
<label class="checkbox"><input v-model="sub.downloadable" type="checkbox" />可下载</label>
<label class="wide">描述<textarea v-model="sub.description" rows="2"></textarea></label>
<div class="button-row">
<button class="btn ghost compact" :disabled="!ctx.activeMediaCategory" @click="ctx.openMediaCategoryModal(ctx.activeMediaCategoryIndex)"><Pencil :size="14" />编辑分类</button>
<button class="btn ghost compact" :disabled="!ctx.activeMediaCategory" @click="ctx.openMediaSubcategoryModal(ctx.activeMediaCategoryIndex)"><Plus :size="14" />新增子接口</button>
<button class="btn" @click="ctx.updateLegacyRawFromForm('media-types')">生成预览 JSON</button>
</div>
</div>
<section v-if="ctx.activeMediaCategory" class="source-group">
<div class="button-row">
<span :class="['badge', ctx.activeMediaCategory.enabled === false ? 'neutral' : 'good']">{{ ctx.activeMediaCategory.enabled === false ? "停用" : "启用" }}</span>
<button class="btn ghost compact danger" @click="ctx.removeItem(ctx.legacyDrafts['media-types'].form.categories, ctx.activeMediaCategoryIndex)"><Trash2 :size="14" />删除分类</button>
</div>
<div class="table-scroll media-subcategory-table">
<table>
<thead><tr><th>ID</th><th>名称</th><th>刷新</th><th>URL</th><th>操作</th></tr></thead>
<tbody>
<tr v-for="(sub, sIndex) in ctx.activeMediaCategory.subcategories || []" :key="sub.id || sIndex">
<td class="mono">{{ sub.id }}</td>
<td>{{ sub.name }}</td>
<td>{{ sub.refresh_interval || 300 }}s</td>
<td class="hash url-cell" :title="sub.api_url">{{ sub.api_url }}</td>
<td>
<div class="button-row">
<button class="btn ghost compact" @click="ctx.openMediaSubcategoryModal(ctx.activeMediaCategoryIndex, sIndex)"><Pencil :size="14" />编辑</button>
<button class="btn ghost compact danger" @click="ctx.removeItem(ctx.activeMediaCategory.subcategories, sIndex)"><Trash2 :size="14" />删除</button>
</div>
</td>
</tr>
<tr v-if="!(ctx.activeMediaCategory.subcategories || []).length"><td colspan="5">当前分类暂无子接口</td></tr>
</tbody>
</table>
</div>
</section>
</section>
<div v-else class="empty-state compact">请选择或新增一个分类</div>
</aside>
</section>
<section v-else-if="ctx.legacyDrafts[ctx.activeLegacyName].tab === 'raw'" class="page-stack">
@@ -102,5 +155,46 @@ defineProps<{ ctx: any }>();
</button>
<div v-if="(ctx.legacyDocuments[ctx.activeLegacyName]?.revisions || []).length === 0" class="empty-state compact">暂无历史版本</div>
</section>
<Teleport to="body">
<div v-if="ctx.legacyModal.open" class="modal-backdrop" @click.self="ctx.closeLegacyModal">
<section class="modal-panel">
<div class="section-head">
<h2>{{ ctx.legacyModal.type === 'media-category' ? '分类' : ctx.legacyModal.type === 'media-subcategory' ? '子接口' : '下载镜像' }}</h2>
<button class="btn ghost compact" @click="ctx.closeLegacyModal">关闭</button>
</div>
<div v-if="ctx.legacyModal.type === 'media-category'" class="form-grid">
<label>ID<input v-model="ctx.legacyModal.draft.id" /></label>
<label>名称<input v-model="ctx.legacyModal.draft.name" /></label>
<label class="checkbox wide"><input v-model="ctx.legacyModal.draft.enabled" type="checkbox" />启用分类</label>
</div>
<div v-else-if="ctx.legacyModal.type === 'media-subcategory'" class="form-grid">
<label>ID<input v-model="ctx.legacyModal.draft.id" /></label>
<label>名称<input v-model="ctx.legacyModal.draft.name" /></label>
<label class="wide">接口 URL<input v-model="ctx.legacyModal.draft.api_url" /></label>
<label>缩略图<input v-model="ctx.legacyModal.draft.thumbnail_url" /></label>
<label>刷新间隔<input v-model.number="ctx.legacyModal.draft.refresh_interval" type="number" /></label>
<label>格式<input v-model="ctx.legacyModal.draft.supported_formats" placeholder="json, mp4, webp" /></label>
<label class="checkbox"><input v-model="ctx.legacyModal.draft.downloadable" type="checkbox" />可下载</label>
<label class="wide">描述<textarea v-model="ctx.legacyModal.draft.description" rows="2"></textarea></label>
</div>
<div v-else class="form-grid">
<label>ID<input v-model="ctx.legacyModal.draft.id" /></label>
<label>名称<input v-model="ctx.legacyModal.draft.name" /></label>
<label>类型<input v-model="ctx.legacyModal.draft.type" /></label>
<label class="wide">URL<input v-model="ctx.legacyModal.draft.url" /></label>
<label>SHA256<input v-model="ctx.legacyModal.draft.sha256" /></label>
<label class="checkbox"><input v-model="ctx.legacyModal.draft.enabled" type="checkbox" />启用</label>
</div>
<div class="button-row">
<button class="btn primary" @click="ctx.applyLegacyModal">保存</button>
</div>
</section>
</div>
</Teleport>
</section>
</template>
@@ -56,13 +56,18 @@ defineProps<{ ctx: any }>();
</tbody>
</table>
</section>
<aside class="panel editor-panel">
<div class="section-head"><h2>版本日志</h2><button class="btn ghost" @click="ctx.importNotices">导入目录</button></div>
<aside class="panel editor-panel compact-side">
<div class="section-head">
<div>
<h2>版本日志</h2>
<p class="muted"> update-info.json 模板为基础动态生成更新信息</p>
</div>
</div>
<div class="revision-list">
<button v-for="item in ctx.releaseNotices" :key="item.version" :class="{ active: ctx.noticeDraft.version === item.version }" @click="ctx.openNotice(item.version)">
<strong>{{ item.version }}</strong><small>{{ item.title || item.updatedAt }}</small>
</button>
<div v-if="ctx.releaseNotices.length === 0" class="empty-state compact">暂无版本日志先执行导入</div>
<div v-if="ctx.releaseNotices.length === 0" class="empty-state compact">暂无版本日志直接填写版本和 Raw JSON 后保存</div>
</div>
<label>版本<input v-model="ctx.noticeDraft.version" placeholder="1.0.0" /></label>
<label>Raw JSON<textarea v-model="ctx.noticeDraft.raw" class="code-editor compact-editor"></textarea></label>
@@ -1,12 +1,13 @@
<script setup lang="ts">
import { Activity, AlertTriangle, ArrowDownUp, Clock3, Database, KeyRound, ListChecks, RefreshCw, ShieldCheck } from "lucide-vue-next";
import { Activity, AlertTriangle, ArrowDownUp, Clock3, Database, HardDrive, KeyRound, ListChecks, Mail, RefreshCw, Save, ShieldCheck, UserRound } from "lucide-vue-next";
defineProps<{ ctx: any }>();
const tabs = [
{ id: "database", label: "数据库", icon: Database },
{ id: "migration", label: "迁移状态", icon: HardDrive },
{ id: "sync", label: "旧项目同步", icon: RefreshCw },
{ id: "security", label: "安全设置", icon: ShieldCheck },
{ id: "security", label: "安全与邮件", icon: ShieldCheck },
{ id: "health", label: "健康快照", icon: Activity },
{ id: "audit", label: "审计日志", icon: ListChecks },
];
@@ -15,13 +16,7 @@ const tabs = [
<template>
<section class="page-stack">
<nav class="tabs" aria-label="系统运维标签">
<button
v-for="tab in tabs"
:key="tab.id"
type="button"
:class="{ active: ctx.systemTab === tab.id }"
@click="ctx.setSystemTab(tab.id)"
>
<button v-for="tab in tabs" :key="tab.id" type="button" :class="{ active: ctx.systemTab === tab.id }" @click="ctx.setSystemTab(tab.id)">
<component :is="tab.icon" :size="15" />
{{ tab.label }}
</button>
@@ -43,35 +38,86 @@ const tabs = [
</div>
<div class="sync-summary">
<div>
<span><ArrowDownUp :size="15" />最近同步方向</span>
<strong>{{ ctx.databaseSyncDirectionLabel(ctx.databaseLastSync?.direction) }}</strong>
</div>
<div>
<span><ListChecks :size="15" />影响记录</span>
<strong>{{ ctx.databaseSyncTableCount(ctx.databaseLastSync) }}</strong>
</div>
<div>
<span><Clock3 :size="15" />完成时间</span>
<strong>{{ ctx.databaseLastSync?.finishedAt || ctx.database?.lastSyncAt || "-" }}</strong>
</div>
<div><span><ArrowDownUp :size="15" />最近方向</span><strong>{{ ctx.databaseSyncDirectionLabel(ctx.databaseLastSync?.direction) }}</strong></div>
<div><span><ListChecks :size="15" />最近状态</span><strong>{{ ctx.databaseSyncStatusLabel(ctx.databaseLastSync?.status) }}</strong></div>
<div><span><Clock3 :size="15" />影响记录</span><strong>{{ ctx.databaseSyncTableCount(ctx.databaseLastSync) }}</strong></div>
</div>
<div class="ops-note">
<div v-if="ctx.databaseLastSync?.warnings?.length" class="ops-note">
<AlertTriangle :size="16" />
<span>数据库同步是覆盖式全表 upsert执行前确认方向SQLite 导入远端会以本地库为源远端同步回本地会以 MySQL 为源</span>
<span>{{ ctx.databaseLastSync.warnings.join("") }}</span>
</div>
</section>
<aside class="panel editor-panel">
<h2>连接与同步</h2>
<label>Provider<select v-model="ctx.databaseForm.provider"><option>sqlite</option><option>mysql</option></select></label>
<label>SQLite 路径<input v-model="ctx.databaseForm.sqlitePath" placeholder="留空使用当前配置" /></label>
<label>MySQL DSN<input v-model="ctx.databaseForm.mysqlDsn" placeholder="user:pass@tcp(host:3306)/db?parseTime=true" /></label>
<div class="section-head">
<h2>连接与同步</h2>
<div class="button-row compact-row">
<span v-if="ctx.databaseFormEditing" class="badge warn">编辑中自动刷新不会覆盖表单</span>
<button v-if="ctx.databaseFormEditing" class="btn ghost compact" type="button" @click="ctx.reloadDatabaseConfig">重新读取配置</button>
<button v-if="ctx.databaseConfigCollapsed" class="btn ghost compact" type="button" @click="ctx.editDatabaseConfig">修改配置</button>
</div>
</div>
<div v-if="ctx.databaseConfigCollapsed" class="kv-grid">
<span>当前配置</span><strong>{{ ctx.databaseConfigSummary() }}</strong>
<span>密码状态</span><strong>{{ ctx.databaseConfig?.hasPassword ? "已保存,前端不回显" : "未保存" }}</strong>
</div>
<div v-else class="form-stack" @input="ctx.markDatabaseFormEditing" @change="ctx.markDatabaseFormEditing">
<label>Provider
<select v-model="ctx.databaseForm.provider">
<option value="sqlite">SQLite</option>
<option value="mysql">MySQL</option>
</select>
</label>
<label v-if="ctx.databaseForm.provider === 'sqlite'">SQLite 路径
<input v-model="ctx.databaseForm.sqlitePath" placeholder="留空使用当前配置" />
</label>
<div v-else class="form-grid">
<label>主机<input v-model="ctx.databaseForm.mysqlHost" placeholder="127.0.0.1" /></label>
<label>端口<input v-model.number="ctx.databaseForm.mysqlPort" type="number" min="1" placeholder="3306" /></label>
<label>数据库名<input v-model="ctx.databaseForm.mysqlDatabase" placeholder="ymhut_unified" /></label>
<label>数据库用户<input v-model="ctx.databaseForm.mysqlUser" autocomplete="username" /></label>
<label class="wide">数据库密码
<input v-model="ctx.databaseForm.mysqlPassword" type="password" autocomplete="new-password" :placeholder="ctx.databaseConfig?.hasPassword ? '留空沿用已保存密码' : '请输入数据库密码'" />
</label>
</div>
</div>
<div class="button-row">
<button class="btn ghost" @click="ctx.testDatabase">测试连接</button>
<button class="btn primary" @click="ctx.syncDatabase('import')">SQLite 导入远端</button>
<button class="btn ghost" @click="ctx.syncDatabase('sync')">远端同步回本地</button>
<button class="btn primary" @click="ctx.saveDatabase"><Save :size="16" />测试并保存</button>
<button class="btn ghost" @click="ctx.syncDatabase('import')">SQLite -> MySQL</button>
<button class="btn ghost" @click="ctx.syncDatabase('sync')">MySQL -> SQLite</button>
</div>
</aside>
</section>
<section v-else-if="ctx.systemTab === 'migration'" class="split">
<section class="panel page-stack">
<div class="section-head"><h2>数据库优先迁移</h2><button class="btn ghost" @click="ctx.loadMigrationStatus"><RefreshCw :size="16" />刷新</button></div>
<div class="kv-grid">
<span>策略</span><strong>{{ ctx.migrationStatus?.strategy || "-" }}</strong>
<span>SQLite 文件</span><strong>{{ ctx.migrationStatus?.sqlitePath || "-" }}</strong>
<span>活动数据库</span><strong>{{ ctx.migrationStatus?.activeProvider || "-" }}</strong>
<span>最后同步</span><strong>{{ ctx.migrationStatus?.lastSyncAt || "-" }}</strong>
<span>同步错误</span><strong>{{ ctx.migrationStatus?.lastSyncError || "-" }}</strong>
</div>
<div class="ops-note">
<AlertTriangle :size="16" />
<span>数据库保存站点结构化状态发布包下载文件和反馈附件仍属于文件资产迁移时需要连同数据库一起备份</span>
</div>
</section>
<aside class="panel page-stack">
<h2>数据库覆盖范围</h2>
<ul class="plain-list">
<li v-for="item in ctx.migrationStatus?.databaseCovers || []" :key="item">{{ item }}</li>
</ul>
<h2>文件资产目录</h2>
<div v-for="asset in ctx.migrationStatus?.fileAssets || []" :key="asset.name" class="asset-row">
<strong>{{ asset.name }}</strong>
<span>{{ asset.description }}</span>
<code>{{ asset.path }}</code>
</div>
</aside>
</section>
@@ -80,37 +126,23 @@ const tabs = [
<div class="section-head">
<div>
<h2>旧项目同步</h2>
<p class="muted">预览只检查旧目录和影响范围执行会备份当前发布目录再复制旧项目数据并导入反馈记录</p>
<p class="muted">预览只检查旧目录和影响范围执行会备份当前兼容输出再复制旧项目数据并导入记录</p>
</div>
<button class="btn ghost" @click="ctx.previewLegacySync"><RefreshCw :size="16" />刷新预览</button>
</div>
<div class="sync-summary">
<div>
<span>当前模式</span>
<strong>{{ ctx.legacySyncMode === "run" || ctx.legacySync?.dryRun === false ? "执行结果" : "Dry-run 预览" }}</strong>
</div>
<div>
<span>状态</span>
<strong>{{ ctx.legacySync?.ok === false ? "存在错误" : "可继续" }}</strong>
</div>
<div>
<span>完成时间</span>
<strong>{{ ctx.legacySync?.finishedAt || "-" }}</strong>
</div>
<div><span>当前模式</span><strong>{{ ctx.legacySyncMode === "run" || ctx.legacySync?.dryRun === false ? "执行结果" : "Dry-run 预览" }}</strong></div>
<div><span>状态</span><strong>{{ ctx.legacySync?.ok === false ? "存在错误" : "可继续" }}</strong></div>
<div><span>完成时间</span><strong>{{ ctx.legacySync?.finishedAt || "-" }}</strong></div>
</div>
<div class="kv-grid">
<span>复制文件</span><strong>{{ ctx.legacySync?.stats?.copiedFiles || 0 }}</strong>
<span>复制目录</span><strong>{{ ctx.legacySync?.stats?.copiedDirectories || 0 }}</strong>
<span>导入记录</span><strong>{{ ctx.legacySync?.stats?.importedRows || 0 }}</strong>
<span>缺失路径</span><strong>{{ ctx.legacySync?.stats?.missingPaths || 0 }}</strong>
</div>
<pre class="json-preview tall">{{ ctx.pretty(ctx.legacySync) }}</pre>
<div class="button-row">
<button class="btn primary" @click="ctx.runLegacySync">执行旧项目同步</button>
</div>
<div class="button-row"><button class="btn primary" @click="ctx.runLegacySync">执行旧项目同步</button></div>
</section>
<section v-else-if="ctx.systemTab === 'security'" class="split">
@@ -121,15 +153,52 @@ const tabs = [
<button class="btn primary" @click="ctx.changePassword"><KeyRound :size="16" />保存密码</button>
</section>
<section class="panel page-stack">
<div class="section-head"><h2>当前安全策略</h2><span class="badge good">已启用</span></div>
<div class="kv-grid">
<span>登录保护</span><strong>验证码 + 连续失败限流</strong>
<span>写操作保护</span><strong>HttpOnly Session + CSRF Token</strong>
<span>Cookie</span><strong>HTTPS X-Forwarded-Proto=https 时自动 Secure</strong>
<span>会话范围</span><strong>后台 API SSE 事件流均要求登录</strong>
<span>密码规则</span><strong>至少 8 不能为 admin不能与当前密码相同</strong>
<span>兼容哈希</span><strong>保留 SHA-256 登录兼容后续可平滑迁移到更强算法</strong>
<section class="panel editor-panel">
<div class="section-head">
<h2>站点品牌</h2>
<span class="badge neutral">{{ ctx.branding.developerName || "YMhut" }}</span>
</div>
<div class="brand-preview">
<img :src="ctx.branding.siteIconUrl" alt="站点图标" />
<img :src="ctx.branding.developerAvatarUrl" alt="开发者头像" />
<strong>{{ ctx.branding.developerName }}</strong>
</div>
<label>站点图标 URL<input v-model="ctx.branding.siteIconUrl" /></label>
<label>开发者头像 URL<input v-model="ctx.branding.developerAvatarUrl" /></label>
<label>开发者名称<input v-model="ctx.branding.developerName" /></label>
<label>反馈邮箱<input v-model="ctx.branding.feedbackEmail" /></label>
<button class="btn primary" @click="ctx.saveBranding"><UserRound :size="16" />保存品牌</button>
</section>
<section class="panel editor-panel">
<div class="section-head">
<h2>反馈邮件通知</h2>
<div class="button-row compact-row">
<span v-if="ctx.mailConfigEditing" class="badge warn">编辑中自动刷新不会覆盖表单</span>
<button v-if="ctx.mailConfigEditing" class="btn ghost compact" type="button" @click="ctx.reloadMailConfig">重新读取配置</button>
<span :class="['badge', ctx.mailConfig.configured ? 'good' : 'warn']">{{ ctx.mailConfig.configured ? "已配置" : "未完成" }}</span>
</div>
</div>
<div class="form-grid" @input="ctx.markMailConfigEditing" @change="ctx.markMailConfigEditing">
<label>SMTP 主机<input v-model="ctx.mailConfig.host" placeholder="smtp.example.com" /></label>
<label>端口<input v-model.number="ctx.mailConfig.port" type="number" min="1" /></label>
<label>加密方式
<select v-model="ctx.mailConfig.secure">
<option value="ssl">SSL/TLS</option>
<option value="starttls">STARTTLS</option>
<option value="none">不加密</option>
</select>
</label>
<label>账号<input v-model="ctx.mailConfig.username" autocomplete="username" /></label>
<label>密码<input v-model="ctx.mailConfig.password" type="password" autocomplete="new-password" :placeholder="ctx.mailConfig.hasPassword ? '留空沿用已保存密码' : '请输入 SMTP 密码'" /></label>
<label>超时秒数<input v-model.number="ctx.mailConfig.timeoutSeconds" type="number" min="3" /></label>
<label>发件地址<input v-model="ctx.mailConfig.fromAddress" /></label>
<label>发件名称<input v-model="ctx.mailConfig.fromName" /></label>
<label class="wide">开发者收件地址<input v-model="ctx.mailConfig.developerAddress" :placeholder="ctx.branding.feedbackEmail" /></label>
</div>
<div class="button-row">
<button class="btn primary" @click="ctx.saveMailConfig"><Mail :size="16" />保存邮件配置</button>
<button class="btn ghost" @click="ctx.testMail">发送测试邮件</button>
</div>
</section>
</section>
@@ -140,20 +209,46 @@ const tabs = [
</section>
<section v-else class="panel page-stack">
<div class="section-head"><h2>审计日志</h2><button class="btn ghost" @click="ctx.loadAudit">刷新</button></div>
<div class="section-head">
<h2>审计日志</h2>
<button class="btn ghost" @click="ctx.loadAudit">刷新</button>
</div>
<div class="toolbar">
<input v-model="ctx.auditPage.q" placeholder="搜索操作、目标、信息或 IP" @keyup.enter="ctx.loadAudit" />
<input v-model="ctx.auditPage.type" placeholder="类型,例如 source.deleted" @keyup.enter="ctx.loadAudit" />
<input v-model="ctx.auditPage.target" placeholder="目标" @keyup.enter="ctx.loadAudit" />
<button class="btn ghost" @click="ctx.auditPage.page = 1; ctx.loadAudit()">筛选</button>
</div>
<table>
<thead><tr><th>类型</th><th>目标</th><th>信息</th><th>IP</th><th>时间</th></tr></thead>
<tbody>
<tr v-for="item in ctx.auditLogs" :key="item.id">
<tr v-for="item in ctx.auditPage.items" :key="item.id" class="clickable" @click="ctx.selectAuditLog(item)">
<td><span class="badge neutral">{{ ctx.auditTypeLabel(item.type) }}</span></td>
<td>{{ item.target }}</td>
<td>{{ ctx.auditMessage(item) }}</td>
<td>{{ item.ip || "-" }}</td>
<td>{{ item.createdAt }}</td>
</tr>
<tr v-if="ctx.auditLogs.length === 0"><td colspan="5">暂无审计日志</td></tr>
<tr v-if="ctx.auditPage.items.length === 0"><td colspan="5">暂无审计日志</td></tr>
</tbody>
</table>
<div class="pager">
<button class="btn ghost compact" :disabled="ctx.auditPage.page <= 1" @click="ctx.setAuditPage(ctx.auditPage.page - 1)">上一页</button>
<span> {{ ctx.auditPage.page }} / {{ Math.max(1, Math.ceil(ctx.auditPage.total / ctx.auditPage.perPage)) }} {{ ctx.auditPage.total }} </span>
<button class="btn ghost compact" :disabled="ctx.auditPage.page >= Math.ceil(ctx.auditPage.total / ctx.auditPage.perPage)" @click="ctx.setAuditPage(ctx.auditPage.page + 1)">下一页</button>
</div>
<Teleport to="body">
<div v-if="ctx.auditPage.selected" class="modal-backdrop" @click.self="ctx.auditPage.selected = null">
<section class="modal-panel">
<div class="section-head">
<h2>审计详情</h2>
<button class="btn ghost compact" @click="ctx.auditPage.selected = null">关闭</button>
</div>
<pre class="json-preview tall">{{ ctx.pretty(ctx.auditPage.selected) }}</pre>
</section>
</div>
</Teleport>
</section>
</section>
</template>
@@ -22,8 +22,8 @@ onMounted(() => state.load());
<main class="portal-shell">
<nav class="topnav">
<RouterLink class="brand" to="/">
<span><img src="/logo-44.png" alt="YMhut Box" /></span>
<strong>YMhut Box</strong>
<span><img :src="state.branding.value.siteIconUrl" :alt="state.branding.value.developerName" /></span>
<strong>{{ state.branding.value.developerName }}</strong>
</RouterLink>
<div class="nav-links">
<RouterLink v-for="item in navItems" :key="item.path" :to="item.path" :class="{ active: route.path === item.path }">
@@ -53,6 +53,12 @@ export function usePortalState() {
const downloadUrl = computed(() => releases.value?.download_url || bootstrap.value?.release?.download_url || packages.value[0]?.url || "");
const appVersion = computed(() => releases.value?.app_version || bootstrap.value?.release?.app_version || latestNotice.value?.version || "未发布");
const serviceVersion = computed(() => bootstrap.value?.serviceVersion || "-");
const branding = computed(() => ({
siteIconUrl: bootstrap.value?.branding?.siteIconUrl || "https://img.ymhut.cn/file/1782108850041_icon.webp",
developerAvatarUrl: bootstrap.value?.branding?.developerAvatarUrl || "https://img.ymhut.cn/file/1782108780690_b_3db45f3787f19192c8de8e06bc0987ef.webp",
developerName: bootstrap.value?.branding?.developerName || "YMhut",
feedbackEmail: bootstrap.value?.branding?.feedbackEmail || "support@ymhut.cn",
}));
const isReady = computed(() => loaded && !loading.value && !error.value);
const hasPartialData = computed(() => Boolean(bootstrap.value || releases.value || sources.value || notices.value.length));
const releasesEmpty = computed(() => !loading.value && packages.value.length === 0 && notices.value.length === 0);
@@ -123,6 +129,7 @@ export function usePortalState() {
downloadUrl,
appVersion,
serviceVersion,
branding,
isReady,
hasPartialData,
releasesEmpty,
+23 -12
View File
@@ -25,7 +25,8 @@
"png",
"webp"
],
"thumbnail_url": "https://pic2.zhimg.com/v2-379be37e0b4d372aa60046f9ce771f12_r.jpg"
"thumbnail_url": "https://pic2.zhimg.com/v2-379be37e0b4d372aa60046f9ce771f12_r.jpg",
"mediaType": "image"
},
{
"api_url": "https://v2.xxapi.cn/api/baisi?return=302",
@@ -40,7 +41,8 @@
"png",
"webp"
],
"thumbnail_url": "https://n.sinaimg.cn/sinacn10112/760/w640h920/20200126/4b00-innckcf8208822.jpg"
"thumbnail_url": "https://n.sinaimg.cn/sinacn10112/760/w640h920/20200126/4b00-innckcf8208822.jpg",
"mediaType": "image"
},
{
"api_url": "https://v2.xxapi.cn/api/heisi?return=302",
@@ -55,7 +57,8 @@
"png",
"webp"
],
"thumbnail_url": "https://img-baofun.zhhainiao.com/pcwallpaper_ugc_mobile/static/6902725194a8c081767ee82373d3b017.jpeg"
"thumbnail_url": "https://img-baofun.zhhainiao.com/pcwallpaper_ugc_mobile/static/6902725194a8c081767ee82373d3b017.jpeg",
"mediaType": "image"
},
{
"api_url": "https://api.pearapi.ai/api/beautifulgirl?type=image",
@@ -70,7 +73,8 @@
"png",
"webp"
],
"thumbnail_url": "https://www.sgpjbg.com/FileUpload/News/c358f121-6683-490b-beed-6debb44e4824.jpg"
"thumbnail_url": "https://www.sgpjbg.com/FileUpload/News/c358f121-6683-490b-beed-6debb44e4824.jpg",
"mediaType": "image"
},
{
"api_url": "https://apii.ctose.cn/api/cy/api/",
@@ -85,7 +89,8 @@
"png",
"webp"
],
"thumbnail_url": "https://apii.ctose.cn/api/cy/api/"
"thumbnail_url": "https://apii.ctose.cn/api/cy/api/",
"mediaType": "image"
},
{
"api_url": "https://api.suyanw.cn/api/mao.php",
@@ -100,7 +105,8 @@
"png",
"webp"
],
"thumbnail_url": "https://api.suyanw.cn/api/mao.php"
"thumbnail_url": "https://api.suyanw.cn/api/mao.php",
"mediaType": "image"
},
{
"api_url": "https://api.suyanw.cn/api/scenery.php",
@@ -115,9 +121,11 @@
"png",
"webp"
],
"thumbnail_url": "https://api.suyanw.cn/api/scenery.php"
"thumbnail_url": "https://api.suyanw.cn/api/scenery.php",
"mediaType": "image"
}
]
],
"kind": "image"
},
{
"enabled": true,
@@ -143,7 +151,8 @@
"mp4",
"webm"
],
"thumbnail_url": "https://n.sinaimg.cn/sinacn19/176/w888h888/20181119/0c26-hmhhnqt1050818.jpg"
"thumbnail_url": "https://n.sinaimg.cn/sinacn19/176/w888h888/20181119/0c26-hmhhnqt1050818.jpg",
"mediaType": "video"
},
{
"api_url": "https://api.mmp.cc/api/miss?type=mp4",
@@ -156,9 +165,11 @@
"mp4",
"webm"
],
"thumbnail_url": "https://weather-real.oss-cn-shanghai.aliyuncs.com/weather/2025-06-17/1750091559255t7FNOX.jpg"
"thumbnail_url": "https://weather-real.oss-cn-shanghai.aliyuncs.com/weather/2025-06-17/1750091559255t7FNOX.jpg",
"mediaType": "video"
}
]
],
"kind": "video"
}
],
"last_updated": "2025-09-9T17:45:00Z",
@@ -172,4 +183,4 @@
"default_view": "grid",
"show_thumbnails": false
}
}
}
+9 -9
View File
@@ -10,15 +10,15 @@
"fullInstaller": {
"fileName": "YMhut_Box_WinUI_Setup_2.0.7.5.exe",
"url": "https://update.ymhut.cn/downloads/YMhut_Box_WinUI_Setup_2.0.7.5.exe",
"sha256": "3852e0f6ef98bda862b06de61788deaa836c50fcfcf9703a903bbcfbdd09ce4b",
"size": 113480968,
"sha256": "52283178f0dce0f34eb0da81c745e7eddae761491e776b828a61dc74d1310228",
"size": 113484192,
"version": "2.0.7.5"
},
"msix": {
"fileName": "YMhutBox_2.0.7.5_x64.msix",
"url": "https://update.ymhut.cn/downloads/YMhutBox_2.0.7.5_x64.msix",
"sha256": "c1130c0cc5381854681d0879f168cf31bb346b589bc916a458552763b1fa47db",
"size": 259959751,
"sha256": "388bc5374023bb7d71e42e4c88915b0d59935a06b0be40d7eab6244832220228",
"size": 259968386,
"version": "2.0.7.5"
},
"appInstaller": {
@@ -32,15 +32,15 @@
"fullInstaller": {
"fileName": "YMhut_Box_WinUI_Setup_2.0.7.5.exe",
"url": "https://update.ymhut.cn/downloads/YMhut_Box_WinUI_Setup_2.0.7.5.exe",
"sha256": "3852e0f6ef98bda862b06de61788deaa836c50fcfcf9703a903bbcfbdd09ce4b",
"size": 113480968,
"sha256": "52283178f0dce0f34eb0da81c745e7eddae761491e776b828a61dc74d1310228",
"size": 113484192,
"version": "2.0.7.5"
},
"msix": {
"fileName": "YMhutBox_2.0.7.5_x64.msix",
"url": "https://update.ymhut.cn/downloads/YMhutBox_2.0.7.5_x64.msix",
"sha256": "c1130c0cc5381854681d0879f168cf31bb346b589bc916a458552763b1fa47db",
"size": 259959751,
"sha256": "388bc5374023bb7d71e42e4c88915b0d59935a06b0be40d7eab6244832220228",
"size": 259968386,
"version": "2.0.7.5"
},
"appInstaller": {
@@ -56,5 +56,5 @@
"updateInfo": "The official update-info catalog only describes the full offline installer, MSIX, and appinstaller artifacts.",
"distribution": "The update channel publishes the full offline installer, MSIX, and appinstaller artifacts."
},
"createdAt": "2026-06-26T10:00:33.3827184Z"
"createdAt": "2026-06-27T08:54:56.8073504Z"
}
+46 -4
View File
@@ -61,13 +61,18 @@ public sealed record RemoteMediaSource(
string Name,
string Description,
string ApiUrl,
string ResolvedUrl,
string ResolvedKey,
string MediaType,
string ThumbnailUrl,
bool Downloadable,
int RefreshIntervalSeconds,
IReadOnlyList<string> SupportedFormats,
RemoteMediaKind Kind)
{
public bool IsAvailable => Uri.TryCreate(ApiUrl, UriKind.Absolute, out _);
public string EffectiveApiUrl => string.IsNullOrWhiteSpace(ResolvedUrl) ? ApiUrl : ResolvedUrl;
public bool IsAvailable => Uri.TryCreate(EffectiveApiUrl, UriKind.Absolute, out _);
public string DisplayName => RemoteMediaCatalogNames.SourceName(Id, Name);
@@ -132,7 +137,11 @@ public static class RemoteMediaCatalogParser
}
var id = JsonString(categoryElement, "id");
var categoryKind = InferKind(id, []);
var categoryKind = ParseKind(JsonString(categoryElement, "kind", "type", "mediaType", "media_type"));
if (categoryKind == RemoteMediaKind.Unknown)
{
categoryKind = InferKind(id, []);
}
var sources = ParseSources(categoryElement, categoryKind);
if (categoryKind == RemoteMediaKind.Unknown)
{
@@ -316,7 +325,12 @@ public static class RemoteMediaCatalogParser
var id = JsonString(sourceElement, "id");
var formats = JsonStringArray(sourceElement, "supported_formats", "supportedFormats");
var kind = InferKind(id, formats);
var mediaType = JsonString(sourceElement, "mediaType", "media_type", "kind", "type");
var kind = ParseKind(mediaType);
if (kind == RemoteMediaKind.Unknown)
{
kind = InferKind(id, formats);
}
if (kind == RemoteMediaKind.Unknown)
{
kind = categoryKind;
@@ -328,13 +342,18 @@ public static class RemoteMediaCatalogParser
}
var apiUrl = JsonString(sourceElement, "api_url", "apiUrl", "url");
var resolvedUrl = JsonString(sourceElement, "resolvedUrl", "resolved_url");
var resolvedKey = JsonString(sourceElement, "resolvedKey", "resolved_key");
var thumbnailUrl = JsonString(sourceElement, "thumbnail_url", "thumbnailUrl", "thumbnail", "cover");
sources.Add(new RemoteMediaSource(
Id: string.IsNullOrWhiteSpace(id) ? $"source-{sources.Count + 1}" : id,
Name: JsonString(sourceElement, "name"),
Description: JsonString(sourceElement, "description"),
ApiUrl: apiUrl,
ThumbnailUrl: string.IsNullOrWhiteSpace(thumbnailUrl) ? apiUrl : thumbnailUrl,
ResolvedUrl: resolvedUrl,
ResolvedKey: resolvedKey,
MediaType: string.IsNullOrWhiteSpace(mediaType) ? KindName(kind) : mediaType,
ThumbnailUrl: string.IsNullOrWhiteSpace(thumbnailUrl) ? (string.IsNullOrWhiteSpace(resolvedUrl) ? apiUrl : resolvedUrl) : thumbnailUrl,
Downloadable: JsonBool(sourceElement, true, "downloadable"),
RefreshIntervalSeconds: NormalizedRefreshInterval(sourceElement, kind),
SupportedFormats: formats,
@@ -437,6 +456,29 @@ public static class RemoteMediaCatalogParser
return kind;
}
private static RemoteMediaKind ParseKind(string value)
{
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
return normalized switch
{
"image" or "img" or "picture" or "photo" => RemoteMediaKind.Image,
"video" or "movie" or "mp4" => RemoteMediaKind.Video,
"audio" or "music" or "mp3" => RemoteMediaKind.Audio,
_ => RemoteMediaKind.Unknown
};
}
private static string KindName(RemoteMediaKind kind)
{
return kind switch
{
RemoteMediaKind.Image => "image",
RemoteMediaKind.Video => "video",
RemoteMediaKind.Audio => "audio",
_ => string.Empty
};
}
private static string JsonString(JsonElement root, params string[] names)
{
if (!TryGet(root, out var value, names))
@@ -22,8 +22,12 @@ public sealed class RemoteMediaCatalogService(
ILogService? logService = null) : IRemoteMediaCatalogService
{
public static readonly Uri PrimaryConfigUri = new("https://update.ymhut.cn/media-types.json");
public static readonly Uri BootstrapUri = new("https://update.ymhut.cn/api/client/bootstrap");
public static readonly Uri SourcesUri = new("https://update.ymhut.cn/api/client/sources");
private const string EndpointId = "media_types";
private const string BootstrapEndpointId = "client_bootstrap";
private const string SourcesEndpointId = "client_sources";
private const string SnapshotFileName = "media-types.json";
public string CacheDirectory => Path.Combine(paths.Cache, "remote-media");
@@ -35,18 +39,7 @@ public sealed class RemoteMediaCatalogService(
string? warning = null;
try
{
var response = forceRefresh
? await apiManager.FetchUriAsync(EndpointId, AddCacheBuster(PrimaryConfigUri), string.Empty, cancellationToken).ConfigureAwait(false)
: await apiManager.FetchAsync(EndpointId, string.Empty, cancellationToken).ConfigureAwait(false);
if (!response.Success)
{
throw new InvalidOperationException(response.Error ?? "Remote media configuration request failed.");
}
var catalog = RemoteMediaCatalogParser.Parse(response.Content, response.FetchedAt);
await WriteSnapshotAsync(response.Content, cancellationToken).ConfigureAwait(false);
return new RemoteMediaCatalogLoadResult(catalog, RemoteMediaCatalogLoadSource.Remote);
return await LoadRemoteCatalogAsync(forceRefresh, cancellationToken).ConfigureAwait(false);
}
catch (Exception exception) when (exception is HttpRequestException or IOException or JsonException or InvalidDataException or TaskCanceledException or InvalidOperationException)
{
@@ -63,6 +56,97 @@ public sealed class RemoteMediaCatalogService(
throw new InvalidOperationException(warning ?? "Remote media configuration is unavailable and no local snapshot exists.");
}
private async Task<RemoteMediaCatalogLoadResult> LoadRemoteCatalogAsync(bool forceRefresh, CancellationToken cancellationToken)
{
var attempts = new[]
{
(EndpointId: BootstrapEndpointId, Uri: BootstrapUri, Legacy: false),
(EndpointId: SourcesEndpointId, Uri: SourcesUri, Legacy: false),
(EndpointId: EndpointId, Uri: PrimaryConfigUri, Legacy: true)
};
var errors = new List<string>();
foreach (var attempt in attempts)
{
var uri = forceRefresh ? AddCacheBuster(attempt.Uri) : attempt.Uri;
var response = attempt.Legacy && !forceRefresh
? await apiManager.FetchAsync(EndpointId, string.Empty, cancellationToken).ConfigureAwait(false)
: await apiManager.FetchUriAsync(attempt.EndpointId, uri, string.Empty, cancellationToken).ConfigureAwait(false);
if (!response.Success)
{
errors.Add($"{attempt.Uri.AbsolutePath}: {response.Error ?? response.StatusCode.ToString()}");
continue;
}
try
{
var content = ExtractCatalogContent(response.Content);
var catalog = RemoteMediaCatalogParser.Parse(content, response.FetchedAt);
await WriteSnapshotAsync(content, cancellationToken).ConfigureAwait(false);
return new RemoteMediaCatalogLoadResult(catalog, RemoteMediaCatalogLoadSource.Remote);
}
catch (Exception exception) when (exception is JsonException or InvalidDataException)
{
errors.Add($"{attempt.Uri.AbsolutePath}: {exception.Message}");
}
}
throw new InvalidOperationException(errors.Count == 0
? "Remote media configuration request failed."
: string.Join(" | ", errors));
}
private static string ExtractCatalogContent(string content)
{
using var document = JsonDocument.Parse(content, new JsonDocumentOptions
{
AllowTrailingCommas = true,
CommentHandling = JsonCommentHandling.Skip
});
var root = document.RootElement;
if (root.ValueKind != JsonValueKind.Object)
{
throw new InvalidDataException("Remote media configuration does not contain an object.");
}
if (root.TryGetProperty("categories", out var categories) && categories.ValueKind == JsonValueKind.Array)
{
return content;
}
if (TryGetObject(root, out var sources, "sources", "catalog", "mediaTypes", "media_types") &&
sources.TryGetProperty("categories", out var nestedCategories) &&
nestedCategories.ValueKind == JsonValueKind.Array)
{
return sources.GetRawText();
}
throw new InvalidDataException("Remote media configuration does not contain categories.");
}
private static bool TryGetObject(JsonElement root, out JsonElement value, params string[] names)
{
value = default;
foreach (var name in names)
{
if (root.TryGetProperty(name, out value) && value.ValueKind == JsonValueKind.Object)
{
return true;
}
}
foreach (var property in root.EnumerateObject())
{
if (names.Any(name => string.Equals(name, property.Name, StringComparison.OrdinalIgnoreCase)) &&
property.Value.ValueKind == JsonValueKind.Object)
{
value = property.Value;
return true;
}
}
return false;
}
public async Task<RemoteMediaCatalogLoadResult?> TryReadCacheAsync(CancellationToken cancellationToken = default)
{
if (!File.Exists(SnapshotPath))
@@ -133,7 +133,7 @@ public sealed class RemoteMediaResolver : IRemoteMediaResolver
}
catch when (!cancellationToken.IsCancellationRequested)
{
return lastResolution ?? FromUriOnly(current, expectedKind);
return lastResolution ?? FromProbeFailure(current, expectedKind);
}
}
@@ -194,9 +194,20 @@ public sealed class RemoteMediaResolver : IRemoteMediaResolver
extension);
}
private static RemoteMediaResolution FromProbeFailure(Uri uri, RemoteMediaKind expectedKind)
{
var extension = SuggestedExtension(uri, string.Empty, expectedKind);
return new RemoteMediaResolution(
uri,
"application/x-ymhut-probe-failed",
null,
false,
extension);
}
private static bool IsDirectMedia(Uri uri, string contentType, long? length, RemoteMediaKind expectedKind)
{
if (IsMediaContentType(contentType) || LooksLikeDirectMediaUri(uri, expectedKind))
if (IsExpectedMediaContentType(contentType, expectedKind) || LooksLikeDirectMediaUri(uri, expectedKind))
{
return true;
}
@@ -252,8 +263,23 @@ public sealed class RemoteMediaResolver : IRemoteMediaResolver
private static bool IsImageExtension(string extension)
=> extension is "png" or "jpg" or "jpeg" or "bmp" or "gif" or "webp" or "tif" or "tiff";
private static bool IsMediaContentType(string contentType)
private static bool IsExpectedMediaContentType(string contentType, RemoteMediaKind expectedKind)
{
if (expectedKind == RemoteMediaKind.Image)
{
return contentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase);
}
if (expectedKind == RemoteMediaKind.Video)
{
return contentType.StartsWith("video/", StringComparison.OrdinalIgnoreCase);
}
if (expectedKind == RemoteMediaKind.Audio)
{
return contentType.StartsWith("audio/", StringComparison.OrdinalIgnoreCase);
}
return contentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase) ||
contentType.StartsWith("video/", StringComparison.OrdinalIgnoreCase) ||
contentType.StartsWith("audio/", StringComparison.OrdinalIgnoreCase);
@@ -84,6 +84,8 @@ public sealed class AppSettings
public bool UpdateNotification { get; set; } = true;
public double RandomCinemaVolumePercent { get; set; } = 70;
public bool HardwareAccelerationEnabled { get; set; } = true;
public int ProxyTestTimeoutSeconds { get; set; } = 6;
+133 -5
View File
@@ -18,15 +18,19 @@ public sealed class RemoteMediaCatalogTests
var image = catalog.Categories.Single(category => category.Id == "image");
Assert.IsTrue(image.Enabled);
Assert.AreEqual("随机图片", image.DisplayName);
Assert.AreEqual(RemoteMediaKind.Image, image.Kind);
Assert.IsTrue(image.Layout.ShowPreview);
CollectionAssert.Contains(image.Sources.First(source => source.Id == "xjj").SupportedFormats.ToArray(), "jpg");
Assert.AreEqual(30, image.Sources.First(source => source.Id == "xjj").RefreshIntervalSeconds);
Assert.AreEqual("image", image.Sources.First(source => source.Id == "xjj").MediaType);
var video = catalog.Categories.Single(category => category.Id == "video");
Assert.AreEqual("随机视频", video.DisplayName);
Assert.AreEqual(RemoteMediaKind.Video, video.Kind);
Assert.IsFalse(video.Layout.AutoPlay);
CollectionAssert.Contains(video.Sources.First().SupportedFormats.ToArray(), "mp4");
Assert.AreEqual("video", video.Sources.First().MediaType);
}
[TestMethod]
@@ -75,6 +79,74 @@ public sealed class RemoteMediaCatalogTests
Assert.AreEqual("https://example.test/media", source.ThumbnailUrl);
}
[TestMethod]
public void ParsesUnifiedResolvedMediaFields()
{
const string content = """
{
"categories": [
{
"id": "image",
"subcategories": [
{
"id": "demo",
"api_url": "https://api.example.test/random",
"resolvedUrl": "https://cdn.example.test/media/demo.webp",
"resolvedKey": "data.cover",
"mediaType": "image",
"supported_formats": ["json", "webp"]
}
]
}
]
}
""";
var source = RemoteMediaCatalogParser.Parse(content).Categories.Single().Sources.Single();
Assert.AreEqual("https://api.example.test/random", source.ApiUrl);
Assert.AreEqual("https://cdn.example.test/media/demo.webp", source.ResolvedUrl);
Assert.AreEqual("data.cover", source.ResolvedKey);
Assert.AreEqual("image", source.MediaType);
Assert.AreEqual(source.ResolvedUrl, source.EffectiveApiUrl);
Assert.IsTrue(source.IsAvailable);
}
[TestMethod]
public void ExplicitMediaTypeWinsOverCategoryAndFormats()
{
const string content = """
{
"categories": [
{
"id": "mixed",
"type": "image",
"subcategories": [
{
"id": "json_picture",
"api_url": "https://api.example.test/random",
"mediaType": "image",
"supported_formats": ["json", "mp4"]
},
{
"id": "clip",
"api_url": "https://api.example.test/clip",
"type": "video",
"supported_formats": ["jpg"]
}
]
}
]
}
""";
var category = RemoteMediaCatalogParser.Parse(content).Categories.Single();
Assert.AreEqual(RemoteMediaKind.Image, category.Kind);
Assert.AreEqual(RemoteMediaKind.Image, category.Sources[0].Kind);
Assert.AreEqual(RemoteMediaKind.Video, category.Sources[1].Kind);
}
[TestMethod]
public async Task ServiceWritesReadsFallsBackAndClearsCache()
{
@@ -112,6 +184,58 @@ public sealed class RemoteMediaCatalogTests
}
}
[TestMethod]
public async Task ServicePrefersUnifiedBootstrapSources()
{
var root = Path.Combine(Path.GetTempPath(), "ymhut-remote-media-" + Guid.NewGuid().ToString("N"));
try
{
var paths = new AppPaths(root);
paths.EnsureCreated();
const string bootstrap = """
{
"ok": true,
"sources": {
"layout_version": "2.0.0",
"categories": [
{
"id": "image",
"subcategories": [
{
"id": "demo",
"api_url": "https://api.example.test/random",
"resolvedUrl": "https://cdn.example.test/media/demo.webp",
"supported_formats": ["json", "webp"]
}
]
}
]
}
}
""";
var api = new FakeApiManager(
string.Empty,
uriResponses: new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
[RemoteMediaCatalogService.BootstrapUri.AbsolutePath] = bootstrap
});
var service = new RemoteMediaCatalogService(paths, api);
var result = await service.LoadAsync(forceRefresh: false);
Assert.AreEqual(RemoteMediaCatalogLoadSource.Remote, result.Source);
Assert.AreEqual(RemoteMediaCatalogService.BootstrapUri.AbsolutePath, api.LastUri?.AbsolutePath);
Assert.AreEqual("https://cdn.example.test/media/demo.webp", result.Catalog.Categories.Single().Sources.Single().EffectiveApiUrl);
}
finally
{
if (Directory.Exists(root))
{
Directory.Delete(root, recursive: true);
}
}
}
private static string ReadRepoFile(params string[] segments)
{
var directory = new DirectoryInfo(Directory.GetCurrentDirectory());
@@ -129,7 +253,7 @@ public sealed class RemoteMediaCatalogTests
throw new DirectoryNotFoundException("Unable to locate repository sample file.");
}
private sealed class FakeApiManager(string content, bool success = true) : IApiManager
private sealed class FakeApiManager(string content, bool success = true, IReadOnlyDictionary<string, string>? uriResponses = null) : IApiManager
{
public Uri? LastUri { get; private set; }
@@ -149,14 +273,18 @@ public sealed class RemoteMediaCatalogTests
public Task<ApiResponse> FetchUriAsync(string endpointId, Uri uri, string input = "", CancellationToken cancellationToken = default)
{
LastUri = uri;
var responseContent = uriResponses is not null && uriResponses.TryGetValue(uri.AbsolutePath, out var match)
? match
: content;
var responseSuccess = success || !string.IsNullOrWhiteSpace(responseContent);
return Task.FromResult(new ApiResponse(
endpointId,
uri,
success,
success ? content : string.Empty,
success ? null : "offline",
responseSuccess,
responseSuccess ? responseContent : string.Empty,
responseSuccess ? null : "offline",
DateTimeOffset.Now,
success ? 200 : 0));
responseSuccess ? 200 : 0));
}
public Task<ApiHealthStatus> CheckHealthAsync(string endpointId, string input = "", CancellationToken cancellationToken = default)
@@ -85,6 +85,30 @@ public sealed class RemoteMediaResolverTests
Assert.AreEqual("text/html", result.ContentType);
}
[TestMethod]
public async Task ProbeFailureDoesNotPretendUrlIsDirectMedia()
{
var resolver = CreateResolver(_ => throw new HttpRequestException("SSL connection failed"));
var result = await resolver.ResolveMediaAsync("https://cdn.test/broken/video.mp4", RemoteMediaKind.Video, cacheBust: false);
Assert.AreEqual("https://cdn.test/broken/video.mp4", result.Uri.AbsoluteUri);
Assert.IsFalse(result.IsDirectMedia);
Assert.AreEqual("application/x-ymhut-probe-failed", result.ContentType);
Assert.AreEqual(".mp4", result.SuggestedExtension);
}
[TestMethod]
public async Task RejectsWrongKindMediaAsNonDirectForExpectedKind()
{
var resolver = CreateResolver(_ => Text(HttpStatusCode.OK, string.Empty, "image/jpeg"));
var result = await resolver.ResolveMediaAsync("https://example.test/random", RemoteMediaKind.Video, cacheBust: false);
Assert.IsFalse(result.IsDirectMedia);
Assert.AreEqual("image/jpeg", result.ContentType);
}
[TestMethod]
public async Task TreatsDirectMediaContentTypeAsPlayable()
{
+10 -10
View File
@@ -708,26 +708,26 @@ public sealed class ToolExecutorTests
public void UpdateNoticeJsonKeepsPlainTextAndAddsMarkdown()
{
var repoRoot = Directory.GetParent(FindAssetsRoot())!.FullName;
var noticePath = Path.Combine(repoRoot, "update-notice", "2.0.6.3.json");
var noticePath = Path.Combine(repoRoot, "update-notice", "2.0.7.5.json");
var totalPath = Path.Combine(repoRoot, "update-notice", "total.json");
using var notice = JsonDocument.Parse(File.ReadAllText(noticePath));
var noticeRoot = notice.RootElement;
Assert.AreEqual("2.0.6.3", noticeRoot.GetProperty("app_version").GetString());
Assert.AreEqual("2.0.7.5", noticeRoot.GetProperty("app_version").GetString());
Assert.IsFalse(string.IsNullOrWhiteSpace(noticeRoot.GetProperty("message").GetString()));
Assert.IsFalse(string.IsNullOrWhiteSpace(noticeRoot.GetProperty("release_notes").GetString()));
StringAssert.Contains(noticeRoot.GetProperty("message_md").GetString(), "YMhut Box 2.0.6.3");
StringAssert.Contains(noticeRoot.GetProperty("release_notes_md").GetString(), "QQ 信息");
StringAssert.Contains(noticeRoot.GetProperty("release_notes_md").GetString(), "安全浏览器");
StringAssert.Contains(noticeRoot.GetProperty("message_md").GetString(), "YMhut Box 2.0.7.5");
StringAssert.Contains(noticeRoot.GetProperty("release_notes_md").GetString(), "随机放映室");
StringAssert.Contains(noticeRoot.GetProperty("release_notes_md").GetString(), "音量控制");
using var total = JsonDocument.Parse(File.ReadAllText(totalPath));
var totalRoot = total.RootElement;
Assert.AreEqual("2.0.6.3", totalRoot.GetProperty("latest_version").GetString());
Assert.AreEqual("2.0.7.5", totalRoot.GetProperty("latest_version").GetString());
var latest = totalRoot.GetProperty("latest");
Assert.AreEqual("2.0.6.3", latest.GetProperty("version").GetString());
StringAssert.Contains(latest.GetProperty("release_notes_md").GetString(), "QQ 信息");
Assert.AreEqual("2.0.6.3", totalRoot.GetProperty("versions")[0].GetProperty("version").GetString());
StringAssert.Contains(totalRoot.GetProperty("versions")[0].GetProperty("summary").GetString(), "QQ 信息");
Assert.AreEqual("2.0.7.5", latest.GetProperty("version").GetString());
StringAssert.Contains(latest.GetProperty("release_notes_md").GetString(), "随机放映室");
Assert.AreEqual("2.0.7.5", totalRoot.GetProperty("versions")[0].GetProperty("version").GetString());
StringAssert.Contains(totalRoot.GetProperty("versions")[0].GetProperty("summary").GetString(), "随机放映室");
}
[TestMethod]
+456 -140
View File
@@ -1,7 +1,10 @@
using System.Runtime.InteropServices.WindowsRuntime;
using Microsoft.UI;
using Microsoft.UI.Text;
using Microsoft.UI.Windowing;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Input;
using Microsoft.UI.Xaml.Media;
using Microsoft.UI.Xaml.Media.Imaging;
using Windows.Media.Core;
@@ -9,10 +12,12 @@ using Windows.Media.Playback;
using Windows.Storage;
using Windows.Storage.Pickers;
using Windows.Storage.Streams;
using Windows.System;
using WinRT.Interop;
using YMhut.Box.Core.App;
using YMhut.Box.Core.Logging;
using YMhut.Box.Core.Media;
using YMhut.Box.Core.Settings;
using YMhut.Box.Core.Tools;
using YMhut.Box.WinUI.Services;
using YMhut.Box.WinUI.ViewModels.Tools;
@@ -25,8 +30,15 @@ public class RandomCinemaPage : ToolPageBase
private readonly AppPaths _appPaths = AppServices.GetRequiredService<AppPaths>();
private readonly IRemoteMediaCatalogService _catalogService = AppServices.GetRequiredService<IRemoteMediaCatalogService>();
private readonly IRemoteMediaResolver _mediaResolver = AppServices.GetRequiredService<IRemoteMediaResolver>();
private readonly ISettingsService _settingsService = AppServices.GetRequiredService<ISettingsService>();
private readonly AdaptiveToolViewModel _viewModel;
private readonly Grid _root = new();
private readonly ContentControl _bodyContent = new()
{
HorizontalContentAlignment = HorizontalAlignment.Stretch,
VerticalContentAlignment = VerticalAlignment.Stretch
};
private readonly ScrollViewer _bodyScroll = new();
private readonly StackPanel _contentHost = new() { Spacing = 16 };
private readonly Grid _fullscreenOverlay = new() { Visibility = Visibility.Collapsed };
private readonly Grid _fullscreenStage = new();
@@ -38,6 +50,7 @@ public class RandomCinemaPage : ToolPageBase
IsActive = true,
Visibility = Visibility.Visible
};
private const int SourcePageSize = 8;
private RemoteMediaCatalog? _catalog;
private byte[]? _currentMediaBytes;
@@ -50,7 +63,12 @@ public class RandomCinemaPage : ToolPageBase
private RemoteMediaSource? _activeSource;
private UIElement? _activeMediaView;
private Panel? _activeMediaHost;
private MediaPlayer? _currentPlayer;
private Slider? _volumeSlider;
private TextBlock? _volumeText;
private bool _inlineFullscreen;
private bool _windowFullscreen;
private AppWindow? _appWindow;
public RandomCinemaPage(IToolModule module, AdaptiveToolViewModel viewModel, Action? goBack = null)
{
@@ -59,8 +77,19 @@ public class RandomCinemaPage : ToolPageBase
Background = ModernUi.AppBackground;
BindModule(module);
Content = BuildContent(module);
var exitFullscreen = new KeyboardAccelerator { Key = VirtualKey.Escape };
exitFullscreen.Invoked += (_, args) =>
{
if (_windowFullscreen || _inlineFullscreen)
{
ExitWindowFullscreen();
ExitInlineFullscreen();
args.Handled = true;
}
};
KeyboardAccelerators.Add(exitFullscreen);
Loaded += async (_, _) => await LoadRemoteConfigAsync();
Unloaded += (_, _) => DisposeCurrentImageStream();
Unloaded += (_, _) => DisposeCurrentMedia();
}
private UIElement BuildContent(IToolModule module)
@@ -72,24 +101,15 @@ public class RandomCinemaPage : ToolPageBase
Grid.SetRow(header, 0);
_root.Children.Add(header);
var bodyStack = new StackPanel
{
Spacing = 16,
HorizontalAlignment = HorizontalAlignment.Stretch,
Children = { _contentHost }
};
var body = new ScrollViewer
{
Margin = new Thickness(32, 0, 32, 32),
Padding = new Thickness(0, 0, 8, 0),
VerticalScrollBarVisibility = ScrollBarVisibility.Auto,
HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled,
Content = bodyStack
};
body.SizeChanged += (_, e) => bodyStack.Width = Math.Max(280, e.NewSize.Width - 16);
Grid.SetRow(body, 1);
_root.Children.Add(body);
_bodyScroll.Margin = new Thickness(32, 0, 32, 32);
_bodyScroll.Padding = new Thickness(0, 0, 8, 0);
_bodyScroll.VerticalScrollBarVisibility = ScrollBarVisibility.Auto;
_bodyScroll.HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled;
_bodyScroll.Content = _contentHost;
_bodyScroll.SizeChanged += (_, e) => _contentHost.Width = Math.Max(280, e.NewSize.Width - 16);
_bodyContent.Content = _bodyScroll;
Grid.SetRow(_bodyContent, 1);
_root.Children.Add(_bodyContent);
BuildFullscreenOverlay();
Grid.SetRowSpan(_fullscreenOverlay, 2);
@@ -127,33 +147,32 @@ public class RandomCinemaPage : ToolPageBase
_fullscreenOverlay.Children.Add(_fullscreenStage);
}
private Border BuildHeader(IToolModule module)
private Grid BuildHeader(IToolModule module)
{
var back = ModernUi.IconButton("\uE72B", AppLocalizer.T("返回工具箱", "Back to toolbox"), () => _goBack?.Invoke());
var refresh = ModernUi.PillButton(AppLocalizer.T("重新加载配置", "Reload sources"), "\uE895", async () => await LoadRemoteConfigAsync(forceRefresh: true), primary: true);
var grid = new Grid { ColumnSpacing = 14 };
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
var grid = new Grid
{
ColumnSpacing = 12,
Margin = new Thickness(32, 24, 32, 12)
};
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
grid.ColumnDefinitions.Add(new ColumnDefinition());
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
grid.Children.Add(back);
var icon = ModernUi.IconTile(module.Metadata.IconGlyph, 48, ModernUi.AccentSoft, ModernUi.Accent, 21);
Grid.SetColumn(icon, 1);
grid.Children.Add(icon);
var title = new StackPanel
{
Spacing = 4,
Spacing = 2,
VerticalAlignment = VerticalAlignment.Center,
Children =
{
ModernUi.Text(ToolText.Name(module), 24, FontWeights.SemiBold, maxLines: 1),
ModernUi.Text(AppLocalizer.T("先选择随机图片或随机视频,再进入远程源加载媒体。", "Choose Random Images or Random Videos, then select a remote source."), 14, foreground: ModernUi.TextSecondary, maxLines: 2),
ModernUi.Text(ToolText.Name(module), 20, FontWeights.SemiBold, maxLines: 1),
_statusText
}
};
Grid.SetColumn(title, 2);
Grid.SetColumn(title, 1);
grid.Children.Add(title);
var actions = new StackPanel
@@ -163,13 +182,27 @@ public class RandomCinemaPage : ToolPageBase
VerticalAlignment = VerticalAlignment.Center,
Children = { _progress, refresh }
};
Grid.SetColumn(actions, 3);
Grid.SetColumn(actions, 2);
grid.Children.Add(actions);
return ModernUi.Card(grid, new Thickness(18), margin: new Thickness(32, 28, 32, 12), radius: 8);
return grid;
}
private void UseScrollableBody()
{
if (!ReferenceEquals(_bodyContent.Content, _bodyScroll))
{
_bodyContent.Content = _bodyScroll;
}
}
private void UseFixedBody(UIElement content)
{
_bodyContent.Content = content;
}
private async Task LoadRemoteConfigAsync(bool forceRefresh = false)
{
UseScrollableBody();
_viewModel.IsBusy = true;
_progress.IsActive = true;
_progress.Visibility = Visibility.Visible;
@@ -220,13 +253,16 @@ public class RandomCinemaPage : ToolPageBase
private void RenderChoiceCards()
{
UseScrollableBody();
_contentHost.Children.Clear();
var categories = _catalog?.EnabledCategories.ToList() ?? [];
var categories = _catalog?.EnabledCategories
.Where(IsSupportedCategory)
.ToList() ?? [];
if (categories.Count == 0)
{
_contentHost.Children.Add(BuildEmptyState(
AppLocalizer.T("没有用的媒体分类", "No enabled media categories"),
AppLocalizer.T("远程配置已读取,但没有可展示的分类。请刷新配置或稍后重试。", "The remote configuration loaded, but no categories are enabled. Reload sources or try again later.")));
AppLocalizer.T("没有用的图片或视频分类", "No image or video categories"),
AppLocalizer.T("随机放映室只显示图片和视频源。请刷新配置或稍后重试。", "Random Cinema only shows image and video sources. Reload sources or try again later.")));
return;
}
@@ -246,8 +282,9 @@ public class RandomCinemaPage : ToolPageBase
private Button BuildChoiceCard(RemoteMediaCategory category)
{
var total = category.Subcategories.Count;
var available = category.Subcategories.Count(source => source.IsAvailable);
var sources = DisplaySources(category);
var total = sources.Count;
var available = sources.Count(source => source.IsAvailable);
var enabled = total > 0;
var top = new Grid { ColumnSpacing = 12 };
top.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
@@ -297,13 +334,15 @@ public class RandomCinemaPage : ToolPageBase
return button;
}
private void RenderSourceCards(RemoteMediaCategory category)
private void RenderSourceCards(RemoteMediaCategory category, int page = 1)
{
UseScrollableBody();
_activeCategory = category;
_contentHost.Children.Clear();
_contentHost.Children.Add(BuildBreadcrumb(DisplayCategoryName(category), RenderChoiceCards));
if (category.Subcategories.Count == 0)
var sources = DisplaySources(category);
if (sources.Count == 0)
{
_contentHost.Children.Add(BuildEmptyState(
AppLocalizer.T("暂无资源源", "No media sources"),
@@ -311,18 +350,28 @@ public class RandomCinemaPage : ToolPageBase
return;
}
var totalPages = Math.Max(1, (int)Math.Ceiling(sources.Count / (double)SourcePageSize));
var currentPage = Math.Clamp(page, 1, totalPages);
var pageItems = sources
.Skip((currentPage - 1) * SourcePageSize)
.Take(SourcePageSize)
.ToArray();
var wrap = new VariableSizedWrapGrid
{
Orientation = Orientation.Horizontal,
ItemWidth = SourceCardWidth(category),
ItemHeight = SourceCardHeight(category)
};
foreach (var source in category.Subcategories)
foreach (var source in pageItems)
{
wrap.Children.Add(BuildSourceCard(category, source));
}
_contentHost.Children.Add(wrap);
if (totalPages > 1)
{
_contentHost.Children.Add(BuildPager(category, currentPage, totalPages));
}
}
private UIElement BuildBreadcrumb(string title, Action back)
@@ -428,6 +477,30 @@ public class RandomCinemaPage : ToolPageBase
}, new Thickness(24), radius: 8, background: ModernUi.SurfaceAlt);
}
private UIElement BuildPager(RemoteMediaCategory category, int page, int totalPages)
{
var previous = ModernUi.PillButton(AppLocalizer.T("上一页", "Previous"), "\uE76B", () => RenderSourceCards(category, page - 1));
previous.IsEnabled = page > 1;
previous.Opacity = page > 1 ? 1 : 0.5;
var next = ModernUi.PillButton(AppLocalizer.T("下一页", "Next"), "\uE76C", () => RenderSourceCards(category, page + 1));
next.IsEnabled = page < totalPages;
next.Opacity = page < totalPages ? 1 : 0.5;
return new StackPanel
{
Orientation = Orientation.Horizontal,
Spacing = 10,
HorizontalAlignment = HorizontalAlignment.Center,
Children =
{
previous,
ModernUi.SmallBadge(AppLocalizer.T($"{page} / {totalPages} 页", $"Page {page} / {totalPages}"), ModernUi.TextSecondary, ModernUi.SurfaceAlt),
next
}
};
}
private static StackPanel BadgeRows(params UIElement[] badges)
{
var panel = new StackPanel { Spacing = 6 };
@@ -452,11 +525,10 @@ public class RandomCinemaPage : ToolPageBase
private static int CategorySortKey(RemoteMediaCategory category)
{
return category.Kind switch
return PrimaryKind(category) switch
{
RemoteMediaKind.Image => 0,
RemoteMediaKind.Video => 1,
RemoteMediaKind.Audio => 2,
_ => 3
};
}
@@ -484,23 +556,20 @@ public class RandomCinemaPage : ToolPageBase
return IsVideoCategory(category)
? "\uE714"
: IsAudioCategory(category)
? "\uE8D6"
: "\uEB9F";
: "\uEB9F";
}
private static string KindLabel(RemoteMediaCategory category)
{
return IsVideoCategory(category)
? AppLocalizer.T("视频", "Video")
: IsAudioCategory(category)
? AppLocalizer.T("音频", "Audio")
: AppLocalizer.T("图片", "Image");
: AppLocalizer.T("图片", "Image");
}
private static string CategoryDescription(RemoteMediaCategory category)
{
var sourceCount = AppLocalizer.T($"{category.Subcategories.Count} 个远程源", $"{category.Subcategories.Count} remote sources");
var count = DisplaySources(category).Count;
var sourceCount = AppLocalizer.T($"{count} 个远程源", $"{count} remote sources");
var playback = category.Layout.AutoPlay
? AppLocalizer.T("自动播放", "autoplay")
: AppLocalizer.T("手动播放", "manual play");
@@ -542,16 +611,22 @@ public class RandomCinemaPage : ToolPageBase
_activeSource = source;
ExitInlineFullscreen();
DisposeCurrentImageStream();
_currentPlayer?.Dispose();
_currentPlayer = null;
_volumeSlider = null;
_volumeText = null;
_currentMediaBytes = null;
_currentMediaUri = null;
_currentMediaCachePath = null;
_currentFileName = $"{source.Id}_{DateTime.Now:yyyyMMdd_HHmmss}";
_currentExtension = "." + (source.SupportedFormats.FirstOrDefault() ?? (IsVideoCategory(category) ? "mp4" : IsAudioCategory(category) ? "mp3" : "jpg")).TrimStart('.');
var mediaKind = MediaKind(category, source);
_currentExtension = "." + (source.SupportedFormats.FirstOrDefault() ?? (mediaKind == RemoteMediaKind.Video ? "mp4" : "jpg")).TrimStart('.');
_contentHost.Children.Clear();
_contentHost.Children.Add(BuildBreadcrumb($"{DisplayCategoryName(category)} / {DisplaySourceName(source)}", () => RenderSourceCards(category)));
var host = new Grid { MinHeight = 460 };
var host = new Grid
{
HorizontalAlignment = HorizontalAlignment.Stretch,
VerticalAlignment = VerticalAlignment.Stretch
};
var loadingText = ModernUi.Text(AppLocalizer.T("正在解析媒体地址...", "Resolving media address..."), 14, foreground: ModernUi.TextSecondary);
var loadingProgress = new ProgressBar
{
@@ -582,16 +657,35 @@ public class RandomCinemaPage : ToolPageBase
saveButton.IsEnabled = source.Downloadable;
saveButton.Opacity = source.Downloadable ? 1 : 0.5;
actions.Children.Add(saveButton);
var volumeControl = BuildVolumeControl();
volumeControl.Visibility = mediaKind == RemoteMediaKind.Video ? Visibility.Visible : Visibility.Collapsed;
actions.Children.Add(volumeControl);
_contentHost.Children.Add(ModernUi.Card(new StackPanel
actions.HorizontalAlignment = HorizontalAlignment.Right;
actions.VerticalAlignment = VerticalAlignment.Center;
var toolbar = new Grid { ColumnSpacing = 12 };
toolbar.ColumnDefinitions.Add(new ColumnDefinition());
toolbar.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
toolbar.Children.Add(BuildBreadcrumb($"{DisplayCategoryName(category)} / {DisplaySourceName(source)}", () => RenderSourceCards(category)));
Grid.SetColumn(actions, 1);
toolbar.Children.Add(actions);
var panel = ModernUi.Card(new Grid
{
Spacing = 14,
RowDefinitions =
{
new RowDefinition { Height = GridLength.Auto },
new RowDefinition()
},
Children =
{
host,
actions
toolbar,
WithRow(host, 1)
}
}, new Thickness(14), radius: 8));
}, new Thickness(14), new Thickness(32, 0, 32, 24), radius: 8);
panel.VerticalAlignment = VerticalAlignment.Stretch;
UseFixedBody(panel);
try
{
@@ -602,11 +696,9 @@ public class RandomCinemaPage : ToolPageBase
? AppLocalizer.T("正在准备预览...", "Preparing preview...")
: AppLocalizer.T($"正在加载媒体... {value:0}%", $"Loading media... {value:0}%");
});
UIElement media = IsVideoCategory(category)
UIElement media = mediaKind == RemoteMediaKind.Video
? await BuildVideoViewerAsync(category, source, progress)
: IsAudioCategory(category)
? await BuildAudioViewerAsync(category, source, progress)
: await BuildImageViewerAsync(source, progress);
: await BuildImageViewerAsync(source, progress);
host.Children.Clear();
host.Children.Add(media);
_activeMediaHost = host;
@@ -635,9 +727,10 @@ public class RandomCinemaPage : ToolPageBase
}
}
private async Task<Image> BuildImageViewerAsync(RemoteMediaSource source, IProgress<double>? progress)
private async Task<UIElement> BuildImageViewerAsync(RemoteMediaSource source, IProgress<double>? progress)
{
var resolution = await _mediaResolver.ResolveMediaAsync(source.ApiUrl, RemoteMediaKind.Image, progress: progress);
var resolution = await _mediaResolver.ResolveMediaAsync(source.EffectiveApiUrl, RemoteMediaKind.Image, progress: progress);
EnsureExpectedMedia(resolution, RemoteMediaKind.Image);
_currentMediaUri = resolution.Uri;
_currentExtension = resolution.SuggestedExtension;
progress?.Report(45);
@@ -645,30 +738,32 @@ public class RandomCinemaPage : ToolPageBase
var bitmap = await BitmapFromBytesAsync(_currentMediaBytes);
_currentImageStream = bitmap.Stream;
progress?.Report(100);
return new Image
var image = new Image
{
MinHeight = 420,
Stretch = Stretch.Uniform,
Source = bitmap.Image,
HorizontalAlignment = HorizontalAlignment.Stretch,
VerticalAlignment = VerticalAlignment.Stretch
};
return MediaStage(image);
}
private async Task<UIElement> BuildVideoViewerAsync(RemoteMediaCategory category, RemoteMediaSource source, IProgress<double>? progress)
{
var resolution = await _mediaResolver.ResolveMediaAsync(source.ApiUrl, RemoteMediaKind.Video, progress: progress);
var resolution = await _mediaResolver.ResolveMediaAsync(source.EffectiveApiUrl, RemoteMediaKind.Video, progress: progress);
EnsureExpectedMedia(resolution, RemoteMediaKind.Video);
_currentMediaUri = resolution.Uri;
_currentExtension = resolution.SuggestedExtension;
progress?.Report(90);
var media = new MediaPlayerElement
{
MinHeight = 420,
AreTransportControlsEnabled = true,
HorizontalAlignment = HorizontalAlignment.Stretch,
VerticalAlignment = VerticalAlignment.Stretch
};
var player = CreateRemoteMediaPlayer(resolution);
_currentPlayer = player;
ApplyVolumeToPlayer();
media.SetMediaPlayer(player);
AttachPlaybackFallback(media, player, resolution, category, source);
if (category.Layout.AutoPlay)
@@ -676,67 +771,122 @@ public class RandomCinemaPage : ToolPageBase
player.Play();
}
return WrapPlayableMedia(media, category, source);
}
private async Task<UIElement> BuildAudioViewerAsync(RemoteMediaCategory category, RemoteMediaSource source, IProgress<double>? progress)
{
var resolution = await _mediaResolver.ResolveMediaAsync(source.ApiUrl, RemoteMediaKind.Audio, progress: progress);
_currentMediaUri = resolution.Uri;
_currentExtension = resolution.SuggestedExtension;
progress?.Report(90);
var media = new MediaPlayerElement
{
MinHeight = 88,
AreTransportControlsEnabled = true,
HorizontalAlignment = HorizontalAlignment.Stretch,
VerticalAlignment = VerticalAlignment.Center
};
var player = CreateRemoteMediaPlayer(resolution);
media.SetMediaPlayer(player);
AttachPlaybackFallback(media, player, resolution, category, source);
if (category.Layout.AutoPlay)
{
player.Play();
}
return new StackPanel
{
Spacing = 16,
HorizontalAlignment = HorizontalAlignment.Stretch,
Children =
{
new Border
{
Padding = new Thickness(18),
CornerRadius = new CornerRadius(8),
Background = ModernUi.SurfaceAlt,
Child = new StackPanel
{
Spacing = 12,
HorizontalAlignment = HorizontalAlignment.Center,
Children =
{
ModernUi.IconTile("\uE8D6", 72, ModernUi.AccentSoft, ModernUi.Accent, 30),
ModernUi.Text(DisplaySourceName(source), 18, FontWeights.SemiBold, maxLines: 1),
ModernUi.Text(AppLocalizer.T("音频已加载,使用下方控件播放、暂停和定位。", "Audio is loaded. Use the controls below to play, pause, and seek."), 13, foreground: ModernUi.TextSecondary, maxLines: 2)
}
}
},
WrapPlayableMedia(media, category, source)
}
};
return MediaStage(WrapPlayableMedia(media, category, source));
}
private static MediaPlayer CreateRemoteMediaPlayer(RemoteMediaResolution resolution)
{
return new MediaPlayer
{
Source = MediaSource.CreateFromUri(resolution.Uri),
Volume = 0.7
Source = MediaSource.CreateFromUri(resolution.Uri)
};
}
private static void EnsureExpectedMedia(RemoteMediaResolution resolution, RemoteMediaKind expectedKind)
{
if (!resolution.IsDirectMedia || !ResolutionMatchesExpectedKind(resolution, expectedKind))
{
throw new InvalidOperationException(expectedKind == RemoteMediaKind.Image
? AppLocalizer.T("远程图片源没有返回可识别的图片地址,请稍后重试或换一个图片源。", "The remote image source did not return a usable image.")
: AppLocalizer.T("远程视频源没有返回可播放的视频地址,请稍后重试或换一个视频源。", "The remote video source did not return a playable video."));
}
}
private static bool ResolutionMatchesExpectedKind(RemoteMediaResolution resolution, RemoteMediaKind expectedKind)
{
var contentType = (resolution.ContentType ?? string.Empty).Trim().ToLowerInvariant();
if (expectedKind == RemoteMediaKind.Image)
{
return contentType.StartsWith("image/", StringComparison.Ordinal) || IsImageFormat(resolution.SuggestedExtension);
}
if (expectedKind == RemoteMediaKind.Video)
{
return contentType.StartsWith("video/", StringComparison.Ordinal) || IsVideoFormat(resolution.SuggestedExtension);
}
return true;
}
private UIElement MediaStage(UIElement child)
{
var frame = new Viewbox
{
Stretch = Stretch.Uniform,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
Child = new Border
{
Width = 960,
Height = 540,
CornerRadius = new CornerRadius(8),
Background = ModernUi.Brush("#FF0B0B0B"),
Clip = new Microsoft.UI.Xaml.Media.RectangleGeometry
{
Rect = new Windows.Foundation.Rect(0, 0, 960, 540)
},
Child = child
}
};
return new Grid
{
MinHeight = 320,
HorizontalAlignment = HorizontalAlignment.Stretch,
VerticalAlignment = VerticalAlignment.Stretch,
Background = ModernUi.SurfaceAlt,
Children = { frame }
};
}
private UIElement BuildVolumeControl()
{
_volumeText = ModernUi.Text(string.Empty, 12, FontWeights.SemiBold, ModernUi.TextSecondary, maxLines: 1);
_volumeSlider = new Slider
{
Minimum = MediaVolumeModel.MinPercent,
Maximum = MediaVolumeModel.NormalMaxPercent,
Width = 130,
Value = Math.Clamp(_settingsService.Current.RandomCinemaVolumePercent, MediaVolumeModel.MinPercent, MediaVolumeModel.NormalMaxPercent),
VerticalAlignment = VerticalAlignment.Center
};
_volumeSlider.ValueChanged += async (_, _) =>
{
ApplyVolumeToPlayer();
var percent = _volumeSlider.Value;
await _settingsService.UpdateAsync(settings => settings.RandomCinemaVolumePercent = percent);
};
ApplyVolumeToPlayer();
return new StackPanel
{
Orientation = Orientation.Horizontal,
Spacing = 8,
VerticalAlignment = VerticalAlignment.Center,
Children =
{
new FontIcon { Glyph = "\uE767", FontSize = 15, Foreground = ModernUi.TextSecondary },
_volumeSlider,
_volumeText
}
};
}
private void ApplyVolumeToPlayer()
{
var value = _volumeSlider?.Value ?? _settingsService.Current.RandomCinemaVolumePercent;
var state = MediaVolumeModel.FromPercent(Math.Clamp(value, MediaVolumeModel.MinPercent, MediaVolumeModel.NormalMaxPercent));
if (_currentPlayer is not null)
{
_currentPlayer.Volume = state.PlatformVolume;
}
if (_volumeText is not null)
{
_volumeText.Text = MediaVolumeModel.FormatPercent(state.Percent, AppLocalizer.CurrentLanguage);
}
}
private Grid WrapPlayableMedia(MediaPlayerElement media, RemoteMediaCategory category, RemoteMediaSource source)
{
var status = PlaybackStatusOverlay();
@@ -798,7 +948,7 @@ public class RandomCinemaPage : ToolPageBase
_currentMediaBytes = bytes;
var file = await StorageFile.GetFileFromPathAsync(path);
player.Source = MediaSource.CreateFromStorageFile(file);
player.Volume = 0.7;
ApplyVolumeToPlayer();
SetPlaybackStatus(overlays, string.Empty, false);
if (category.Layout.AutoPlay)
{
@@ -934,9 +1084,42 @@ public class RandomCinemaPage : ToolPageBase
_inlineFullscreen = false;
}
private bool EnterWindowFullscreen()
{
_appWindow ??= GetCurrentAppWindow();
if (_appWindow is null)
{
return false;
}
_appWindow.SetPresenter(AppWindowPresenterKind.FullScreen);
_windowFullscreen = true;
return true;
}
private void ExitWindowFullscreen()
{
if (!_windowFullscreen)
{
return;
}
_appWindow ??= GetCurrentAppWindow();
_appWindow?.SetPresenter(AppWindowPresenterKind.Default);
_windowFullscreen = false;
}
private async Task ShowFullscreenAsync()
{
EnterInlineFullscreen();
if (EnterWindowFullscreen())
{
ToastService.Show(AppLocalizer.T("已进入全屏,按 Esc 或点击窗口控件可退出。", "Fullscreen enabled."), ToastKind.Info, TimeSpan.FromSeconds(2));
}
else
{
EnterInlineFullscreen();
}
await Task.CompletedTask;
}
@@ -1023,25 +1206,115 @@ public class RandomCinemaPage : ToolPageBase
}
}
private void DisposeCurrentMedia()
{
ExitWindowFullscreen();
ExitInlineFullscreen();
DisposeCurrentImageStream();
try
{
_currentPlayer?.Dispose();
}
catch (Exception exception)
{
CrashLog.Write(exception);
}
finally
{
_currentPlayer = null;
}
}
private sealed record BitmapStreamResult(BitmapImage Image, InMemoryRandomAccessStream Stream);
private static bool IsSupportedCategory(RemoteMediaCategory category)
{
return PrimaryKind(category) is RemoteMediaKind.Image or RemoteMediaKind.Video ||
DisplaySources(category).Count > 0;
}
private static IReadOnlyList<RemoteMediaSource> DisplaySources(RemoteMediaCategory category)
{
return category.Subcategories
.Where(source => MediaKind(category, source) is RemoteMediaKind.Image or RemoteMediaKind.Video)
.ToArray();
}
private static RemoteMediaKind PrimaryKind(RemoteMediaCategory category)
{
if (category.Kind is RemoteMediaKind.Image or RemoteMediaKind.Video)
{
return category.Kind;
}
if (category.Id.Contains("video", StringComparison.OrdinalIgnoreCase) ||
category.Id.Contains("movie", StringComparison.OrdinalIgnoreCase))
{
return RemoteMediaKind.Video;
}
if (category.Id.Contains("image", StringComparison.OrdinalIgnoreCase) ||
category.Id.Contains("img", StringComparison.OrdinalIgnoreCase) ||
category.Id.Contains("pic", StringComparison.OrdinalIgnoreCase))
{
return RemoteMediaKind.Image;
}
var sourceKinds = category.Subcategories
.Select(source => MediaKind(category, source))
.Where(kind => kind is RemoteMediaKind.Image or RemoteMediaKind.Video)
.Distinct()
.ToArray();
return sourceKinds.Length == 1 ? sourceKinds[0] : RemoteMediaKind.Unknown;
}
private static RemoteMediaKind MediaKind(RemoteMediaCategory category, RemoteMediaSource source)
{
if (source.Kind is RemoteMediaKind.Image or RemoteMediaKind.Video)
{
return source.Kind;
}
if (category.Kind is RemoteMediaKind.Image or RemoteMediaKind.Video)
{
return category.Kind;
}
if (!string.IsNullOrWhiteSpace(source.MediaType))
{
var mediaType = source.MediaType.Trim().ToLowerInvariant();
if (mediaType.Contains("video", StringComparison.Ordinal) || mediaType.Contains("mp4", StringComparison.Ordinal))
{
return RemoteMediaKind.Video;
}
if (mediaType.Contains("image", StringComparison.Ordinal) || mediaType.Contains("img", StringComparison.Ordinal))
{
return RemoteMediaKind.Image;
}
}
if (source.SupportedFormats.Any(IsVideoFormat))
{
return RemoteMediaKind.Video;
}
if (source.SupportedFormats.Any(IsImageFormat))
{
return RemoteMediaKind.Image;
}
return RemoteMediaKind.Unknown;
}
private static bool IsVideoCategory(RemoteMediaCategory category)
{
return category.Kind == RemoteMediaKind.Video ||
category.Id.Contains("video", StringComparison.OrdinalIgnoreCase) ||
category.Subcategories.SelectMany(source => source.SupportedFormats).Any(IsVideoFormat);
}
private static bool IsAudioCategory(RemoteMediaCategory category)
{
return category.Kind == RemoteMediaKind.Audio ||
category.Id.Contains("audio", StringComparison.OrdinalIgnoreCase) ||
category.Subcategories.SelectMany(source => source.SupportedFormats).Any(IsAudioFormat);
return PrimaryKind(category) == RemoteMediaKind.Video;
}
private static bool IsImageCategory(RemoteMediaCategory category)
{
return !IsVideoCategory(category) && !IsAudioCategory(category);
return PrimaryKind(category) == RemoteMediaKind.Image;
}
private static bool IsVideoFormat(string format)
@@ -1050,24 +1323,34 @@ public class RandomCinemaPage : ToolPageBase
return ext is "mp4" or "mkv" or "webm" or "avi" or "mov" or "wmv" or "m4v";
}
private static bool IsAudioFormat(string format)
private static bool IsImageFormat(string format)
{
var ext = format.Trim().TrimStart('.').ToLowerInvariant();
return ext is "mp3" or "wav" or "flac" or "aac" or "m4a" or "ogg" or "wma";
return ext is "jpg" or "jpeg" or "png" or "webp" or "gif" or "bmp" or "tif" or "tiff";
}
private static string DisplayCategoryName(RemoteMediaCategory category)
{
if (!string.IsNullOrWhiteSpace(category.DisplayName))
if (IsVideoCategory(category))
{
return AppLocalizer.T("随机视频", "Random Videos");
}
if (IsImageCategory(category))
{
return AppLocalizer.T("随机图片", "Random Images");
}
if (!string.IsNullOrWhiteSpace(category.DisplayName) &&
!string.Equals(category.DisplayName, "image", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(category.DisplayName, "video", StringComparison.OrdinalIgnoreCase))
{
return category.DisplayName;
}
return IsVideoCategory(category)
? AppLocalizer.T("随机视频", "Random Videos")
: IsAudioCategory(category)
? AppLocalizer.T("随机音频", "Random Audio")
: AppLocalizer.T("随机图片", "Random Images");
: AppLocalizer.T("随机图片", "Random Images");
}
private static string DisplaySourceName(RemoteMediaSource source)
@@ -1126,7 +1409,22 @@ public class RandomCinemaPage : ToolPageBase
private static string FriendlyError(string message)
{
return AppLocalizer.SanitizeSensitiveText(message, 120);
var normalized = message ?? string.Empty;
if (normalized.Contains("SSL", StringComparison.OrdinalIgnoreCase) ||
normalized.Contains("certificate", StringComparison.OrdinalIgnoreCase) ||
normalized.Contains("connection could not be established", StringComparison.OrdinalIgnoreCase))
{
return AppLocalizer.T("远程源连接失败,可能是证书、网络或源站临时不可用。请稍后重试或换一个媒体源。", "The remote source is unavailable. Try again later or choose another source.");
}
if (normalized.Contains("unsupported", StringComparison.OrdinalIgnoreCase) ||
normalized.Contains("file path", StringComparison.OrdinalIgnoreCase) ||
normalized.Contains("invalid", StringComparison.OrdinalIgnoreCase))
{
return AppLocalizer.T("远程源返回的内容不是当前类型可用的媒体文件,请换一个媒体源。", "The remote source did not return a usable media file.");
}
return AppLocalizer.SanitizeSensitiveText(normalized, 120);
}
private static T WithColumn<T>(T element, int column) where T : FrameworkElement
@@ -1134,4 +1432,22 @@ public class RandomCinemaPage : ToolPageBase
Grid.SetColumn(element, column);
return element;
}
private static T WithRow<T>(T element, int row) where T : FrameworkElement
{
Grid.SetRow(element, row);
return element;
}
private static AppWindow? GetCurrentAppWindow()
{
var window = App.CurrentWindow;
if (window is null)
{
return null;
}
var windowId = Win32Interop.GetWindowIdFromWindow(WindowNative.GetWindowHandle(window));
return AppWindow.GetFromWindowId(windowId);
}
}
+96
View File
@@ -0,0 +1,96 @@
{
"api_keys": {
"uapipro": ""
},
"app_version": "2.0.7.5",
"build": "05",
"channel": "stable",
"title": "YMhut Box 2.0.7.5",
"message": "本版本优化随机放映室:移除顶部说明卡片,恢复接近旧版的类型、来源、预览三段式布局,修复图片源误用视频播放器的问题,并补齐图片/视频自适应预览、全屏和视频音量控制。",
"message_md": "# YMhut Box 2.0.7.5\n\n本版本专门打磨随机放映室体验:入口更轻,图片和视频类型更明确,远程媒体预览不会撑出容器,旧版媒体配置和新版统一服务继续兼容。",
"release_notes": "随机放映室移除顶部说明卡片,改为更接近旧版的媒体类型、来源卡片、媒体预览三段式布局;修复图片源被视频播放器打开导致无法显示的问题;图片和视频预览会自适应容器大小,支持全屏、刷新、保存和视频音量调节;修复随机图片、随机视频远程配置中的中文乱码;继续优先读取新版统一服务,并保留旧版 media-types.json 回退兼容。",
"release_notes_md": "## 随机放映室\n\n- 移除顶部“随机放映室”说明卡片,首页直接展示随机图片和随机视频入口。\n- 恢复接近旧版的三段式体验:媒体类型、来源卡片、媒体预览。\n- 修复图片源误用视频播放器导致无法打开的问题,图片和视频现在按显式类型渲染。\n- 预览舞台使用固定比例自适应,图片和视频不会超出容器。\n- 视频保留播放、暂停、进度、全屏和音量控制,音量会记住本工具的上次设置。\n\n## 兼容与配置\n\n- 修复随机图片、随机视频远程配置中的中文乱码。\n- 媒体配置新增非破坏性类型字段,旧版 categories/subcategories/api_url/supported_formats 结构保持不变。\n- 客户端继续优先读取新版统一服务,失败后回退旧版 media-types.json。",
"category_list": [
{
"id": "random_cinema",
"name": "随机放映室",
"icon": "media"
},
{
"id": "compatibility",
"name": "兼容修复",
"icon": "shield"
},
{
"id": "experience",
"name": "体验优化",
"icon": "monitor"
}
],
"detected_product": "YMhut Box",
"detected_packages": {
"YMhut Box": [
{
"version": "2.0.7.5",
"extension": "exe",
"fileName": "YMhut_Box_WinUI_Setup_2.0.7.5.exe",
"downloadPath": "/downloads/YMhut_Box_WinUI_Setup_2.0.7.5.exe",
"size": "108.2 MB",
"sizeBytes": 113480968,
"updateDate": "2026-06-26",
"updateTime": "2026-06-26 10:00:33"
},
{
"version": "2.0.7.5",
"extension": "msix",
"fileName": "YMhutBox_2.0.7.5_x64.msix",
"downloadPath": "/downloads/YMhutBox_2.0.7.5_x64.msix",
"size": "247.9 MB",
"sizeBytes": 259959751,
"updateDate": "2026-06-26",
"updateTime": "2026-06-26 10:00:33"
}
]
},
"download_mirrors": [
{
"enabled": true,
"id": "fullInstaller",
"name": "完整离线安装包",
"sha256": "3852e0f6ef98bda862b06de61788deaa836c50fcfcf9703a903bbcfbdd09ce4b",
"type": "direct",
"url": "https://update.ymhut.cn/downloads/YMhut_Box_WinUI_Setup_2.0.7.5.exe"
},
{
"enabled": true,
"id": "msix",
"name": "MSIX 安装包",
"sha256": "c1130c0cc5381854681d0879f168cf31bb346b589bc916a458552763b1fa47db",
"type": "direct",
"url": "https://update.ymhut.cn/downloads/YMhutBox_2.0.7.5_x64.msix"
},
{
"enabled": true,
"id": "appInstaller",
"name": "App Installer",
"sha256": "12897720203ed1b41f418f6daf097c8f2e21d0b2c609d54d561219b2f353f17e",
"type": "direct",
"url": "https://update.ymhut.cn/downloads/winui.appinstaller"
}
],
"download_url": "https://update.ymhut.cn/downloads/YMhut_Box_WinUI_Setup_2.0.7.5.exe",
"home_notes": "v2.0.7.5 优化随机放映室:更轻的入口、三段式来源浏览、图片/视频独立渲染、自适应预览、全屏和视频音量控制。",
"last_update_notes": {
"v2.0.6.3 工具体验": "上一版本优化远程工具请求、QQ 信息和安全浏览器;本版本聚焦随机放映室的媒体浏览体验。",
"v2.0.6.2 媒体能力": "上一轮补齐随机放映室远程媒体加载;本版本修复图片/视频类型识别并改善预览布局。"
},
"last_updated": "2026-06-26T10:00:33Z",
"tool_metadata": {},
"update_notes": {
"随机放映室布局": "移除顶部说明卡片,按媒体类型、来源卡片、媒体预览三段式组织。",
"图片视频渲染": "图片源使用图片查看器,视频源使用播放器,避免图片被视频组件打开。",
"预览舞台": "图片和视频按固定比例自适应容器,支持全屏、刷新和另存。",
"视频音量": "视频预览页新增音量滑块,并记住本工具上次音量。",
"远程配置": "修复随机图片、随机视频配置中文乱码,并保留旧版字段兼容。"
}
}
+34 -12
View File
@@ -1,22 +1,44 @@
{
"schema_version": 1,
"product": "YMhut Box",
"latest_version": "2.0.6.3",
"latest_notice_file": "2.0.6.3.json",
"last_updated": "2026-06-23T00:00:00Z",
"latest_version": "2.0.7.5",
"latest_notice_file": "2.0.7.5.json",
"last_updated": "2026-06-26T10:00:33Z",
"latest": {
"version": "2.0.6.3",
"build": "3",
"version": "2.0.7.5",
"build": "05",
"channel": "stable",
"title": "YMhut Box 2.0.6.3",
"message": "QQ 信息查询升级、远程工具请求友好化、榜单资讯外链进入安全浏览器、工具箱宽屏布局和窗口拖动体验优化。",
"release_date": "2026-06-23",
"download_url": "https://update.ymhut.cn/downloads/YMhut_Box_WinUI_Setup_2.0.6.3.exe",
"release_notes": "本版本升级 QQ 信息查询,适配新版接口返回的头像、昵称、个性签名、等级、会员状态和时间字段;敏感请求地址继续隐藏,头像等公开响应链接可展示。远程工具请求使用更长策略并支持一次短重试,超时和网络失败显示友好状态。榜单、资讯和公开链接默认通过应用内安全浏览器打开。宽屏工具箱减少右侧详情预览占位,窗口拖动期间启用轻量模式,降低 WebView 动效和布局重算。",
"message_md": "# YMhut Box 2.0.6.3\n\n本版本继续打磨工具工作台:QQ 信息查询升级到新版资料源,远程工具请求更稳,榜单和资讯外链默认进入应用内安全浏览器,宽屏工具箱展示更多工具卡片,窗口拖动过程减少动画和重排。",
"release_notes_md": "## 工具增强\n\n- QQ 信息查询按新版接口文档重做字段映射,支持头像、昵称、个性签名、QQ 等级、邮箱、VIP、SVIP、QQ 大会员、注册时间和最后更新时间。\n- QQ 信息查询的请求地址继续作为敏感信息隐藏;接口返回的头像等公开链接会作为图片结果展示。\n- 今日热榜、B 站热榜、知乎热榜、电影票房和资讯类结果保留公开外链,默认通过应用内安全浏览器打开。\n\n## 稳定性与体验\n\n- 远程工具请求使用更长的工具请求策略,GET 类请求支持一次短退避重试。\n- 超时、取消和网络失败会显示友好状态卡,不再把 `HttpClient.Timeout` 原始英文异常直接暴露给用户。\n- 宽屏/最大化工具箱减少右侧详情预览占位,优先展示更多工具卡片。\n- 窗口拖动时进入轻量模式,降低 WebView 动效、悬浮刷新和不必要布局重算,拖动更顺。"
"title": "YMhut Box 2.0.7.5",
"message": "随机放映室布局、图片/视频渲染、自适应预览、全屏和视频音量控制优化。",
"release_date": "2026-06-26",
"download_url": "https://update.ymhut.cn/downloads/YMhut_Box_WinUI_Setup_2.0.7.5.exe",
"release_notes": "随机放映室移除顶部说明卡片,改为更接近旧版的媒体类型、来源卡片、媒体预览三段式布局;修复图片源被视频播放器打开导致无法显示的问题;图片和视频预览会自适应容器大小,支持全屏、刷新、保存和视频音量调节;修复随机图片、随机视频远程配置中的中文乱码;继续优先读取新版统一服务,并保留旧版 media-types.json 回退兼容。",
"message_md": "# YMhut Box 2.0.7.5\n\n本版本专门打磨随机放映室体验:入口更轻,图片和视频类型更明确,远程媒体预览不会撑出容器,旧版媒体配置和新版统一服务继续兼容。",
"release_notes_md": "## 随机放映室\n\n- 移除顶部“随机放映室”说明卡片,首页直接展示随机图片和随机视频入口。\n- 恢复接近旧版的三段式体验:媒体类型、来源卡片、媒体预览。\n- 修复图片源误用视频播放器导致无法打开的问题,图片和视频现在按显式类型渲染。\n- 预览舞台使用固定比例自适应,图片和视频不会超出容器。\n- 视频保留播放、暂停、进度、全屏和音量控制,音量会记住本工具的上次设置。\n\n## 兼容与配置\n\n- 修复随机图片、随机视频远程配置中的中文乱码。\n- 媒体配置新增非破坏性类型字段,旧版 categories/subcategories/api_url/supported_formats 结构保持不变。\n- 客户端继续优先读取新版统一服务,失败后回退旧版 media-types.json。"
},
"versions": [
{
"version": "2.0.7.5",
"channel": "stable",
"release_date": "2026-06-26",
"notice_file": "2.0.7.5.json",
"summary": "随机放映室布局、图片/视频渲染、自适应预览、全屏和视频音量控制优化。",
"highlights": [
"随机放映室移除顶部说明卡片,首页直接展示随机图片和随机视频入口。",
"恢复接近旧版的媒体类型、来源卡片、媒体预览三段式布局。",
"修复图片源误用视频播放器导致无法打开的问题。",
"图片和视频预览自适应固定比例舞台,不再超出预览框。",
"视频保留播放、暂停、进度和全屏,并新增页面音量控制。",
"修复随机图片、随机视频远程配置中的中文乱码。",
"继续优先读取新版统一服务,失败后回退旧版 media-types.json。"
],
"categories": {
"random_cinema": "随机放映室改为类型、来源、预览三段式体验。",
"media_rendering": "图片和视频按显式类型选择对应查看器。",
"preview": "预览舞台自适应容器并支持全屏、刷新、保存和视频音量。",
"compatibility": "新版统一服务和旧版 media-types.json 继续兼容。"
}
},
{
"version": "2.0.6.3",
"channel": "stable",