mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +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.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@ -509,13 +512,19 @@ public class WikiController {
|
||||
// ==================== Raw Materials ====================
|
||||
|
||||
@RequireWorkspaceRole("viewer")
|
||||
@Operation(summary = "获取原始材料列表(含每条材料生成的页面数)")
|
||||
@Operation(summary = "获取原始材料列表(含每条材料生成的页面数),支持按状态/类型/关键词/时间筛选")
|
||||
@GetMapping("/knowledge-bases/{kbId}/raw")
|
||||
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) {
|
||||
verifyKBWorkspace(kbId, workspaceId);
|
||||
List<WikiRawMaterialEntity> raws = rawService.listByKbId(kbId);
|
||||
List<Map<String, Object>> result = new java.util.ArrayList<>(raws.size());
|
||||
List<WikiRawMaterialEntity> raws = rawService.listByKbIdFiltered(
|
||||
kbId, status, sourceType, keyword, parseTime(startTime, false), parseTime(endTime, true));
|
||||
List<Map<String, Object>> result = new ArrayList<>(raws.size());
|
||||
for (WikiRawMaterialEntity raw : raws) {
|
||||
Map<String, Object> item = new LinkedHashMap<>();
|
||||
// Serialize all entity fields via Jackson-friendly approach
|
||||
@ -648,6 +657,137 @@ public class WikiController {
|
||||
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")
|
||||
@Operation(summary = "下载原始材料")
|
||||
@GetMapping("/knowledge-bases/{kbId}/raw/{rawId}/download")
|
||||
|
||||
@ -55,6 +55,15 @@ public class WikiLinkService {
|
||||
*/
|
||||
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
|
||||
* 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();
|
||||
List<String> broken = new ArrayList<>();
|
||||
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);
|
||||
}
|
||||
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
|
||||
* {@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.security.MessageDigest;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import vip.mate.wiki.dto.WikiFailureItem;
|
||||
@ -64,9 +65,40 @@ public class WikiRawMaterialService {
|
||||
private final Set<Long> partialResumeIds = ConcurrentHashMap.newKeySet();
|
||||
|
||||
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(
|
||||
new LambdaQueryWrapper<WikiRawMaterialEntity>()
|
||||
.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));
|
||||
// 不返回大文本字段
|
||||
list.forEach(r -> {
|
||||
@ -80,6 +112,23 @@ public class WikiRawMaterialService {
|
||||
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) {
|
||||
return rawMapper.selectOne(
|
||||
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}`),
|
||||
|
||||
// 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 }) =>
|
||||
http.post(`/wiki/knowledge-bases/${kbId}/raw/text`, data),
|
||||
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`),
|
||||
cancelRaw: (kbId: number, rawId: number) =>
|
||||
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) =>
|
||||
http.get<Blob>(`/wiki/knowledge-bases/${kbId}/raw/${rawId}/download`, {
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@ -16,6 +16,7 @@ export type SSEEventType =
|
||||
| 'message_start'
|
||||
// Agent 事件
|
||||
| 'tool_call_started'
|
||||
| 'tool_call_progress'
|
||||
| 'tool_call_completed'
|
||||
| 'phase'
|
||||
| 'plan_created'
|
||||
|
||||
@ -63,6 +63,17 @@ export function useGlobalWikilinkClick() {
|
||||
e.preventDefault()
|
||||
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 {
|
||||
// Pass both — backend matches slug first, falls back to title. For
|
||||
// a bracket like `[[StateGraph]]` the captured "title" is actually
|
||||
|
||||
@ -31,6 +31,7 @@ export interface WikilinkRef {
|
||||
/** Result of resolving a single `[[...]]` target string. */
|
||||
export type WikilinkResolution =
|
||||
| { 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' }
|
||||
|
||||
/**
|
||||
@ -61,6 +62,14 @@ const MAX_SLUG_LEN = 256
|
||||
/** `[[...]]` matcher used during text-node walking. Non-greedy. */
|
||||
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.
|
||||
*
|
||||
@ -115,6 +124,19 @@ export function resolveWikilink(
|
||||
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 lookupTitle = target.trim().toLowerCase()
|
||||
|
||||
@ -168,6 +190,20 @@ function buildLinkElement(
|
||||
a.textContent = resolution.display
|
||||
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')
|
||||
span.className = 'wiki-link-broken'
|
||||
span.setAttribute('title', `Target not found (${resolution.reason})`)
|
||||
|
||||
@ -2567,6 +2567,22 @@ export default {
|
||||
cancelled: 'CANCELLED',
|
||||
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.
|
||||
// The raw exception text is kept as the hover tooltip for troubleshooting.
|
||||
errorCode: {
|
||||
|
||||
@ -2579,6 +2579,22 @@ export default {
|
||||
cancelled: '已取消',
|
||||
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.
|
||||
// The raw exception text is kept as the hover tooltip for troubleshooting.
|
||||
errorCode: {
|
||||
|
||||
@ -148,6 +148,16 @@ export const useWikiStore = defineStore('wiki', () => {
|
||||
const selectedRawId = ref<number | null>(null)
|
||||
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
|
||||
// grouping, graph colouring and the transformation editor can render the
|
||||
// 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 }
|
||||
brokenLinksLoading.value = false
|
||||
selectedRawId.value = null
|
||||
rawFilters.value = { status: '', sourceType: '', keyword: '' }
|
||||
pageTypeProfile.value = null
|
||||
}
|
||||
|
||||
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 || []
|
||||
}
|
||||
|
||||
// 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) {
|
||||
const res: any = await wikiApi.listPages(kbId, rawId ?? undefined)
|
||||
pages.value = res.data || []
|
||||
@ -474,6 +507,9 @@ export const useWikiStore = defineStore('wiki', () => {
|
||||
loading,
|
||||
selectedRawId,
|
||||
totalPageCount,
|
||||
rawFilters,
|
||||
setRawFilters,
|
||||
resetRawFilters,
|
||||
pageTypeProfile,
|
||||
pageRefs,
|
||||
archivedPageRefs,
|
||||
|
||||
@ -97,8 +97,61 @@
|
||||
<h4 class="raw-list-title">
|
||||
{{ t('wiki.rawMaterials') }} ({{ store.rawMaterials.length + uploadingFiles.length }})
|
||||
</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">
|
||||
{{ t('wiki.noRawMaterials') }}
|
||||
{{ hasActiveFilter ? t('wiki.batch.noMatch') : t('wiki.noRawMaterials') }}
|
||||
</div>
|
||||
|
||||
<!-- Optimistic uploading items shown at the top -->
|
||||
@ -156,6 +209,13 @@
|
||||
@click="toggleRawFilter(raw.id)"
|
||||
>
|
||||
<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">
|
||||
<span class="raw-item-title">{{ raw.title }}</span>
|
||||
<span class="raw-item-type">{{ raw.sourceType }}</span>
|
||||
@ -316,6 +376,7 @@
|
||||
import { ref, reactive, computed, watch, onBeforeUnmount } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { mcToast } from '@/composables/useMcToast'
|
||||
import { mcConfirm } from '@/components/common/useConfirm'
|
||||
import { useFileDrop } from '@/composables/useFileDrop'
|
||||
import { Download } from '@element-plus/icons-vue'
|
||||
import { useWikiStore } from '@/stores/useWikiStore'
|
||||
@ -333,6 +394,139 @@ const workspace = useWorkspaceStore()
|
||||
const canManageWiki = computed(() => workspace.can('manage:wiki'))
|
||||
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.
|
||||
// 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.
|
||||
@ -567,6 +761,15 @@ watch(() => store.rawMaterials, (rows) => {
|
||||
}
|
||||
}, { 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) {
|
||||
if (!store.currentKB) return
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- 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>
|
||||
|
||||
@ -98,6 +98,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, nextTick } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useWikiStore, isProtectedPage, type WikiPage } from '@/stores/useWikiStore'
|
||||
import { useWorkspaceStore } from '@/stores/useWorkspaceStore'
|
||||
import { wikiApi } from '@/api/index'
|
||||
@ -110,6 +111,7 @@ import CitationDrawer from './CitationDrawer.vue'
|
||||
import ImageLightbox from './ImageLightbox.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const store = useWikiStore()
|
||||
const workspace = useWorkspaceStore()
|
||||
const { renderMarkdown } = useMarkdownRenderer()
|
||||
@ -280,7 +282,17 @@ onMounted(() => {
|
||||
const target = e.target as HTMLElement
|
||||
if (target.classList.contains('wiki-link')) {
|
||||
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;
|
||||
}
|
||||
.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
|
||||
tells the reader the wikilink couldn't be resolved without committing to
|
||||
navigation that would 404. Tooltip shows the rejection reason. */
|
||||
|
||||
Loading…
Reference in New Issue
Block a user