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