更新客户端适配性问题
build-winui / winui (push) Waiting to run

This commit is contained in:
2026-06-28 08:56:45 +08:00
parent 962a2f2143
commit f00124c1c0
21 changed files with 428 additions and 63 deletions
@@ -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"`
+61 -12
View File
@@ -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);
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

+51 -1
View File
@@ -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;
+101 -34
View File
@@ -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;
}
+41 -5
View File
@@ -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))
+48 -7
View File
@@ -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