diff --git a/docs/02-domain-model.md b/docs/02-domain-model.md index 61a82975..20e31621 100644 --- a/docs/02-domain-model.md +++ b/docs/02-domain-model.md @@ -359,7 +359,7 @@ | title | varchar(256) | | | summary | varchar(512) | | | keywords | varchar(512) | | -| search_text | text | SKILL.md 正文 + frontmatter 拼接 | +| search_text | text | `displayName`、`slug`、`summary`,以及 frontmatter 中除 `name` / `description` / `version` 外的字段展开结果 | | visibility | enum | 冗余,避免搜索时 join | | status | enum | | | updated_at | datetime | | diff --git a/docs/04-search-architecture.md b/docs/04-search-architecture.md index 4b38a3eb..e40d6da4 100644 --- a/docs/04-search-architecture.md +++ b/docs/04-search-architecture.md @@ -68,7 +68,7 @@ WHERE (visibility = 'PUBLIC') | title | varchar(256) | | | summary | varchar(512) | | | keywords | varchar(512) | | -| search_text | text | SKILL.md 正文 + frontmatter 拼接 | +| search_text | text | `displayName`、`slug`、`summary`,以及 frontmatter 中除 `name` / `description` / `version` 外的字段展开结果 | | visibility | enum | 冗余,避免搜索时 join | | status | enum | | | updated_at | datetime | | diff --git a/document/docs/03-user-guide/discovery/search.md b/document/docs/03-user-guide/discovery/search.md index 6a457ff9..01da8f73 100644 --- a/document/docs/03-user-guide/discovery/search.md +++ b/document/docs/03-user-guide/discovery/search.md @@ -11,8 +11,9 @@ description: 搜索和筛选技能 在搜索框输入关键词,SkillHub 会在以下字段中搜索: - 技能名称 - 技能描述 -- SKILL.md 正文内容 -- 关键词 +- 技能 slug +- frontmatter 中除 `name`、`description`、`version` 外的其他字段 +- `keywords` / `tags` 等关键词字段 ## 筛选条件 diff --git a/document/i18n/en/docusaurus-plugin-content-docs/current/03-user-guide/discovery/search.md b/document/i18n/en/docusaurus-plugin-content-docs/current/03-user-guide/discovery/search.md index f0c1b3d4..825ef714 100644 --- a/document/i18n/en/docusaurus-plugin-content-docs/current/03-user-guide/discovery/search.md +++ b/document/i18n/en/docusaurus-plugin-content-docs/current/03-user-guide/discovery/search.md @@ -11,8 +11,9 @@ description: Search and filter skills Enter keywords in the search box, SkillHub searches in the following fields: - Skill name - Skill description -- SKILL.md body content -- Keywords +- Skill slug +- Frontmatter fields other than `name`, `description`, and `version` +- Keyword-style fields such as `keywords` and `tags` ## Filter Conditions diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSearchController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSearchController.java new file mode 100644 index 00000000..b6dc264a --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSearchController.java @@ -0,0 +1,52 @@ +package com.iflytek.skillhub.controller.admin; + +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.controller.BaseApiController; +import com.iflytek.skillhub.dto.ApiResponse; +import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.domain.audit.AuditLogService; +import jakarta.servlet.http.HttpServletRequest; +import org.slf4j.MDC; +import com.iflytek.skillhub.search.SearchRebuildService; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Administrative maintenance endpoints for search-index operations reserved for super administrators. + */ +@RestController +@RequestMapping("/api/v1/admin/search") +public class AdminSearchController extends BaseApiController { + + private final SearchRebuildService searchRebuildService; + private final AuditLogService auditLogService; + + public AdminSearchController(ApiResponseFactory responseFactory, + SearchRebuildService searchRebuildService, + AuditLogService auditLogService) { + super(responseFactory); + this.searchRebuildService = searchRebuildService; + this.auditLogService = auditLogService; + } + + @PostMapping("/rebuild") + @PreAuthorize("hasRole('SUPER_ADMIN')") + public ApiResponse rebuildAll(@AuthenticationPrincipal PlatformPrincipal principal, + HttpServletRequest httpRequest) { + searchRebuildService.rebuildAll(); + auditLogService.record( + principal.userId(), + "REBUILD_SEARCH_INDEX", + "SEARCH_INDEX", + null, + MDC.get("requestId"), + httpRequest.getRemoteAddr(), + httpRequest.getHeader("User-Agent"), + "{\"scope\":\"ALL\"}" + ); + return ok("response.success.updated", null); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AdminSearchControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AdminSearchControllerTest.java new file mode 100644 index 00000000..bc281a81 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AdminSearchControllerTest.java @@ -0,0 +1,82 @@ +package com.iflytek.skillhub.controller.admin; + +import com.iflytek.skillhub.auth.device.DeviceAuthService; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.search.SearchRebuildService; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import static org.mockito.Mockito.verify; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class AdminSearchControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private SearchRebuildService searchRebuildService; + + @MockBean + private AuditLogService auditLogService; + + @MockBean + private NamespaceMemberRepository namespaceMemberRepository; + + @MockBean + private DeviceAuthService deviceAuthService; + + @Test + void rebuildAll_returnsOkForSuperAdmin() throws Exception { + PlatformPrincipal principal = new PlatformPrincipal("admin", "admin", "a@example.com", "", "github", Set.of("SUPER_ADMIN")); + var auth = new UsernamePasswordAuthenticationToken(principal, null, List.of(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN"))); + + mockMvc.perform(post("/api/v1/admin/search/rebuild") + .with(authentication(auth)) + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + + verify(searchRebuildService).rebuildAll(); + verify(auditLogService).record( + org.mockito.ArgumentMatchers.eq("admin"), + org.mockito.ArgumentMatchers.eq("REBUILD_SEARCH_INDEX"), + org.mockito.ArgumentMatchers.eq("SEARCH_INDEX"), + org.mockito.ArgumentMatchers.isNull(), + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.eq("{\"scope\":\"ALL\"}") + ); + } + + @Test + void rebuildAll_returnsForbiddenForSkillAdmin() throws Exception { + PlatformPrincipal principal = new PlatformPrincipal("admin", "admin", "a@example.com", "", "github", Set.of("SKILL_ADMIN")); + var auth = new UsernamePasswordAuthenticationToken(principal, null, List.of(new SimpleGrantedAuthority("ROLE_SKILL_ADMIN"))); + + mockMvc.perform(post("/api/v1/admin/search/rebuild") + .with(authentication(auth)) + .with(csrf())) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(403)); + } +} diff --git a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresSearchRebuildService.java b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresSearchRebuildService.java index 1841a36a..c5d13f82 100644 --- a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresSearchRebuildService.java +++ b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresSearchRebuildService.java @@ -1,35 +1,54 @@ package com.iflytek.skillhub.search.postgres; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.skill.Skill; import com.iflytek.skillhub.domain.skill.SkillRepository; import com.iflytek.skillhub.domain.skill.SkillStatus; +import com.iflytek.skillhub.domain.skill.SkillVersion; +import com.iflytek.skillhub.domain.skill.SkillVersionRepository; import com.iflytek.skillhub.search.SearchIndexService; import com.iflytek.skillhub.search.SearchRebuildService; import com.iflytek.skillhub.search.SkillSearchDocument; import org.springframework.stereotype.Service; +import java.util.ArrayList; +import java.util.Collection; import java.util.List; +import java.util.Map; +import java.util.Objects; import java.util.Optional; +import java.util.Set; +import java.util.TreeSet; +import java.util.stream.Collectors; /** * Reconstructs PostgreSQL search documents from canonical skill and namespace records. */ @Service public class PostgresSearchRebuildService implements SearchRebuildService { + private static final Set RESERVED_FRONTMATTER_FIELDS = Set.of("name", "description", "version"); + private static final Set KEYWORD_FIELD_NAMES = Set.of("keywords", "keyword", "tags", "tag"); + private static final TypeReference> MAP_TYPE = new TypeReference<>() {}; private final SkillRepository skillRepository; private final NamespaceRepository namespaceRepository; + private final SkillVersionRepository skillVersionRepository; private final SearchIndexService searchIndexService; + private final ObjectMapper objectMapper; public PostgresSearchRebuildService( SkillRepository skillRepository, NamespaceRepository namespaceRepository, + SkillVersionRepository skillVersionRepository, SearchIndexService searchIndexService) { this.skillRepository = skillRepository; this.namespaceRepository = namespaceRepository; + this.skillVersionRepository = skillVersionRepository; this.searchIndexService = searchIndexService; + this.objectMapper = new ObjectMapper(); } @Override @@ -61,16 +80,112 @@ public class PostgresSearchRebuildService implements SearchRebuildService { toDocument(skillOpt.get()).ifPresent(searchIndexService::index); } - private String buildSearchText(Skill skill) { - StringBuilder sb = new StringBuilder(); - if (skill.getDisplayName() != null) { - sb.append(skill.getDisplayName()).append(" "); + private SearchIndexPayload buildSearchPayload(Skill skill) { + List searchParts = new ArrayList<>(); + addPart(searchParts, skill.getDisplayName()); + addPart(searchParts, skill.getSlug()); + addPart(searchParts, skill.getSummary()); + + Set keywords = new TreeSet<>(); + resolveLatestVersion(skill) + .map(this::extractParsedMetadata) + .map(metadata -> metadata.get("frontmatter")) + .map(this::asMap) + .ifPresent(frontmatter -> appendFrontmatter(frontmatter, keywords, searchParts)); + + return new SearchIndexPayload( + String.join(", ", keywords), + String.join(" ", searchParts).trim() + ); + } + + private Optional resolveLatestVersion(Skill skill) { + if (skill.getLatestVersionId() == null) { + return Optional.empty(); } - sb.append(skill.getSlug()).append(" "); - if (skill.getSummary() != null) { - sb.append(skill.getSummary()).append(" "); + return skillVersionRepository.findById(skill.getLatestVersionId()); + } + + private Map extractParsedMetadata(SkillVersion version) { + String metadataJson = version.getParsedMetadataJson(); + if (metadataJson == null || metadataJson.isBlank()) { + return Map.of(); + } + try { + return objectMapper.readValue(metadataJson, MAP_TYPE); + } catch (Exception e) { + return Map.of(); + } + } + + @SuppressWarnings("unchecked") + private Map asMap(Object value) { + if (value instanceof Map map) { + return map.entrySet().stream() + .filter(entry -> entry.getKey() != null) + .collect(Collectors.toMap(entry -> String.valueOf(entry.getKey()), Map.Entry::getValue)); + } + return Map.of(); + } + + private void appendFrontmatter(Map frontmatter, Set keywords, List searchParts) { + for (Map.Entry entry : frontmatter.entrySet()) { + String fieldName = entry.getKey(); + Object value = entry.getValue(); + if (value == null) { + continue; + } + + if (KEYWORD_FIELD_NAMES.contains(fieldName.toLowerCase())) { + flattenToStrings(value).forEach(keyword -> { + String normalized = keyword.trim(); + if (!normalized.isBlank()) { + keywords.add(normalized); + } + }); + } + + if (!RESERVED_FRONTMATTER_FIELDS.contains(fieldName.toLowerCase())) { + addPart(searchParts, fieldName); + flattenToStrings(value).forEach(text -> addPart(searchParts, text)); + } + } + } + + private List flattenToStrings(Object value) { + if (value instanceof String text) { + return List.of(text); + } + if (value instanceof Number || value instanceof Boolean) { + return List.of(String.valueOf(value)); + } + if (value instanceof Map map) { + List values = new ArrayList<>(); + for (Map.Entry entry : map.entrySet()) { + if (entry.getKey() != null) { + values.add(String.valueOf(entry.getKey())); + } + values.addAll(flattenToStrings(entry.getValue())); + } + return values; + } + if (value instanceof Collection collection) { + return collection.stream() + .filter(Objects::nonNull) + .flatMap(item -> flattenToStrings(item).stream()) + .toList(); + } + return List.of(String.valueOf(value)); + } + + private void addPart(List parts, String value) { + if (value == null) { + return; + } + String normalized = value.trim(); + if (!normalized.isBlank()) { + parts.add(normalized); } - return sb.toString().trim(); } private Optional toDocument(Skill skill) { @@ -80,7 +195,7 @@ public class PostgresSearchRebuildService implements SearchRebuildService { } Namespace namespace = namespaceOpt.get(); - String searchText = buildSearchText(skill); + SearchIndexPayload payload = buildSearchPayload(skill); return Optional.of(new SkillSearchDocument( skill.getId(), @@ -89,11 +204,14 @@ public class PostgresSearchRebuildService implements SearchRebuildService { skill.getOwnerId(), skill.getDisplayName() != null ? skill.getDisplayName() : skill.getSlug(), skill.getSummary(), - "", - searchText, + payload.keywords(), + payload.searchText(), null, skill.getVisibility().name(), skill.getStatus().name() )); } + + private record SearchIndexPayload(String keywords, String searchText) { + } } diff --git a/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresSearchRebuildServiceTest.java b/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresSearchRebuildServiceTest.java new file mode 100644 index 00000000..9e186727 --- /dev/null +++ b/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresSearchRebuildServiceTest.java @@ -0,0 +1,87 @@ +package com.iflytek.skillhub.search.postgres; + +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.SkillVersion; +import com.iflytek.skillhub.domain.skill.SkillVersionRepository; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.search.SearchIndexService; +import com.iflytek.skillhub.search.SkillSearchDocument; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class PostgresSearchRebuildServiceTest { + + @Test + void rebuildBySkill_shouldIndexFrontmatterFieldsAndKeywordsWithoutBody() { + SkillRepository skillRepository = mock(SkillRepository.class); + NamespaceRepository namespaceRepository = mock(NamespaceRepository.class); + SkillVersionRepository skillVersionRepository = mock(SkillVersionRepository.class); + SearchIndexService searchIndexService = mock(SearchIndexService.class); + + Skill skill = new Skill(7L, "smart-agent", "owner-1", SkillVisibility.PUBLIC); + skill.setDisplayName("Smart Agent"); + skill.setSummary("Builds workflows"); + skill.setLatestVersionId(99L); + + Namespace namespace = new Namespace("team-ai", "Team AI", "owner-1"); + + SkillVersion version = new SkillVersion(1L, "1.2.0", "owner-1"); + version.setParsedMetadataJson(""" + { + "name": "Smart Agent", + "description": "Builds workflows", + "version": "1.2.0", + "body": "# ignored", + "frontmatter": { + "name": "Smart Agent", + "description": "Builds workflows", + "version": "1.2.0", + "author": "Jane Doe", + "tags": ["automation", "agentic"], + "keywords": ["workflow", "assistant"], + "config": { + "provider": "openai" + } + } + } + """); + + when(skillRepository.findById(1L)).thenReturn(Optional.of(skill)); + when(namespaceRepository.findById(7L)).thenReturn(Optional.of(namespace)); + when(skillVersionRepository.findById(99L)).thenReturn(Optional.of(version)); + + PostgresSearchRebuildService service = new PostgresSearchRebuildService( + skillRepository, + namespaceRepository, + skillVersionRepository, + searchIndexService + ); + + service.rebuildBySkill(1L); + + ArgumentCaptor captor = ArgumentCaptor.forClass(SkillSearchDocument.class); + verify(searchIndexService).index(captor.capture()); + + SkillSearchDocument document = captor.getValue(); + assertThat(document.keywords()).isEqualTo("agentic, assistant, automation, workflow"); + assertThat(document.searchText()).contains("Smart Agent"); + assertThat(document.searchText()).contains("smart-agent"); + assertThat(document.searchText()).contains("Builds workflows"); + assertThat(document.searchText()).contains("author"); + assertThat(document.searchText()).contains("Jane Doe"); + assertThat(document.searchText()).contains("config"); + assertThat(document.searchText()).contains("provider"); + assertThat(document.searchText()).contains("openai"); + assertThat(document.searchText()).doesNotContain("# ignored"); + } +} diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 5d92ecbc..8cb60e4a 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -831,6 +831,13 @@ export const governanceApi = { headers: getCsrfHeaders(), }) }, + + async rebuildSearchIndex(): Promise { + await fetchJson('/api/v1/admin/search/rebuild', { + method: 'POST', + headers: getCsrfHeaders(), + }) + }, } export const meApi = { diff --git a/web/src/features/governance/use-governance.ts b/web/src/features/governance/use-governance.ts index 56a83584..57cfaa4b 100644 --- a/web/src/features/governance/use-governance.ts +++ b/web/src/features/governance/use-governance.ts @@ -49,3 +49,9 @@ export function useMarkGovernanceNotificationRead() { }, }) } + +export function useRebuildSearchIndex() { + return useMutation({ + mutationFn: () => governanceApi.rebuildSearchIndex(), + }) +} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index e7fe9007..7b2f5794 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -493,6 +493,9 @@ "filterUnhideSkill": "Skill Unhidden", "filterUnarchiveSkill": "Skill Restored", "filterYankVersion": "Version Yanked", + "filterRebuildSearchIndex": "Search Index Rebuilt", + "quickFilterSearchRebuild": "Search index rebuilds only", + "clearFilters": "Clear filters", "userIdPlaceholder": "User ID...", "requestIdPlaceholder": "Request ID...", "ipPlaceholder": "IP address...", @@ -762,7 +765,17 @@ "activityTitle": "Governance activity", "activitySubtitle": "Recent audit events for review, promotion, report, and lifecycle actions.", "emptyActivity": "No recent governance activity.", - "unknownActor": "Unknown actor" + "unknownActor": "Unknown actor", + "searchMaintenanceTitle": "Search Index Maintenance", + "searchMaintenanceDescription": "Rebuild the full skill search index. This action is available only to super administrators and is intended for full backfills after search rule changes.", + "searchMaintenanceHint": "This may take a while. Avoid triggering it repeatedly.", + "searchRebuildAction": "Rebuild full search index", + "searchRebuildRunning": "Rebuilding...", + "searchRebuildConfirmTitle": "Rebuild the full search index?", + "searchRebuildConfirmDescription": "The system will rebuild search documents for all skills using the current indexing rules. This operation may take some time.", + "searchRebuildSuccessTitle": "Search index rebuild started", + "searchRebuildSuccessDescription": "The system is rebuilding the full search index using the current rules.", + "searchRebuildErrorTitle": "Search index rebuild failed" }, "members": { "title": "Member Management", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 0afce248..ff5dcc9a 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -493,6 +493,9 @@ "filterUnhideSkill": "恢复隐藏", "filterUnarchiveSkill": "恢复归档", "filterYankVersion": "版本撤回", + "filterRebuildSearchIndex": "重建搜索索引", + "quickFilterSearchRebuild": "仅看搜索索引重建", + "clearFilters": "清空筛选", "userIdPlaceholder": "用户 ID...", "requestIdPlaceholder": "请求 ID...", "ipPlaceholder": "IP 地址...", @@ -762,7 +765,17 @@ "activityTitle": "治理活动", "activitySubtitle": "最近的审核、提升、举报和生命周期治理审计记录。", "emptyActivity": "暂无治理活动。", - "unknownActor": "未知操作者" + "unknownActor": "未知操作者", + "searchMaintenanceTitle": "搜索索引维护", + "searchMaintenanceDescription": "重新构建全部技能搜索索引。该操作仅超级管理员可执行,适用于搜索规则变更后的全量回填。", + "searchMaintenanceHint": "执行期间可能耗时较长,请避免频繁触发。", + "searchRebuildAction": "重建全部搜索索引", + "searchRebuildRunning": "重建中...", + "searchRebuildConfirmTitle": "确认重建全部搜索索引?", + "searchRebuildConfirmDescription": "系统会按当前搜索规则重建所有技能的搜索文档。该操作可能持续一段时间。", + "searchRebuildSuccessTitle": "已触发搜索索引重建", + "searchRebuildSuccessDescription": "系统正在按当前规则重建全部搜索索引。", + "searchRebuildErrorTitle": "搜索索引重建失败" }, "members": { "title": "成员管理", diff --git a/web/src/pages/admin/audit-log.tsx b/web/src/pages/admin/audit-log.tsx index 1ef81573..a53e9e6a 100644 --- a/web/src/pages/admin/audit-log.tsx +++ b/web/src/pages/admin/audit-log.tsx @@ -33,6 +33,7 @@ const ACTION_OPTIONS = [ { value: 'UNHIDE_SKILL', labelKey: 'auditLog.filterUnhideSkill' }, { value: 'UNARCHIVE_SKILL', labelKey: 'auditLog.filterUnarchiveSkill' }, { value: 'YANK_SKILL_VERSION', labelKey: 'auditLog.filterYankVersion' }, + { value: 'REBUILD_SEARCH_INDEX', labelKey: 'auditLog.filterRebuildSearchIndex' }, ] as const /** @@ -68,6 +69,24 @@ export function AuditLogPage() { return formatLocalDateTime(dateString, i18n.language) } + const applySearchIndexRebuildFilter = () => { + setActionFilter('REBUILD_SEARCH_INDEX') + setResourceTypeFilter('SEARCH_INDEX') + setPage(0) + } + + const clearFilters = () => { + setActionFilter('') + setUserIdFilter('') + setRequestIdFilter('') + setIpFilter('') + setResourceTypeFilter('') + setResourceIdFilter('') + setStartTimeFilter('') + setEndTimeFilter('') + setPage(0) + } + return (
@@ -76,6 +95,15 @@ export function AuditLogPage() {
+
+ + +
+