refactor(hermes): emoji → SVG 图标,统一视觉语言

## 问题
新加的 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)
This commit is contained in:
晴天
2026-05-14 06:50:14 +08:00
parent d97e196a48
commit bf55ca0135
10 changed files with 121 additions and 28 deletions

View File

@@ -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': '<path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
'check': '<polyline points="20 6 9 17 4 12"/>',
'x': '<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>',
'check-circle': '<path d="M22 11.08V12a10 10 0 11-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/>',
'x-circle': '<circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/>',
'info': '<circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/>',
// === 文件系统 ===
'folder': '<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/>',
'folder-up': '<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/><polyline points="9 14 12 11 15 14"/><line x1="12" y1="11" x2="12" y2="17"/>',
'file': '<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/>',
'file-text': '<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/>',
'image': '<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/>',
'link-2': '<path d="M15 7h3a5 5 0 015 5 5 5 0 01-5 5h-3m-6 0H6a5 5 0 01-5-5 5 5 0 015-5h3"/><line x1="8" y1="12" x2="16" y2="12"/>',
'settings': '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-2 2 2 2 0 01-2-2v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83 0 2 2 0 010-2.83l.06-.06a1.65 1.65 0 00.33-1.82 1.65 1.65 0 00-1.51-1H3a2 2 0 01-2-2 2 2 0 012-2h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 010-2.83 2 2 0 012.83 0l.06.06a1.65 1.65 0 001.82.33H9a1.65 1.65 0 001-1.51V3a2 2 0 012-2 2 2 0 012 2v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 0 2 2 0 010 2.83l-.06.06a1.65 1.65 0 00-.33 1.82V9a1.65 1.65 0 001.51 1H21a2 2 0 012 2 2 2 0 01-2 2h-.09a1.65 1.65 0 00-1.51 1z"/>',
// === 列表 / 看板 ===
'clipboard-list': '<path d="M16 4h2a2 2 0 012 2v14a2 2 0 01-2 2H6a2 2 0 01-2-2V6a2 2 0 012-2h2"/><rect x="8" y="2" width="8" height="4" rx="1" ry="1"/><line x1="9" y1="12" x2="15" y2="12"/><line x1="9" y1="16" x2="13" y2="16"/>',
'message-square': '<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/>',
'inbox': '<polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11L2 12v6a2 2 0 002 2h16a2 2 0 002-2v-6l-3.45-6.89A2 2 0 0016.76 4H7.24a2 2 0 00-1.79 1.11z"/>',
// === 安全 / 认证 ===
'lock': '<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0110 0v4"/>',
'shield': '<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>',
'key': '<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 11-7.778 7.778 5.5 5.5 0 017.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/>',
// === 媒体 ===
'volume': '<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M19.07 4.93a10 10 0 010 14.14M15.54 8.46a5 5 0 010 7.07"/>',
'mic': '<path d="M12 1a3 3 0 00-3 3v8a3 3 0 006 0V4a3 3 0 00-3-3z"/><path d="M19 10v2a7 7 0 01-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/>',
'search': '<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>',
}
/**
* 渲染指定图标为 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 `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"${color} stroke-width="${stroke}" stroke-linecap="round" stroke-linejoin="round" width="${size}" height="${size}"${cls}>${path}</svg>`
}
/** 直接拿原 path 字符串(用于场景需要自定义 svg 包装的情况) */
export function svgPath(name) {
return PATHS[name] || ''
}

View File

@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
@@ -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 `
<div class="hm-files-list">
${currentDir ? `<div class="hm-files-entry hm-files-entry--up" data-cd="${escAttr(parentDir(currentDir))}"><span class="hm-files-icon">📁</span><span class="hm-files-name">..</span></div>` : ''}
${currentDir ? `<div class="hm-files-entry hm-files-entry--up" data-cd="${escAttr(parentDir(currentDir))}"><span class="hm-files-icon">${svgIcon('folder-up', { size: 14 })}</span><span class="hm-files-name">..</span></div>` : ''}
${entries.map(e => `
<div class="hm-files-entry ${e.kind === 'dir' ? 'is-dir' : 'is-file'} ${selectedRel === joinRel(currentDir, e.name) ? 'is-selected' : ''}"
data-kind="${escAttr(e.kind)}"

View File

