diff --git a/server/unified-management/internal/db/models.go b/server/unified-management/internal/db/models.go index ee5d2b4..54bee2b 100644 --- a/server/unified-management/internal/db/models.go +++ b/server/unified-management/internal/db/models.go @@ -22,6 +22,7 @@ type adminRow struct { type DatabaseStatus struct { ActiveProvider string `json:"activeProvider"` ConfigProvider string `json:"configProvider"` + SchemaVersion string `json:"schemaVersion"` SQLiteReady bool `json:"sqliteReady"` RemoteReady bool `json:"remoteReady"` FailoverActive bool `json:"failoverActive"` diff --git a/server/unified-management/internal/db/schema.go b/server/unified-management/internal/db/schema.go index 4ed41ba..b1f8345 100644 --- a/server/unified-management/internal/db/schema.go +++ b/server/unified-management/internal/db/schema.go @@ -22,6 +22,9 @@ func (s *Store) migrate(conn *sql.DB, d dialect) error { return err } } + if err := createSchemaIndexes(conn, d); err != nil { + return err + } return s.recordSchemaVersion(conn, d) } @@ -281,21 +284,67 @@ func schemaStatements(d dialect) []string { created_at %s NOT NULL, finished_at %s NOT NULL DEFAULT '' )`, d.idType(), keyText, keyText, keyText, longText, shortText, shortText, shortText), - `CREATE INDEX IF NOT EXISTS idx_feedback_tickets_activity ON feedback_tickets(last_activity_at)`, - `CREATE INDEX IF NOT EXISTS idx_feedback_comments_code ON feedback_comments(feedback_code)`, - `CREATE INDEX IF NOT EXISTS idx_feedback_attachments_code ON feedback_attachments(feedback_code)`, - `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_audit_logs_type ON audit_logs(type)`, - `CREATE INDEX IF NOT EXISTS idx_audit_logs_target ON audit_logs(target)`, - `CREATE INDEX IF NOT EXISTS idx_legacy_json_revisions_name ON legacy_json_revisions(name, id)`, - `CREATE INDEX IF NOT EXISTS idx_release_notices_version ON release_notices(version)`, - `CREATE INDEX IF NOT EXISTS idx_release_notice_revisions_version ON release_notice_revisions(version, id)`, } } +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 { columns := []string{"version", "applied_at", "description"} _, err := conn.Exec(d.rebind(d.upsert("schema_migrations", columns, []string{"version"})), diff --git a/server/unified-management/internal/db/store.go b/server/unified-management/internal/db/store.go index 4e29184..8a17753 100644 --- a/server/unified-management/internal/db/store.go +++ b/server/unified-management/internal/db/store.go @@ -66,6 +66,7 @@ func Open(cfg *config.Config) (*Store, error) { status: DatabaseStatus{ ActiveProvider: "sqlite", ConfigProvider: cfg.Database.Provider, + SchemaVersion: CurrentSchemaVersion, SQLiteReady: true, LastRecoveredAt: Now(), }, @@ -165,6 +166,7 @@ func (s *Store) ReconfigureDatabase(cfg *config.Config) error { 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 = "" @@ -269,6 +271,7 @@ func (s *Store) openRemote() error { s.dialect = remoteDialect s.status.ActiveProvider = "mysql" s.status.ConfigProvider = "mysql" + s.status.SchemaVersion = CurrentSchemaVersion s.status.RemoteReady = true s.status.FailoverActive = false s.status.LastError = "" @@ -304,6 +307,7 @@ func (s *Store) checkRemote() { } s.status.ActiveProvider = "mysql" s.status.RemoteReady = true + s.status.SchemaVersion = CurrentSchemaVersion s.status.FailoverActive = false s.status.LastError = "" s.status.LastRecoveredAt = Now() @@ -320,6 +324,7 @@ func (s *Store) markFailover(err error) { s.dialect = s.localDialect s.status.ActiveProvider = "sqlite" s.status.ConfigProvider = s.cfg.Database.Provider + s.status.SchemaVersion = CurrentSchemaVersion s.status.RemoteReady = false s.status.FailoverActive = !strings.EqualFold(s.cfg.Database.Provider, "sqlite") s.status.LastError = err.Error() diff --git a/server/unified-management/internal/db/store_test.go b/server/unified-management/internal/db/store_test.go index 3478ae3..4f5b961 100644 --- a/server/unified-management/internal/db/store_test.go +++ b/server/unified-management/internal/db/store_test.go @@ -138,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) { root := t.TempDir() path := filepath.Join(root, "unified.sqlite") diff --git a/server/unified-management/internal/web/admin_system_routes.go b/server/unified-management/internal/web/admin_system_routes.go index 91f324a..9e7f01c 100644 --- a/server/unified-management/internal/web/admin_system_routes.go +++ b/server/unified-management/internal/web/admin_system_routes.go @@ -271,12 +271,16 @@ func (r *router) handleMigrationStatus(w http.ResponseWriter, req *http.Request) "fileAssets": []map[string]string{ {"name": "downloads", "path": r.cfg.DownloadsDir, "description": "发布包和下载文件"}, {"name": "update public", "path": r.cfg.UpdatePublicDir, "description": "旧客户端兼容 JSON 生成物"}, - {"name": "feedback packages", "path": filepath.Join(r.cfg.StorageDir, "feedback-packages"), "description": "反馈附件包"}, + {"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, }, }) diff --git a/server/unified-management/web/admin/src/views/SystemView.vue b/server/unified-management/web/admin/src/views/SystemView.vue index 641650f..5f6163a 100644 --- a/server/unified-management/web/admin/src/views/SystemView.vue +++ b/server/unified-management/web/admin/src/views/SystemView.vue @@ -30,9 +30,12 @@ const tabs = [
配置类型{{ ctx.database?.configProvider || "-" }} + Schema{{ ctx.database?.schemaVersion || "-" }} + 活动数据库{{ ctx.database?.activeProvider || "-" }} SQLite{{ ctx.database?.sqliteReady ? "ready" : "missing" }} MySQL{{ ctx.database?.remoteReady ? "ready" : "offline" }} Failover{{ ctx.database?.failoverActive ? "active" : "standby" }} + 恢复时间{{ ctx.database?.lastRecoveredAt || "-" }} 最后同步{{ ctx.database?.lastSyncAt || "-" }} 最近错误{{ ctx.database?.lastSyncError || ctx.database?.lastError || "-" }}
@@ -98,9 +101,12 @@ const tabs = [
策略{{ ctx.migrationStatus?.strategy || "-" }} SQLite 文件{{ ctx.migrationStatus?.sqlitePath || "-" }} + Schema{{ ctx.migrationStatus?.schemaVersion || "-" }} 活动数据库{{ ctx.migrationStatus?.activeProvider || "-" }} + MySQL{{ ctx.migrationStatus?.remoteReady ? "ready" : "offline" }} + Failover{{ ctx.migrationStatus?.failoverActive ? "active" : "standby" }} 最后同步{{ ctx.migrationStatus?.lastSyncAt || "-" }} - 同步错误{{ ctx.migrationStatus?.lastSyncError || "-" }} + 同步错误{{ ctx.migrationStatus?.lastSyncError || ctx.migrationStatus?.lastError || "-" }}
diff --git a/src/YMhut.Box.Core/Feedback/FeedbackSubmissionService.cs b/src/YMhut.Box.Core/Feedback/FeedbackSubmissionService.cs index 332ff5b..a67b31d 100644 --- a/src/YMhut.Box.Core/Feedback/FeedbackSubmissionService.cs +++ b/src/YMhut.Box.Core/Feedback/FeedbackSubmissionService.cs @@ -7,7 +7,7 @@ namespace YMhut.Box.Core.Feedback; 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"; private readonly HttpClient _httpClient = httpClient ?? new HttpClient { Timeout = TimeSpan.FromSeconds(30) }; diff --git a/src/YMhut.Box.Core/Media/RemoteMediaCatalog.cs b/src/YMhut.Box.Core/Media/RemoteMediaCatalog.cs index dabb831..16e98f2 100644 --- a/src/YMhut.Box.Core/Media/RemoteMediaCatalog.cs +++ b/src/YMhut.Box.Core/Media/RemoteMediaCatalog.cs @@ -72,6 +72,8 @@ public sealed record RemoteMediaSource( { 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); diff --git a/src/YMhut.Box.Core/Media/RemoteMediaResolver.cs b/src/YMhut.Box.Core/Media/RemoteMediaResolver.cs index 8da94b7..b2cf789 100644 --- a/src/YMhut.Box.Core/Media/RemoteMediaResolver.cs +++ b/src/YMhut.Box.Core/Media/RemoteMediaResolver.cs @@ -30,6 +30,7 @@ public sealed class RemoteMediaResolver : IRemoteMediaResolver { private const int MaxMediaRedirects = 8; private const long MaxTextProbeLength = 2 * 1024 * 1024; + private static long CacheBustSequence; private static readonly Regex AbsoluteUrlRegex = new(@"https?://[^\s""'<>\\]+", RegexOptions.IgnoreCase | RegexOptions.Compiled); private readonly Func? _handlerFactory; @@ -161,6 +162,14 @@ public sealed class RemoteMediaResolver : IRemoteMediaResolver 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.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; } @@ -180,7 +189,9 @@ public sealed class RemoteMediaResolver : IRemoteMediaResolver } 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) diff --git a/src/YMhut.Box.Tests/FeedbackServiceTests.cs b/src/YMhut.Box.Tests/FeedbackServiceTests.cs index 3362a71..828c7f2 100644 --- a/src/YMhut.Box.Tests/FeedbackServiceTests.cs +++ b/src/YMhut.Box.Tests/FeedbackServiceTests.cs @@ -168,6 +168,12 @@ public sealed class FeedbackServiceTests 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] public void FeedbackStatusResponseParsesExtendedServerFields() { diff --git a/src/YMhut.Box.Tests/RemoteMediaCatalogTests.cs b/src/YMhut.Box.Tests/RemoteMediaCatalogTests.cs index 2b98651..e16792b 100644 --- a/src/YMhut.Box.Tests/RemoteMediaCatalogTests.cs +++ b/src/YMhut.Box.Tests/RemoteMediaCatalogTests.cs @@ -109,6 +109,7 @@ public sealed class RemoteMediaCatalogTests 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); } diff --git a/src/YMhut.Box.Tests/RemoteMediaResolverTests.cs b/src/YMhut.Box.Tests/RemoteMediaResolverTests.cs index 5cfaa25..41f7193 100644 --- a/src/YMhut.Box.Tests/RemoteMediaResolverTests.cs +++ b/src/YMhut.Box.Tests/RemoteMediaResolverTests.cs @@ -121,6 +121,29 @@ public sealed class RemoteMediaResolverTests Assert.AreEqual(".mp3", result.SuggestedExtension); } + [TestMethod] + public async Task CacheBustsEachRefreshAndDisablesHttpCaches() + { + var observed = new List(); + 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 responseFactory) { return new RemoteMediaResolver(() => new StubHttpHandler(responseFactory)); @@ -150,4 +173,22 @@ public sealed class RemoteMediaResolverTests 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); + } } diff --git a/src/box-winUI/Assets/LockScreenLogo.png b/src/box-winUI/Assets/LockScreenLogo.png index c0e57c6..f56993c 100644 Binary files a/src/box-winUI/Assets/LockScreenLogo.png and b/src/box-winUI/Assets/LockScreenLogo.png differ diff --git a/src/box-winUI/Assets/Square150x150Logo.png b/src/box-winUI/Assets/Square150x150Logo.png index f482e4e..1432f18 100644 Binary files a/src/box-winUI/Assets/Square150x150Logo.png and b/src/box-winUI/Assets/Square150x150Logo.png differ diff --git a/src/box-winUI/Assets/Square44x44Logo.png b/src/box-winUI/Assets/Square44x44Logo.png index 3a37741..a9c7f8f 100644 Binary files a/src/box-winUI/Assets/Square44x44Logo.png and b/src/box-winUI/Assets/Square44x44Logo.png differ diff --git a/src/box-winUI/Assets/StoreLogo.png b/src/box-winUI/Assets/StoreLogo.png index f1cdf82..664681a 100644 Binary files a/src/box-winUI/Assets/StoreLogo.png and b/src/box-winUI/Assets/StoreLogo.png differ diff --git a/src/box-winUI/Assets/Wide310x150Logo.png b/src/box-winUI/Assets/Wide310x150Logo.png index 6530c38..6897305 100644 Binary files a/src/box-winUI/Assets/Wide310x150Logo.png and b/src/box-winUI/Assets/Wide310x150Logo.png differ diff --git a/src/box-winUI/Assets/tool-results/result.css b/src/box-winUI/Assets/tool-results/result.css index fdc4f9f..8fd28a5 100644 --- a/src/box-winUI/Assets/tool-results/result.css +++ b/src/box-winUI/Assets/tool-results/result.css @@ -33,6 +33,12 @@ html[data-theme="dark"] { * { box-sizing: border-box; } +*, +*::before, +*::after { + min-width: 0; +} + .drag-light *, .drag-light *::before, .drag-light *::after { @@ -63,6 +69,9 @@ button { padding: 7px 12px; cursor: pointer; transition: border-color 160ms ease, background-color 160ms ease, color 160ms ease; + max-width: 100%; + white-space: normal; + overflow-wrap: anywhere; } button:hover, @@ -85,6 +94,8 @@ button.compact { .result-page { display: grid; gap: 14px; + max-width: 100%; + overflow: hidden; } .result-chrome, @@ -104,6 +115,7 @@ button.compact { gap: 12px; align-items: center; padding: 14px 16px; + min-width: 0; } .result-chrome .identity { @@ -134,6 +146,7 @@ button.compact { margin: 0; font-size: clamp(1.05rem, 1.25vw, 1.45rem); line-height: 1.18; + overflow-wrap: anywhere; } .result-chrome .meta, @@ -146,6 +159,7 @@ button.compact { flex-wrap: wrap; gap: 8px; justify-content: flex-end; + min-width: 0; } .result-chrome .metrics { @@ -156,6 +170,7 @@ button.compact { .blocks { display: grid; gap: 12px; + min-width: 0; } .details-drawer { @@ -307,6 +322,7 @@ h2 { .block { padding: 14px; overflow: hidden; + min-width: 0; } .side-card { @@ -328,6 +344,17 @@ h2 { min-width: 0; } +.block-title h2, +.paragraph, +.muted, +.meta, +.item-body, +.kv, +.metric, +.badge { + overflow-wrap: anywhere; +} + .block-icon { width: 20px; height: 20px; @@ -347,6 +374,8 @@ h2 { background: var(--accent-soft); font-size: .74rem; font-weight: 800; + max-width: 100%; + white-space: normal; } .kv-grid { @@ -378,6 +407,7 @@ h2 { .list-item strong { display: block; overflow-wrap: anywhere; + word-break: break-word; } .metric-grid .primary-kv { @@ -394,6 +424,9 @@ h2 { .table-wrap { overflow: auto; + max-width: 100%; + max-height: min(58vh, 560px); + scrollbar-gutter: stable; } table { @@ -408,6 +441,10 @@ th { border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; + min-width: 120px; + max-width: 520px; + overflow-wrap: anywhere; + word-break: break-word; } th { @@ -432,6 +469,7 @@ th { gap: 10px; align-items: start; padding: 10px; + max-width: 100%; } .ranked-item { @@ -483,6 +521,9 @@ th { .rank-core { position: relative; z-index: 1; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; } .rank-1 { @@ -510,6 +551,7 @@ th { .item-body { min-width: 0; + max-width: 100%; } .item-body strong { @@ -542,21 +584,29 @@ th { } .code-wrap { - max-height: 420px; + max-height: min(62vh, 560px); overflow: auto; background: var(--panel-strong); + max-width: 100%; + scrollbar-gutter: stable; } pre { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; + word-break: break-word; padding: 12px; font-family: "Cascadia Mono", Consolas, monospace; font-size: .84rem; line-height: 1.55; } +.limit-note { + padding: 8px 12px 10px; + font-size: .78rem; +} + .media-frame { display: grid; place-items: center; diff --git a/src/box-winUI/Assets/tool-results/result.js b/src/box-winUI/Assets/tool-results/result.js index caed7a4..1826e2c 100644 --- a/src/box-winUI/Assets/tool-results/result.js +++ b/src/box-winUI/Assets/tool-results/result.js @@ -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.addEventListener('message', event => { @@ -82,6 +95,15 @@ function text(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) { const node = document.createElement(tag); if (className) node.className = className; @@ -147,35 +169,58 @@ function metrics(data) { 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) { const kind = (block.kind || '').toLowerCase(); const root = el('article', `block kind-${kind || 'text'}`); const head = el('div', 'block-head'); 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))); root.append(head); - if (kind === 'keyvalue' || kind === 'metric') root.append(kvGrid(block.pairs || [], data, kind)); - else if (kind === 'table' || kind === 'linechart') root.append(table(block.rows || [], data)); - 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))); + const renderer = blockRenderers[kind] || paragraphBlock; + root.append(renderer(block, data, kind)); return root; } +function paragraphBlock(block, data) { + return el('p', 'paragraph', previewText(block.text || '', data.privacyPolicy, LIMITS.subtitleChars)); +} + function kvGrid(pairs, data, kind) { 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' : ''}`); - 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); } + appendLimitNote(grid, pairs.length, LIMITS.keyValues); if (!pairs.length) grid.append(el('p', 'muted', t('emptyText'))); return grid; } @@ -183,10 +228,12 @@ function kvGrid(pairs, data, kind) { function table(rows, data) { const wrap = el('div', 'table-wrap'); 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 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); } if (!rows.length) { @@ -195,22 +242,24 @@ function table(rows, data) { tableNode.append(tr); } wrap.append(tableNode); + appendLimitNote(wrap, rows.length, LIMITS.tableRows); return wrap; } function list(items, data, 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 row = el('div', `list-item ${kind === 'rankedlist' ? 'ranked-item' : ''}`); row.append(rankBadge(normalized.rank, normalized.leading, kind)); const body = el('div', 'item-body'); - body.append(el('strong', '', redact(normalized.title, data.privacyPolicy))); - if (normalized.subtitle) body.append(el('span', 'item-subtitle', redact(normalized.subtitle, data.privacyPolicy))); + body.append(el('strong', '', previewText(normalized.title, data.privacyPolicy, LIMITS.titleChars))); + if (normalized.subtitle) body.append(el('span', 'item-subtitle', previewText(normalized.subtitle, data.privacyPolicy, LIMITS.subtitleChars))); row.append(body); makeCardLink(row, normalized.uri, normalized.title); root.append(row); } + appendLimitNote(root, items.length, LIMITS.listItems); if (!items.length) root.append(el('p', 'muted', t('emptyText'))); return root; } @@ -218,24 +267,33 @@ function list(items, data, kind) { function statusList(items, data, fallbackText) { if (!items.length) return code(redact(fallbackText || '', data.privacyPolicy)); 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 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'); - body.append(el('strong', '', redact(normalized.title, data.privacyPolicy))); - if (normalized.subtitle) body.append(el('span', 'item-subtitle', redact(normalized.subtitle, data.privacyPolicy))); + body.append(el('strong', '', previewText(normalized.title, data.privacyPolicy, LIMITS.titleChars))); + if (normalized.subtitle) body.append(el('span', 'item-subtitle', previewText(normalized.subtitle, data.privacyPolicy, LIMITS.subtitleChars))); row.append(body); if (normalized.uri) row.append(linkActions(normalized.uri)); root.append(row); } + appendLimitNote(root, items.length, LIMITS.listItems); 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) { - let title = text(item?.title || item?.text || t('emptyTitle')); - let subtitle = text(item?.subtitle || ''); - const leading = text(item?.leading || item?.status || index + 1); + let title = clampText(item?.title || item?.text || t('emptyTitle'), LIMITS.titleChars * 2); + let subtitle = clampText(item?.subtitle || '', LIMITS.subtitleChars * 2); + const leading = clampText(item?.leading || item?.status || index + 1, LIMITS.badgeChars); const rank = Number.parseInt(leading, 10) || index + 1; const parts = title.split(/\s+\/\s+/).filter(Boolean); if (parts.length > 1) { @@ -254,7 +312,7 @@ function normalizeListItem(item, index) { function rankBadge(rank, label, kind) { 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; } @@ -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 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); + if (truncated) { + wrap.append(el('p', 'muted limit-note', isEnglish() + ? 'Preview truncated. Copy raw output for the full result.' + : '预览已截断;复制原文可获取完整结果。')); + } return wrap; } @@ -296,8 +363,8 @@ function fileBlock(block, data) { const row = el('div', 'list-item file-card'); row.append(el('span', 'badge', 'FILE')); const body = el('div', 'item-body'); - body.append(el('strong', '', block.text || block.path || t('file'))); - body.append(el('span', '', redact(block.path || '', data.privacyPolicy))); + body.append(el('strong', '', previewText(block.text || block.path || t('file'), data.privacyPolicy, LIMITS.titleChars))); + body.append(el('span', '', previewText(block.path || '', data.privacyPolicy, LIMITS.subtitleChars))); row.append(body, button(t('openFolder'), () => post('openFolder', block.path))); return row; } @@ -306,8 +373,8 @@ function linkBlock(block, data) { const row = el('div', 'list-item link-card'); row.append(el('span', 'badge', 'URL')); const body = el('div', 'item-body'); - body.append(el('strong', '', redact(block.text || block.uri || t('link'), data.privacyPolicy))); - body.append(el('span', '', redact(block.uri || '', data.privacyPolicy))); + body.append(el('strong', '', previewText(block.text || block.uri || t('link'), data.privacyPolicy, LIMITS.titleChars))); + body.append(el('span', '', previewText(block.uri || '', data.privacyPolicy, LIMITS.subtitleChars))); row.append(body, linkActions(block.uri)); return row; } @@ -359,7 +426,7 @@ function rawCard(data) { event.stopPropagation?.(); post('copy', data.resultDocument?.rawText || ''); }, '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; } @@ -368,7 +435,7 @@ function emptyBlock(data) { root.append(el('h2', '', t('emptyTitle'))); root.append(el('p', 'muted', t('emptyText'))); 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; } diff --git a/src/box-winUI/Views/Tools/AdaptiveToolPage.cs b/src/box-winUI/Views/Tools/AdaptiveToolPage.cs index 2d700ff..1eeed9f 100644 --- a/src/box-winUI/Views/Tools/AdaptiveToolPage.cs +++ b/src/box-winUI/Views/Tools/AdaptiveToolPage.cs @@ -165,6 +165,7 @@ public abstract partial class AdaptiveToolPage : ToolPageBase private bool _toolPageWebViewFailed; private bool _useToolPageWebView; private string _lastRawOutput = string.Empty; + private ToolResultWebPayload? _pendingResultWebPayload; private ToolResultDocument? _lastToolPageDocument; private ToolResultRunState? _lastToolPageRunState; private CancellationTokenSource? _runCts; @@ -1997,6 +1998,12 @@ public abstract partial class AdaptiveToolPage : ToolPageBase resourceFolder = Path.Combine(AppContext.BaseDirectory, "Assets", "tool-results"); } + if (!Directory.Exists(resourceFolder)) + { + _resultWebViewFailed = true; + return; + } + var environment = await _webViewEnvironmentFactory.CreateAsync("ToolResults").ConfigureAwait(true); await _resultWebView.EnsureCoreWebView2Async(environment); _resultWebView.CoreWebView2.Settings.AreDefaultContextMenusEnabled = false; @@ -2013,15 +2020,27 @@ public abstract partial class AdaptiveToolPage : ToolPageBase { if (args.IsSuccess) { + _resultWebViewReady = true; + _resultWebViewFailed = false; + var hasResultPayload = _pendingResultWebPayload is not null || !string.IsNullOrWhiteSpace(_lastRawOutput); SendPerformanceMode(); + SendPendingResultWebPayload(); + if (hasResultPayload) + { + SetResultViewMode(_showRawResult); + } + return; } + + _resultWebViewReady = false; + _resultWebViewFailed = true; + SetResultViewMode(_showRawResult); }; _resultWebView.Source = new Uri("https://tool-results.ymhut.local/index.html"); - _resultWebViewReady = true; - SendPerformanceMode(); } catch (Exception exception) { + _resultWebViewReady = false; _resultWebViewFailed = true; CrashLog.Write(exception); } @@ -2029,16 +2048,22 @@ public abstract partial class AdaptiveToolPage : ToolPageBase private bool TryRenderResultWebView(ToolResultDocument document, long durationMs) { - if (Module is null || _resultWebViewFailed || !_resultWebViewReady || _resultWebView.CoreWebView2 is null) + if (Module is null || _resultWebViewFailed) { return false; } try { - _resultCards.Children.Clear(); 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(); 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) { if (TryRenderResultWebView(document, durationMs)) diff --git a/src/box-winUI/Views/Tools/RandomCinemaPage.cs b/src/box-winUI/Views/Tools/RandomCinemaPage.cs index b583387..81cec95 100644 --- a/src/box-winUI/Views/Tools/RandomCinemaPage.cs +++ b/src/box-winUI/Views/Tools/RandomCinemaPage.cs @@ -150,7 +150,7 @@ public class RandomCinemaPage : ToolPageBase private Grid BuildHeader(IToolModule module) { var back = ModernUi.IconButton("\uE72B", AppLocalizer.T("返回工具箱", "Back to toolbox"), () => _goBack?.Invoke()); - var refresh = ModernUi.PillButton(AppLocalizer.T("重新加载配置", "Reload sources"), "\uE895", async () => await LoadRemoteConfigAsync(forceRefresh: true), primary: true); + var refresh = ModernUi.PillButton(AppLocalizer.T("刷新源列表", "Refresh sources"), "\uE895", async () => await LoadRemoteConfigAsync(forceRefresh: true), primary: true); var grid = new Grid { @@ -651,7 +651,7 @@ public class RandomCinemaPage : ToolPageBase host.Children.Add(loading); 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())); var saveButton = ModernUi.PillButton(AppLocalizer.T("另存为", "Save as"), "\uE74E", async () => await SaveMediaAsync()); saveButton.IsEnabled = source.Downloadable; @@ -703,7 +703,7 @@ public class RandomCinemaPage : ToolPageBase host.Children.Add(media); _activeMediaHost = host; _activeMediaView = media; - ToastService.Show(AppLocalizer.T("媒体已重新加载", "Media reloaded"), ToastKind.Success); + ToastService.Show(AppLocalizer.T("媒体已重新获取", "Media refreshed"), ToastKind.Success); } catch (Exception exception) { @@ -729,8 +729,7 @@ public class RandomCinemaPage : ToolPageBase private async Task BuildImageViewerAsync(RemoteMediaSource source, IProgress? progress) { - var resolution = await _mediaResolver.ResolveMediaAsync(source.EffectiveApiUrl, RemoteMediaKind.Image, progress: progress); - EnsureExpectedMedia(resolution, RemoteMediaKind.Image); + var resolution = await ResolveFreshMediaAsync(source, RemoteMediaKind.Image, progress); _currentMediaUri = resolution.Uri; _currentExtension = resolution.SuggestedExtension; progress?.Report(45); @@ -750,8 +749,7 @@ public class RandomCinemaPage : ToolPageBase private async Task BuildVideoViewerAsync(RemoteMediaCategory category, RemoteMediaSource source, IProgress? progress) { - var resolution = await _mediaResolver.ResolveMediaAsync(source.EffectiveApiUrl, RemoteMediaKind.Video, progress: progress); - EnsureExpectedMedia(resolution, RemoteMediaKind.Video); + var resolution = await ResolveFreshMediaAsync(source, RemoteMediaKind.Video, progress); _currentMediaUri = resolution.Uri; _currentExtension = resolution.SuggestedExtension; progress?.Report(90); @@ -774,6 +772,49 @@ public class RandomCinemaPage : ToolPageBase return MediaStage(WrapPlayableMedia(media, category, source)); } + private async Task ResolveFreshMediaAsync( + RemoteMediaSource source, + RemoteMediaKind expectedKind, + IProgress? progress) + { + var primaryUrl = source.RefreshApiUrl; + var fallbackUrl = source.ResolvedUrl; + Exception? primaryError = null; + + if (!string.IsNullOrWhiteSpace(primaryUrl)) + { + try + { + var resolution = await _mediaResolver.ResolveMediaAsync(primaryUrl, expectedKind, cacheBust: true, progress: progress); + EnsureExpectedMedia(resolution, expectedKind); + return resolution; + } + catch (Exception exception) when (ShouldTryResolvedFallback(primaryUrl, fallbackUrl)) + { + primaryError = exception; + progress?.Report(35); + } + } + + if (ShouldTryResolvedFallback(primaryUrl, fallbackUrl)) + { + var resolution = await _mediaResolver.ResolveMediaAsync(fallbackUrl, expectedKind, cacheBust: true, progress: progress); + EnsureExpectedMedia(resolution, expectedKind); + return resolution; + } + + if (primaryError is not null) + { + throw new InvalidOperationException(primaryError.Message, primaryError); + } + + throw new InvalidOperationException(AppLocalizer.T("远程媒体源没有可用地址。", "The remote media source does not have a usable URL.")); + } + + 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) { return new MediaPlayer