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
* titleslug 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 = [