mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(ui): MateClaw skill detail drawer + shared McPagination / McConfirm components
This commit is contained in:
parent
f8069b1b5d
commit
9c6b728704
@ -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()
|
||||
|
||||
247
mateclaw-ui/src/components/common/McConfirmHost.vue
Normal file
247
mateclaw-ui/src/components/common/McConfirmHost.vue
Normal 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>
|
||||
322
mateclaw-ui/src/components/common/McPagination.vue
Normal file
322
mateclaw-ui/src/components/common/McPagination.vue
Normal 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>
|
||||
62
mateclaw-ui/src/components/common/useConfirm.ts
Normal file
62
mateclaw-ui/src/components/common/useConfirm.ts
Normal 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)
|
||||
}
|
||||
@ -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',
|
||||
|
||||
@ -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: '删除技能失败',
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
// 拖拽上传
|
||||
|
||||
@ -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
Loading…
Reference in New Issue
Block a user