mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-17 04:44:40 +08:00
feat(wiki): cross-KB wikilinks [[kbId/slug]] and raw-material batch filter/reprocess/delete (#506)
This commit is contained in:
parent
84bbf1cb3e
commit
71ad735e95
@ -36,6 +36,9 @@ import java.nio.charset.StandardCharsets;
|
|||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.nio.file.Paths;
|
import java.nio.file.Paths;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@ -509,13 +512,19 @@ public class WikiController {
|
|||||||
// ==================== Raw Materials ====================
|
// ==================== Raw Materials ====================
|
||||||
|
|
||||||
@RequireWorkspaceRole("viewer")
|
@RequireWorkspaceRole("viewer")
|
||||||
@Operation(summary = "获取原始材料列表(含每条材料生成的页面数)")
|
@Operation(summary = "获取原始材料列表(含每条材料生成的页面数),支持按状态/类型/关键词/时间筛选")
|
||||||
@GetMapping("/knowledge-bases/{kbId}/raw")
|
@GetMapping("/knowledge-bases/{kbId}/raw")
|
||||||
public R<List<Map<String, Object>>> listRaw(@PathVariable Long kbId,
|
public R<List<Map<String, Object>>> listRaw(@PathVariable Long kbId,
|
||||||
|
@RequestParam(required = false) String status,
|
||||||
|
@RequestParam(required = false) String sourceType,
|
||||||
|
@RequestParam(required = false) String keyword,
|
||||||
|
@RequestParam(required = false) String startTime,
|
||||||
|
@RequestParam(required = false) String endTime,
|
||||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||||
verifyKBWorkspace(kbId, workspaceId);
|
verifyKBWorkspace(kbId, workspaceId);
|
||||||
List<WikiRawMaterialEntity> raws = rawService.listByKbId(kbId);
|
List<WikiRawMaterialEntity> raws = rawService.listByKbIdFiltered(
|
||||||
List<Map<String, Object>> result = new java.util.ArrayList<>(raws.size());
|
kbId, status, sourceType, keyword, parseTime(startTime, false), parseTime(endTime, true));
|
||||||
|
List<Map<String, Object>> result = new ArrayList<>(raws.size());
|
||||||
for (WikiRawMaterialEntity raw : raws) {
|
for (WikiRawMaterialEntity raw : raws) {
|
||||||
Map<String, Object> item = new LinkedHashMap<>();
|
Map<String, Object> item = new LinkedHashMap<>();
|
||||||
// Serialize all entity fields via Jackson-friendly approach
|
// Serialize all entity fields via Jackson-friendly approach
|
||||||
@ -648,6 +657,137 @@ public class WikiController {
|
|||||||
return R.ok();
|
return R.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Hard cap on how many raw materials one batch call may touch. */
|
||||||
|
private static final int MAX_RAW_BATCH = 500;
|
||||||
|
|
||||||
|
@RequireWorkspaceRole("member")
|
||||||
|
@Operation(summary = "批量重新处理原始材料(按 ids 或按 status 选取;force=true 绕过 content_hash 短路)")
|
||||||
|
@PostMapping("/knowledge-bases/{kbId}/raw/batch/reprocess")
|
||||||
|
public R<Map<String, Object>> batchReprocessRaw(@PathVariable Long kbId,
|
||||||
|
@RequestBody Map<String, Object> body,
|
||||||
|
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||||
|
verifyKBWorkspace(kbId, workspaceId);
|
||||||
|
List<Long> ids = resolveBatchIds(kbId, body);
|
||||||
|
if (ids == null) {
|
||||||
|
return R.fail(400, "Must supply non-empty 'ids' or a 'status' selector");
|
||||||
|
}
|
||||||
|
if (ids.size() > MAX_RAW_BATCH) {
|
||||||
|
return R.fail(400, "Batch too large: " + ids.size() + " > " + MAX_RAW_BATCH);
|
||||||
|
}
|
||||||
|
boolean force = Boolean.TRUE.equals(body.get("force"));
|
||||||
|
int processed = 0;
|
||||||
|
int skipped = 0;
|
||||||
|
for (Long id : ids) {
|
||||||
|
WikiRawMaterialEntity raw = rawService.getById(id);
|
||||||
|
// Guard against cross-KB ids sneaking in via an explicit id list.
|
||||||
|
if (raw == null || !kbId.equals(raw.getKbId())) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (force) {
|
||||||
|
rawService.setLastProcessedHash(id, null);
|
||||||
|
}
|
||||||
|
rawService.reprocess(id);
|
||||||
|
processed++;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[Wiki] Batch reprocess skipped raw={}: {}", id, e.getMessage());
|
||||||
|
skipped++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return R.ok(Map.of("requested", ids.size(), "processed", processed, "skipped", skipped));
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequireWorkspaceRole("admin")
|
||||||
|
@Operation(summary = "批量删除原始材料(按 ids 或按 status 选取;级联清理页面/分块)")
|
||||||
|
@PostMapping("/knowledge-bases/{kbId}/raw/batch/delete")
|
||||||
|
public R<Map<String, Object>> batchDeleteRaw(@PathVariable Long kbId,
|
||||||
|
@RequestBody Map<String, Object> body,
|
||||||
|
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||||
|
verifyKBWorkspace(kbId, workspaceId);
|
||||||
|
List<Long> ids = resolveBatchIds(kbId, body);
|
||||||
|
if (ids == null) {
|
||||||
|
return R.fail(400, "Must supply non-empty 'ids' or a 'status' selector");
|
||||||
|
}
|
||||||
|
if (ids.size() > MAX_RAW_BATCH) {
|
||||||
|
return R.fail(400, "Batch too large: " + ids.size() + " > " + MAX_RAW_BATCH);
|
||||||
|
}
|
||||||
|
int deleted = 0;
|
||||||
|
int skipped = 0;
|
||||||
|
for (Long id : ids) {
|
||||||
|
WikiRawMaterialEntity raw = rawService.getById(id);
|
||||||
|
if (raw == null || !kbId.equals(raw.getKbId())) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
rawService.delete(id);
|
||||||
|
kbService.decrementRawCount(kbId);
|
||||||
|
deleted++;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[Wiki] Batch delete skipped raw={}: {}", id, e.getMessage());
|
||||||
|
skipped++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return R.ok(Map.of("requested", ids.size(), "deleted", deleted, "skipped", skipped));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the target id set for a batch operation from the request body.
|
||||||
|
* An explicit non-empty {@code ids} array wins; otherwise a {@code status}
|
||||||
|
* selector resolves to every raw in the KB with that status. Returns
|
||||||
|
* {@code null} when neither is usable (caller returns 400). Ids are parsed
|
||||||
|
* leniently from string or number JSON forms (Snowflake precision).
|
||||||
|
*/
|
||||||
|
private List<Long> resolveBatchIds(Long kbId, Map<String, Object> body) {
|
||||||
|
Object rawIds = body.get("ids");
|
||||||
|
if (rawIds instanceof List<?> list && !list.isEmpty()) {
|
||||||
|
List<Long> ids = new ArrayList<>(list.size());
|
||||||
|
for (Object o : list) {
|
||||||
|
if (o == null) continue;
|
||||||
|
try {
|
||||||
|
ids.add(Long.parseLong(String.valueOf(o).trim()));
|
||||||
|
} catch (NumberFormatException ignored) {
|
||||||
|
// Skip malformed ids rather than failing the whole batch.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids.isEmpty() ? null : ids;
|
||||||
|
}
|
||||||
|
Object status = body.get("status");
|
||||||
|
if (status != null && !String.valueOf(status).isBlank()) {
|
||||||
|
List<Long> ids = rawService.selectIdsByStatus(kbId, String.valueOf(status).trim());
|
||||||
|
return ids.isEmpty() ? List.of() : ids;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse an optional ISO date or date-time filter bound. Accepts a full
|
||||||
|
* {@code LocalDateTime} (e.g. {@code 2026-07-10T13:00:00}) or a bare date
|
||||||
|
* (e.g. {@code 2026-07-10}); a bare date maps to start-of-day for a lower
|
||||||
|
* bound or end-of-day for an upper bound. Blank/unparseable input yields
|
||||||
|
* {@code null} (no clause) rather than an error.
|
||||||
|
*
|
||||||
|
* @param value the raw query-parameter string
|
||||||
|
* @param endOfDay when true and {@code value} is date-only, use 23:59:59.999999999
|
||||||
|
*/
|
||||||
|
private LocalDateTime parseTime(String value, boolean endOfDay) {
|
||||||
|
if (value == null || value.isBlank()) return null;
|
||||||
|
String v = value.trim();
|
||||||
|
try {
|
||||||
|
return LocalDateTime.parse(v);
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
// Fall through to date-only parsing.
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
LocalDate d = LocalDate.parse(v);
|
||||||
|
return endOfDay ? d.atTime(java.time.LocalTime.MAX) : d.atStartOfDay();
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
log.warn("[Wiki] Ignoring unparseable raw-material time filter: {}", v);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@RequireWorkspaceRole("viewer")
|
@RequireWorkspaceRole("viewer")
|
||||||
@Operation(summary = "下载原始材料")
|
@Operation(summary = "下载原始材料")
|
||||||
@GetMapping("/knowledge-bases/{kbId}/raw/{rawId}/download")
|
@GetMapping("/knowledge-bases/{kbId}/raw/{rawId}/download")
|
||||||
|
|||||||
@ -55,6 +55,15 @@ public class WikiLinkService {
|
|||||||
*/
|
*/
|
||||||
private static final Pattern WIKILINK = Pattern.compile("\\[\\[([^\\]]+?)]]");
|
private static final Pattern WIKILINK = Pattern.compile("\\[\\[([^\\]]+?)]]");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matches a cross-KB wikilink target of the form {@code kbId/slug}, where
|
||||||
|
* {@code kbId} is a numeric knowledge-base id and {@code slug} is a page
|
||||||
|
* slug inside that KB. A plain single-KB slug never contains {@code /}, so
|
||||||
|
* this pattern is unambiguous — historical {@code [[slug]]} /
|
||||||
|
* {@code [[Title]]} content is unaffected.
|
||||||
|
*/
|
||||||
|
private static final Pattern CROSS_KB = Pattern.compile("^(\\d+)/(.+)$");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Matches a fenced code block. Anchored to {@code ^```} on a line so a
|
* Matches a fenced code block. Anchored to {@code ^```} on a line so a
|
||||||
* stray triple-backtick mid-paragraph does not flip the world into "in
|
* stray triple-backtick mid-paragraph does not flip the world into "in
|
||||||
@ -135,11 +144,44 @@ public class WikiLinkService {
|
|||||||
if (resolvableKeysLower == null) resolvableKeysLower = Collections.emptySet();
|
if (resolvableKeysLower == null) resolvableKeysLower = Collections.emptySet();
|
||||||
List<String> broken = new ArrayList<>();
|
List<String> broken = new ArrayList<>();
|
||||||
for (String t : outlinks) {
|
for (String t : outlinks) {
|
||||||
|
// Cross-KB targets ([[kbId/slug]]) can't be validated against this
|
||||||
|
// KB's key set — checking existence would need a cross-KB query.
|
||||||
|
// Exempt well-formed cross-KB refs from the single-KB broken-link
|
||||||
|
// rule so they aren't false-flagged; the target KB's viewer surfaces
|
||||||
|
// a real page-not-found on click if the slug is stale.
|
||||||
|
if (parseCrossKb(t) != null) continue;
|
||||||
if (!resolvableKeysLower.contains(t)) broken.add(t);
|
if (!resolvableKeysLower.contains(t)) broken.add(t);
|
||||||
}
|
}
|
||||||
return broken;
|
return broken;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a cross-KB wikilink target {@code kbId/slug} into its parts, or
|
||||||
|
* {@code null} when {@code target} is a plain single-KB slug/title.
|
||||||
|
* Pure function — no I/O, no existence check.
|
||||||
|
*
|
||||||
|
* @param target the wikilink target (before any {@code |alias}); may be
|
||||||
|
* already lowercased by {@link #extractOutlinks}
|
||||||
|
* @return {@link CrossKbRef} when the target has a numeric KB prefix, else null
|
||||||
|
*/
|
||||||
|
public CrossKbRef parseCrossKb(String target) {
|
||||||
|
if (target == null || target.isBlank()) return null;
|
||||||
|
Matcher m = CROSS_KB.matcher(target.trim());
|
||||||
|
if (!m.matches()) return null;
|
||||||
|
try {
|
||||||
|
long kbId = Long.parseLong(m.group(1));
|
||||||
|
String slug = m.group(2).trim();
|
||||||
|
if (slug.isEmpty()) return null;
|
||||||
|
return new CrossKbRef(kbId, slug);
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
// kbId overflowed long — treat as a plain (broken) single-KB target.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A parsed cross-KB wikilink target: target KB id + page slug. */
|
||||||
|
public record CrossKbRef(long kbId, String slug) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convenience: extract + compute in one call. Used from
|
* Convenience: extract + compute in one call. Used from
|
||||||
* {@code WikiPageService.save/update} where both fields are written in
|
* {@code WikiPageService.save/update} where both fields are written in
|
||||||
|
|||||||
@ -20,6 +20,7 @@ import vip.mate.wiki.repository.WikiRawMaterialMapper;
|
|||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.security.MessageDigest;
|
import java.security.MessageDigest;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
import java.util.HexFormat;
|
import java.util.HexFormat;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import vip.mate.wiki.dto.WikiFailureItem;
|
import vip.mate.wiki.dto.WikiFailureItem;
|
||||||
@ -64,9 +65,40 @@ public class WikiRawMaterialService {
|
|||||||
private final Set<Long> partialResumeIds = ConcurrentHashMap.newKeySet();
|
private final Set<Long> partialResumeIds = ConcurrentHashMap.newKeySet();
|
||||||
|
|
||||||
public List<WikiRawMaterialEntity> listByKbId(Long kbId) {
|
public List<WikiRawMaterialEntity> listByKbId(Long kbId) {
|
||||||
|
return listByKbIdFiltered(kbId, null, null, null, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List raw materials in a KB, optionally narrowed by any combination of
|
||||||
|
* processing status, source type, a title keyword, and a create-time range.
|
||||||
|
* All filter arguments are optional — a {@code null}/blank value drops that
|
||||||
|
* clause, so calling with all-null is identical to the unfiltered list.
|
||||||
|
* <p>
|
||||||
|
* Powers the raw-material panel's filter bar (issue #506): with dozens of
|
||||||
|
* materials per KB, filtering by {@code status = "failed"} or a title
|
||||||
|
* keyword replaces page-by-page scrolling.
|
||||||
|
*
|
||||||
|
* @param kbId owning knowledge base (required)
|
||||||
|
* @param status processing status (pending/processing/completed/failed/partial/cancelled)
|
||||||
|
* @param sourceType source type (text/pdf/docx/image/…)
|
||||||
|
* @param keyword case-insensitive substring matched against the title
|
||||||
|
* @param startTime inclusive lower bound on create time
|
||||||
|
* @param endTime inclusive upper bound on create time
|
||||||
|
*/
|
||||||
|
public List<WikiRawMaterialEntity> listByKbIdFiltered(Long kbId, String status, String sourceType,
|
||||||
|
String keyword,
|
||||||
|
LocalDateTime startTime, LocalDateTime endTime) {
|
||||||
List<WikiRawMaterialEntity> list = rawMapper.selectList(
|
List<WikiRawMaterialEntity> list = rawMapper.selectList(
|
||||||
new LambdaQueryWrapper<WikiRawMaterialEntity>()
|
new LambdaQueryWrapper<WikiRawMaterialEntity>()
|
||||||
.eq(WikiRawMaterialEntity::getKbId, kbId)
|
.eq(WikiRawMaterialEntity::getKbId, kbId)
|
||||||
|
.eq(status != null && !status.isBlank(),
|
||||||
|
WikiRawMaterialEntity::getProcessingStatus, status)
|
||||||
|
.eq(sourceType != null && !sourceType.isBlank(),
|
||||||
|
WikiRawMaterialEntity::getSourceType, sourceType)
|
||||||
|
.like(keyword != null && !keyword.isBlank(),
|
||||||
|
WikiRawMaterialEntity::getTitle, keyword)
|
||||||
|
.ge(startTime != null, WikiRawMaterialEntity::getCreateTime, startTime)
|
||||||
|
.le(endTime != null, WikiRawMaterialEntity::getCreateTime, endTime)
|
||||||
.orderByDesc(WikiRawMaterialEntity::getCreateTime));
|
.orderByDesc(WikiRawMaterialEntity::getCreateTime));
|
||||||
// 不返回大文本字段
|
// 不返回大文本字段
|
||||||
list.forEach(r -> {
|
list.forEach(r -> {
|
||||||
@ -80,6 +112,23 @@ public class WikiRawMaterialService {
|
|||||||
return rawMapper.selectById(id);
|
return rawMapper.selectById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ids of all raw materials in {@code kbId} with the given processing
|
||||||
|
* status, newest first. Used by the batch reprocess/delete endpoints to
|
||||||
|
* resolve a status selector (e.g. "retry all failed") server-side so the
|
||||||
|
* client doesn't have to enumerate ids.
|
||||||
|
*/
|
||||||
|
public List<Long> selectIdsByStatus(Long kbId, String status) {
|
||||||
|
if (status == null || status.isBlank()) return List.of();
|
||||||
|
return rawMapper.selectList(
|
||||||
|
new LambdaQueryWrapper<WikiRawMaterialEntity>()
|
||||||
|
.select(WikiRawMaterialEntity::getId)
|
||||||
|
.eq(WikiRawMaterialEntity::getKbId, kbId)
|
||||||
|
.eq(WikiRawMaterialEntity::getProcessingStatus, status)
|
||||||
|
.orderByDesc(WikiRawMaterialEntity::getCreateTime))
|
||||||
|
.stream().map(WikiRawMaterialEntity::getId).toList();
|
||||||
|
}
|
||||||
|
|
||||||
public WikiRawMaterialEntity findBySourcePath(Long kbId, String sourcePath) {
|
public WikiRawMaterialEntity findBySourcePath(Long kbId, String sourcePath) {
|
||||||
return rawMapper.selectOne(
|
return rawMapper.selectOne(
|
||||||
new LambdaQueryWrapper<WikiRawMaterialEntity>()
|
new LambdaQueryWrapper<WikiRawMaterialEntity>()
|
||||||
|
|||||||
@ -0,0 +1,64 @@
|
|||||||
|
package vip.mate.wiki.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coverage for cross-KB wikilink targets ({@code [[kbId/slug]]}): parsing and
|
||||||
|
* the broken-link exemption. A cross-KB target must never be flagged broken by
|
||||||
|
* the single-KB lint (existence can only be checked in the target KB), while a
|
||||||
|
* plain single-KB target keeps its exact slug/title resolution.
|
||||||
|
*/
|
||||||
|
class WikiLinkServiceCrossKbTest {
|
||||||
|
|
||||||
|
private final WikiLinkService svc = new WikiLinkService(new ObjectMapper());
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void parseCrossKb_recognisesNumericPrefix() {
|
||||||
|
WikiLinkService.CrossKbRef ref = svc.parseCrossKb("2055137662148763649/photosynthesis");
|
||||||
|
assertNotNull(ref);
|
||||||
|
assertEquals(2055137662148763649L, ref.kbId());
|
||||||
|
assertEquals("photosynthesis", ref.slug());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void parseCrossKb_ignoresPlainSlugAndTitle() {
|
||||||
|
assertNull(svc.parseCrossKb("photosynthesis"));
|
||||||
|
assertNull(svc.parseCrossKb("Energy Metabolism"));
|
||||||
|
// Slug-shaped but non-numeric prefix stays single-KB.
|
||||||
|
assertNull(svc.parseCrossKb("chapter/section"));
|
||||||
|
// Numeric prefix but empty slug is not a valid cross-KB ref.
|
||||||
|
assertNull(svc.parseCrossKb("123/"));
|
||||||
|
assertNull(svc.parseCrossKb(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void computeBrokenLinks_exemptsCrossKbTargets() {
|
||||||
|
Set<String> outlinks = svc.extractOutlinks(
|
||||||
|
"See [[123/photosynthesis]] and [[missing-local]] and [[known-local]].");
|
||||||
|
// Only this KB's own slug is resolvable.
|
||||||
|
Set<String> resolvable = Set.of("known-local");
|
||||||
|
List<String> broken = svc.computeBrokenLinks(outlinks, resolvable);
|
||||||
|
// Cross-KB target exempt; local unknown flagged; local known resolves.
|
||||||
|
assertTrue(broken.contains("missing-local"));
|
||||||
|
assertFalse(broken.contains("123/photosynthesis"),
|
||||||
|
"cross-KB target must not be flagged broken by the single-KB lint");
|
||||||
|
assertFalse(broken.contains("known-local"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void extractOutlinks_keepsCrossKbTargetVerbatim() {
|
||||||
|
Set<String> outlinks = svc.extractOutlinks("Ref [[456/Some-Page|display]].");
|
||||||
|
// Lowercased, alias stripped, prefix preserved.
|
||||||
|
assertTrue(outlinks.contains("456/some-page"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -824,7 +824,10 @@ export const wikiApi = {
|
|||||||
listFailures: (limit = 100) => http.get<{ data: WikiFailureItem[] }>(`/wiki/admin/failures?limit=${limit}`),
|
listFailures: (limit = 100) => http.get<{ data: WikiFailureItem[] }>(`/wiki/admin/failures?limit=${limit}`),
|
||||||
|
|
||||||
// Raw Materials
|
// Raw Materials
|
||||||
listRaw: (kbId: number) => http.get(`/wiki/knowledge-bases/${kbId}/raw`),
|
listRaw: (
|
||||||
|
kbId: number,
|
||||||
|
filters?: { status?: string; sourceType?: string; keyword?: string; startTime?: string; endTime?: string },
|
||||||
|
) => http.get(`/wiki/knowledge-bases/${kbId}/raw`, filters ? { params: filters } : undefined),
|
||||||
addRawText: (kbId: number, data: { title: string; content: string }) =>
|
addRawText: (kbId: number, data: { title: string; content: string }) =>
|
||||||
http.post(`/wiki/knowledge-bases/${kbId}/raw/text`, data),
|
http.post(`/wiki/knowledge-bases/${kbId}/raw/text`, data),
|
||||||
uploadRaw: (kbId: number, formData: FormData, onProgress?: (pct: number) => void) =>
|
uploadRaw: (kbId: number, formData: FormData, onProgress?: (pct: number) => void) =>
|
||||||
@ -840,6 +843,16 @@ export const wikiApi = {
|
|||||||
http.post(`/wiki/knowledge-bases/${kbId}/raw/${rawId}/reprocess`),
|
http.post(`/wiki/knowledge-bases/${kbId}/raw/${rawId}/reprocess`),
|
||||||
cancelRaw: (kbId: number, rawId: number) =>
|
cancelRaw: (kbId: number, rawId: number) =>
|
||||||
http.post(`/wiki/knowledge-bases/${kbId}/raw/${rawId}/cancel`),
|
http.post(`/wiki/knowledge-bases/${kbId}/raw/${rawId}/cancel`),
|
||||||
|
// Batch reprocess/delete. Select by explicit `ids` (Snowflake strings — never
|
||||||
|
// coerce to number) or by a `status` selector (e.g. retry all failed).
|
||||||
|
batchReprocessRaw: (
|
||||||
|
kbId: number,
|
||||||
|
body: { ids?: (string | number)[]; status?: string; force?: boolean },
|
||||||
|
) => http.post(`/wiki/knowledge-bases/${kbId}/raw/batch/reprocess`, body),
|
||||||
|
batchDeleteRaw: (
|
||||||
|
kbId: number,
|
||||||
|
body: { ids?: (string | number)[]; status?: string },
|
||||||
|
) => http.post(`/wiki/knowledge-bases/${kbId}/raw/batch/delete`, body),
|
||||||
downloadRaw: (kbId: number, rawId: number) =>
|
downloadRaw: (kbId: number, rawId: number) =>
|
||||||
http.get<Blob>(`/wiki/knowledge-bases/${kbId}/raw/${rawId}/download`, {
|
http.get<Blob>(`/wiki/knowledge-bases/${kbId}/raw/${rawId}/download`, {
|
||||||
responseType: 'blob',
|
responseType: 'blob',
|
||||||
|
|||||||
@ -128,6 +128,46 @@ describe('resolveWikilink — safety cases', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// resolveWikilink — cross-KB targets [[kbId/slug]]
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('resolveWikilink — cross-KB', () => {
|
||||||
|
it('resolves [[kbId/slug]] to a cross-kb link', () => {
|
||||||
|
const r = resolveWikilink('2055137662148763649/photosynthesis', REFS)
|
||||||
|
expect(r).toEqual({
|
||||||
|
kind: 'cross-kb',
|
||||||
|
kbId: '2055137662148763649',
|
||||||
|
slug: 'photosynthesis',
|
||||||
|
display: 'photosynthesis',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('honours explicit display for cross-KB targets', () => {
|
||||||
|
const r = resolveWikilink('123/some-page|Some Page', REFS)
|
||||||
|
expect(r.kind).toBe('cross-kb')
|
||||||
|
if (r.kind === 'cross-kb') {
|
||||||
|
expect(r.kbId).toBe('123')
|
||||||
|
expect(r.slug).toBe('some-page')
|
||||||
|
expect(r.display).toBe('Some Page')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not treat a non-numeric prefix as cross-KB', () => {
|
||||||
|
// Falls through to normal (broken here) single-KB resolution.
|
||||||
|
expect(resolveWikilink('chapter/section', REFS).kind).toBe('broken')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders a cross-KB anchor with data-kbid + data-slug', () => {
|
||||||
|
const root = document.createElement('div')
|
||||||
|
root.innerHTML = 'See [[123/photosynthesis]].'
|
||||||
|
postprocessWikilinks(root, (raw) => resolveWikilink(raw, REFS, ARCHIVED_REFS))
|
||||||
|
const a = root.querySelector('a.wiki-link-crosskb') as HTMLAnchorElement
|
||||||
|
expect(a).not.toBeNull()
|
||||||
|
expect(a.getAttribute('data-kbid')).toBe('123')
|
||||||
|
expect(a.getAttribute('data-slug')).toBe('photosynthesis')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// postprocessWikilinks — DOM walker behaviour
|
// postprocessWikilinks — DOM walker behaviour
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@ -16,6 +16,7 @@ export type SSEEventType =
|
|||||||
| 'message_start'
|
| 'message_start'
|
||||||
// Agent 事件
|
// Agent 事件
|
||||||
| 'tool_call_started'
|
| 'tool_call_started'
|
||||||
|
| 'tool_call_progress'
|
||||||
| 'tool_call_completed'
|
| 'tool_call_completed'
|
||||||
| 'phase'
|
| 'phase'
|
||||||
| 'plan_created'
|
| 'plan_created'
|
||||||
|
|||||||
@ -63,6 +63,17 @@ export function useGlobalWikilinkClick() {
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
|
||||||
|
// Cross-KB reference [[kbId/slug]] — the token carries an explicit numeric
|
||||||
|
// KB id, so route straight to that KB instead of guessing via lookup.
|
||||||
|
const crossKb = /^(\d+)\/(.+)$/.exec(title.trim())
|
||||||
|
if (crossKb) {
|
||||||
|
const slug = crossKb[2].trim()
|
||||||
|
if (slug) {
|
||||||
|
router.push({ name: 'Wiki', query: { kbId: crossKb[1], slug } })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Pass both — backend matches slug first, falls back to title. For
|
// Pass both — backend matches slug first, falls back to title. For
|
||||||
// a bracket like `[[StateGraph]]` the captured "title" is actually
|
// a bracket like `[[StateGraph]]` the captured "title" is actually
|
||||||
|
|||||||
@ -31,6 +31,7 @@ export interface WikilinkRef {
|
|||||||
/** Result of resolving a single `[[...]]` target string. */
|
/** Result of resolving a single `[[...]]` target string. */
|
||||||
export type WikilinkResolution =
|
export type WikilinkResolution =
|
||||||
| { kind: 'hit'; slug: string; display: string; archived: boolean }
|
| { kind: 'hit'; slug: string; display: string; archived: boolean }
|
||||||
|
| { kind: 'cross-kb'; kbId: string; slug: string; display: string }
|
||||||
| { kind: 'broken'; display: string; reason: 'empty' | 'dangerous' | 'too-long' | 'unknown' }
|
| { kind: 'broken'; display: string; reason: 'empty' | 'dangerous' | 'too-long' | 'unknown' }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -61,6 +62,14 @@ const MAX_SLUG_LEN = 256
|
|||||||
/** `[[...]]` matcher used during text-node walking. Non-greedy. */
|
/** `[[...]]` matcher used during text-node walking. Non-greedy. */
|
||||||
const WIKILINK_RE = /\[\[([^\]]+?)\]\]/g
|
const WIKILINK_RE = /\[\[([^\]]+?)\]\]/g
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cross-KB target matcher: `kbId/slug` where `kbId` is a numeric knowledge-base
|
||||||
|
* id. A plain single-KB slug never contains `/`, so this is unambiguous and
|
||||||
|
* historical `[[slug]]` / `[[Title]]` content is unaffected. Mirrors the
|
||||||
|
* backend `WikiLinkService.CROSS_KB` pattern — both must stay in lockstep.
|
||||||
|
*/
|
||||||
|
const CROSS_KB_RE = /^(\d+)\/(.+)$/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve a single raw target into a render directive.
|
* Resolve a single raw target into a render directive.
|
||||||
*
|
*
|
||||||
@ -115,6 +124,19 @@ export function resolveWikilink(
|
|||||||
return { kind: 'broken', display: literal, reason: 'too-long' }
|
return { kind: 'broken', display: literal, reason: 'too-long' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cross-KB reference `[[kbId/slug]]`: resolve to a deterministic link into
|
||||||
|
// the target KB. Existence is not checked here (that would need the target
|
||||||
|
// KB's refs) — the target KB's viewer surfaces page-not-found on click if
|
||||||
|
// the slug is stale. Display falls back to the slug since the target page's
|
||||||
|
// title lives in another KB.
|
||||||
|
const crossKb = CROSS_KB_RE.exec(target)
|
||||||
|
if (crossKb) {
|
||||||
|
const slug = crossKb[2].trim()
|
||||||
|
if (slug) {
|
||||||
|
return { kind: 'cross-kb', kbId: crossKb[1], slug, display: explicitDisplay || slug }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const lookupSlug = target.toLowerCase()
|
const lookupSlug = target.toLowerCase()
|
||||||
const lookupTitle = target.trim().toLowerCase()
|
const lookupTitle = target.trim().toLowerCase()
|
||||||
|
|
||||||
@ -168,6 +190,20 @@ function buildLinkElement(
|
|||||||
a.textContent = resolution.display
|
a.textContent = resolution.display
|
||||||
return a
|
return a
|
||||||
}
|
}
|
||||||
|
if (resolution.kind === 'cross-kb') {
|
||||||
|
const a = doc.createElement('a')
|
||||||
|
a.className = 'wiki-link wiki-link-crosskb'
|
||||||
|
// data-slug keeps the existing click contract; data-kbid signals the
|
||||||
|
// viewer's click handler to route into a different KB rather than the
|
||||||
|
// current one.
|
||||||
|
a.setAttribute('data-slug', resolution.slug)
|
||||||
|
a.setAttribute('data-kbid', resolution.kbId)
|
||||||
|
a.setAttribute('role', 'link')
|
||||||
|
a.setAttribute('tabindex', '0')
|
||||||
|
a.setAttribute('title', 'Cross-KB link')
|
||||||
|
a.textContent = resolution.display
|
||||||
|
return a
|
||||||
|
}
|
||||||
const span = doc.createElement('span')
|
const span = doc.createElement('span')
|
||||||
span.className = 'wiki-link-broken'
|
span.className = 'wiki-link-broken'
|
||||||
span.setAttribute('title', `Target not found (${resolution.reason})`)
|
span.setAttribute('title', `Target not found (${resolution.reason})`)
|
||||||
|
|||||||
@ -2567,6 +2567,22 @@ export default {
|
|||||||
cancelled: 'CANCELLED',
|
cancelled: 'CANCELLED',
|
||||||
cancelling: 'CANCELLING…',
|
cancelling: 'CANCELLING…',
|
||||||
},
|
},
|
||||||
|
// Raw-material filtering + batch operations (issue #506).
|
||||||
|
batch: {
|
||||||
|
allStatus: 'All statuses',
|
||||||
|
allTypes: 'All types',
|
||||||
|
keywordPlaceholder: 'Search title…',
|
||||||
|
clearFilter: 'Clear filter',
|
||||||
|
noMatch: 'No matching materials',
|
||||||
|
selectAll: 'Select all',
|
||||||
|
selectedCount: '{n} selected',
|
||||||
|
reprocess: 'Reprocess',
|
||||||
|
delete: 'Delete',
|
||||||
|
retryAllFailed: 'Retry all failed ({n})',
|
||||||
|
deleteConfirm: 'Delete the {n} selected material(s)? Their generated pages are cascade-deleted and this cannot be undone.',
|
||||||
|
reprocessDone: 'Re-queued {n} item(s)',
|
||||||
|
deleteDone: 'Deleted {n} item(s)',
|
||||||
|
},
|
||||||
// Friendly, localized hints keyed by the backend's structured error code.
|
// Friendly, localized hints keyed by the backend's structured error code.
|
||||||
// The raw exception text is kept as the hover tooltip for troubleshooting.
|
// The raw exception text is kept as the hover tooltip for troubleshooting.
|
||||||
errorCode: {
|
errorCode: {
|
||||||
|
|||||||
@ -2579,6 +2579,22 @@ export default {
|
|||||||
cancelled: '已取消',
|
cancelled: '已取消',
|
||||||
cancelling: '正在取消…',
|
cancelling: '正在取消…',
|
||||||
},
|
},
|
||||||
|
// Raw-material filtering + batch operations (issue #506).
|
||||||
|
batch: {
|
||||||
|
allStatus: '全部状态',
|
||||||
|
allTypes: '全部类型',
|
||||||
|
keywordPlaceholder: '搜索标题…',
|
||||||
|
clearFilter: '清除筛选',
|
||||||
|
noMatch: '没有匹配的材料',
|
||||||
|
selectAll: '全选',
|
||||||
|
selectedCount: '已选 {n} 项',
|
||||||
|
reprocess: '批量重试',
|
||||||
|
delete: '批量删除',
|
||||||
|
retryAllFailed: '重试全部失败({n})',
|
||||||
|
deleteConfirm: '确定删除选中的 {n} 项材料?将级联删除其生成的页面,且不可撤销。',
|
||||||
|
reprocessDone: '已重新排队 {n} 项',
|
||||||
|
deleteDone: '已删除 {n} 项',
|
||||||
|
},
|
||||||
// Friendly, localized hints keyed by the backend's structured error code.
|
// Friendly, localized hints keyed by the backend's structured error code.
|
||||||
// The raw exception text is kept as the hover tooltip for troubleshooting.
|
// The raw exception text is kept as the hover tooltip for troubleshooting.
|
||||||
errorCode: {
|
errorCode: {
|
||||||
|
|||||||
@ -148,6 +148,16 @@ export const useWikiStore = defineStore('wiki', () => {
|
|||||||
const selectedRawId = ref<number | null>(null)
|
const selectedRawId = ref<number | null>(null)
|
||||||
const totalPageCount = ref(0)
|
const totalPageCount = ref(0)
|
||||||
|
|
||||||
|
// Raw-material list query filters (status / sourceType / title keyword).
|
||||||
|
// Held in the store so they stay applied across background refreshes
|
||||||
|
// (SSE completion, job poller) — a filtered list must not silently snap
|
||||||
|
// back to the full list when a raw finishes processing.
|
||||||
|
const rawFilters = ref<{ status: string; sourceType: string; keyword: string }>({
|
||||||
|
status: '',
|
||||||
|
sourceType: '',
|
||||||
|
keyword: '',
|
||||||
|
})
|
||||||
|
|
||||||
// Active KB's pageType profile (parsed). Loaded alongside the KB so sidebar
|
// Active KB's pageType profile (parsed). Loaded alongside the KB so sidebar
|
||||||
// grouping, graph colouring and the transformation editor can render the
|
// grouping, graph colouring and the transformation editor can render the
|
||||||
// KB's own classification. Null until a KB is selected / its profile loads.
|
// KB's own classification. Null until a KB is selected / its profile loads.
|
||||||
@ -278,14 +288,37 @@ export const useWikiStore = defineStore('wiki', () => {
|
|||||||
if (brokenLinksPollTimer) { clearInterval(brokenLinksPollTimer); brokenLinksPollTimer = null }
|
if (brokenLinksPollTimer) { clearInterval(brokenLinksPollTimer); brokenLinksPollTimer = null }
|
||||||
brokenLinksLoading.value = false
|
brokenLinksLoading.value = false
|
||||||
selectedRawId.value = null
|
selectedRawId.value = null
|
||||||
|
rawFilters.value = { status: '', sourceType: '', keyword: '' }
|
||||||
pageTypeProfile.value = null
|
pageTypeProfile.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchRawMaterials(kbId: number) {
|
async function fetchRawMaterials(kbId: number) {
|
||||||
const res: any = await wikiApi.listRaw(kbId)
|
const f = rawFilters.value
|
||||||
|
const params = f.status || f.sourceType || f.keyword
|
||||||
|
? {
|
||||||
|
status: f.status || undefined,
|
||||||
|
sourceType: f.sourceType || undefined,
|
||||||
|
keyword: f.keyword || undefined,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
const res: any = await wikiApi.listRaw(kbId, params)
|
||||||
rawMaterials.value = res.data || []
|
rawMaterials.value = res.data || []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update the raw-material filters and refetch. Empty-string fields clear
|
||||||
|
// their clause. Used by the raw-material panel's filter bar.
|
||||||
|
async function setRawFilters(
|
||||||
|
kbId: number,
|
||||||
|
filters: Partial<{ status: string; sourceType: string; keyword: string }>,
|
||||||
|
) {
|
||||||
|
rawFilters.value = { ...rawFilters.value, ...filters }
|
||||||
|
await fetchRawMaterials(kbId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetRawFilters() {
|
||||||
|
rawFilters.value = { status: '', sourceType: '', keyword: '' }
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchPages(kbId: number, rawId?: number | null) {
|
async function fetchPages(kbId: number, rawId?: number | null) {
|
||||||
const res: any = await wikiApi.listPages(kbId, rawId ?? undefined)
|
const res: any = await wikiApi.listPages(kbId, rawId ?? undefined)
|
||||||
pages.value = res.data || []
|
pages.value = res.data || []
|
||||||
@ -474,6 +507,9 @@ export const useWikiStore = defineStore('wiki', () => {
|
|||||||
loading,
|
loading,
|
||||||
selectedRawId,
|
selectedRawId,
|
||||||
totalPageCount,
|
totalPageCount,
|
||||||
|
rawFilters,
|
||||||
|
setRawFilters,
|
||||||
|
resetRawFilters,
|
||||||
pageTypeProfile,
|
pageTypeProfile,
|
||||||
pageRefs,
|
pageRefs,
|
||||||
archivedPageRefs,
|
archivedPageRefs,
|
||||||
|
|||||||
@ -97,8 +97,61 @@
|
|||||||
<h4 class="raw-list-title">
|
<h4 class="raw-list-title">
|
||||||
{{ t('wiki.rawMaterials') }} ({{ store.rawMaterials.length + uploadingFiles.length }})
|
{{ t('wiki.rawMaterials') }} ({{ store.rawMaterials.length + uploadingFiles.length }})
|
||||||
</h4>
|
</h4>
|
||||||
|
|
||||||
|
<!-- Filter bar: status / source type / title keyword. Filters are held in
|
||||||
|
the store so they survive background refreshes. -->
|
||||||
|
<div v-if="store.rawMaterials.length > 0 || hasActiveFilter" class="raw-toolbar">
|
||||||
|
<div class="raw-filters">
|
||||||
|
<select v-model="filterStatus" class="raw-filter-select" @change="applyFilters">
|
||||||
|
<option value="">{{ t('wiki.batch.allStatus') }}</option>
|
||||||
|
<option v-for="s in RAW_STATUSES" :key="s" :value="s">{{ t(`wiki.status.${s}`) }}</option>
|
||||||
|
</select>
|
||||||
|
<select v-model="filterSourceType" class="raw-filter-select" @change="applyFilters">
|
||||||
|
<option value="">{{ t('wiki.batch.allTypes') }}</option>
|
||||||
|
<option v-for="st in RAW_SOURCE_TYPES" :key="st" :value="st">{{ st }}</option>
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
v-model="filterKeyword"
|
||||||
|
type="text"
|
||||||
|
class="raw-filter-input"
|
||||||
|
:placeholder="t('wiki.batch.keywordPlaceholder')"
|
||||||
|
/>
|
||||||
|
<button v-if="hasActiveFilter" class="btn-text" @click="clearFilters">
|
||||||
|
{{ t('wiki.batch.clearFilter') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-if="canManageWiki && failedCount > 0"
|
||||||
|
class="btn-text retry-all-failed"
|
||||||
|
:disabled="batchBusy"
|
||||||
|
@click="retryAllFailed"
|
||||||
|
>
|
||||||
|
{{ t('wiki.batch.retryAllFailed', { n: failedCount }) }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Selection + batch actions. Always shown (when manageable and rows
|
||||||
|
exist); the action buttons are disabled until at least one row is
|
||||||
|
selected. -->
|
||||||
|
<div v-if="canManageWiki && store.rawMaterials.length > 0" class="raw-selection-bar">
|
||||||
|
<label class="raw-select-all" @click.stop>
|
||||||
|
<input type="checkbox" :checked="allSelected" @change="toggleSelectAll" />
|
||||||
|
<span>{{ selectedIds.size > 0
|
||||||
|
? t('wiki.batch.selectedCount', { n: selectedIds.size })
|
||||||
|
: t('wiki.batch.selectAll') }}</span>
|
||||||
|
</label>
|
||||||
|
<div class="raw-selection-actions">
|
||||||
|
<button class="btn-text" :disabled="batchBusy || selectedIds.size === 0" @click="batchReprocess">
|
||||||
|
{{ t('wiki.batch.reprocess') }}
|
||||||
|
</button>
|
||||||
|
<button class="btn-text btn-danger-text" :disabled="batchBusy || selectedIds.size === 0" @click="batchDelete">
|
||||||
|
{{ t('wiki.batch.delete') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="store.rawMaterials.length === 0 && uploadingFiles.length === 0" class="empty-hint">
|
<div v-if="store.rawMaterials.length === 0 && uploadingFiles.length === 0" class="empty-hint">
|
||||||
{{ t('wiki.noRawMaterials') }}
|
{{ hasActiveFilter ? t('wiki.batch.noMatch') : t('wiki.noRawMaterials') }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Optimistic uploading items shown at the top -->
|
<!-- Optimistic uploading items shown at the top -->
|
||||||
@ -156,6 +209,13 @@
|
|||||||
@click="toggleRawFilter(raw.id)"
|
@click="toggleRawFilter(raw.id)"
|
||||||
>
|
>
|
||||||
<div class="raw-item-row">
|
<div class="raw-item-row">
|
||||||
|
<label v-if="canManageWiki" class="raw-select-box" @click.stop>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="selectedIds.has(String(raw.id))"
|
||||||
|
@change="toggleSelect(raw.id)"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
<div class="raw-item-info">
|
<div class="raw-item-info">
|
||||||
<span class="raw-item-title">{{ raw.title }}</span>
|
<span class="raw-item-title">{{ raw.title }}</span>
|
||||||
<span class="raw-item-type">{{ raw.sourceType }}</span>
|
<span class="raw-item-type">{{ raw.sourceType }}</span>
|
||||||
@ -316,6 +376,7 @@
|
|||||||
import { ref, reactive, computed, watch, onBeforeUnmount } from 'vue'
|
import { ref, reactive, computed, watch, onBeforeUnmount } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { mcToast } from '@/composables/useMcToast'
|
import { mcToast } from '@/composables/useMcToast'
|
||||||
|
import { mcConfirm } from '@/components/common/useConfirm'
|
||||||
import { useFileDrop } from '@/composables/useFileDrop'
|
import { useFileDrop } from '@/composables/useFileDrop'
|
||||||
import { Download } from '@element-plus/icons-vue'
|
import { Download } from '@element-plus/icons-vue'
|
||||||
import { useWikiStore } from '@/stores/useWikiStore'
|
import { useWikiStore } from '@/stores/useWikiStore'
|
||||||
@ -333,6 +394,139 @@ const workspace = useWorkspaceStore()
|
|||||||
const canManageWiki = computed(() => workspace.can('manage:wiki'))
|
const canManageWiki = computed(() => workspace.can('manage:wiki'))
|
||||||
const fileInput = ref<HTMLInputElement | null>(null)
|
const fileInput = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
|
// ---- Raw-material filtering + batch operations (issue #506) --------------
|
||||||
|
// Processing states and source types used to populate the filter dropdowns.
|
||||||
|
const RAW_STATUSES = ['pending', 'processing', 'completed', 'failed', 'partial', 'cancelled'] as const
|
||||||
|
const RAW_SOURCE_TYPES = ['text', 'pdf', 'docx', 'xlsx', 'pptx', 'html', 'image', 'url'] as const
|
||||||
|
|
||||||
|
// Local mirrors of the store's filter state; edits flow back through
|
||||||
|
// store.setRawFilters so background refreshes keep the filter applied.
|
||||||
|
const filterStatus = ref(store.rawFilters.status)
|
||||||
|
const filterSourceType = ref(store.rawFilters.sourceType)
|
||||||
|
const filterKeyword = ref(store.rawFilters.keyword)
|
||||||
|
|
||||||
|
const hasActiveFilter = computed(
|
||||||
|
() => !!(filterStatus.value || filterSourceType.value || filterKeyword.value),
|
||||||
|
)
|
||||||
|
const failedCount = computed(
|
||||||
|
() => store.rawMaterials.filter(r => r.processingStatus === 'failed').length,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Selected raw ids for batch ops. Keyed by String(id) — raw ids are Snowflake
|
||||||
|
// values and must stay strings (never coerce to number).
|
||||||
|
const selectedIds = ref<Set<string>>(new Set())
|
||||||
|
const batchBusy = ref(false)
|
||||||
|
const allSelected = computed(
|
||||||
|
() => store.rawMaterials.length > 0
|
||||||
|
&& store.rawMaterials.every(r => selectedIds.value.has(String(r.id))),
|
||||||
|
)
|
||||||
|
|
||||||
|
function applyFilters() {
|
||||||
|
if (!store.currentKB) return
|
||||||
|
clearSelection()
|
||||||
|
void store.setRawFilters(store.currentKB.id, {
|
||||||
|
status: filterStatus.value,
|
||||||
|
sourceType: filterSourceType.value,
|
||||||
|
keyword: filterKeyword.value.trim(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debounce keyword typing so we don't refetch on every keystroke.
|
||||||
|
let keywordTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
watch(filterKeyword, () => {
|
||||||
|
if (keywordTimer) clearTimeout(keywordTimer)
|
||||||
|
keywordTimer = setTimeout(applyFilters, 350)
|
||||||
|
})
|
||||||
|
|
||||||
|
function clearFilters() {
|
||||||
|
filterStatus.value = ''
|
||||||
|
filterSourceType.value = ''
|
||||||
|
filterKeyword.value = ''
|
||||||
|
applyFilters()
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSelect(rawId: number | string) {
|
||||||
|
const key = String(rawId)
|
||||||
|
const next = new Set(selectedIds.value)
|
||||||
|
if (next.has(key)) next.delete(key)
|
||||||
|
else next.add(key)
|
||||||
|
selectedIds.value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSelectAll() {
|
||||||
|
if (allSelected.value) {
|
||||||
|
selectedIds.value = new Set()
|
||||||
|
} else {
|
||||||
|
selectedIds.value = new Set(store.rawMaterials.map(r => String(r.id)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSelection() {
|
||||||
|
selectedIds.value = new Set()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function batchReprocess() {
|
||||||
|
if (!store.currentKB || selectedIds.value.size === 0) return
|
||||||
|
const kbId = store.currentKB.id
|
||||||
|
batchBusy.value = true
|
||||||
|
try {
|
||||||
|
const res: any = await wikiApi.batchReprocessRaw(kbId, { ids: [...selectedIds.value] })
|
||||||
|
const d = res.data || res || {}
|
||||||
|
mcToast.success(t('wiki.batch.reprocessDone', { n: d.processed ?? 0 }))
|
||||||
|
clearSelection()
|
||||||
|
await store.fetchRawMaterials(kbId)
|
||||||
|
setTimeout(() => { store.fetchRawMaterials(kbId) }, 5000)
|
||||||
|
setTimeout(() => { store.fetchRawMaterials(kbId) }, 15000)
|
||||||
|
} catch (e) {
|
||||||
|
mcToast.error(e instanceof Error ? e.message : String(e))
|
||||||
|
} finally {
|
||||||
|
batchBusy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function batchDelete() {
|
||||||
|
if (!store.currentKB || selectedIds.value.size === 0) return
|
||||||
|
const ok = await mcConfirm({
|
||||||
|
title: t('wiki.batch.delete'),
|
||||||
|
message: t('wiki.batch.deleteConfirm', { n: selectedIds.value.size }),
|
||||||
|
confirmText: t('common.delete'),
|
||||||
|
tone: 'danger',
|
||||||
|
})
|
||||||
|
if (!ok) return
|
||||||
|
const kbId = store.currentKB.id
|
||||||
|
batchBusy.value = true
|
||||||
|
try {
|
||||||
|
const res: any = await wikiApi.batchDeleteRaw(kbId, { ids: [...selectedIds.value] })
|
||||||
|
const d = res.data || res || {}
|
||||||
|
mcToast.success(t('wiki.batch.deleteDone', { n: d.deleted ?? 0 }))
|
||||||
|
clearSelection()
|
||||||
|
await store.fetchRawMaterials(kbId)
|
||||||
|
} catch (e) {
|
||||||
|
mcToast.error(e instanceof Error ? e.message : String(e))
|
||||||
|
} finally {
|
||||||
|
batchBusy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function retryAllFailed() {
|
||||||
|
if (!store.currentKB) return
|
||||||
|
const kbId = store.currentKB.id
|
||||||
|
batchBusy.value = true
|
||||||
|
try {
|
||||||
|
const res: any = await wikiApi.batchReprocessRaw(kbId, { status: 'failed' })
|
||||||
|
const d = res.data || res || {}
|
||||||
|
mcToast.success(t('wiki.batch.reprocessDone', { n: d.processed ?? 0 }))
|
||||||
|
clearSelection()
|
||||||
|
await store.fetchRawMaterials(kbId)
|
||||||
|
setTimeout(() => { store.fetchRawMaterials(kbId) }, 5000)
|
||||||
|
setTimeout(() => { store.fetchRawMaterials(kbId) }, 15000)
|
||||||
|
} catch (e) {
|
||||||
|
mcToast.error(e instanceof Error ? e.message : String(e))
|
||||||
|
} finally {
|
||||||
|
batchBusy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Map a structured backend errorCode to a localized, user-friendly hint.
|
// Map a structured backend errorCode to a localized, user-friendly hint.
|
||||||
// Falls back to the raw backend message, then to a generic failure label, so
|
// Falls back to the raw backend message, then to a generic failure label, so
|
||||||
// the user always sees something meaningful — never a blank "failed" badge.
|
// the user always sees something meaningful — never a blank "failed" badge.
|
||||||
@ -567,6 +761,15 @@ watch(() => store.rawMaterials, (rows) => {
|
|||||||
}
|
}
|
||||||
}, { deep: true })
|
}, { deep: true })
|
||||||
|
|
||||||
|
// Prune batch selections that no longer exist after a refresh (deleted rows,
|
||||||
|
// or rows filtered out of the current view).
|
||||||
|
watch(() => store.rawMaterials, (rows) => {
|
||||||
|
if (selectedIds.value.size === 0) return
|
||||||
|
const present = new Set(rows.map(r => String(r.id)))
|
||||||
|
const next = new Set([...selectedIds.value].filter(id => present.has(id)))
|
||||||
|
if (next.size !== selectedIds.value.size) selectedIds.value = next
|
||||||
|
})
|
||||||
|
|
||||||
async function handleLocalRepair(rawId: number) {
|
async function handleLocalRepair(rawId: number) {
|
||||||
if (!store.currentKB) return
|
if (!store.currentKB) return
|
||||||
// For local repair, we'd need a page slug. For now, reprocess the raw material.
|
// For local repair, we'd need a page slug. For now, reprocess the raw material.
|
||||||
@ -987,4 +1190,69 @@ async function handleScanDir() {
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- Filter bar + batch operations (issue #506) ---- */
|
||||||
|
.raw-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.raw-filters {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.raw-filter-select,
|
||||||
|
.raw-filter-input {
|
||||||
|
padding: 5px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
border: 1px solid var(--mc-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--mc-bg-elevated);
|
||||||
|
color: var(--mc-text-primary);
|
||||||
|
}
|
||||||
|
.raw-filter-input { min-width: 140px; }
|
||||||
|
.raw-filter-select:focus,
|
||||||
|
.raw-filter-input:focus { outline: none; border-color: var(--mc-primary); }
|
||||||
|
.btn-text {
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--mc-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.btn-text:hover:not(:disabled) { background: var(--mc-bg-sunken); }
|
||||||
|
.btn-text:disabled { color: var(--mc-text-tertiary); cursor: not-allowed; }
|
||||||
|
.btn-danger-text { color: var(--mc-danger, #e05252); }
|
||||||
|
.raw-selection-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
background: var(--mc-bg-sunken);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.raw-select-all {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--mc-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.raw-selection-actions { display: inline-flex; align-items: center; gap: 4px; }
|
||||||
|
.raw-select-box {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-right: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -98,6 +98,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch, onMounted, nextTick } from 'vue'
|
import { ref, computed, watch, onMounted, nextTick } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
import { useWikiStore, isProtectedPage, type WikiPage } from '@/stores/useWikiStore'
|
import { useWikiStore, isProtectedPage, type WikiPage } from '@/stores/useWikiStore'
|
||||||
import { useWorkspaceStore } from '@/stores/useWorkspaceStore'
|
import { useWorkspaceStore } from '@/stores/useWorkspaceStore'
|
||||||
import { wikiApi } from '@/api/index'
|
import { wikiApi } from '@/api/index'
|
||||||
@ -110,6 +111,7 @@ import CitationDrawer from './CitationDrawer.vue'
|
|||||||
import ImageLightbox from './ImageLightbox.vue'
|
import ImageLightbox from './ImageLightbox.vue'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
const router = useRouter()
|
||||||
const store = useWikiStore()
|
const store = useWikiStore()
|
||||||
const workspace = useWorkspaceStore()
|
const workspace = useWorkspaceStore()
|
||||||
const { renderMarkdown } = useMarkdownRenderer()
|
const { renderMarkdown } = useMarkdownRenderer()
|
||||||
@ -280,7 +282,17 @@ onMounted(() => {
|
|||||||
const target = e.target as HTMLElement
|
const target = e.target as HTMLElement
|
||||||
if (target.classList.contains('wiki-link')) {
|
if (target.classList.contains('wiki-link')) {
|
||||||
const slug = target.dataset.slug
|
const slug = target.dataset.slug
|
||||||
if (slug) openPage(slug)
|
if (!slug) return
|
||||||
|
// Cross-KB link [[kbId/slug]] — data-kbid points at a different KB.
|
||||||
|
// Route through the query contract the Wiki view reads (?kbId&slug)
|
||||||
|
// so the store loads the target KB rather than the current one. IDs
|
||||||
|
// stay strings end-to-end (Snowflake precision).
|
||||||
|
const kbId = target.dataset.kbid
|
||||||
|
if (kbId && kbId !== String(store.currentKB?.id ?? '')) {
|
||||||
|
router.push({ name: 'Wiki', query: { kbId, slug } })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
openPage(slug)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@ -359,6 +371,10 @@ onMounted(() => {
|
|||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
.page-content :deep(.wiki-link.wiki-link-archived:hover) { color: var(--mc-text-secondary); }
|
.page-content :deep(.wiki-link.wiki-link-archived:hover) { color: var(--mc-text-secondary); }
|
||||||
|
/* Cross-KB target — clickable, routes to another knowledge base. Solid
|
||||||
|
underline distinguishes it from same-KB (dashed) links so readers know the
|
||||||
|
click leaves the current KB. */
|
||||||
|
.page-content :deep(.wiki-link.wiki-link-crosskb) { border-bottom-style: solid; }
|
||||||
/* Broken target — no click, no href, no request. Dashed underline + muted tone
|
/* Broken target — no click, no href, no request. Dashed underline + muted tone
|
||||||
tells the reader the wikilink couldn't be resolved without committing to
|
tells the reader the wikilink couldn't be resolved without committing to
|
||||||
navigation that would 404. Tooltip shows the rejection reason. */
|
navigation that would 404. Tooltip shows the rejection reason. */
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user