@@ -15,6 +15,7 @@ import { t } from '../../../lib/i18n.js'
import { api, isTauriRuntime } 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'
function escHtml(s) {
return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
@@ -131,7 +132,7 @@ export function render() {
if (m.error) {
return `
<div class="hm-gc-msg hm-gc-msg--assistant hm-gc-msg--error">
<div class="hm-gc-msg-meta">${fromTag} <span style="color:var(--error)">⚠️ ${escHtml(t('engine.hermesGroupChatRunFailed'))}</span></div>
<div class="hm-gc-msg-meta">${fromTag} <span style="color:var(--error);display:inline-flex;align-items:center;gap:4px">${svgIcon('alert-triangle', { size: 12 })} ${escHtml(t('engine.hermesGroupChatRunFailed'))}</span></div>
<div class="hm-gc-msg-bubble" style="color:var(--error)">${escHtml(m.error)}</div>
</div>
`

View File

@@ -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 `
<div class="page-inline-error">
<div class="page-inline-error-icon">⚠️</div>
<div class="page-inline-error-icon">${svgIcon('alert-triangle', { size: 20 })}</div>
<div class="page-inline-error-body">
<div class="page-inline-error-message">${escHtml(h.message)}</div>
${h.hint ? `<div class="page-inline-error-hint">${escHtml(h.hint)}</div>` : ''}
@@ -77,7 +78,7 @@ export function render() {
function renderBoard() {
if (!board?.columns?.length) {
return `<div class="empty-state empty-compact"><div class="empty-icon">📋</div><div class="empty-title">${escHtml(t('engine.hermesKanbanEmpty'))}</div></div>`
return `<div class="empty-state empty-compact"><div class="empty-icon">${svgIcon('clipboard-list', { size: 32 })}</div><div class="empty-title">${escHtml(t('engine.hermesKanbanEmpty'))}</div></div>`
}
return `
<div class="hm-kanban-board">
@@ -107,7 +108,7 @@ export function render() {
<div class="hm-kanban-task-meta">
${priorityBadge}
${assignee}
${task.comment_count ? `<span class="hm-kanban-task-meta-item">💬 ${task.comment_count}</span>` : ''}
${task.comment_count ? `<span class="hm-kanban-task-meta-item">${svgIcon('message-square', { size: 12 })} ${task.comment_count}</span>` : ''}
</div>
</div>
`

View File

@@ -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 = `<div class="empty-state empty-compact">
<div class="empty-icon">📦</div>
<div class="empty-icon">${svgIcon('inbox', { size: 32 })}</div>
<div class="empty-title">${escapeHtml(t('hermesLazyDeps.emptyTitle'))}</div>
</div>`
return
@@ -113,7 +114,7 @@ function renderGroup(group, status) {
return `
<div class="config-section">
<div class="config-section-title">
<span style="font-size:18px;line-height:1">${group.emoji}</span>
<span style="display:inline-flex;align-items:center;color:var(--accent);margin-right:8px">${svgIcon(group.icon, { size: 18 })}</span>
${escapeHtml(t(group.titleKey))}
</div>
<div class="lazy-deps-grid">
@@ -130,7 +131,7 @@ function renderItem(f, st) {
const specsTitle = (f.specs || []).join('\n')
const featureLabel = featureDisplayName(f.feature)
const stateBadge = satisfied
? `<span class="lazy-deps-badge ok"> ${escapeHtml(t('hermesLazyDeps.installed'))}</span>`
? `<span class="lazy-deps-badge ok">${svgIcon('check', { size: 11 })} ${escapeHtml(t('hermesLazyDeps.installed'))}</span>`
: (known
? `<span class="lazy-deps-badge warn">${escapeHtml(t('hermesLazyDeps.notInstalled'))}</span>`
: `<span class="lazy-deps-badge unknown">?</span>`)

View File

@@ -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()

View File

@@ -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 `
<div class="page-inline-error">
<div class="page-inline-error-icon">⚠️</div>
<div class="page-inline-error-icon">${svgIcon('alert-triangle', { size: 20 })}</div>
<div class="page-inline-error-body">
<div class="page-inline-error-message">${escHtml(h.message)}</div>
${h.hint ? `<div class="page-inline-error-hint">${escHtml(h.hint)}</div>` : ''}
@@ -62,7 +63,7 @@ export function render() {
${error ? renderInlineError(error) : ''}
${(!loading && !error && !providers.length) ? `
<div class="empty-state empty-compact">
<div class="empty-icon">🔐</div>
<div class="empty-icon">${svgIcon('lock', { size: 32 })}</div>
<div class="empty-title">${escHtml(t('engine.hermesOAuthEmpty'))}</div>
</div>` : ''}
${(!loading && providers.length) ? `

View File

@@ -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 `
<div class="page-inline-error">
<div class="page-inline-error-icon">⚠️</div>
<div class="page-inline-error-icon">${svgIcon('alert-triangle', { size: 20 })}</div>
<div class="page-inline-error-body">
<div class="page-inline-error-message">${escHtml(h.message)}</div>
${h.hint ? `<div class="page-inline-error-hint">${escHtml(h.hint)}</div>` : ''}
@@ -64,7 +65,7 @@ export function render() {
${error ? renderInlineError(error) : ''}
${(!loading && !error && !profiles.length) ? `
<div class="empty-state empty-compact">
<div class="empty-icon">📁</div>
<div class="empty-icon">${svgIcon('folder', { size: 32 })}</div>
<div class="empty-title">${escHtml(t('engine.hermesProfilesEmpty'))}</div>
</div>` : ''}
${(!loading && profiles.length) ? `

View File

@@ -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 {

View File

@@ -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;