feat(ui): MateClaw skill detail drawer + shared McPagination / McConfirm components

This commit is contained in:
matevip 2026-05-01 09:52:04 +08:00
parent f8069b1b5d
commit 9c6b728704
9 changed files with 1638 additions and 244 deletions

View File

@ -1,6 +1,9 @@
<template>
<el-config-provider :locale="elementLocale">
<router-view />
<!-- Mounted once at the app root so mcConfirm() can pop a dialog
from anywhere without each caller wiring its own host. -->
<McConfirmHost />
</el-config-provider>
</template>
@ -11,6 +14,7 @@ import en from 'element-plus/es/locale/lang/en'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import { currentLocale } from '@/i18n'
import { useThemeStore } from '@/stores/useThemeStore'
import McConfirmHost from '@/components/common/McConfirmHost.vue'
// Initialize theme applies .dark class to <html> immediately
useThemeStore()

View File

@ -0,0 +1,247 @@
<template>
<Teleport to="body">
<Transition name="mc-confirm-fade">
<div
v-if="current"
class="mc-confirm-overlay"
@click.self="onCancel"
@keydown.esc="onCancel"
>
<div
class="mc-confirm-panel"
role="alertdialog"
aria-modal="true"
:aria-labelledby="`mc-confirm-title-${seq}`"
>
<div class="mc-confirm-head">
<span class="mc-confirm-icon" :class="`mc-confirm-icon--${current.tone || 'default'}`">
<svg v-if="current.tone === 'danger'" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 9v4"/><path d="M12 17h.01"/>
<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
</svg>
<svg v-else width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<path d="M12 8v4"/><path d="M12 16h.01"/>
</svg>
</span>
<h3 :id="`mc-confirm-title-${seq}`" class="mc-confirm-title">
{{ current.title || t('common.confirm') }}
</h3>
</div>
<p class="mc-confirm-message">{{ current.message }}</p>
<div class="mc-confirm-actions">
<button
ref="cancelBtnRef"
type="button"
class="mc-confirm-btn mc-confirm-btn--ghost"
@click="onCancel"
>
{{ current.cancelText || t('common.cancel') }}
</button>
<button
type="button"
class="mc-confirm-btn mc-confirm-btn--primary"
:class="`mc-confirm-btn--${current.tone || 'default'}`"
@click="onConfirm"
>
{{ current.confirmText || t('common.confirm') }}
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { activeConfirm, resolveConfirm } from './useConfirm'
const { t } = useI18n()
const current = computed(() => activeConfirm.value)
const cancelBtnRef = ref<HTMLButtonElement | null>(null)
// Stable id seed so the aria-labelledby can point at a unique title even
// if two prompts open in close succession.
let seqCounter = 0
const seq = ref(0)
watch(current, async (next) => {
if (next) {
seq.value = ++seqCounter
// Focus the cancel button on open. Cancel-as-default matches the
// safety-first stance: if the user hits Enter twice on a destructive
// prompt by mistake, nothing destructive happens.
await nextTick()
cancelBtnRef.value?.focus()
}
})
function onConfirm() {
resolveConfirm(true)
}
function onCancel() {
resolveConfirm(false)
}
</script>
<style scoped>
.mc-confirm-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: 2000;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
:global(html.dark .mc-confirm-overlay) {
background: rgba(0, 0, 0, 0.55);
}
.mc-confirm-panel {
width: 100%;
max-width: 420px;
background: rgba(255, 250, 245, 0.88);
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;
padding: 22px 24px 18px;
box-shadow:
0 24px 60px rgba(25, 14, 8, 0.22),
0 2px 6px rgba(25, 14, 8, 0.08);
/* iOS-style spring on entry — small overshoot, settles fast. */
animation: mc-confirm-pop 0.28s cubic-bezier(0.32, 0.72, 0, 1.2);
}
:global(html.dark .mc-confirm-panel) {
background: rgba(32, 26, 22, 0.88);
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-confirm-pop {
from { opacity: 0; transform: scale(0.94) translateY(6px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
.mc-confirm-head {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.mc-confirm-icon {
flex-shrink: 0;
width: 36px;
height: 36px;
border-radius: 12px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.mc-confirm-icon--default {
background: rgba(123, 88, 67, 0.10);
color: var(--mc-text-secondary);
}
.mc-confirm-icon--primary {
background: var(--mc-primary-bg);
color: var(--mc-primary);
}
.mc-confirm-icon--danger {
background: rgba(239, 68, 68, 0.12);
color: #dc2626;
}
:global(html.dark .mc-confirm-icon--danger) {
background: rgba(248, 113, 113, 0.18);
color: #fca5a5;
}
.mc-confirm-title {
margin: 0;
font-size: 16px;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--mc-text-primary);
}
.mc-confirm-message {
margin: 0 0 22px;
font-size: 14px;
line-height: 1.55;
color: var(--mc-text-secondary);
}
.mc-confirm-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.mc-confirm-btn {
appearance: none;
border: 0;
padding: 9px 18px;
border-radius: 10px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
letter-spacing: 0.01em;
transition: background 0.15s ease, color 0.15s ease, transform 0.1s ease, box-shadow 0.15s ease;
}
.mc-confirm-btn:active {
transform: scale(0.98);
}
.mc-confirm-btn--ghost {
background: rgba(123, 88, 67, 0.08);
color: var(--mc-text-primary);
}
.mc-confirm-btn--ghost:hover {
background: rgba(123, 88, 67, 0.14);
}
:global(html.dark .mc-confirm-btn--ghost) {
background: rgba(255, 255, 255, 0.08);
}
:global(html.dark .mc-confirm-btn--ghost:hover) {
background: rgba(255, 255, 255, 0.14);
}
.mc-confirm-btn--primary {
background: var(--mc-primary);
color: #fff;
box-shadow: 0 1px 3px rgba(217, 119, 87, 0.25);
}
.mc-confirm-btn--primary:hover {
background: var(--mc-primary-hover);
}
.mc-confirm-btn--primary.mc-confirm-btn--danger {
background: #dc2626;
box-shadow: 0 1px 3px rgba(220, 38, 38, 0.3);
}
.mc-confirm-btn--primary.mc-confirm-btn--danger:hover {
background: #b91c1c;
}
.mc-confirm-btn:focus-visible {
outline: none;
box-shadow: 0 0 0 3px rgba(217, 119, 87, 0.3);
}
.mc-confirm-btn--danger:focus-visible {
box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.3);
}
.mc-confirm-fade-enter-active,
.mc-confirm-fade-leave-active {
transition: opacity 0.18s ease;
}
.mc-confirm-fade-enter-from,
.mc-confirm-fade-leave-to {
opacity: 0;
}
</style>

View File

@ -0,0 +1,322 @@
<template>
<div v-if="shouldRender" class="mc-pager">
<span class="mc-pager-total">{{ t('common.pager.total', { n: total }) }}</span>
<!-- Page-size pill: opens a small native-style dropdown. We don't
use el-select to avoid pulling EP styling the whole point of
this component is to read MateClaw, not Element. -->
<label class="mc-pager-size">
<select
:value="size"
class="mc-pager-size__native"
@change="onSizeChange(($event.target as HTMLSelectElement).value)"
>
<option v-for="s in sizes" :key="s" :value="s">
{{ t('common.pager.perPage', { n: s }) }}
</option>
</select>
<span class="mc-pager-size__label">{{ t('common.pager.perPage', { n: size }) }}</span>
<svg class="mc-pager-size__chev" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">
<polyline points="6 9 12 15 18 9"/>
</svg>
</label>
<button
class="mc-pager-arrow"
:disabled="page <= 1"
:aria-label="t('common.pager.prev')"
@click="goTo(page - 1)"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">
<polyline points="15 18 9 12 15 6"/>
</svg>
</button>
<button
v-for="(item, idx) in pageItems"
:key="`${item.type}-${idx}`"
class="mc-pager-num"
:class="{ 'mc-pager-num--active': item.type === 'page' && item.value === page, 'mc-pager-num--gap': item.type === 'gap' }"
:disabled="item.type === 'gap'"
@click="item.type === 'page' && goTo(item.value!)"
>
<span v-if="item.type === 'page'">{{ item.value }}</span>
<span v-else></span>
</button>
<button
class="mc-pager-arrow"
:disabled="page >= totalPages"
:aria-label="t('common.pager.next')"
@click="goTo(page + 1)"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">
<polyline points="9 18 15 12 9 6"/>
</svg>
</button>
<!-- Jumper, only useful past a few pages. -->
<span v-if="totalPages > 5" class="mc-pager-jump">
<span class="mc-pager-jump__label">{{ t('common.pager.jumpTo') }}</span>
<input
:value="jumpDraft"
class="mc-pager-jump__input"
type="number"
min="1"
:max="totalPages"
@input="jumpDraft = ($event.target as HTMLInputElement).value"
@keydown.enter="commitJump"
@blur="commitJump"
/>
<span class="mc-pager-jump__suffix">{{ t('common.pager.pageSuffix') }}</span>
</span>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
const props = withDefaults(defineProps<{
/** Current 1-based page. */
page: number
/** Items per page. */
size: number
/** Total record count. */
total: number
/** Available page sizes. */
sizes?: number[]
/** Hide the whole bar when there's only one page. Defaults to false
* (we still show "共 N 条" + size selector for transparency). */
hideOnSinglePage?: boolean
}>(), {
sizes: () => [10, 20, 50],
hideOnSinglePage: false,
})
const emit = defineEmits<{
'update:page': [value: number]
'update:size': [value: number]
/** Convenience event fired whenever either page or size changes. */
'change': [value: { page: number; size: number }]
}>()
const { t } = useI18n()
const totalPages = computed(() => Math.max(1, Math.ceil(props.total / props.size)))
const shouldRender = computed(() => {
if (props.total <= 0) return false
if (props.hideOnSinglePage && totalPages.value <= 1) return false
return true
})
/**
* Build the page-number list with ellipsis collapsing. Strategy:
* - always show first + last
* - always show ±1 around current
* - elide other ranges with a single "…" placeholder
*
* Returns alternating page/gap items so the template can render them
* with stable keys.
*/
type PageItem = { type: 'page'; value: number } | { type: 'gap' }
const pageItems = computed<PageItem[]>(() => {
const total = totalPages.value
const cur = props.page
if (total <= 7) {
return Array.from({ length: total }, (_, i) => ({ type: 'page', value: i + 1 }))
}
const items: PageItem[] = [{ type: 'page', value: 1 }]
const left = Math.max(2, cur - 1)
const right = Math.min(total - 1, cur + 1)
if (left > 2) items.push({ type: 'gap' })
for (let i = left; i <= right; i++) items.push({ type: 'page', value: i })
if (right < total - 1) items.push({ type: 'gap' })
items.push({ type: 'page', value: total })
return items
})
function goTo(p: number) {
const next = Math.min(Math.max(1, p), totalPages.value)
if (next === props.page) return
emit('update:page', next)
emit('change', { page: next, size: props.size })
}
function onSizeChange(raw: string) {
const next = Number(raw) || props.size
if (next === props.size) return
emit('update:size', next)
// When the page size changes, snap back to page 1 so the user doesn't
// land on a now-out-of-range page.
if (props.page !== 1) emit('update:page', 1)
emit('change', { page: 1, size: next })
}
const jumpDraft = ref<string>(String(props.page))
watch(() => props.page, (p) => { jumpDraft.value = String(p) })
function commitJump() {
const target = Number(jumpDraft.value)
if (!Number.isFinite(target)) {
jumpDraft.value = String(props.page)
return
}
goTo(target)
// Reset draft to whatever goTo clamped it to.
jumpDraft.value = String(Math.min(Math.max(1, Math.floor(target)), totalPages.value))
}
</script>
<style scoped>
.mc-pager {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
padding: 8px 14px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.55);
backdrop-filter: blur(14px) saturate(1.1);
-webkit-backdrop-filter: blur(14px) saturate(1.1);
box-shadow: 0 1px 3px rgba(25, 14, 8, 0.04);
}
:global(html.dark .mc-pager) {
background: rgba(255, 255, 255, 0.06);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
.mc-pager-total {
font-size: 12px;
color: var(--mc-text-tertiary);
margin-right: 6px;
font-weight: 500;
}
/* Page-size pill: native <select> overlaid invisibly so the OS picker
* works for free, with a custom-styled label sitting on top. */
.mc-pager-size {
position: relative;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 5px 10px;
border-radius: 999px;
background: rgba(123, 88, 67, 0.07);
font-size: 12px;
font-weight: 500;
color: var(--mc-text-primary);
cursor: pointer;
transition: background 0.15s ease;
}
.mc-pager-size:hover {
background: rgba(123, 88, 67, 0.12);
}
:global(html.dark .mc-pager-size) {
background: rgba(255, 255, 255, 0.08);
}
:global(html.dark .mc-pager-size:hover) {
background: rgba(255, 255, 255, 0.14);
}
.mc-pager-size__native {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
border: 0;
background: transparent;
font: inherit;
}
.mc-pager-size__chev {
color: var(--mc-text-tertiary);
}
.mc-pager-arrow,
.mc-pager-num {
appearance: none;
border: 0;
background: transparent;
min-width: 28px;
height: 28px;
padding: 0 8px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
color: var(--mc-text-secondary);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition: background 0.15s ease, color 0.15s ease;
}
.mc-pager-arrow:hover:not(:disabled),
.mc-pager-num:hover:not(:disabled):not(.mc-pager-num--active) {
background: rgba(123, 88, 67, 0.10);
color: var(--mc-text-primary);
}
:global(html.dark .mc-pager-arrow:hover:not(:disabled)),
:global(html.dark .mc-pager-num:hover:not(:disabled):not(.mc-pager-num--active)) {
background: rgba(255, 255, 255, 0.10);
}
.mc-pager-arrow:disabled,
.mc-pager-num:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.mc-pager-num--active {
background: var(--mc-primary);
color: #fff;
cursor: default;
}
.mc-pager-num--gap {
color: var(--mc-text-tertiary);
cursor: default;
background: transparent;
}
.mc-pager-num--gap:hover {
background: transparent !important;
}
.mc-pager-jump {
display: inline-flex;
align-items: center;
gap: 5px;
margin-left: 4px;
font-size: 12px;
color: var(--mc-text-tertiary);
}
.mc-pager-jump__input {
width: 50px;
height: 26px;
padding: 0 6px;
border-radius: 8px;
border: 1px solid transparent;
background: rgba(123, 88, 67, 0.07);
text-align: center;
font-size: 12px;
color: var(--mc-text-primary);
outline: none;
transition: background 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
/* Strip native spinners — they're aesthetic noise. */
appearance: textfield;
-moz-appearance: textfield;
}
.mc-pager-jump__input::-webkit-outer-spin-button,
.mc-pager-jump__input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.mc-pager-jump__input:focus {
border-color: rgba(217, 119, 87, 0.4);
background: rgba(255, 255, 255, 0.7);
box-shadow: 0 0 0 3px rgba(217, 119, 87, 0.12);
}
:global(html.dark .mc-pager-jump__input) {
background: rgba(255, 255, 255, 0.08);
}
:global(html.dark .mc-pager-jump__input:focus) {
background: rgba(255, 255, 255, 0.14);
}
</style>

View File

@ -0,0 +1,62 @@
import { shallowRef } from 'vue'
/**
* MateClaw confirm dialog imperative API.
*
* Usage:
* const ok = await mcConfirm({
* title: '确认',
* message: '确定删除这个会话?此操作不可恢复。',
* tone: 'danger',
* })
* if (!ok) return
*
* Resolves with `true` on confirm, `false` on cancel / overlay click /
* Esc. Never rejects that pattern (which `ElMessageBox.confirm` uses)
* forces every caller to add a noop `.catch`, and turns ordinary user
* cancellation into a console-looking "error".
*/
export type ConfirmTone = 'default' | 'primary' | 'danger'
export interface ConfirmOptions {
/** Header text. Defaults to a generic "Confirm" string. */
title?: string
/** Body copy. Required — this is the question being asked. */
message: string
/** Confirm button label. Defaults to common.confirm. */
confirmText?: string
/** Cancel button label. Defaults to common.cancel. */
cancelText?: string
/** Visual tone of the icon + confirm button. */
tone?: ConfirmTone
}
export interface ActiveConfirm extends ConfirmOptions {
resolve: (ok: boolean) => void
}
/**
* Singleton slot. The host component watches this and renders one dialog
* at a time. We use `shallowRef` because the resolve fn is non-reactive
* and the options blob is replaced wholesale, never mutated.
*/
export const activeConfirm = shallowRef<ActiveConfirm | null>(null)
export function mcConfirm(opts: ConfirmOptions): Promise<boolean> {
return new Promise<boolean>((resolve) => {
// If a previous prompt is somehow still open (unlikely — clicks
// close it before resolving), cancel it so we don't leak a never-
// settled promise.
if (activeConfirm.value) {
activeConfirm.value.resolve(false)
}
activeConfirm.value = { ...opts, resolve }
})
}
export function resolveConfirm(ok: boolean) {
const current = activeConfirm.value
if (!current) return
activeConfirm.value = null
current.resolve(ok)
}

View File

@ -37,6 +37,14 @@ export default {
collapse: 'Collapse',
enable: 'Enable',
disable: 'Disable',
pager: {
total: '{n} total',
perPage: '{n}/page',
prev: 'Previous',
next: 'Next',
jumpTo: 'Go to',
pageSuffix: '',
},
},
auth: {
changePassword: 'Change Password',
@ -2012,6 +2020,7 @@ export default {
modal: {
configureTitle: 'Configure Skill',
newTitle: 'New Skill',
newSimpleHint: 'Just the bones — keep configuring icon, body, and tools in the detail panel after this.',
},
fields: {
name: 'Name',
@ -2062,6 +2071,9 @@ export default {
delete: 'Delete',
saveChanges: 'Save Changes',
createSkill: 'Create Skill',
edit: 'Edit',
save: 'Save',
cancel: 'Cancel',
},
preflight: {
title: 'Pre-flight check',
@ -2079,6 +2091,8 @@ export default {
},
detail: {
title: 'Skill detail',
overview: 'Overview',
body: 'Body',
manifest: 'Manifest',
tools: 'Tools',
features: 'Features',
@ -2090,6 +2104,20 @@ export default {
runtimeError: 'Runtime error',
path: 'Resolved path',
synthesized: 'Source',
identitySection: 'Identity',
manifestProjectedSection: 'From SKILL.md',
manifestProjectedHint: 'These fields mirror the SKILL.md frontmatter — editing the row directly does not stick (the next resolve overwrites it). Edit the SKILL.md body below to change them.',
displayOverridesSection: 'Display & tags',
displayOverridesHint: 'DB-only — these are not overwritten by the resolver.',
bodyHint: 'SKILL.md body — injected into the LLM system prompt at load time.',
sourceCodeHint: 'Executable body (dynamic skills only). Re-runs on next call after save.',
viewRawManifest: 'View parsed runtime manifest',
noBody: 'No SKILL.md body has been provided yet.',
builtinReadonly: 'Built-in skill: only nameZh / nameEn / tags / description / body are editable.',
virtualReadonly: 'Virtual MCP-derived skill — edit it on the MCP Connections page instead.',
editSource: 'Edit SKILL.md',
editingSource: 'Editing SKILL.md',
sourceSavedReprojection: 'Saved. The next resolve will sync icon/version/author and friends from the new frontmatter.',
noManifest: 'This skill does not declare a v3 manifest. Legacy fields apply.',
noTools: 'No allowed-tools declared by this skill. The LLM will fall back to the global tool set when this skill is bound to an agent.',
noFeatures: 'No features[] matrix declared. The skill is treated as a single default feature.',
@ -2116,6 +2144,7 @@ export default {
},
messages: {
saveFailed: 'Failed to save skill',
saveSuccess: 'Saved',
deleteConfirm: 'Are you sure you want to delete this skill? This cannot be undone.',
deleteTitle: 'Confirm Delete',
deleteFailed: 'Failed to delete skill',

View File

@ -37,6 +37,14 @@ export default {
disable: '停用',
refresh: '刷新',
collapse: '收起',
pager: {
total: '共 {n} 条',
perPage: '{n} 条/页',
prev: '上一页',
next: '下一页',
jumpTo: '前往',
pageSuffix: '页',
},
},
auth: {
changePassword: '修改密码',
@ -2014,6 +2022,7 @@ export default {
modal: {
configureTitle: '配置技能',
newTitle: '新建技能',
newSimpleHint: '先建好骨架,下一步在详情面板里继续配置图标、正文和工具。',
},
fields: {
name: '名称',
@ -2064,6 +2073,9 @@ export default {
delete: '删除',
saveChanges: '保存更改',
createSkill: '创建技能',
edit: '编辑',
save: '保存',
cancel: '取消',
},
preflight: {
title: '装前检查',
@ -2081,6 +2093,8 @@ export default {
},
detail: {
title: '技能详情',
overview: '概览',
body: '正文',
manifest: 'Manifest',
tools: '工具',
features: '特性',
@ -2092,6 +2106,20 @@ export default {
runtimeError: '运行时错误',
path: '解析路径',
synthesized: '来源',
identitySection: '身份',
manifestProjectedSection: '来自 SKILL.md',
manifestProjectedHint: '这些字段是 SKILL.md frontmatter 的镜像 — 直接改 row 不持久(下次解析时被覆盖)。要改请编辑下方 SKILL.md 正文。',
displayOverridesSection: '显示与标签',
displayOverridesHint: '仅存于数据库,不会被解析器覆盖。',
bodyHint: 'SKILL.md 正文 — 部署后注入到 LLM 的系统提示。',
sourceCodeHint: '执行体(仅 dynamic 类型)。修改后下次调用立即生效。',
viewRawManifest: '查看解析后的运行时 manifest',
noBody: '尚未提供 SKILL.md 正文。',
builtinReadonly: '内置技能:仅 nameZh / nameEn / 标签 / description / 正文可改。',
virtualReadonly: 'MCP 派生的虚拟技能不可在此编辑,请去 MCP 连接页修改。',
editSource: '编辑 SKILL.md',
editingSource: '编辑 SKILL.md',
sourceSavedReprojection: '已保存。下次解析将根据新 frontmatter 同步 icon/version/author 等字段。',
noManifest: '该技能未声明 v3 manifest使用旧字段。',
noTools: '该 skill 未声明 allowed-tools。绑定到 agent 时 LLM 将回退到全局工具集。',
noFeatures: '未声明 features[] 矩阵,技能被视为单一默认特性。',
@ -2118,6 +2146,7 @@ export default {
},
messages: {
saveFailed: '保存技能失败',
saveSuccess: '已保存',
deleteConfirm: '确定要删除这个技能吗?此操作不可撤销。',
deleteTitle: '确认删除',
deleteFailed: '删除技能失败',

View File

@ -300,7 +300,8 @@
import { ref, computed, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ElMessage } from 'element-plus'
import { mcConfirm } from '@/components/common/useConfirm'
import { ChatDotRound, Delete, Plus, Setting, UploadFilled } from '@element-plus/icons-vue'
import { conversationApi, agentApi, modelApi, chatApi, cronJobApi } from '@/api/index'
import { channelIconUrl } from '@/utils/channelSource'
@ -463,12 +464,13 @@ function cancelRename() {
}
// Delete with confirmation
function confirmDeleteConversation(conversationId: string) {
ElMessageBox.confirm(
t('chat.deleteConfirm') || 'Delete this conversation?',
t('common.confirm'),
{ type: 'warning', confirmButtonText: t('common.confirm'), cancelButtonText: t('common.cancel') }
).then(() => deleteConversation(conversationId)).catch(() => {})
async function confirmDeleteConversation(conversationId: string) {
const ok = await mcConfirm({
title: t('common.confirm'),
message: t('chat.deleteConfirm') || 'Delete this conversation?',
tone: 'danger',
})
if (ok) deleteConversation(conversationId)
}
//

View File

@ -98,21 +98,16 @@
</template>
</div>
<!-- Element Plus pagination. We use a single guard:
total > pageSize on the wrapper so EP itself doesn't have
to negotiate hide-on-single-page (which in EP 2.9.x can
return null and leave the .pagination wrapper empty). -->
<!-- MateClaw frosted-pill pagination replaces el-pagination
so the activity feed reads in the same visual language as
the rest of the app. -->
<div v-if="total > pageSize" class="pagination">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
<McPagination
v-model:page="page"
v-model:size="pageSize"
:total="total"
:page-sizes="[20, 50, 100]"
:small="isMobile"
background
:layout="paginationLayout"
@size-change="onPageSizeChange"
@current-change="loadEvents"
:sizes="[20, 50, 100]"
@change="loadEvents"
/>
</div>
</div>
@ -209,6 +204,7 @@
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { activityApi } from '@/api'
import McPagination from '@/components/common/McPagination.vue'
const { t } = useI18n()
@ -241,9 +237,6 @@ function syncMobile(e: MediaQueryListEvent | MediaQueryList) {
}
const drawerSize = computed(() => isMobile.value ? '100%' : '720px')
const paginationLayout = computed(() =>
isMobile.value ? 'prev, pager, next' : 'total, sizes, prev, pager, next, jumper',
)
const events = ref<ActivityEvent[]>([])
const loading = ref(false)
@ -400,15 +393,6 @@ async function loadEvents() {
}
}
function onPageSizeChange(newSize: number) {
pageSize.value = newSize
// Switching page size resets to page 1 Element Plus emits both
// size-change AND current-change, but order isn't guaranteed; we
// pin page=1 here so the request never fires with a stale offset.
page.value = 1
loadEvents()
}
function filterEventsLocally() { /* trigger recompute via reactive filter */ }
function openDetail(event: ActivityEvent) {

File diff suppressed because it is too large Load Diff