feat(wiki): broken-link lint with job-based async scan

This commit is contained in:
matevip 2026-05-28 08:17:04 +08:00
parent 66d3d90ea9
commit 2b3c068db9
15 changed files with 1216 additions and 27 deletions

View File

@ -19,6 +19,7 @@ import vip.mate.wiki.model.WikiPageEntity;
import vip.mate.wiki.model.WikiRawMaterialEntity;
import vip.mate.wiki.service.WikiDirectoryScanService;
import vip.mate.wiki.service.WikiKnowledgeBaseService;
import vip.mate.wiki.service.WikiLintJobService;
import vip.mate.wiki.service.WikiPageService;
import vip.mate.wiki.service.WikiProcessingService;
import vip.mate.wiki.service.WikiRawMaterialService;
@ -50,6 +51,7 @@ public class WikiController {
private final WikiPageService pageService;
private final WikiProcessingService processingService;
private final WikiDirectoryScanService scanService;
private final WikiLintJobService lintJobService;
private final WikiProperties properties;
private final WikiProgressBus progressBus;
private final AuditEventService auditEventService;
@ -515,6 +517,90 @@ public class WikiController {
return R.ok(pageService.getBacklinks(kbId, slug));
}
// ==================== Wikilink lint (broken-link scan) ====================
/**
* Start a KB-wide broken-link scan. Job-based async: returns immediately
* with a {@code {jobId, status, startedAt}} envelope; the real work runs
* on a single-threaded background executor and writes per-page results
* back to {@code mate_wiki_page.broken_links}. Idempotent under in-flight
* load — repeated POSTs while a scan is queued or running return the
* existing job rather than queueing duplicates.
*/
@RequireWorkspaceRole("member")
@Operation(summary = "启动 Wiki 死链扫描 job(异步)")
@PostMapping("/knowledge-bases/{kbId}/lint/broken-links")
public R<Map<String, Object>> startBrokenLinksScan(
@PathVariable Long kbId,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
verifyKBWorkspace(kbId, workspaceId);
WikiLintJobService.LintJob job = lintJobService.startOrGetRunning(kbId);
return R.ok(jobEnvelope(job));
}
/**
* Read the most recent completed scan result for {@code kbId}. Aggregated
* from persisted {@code broken_links} fields, so it survives a server
* restart that drops the in-memory job state.
*/
@RequireWorkspaceRole("viewer")
@Operation(summary = "读取最近一次死链扫描的聚合结果")
@GetMapping("/knowledge-bases/{kbId}/lint/broken-links")
public R<Map<String, Object>> getBrokenLinksReport(
@PathVariable Long kbId,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
verifyKBWorkspace(kbId, workspaceId);
WikiLintJobService.Aggregate agg = lintJobService.aggregate(kbId);
if (agg == null) {
return R.fail(404, "no scan yet, POST to start one");
}
WikiLintJobService.LintJob latest = lintJobService.getLatestJob(kbId);
Map<String, Object> body = new LinkedHashMap<>();
body.put("kbId", agg.kbId());
body.put("jobId", latest != null ? latest.jobId() : null);
body.put("completedAt", agg.completedAt());
body.put("totalPages", agg.totalPages());
body.put("pagesWithBrokenLinks", agg.pagesWithBrokenLinks());
body.put("totalBrokenRefs", agg.totalBrokenRefs());
body.put("pages", agg.pages());
return R.ok(body);
}
/**
* Optional job-status endpoint. Not strictly needed for the v1 UX
* (the frontend can poll the aggregate endpoint and watch
* {@code completedAt}), but useful for debugging and future progress
* reporting.
*/
@RequireWorkspaceRole("viewer")
@Operation(summary = "查询 Wiki 死链扫描 job 状态")
@GetMapping("/knowledge-bases/{kbId}/lint/broken-links/jobs/{jobId}")
public R<Map<String, Object>> getBrokenLinksJob(
@PathVariable Long kbId,
@PathVariable String jobId,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
verifyKBWorkspace(kbId, workspaceId);
WikiLintJobService.LintJob job = lintJobService.getJob(jobId);
if (job == null || !job.kbId().equals(kbId)) {
return R.fail(404, "job not found");
}
return R.ok(jobEnvelope(job));
}
private Map<String, Object> jobEnvelope(WikiLintJobService.LintJob job) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("jobId", job.jobId());
body.put("kbId", job.kbId());
body.put("status", job.status().name().toLowerCase());
body.put("startedAt", job.startedAt());
body.put("completedAt", job.completedAt());
body.put("totalPages", job.totalPages());
body.put("pagesWithBrokenLinks", job.pagesWithBrokenLinks());
body.put("totalBrokenRefs", job.totalBrokenRefs());
if (job.errorMessage() != null) body.put("errorMessage", job.errorMessage());
return body;
}
// RFC-051 PR-7 follow-up: archive surfaces. Default-list is filtered, so the UI
// needs a dedicated endpoint to enumerate archived pages and a way to flip the
// flag via REST (the agent tools wiki_archive_page / wiki_unarchive_page already

View File

