feat(skill): pixelarticons icon library + picker component

This commit is contained in:
matevip 2026-05-01 09:52:12 +08:00
parent 9c6b728704
commit efb91796fb
10 changed files with 1124 additions and 11 deletions

View File

@ -175,6 +175,15 @@ public class SkillPackageResolver {
// RFC-090 §14.6 column projection from manifest (SoT).
// Snapshot pre-projection values so we know which legacy columns
// need a row-level update. Skipped when the manifest is null.
//
// Icon is a special case: the UI lets users pick a custom icon
// (emoji / pixelarticons / URL) per skill. If we unconditionally
// re-project the manifest icon every resolve, the user's pick
// gets silently reverted as soon as the runtime cache refreshes.
// So we only seed the icon from manifest when the row's icon is
// empty meaning user-set icons stick, and clearing the icon
// ("no icon" in the picker) explicitly opts back into the
// manifest default on the next resolve.
String newSkillType = entity.getSkillType();
String newIcon = entity.getIcon();
String newVersion = entity.getVersion();
@ -182,7 +191,10 @@ public class SkillPackageResolver {
if (resolved.getManifest() != null) {
SkillManifest m = resolved.getManifest();
if (m.getType() != null && !m.getType().isBlank()) newSkillType = m.getType();
if (m.getIcon() != null && !m.getIcon().isBlank()) newIcon = m.getIcon();
boolean rowIconBlank = entity.getIcon() == null || entity.getIcon().isBlank();
if (rowIconBlank && m.getIcon() != null && !m.getIcon().isBlank()) {
newIcon = m.getIcon();
}
if (m.getVersion() != null && !m.getVersion().isBlank()) newVersion = m.getVersion();
if (m.getAuthor() != null && !m.getAuthor().isBlank()) newAuthor = m.getAuthor();
}

View File

@ -234,22 +234,45 @@ public class SkillService {
/**
* 更新技能
* 内置技能只允许修改 enabledconfigJsondescription
* <p>
* 内置技能允许修改的字段集合
* <ul>
* <li>{@code enabled} 启用开关</li>
* <li>{@code configJson} / {@code skillContent} 运维 fallback 内容</li>
* <li>{@code description} 文案微调</li>
* <li><b>展示覆盖</b>{@code icon}{@code nameZh}{@code nameEn}{@code tags}
* 这些纯前台显示字段不影响 builtin 的运行时安全性让用户能为内置 skill
* 挑像素图标 / 改本地化显示名而不必碰源码
* <br>{@code icon} manifest 投影字段会被 SkillPackageResolver
* 根据 SKILL.md frontmatter 同步配套的解析器修改仅在 row icon 为空时
* 才投影确保用户覆盖能持久化
* </li>
* </ul>
* 仍不允许name / version / author / skillType / builtin 这些是身份字段
* 改动会破坏绑定与解析
*/
public SkillEntity updateSkill(SkillEntity skill) {
SkillEntity existing = getSkill(skill.getId());
if (Boolean.TRUE.equals(existing.getBuiltin())) {
// 内置技能只允许修改有限字段
// Functional fields
existing.setEnabled(skill.getEnabled() != null ? skill.getEnabled() : existing.getEnabled());
existing.setConfigJson(skill.getConfigJson());
existing.setDescription(skill.getDescription() != null ? skill.getDescription() : existing.getDescription());
// builtin skill 也允许更新 skillContent用于维护 fallback 内容
if (skill.getSkillContent() != null) {
existing.setSkillContent(skill.getSkillContent());
}
// Display overrides pure frontend-facing, no runtime impact.
// Treat blank-string as an explicit "clear" (so the picker's
// "no icon" path reverts the row icon back to the manifest
// projection on the next resolve).
if (skill.getIcon() != null) existing.setIcon(skill.getIcon());
if (skill.getNameZh() != null) existing.setNameZh(skill.getNameZh());
if (skill.getNameEn() != null) existing.setNameEn(skill.getNameEn());
if (skill.getTags() != null) existing.setTags(skill.getTags());
skillMapper.updateById(existing);
log.info("Updated builtin skill (limited): {}", existing.getName());
log.info("Updated builtin skill (display overrides allowed): {}", existing.getName());
// builtin skill 也同步 workspace SKILL.md
syncSkillContentToWorkspace(existing);

View File

@ -23,6 +23,7 @@
"marked-highlight": "^2.2.3",
"mermaid": "^11.14.0",
"pinia": "^3.0.1",
"pixelarticons": "2.1.0",
"vue": "^3.5.13",
"vue-i18n": "9.14.4",
"vue-router": "^4.5.0"

View File

@ -44,6 +44,9 @@ importers:
pinia:
specifier: ^3.0.1
version: 3.0.4(typescript@5.7.3)(vue@3.5.31(typescript@5.7.3))
pixelarticons:
specifier: 2.1.0
version: 2.1.0(react@19.2.5)
vue:
specifier: ^3.5.13
version: 3.5.31(typescript@5.7.3)
@ -430,66 +433,79 @@ packages:
resolution: {integrity: sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.60.1':
resolution: {integrity: sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.60.1':
resolution: {integrity: sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.60.1':
resolution: {integrity: sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.60.1':
resolution: {integrity: sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-loong64-musl@4.60.1':
resolution: {integrity: sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==}
cpu: [loong64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.60.1':
resolution: {integrity: sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-musl@4.60.1':
resolution: {integrity: sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==}
cpu: [ppc64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.60.1':
resolution: {integrity: sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.60.1':
resolution: {integrity: sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.60.1':
resolution: {integrity: sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.60.1':
resolution: {integrity: sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.60.1':
resolution: {integrity: sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-openbsd-x64@4.60.1':
resolution: {integrity: sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==}
@ -562,24 +578,28 @@ packages:
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.2.2':
resolution: {integrity: sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.2.2':
resolution: {integrity: sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-x64-musl@4.2.2':
resolution: {integrity: sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-wasm32-wasi@4.2.2':
resolution: {integrity: sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==}
@ -1466,24 +1486,28 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.32.0:
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.32.0:
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.32.0:
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.32.0:
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
@ -1644,6 +1668,12 @@ packages:
typescript:
optional: true
pixelarticons@2.1.0:
resolution: {integrity: sha512-t03/DsFadIEU7SCwmi4mcnYgZMCKPawvvPt+KM9bW5nC4TcNFatCD196Qm9IT/5bIIbSCGjmjAoMrTZKxRBdJw==}
hasBin: true
peerDependencies:
react: '>=16'
pkg-types@1.3.1:
resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==}
@ -1676,6 +1706,10 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
react@19.2.5:
resolution: {integrity: sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==}
engines: {node: '>=0.10.0'}
resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'}
@ -3412,6 +3446,10 @@ snapshots:
optionalDependencies:
typescript: 5.7.3
pixelarticons@2.1.0(react@19.2.5):
dependencies:
react: 19.2.5
pkg-types@1.3.1:
dependencies:
confbox: 0.1.8
@ -3444,6 +3482,8 @@ snapshots:
punycode@2.3.1: {}
react@19.2.5: {}
resolve-from@4.0.0: {}
rfdc@1.4.1: {}

View File

@ -0,0 +1,130 @@
<template>
<span
class="skill-icon"
:class="[`skill-icon--${parsed.kind}`, sizeClass]"
:style="{ width: `${size}px`, height: `${size}px`, fontSize: `${Math.round(size * 0.7)}px` }"
:title="title"
>
<!-- Pixelart: inlined SVG inherits color via fill="currentColor". -->
<span
v-if="parsed.kind === 'pixelart' && parsed.svg"
class="skill-icon__svg"
v-html="parsed.svg"
/>
<!-- Pixelart name we don't recognize (icon was renamed across
pixelarticons versions, or string is malformed). Show a
neutral placeholder so the layout doesn't shift. -->
<span
v-else-if="parsed.kind === 'pixelart'"
class="skill-icon__missing"
:title="`pi:${parsed.name}(未找到)`"
>?</span>
<img
v-else-if="parsed.kind === 'url'"
class="skill-icon__img"
:src="parsed.url"
:alt="title || ''"
loading="lazy"
/>
<span
v-else-if="parsed.kind === 'emoji'"
class="skill-icon__glyph"
>{{ parsed.value }}</span>
<span
v-else
class="skill-icon__fallback"
aria-hidden="true"
>{{ fallback }}</span>
</span>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { parseIconValue } from '@/composables/usePixelarticons'
const props = withDefaults(defineProps<{
/** Persisted icon string (emoji glyph / `pi:name` / http(s) URL). */
value: string | null | undefined
/** Icon side length in pixels. Drives both font-size and box. */
size?: number
/** Glyph shown when value is empty. */
fallback?: string
/** Tooltip / a11y label. */
title?: string
}>(), {
size: 20,
fallback: '🛠️',
title: undefined,
})
const parsed = computed(() => parseIconValue(props.value))
/** Coarse size bucket so callers can target with CSS instead of inline
* styles when they need to (eg. card hover transforms). */
const sizeClass = computed(() => {
if (props.size <= 16) return 'skill-icon--xs'
if (props.size <= 22) return 'skill-icon--sm'
if (props.size <= 32) return 'skill-icon--md'
return 'skill-icon--lg'
})
</script>
<style scoped>
.skill-icon {
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 1;
flex-shrink: 0;
color: var(--mc-text-primary);
}
/* Pixelart SVGs ship with viewBox=0 0 24 24 + fill=currentColor they
* scale crisply at any integer multiple. We force display:block on the
* inner svg so the wrapper's flex centering works. */
.skill-icon__svg {
display: inline-flex;
width: 100%;
height: 100%;
}
.skill-icon__svg :deep(svg) {
width: 100%;
height: 100%;
display: block;
/* image-rendering: pixelated keeps the chunky pixel-art aesthetic
* even when the box is rendered at non-integer device pixels. */
image-rendering: pixelated;
shape-rendering: crispEdges;
}
.skill-icon__img {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
.skill-icon__glyph,
.skill-icon__fallback {
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 1;
}
.skill-icon__missing {
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
border-radius: 6px;
background: rgba(123, 88, 67, 0.10);
color: var(--mc-text-tertiary);
font-size: 0.7em;
font-weight: 700;
}
:global(html.dark .skill-icon__missing) {
background: rgba(255, 255, 255, 0.08);
}
</style>

View File

@ -0,0 +1,641 @@
<template>
<Teleport to="body">
<Transition name="mc-picker-fade">
<div
v-if="visible"
class="mc-picker-overlay"
@click.self="onCancel"
@keydown.esc.stop="onCancel"
>
<div class="mc-picker-panel" role="dialog" aria-modal="true">
<header class="mc-picker-head">
<div class="mc-picker-head__meta">
<SkillIcon :value="draft" :size="36" :fallback="'🛠️'" class="mc-picker-preview" />
<div>
<h3 class="mc-picker-title">{{ t('common.iconPicker.title') }}</h3>
<p class="mc-picker-current">
<span v-if="draftSummary" class="mc-picker-current__value">{{ draftSummary }}</span>
<span v-else class="mc-picker-current__empty">{{ t('common.iconPicker.none') }}</span>
</p>
</div>
</div>
<button class="mc-picker-close" :title="t('common.cancel')" @click="onCancel">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</header>
<!-- Tab strip frosted pill, same look as the skill drawer. -->
<div class="mc-picker-tabs">
<button
v-for="tab in tabs"
:key="tab.id"
type="button"
class="mc-picker-tab"
:class="{ 'mc-picker-tab--active': activeTab === tab.id }"
@click="activeTab = tab.id"
>{{ tab.label }}</button>
</div>
<!-- Pixelart browser -->
<div v-if="activeTab === 'pixelart'" class="mc-picker-body mc-picker-body--pixelart">
<div class="mc-picker-search">
<svg class="mc-picker-search__icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="7" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<input
ref="searchInputRef"
v-model="search"
class="mc-picker-search__input"
type="text"
:placeholder="t('common.iconPicker.search')"
spellcheck="false"
autocomplete="off"
/>
<button
v-if="search"
class="mc-picker-search__clear"
:title="t('common.clear')"
type="button"
@click="search = ''"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
<div v-if="filteredNames.length === 0" class="mc-picker-empty">
{{ t('common.iconPicker.empty') }}
</div>
<!-- Grid: ~50px tiles, fills width. With ~400 icons the
native scroll is fine; virtualization isn't worth a dep. -->
<div v-else class="mc-picker-grid" role="listbox">
<button
v-for="name in filteredNames"
:key="name"
type="button"
class="mc-picker-tile"
:class="{ 'mc-picker-tile--active': draft === `pi:${name}` }"
:title="name"
role="option"
:aria-selected="draft === `pi:${name}`"
@click="pickPixelart(name)"
>
<SkillIcon :value="`pi:${name}`" :size="22" />
</button>
</div>
<p class="mc-picker-foot">
{{ t('common.iconPicker.pixelartCount', { n: pixelartCount }) }}
</p>
</div>
<!-- Free-form emoji input -->
<div v-else-if="activeTab === 'emoji'" class="mc-picker-body mc-picker-body--emoji">
<p class="mc-picker-hint">{{ t('common.iconPicker.emojiHint') }}</p>
<input
v-model="emojiDraft"
class="mc-picker-input"
type="text"
maxlength="8"
:placeholder="t('common.iconPicker.emojiPlaceholder')"
@input="onEmojiInput"
/>
</div>
<!-- URL -->
<div v-else class="mc-picker-body mc-picker-body--url">
<p class="mc-picker-hint">{{ t('common.iconPicker.urlHint') }}</p>
<input
v-model="urlDraft"
class="mc-picker-input"
type="url"
:placeholder="t('common.iconPicker.urlPlaceholder')"
@input="onUrlInput"
/>
</div>
<footer class="mc-picker-actions">
<button type="button" class="mc-picker-btn mc-picker-btn--ghost" @click="clearDraft">
{{ t('common.iconPicker.none') }}
</button>
<span class="mc-picker-actions__spacer" />
<button type="button" class="mc-picker-btn mc-picker-btn--ghost" @click="onCancel">
{{ t('common.cancel') }}
</button>
<button
type="button"
class="mc-picker-btn mc-picker-btn--primary"
:disabled="draft === modelValue"
@click="onApply"
>{{ t('common.iconPicker.apply') }}</button>
</footer>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import SkillIcon from './SkillIcon.vue'
import { pixelartIconNames, parseIconValue } from '@/composables/usePixelarticons'
const props = withDefaults(defineProps<{
/** Two-way bound visibility flag — `v-model:visible` on the parent. */
visible: boolean
/** Currently persisted icon string. The picker stages edits in {@link draft}
* and only applies on confirm so cancel is truly a cancel. */
modelValue?: string | null
}>(), {
modelValue: '',
})
const emit = defineEmits<{
'update:visible': [value: boolean]
'update:modelValue': [value: string]
'apply': [value: string]
}>()
const { t } = useI18n()
const tabs = computed(() => [
{ id: 'pixelart' as const, label: t('common.iconPicker.tabPixelart') },
{ id: 'emoji' as const, label: t('common.iconPicker.tabEmoji') },
{ id: 'url' as const, label: t('common.iconPicker.tabUrl') },
])
const activeTab = ref<'pixelart' | 'emoji' | 'url'>('pixelart')
const search = ref('')
const searchInputRef = ref<HTMLInputElement | null>(null)
/** Working buffer — what the user has staged but not committed. */
const draft = ref<string>(props.modelValue || '')
const emojiDraft = ref('')
const urlDraft = ref('')
const pixelartCount = computed(() => pixelartIconNames.length)
/**
* Reset all staging state when the picker re-opens, and pick the right
* tab based on what the current value looks like so opening for an
* existing emoji lands on the Emoji tab, not Pixelart.
*/
watch(() => props.visible, async (open) => {
if (!open) return
draft.value = props.modelValue || ''
search.value = ''
const parsed = parseIconValue(props.modelValue)
if (parsed.kind === 'pixelart') {
activeTab.value = 'pixelart'
emojiDraft.value = ''
urlDraft.value = ''
} else if (parsed.kind === 'url') {
activeTab.value = 'url'
urlDraft.value = parsed.url
emojiDraft.value = ''
} else if (parsed.kind === 'emoji') {
activeTab.value = 'emoji'
emojiDraft.value = parsed.value
urlDraft.value = ''
} else {
activeTab.value = 'pixelart'
emojiDraft.value = ''
urlDraft.value = ''
}
await nextTick()
if (activeTab.value === 'pixelart') searchInputRef.value?.focus()
})
const filteredNames = computed(() => {
const q = search.value.trim().toLowerCase()
if (!q) return pixelartIconNames
return pixelartIconNames.filter(n => n.includes(q))
})
/** Human-readable label for the current draft, surfaced in the header. */
const draftSummary = computed(() => {
const parsed = parseIconValue(draft.value)
if (parsed.kind === 'pixelart') return `pi:${parsed.name}`
if (parsed.kind === 'url') return parsed.url
if (parsed.kind === 'emoji') return parsed.value
return ''
})
function pickPixelart(name: string) {
draft.value = `pi:${name}`
}
function onEmojiInput() {
draft.value = emojiDraft.value
}
function onUrlInput() {
// Only treat as URL once it actually looks like one typing "h"
// shouldn't immediately mark the draft as a URL icon.
const v = urlDraft.value.trim()
draft.value = (v.startsWith('http://') || v.startsWith('https://')) ? v : ''
}
function clearDraft() {
draft.value = ''
emojiDraft.value = ''
urlDraft.value = ''
}
function onCancel() {
emit('update:visible', false)
}
function onApply() {
emit('update:modelValue', draft.value)
emit('apply', draft.value)
emit('update:visible', false)
}
</script>
<style scoped>
/* Overlay + panel mirror the MateClaw drawer / confirm same blur,
* same warm tone, same iOS-spring entrance. */
.mc-picker-overlay {
position: fixed;
inset: 0;
background: rgba(20, 14, 10, 0.32);
backdrop-filter: blur(8px) saturate(140%);
-webkit-backdrop-filter: blur(8px) saturate(140%);
z-index: 1900;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
:global(html.dark .mc-picker-overlay) {
background: rgba(0, 0, 0, 0.5);
}
.mc-picker-panel {
width: 100%;
max-width: 540px;
max-height: min(680px, 92vh);
display: flex;
flex-direction: column;
background: rgba(255, 250, 245, 0.85);
backdrop-filter: blur(48px) saturate(180%);
-webkit-backdrop-filter: blur(48px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.5);
border-radius: 18px;
box-shadow:
0 24px 60px rgba(25, 14, 8, 0.2),
0 2px 6px rgba(25, 14, 8, 0.06);
animation: mc-picker-pop 0.28s cubic-bezier(0.32, 0.72, 0, 1.2);
overflow: hidden;
}
:global(html.dark .mc-picker-panel) {
background: rgba(32, 26, 22, 0.86);
border-color: rgba(255, 255, 255, 0.10);
box-shadow:
0 24px 60px rgba(0, 0, 0, 0.6),
0 2px 6px rgba(0, 0, 0, 0.4);
}
@keyframes mc-picker-pop {
from { opacity: 0; transform: scale(0.95) translateY(8px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
/* Header */
.mc-picker-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 18px 22px 14px;
border-bottom: 1px solid rgba(123, 88, 67, 0.10);
}
:global(html.dark .mc-picker-head) {
border-bottom-color: rgba(255, 255, 255, 0.06);
}
.mc-picker-head__meta {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.mc-picker-preview {
flex-shrink: 0;
background: rgba(123, 88, 67, 0.08);
border-radius: 10px;
padding: 4px;
box-sizing: content-box;
}
:global(html.dark .mc-picker-preview) {
background: rgba(255, 255, 255, 0.06);
}
.mc-picker-title {
margin: 0;
font-size: 15px;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--mc-text-primary);
}
.mc-picker-current {
margin: 2px 0 0;
font-size: 11px;
color: var(--mc-text-tertiary);
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
max-width: 320px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mc-picker-current__empty {
font-style: italic;
font-family: inherit;
}
.mc-picker-close {
background: transparent;
border: 0;
padding: 8px;
border-radius: 999px;
cursor: pointer;
color: var(--mc-text-tertiary);
flex-shrink: 0;
transition: background 0.15s ease, color 0.15s ease;
}
.mc-picker-close:hover {
background: rgba(123, 88, 67, 0.08);
color: var(--mc-text-primary);
}
:global(html.dark .mc-picker-close:hover) {
background: rgba(255, 255, 255, 0.08);
color: var(--mc-text-primary);
}
/* Tabs */
.mc-picker-tabs {
display: flex;
gap: 2px;
margin: 12px 22px 0;
padding: 4px;
background: rgba(123, 88, 67, 0.06);
border-radius: 999px;
width: fit-content;
}
:global(html.dark .mc-picker-tabs) {
background: rgba(255, 255, 255, 0.06);
}
.mc-picker-tab {
appearance: none;
border: 0;
background: transparent;
padding: 6px 14px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
color: var(--mc-text-secondary);
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease;
}
.mc-picker-tab:hover:not(.mc-picker-tab--active) {
color: var(--mc-text-primary);
}
.mc-picker-tab--active {
background: rgba(255, 255, 255, 0.85);
color: var(--mc-primary-hover);
box-shadow: 0 1px 3px rgba(25, 14, 8, 0.08);
}
:global(html.dark .mc-picker-tab--active) {
background: rgba(255, 255, 255, 0.14);
}
/* Body containers */
.mc-picker-body {
flex: 1;
min-height: 0;
overflow: hidden;
padding: 14px 22px 0;
display: flex;
flex-direction: column;
}
/* Pixelart-specific */
.mc-picker-search {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
border-radius: 999px;
background: rgba(123, 88, 67, 0.06);
margin-bottom: 12px;
flex-shrink: 0;
}
.mc-picker-search:focus-within {
background: rgba(255, 255, 255, 0.65);
box-shadow:
inset 0 0 0 1px rgba(217, 119, 87, 0.3),
0 0 0 3px rgba(217, 119, 87, 0.10);
}
:global(html.dark .mc-picker-search) {
background: rgba(255, 255, 255, 0.08);
}
:global(html.dark .mc-picker-search:focus-within) {
background: rgba(255, 255, 255, 0.14);
}
.mc-picker-search__icon { color: var(--mc-text-tertiary); flex-shrink: 0; }
.mc-picker-search__input {
flex: 1;
border: 0;
background: transparent;
padding: 4px 0;
font-size: 13px;
color: var(--mc-text-primary);
outline: none;
}
.mc-picker-search__input::placeholder { color: var(--mc-text-tertiary); }
.mc-picker-search__clear {
background: transparent;
border: 0;
padding: 2px;
color: var(--mc-text-tertiary);
cursor: pointer;
display: inline-flex;
border-radius: 999px;
}
.mc-picker-search__clear:hover {
color: var(--mc-text-primary);
background: rgba(123, 88, 67, 0.10);
}
:global(html.dark .mc-picker-search__clear:hover) {
background: rgba(255, 255, 255, 0.10);
}
.mc-picker-grid {
flex: 1;
min-height: 0;
overflow-y: auto;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(44px, 1fr));
gap: 4px;
padding: 4px 0 4px;
/* Fade scrollbar for the picker body. */
scrollbar-width: thin;
}
.mc-picker-grid::-webkit-scrollbar {
width: 6px;
}
.mc-picker-grid::-webkit-scrollbar-thumb {
background: rgba(123, 88, 67, 0.2);
border-radius: 3px;
}
.mc-picker-tile {
appearance: none;
border: 0;
background: transparent;
width: 100%;
aspect-ratio: 1;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 10px;
cursor: pointer;
color: var(--mc-text-secondary);
transition: background 0.12s ease, color 0.12s ease, transform 0.08s ease;
}
.mc-picker-tile:hover {
background: rgba(123, 88, 67, 0.10);
color: var(--mc-text-primary);
}
:global(html.dark .mc-picker-tile:hover) {
background: rgba(255, 255, 255, 0.10);
}
.mc-picker-tile:active {
transform: scale(0.94);
}
.mc-picker-tile--active {
background: var(--mc-primary);
color: #fff;
}
.mc-picker-tile--active:hover {
background: var(--mc-primary-hover);
color: #fff;
}
.mc-picker-empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
color: var(--mc-text-tertiary);
font-style: italic;
}
.mc-picker-foot {
margin: 8px 0 0;
font-size: 11px;
color: var(--mc-text-tertiary);
text-align: center;
flex-shrink: 0;
}
/* Emoji & URL panes share an input + hint layout. */
.mc-picker-hint {
margin: 0 0 10px;
font-size: 12px;
color: var(--mc-text-tertiary);
line-height: 1.5;
}
.mc-picker-input {
width: 100%;
padding: 10px 14px;
border-radius: 12px;
border: 1px solid rgba(123, 88, 67, 0.12);
background: rgba(255, 255, 255, 0.7);
font-size: 14px;
color: var(--mc-text-primary);
outline: none;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.mc-picker-input:focus {
border-color: rgba(217, 119, 87, 0.45);
box-shadow: 0 0 0 3px rgba(217, 119, 87, 0.10);
}
:global(html.dark .mc-picker-input) {
background: rgba(0, 0, 0, 0.25);
border-color: rgba(255, 255, 255, 0.10);
}
/* Footer */
.mc-picker-actions {
display: flex;
align-items: center;
gap: 8px;
padding: 14px 22px 18px;
border-top: 1px solid rgba(123, 88, 67, 0.10);
margin-top: 12px;
}
:global(html.dark .mc-picker-actions) {
border-top-color: rgba(255, 255, 255, 0.06);
}
.mc-picker-actions__spacer { flex: 1; }
.mc-picker-btn {
appearance: none;
border: 0;
padding: 8px 16px;
border-radius: 10px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease, transform 0.1s ease;
}
.mc-picker-btn:active { transform: scale(0.98); }
.mc-picker-btn--ghost {
background: rgba(123, 88, 67, 0.08);
color: var(--mc-text-primary);
}
.mc-picker-btn--ghost:hover {
background: rgba(123, 88, 67, 0.14);
}
:global(html.dark .mc-picker-btn--ghost) {
background: rgba(255, 255, 255, 0.08);
}
:global(html.dark .mc-picker-btn--ghost:hover) {
background: rgba(255, 255, 255, 0.14);
}
.mc-picker-btn--primary {
background: var(--mc-primary);
color: #fff;
box-shadow: 0 1px 3px rgba(217, 119, 87, 0.25);
}
.mc-picker-btn--primary:hover:not(:disabled) {
background: var(--mc-primary-hover);
}
.mc-picker-btn--primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.mc-picker-fade-enter-active,
.mc-picker-fade-leave-active {
transition: opacity 0.18s ease;
}
.mc-picker-fade-enter-from,
.mc-picker-fade-leave-to {
opacity: 0;
}
/* Mobile: full-bleed sheet, taller body */
@media (max-width: 600px) {
.mc-picker-overlay {
align-items: flex-end;
padding: 0;
}
.mc-picker-panel {
max-width: 100%;
max-height: 88vh;
border-radius: 20px 20px 0 0;
}
}
</style>

View File

@ -0,0 +1,76 @@
/**
* pixelarticons (npm `pixelarticons`) SVG icon loader.
*
* The package ships ~400 icons each with a "sharp" twin variant (~800
* SVGs total). We surface the regular set only the sharp variants are
* a stylistic alternative that doubles bundle size for marginal value
* in our context. Each SVG is ~150B, so the regular set lands around
* 60KB raw / ~15KB gzipped small enough to bundle eagerly so the
* picker can search/grid without lazy-loading round trips.
*
* Icon scheme: we encode a chosen pixelart icon as the string `pi:name`
* (kebab-case). The {@link SkillIcon} renderer detects this prefix and
* inlines the SVG at render time.
*/
// Eager glob into node_modules — Vite resolves `?raw` text imports for
// SVGs the same as any other asset. We exclude the `*-sharp.svg` files
// at the glob level so they never enter the bundle.
const modules = import.meta.glob<string>(
'../../node_modules/pixelarticons/svg/!(*-sharp).svg',
{ query: '?raw', import: 'default', eager: true },
)
/**
* Map from kebab-case icon name raw SVG markup.
* Built once at module load; ~400 entries.
*/
export const pixelartIcons: Record<string, string> = (() => {
const out: Record<string, string> = {}
for (const [path, raw] of Object.entries(modules)) {
const name = path.split('/').pop()!.replace(/\.svg$/, '')
out[name] = raw
}
return out
})()
/**
* Sorted list of icon names drives the picker grid. Cached because the
* picker reads it on every keystroke (search filter).
*/
export const pixelartIconNames: string[] = Object.keys(pixelartIcons).sort()
/**
* Encode a pixelart icon name into the persisted icon string.
* Symmetric with {@link parseIconValue}.
*/
export function encodePixelartIcon(name: string): string {
return `pi:${name}`
}
export type ParsedIcon =
| { kind: 'pixelart'; name: string; svg: string | null }
| { kind: 'url'; url: string }
| { kind: 'emoji'; value: string }
| { kind: 'empty' }
/**
* Decode a stored icon string into a renderable shape. Returns
* {@code kind: 'empty'} for null/blank so callers can branch on a
* single discriminator.
*/
export function parseIconValue(value: string | null | undefined): ParsedIcon {
if (!value || !value.trim()) return { kind: 'empty' }
const v = value.trim()
if (v.startsWith('pi:')) {
const name = v.slice(3)
return { kind: 'pixelart', name, svg: pixelartIcons[name] ?? null }
}
if (v.startsWith('http://') || v.startsWith('https://')) {
return { kind: 'url', url: v }
}
// Anything else falls through as an emoji / text glyph. We don't
// validate "is this really an emoji" — letting the user pick any
// unicode glyph is intentionally flexible.
return { kind: 'emoji', value: v }
}

View File

@ -45,6 +45,24 @@ export default {
jumpTo: 'Go to',
pageSuffix: '',
},
iconPicker: {
title: 'Pick an icon',
tabPixelart: 'Pixelart',
tabEmoji: 'Emoji',
tabUrl: 'Image URL',
search: 'Search by name…',
empty: 'No icons match',
preview: 'Preview',
none: 'No icon',
apply: 'Apply',
emojiHint: 'Type or paste any emoji / text glyph.',
emojiPlaceholder: '🎬 / 📊 / any symbol',
urlHint: 'Use a hosted image (PNG / SVG / JPG) as the icon.',
urlPlaceholder: 'https://example.com/icon.svg',
pixelartCount: '{n} pixelart icons by pixelarticons (MIT)',
pickerOpen: 'Open icon picker',
reprojectionWarning: 'Heads up: if SKILL.md frontmatter declares `icon`, your override will be overwritten on next resolve — edit the body to make it permanent.',
},
},
auth: {
changePassword: 'Change Password',

View File

@ -45,6 +45,24 @@ export default {
jumpTo: '前往',
pageSuffix: '页',
},
iconPicker: {
title: '选择图标',
tabPixelart: '像素图标',
tabEmoji: '表情符号',
tabUrl: '图片链接',
search: '搜索图标名…',
empty: '没有匹配的图标',
preview: '预览',
none: '不设图标',
apply: '应用',
emojiHint: '直接输入或粘贴一个 emoji / 文字符号。',
emojiPlaceholder: '🎬 / 📊 / 任意符号',
urlHint: '使用线上图片作为图标PNG / SVG / JPG。',
urlPlaceholder: 'https://example.com/icon.svg',
pixelartCount: '共 {n} 个像素图标,由 pixelarticons 提供MIT',
pickerOpen: '打开图标选择器',
reprojectionWarning: '提示:若 SKILL.md frontmatter 已声明 icon下次解析会覆盖此处的修改 — 在正文里改才永久。',
},
},
auth: {
changePassword: '修改密码',

View File

@ -80,7 +80,11 @@
>
<div class="skill-header">
<div class="skill-icon-wrap" :class="getSkillIconBg(skill.skillType)">
<span class="skill-icon">{{ skill.icon || getSkillIcon(skill.skillType) }}</span>
<SkillIcon
:value="skill.icon"
:fallback="getSkillIcon(skill.skillType)"
:size="22"
/>
</div>
<div class="skill-meta">
<h3 class="skill-name">{{ resolveSkillName(skill) }}</h3>
@ -187,7 +191,9 @@
<div class="mc-drawer-panel" :class="{ 'mc-drawer-panel--wide': editingBody }">
<div class="mc-drawer-header">
<div class="mc-drawer-header__meta">
<span class="mc-drawer-icon-shell">{{ detailSkill.icon || '🛠️' }}</span>
<span class="mc-drawer-icon-shell">
<SkillIcon :value="detailSkill.icon" :size="24" />
</span>
<div>
<h3 class="mc-drawer-title">{{ resolveSkillName(detailSkill) }}</h3>
<p class="mc-drawer-subtitle">
@ -283,7 +289,6 @@
<div class="meta-chips">
<span class="meta-chip"><span class="meta-chip-label">slug</span><code>{{ detailSkill.name }}</code></span>
<span class="meta-chip"><span class="meta-chip-label">{{ t('skills.fields.type') }}</span>{{ detailSkill.skillType || '—' }}</span>
<span v-if="detailSkill.icon" class="meta-chip"><span class="meta-chip-label">{{ t('skills.fields.icon') }}</span>{{ detailSkill.icon }}</span>
<span v-if="detailSkill.version" class="meta-chip"><span class="meta-chip-label">{{ t('skills.fields.version') }}</span>v{{ detailSkill.version }}</span>
<span v-if="detailSkill.author" class="meta-chip"><span class="meta-chip-label">{{ t('skills.fields.author') }}</span>{{ detailSkill.author }}</span>
</div>
@ -316,6 +321,28 @@
<p v-if="isVirtualSkill" class="detail-readonly-banner">{{ t('skills.detail.virtualReadonly') }}</p>
<p v-else class="detail-hint">{{ t('skills.detail.displayOverridesHint') }}</p>
<!-- Icon row spans both modes view shows the resolved
glyph + label; edit turns the tile into a "pick"
affordance that opens the picker. -->
<div class="identity-icon-row">
<SkillIcon :value="editingIdentity ? editForm.icon : detailSkill.icon" :size="40" class="identity-icon-preview" />
<div class="identity-icon-meta">
<span class="identity-icon-label">{{ t('skills.fields.icon') }}</span>
<code v-if="(editingIdentity ? editForm.icon : detailSkill.icon)" class="identity-icon-value">
{{ editingIdentity ? editForm.icon : detailSkill.icon }}
</code>
<span v-else class="identity-icon-empty">{{ t('common.iconPicker.none') }}</span>
</div>
<button
v-if="editingIdentity"
type="button"
class="detail-edit-btn"
@click="openIconPickerFor('edit')"
>
{{ t('common.iconPicker.pickerOpen') }}
</button>
</div>
<dl v-if="!editingIdentity" class="identity-grid">
<div class="kv"><dt>{{ t('skills.fields.nameZh') }}</dt><dd>{{ detailSkill.nameZh || '—' }}</dd></div>
<div class="kv"><dt>{{ t('skills.fields.nameEn') }}</dt><dd>{{ detailSkill.nameEn || '—' }}</dd></div>
@ -340,6 +367,9 @@
<input v-model="editForm.description" class="form-input" :placeholder="t('skills.placeholders.description')" />
</div>
</div>
<p v-if="editingIdentity && manifestDeclaresIcon" class="detail-readonly-banner">
{{ t('common.iconPicker.reprojectionWarning') }}
</p>
</div>
<details class="detail-collapsible">
@ -545,6 +575,24 @@
</div>
<div class="modal-body">
<p class="modal-hint">{{ t('skills.modal.newSimpleHint') }}</p>
<!-- Icon picker affordance tile up top, mirroring the
drawer's identity-icon-row so users see one consistent
pattern across create + edit. -->
<div class="identity-icon-row identity-icon-row--create">
<SkillIcon :value="newForm.icon" :size="40" class="identity-icon-preview" />
<div class="identity-icon-meta">
<span class="identity-icon-label">{{ t('skills.fields.icon') }}</span>
<code v-if="newForm.icon" class="identity-icon-value">{{ newForm.icon }}</code>
<span v-else class="identity-icon-empty">{{ t('common.iconPicker.none') }}</span>
</div>
<button
type="button"
class="detail-edit-btn"
@click="openIconPickerFor('create')"
>
{{ t('common.iconPicker.pickerOpen') }}
</button>
</div>
<div class="form-grid">
<div class="form-group full-width">
<label class="form-label">{{ t('skills.fields.name') }} *</label>
@ -574,6 +622,13 @@
</div>
</div>
</div>
<!-- Shared icon picker single instance routed by iconPickerTarget. -->
<SkillIconPicker
v-model:visible="iconPickerVisible"
:model-value="iconPickerTarget === 'create' ? newForm.icon : editForm.icon"
@apply="onIconPicked"
/>
</div>
</template>
@ -586,6 +641,8 @@ import type { Skill, SkillRuntimeStatus, SkillSecurityFinding } from '@/types/in
import ImportHubDialog from '@/components/skill/ImportHubDialog.vue'
import PreflightInstallDialog from '@/components/skill/PreflightInstallDialog.vue'
import McPagination from '@/components/common/McPagination.vue'
import SkillIcon from '@/components/common/SkillIcon.vue'
import SkillIconPicker from '@/components/common/SkillIconPicker.vue'
import { mcConfirm } from '@/components/common/useConfirm'
import { useSkillName } from '@/composables/useSkillName'
@ -645,7 +702,25 @@ const editForm = ref<{
nameEn: string
description: string
tags: string
}>({ nameZh: '', nameEn: '', description: '', tags: '' })
icon: string
}>({ nameZh: '', nameEn: '', description: '', tags: '', icon: '' })
/** Icon picker visibility shared between the create modal and the
* drawer Display section. We only ever have one picker open at a time. */
const iconPickerVisible = ref(false)
/** Routes the picker's apply event to either the create form or the
* drawer's identity edit, depending on who opened it. */
type IconPickerTarget = 'create' | 'edit'
const iconPickerTarget = ref<IconPickerTarget>('edit')
/** Whether the underlying SKILL.md frontmatter has a declared `icon`.
* When true, the resolver will overwrite a row-level icon override on
* the next resolve we surface a warning so the user knows their
* edit may be ephemeral and should be made in the body instead. */
const manifestDeclaresIcon = computed(() => {
const m = detailManifest.value as { icon?: string } | null
return !!(m && typeof m.icon === 'string' && m.icon.trim())
})
const editBodyForm = ref<{ skillContent: string; sourceCode: string }>({
skillContent: '',
sourceCode: '',
@ -653,7 +728,7 @@ const editBodyForm = ref<{ skillContent: string; sourceCode: string }>({
/** New-skill modal pared down to the two questions that *must* be answered
* at creation time. Everything else is filled in via the drawer. */
const newForm = ref<{ name: string; description: string }>({ name: '', description: '' })
const newForm = ref<{ name: string; description: string; icon: string }>({ name: '', description: '', icon: '' })
/** Virtual MCP-derived skills synthesize their id from
* {@link McpSkillBridge#VIRTUAL_ID_BASE} (= 9e18). The DB update path
@ -898,7 +973,7 @@ async function loadRuntimeStatus() {
}
function openCreateModal() {
newForm.value = { name: '', description: '' }
newForm.value = { name: '', description: '', icon: '' }
showModal.value = true
}
@ -917,6 +992,7 @@ async function createSkillFromModal() {
const payload = {
name: newForm.value.name.trim(),
description: newForm.value.description.trim(),
icon: newForm.value.icon || undefined,
skillType: 'dynamic',
version: '1.0.0',
enabled: true,
@ -952,10 +1028,24 @@ function startEditIdentity() {
nameEn: s.nameEn || '',
description: s.description || '',
tags: s.tags || '',
icon: s.icon || '',
}
editingIdentity.value = true
}
function openIconPickerFor(target: IconPickerTarget) {
iconPickerTarget.value = target
iconPickerVisible.value = true
}
function onIconPicked(value: string) {
if (iconPickerTarget.value === 'edit') {
editForm.value.icon = value
} else {
newForm.value.icon = value
}
}
function cancelEditIdentity() {
editingIdentity.value = false
}
@ -974,6 +1064,13 @@ async function saveIdentity() {
nameEn: editForm.value.nameEn,
description: editForm.value.description,
tags: editForm.value.tags,
// Icon is technically a manifest-projected field, but the
// resolver only overwrites when the manifest declares one
// (SkillPackageResolver.java:185). Sending it here lets users
// override icons for skills whose SKILL.md has no `icon:` and
// for skills that do declare it the warning above tells
// them to expect re-projection.
icon: editForm.value.icon,
}
const res: any = await skillApi.update(detailSkill.value.id, payload)
const updated: Skill | undefined = res?.data
@ -1922,6 +2019,63 @@ html.dark .scan-finding-item { background: rgba(255, 255, 255, 0.05); }
.identity-grid { grid-template-columns: 1fr; }
}
/* Icon row preview tile + label + picker button. Used in the drawer
* Display section and the create modal so both flows look identical. */
.identity-icon-row {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
border-radius: 12px;
background: rgba(123, 88, 67, 0.05);
margin-bottom: 12px;
}
:global(html.dark .identity-icon-row) {
background: rgba(255, 255, 255, 0.04);
}
.identity-icon-row--create {
margin: 4px 0 16px;
}
.identity-icon-preview {
background: rgba(255, 255, 255, 0.7);
border-radius: 10px;
padding: 4px;
box-sizing: content-box;
flex-shrink: 0;
}
:global(html.dark .identity-icon-preview) {
background: rgba(255, 255, 255, 0.08);
}
.identity-icon-meta {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
flex: 1;
}
.identity-icon-label {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--mc-text-tertiary);
}
.identity-icon-value {
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
font-size: 12px;
color: var(--mc-text-primary);
background: transparent;
padding: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.identity-icon-empty {
font-size: 12px;
font-style: italic;
color: var(--mc-text-tertiary);
}
/* ============================================================
* MateClaw frosted-glass drawer
* Mirrors Settings/Models/AddProviderDrawer.vue so the skill