From bf55ca0135ecde3bfc484c40407f838de78c8fb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=B4=E5=A4=A9?= Date: Thu, 14 May 2026 06:50:14 +0800 Subject: [PATCH] =?UTF-8?q?refactor(hermes):=20emoji=20=E2=86=92=20SVG=20?= =?UTF-8?q?=E5=9B=BE=E6=A0=87=EF=BC=8C=E7=BB=9F=E4=B8=80=E8=A7=86=E8=A7=89?= =?UTF-8?q?=E8=AF=AD=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 问题 新加的 hermes 页面里到处是 emoji(⚠️📁📋💬🔐🔗🖼️📝⚙️📦💬🔊🎙🔍✓ 等), 不同 OS 渲染样式差异大(macOS Apple Color Emoji vs Windows Segoe UI Emoji vs Linux Noto Color Emoji),看着很不专业,也跟现有 SVG 图标系统割裂。 ## 方案 新建 src/engines/hermes/lib/svg-icons.js — 集中所有需要的 SVG path: 状态:alert-triangle, check, x, check-circle, x-circle, info 文件:folder, folder-up, file, file-text, image, link-2, settings 列表:clipboard-list, message-square, inbox 安全:lock, shield, key 媒体:volume, mic, search 导出 svgIcon(name, opts) — 渲染时统一 viewBox / stroke-width / currentColor, size 可选覆盖。 ## 替换覆盖(7 个页面 + 2 处 CSS) - profiles.js: ⚠️ → alert-triangle, 📁 → folder - kanban.js: ⚠️ → alert-triangle, 📋 → clipboard-list, 💬 → message-square - oauth.js: ⚠️ → alert-triangle, 🔐 → lock - group-chat.js: ⚠️ → alert-triangle - logs.js: ⚠️ → 纯文本 [ERROR](log raw 字段不需要图标) - files.js: 📁🔗🖼️📝⚙️📄 全部 → folder/link-2/image/file-text/settings/file ".." → folder-up - lazy-deps.js: 7 个 category emoji → message-square/volume/mic/search/ shield/inbox/image ✓ 装好标识 → check svg 📦 empty → inbox CSS 适配: - .page-inline-error-icon: 加 inline-flex + 自动 currentColor,svg 20×20 - .empty-state .empty-icon: 加 inline-flex + svg 跟 font-size 走(1em) ## 范围控制 本次只处理我最近 3 个 commit 里新加的页面 emoji。 历史代码(setup.js / services.js / chat.js 的 ✅❌✓ 等)暂不动 — 那是 跨工作流的复杂改动,单独评估再做,避免连锁影响 CI。 ## 验证 ✓ npm run build PASS(1.80s) --- src/engines/hermes/lib/svg-icons.js | 72 ++++++++++++++++++++++++++ src/engines/hermes/pages/files.js | 16 +++--- src/engines/hermes/pages/group-chat.js | 3 +- src/engines/hermes/pages/kanban.js | 7 +-- src/engines/hermes/pages/lazy-deps.js | 23 ++++---- src/engines/hermes/pages/logs.js | 2 +- src/engines/hermes/pages/oauth.js | 5 +- src/engines/hermes/pages/profiles.js | 5 +- src/style/components.css | 8 +++ src/style/pages.css | 8 +++ 10 files changed, 121 insertions(+), 28 deletions(-) create mode 100644 src/engines/hermes/lib/svg-icons.js diff --git a/src/engines/hermes/lib/svg-icons.js b/src/engines/hermes/lib/svg-icons.js new file mode 100644 index 0000000..e018460 --- /dev/null +++ b/src/engines/hermes/lib/svg-icons.js @@ -0,0 +1,72 @@ +/** + * Hermes 页面共用 SVG 图标集 — 替换分散的 emoji,统一视觉语言。 + * + * 命名约定: + * - 名字与 lucide / feather 一致,方便后续替换 + * - 所有 path 默认使用 currentColor + fill:none + stroke-width:1.6 + * - viewBox 全部 0 0 24 24,渲染时由调用方提供 width/height + 容器颜色 + * + * 用法: + * import { svgIcon } from '../lib/svg-icons.js' + * svgIcon('folder', { size: 18, className: 'hm-files-icon' }) + */ + +/* eslint-disable max-len */ +const PATHS = { + // === 通用 / 状态 === + 'alert-triangle': '', + 'check': '', + 'x': '', + 'check-circle': '', + 'x-circle': '', + 'info': '', + + // === 文件系统 === + 'folder': '', + 'folder-up': '', + 'file': '', + 'file-text': '', + 'image': '', + 'link-2': '', + 'settings': '', + + // === 列表 / 看板 === + 'clipboard-list': '', + 'message-square': '', + 'inbox': '', + + // === 安全 / 认证 === + 'lock': '', + 'shield': '', + 'key': '', + + // === 媒体 === + 'volume': '', + 'mic': '', + 'search': '', +} + +/** + * 渲染指定图标为 inline SVG 字符串(可直接放进 innerHTML)。 + * + * @param {string} name - PATHS 里的 key + * @param {object} [opts] + * @param {number} [opts.size=14] - 边长 + * @param {number} [opts.stroke=1.6] - stroke-width + * @param {string} [opts.className] - 额外 class + * @param {string} [opts.color] - 强制颜色(不传则跟随 currentColor) + */ +export function svgIcon(name, opts = {}) { + const path = PATHS[name] + if (!path) return '' + const size = opts.size ?? 14 + const stroke = opts.stroke ?? 1.6 + const cls = opts.className ? ` class="${opts.className}"` : '' + const color = opts.color ? ` stroke="${opts.color}"` : '' + return `${path}` +} + +/** 直接拿原 path 字符串(用于场景需要自定义 svg 包装的情况) */ +export function svgPath(name) { + return PATHS[name] || '' +} diff --git a/src/engines/hermes/pages/files.js b/src/engines/hermes/pages/files.js index 91117fe..7e84393 100644 --- a/src/engines/hermes/pages/files.js +++ b/src/engines/hermes/pages/files.js @@ -15,6 +15,7 @@ import { api } from '../../../lib/tauri-api.js' import { toast } from '../../../components/toast.js' import { showConfirm } from '../../../components/modal.js' import { humanizeError } from '../../../lib/humanize-error.js' +import { svgIcon } from '../lib/svg-icons.js' function escHtml(s) { return String(s ?? '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') @@ -35,14 +36,13 @@ function formatTime(secs) { } function iconForKind(kind, name) { - if (kind === 'dir') return '📁' - if (kind === 'symlink') return '🔗' + if (kind === 'dir') return svgIcon('folder', { size: 14 }) + if (kind === 'symlink') return svgIcon('link-2', { size: 14 }) const ext = (name.split('.').pop() || '').toLowerCase() - if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(ext)) return '🖼️' - if (['md', 'txt'].includes(ext)) return '📝' - if (['json', 'yaml', 'yml', 'toml'].includes(ext)) return '⚙️' - if (['py', 'js', 'ts', 'rs', 'go'].includes(ext)) return '📄' - return '📄' + if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(ext)) return svgIcon('image', { size: 14 }) + if (['md', 'txt'].includes(ext)) return svgIcon('file-text', { size: 14 }) + if (['json', 'yaml', 'yml', 'toml'].includes(ext)) return svgIcon('settings', { size: 14 }) + return svgIcon('file', { size: 14 }) } export function render() { @@ -118,7 +118,7 @@ export function render() { } return `
- ${currentDir ? `
📁..
` : ''} + ${currentDir ? `
${svgIcon('folder-up', { size: 14 })}..
` : ''} ${entries.map(e => `
/g, '>').replace(/"/g, '"') @@ -131,7 +132,7 @@ export function render() { if (m.error) { return `
-
${fromTag} ⚠️ ${escHtml(t('engine.hermesGroupChatRunFailed'))}
+
${fromTag} ${svgIcon('alert-triangle', { size: 12 })} ${escHtml(t('engine.hermesGroupChatRunFailed'))}
${escHtml(m.error)}
` diff --git a/src/engines/hermes/pages/kanban.js b/src/engines/hermes/pages/kanban.js index 91bb752..21d4bf9 100644 --- a/src/engines/hermes/pages/kanban.js +++ b/src/engines/hermes/pages/kanban.js @@ -18,6 +18,7 @@ import { api } from '../../../lib/tauri-api.js' import { toast } from '../../../components/toast.js' import { showModal, showContentModal } from '../../../components/modal.js' import { humanizeError } from '../../../lib/humanize-error.js' +import { svgIcon } from '../lib/svg-icons.js' const KANBAN_BASE = '/api/plugins/kanban' @@ -30,7 +31,7 @@ function renderInlineError(err) { const h = humanizeError(err, t('engine.hermesKanbanTaskLoadFailed')) return `
-
⚠️
+
${svgIcon('alert-triangle', { size: 20 })}
${escHtml(h.message)}
${h.hint ? `
${escHtml(h.hint)}
` : ''} @@ -77,7 +78,7 @@ export function render() { function renderBoard() { if (!board?.columns?.length) { - return `
📋
${escHtml(t('engine.hermesKanbanEmpty'))}
` + return `
${svgIcon('clipboard-list', { size: 32 })}
${escHtml(t('engine.hermesKanbanEmpty'))}
` } return `
@@ -107,7 +108,7 @@ export function render() {
${priorityBadge} ${assignee} - ${task.comment_count ? `💬 ${task.comment_count}` : ''} + ${task.comment_count ? `${svgIcon('message-square', { size: 12 })} ${task.comment_count}` : ''}
` diff --git a/src/engines/hermes/pages/lazy-deps.js b/src/engines/hermes/pages/lazy-deps.js index 4681021..75a48f6 100644 --- a/src/engines/hermes/pages/lazy-deps.js +++ b/src/engines/hermes/pages/lazy-deps.js @@ -11,16 +11,17 @@ import { t } from '../../../lib/i18n.js' import { api } from '../../../lib/tauri-api.js' import { toast } from '../../../components/toast.js' import { humanizeError } from '../../../lib/humanize-error.js' +import { svgIcon } from '../lib/svg-icons.js' // feature 分类配置(决定分组顺序 + 图标 + 文案) const CATEGORIES = [ - { prefix: 'platform.', emoji: '💬', titleKey: 'hermesLazyDeps.catPlatform' }, - { prefix: 'tts.', emoji: '🔊', titleKey: 'hermesLazyDeps.catTts' }, - { prefix: 'stt.', emoji: '🎙️', titleKey: 'hermesLazyDeps.catStt' }, - { prefix: 'search.', emoji: '🔍', titleKey: 'hermesLazyDeps.catSearch' }, - { prefix: 'provider.', emoji: '🧠', titleKey: 'hermesLazyDeps.catProvider' }, - { prefix: 'memory.', emoji: '🗂️', titleKey: 'hermesLazyDeps.catMemory' }, - { prefix: 'image.', emoji: '🎨', titleKey: 'hermesLazyDeps.catImage' }, + { prefix: 'platform.', icon: 'message-square', titleKey: 'hermesLazyDeps.catPlatform' }, + { prefix: 'tts.', icon: 'volume', titleKey: 'hermesLazyDeps.catTts' }, + { prefix: 'stt.', icon: 'mic', titleKey: 'hermesLazyDeps.catStt' }, + { prefix: 'search.', icon: 'search', titleKey: 'hermesLazyDeps.catSearch' }, + { prefix: 'provider.', icon: 'shield', titleKey: 'hermesLazyDeps.catProvider' }, + { prefix: 'memory.', icon: 'inbox', titleKey: 'hermesLazyDeps.catMemory' }, + { prefix: 'image.', icon: 'image', titleKey: 'hermesLazyDeps.catImage' }, ] const DESC_OVERRIDE_KEY = 'hermesLazyDeps.descOverride' // i18n.key 下的 feature → 描述 @@ -28,7 +29,7 @@ const DESC_OVERRIDE_KEY = 'hermesLazyDeps.descOverride' // i18n.key 下的 feat // 把 feature 按分类分组 function groupByCategory(features) { const groups = CATEGORIES.map(c => ({ ...c, items: [] })) - const other = { prefix: '', emoji: '🧩', titleKey: 'hermesLazyDeps.catOther', items: [] } + const other = { prefix: '', icon: 'file', titleKey: 'hermesLazyDeps.catOther', items: [] } for (const f of features) { const cat = groups.find(g => f.feature.startsWith(g.prefix)) if (cat) cat.items.push(f) @@ -83,7 +84,7 @@ async function loadAndRender(page) { const features = featuresResp.features || [] if (!features.length) { content.innerHTML = `
-
📦
+
${svgIcon('inbox', { size: 32 })}
${escapeHtml(t('hermesLazyDeps.emptyTitle'))}
` return @@ -113,7 +114,7 @@ function renderGroup(group, status) { return `
- ${group.emoji} + ${svgIcon(group.icon, { size: 18 })} ${escapeHtml(t(group.titleKey))}
@@ -130,7 +131,7 @@ function renderItem(f, st) { const specsTitle = (f.specs || []).join('\n') const featureLabel = featureDisplayName(f.feature) const stateBadge = satisfied - ? `✓ ${escapeHtml(t('hermesLazyDeps.installed'))}` + ? `${svgIcon('check', { size: 11 })} ${escapeHtml(t('hermesLazyDeps.installed'))}` : (known ? `${escapeHtml(t('hermesLazyDeps.notInstalled'))}` : `?`) diff --git a/src/engines/hermes/pages/logs.js b/src/engines/hermes/pages/logs.js index ff1bce3..1929dc8 100644 --- a/src/engines/hermes/pages/logs.js +++ b/src/engines/hermes/pages/logs.js @@ -117,7 +117,7 @@ export function render() { levelFilter !== 'ALL' ? levelFilter : null, ) } catch (e) { - entries = [{ raw: `⚠️ ${t('engine.logsLoadFailed')}: ${e.message || e}` }] + entries = [{ raw: `[ERROR] ${t('engine.logsLoadFailed')}: ${e.message || e}`, level: 'ERROR' }] } loading = false draw() diff --git a/src/engines/hermes/pages/oauth.js b/src/engines/hermes/pages/oauth.js index baaece4..968b4de 100644 --- a/src/engines/hermes/pages/oauth.js +++ b/src/engines/hermes/pages/oauth.js @@ -15,6 +15,7 @@ import { api } from '../../../lib/tauri-api.js' import { toast } from '../../../components/toast.js' import { showModal, showContentModal } from '../../../components/modal.js' import { humanizeError } from '../../../lib/humanize-error.js' +import { svgIcon } from '../lib/svg-icons.js' const OAUTH_BASE = '/api/providers/oauth' @@ -27,7 +28,7 @@ function renderInlineError(err) { const h = humanizeError(err, t('engine.hermesOAuthTitle')) return `
-
⚠️
+
${svgIcon('alert-triangle', { size: 20 })}
${escHtml(h.message)}
${h.hint ? `
${escHtml(h.hint)}
` : ''} @@ -62,7 +63,7 @@ export function render() { ${error ? renderInlineError(error) : ''} ${(!loading && !error && !providers.length) ? `
-
🔐
+
${svgIcon('lock', { size: 32 })}
${escHtml(t('engine.hermesOAuthEmpty'))}
` : ''} ${(!loading && providers.length) ? ` diff --git a/src/engines/hermes/pages/profiles.js b/src/engines/hermes/pages/profiles.js index 153b183..e67ef41 100644 --- a/src/engines/hermes/pages/profiles.js +++ b/src/engines/hermes/pages/profiles.js @@ -16,6 +16,7 @@ import { toast } from '../../../components/toast.js' import { showConfirm, showModal } from '../../../components/modal.js' import { humanizeError } from '../../../lib/humanize-error.js' import { getChatStore } from '../lib/chat-store.js' +import { svgIcon } from '../lib/svg-icons.js' const chatStore = getChatStore() @@ -28,7 +29,7 @@ function renderInlineError(err) { const h = humanizeError(err, t('engine.hermesProfilesTitle')) return `
-
⚠️
+
${svgIcon('alert-triangle', { size: 20 })}
${escHtml(h.message)}
${h.hint ? `
${escHtml(h.hint)}
` : ''} @@ -64,7 +65,7 @@ export function render() { ${error ? renderInlineError(error) : ''} ${(!loading && !error && !profiles.length) ? `
-
📁
+
${svgIcon('folder', { size: 32 })}
${escHtml(t('engine.hermesProfilesEmpty'))}
` : ''} ${(!loading && profiles.length) ? ` diff --git a/src/style/components.css b/src/style/components.css index f8bd2d5..04fe822 100644 --- a/src/style/components.css +++ b/src/style/components.css @@ -438,6 +438,14 @@ mark { margin-bottom: var(--space-md); opacity: 0.85; user-select: none; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--text-tertiary); +} +.empty-state .empty-icon svg { + width: 1em; + height: 1em; } .empty-state .empty-title { diff --git a/src/style/pages.css b/src/style/pages.css index f12dd26..6e4ce33 100644 --- a/src/style/pages.css +++ b/src/style/pages.css @@ -2675,6 +2675,14 @@ line-height: 1; margin-top: 2px; flex: 0 0 auto; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--error, #ef4444); +} +.page-inline-error-icon svg { + width: 20px; + height: 20px; } .page-inline-error-body { flex: 1;