@@ -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"`
|
||||
|
||||
@@ -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"})),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -30,9 +30,12 @@ const tabs = [
|
||||
</div>
|
||||
<div class="kv-grid">
|
||||
<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>MySQL</span><strong>{{ ctx.database?.remoteReady ? "ready" : "offline" }}</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?.lastSyncError || ctx.database?.lastError || "-" }}</strong>
|
||||
</div>
|
||||
@@ -98,9 +101,12 @@ const tabs = [
|
||||
<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 || "-" }}</strong>
|
||||
<span>同步错误</span><strong>{{ ctx.migrationStatus?.lastSyncError || ctx.migrationStatus?.lastError || "-" }}</strong>
|
||||
</div>
|
||||
<div class="ops-note">
|
||||
<AlertTriangle :size="16" />
|
||||
|
||||
@@ -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) };
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<HttpMessageHandler>? _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)
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -121,6 +121,29 @@ public sealed class RemoteMediaResolverTests
|
||||
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)
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
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; }
|
||||
|
||||
*,
|
||||
*::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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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<UIElement> BuildImageViewerAsync(RemoteMediaSource source, IProgress<double>? 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<UIElement> BuildVideoViewerAsync(RemoteMediaCategory category, RemoteMediaSource source, IProgress<double>? 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<RemoteMediaResolution> ResolveFreshMediaAsync(
|
||||
RemoteMediaSource source,
|
||||
RemoteMediaKind expectedKind,
|
||||
IProgress<double>? 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
|
||||
|
||||