@@ -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"`
|
||||
|
||||
@@ -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, "&", "&")
|
||||
value = strings.ReplaceAll(value, "<", "<")
|
||||
value = strings.ReplaceAll(value, ">", ">")
|
||||
value = strings.ReplaceAll(value, `"`, """)
|
||||
return strings.ReplaceAll(value, "'", "'")
|
||||
}
|
||||
|
||||
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"),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user