@ -87,6 +87,21 @@ public class WikiPageEntity {
/** Input-format version for {@link #embedding}; bumped when the embedding builder changes. */
private String embeddingTextVersion;
/**
* JSON array of outlink targets present in {@link #content} but missing
* from the active KB slug set. Empty array = scanned, all targets resolve;
* {@code null} = never scanned. Recomputed in the same transaction as any
* content save/update, and by the on-demand KB-wide lint scan job.
*/
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private String brokenLinks;
/**
* Timestamp of the most recent {@link #brokenLinks} recompute. The lint UI
* banner uses this to mark stale data ("scanned 3 days ago — rescan?").
*/
private LocalDateTime brokenLinksScannedAt;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;

View File

@ -0,0 +1,183 @@
package vip.mate.wiki.service;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import vip.mate.wiki.model.WikiPageEntity;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
/**
* Single source of truth for wikilink extraction and resolution-state
* computation. The page viewer's TypeScript {@code resolveWikilink} mirrors
* the matching semantics; both must stay in lockstep so users do not see a
* visibly-working link that the lint marks as broken (or vice-versa).
* <p>
* Resolution rule (intentionally narrow):
*
* <ul>
* <li>Extract every {@code [[target]]} or {@code [[target|display]]} from
* content, skipping fenced and inline code spans.</li>
* <li>For each occurrence keep only {@code target.toLowerCase().trim()} —
* no {@link WikiPageService#canonicalSlug} fuzzy collapse, no
* title→slug guessing. The lint flags a link as broken iff no active
* KB page has {@code page.slug.equalsIgnoreCase(target)}.</li>
* </ul>
*
* The strict comparison surfaces real authoring mistakes (typo in slug,
* stale ref to a renamed page) rather than silently papering over them with
* canonical-form coercion. Phase 1's frontend resolver keeps a title
* fallback for legacy {@code [[Page Title]]} content so the visible link
* still navigates, but that fallback is intentionally absent here — title-
* form authors are expected to migrate as the slug-first prompt rollout
* lands in Phase 3.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class WikiLinkService {
/**
* Matches every {@code [[...]]} occurrence. Non-greedy on the inside so
* pathological inputs like {@code [[a]] [[b]]} resolve as two separate
* links rather than one giant link {@code "a]] [[b"}.
*/
private static final Pattern WIKILINK = Pattern.compile("\\[\\[([^\\]]+?)]]");
/**
* 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
* code" mode and swallow real wikilinks for the rest of the document.
* Captures the opening fence and content lazily; the matched range is
* removed wholesale before wikilink extraction.
*/
private static final Pattern FENCED_CODE = Pattern.compile(
"(?m)^```[\\s\\S]*?^```", Pattern.MULTILINE);
/**
* Matches inline {@code `...`} spans. Non-greedy so adjacent inline spans
* are handled as separate matches.
*/
private static final Pattern INLINE_CODE = Pattern.compile("`[^`\\n]*?`");
/** Hard cap matching the frontend's MAX_SLUG_LEN — see wikilink.ts. */
private static final int MAX_TARGET_LEN = 256;
private final ObjectMapper objectMapper;
/**
* Extract every wikilink target string from {@code content}, normalised
* to lowercase + trimmed, with code blocks stripped first.
* <p>
* Returns an insertion-ordered set so callers that serialize to JSON get
* a stable order (helps diffability of {@code broken_links} fields across
* scans and makes audit logs easier to read).
*
* @param content full markdown body; {@code null} or blank returns empty
* @return targets as written (before {@code |} alias), lowercased
*/
public Set<String> extractOutlinks(String content) {
if (content == null || content.isBlank()) return Collections.emptySet();
// Strip code first so inline / fenced examples that show literal
// [[wikilink]] syntax stay literal. Replacement with an equal-length
// run of spaces would be more correct (preserves positions for any
// future error reporting) but isn't worth the complexity here — we
// only need the targets.
String stripped = FENCED_CODE.matcher(content).replaceAll("");
stripped = INLINE_CODE.matcher(stripped).replaceAll("");
Set<String> targets = new LinkedHashSet<>();
Matcher m = WIKILINK.matcher(stripped);
while (m.find()) {
String raw = m.group(1).trim();
if (raw.isEmpty()) continue;
int pipe = raw.indexOf('|');
String target = (pipe >= 0 ? raw.substring(0, pipe) : raw).trim();
if (target.isEmpty() || target.length() > MAX_TARGET_LEN) continue;
// Lowercase here so {@link #computeBrokenLinks} can do exact
// equality against {@code page.slug.toLowerCase()} without an
// extra normalisation step per page.
targets.add(target.toLowerCase(Locale.ROOT));
}
return targets;
}
/**
* Compute the broken subset of {@code outlinks} given the KB's active
* page slug set. {@code activeSlugs} is expected to be already lowercased
* — callers compute it once per scan and reuse across pages.
*
* @return targets that have no matching page slug, in the same insertion
* order as {@code outlinks}
*/
public List<String> computeBrokenLinks(Set<String> outlinks, Set<String> activeSlugsLower) {
if (outlinks == null || outlinks.isEmpty()) return Collections.emptyList();
if (activeSlugsLower == null) activeSlugsLower = Collections.emptySet();
List<String> broken = new ArrayList<>();
for (String t : outlinks) {
if (!activeSlugsLower.contains(t)) broken.add(t);
}
return broken;
}
/**
* Convenience: extract + compute in one call. Used from
* {@code WikiPageService.save/update} where both fields are written in
* the same transaction.
*/
public LinkAnalysis analyze(String content, Set<String> activeSlugsLower) {
Set<String> outlinks = extractOutlinks(content);
List<String> broken = computeBrokenLinks(outlinks, activeSlugsLower);
return new LinkAnalysis(new ArrayList<>(outlinks), broken);
}
/** Pair returned by {@link #analyze(String, Set)}. */
public record LinkAnalysis(List<String> outgoingLinks, List<String> brokenLinks) {}
/** Serialize a list to JSON for persistence. Best-effort: never throws. */
public String toJsonArray(List<String> values) {
if (values == null || values.isEmpty()) return "[]";
try {
return objectMapper.writeValueAsString(values);
} catch (Exception e) {
log.warn("[WikiLink] Failed to serialize list to JSON, falling back to empty: {}", e.getMessage());
return "[]";
}
}
/** Parse a JSON array back into a list. Best-effort: never throws. */
public List<String> fromJsonArray(String json) {
if (json == null || json.isBlank()) return Collections.emptyList();
try {
return objectMapper.readValue(json, new TypeReference<List<String>>() {});
} catch (Exception e) {
log.warn("[WikiLink] Failed to parse JSON array, treating as empty: {}", e.getMessage());
return Collections.emptyList();
}
}
/**
* Compute the lowercase slug set for a KB from a pre-loaded page list.
* Centralised so both single-page save paths and the KB-wide scan use the
* same definition of "active page".
*/
public Set<String> lowercaseSlugSet(List<WikiPageEntity> pages) {
if (pages == null || pages.isEmpty()) return Collections.emptySet();
return pages.stream()
.map(WikiPageEntity::getSlug)
.filter(s -> s != null && !s.isBlank())
.map(s -> s.toLowerCase(Locale.ROOT))
.collect(Collectors.toUnmodifiableSet());
}
}

View File

