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

@@ -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) ? `