mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(skill-market): paginated skill list with search and frosted-glass UI
This commit is contained in:
parent
1073890e64
commit
aa6e2b6afe
@ -1,5 +1,6 @@
|
||||
package vip.mate.skill.controller;
|
||||
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@ -34,10 +35,21 @@ public class SkillController {
|
||||
private final SkillWorkspaceManager workspaceManager;
|
||||
private final SkillSynthesisService synthesisService;
|
||||
|
||||
@Operation(summary = "获取技能列表")
|
||||
@Operation(summary = "获取技能分页列表(RFC-042 §2.1)")
|
||||
@GetMapping
|
||||
public R<List<SkillEntity>> list() {
|
||||
return R.ok(skillService.listSkills());
|
||||
public R<IPage<SkillEntity>> list(
|
||||
@RequestParam(defaultValue = "1") int page,
|
||||
@RequestParam(defaultValue = "20") int size,
|
||||
@RequestParam(required = false) String keyword,
|
||||
@RequestParam(required = false) String skillType,
|
||||
@RequestParam(required = false) Boolean enabled) {
|
||||
return R.ok(skillService.pageSkills(page, size, keyword, skillType, enabled));
|
||||
}
|
||||
|
||||
@Operation(summary = "获取各类型技能计数(tab 徽章用)")
|
||||
@GetMapping("/counts")
|
||||
public R<Map<String, Long>> counts() {
|
||||
return R.ok(skillService.countByType());
|
||||
}
|
||||
|
||||
@Operation(summary = "获取已启用技能列表")
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
package vip.mate.skill.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
@ -12,6 +14,7 @@ import vip.mate.skill.workspace.SkillWorkspaceProperties;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
@ -58,6 +61,53 @@ public class SkillService {
|
||||
.orderByDesc(SkillEntity::getCreateTime));
|
||||
}
|
||||
|
||||
/**
|
||||
* Paginated skill listing for the SkillMarket admin UI.
|
||||
*
|
||||
* <p>RFC-042 §2.1 — replaces the unbounded {@code /skills} list. Filters
|
||||
* are all optional; empty or {@code null} means "no filter". Keyword
|
||||
* searches name / description / tags with LIKE.
|
||||
*/
|
||||
public IPage<SkillEntity> pageSkills(int page, int size, String keyword,
|
||||
String skillType, Boolean enabled) {
|
||||
Page<SkillEntity> pageParam = new Page<>(Math.max(page, 1), Math.max(size, 1));
|
||||
LambdaQueryWrapper<SkillEntity> wrapper = new LambdaQueryWrapper<>();
|
||||
|
||||
if (keyword != null && !keyword.isBlank()) {
|
||||
String kw = keyword.trim();
|
||||
wrapper.and(w -> w
|
||||
.like(SkillEntity::getName, kw)
|
||||
.or().like(SkillEntity::getDescription, kw)
|
||||
.or().like(SkillEntity::getTags, kw));
|
||||
}
|
||||
if (skillType != null && !skillType.isBlank()) {
|
||||
wrapper.eq(SkillEntity::getSkillType, skillType);
|
||||
}
|
||||
if (enabled != null) {
|
||||
wrapper.eq(SkillEntity::getEnabled, enabled);
|
||||
}
|
||||
|
||||
wrapper.orderByDesc(SkillEntity::getBuiltin)
|
||||
.orderByDesc(SkillEntity::getCreateTime);
|
||||
|
||||
return skillMapper.selectPage(pageParam, wrapper);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate skill counts per {@code skill_type}, plus an {@code all}
|
||||
* rollup. Feeds the SkillMarket tab badges without pulling every row.
|
||||
*/
|
||||
public Map<String, Long> countByType() {
|
||||
Map<String, Long> result = new LinkedHashMap<>();
|
||||
result.put("all", skillMapper.selectCount(null));
|
||||
for (String type : List.of("builtin", "mcp", "dynamic")) {
|
||||
result.put(type, skillMapper.selectCount(
|
||||
new LambdaQueryWrapper<SkillEntity>()
|
||||
.eq(SkillEntity::getSkillType, type)));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取已启用的技能列表(Agent 运行时使用)
|
||||
* <p>
|
||||
|
||||
@ -144,7 +144,16 @@ export const conversationApi = {
|
||||
|
||||
// ==================== Skill ====================
|
||||
export const skillApi = {
|
||||
list: () => http.get('/skills'),
|
||||
/** RFC-042 §2.1 — paginated skill listing with search/type/enabled filters */
|
||||
page: (params: {
|
||||
page?: number
|
||||
size?: number
|
||||
keyword?: string
|
||||
skillType?: string
|
||||
enabled?: boolean
|
||||
} = {}) => http.get('/skills', { params }),
|
||||
/** Tab count aggregate — returns { all, builtin, mcp, dynamic } */
|
||||
counts: () => http.get('/skills/counts'),
|
||||
listEnabled: () => http.get('/skills/enabled'),
|
||||
get: (id: string | number) => http.get(`/skills/${id}`),
|
||||
create: (data: any) => http.post('/skills', data),
|
||||
|
||||
@ -1606,6 +1606,14 @@ export default {
|
||||
mcp: 'MCP',
|
||||
dynamic: 'Dynamic',
|
||||
},
|
||||
search: {
|
||||
placeholder: 'Search by name, description, or tags...',
|
||||
},
|
||||
filter: {
|
||||
all: 'All statuses',
|
||||
enabled: 'Enabled',
|
||||
disabled: 'Disabled',
|
||||
},
|
||||
empty: 'No skills found',
|
||||
emptyDesc: 'Add skills to enhance your agents\' capabilities',
|
||||
noDescription: 'No description',
|
||||
|
||||
@ -1616,6 +1616,14 @@ export default {
|
||||
mcp: 'MCP',
|
||||
dynamic: '动态',
|
||||
},
|
||||
search: {
|
||||
placeholder: '搜索技能名称、描述或标签...',
|
||||
},
|
||||
filter: {
|
||||
all: '全部状态',
|
||||
enabled: '已启用',
|
||||
disabled: '已禁用',
|
||||
},
|
||||
empty: '暂无技能',
|
||||
emptyDesc: '添加技能以增强 Agent 的能力',
|
||||
noDescription: '暂无描述',
|
||||
|
||||
@ -488,7 +488,9 @@ async function openEditModal(agent: Agent) {
|
||||
// Load available skills/tools/providers and current bindings in parallel
|
||||
try {
|
||||
const [skillsRes, toolsRes, providersRes, boundSkillsRes, boundToolsRes, providerPrefsRes] = await Promise.all([
|
||||
skillApi.list(),
|
||||
// RFC-042: /skills is now paginated; binding dropdown only needs enabled skills,
|
||||
// so listEnabled() is both semantically correct and shape-stable (returns array).
|
||||
skillApi.listEnabled(),
|
||||
toolApi.list(),
|
||||
modelApi.listProviders(),
|
||||
agentBindingApi.listSkills(agent.id),
|
||||
|
||||
@ -34,16 +34,31 @@
|
||||
<!-- 分类 Tab -->
|
||||
<div class="category-tabs mc-surface-card">
|
||||
<button v-for="tab in categoryTabs" :key="tab.value" class="cat-tab"
|
||||
:class="{ active: activeCategory === tab.value }" @click="activeCategory = tab.value">
|
||||
:class="{ active: query.skillType === tab.value }" @click="onTabChange(tab.value)">
|
||||
<span class="cat-icon">{{ tab.icon }}</span>
|
||||
{{ tab.label }}
|
||||
<span class="cat-count">{{ getCategoryCount(tab.value) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Search + status filter (RFC-042 §2.1) -->
|
||||
<div class="skill-filter-bar mc-surface-card">
|
||||
<input
|
||||
v-model="query.keyword"
|
||||
class="skill-search-input"
|
||||
type="search"
|
||||
:placeholder="t('skills.search.placeholder')"
|
||||
/>
|
||||
<select v-model="query.enabledFilter" class="skill-status-filter" @change="onFilterChange">
|
||||
<option value="">{{ t('skills.filter.all') }}</option>
|
||||
<option value="true">{{ t('skills.filter.enabled') }}</option>
|
||||
<option value="false">{{ t('skills.filter.disabled') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 技能列表 -->
|
||||
<div class="skill-grid" v-if="filteredSkills.length > 0">
|
||||
<div v-for="skill in filteredSkills" :key="skill.id" class="skill-card mc-surface-card"
|
||||
<div class="skill-grid" v-if="skills.length > 0">
|
||||
<div v-for="skill in skills" :key="skill.id" class="skill-card mc-surface-card"
|
||||
:class="{ disabled: !skill.enabled }">
|
||||
<div class="skill-header">
|
||||
<div class="skill-icon-wrap" :class="getSkillIconBg(skill.skillType)">
|
||||
@ -132,6 +147,20 @@
|
||||
<h3>{{ t('skills.empty') }}</h3>
|
||||
<p>{{ t('skills.emptyDesc') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Pagination (RFC-042 §2.1) -->
|
||||
<el-pagination
|
||||
v-if="total > 0"
|
||||
class="skill-pagination"
|
||||
v-model:current-page="query.page"
|
||||
v-model:page-size="query.size"
|
||||
:page-sizes="[12, 24, 48]"
|
||||
:total="total"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
background
|
||||
@size-change="onPageSizeChange"
|
||||
@current-change="loadSkills"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -222,7 +251,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, reactive, computed, onMounted, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { skillApi } from '@/api/index'
|
||||
@ -231,13 +260,24 @@ import ImportHubDialog from '@/components/skill/ImportHubDialog.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const skills = ref<Skill[]>([])
|
||||
const total = ref(0)
|
||||
const counts = ref<Record<string, number>>({})
|
||||
const runtimeStatusMap = ref<Record<string, SkillRuntimeStatus>>({})
|
||||
const activeCategory = ref('all')
|
||||
const showModal = ref(false)
|
||||
const editingSkill = ref<Skill | null>(null)
|
||||
const refreshing = ref(false)
|
||||
const showImportDialog = ref(false)
|
||||
|
||||
/** Paginated query state — RFC-042 §2.1 */
|
||||
const query = reactive({
|
||||
page: 1,
|
||||
size: 24,
|
||||
keyword: '',
|
||||
skillType: 'all' as string,
|
||||
/** '' = all, 'true' = enabled only, 'false' = disabled only (string to avoid tri-state checkbox quirks) */
|
||||
enabledFilter: '' as string,
|
||||
})
|
||||
|
||||
const categoryTabs = computed(() => [
|
||||
{ label: t('skills.tabs.all'), value: 'all', icon: '🗂️' },
|
||||
{ label: t('skills.tabs.builtin'), value: 'builtin', icon: '🔧' },
|
||||
@ -263,14 +303,8 @@ const form = ref<any>(defaultForm())
|
||||
/** 是否正在编辑内置技能(限制可编辑字段) */
|
||||
const isBuiltinEditing = computed(() => editingSkill.value?.skillType === 'builtin')
|
||||
|
||||
const filteredSkills = computed(() => {
|
||||
if (activeCategory.value === 'all') return skills.value
|
||||
return skills.value.filter(s => s.skillType === activeCategory.value)
|
||||
})
|
||||
|
||||
function getCategoryCount(category: string) {
|
||||
if (category === 'all') return skills.value.length
|
||||
return skills.value.filter(s => s.skillType === category).length
|
||||
return counts.value[category] ?? 0
|
||||
}
|
||||
|
||||
function parseTags(tags: string): string[] {
|
||||
@ -281,20 +315,58 @@ function parseTags(tags: string): string[] {
|
||||
onMounted(loadAll)
|
||||
|
||||
async function loadAll() {
|
||||
await Promise.all([loadSkills(), loadRuntimeStatus()])
|
||||
await Promise.all([loadSkills(), loadCounts(), loadRuntimeStatus()])
|
||||
}
|
||||
|
||||
/** Coalesce keyword edits into one server call per 300ms so typing doesn't thrash. */
|
||||
let searchDebounce: ReturnType<typeof setTimeout> | null = null
|
||||
watch(() => query.keyword, () => {
|
||||
if (searchDebounce) clearTimeout(searchDebounce)
|
||||
searchDebounce = setTimeout(() => {
|
||||
query.page = 1
|
||||
loadSkills()
|
||||
}, 300)
|
||||
})
|
||||
|
||||
function onTabChange(tab: string) {
|
||||
query.skillType = tab
|
||||
query.page = 1
|
||||
loadSkills()
|
||||
}
|
||||
|
||||
function onFilterChange() {
|
||||
query.page = 1
|
||||
loadSkills()
|
||||
}
|
||||
|
||||
function onPageSizeChange() {
|
||||
query.page = 1
|
||||
loadSkills()
|
||||
}
|
||||
|
||||
async function loadSkills() {
|
||||
try {
|
||||
const res: any = await skillApi.list()
|
||||
skills.value = [...(res.data || [])].sort((a: Skill, b: Skill) => {
|
||||
if (Boolean(b.builtin) !== Boolean(a.builtin)) {
|
||||
return Number(Boolean(b.builtin)) - Number(Boolean(a.builtin))
|
||||
}
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
const params: Record<string, unknown> = { page: query.page, size: query.size }
|
||||
if (query.keyword) params.keyword = query.keyword.trim()
|
||||
if (query.skillType && query.skillType !== 'all') params.skillType = query.skillType
|
||||
if (query.enabledFilter !== '') params.enabled = query.enabledFilter === 'true'
|
||||
|
||||
const res: any = await skillApi.page(params)
|
||||
const data = res.data || {}
|
||||
skills.value = data.records || []
|
||||
total.value = data.total || 0
|
||||
} catch (e) {
|
||||
skills.value = []
|
||||
total.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCounts() {
|
||||
try {
|
||||
const res: any = await skillApi.counts()
|
||||
counts.value = res.data || {}
|
||||
} catch (e) {
|
||||
counts.value = {}
|
||||
}
|
||||
}
|
||||
|
||||
@ -538,6 +610,95 @@ function getSkillTypeLabel(type: string) {
|
||||
.cat-count { background: var(--mc-bg-sunken); color: var(--mc-text-secondary); padding: 1px 6px; border-radius: 10px; font-size: 11px; }
|
||||
.cat-tab.active .cat-count { background: rgba(217, 119, 87, 0.2); color: var(--mc-primary); }
|
||||
|
||||
/* RFC-042 §2.1 — search + status filter bar (frosted glass) */
|
||||
.skill-filter-bar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
align-items: center;
|
||||
backdrop-filter: blur(14px) saturate(1.1);
|
||||
-webkit-backdrop-filter: blur(14px) saturate(1.1);
|
||||
}
|
||||
.skill-search-input,
|
||||
.skill-status-filter {
|
||||
height: 34px;
|
||||
border: 1px solid transparent;
|
||||
background: rgba(255, 255, 255, 0.45);
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
color: var(--mc-text-primary);
|
||||
outline: none;
|
||||
transition: background 0.18s, border-color 0.18s, box-shadow 0.18s;
|
||||
}
|
||||
html.dark .skill-search-input,
|
||||
html.dark .skill-status-filter {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.skill-search-input { flex: 1; padding: 0 12px; }
|
||||
.skill-status-filter { padding: 0 10px; cursor: pointer; min-width: 140px; }
|
||||
.skill-search-input:hover,
|
||||
.skill-status-filter:hover {
|
||||
background: rgba(255, 255, 255, 0.65);
|
||||
}
|
||||
html.dark .skill-search-input:hover,
|
||||
html.dark .skill-status-filter:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.skill-search-input:focus,
|
||||
.skill-status-filter:focus {
|
||||
border-color: rgba(217, 119, 87, 0.45);
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
box-shadow: 0 0 0 3px rgba(217, 119, 87, 0.12);
|
||||
}
|
||||
html.dark .skill-search-input:focus,
|
||||
html.dark .skill-status-filter:focus {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
/* Pagination — strip Element Plus's heavy boxed look, blend with the glass surface */
|
||||
.skill-pagination { margin-top: 18px; display: flex; justify-content: center; }
|
||||
.skill-pagination :deep(.el-pagination) {
|
||||
--el-pagination-bg-color: transparent;
|
||||
--el-pagination-button-bg-color: transparent;
|
||||
--el-pagination-hover-color: var(--mc-primary);
|
||||
background: transparent;
|
||||
font-weight: 500;
|
||||
color: var(--mc-text-secondary);
|
||||
}
|
||||
.skill-pagination :deep(.el-pagination .btn-prev),
|
||||
.skill-pagination :deep(.el-pagination .btn-next),
|
||||
.skill-pagination :deep(.el-pagination .el-pager li),
|
||||
.skill-pagination :deep(.el-pagination .el-input__wrapper),
|
||||
.skill-pagination :deep(.el-pagination .el-select .el-input__wrapper) {
|
||||
background: rgba(255, 255, 255, 0.45) !important;
|
||||
box-shadow: none !important;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
html.dark .skill-pagination :deep(.el-pagination .btn-prev),
|
||||
html.dark .skill-pagination :deep(.el-pagination .btn-next),
|
||||
html.dark .skill-pagination :deep(.el-pagination .el-pager li),
|
||||
html.dark .skill-pagination :deep(.el-pagination .el-input__wrapper),
|
||||
html.dark .skill-pagination :deep(.el-pagination .el-select .el-input__wrapper) {
|
||||
background: rgba(255, 255, 255, 0.06) !important;
|
||||
}
|
||||
.skill-pagination :deep(.el-pagination .btn-prev:hover),
|
||||
.skill-pagination :deep(.el-pagination .btn-next:hover),
|
||||
.skill-pagination :deep(.el-pagination .el-pager li:hover) {
|
||||
background: rgba(217, 119, 87, 0.12) !important;
|
||||
color: var(--mc-primary);
|
||||
}
|
||||
.skill-pagination :deep(.el-pagination .el-pager li.is-active) {
|
||||
background: var(--mc-primary) !important;
|
||||
color: #fff;
|
||||
border-color: transparent;
|
||||
}
|
||||
.skill-pagination :deep(.el-pagination .el-pagination__total),
|
||||
.skill-pagination :deep(.el-pagination .el-pagination__sizes) {
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
/* 技能网格 */
|
||||
.skill-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 18px; }
|
||||
.skill-card { padding: 18px; transition: all 0.15s; display: flex; flex-direction: column; min-height: 280px; }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user