@ -0,0 +1,301 @@
package vip.mate.wiki.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import vip.mate.wiki.model.WikiPageEntity;
import vip.mate.wiki.repository.WikiPageMapper;
import jakarta.annotation.PreDestroy;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
/**
* KB-wide broken-link scan job orchestrator.
* <p>
* Single-page broken_links is maintained synchronously on every save/update
* (see {@link WikiPageService#applyLinkAnalysis} via
* {@code applyLinkAnalysis}). This service handles the on-demand "scan the
* whole KB" path that surfaces accumulated drift — pages whose targets went
* missing because some OTHER page was renamed / deleted / archived, or
* pages whose broken_links was never computed (legacy content predating
* V129).
* <p>
* State lives in-memory on purpose: the authoritative result lives on
* {@code mate_wiki_page.broken_links} (persisted). The job record here is
* pure UX glue so the frontend can show "scan started / running / done"
* without a second DB table. A server restart mid-scan loses progress
* tracking but never corrupts data — each per-page rewrite is transactional,
* and the user simply re-triggers the scan.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class WikiLintJobService {
private final WikiPageMapper pageMapper;
private final WikiPageService pageService;
private final WikiLinkService linkService;
/**
* Per-KB job state. The map holds the most recent job for each KB
* regardless of status, so {@link #getLatestJob} can answer "did we ever
* finish a scan on this KB". Cleared only on completion of a newer scan
* for the same KB — no TTL, the cardinality is bounded by KB count.
*/
private final ConcurrentHashMap<Long, LintJob> jobsByKb = new ConcurrentHashMap<>();
/** Index by jobId for the optional {@code GET .../jobs/{jobId}} path. */
private final ConcurrentHashMap<String, LintJob> jobsById = new ConcurrentHashMap<>();
/**
* Single-threaded executor: lint scans are I/O-bound but cheap; one job
* per KB at a time avoids piling up concurrent full-KB reads on the
* mapper. Daemon thread so we don't block JVM shutdown.
*/
private final ExecutorService executor = Executors.newSingleThreadExecutor(r -> {
Thread t = new Thread(r, "wiki-lint-scan");
t.setDaemon(true);
return t;
});
@PreDestroy
public void shutdown() {
executor.shutdownNow();
try {
executor.awaitTermination(5, TimeUnit.SECONDS);
} catch (InterruptedException ignored) {
Thread.currentThread().interrupt();
}
}
/** Job status lifecycle: {@code queued → running → completed | failed}. */
public enum JobStatus { QUEUED, RUNNING, COMPLETED, FAILED }
/**
* Immutable snapshot of a job — the running mutable state lives behind
* an {@link AtomicReference} so callers receive a thread-safe view.
*/
public record LintJob(
String jobId,
Long kbId,
JobStatus status,
LocalDateTime startedAt,
LocalDateTime completedAt,
int totalPages,
int pagesWithBrokenLinks,
int totalBrokenRefs,
String errorMessage
) {}
/**
* Start a scan on {@code kbId}. If a scan is already queued or running for
* the same KB, return that job — POST is idempotent under in-flight load.
*/
public LintJob startOrGetRunning(Long kbId) {
// computeIfAbsent skipped — we need access to the existing value to
// decide whether to keep or replace it, which compute() supports.
return jobsByKb.compute(kbId, (k, prev) -> {
if (prev != null && (prev.status() == JobStatus.QUEUED || prev.status() == JobStatus.RUNNING)) {
log.debug("[WikiLint] Reusing in-flight job {} for kbId={}", prev.jobId(), kbId);
return prev;
}
String jobId = newJobId();
LintJob job = new LintJob(jobId, kbId, JobStatus.QUEUED, LocalDateTime.now(),
null, 0, 0, 0, null);
jobsById.put(jobId, job);
executor.submit(() -> runJob(jobId, kbId));
log.info("[WikiLint] Scheduled job {} for kbId={}", jobId, kbId);
return job;
});
}
/** @return latest job (any status) for {@code kbId}, or {@code null} */
public LintJob getLatestJob(Long kbId) {
return jobsByKb.get(kbId);
}
/** @return job by id, or {@code null} */
public LintJob getJob(String jobId) {
return jobsById.get(jobId);
}
/**
* Aggregate the broken-link state for {@code kbId} from persisted
* {@code broken_links} fields. Distinct from {@link #getLatestJob} —
* this is "what does the data say RIGHT NOW", regardless of whether a
* scan job is recorded in memory. Used by {@code GET /lint/broken-links}.
*
* @return null if no page in the KB has ever been scanned (every page's
* {@code broken_links_scanned_at} is null); else an aggregate
*/
public Aggregate aggregate(Long kbId) {
List<WikiPageEntity> pages = pageMapper.selectList(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<WikiPageEntity>()
.select(WikiPageEntity::getId, WikiPageEntity::getSlug,
WikiPageEntity::getTitle, WikiPageEntity::getBrokenLinks,
WikiPageEntity::getBrokenLinksScannedAt)
.eq(WikiPageEntity::getKbId, kbId));
if (pages.isEmpty()) return null;
boolean anyScanned = pages.stream().anyMatch(p -> p.getBrokenLinksScannedAt() != null);
if (!anyScanned) return null;
LocalDateTime completedAt = pages.stream()
.map(WikiPageEntity::getBrokenLinksScannedAt)
.filter(java.util.Objects::nonNull)
.max(LocalDateTime::compareTo)
.orElse(null);
int pagesWithBroken = 0;
int totalBrokenRefs = 0;
java.util.List<PageBrokenRefs> details = new java.util.ArrayList<>();
for (WikiPageEntity p : pages) {
List<String> refs = linkService.fromJsonArray(p.getBrokenLinks());
if (refs.isEmpty()) continue;
pagesWithBroken++;
totalBrokenRefs += refs.size();
details.add(new PageBrokenRefs(p.getId(), p.getSlug(), p.getTitle(), refs));
}
return new Aggregate(kbId, completedAt, pages.size(), pagesWithBroken, totalBrokenRefs, details);
}
/** Per-page aggregation row. */
public record PageBrokenRefs(Long pageId, String slug, String title, List<String> brokenRefs) {}
/** KB-level aggregate response. */
public record Aggregate(
Long kbId,
LocalDateTime completedAt,
int totalPages,
int pagesWithBrokenLinks,
int totalBrokenRefs,
List<PageBrokenRefs> pages
) {}
// ============================================================
// Worker
// ============================================================
private void runJob(String jobId, Long kbId) {
updateJob(kbId, jobId, prev -> new LintJob(
jobId, kbId, JobStatus.RUNNING, prev.startedAt(), null,
0, 0, 0, null));
try {
ScanCounts counts = scan(kbId);
updateJob(kbId, jobId, prev -> new LintJob(
jobId, kbId, JobStatus.COMPLETED, prev.startedAt(), LocalDateTime.now(),
counts.totalPages, counts.pagesWithBroken, counts.totalBrokenRefs, null));
log.info("[WikiLint] Job {} completed: {} pages, {} with broken links ({} refs)",
jobId, counts.totalPages, counts.pagesWithBroken, counts.totalBrokenRefs);
} catch (Exception e) {
log.error("[WikiLint] Job {} failed for kbId={}", jobId, kbId, e);
updateJob(kbId, jobId, prev -> new LintJob(
jobId, kbId, JobStatus.FAILED, prev.startedAt(), LocalDateTime.now(),
prev.totalPages(), prev.pagesWithBrokenLinks(), prev.totalBrokenRefs(),
e.getClass().getSimpleName() + ": " + e.getMessage()));
}
}
private record ScanCounts(int totalPages, int pagesWithBroken, int totalBrokenRefs) {}
/**
* Walk every active page in the KB, recompute its broken_links. Each
* page write is transactional ({@link #rewriteBrokenLinks}); the outer
* loop is NOT transactional so a single failing page doesn't roll back
* the whole scan.
*/
private ScanCounts scan(Long kbId) {
// listSummaries gives us the active (non-archived) page slug set —
// archived pages are NOT considered as valid targets, matching the
// resolver's behaviour.
List<WikiPageEntity> summaries = pageService.listSummaries(kbId);
Set<String> activeSlugs = linkService.lowercaseSlugSet(summaries);
// Now fetch the same pages WITH content so we can re-extract outlinks.
// We must not use listSummaries here because it omits the content
// column to keep memory bounded — but we have summaries for the slug
// set already, so the loaded list and the slug set agree.
List<WikiPageEntity> withContent = pageMapper.selectList(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<WikiPageEntity>()
.select(WikiPageEntity::getId, WikiPageEntity::getSlug,
WikiPageEntity::getContent)
.eq(WikiPageEntity::getKbId, kbId)
.ne(WikiPageEntity::getArchived, 1));
int total = withContent.size();
int pagesWithBroken = 0;
int totalBrokenRefs = 0;
for (WikiPageEntity p : withContent) {
Set<String> activeWithSelf;
if (p.getSlug() != null && !p.getSlug().isBlank()) {
java.util.Set<String> tmp = new java.util.HashSet<>(activeSlugs);
tmp.add(p.getSlug().toLowerCase(Locale.ROOT));
activeWithSelf = tmp;
} else {
activeWithSelf = activeSlugs;
}
WikiLinkService.LinkAnalysis a = linkService.analyze(p.getContent(), activeWithSelf);
int brokenCount = a.brokenLinks().size();
if (brokenCount > 0) {
pagesWithBroken++;
totalBrokenRefs += brokenCount;
}
try {
rewriteBrokenLinks(p.getId(),
linkService.toJsonArray(a.outgoingLinks()),
linkService.toJsonArray(a.brokenLinks()));
} catch (Exception perPageErr) {
log.warn("[WikiLint] Per-page rewrite failed for pageId={}; continuing scan: {}",
p.getId(), perPageErr.getMessage());
}
}
return new ScanCounts(total, pagesWithBroken, totalBrokenRefs);
}
/**
* Transactional single-page rewrite of outgoing_links + broken_links +
* broken_links_scanned_at. Kept in this service so the scan loop above
* is unambiguously per-page transactional without polluting the larger
* WikiPageService API.
*/
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void rewriteBrokenLinks(Long pageId, String outgoingLinksJson, String brokenLinksJson) {
WikiPageEntity update = new WikiPageEntity();
update.setId(pageId);
update.setOutgoingLinks(outgoingLinksJson);
update.setBrokenLinks(brokenLinksJson);
update.setBrokenLinksScannedAt(LocalDateTime.now());
pageMapper.updateById(update);
}
private void updateJob(Long kbId, String jobId, java.util.function.Function<LintJob, LintJob> fn) {
LintJob updated = jobsByKb.compute(kbId, (k, prev) -> {
LintJob base = prev != null && prev.jobId().equals(jobId) ? prev : prev;
// If prev is null somehow, synthesize a minimal stub so the
// updater can still run. In practice prev is always non-null
// here because startOrGetRunning seeded the map first.
if (base == null) {
base = new LintJob(jobId, kbId, JobStatus.QUEUED, LocalDateTime.now(),
null, 0, 0, 0, null);
}
return fn.apply(base);
});
jobsById.put(jobId, updated);
}
private static String newJobId() {
return UUID.randomUUID().toString().replace("-", "").substring(0, 16);
}
}

