Compare commits
2 Commits
2513eb2903
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f00124c1c0 | |||
| 962a2f2143 |
@@ -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"`
|
MaxRequestBytes int64 `json:"max_request_bytes"`
|
||||||
MaxPackageBytes int64 `json:"max_package_bytes"`
|
MaxPackageBytes int64 `json:"max_package_bytes"`
|
||||||
Database DatabaseConfig `json:"database"`
|
Database DatabaseConfig `json:"database"`
|
||||||
|
Mail MailConfig `json:"mail"`
|
||||||
|
Branding BrandingConfig `json:"branding"`
|
||||||
UploadGuard UploadGuardConfig `json:"upload_guard"`
|
UploadGuard UploadGuardConfig `json:"upload_guard"`
|
||||||
SourceCheckSeconds int `json:"source_check_seconds"`
|
SourceCheckSeconds int `json:"source_check_seconds"`
|
||||||
}
|
}
|
||||||
@@ -44,6 +46,11 @@ type DatabaseConfig struct {
|
|||||||
Provider string `json:"provider"`
|
Provider string `json:"provider"`
|
||||||
SQLitePath string `json:"sqlite_path"`
|
SQLitePath string `json:"sqlite_path"`
|
||||||
MySQLDSN string `json:"mysql_dsn"`
|
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"`
|
FailoverEnabled bool `json:"failover_enabled"`
|
||||||
HotSyncEnabled bool `json:"hot_sync_enabled"`
|
HotSyncEnabled bool `json:"hot_sync_enabled"`
|
||||||
HealthIntervalSec int `json:"health_interval_sec"`
|
HealthIntervalSec int `json:"health_interval_sec"`
|
||||||
@@ -52,6 +59,25 @@ type DatabaseConfig struct {
|
|||||||
ConnMaxLifetimeSeconds int `json:"conn_max_lifetime_seconds"`
|
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 {
|
type UploadGuardConfig struct {
|
||||||
MaxZipFiles int `json:"max_zip_files"`
|
MaxZipFiles int `json:"max_zip_files"`
|
||||||
MaxDecompressedBytes int64 `json:"max_decompressed_bytes"`
|
MaxDecompressedBytes int64 `json:"max_decompressed_bytes"`
|
||||||
@@ -120,6 +146,8 @@ func defaults(root string) *Config {
|
|||||||
Database: DatabaseConfig{
|
Database: DatabaseConfig{
|
||||||
Provider: "sqlite",
|
Provider: "sqlite",
|
||||||
SQLitePath: filepath.Join(root, "storage", "unified.sqlite"),
|
SQLitePath: filepath.Join(root, "storage", "unified.sqlite"),
|
||||||
|
MySQLHost: "127.0.0.1",
|
||||||
|
MySQLPort: 3306,
|
||||||
FailoverEnabled: true,
|
FailoverEnabled: true,
|
||||||
HotSyncEnabled: true,
|
HotSyncEnabled: true,
|
||||||
HealthIntervalSec: 30,
|
HealthIntervalSec: 30,
|
||||||
@@ -127,6 +155,19 @@ func defaults(root string) *Config {
|
|||||||
MaxIdleConns: 4,
|
MaxIdleConns: 4,
|
||||||
ConnMaxLifetimeSeconds: 300,
|
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{
|
UploadGuard: UploadGuardConfig{
|
||||||
MaxZipFiles: 80,
|
MaxZipFiles: 80,
|
||||||
MaxDecompressedBytes: 30 * 1024 * 1024,
|
MaxDecompressedBytes: 30 * 1024 * 1024,
|
||||||
@@ -184,6 +225,66 @@ func applyEnv(cfg *Config) {
|
|||||||
if value := os.Getenv("YMHUT_MYSQL_DSN"); value != "" {
|
if value := os.Getenv("YMHUT_MYSQL_DSN"); value != "" {
|
||||||
cfg.Database.MySQLDSN = 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 != "" {
|
if value := os.Getenv("YMHUT_CLIENT_SIGNATURE_KEY"); value != "" {
|
||||||
cfg.ClientSignatureKey = value
|
cfg.ClientSignatureKey = value
|
||||||
}
|
}
|
||||||
@@ -267,10 +368,30 @@ func normalize(root string, cfg *Config) {
|
|||||||
if cfg.Database.Provider == "" {
|
if cfg.Database.Provider == "" {
|
||||||
cfg.Database.Provider = "sqlite"
|
cfg.Database.Provider = "sqlite"
|
||||||
}
|
}
|
||||||
|
cfg.Database.Provider = strings.ToLower(strings.TrimSpace(cfg.Database.Provider))
|
||||||
if cfg.Database.SQLitePath == "" {
|
if cfg.Database.SQLitePath == "" {
|
||||||
cfg.Database.SQLitePath = filepath.Join(cfg.StorageDir, "unified.sqlite")
|
cfg.Database.SQLitePath = filepath.Join(cfg.StorageDir, "unified.sqlite")
|
||||||
}
|
}
|
||||||
cfg.Database.SQLitePath = absPath(cfg.BaseDir, cfg.Database.SQLitePath)
|
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 {
|
if cfg.Database.HealthIntervalSec <= 0 {
|
||||||
cfg.Database.HealthIntervalSec = 30
|
cfg.Database.HealthIntervalSec = 30
|
||||||
}
|
}
|
||||||
@@ -316,6 +437,37 @@ func normalize(root string, cfg *Config) {
|
|||||||
if cfg.SourceCheckSeconds <= 0 {
|
if cfg.SourceCheckSeconds <= 0 {
|
||||||
cfg.SourceCheckSeconds = 300
|
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) {
|
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 (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"time"
|
"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) {
|
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
|
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)
|
ORDER BY h.checked_at DESC, h.id DESC LIMIT ?`, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -49,13 +50,19 @@ func (s *Store) RecentSourceChecks(limit int) ([]map[string]any, error) {
|
|||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
items := []map[string]any{}
|
items := []map[string]any{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var id int64
|
var id, sourceDBID int64
|
||||||
var sourceID, name, status, message, checkedAt string
|
var sourceID, name, status, message, checkedAt string
|
||||||
var latency int
|
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
|
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()
|
return items, rows.Err()
|
||||||
}
|
}
|
||||||
@@ -100,6 +107,59 @@ func (s *Store) ListAuditLogs(limit int) ([]AuditLog, error) {
|
|||||||
return scanAuditRows(rows)
|
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) {
|
func (s *Store) ListAuditLogsForTarget(target string, limit int) ([]AuditLog, error) {
|
||||||
if limit <= 0 || limit > 200 {
|
if limit <= 0 || limit > 200 {
|
||||||
limit = 100
|
limit = 100
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ func (s *Store) CopyRemoteToSQLite() (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) ImportSQLiteToRemote() (SyncResult, 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()
|
s.mu.RLock()
|
||||||
remote := s.remoteDB
|
remote := s.remoteDB
|
||||||
remoteDialect := s.remoteDialect
|
remoteDialect := s.remoteDialect
|
||||||
@@ -28,9 +32,9 @@ func (s *Store) ImportSQLiteToRemote() (SyncResult, error) {
|
|||||||
localDialect := s.localDialect
|
localDialect := s.localDialect
|
||||||
s.mu.RUnlock()
|
s.mu.RUnlock()
|
||||||
if remote == nil {
|
if remote == nil {
|
||||||
err := errors.New("remote database is not configured")
|
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(SyncResult{Direction: "sqlite_to_remote", Tables: map[string]int{}, FinishedAt: Now()}, err)
|
s.setSyncStatus(result, nil)
|
||||||
return SyncResult{}, err
|
return result, nil
|
||||||
}
|
}
|
||||||
result, err := copyAllTables(local, localDialect, remote, remoteDialect, "sqlite_to_remote")
|
result, err := copyAllTables(local, localDialect, remote, remoteDialect, "sqlite_to_remote")
|
||||||
s.setSyncStatus(result, err)
|
s.setSyncStatus(result, err)
|
||||||
@@ -38,6 +42,10 @@ func (s *Store) ImportSQLiteToRemote() (SyncResult, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) SyncNow() (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()
|
s.mu.RLock()
|
||||||
remote := s.remoteDB
|
remote := s.remoteDB
|
||||||
remoteDialect := s.remoteDialect
|
remoteDialect := s.remoteDialect
|
||||||
@@ -45,7 +53,7 @@ func (s *Store) SyncNow() (SyncResult, error) {
|
|||||||
localDialect := s.localDialect
|
localDialect := s.localDialect
|
||||||
s.mu.RUnlock()
|
s.mu.RUnlock()
|
||||||
if remote == nil {
|
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)
|
s.setSyncStatus(result, nil)
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
@@ -66,6 +74,10 @@ func (s *Store) setSyncStatus(result SyncResult, err error) {
|
|||||||
s.status.LastSyncError = ""
|
s.status.LastSyncError = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) trySyncLock() bool {
|
||||||
|
return s.syncMu.TryLock()
|
||||||
|
}
|
||||||
|
|
||||||
type tableSpec struct {
|
type tableSpec struct {
|
||||||
Name string
|
Name string
|
||||||
Columns []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"}},
|
{"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_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"}},
|
{"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"}},
|
{"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"}},
|
{"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"}},
|
{"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) {
|
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 {
|
for _, table := range syncTables {
|
||||||
count, err := copyTable(src, srcDialect, dst, dstDialect, table)
|
count, err := copyTable(src, srcDialect, dst, dstDialect, table)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
result.Status = "failed"
|
||||||
|
result.FinishedAt = Now()
|
||||||
return result, err
|
return result, err
|
||||||
}
|
}
|
||||||
result.Tables[table.Name] = count
|
result.Tables[table.Name] = count
|
||||||
}
|
}
|
||||||
|
result.FinishedAt = Now()
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func copyTable(src *sql.DB, srcDialect dialect, dst *sql.DB, dstDialect dialect, spec tableSpec) (int, error) {
|
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 {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,46 @@ func (d dialect) idType() string {
|
|||||||
return "INTEGER PRIMARY KEY AUTOINCREMENT"
|
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 {
|
func (d dialect) boolExpr(value bool) int {
|
||||||
if value {
|
if value {
|
||||||
return 1
|
return 1
|
||||||
@@ -54,7 +94,7 @@ func (d dialect) upsert(table string, columns, conflict []string) string {
|
|||||||
for i := range placeholders {
|
for i := range placeholders {
|
||||||
placeholders[i] = "?"
|
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{}
|
conflictSet := map[string]bool{}
|
||||||
for _, column := range conflict {
|
for _, column := range conflict {
|
||||||
conflictSet[column] = true
|
conflictSet[column] = true
|
||||||
@@ -64,10 +104,11 @@ func (d dialect) upsert(table string, columns, conflict []string) string {
|
|||||||
if conflictSet[column] {
|
if conflictSet[column] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
quoted := d.quoteIdent(column)
|
||||||
if d.name == "mysql" {
|
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 {
|
} 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 {
|
if len(updates) == 0 {
|
||||||
@@ -79,7 +120,7 @@ func (d dialect) upsert(table string, columns, conflict []string) string {
|
|||||||
if d.name == "mysql" {
|
if d.name == "mysql" {
|
||||||
return base + " ON DUPLICATE KEY UPDATE " + strings.Join(updates, ", ")
|
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 {
|
func (d dialect) limitOffset(limit, offset int) string {
|
||||||
|
|||||||
@@ -304,6 +304,36 @@ func (s *Store) UpsertMailRecord(item LegacyMailRecord) error {
|
|||||||
return err
|
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) {
|
func (s *Store) ListFeedbackEvents(code string, limit int) ([]LegacyFeedbackEvent, error) {
|
||||||
if limit <= 0 || limit > 200 {
|
if limit <= 0 || limit > 200 {
|
||||||
limit = 100
|
limit = 100
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ type adminRow struct {
|
|||||||
type DatabaseStatus struct {
|
type DatabaseStatus struct {
|
||||||
ActiveProvider string `json:"activeProvider"`
|
ActiveProvider string `json:"activeProvider"`
|
||||||
ConfigProvider string `json:"configProvider"`
|
ConfigProvider string `json:"configProvider"`
|
||||||
|
SchemaVersion string `json:"schemaVersion"`
|
||||||
SQLiteReady bool `json:"sqliteReady"`
|
SQLiteReady bool `json:"sqliteReady"`
|
||||||
RemoteReady bool `json:"remoteReady"`
|
RemoteReady bool `json:"remoteReady"`
|
||||||
FailoverActive bool `json:"failoverActive"`
|
FailoverActive bool `json:"failoverActive"`
|
||||||
@@ -34,6 +35,9 @@ type DatabaseStatus struct {
|
|||||||
|
|
||||||
type SyncResult struct {
|
type SyncResult struct {
|
||||||
Direction string `json:"direction"`
|
Direction string `json:"direction"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Skipped bool `json:"skipped"`
|
||||||
|
Warnings []string `json:"warnings,omitempty"`
|
||||||
Tables map[string]int `json:"tables"`
|
Tables map[string]int `json:"tables"`
|
||||||
FinishedAt string `json:"finishedAt"`
|
FinishedAt string `json:"finishedAt"`
|
||||||
}
|
}
|
||||||
@@ -121,6 +125,8 @@ type LegacyMailRecord struct {
|
|||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
ToAddress string `json:"toAddress"`
|
ToAddress string `json:"toAddress"`
|
||||||
Subject string `json:"subject"`
|
Subject string `json:"subject"`
|
||||||
|
PlainBody string `json:"plainBody,omitempty"`
|
||||||
|
HTMLBody string `json:"htmlBody,omitempty"`
|
||||||
AttachmentPath string `json:"attachmentPath"`
|
AttachmentPath string `json:"attachmentPath"`
|
||||||
AttachmentName string `json:"attachmentName"`
|
AttachmentName string `json:"attachmentName"`
|
||||||
ErrorMessage string `json:"errorMessage"`
|
ErrorMessage string `json:"errorMessage"`
|
||||||
@@ -272,6 +278,21 @@ type AuditLog struct {
|
|||||||
CreatedAt string `json:"createdAt"`
|
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 {
|
type LegacyJsonRevision struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
|||||||
@@ -22,269 +22,329 @@ func (s *Store) migrate(conn *sql.DB, d dialect) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if err := createSchemaIndexes(conn, d); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return s.recordSchemaVersion(conn, d)
|
return s.recordSchemaVersion(conn, d)
|
||||||
}
|
}
|
||||||
|
|
||||||
func schemaStatements(d dialect) []string {
|
func schemaStatements(d dialect) []string {
|
||||||
|
keyText := d.keyTextType()
|
||||||
|
shortText := d.shortTextType()
|
||||||
|
mediumText := d.mediumTextType()
|
||||||
|
longText := d.longTextType()
|
||||||
return []string{
|
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,
|
version VARCHAR(64) NOT NULL PRIMARY KEY,
|
||||||
applied_at TEXT NOT NULL,
|
applied_at %s NOT NULL,
|
||||||
description VARCHAR(255) NOT NULL DEFAULT ''
|
description VARCHAR(255) NOT NULL DEFAULT ''
|
||||||
)`,
|
)`, shortText),
|
||||||
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS admin_users (
|
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS admin_users (
|
||||||
id %s,
|
id %s,
|
||||||
username TEXT NOT NULL UNIQUE,
|
username %s NOT NULL UNIQUE,
|
||||||
password_hash TEXT NOT NULL,
|
password_hash %s NOT NULL,
|
||||||
password_changed INTEGER NOT NULL DEFAULT 0,
|
password_changed INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at TEXT NOT NULL,
|
created_at %s NOT NULL,
|
||||||
updated_at TEXT NOT NULL
|
updated_at %s NOT NULL
|
||||||
)`, d.idType()),
|
)`, d.idType(), keyText, shortText, shortText, shortText),
|
||||||
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS sessions (
|
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS sessions (
|
||||||
id %s,
|
id %s,
|
||||||
session_id TEXT NOT NULL UNIQUE,
|
session_id %s NOT NULL UNIQUE,
|
||||||
username TEXT NOT NULL,
|
username %s NOT NULL,
|
||||||
csrf TEXT NOT NULL,
|
csrf %s NOT NULL,
|
||||||
expires_at TEXT NOT NULL,
|
expires_at %s NOT NULL,
|
||||||
created_at TEXT NOT NULL
|
created_at %s NOT NULL
|
||||||
)`, d.idType()),
|
)`, d.idType(), keyText, keyText, shortText, shortText, shortText),
|
||||||
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS release_packages (
|
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS release_packages (
|
||||||
id %s,
|
id %s,
|
||||||
product TEXT NOT NULL,
|
product %s NOT NULL,
|
||||||
version TEXT NOT NULL,
|
version %s NOT NULL,
|
||||||
platform TEXT NOT NULL,
|
platform %s NOT NULL,
|
||||||
arch TEXT NOT NULL,
|
arch %s NOT NULL,
|
||||||
file_name TEXT NOT NULL UNIQUE,
|
file_name %s NOT NULL UNIQUE,
|
||||||
url TEXT NOT NULL,
|
url %s NOT NULL,
|
||||||
sha256 TEXT NOT NULL,
|
sha256 %s NOT NULL,
|
||||||
size_bytes BIGINT NOT NULL DEFAULT 0,
|
size_bytes BIGINT NOT NULL DEFAULT 0,
|
||||||
enabled INTEGER NOT NULL DEFAULT 1,
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
created_at TEXT NOT NULL,
|
created_at %s NOT NULL,
|
||||||
updated_at TEXT NOT NULL
|
updated_at %s NOT NULL
|
||||||
)`, d.idType()),
|
)`, d.idType(), keyText, keyText, keyText, keyText, keyText, mediumText, shortText, shortText, shortText),
|
||||||
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS release_notices (
|
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS release_notices (
|
||||||
id %s,
|
id %s,
|
||||||
version TEXT NOT NULL UNIQUE,
|
version %s NOT NULL UNIQUE,
|
||||||
build TEXT NOT NULL DEFAULT '',
|
build %s NOT NULL DEFAULT '',
|
||||||
channel TEXT NOT NULL DEFAULT 'stable',
|
channel %s NOT NULL DEFAULT 'stable',
|
||||||
title TEXT NOT NULL DEFAULT '',
|
title %s NOT NULL DEFAULT '',
|
||||||
message TEXT NOT NULL DEFAULT '',
|
message %s NOT NULL,
|
||||||
release_notes TEXT NOT NULL DEFAULT '',
|
release_notes %s NOT NULL,
|
||||||
message_md TEXT NOT NULL DEFAULT '',
|
message_md %s NOT NULL,
|
||||||
release_notes_md TEXT NOT NULL DEFAULT '',
|
release_notes_md %s NOT NULL,
|
||||||
download_url TEXT NOT NULL DEFAULT '',
|
download_url %s NOT NULL DEFAULT '',
|
||||||
notice_file TEXT NOT NULL DEFAULT '',
|
notice_file %s NOT NULL DEFAULT '',
|
||||||
raw_json TEXT NOT NULL,
|
raw_json %s NOT NULL,
|
||||||
published_at TEXT NOT NULL DEFAULT '',
|
published_at %s NOT NULL DEFAULT '',
|
||||||
created_at TEXT NOT NULL,
|
created_at %s NOT NULL,
|
||||||
updated_at TEXT NOT NULL
|
updated_at %s NOT NULL
|
||||||
)`, d.idType()),
|
)`, 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 (
|
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS release_notice_revisions (
|
||||||
id %s,
|
id %s,
|
||||||
version TEXT NOT NULL,
|
version %s NOT NULL,
|
||||||
raw_json TEXT NOT NULL,
|
raw_json %s NOT NULL,
|
||||||
note TEXT NOT NULL DEFAULT '',
|
note %s NOT NULL DEFAULT '',
|
||||||
created_by TEXT NOT NULL DEFAULT '',
|
created_by %s NOT NULL DEFAULT '',
|
||||||
created_at TEXT NOT NULL
|
created_at %s NOT NULL
|
||||||
)`, d.idType()),
|
)`, d.idType(), keyText, longText, mediumText, keyText, shortText),
|
||||||
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS feedback_tickets (
|
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS feedback_tickets (
|
||||||
code TEXT PRIMARY KEY,
|
code %s PRIMARY KEY,
|
||||||
title TEXT NOT NULL,
|
title %s NOT NULL,
|
||||||
type TEXT NOT NULL,
|
type %s NOT NULL,
|
||||||
severity TEXT NOT NULL,
|
severity %s NOT NULL,
|
||||||
category TEXT NOT NULL DEFAULT '',
|
category %s NOT NULL DEFAULT '',
|
||||||
priority TEXT NOT NULL DEFAULT '',
|
priority %s NOT NULL DEFAULT '',
|
||||||
contact TEXT NOT NULL DEFAULT '',
|
contact %s NOT NULL DEFAULT '',
|
||||||
body TEXT NOT NULL DEFAULT '',
|
body %s NOT NULL,
|
||||||
status TEXT NOT NULL,
|
status %s NOT NULL,
|
||||||
status_detail TEXT NOT NULL DEFAULT '',
|
status_detail %s NOT NULL DEFAULT '',
|
||||||
public_reply TEXT NOT NULL DEFAULT '',
|
public_reply %s NOT NULL,
|
||||||
note TEXT NOT NULL DEFAULT '',
|
note %s NOT NULL,
|
||||||
assignee TEXT NOT NULL DEFAULT '',
|
assignee %s NOT NULL DEFAULT '',
|
||||||
handled_by TEXT NOT NULL DEFAULT '',
|
handled_by %s NOT NULL DEFAULT '',
|
||||||
due_at TEXT NOT NULL DEFAULT '',
|
due_at %s NOT NULL DEFAULT '',
|
||||||
resolved_at TEXT NOT NULL DEFAULT '',
|
resolved_at %s NOT NULL DEFAULT '',
|
||||||
archived_at TEXT NOT NULL DEFAULT '',
|
archived_at %s NOT NULL DEFAULT '',
|
||||||
sla_level TEXT NOT NULL DEFAULT '',
|
sla_level %s NOT NULL DEFAULT '',
|
||||||
source_channel TEXT NOT NULL DEFAULT '',
|
source_channel %s NOT NULL DEFAULT '',
|
||||||
risk_score INTEGER NOT NULL DEFAULT 0,
|
risk_score INTEGER NOT NULL DEFAULT 0,
|
||||||
resolution TEXT NOT NULL DEFAULT '',
|
resolution %s NOT NULL,
|
||||||
attachment TEXT NOT NULL DEFAULT '',
|
attachment %s NOT NULL DEFAULT '',
|
||||||
package_path TEXT NOT NULL DEFAULT '',
|
package_path %s NOT NULL DEFAULT '',
|
||||||
encrypted_package_path TEXT NOT NULL DEFAULT '',
|
encrypted_package_path %s NOT NULL DEFAULT '',
|
||||||
package_sha256 TEXT NOT NULL DEFAULT '',
|
package_sha256 %s NOT NULL DEFAULT '',
|
||||||
plain_package_sha256 TEXT NOT NULL DEFAULT '',
|
plain_package_sha256 %s NOT NULL DEFAULT '',
|
||||||
summary_text TEXT NOT NULL DEFAULT '',
|
summary_text %s NOT NULL,
|
||||||
included_files TEXT NOT NULL DEFAULT '',
|
included_files %s NOT NULL,
|
||||||
mail_sent INTEGER NOT NULL DEFAULT 0,
|
mail_sent INTEGER NOT NULL DEFAULT 0,
|
||||||
remote_addr TEXT NOT NULL DEFAULT '',
|
remote_addr %s NOT NULL DEFAULT '',
|
||||||
tags TEXT NOT NULL DEFAULT '[]',
|
tags %s NOT NULL,
|
||||||
created_at TEXT NOT NULL,
|
created_at %s NOT NULL,
|
||||||
updated_at TEXT NOT NULL,
|
updated_at %s NOT NULL,
|
||||||
last_activity_at TEXT 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 (
|
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS feedback_comments (
|
||||||
id %s,
|
id %s,
|
||||||
feedback_code TEXT NOT NULL,
|
feedback_code %s NOT NULL,
|
||||||
author TEXT NOT NULL DEFAULT '',
|
author %s NOT NULL DEFAULT '',
|
||||||
body TEXT NOT NULL,
|
body %s NOT NULL,
|
||||||
internal INTEGER NOT NULL DEFAULT 1,
|
internal INTEGER NOT NULL DEFAULT 1,
|
||||||
created_at TEXT NOT NULL
|
created_at %s NOT NULL
|
||||||
)`, d.idType()),
|
)`, d.idType(), keyText, keyText, longText, shortText),
|
||||||
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS feedback_attachments (
|
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS feedback_attachments (
|
||||||
id %s,
|
id %s,
|
||||||
feedback_code TEXT NOT NULL,
|
feedback_code %s NOT NULL,
|
||||||
kind TEXT NOT NULL,
|
kind %s NOT NULL,
|
||||||
path TEXT NOT NULL,
|
path %s NOT NULL,
|
||||||
file_name TEXT NOT NULL,
|
file_name %s NOT NULL,
|
||||||
sha256 TEXT NOT NULL DEFAULT '',
|
sha256 %s NOT NULL DEFAULT '',
|
||||||
size_bytes BIGINT NOT NULL DEFAULT 0,
|
size_bytes BIGINT NOT NULL DEFAULT 0,
|
||||||
created_at TEXT NOT NULL
|
created_at %s NOT NULL
|
||||||
)`, d.idType()),
|
)`, d.idType(), keyText, keyText, mediumText, mediumText, shortText, shortText),
|
||||||
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS feedback_events (
|
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS feedback_events (
|
||||||
id %s,
|
id %s,
|
||||||
feedback_code TEXT NOT NULL,
|
feedback_code %s NOT NULL,
|
||||||
event_type TEXT NOT NULL,
|
event_type %s NOT NULL,
|
||||||
actor TEXT NOT NULL DEFAULT '',
|
actor %s NOT NULL DEFAULT '',
|
||||||
from_value TEXT NOT NULL DEFAULT '',
|
from_value %s NOT NULL DEFAULT '',
|
||||||
to_value TEXT NOT NULL DEFAULT '',
|
to_value %s NOT NULL DEFAULT '',
|
||||||
message TEXT NOT NULL DEFAULT '',
|
message %s NOT NULL DEFAULT '',
|
||||||
created_at TEXT NOT NULL
|
created_at %s NOT NULL
|
||||||
)`, d.idType()),
|
)`, d.idType(), keyText, keyText, keyText, mediumText, mediumText, mediumText, shortText),
|
||||||
`CREATE TABLE IF NOT EXISTS feedback_tags (
|
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS feedback_tags (
|
||||||
feedback_code TEXT NOT NULL,
|
feedback_code %s NOT NULL,
|
||||||
tag TEXT NOT NULL,
|
tag %s NOT NULL,
|
||||||
created_at TEXT NOT NULL,
|
created_at %s NOT NULL,
|
||||||
PRIMARY KEY (feedback_code, tag)
|
PRIMARY KEY (feedback_code, tag)
|
||||||
)`,
|
)`, keyText, keyText, shortText),
|
||||||
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS mail_records (
|
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS mail_records (
|
||||||
id %s,
|
id %s,
|
||||||
feedback_code TEXT NOT NULL DEFAULT '',
|
feedback_code %s NOT NULL DEFAULT '',
|
||||||
kind TEXT NOT NULL DEFAULT '',
|
kind %s NOT NULL DEFAULT '',
|
||||||
status TEXT NOT NULL DEFAULT '',
|
status %s NOT NULL DEFAULT '',
|
||||||
to_address TEXT NOT NULL DEFAULT '',
|
to_address %s NOT NULL DEFAULT '',
|
||||||
subject TEXT NOT NULL DEFAULT '',
|
subject %s NOT NULL DEFAULT '',
|
||||||
plain_body TEXT NOT NULL DEFAULT '',
|
plain_body %s NOT NULL,
|
||||||
html_body TEXT NOT NULL DEFAULT '',
|
html_body %s NOT NULL,
|
||||||
attachment_path TEXT NOT NULL DEFAULT '',
|
attachment_path %s NOT NULL DEFAULT '',
|
||||||
attachment_name TEXT NOT NULL DEFAULT '',
|
attachment_name %s NOT NULL DEFAULT '',
|
||||||
error_message TEXT NOT NULL DEFAULT '',
|
error_message %s NOT NULL,
|
||||||
created_at TEXT NOT NULL,
|
created_at %s NOT NULL,
|
||||||
sent_at TEXT NOT NULL DEFAULT ''
|
sent_at %s NOT NULL DEFAULT ''
|
||||||
)`, d.idType()),
|
)`, d.idType(), keyText, keyText, keyText, mediumText, mediumText, longText, longText, mediumText, mediumText, longText, shortText, shortText),
|
||||||
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS source_categories (
|
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS source_categories (
|
||||||
id %s,
|
id %s,
|
||||||
category_id TEXT NOT NULL UNIQUE,
|
category_id %s NOT NULL UNIQUE,
|
||||||
name TEXT NOT NULL,
|
name %s NOT NULL,
|
||||||
enabled INTEGER NOT NULL DEFAULT 1,
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
ui_config TEXT NOT NULL DEFAULT '{}',
|
ui_config %s NOT NULL,
|
||||||
created_at TEXT NOT NULL,
|
created_at %s NOT NULL,
|
||||||
updated_at TEXT NOT NULL
|
updated_at %s NOT NULL
|
||||||
)`, d.idType()),
|
)`, d.idType(), keyText, shortText, longText, shortText, shortText),
|
||||||
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS source_endpoints (
|
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS source_endpoints (
|
||||||
id %s,
|
id %s,
|
||||||
category_id TEXT NOT NULL,
|
category_id %s NOT NULL,
|
||||||
category_name TEXT NOT NULL,
|
category_name %s NOT NULL,
|
||||||
source_id TEXT NOT NULL UNIQUE,
|
source_id %s NOT NULL UNIQUE,
|
||||||
name TEXT NOT NULL,
|
name %s NOT NULL,
|
||||||
description TEXT NOT NULL DEFAULT '',
|
description %s NOT NULL DEFAULT '',
|
||||||
method TEXT NOT NULL DEFAULT 'GET',
|
method %s NOT NULL DEFAULT 'GET',
|
||||||
api_url TEXT NOT NULL DEFAULT '',
|
api_url %s NOT NULL DEFAULT '',
|
||||||
url_template TEXT NOT NULL DEFAULT '',
|
url_template %s NOT NULL DEFAULT '',
|
||||||
thumbnail_url TEXT NOT NULL DEFAULT '',
|
thumbnail_url %s NOT NULL DEFAULT '',
|
||||||
proxy_mode TEXT NOT NULL DEFAULT 'client_direct',
|
proxy_mode %s NOT NULL DEFAULT 'client_direct',
|
||||||
timeout_ms INTEGER NOT NULL DEFAULT 8000,
|
timeout_ms INTEGER NOT NULL DEFAULT 8000,
|
||||||
retry_count INTEGER NOT NULL DEFAULT 1,
|
retry_count INTEGER NOT NULL DEFAULT 1,
|
||||||
cache_seconds INTEGER NOT NULL DEFAULT 300,
|
cache_seconds INTEGER NOT NULL DEFAULT 300,
|
||||||
check_interval_sec INTEGER NOT NULL DEFAULT 300,
|
check_interval_sec INTEGER NOT NULL DEFAULT 300,
|
||||||
enabled INTEGER NOT NULL DEFAULT 1,
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
client_visible INTEGER NOT NULL DEFAULT 1,
|
client_visible INTEGER NOT NULL DEFAULT 1,
|
||||||
supported_formats TEXT NOT NULL DEFAULT '[]',
|
supported_formats %s NOT NULL,
|
||||||
last_status TEXT NOT NULL DEFAULT 'unknown',
|
last_status %s NOT NULL DEFAULT 'unknown',
|
||||||
last_latency_ms INTEGER NOT NULL DEFAULT 0,
|
last_latency_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
last_checked_at TEXT NOT NULL DEFAULT '',
|
last_checked_at %s NOT NULL DEFAULT '',
|
||||||
last_error TEXT NOT NULL DEFAULT '',
|
last_error %s NOT NULL,
|
||||||
consecutive_failure INTEGER NOT NULL DEFAULT 0,
|
consecutive_failure INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at TEXT NOT NULL,
|
created_at %s NOT NULL,
|
||||||
updated_at TEXT NOT NULL
|
updated_at %s NOT NULL
|
||||||
)`, d.idType()),
|
)`, 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 (
|
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS endpoint_health_checks (
|
||||||
id %s,
|
id %s,
|
||||||
source_db_id BIGINT NOT NULL,
|
source_db_id BIGINT NOT NULL,
|
||||||
status TEXT NOT NULL,
|
status %s NOT NULL,
|
||||||
latency_ms INTEGER NOT NULL DEFAULT 0,
|
latency_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
error TEXT NOT NULL DEFAULT '',
|
error %s NOT NULL,
|
||||||
checked_at TEXT NOT NULL
|
checked_at %s NOT NULL
|
||||||
)`, d.idType()),
|
)`, d.idType(), keyText, longText, shortText),
|
||||||
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS endpoint_call_logs (
|
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS endpoint_call_logs (
|
||||||
id %s,
|
id %s,
|
||||||
source_id TEXT NOT NULL,
|
source_id %s NOT NULL,
|
||||||
status TEXT NOT NULL,
|
status %s NOT NULL,
|
||||||
latency_ms INTEGER NOT NULL DEFAULT 0,
|
latency_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
error TEXT NOT NULL DEFAULT '',
|
error %s NOT NULL,
|
||||||
client TEXT NOT NULL DEFAULT '',
|
client %s NOT NULL DEFAULT '',
|
||||||
created_at TEXT NOT NULL
|
created_at %s NOT NULL
|
||||||
)`, d.idType()),
|
)`, d.idType(), keyText, keyText, longText, mediumText, shortText),
|
||||||
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS database_sync_jobs (
|
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS database_sync_jobs (
|
||||||
id %s,
|
id %s,
|
||||||
direction TEXT NOT NULL,
|
direction %s NOT NULL,
|
||||||
status TEXT NOT NULL,
|
status %s NOT NULL,
|
||||||
message TEXT NOT NULL DEFAULT '',
|
message %s NOT NULL,
|
||||||
tables_json TEXT NOT NULL DEFAULT '{}',
|
tables_json %s NOT NULL,
|
||||||
started_at TEXT NOT NULL,
|
started_at %s NOT NULL,
|
||||||
finished_at TEXT NOT NULL DEFAULT ''
|
finished_at %s NOT NULL DEFAULT ''
|
||||||
)`, d.idType()),
|
)`, 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 (
|
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS legacy_sync_jobs (
|
||||||
id %s,
|
id %s,
|
||||||
status TEXT NOT NULL,
|
status %s NOT NULL,
|
||||||
summary TEXT NOT NULL DEFAULT '',
|
summary %s NOT NULL,
|
||||||
stats_json TEXT NOT NULL DEFAULT '{}',
|
stats_json %s NOT NULL,
|
||||||
started_at TEXT NOT NULL,
|
started_at %s NOT NULL,
|
||||||
finished_at TEXT NOT NULL DEFAULT ''
|
finished_at %s NOT NULL DEFAULT ''
|
||||||
)`, d.idType()),
|
)`, d.idType(), keyText, longText, longText, shortText, shortText),
|
||||||
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS audit_logs (
|
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS audit_logs (
|
||||||
id %s,
|
id %s,
|
||||||
actor TEXT NOT NULL DEFAULT '',
|
actor %s NOT NULL DEFAULT '',
|
||||||
type TEXT NOT NULL,
|
type %s NOT NULL,
|
||||||
target TEXT NOT NULL DEFAULT '',
|
target %s NOT NULL DEFAULT '',
|
||||||
message TEXT NOT NULL DEFAULT '',
|
message %s NOT NULL,
|
||||||
ip TEXT NOT NULL DEFAULT '',
|
ip %s NOT NULL DEFAULT '',
|
||||||
user_agent TEXT NOT NULL DEFAULT '',
|
user_agent %s NOT NULL DEFAULT '',
|
||||||
created_at TEXT NOT NULL
|
created_at %s NOT NULL
|
||||||
)`, d.idType()),
|
)`, d.idType(), keyText, keyText, keyText, longText, keyText, mediumText, shortText),
|
||||||
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS legacy_json_revisions (
|
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS legacy_json_revisions (
|
||||||
id %s,
|
id %s,
|
||||||
name TEXT NOT NULL,
|
name %s NOT NULL,
|
||||||
raw TEXT NOT NULL,
|
raw %s NOT NULL,
|
||||||
note TEXT NOT NULL DEFAULT '',
|
note %s NOT NULL DEFAULT '',
|
||||||
created_by TEXT NOT NULL DEFAULT '',
|
created_by %s NOT NULL DEFAULT '',
|
||||||
created_at TEXT NOT NULL
|
created_at %s NOT NULL
|
||||||
)`, d.idType()),
|
)`, d.idType(), keyText, longText, mediumText, keyText, shortText),
|
||||||
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS webhook_deliveries (
|
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS webhook_deliveries (
|
||||||
id %s,
|
id %s,
|
||||||
webhook_name TEXT NOT NULL DEFAULT '',
|
webhook_name %s NOT NULL DEFAULT '',
|
||||||
event TEXT NOT NULL DEFAULT '',
|
event %s NOT NULL DEFAULT '',
|
||||||
status TEXT NOT NULL DEFAULT '',
|
status %s NOT NULL DEFAULT '',
|
||||||
attempts INTEGER NOT NULL DEFAULT 0,
|
attempts INTEGER NOT NULL DEFAULT 0,
|
||||||
response_code INTEGER NOT NULL DEFAULT 0,
|
response_code INTEGER NOT NULL DEFAULT 0,
|
||||||
error_message TEXT NOT NULL DEFAULT '',
|
error_message %s NOT NULL,
|
||||||
payload_sha256 TEXT NOT NULL DEFAULT '',
|
payload_sha256 %s NOT NULL DEFAULT '',
|
||||||
created_at TEXT NOT NULL,
|
created_at %s NOT NULL,
|
||||||
finished_at TEXT NOT NULL DEFAULT ''
|
finished_at %s NOT NULL DEFAULT ''
|
||||||
)`, d.idType()),
|
)`, 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)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_feedback_events_code ON feedback_events(feedback_code)`,
|
|
||||||
`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_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)`,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type schemaIndex struct {
|
||||||
|
name string
|
||||||
|
table string
|
||||||
|
columns string
|
||||||
|
}
|
||||||
|
|
||||||
|
func schemaIndexes() []schemaIndex {
|
||||||
|
return []schemaIndex{
|
||||||
|
{name: "idx_feedback_tickets_activity", table: "feedback_tickets", columns: "last_activity_at"},
|
||||||
|
{name: "idx_feedback_comments_code", table: "feedback_comments", columns: "feedback_code"},
|
||||||
|
{name: "idx_feedback_attachments_code", table: "feedback_attachments", columns: "feedback_code"},
|
||||||
|
{name: "idx_feedback_events_code", table: "feedback_events", columns: "feedback_code"},
|
||||||
|
{name: "idx_mail_records_code", table: "mail_records", columns: "feedback_code"},
|
||||||
|
{name: "idx_endpoint_call_logs_source", table: "endpoint_call_logs", columns: "source_id"},
|
||||||
|
{name: "idx_audit_logs_created", table: "audit_logs", columns: "created_at"},
|
||||||
|
{name: "idx_audit_logs_type", table: "audit_logs", columns: "type"},
|
||||||
|
{name: "idx_audit_logs_target", table: "audit_logs", columns: "target"},
|
||||||
|
{name: "idx_legacy_json_revisions_name", table: "legacy_json_revisions", columns: "name, id"},
|
||||||
|
{name: "idx_release_notices_version", table: "release_notices", columns: "version"},
|
||||||
|
{name: "idx_release_notice_revisions_version", table: "release_notice_revisions", columns: "version, id"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func createSchemaIndexes(conn *sql.DB, d dialect) error {
|
||||||
|
for _, index := range schemaIndexes() {
|
||||||
|
if d.name == "mysql" {
|
||||||
|
exists, err := mysqlIndexExists(conn, index)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := conn.Exec(d.rebind(createIndexStatement(d, index))); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mysqlIndexExists(conn *sql.DB, index schemaIndex) (bool, error) {
|
||||||
|
var count int
|
||||||
|
err := conn.QueryRow(`SELECT COUNT(1)
|
||||||
|
FROM information_schema.statistics
|
||||||
|
WHERE table_schema = DATABASE()
|
||||||
|
AND table_name = ?
|
||||||
|
AND index_name = ?`, index.table, index.name).Scan(&count)
|
||||||
|
return count > 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func createIndexStatement(d dialect, index schemaIndex) string {
|
||||||
|
if d.name == "mysql" {
|
||||||
|
return fmt.Sprintf("CREATE INDEX %s ON %s(%s)", d.quoteIdent(index.name), d.quoteIdent(index.table), index.columns)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("CREATE INDEX IF NOT EXISTS %s ON %s(%s)", d.quoteIdent(index.name), d.quoteIdent(index.table), index.columns)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) recordSchemaVersion(conn *sql.DB, d dialect) error {
|
func (s *Store) recordSchemaVersion(conn *sql.DB, d dialect) error {
|
||||||
columns := []string{"version", "applied_at", "description"}
|
columns := []string{"version", "applied_at", "description"}
|
||||||
_, err := conn.Exec(d.rebind(d.upsert("schema_migrations", columns, []string{"version"})),
|
_, err := conn.Exec(d.rebind(d.upsert("schema_migrations", columns, []string{"version"})),
|
||||||
|
|||||||
@@ -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
|
return err
|
||||||
}
|
}
|
||||||
if status == "ok" {
|
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 = ?`,
|
_, 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)
|
status, latency, now, sanitize(message), now, sourceDBID)
|
||||||
} else if status == "redirected" {
|
} 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 = ?`,
|
_, 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)
|
status, latency, now, sanitize(message), now, sourceDBID)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
|
|
||||||
type Store struct {
|
type Store struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
|
syncMu sync.Mutex
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
path string
|
path string
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
@@ -65,6 +66,7 @@ func Open(cfg *config.Config) (*Store, error) {
|
|||||||
status: DatabaseStatus{
|
status: DatabaseStatus{
|
||||||
ActiveProvider: "sqlite",
|
ActiveProvider: "sqlite",
|
||||||
ConfigProvider: cfg.Database.Provider,
|
ConfigProvider: cfg.Database.Provider,
|
||||||
|
SchemaVersion: CurrentSchemaVersion,
|
||||||
SQLiteReady: true,
|
SQLiteReady: true,
|
||||||
LastRecoveredAt: Now(),
|
LastRecoveredAt: Now(),
|
||||||
},
|
},
|
||||||
@@ -114,6 +116,81 @@ func (s *Store) Path() string {
|
|||||||
return s.path
|
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.SchemaVersion = CurrentSchemaVersion
|
||||||
|
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) {
|
func (s *Store) active() (*sql.DB, dialect) {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
defer s.mu.RUnlock()
|
defer s.mu.RUnlock()
|
||||||
@@ -194,6 +271,7 @@ func (s *Store) openRemote() error {
|
|||||||
s.dialect = remoteDialect
|
s.dialect = remoteDialect
|
||||||
s.status.ActiveProvider = "mysql"
|
s.status.ActiveProvider = "mysql"
|
||||||
s.status.ConfigProvider = "mysql"
|
s.status.ConfigProvider = "mysql"
|
||||||
|
s.status.SchemaVersion = CurrentSchemaVersion
|
||||||
s.status.RemoteReady = true
|
s.status.RemoteReady = true
|
||||||
s.status.FailoverActive = false
|
s.status.FailoverActive = false
|
||||||
s.status.LastError = ""
|
s.status.LastError = ""
|
||||||
@@ -229,6 +307,7 @@ func (s *Store) checkRemote() {
|
|||||||
}
|
}
|
||||||
s.status.ActiveProvider = "mysql"
|
s.status.ActiveProvider = "mysql"
|
||||||
s.status.RemoteReady = true
|
s.status.RemoteReady = true
|
||||||
|
s.status.SchemaVersion = CurrentSchemaVersion
|
||||||
s.status.FailoverActive = false
|
s.status.FailoverActive = false
|
||||||
s.status.LastError = ""
|
s.status.LastError = ""
|
||||||
s.status.LastRecoveredAt = Now()
|
s.status.LastRecoveredAt = Now()
|
||||||
@@ -245,6 +324,7 @@ func (s *Store) markFailover(err error) {
|
|||||||
s.dialect = s.localDialect
|
s.dialect = s.localDialect
|
||||||
s.status.ActiveProvider = "sqlite"
|
s.status.ActiveProvider = "sqlite"
|
||||||
s.status.ConfigProvider = s.cfg.Database.Provider
|
s.status.ConfigProvider = s.cfg.Database.Provider
|
||||||
|
s.status.SchemaVersion = CurrentSchemaVersion
|
||||||
s.status.RemoteReady = false
|
s.status.RemoteReady = false
|
||||||
s.status.FailoverActive = !strings.EqualFold(s.cfg.Database.Provider, "sqlite")
|
s.status.FailoverActive = !strings.EqualFold(s.cfg.Database.Provider, "sqlite")
|
||||||
s.status.LastError = err.Error()
|
s.status.LastError = err.Error()
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"ymhut-box/server/unified-management/internal/config"
|
"ymhut-box/server/unified-management/internal/config"
|
||||||
@@ -137,6 +138,51 @@ func TestOpenRecordsCurrentSchemaVersion(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSchemaIndexStatementsAreDialectAware(t *testing.T) {
|
||||||
|
mysql := dialectFor("mysql")
|
||||||
|
sqlite := dialectFor("sqlite")
|
||||||
|
for _, statement := range schemaStatements(mysql) {
|
||||||
|
if strings.Contains(strings.ToUpper(statement), "CREATE INDEX IF NOT EXISTS") {
|
||||||
|
t.Fatalf("mysql schema statement contains unsupported index syntax: %s", statement)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
index := schemaIndexes()[0]
|
||||||
|
mysqlStatement := createIndexStatement(mysql, index)
|
||||||
|
if strings.Contains(strings.ToUpper(mysqlStatement), "IF NOT EXISTS") {
|
||||||
|
t.Fatalf("mysql index statement contains unsupported syntax: %s", mysqlStatement)
|
||||||
|
}
|
||||||
|
if !strings.Contains(createIndexStatement(sqlite, index), "IF NOT EXISTS") {
|
||||||
|
t.Fatalf("sqlite index statement should remain idempotent")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMigrateCreatesSQLiteIndexesIdempotently(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
conn, err := sql.Open("sqlite", filepath.Join(root, "indexes.sqlite"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
store := &Store{}
|
||||||
|
d := dialectFor("sqlite")
|
||||||
|
if err := store.migrate(conn, d); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := store.migrate(conn, d); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var name string
|
||||||
|
if err := conn.QueryRow(`SELECT name FROM sqlite_master WHERE type = 'index' AND name = ?`, "idx_feedback_tickets_activity").Scan(&name); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if name != "idx_feedback_tickets_activity" {
|
||||||
|
t.Fatalf("unexpected index name %q", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestChangeAdminPasswordPersistsWhenRemoteSyncFails(t *testing.T) {
|
func TestChangeAdminPasswordPersistsWhenRemoteSyncFails(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
path := filepath.Join(root, "unified.sqlite")
|
path := filepath.Join(root, "unified.sqlite")
|
||||||
@@ -273,3 +319,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/config"
|
||||||
"ymhut-box/server/unified-management/internal/db"
|
"ymhut-box/server/unified-management/internal/db"
|
||||||
|
feedbackmail "ymhut-box/server/unified-management/internal/mail"
|
||||||
)
|
)
|
||||||
|
|
||||||
const PackageMagic = "YMHUTFB1"
|
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) {
|
func (s *Service) Submit(r *http.Request) (db.Feedback, error) {
|
||||||
contentType := r.Header.Get("Content-Type")
|
contentType := r.Header.Get("Content-Type")
|
||||||
|
var item db.Feedback
|
||||||
|
var err error
|
||||||
if strings.Contains(contentType, "multipart/form-data") {
|
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
|
return item, nil
|
||||||
} else if hasSignedFields(r) || !strings.Contains(strings.ToLower(err.Error()), "signed multipart fields are required") {
|
} else if hasSignedFields(r) || !strings.Contains(strings.ToLower(err.Error()), "signed multipart fields are required") {
|
||||||
return db.Feedback{}, err
|
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) {
|
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":
|
case "update-info":
|
||||||
if _, ok := parsed["app_version"]; !ok {
|
if _, ok := parsed["app_version"]; !ok {
|
||||||
if _, ok := parsed["title"]; !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":
|
case "media-types":
|
||||||
if _, ok := parsed["categories"].([]any); !ok {
|
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 {
|
if _, ok := parsed["layout_version"]; !ok {
|
||||||
parsed["layout_version"] = "1.0.0"
|
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)
|
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) {
|
func (s *Service) Restore(ctx context.Context, version string, revisionID int64, actor string) (Document, error) {
|
||||||
revision, err := s.store.GetReleaseNoticeRevision(version, revisionID)
|
revision, err := s.store.GetReleaseNoticeRevision(version, revisionID)
|
||||||
if err != nil {
|
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 {
|
func (s *Service) syncLegacyUpdateInfo(item db.ReleaseNotice, parsed map[string]any) error {
|
||||||
path := filepath.Join(s.cfg.UpdatePublicDir, "update-info.json")
|
path := filepath.Join(s.cfg.UpdatePublicDir, "update-info.json")
|
||||||
payload := map[string]any{}
|
payload := s.legacyUpdateBase(path)
|
||||||
if data, err := os.ReadFile(path); err == nil {
|
|
||||||
_ = json.Unmarshal(data, &payload)
|
|
||||||
}
|
|
||||||
payload["app_version"] = item.Version
|
payload["app_version"] = item.Version
|
||||||
setNonEmpty(payload, "build", item.Build)
|
setNonEmpty(payload, "build", item.Build)
|
||||||
setNonEmpty(payload, "channel", item.Channel)
|
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'))
|
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) {
|
func parseAndFormat(data []byte, fallbackVersion, noticeFile string) (map[string]any, string, error) {
|
||||||
_, parsed, formatted, err := parseNotice(data, fallbackVersion, noticeFile)
|
_, parsed, formatted, err := parseNotice(data, fallbackVersion, noticeFile)
|
||||||
return parsed, formatted, err
|
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)
|
version := firstNonEmpty(stringValue(parsed, "app_version"), stringValue(parsed, "version"), fallbackVersion)
|
||||||
if version == "" {
|
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 == "" {
|
if noticeFile == "" {
|
||||||
noticeFile = version + ".json"
|
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) {
|
func writeJSON(t *testing.T, path string, payload any) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
data, err := json.Marshal(payload)
|
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 {
|
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)
|
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"} {
|
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 {
|
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 {
|
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)
|
packages := s.ScanPackages(r)
|
||||||
modules := readJSON(filepath.Join(s.cfg.UpdatePublicDir, "modules.json"))["modules"]
|
modules := readJSON(filepath.Join(s.cfg.UpdatePublicDir, "modules.json"))["modules"]
|
||||||
if modules == nil {
|
if modules == nil {
|
||||||
@@ -116,6 +116,20 @@ func (s *Service) Manifest(r *http.Request) map[string]any {
|
|||||||
return payload
|
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) {
|
func setIfMissing(payload map[string]any, key, value string) {
|
||||||
if strings.TrimSpace(value) == "" {
|
if strings.TrimSpace(value) == "" {
|
||||||
return
|
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 {
|
func (s *Service) updateLegacyManifest(pkg Package, opts UploadOptions) error {
|
||||||
path := filepath.Join(s.cfg.UpdatePublicDir, "update-info.json")
|
path := filepath.Join(s.cfg.UpdatePublicDir, "update-info.json")
|
||||||
payload := readJSON(path)
|
payload := s.legacyUpdateBase()
|
||||||
payload["app_version"] = pkg.Version
|
payload["app_version"] = pkg.Version
|
||||||
payload["download_url"] = pkg.URL
|
payload["download_url"] = pkg.URL
|
||||||
payload["package_sha256"] = pkg.SHA256
|
payload["package_sha256"] = pkg.SHA256
|
||||||
@@ -265,10 +279,32 @@ func (s *Service) updateLegacyManifest(pkg Package, opts UploadOptions) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
return atomicWrite(path, append(data, '\n'))
|
||||||
return err
|
}
|
||||||
|
|
||||||
|
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 {
|
func readJSON(path string) map[string]any {
|
||||||
@@ -283,6 +319,30 @@ func readJSON(path string) map[string]any {
|
|||||||
return payload
|
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 {
|
func requestBaseURL(r *http.Request, fallback string) string {
|
||||||
if r != nil {
|
if r != nil {
|
||||||
scheme := r.Header.Get("X-Forwarded-Proto")
|
scheme := r.Header.Get("X-Forwarded-Proto")
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -44,10 +46,28 @@ type CheckJob struct {
|
|||||||
LastError string `json:"lastError"`
|
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 {
|
type legacyMedia struct {
|
||||||
Categories []legacyCategory `json:"categories"`
|
Categories []legacyCategory `json:"categories"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const maxSourceProbeBytes int64 = 2 * 1024 * 1024
|
||||||
|
|
||||||
|
var absoluteURLPattern = regexp.MustCompile(`https?://[^\s"'<>\\]+`)
|
||||||
|
|
||||||
type legacyCategory struct {
|
type legacyCategory struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -152,6 +172,42 @@ func (s *Service) ImportLegacyMediaTypes(ctx context.Context) error {
|
|||||||
return nil
|
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) {
|
func (s *Service) Catalog(includeHidden bool) (map[string]any, error) {
|
||||||
items, err := s.store.ListSources(includeHidden)
|
items, err := s.store.ListSources(includeHidden)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -194,6 +250,7 @@ func (s *Service) Catalog(includeHidden bool) (map[string]any, error) {
|
|||||||
"meta": parseHealthMeta(item.LastError),
|
"meta": parseHealthMeta(item.LastError),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
applyResolvedFields(sub, item.LastError)
|
||||||
cat["subcategories"] = append(cat["subcategories"].([]map[string]any), sub)
|
cat["subcategories"] = append(cat["subcategories"].([]map[string]any), sub)
|
||||||
}
|
}
|
||||||
out := []map[string]any{}
|
out := []map[string]any{}
|
||||||
@@ -216,7 +273,7 @@ func (s *Service) Endpoints(includeHidden bool) ([]map[string]any, error) {
|
|||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
var formats []string
|
var formats []string
|
||||||
_ = json.Unmarshal([]byte(item.SupportedFormats), &formats)
|
_ = json.Unmarshal([]byte(item.SupportedFormats), &formats)
|
||||||
out = append(out, map[string]any{
|
endpoint := map[string]any{
|
||||||
"id": item.SourceID,
|
"id": item.SourceID,
|
||||||
"category": item.CategoryID,
|
"category": item.CategoryID,
|
||||||
"name": item.Name,
|
"name": item.Name,
|
||||||
@@ -235,7 +292,9 @@ func (s *Service) Endpoints(includeHidden bool) ([]map[string]any, error) {
|
|||||||
"consecutiveFailure": item.ConsecutiveFailure,
|
"consecutiveFailure": item.ConsecutiveFailure,
|
||||||
"meta": parseHealthMeta(item.LastError),
|
"meta": parseHealthMeta(item.LastError),
|
||||||
},
|
},
|
||||||
})
|
}
|
||||||
|
applyResolvedFields(endpoint, item.LastError)
|
||||||
|
out = append(out, endpoint)
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
@@ -434,6 +493,18 @@ func (s *Service) CheckOneStatus(ctx context.Context, item db.Source) (string, e
|
|||||||
"error": resp.Status,
|
"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 {
|
if err := s.store.RecordSourceCheck(item.ID, status, latency, message); err != nil {
|
||||||
return status, err
|
return status, err
|
||||||
}
|
}
|
||||||
@@ -486,6 +557,259 @@ func isHTTPURL(value *url.URL) bool {
|
|||||||
return scheme == "http" || scheme == "https"
|
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 {
|
func healthMetaMessage(meta map[string]any) string {
|
||||||
data, err := json.Marshal(meta)
|
data, err := json.Marshal(meta)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package sources
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -119,6 +120,115 @@ func TestSubscribeEventsBroadcastsToAllSubscribers(t *testing.T) {
|
|||||||
assertEvent("subscriber B", eventsB)
|
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) {
|
func testStore(t *testing.T) (*config.Config, *db.Store) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
dir := t.TempDir()
|
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})
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "comment": comment})
|
||||||
return
|
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/") {
|
if req.Method == http.MethodPatch && strings.HasPrefix(path, "/api/admin/feedbacks/") {
|
||||||
code := strings.TrimPrefix(path, "/api/admin/feedbacks/")
|
code := strings.TrimPrefix(path, "/api/admin/feedbacks/")
|
||||||
var body struct {
|
var body struct {
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
|
Priority string `json:"priority"`
|
||||||
StatusDetail string `json:"statusDetail"`
|
StatusDetail string `json:"statusDetail"`
|
||||||
PublicReply string `json:"publicReply"`
|
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)
|
writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", err)
|
||||||
return
|
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)
|
writeError(w, http.StatusInternalServerError, "FEEDBACK_UPDATE_FAILED", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,9 @@ func (r *router) handleAdminLegacy(w http.ResponseWriter, req *http.Request) {
|
|||||||
if name == "media-types" {
|
if name == "media-types" {
|
||||||
_ = r.sources.ImportLegacyMediaTypes(req.Context())
|
_ = 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})
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "document": doc})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -83,8 +86,18 @@ func (r *router) handleAdminLegacy(w http.ResponseWriter, req *http.Request) {
|
|||||||
if name == "media-types" {
|
if name == "media-types" {
|
||||||
_ = r.sources.ImportLegacyMediaTypes(req.Context())
|
_ = 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})
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "document": doc})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
http.NotFound(w, req)
|
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)
|
writeError(w, http.StatusInternalServerError, "SOURCE_SAVE_FAILED", err)
|
||||||
return
|
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})
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "source": saved})
|
||||||
case req.Method == http.MethodDelete && strings.HasPrefix(path, "/api/admin/sources/"):
|
case req.Method == http.MethodDelete && strings.HasPrefix(path, "/api/admin/sources/"):
|
||||||
sourceID := strings.TrimPrefix(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)
|
writeError(w, http.StatusInternalServerError, "SOURCE_DELETE_FAILED", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
_ = r.releases.PublishLegacyUpdateInfo(req, "admin")
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||||
default:
|
default:
|
||||||
http.NotFound(w, req)
|
http.NotFound(w, req)
|
||||||
|
|||||||
@@ -4,38 +4,57 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"ymhut-box/server/unified-management/internal/config"
|
"ymhut-box/server/unified-management/internal/config"
|
||||||
"ymhut-box/server/unified-management/internal/db"
|
"ymhut-box/server/unified-management/internal/db"
|
||||||
"ymhut-box/server/unified-management/internal/health"
|
"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) {
|
func (r *router) handleAdminDatabase(w http.ResponseWriter, req *http.Request) {
|
||||||
path := cleanPath(req.URL.Path)
|
path := cleanPath(req.URL.Path)
|
||||||
switch {
|
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":
|
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":
|
case req.Method == http.MethodPost && path == "/api/admin/database/test":
|
||||||
var body config.DatabaseConfig
|
body, err := decodeAdminDatabaseConfig(req, r.cfg.BaseDir, r.cfg.Database, true)
|
||||||
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", err)
|
writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", err)
|
||||||
return
|
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 {
|
if err := db.TestDatabase(body); err != nil {
|
||||||
writeError(w, http.StatusBadGateway, "DATABASE_TEST_FAILED", err)
|
writeError(w, http.StatusBadGateway, "DATABASE_TEST_FAILED", err)
|
||||||
return
|
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":
|
case req.Method == http.MethodPost && path == "/api/admin/database/import-sqlite":
|
||||||
result, err := r.store.ImportSQLiteToRemote()
|
result, err := r.store.ImportSQLiteToRemote()
|
||||||
if err != nil {
|
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) {
|
func (r *router) handleAdminDashboard(w http.ResponseWriter, req *http.Request) {
|
||||||
path := cleanPath(req.URL.Path)
|
path := cleanPath(req.URL.Path)
|
||||||
if req.Method != http.MethodGet || path != "/api/admin/dashboard/overview" {
|
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":
|
case "/api/admin/system/health":
|
||||||
writeJSON(w, http.StatusOK, health.Snapshot(r.cfg, r.store))
|
writeJSON(w, http.StatusOK, health.Snapshot(r.cfg, r.store))
|
||||||
case "/api/admin/system/audit":
|
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 {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "AUDIT_FAILED", err)
|
writeError(w, http.StatusInternalServerError, "AUDIT_FAILED", err)
|
||||||
return
|
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":
|
case "/api/admin/system/database/sync":
|
||||||
if req.Method != http.MethodPost {
|
if req.Method != http.MethodPost {
|
||||||
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", errors.New("POST required"))
|
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", errors.New("POST required"))
|
||||||
@@ -162,3 +241,192 @@ func (r *router) handleAdminSystem(w http.ResponseWriter, req *http.Request) {
|
|||||||
http.NotFound(w, req)
|
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"), "description": "反馈加密包和解密后的本地包"},
|
||||||
|
},
|
||||||
|
"sqlitePath": r.store.Path(),
|
||||||
|
"mysql": config.SafeDatabase(r.cfg.BaseDir, r.cfg.Database),
|
||||||
|
"schemaVersion": status.SchemaVersion,
|
||||||
|
"lastSyncAt": status.LastSyncAt,
|
||||||
|
"lastSyncError": status.LastSyncError,
|
||||||
|
"lastError": status.LastError,
|
||||||
|
"failoverActive": status.FailoverActive,
|
||||||
|
"remoteReady": status.RemoteReady,
|
||||||
|
"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,
|
"release": release,
|
||||||
"sources": sourceCatalog,
|
"sources": sourceCatalog,
|
||||||
"feedback": map[string]any{"submit": "/", "status": "/?api=status&code=:code"},
|
"feedback": map[string]any{"submit": "/", "status": "/?api=status&code=:code"},
|
||||||
|
"branding": config.SafeBranding(r.effectiveBranding()),
|
||||||
"health": health.Snapshot(r.cfg, r.store),
|
"health": health.Snapshot(r.cfg, r.store),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,10 +60,18 @@ func localizedErrorMessage(code, message string) string {
|
|||||||
"database is not available": "数据库当前不可用",
|
"database is not available": "数据库当前不可用",
|
||||||
"provider must be sqlite or mysql": "数据库类型必须是 SQLite 或 MySQL",
|
"provider must be sqlite or mysql": "数据库类型必须是 SQLite 或 MySQL",
|
||||||
"mysql connection is required": "请填写 MySQL 连接信息",
|
"mysql connection is required": "请填写 MySQL 连接信息",
|
||||||
|
"mysql database is required": "请填写 MySQL 数据库名",
|
||||||
|
"mysql username is required": "请填写 MySQL 数据库用户",
|
||||||
"sqlite path is required": "请填写 SQLite 路径",
|
"sqlite path is required": "请填写 SQLite 路径",
|
||||||
"mysql_dsn is required": "请填写 MySQL DSN",
|
"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": "版本日志功能尚未配置",
|
"release notices are not configured": "版本日志功能尚未配置",
|
||||||
"legacy sync service is 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 {
|
if translated, ok := exact[lower]; ok {
|
||||||
return translated
|
return translated
|
||||||
@@ -74,6 +82,7 @@ func localizedErrorMessage(code, message string) string {
|
|||||||
"PASSWORD_CHANGE_FAILED": "密码修改失败",
|
"PASSWORD_CHANGE_FAILED": "密码修改失败",
|
||||||
"INVALID_PAYLOAD": "提交内容格式不正确",
|
"INVALID_PAYLOAD": "提交内容格式不正确",
|
||||||
"DATABASE_TEST_FAILED": "数据库连接测试失败",
|
"DATABASE_TEST_FAILED": "数据库连接测试失败",
|
||||||
|
"DATABASE_SAVE_FAILED": "数据库配置保存失败",
|
||||||
"DATABASE_IMPORT_FAILED": "SQLite 导入远端库失败",
|
"DATABASE_IMPORT_FAILED": "SQLite 导入远端库失败",
|
||||||
"DATABASE_SYNC_FAILED": "远端库同步回本地失败",
|
"DATABASE_SYNC_FAILED": "远端库同步回本地失败",
|
||||||
"LEGACY_SAVE_FAILED": "兼容 JSON 保存失败",
|
"LEGACY_SAVE_FAILED": "兼容 JSON 保存失败",
|
||||||
@@ -97,6 +106,9 @@ func localizedErrorMessage(code, message string) string {
|
|||||||
"AUDIT_FAILED": "审计日志加载失败",
|
"AUDIT_FAILED": "审计日志加载失败",
|
||||||
"FEEDBACK_LIST_FAILED": "反馈列表加载失败",
|
"FEEDBACK_LIST_FAILED": "反馈列表加载失败",
|
||||||
"FEEDBACK_UPDATE_FAILED": "反馈工单更新失败",
|
"FEEDBACK_UPDATE_FAILED": "反馈工单更新失败",
|
||||||
|
"MAIL_CONFIG_FAILED": "邮件配置保存失败",
|
||||||
|
"MAIL_TEST_FAILED": "测试邮件发送失败",
|
||||||
|
"MAIL_RETRY_FAILED": "反馈邮件重试失败",
|
||||||
"NOTICE_NOT_FOUND": "未找到版本日志",
|
"NOTICE_NOT_FOUND": "未找到版本日志",
|
||||||
"NOTICES_FAILED": "版本日志加载失败",
|
"NOTICES_FAILED": "版本日志加载失败",
|
||||||
"MEDIA_TYPES_FAILED": "媒体源 JSON 加载失败",
|
"MEDIA_TYPES_FAILED": "媒体源 JSON 加载失败",
|
||||||
|
|||||||
@@ -130,12 +130,111 @@ func TestClientBootstrapAndEndpointsShape(t *testing.T) {
|
|||||||
}
|
}
|
||||||
continue
|
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 {
|
if payload["ok"] != true {
|
||||||
t.Fatalf("%s missing ok=true: %#v", path, payload)
|
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) {
|
func TestLegacyFeedbackStatusDTOContract(t *testing.T) {
|
||||||
payload := legacyFeedbackStatus(db.Feedback{
|
payload := legacyFeedbackStatus(db.Feedback{
|
||||||
Code: "FB-20260626-ABCDEF",
|
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) {
|
func TestAdminWriteRequiresCSRF(t *testing.T) {
|
||||||
handler, cleanup := testRouter(t)
|
handler, cleanup := testRouter(t)
|
||||||
defer cleanup()
|
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"})
|
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{
|
cfg := &config.Config{
|
||||||
|
BaseDir: root,
|
||||||
|
ConfigPath: filepath.Join(root, "config.json"),
|
||||||
Listen: ":0",
|
Listen: ":0",
|
||||||
BaseURL: "https://update.ymhut.cn",
|
BaseURL: "https://update.ymhut.cn",
|
||||||
StorageDir: filepath.Join(root, "storage"),
|
StorageDir: filepath.Join(root, "storage"),
|
||||||
|
DataDir: filepath.Join(root, "data"),
|
||||||
UpdatePublicDir: public,
|
UpdatePublicDir: public,
|
||||||
UpdateNoticeDir: noticeDir,
|
UpdateNoticeDir: noticeDir,
|
||||||
DownloadsDir: filepath.Join(public, "downloads"),
|
DownloadsDir: filepath.Join(public, "downloads"),
|
||||||
|
|||||||
@@ -3,11 +3,8 @@ package web
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -24,18 +21,7 @@ type setupRequest struct {
|
|||||||
BaseURL string `json:"baseUrl"`
|
BaseURL string `json:"baseUrl"`
|
||||||
SQLitePath string `json:"sqlitePath"`
|
SQLitePath string `json:"sqlitePath"`
|
||||||
MySQLDSN string `json:"mysqlDsn"`
|
MySQLDSN string `json:"mysqlDsn"`
|
||||||
MySQL setupMySQLConfig `json:"mysql"`
|
MySQL config.MySQLInput `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"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSetupRouter(cfg *config.Config) http.Handler {
|
func NewSetupRouter(cfg *config.Config) http.Handler {
|
||||||
@@ -73,7 +59,7 @@ func (r *setupRouter) status() map[string]any {
|
|||||||
"defaults": map[string]any{
|
"defaults": map[string]any{
|
||||||
"provider": firstNonEmpty(r.cfg.Database.Provider, "sqlite"),
|
"provider": firstNonEmpty(r.cfg.Database.Provider, "sqlite"),
|
||||||
"sqlitePath": relativeToBase(r.cfg.BaseDir, r.cfg.Database.SQLitePath),
|
"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,
|
"baseUrl": r.cfg.BaseURL,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -103,7 +89,7 @@ func (r *setupRouter) handleDatabaseTest(w http.ResponseWriter, req *http.Reques
|
|||||||
"provider": next.Provider,
|
"provider": next.Provider,
|
||||||
"baseUrl": firstNonEmpty(body.BaseURL, r.cfg.BaseURL),
|
"baseUrl": firstNonEmpty(body.BaseURL, r.cfg.BaseURL),
|
||||||
"sqlitePath": relativeToBase(r.cfg.BaseDir, next.SQLitePath),
|
"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 {
|
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||||
return config.DatabaseConfig{}, body, err
|
return config.DatabaseConfig{}, body, err
|
||||||
}
|
}
|
||||||
next := r.cfg.Database
|
incoming := config.DatabaseConfig{
|
||||||
next.Provider = strings.ToLower(strings.TrimSpace(firstNonEmpty(body.Provider, next.Provider, "sqlite")))
|
Provider: body.Provider,
|
||||||
if body.SQLitePath != "" {
|
SQLitePath: body.SQLitePath,
|
||||||
next.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, err := config.NormalizeDatabase(r.cfg.BaseDir, r.cfg.Database, incoming, false)
|
||||||
next.SQLitePath = filepath.Join(r.cfg.BaseDir, next.SQLitePath)
|
return next, body, err
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *setupRouter) serveSetup(w http.ResponseWriter, req *http.Request) {
|
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>`))
|
_, _ = 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 {
|
func maskedDatabaseTarget(base string, cfg config.DatabaseConfig) string {
|
||||||
if strings.EqualFold(cfg.Provider, "mysql") {
|
if strings.EqualFold(cfg.Provider, "mysql") {
|
||||||
return maskDSN(cfg.MySQLDSN)
|
return config.MaskDSN(cfg.MySQLDSN)
|
||||||
}
|
}
|
||||||
return relativeToBase(base, cfg.SQLitePath)
|
return relativeToBase(base, cfg.SQLitePath)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,8 +31,11 @@ import { createSystemStore } from "./stores/system";
|
|||||||
|
|
||||||
const DashboardView = defineAsyncComponent(() => import("./views/DashboardView.vue"));
|
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 ToastState = { message: string; type: "success" | "warn" | "error" };
|
||||||
|
type LoadSystemOptions = { preserveForms?: boolean };
|
||||||
|
type LoadDatabaseOptions = { previewLegacy?: boolean; preserveForm?: boolean };
|
||||||
|
type LoadMailOptions = { preserveForm?: boolean };
|
||||||
|
|
||||||
type Captcha = {
|
type Captcha = {
|
||||||
captchaId: string;
|
captchaId: string;
|
||||||
@@ -58,6 +61,8 @@ const currentPath = computed(() => normalizeAdminPath(route.path));
|
|||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const toast = ref<ToastState | null>(null);
|
const toast = ref<ToastState | null>(null);
|
||||||
const autoRefreshPaused = ref(false);
|
const autoRefreshPaused = ref(false);
|
||||||
|
const databaseFormEditing = ref(false);
|
||||||
|
const mailConfigEditing = ref(false);
|
||||||
let refreshTimer: number | undefined;
|
let refreshTimer: number | undefined;
|
||||||
let toastTimer: number | undefined;
|
let toastTimer: number | undefined;
|
||||||
let events: EventSource | null = null;
|
let events: EventSource | null = null;
|
||||||
@@ -74,9 +79,9 @@ const { csrf, captcha, bootstrap: authBootstrap, loginForm, passwordForm } = aut
|
|||||||
const { dashboard, sourceCheckJobs } = dashboardStore;
|
const { dashboard, sourceCheckJobs } = dashboardStore;
|
||||||
const { page: feedbackPage, selected: selectedFeedback, filters: feedbackFilters, update: feedbackUpdate, commentDraft } = feedbackStore;
|
const { page: feedbackPage, selected: selectedFeedback, filters: feedbackFilters, update: feedbackUpdate, commentDraft } = feedbackStore;
|
||||||
const { releases, notices: releaseNotices, selectedNotice, noticeDraft, uploadDraft } = releaseStore;
|
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 { 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[] = [
|
const routes: RouteItem[] = [
|
||||||
{ path: "/admin/dashboard", label: "仪表盘", description: "服务状态、接口心跳与运营指标", icon: LayoutDashboard },
|
{ 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 healthyEndpointCount = computed(() => endpoints.value.filter((item) => ["ok", "redirected"].includes(endpointStatus(item))).length);
|
||||||
const latestNotice = computed(() => releaseNotices.value[0] || null);
|
const latestNotice = computed(() => releaseNotices.value[0] || null);
|
||||||
const activeLegacyLabel = computed(() => activeLegacyName.value === "media-types" ? "media-types.json" : "update-info.json");
|
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 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(() => ({
|
const heartbeatOption = computed(() => ({
|
||||||
|
animation: true,
|
||||||
tooltip: { trigger: "axis" },
|
tooltip: { trigger: "axis" },
|
||||||
grid: { left: 44, right: 18, top: 28, bottom: 34 },
|
grid: { left: 48, right: 22, top: 28, bottom: 40, containLabel: true },
|
||||||
xAxis: {
|
xAxis: {
|
||||||
type: "category",
|
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" } },
|
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: [
|
series: [
|
||||||
{
|
{
|
||||||
name: "接口延迟",
|
name: "接口延迟",
|
||||||
type: "line",
|
type: "line",
|
||||||
smooth: true,
|
smooth: true,
|
||||||
|
showSymbol: true,
|
||||||
|
symbolSize: 7,
|
||||||
|
connectNulls: true,
|
||||||
areaStyle: { opacity: 0.18 },
|
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",
|
color: "#2563eb",
|
||||||
|
lineStyle: { width: 3 },
|
||||||
|
emphasis: { focus: "series" },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}));
|
}));
|
||||||
@@ -199,19 +238,29 @@ const viewContext = computed(() => ({
|
|||||||
addMediaCategory,
|
addMediaCategory,
|
||||||
addMediaSubcategory,
|
addMediaSubcategory,
|
||||||
addUpdateMirror,
|
addUpdateMirror,
|
||||||
|
applyLegacyModal,
|
||||||
|
auditPage,
|
||||||
auditLogs: auditLogs.value,
|
auditLogs: auditLogs.value,
|
||||||
autoRefreshPaused: autoRefreshPaused.value,
|
autoRefreshPaused: autoRefreshPaused.value,
|
||||||
availabilityOption: availabilityOption.value,
|
availabilityOption: availabilityOption.value,
|
||||||
|
branding,
|
||||||
changePassword,
|
changePassword,
|
||||||
checkSources,
|
checkSources,
|
||||||
clientCalls: clientCalls.value,
|
clientCalls: clientCalls.value,
|
||||||
commentDraft,
|
commentDraft,
|
||||||
copyEndpointToSource,
|
copyEndpointToSource,
|
||||||
database: database.value,
|
database: database.value,
|
||||||
|
databaseConfig: databaseConfig.value,
|
||||||
|
databaseConfigCollapsed: databaseConfigCollapsed.value,
|
||||||
|
databaseFormEditing: databaseFormEditing.value,
|
||||||
databaseForm,
|
databaseForm,
|
||||||
databaseLastSync: databaseLastSync.value,
|
databaseLastSync: databaseLastSync.value,
|
||||||
|
databaseSyncStatusLabel,
|
||||||
databaseSyncDirectionLabel,
|
databaseSyncDirectionLabel,
|
||||||
databaseSyncTableCount,
|
databaseSyncTableCount,
|
||||||
|
databaseConfigSummary,
|
||||||
|
deleteEndpoint,
|
||||||
|
editDatabaseConfig,
|
||||||
endpointStatus,
|
endpointStatus,
|
||||||
endpoints: endpoints.value,
|
endpoints: endpoints.value,
|
||||||
feedbackFilters,
|
feedbackFilters,
|
||||||
@@ -224,16 +273,36 @@ const viewContext = computed(() => ({
|
|||||||
healthyEndpointCount: healthyEndpointCount.value,
|
healthyEndpointCount: healthyEndpointCount.value,
|
||||||
heartbeatOption: heartbeatOption.value,
|
heartbeatOption: heartbeatOption.value,
|
||||||
heartbeats: heartbeats.value,
|
heartbeats: heartbeats.value,
|
||||||
|
isHeartbeatChartEmpty: isHeartbeatChartEmpty.value,
|
||||||
importNotices,
|
importNotices,
|
||||||
kpis: kpis.value,
|
kpis: kpis.value,
|
||||||
labelStatus,
|
labelStatus,
|
||||||
|
labelPriority,
|
||||||
latestNotice: latestNotice.value,
|
latestNotice: latestNotice.value,
|
||||||
legacyDocuments,
|
legacyDocuments,
|
||||||
legacyDrafts,
|
legacyDrafts,
|
||||||
|
legacyModal,
|
||||||
|
activeMediaCategoryIndex: activeMediaCategoryIndex.value,
|
||||||
|
activeMediaCategory: activeMediaCategory.value,
|
||||||
legacySync: legacySync.value,
|
legacySync: legacySync.value,
|
||||||
legacySyncMode: legacySyncMode.value,
|
legacySyncMode: legacySyncMode.value,
|
||||||
loadAudit,
|
loadAudit,
|
||||||
|
loadBranding,
|
||||||
loadFeedbacks,
|
loadFeedbacks,
|
||||||
|
loadMigrationStatus,
|
||||||
|
mailConfig,
|
||||||
|
mailConfigEditing: mailConfigEditing.value,
|
||||||
|
markDatabaseFormEditing,
|
||||||
|
markMailConfigEditing,
|
||||||
|
migrationStatus: migrationStatus.value,
|
||||||
|
loadMailConfig,
|
||||||
|
reloadDatabaseConfig,
|
||||||
|
reloadMailConfig,
|
||||||
|
saveDatabase,
|
||||||
|
saveBranding,
|
||||||
|
saveMailConfig,
|
||||||
|
testMail,
|
||||||
|
retryFeedbackMail,
|
||||||
navigate,
|
navigate,
|
||||||
noticeDraft,
|
noticeDraft,
|
||||||
onPackageSelected,
|
onPackageSelected,
|
||||||
@@ -262,8 +331,15 @@ const viewContext = computed(() => ({
|
|||||||
syncDatabase,
|
syncDatabase,
|
||||||
systemTab: systemTab.value,
|
systemTab: systemTab.value,
|
||||||
setSystemTab,
|
setSystemTab,
|
||||||
|
setAuditPage,
|
||||||
|
selectAuditLog,
|
||||||
testDatabase,
|
testDatabase,
|
||||||
toggleAutoRefresh,
|
toggleAutoRefresh,
|
||||||
|
openMediaCategoryModal,
|
||||||
|
openMediaSubcategoryModal,
|
||||||
|
openUpdateMirrorModal,
|
||||||
|
selectMediaCategory,
|
||||||
|
closeLegacyModal,
|
||||||
updateLegacyRawFromForm,
|
updateLegacyRawFromForm,
|
||||||
uploadDraft,
|
uploadDraft,
|
||||||
uploadPackage,
|
uploadPackage,
|
||||||
@@ -291,7 +367,7 @@ function normalizeAdminPath(value: string) {
|
|||||||
|
|
||||||
function normalizeSystemTab(value: unknown): SystemTab {
|
function normalizeSystemTab(value: unknown): SystemTab {
|
||||||
const tab = Array.isArray(value) ? value[0] : value;
|
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";
|
return "database";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -392,7 +468,7 @@ async function load() {
|
|||||||
if (currentPath.value === "/admin/releases") await loadReleases();
|
if (currentPath.value === "/admin/releases") await loadReleases();
|
||||||
if (currentPath.value === "/admin/sources") await loadSources();
|
if (currentPath.value === "/admin/sources") await loadSources();
|
||||||
if (currentPath.value === "/admin/endpoints") await loadEndpoints();
|
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;
|
const legacyName = activeLegacyName.value;
|
||||||
if (legacyName) await loadLegacy(legacyName);
|
if (legacyName) await loadLegacy(legacyName);
|
||||||
connectAdminEvents();
|
connectAdminEvents();
|
||||||
@@ -403,14 +479,22 @@ async function loadDashboard() {
|
|||||||
dashboard.value = await api("/api/admin/dashboard/overview?window=24h");
|
dashboard.value = await api("/api/admin/dashboard/overview?window=24h");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadSystem() {
|
async function loadSystem(options: LoadSystemOptions = {}) {
|
||||||
await Promise.all([loadDatabase(), loadHealth(), loadAudit()]);
|
await Promise.all([
|
||||||
|
loadDatabase({ preserveForm: options.preserveForms }),
|
||||||
|
loadMailConfig({ preserveForm: options.preserveForms }),
|
||||||
|
loadHealth(),
|
||||||
|
loadAudit(),
|
||||||
|
loadMigrationStatus(),
|
||||||
|
loadBranding(),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadFeedbacks() {
|
async function loadFeedbacks() {
|
||||||
const params = new URLSearchParams({ page: String(feedbackFilters.page), perPage: String(feedbackFilters.perPage) });
|
const params = new URLSearchParams({ page: String(feedbackFilters.page), perPage: String(feedbackFilters.perPage) });
|
||||||
if (feedbackFilters.q) params.set("q", feedbackFilters.q);
|
if (feedbackFilters.q) params.set("q", feedbackFilters.q);
|
||||||
if (feedbackFilters.status) params.set("status", feedbackFilters.status);
|
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}`);
|
const data = await api<{ page: any }>(`/api/admin/feedbacks?${params}`);
|
||||||
feedbackPage.value = data.page || { items: [], total: 0, page: 1, perPage: 20 };
|
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)}`);
|
const data = await api<{ feedback: any }>(`/api/admin/feedbacks/${encodeURIComponent(item.code)}`);
|
||||||
selectedFeedback.value = data.feedback;
|
selectedFeedback.value = data.feedback;
|
||||||
feedbackUpdate.status = data.feedback.status || "new";
|
feedbackUpdate.status = data.feedback.status || "new";
|
||||||
|
feedbackUpdate.priority = data.feedback.priority || "normal";
|
||||||
feedbackUpdate.statusDetail = data.feedback.statusDetail || "";
|
feedbackUpdate.statusDetail = data.feedback.statusDetail || "";
|
||||||
feedbackUpdate.publicReply = data.feedback.publicReply || "";
|
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([
|
const [releaseData, noticeData] = await Promise.all([
|
||||||
api<{ manifest: any }>("/api/admin/releases"),
|
api<{ manifest: any }>("/api/admin/releases"),
|
||||||
api<{ items: any[] }>("/api/admin/releases/notices"),
|
api<{ items: any[] }>("/api/admin/releases/notices"),
|
||||||
]);
|
]);
|
||||||
releases.value = releaseData.manifest;
|
releases.value = releaseData.manifest;
|
||||||
releaseNotices.value = noticeData.items || [];
|
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() {
|
async function importNotices() {
|
||||||
@@ -492,7 +580,7 @@ async function saveNotice() {
|
|||||||
selectedNotice.value = data.document;
|
selectedNotice.value = data.document;
|
||||||
noticeDraft.note = "";
|
noticeDraft.note = "";
|
||||||
setToast("版本日志已保存并同步兼容更新信息");
|
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].raw = data.document.raw || "";
|
||||||
legacyDrafts[name].preview = data.document.parsed || null;
|
legacyDrafts[name].preview = data.document.parsed || null;
|
||||||
legacyDrafts[name].form = makeLegacyForm(name, data.document.parsed || {});
|
legacyDrafts[name].form = makeLegacyForm(name, data.document.parsed || {});
|
||||||
|
if (name === "media-types") clampMediaCategoryIndex();
|
||||||
}
|
}
|
||||||
|
|
||||||
function onPackageSelected(event: Event) {
|
function onPackageSelected(event: Event) {
|
||||||
@@ -597,6 +686,12 @@ async function saveLegacy(name: LegacyName) {
|
|||||||
legacyDrafts[name].preview = data.document.parsed;
|
legacyDrafts[name].preview = data.document.parsed;
|
||||||
legacyDrafts[name].form = makeLegacyForm(name, data.document.parsed || {});
|
legacyDrafts[name].form = makeLegacyForm(name, data.document.parsed || {});
|
||||||
legacyDrafts[name].note = "";
|
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 已保存并发布到旧路径");
|
setToast("兼容 JSON 已保存并发布到旧路径");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -611,6 +706,8 @@ async function restoreLegacy(name: LegacyName, revisionId: number) {
|
|||||||
legacyDrafts[name].raw = data.document.raw;
|
legacyDrafts[name].raw = data.document.raw;
|
||||||
legacyDrafts[name].preview = data.document.parsed;
|
legacyDrafts[name].preview = data.document.parsed;
|
||||||
legacyDrafts[name].form = makeLegacyForm(name, 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 已恢复");
|
setToast("兼容 JSON 已恢复");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -648,6 +745,7 @@ function makeLegacyForm(name: LegacyName, parsed: any) {
|
|||||||
release_notes_md: parsed.release_notes_md || "",
|
release_notes_md: parsed.release_notes_md || "",
|
||||||
update_notes: JSON.stringify(parsed.update_notes || {}, null, 2),
|
update_notes: JSON.stringify(parsed.update_notes || {}, null, 2),
|
||||||
last_update_notes: JSON.stringify(parsed.last_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_sha256: parsed.package_sha256 || "",
|
||||||
package_size: parsed.package_size || "",
|
package_size: parsed.package_size || "",
|
||||||
updated_at: parsed.updated_at || parsed.last_updated || "",
|
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[key] !== undefined) current[key] = form[key];
|
||||||
}
|
}
|
||||||
if (form.package_size !== "") current.package_size = Number(form.package_size || 0);
|
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.update_notes = parseJSONSafe(form.update_notes, current.update_notes || {});
|
||||||
current.last_update_notes = parseJSONSafe(form.last_update_notes, current.last_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 });
|
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) {
|
function removeItem(list: any[], index: number) {
|
||||||
list.splice(index, 1);
|
list.splice(index, 1);
|
||||||
|
clampMediaCategoryIndex();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadSources() {
|
async function loadSources() {
|
||||||
@@ -731,10 +926,10 @@ async function checkSources() {
|
|||||||
await guarded(async () => {
|
await guarded(async () => {
|
||||||
const data = await api<{ jobId: string; job: any }>("/api/admin/sources/check", { method: "POST", body: "{}" });
|
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);
|
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/dashboard") await loadDashboard();
|
||||||
if (currentPath.value === "/admin/sources") await loadSources();
|
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");
|
navigate("/admin/sources");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadDatabase(options: { previewLegacy?: boolean } = {}) {
|
async function deleteEndpoint(item: any) {
|
||||||
const data = await api<{ database: any }>("/api/admin/database/status");
|
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;
|
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();
|
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() {
|
async function testDatabase() {
|
||||||
await guarded(async () => {
|
await guarded(async () => {
|
||||||
await api("/api/admin/database/test", {
|
await api("/api/admin/database/test", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ provider: databaseForm.provider, sqlite_path: databaseForm.sqlitePath, mysql_dsn: databaseForm.mysqlDsn }),
|
body: JSON.stringify(databasePayload()),
|
||||||
});
|
});
|
||||||
setToast("数据库连接测试通过");
|
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") {
|
async function syncDatabase(direction: "import" | "sync") {
|
||||||
await guarded(async () => {
|
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: "{}" });
|
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 };
|
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 });
|
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() {
|
async function previewLegacySync() {
|
||||||
legacySyncMode.value = "preview";
|
legacySyncMode.value = "preview";
|
||||||
legacySync.value = await api("/api/admin/sync/legacy/preview").catch((error) => ({ ok: false, errors: [String(error)] }));
|
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";
|
legacySyncMode.value = "run";
|
||||||
legacySync.value = await api("/api/admin/sync/legacy/run", { method: "POST", body: "{}" });
|
legacySync.value = await api("/api/admin/sync/legacy/run", { method: "POST", body: "{}" });
|
||||||
setToast("旧项目同步已完成");
|
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() {
|
async function loadAudit() {
|
||||||
const data = await api<{ items: any[] }>("/api/admin/system/audit");
|
const params = new URLSearchParams({
|
||||||
auditLogs.value = data.items || [];
|
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() {
|
async function changePassword() {
|
||||||
@@ -832,9 +1244,9 @@ function endpointStatus(item: any) {
|
|||||||
|
|
||||||
function statusTone(status: string) {
|
function statusTone(status: string) {
|
||||||
const value = String(status || "").toLowerCase();
|
const value = String(status || "").toLowerCase();
|
||||||
if (["ok", "online", "new", "sqlite", "mysql", "sent", "ready"].includes(value)) return "good";
|
if (["ok", "online", "new", "sqlite", "mysql", "sent", "ready", "completed"].includes(value)) return "good";
|
||||||
if (["redirected", "degraded", "pending", "processing", "queued", "missing"].includes(value)) return "warn";
|
if (["redirected", "degraded", "pending", "processing", "queued", "missing", "skipped", "running", "normal"].includes(value)) return "warn";
|
||||||
if (["error", "failed", "closed", "offline"].includes(value)) return "bad";
|
if (["error", "failed", "closed", "offline", "urgent", "high", "blocking", "major"].includes(value)) return "bad";
|
||||||
return "neutral";
|
return "neutral";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -852,11 +1264,30 @@ function labelStatus(value: string) {
|
|||||||
new: "新建",
|
new: "新建",
|
||||||
processing: "处理中",
|
processing: "处理中",
|
||||||
closed: "已关闭",
|
closed: "已关闭",
|
||||||
|
pending: "待发送",
|
||||||
|
sent: "已发送",
|
||||||
|
skipped: "已跳过",
|
||||||
|
running: "执行中",
|
||||||
|
completed: "已完成",
|
||||||
failed: "失败",
|
failed: "失败",
|
||||||
};
|
};
|
||||||
return labels[value] || value || "未知";
|
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) {
|
function auditTypeLabel(value: string) {
|
||||||
const labels: Record<string, string> = {
|
const labels: Record<string, string> = {
|
||||||
"auth.login": "管理员登录",
|
"auth.login": "管理员登录",
|
||||||
@@ -892,6 +1323,16 @@ function databaseSyncDirectionLabel(value: string) {
|
|||||||
return value || "-";
|
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) {
|
function databaseSyncTableCount(result: any) {
|
||||||
const tables = result?.tables || {};
|
const tables = result?.tables || {};
|
||||||
return Object.values(tables).reduce((total: number, value: any) => total + Number(value || 0), 0);
|
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;
|
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) {
|
function pretty(value: any) {
|
||||||
return JSON.stringify(value || {}, null, 2);
|
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/dashboard") void Promise.all([loadDashboard(), loadSourceCheckJobs().catch(() => undefined)]);
|
||||||
if (currentPath.value === "/admin/sources") void Promise.all([loadSources(), 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/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"]) {
|
for (const name of ["source_check.item", "source_check.progress", "source_check.completed", "heartbeat"]) {
|
||||||
events.addEventListener(name, refreshCurrent);
|
events.addEventListener(name, refreshCurrent);
|
||||||
@@ -1017,8 +1464,11 @@ function connectAdminEvents() {
|
|||||||
<main v-else class="app-shell">
|
<main v-else class="app-shell">
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
<div class="brand">
|
<div class="brand">
|
||||||
<span class="brand-mark"><ShieldCheck :size="22" /></span>
|
<span class="brand-mark">
|
||||||
<div><strong>YMhut</strong><small>统一管理台</small></div>
|
<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>
|
</div>
|
||||||
<nav class="nav-groups">
|
<nav class="nav-groups">
|
||||||
<section v-for="group in navGroups" :key="group.label" class="nav-group">
|
<section v-for="group in navGroups" :key="group.label" class="nav-group">
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const exactMessages: Record<string, string> = {
|
|||||||
"file is required": "请选择要上传的文件",
|
"file is required": "请选择要上传的文件",
|
||||||
"invalid filename": "文件名不合法",
|
"invalid filename": "文件名不合法",
|
||||||
"path escape rejected": "文件路径不合法",
|
"path escape rejected": "文件路径不合法",
|
||||||
"check job not found": "未找到心跳检测任务",
|
"check job not found": "未找到服务端检测任务",
|
||||||
"streaming is not supported": "当前运行环境不支持实时事件流",
|
"streaming is not supported": "当前运行环境不支持实时事件流",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { reactive, ref } from "vue";
|
|||||||
export function createFeedbackStore() {
|
export function createFeedbackStore() {
|
||||||
const page = ref<any>({ items: [], total: 0, page: 1, perPage: 20 });
|
const page = ref<any>({ items: [], total: 0, page: 1, perPage: 20 });
|
||||||
const selected = ref<any | null>(null);
|
const selected = ref<any | null>(null);
|
||||||
const filters = reactive({ q: "", status: "", page: 1, perPage: 20 });
|
const filters = reactive({ q: "", status: "", priority: "", page: 1, perPage: 20 });
|
||||||
const update = reactive({ status: "", statusDetail: "", publicReply: "" });
|
const update = reactive({ status: "", priority: "", statusDetail: "", publicReply: "" });
|
||||||
const commentDraft = reactive({ body: "", internal: true });
|
const commentDraft = reactive({ body: "", internal: true });
|
||||||
|
|
||||||
return { page, selected, filters, update, commentDraft };
|
return { page, selected, filters, update, commentDraft };
|
||||||
|
|||||||
@@ -5,10 +5,18 @@ export type LegacyName = "update-info" | "media-types";
|
|||||||
export function createLegacyStore() {
|
export function createLegacyStore() {
|
||||||
const sync = ref<any>(null);
|
const sync = ref<any>(null);
|
||||||
const documents = reactive<Record<LegacyName, any | null>>({ "update-info": null, "media-types": 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 }>>({
|
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: {} },
|
"update-info": { raw: "", note: "", preview: null, tab: "form", form: {} },
|
||||||
"media-types": { raw: "", note: "", preview: null, tab: "form", form: { categories: [] } },
|
"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() {
|
export function createSystemStore() {
|
||||||
const database = ref<any>(null);
|
const database = ref<any>(null);
|
||||||
|
const databaseConfig = ref<any>(null);
|
||||||
const databaseLastSync = ref<any>(null);
|
const databaseLastSync = ref<any>(null);
|
||||||
const healthSnapshot = ref<any>(null);
|
const healthSnapshot = ref<any>(null);
|
||||||
const auditLogs = ref<any[]>([]);
|
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");
|
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.ghost { background: transparent; }
|
||||||
.btn.compact { min-height: 30px; padding: 5px 8px; font-size: 12px; }
|
.btn.compact { min-height: 30px; padding: 5px 8px; font-size: 12px; }
|
||||||
.btn.full { width: 100%; }
|
.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; }
|
.button-row, .top-actions, .toolbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||||
|
|
||||||
.alert-line, .notice {
|
.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 { 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 { 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 strong { display: block; }
|
||||||
.brand small { display: block; color: var(--muted); margin-top: 2px; }
|
.brand small { display: block; color: var(--muted); margin-top: 2px; }
|
||||||
.brand > div { min-width: 0; overflow: hidden; }
|
.brand > div { min-width: 0; overflow: hidden; }
|
||||||
@@ -229,6 +232,13 @@ input:focus, textarea:focus, select:focus {
|
|||||||
padding: 16px;
|
padding: 16px;
|
||||||
box-shadow: 0 1px 2px rgba(17, 24, 39, 0.04);
|
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 {
|
.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;
|
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-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 { min-height: 330px; display: flex; flex-direction: column; }
|
||||||
|
.chart-panel-relative { position: relative; }
|
||||||
.chart { min-height: 260px; width: 100%; flex: 1; }
|
.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 { 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); }
|
.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 {
|
.search-box {
|
||||||
min-width: min(420px, 100%);
|
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; }
|
.empty-state.compact { min-height: 96px; border: 1px dashed var(--line); border-radius: 6px; }
|
||||||
.source-group { margin-top: 12px; }
|
.source-group { margin-top: 12px; }
|
||||||
.source-group h3 { display: flex; align-items: center; gap: 8px; }
|
.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; }
|
.code-editor { min-height: 56dvh; white-space: pre; overflow: auto; font-size: 13px; }
|
||||||
.compact-editor { min-height: 260px; }
|
.compact-editor { min-height: 260px; }
|
||||||
details {
|
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 button:hover, .revision-list button.active { border-color: var(--primary); background: #f8fbff; }
|
||||||
.revision-list small { display: block; color: var(--muted); margin-top: 3px; }
|
.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 { display: grid; grid-template-columns: 140px minmax(0, 1fr); gap: 11px 14px; }
|
||||||
.kv-grid span { color: var(--muted); }
|
.kv-grid span { color: var(--muted); }
|
||||||
.kv-grid strong { overflow-wrap: anywhere; }
|
.kv-grid strong { overflow-wrap: anywhere; }
|
||||||
@@ -375,6 +445,63 @@ summary { cursor: pointer; font-weight: 900; margin-bottom: 10px; }
|
|||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
}
|
}
|
||||||
.ops-note svg { flex: 0 0 auto; margin-top: 3px; }
|
.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 {
|
.tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ use([CanvasRenderer, LineChart, PieChart, BarChart, GaugeChart, GridComponent, T
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="toolbar">
|
<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">
|
<button class="btn ghost" @click="ctx.toggleAutoRefresh">
|
||||||
<component :is="ctx.autoRefreshPaused ? PlayCircle : PauseCircle" :size="16" />
|
<component :is="ctx.autoRefreshPaused ? PlayCircle : PauseCircle" :size="16" />
|
||||||
{{ ctx.autoRefreshPaused ? "恢复自动刷新" : "暂停自动刷新" }}
|
{{ ctx.autoRefreshPaused ? "恢复自动刷新" : "暂停自动刷新" }}
|
||||||
@@ -30,7 +30,7 @@ use([CanvasRenderer, LineChart, PieChart, BarChart, GaugeChart, GridComponent, T
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section v-if="ctx.sourceCheckJobs.length" class="panel">
|
<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>
|
<table>
|
||||||
<thead><tr><th>任务</th><th>进度</th><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><th>开始时间</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -48,14 +48,21 @@ use([CanvasRenderer, LineChart, PieChart, BarChart, GaugeChart, GridComponent, T
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="chart-grid">
|
<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.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.feedbackOption" autoresize /></section>
|
||||||
<section class="panel chart-panel"><h2>服务可用率</h2><VChart class="chart" :option="ctx.availabilityOption" autoresize /></section>
|
<section class="panel chart-panel"><h2>服务可用率</h2><VChart class="chart" :option="ctx.availabilityOption" autoresize /></section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="panel">
|
<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>
|
<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></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -66,7 +73,7 @@ use([CanvasRenderer, LineChart, PieChart, BarChart, GaugeChart, GridComponent, T
|
|||||||
<td class="hash">{{ item.error || "-" }}</td>
|
<td class="hash">{{ item.error || "-" }}</td>
|
||||||
<td>{{ item.checkedAt || "-" }}</td>
|
<td>{{ item.checkedAt || "-" }}</td>
|
||||||
</tr>
|
</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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,12 +1,20 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { Pencil, Trash2 } from "lucide-vue-next";
|
||||||
|
|
||||||
defineProps<{ ctx: any }>();
|
defineProps<{ ctx: any }>();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="panel page-stack">
|
<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>
|
<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>
|
<tbody>
|
||||||
<tr v-for="item in ctx.endpoints" :key="item.id || item.sourceId">
|
<tr v-for="item in ctx.endpoints" :key="item.id || item.sourceId">
|
||||||
<td class="mono">{{ item.id || item.sourceId }}</td>
|
<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>
|
<span v-if="ctx.endpointStatus(item) === 'redirected' || item.health?.meta?.redirected" class="badge warn">重定向接口</span>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ item.cacheSeconds || 0 }}s</td>
|
<td>{{ item.cacheSeconds || 0 }}s</td>
|
||||||
<td class="hash">{{ item.urlTemplate || item.apiUrl }}</td>
|
<td class="hash">{{ item.resolvedUrl || item.urlTemplate || item.apiUrl }}</td>
|
||||||
<td><button class="btn ghost compact" @click="ctx.copyEndpointToSource(item)">编辑</button></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>
|
||||||
<tr v-if="ctx.endpoints.length === 0"><td colspan="7">暂无客户端接口。</td></tr>
|
<tr v-if="ctx.endpoints.length === 0"><td colspan="7">暂无客户端接口。</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Save, Search, UploadCloud } from "lucide-vue-next";
|
import { Mail, Save, Search, UploadCloud } from "lucide-vue-next";
|
||||||
|
|
||||||
defineProps<{ ctx: any }>();
|
defineProps<{ ctx: any }>();
|
||||||
</script>
|
</script>
|
||||||
@@ -8,44 +8,107 @@ defineProps<{ ctx: any }>();
|
|||||||
<section class="split">
|
<section class="split">
|
||||||
<section class="panel page-stack">
|
<section class="panel page-stack">
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<label class="search-box"><Search :size="16" /><input v-model="ctx.feedbackFilters.q" placeholder="搜索编号、标题、联系方式" @keyup.enter="ctx.loadFeedbacks" /></label>
|
<label class="search-box">
|
||||||
<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>
|
<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>
|
<button class="btn ghost" @click="ctx.loadFeedbacks">查询</button>
|
||||||
<a class="btn ghost" href="/api/admin/feedbacks/export" target="_blank"><UploadCloud :size="16" />CSV</a>
|
<a class="btn ghost" href="/api/admin/feedbacks/export" target="_blank"><UploadCloud :size="16" />CSV</a>
|
||||||
</div>
|
</div>
|
||||||
<table>
|
<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>
|
<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)">
|
<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 class="mono">{{ item.code }}</td>
|
||||||
<td>{{ item.title || item.summaryText || "未命名反馈" }}</td>
|
<td>{{ item.title || item.summaryText || "未命名反馈" }}</td>
|
||||||
<td><span :class="['badge', ctx.statusTone(item.status)]">{{ item.status }}</span></td>
|
<td><span :class="['badge', ctx.statusTone(item.status)]">{{ ctx.labelStatus(item.status) }}</span></td>
|
||||||
<td>{{ item.priority || "-" }}</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>
|
<td>{{ item.lastActivityAt || item.createdAt }}</td>
|
||||||
</tr>
|
</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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<aside class="panel detail-panel">
|
<aside class="panel detail-panel">
|
||||||
<template v-if="ctx.selectedFeedback">
|
<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>
|
<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>状态说明<input v-model="ctx.feedbackUpdate.statusDetail" /></label>
|
||||||
<label>公开回复<textarea v-model="ctx.feedbackUpdate.publicReply" rows="3"></textarea></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 />
|
<hr />
|
||||||
<h3>评论</h3>
|
<h3>评论</h3>
|
||||||
<div class="comment-list">
|
<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>
|
</div>
|
||||||
<label>新增评论<textarea v-model="ctx.commentDraft.body" rows="3"></textarea></label>
|
<label>新增评论<textarea v-model="ctx.commentDraft.body" rows="3"></textarea></label>
|
||||||
<label class="checkbox"><input v-model="ctx.commentDraft.internal" type="checkbox" />内部备注</label>
|
<label class="checkbox"><input v-model="ctx.commentDraft.internal" type="checkbox" />内部备注</label>
|
||||||
<button class="btn ghost" @click="ctx.addFeedbackComment">添加评论</button>
|
<button class="btn ghost" @click="ctx.addFeedbackComment">添加评论</button>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>旧反馈事件 / 邮件记录</summary>
|
<summary>邮件记录</summary>
|
||||||
<pre class="json-preview small">{{ ctx.pretty({ events: ctx.selectedFeedback.legacyEvents, mail: ctx.selectedFeedback.mailRecords }) }}</pre>
|
<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>
|
</details>
|
||||||
</template>
|
</template>
|
||||||
<div v-else class="empty-state">选择一条工单查看详情。</div>
|
<div v-else class="empty-state">选择一条工单查看详情。</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<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 }>();
|
defineProps<{ ctx: any }>();
|
||||||
</script>
|
</script>
|
||||||
@@ -9,7 +9,7 @@ defineProps<{ ctx: any }>();
|
|||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<div>
|
<div>
|
||||||
<h2>{{ ctx.activeLegacyLabel }}</h2>
|
<h2>{{ ctx.activeLegacyLabel }}</h2>
|
||||||
<p class="muted">以当前兼容 JSON 为基板,表单保存会合并进原 JSON,未知字段保留。</p>
|
<p class="muted">可视化表单只维护常用字段,保存时会合并回当前 JSON,未识别字段继续保留。</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="button-row">
|
<div class="button-row">
|
||||||
<button class="btn ghost" @click="ctx.validateLegacy(ctx.activeLegacyName)"><CheckCircle2 :size="16" />校验</button>
|
<button class="btn ghost" @click="ctx.validateLegacy(ctx.activeLegacyName)"><CheckCircle2 :size="16" />校验</button>
|
||||||
@@ -18,73 +18,126 @@ defineProps<{ ctx: any }>();
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tabs">
|
<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 === '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 === '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>
|
<button :class="{ active: ctx.legacyDrafts[ctx.activeLegacyName].tab === 'history' }" @click="ctx.legacyDrafts[ctx.activeLegacyName].tab = 'history'">历史版本</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="ctx.activeLegacyName === 'media-types'" class="notice">
|
<section v-if="ctx.legacyDrafts[ctx.activeLegacyName].tab === 'form' && ctx.activeLegacyName === 'update-info'" class="page-stack">
|
||||||
生产环境不再自动依赖旧项目路径。需要以 server/update/public/media-types.json 为基板时,请切换到 Raw JSON 粘贴完整内容,校验通过后保存发布。
|
<section class="form-grid">
|
||||||
</p>
|
<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">
|
<section class="nested-card">
|
||||||
<label>版本号<input v-model="ctx.legacyDrafts['update-info'].form.app_version" /></label>
|
<div class="section-head">
|
||||||
<label>标题<input v-model="ctx.legacyDrafts['update-info'].form.title" /></label>
|
<h3>下载镜像</h3>
|
||||||
<label class="wide">公告<textarea v-model="ctx.legacyDrafts['update-info'].form.message" rows="3"></textarea></label>
|
<button class="btn ghost compact" @click="ctx.openUpdateMirrorModal()"><Plus :size="14" />新增镜像</button>
|
||||||
<label class="wide">下载地址<input v-model="ctx.legacyDrafts['update-info'].form.download_url" /></label>
|
</div>
|
||||||
<label>包 SHA256<input v-model="ctx.legacyDrafts['update-info'].form.package_sha256" /></label>
|
<table>
|
||||||
<label>包大小<input v-model="ctx.legacyDrafts['update-info'].form.package_size" type="number" /></label>
|
<thead><tr><th>ID</th><th>名称</th><th>类型</th><th>状态</th><th>URL</th><th>操作</th></tr></thead>
|
||||||
<label class="wide">发布说明<textarea v-model="ctx.legacyDrafts['update-info'].form.release_notes" rows="4"></textarea></label>
|
<tbody>
|
||||||
<label class="wide">发布说明 Markdown<textarea v-model="ctx.legacyDrafts['update-info'].form.release_notes_md" rows="5"></textarea></label>
|
<tr v-for="(mirror, index) in ctx.legacyDrafts['update-info'].form.download_mirrors || []" :key="mirror.id || index">
|
||||||
<label class="wide">更新说明 JSON<textarea v-model="ctx.legacyDrafts['update-info'].form.update_notes" class="code-editor mini-editor"></textarea></label>
|
<td class="mono">{{ mirror.id }}</td>
|
||||||
<label class="wide">上次更新说明 JSON<textarea v-model="ctx.legacyDrafts['update-info'].form.last_update_notes" class="code-editor mini-editor"></textarea></label>
|
<td>{{ mirror.name }}</td>
|
||||||
<div class="wide button-row">
|
<td>{{ mirror.type || "direct" }}</td>
|
||||||
<button class="btn ghost" @click="ctx.addUpdateMirror"><Plus :size="16" />新增镜像字段到底稿</button>
|
<td><span :class="['badge', mirror.enabled === false ? 'neutral' : 'good']">{{ mirror.enabled === false ? "停用" : "启用" }}</span></td>
|
||||||
<button class="btn" @click="ctx.updateLegacyRawFromForm('update-info')">生成预览 JSON</button>
|
<td class="hash">{{ mirror.url }}</td>
|
||||||
</div>
|
<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>
|
||||||
|
|
||||||
<section v-else-if="ctx.legacyDrafts[ctx.activeLegacyName].tab === 'form'" class="page-stack">
|
<section v-else-if="ctx.legacyDrafts[ctx.activeLegacyName].tab === 'form'" class="split legacy-media-editor">
|
||||||
<div class="form-grid">
|
<section class="panel-soft page-stack">
|
||||||
<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>
|
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<label>ID<input v-model="cat.id" /></label>
|
<label>布局版本<input v-model="ctx.legacyDrafts['media-types'].form.layout_version" /></label>
|
||||||
<label>名称<input v-model="cat.name" /></label>
|
<label>更新时间<input v-model="ctx.legacyDrafts['media-types'].form.last_updated" /></label>
|
||||||
<label class="checkbox"><input v-model="cat.enabled" type="checkbox" />启用分类</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>
|
||||||
<div class="button-row">
|
<div class="section-head">
|
||||||
<button class="btn ghost compact" @click="ctx.addMediaSubcategory(cat)"><Plus :size="14" />新增子接口</button>
|
<h3>分类</h3>
|
||||||
|
<button class="btn ghost compact" @click="ctx.openMediaCategoryModal()"><Plus :size="14" />新增分类</button>
|
||||||
</div>
|
</div>
|
||||||
<section v-for="(sub, sIndex) in cat.subcategories" :key="sIndex" class="nested-card inner">
|
<div class="category-list" v-if="ctx.legacyDrafts['media-types'].form.categories.length">
|
||||||
<div class="section-head">
|
<button
|
||||||
<h3>{{ sub.name || "子接口" }}</h3>
|
v-for="(cat, cIndex) in ctx.legacyDrafts['media-types'].form.categories"
|
||||||
<button class="btn ghost compact" @click="ctx.removeItem(cat.subcategories, sIndex)"><Trash2 :size="14" />删除</button>
|
: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>
|
||||||
<div class="form-grid">
|
<div class="button-row">
|
||||||
<label>ID<input v-model="sub.id" /></label>
|
<button class="btn ghost compact" :disabled="!ctx.activeMediaCategory" @click="ctx.openMediaCategoryModal(ctx.activeMediaCategoryIndex)"><Pencil :size="14" />编辑分类</button>
|
||||||
<label>名称<input v-model="sub.name" /></label>
|
<button class="btn ghost compact" :disabled="!ctx.activeMediaCategory" @click="ctx.openMediaSubcategoryModal(ctx.activeMediaCategoryIndex)"><Plus :size="14" />新增子接口</button>
|
||||||
<label class="wide">接口 URL<input v-model="sub.api_url" /></label>
|
<button class="btn" @click="ctx.updateLegacyRawFromForm('media-types')">生成预览 JSON</button>
|
||||||
<label>缩略图<input v-model="sub.thumbnail_url" /></label>
|
</div>
|
||||||
<label>刷新间隔<input v-model.number="sub.refresh_interval" type="number" /></label>
|
</div>
|
||||||
<label>格式<input v-model="sub.supported_formats" placeholder="json, xml" /></label>
|
<section v-if="ctx.activeMediaCategory" class="source-group">
|
||||||
<label class="checkbox"><input v-model="sub.downloadable" type="checkbox" />可下载</label>
|
<div class="button-row">
|
||||||
<label class="wide">描述<textarea v-model="sub.description" rows="2"></textarea></label>
|
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</section>
|
<div v-else class="empty-state compact">请选择或新增一个分类。</div>
|
||||||
|
</aside>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-else-if="ctx.legacyDrafts[ctx.activeLegacyName].tab === 'raw'" class="page-stack">
|
<section v-else-if="ctx.legacyDrafts[ctx.activeLegacyName].tab === 'raw'" class="page-stack">
|
||||||
@@ -102,5 +155,46 @@ defineProps<{ ctx: any }>();
|
|||||||
</button>
|
</button>
|
||||||
<div v-if="(ctx.legacyDocuments[ctx.activeLegacyName]?.revisions || []).length === 0" class="empty-state compact">暂无历史版本。</div>
|
<div v-if="(ctx.legacyDocuments[ctx.activeLegacyName]?.revisions || []).length === 0" class="empty-state compact">暂无历史版本。</div>
|
||||||
</section>
|
</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>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -56,13 +56,18 @@ defineProps<{ ctx: any }>();
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</section>
|
</section>
|
||||||
<aside class="panel editor-panel">
|
<aside class="panel editor-panel compact-side">
|
||||||
<div class="section-head"><h2>版本日志</h2><button class="btn ghost" @click="ctx.importNotices">导入目录</button></div>
|
<div class="section-head">
|
||||||
|
<div>
|
||||||
|
<h2>版本日志</h2>
|
||||||
|
<p class="muted">以 update-info.json 模板为基础动态生成更新信息。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="revision-list">
|
<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)">
|
<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>
|
<strong>{{ item.version }}</strong><small>{{ item.title || item.updatedAt }}</small>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
<label>版本<input v-model="ctx.noticeDraft.version" placeholder="1.0.0" /></label>
|
<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>
|
<label>Raw JSON<textarea v-model="ctx.noticeDraft.raw" class="code-editor compact-editor"></textarea></label>
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<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 }>();
|
defineProps<{ ctx: any }>();
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ id: "database", label: "数据库", icon: Database },
|
{ id: "database", label: "数据库", icon: Database },
|
||||||
|
{ id: "migration", label: "迁移状态", icon: HardDrive },
|
||||||
{ id: "sync", label: "旧项目同步", icon: RefreshCw },
|
{ id: "sync", label: "旧项目同步", icon: RefreshCw },
|
||||||
{ id: "security", label: "安全设置", icon: ShieldCheck },
|
{ id: "security", label: "安全与邮件", icon: ShieldCheck },
|
||||||
{ id: "health", label: "健康快照", icon: Activity },
|
{ id: "health", label: "健康快照", icon: Activity },
|
||||||
{ id: "audit", label: "审计日志", icon: ListChecks },
|
{ id: "audit", label: "审计日志", icon: ListChecks },
|
||||||
];
|
];
|
||||||
@@ -15,13 +16,7 @@ const tabs = [
|
|||||||
<template>
|
<template>
|
||||||
<section class="page-stack">
|
<section class="page-stack">
|
||||||
<nav class="tabs" aria-label="系统运维标签">
|
<nav class="tabs" aria-label="系统运维标签">
|
||||||
<button
|
<button v-for="tab in tabs" :key="tab.id" type="button" :class="{ active: ctx.systemTab === tab.id }" @click="ctx.setSystemTab(tab.id)">
|
||||||
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" />
|
<component :is="tab.icon" :size="15" />
|
||||||
{{ tab.label }}
|
{{ tab.label }}
|
||||||
</button>
|
</button>
|
||||||
@@ -35,43 +30,100 @@ const tabs = [
|
|||||||
</div>
|
</div>
|
||||||
<div class="kv-grid">
|
<div class="kv-grid">
|
||||||
<span>配置类型</span><strong>{{ ctx.database?.configProvider || "-" }}</strong>
|
<span>配置类型</span><strong>{{ ctx.database?.configProvider || "-" }}</strong>
|
||||||
|
<span>Schema</span><strong>{{ ctx.database?.schemaVersion || "-" }}</strong>
|
||||||
|
<span>活动数据库</span><strong>{{ ctx.database?.activeProvider || "-" }}</strong>
|
||||||
<span>SQLite</span><strong>{{ ctx.database?.sqliteReady ? "ready" : "missing" }}</strong>
|
<span>SQLite</span><strong>{{ ctx.database?.sqliteReady ? "ready" : "missing" }}</strong>
|
||||||
<span>MySQL</span><strong>{{ ctx.database?.remoteReady ? "ready" : "offline" }}</strong>
|
<span>MySQL</span><strong>{{ ctx.database?.remoteReady ? "ready" : "offline" }}</strong>
|
||||||
<span>Failover</span><strong>{{ ctx.database?.failoverActive ? "active" : "standby" }}</strong>
|
<span>Failover</span><strong>{{ ctx.database?.failoverActive ? "active" : "standby" }}</strong>
|
||||||
|
<span>恢复时间</span><strong>{{ ctx.database?.lastRecoveredAt || "-" }}</strong>
|
||||||
<span>最后同步</span><strong>{{ ctx.database?.lastSyncAt || "-" }}</strong>
|
<span>最后同步</span><strong>{{ ctx.database?.lastSyncAt || "-" }}</strong>
|
||||||
<span>最近错误</span><strong>{{ ctx.database?.lastSyncError || ctx.database?.lastError || "-" }}</strong>
|
<span>最近错误</span><strong>{{ ctx.database?.lastSyncError || ctx.database?.lastError || "-" }}</strong>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sync-summary">
|
<div class="sync-summary">
|
||||||
<div>
|
<div><span><ArrowDownUp :size="15" />最近方向</span><strong>{{ ctx.databaseSyncDirectionLabel(ctx.databaseLastSync?.direction) }}</strong></div>
|
||||||
<span><ArrowDownUp :size="15" />最近同步方向</span>
|
<div><span><ListChecks :size="15" />最近状态</span><strong>{{ ctx.databaseSyncStatusLabel(ctx.databaseLastSync?.status) }}</strong></div>
|
||||||
<strong>{{ ctx.databaseSyncDirectionLabel(ctx.databaseLastSync?.direction) }}</strong>
|
<div><span><Clock3 :size="15" />影响记录</span><strong>{{ ctx.databaseSyncTableCount(ctx.databaseLastSync) }}</strong></div>
|
||||||
</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>
|
</div>
|
||||||
|
<div v-if="ctx.databaseLastSync?.warnings?.length" class="ops-note">
|
||||||
<div class="ops-note">
|
|
||||||
<AlertTriangle :size="16" />
|
<AlertTriangle :size="16" />
|
||||||
<span>数据库同步是覆盖式全表 upsert。执行前确认方向:SQLite 导入远端会以本地库为源,远端同步回本地会以 MySQL 为源。</span>
|
<span>{{ ctx.databaseLastSync.warnings.join(";") }}</span>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<aside class="panel editor-panel">
|
<aside class="panel editor-panel">
|
||||||
<h2>连接与同步</h2>
|
<div class="section-head">
|
||||||
<label>Provider<select v-model="ctx.databaseForm.provider"><option>sqlite</option><option>mysql</option></select></label>
|
<h2>连接与同步</h2>
|
||||||
<label>SQLite 路径<input v-model="ctx.databaseForm.sqlitePath" placeholder="留空使用当前配置" /></label>
|
<div class="button-row compact-row">
|
||||||
<label>MySQL DSN<input v-model="ctx.databaseForm.mysqlDsn" placeholder="user:pass@tcp(host:3306)/db?parseTime=true" /></label>
|
<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">
|
<div class="button-row">
|
||||||
<button class="btn ghost" @click="ctx.testDatabase">测试连接</button>
|
<button class="btn ghost" @click="ctx.testDatabase">测试连接</button>
|
||||||
<button class="btn primary" @click="ctx.syncDatabase('import')">SQLite 导入远端</button>
|
<button class="btn primary" @click="ctx.saveDatabase"><Save :size="16" />测试并保存</button>
|
||||||
<button class="btn ghost" @click="ctx.syncDatabase('sync')">远端同步回本地</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>Schema</span><strong>{{ ctx.migrationStatus?.schemaVersion || "-" }}</strong>
|
||||||
|
<span>活动数据库</span><strong>{{ ctx.migrationStatus?.activeProvider || "-" }}</strong>
|
||||||
|
<span>MySQL</span><strong>{{ ctx.migrationStatus?.remoteReady ? "ready" : "offline" }}</strong>
|
||||||
|
<span>Failover</span><strong>{{ ctx.migrationStatus?.failoverActive ? "active" : "standby" }}</strong>
|
||||||
|
<span>最后同步</span><strong>{{ ctx.migrationStatus?.lastSyncAt || "-" }}</strong>
|
||||||
|
<span>同步错误</span><strong>{{ ctx.migrationStatus?.lastSyncError || ctx.migrationStatus?.lastError || "-" }}</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>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</section>
|
</section>
|
||||||
@@ -80,37 +132,23 @@ const tabs = [
|
|||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<div>
|
<div>
|
||||||
<h2>旧项目同步</h2>
|
<h2>旧项目同步</h2>
|
||||||
<p class="muted">预览只检查旧目录和影响范围;执行会先备份当前发布目录,再复制旧项目数据并导入反馈记录。</p>
|
<p class="muted">预览只检查旧目录和影响范围;执行前会备份当前兼容输出,再复制旧项目数据并导入记录。</p>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn ghost" @click="ctx.previewLegacySync"><RefreshCw :size="16" />刷新预览</button>
|
<button class="btn ghost" @click="ctx.previewLegacySync"><RefreshCw :size="16" />刷新预览</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sync-summary">
|
<div class="sync-summary">
|
||||||
<div>
|
<div><span>当前模式</span><strong>{{ ctx.legacySyncMode === "run" || ctx.legacySync?.dryRun === false ? "执行结果" : "Dry-run 预览" }}</strong></div>
|
||||||
<span>当前模式</span>
|
<div><span>状态</span><strong>{{ ctx.legacySync?.ok === false ? "存在错误" : "可继续" }}</strong></div>
|
||||||
<strong>{{ ctx.legacySyncMode === "run" || ctx.legacySync?.dryRun === false ? "执行结果" : "Dry-run 预览" }}</strong>
|
<div><span>完成时间</span><strong>{{ ctx.legacySync?.finishedAt || "-" }}</strong></div>
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>状态</span>
|
|
||||||
<strong>{{ ctx.legacySync?.ok === false ? "存在错误" : "可继续" }}</strong>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>完成时间</span>
|
|
||||||
<strong>{{ ctx.legacySync?.finishedAt || "-" }}</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="kv-grid">
|
<div class="kv-grid">
|
||||||
<span>复制文件</span><strong>{{ ctx.legacySync?.stats?.copiedFiles || 0 }}</strong>
|
<span>复制文件</span><strong>{{ ctx.legacySync?.stats?.copiedFiles || 0 }}</strong>
|
||||||
<span>复制目录</span><strong>{{ ctx.legacySync?.stats?.copiedDirectories || 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?.importedRows || 0 }}</strong>
|
||||||
<span>缺失路径</span><strong>{{ ctx.legacySync?.stats?.missingPaths || 0 }}</strong>
|
<span>缺失路径</span><strong>{{ ctx.legacySync?.stats?.missingPaths || 0 }}</strong>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<pre class="json-preview tall">{{ ctx.pretty(ctx.legacySync) }}</pre>
|
<pre class="json-preview tall">{{ ctx.pretty(ctx.legacySync) }}</pre>
|
||||||
<div class="button-row">
|
<div class="button-row"><button class="btn primary" @click="ctx.runLegacySync">执行旧项目同步</button></div>
|
||||||
<button class="btn primary" @click="ctx.runLegacySync">执行旧项目同步</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-else-if="ctx.systemTab === 'security'" class="split">
|
<section v-else-if="ctx.systemTab === 'security'" class="split">
|
||||||
@@ -121,15 +159,52 @@ const tabs = [
|
|||||||
<button class="btn primary" @click="ctx.changePassword"><KeyRound :size="16" />保存密码</button>
|
<button class="btn primary" @click="ctx.changePassword"><KeyRound :size="16" />保存密码</button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel page-stack">
|
<section class="panel editor-panel">
|
||||||
<div class="section-head"><h2>当前安全策略</h2><span class="badge good">已启用</span></div>
|
<div class="section-head">
|
||||||
<div class="kv-grid">
|
<h2>站点品牌</h2>
|
||||||
<span>登录保护</span><strong>验证码 + 连续失败限流</strong>
|
<span class="badge neutral">{{ ctx.branding.developerName || "YMhut" }}</span>
|
||||||
<span>写操作保护</span><strong>HttpOnly Session + CSRF Token</strong>
|
</div>
|
||||||
<span>Cookie</span><strong>HTTPS 或 X-Forwarded-Proto=https 时自动 Secure</strong>
|
<div class="brand-preview">
|
||||||
<span>会话范围</span><strong>后台 API 与 SSE 事件流均要求登录</strong>
|
<img :src="ctx.branding.siteIconUrl" alt="站点图标" />
|
||||||
<span>密码规则</span><strong>至少 8 位,不能为 admin,不能与当前密码相同</strong>
|
<img :src="ctx.branding.developerAvatarUrl" alt="开发者头像" />
|
||||||
<span>兼容哈希</span><strong>保留 SHA-256 登录兼容,后续可平滑迁移到更强算法</strong>
|
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
@@ -140,20 +215,46 @@ const tabs = [
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-else class="panel page-stack">
|
<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>
|
<table>
|
||||||
<thead><tr><th>类型</th><th>目标</th><th>信息</th><th>IP</th><th>时间</th></tr></thead>
|
<thead><tr><th>类型</th><th>目标</th><th>信息</th><th>IP</th><th>时间</th></tr></thead>
|
||||||
<tbody>
|
<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><span class="badge neutral">{{ ctx.auditTypeLabel(item.type) }}</span></td>
|
||||||
<td>{{ item.target }}</td>
|
<td>{{ item.target }}</td>
|
||||||
<td>{{ ctx.auditMessage(item) }}</td>
|
<td>{{ ctx.auditMessage(item) }}</td>
|
||||||
<td>{{ item.ip || "-" }}</td>
|
<td>{{ item.ip || "-" }}</td>
|
||||||
<td>{{ item.createdAt }}</td>
|
<td>{{ item.createdAt }}</td>
|
||||||
</tr>
|
</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>
|
</tbody>
|
||||||
</table>
|
</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>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ onMounted(() => state.load());
|
|||||||
<main class="portal-shell">
|
<main class="portal-shell">
|
||||||
<nav class="topnav">
|
<nav class="topnav">
|
||||||
<RouterLink class="brand" to="/">
|
<RouterLink class="brand" to="/">
|
||||||
<span><img src="/logo-44.png" alt="YMhut Box" /></span>
|
<span><img :src="state.branding.value.siteIconUrl" :alt="state.branding.value.developerName" /></span>
|
||||||
<strong>YMhut Box</strong>
|
<strong>{{ state.branding.value.developerName }}</strong>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<div class="nav-links">
|
<div class="nav-links">
|
||||||
<RouterLink v-for="item in navItems" :key="item.path" :to="item.path" :class="{ active: route.path === item.path }">
|
<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 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 appVersion = computed(() => releases.value?.app_version || bootstrap.value?.release?.app_version || latestNotice.value?.version || "未发布");
|
||||||
const serviceVersion = computed(() => bootstrap.value?.serviceVersion || "-");
|
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 isReady = computed(() => loaded && !loading.value && !error.value);
|
||||||
const hasPartialData = computed(() => Boolean(bootstrap.value || releases.value || sources.value || notices.value.length));
|
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);
|
const releasesEmpty = computed(() => !loading.value && packages.value.length === 0 && notices.value.length === 0);
|
||||||
@@ -123,6 +129,7 @@ export function usePortalState() {
|
|||||||
downloadUrl,
|
downloadUrl,
|
||||||
appVersion,
|
appVersion,
|
||||||
serviceVersion,
|
serviceVersion,
|
||||||
|
branding,
|
||||||
isReady,
|
isReady,
|
||||||
hasPartialData,
|
hasPartialData,
|
||||||
releasesEmpty,
|
releasesEmpty,
|
||||||
|
|||||||
@@ -25,7 +25,8 @@
|
|||||||
"png",
|
"png",
|
||||||
"webp"
|
"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",
|
"api_url": "https://v2.xxapi.cn/api/baisi?return=302",
|
||||||
@@ -40,7 +41,8 @@
|
|||||||
"png",
|
"png",
|
||||||
"webp"
|
"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",
|
"api_url": "https://v2.xxapi.cn/api/heisi?return=302",
|
||||||
@@ -55,7 +57,8 @@
|
|||||||
"png",
|
"png",
|
||||||
"webp"
|
"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",
|
"api_url": "https://api.pearapi.ai/api/beautifulgirl?type=image",
|
||||||
@@ -70,7 +73,8 @@
|
|||||||
"png",
|
"png",
|
||||||
"webp"
|
"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/",
|
"api_url": "https://apii.ctose.cn/api/cy/api/",
|
||||||
@@ -85,7 +89,8 @@
|
|||||||
"png",
|
"png",
|
||||||
"webp"
|
"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",
|
"api_url": "https://api.suyanw.cn/api/mao.php",
|
||||||
@@ -100,7 +105,8 @@
|
|||||||
"png",
|
"png",
|
||||||
"webp"
|
"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",
|
"api_url": "https://api.suyanw.cn/api/scenery.php",
|
||||||
@@ -115,9 +121,11 @@
|
|||||||
"png",
|
"png",
|
||||||
"webp"
|
"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,
|
"enabled": true,
|
||||||
@@ -143,7 +151,8 @@
|
|||||||
"mp4",
|
"mp4",
|
||||||
"webm"
|
"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",
|
"api_url": "https://api.mmp.cc/api/miss?type=mp4",
|
||||||
@@ -156,9 +165,11 @@
|
|||||||
"mp4",
|
"mp4",
|
||||||
"webm"
|
"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",
|
"last_updated": "2025-09-9T17:45:00Z",
|
||||||
@@ -172,4 +183,4 @@
|
|||||||
"default_view": "grid",
|
"default_view": "grid",
|
||||||
"show_thumbnails": false
|
"show_thumbnails": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,15 +10,15 @@
|
|||||||
"fullInstaller": {
|
"fullInstaller": {
|
||||||
"fileName": "YMhut_Box_WinUI_Setup_2.0.7.5.exe",
|
"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",
|
"url": "https://update.ymhut.cn/downloads/YMhut_Box_WinUI_Setup_2.0.7.5.exe",
|
||||||
"sha256": "3852e0f6ef98bda862b06de61788deaa836c50fcfcf9703a903bbcfbdd09ce4b",
|
"sha256": "52283178f0dce0f34eb0da81c745e7eddae761491e776b828a61dc74d1310228",
|
||||||
"size": 113480968,
|
"size": 113484192,
|
||||||
"version": "2.0.7.5"
|
"version": "2.0.7.5"
|
||||||
},
|
},
|
||||||
"msix": {
|
"msix": {
|
||||||
"fileName": "YMhutBox_2.0.7.5_x64.msix",
|
"fileName": "YMhutBox_2.0.7.5_x64.msix",
|
||||||
"url": "https://update.ymhut.cn/downloads/YMhutBox_2.0.7.5_x64.msix",
|
"url": "https://update.ymhut.cn/downloads/YMhutBox_2.0.7.5_x64.msix",
|
||||||
"sha256": "c1130c0cc5381854681d0879f168cf31bb346b589bc916a458552763b1fa47db",
|
"sha256": "388bc5374023bb7d71e42e4c88915b0d59935a06b0be40d7eab6244832220228",
|
||||||
"size": 259959751,
|
"size": 259968386,
|
||||||
"version": "2.0.7.5"
|
"version": "2.0.7.5"
|
||||||
},
|
},
|
||||||
"appInstaller": {
|
"appInstaller": {
|
||||||
@@ -32,15 +32,15 @@
|
|||||||
"fullInstaller": {
|
"fullInstaller": {
|
||||||
"fileName": "YMhut_Box_WinUI_Setup_2.0.7.5.exe",
|
"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",
|
"url": "https://update.ymhut.cn/downloads/YMhut_Box_WinUI_Setup_2.0.7.5.exe",
|
||||||
"sha256": "3852e0f6ef98bda862b06de61788deaa836c50fcfcf9703a903bbcfbdd09ce4b",
|
"sha256": "52283178f0dce0f34eb0da81c745e7eddae761491e776b828a61dc74d1310228",
|
||||||
"size": 113480968,
|
"size": 113484192,
|
||||||
"version": "2.0.7.5"
|
"version": "2.0.7.5"
|
||||||
},
|
},
|
||||||
"msix": {
|
"msix": {
|
||||||
"fileName": "YMhutBox_2.0.7.5_x64.msix",
|
"fileName": "YMhutBox_2.0.7.5_x64.msix",
|
||||||
"url": "https://update.ymhut.cn/downloads/YMhutBox_2.0.7.5_x64.msix",
|
"url": "https://update.ymhut.cn/downloads/YMhutBox_2.0.7.5_x64.msix",
|
||||||
"sha256": "c1130c0cc5381854681d0879f168cf31bb346b589bc916a458552763b1fa47db",
|
"sha256": "388bc5374023bb7d71e42e4c88915b0d59935a06b0be40d7eab6244832220228",
|
||||||
"size": 259959751,
|
"size": 259968386,
|
||||||
"version": "2.0.7.5"
|
"version": "2.0.7.5"
|
||||||
},
|
},
|
||||||
"appInstaller": {
|
"appInstaller": {
|
||||||
@@ -56,5 +56,5 @@
|
|||||||
"updateInfo": "The official update-info catalog only describes the full offline installer, MSIX, and appinstaller artifacts.",
|
"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."
|
"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"
|
||||||
}
|
}
|
||||||
@@ -7,7 +7,7 @@ namespace YMhut.Box.Core.Feedback;
|
|||||||
|
|
||||||
public sealed class FeedbackSubmissionService(HttpClient? httpClient = null) : IFeedbackSubmissionService
|
public sealed class FeedbackSubmissionService(HttpClient? httpClient = null) : IFeedbackSubmissionService
|
||||||
{
|
{
|
||||||
public const string Endpoint = "https://mail-smtp.ymhut.cn/";
|
public const string Endpoint = "https://update.ymhut.cn/";
|
||||||
public const string ClientSignatureKey = "ymhut-box-feedback-client-v1";
|
public const string ClientSignatureKey = "ymhut-box-feedback-client-v1";
|
||||||
private readonly HttpClient _httpClient = httpClient ?? new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
|
private readonly HttpClient _httpClient = httpClient ?? new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
|
||||||
|
|
||||||
|
|||||||
@@ -61,13 +61,20 @@ public sealed record RemoteMediaSource(
|
|||||||
string Name,
|
string Name,
|
||||||
string Description,
|
string Description,
|
||||||
string ApiUrl,
|
string ApiUrl,
|
||||||
|
string ResolvedUrl,
|
||||||
|
string ResolvedKey,
|
||||||
|
string MediaType,
|
||||||
string ThumbnailUrl,
|
string ThumbnailUrl,
|
||||||
bool Downloadable,
|
bool Downloadable,
|
||||||
int RefreshIntervalSeconds,
|
int RefreshIntervalSeconds,
|
||||||
IReadOnlyList<string> SupportedFormats,
|
IReadOnlyList<string> SupportedFormats,
|
||||||
RemoteMediaKind Kind)
|
RemoteMediaKind Kind)
|
||||||
{
|
{
|
||||||
public bool IsAvailable => Uri.TryCreate(ApiUrl, UriKind.Absolute, out _);
|
public string EffectiveApiUrl => string.IsNullOrWhiteSpace(ResolvedUrl) ? ApiUrl : ResolvedUrl;
|
||||||
|
|
||||||
|
public string RefreshApiUrl => string.IsNullOrWhiteSpace(ApiUrl) ? EffectiveApiUrl : ApiUrl;
|
||||||
|
|
||||||
|
public bool IsAvailable => Uri.TryCreate(EffectiveApiUrl, UriKind.Absolute, out _);
|
||||||
|
|
||||||
public string DisplayName => RemoteMediaCatalogNames.SourceName(Id, Name);
|
public string DisplayName => RemoteMediaCatalogNames.SourceName(Id, Name);
|
||||||
|
|
||||||
@@ -132,7 +139,11 @@ public static class RemoteMediaCatalogParser
|
|||||||
}
|
}
|
||||||
|
|
||||||
var id = JsonString(categoryElement, "id");
|
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);
|
var sources = ParseSources(categoryElement, categoryKind);
|
||||||
if (categoryKind == RemoteMediaKind.Unknown)
|
if (categoryKind == RemoteMediaKind.Unknown)
|
||||||
{
|
{
|
||||||
@@ -316,7 +327,12 @@ public static class RemoteMediaCatalogParser
|
|||||||
|
|
||||||
var id = JsonString(sourceElement, "id");
|
var id = JsonString(sourceElement, "id");
|
||||||
var formats = JsonStringArray(sourceElement, "supported_formats", "supportedFormats");
|
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)
|
if (kind == RemoteMediaKind.Unknown)
|
||||||
{
|
{
|
||||||
kind = categoryKind;
|
kind = categoryKind;
|
||||||
@@ -328,13 +344,18 @@ public static class RemoteMediaCatalogParser
|
|||||||
}
|
}
|
||||||
|
|
||||||
var apiUrl = JsonString(sourceElement, "api_url", "apiUrl", "url");
|
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");
|
var thumbnailUrl = JsonString(sourceElement, "thumbnail_url", "thumbnailUrl", "thumbnail", "cover");
|
||||||
sources.Add(new RemoteMediaSource(
|
sources.Add(new RemoteMediaSource(
|
||||||
Id: string.IsNullOrWhiteSpace(id) ? $"source-{sources.Count + 1}" : id,
|
Id: string.IsNullOrWhiteSpace(id) ? $"source-{sources.Count + 1}" : id,
|
||||||
Name: JsonString(sourceElement, "name"),
|
Name: JsonString(sourceElement, "name"),
|
||||||
Description: JsonString(sourceElement, "description"),
|
Description: JsonString(sourceElement, "description"),
|
||||||
ApiUrl: apiUrl,
|
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"),
|
Downloadable: JsonBool(sourceElement, true, "downloadable"),
|
||||||
RefreshIntervalSeconds: NormalizedRefreshInterval(sourceElement, kind),
|
RefreshIntervalSeconds: NormalizedRefreshInterval(sourceElement, kind),
|
||||||
SupportedFormats: formats,
|
SupportedFormats: formats,
|
||||||
@@ -437,6 +458,29 @@ public static class RemoteMediaCatalogParser
|
|||||||
return kind;
|
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)
|
private static string JsonString(JsonElement root, params string[] names)
|
||||||
{
|
{
|
||||||
if (!TryGet(root, out var value, names))
|
if (!TryGet(root, out var value, names))
|
||||||
|
|||||||
@@ -22,8 +22,12 @@ public sealed class RemoteMediaCatalogService(
|
|||||||
ILogService? logService = null) : IRemoteMediaCatalogService
|
ILogService? logService = null) : IRemoteMediaCatalogService
|
||||||
{
|
{
|
||||||
public static readonly Uri PrimaryConfigUri = new("https://update.ymhut.cn/media-types.json");
|
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 EndpointId = "media_types";
|
||||||
|
private const string BootstrapEndpointId = "client_bootstrap";
|
||||||
|
private const string SourcesEndpointId = "client_sources";
|
||||||
private const string SnapshotFileName = "media-types.json";
|
private const string SnapshotFileName = "media-types.json";
|
||||||
|
|
||||||
public string CacheDirectory => Path.Combine(paths.Cache, "remote-media");
|
public string CacheDirectory => Path.Combine(paths.Cache, "remote-media");
|
||||||
@@ -35,18 +39,7 @@ public sealed class RemoteMediaCatalogService(
|
|||||||
string? warning = null;
|
string? warning = null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var response = forceRefresh
|
return await LoadRemoteCatalogAsync(forceRefresh, cancellationToken).ConfigureAwait(false);
|
||||||
? 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);
|
|
||||||
}
|
}
|
||||||
catch (Exception exception) when (exception is HttpRequestException or IOException or JsonException or InvalidDataException or TaskCanceledException or InvalidOperationException)
|
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.");
|
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)
|
public async Task<RemoteMediaCatalogLoadResult?> TryReadCacheAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
if (!File.Exists(SnapshotPath))
|
if (!File.Exists(SnapshotPath))
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ public sealed class RemoteMediaResolver : IRemoteMediaResolver
|
|||||||
{
|
{
|
||||||
private const int MaxMediaRedirects = 8;
|
private const int MaxMediaRedirects = 8;
|
||||||
private const long MaxTextProbeLength = 2 * 1024 * 1024;
|
private const long MaxTextProbeLength = 2 * 1024 * 1024;
|
||||||
|
private static long CacheBustSequence;
|
||||||
private static readonly Regex AbsoluteUrlRegex = new(@"https?://[^\s""'<>\\]+", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
private static readonly Regex AbsoluteUrlRegex = new(@"https?://[^\s""'<>\\]+", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||||
|
|
||||||
private readonly Func<HttpMessageHandler>? _handlerFactory;
|
private readonly Func<HttpMessageHandler>? _handlerFactory;
|
||||||
@@ -133,7 +134,7 @@ public sealed class RemoteMediaResolver : IRemoteMediaResolver
|
|||||||
}
|
}
|
||||||
catch when (!cancellationToken.IsCancellationRequested)
|
catch when (!cancellationToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
return lastResolution ?? FromUriOnly(current, expectedKind);
|
return lastResolution ?? FromProbeFailure(current, expectedKind);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,6 +162,14 @@ public sealed class RemoteMediaResolver : IRemoteMediaResolver
|
|||||||
AddHeader(client.DefaultRequestHeaders.UserAgent, "YMhutBox/2.0");
|
AddHeader(client.DefaultRequestHeaders.UserAgent, "YMhutBox/2.0");
|
||||||
client.DefaultRequestHeaders.Accept.ParseAdd("image/*, video/*, audio/*, application/json, text/plain, text/html, application/octet-stream, */*");
|
client.DefaultRequestHeaders.Accept.ParseAdd("image/*, video/*, audio/*, application/json, text/plain, text/html, application/octet-stream, */*");
|
||||||
client.DefaultRequestHeaders.AcceptLanguage.ParseAdd("zh-CN,zh;q=0.9,en;q=0.8");
|
client.DefaultRequestHeaders.AcceptLanguage.ParseAdd("zh-CN,zh;q=0.9,en;q=0.8");
|
||||||
|
client.DefaultRequestHeaders.CacheControl = new CacheControlHeaderValue
|
||||||
|
{
|
||||||
|
NoCache = true,
|
||||||
|
NoStore = true,
|
||||||
|
MaxAge = TimeSpan.Zero
|
||||||
|
};
|
||||||
|
client.DefaultRequestHeaders.Pragma.ParseAdd("no-cache");
|
||||||
|
client.DefaultRequestHeaders.IfModifiedSince = DateTimeOffset.UnixEpoch;
|
||||||
return client;
|
return client;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,7 +189,9 @@ public sealed class RemoteMediaResolver : IRemoteMediaResolver
|
|||||||
}
|
}
|
||||||
|
|
||||||
var separator = url.Contains('?', StringComparison.Ordinal) ? '&' : '?';
|
var separator = url.Contains('?', StringComparison.Ordinal) ? '&' : '?';
|
||||||
return $"{url}{separator}_={DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}";
|
var sequence = global::System.Threading.Interlocked.Increment(ref CacheBustSequence);
|
||||||
|
var token = $"{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}-{sequence}";
|
||||||
|
return $"{url}{separator}_={Uri.EscapeDataString(token)}";
|
||||||
}
|
}
|
||||||
|
|
||||||
private static RemoteMediaResolution FromUriOnly(Uri uri, RemoteMediaKind expectedKind)
|
private static RemoteMediaResolution FromUriOnly(Uri uri, RemoteMediaKind expectedKind)
|
||||||
@@ -194,9 +205,20 @@ public sealed class RemoteMediaResolver : IRemoteMediaResolver
|
|||||||
extension);
|
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)
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -252,8 +274,23 @@ public sealed class RemoteMediaResolver : IRemoteMediaResolver
|
|||||||
private static bool IsImageExtension(string extension)
|
private static bool IsImageExtension(string extension)
|
||||||
=> extension is "png" or "jpg" or "jpeg" or "bmp" or "gif" or "webp" or "tif" or "tiff";
|
=> 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) ||
|
return contentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase) ||
|
||||||
contentType.StartsWith("video/", StringComparison.OrdinalIgnoreCase) ||
|
contentType.StartsWith("video/", StringComparison.OrdinalIgnoreCase) ||
|
||||||
contentType.StartsWith("audio/", StringComparison.OrdinalIgnoreCase);
|
contentType.StartsWith("audio/", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|||||||
@@ -84,6 +84,8 @@ public sealed class AppSettings
|
|||||||
|
|
||||||
public bool UpdateNotification { get; set; } = true;
|
public bool UpdateNotification { get; set; } = true;
|
||||||
|
|
||||||
|
public double RandomCinemaVolumePercent { get; set; } = 70;
|
||||||
|
|
||||||
public bool HardwareAccelerationEnabled { get; set; } = true;
|
public bool HardwareAccelerationEnabled { get; set; } = true;
|
||||||
|
|
||||||
public int ProxyTestTimeoutSeconds { get; set; } = 6;
|
public int ProxyTestTimeoutSeconds { get; set; } = 6;
|
||||||
|
|||||||
@@ -168,6 +168,12 @@ public sealed class FeedbackServiceTests
|
|||||||
Assert.AreEqual(signature, FeedbackSubmissionService.Sign("1760000000", "abc123", new string('a', 64), "{\"ok\":true}"));
|
Assert.AreEqual(signature, FeedbackSubmissionService.Sign("1760000000", "abc123", new string('a', 64), "{\"ok\":true}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void FeedbackSubmissionUsesUnifiedManagementEndpoint()
|
||||||
|
{
|
||||||
|
Assert.AreEqual("https://update.ymhut.cn/", FeedbackSubmissionService.Endpoint);
|
||||||
|
}
|
||||||
|
|
||||||
[TestMethod]
|
[TestMethod]
|
||||||
public void FeedbackStatusResponseParsesExtendedServerFields()
|
public void FeedbackStatusResponseParsesExtendedServerFields()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -18,15 +18,19 @@ public sealed class RemoteMediaCatalogTests
|
|||||||
|
|
||||||
var image = catalog.Categories.Single(category => category.Id == "image");
|
var image = catalog.Categories.Single(category => category.Id == "image");
|
||||||
Assert.IsTrue(image.Enabled);
|
Assert.IsTrue(image.Enabled);
|
||||||
|
Assert.AreEqual("随机图片", image.DisplayName);
|
||||||
Assert.AreEqual(RemoteMediaKind.Image, image.Kind);
|
Assert.AreEqual(RemoteMediaKind.Image, image.Kind);
|
||||||
Assert.IsTrue(image.Layout.ShowPreview);
|
Assert.IsTrue(image.Layout.ShowPreview);
|
||||||
CollectionAssert.Contains(image.Sources.First(source => source.Id == "xjj").SupportedFormats.ToArray(), "jpg");
|
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(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");
|
var video = catalog.Categories.Single(category => category.Id == "video");
|
||||||
|
Assert.AreEqual("随机视频", video.DisplayName);
|
||||||
Assert.AreEqual(RemoteMediaKind.Video, video.Kind);
|
Assert.AreEqual(RemoteMediaKind.Video, video.Kind);
|
||||||
Assert.IsFalse(video.Layout.AutoPlay);
|
Assert.IsFalse(video.Layout.AutoPlay);
|
||||||
CollectionAssert.Contains(video.Sources.First().SupportedFormats.ToArray(), "mp4");
|
CollectionAssert.Contains(video.Sources.First().SupportedFormats.ToArray(), "mp4");
|
||||||
|
Assert.AreEqual("video", video.Sources.First().MediaType);
|
||||||
}
|
}
|
||||||
|
|
||||||
[TestMethod]
|
[TestMethod]
|
||||||
@@ -75,6 +79,75 @@ public sealed class RemoteMediaCatalogTests
|
|||||||
Assert.AreEqual("https://example.test/media", source.ThumbnailUrl);
|
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.AreEqual(source.ApiUrl, source.RefreshApiUrl);
|
||||||
|
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]
|
[TestMethod]
|
||||||
public async Task ServiceWritesReadsFallsBackAndClearsCache()
|
public async Task ServiceWritesReadsFallsBackAndClearsCache()
|
||||||
{
|
{
|
||||||
@@ -112,6 +185,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)
|
private static string ReadRepoFile(params string[] segments)
|
||||||
{
|
{
|
||||||
var directory = new DirectoryInfo(Directory.GetCurrentDirectory());
|
var directory = new DirectoryInfo(Directory.GetCurrentDirectory());
|
||||||
@@ -129,7 +254,7 @@ public sealed class RemoteMediaCatalogTests
|
|||||||
throw new DirectoryNotFoundException("Unable to locate repository sample file.");
|
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; }
|
public Uri? LastUri { get; private set; }
|
||||||
|
|
||||||
@@ -149,14 +274,18 @@ public sealed class RemoteMediaCatalogTests
|
|||||||
public Task<ApiResponse> FetchUriAsync(string endpointId, Uri uri, string input = "", CancellationToken cancellationToken = default)
|
public Task<ApiResponse> FetchUriAsync(string endpointId, Uri uri, string input = "", CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
LastUri = uri;
|
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(
|
return Task.FromResult(new ApiResponse(
|
||||||
endpointId,
|
endpointId,
|
||||||
uri,
|
uri,
|
||||||
success,
|
responseSuccess,
|
||||||
success ? content : string.Empty,
|
responseSuccess ? responseContent : string.Empty,
|
||||||
success ? null : "offline",
|
responseSuccess ? null : "offline",
|
||||||
DateTimeOffset.Now,
|
DateTimeOffset.Now,
|
||||||
success ? 200 : 0));
|
responseSuccess ? 200 : 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<ApiHealthStatus> CheckHealthAsync(string endpointId, string input = "", CancellationToken cancellationToken = default)
|
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);
|
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]
|
[TestMethod]
|
||||||
public async Task TreatsDirectMediaContentTypeAsPlayable()
|
public async Task TreatsDirectMediaContentTypeAsPlayable()
|
||||||
{
|
{
|
||||||
@@ -97,6 +121,29 @@ public sealed class RemoteMediaResolverTests
|
|||||||
Assert.AreEqual(".mp3", result.SuggestedExtension);
|
Assert.AreEqual(".mp3", result.SuggestedExtension);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public async Task CacheBustsEachRefreshAndDisablesHttpCaches()
|
||||||
|
{
|
||||||
|
var observed = new List<ObservedRequest>();
|
||||||
|
var resolver = CreateResolver(request =>
|
||||||
|
{
|
||||||
|
observed.Add(ObservedRequest.From(request));
|
||||||
|
return Text(HttpStatusCode.OK, string.Empty, "image/jpeg");
|
||||||
|
});
|
||||||
|
|
||||||
|
await resolver.ResolveMediaAsync("https://example.test/random", RemoteMediaKind.Image);
|
||||||
|
await resolver.ResolveMediaAsync("https://example.test/random", RemoteMediaKind.Image);
|
||||||
|
|
||||||
|
var randomRequests = observed.Where(item => item.Uri.AbsolutePath == "/random").ToArray();
|
||||||
|
Assert.IsTrue(randomRequests.Length >= 4);
|
||||||
|
Assert.AreNotEqual(randomRequests[0].Uri.Query, randomRequests[2].Uri.Query);
|
||||||
|
Assert.IsTrue(randomRequests.All(item => item.NoCache));
|
||||||
|
Assert.IsTrue(randomRequests.All(item => item.NoStore));
|
||||||
|
Assert.IsTrue(randomRequests.All(item => item.MaxAge == TimeSpan.Zero));
|
||||||
|
Assert.IsTrue(randomRequests.All(item => item.PragmaNoCache));
|
||||||
|
Assert.IsTrue(randomRequests.All(item => item.IfModifiedSince == DateTimeOffset.UnixEpoch));
|
||||||
|
}
|
||||||
|
|
||||||
private static RemoteMediaResolver CreateResolver(Func<HttpRequestMessage, HttpResponseMessage> responseFactory)
|
private static RemoteMediaResolver CreateResolver(Func<HttpRequestMessage, HttpResponseMessage> responseFactory)
|
||||||
{
|
{
|
||||||
return new RemoteMediaResolver(() => new StubHttpHandler(responseFactory));
|
return new RemoteMediaResolver(() => new StubHttpHandler(responseFactory));
|
||||||
@@ -126,4 +173,22 @@ public sealed class RemoteMediaResolverTests
|
|||||||
return Task.FromResult(response);
|
return Task.FromResult(response);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed record ObservedRequest(
|
||||||
|
Uri Uri,
|
||||||
|
bool NoCache,
|
||||||
|
bool NoStore,
|
||||||
|
TimeSpan? MaxAge,
|
||||||
|
bool PragmaNoCache,
|
||||||
|
DateTimeOffset? IfModifiedSince)
|
||||||
|
{
|
||||||
|
public static ObservedRequest From(HttpRequestMessage request)
|
||||||
|
=> new(
|
||||||
|
request.RequestUri!,
|
||||||
|
request.Headers.CacheControl?.NoCache == true,
|
||||||
|
request.Headers.CacheControl?.NoStore == true,
|
||||||
|
request.Headers.CacheControl?.MaxAge,
|
||||||
|
request.Headers.Pragma.Any(value => string.Equals(value.Name, "no-cache", StringComparison.OrdinalIgnoreCase)),
|
||||||
|
request.Headers.IfModifiedSince);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -708,26 +708,26 @@ public sealed class ToolExecutorTests
|
|||||||
public void UpdateNoticeJsonKeepsPlainTextAndAddsMarkdown()
|
public void UpdateNoticeJsonKeepsPlainTextAndAddsMarkdown()
|
||||||
{
|
{
|
||||||
var repoRoot = Directory.GetParent(FindAssetsRoot())!.FullName;
|
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");
|
var totalPath = Path.Combine(repoRoot, "update-notice", "total.json");
|
||||||
|
|
||||||
using var notice = JsonDocument.Parse(File.ReadAllText(noticePath));
|
using var notice = JsonDocument.Parse(File.ReadAllText(noticePath));
|
||||||
var noticeRoot = notice.RootElement;
|
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("message").GetString()));
|
||||||
Assert.IsFalse(string.IsNullOrWhiteSpace(noticeRoot.GetProperty("release_notes").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("message_md").GetString(), "YMhut Box 2.0.7.5");
|
||||||
StringAssert.Contains(noticeRoot.GetProperty("release_notes_md").GetString(), "QQ 信息");
|
StringAssert.Contains(noticeRoot.GetProperty("release_notes_md").GetString(), "随机放映室");
|
||||||
StringAssert.Contains(noticeRoot.GetProperty("release_notes_md").GetString(), "安全浏览器");
|
StringAssert.Contains(noticeRoot.GetProperty("release_notes_md").GetString(), "音量控制");
|
||||||
|
|
||||||
using var total = JsonDocument.Parse(File.ReadAllText(totalPath));
|
using var total = JsonDocument.Parse(File.ReadAllText(totalPath));
|
||||||
var totalRoot = total.RootElement;
|
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");
|
var latest = totalRoot.GetProperty("latest");
|
||||||
Assert.AreEqual("2.0.6.3", latest.GetProperty("version").GetString());
|
Assert.AreEqual("2.0.7.5", latest.GetProperty("version").GetString());
|
||||||
StringAssert.Contains(latest.GetProperty("release_notes_md").GetString(), "QQ 信息");
|
StringAssert.Contains(latest.GetProperty("release_notes_md").GetString(), "随机放映室");
|
||||||
Assert.AreEqual("2.0.6.3", totalRoot.GetProperty("versions")[0].GetProperty("version").GetString());
|
Assert.AreEqual("2.0.7.5", totalRoot.GetProperty("versions")[0].GetProperty("version").GetString());
|
||||||
StringAssert.Contains(totalRoot.GetProperty("versions")[0].GetProperty("summary").GetString(), "QQ 信息");
|
StringAssert.Contains(totalRoot.GetProperty("versions")[0].GetProperty("summary").GetString(), "随机放映室");
|
||||||
}
|
}
|
||||||
|
|
||||||
[TestMethod]
|
[TestMethod]
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 6.4 KiB |
@@ -33,6 +33,12 @@ html[data-theme="dark"] {
|
|||||||
|
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.drag-light *,
|
.drag-light *,
|
||||||
.drag-light *::before,
|
.drag-light *::before,
|
||||||
.drag-light *::after {
|
.drag-light *::after {
|
||||||
@@ -63,6 +69,9 @@ button {
|
|||||||
padding: 7px 12px;
|
padding: 7px 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: border-color 160ms ease, background-color 160ms ease, color 160ms ease;
|
transition: border-color 160ms ease, background-color 160ms ease, color 160ms ease;
|
||||||
|
max-width: 100%;
|
||||||
|
white-space: normal;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
button:hover,
|
button:hover,
|
||||||
@@ -85,6 +94,8 @@ button.compact {
|
|||||||
.result-page {
|
.result-page {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.result-chrome,
|
.result-chrome,
|
||||||
@@ -104,6 +115,7 @@ button.compact {
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 14px 16px;
|
padding: 14px 16px;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.result-chrome .identity {
|
.result-chrome .identity {
|
||||||
@@ -134,6 +146,7 @@ button.compact {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: clamp(1.05rem, 1.25vw, 1.45rem);
|
font-size: clamp(1.05rem, 1.25vw, 1.45rem);
|
||||||
line-height: 1.18;
|
line-height: 1.18;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.result-chrome .meta,
|
.result-chrome .meta,
|
||||||
@@ -146,6 +159,7 @@ button.compact {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.result-chrome .metrics {
|
.result-chrome .metrics {
|
||||||
@@ -156,6 +170,7 @@ button.compact {
|
|||||||
.blocks {
|
.blocks {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.details-drawer {
|
.details-drawer {
|
||||||
@@ -307,6 +322,7 @@ h2 {
|
|||||||
.block {
|
.block {
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.side-card {
|
.side-card {
|
||||||
@@ -328,6 +344,17 @@ h2 {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.block-title h2,
|
||||||
|
.paragraph,
|
||||||
|
.muted,
|
||||||
|
.meta,
|
||||||
|
.item-body,
|
||||||
|
.kv,
|
||||||
|
.metric,
|
||||||
|
.badge {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
.block-icon {
|
.block-icon {
|
||||||
width: 20px;
|
width: 20px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
@@ -347,6 +374,8 @@ h2 {
|
|||||||
background: var(--accent-soft);
|
background: var(--accent-soft);
|
||||||
font-size: .74rem;
|
font-size: .74rem;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
|
max-width: 100%;
|
||||||
|
white-space: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
.kv-grid {
|
.kv-grid {
|
||||||
@@ -378,6 +407,7 @@ h2 {
|
|||||||
.list-item strong {
|
.list-item strong {
|
||||||
display: block;
|
display: block;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.metric-grid .primary-kv {
|
.metric-grid .primary-kv {
|
||||||
@@ -394,6 +424,9 @@ h2 {
|
|||||||
|
|
||||||
.table-wrap {
|
.table-wrap {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: min(58vh, 560px);
|
||||||
|
scrollbar-gutter: stable;
|
||||||
}
|
}
|
||||||
|
|
||||||
table {
|
table {
|
||||||
@@ -408,6 +441,10 @@ th {
|
|||||||
border-bottom: 1px solid var(--line);
|
border-bottom: 1px solid var(--line);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
|
min-width: 120px;
|
||||||
|
max-width: 520px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
th {
|
th {
|
||||||
@@ -432,6 +469,7 @@ th {
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ranked-item {
|
.ranked-item {
|
||||||
@@ -483,6 +521,9 @@ th {
|
|||||||
.rank-core {
|
.rank-core {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rank-1 {
|
.rank-1 {
|
||||||
@@ -510,6 +551,7 @@ th {
|
|||||||
|
|
||||||
.item-body {
|
.item-body {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-body strong {
|
.item-body strong {
|
||||||
@@ -542,21 +584,29 @@ th {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.code-wrap {
|
.code-wrap {
|
||||||
max-height: 420px;
|
max-height: min(62vh, 560px);
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
background: var(--panel-strong);
|
background: var(--panel-strong);
|
||||||
|
max-width: 100%;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
}
|
}
|
||||||
|
|
||||||
pre {
|
pre {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
font-family: "Cascadia Mono", Consolas, monospace;
|
font-family: "Cascadia Mono", Consolas, monospace;
|
||||||
font-size: .84rem;
|
font-size: .84rem;
|
||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.limit-note {
|
||||||
|
padding: 8px 12px 10px;
|
||||||
|
font-size: .78rem;
|
||||||
|
}
|
||||||
|
|
||||||
.media-frame {
|
.media-frame {
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
|
|||||||
@@ -42,6 +42,19 @@ const labels = {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const LIMITS = {
|
||||||
|
keyValues: 80,
|
||||||
|
tableRows: 160,
|
||||||
|
tableColumns: 8,
|
||||||
|
tableCellChars: 600,
|
||||||
|
listItems: 180,
|
||||||
|
titleChars: 220,
|
||||||
|
subtitleChars: 900,
|
||||||
|
badgeChars: 28,
|
||||||
|
codeChars: 52000,
|
||||||
|
rawChars: 90000
|
||||||
|
};
|
||||||
|
|
||||||
window.chrome?.webview?.addEventListener('message', event => receive(event.data));
|
window.chrome?.webview?.addEventListener('message', event => receive(event.data));
|
||||||
|
|
||||||
window.addEventListener('message', event => {
|
window.addEventListener('message', event => {
|
||||||
@@ -82,6 +95,15 @@ function text(value) {
|
|||||||
return value === null || value === undefined ? '' : String(value);
|
return value === null || value === undefined ? '' : String(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clampText(value, max = 0) {
|
||||||
|
const output = text(value);
|
||||||
|
return max > 0 && output.length > max ? `${output.slice(0, max)}...` : output;
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewText(value, policy, max) {
|
||||||
|
return clampText(redact(value, policy), max);
|
||||||
|
}
|
||||||
|
|
||||||
function el(tag, className, content) {
|
function el(tag, className, content) {
|
||||||
const node = document.createElement(tag);
|
const node = document.createElement(tag);
|
||||||
if (className) node.className = className;
|
if (className) node.className = className;
|
||||||
@@ -147,35 +169,58 @@ function metrics(data) {
|
|||||||
return root;
|
return root;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const blockRenderers = {
|
||||||
|
keyvalue: (block, data, kind) => kvGrid(block.pairs || [], data, kind),
|
||||||
|
metric: (block, data, kind) => kvGrid(block.pairs || [], data, kind),
|
||||||
|
table: (block, data) => table(block.rows || [], data),
|
||||||
|
linechart: (block, data) => table(block.rows || [], data),
|
||||||
|
rankedlist: (block, data, kind) => list(block.items || [], data, kind),
|
||||||
|
newslist: (block, data, kind) => list(block.items || [], data, kind),
|
||||||
|
cardlist: (block, data, kind) => list(block.items || [], data, kind),
|
||||||
|
text: (block, data) => statusList(block.items || [], data, block.text),
|
||||||
|
diff: (block, data) => statusList(block.items || [], data, block.text),
|
||||||
|
status: (block, data) => statusList(block.items || [], data, block.text),
|
||||||
|
timeline: (block, data) => statusList(block.items || [], data, block.text),
|
||||||
|
code: (block, data) => code(redact(block.text || '', data.privacyPolicy)),
|
||||||
|
json: (block, data) => code(redact(block.text || '', data.privacyPolicy)),
|
||||||
|
jsontree: (block, data) => code(redact(block.text || '', data.privacyPolicy)),
|
||||||
|
raw: (block, data) => code(redact(block.text || '', data.privacyPolicy), LIMITS.rawChars),
|
||||||
|
file: fileBlock,
|
||||||
|
link: linkBlock,
|
||||||
|
image: mediaBlock,
|
||||||
|
media: mediaBlock,
|
||||||
|
color: colorBlock
|
||||||
|
};
|
||||||
|
|
||||||
function renderBlock(block, data) {
|
function renderBlock(block, data) {
|
||||||
const kind = (block.kind || '').toLowerCase();
|
const kind = (block.kind || '').toLowerCase();
|
||||||
const root = el('article', `block kind-${kind || 'text'}`);
|
const root = el('article', `block kind-${kind || 'text'}`);
|
||||||
const head = el('div', 'block-head');
|
const head = el('div', 'block-head');
|
||||||
const title = el('div', 'block-title');
|
const title = el('div', 'block-title');
|
||||||
title.append(blockIcon(kind), el('h2', '', block.title || kindLabel(kind)));
|
title.append(blockIcon(kind), el('h2', '', clampText(block.title || kindLabel(kind), LIMITS.titleChars)));
|
||||||
head.append(title, el('span', 'badge', kindLabel(kind)));
|
head.append(title, el('span', 'badge', kindLabel(kind)));
|
||||||
root.append(head);
|
root.append(head);
|
||||||
|
|
||||||
if (kind === 'keyvalue' || kind === 'metric') root.append(kvGrid(block.pairs || [], data, kind));
|
const renderer = blockRenderers[kind] || paragraphBlock;
|
||||||
else if (kind === 'table' || kind === 'linechart') root.append(table(block.rows || [], data));
|
root.append(renderer(block, data, kind));
|
||||||
else if (['rankedlist', 'newslist', 'cardlist'].includes(kind)) root.append(list(block.items || [], data, kind));
|
|
||||||
else if (['text', 'diff', 'status', 'timeline'].includes(kind)) root.append(statusList(block.items || [], data, block.text));
|
|
||||||
else if (['code', 'json', 'jsontree', 'raw'].includes(kind)) root.append(code(redact(block.text || '', data.privacyPolicy)));
|
|
||||||
else if (kind === 'file') root.append(fileBlock(block, data));
|
|
||||||
else if (kind === 'link') root.append(linkBlock(block, data));
|
|
||||||
else if (kind === 'image' || kind === 'media') root.append(mediaBlock(block, data));
|
|
||||||
else if (kind === 'color') root.append(colorBlock(block));
|
|
||||||
else root.append(el('p', 'paragraph', redact(block.text || '', data.privacyPolicy)));
|
|
||||||
return root;
|
return root;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function paragraphBlock(block, data) {
|
||||||
|
return el('p', 'paragraph', previewText(block.text || '', data.privacyPolicy, LIMITS.subtitleChars));
|
||||||
|
}
|
||||||
|
|
||||||
function kvGrid(pairs, data, kind) {
|
function kvGrid(pairs, data, kind) {
|
||||||
const grid = el('div', `kv-grid ${kind === 'metric' ? 'metric-grid' : ''}`);
|
const grid = el('div', `kv-grid ${kind === 'metric' ? 'metric-grid' : ''}`);
|
||||||
for (const [index, pair] of pairs.slice(0, 80).entries()) {
|
for (const [index, pair] of pairs.slice(0, LIMITS.keyValues).entries()) {
|
||||||
const item = el('div', `kv ${index === 0 && kind === 'metric' ? 'primary-kv' : ''}`);
|
const item = el('div', `kv ${index === 0 && kind === 'metric' ? 'primary-kv' : ''}`);
|
||||||
item.append(el('span', '', pair.key || ''), el('strong', '', redact(pair.value || '', data.privacyPolicy)));
|
item.append(
|
||||||
|
el('span', '', clampText(pair.key || '', LIMITS.titleChars)),
|
||||||
|
el('strong', '', previewText(pair.value || '', data.privacyPolicy, LIMITS.subtitleChars))
|
||||||
|
);
|
||||||
grid.append(item);
|
grid.append(item);
|
||||||
}
|
}
|
||||||
|
appendLimitNote(grid, pairs.length, LIMITS.keyValues);
|
||||||
if (!pairs.length) grid.append(el('p', 'muted', t('emptyText')));
|
if (!pairs.length) grid.append(el('p', 'muted', t('emptyText')));
|
||||||
return grid;
|
return grid;
|
||||||
}
|
}
|
||||||
@@ -183,10 +228,12 @@ function kvGrid(pairs, data, kind) {
|
|||||||
function table(rows, data) {
|
function table(rows, data) {
|
||||||
const wrap = el('div', 'table-wrap');
|
const wrap = el('div', 'table-wrap');
|
||||||
const tableNode = el('table');
|
const tableNode = el('table');
|
||||||
for (const [rowIndex, row] of rows.slice(0, 180).entries()) {
|
for (const [rowIndex, row] of rows.slice(0, LIMITS.tableRows).entries()) {
|
||||||
const tr = el('tr');
|
const tr = el('tr');
|
||||||
const cells = Array.isArray(row) ? row : Object.values(row || {});
|
const cells = Array.isArray(row) ? row : Object.values(row || {});
|
||||||
for (const cell of cells.slice(0, 8)) tr.append(el(rowIndex === 0 ? 'th' : 'td', '', redact(cell, data.privacyPolicy)));
|
for (const cell of cells.slice(0, LIMITS.tableColumns)) {
|
||||||
|
tr.append(el(rowIndex === 0 ? 'th' : 'td', '', previewText(cell, data.privacyPolicy, LIMITS.tableCellChars)));
|
||||||
|
}
|
||||||
tableNode.append(tr);
|
tableNode.append(tr);
|
||||||
}
|
}
|
||||||
if (!rows.length) {
|
if (!rows.length) {
|
||||||
@@ -195,22 +242,24 @@ function table(rows, data) {
|
|||||||
tableNode.append(tr);
|
tableNode.append(tr);
|
||||||
}
|
}
|
||||||
wrap.append(tableNode);
|
wrap.append(tableNode);
|
||||||
|
appendLimitNote(wrap, rows.length, LIMITS.tableRows);
|
||||||
return wrap;
|
return wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
function list(items, data, kind) {
|
function list(items, data, kind) {
|
||||||
const root = el('div', `list ${kind === 'rankedlist' ? 'ranked-list' : 'card-list'} ${kind}`);
|
const root = el('div', `list ${kind === 'rankedlist' ? 'ranked-list' : 'card-list'} ${kind}`);
|
||||||
for (const [index, item] of items.slice(0, 220).entries()) {
|
for (const [index, item] of items.slice(0, LIMITS.listItems).entries()) {
|
||||||
const normalized = normalizeListItem(item, index);
|
const normalized = normalizeListItem(item, index);
|
||||||
const row = el('div', `list-item ${kind === 'rankedlist' ? 'ranked-item' : ''}`);
|
const row = el('div', `list-item ${kind === 'rankedlist' ? 'ranked-item' : ''}`);
|
||||||
row.append(rankBadge(normalized.rank, normalized.leading, kind));
|
row.append(rankBadge(normalized.rank, normalized.leading, kind));
|
||||||
const body = el('div', 'item-body');
|
const body = el('div', 'item-body');
|
||||||
body.append(el('strong', '', redact(normalized.title, data.privacyPolicy)));
|
body.append(el('strong', '', previewText(normalized.title, data.privacyPolicy, LIMITS.titleChars)));
|
||||||
if (normalized.subtitle) body.append(el('span', 'item-subtitle', redact(normalized.subtitle, data.privacyPolicy)));
|
if (normalized.subtitle) body.append(el('span', 'item-subtitle', previewText(normalized.subtitle, data.privacyPolicy, LIMITS.subtitleChars)));
|
||||||
row.append(body);
|
row.append(body);
|
||||||
makeCardLink(row, normalized.uri, normalized.title);
|
makeCardLink(row, normalized.uri, normalized.title);
|
||||||
root.append(row);
|
root.append(row);
|
||||||
}
|
}
|
||||||
|
appendLimitNote(root, items.length, LIMITS.listItems);
|
||||||
if (!items.length) root.append(el('p', 'muted', t('emptyText')));
|
if (!items.length) root.append(el('p', 'muted', t('emptyText')));
|
||||||
return root;
|
return root;
|
||||||
}
|
}
|
||||||
@@ -218,24 +267,33 @@ function list(items, data, kind) {
|
|||||||
function statusList(items, data, fallbackText) {
|
function statusList(items, data, fallbackText) {
|
||||||
if (!items.length) return code(redact(fallbackText || '', data.privacyPolicy));
|
if (!items.length) return code(redact(fallbackText || '', data.privacyPolicy));
|
||||||
const root = el('div', 'list status-list');
|
const root = el('div', 'list status-list');
|
||||||
for (const [index, item] of items.slice(0, 220).entries()) {
|
for (const [index, item] of items.slice(0, LIMITS.listItems).entries()) {
|
||||||
const normalized = normalizeListItem(item, index);
|
const normalized = normalizeListItem(item, index);
|
||||||
const row = el('div', `list-item status-${normalized.status || 'info'}`);
|
const row = el('div', `list-item status-${normalized.status || 'info'}`);
|
||||||
row.append(el('span', 'badge', normalized.leading));
|
row.append(el('span', 'badge', clampText(normalized.leading, LIMITS.badgeChars)));
|
||||||
const body = el('div', 'item-body');
|
const body = el('div', 'item-body');
|
||||||
body.append(el('strong', '', redact(normalized.title, data.privacyPolicy)));
|
body.append(el('strong', '', previewText(normalized.title, data.privacyPolicy, LIMITS.titleChars)));
|
||||||
if (normalized.subtitle) body.append(el('span', 'item-subtitle', redact(normalized.subtitle, data.privacyPolicy)));
|
if (normalized.subtitle) body.append(el('span', 'item-subtitle', previewText(normalized.subtitle, data.privacyPolicy, LIMITS.subtitleChars)));
|
||||||
row.append(body);
|
row.append(body);
|
||||||
if (normalized.uri) row.append(linkActions(normalized.uri));
|
if (normalized.uri) row.append(linkActions(normalized.uri));
|
||||||
root.append(row);
|
root.append(row);
|
||||||
}
|
}
|
||||||
|
appendLimitNote(root, items.length, LIMITS.listItems);
|
||||||
return root;
|
return root;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function appendLimitNote(root, total, visible) {
|
||||||
|
if (total <= visible) return;
|
||||||
|
const hidden = total - visible;
|
||||||
|
root.append(el('p', 'muted limit-note', isEnglish()
|
||||||
|
? `${hidden} more items are hidden in the preview. Copy raw output for the full result.`
|
||||||
|
: `预览已隐藏 ${hidden} 条;复制原文可获取完整结果。`));
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeListItem(item, index) {
|
function normalizeListItem(item, index) {
|
||||||
let title = text(item?.title || item?.text || t('emptyTitle'));
|
let title = clampText(item?.title || item?.text || t('emptyTitle'), LIMITS.titleChars * 2);
|
||||||
let subtitle = text(item?.subtitle || '');
|
let subtitle = clampText(item?.subtitle || '', LIMITS.subtitleChars * 2);
|
||||||
const leading = text(item?.leading || item?.status || index + 1);
|
const leading = clampText(item?.leading || item?.status || index + 1, LIMITS.badgeChars);
|
||||||
const rank = Number.parseInt(leading, 10) || index + 1;
|
const rank = Number.parseInt(leading, 10) || index + 1;
|
||||||
const parts = title.split(/\s+\/\s+/).filter(Boolean);
|
const parts = title.split(/\s+\/\s+/).filter(Boolean);
|
||||||
if (parts.length > 1) {
|
if (parts.length > 1) {
|
||||||
@@ -254,7 +312,7 @@ function normalizeListItem(item, index) {
|
|||||||
|
|
||||||
function rankBadge(rank, label, kind) {
|
function rankBadge(rank, label, kind) {
|
||||||
const badge = el('span', `rank-badge rank-${rank <= 3 && kind === 'rankedlist' ? rank : 'other'} ${kind !== 'rankedlist' ? 'plain' : ''}`);
|
const badge = el('span', `rank-badge rank-${rank <= 3 && kind === 'rankedlist' ? rank : 'other'} ${kind !== 'rankedlist' ? 'plain' : ''}`);
|
||||||
badge.append(el('span', 'rank-core', rank <= 3 && kind === 'rankedlist' ? String(rank) : label));
|
badge.append(el('span', 'rank-core', rank <= 3 && kind === 'rankedlist' ? String(rank) : clampText(label, LIMITS.badgeChars)));
|
||||||
return badge;
|
return badge;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,11 +342,20 @@ function makeCardLink(row, uri, label) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function code(value) {
|
function code(value, max = LIMITS.codeChars) {
|
||||||
const wrap = el('div', 'code-wrap');
|
const wrap = el('div', 'code-wrap');
|
||||||
const pre = el('pre');
|
const pre = el('pre');
|
||||||
pre.textContent = value;
|
const source = text(value);
|
||||||
|
const truncated = source.length > max;
|
||||||
|
pre.textContent = truncated
|
||||||
|
? `${source.slice(0, max)}\n...(preview truncated; copy raw output for the full result)`
|
||||||
|
: source;
|
||||||
wrap.append(pre);
|
wrap.append(pre);
|
||||||
|
if (truncated) {
|
||||||
|
wrap.append(el('p', 'muted limit-note', isEnglish()
|
||||||
|
? 'Preview truncated. Copy raw output for the full result.'
|
||||||
|
: '预览已截断;复制原文可获取完整结果。'));
|
||||||
|
}
|
||||||
return wrap;
|
return wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,8 +363,8 @@ function fileBlock(block, data) {
|
|||||||
const row = el('div', 'list-item file-card');
|
const row = el('div', 'list-item file-card');
|
||||||
row.append(el('span', 'badge', 'FILE'));
|
row.append(el('span', 'badge', 'FILE'));
|
||||||
const body = el('div', 'item-body');
|
const body = el('div', 'item-body');
|
||||||
body.append(el('strong', '', block.text || block.path || t('file')));
|
body.append(el('strong', '', previewText(block.text || block.path || t('file'), data.privacyPolicy, LIMITS.titleChars)));
|
||||||
body.append(el('span', '', redact(block.path || '', data.privacyPolicy)));
|
body.append(el('span', '', previewText(block.path || '', data.privacyPolicy, LIMITS.subtitleChars)));
|
||||||
row.append(body, button(t('openFolder'), () => post('openFolder', block.path)));
|
row.append(body, button(t('openFolder'), () => post('openFolder', block.path)));
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
@@ -306,8 +373,8 @@ function linkBlock(block, data) {
|
|||||||
const row = el('div', 'list-item link-card');
|
const row = el('div', 'list-item link-card');
|
||||||
row.append(el('span', 'badge', 'URL'));
|
row.append(el('span', 'badge', 'URL'));
|
||||||
const body = el('div', 'item-body');
|
const body = el('div', 'item-body');
|
||||||
body.append(el('strong', '', redact(block.text || block.uri || t('link'), data.privacyPolicy)));
|
body.append(el('strong', '', previewText(block.text || block.uri || t('link'), data.privacyPolicy, LIMITS.titleChars)));
|
||||||
body.append(el('span', '', redact(block.uri || '', data.privacyPolicy)));
|
body.append(el('span', '', previewText(block.uri || '', data.privacyPolicy, LIMITS.subtitleChars)));
|
||||||
row.append(body, linkActions(block.uri));
|
row.append(body, linkActions(block.uri));
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
@@ -359,7 +426,7 @@ function rawCard(data) {
|
|||||||
event.stopPropagation?.();
|
event.stopPropagation?.();
|
||||||
post('copy', data.resultDocument?.rawText || '');
|
post('copy', data.resultDocument?.rawText || '');
|
||||||
}, 'ghost compact'));
|
}, 'ghost compact'));
|
||||||
root.append(head, code(redact(data.resultDocument?.rawText || '', data.privacyPolicy)));
|
root.append(head, code(redact(data.resultDocument?.rawText || '', data.privacyPolicy), LIMITS.rawChars));
|
||||||
return root;
|
return root;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -368,7 +435,7 @@ function emptyBlock(data) {
|
|||||||
root.append(el('h2', '', t('emptyTitle')));
|
root.append(el('h2', '', t('emptyTitle')));
|
||||||
root.append(el('p', 'muted', t('emptyText')));
|
root.append(el('p', 'muted', t('emptyText')));
|
||||||
const raw = data.resultDocument?.rawText || '';
|
const raw = data.resultDocument?.rawText || '';
|
||||||
if (raw) root.append(code(redact(raw, data.privacyPolicy)));
|
if (raw) root.append(code(redact(raw, data.privacyPolicy), LIMITS.rawChars));
|
||||||
return root;
|
return root;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -165,6 +165,7 @@ public abstract partial class AdaptiveToolPage : ToolPageBase
|
|||||||
private bool _toolPageWebViewFailed;
|
private bool _toolPageWebViewFailed;
|
||||||
private bool _useToolPageWebView;
|
private bool _useToolPageWebView;
|
||||||
private string _lastRawOutput = string.Empty;
|
private string _lastRawOutput = string.Empty;
|
||||||
|
private ToolResultWebPayload? _pendingResultWebPayload;
|
||||||
private ToolResultDocument? _lastToolPageDocument;
|
private ToolResultDocument? _lastToolPageDocument;
|
||||||
private ToolResultRunState? _lastToolPageRunState;
|
private ToolResultRunState? _lastToolPageRunState;
|
||||||
private CancellationTokenSource? _runCts;
|
private CancellationTokenSource? _runCts;
|
||||||
@@ -1997,6 +1998,12 @@ public abstract partial class AdaptiveToolPage : ToolPageBase
|
|||||||
resourceFolder = Path.Combine(AppContext.BaseDirectory, "Assets", "tool-results");
|
resourceFolder = Path.Combine(AppContext.BaseDirectory, "Assets", "tool-results");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!Directory.Exists(resourceFolder))
|
||||||
|
{
|
||||||
|
_resultWebViewFailed = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var environment = await _webViewEnvironmentFactory.CreateAsync("ToolResults").ConfigureAwait(true);
|
var environment = await _webViewEnvironmentFactory.CreateAsync("ToolResults").ConfigureAwait(true);
|
||||||
await _resultWebView.EnsureCoreWebView2Async(environment);
|
await _resultWebView.EnsureCoreWebView2Async(environment);
|
||||||
_resultWebView.CoreWebView2.Settings.AreDefaultContextMenusEnabled = false;
|
_resultWebView.CoreWebView2.Settings.AreDefaultContextMenusEnabled = false;
|
||||||
@@ -2013,15 +2020,27 @@ public abstract partial class AdaptiveToolPage : ToolPageBase
|
|||||||
{
|
{
|
||||||
if (args.IsSuccess)
|
if (args.IsSuccess)
|
||||||
{
|
{
|
||||||
|
_resultWebViewReady = true;
|
||||||
|
_resultWebViewFailed = false;
|
||||||
|
var hasResultPayload = _pendingResultWebPayload is not null || !string.IsNullOrWhiteSpace(_lastRawOutput);
|
||||||
SendPerformanceMode();
|
SendPerformanceMode();
|
||||||
|
SendPendingResultWebPayload();
|
||||||
|
if (hasResultPayload)
|
||||||
|
{
|
||||||
|
SetResultViewMode(_showRawResult);
|
||||||
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_resultWebViewReady = false;
|
||||||
|
_resultWebViewFailed = true;
|
||||||
|
SetResultViewMode(_showRawResult);
|
||||||
};
|
};
|
||||||
_resultWebView.Source = new Uri("https://tool-results.ymhut.local/index.html");
|
_resultWebView.Source = new Uri("https://tool-results.ymhut.local/index.html");
|
||||||
_resultWebViewReady = true;
|
|
||||||
SendPerformanceMode();
|
|
||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
|
_resultWebViewReady = false;
|
||||||
_resultWebViewFailed = true;
|
_resultWebViewFailed = true;
|
||||||
CrashLog.Write(exception);
|
CrashLog.Write(exception);
|
||||||
}
|
}
|
||||||
@@ -2029,16 +2048,22 @@ public abstract partial class AdaptiveToolPage : ToolPageBase
|
|||||||
|
|
||||||
private bool TryRenderResultWebView(ToolResultDocument document, long durationMs)
|
private bool TryRenderResultWebView(ToolResultDocument document, long durationMs)
|
||||||
{
|
{
|
||||||
if (Module is null || _resultWebViewFailed || !_resultWebViewReady || _resultWebView.CoreWebView2 is null)
|
if (Module is null || _resultWebViewFailed)
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_resultCards.Children.Clear();
|
|
||||||
var payload = _toolResultWebBridge.CreatePayload(Module, document, durationMs);
|
var payload = _toolResultWebBridge.CreatePayload(Module, document, durationMs);
|
||||||
_resultWebView.CoreWebView2.PostWebMessageAsJson(_toolResultWebBridge.Serialize(payload));
|
_pendingResultWebPayload = payload;
|
||||||
|
if (!_resultWebViewReady || _resultWebView.CoreWebView2 is null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_resultCards.Children.Clear();
|
||||||
|
SendPendingResultWebPayload();
|
||||||
SendPerformanceMode();
|
SendPerformanceMode();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -2050,6 +2075,17 @@ public abstract partial class AdaptiveToolPage : ToolPageBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void SendPendingResultWebPayload()
|
||||||
|
{
|
||||||
|
if (_pendingResultWebPayload is null || !_resultWebViewReady || _resultWebView.CoreWebView2 is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_resultWebView.CoreWebView2.PostWebMessageAsJson(_toolResultWebBridge.Serialize(_pendingResultWebPayload));
|
||||||
|
_pendingResultWebPayload = null;
|
||||||
|
}
|
||||||
|
|
||||||
private void RenderResultDocument(ToolResultDocument document, long durationMs = 0)
|
private void RenderResultDocument(ToolResultDocument document, long durationMs = 0)
|
||||||
{
|
{
|
||||||
if (TryRenderResultWebView(document, durationMs))
|
if (TryRenderResultWebView(document, durationMs))
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
using System.Runtime.InteropServices.WindowsRuntime;
|
using System.Runtime.InteropServices.WindowsRuntime;
|
||||||
|
using Microsoft.UI;
|
||||||
using Microsoft.UI.Text;
|
using Microsoft.UI.Text;
|
||||||
|
using Microsoft.UI.Windowing;
|
||||||
using Microsoft.UI.Xaml;
|
using Microsoft.UI.Xaml;
|
||||||
using Microsoft.UI.Xaml.Controls;
|
using Microsoft.UI.Xaml.Controls;
|
||||||
|
using Microsoft.UI.Xaml.Input;
|
||||||
using Microsoft.UI.Xaml.Media;
|
using Microsoft.UI.Xaml.Media;
|
||||||
using Microsoft.UI.Xaml.Media.Imaging;
|
using Microsoft.UI.Xaml.Media.Imaging;
|
||||||
using Windows.Media.Core;
|
using Windows.Media.Core;
|
||||||
@@ -9,10 +12,12 @@ using Windows.Media.Playback;
|
|||||||
using Windows.Storage;
|
using Windows.Storage;
|
||||||
using Windows.Storage.Pickers;
|
using Windows.Storage.Pickers;
|
||||||
using Windows.Storage.Streams;
|
using Windows.Storage.Streams;
|
||||||
|
using Windows.System;
|
||||||
using WinRT.Interop;
|
using WinRT.Interop;
|
||||||
using YMhut.Box.Core.App;
|
using YMhut.Box.Core.App;
|
||||||
using YMhut.Box.Core.Logging;
|
using YMhut.Box.Core.Logging;
|
||||||
using YMhut.Box.Core.Media;
|
using YMhut.Box.Core.Media;
|
||||||
|
using YMhut.Box.Core.Settings;
|
||||||
using YMhut.Box.Core.Tools;
|
using YMhut.Box.Core.Tools;
|
||||||
using YMhut.Box.WinUI.Services;
|
using YMhut.Box.WinUI.Services;
|
||||||
using YMhut.Box.WinUI.ViewModels.Tools;
|
using YMhut.Box.WinUI.ViewModels.Tools;
|
||||||
@@ -25,8 +30,15 @@ public class RandomCinemaPage : ToolPageBase
|
|||||||
private readonly AppPaths _appPaths = AppServices.GetRequiredService<AppPaths>();
|
private readonly AppPaths _appPaths = AppServices.GetRequiredService<AppPaths>();
|
||||||
private readonly IRemoteMediaCatalogService _catalogService = AppServices.GetRequiredService<IRemoteMediaCatalogService>();
|
private readonly IRemoteMediaCatalogService _catalogService = AppServices.GetRequiredService<IRemoteMediaCatalogService>();
|
||||||
private readonly IRemoteMediaResolver _mediaResolver = AppServices.GetRequiredService<IRemoteMediaResolver>();
|
private readonly IRemoteMediaResolver _mediaResolver = AppServices.GetRequiredService<IRemoteMediaResolver>();
|
||||||
|
private readonly ISettingsService _settingsService = AppServices.GetRequiredService<ISettingsService>();
|
||||||
private readonly AdaptiveToolViewModel _viewModel;
|
private readonly AdaptiveToolViewModel _viewModel;
|
||||||
private readonly Grid _root = new();
|
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 StackPanel _contentHost = new() { Spacing = 16 };
|
||||||
private readonly Grid _fullscreenOverlay = new() { Visibility = Visibility.Collapsed };
|
private readonly Grid _fullscreenOverlay = new() { Visibility = Visibility.Collapsed };
|
||||||
private readonly Grid _fullscreenStage = new();
|
private readonly Grid _fullscreenStage = new();
|
||||||
@@ -38,6 +50,7 @@ public class RandomCinemaPage : ToolPageBase
|
|||||||
IsActive = true,
|
IsActive = true,
|
||||||
Visibility = Visibility.Visible
|
Visibility = Visibility.Visible
|
||||||
};
|
};
|
||||||
|
private const int SourcePageSize = 8;
|
||||||
|
|
||||||
private RemoteMediaCatalog? _catalog;
|
private RemoteMediaCatalog? _catalog;
|
||||||
private byte[]? _currentMediaBytes;
|
private byte[]? _currentMediaBytes;
|
||||||
@@ -50,7 +63,12 @@ public class RandomCinemaPage : ToolPageBase
|
|||||||
private RemoteMediaSource? _activeSource;
|
private RemoteMediaSource? _activeSource;
|
||||||
private UIElement? _activeMediaView;
|
private UIElement? _activeMediaView;
|
||||||
private Panel? _activeMediaHost;
|
private Panel? _activeMediaHost;
|
||||||
|
private MediaPlayer? _currentPlayer;
|
||||||
|
private Slider? _volumeSlider;
|
||||||
|
private TextBlock? _volumeText;
|
||||||
private bool _inlineFullscreen;
|
private bool _inlineFullscreen;
|
||||||
|
private bool _windowFullscreen;
|
||||||
|
private AppWindow? _appWindow;
|
||||||
|
|
||||||
public RandomCinemaPage(IToolModule module, AdaptiveToolViewModel viewModel, Action? goBack = null)
|
public RandomCinemaPage(IToolModule module, AdaptiveToolViewModel viewModel, Action? goBack = null)
|
||||||
{
|
{
|
||||||
@@ -59,8 +77,19 @@ public class RandomCinemaPage : ToolPageBase
|
|||||||
Background = ModernUi.AppBackground;
|
Background = ModernUi.AppBackground;
|
||||||
BindModule(module);
|
BindModule(module);
|
||||||
Content = BuildContent(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();
|
Loaded += async (_, _) => await LoadRemoteConfigAsync();
|
||||||
Unloaded += (_, _) => DisposeCurrentImageStream();
|
Unloaded += (_, _) => DisposeCurrentMedia();
|
||||||
}
|
}
|
||||||
|
|
||||||
private UIElement BuildContent(IToolModule module)
|
private UIElement BuildContent(IToolModule module)
|
||||||
@@ -72,24 +101,15 @@ public class RandomCinemaPage : ToolPageBase
|
|||||||
Grid.SetRow(header, 0);
|
Grid.SetRow(header, 0);
|
||||||
_root.Children.Add(header);
|
_root.Children.Add(header);
|
||||||
|
|
||||||
var bodyStack = new StackPanel
|
_bodyScroll.Margin = new Thickness(32, 0, 32, 32);
|
||||||
{
|
_bodyScroll.Padding = new Thickness(0, 0, 8, 0);
|
||||||
Spacing = 16,
|
_bodyScroll.VerticalScrollBarVisibility = ScrollBarVisibility.Auto;
|
||||||
HorizontalAlignment = HorizontalAlignment.Stretch,
|
_bodyScroll.HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled;
|
||||||
Children = { _contentHost }
|
_bodyScroll.Content = _contentHost;
|
||||||
};
|
_bodyScroll.SizeChanged += (_, e) => _contentHost.Width = Math.Max(280, e.NewSize.Width - 16);
|
||||||
|
_bodyContent.Content = _bodyScroll;
|
||||||
var body = new ScrollViewer
|
Grid.SetRow(_bodyContent, 1);
|
||||||
{
|
_root.Children.Add(_bodyContent);
|
||||||
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);
|
|
||||||
|
|
||||||
BuildFullscreenOverlay();
|
BuildFullscreenOverlay();
|
||||||
Grid.SetRowSpan(_fullscreenOverlay, 2);
|
Grid.SetRowSpan(_fullscreenOverlay, 2);
|
||||||
@@ -127,33 +147,32 @@ public class RandomCinemaPage : ToolPageBase
|
|||||||
_fullscreenOverlay.Children.Add(_fullscreenStage);
|
_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 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 refresh = ModernUi.PillButton(AppLocalizer.T("刷新源列表", "Refresh sources"), "\uE895", async () => await LoadRemoteConfigAsync(forceRefresh: true), primary: true);
|
||||||
|
|
||||||
var grid = new Grid { ColumnSpacing = 14 };
|
var grid = new Grid
|
||||||
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
{
|
||||||
|
ColumnSpacing = 12,
|
||||||
|
Margin = new Thickness(32, 24, 32, 12)
|
||||||
|
};
|
||||||
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
||||||
grid.ColumnDefinitions.Add(new ColumnDefinition());
|
grid.ColumnDefinitions.Add(new ColumnDefinition());
|
||||||
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
||||||
grid.Children.Add(back);
|
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
|
var title = new StackPanel
|
||||||
{
|
{
|
||||||
Spacing = 4,
|
Spacing = 2,
|
||||||
VerticalAlignment = VerticalAlignment.Center,
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
Children =
|
Children =
|
||||||
{
|
{
|
||||||
ModernUi.Text(ToolText.Name(module), 24, FontWeights.SemiBold, maxLines: 1),
|
ModernUi.Text(ToolText.Name(module), 20, 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),
|
|
||||||
_statusText
|
_statusText
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
Grid.SetColumn(title, 2);
|
Grid.SetColumn(title, 1);
|
||||||
grid.Children.Add(title);
|
grid.Children.Add(title);
|
||||||
|
|
||||||
var actions = new StackPanel
|
var actions = new StackPanel
|
||||||
@@ -163,13 +182,27 @@ public class RandomCinemaPage : ToolPageBase
|
|||||||
VerticalAlignment = VerticalAlignment.Center,
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
Children = { _progress, refresh }
|
Children = { _progress, refresh }
|
||||||
};
|
};
|
||||||
Grid.SetColumn(actions, 3);
|
Grid.SetColumn(actions, 2);
|
||||||
grid.Children.Add(actions);
|
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)
|
private async Task LoadRemoteConfigAsync(bool forceRefresh = false)
|
||||||
{
|
{
|
||||||
|
UseScrollableBody();
|
||||||
_viewModel.IsBusy = true;
|
_viewModel.IsBusy = true;
|
||||||
_progress.IsActive = true;
|
_progress.IsActive = true;
|
||||||
_progress.Visibility = Visibility.Visible;
|
_progress.Visibility = Visibility.Visible;
|
||||||
@@ -220,13 +253,16 @@ public class RandomCinemaPage : ToolPageBase
|
|||||||
|
|
||||||
private void RenderChoiceCards()
|
private void RenderChoiceCards()
|
||||||
{
|
{
|
||||||
|
UseScrollableBody();
|
||||||
_contentHost.Children.Clear();
|
_contentHost.Children.Clear();
|
||||||
var categories = _catalog?.EnabledCategories.ToList() ?? [];
|
var categories = _catalog?.EnabledCategories
|
||||||
|
.Where(IsSupportedCategory)
|
||||||
|
.ToList() ?? [];
|
||||||
if (categories.Count == 0)
|
if (categories.Count == 0)
|
||||||
{
|
{
|
||||||
_contentHost.Children.Add(BuildEmptyState(
|
_contentHost.Children.Add(BuildEmptyState(
|
||||||
AppLocalizer.T("没有启用的媒体分类", "No enabled media categories"),
|
AppLocalizer.T("没有可用的图片或视频分类", "No image or video categories"),
|
||||||
AppLocalizer.T("远程配置已读取,但没有可展示的分类。请刷新配置或稍后重试。", "The remote configuration loaded, but no categories are enabled. Reload sources or try again later.")));
|
AppLocalizer.T("随机放映室只显示图片和视频源。请刷新配置或稍后重试。", "Random Cinema only shows image and video sources. Reload sources or try again later.")));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,8 +282,9 @@ public class RandomCinemaPage : ToolPageBase
|
|||||||
|
|
||||||
private Button BuildChoiceCard(RemoteMediaCategory category)
|
private Button BuildChoiceCard(RemoteMediaCategory category)
|
||||||
{
|
{
|
||||||
var total = category.Subcategories.Count;
|
var sources = DisplaySources(category);
|
||||||
var available = category.Subcategories.Count(source => source.IsAvailable);
|
var total = sources.Count;
|
||||||
|
var available = sources.Count(source => source.IsAvailable);
|
||||||
var enabled = total > 0;
|
var enabled = total > 0;
|
||||||
var top = new Grid { ColumnSpacing = 12 };
|
var top = new Grid { ColumnSpacing = 12 };
|
||||||
top.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
top.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
||||||
@@ -297,13 +334,15 @@ public class RandomCinemaPage : ToolPageBase
|
|||||||
return button;
|
return button;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void RenderSourceCards(RemoteMediaCategory category)
|
private void RenderSourceCards(RemoteMediaCategory category, int page = 1)
|
||||||
{
|
{
|
||||||
|
UseScrollableBody();
|
||||||
_activeCategory = category;
|
_activeCategory = category;
|
||||||
_contentHost.Children.Clear();
|
_contentHost.Children.Clear();
|
||||||
_contentHost.Children.Add(BuildBreadcrumb(DisplayCategoryName(category), RenderChoiceCards));
|
_contentHost.Children.Add(BuildBreadcrumb(DisplayCategoryName(category), RenderChoiceCards));
|
||||||
|
|
||||||
if (category.Subcategories.Count == 0)
|
var sources = DisplaySources(category);
|
||||||
|
if (sources.Count == 0)
|
||||||
{
|
{
|
||||||
_contentHost.Children.Add(BuildEmptyState(
|
_contentHost.Children.Add(BuildEmptyState(
|
||||||
AppLocalizer.T("暂无资源源", "No media sources"),
|
AppLocalizer.T("暂无资源源", "No media sources"),
|
||||||
@@ -311,18 +350,28 @@ public class RandomCinemaPage : ToolPageBase
|
|||||||
return;
|
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
|
var wrap = new VariableSizedWrapGrid
|
||||||
{
|
{
|
||||||
Orientation = Orientation.Horizontal,
|
Orientation = Orientation.Horizontal,
|
||||||
ItemWidth = SourceCardWidth(category),
|
ItemWidth = SourceCardWidth(category),
|
||||||
ItemHeight = SourceCardHeight(category)
|
ItemHeight = SourceCardHeight(category)
|
||||||
};
|
};
|
||||||
foreach (var source in category.Subcategories)
|
foreach (var source in pageItems)
|
||||||
{
|
{
|
||||||
wrap.Children.Add(BuildSourceCard(category, source));
|
wrap.Children.Add(BuildSourceCard(category, source));
|
||||||
}
|
}
|
||||||
|
|
||||||
_contentHost.Children.Add(wrap);
|
_contentHost.Children.Add(wrap);
|
||||||
|
if (totalPages > 1)
|
||||||
|
{
|
||||||
|
_contentHost.Children.Add(BuildPager(category, currentPage, totalPages));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private UIElement BuildBreadcrumb(string title, Action back)
|
private UIElement BuildBreadcrumb(string title, Action back)
|
||||||
@@ -428,6 +477,30 @@ public class RandomCinemaPage : ToolPageBase
|
|||||||
}, new Thickness(24), radius: 8, background: ModernUi.SurfaceAlt);
|
}, 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)
|
private static StackPanel BadgeRows(params UIElement[] badges)
|
||||||
{
|
{
|
||||||
var panel = new StackPanel { Spacing = 6 };
|
var panel = new StackPanel { Spacing = 6 };
|
||||||
@@ -452,11 +525,10 @@ public class RandomCinemaPage : ToolPageBase
|
|||||||
|
|
||||||
private static int CategorySortKey(RemoteMediaCategory category)
|
private static int CategorySortKey(RemoteMediaCategory category)
|
||||||
{
|
{
|
||||||
return category.Kind switch
|
return PrimaryKind(category) switch
|
||||||
{
|
{
|
||||||
RemoteMediaKind.Image => 0,
|
RemoteMediaKind.Image => 0,
|
||||||
RemoteMediaKind.Video => 1,
|
RemoteMediaKind.Video => 1,
|
||||||
RemoteMediaKind.Audio => 2,
|
|
||||||
_ => 3
|
_ => 3
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -484,23 +556,20 @@ public class RandomCinemaPage : ToolPageBase
|
|||||||
|
|
||||||
return IsVideoCategory(category)
|
return IsVideoCategory(category)
|
||||||
? "\uE714"
|
? "\uE714"
|
||||||
: IsAudioCategory(category)
|
: "\uEB9F";
|
||||||
? "\uE8D6"
|
|
||||||
: "\uEB9F";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string KindLabel(RemoteMediaCategory category)
|
private static string KindLabel(RemoteMediaCategory category)
|
||||||
{
|
{
|
||||||
return IsVideoCategory(category)
|
return IsVideoCategory(category)
|
||||||
? AppLocalizer.T("视频", "Video")
|
? AppLocalizer.T("视频", "Video")
|
||||||
: IsAudioCategory(category)
|
: AppLocalizer.T("图片", "Image");
|
||||||
? AppLocalizer.T("音频", "Audio")
|
|
||||||
: AppLocalizer.T("图片", "Image");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string CategoryDescription(RemoteMediaCategory category)
|
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
|
var playback = category.Layout.AutoPlay
|
||||||
? AppLocalizer.T("自动播放", "autoplay")
|
? AppLocalizer.T("自动播放", "autoplay")
|
||||||
: AppLocalizer.T("手动播放", "manual play");
|
: AppLocalizer.T("手动播放", "manual play");
|
||||||
@@ -542,16 +611,22 @@ public class RandomCinemaPage : ToolPageBase
|
|||||||
_activeSource = source;
|
_activeSource = source;
|
||||||
ExitInlineFullscreen();
|
ExitInlineFullscreen();
|
||||||
DisposeCurrentImageStream();
|
DisposeCurrentImageStream();
|
||||||
|
_currentPlayer?.Dispose();
|
||||||
|
_currentPlayer = null;
|
||||||
|
_volumeSlider = null;
|
||||||
|
_volumeText = null;
|
||||||
_currentMediaBytes = null;
|
_currentMediaBytes = null;
|
||||||
_currentMediaUri = null;
|
_currentMediaUri = null;
|
||||||
_currentMediaCachePath = null;
|
_currentMediaCachePath = null;
|
||||||
_currentFileName = $"{source.Id}_{DateTime.Now:yyyyMMdd_HHmmss}";
|
_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();
|
var host = new Grid
|
||||||
_contentHost.Children.Add(BuildBreadcrumb($"{DisplayCategoryName(category)} / {DisplaySourceName(source)}", () => RenderSourceCards(category)));
|
{
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Stretch,
|
||||||
var host = new Grid { MinHeight = 460 };
|
VerticalAlignment = VerticalAlignment.Stretch
|
||||||
|
};
|
||||||
var loadingText = ModernUi.Text(AppLocalizer.T("正在解析媒体地址...", "Resolving media address..."), 14, foreground: ModernUi.TextSecondary);
|
var loadingText = ModernUi.Text(AppLocalizer.T("正在解析媒体地址...", "Resolving media address..."), 14, foreground: ModernUi.TextSecondary);
|
||||||
var loadingProgress = new ProgressBar
|
var loadingProgress = new ProgressBar
|
||||||
{
|
{
|
||||||
@@ -576,22 +651,41 @@ public class RandomCinemaPage : ToolPageBase
|
|||||||
host.Children.Add(loading);
|
host.Children.Add(loading);
|
||||||
|
|
||||||
var actions = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 10 };
|
var actions = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 10 };
|
||||||
actions.Children.Add(ModernUi.PillButton(AppLocalizer.T("重新加载", "Reload"), "\uE895", async () => await RenderMediaPageAsync(category, source), primary: true));
|
actions.Children.Add(ModernUi.PillButton(AppLocalizer.T("重新获取", "Fetch new media"), "\uE895", async () => await RenderMediaPageAsync(category, source), primary: true));
|
||||||
actions.Children.Add(ModernUi.PillButton(AppLocalizer.T("全屏", "Fullscreen"), "\uE740", async () => await ShowFullscreenAsync()));
|
actions.Children.Add(ModernUi.PillButton(AppLocalizer.T("全屏", "Fullscreen"), "\uE740", async () => await ShowFullscreenAsync()));
|
||||||
var saveButton = ModernUi.PillButton(AppLocalizer.T("另存为", "Save as"), "\uE74E", async () => await SaveMediaAsync());
|
var saveButton = ModernUi.PillButton(AppLocalizer.T("另存为", "Save as"), "\uE74E", async () => await SaveMediaAsync());
|
||||||
saveButton.IsEnabled = source.Downloadable;
|
saveButton.IsEnabled = source.Downloadable;
|
||||||
saveButton.Opacity = source.Downloadable ? 1 : 0.5;
|
saveButton.Opacity = source.Downloadable ? 1 : 0.5;
|
||||||
actions.Children.Add(saveButton);
|
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 =
|
Children =
|
||||||
{
|
{
|
||||||
host,
|
toolbar,
|
||||||
actions
|
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
|
try
|
||||||
{
|
{
|
||||||
@@ -602,16 +696,14 @@ public class RandomCinemaPage : ToolPageBase
|
|||||||
? AppLocalizer.T("正在准备预览...", "Preparing preview...")
|
? AppLocalizer.T("正在准备预览...", "Preparing preview...")
|
||||||
: AppLocalizer.T($"正在加载媒体... {value:0}%", $"Loading media... {value:0}%");
|
: AppLocalizer.T($"正在加载媒体... {value:0}%", $"Loading media... {value:0}%");
|
||||||
});
|
});
|
||||||
UIElement media = IsVideoCategory(category)
|
UIElement media = mediaKind == RemoteMediaKind.Video
|
||||||
? await BuildVideoViewerAsync(category, source, progress)
|
? await BuildVideoViewerAsync(category, source, progress)
|
||||||
: IsAudioCategory(category)
|
: await BuildImageViewerAsync(source, progress);
|
||||||
? await BuildAudioViewerAsync(category, source, progress)
|
|
||||||
: await BuildImageViewerAsync(source, progress);
|
|
||||||
host.Children.Clear();
|
host.Children.Clear();
|
||||||
host.Children.Add(media);
|
host.Children.Add(media);
|
||||||
_activeMediaHost = host;
|
_activeMediaHost = host;
|
||||||
_activeMediaView = media;
|
_activeMediaView = media;
|
||||||
ToastService.Show(AppLocalizer.T("媒体已重新加载", "Media reloaded"), ToastKind.Success);
|
ToastService.Show(AppLocalizer.T("媒体已重新获取", "Media refreshed"), ToastKind.Success);
|
||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
@@ -635,9 +727,9 @@ 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 ResolveFreshMediaAsync(source, RemoteMediaKind.Image, progress);
|
||||||
_currentMediaUri = resolution.Uri;
|
_currentMediaUri = resolution.Uri;
|
||||||
_currentExtension = resolution.SuggestedExtension;
|
_currentExtension = resolution.SuggestedExtension;
|
||||||
progress?.Report(45);
|
progress?.Report(45);
|
||||||
@@ -645,30 +737,31 @@ public class RandomCinemaPage : ToolPageBase
|
|||||||
var bitmap = await BitmapFromBytesAsync(_currentMediaBytes);
|
var bitmap = await BitmapFromBytesAsync(_currentMediaBytes);
|
||||||
_currentImageStream = bitmap.Stream;
|
_currentImageStream = bitmap.Stream;
|
||||||
progress?.Report(100);
|
progress?.Report(100);
|
||||||
return new Image
|
var image = new Image
|
||||||
{
|
{
|
||||||
MinHeight = 420,
|
|
||||||
Stretch = Stretch.Uniform,
|
Stretch = Stretch.Uniform,
|
||||||
Source = bitmap.Image,
|
Source = bitmap.Image,
|
||||||
HorizontalAlignment = HorizontalAlignment.Stretch,
|
HorizontalAlignment = HorizontalAlignment.Stretch,
|
||||||
VerticalAlignment = VerticalAlignment.Stretch
|
VerticalAlignment = VerticalAlignment.Stretch
|
||||||
};
|
};
|
||||||
|
return MediaStage(image);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<UIElement> BuildVideoViewerAsync(RemoteMediaCategory category, RemoteMediaSource source, IProgress<double>? progress)
|
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 ResolveFreshMediaAsync(source, RemoteMediaKind.Video, progress);
|
||||||
_currentMediaUri = resolution.Uri;
|
_currentMediaUri = resolution.Uri;
|
||||||
_currentExtension = resolution.SuggestedExtension;
|
_currentExtension = resolution.SuggestedExtension;
|
||||||
progress?.Report(90);
|
progress?.Report(90);
|
||||||
var media = new MediaPlayerElement
|
var media = new MediaPlayerElement
|
||||||
{
|
{
|
||||||
MinHeight = 420,
|
|
||||||
AreTransportControlsEnabled = true,
|
AreTransportControlsEnabled = true,
|
||||||
HorizontalAlignment = HorizontalAlignment.Stretch,
|
HorizontalAlignment = HorizontalAlignment.Stretch,
|
||||||
VerticalAlignment = VerticalAlignment.Stretch
|
VerticalAlignment = VerticalAlignment.Stretch
|
||||||
};
|
};
|
||||||
var player = CreateRemoteMediaPlayer(resolution);
|
var player = CreateRemoteMediaPlayer(resolution);
|
||||||
|
_currentPlayer = player;
|
||||||
|
ApplyVolumeToPlayer();
|
||||||
media.SetMediaPlayer(player);
|
media.SetMediaPlayer(player);
|
||||||
AttachPlaybackFallback(media, player, resolution, category, source);
|
AttachPlaybackFallback(media, player, resolution, category, source);
|
||||||
if (category.Layout.AutoPlay)
|
if (category.Layout.AutoPlay)
|
||||||
@@ -676,67 +769,165 @@ public class RandomCinemaPage : ToolPageBase
|
|||||||
player.Play();
|
player.Play();
|
||||||
}
|
}
|
||||||
|
|
||||||
return WrapPlayableMedia(media, category, source);
|
return MediaStage(WrapPlayableMedia(media, category, source));
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<UIElement> BuildAudioViewerAsync(RemoteMediaCategory category, RemoteMediaSource source, IProgress<double>? progress)
|
private async Task<RemoteMediaResolution> ResolveFreshMediaAsync(
|
||||||
|
RemoteMediaSource source,
|
||||||
|
RemoteMediaKind expectedKind,
|
||||||
|
IProgress<double>? progress)
|
||||||
{
|
{
|
||||||
var resolution = await _mediaResolver.ResolveMediaAsync(source.ApiUrl, RemoteMediaKind.Audio, progress: progress);
|
var primaryUrl = source.RefreshApiUrl;
|
||||||
_currentMediaUri = resolution.Uri;
|
var fallbackUrl = source.ResolvedUrl;
|
||||||
_currentExtension = resolution.SuggestedExtension;
|
Exception? primaryError = null;
|
||||||
progress?.Report(90);
|
|
||||||
var media = new MediaPlayerElement
|
if (!string.IsNullOrWhiteSpace(primaryUrl))
|
||||||
{
|
{
|
||||||
MinHeight = 88,
|
try
|
||||||
AreTransportControlsEnabled = true,
|
{
|
||||||
HorizontalAlignment = HorizontalAlignment.Stretch,
|
var resolution = await _mediaResolver.ResolveMediaAsync(primaryUrl, expectedKind, cacheBust: true, progress: progress);
|
||||||
VerticalAlignment = VerticalAlignment.Center
|
EnsureExpectedMedia(resolution, expectedKind);
|
||||||
};
|
return resolution;
|
||||||
var player = CreateRemoteMediaPlayer(resolution);
|
}
|
||||||
media.SetMediaPlayer(player);
|
catch (Exception exception) when (ShouldTryResolvedFallback(primaryUrl, fallbackUrl))
|
||||||
AttachPlaybackFallback(media, player, resolution, category, source);
|
{
|
||||||
if (category.Layout.AutoPlay)
|
primaryError = exception;
|
||||||
{
|
progress?.Report(35);
|
||||||
player.Play();
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return new StackPanel
|
if (ShouldTryResolvedFallback(primaryUrl, fallbackUrl))
|
||||||
{
|
{
|
||||||
Spacing = 16,
|
var resolution = await _mediaResolver.ResolveMediaAsync(fallbackUrl, expectedKind, cacheBust: true, progress: progress);
|
||||||
HorizontalAlignment = HorizontalAlignment.Stretch,
|
EnsureExpectedMedia(resolution, expectedKind);
|
||||||
Children =
|
return resolution;
|
||||||
{
|
}
|
||||||
new Border
|
|
||||||
{
|
if (primaryError is not null)
|
||||||
Padding = new Thickness(18),
|
{
|
||||||
CornerRadius = new CornerRadius(8),
|
throw new InvalidOperationException(primaryError.Message, primaryError);
|
||||||
Background = ModernUi.SurfaceAlt,
|
}
|
||||||
Child = new StackPanel
|
|
||||||
{
|
throw new InvalidOperationException(AppLocalizer.T("远程媒体源没有可用地址。", "The remote media source does not have a usable URL."));
|
||||||
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)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool ShouldTryResolvedFallback(string? primaryUrl, string? fallbackUrl)
|
||||||
|
=> !string.IsNullOrWhiteSpace(fallbackUrl) &&
|
||||||
|
!string.Equals(primaryUrl?.Trim(), fallbackUrl.Trim(), StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
private static MediaPlayer CreateRemoteMediaPlayer(RemoteMediaResolution resolution)
|
private static MediaPlayer CreateRemoteMediaPlayer(RemoteMediaResolution resolution)
|
||||||
{
|
{
|
||||||
return new MediaPlayer
|
return new MediaPlayer
|
||||||
{
|
{
|
||||||
Source = MediaSource.CreateFromUri(resolution.Uri),
|
Source = MediaSource.CreateFromUri(resolution.Uri)
|
||||||
Volume = 0.7
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
private Grid WrapPlayableMedia(MediaPlayerElement media, RemoteMediaCategory category, RemoteMediaSource source)
|
||||||
{
|
{
|
||||||
var status = PlaybackStatusOverlay();
|
var status = PlaybackStatusOverlay();
|
||||||
@@ -798,7 +989,7 @@ public class RandomCinemaPage : ToolPageBase
|
|||||||
_currentMediaBytes = bytes;
|
_currentMediaBytes = bytes;
|
||||||
var file = await StorageFile.GetFileFromPathAsync(path);
|
var file = await StorageFile.GetFileFromPathAsync(path);
|
||||||
player.Source = MediaSource.CreateFromStorageFile(file);
|
player.Source = MediaSource.CreateFromStorageFile(file);
|
||||||
player.Volume = 0.7;
|
ApplyVolumeToPlayer();
|
||||||
SetPlaybackStatus(overlays, string.Empty, false);
|
SetPlaybackStatus(overlays, string.Empty, false);
|
||||||
if (category.Layout.AutoPlay)
|
if (category.Layout.AutoPlay)
|
||||||
{
|
{
|
||||||
@@ -934,9 +1125,42 @@ public class RandomCinemaPage : ToolPageBase
|
|||||||
_inlineFullscreen = false;
|
_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()
|
private async Task ShowFullscreenAsync()
|
||||||
{
|
{
|
||||||
EnterInlineFullscreen();
|
if (EnterWindowFullscreen())
|
||||||
|
{
|
||||||
|
ToastService.Show(AppLocalizer.T("已进入全屏,按 Esc 或点击窗口控件可退出。", "Fullscreen enabled."), ToastKind.Info, TimeSpan.FromSeconds(2));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
EnterInlineFullscreen();
|
||||||
|
}
|
||||||
|
|
||||||
await Task.CompletedTask;
|
await Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1023,25 +1247,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 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)
|
private static bool IsVideoCategory(RemoteMediaCategory category)
|
||||||
{
|
{
|
||||||
return category.Kind == RemoteMediaKind.Video ||
|
return PrimaryKind(category) == 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool IsImageCategory(RemoteMediaCategory category)
|
private static bool IsImageCategory(RemoteMediaCategory category)
|
||||||
{
|
{
|
||||||
return !IsVideoCategory(category) && !IsAudioCategory(category);
|
return PrimaryKind(category) == RemoteMediaKind.Image;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool IsVideoFormat(string format)
|
private static bool IsVideoFormat(string format)
|
||||||
@@ -1050,24 +1364,34 @@ public class RandomCinemaPage : ToolPageBase
|
|||||||
return ext is "mp4" or "mkv" or "webm" or "avi" or "mov" or "wmv" or "m4v";
|
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();
|
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)
|
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 category.DisplayName;
|
||||||
}
|
}
|
||||||
|
|
||||||
return IsVideoCategory(category)
|
return IsVideoCategory(category)
|
||||||
? AppLocalizer.T("随机视频", "Random Videos")
|
? AppLocalizer.T("随机视频", "Random Videos")
|
||||||
: IsAudioCategory(category)
|
: AppLocalizer.T("随机图片", "Random Images");
|
||||||
? AppLocalizer.T("随机音频", "Random Audio")
|
|
||||||
: AppLocalizer.T("随机图片", "Random Images");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string DisplaySourceName(RemoteMediaSource source)
|
private static string DisplaySourceName(RemoteMediaSource source)
|
||||||
@@ -1126,7 +1450,22 @@ public class RandomCinemaPage : ToolPageBase
|
|||||||
|
|
||||||
private static string FriendlyError(string message)
|
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
|
private static T WithColumn<T>(T element, int column) where T : FrameworkElement
|
||||||
@@ -1134,4 +1473,22 @@ public class RandomCinemaPage : ToolPageBase
|
|||||||
Grid.SetColumn(element, column);
|
Grid.SetColumn(element, column);
|
||||||
return element;
|
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": {
|
||||||
|
"随机放映室布局": "移除顶部说明卡片,按媒体类型、来源卡片、媒体预览三段式组织。",
|
||||||
|
"图片视频渲染": "图片源使用图片查看器,视频源使用播放器,避免图片被视频组件打开。",
|
||||||
|
"预览舞台": "图片和视频按固定比例自适应容器,支持全屏、刷新和另存。",
|
||||||
|
"视频音量": "视频预览页新增音量滑块,并记住本工具上次音量。",
|
||||||
|
"远程配置": "修复随机图片、随机视频配置中文乱码,并保留旧版字段兼容。"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,22 +1,44 @@
|
|||||||
{
|
{
|
||||||
"schema_version": 1,
|
"schema_version": 1,
|
||||||
"product": "YMhut Box",
|
"product": "YMhut Box",
|
||||||
"latest_version": "2.0.6.3",
|
"latest_version": "2.0.7.5",
|
||||||
"latest_notice_file": "2.0.6.3.json",
|
"latest_notice_file": "2.0.7.5.json",
|
||||||
"last_updated": "2026-06-23T00:00:00Z",
|
"last_updated": "2026-06-26T10:00:33Z",
|
||||||
"latest": {
|
"latest": {
|
||||||
"version": "2.0.6.3",
|
"version": "2.0.7.5",
|
||||||
"build": "3",
|
"build": "05",
|
||||||
"channel": "stable",
|
"channel": "stable",
|
||||||
"title": "YMhut Box 2.0.6.3",
|
"title": "YMhut Box 2.0.7.5",
|
||||||
"message": "QQ 信息查询升级、远程工具请求友好化、榜单资讯外链进入安全浏览器、工具箱宽屏布局和窗口拖动体验优化。",
|
"message": "随机放映室布局、图片/视频渲染、自适应预览、全屏和视频音量控制优化。",
|
||||||
"release_date": "2026-06-23",
|
"release_date": "2026-06-26",
|
||||||
"download_url": "https://update.ymhut.cn/downloads/YMhut_Box_WinUI_Setup_2.0.6.3.exe",
|
"download_url": "https://update.ymhut.cn/downloads/YMhut_Box_WinUI_Setup_2.0.7.5.exe",
|
||||||
"release_notes": "本版本升级 QQ 信息查询,适配新版接口返回的头像、昵称、个性签名、等级、会员状态和时间字段;敏感请求地址继续隐藏,头像等公开响应链接可展示。远程工具请求使用更长策略并支持一次短重试,超时和网络失败显示友好状态。榜单、资讯和公开链接默认通过应用内安全浏览器打开。宽屏工具箱减少右侧详情预览占位,窗口拖动期间启用轻量模式,降低 WebView 动效和布局重算。",
|
"release_notes": "随机放映室移除顶部说明卡片,改为更接近旧版的媒体类型、来源卡片、媒体预览三段式布局;修复图片源被视频播放器打开导致无法显示的问题;图片和视频预览会自适应容器大小,支持全屏、刷新、保存和视频音量调节;修复随机图片、随机视频远程配置中的中文乱码;继续优先读取新版统一服务,并保留旧版 media-types.json 回退兼容。",
|
||||||
"message_md": "# YMhut Box 2.0.6.3\n\n本版本继续打磨工具工作台:QQ 信息查询升级到新版资料源,远程工具请求更稳,榜单和资讯外链默认进入应用内安全浏览器,宽屏工具箱展示更多工具卡片,窗口拖动过程减少动画和重排。",
|
"message_md": "# YMhut Box 2.0.7.5\n\n本版本专门打磨随机放映室体验:入口更轻,图片和视频类型更明确,远程媒体预览不会撑出容器,旧版媒体配置和新版统一服务继续兼容。",
|
||||||
"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 动效、悬浮刷新和不必要布局重算,拖动更顺。"
|
"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": [
|
"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",
|
"version": "2.0.6.3",
|
||||||
"channel": "stable",
|
"channel": "stable",
|
||||||
|
|||||||