feat(skill-market): paginated skill list with search and frosted-glass UI

This commit is contained in:
matevip 2026-04-24 06:55:19 +08:00
parent 1073890e64
commit aa6e2b6afe
7 changed files with 275 additions and 25 deletions

View File

@ -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 = "获取已启用技能列表")

View File

@ -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>

View File

@ -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),

View File

@ -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',

View File

@ -1616,6 +1616,14 @@ export default {
mcp: 'MCP',
dynamic: '动态',
},
search: {
placeholder: '搜索技能名称、描述或标签...',
},
filter: {
all: '全部状态',
enabled: '已启用',
disabled: '已禁用',
},
empty: '暂无技能',
emptyDesc: '添加技能以增强 Agent 的能力',
noDescription: '暂无描述',

View File

@ -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),

View File

@ -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; }