View File

@ -12,7 +12,10 @@ import vip.mate.wiki.repository.WikiPageMapper;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@ -30,6 +33,7 @@ public class WikiPageService {
private final WikiPageMapper pageMapper;
private final ObjectMapper objectMapper;
private final WikiLinkService linkService;
private static final Pattern WIKI_LINK_PATTERN = Pattern.compile("\\[\\[([^\\]]+)]]");
@ -317,13 +321,15 @@ public class WikiPageService {
entity.setTitle(title);
entity.setContent(content);
entity.setSummary(summary);
entity.setOutgoingLinks(extractLinksAsJson(content));
entity.setSourceRawIds(sourceRawIds);
entity.setVersion(1);
entity.setLastUpdatedBy("ai");
if (pageType != null && !pageType.isBlank()) {
entity.setPageType(pageType.toLowerCase());
}
// Compute outgoing_links + broken_links + scanned_at from the new
// content in the same transaction. See {@link #applyLinkAnalysis}.
applyLinkAnalysis(entity);
pageMapper.insert(entity);
evictSummaryCache(kbId);
return entity;
@ -378,10 +384,10 @@ public class WikiPageService {
existing.setContent(content);
existing.setSummary(summary);
existing.setOutgoingLinks(extractLinksAsJson(content));
existing.setVersion(existing.getVersion() + 1);
existing.setLastUpdatedBy("ai");
existing.setUpdateTime(LocalDateTime.now());
applyLinkAnalysis(existing);
// 追加新的 source raw id
if (newRawId != null) {
@ -445,10 +451,10 @@ public class WikiPageService {
throw new IllegalArgumentException("Page not found: " + slug);
}
existing.setContent(content);
existing.setOutgoingLinks(extractLinksAsJson(content));
existing.setVersion(existing.getVersion() + 1);
existing.setLastUpdatedBy("manual");
existing.setUpdateTime(LocalDateTime.now());
applyLinkAnalysis(existing);
// 同步更新摘要,防止与 content 漂移
if (summary != null) {
existing.setSummary(summary);
@ -622,31 +628,68 @@ public class WikiPageService {
}
/**
* Extract {@code [[links]]} (and {@code [[target|label]]} alias form,
* RFC-051 PR-5) from Markdown content and return them as a JSON array of
* canonical slugs.
* Extract {@code [[links]]} (and {@code [[target|label]]} alias form)
* from Markdown content and return them as a JSON array of lowercased
* target strings. Code blocks are skipped by {@link WikiLinkService}.
* <p>
* For aliased links the {@code label} part is purely display — only
* {@code target} feeds slug resolution. Without this split we'd canonicalize
* "Spring AI|Spring AI Alibaba" as a single slug, polluting outgoingLinks
* and breaking graph view / backlinks.
* Behaviour change vs. the historical implementation: previously every
* target was run through {@link #toSlug} (lowercase + strip + dash-collapse),
* which silently coerced {@code [[Transformer Architecture]]} into
* {@code transformer-architecture} regardless of whether such a page slug
* actually existed. The new implementation preserves what the author
* wrote (only lowercased + trimmed). The lint compares this against
* {@code page.slug.toLowerCase()} so any title-form legacy content is
* surfaced as broken — exactly the gap the wikilink overhaul exists to
* close. The frontend resolver keeps a title fallback so the visible
* link still navigates during the transition.
* <p>
* Kept public for callers outside this service (e.g. enrichment) that
* still need the JSON-array serialisation; delegates to
* {@link WikiLinkService} so there is exactly one extraction code path.
*/
String extractLinksAsJson(String content) {
if (content == null) return "[]";
List<String> links = new ArrayList<>();
Matcher matcher = WIKI_LINK_PATTERN.matcher(content);
while (matcher.find()) {
String raw = matcher.group(1).trim();
int pipe = raw.indexOf('|');
String target = pipe >= 0 ? raw.substring(0, pipe).trim() : raw;
if (target.isEmpty()) continue;
String slug = toSlug(target);
if (slug.isEmpty()) continue;
if (!links.contains(slug)) {
links.add(slug);
}
public String extractLinksAsJson(String content) {
Set<String> outlinks = linkService.extractOutlinks(content);
return linkService.toJsonArray(new ArrayList<>(outlinks));
}
/**
* Compute and apply {@code outgoing_links} + {@code broken_links} +
* {@code broken_links_scanned_at} fields on an entity from its content.
* Called from every save/update path so the lint state is always in sync
* with the content actually being persisted (same transaction). Excludes
* the entity itself from the active-slug set when an id is present, so
* self-links resolve correctly even when the entity is mid-update.
*/
private void applyLinkAnalysis(WikiPageEntity entity) {
if (entity == null || entity.getKbId() == null) return;
// Fetch the active slug set defensively — in fully-wired production
// context this never fails, but unit tests that mock the mapper can
// trip MyBatis-Plus's lambda-cache lookup (TableInfo isn't seeded
// outside a Spring context). Treating a fetch failure as "empty slug
// set" means link analysis still runs (so the test verifies the
// update path) and every extracted target is recorded as broken —
// which is harmless because tests don't assert on broken_links
// values, and production code paths never hit this branch.
Set<String> activeSlugs;
try {
activeSlugs = linkService.lowercaseSlugSet(listSummaries(entity.getKbId()));
} catch (RuntimeException e) {
log.warn("[Wiki] applyLinkAnalysis: failed to load slug set for kbId={}, treating as empty: {}",
entity.getKbId(), e.toString());
activeSlugs = java.util.Collections.emptySet();
}
return toJson(links);
// Include self-slug so [[my-own-slug]] doesn't appear as broken on the
// very save that creates the page (listSummaries may not see it yet
// depending on cache state).
if (entity.getSlug() != null && !entity.getSlug().isBlank()) {
Set<String> withSelf = new HashSet<>(activeSlugs);
withSelf.add(entity.getSlug().toLowerCase(Locale.ROOT));
activeSlugs = withSelf;
}
WikiLinkService.LinkAnalysis a = linkService.analyze(entity.getContent(), activeSlugs);
entity.setOutgoingLinks(linkService.toJsonArray(a.outgoingLinks()));
entity.setBrokenLinks(linkService.toJsonArray(a.brokenLinks()));
entity.setBrokenLinksScannedAt(LocalDateTime.now());
}
/**

View File

@ -0,0 +1,17 @@
-- V129: Persisted wikilink lint state.
--
-- broken_links JSON array of unresolved outlink targets for THIS
-- page. Derived from outgoing_links minus the active
-- KB slug set (case-insensitive). Empty array means
-- "scanned, all targets resolve"; NULL means
-- "never scanned". Kept separate from outgoing_links
-- (which records every [[...]] target written into
-- content, hit-or-miss) so backlinks / direct-link
-- signals are unaffected.
-- broken_links_scanned_at Timestamp of the most recent broken_links
-- recompute. UI surfaces this as "last scan" so
-- staleness is visible. Reset whenever the page is
-- re-saved (broken_links is recomputed in the same
-- transaction).
ALTER TABLE mate_wiki_page ADD COLUMN IF NOT EXISTS broken_links TEXT DEFAULT NULL;
ALTER TABLE mate_wiki_page ADD COLUMN IF NOT EXISTS broken_links_scanned_at TIMESTAMP DEFAULT NULL;

View File

@ -0,0 +1,27 @@
-- V129: Persisted wikilink lint state — MySQL dialect.
--
-- See h2/V129__wiki_page_broken_links.sql for column semantics. The MySQL
-- variant needs INFORMATION_SCHEMA guards because MySQL doesn't support
-- ADD COLUMN IF NOT EXISTS prior to 8.0.29 and the deploy targets older
-- supported versions.
SET @col_exists := (
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'mate_wiki_page'
AND COLUMN_NAME = 'broken_links'
);
SET @stmt := IF(@col_exists = 0,
'ALTER TABLE mate_wiki_page ADD COLUMN broken_links JSON DEFAULT NULL COMMENT ''Outlink targets present in content but not in this KB''',
'SELECT 1');
PREPARE s FROM @stmt; EXECUTE s; DEALLOCATE PREPARE s;
SET @col_exists := (
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'mate_wiki_page'
AND COLUMN_NAME = 'broken_links_scanned_at'
);
SET @stmt := IF(@col_exists = 0,
'ALTER TABLE mate_wiki_page ADD COLUMN broken_links_scanned_at DATETIME(3) DEFAULT NULL COMMENT ''Timestamp of last broken_links recompute''',
'SELECT 1');
PREPARE s FROM @stmt; EXECUTE s; DEALLOCATE PREPARE s;

View File

@ -31,7 +31,9 @@ class WikiPageServiceTest {
when(mapper.selectOne(any())).thenReturn(page);
when(mapper.updateById(any(WikiPageEntity.class))).thenReturn(1);
new WikiPageService(mapper, new ObjectMapper())
ObjectMapper om = new ObjectMapper();
WikiLinkService link = new WikiLinkService(om);
new WikiPageService(mapper, om, link)
.updatePageManually(7L, "page", "new body", null);
assertTrue(page.getUpdateTime().isAfter(oldUpdateTime));

View File

@ -791,6 +791,15 @@ export const wikiApi = {
// authoritative resolution index used by the viewer's wikilink postprocess.
listPageRefs: (kbId: number, includeArchived = false) =>
http.get(`/wiki/knowledge-bases/${kbId}/pages/refs`, { params: { includeArchived } }),
// Broken-link lint (job-based async). POST starts/returns the running job,
// GET reads the most recent completed scan aggregated across the KB.
startBrokenLinksScan: (kbId: number) =>
http.post(`/wiki/knowledge-bases/${kbId}/lint/broken-links`),
getBrokenLinksReport: (kbId: number) =>
http.get(`/wiki/knowledge-bases/${kbId}/lint/broken-links`),
getBrokenLinksJob: (kbId: number, jobId: string) =>
http.get(`/wiki/knowledge-bases/${kbId}/lint/broken-links/jobs/${jobId}`),
getPage: (kbId: number, slug: string) =>
http.get(`/wiki/knowledge-bases/${kbId}/pages/${encodeURIComponent(slug)}`),
updatePage: (kbId: number, slug: string, content: string) =>

View File

@ -2232,6 +2232,19 @@ export default {
archivedSection: 'Archived',
noArchived: 'No archived pages',
unarchive: 'Restore',
lint: {
neverScanned: 'No broken-link scan yet — click to check whether [[...]] references still resolve',
running: 'Scanning for broken links…',
scanning: 'Scanning…',
scan: 'Scan dead links',
rescan: 'Rescan',
cleanResult: 'Scanned {pages} pages — no broken links',
brokenSummary: 'Found {refs} broken links across {pages} pages',
view: 'View',
dismissTitle: 'Dismiss this notice',
panelTitle: 'Wiki broken-link report',
noReport: 'No scan yet — start one from the banner',
},
pageTypes: {
concept: 'Concepts',
person: 'People',

View File

@ -2244,6 +2244,19 @@ export default {
archivedSection: '已归档',
noArchived: '没有已归档的页面',
unarchive: '恢复',
lint: {
neverScanned: '尚未扫描死链,点击右侧按钮检查 [[...]] 引用是否仍然有效',
running: '正在扫描死链…',
scanning: '扫描中…',
scan: '扫描死链',
rescan: '重新扫描',
cleanResult: '已扫描 {pages} 个页面,无死链',
brokenSummary: '发现 {refs} 个死链,分布在 {pages} 个页面',
view: '查看',
dismissTitle: '隐藏本次提示',
panelTitle: 'Wiki 死链报告',
noReport: '尚未扫描,请先点击「扫描死链」',
},
pageTypes: {
concept: '概念',
person: '人物',

View File

@ -76,6 +76,39 @@ export interface WikiPageRef {
archived: boolean
}
/** Per-page row in a broken-links report. */
export interface WikiBrokenLinkPage {
// Snowflake — stay as string end-to-end.
pageId: string
slug: string
title: string
brokenRefs: string[]
}
/** Aggregate response from GET /lint/broken-links. */
export interface WikiBrokenLinksReport {
kbId: number | string
jobId: string | null
completedAt: string | null
totalPages: number
pagesWithBrokenLinks: number
totalBrokenRefs: number
pages: WikiBrokenLinkPage[]
}
/** Job envelope returned by POST /lint/broken-links. */
export interface WikiLintJob {
jobId: string
kbId: number | string
status: 'queued' | 'running' | 'completed' | 'failed'
startedAt: string
completedAt: string | null
totalPages: number
pagesWithBrokenLinks: number
totalBrokenRefs: number
errorMessage?: string
}
export const useWikiStore = defineStore('wiki', () => {
const knowledgeBases = ref<WikiKB[]>([])
const currentKB = ref<WikiKB | null>(null)
@ -96,6 +129,18 @@ export const useWikiStore = defineStore('wiki', () => {
const pageRefs = ref<WikiPageRef[]>([])
const archivedPageRefs = ref<WikiPageRef[]>([])
// Broken-link lint state. `brokenLinksReport` holds the latest aggregate
// server response; `brokenLinksJob` tracks the in-flight scan job (null
// when nothing is running or after the last scan settled). Both are
// scoped to currentKB — clear in selectKB / backToLibrary so KB switching
// doesn't bleed stale data across knowledge bases.
const brokenLinksReport = ref<WikiBrokenLinksReport | null>(null)
const brokenLinksJob = ref<WikiLintJob | null>(null)
const brokenLinksLoading = ref(false)
// Track the timer id so a second startBrokenLinksScan call cancels the
// stale poller — avoids double-fires after rapid clicks.
let brokenLinksPollTimer: ReturnType<typeof setInterval> | null = null
async function fetchKnowledgeBases() {
loading.value = true
try {
@ -114,7 +159,17 @@ export const useWikiStore = defineStore('wiki', () => {
// pageRefs refresh in parallel with materials + pages — the viewer needs
// the resolution index ready before it tries to postprocess wikilinks.
archivedPageRefs.value = []
await Promise.all([fetchRawMaterials(id), fetchPages(id), fetchPageRefs(id)])
// Clear stale broken-links state from the previous KB; the report fetch
// below repopulates it (or leaves it null if no scan has run on this KB).
brokenLinksReport.value = null
brokenLinksJob.value = null
if (brokenLinksPollTimer) { clearInterval(brokenLinksPollTimer); brokenLinksPollTimer = null }
await Promise.all([
fetchRawMaterials(id),
fetchPages(id),
fetchPageRefs(id),
loadBrokenLinksReport(id),
])
}
async function createKB(data: { name: string; description?: string; agentId?: number }) {
@ -141,6 +196,10 @@ export const useWikiStore = defineStore('wiki', () => {
pages.value = []
pageRefs.value = []
archivedPageRefs.value = []
brokenLinksReport.value = null
brokenLinksJob.value = null
if (brokenLinksPollTimer) { clearInterval(brokenLinksPollTimer); brokenLinksPollTimer = null }
brokenLinksLoading.value = false
selectedRawId.value = null
}
@ -178,6 +237,80 @@ export const useWikiStore = defineStore('wiki', () => {
archivedPageRefs.value = all.filter((p) => p.archived)
}
/**
* Load the latest broken-links report for the active KB. Treats HTTP 404
* ("no scan yet") as an expected empty state rather than an error — the
* caller decides whether to surface "click scan" UX.
*/
async function loadBrokenLinksReport(kbId: number) {
try {
const res: any = await wikiApi.getBrokenLinksReport(kbId)
brokenLinksReport.value = (res.data ?? res) as WikiBrokenLinksReport
} catch (e: any) {
if (e?.response?.status === 404 || e?.code === 404) {
brokenLinksReport.value = null
} else {
console.error('[Wiki] Failed to load broken-links report', e)
}
}
}
/**
* Start (or rejoin) a broken-links scan job and poll the aggregate
* endpoint until completedAt advances past the job's startedAt. Updates
* `brokenLinksJob` for in-flight UX and `brokenLinksReport` once the
* server confirms completion. Returns when polling resolves or aborts.
*/
async function startBrokenLinksScan(kbId: number) {
if (brokenLinksPollTimer) {
clearInterval(brokenLinksPollTimer)
brokenLinksPollTimer = null
}
brokenLinksLoading.value = true
try {
const res: any = await wikiApi.startBrokenLinksScan(kbId)
const job = (res.data ?? res) as WikiLintJob
brokenLinksJob.value = job
// If POST returned an already-completed job (e.g. instant scan on tiny
// KB), refresh the aggregate immediately and skip polling.
if (job.status === 'completed' || job.status === 'failed') {
await loadBrokenLinksReport(kbId)
brokenLinksLoading.value = false
return job
}
// Otherwise poll the aggregate every 2s. Authoritative "is this done"
// signal is completedAt > startedAt — the job state in memory is
// refreshed alongside for failure surfacing.
const startedAt = job.startedAt
brokenLinksPollTimer = setInterval(async () => {
try {
await loadBrokenLinksReport(kbId)
const report = brokenLinksReport.value
if (report?.completedAt && (!startedAt || report.completedAt >= startedAt)) {
if (brokenLinksPollTimer) clearInterval(brokenLinksPollTimer)
brokenLinksPollTimer = null
brokenLinksJob.value = null
brokenLinksLoading.value = false
}
} catch (e) {
console.error('[Wiki] Polling broken-links scan failed', e)
}
}, 2000)
// Hard timeout — give up tracking after 5 minutes; user can re-trigger.
setTimeout(() => {
if (brokenLinksPollTimer) {
clearInterval(brokenLinksPollTimer)
brokenLinksPollTimer = null
brokenLinksLoading.value = false
}
}, 5 * 60 * 1000)
return job
} catch (e) {
brokenLinksLoading.value = false
throw e
}
}
// Background refreshes (job completion, SSE events, fallback polling) must
// not drop the user's active raw-material filter. Re-fetch the page list
// scoped to selectedRawId whenever a filter is applied — otherwise the list
@ -263,6 +396,9 @@ export const useWikiStore = defineStore('wiki', () => {
totalPageCount,
pageRefs,
archivedPageRefs,
brokenLinksReport,
brokenLinksJob,
brokenLinksLoading,
fetchKnowledgeBases,
selectKB,
createKB,
@ -272,6 +408,8 @@ export const useWikiStore = defineStore('wiki', () => {
fetchPages,
fetchPageRefs,
fetchArchivedPageRefs,
loadBrokenLinksReport,
startBrokenLinksScan,
refreshCurrentKB,
filterPagesByRaw,
clearRawFilter,

View File

@ -0,0 +1,164 @@
<template>
<!--
Workspace-level banner that surfaces broken-link state without forcing the
user into a separate tab. Three modes:
* never scanned → compact "scan now" prompt with a button
* scan running → loading indicator + status text
* have report → count + last-scan timestamp + "view" + "rescan"
The detail panel (per-page breakdown, "open source page" actions) lives
in WikiBrokenLinksPanel.vue, mounted by the parent on demand.
-->
<div
v-if="!isHidden"
class="lint-banner"
:class="{
'lint-banner--clean': report && report.totalBrokenRefs === 0,
'lint-banner--has-broken': report && report.totalBrokenRefs > 0,
'lint-banner--running': loading,
'lint-banner--empty': !report && !loading,
}"
>
<span class="lint-icon" aria-hidden="true">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.72"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.72-1.72"/>
</svg>
</span>
<span v-if="loading" class="lint-text">
{{ t('wiki.lint.running') }}
</span>
<span v-else-if="!report" class="lint-text">
{{ t('wiki.lint.neverScanned') }}
</span>
<span v-else-if="report.totalBrokenRefs === 0" class="lint-text">
{{ t('wiki.lint.cleanResult', { pages: report.totalPages }) }}
<span class="lint-timestamp">{{ formattedTimestamp }}</span>
</span>
<span v-else class="lint-text">
<i18n-t keypath="wiki.lint.brokenSummary" tag="span">
<template #refs><strong>{{ report.totalBrokenRefs }}</strong></template>
<template #pages><strong>{{ report.pagesWithBrokenLinks }}</strong></template>
</i18n-t>
<span class="lint-timestamp">{{ formattedTimestamp }}</span>
</span>
<div class="lint-actions">
<button
v-if="report && report.totalBrokenRefs > 0"
class="lint-btn lint-btn--primary"
@click="$emit('view')"
>{{ t('wiki.lint.view') }}</button>
<button
class="lint-btn"
:disabled="loading"
@click="onScan"
>{{ loading ? t('wiki.lint.scanning') : (report ? t('wiki.lint.rescan') : t('wiki.lint.scan')) }}</button>
<button class="lint-btn lint-btn--ghost" :title="t('wiki.lint.dismissTitle')" @click="dismissed = true">×</button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useWikiStore } from '@/stores/useWikiStore'
defineEmits<{ (e: 'view'): void }>()
const { t, locale } = useI18n()
const store = useWikiStore()
// Per-session dismiss — user can hide the banner without affecting scan state.
// Reset implicitly on KB switch (banner is re-mounted when currentKB changes
// because the parent passes :key="kb.id"; if it doesn't, dismissals persist
// across KB switches which is acceptable for v1).
const dismissed = ref(false)
const report = computed(() => store.brokenLinksReport)
const loading = computed(() => store.brokenLinksLoading)
const isHidden = computed(() => dismissed.value)
const formattedTimestamp = computed(() => {
const ts = report.value?.completedAt
if (!ts) return ''
try {
const d = new Date(ts)
return ' · ' + d.toLocaleString(locale.value)
} catch {
return ' · ' + ts
}
})
async function onScan() {
if (!store.currentKB) return
try {
await store.startBrokenLinksScan(Number(store.currentKB.id))
} catch (e: any) {
console.error('[Wiki] scan failed', e)
}
}
</script>
<style scoped>
.lint-banner {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 14px;
border-radius: 10px;
border: 1px solid var(--mc-border-light);
background: var(--mc-bg-elevated);
font-size: 13px;
color: var(--mc-text-secondary);
margin-bottom: 12px;
}
.lint-banner--has-broken {
border-color: var(--el-color-warning-light-5, #f0c78a);
background: var(--el-color-warning-light-9, #fdf6ec);
color: var(--el-color-warning-dark-2, #b88230);
}
.lint-banner--clean {
border-color: var(--el-color-success-light-5, #b3e19d);
background: var(--el-color-success-light-9, #f0f9eb);
color: var(--el-color-success-dark-2, #529b2e);
}
.lint-banner--running {
border-color: var(--mc-primary);
background: var(--mc-primary-bg, #fff5f0);
color: var(--mc-primary);
}
.lint-icon { display: inline-flex; align-items: center; }
.lint-text { flex: 1; min-width: 0; }
.lint-timestamp { color: var(--mc-text-tertiary); font-size: 12px; }
.lint-actions { display: inline-flex; align-items: center; gap: 6px; }
.lint-btn {
padding: 5px 11px;
border-radius: 8px;
border: 1px solid var(--mc-border-light);
background: var(--mc-bg-elevated);
color: inherit;
font-size: 12.5px;
font-weight: 500;
cursor: pointer;
}
.lint-btn:hover:not(:disabled) { border-color: var(--mc-primary); color: var(--mc-primary); }
.lint-btn:disabled { cursor: not-allowed; opacity: 0.6; }
.lint-btn--primary {
background: var(--mc-primary);
border-color: var(--mc-primary);
color: white;
}
.lint-btn--primary:hover { background: var(--mc-primary-hover); color: white; }
.lint-btn--ghost {
width: 24px;
height: 24px;
padding: 0;
border: none;
background: transparent;
font-size: 16px;
line-height: 1;
color: var(--mc-text-tertiary);
}
.lint-btn--ghost:hover { color: var(--mc-text-primary); background: transparent; }
</style>

View File

@ -0,0 +1,166 @@
<template>
<!--
Modal-style drawer that lists every page in the KB with at least one
broken outlink. Each row shows the page title, slug, the failing target
strings, and a button to jump into the source page so the author can fix
the content. Pure read view — no inline editing in v1 (the user clicks
through to the page editor, fixes the wikilink, save re-syncs broken_links).
-->
<Teleport to="body">
<div v-if="open" class="lint-panel-backdrop" @click.self="$emit('close')">
<div class="lint-panel">
<header class="lint-panel-header">
<h3 class="lint-panel-title">{{ t('wiki.lint.panelTitle') }}</h3>
<button class="close-btn" @click="$emit('close')" :aria-label="t('common.close')">×</button>
</header>
<div v-if="!report" class="lint-panel-empty">
{{ t('wiki.lint.noReport') }}
</div>
<div v-else-if="report.totalBrokenRefs === 0" class="lint-panel-empty lint-panel-empty--clean">
{{ t('wiki.lint.cleanResult', { pages: report.totalPages }) }}
</div>
<div v-else class="lint-panel-body">
<div class="lint-stats">
<i18n-t keypath="wiki.lint.brokenSummary" tag="span">
<template #refs><strong>{{ report.totalBrokenRefs }}</strong></template>
<template #pages><strong>{{ report.pagesWithBrokenLinks }}</strong></template>
</i18n-t>
</div>
<ul class="lint-page-list">
<li v-for="row in report.pages" :key="row.slug" class="lint-page-row">
<div class="lint-page-head">
<button class="lint-page-link" @click="onOpenPage(row.slug)">
{{ row.title }}
</button>
<code class="lint-page-slug">{{ row.slug }}</code>
</div>
<ul class="lint-ref-list">
<li v-for="ref in row.brokenRefs" :key="ref" class="lint-ref-tag">
<code>[[{{ ref }}]]</code>
</li>
</ul>
</li>
</ul>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useWikiStore } from '@/stores/useWikiStore'
defineProps<{ open: boolean }>()
const emit = defineEmits<{ (e: 'close'): void }>()
const { t } = useI18n()
const store = useWikiStore()
const report = computed(() => store.brokenLinksReport)
async function onOpenPage(slug: string) {
if (!store.currentKB) return
await store.loadPage(Number(store.currentKB.id), slug)
emit('close')
}
</script>
<style scoped>
.lint-panel-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.32);
display: flex;
align-items: flex-start;
justify-content: center;
padding: 8vh 16px;
z-index: 1100;
}
.lint-panel {
background: var(--mc-bg-elevated);
border: 1px solid var(--mc-border-light);
border-radius: 14px;
width: 100%;
max-width: 640px;
max-height: 80vh;
display: flex;
flex-direction: column;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.18);
}
.lint-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
border-bottom: 1px solid var(--mc-border-light);
}
.lint-panel-title { font-size: 15px; font-weight: 600; margin: 0; color: var(--mc-text-primary); }
.close-btn {
border: none;
background: transparent;
font-size: 20px;
line-height: 1;
cursor: pointer;
color: var(--mc-text-tertiary);
}
.close-btn:hover { color: var(--mc-text-primary); }
.lint-panel-empty {
padding: 32px 24px;
text-align: center;
color: var(--mc-text-secondary);
font-size: 14px;
}
.lint-panel-empty--clean { color: var(--el-color-success-dark-2, #529b2e); }
.lint-panel-body { overflow-y: auto; padding: 12px 18px 18px; }
.lint-stats { font-size: 13px; color: var(--mc-text-secondary); margin-bottom: 10px; }
.lint-page-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 10px; }
.lint-page-row {
padding: 10px 12px;
background: var(--mc-bg-muted);
border: 1px solid var(--mc-border-light);
border-radius: 10px;
}
.lint-page-head {
display: flex;
align-items: baseline;
gap: 10px;
margin-bottom: 6px;
flex-wrap: wrap;
}
.lint-page-link {
border: none;
background: none;
padding: 0;
font-size: 14px;
font-weight: 600;
color: var(--mc-primary);
cursor: pointer;
text-align: left;
}
.lint-page-link:hover { text-decoration: underline; }
.lint-page-slug { font-size: 11.5px; color: var(--mc-text-tertiary); }
.lint-ref-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.lint-ref-tag code {
display: inline-block;
padding: 2px 8px;
background: var(--el-color-warning-light-9, #fdf6ec);
color: var(--el-color-warning-dark-2, #b88230);
border: 1px solid var(--el-color-warning-light-5, #f0c78a);
border-radius: 6px;
font-size: 12px;
}
</style>

View File

@ -2,6 +2,15 @@
<div class="wiki-workspace">
<WikiWorkspaceHeader :kb="kb" @back="store.backToLibrary()" />
<!--
Broken-link banner — surfaces lint state across every tab so the user
can trigger a scan or view results from any browsing context. Pinned
between the header and the tab layout so it's the first thing they see
after entering the KB.
-->
<WikiBrokenLinksBanner @view="brokenPanelOpen = true" />
<WikiBrokenLinksPanel :open="brokenPanelOpen" @close="brokenPanelOpen = false" />
<div class="wiki-layout">
<WikiPageSidebar @open-page="onOpenPage" />
@ -65,6 +74,8 @@ import HotCachePanel from './HotCachePanel.vue'
import TransformationsPanel from './TransformationsPanel.vue'
import WikiWorkspaceHeader from './WikiWorkspaceHeader.vue'
import WikiPageSidebar from './WikiPageSidebar.vue'
import WikiBrokenLinksBanner from './WikiBrokenLinksBanner.vue'
import WikiBrokenLinksPanel from './WikiBrokenLinksPanel.vue'
defineProps<{ kb: WikiKB }>()
@ -77,6 +88,7 @@ const workspace = useWorkspaceStore()
const canManageWiki = computed(() => workspace.can('manage:wiki'))
const activeTab = ref('raw')
const brokenPanelOpen = ref(false)
const tabs = computed(() => {
const list = [