mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 11:13:43 +08:00
feat(skill): pixelarticons icon library + picker component
This commit is contained in:
parent
9c6b728704
commit
efb91796fb
@ -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();
|
||||
}
|
||||
|
||||
@ -234,22 +234,45 @@ public class SkillService {
|
||||
|
||||
/**
|
||||
* 更新技能
|
||||
* 内置技能只允许修改 enabled、configJson、description
|
||||
* <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);
|
||||
|
||||
@ -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"
|
||||
|
||||
40
mateclaw-ui/pnpm-lock.yaml
generated
40
mateclaw-ui/pnpm-lock.yaml
generated
@ -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: {}
|
||||
|
||||
130
mateclaw-ui/src/components/common/SkillIcon.vue
Normal file
130
mateclaw-ui/src/components/common/SkillIcon.vue
Normal 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>
|
||||
641
mateclaw-ui/src/components/common/SkillIconPicker.vue
Normal file
641
mateclaw-ui/src/components/common/SkillIconPicker.vue
Normal 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>
|
||||
76
mateclaw-ui/src/composables/usePixelarticons.ts
Normal file
76
mateclaw-ui/src/composables/usePixelarticons.ts
Normal 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 }
|
||||
}
|
||||
@ -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',
|
||||
|
||||
@ -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: '修改密码',
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user