diff --git a/TeXmacs/misc/themes/liii-night.css b/TeXmacs/misc/themes/liii-night.css index 4b6db867fd..42268b233e 100644 --- a/TeXmacs/misc/themes/liii-night.css +++ b/TeXmacs/misc/themes/liii-night.css @@ -1052,6 +1052,33 @@ QLabel#startup-tab-page-desc { color: #aaaaaa; } +/* 分类按钮 */ +QPushButton#startup-tab-category-btn { + background: transparent; + border: none; + color: #aaaaaa; +} + +QPushButton#startup-tab-category-btn:hover { + background: #4a4f57; + color: #ffffff; +} + +QPushButton#startup-tab-category-btn:checked { + background: #215a6a; + color: white; +} + +/* 模板卡片 */ +QFrame#startup-tab-template-card { + background: #3a3a3a; + border: 1px solid #4a4f57; +} + +QFrame#startup-tab-template-card:hover { + border: 1px solid #2791ad; +} + /* 模板使用按钮 - Template Use Button */ QPushButton#template-use-btn { color: white; @@ -1060,11 +1087,11 @@ QPushButton#template-use-btn { } QWidget#centralWidget QPushButton#template-use-btn { - background-color: #2791ad; + background-color: #215a6a; } QWidget#centralWidget QPushButton#template-use-btn:hover { - background-color: #215a6a; + background-color: #2791ad; } QWidget#centralWidget QPushButton#template-cancel-btn { diff --git a/TeXmacs/misc/themes/liii.css b/TeXmacs/misc/themes/liii.css index 5add1b9354..433011df61 100644 --- a/TeXmacs/misc/themes/liii.css +++ b/TeXmacs/misc/themes/liii.css @@ -997,6 +997,33 @@ QLabel#startup-tab-page-desc { color: #666666; } +/* 分类按钮 */ +QPushButton#startup-tab-category-btn { + background: transparent; + border: none; + color: #666666; +} + +QPushButton#startup-tab-category-btn:hover { + background: #F0F0F0; + color: #333333; +} + +QPushButton#startup-tab-category-btn:checked { + background: #215a6a; + color: white; +} + +/* 模板卡片 */ +QFrame#startup-tab-template-card { + background: white; + border: 1px solid #E5E5EA; +} + +QFrame#startup-tab-template-card:hover { + border: 1px solid #2791ad; +} + /* 模板使用按钮 - Template Use Button */ QPushButton#template-use-btn { background-color: #2791ad; diff --git a/devel/216_25.md b/devel/216_25.md new file mode 100644 index 0000000000..84aabc79ef --- /dev/null +++ b/devel/216_25.md @@ -0,0 +1,140 @@ +# 216_25 模板页面缩略图缓存与 UI 优化 + +## 如何测试 +1. 编译:`xmake b stem` +2. 打开启动页 `Template` 页面,确认模板卡片显示正常: + - 卡片有圆角、阴影效果。 + - 分类按钮样式正确(未选中灰色,选中深墨绿 `#215a6a`)。 + - 缩略图显示为顶部裁剪、宽度拉满的效果。 +3. 验证缩略图缓存: + - **首次打开**:观察控制台,应看到 `[TemplatePage] Download:` 或 `[TemplatePage] Validate:` 日志。 + - 关闭软件,重新打开同一页面: + - 已验证过的 URL 应显示 `[TemplatePage] Use cache:`,且**无网络请求**。 + - 未验证过的 URL(新会话首次)显示 `[TemplatePage] Validate:`,随后可能看到 `[TemplatePage] Cache fresh:`(304)。 + - 切换不同分类、切换到其他标签页(如 Recent)再切回 Template: + - 已显示的缩略图**不应再触发网络请求**。 +4. 验证缓存更新: + - **方案 A(修改服务器)**:修改服务器上的缩略图文件,下次启动验证时应返回 200,更新缓存。 + - **方案 B(本地测试)- 修改 ETag**: + 1. 打开模板页面,等待缩略图下载/验证完成。 + 2. 找到缓存目录(通常为 `~/.local/share/moganlab/system/cache/thumbnails/`)。 + 3. 打开 `thumbnail-index.json`,找到对应 URL 的 `etag` 字段,修改为一个错误值(如 `"wrong-etag"`)。 + 4. 关闭软件重新打开,进入 Template 页面。 + 5. 验证:由于 ETag 不匹配,服务器返回 200,重新下载缩略图并更新缓存。控制台应显示 `[TemplatePage] Update cache:`。 + - **方案 C(本地测试)- 删除索引文件**: + 1. 删除 `thumbnail-index.json`,但保留 `.jpg` 缓存文件。 + 2. 重新打开软件进入 Template 页面。 + 3. 验证:缺少 ETag 元数据,请求不会带 `If-None-Match`,服务器返回 200,重新下载。 + - **方案 D(本地测试)- 过期缓存**: + 1. 修改某个 `.jpg` 缓存文件的修改时间为很久以前:`touch -d "2020-01-01" xxx.jpg` + 2. 重新打开软件。 + 3. 验证:由于文件被认为已"过期",会触发重新下载。 +5. 检查缓存目录结构: + - 确认 `~/.local/share/moganlab/system/cache/thumbnails/` 下有 `.jpg` 图片文件和 `thumbnail-index.json` 元数据索引文件。 + - 确认没有 per-file 的 `.meta` 文件(已改为统一 JSON 索引)。 +6. 测试响应式布局: + - 调整窗口宽度,确认列数自适应变化(1~6 列)。 + - 窗口较小时不应只显示 1 列(早期 bug)。 +7. 测试预览弹窗: + - 点击任意模板卡片,确认弹窗初始尺寸正确(macOS 上不会出现高度过小的问题)。 + - 预览区域使用 `QTPdfPreviewWidget` 正常加载 PDF。 + - 点击 "Use Template" 和 "Cancel" 按钮正常。 +8. 验证 PDF 预览缓存(与缩略图类似的会话级 ETag 验证): + - **首次打开**某模板的预览弹窗: + - 若本地已有 PDF 缓存,预览应直接渲染(不先显示 "No Preview")。 + - 控制台应输出 `[PDF Preview] Validate:`,且请求携带 `If-None-Match`。 + - **关闭弹窗后,再次点击同一模板**(同一会话内): + - 控制台应输出 `[PDF Preview] Use cache:`,且**无网络请求**。 + - **关闭软件,重新打开**,再次进入同一模板预览: + - 新会话首次访问,应再次输出 `[PDF Preview] Validate:`,正确发送 ETag。 + - 若服务器返回 304,则后续同一会话内继续使用缓存。 + - 验证缓存更新(本地测试): + 1. 找到 `PdfFileCache` 缓存目录(通常为 `~/.local/share/moganlab/system/cache/pdf/`)。 + 2. 打开索引文件(如 `pdf-index.json` 或类似元数据),找到对应 URL 的 `etag` 字段,修改为错误值。 + 3. 关闭软件重新打开,进入该模板预览。 + 4. 验证:ETag 不匹配导致服务器返回 200,重新下载并更新缓存。控制台应显示 `[PDF Preview] Update cache:`。 + +## 2026/04/22 实现说明 + +### What +本次对模板页面(`QTTemplatePage`)进行了重构和优化,主要改动: + +- **缩略图缓存系统**:基于 `ThumbnailCache` 实现内存 LRU + 磁盘持久化缓存,支持 HTTP ETag 条件请求。 +- **会话级验证控制**:引入 `validatedUrls_` 集合,确保每个 URL 在每个会话中只发一次条件请求,后续直接使用缓存。 +- **UI 美化**:圆角卡片、阴影效果、分类按钮样式、缩略图顶部裁剪显示。 +- **响应式网格**:根据窗口宽度自动计算列数(1~6 列)。 +- **macOS 弹窗尺寸修复**:`setMinimumSize` 后添加 `resize` 确保初始尺寸正确。 +- **PDF 预览缓存优化**:基于 `PdfFileCache` 实现会话级验证,先显示缓存再后台校验,避免"No Preview"闪烁。 + +#### 修改文件 + +**src/Plugins/Qt/qt_template_page.cpp** (修改) +- **缩略图加载逻辑**: + - `loadThumbnail()`:缓存命中时**先显示缓存图**,再决定是否后台验证。 + - 引入 `validatedUrls_` 判断:已验证过的 URL 直接显示,不再发请求。 + - 未验证的缓存发送 `If-None-Match` 条件请求,304 时仅标记为已验证,200 时更新缓存和 UI。 +- **网络请求队列**:`processThumbnailQueue()` 控制最多 6 个并发请求。 +- **UI 样式**: + - 卡片使用 `QGraphicsDropShadowEffect` 添加阴影。 + - 卡片圆角、边框、hover 变色(`#2791ad`)。 + - 分类按钮选中状态背景色为 `#215a6a`。 + - 缩略图使用 `Qt::KeepAspectRatioByExpanding` + `copy(x, 0, w, h)` 实现顶部裁剪。 +- **预览弹窗尺寸**:`showTemplatePreview()` 中使用正方形预览区域,确保各平台显示一致。 +- **macOS 弹窗修复**:`showTemplatePreview()` 中 `dialog->resize()` 紧跟 `setMinimumSize()`。 +- **响应式布局**:`calculateColumnCount()` 在 viewport 未就绪时返回默认值 4,避免首屏显示 1 列。 +- **防重复刷新**:`gridNeedsRefresh_` 标志避免 `onTemplatesLoaded` 和 `showEvent` 双重刷新。 +- **网络错误时保留缓存图**:`onThumbnailLoaded` 中错误分支仅在 `req.label->pixmap().isNull()` 时才显示 `"Preview"` placeholder,避免覆盖已有缓存缩略图。 +- **无预览 URL 时立即清空**:`clearPreview("No Preview")` 仅在 `previewUrl.isEmpty()` 时调用,避免有缓存 PDF 时先显示无预览。 + +**src/Plugins/Qt/qt_template_page.hpp** (修改) +- `ThumbnailRequest` 结构体新增 `cachedEtag` 字段。 +- `QTTemplatePage` 新增 `validatedUrls_` 成员(`QSet`)。 +- 新增 `gridNeedsRefresh_` 布尔标志。 + +**src/Plugins/Qt/qt_pdf_preview_widget.cpp** (修改) +- **默认文案**:`setupUI()` 默认从 `"No Preview"` 改为 `"Loading..."`,避免首次打开时先闪出无预览状态。 +- **即时加载缓存**:`loadFromUrl()` 中若 `PdfFileCache` 命中,立即调用 `loadFromFile()` 渲染,**不再等待网络请求**。 +- **会话级验证**:引入 `static QSet s_validatedPdfUrls`。 + - 首次会话遇到缓存 -> 发 `If-None-Match` 条件请求。 + - 304 / 200 均标记为已验证,同一会话内后续打开直接 `Use cache`,**不再发请求**。 + - 新会话(软件重启)重新验证一次。 +- **日志示例**: + ``` + [PDF Preview] Update cache: "https://cdn.../report-example.pdf" + [PDF Preview] Use cache: "https://cdn.../report-example.pdf" + ``` + +**src/Mogan/Cache/thumbnail_cache.cpp** (修改) +- 实现基于 `QCache` 的内存 LRU 缓存(默认 50MB)。 +- 磁盘缓存使用统一 `thumbnail-index.json` 索引文件(替代早期 per-file `.meta` 方案)。 +- `getEntry()` / `put()` 支持 ETag 和 Last-Modified 读写。 +- `loadIndex()` / `saveIndex()` 管理 JSON 索引。 +- `cleanupExpired()` 清理过期文件(默认 30 天)。 + +**src/Mogan/Cache/thumbnail_cache.hpp** (修改) +- 新增 `ThumbnailCacheEntry` 结构体(`pixmap`, `etag`, `lastModified`, `isValid()`)。 +- 新增 `getEntry()`, `preload()`, `memoryCacheSize()`, `diskCacheSize()`, `memoryHits()` 等接口。 + +**src/Mogan/Cache/image_cache_base.hpp** (修改) +- `ImageCacheEntry` 新增 `etag` 和 `lastModified` 字段。 + +### Why +1. **ETag 条件请求**:避免每次切换分类都重复下载缩略图,同时保证软件重启后能检测到服务器上的更新。 +2. **会话级验证**:用户明确要求"请求应该就是软件打开的时候直接发起一次就可以了,没必要反复来"。缩略图和 PDF 预览均引入会话级 `validatedUrls_` / `s_validatedPdfUrls`,同一会话内仅验证一次。 +3. **统一 JSON 索引**:替代 per-file `.meta`,减少文件数量,简化管理。 +4. **UI 美化**:提升模板页面的视觉质感,与整体设计风格保持一致。 +5. **顶部裁剪**:缩略图原比例显示时高度不一,裁剪为统一尺寸后网格更整齐。 +6. **PDF 即时加载**:避免用户先看到 "No Preview" 再加载缓存的闪烁体验,有缓存时直接渲染。 + +### How +1. **缓存命中时先显示、后验证**:`loadThumbnail()` 中无论是否已验证,都先把缓存的 `pixmap` 设置到 `label`,避免用户看到 "Loading..."。PDF 预览同理,有缓存直接 `loadFromFile()`。 +2. **条件请求流程**: + - 首次会话遇到缓存 -> 发 `If-None-Match`。 + - 304 -> 标记 `validatedUrls_` / `s_validatedPdfUrls`,UI 不变。 + - 200 -> 渲染新图、更新缓存、更新 UI、标记已验证。 +3. **统一索引文件**: + - `diskIndex_` 维护在内存中的 `QHash`。 + - 构造时 `loadIndex()` 读取 `thumbnail-index.json`。 + - `put()` 时通过 `QMetaObject::invokeMethod(..., Qt::QueuedConnection)` 异步保存图片和索引,避免死锁。 +4. **响应式列数**:`availableWidth < cardSpace` 时返回 4(而非 1),解决首屏布局问题。 +5. **错误时保留缓存**:网络请求失败(非 200/304)时,若 `label` 已有缓存图则保持显示,仅在没有缓存图时才显示 placeholder。 diff --git a/src/Mogan/Cache/image_cache_base.hpp b/src/Mogan/Cache/image_cache_base.hpp index 5f4cef4599..a6a45b55d9 100644 --- a/src/Mogan/Cache/image_cache_base.hpp +++ b/src/Mogan/Cache/image_cache_base.hpp @@ -24,11 +24,15 @@ struct ImageCacheEntry { QString key; QDateTime cachedAt; qint64 cost; // Memory cost (bytes) + QString etag; + QDateTime lastModified; ImageCacheEntry () : cost (0) {} - ImageCacheEntry (const QPixmap& px, const QString& k, qint64 c) + ImageCacheEntry (const QPixmap& px, const QString& k, qint64 c, + const QString& e = QString (), + const QDateTime& lm= QDateTime ()) : pixmap (px), key (k), cachedAt (QDateTime::currentDateTime ()), - cost (c) {} + cost (c), etag (e), lastModified (lm) {} }; /** diff --git a/src/Mogan/Cache/thumbnail_cache.cpp b/src/Mogan/Cache/thumbnail_cache.cpp index eb60baa1ec..53ae60186c 100644 --- a/src/Mogan/Cache/thumbnail_cache.cpp +++ b/src/Mogan/Cache/thumbnail_cache.cpp @@ -7,9 +7,12 @@ #include "thumbnail_cache.hpp" +#include #include #include #include +#include +#include #include #include #include @@ -18,11 +21,23 @@ static ThumbnailCache* g_instance= nullptr; static QMutex s_instanceMutex; +static void +cleanupThumbnailCache () { + QMutexLocker locker (&s_instanceMutex); + delete g_instance; + g_instance= nullptr; +} + ThumbnailCache::ThumbnailCache (QObject* parent) : QObject (parent), memoryCache_ (MAX_MEMORY_COST_MB * 1024 * 1024), - memoryHits_ (0), diskHits_ (0), misses_ (0) {} + memoryHits_ (0), diskHits_ (0), misses_ (0), indexDirty_ (false), + saveIndexTimer_ (nullptr) { + loadIndex (); +} ThumbnailCache::~ThumbnailCache () { + // Ensure pending index changes are flushed before destruction + flushIndex (); if (g_instance == this) { g_instance= nullptr; } @@ -33,13 +48,15 @@ ThumbnailCache::instance () { QMutexLocker locker (&s_instanceMutex); if (!g_instance) { g_instance= new ThumbnailCache (); + qAddPostRoutine (cleanupThumbnailCache); } return g_instance; } -QPixmap -ThumbnailCache::get (const QString& url, const QSize& targetSize) { - QString key= cacheKey (url, targetSize); +ThumbnailCache::ThumbnailCacheEntry +ThumbnailCache::getEntry (const QString& url, const QSize& targetSize) { + QString key= cacheKey (url, targetSize); + ThumbnailCacheEntry result; QMutexLocker locker (&mutex_); @@ -47,7 +64,10 @@ ThumbnailCache::get (const QString& url, const QSize& targetSize) { ImageCacheEntry* entry= memoryCache_.object (key); if (entry) { memoryHits_++; - return entry->pixmap; + result.pixmap = entry->pixmap; + result.etag = entry->etag; + result.lastModified= entry->lastModified; + return result; } // Try to load from disk @@ -56,21 +76,44 @@ ThumbnailCache::get (const QString& url, const QSize& targetSize) { !ImageCacheUtils::isFileExpired (path, DISK_CACHE_DAYS)) { QPixmap pixmap (path); if (!pixmap.isNull ()) { + QString etag; + QDateTime lastModified; + auto it= diskIndex_.find (key); + if (it != diskIndex_.end ()) { + QJsonObject meta= it.value (); + etag = meta["etag"].toString (); + QString lmStr = meta["lastModified"].toString (); + if (!lmStr.isEmpty ()) { + lastModified= QDateTime::fromString (lmStr, Qt::ISODate); + } + } + // Store in memory cache for future access qint64 cost= ImageCacheUtils::pixmapCost (pixmap); - memoryCache_.insert (key, new ImageCacheEntry (pixmap, key, cost), cost); + memoryCache_.insert ( + key, new ImageCacheEntry (pixmap, key, cost, etag, lastModified), + cost); diskHits_++; - return pixmap; + result.pixmap = pixmap; + result.etag = etag; + result.lastModified= lastModified; + return result; } } misses_++; - return QPixmap (); + return result; +} + +QPixmap +ThumbnailCache::get (const QString& url, const QSize& targetSize) { + return getEntry (url, targetSize).pixmap; } void ThumbnailCache::put (const QString& url, const QSize& targetSize, - const QPixmap& pixmap) { + const QPixmap& pixmap, const QString& etag, + const QDateTime& lastModified) { if (pixmap.isNull ()) return; QString key= cacheKey (url, targetSize); @@ -79,14 +122,38 @@ ThumbnailCache::put (const QString& url, const QSize& targetSize, // Store in memory cache qint64 cost= ImageCacheUtils::pixmapCost (pixmap); - memoryCache_.insert (key, new ImageCacheEntry (pixmap, key, cost), cost); + memoryCache_.insert ( + key, new ImageCacheEntry (pixmap, key, cost, etag, lastModified), cost); + + // Update disk index + QJsonObject meta; + if (!etag.isEmpty ()) meta["etag"]= etag; + if (lastModified.isValid ()) + meta["lastModified"]= lastModified.toString (Qt::ISODate); + meta["cachedAt"]= QDateTime::currentDateTime ().toString (Qt::ISODate); + diskIndex_[key] = meta; + indexDirty_ = true; + + // Debounce index flush to batch multiple puts into a single disk write + if (!saveIndexTimer_) { + saveIndexTimer_= new QTimer (this); + saveIndexTimer_->setSingleShot (true); + saveIndexTimer_->setInterval (500); + connect (saveIndexTimer_, &QTimer::timeout, this, + &ThumbnailCache::flushIndex); + } + saveIndexTimer_->start (); - // Save to disk asynchronously (don't block) - Qt::ConnectionType connType= QThread::currentThread () == this->thread () - ? Qt::DirectConnection - : Qt::QueuedConnection; + // Save image to disk asynchronously (queued to avoid deadlock with mutex) QMetaObject::invokeMethod ( - this, [this, key, pixmap] () { saveToDisk (key, pixmap); }, connType); + this, [this, key, pixmap] () { saveToDisk (key, pixmap); }, + Qt::QueuedConnection); +} + +void +ThumbnailCache::put (const QString& url, const QSize& targetSize, + const QPixmap& pixmap) { + put (url, targetSize, pixmap, QString (), QDateTime ()); } bool @@ -120,8 +187,21 @@ ThumbnailCache::preload (const QString& url, const QSize& targetSize) { if (QFile::exists (path)) { QPixmap pixmap (path); if (!pixmap.isNull ()) { + QString etag; + QDateTime lastModified; + auto it= diskIndex_.find (key); + if (it != diskIndex_.end ()) { + QJsonObject meta= it.value (); + etag = meta["etag"].toString (); + QString lmStr = meta["lastModified"].toString (); + if (!lmStr.isEmpty ()) { + lastModified= QDateTime::fromString (lmStr, Qt::ISODate); + } + } qint64 cost= ImageCacheUtils::pixmapCost (pixmap); - memoryCache_.insert (key, new ImageCacheEntry (pixmap, key, cost), cost); + memoryCache_.insert ( + key, new ImageCacheEntry (pixmap, key, cost, etag, lastModified), + cost); } } } @@ -131,13 +211,15 @@ ThumbnailCache::clear () { QMutexLocker locker (&mutex_); memoryCache_.clear (); + diskIndex_.clear (); - // Clear disk cache + // Clear disk cache (including index file) QString dir= ImageCacheUtils::cacheSubdir (CACHE_SUBDIR); QDir cacheDir (dir); for (const QString& file : cacheDir.entryList (QDir::Files)) { cacheDir.remove (file); } + qDebug () << "[ThumbnailCache] Cleared all cached thumbnails"; } void @@ -178,8 +260,68 @@ ThumbnailCache::diskPath (const QString& key) const { return QDir (dir).filePath (hash + ".jpg"); } +QString +ThumbnailCache::indexPath () const { + QString dir= ImageCacheUtils::cacheSubdir (CACHE_SUBDIR); + return QDir (dir).filePath ("thumbnail-index.json"); +} + +void +ThumbnailCache::loadIndex () { + QString path= indexPath (); + if (!QFile::exists (path)) return; + + QFile file (path); + if (!file.open (QIODevice::ReadOnly)) { + qWarning () << "[ThumbnailCache] Failed to read index:" << path; + return; + } + + QJsonDocument doc= QJsonDocument::fromJson (file.readAll ()); + file.close (); + + if (!doc.isObject ()) return; + + QJsonObject obj= doc.object (); + for (auto it= obj.begin (); it != obj.end (); ++it) { + diskIndex_[it.key ()]= it.value ().toObject (); + } + qDebug () << "[ThumbnailCache] Loaded index with" << diskIndex_.size () + << "entries"; +} + +void +ThumbnailCache::saveIndex () { + QJsonObject obj; + { + QMutexLocker locker (&mutex_); + for (auto it= diskIndex_.begin (); it != diskIndex_.end (); ++it) { + obj[it.key ()]= it.value (); + } + } + + QString path= indexPath (); + QFile file (path); + if (file.open (QIODevice::WriteOnly)) { + file.write (QJsonDocument (obj).toJson ()); + file.close (); + } + else { + qWarning () << "[ThumbnailCache] Failed to write index:" << path; + } +} + +void +ThumbnailCache::flushIndex () { + if (!indexDirty_) return; + saveIndex (); + indexDirty_= false; +} + void ThumbnailCache::saveToDisk (const QString& key, const QPixmap& pixmap) { QString path= diskPath (key); - pixmap.save (path, "JPEG", 85); // Good quality, smaller size + if (!pixmap.save (path, "JPEG", 85)) { + qWarning () << "[ThumbnailCache] Failed to write image:" << path; + } } diff --git a/src/Mogan/Cache/thumbnail_cache.hpp b/src/Mogan/Cache/thumbnail_cache.hpp index 56b49478c1..91a92fdd66 100644 --- a/src/Mogan/Cache/thumbnail_cache.hpp +++ b/src/Mogan/Cache/thumbnail_cache.hpp @@ -9,10 +9,12 @@ #define THUMBNAIL_CACHE_HPP #include +#include #include #include #include #include +#include #include "image_cache_base.hpp" @@ -36,19 +38,42 @@ class ThumbnailCache : public QObject { static ThumbnailCache* instance (); /** - * @brief Get thumbnail from cache + * @brief Cache entry with HTTP metadata + */ + struct ThumbnailCacheEntry { + QPixmap pixmap; + QString etag; + QDateTime lastModified; + bool isValid () const { return !pixmap.isNull (); } + }; + + /** + * @brief Get thumbnail from cache (with HTTP metadata) * @param url Image URL (used as cache key) * @param targetSize Target size for scaling (cached separately for different * sizes) - * @return Thumbnail pixmap, or null if not cached + * @return Cache entry with pixmap and ETag/Last-Modified + */ + ThumbnailCacheEntry getEntry (const QString& url, const QSize& targetSize); + + /** + * @brief Get thumbnail pixmap from cache (convenience wrapper) */ QPixmap get (const QString& url, const QSize& targetSize); /** - * @brief Store thumbnail in cache + * @brief Store thumbnail in cache (with HTTP metadata) * @param url Image URL * @param targetSize Target size * @param pixmap Thumbnail pixmap + * @param etag HTTP ETag header value + * @param lastModified HTTP Last-Modified header value + */ + void put (const QString& url, const QSize& targetSize, const QPixmap& pixmap, + const QString& etag, const QDateTime& lastModified); + + /** + * @brief Store thumbnail in cache (without HTTP metadata) */ void put (const QString& url, const QSize& targetSize, const QPixmap& pixmap); @@ -72,6 +97,11 @@ class ThumbnailCache : public QObject { */ void cleanupExpired (); + /** + * @brief Flush pending index changes to disk immediately + */ + void flushIndex (); + // Cache statistics qint64 memoryCacheSize () const; qint64 diskCacheSize () const; @@ -82,7 +112,9 @@ class ThumbnailCache : public QObject { private: QString cacheKey (const QString& url, const QSize& size) const; QString diskPath (const QString& key) const; - void loadFromDisk (const QString& key); + QString indexPath () const; + void loadIndex (); + void saveIndex (); void saveToDisk (const QString& key, const QPixmap& pixmap); private: @@ -96,6 +128,13 @@ class ThumbnailCache : public QObject { mutable qint64 diskHits_; mutable qint64 misses_; + // Disk index: key -> metadata JSON object (loaded from thumbnail-index.json) + QHash diskIndex_; + + // Index flush debounce (batch multiple puts into a single disk write) + bool indexDirty_ = false; + QTimer* saveIndexTimer_= nullptr; + // Configuration static constexpr int MAX_MEMORY_COST_MB= 50; static constexpr int DISK_CACHE_DAYS = 30; diff --git a/src/Plugins/Qt/qt_pdf_preview_widget.cpp b/src/Plugins/Qt/qt_pdf_preview_widget.cpp index b296be7b20..6faab28470 100644 --- a/src/Plugins/Qt/qt_pdf_preview_widget.cpp +++ b/src/Plugins/Qt/qt_pdf_preview_widget.cpp @@ -14,6 +14,7 @@ #include #include #include +#include #include #include #include @@ -27,6 +28,9 @@ #include "qt_utilities.hpp" #include +// 会话级已验证 URL 集合(同一会话内只发一次条件请求) +static QSet s_validatedPdfUrls; + // 常量定义 namespace { constexpr float kRenderOversample = 2.0F; @@ -140,8 +144,8 @@ QTPdfPreviewWidget::setupUI () { pageIndicator_->setMouseTracking (true); previewLabel_->setMouseTracking (true); - // 显示初始占位符 - clearPreview (qt_translate ("No Preview")); + // 默认显示 Loading,有内容时会被覆盖;无内容时由调用方设为 No Preview + clearPreview (qt_translate ("Loading...")); } void @@ -219,7 +223,20 @@ QTPdfPreviewWidget::loadFromUrl (const QString& url, int dpi) { // First check if PDF file is cached locally PdfCacheEntry cachedEntry= PdfFileCache::instance ()->getEntry (url); if (cachedEntry.isValid ()) { - // Check if remote has updated (conditional request) + // Render cached content immediately so user never sees "No Preview" + loadFromFile (cachedEntry.filePath, dpi); + // Restore URL key so background validation and cache updates use the + // original URL, not the local file path. + currentKey_= url; + + // Already validated this session: use cache directly, no network request + if (s_validatedPdfUrls.contains (url)) { + qDebug () << "[PDF Preview] Use cache:" << url; + return; + } + + // First time this session: validate with a conditional request + qDebug () << "[PDF Preview] Validate:" << url; QNetworkRequest request (url); if (!cachedEntry.etag.isEmpty ()) { request.setRawHeader ("If-None-Match", cachedEntry.etag.toUtf8 ()); @@ -397,6 +414,25 @@ QTPdfPreviewWidget::processNetworkReply (QPointer reply) { } pdfData_= reply->readAll (); + + // Extract HTTP cache headers BEFORE deleteLater to avoid use-after-free + QString etag= QString::fromUtf8 (reply->rawHeader ("ETag")); + QDateTime lastModified; + QString lastModStr= QString::fromUtf8 (reply->rawHeader ("Last-Modified")); + if (!lastModStr.isEmpty ()) { + // RFC 2822 / HTTP-date format: "Thu, 29 Jan 2026 11:32:54 GMT" + // Use RFC2822Date with C locale to ensure consistent parsing + lastModified= QLocale::c ().toDateTime (lastModStr, + "ddd, dd MMM yyyy hh:mm:ss 'GMT'"); + if (!lastModified.isValid ()) { + // Fallback to Qt's built-in RFC2822 parser + lastModified= QDateTime::fromString (lastModStr, Qt::RFC2822Date); + } + if (lastModified.isValid ()) { + lastModified.setTimeZone (QTimeZone::utc ()); + } + } + reply->deleteLater (); if (pdfData_.isEmpty ()) { @@ -406,30 +442,11 @@ QTPdfPreviewWidget::processNetworkReply (QPointer reply) { return; } - // Extract HTTP cache headers and save to file cache - QString etag; - QDateTime lastModified; - if (reply) { - etag = QString::fromUtf8 (reply->rawHeader ("ETag")); - QString lastModStr= QString::fromUtf8 (reply->rawHeader ("Last-Modified")); - if (!lastModStr.isEmpty ()) { - // RFC 2822 / HTTP-date format: "Thu, 29 Jan 2026 11:32:54 GMT" - // Use RFC2822Date with C locale to ensure consistent parsing - lastModified= QLocale::c ().toDateTime ( - lastModStr, "ddd, dd MMM yyyy hh:mm:ss 'GMT'"); - if (!lastModified.isValid ()) { - // Fallback to Qt's built-in RFC2822 parser - lastModified= QDateTime::fromString (lastModStr, Qt::RFC2822Date); - } - if (lastModified.isValid ()) { - lastModified.setTimeZone (QTimeZone::utc ()); - } - } - } - // Save to cache with HTTP metadata PdfFileCache::instance ()->saveToCache (currentKey_, pdfData_, etag, lastModified); + s_validatedPdfUrls.insert (currentKey_); + qDebug () << "[PDF Preview] Update cache:" << currentKey_; renderCurrentPage (); currentLoadType_= LoadType::None; @@ -446,7 +463,8 @@ QTPdfPreviewWidget::onConditionalReplyFinished (const QString& cachedFilePath, // 304 Not Modified - use cached file if (reply->attribute (QNetworkRequest::HttpStatusCodeAttribute).toInt () == 304) { - qDebug () << "[PDF Preview] Remote not modified, using cache"; + qDebug () << "[PDF Preview] Cache fresh:" << currentKey_; + s_validatedPdfUrls.insert (currentKey_); reply->deleteLater (); loadFromFile (cachedFilePath, dpi); return; diff --git a/src/Plugins/Qt/qt_template_page.cpp b/src/Plugins/Qt/qt_template_page.cpp index 8c17f7029d..91508a6f6d 100644 --- a/src/Plugins/Qt/qt_template_page.cpp +++ b/src/Plugins/Qt/qt_template_page.cpp @@ -7,13 +7,14 @@ #include "qt_template_page.hpp" -#include #include #include #include +#include #include #include #include +#include #include #include #include @@ -23,9 +24,11 @@ #include #include #include +#include #include #include #include +#include #include #include @@ -52,7 +55,7 @@ constexpr int kCardHeight = 220; // 模板卡片高度 constexpr int kCardMargin = 12; // 卡片内边距 constexpr int kCardSpacing = 8; // 卡片内部间距 constexpr int kNameLabelMaxHeight = 40; // 模板名称最大高度 -constexpr int kPreviewDialogMinW = 800; // 预览弹窗最小宽度 +constexpr int kPreviewDialogMinW = 700; // 预览弹窗最小宽度 constexpr int kPreviewDialogMinH = 800; // 预览弹窗最小高度 constexpr int kPreviewLayoutSpacing= 16; // 预览弹窗布局间距 constexpr int kPreviewLayoutMargin = 24; // 预览弹窗布局边距 @@ -68,21 +71,18 @@ constexpr int kThumbBorderWidthPx = 1; // 缩略图边框宽度 constexpr int kUseButtonRadiusPx = 4; // Use Template 按钮圆角 constexpr int kUseButtonPadYPx = 8; // Use Template 按钮纵向内边距 constexpr int kUseButtonPadXPx = 24; // Use Template 按钮横向内边距 +constexpr int kGridMarginPx = 30; // 网格布局上下边距 +constexpr int kCategoryBtnRadiusPx = 12; // 分类按钮圆角 +constexpr int kCategoryBtnPadYPx = 6; // 分类按钮纵向内边距 +constexpr int kCategoryBtnPadXPx = 14; // 分类按钮横向内边距 +constexpr int kCardRadiusPx = 8; // 模板卡片圆角 void -applyThumbnailFrameStyle (QLabel* label, bool loaded) { +applyThumbnailFrameStyle (QLabel* label) { if (!label) return; - if (loaded) { - label->setStyleSheet (QString ("border-radius: %1px; border-width: 0px;") - .arg (DpiUtils::scaled (kThumbRadiusPx))); - } - else { - label->setStyleSheet ( - QString ( - "border-radius: %1px; border-width: %2px; border-style: solid;") - .arg (DpiUtils::scaled (kThumbRadiusPx)) - .arg (DpiUtils::scaled (kThumbBorderWidthPx))); - } + label->setStyleSheet ( + QString ("QLabel#startup-tab-template-thumbnail { border-radius: %1px; }") + .arg (DpiUtils::scaled (kThumbRadiusPx))); } } // namespace @@ -92,8 +92,21 @@ QTTemplatePage::QTTemplatePage (QWidget* parent) scrollArea_ (nullptr), gridWidget_ (nullptr), gridLayout_ (nullptr), progressDialog_ (nullptr), templateManager_ (nullptr), currentCategory_ (""), activeCategoryBtn_ (nullptr), - networkManager_ (nullptr) { + networkManager_ (nullptr), resizeDebounceTimer_ (nullptr) { networkManager_= new QNetworkAccessManager (this); + + resizeDebounceTimer_= new QTimer (this); + resizeDebounceTimer_->setSingleShot (true); + resizeDebounceTimer_->setInterval (200); + connect (resizeDebounceTimer_, &QTimer::timeout, this, [this] () { + if (templateManager_ && templateManager_->isInitialized ()) { + int newColumnCount= calculateColumnCount (); + if (newColumnCount != currentColumnCount_) { + refreshTemplateGrid (currentCategory_); + } + } + }); + setupUI (); } @@ -158,7 +171,8 @@ QTTemplatePage::setupUI () { gridWidget_= new QWidget (scrollArea_); gridLayout_= new QGridLayout (gridWidget_); gridLayout_->setSpacing (DpiUtils::scaled (kGridSpacing)); - gridLayout_->setContentsMargins (0, 0, 0, 0); + gridLayout_->setContentsMargins (0, DpiUtils::scaled (kGridMarginPx), 0, + DpiUtils::scaled (kGridMarginPx)); scrollArea_->setWidget (gridWidget_); layout->addWidget (scrollArea_, 1); @@ -169,7 +183,7 @@ QTTemplatePage::setupUI () { loadingLabel->setObjectName ("startup-tab-loading"); loadingLabel->setAlignment (Qt::AlignCenter); DpiUtils::applyScaledFont (loadingLabel, kLoadingFontPx); - gridLayout_->addWidget (loadingLabel, 0, 0, 1, 6); + gridLayout_->addWidget (loadingLabel, 0, 0, 1, 1); } void @@ -194,12 +208,25 @@ QTTemplatePage::setupCategoryBar () { QHBoxLayout* categoryLayout= qobject_cast (layout); if (!categoryLayout) return; + // Helper: apply category button style + auto styleCategoryBtn= [] (QPushButton* btn) { + btn->setStyleSheet (QString ("QPushButton#startup-tab-category-btn {" + " border-radius: %1px;" + " padding: %2px %3px;" + "}") + .arg (DpiUtils::scaled (kCategoryBtnRadiusPx)) + .arg (DpiUtils::scaled (kCategoryBtnPadYPx)) + .arg (DpiUtils::scaled (kCategoryBtnPadXPx))); + btn->setCursor (Qt::PointingHandCursor); + }; + // Add "All" button QPushButton* allBtn= new QPushButton (qt_translate ("All"), categoryBar_); allBtn->setObjectName ("startup-tab-category-btn"); allBtn->setCheckable (true); allBtn->setChecked (currentCategory_.isEmpty ()); allBtn->setProperty ("categoryId", QString ()); + styleCategoryBtn (allBtn); connect (allBtn, &QPushButton::clicked, this, &QTTemplatePage::onCategoryClicked); categoryLayout->addWidget (allBtn); @@ -218,6 +245,7 @@ QTTemplatePage::setupCategoryBar () { btn->setCheckable (true); btn->setChecked (cat.id == currentCategory_); btn->setProperty ("categoryId", cat.id); + styleCategoryBtn (btn); connect (btn, &QPushButton::clicked, this, &QTTemplatePage::onCategoryClicked); categoryLayout->addWidget (btn); @@ -246,18 +274,16 @@ int QTTemplatePage::calculateColumnCount () const { if (!scrollArea_) return 4; - // Calculate available width for grid int availableWidth= scrollArea_->viewport ()->width (); int cardWidth = DpiUtils::scaled (kCardWidth); int spacing = DpiUtils::scaled (kGridSpacing); + int cardSpace = cardWidth + spacing; - // Each card takes: card width + spacing (except last in row) - int cardSpace= cardWidth + spacing; + // Viewport not yet properly laid out (default QWidget size is small), + // return a sensible default instead of 1 column + if (availableWidth < cardSpace && availableWidth < cardWidth * 2) return 4; - // Calculate max columns that fit int columns= (availableWidth + spacing) / cardSpace; - - // Clamp between 1 and 6 return qBound (1, columns, 6); } @@ -291,10 +317,14 @@ QTTemplatePage::refreshTemplateGrid (const QString& category) { delete item; } + // Calculate columns first so placeholder labels span the right width + currentColumnCount_= calculateColumnCount (); + if (!templateManager_ || !templateManager_->isInitialized ()) { QLabel* label= new QLabel (qt_translate ("Initializing..."), gridWidget_); label->setAlignment (Qt::AlignCenter); - gridLayout_->addWidget (label, 0, 0, 1, 6); + gridLayout_->addWidget (label, 0, 0, 1, currentColumnCount_); + gridNeedsRefresh_= false; return; } @@ -311,13 +341,11 @@ QTTemplatePage::refreshTemplateGrid (const QString& category) { QLabel* label= new QLabel (qt_translate ("No templates available."), gridWidget_); label->setAlignment (Qt::AlignCenter); - gridLayout_->addWidget (label, 0, 0, 1, 6); + gridLayout_->addWidget (label, 0, 0, 1, currentColumnCount_); + gridNeedsRefresh_= false; return; } - // Calculate columns based on available width - currentColumnCount_= calculateColumnCount (); - // Add template cards int row= 0, col= 0; for (const auto& tmpl : templates) { @@ -332,11 +360,13 @@ QTTemplatePage::refreshTemplateGrid (const QString& category) { } gridLayout_->setRowStretch (row + 1, 1); + + gridNeedsRefresh_= false; } QWidget* QTTemplatePage::createTemplateCard (const TemplateMetadataPtr& tmpl) { - QWidget* card = new QWidget (gridWidget_); + QFrame* card = new QFrame (gridWidget_); QVBoxLayout* layout= new QVBoxLayout (card); layout->setContentsMargins ( DpiUtils::scaled (kCardMargin), DpiUtils::scaled (kCardMargin), @@ -348,6 +378,11 @@ QTTemplatePage::createTemplateCard (const TemplateMetadataPtr& tmpl) { card->setCursor (Qt::PointingHandCursor); card->setProperty ("templateId", tmpl->id); card->setToolTip (tmpl->description); + card->setFrameShape (QFrame::StyledPanel); + card->setStyleSheet (QString ("QFrame#startup-tab-template-card {" + " border-radius: %1px;" + "}") + .arg (DpiUtils::scaled (kCardRadiusPx))); // Thumbnail image QLabel* thumbnailLabel= new QLabel (card); @@ -356,7 +391,7 @@ QTTemplatePage::createTemplateCard (const TemplateMetadataPtr& tmpl) { DpiUtils::scaled (THUMBNAIL_HEIGHT)); thumbnailLabel->setAlignment (Qt::AlignCenter); thumbnailLabel->setProperty ("thumbnailLoaded", false); - applyThumbnailFrameStyle (thumbnailLabel, false); + applyThumbnailFrameStyle (thumbnailLabel); thumbnailLabel->setText (qt_translate ("Loading...")); layout->addWidget (thumbnailLabel, 0, Qt::AlignHCenter); @@ -395,88 +430,136 @@ QTTemplatePage::createTemplateCard (const TemplateMetadataPtr& tmpl) { void QTTemplatePage::loadThumbnail (QLabel* label, const QString& url) { - // First check if thumbnail is already cached QSize targetSize (DpiUtils::scaled (THUMBNAIL_WIDTH), DpiUtils::scaled (THUMBNAIL_HEIGHT)); - QPixmap cached= ThumbnailCache::instance ()->get (url, targetSize); - if (!cached.isNull ()) { - // Use cached thumbnail, ensure correct DPR for current display - cached.setDevicePixelRatio (label->devicePixelRatioF ()); - label->setPixmap (cached); + ThumbnailCache::ThumbnailCacheEntry cached= + ThumbnailCache::instance ()->getEntry (url, targetSize); + + if (cached.isValid ()) { + // Always display cached pixmap immediately (avoid showing "Loading...") + QPixmap px= cached.pixmap; + px.setDevicePixelRatio (label->devicePixelRatioF ()); + label->setPixmap (px); label->setProperty ("thumbnailLoaded", true); - applyThumbnailFrameStyle (label, true); + applyThumbnailFrameStyle (label); + + // Already validated this session: nothing more to do + if (validatedUrls_.contains (url)) { + qDebug () << "[TemplatePage] Use cache:" << url; + return; + } + + // First time this session: validate in background + qDebug () << "[TemplatePage] Validate:" << url; + thumbnailQueue_.enqueue ({label, url, cached.etag}); + processThumbnailQueue (); return; } - // Add to queue for network download - thumbnailQueue_.enqueue ({label, url}); + qDebug () << "[TemplatePage] Download:" << url; + thumbnailQueue_.enqueue ({label, url, QString ()}); processThumbnailQueue (); } void QTTemplatePage::processThumbnailQueue () { - // Process queued requests up to the concurrency limit while (!thumbnailQueue_.isEmpty () && activeThumbnailRequests_ < MAX_CONCURRENT_THUMBNAIL_REQUESTS) { ThumbnailRequest req= thumbnailQueue_.dequeue (); - // Check if the label is still valid (not deleted) - // QPointer automatically becomes nullptr when QLabel is deleted if (req.label.isNull ()) { - continue; // Skip invalid labels + continue; } activeThumbnailRequests_++; QNetworkRequest request (req.url); - QNetworkReply* reply= networkManager_->get (request); + if (!req.cachedEtag.isEmpty ()) { + request.setRawHeader ("If-None-Match", req.cachedEtag.toUtf8 ()); + } + QNetworkReply* reply= networkManager_->get (request); connect (reply, &QNetworkReply::finished, this, [this, req, reply] () { activeThumbnailRequests_--; - // Check if label is still valid before updating - // QPointer automatically becomes nullptr when QLabel is deleted - if (!req.label.isNull ()) { - if (reply->error () == QNetworkReply::NoError) { - QByteArray data= reply->readAll (); - QImage image; - if (image.loadFromData (data)) { - // Get device pixel ratio for high-DPI displays - qreal dpr= req.label->devicePixelRatioF (); - // Scale to target size considering DPR for crisp display - int targetWidth = DpiUtils::scaled (THUMBNAIL_WIDTH); - int targetHeight= DpiUtils::scaled (THUMBNAIL_HEIGHT); - image = image.scaled (qRound (targetWidth * dpr), - qRound (targetHeight * dpr), - Qt::KeepAspectRatio, Qt::SmoothTransformation); - QPixmap pixmap = QPixmap::fromImage (image); - pixmap.setDevicePixelRatio (dpr); - - // Update UI - req.label->setPixmap (pixmap); - req.label->setProperty ("thumbnailLoaded", true); - applyThumbnailFrameStyle (req.label, true); - req.label->style ()->unpolish (req.label); - req.label->style ()->polish (req.label); - - // Store in cache for future use - QSize targetSize (DpiUtils::scaled (THUMBNAIL_WIDTH), - DpiUtils::scaled (THUMBNAIL_HEIGHT)); - ThumbnailCache::instance ()->put (req.url, targetSize, pixmap); + if (req.label.isNull ()) { + reply->deleteLater (); + validatedUrls_.insert (req.url); + processThumbnailQueue (); + return; + } + + // 304 Not Modified - cached image is still valid + int httpStatus= + reply->attribute (QNetworkRequest::HttpStatusCodeAttribute).toInt (); + if (httpStatus == 304) { + qDebug () << "[TemplatePage] Cache fresh:" << req.url; + validatedUrls_.insert (req.url); + reply->deleteLater (); + processThumbnailQueue (); + return; + } + + if (reply->error () == QNetworkReply::NoError) { + QByteArray data= reply->readAll (); + QImage image; + if (image.loadFromData (data)) { + qreal dpr = req.label->devicePixelRatioF (); + int targetWidth = DpiUtils::scaled (THUMBNAIL_WIDTH); + int targetHeight= DpiUtils::scaled (THUMBNAIL_HEIGHT); + int scaledW = qRound (targetWidth * dpr); + int scaledH = qRound (targetHeight * dpr); + QImage scaled= + image.scaled (scaledW, scaledH, Qt::KeepAspectRatioByExpanding, + Qt::SmoothTransformation); + if (scaled.width () > scaledW || scaled.height () > scaledH) { + int x = (scaled.width () - scaledW) / 2; + int y = 0; + scaled= scaled.copy (x, y, scaledW, scaledH); } - else { - req.label->setText (qt_translate ("Preview")); + QPixmap pixmap= QPixmap::fromImage (scaled); + pixmap.setDevicePixelRatio (dpr); + + req.label->setPixmap (pixmap); + req.label->setProperty ("thumbnailLoaded", true); + applyThumbnailFrameStyle (req.label); + req.label->style ()->unpolish (req.label); + req.label->style ()->polish (req.label); + + // Extract HTTP cache headers and save to cache + QString etag= QString::fromUtf8 (reply->rawHeader ("ETag")); + QDateTime lastModified; + QString lmStr= QString::fromUtf8 (reply->rawHeader ("Last-Modified")); + if (!lmStr.isEmpty ()) { + lastModified= QDateTime::fromString (lmStr, Qt::RFC2822Date); + if (!lastModified.isValid ()) { + lastModified= QLocale::c ().toDateTime ( + lmStr, "ddd, dd MMM yyyy hh:mm:ss 'GMT'"); + } + if (lastModified.isValid ()) { + lastModified.setTimeZone (QTimeZone::utc ()); + } } + + QSize targetSize (targetWidth, targetHeight); + ThumbnailCache::instance ()->put (req.url, targetSize, pixmap, etag, + lastModified); + qDebug () << "[TemplatePage] Update cache:" << req.url; } else { req.label->setText (qt_translate ("Preview")); } } + else { + // Only show placeholder if there was no cached pixmap to preserve + if (req.label->pixmap ().isNull ()) { + req.label->setText (qt_translate ("Preview")); + } + } + validatedUrls_.insert (req.url); reply->deleteLater (); - - // Process next items in queue processThumbnailQueue (); }); } @@ -510,6 +593,8 @@ QTTemplatePage::showTemplatePreview (const QString& templateId) { qt_translate ("Template Preview - %1").arg (tmpl->name)); dialog->setMinimumSize (DpiUtils::scaled (kPreviewDialogMinW), DpiUtils::scaled (kPreviewDialogMinH)); + dialog->resize (DpiUtils::scaled (kPreviewDialogMinW), + DpiUtils::scaled (kPreviewDialogMinH)); QVBoxLayout* layout= new QVBoxLayout (dialog); layout->setSpacing (DpiUtils::scaled (kPreviewLayoutSpacing)); @@ -545,8 +630,7 @@ QTTemplatePage::showTemplatePreview (const QString& templateId) { // Preview area using reusable PDF preview widget QTPdfPreviewWidget* previewWidget= new QTPdfPreviewWidget (dialog); - // 设置固定尺寸,确保无内容时也有足够显示区域 (A4比例) - // A4比例: 高:宽 = 1.414:1 + // 设置固定尺寸,确保无内容时也有足够显示区域 previewWidget->setFixedSize (DpiUtils::scaled (PREVIEW_IMAGE_WIDTH), DpiUtils::scaled (PREVIEW_IMAGE_WIDTH)); @@ -554,6 +638,9 @@ QTTemplatePage::showTemplatePreview (const QString& templateId) { if (!tmpl->previewUrl.isEmpty ()) { previewWidget->loadFromUrl (tmpl->previewUrl); } + else { + previewWidget->clearPreview (qt_translate ("No Preview")); + } layout->addWidget (previewWidget, 0, Qt::AlignCenter); // Buttons @@ -563,7 +650,11 @@ QTTemplatePage::showTemplatePreview (const QString& templateId) { QPushButton* cancelBtn= new QPushButton (qt_translate ("Cancel"), dialog); cancelBtn->setObjectName ("template-cancel-btn"); DpiUtils::applyScaledFont (cancelBtn, kUseButtonFontPx); - cancelBtn->setStyleSheet (QString ("padding: %1px %2px; border-radius: %3px;") + cancelBtn->setCursor (Qt::PointingHandCursor); + cancelBtn->setStyleSheet (QString ("QPushButton#template-cancel-btn {" + " padding: %1px %2px;" + " border-radius: %3px;" + "}") .arg (DpiUtils::scaled (kUseButtonPadYPx)) .arg (DpiUtils::scaled (kUseButtonPadXPx)) .arg (DpiUtils::scaled (kUseButtonRadiusPx))); @@ -573,7 +664,11 @@ QTTemplatePage::showTemplatePreview (const QString& templateId) { QPushButton* useBtn= new QPushButton (qt_translate ("Use Template"), dialog); useBtn->setObjectName ("template-use-btn"); DpiUtils::applyScaledFont (useBtn, kUseButtonFontPx); - useBtn->setStyleSheet (QString ("padding: %1px %2px; border-radius: %3px;") + useBtn->setCursor (Qt::PointingHandCursor); + useBtn->setStyleSheet (QString ("QPushButton#template-use-btn {" + " padding: %1px %2px;" + " border-radius: %3px;" + "}") .arg (DpiUtils::scaled (kUseButtonPadYPx)) .arg (DpiUtils::scaled (kUseButtonPadXPx)) .arg (DpiUtils::scaled (kUseButtonRadiusPx))); @@ -653,16 +748,8 @@ QTTemplatePage::onTemplatesLoaded () { if (categoryBar_ && categoryBar_->layout ()->count () == 0) { setupCategoryBar (); } + gridNeedsRefresh_= true; refreshTemplateGrid (currentCategory_); - - // Force layout update to ensure content is visible - if (gridWidget_) { - gridWidget_->update (); - gridWidget_->adjustSize (); - } - if (scrollArea_) { - scrollArea_->update (); - } } void @@ -721,8 +808,10 @@ void QTTemplatePage::showEvent (QShowEvent* event) { QWidget::showEvent (event); - // Refresh grid when page becomes visible - if (templateManager_ && templateManager_->isInitialized () && + // Refresh grid when page becomes visible (avoid duplicate if already + // refreshed by onTemplatesLoaded) + if (gridNeedsRefresh_ && templateManager_ && + templateManager_->isInitialized () && !templateManager_->templates ().isEmpty ()) { refreshTemplateGrid (currentCategory_); } @@ -732,12 +821,8 @@ void QTTemplatePage::resizeEvent (QResizeEvent* event) { QWidget::resizeEvent (event); - // Recalculate column count on resize - if (templateManager_ && templateManager_->isInitialized ()) { - int newColumnCount= calculateColumnCount (); - if (newColumnCount != currentColumnCount_) { - // Column count changed, refresh the grid - refreshTemplateGrid (currentCategory_); - } + // Debounce resize to avoid frequent grid rebuilds during window dragging + if (resizeDebounceTimer_) { + resizeDebounceTimer_->start (); } } diff --git a/src/Plugins/Qt/qt_template_page.hpp b/src/Plugins/Qt/qt_template_page.hpp index 851527f859..200ac0a12d 100644 --- a/src/Plugins/Qt/qt_template_page.hpp +++ b/src/Plugins/Qt/qt_template_page.hpp @@ -21,6 +21,7 @@ class QProgressDialog; class QPushButton; class QResizeEvent; class QScrollArea; +class QTimer; class TemplateManager; struct TemplateMetadata; using TemplateMetadataPtr= QSharedPointer; @@ -32,6 +33,7 @@ using TemplateMetadataPtr= QSharedPointer; struct ThumbnailRequest { QPointer label; QString url; + QString cachedEtag; }; /** @@ -104,6 +106,17 @@ private slots: // Responsive grid int currentColumnCount_= 4; + + // Avoid duplicate refresh when onTemplatesLoaded and showEvent both fire + bool gridNeedsRefresh_= true; + + // Track URLs that have been validated in this session (conditional request + // sent at least once). Once validated, subsequent displays use cache + // directly. + QSet validatedUrls_; + + // Debounce timer for resize events to avoid frequent grid rebuilds + QTimer* resizeDebounceTimer_; }; #endif // QT_TEMPLATE_PAGE_HPP