feat(content-studio): 交付即扫即记 + 内容日历只读页(自动化与打磨)

This commit is contained in:
mateaix 2026-07-12 11:51:12 +08:00
parent f184b94bcd
commit ac4b277492
20 changed files with 647 additions and 120 deletions

View File

@ -0,0 +1,46 @@
package vip.mate.content.controller;
import com.baomidou.mybatisplus.core.metadata.IPage;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import vip.mate.common.result.R;
import vip.mate.content.model.ContentItemEntity;
import vip.mate.content.service.ContentItemService;
import java.util.Map;
/**
* Read-only content calendar API lists produced 公众号 / 小红书 pieces and their
* lifecycle status, so operators can see what's drafted / packaged / published /
* pending. Writes happen through the tools ({@code content_item} + auto-record on
* delivery), not here.
*/
@Tag(name = "内容日历")
@RestController
@RequestMapping("/api/v1/content-items")
@RequiredArgsConstructor
public class ContentItemController {
private final ContentItemService contentItemService;
@Operation(summary = "内容日历分页列表")
@GetMapping
public R<IPage<ContentItemEntity>> list(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(required = false) String platform,
@RequestParam(required = false) String status) {
return R.ok(contentItemService.page(page, size, platform, status));
}
@Operation(summary = "内容日历状态计数")
@GetMapping("/summary")
public R<Map<String, Long>> summary() {
return R.ok(contentItemService.summary());
}
}

View File

@ -0,0 +1,129 @@
package vip.mate.content.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import vip.mate.content.model.ContentItemEntity;
import vip.mate.content.repository.ContentItemMapper;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Content calendar / dedup ledger service the single home for content-item
* logic, shared by {@code content_item} (the tool), the package tools (which
* auto-record on delivery), and the read-only content-calendar API.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class ContentItemService {
/** Statuses that count as "already covered" for dedup — a discarded draft doesn't. */
private static final List<String> COMMITTED_STATUSES = List.of("packaged", "published");
/** Ignore same-topic rows created within this window, so a recordcheck in one
* run doesn't flag itself as a repeat. */
private static final long SELF_MATCH_GUARD_MINUTES = 2;
private final ContentItemMapper contentItemMapper;
/**
* Recent committed items with the same topic fingerprint on this platform,
* within {@code days}, excluding just-created rows (self-match guard). Empty
* means "not a repeat".
*/
public List<ContentItemEntity> findRecent(String platform, String topic, int days) {
LocalDateTime now = LocalDateTime.now();
return contentItemMapper.selectList(new LambdaQueryWrapper<ContentItemEntity>()
.eq(ContentItemEntity::getPlatform, platform.trim().toLowerCase())
.eq(ContentItemEntity::getTopicFingerprint, fingerprint(topic))
.in(ContentItemEntity::getStatus, COMMITTED_STATUSES)
.ge(ContentItemEntity::getCreateTime, now.minusDays(days))
.lt(ContentItemEntity::getCreateTime, now.minusMinutes(SELF_MATCH_GUARD_MINUTES))
.orderByDesc(ContentItemEntity::getCreateTime));
}
/** Record a produced piece; returns the new item id. */
public Long record(Long workspaceId, String platform, String topic, String title,
String status, String previewUrl, String externalRef) {
ContentItemEntity e = new ContentItemEntity();
e.setWorkspaceId(workspaceId);
e.setPlatform(platform.trim().toLowerCase());
e.setTopic(topic != null ? topic.trim() : null);
e.setTopicFingerprint(fingerprint(topic != null ? topic : title));
e.setTitle(title != null ? title.trim() : null);
e.setStatus(status == null || status.isBlank() ? "packaged" : status.trim().toLowerCase());
e.setPreviewUrl(previewUrl);
e.setExternalRef(externalRef);
contentItemMapper.insert(e);
log.info("[ContentItem] recorded id={} ws={} platform={} status={} title='{}'",
e.getId(), workspaceId, e.getPlatform(), e.getStatus(), title);
return e.getId();
}
/** Flip an item to published. Returns false if the id is unknown. */
public boolean markPublished(Long id, String externalRef) {
ContentItemEntity e = contentItemMapper.selectById(id);
if (e == null) {
return false;
}
e.setStatus("published");
e.setPublishTime(LocalDateTime.now());
if (externalRef != null && !externalRef.isBlank()) {
e.setExternalRef(externalRef);
}
contentItemMapper.updateById(e);
log.info("[ContentItem] item {} marked published (ref={})", id, externalRef);
return true;
}
/** Paged content-calendar listing, newest first, optionally filtered by platform / status. */
public IPage<ContentItemEntity> page(int page, int size, String platform, String status) {
LambdaQueryWrapper<ContentItemEntity> w = new LambdaQueryWrapper<>();
if (platform != null && !platform.isBlank()) {
w.eq(ContentItemEntity::getPlatform, platform.trim().toLowerCase());
}
if (status != null && !status.isBlank()) {
w.eq(ContentItemEntity::getStatus, status.trim().toLowerCase());
}
w.orderByDesc(ContentItemEntity::getCreateTime);
int p = Math.max(1, page);
int s = Math.min(Math.max(1, size), 100);
return contentItemMapper.selectPage(new Page<>(p, s), w);
}
/** Counts by status (draft/packaged/published/failed) plus total, for the summary cards. */
public Map<String, Long> summary() {
Map<String, Long> m = new LinkedHashMap<>();
for (String s : List.of("draft", "packaged", "published", "failed")) {
m.put(s, contentItemMapper.selectCount(
new LambdaQueryWrapper<ContentItemEntity>().eq(ContentItemEntity::getStatus, s)));
}
m.put("total", contentItemMapper.selectCount(null));
return m;
}
/** Stable 32-hex fingerprint of the normalized topic (lowercased, alnum/CJK only). */
public static String fingerprint(String topic) {
String normalized = topic == null ? "" : topic.toLowerCase()
.replaceAll("[\\s\\p{Punct}\\u3000-\\u303F\\uFF00-\\uFFEF]+", "");
try {
byte[] hash = MessageDigest.getInstance("SHA-256")
.digest(normalized.getBytes(StandardCharsets.UTF_8));
StringBuilder hex = new StringBuilder();
for (int i = 0; i < 16; i++) {
hex.append(String.format("%02x", hash[i]));
}
return hex.toString();
} catch (Exception e) {
return Integer.toHexString(normalized.hashCode());
}
}
}

View File

@ -5,6 +5,8 @@ import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.stereotype.Component;
import java.util.Arrays;
/**
* Built-in tool: server-side compliance scan for 公众号 / 小红书 copy. Deterministic
* backstop for the model's skill-side self-check catches 广告法 极限词, WeChat 诱导
@ -25,8 +27,12 @@ public class ComplianceScanTool {
""")
public String compliance_scan(
@ToolParam(description = "Text to scan (title + body)")
String text) {
ComplianceScanner.Result result = ComplianceScanner.scan(text);
String text,
@ToolParam(description = "Extra banned words to enforce, comma-separated (e.g. recalled banned_words)", required = false)
String extraBannedWords) {
ComplianceScanner.Result result = (extraBannedWords == null || extraBannedWords.isBlank())
? ComplianceScanner.scan(text)
: ComplianceScanner.scan(text, Arrays.asList(extraBannedWords.split(",")));
log.info("[ComplianceScan] hits={}, highRisk={}", result.hits().size(), result.hasHighRisk());
return ComplianceScanner.report(result);
}

View File

@ -1,6 +1,7 @@
package vip.mate.tool.builtin;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@ -83,6 +84,34 @@ final class ComplianceScanner {
return new Result(hits);
}
/**
* Scan with additional user-supplied banned words merged in as a
* (non-high-risk) {@code 自定义禁用词} category e.g. the user's
* {@code banned_words} memory or brand-forbidden terms.
*/
static Result scan(String text, Collection<String> extraTerms) {
Result base = scan(text);
if (extraTerms == null || extraTerms.isEmpty() || text == null || text.isBlank()) {
return base;
}
List<String> hitTerms = new ArrayList<>();
for (String t : extraTerms) {
if (t == null) {
continue;
}
String term = t.trim();
if (!term.isEmpty() && text.contains(term) && !hitTerms.contains(term)) {
hitTerms.add(term);
}
}
if (hitTerms.isEmpty()) {
return base;
}
List<CategoryHit> all = new ArrayList<>(base.hits());
all.add(new CategoryHit("自定义禁用词", hitTerms, false));
return new Result(all);
}
/** Render a scan result as a short Chinese report. */
static String report(Result result) {
if (result.clean()) {

View File

@ -1,31 +1,30 @@
package vip.mate.tool.builtin;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Component;
import vip.mate.agent.context.ChatOrigin;
import vip.mate.content.model.ContentItemEntity;
import vip.mate.content.repository.ContentItemMapper;
import vip.mate.content.service.ContentItemService;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.LocalDateTime;
import java.util.List;
/**
* Built-in tool: the content calendar / dedup ledger for the content studio.
* Lets the daily scheduler avoid repeating a topic, records produced pieces, and
* marks them published so publishing is idempotent and auditable.
* Thin wrapper over {@link ContentItemService}; the package tools also call the
* service to auto-record on delivery.
*
* <p>Actions:
* <ul>
* <li>{@code check_recent} has this topic been produced on this platform in
* the last N days? Call BEFORE committing to a topic.</li>
* <li>{@code record} log a produced piece (draft/packaged) with its title and
* preview link.</li>
* <li>{@code mark_published} flip an item to published with its platform ref.</li>
* <li>{@code record} log a produced piece (usually done automatically by the
* package tools; available for manual use).</li>
* <li>{@code mark_published} flip an item to published.</li>
* </ul>
*/
@Slf4j
@ -35,7 +34,7 @@ public class ContentItemTool {
private static final int DEFAULT_RECENT_DAYS = 14;
private final ContentItemMapper contentItemMapper;
private final ContentItemService contentItemService;
@Tool(name = "content_item", description = """
Content calendar / dedup ledger for 公众号 & 小红书 pieces.
@ -43,13 +42,12 @@ public class ContentItemTool {
Actions:
- check_recent: has `topic` already been produced for `platform`
(gzh|xhs) within the last `days` (default 14)? Call this BEFORE picking
a topic in a scheduled run, to avoid repeats. Returns whether it's a
repeat plus recent titles.
- record: log a produced piece platform, topic, title, status
(draft|packaged|published, default packaged), optional previewUrl /
externalRef. Returns the item id.
- mark_published: set item `id` to published with optional externalRef
(draft media_id / publish id).
a topic in a scheduled run, to avoid repeats. Only counts committed
pieces (packaged/published). Returns whether it's a repeat plus titles.
- record: log a produced piece (platform, topic, title, status). NOTE:
gzh_package / xhs_package already auto-record on delivery, so you rarely
need this by hand.
- mark_published: set item `id` to published with optional externalRef.
""")
public String content_item(
@ToolParam(description = "Action: check_recent | record | mark_published")
@ -69,12 +67,13 @@ public class ContentItemTool {
@ToolParam(description = "Recency window in days for check_recent (default 14)", required = false)
Integer days,
@ToolParam(description = "Item id (mark_published)", required = false)
Long id) {
Long id,
@Nullable ToolContext ctx) {
String act = action == null ? "" : action.trim().toLowerCase();
return switch (act) {
case "check_recent" -> checkRecent(platform, topic, days);
case "record" -> record(platform, topic, title, status, previewUrl, externalRef);
case "record" -> record(platform, topic, title, status, previewUrl, externalRef, ctx);
case "mark_published" -> markPublished(id, externalRef);
default -> "Error: unknown action '" + act + "'. Use check_recent | record | mark_published.";
};
@ -85,14 +84,7 @@ public class ContentItemTool {
return "Error: platform and topic are required for check_recent.";
}
int window = (days == null || days <= 0) ? DEFAULT_RECENT_DAYS : days;
LocalDateTime cutoff = LocalDateTime.now().minusDays(window);
String fp = fingerprint(topic);
List<ContentItemEntity> recent = contentItemMapper.selectList(
new LambdaQueryWrapper<ContentItemEntity>()
.eq(ContentItemEntity::getPlatform, platform.trim().toLowerCase())
.eq(ContentItemEntity::getTopicFingerprint, fp)
.ge(ContentItemEntity::getCreateTime, cutoff)
.orderByDesc(ContentItemEntity::getCreateTime));
List<ContentItemEntity> recent = contentItemService.findRecent(platform, topic, window);
if (recent.isEmpty()) {
return "✅ 未重复:最近 " + window + " 天没有在 " + platform + " 做过「" + topic + "」,可以继续。";
}
@ -109,57 +101,27 @@ public class ContentItemTool {
}
private String record(String platform, String topic, String title, String status,
String previewUrl, String externalRef) {
String previewUrl, String externalRef, @Nullable ToolContext ctx) {
if (isBlank(platform) || isBlank(topic)) {
return "Error: platform and topic are required for record.";
}
ContentItemEntity e = new ContentItemEntity();
e.setPlatform(platform.trim().toLowerCase());
e.setTopic(topic.trim());
e.setTopicFingerprint(fingerprint(topic));
e.setTitle(title != null ? title.trim() : null);
e.setStatus(isBlank(status) ? "packaged" : status.trim().toLowerCase());
e.setPreviewUrl(previewUrl);
e.setExternalRef(externalRef);
contentItemMapper.insert(e);
log.info("[ContentItem] recorded id={} platform={} status={} title='{}'",
e.getId(), e.getPlatform(), e.getStatus(), title);
return "✅ 已记入内容日历。item id: " + e.getId() + "status=" + e.getStatus() + "";
Long id = contentItemService.record(workspaceFromContext(ctx), platform, topic, title,
status, previewUrl, externalRef);
return "✅ 已记入内容日历。item id: " + id + "status=" + (isBlank(status) ? "packaged" : status) + "";
}
private String markPublished(Long id, String externalRef) {
if (id == null) {
return "Error: id is required for mark_published.";
}
ContentItemEntity e = contentItemMapper.selectById(id);
if (e == null) {
return "Error: content item " + id + " not found.";
}
e.setStatus("published");
e.setPublishTime(LocalDateTime.now());
if (!isBlank(externalRef)) {
e.setExternalRef(externalRef);
}
contentItemMapper.updateById(e);
log.info("[ContentItem] item {} marked published (ref={})", id, externalRef);
return "✅ 已标记为已发布。item id: " + id;
return contentItemService.markPublished(id, externalRef)
? "✅ 已标记为已发布。item id: " + id
: "Error: content item " + id + " not found.";
}
/** Stable 32-hex fingerprint of the normalized topic (lowercased, alnum/CJK only). */
static String fingerprint(String topic) {
String normalized = topic == null ? "" : topic.toLowerCase()
.replaceAll("[\\s\\p{Punct}\\u3000-\\u303F\\uFF00-\\uFFEF]+", "");
try {
byte[] hash = MessageDigest.getInstance("SHA-256")
.digest(normalized.getBytes(StandardCharsets.UTF_8));
StringBuilder hex = new StringBuilder();
for (int i = 0; i < 16; i++) {
hex.append(String.format("%02x", hash[i]));
}
return hex.toString();
} catch (Exception e) {
return Integer.toHexString(normalized.hashCode());
}
private static Long workspaceFromContext(@Nullable ToolContext ctx) {
ChatOrigin origin = ChatOrigin.from(ctx);
return origin != null && origin.workspaceId() != null ? origin.workspaceId() : 1L;
}
private static boolean isBlank(String s) {

View File

@ -15,6 +15,8 @@ import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Component;
import vip.mate.agent.context.ChatOrigin;
import vip.mate.content.service.ContentItemService;
import vip.mate.tool.browser.UrlSafetyChecker;
import vip.mate.tool.document.GeneratedFileCache;
@ -69,6 +71,7 @@ public class GzhPackageTool {
private static final String CODE_BG = "#f6f8fa";
private final GeneratedFileCache cache;
private final ContentItemService contentItemService;
@Tool(name = "gzh_package", description = """
Package a finished WeChat Official Account (公众号) article and return an
@ -96,6 +99,8 @@ public class GzhPackageTool {
String coverImageUrl,
@ToolParam(description = "Author / source name", required = false)
String author,
@ToolParam(description = "Selected topic (for the content ledger; falls back to title)", required = false)
String topic,
@Nullable ToolContext ctx) {
if (title == null || title.isBlank()) {
@ -181,9 +186,25 @@ public class GzhPackageTool {
out.append("📦 素材下载article.html + article.md + 封面,").append(coverNote).append("")
.append(zipUrl).append('\n');
}
// Auto compliance scan on delivery never relies on the model calling it.
ComplianceScanner.Result scan = ComplianceScanner.scan(title + "\n" + markdown);
if (!scan.clean()) {
out.append('\n').append(ComplianceScanner.report(scan)).append('\n');
}
out.append("\n可将下面的内联样式 HTML 直接粘贴进公众号编辑器(如需直接进草稿箱,用 gzh_publish\n");
out.append("```html\n").append(container).append("\n```");
log.info("[GzhPackage] packaged '{}' ({} md chars, coverResolved={})", title, markdown.length(), cover != null);
// Auto-record into the content ledger the calendar is always populated.
try {
Long itemId = contentItemService.record(workspaceFromContext(ctx), "gzh",
topic != null && !topic.isBlank() ? topic : title.trim(),
title.trim(), "packaged", previewUrl, null);
out.append("\n🗓 已记入内容日历item id: ").append(itemId).append(")。");
} catch (Exception e) {
log.warn("[GzhPackage] auto-record failed: {}", e.getMessage());
}
log.info("[GzhPackage] packaged '{}' ({} md chars, coverResolved={}, complianceHits={})",
title, markdown.length(), cover != null, scan.hits().size());
return out.toString();
}
@ -332,6 +353,11 @@ public class GzhPackageTool {
}
}
private static Long workspaceFromContext(@Nullable ToolContext ctx) {
ChatOrigin origin = ChatOrigin.from(ctx);
return origin != null && origin.workspaceId() != null ? origin.workspaceId() : 1L;
}
private static boolean isImage(GeneratedFileCache.Entry e) {
return e.mimeType() != null && e.mimeType().startsWith("image/");
}

View File

@ -8,6 +8,8 @@ import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Component;
import vip.mate.agent.context.ChatOrigin;
import vip.mate.content.service.ContentItemService;
import vip.mate.tool.browser.UrlSafetyChecker;
import vip.mate.tool.document.GeneratedFileCache;
import vip.mate.tool.guard.WorkspacePathGuard;
@ -66,6 +68,7 @@ public class XhsPackageTool {
private static final String PAGE_BG = "#f4f4f4";
private final GeneratedFileCache cache;
private final ContentItemService contentItemService;
@Tool(name = "xhs_package", description = """
Package a Xiaohongshu (小红书) note into an IMAGE-FIRST online preview plus a
@ -94,6 +97,8 @@ public class XhsPackageTool {
String tags,
@ToolParam(description = "Comma-separated image references in display order (first = cover); >=3 required")
String images,
@ToolParam(description = "Selected topic (for the content ledger; falls back to title)", required = false)
String topic,
@Nullable ToolContext ctx) {
if (title == null || title.isBlank()) {
@ -156,8 +161,23 @@ public class XhsPackageTool {
if (!skipped.isEmpty()) {
out.append("⚠️ 未打包:").append(String.join("", skipped)).append('\n');
}
// Auto compliance scan on delivery never relies on the model calling it.
ComplianceScanner.Result scan = ComplianceScanner.scan(title + "\n" + (body == null ? "" : body));
if (!scan.clean()) {
out.append(ComplianceScanner.report(scan)).append('\n');
}
// Auto-record into the content ledger.
try {
Long itemId = contentItemService.record(workspaceFromContext(ctx), "xhs",
topic != null && !topic.isBlank() ? topic : title.trim(),
title.trim(), "packaged", previewUrl, null);
out.append("🗓️ 已记入内容日历item id: ").append(itemId).append(")。\n");
} catch (Exception e) {
log.warn("[XhsPackage] auto-record failed: {}", e.getMessage());
}
out.append('\n').append(guideText());
log.info("[XhsPackage] packaged '{}' ({} images, {} skipped)", title, imgs.size(), skipped.size());
log.info("[XhsPackage] packaged '{}' ({} images, {} skipped, complianceHits={})",
title, imgs.size(), skipped.size(), scan.hits().size());
return out.toString();
}
@ -309,6 +329,11 @@ public class XhsPackageTool {
5. 核对无违禁词后自行发布""".formatted(CREATOR_URL);
}
private static Long workspaceFromContext(@Nullable ToolContext ctx) {
ChatOrigin origin = ChatOrigin.from(ctx);
return origin != null && origin.workspaceId() != null ? origin.workspaceId() : 1L;
}
/** Last path segment of a reference, minus any {@code ?query} / {@code #fragment}. */
private static String lastSegment(String ref) {
String s = ref.split("[?#]")[0];

View File

@ -1,7 +1,7 @@
---
name: gzh_article
description: '公众号图文创作 / 推文 / 官方号文章 (official account article) — 端到端选题→搜集→成文→配图→去AI化→公众号内联样式排版→交付/草稿箱。honors user persona & style memory.'
version: 1.2.0
version: 1.3.0
tags:
- 公众号
- 图文
@ -102,13 +102,13 @@ gzh_package(title="<标题>", markdown="<正文 Markdown含小标题/列表/
`references/` 里的 `gzh_layout*.html` 是**服务端配色/排版的参考**`gzh_package` 已内置同款风格);只有当用户明确要**高度定制的特殊版式**、且篇幅不大时,才手写内联 HTML 并用 `render_html_image(html=...)` 出预览图,注意控制体量避免截断。
## 定时 / 批量场景:内容日历 + 合规硬闸
## 定时 / 批量场景:内容日历 + 合规
长期投产(尤其每日定时)时,多接两步,避免重复选题和违规发布
长期投产(尤其每日定时)时:
- **选题前查重**`content_item(action="check_recent", platform="gzh", topic="<选题>")`。命中"疑似重复"就换角度或另选。
- **发布前硬扫**:把标题+正文交给 `compliance_scan` 服务端扫一遍(极限词/诱导/承诺/功效)。命中高危词先替换——`gzh_publish` 进草稿箱时也会对高危词硬拦截
- **产出落台账**`gzh_package` / `gzh_publish` 完成后 `content_item(action="record", platform="gzh", title, previewUrl, status="packaged"|"draft")`;真正发表后 `content_item(action="mark_published", id, externalRef)`让每日不重题、发布可追溯。
- **选题前查重(务必在成文前)**`content_item(action="check_recent", platform="gzh", topic="<选题>")`。命中"疑似重复"就换角度或另选。只计已打包/已发布,不会把你这一轮刚产出的算进去。
- **交付即扫即记(自动,无需手动)**`gzh_package` 交付时会**自动**跑合规扫描(极限词/诱导/承诺/功效,报告并入返回)并**自动记入内容日历**。你只需在 `gzh_package` 里传上 `topic="<选题>"`(与 `title` 区分,用于台账去重指纹)。个人 / 品牌禁用词可另调 `compliance_scan(text, extraBannedWords="<recall 到的 banned_words逗号分隔>")`
- **发布**`gzh_publish` 进草稿箱时对高危词(极限/诱导/承诺)硬拦截;真正发表后 `content_item(action="mark_published", id, externalRef)`
## 保存自定义模板 / 对话升级技能

View File

@ -1,7 +1,7 @@
---
name: xhs_note
description: '小红书图文创作 / 笔记 / 种草文案 (xiaohongshu / red note) — 端到端:成文→配图(≥3 张竖版)→去AI化→在线预览打包交付。以图为主、文字辅助标题四件套 + 碎句正文 + 话题标签,配 3:4 竖版卡片,最少 3 张图。honors user persona & style memory.'
version: 1.2.0
version: 1.3.0
tags:
- 小红书
- 图文
@ -118,11 +118,11 @@ xhs_package(title="<标题>", body="<正文,含 emoji 与换行>",
## 定时 / 批量场景:内容日历 + 合规
长期投产(每日定时)时多接两步
长期投产(每日定时)时:
- **选题前查重**`content_item(action="check_recent", platform="xhs", topic="<选题>")`,命中就换角度。
- **打包前硬扫**:把标题+正文交给 `compliance_scan`(极限词/诱导/承诺/功效),命中先替换
- **产出落台账**`xhs_package` 完成后 `content_item(action="record", platform="xhs", title, previewUrl, status="packaged")`;用户手动上传发布后 `content_item(action="mark_published", id)`。这样能知道哪些已发、哪些还在待办。
- **选题前查重(成文前)**`content_item(action="check_recent", platform="xhs", topic="<选题>")`,命中就换角度。只计已打包/已发布。
- **交付即扫即记(自动)**`xhs_package` 交付时会**自动**跑合规扫描并**自动记入内容日历**——你只需在 `xhs_package` 里传上 `topic="<选题>"`(与 `title` 区分)。个人 / 品牌禁用词可另调 `compliance_scan(text, extraBannedWords="<recall 到的 banned_words>")`
- **上传后**:用户手动发布后 `content_item(action="mark_published", id)`,就能知道哪些已发、哪些还在待办。
## 保存自定义卡片模板 / 对话升级技能

View File

@ -3,6 +3,8 @@ package vip.mate.tool.builtin;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
/**
@ -43,6 +45,19 @@ class ComplianceScannerTest {
assertFalse(r.hasHighRisk(), "医疗功效 is a warning, not a hard block");
}
@Test
@DisplayName("extra banned words merge in as a non-high-risk 自定义禁用词 category")
void extraBannedWords() {
ComplianceScanner.Result r = ComplianceScanner.scan(
"这段文字提到了竞品X和内部代号Y", List.of("竞品X", "内部代号Y", "没出现的词"));
assertFalse(r.clean());
assertFalse(r.hasHighRisk(), "custom banned words are a warning, not a hard block");
String rep = ComplianceScanner.report(r);
assertTrue(rep.contains("自定义禁用词"));
assertTrue(rep.contains("竞品X") && rep.contains("内部代号Y"));
assertFalse(rep.contains("没出现的词"), "only actually-present terms are reported");
}
@Test
@DisplayName("clean copy scans clean")
void cleanCopy() {

View File

@ -1,50 +1,48 @@
package vip.mate.tool.builtin;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import vip.mate.content.model.ContentItemEntity;
import vip.mate.content.repository.ContentItemMapper;
import vip.mate.content.service.ContentItemService;
import java.time.LocalDateTime;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/**
* Pin {@link ContentItemTool}: the topic fingerprint is stable across cosmetic
* differences (so repeats are caught), and the check_recent / record /
* mark_published actions behave.
* Pin {@link ContentItemTool}: the fingerprint is stable across cosmetic
* differences, and check_recent / record / mark_published delegate correctly.
*/
class ContentItemToolTest {
private ContentItemMapper mapper;
private ContentItemService service;
private ContentItemTool tool;
@BeforeEach
void setUp() {
mapper = mock(ContentItemMapper.class);
tool = new ContentItemTool(mapper);
service = mock(ContentItemService.class);
tool = new ContentItemTool(service);
}
@Test
@DisplayName("fingerprint ignores case / whitespace / punctuation but distinguishes real topics")
void fingerprintStable() {
String a = ContentItemTool.fingerprint("周末咖啡探店");
String b = ContentItemTool.fingerprint(" 周末 咖啡,探店! ");
String a = ContentItemService.fingerprint("周末咖啡探店");
String b = ContentItemService.fingerprint(" 周末 咖啡,探店! ");
assertEquals(a, b, "cosmetic differences must collapse to the same fingerprint");
assertNotEquals(a, ContentItemTool.fingerprint("露营装备清单"), "different topics differ");
assertNotEquals(a, ContentItemService.fingerprint("露营装备清单"), "different topics differ");
}
@Test
@DisplayName("check_recent: empty history → not a repeat")
void checkRecentEmpty() {
when(mapper.selectList(any())).thenReturn(List.of());
when(service.findRecent(eq("gzh"), eq("周末咖啡探店"), anyInt())).thenReturn(List.of());
String out = tool.content_item("check_recent", "gzh", "周末咖啡探店",
null, null, null, null, 14, null);
null, null, null, null, 14, null, null);
assertTrue(out.contains("未重复"), out);
}
@ -55,42 +53,42 @@ class ContentItemToolTest {
prior.setTitle("上周那篇咖啡探店");
prior.setStatus("published");
prior.setCreateTime(LocalDateTime.now().minusDays(3));
when(mapper.selectList(any())).thenReturn(List.of(prior));
when(service.findRecent(eq("gzh"), eq("周末咖啡探店"), anyInt())).thenReturn(List.of(prior));
String out = tool.content_item("check_recent", "gzh", "周末咖啡探店",
null, null, null, null, 14, null);
null, null, null, null, 14, null, null);
assertTrue(out.contains("疑似重复"), out);
assertTrue(out.contains("上周那篇咖啡探店"), "should show the prior title");
}
@Test
@DisplayName("record: inserts a row and reports the item")
void recordInserts() {
@DisplayName("record: delegates to the service and reports the item id")
void recordDelegates() {
when(service.record(any(), eq("xhs"), eq("露营装备清单"), any(), any(), any(), any()))
.thenReturn(999L);
String out = tool.content_item("record", "xhs", "露营装备清单",
"新手露营必带的8样东西", "packaged", "http://x/preview", null, null, null);
verify(mapper, times(1)).insert(any(ContentItemEntity.class));
assertTrue(out.contains("已记入内容日历"), out);
"新手露营必带的8样东西", "packaged", "http://x/preview", null, null, null, null);
verify(service, times(1)).record(any(), eq("xhs"), eq("露营装备清单"),
eq("新手露营必带的8样东西"), eq("packaged"), eq("http://x/preview"), isNull());
assertTrue(out.contains("999"), out);
}
@Test
@DisplayName("mark_published: flips status and stamps publish time")
@DisplayName("mark_published: reports success / not-found from the service")
void markPublished() {
ContentItemEntity e = new ContentItemEntity();
e.setStatus("packaged");
when(mapper.selectById(123L)).thenReturn(e);
when(service.markPublished(123L, "media_abc")).thenReturn(true);
assertTrue(tool.content_item("mark_published", null, null, null, null,
null, "media_abc", null, 123L, null).contains("已标记为已发布"));
String out = tool.content_item("mark_published", null, null, null, null,
null, "media_abc", null, 123L);
assertTrue(out.contains("已标记为已发布"), out);
assertEquals("published", e.getStatus());
assertNotNull(e.getPublishTime());
verify(mapper).updateById(e);
when(service.markPublished(404L, null)).thenReturn(false);
assertTrue(tool.content_item("mark_published", null, null, null, null,
null, null, null, 404L, null).startsWith("Error:"));
}
@Test
@DisplayName("unknown action is rejected")
void unknownAction() {
assertTrue(tool.content_item("frobnicate", null, null, null, null, null, null, null, null)
assertTrue(tool.content_item("frobnicate", null, null, null, null, null, null, null, null, null)
.startsWith("Error:"));
}
}

View File

@ -4,11 +4,14 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import vip.mate.content.service.ContentItemService;
import vip.mate.tool.document.GeneratedFileCache;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/**
* Reproduce and pin the fix for the broken-cover bug: when the model references
@ -22,12 +25,14 @@ import static org.junit.jupiter.api.Assertions.*;
class GzhPackageCoverHealingTest {
private GeneratedFileCache cache;
private ContentItemService contentSvc;
private GzhPackageTool tool;
@BeforeEach
void setUp(@TempDir Path tempDir) {
cache = new GeneratedFileCache(tempDir);
tool = new GzhPackageTool(cache);
contentSvc = mock(ContentItemService.class);
tool = new GzhPackageTool(cache, contentSvc);
}
private static final String BODY = "## 小节一\n\n正文一段。\n\n## 小节二\n\n又一段。";
@ -42,6 +47,7 @@ class GzhPackageCoverHealingTest {
BODY,
"/api/v1/files/generated/cover_cat_7pits.png",
"内容工作室",
null,
null);
assertTrue(out.contains("/api/v1/files/generated/" + id),
@ -60,6 +66,7 @@ class GzhPackageCoverHealingTest {
BODY,
"/api/v1/files/generated/deadbeef-0000-0000-0000-000000000000",
"内容工作室",
null,
null);
assertTrue(out.contains("⚠️"), "an unresolved cover must be flagged; got:\n" + out);
@ -77,6 +84,7 @@ class GzhPackageCoverHealingTest {
BODY,
"/api/v1/files/generated/" + id,
"内容工作室",
null,
null);
assertTrue(out.contains("/api/v1/files/generated/" + id), "the cover id must be embedded");
@ -84,6 +92,27 @@ class GzhPackageCoverHealingTest {
assertFalse(out.contains("⚠️"), "a resolved cover must not warn");
}
@Test
@DisplayName("delivery auto-scans compliance and auto-records to the ledger")
void autoScanAndRecordOnDelivery() {
String id = cache.put("PNGBYTES".getBytes(), "cover.png", "image/png");
String out = tool.gzh_package(
"夏日凉拌菜",
"## 小节\n本店全国第一、集赞20个送好礼。", // 极限词 + 诱导 高危命中
"/api/v1/files/generated/" + id,
"内容工作室",
"夏天凉拌菜", // topic distinct from title
null);
// Compliance report is baked into the delivery output (not left to the model).
assertTrue(out.contains("合规扫描命中"), "auto-scan report must be in the output:\n" + out);
assertTrue(out.contains("广告法极限词") || out.contains("微信诱导"), "should flag the violation");
// Auto-record uses the topic (not the title) for the ledger fingerprint.
assertTrue(out.contains("已记入内容日历"), "auto-record note must appear");
verify(contentSvc, times(1)).record(any(), eq("gzh"), eq("夏天凉拌菜"),
eq("夏日凉拌菜"), eq("packaged"), any(), isNull());
}
@Test
@DisplayName("a non-image generated file referenced as cover → placeholder, not the non-image")
void nonImageReferenceFallsBackToPlaceholder() {
@ -93,6 +122,7 @@ class GzhPackageCoverHealingTest {
BODY,
"/api/v1/files/generated/" + id,
"内容工作室",
null,
null);
assertTrue(out.contains("⚠️"), "a non-image cover must be flagged");

View File

@ -4,8 +4,11 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import vip.mate.content.service.ContentItemService;
import vip.mate.tool.document.GeneratedFileCache;
import static org.mockito.Mockito.mock;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.regex.Matcher;
@ -26,7 +29,7 @@ class XhsPackageTest {
@BeforeEach
void setUp(@TempDir Path tempDir) {
cache = new GeneratedFileCache(tempDir);
tool = new XhsPackageTool(cache);
tool = new XhsPackageTool(cache, mock(ContentItemService.class));
}
private String putImg(String name) {
@ -45,7 +48,7 @@ class XhsPackageTest {
@DisplayName("fewer than 3 images → refused, no preview minted")
void refusesUnderThreeImages() {
String imgs = putImg("cover.png") + "," + putImg("c1.png");
String out = tool.xhs_package("夏日穿搭", "正文", "穿搭,夏天", imgs, null);
String out = tool.xhs_package("夏日穿搭", "正文", "穿搭,夏天", imgs, null, null);
assertTrue(out.contains("至少需要 3 张"), "should demand >=3 images; got:\n" + out);
assertFalse(out.contains("在线预览"), "must not produce a preview when refused");
}
@ -54,7 +57,7 @@ class XhsPackageTest {
@DisplayName("3 images → packaged; preview is image-first (images before the copy)")
void packagesThreeImagesImageFirst() {
String imgs = putImg("cover.png") + "," + putImg("c1.png") + "," + putImg("c2.png");
String out = tool.xhs_package("3天2夜厦门citywalk", "第一天去了鼓浪屿\n人不多", "厦门,citywalk,旅行", imgs, null);
String out = tool.xhs_package("3天2夜厦门citywalk", "第一天去了鼓浪屿\n人不多", "厦门,citywalk,旅行", imgs, null, null);
assertTrue(out.contains("在线预览"), "should return a preview link");
assertTrue(out.contains("素材下载"), "should return a material zip");
@ -76,7 +79,7 @@ class XhsPackageTest {
// First ref uses the filename (id pattern can't parse it); two more are valid.
String imgs = "/api/v1/files/generated/cover_xhs.png,"
+ putImg("c1.png") + "," + putImg("c2.png");
String out = tool.xhs_package("标题", "正文", "标签", imgs, null);
String out = tool.xhs_package("标题", "正文", "标签", imgs, null, null);
assertTrue(out.contains("在线预览"), "name-based ref should self-heal to reach >=3; got:\n" + out);
assertTrue(out.contains("3 张图"), "healed image should be counted");

View File

@ -187,6 +187,13 @@ export const chatApi = {
}
// ==================== Conversation ====================
// Content calendar (read-only) — produced 公众号 / 小红书 pieces + lifecycle status.
export const contentItemApi = {
list: (params?: { page?: number; size?: number; platform?: string; status?: string }) =>
http.get('/content-items', { params }),
summary: () => http.get('/content-items/summary'),
}
export const conversationApi = {
list: () => http.get('/conversations'),
/**

View File

@ -495,6 +495,27 @@ export default {
failed: 'Failed',
},
},
contentCalendar: {
title: 'Content Calendar',
subtitle: 'Ledger of produced 公众号 / 小红书 pieces — what is packaged, published, or pending upload (auto-recorded on delivery).',
total: 'All',
packaged: 'Packaged',
draft: 'Draft',
published: 'Published',
allPlatforms: 'All platforms',
refresh: 'Refresh',
platform: 'Platform',
colTitle: 'Title',
topic: 'Topic',
status: 'Status',
createTime: 'Created',
publishTime: 'Published',
st_draft: 'Draft',
st_packaged: 'Packaged',
st_published: 'Published',
st_failed: 'Failed',
st_unknown: 'Unknown',
},
nav: {
dashboard: 'Dashboard',
chat: 'Chat',
@ -505,6 +526,7 @@ export default {
workspace: 'Workspace',
agentContext: 'Agent Context',
skills: 'Skills',
contentCalendar: 'Content Calendar',
wiki: 'Wiki',
enterprise: 'Enterprise',
tools: 'Tools',

View File

@ -495,6 +495,27 @@ export default {
failed: '失败',
},
},
contentCalendar: {
title: '内容日历',
subtitle: '公众号 / 小红书 产出台账 —— 哪些已打包、已发布、待上传,一目了然(交付时自动登记)。',
total: '全部',
packaged: '已打包',
draft: '草稿',
published: '已发布',
allPlatforms: '全部平台',
refresh: '刷新',
platform: '平台',
colTitle: '标题',
topic: '选题',
status: '状态',
createTime: '创建时间',
publishTime: '发布时间',
st_draft: '草稿',
st_packaged: '已打包',
st_published: '已发布',
st_failed: '失败',
st_unknown: '未知',
},
nav: {
dashboard: '仪表盘',
chat: '对话',
@ -508,6 +529,7 @@ export default {
workspace: '工作区',
agentContext: '智能体上下文',
skills: '技能',
contentCalendar: '内容日历',
wiki: '知识库',
enterprise: '企业场景',
tools: '工具',

View File

@ -96,6 +96,12 @@ const router = createRouter({
component: () => import('@/views/SkillMarket.vue'),
meta: { title: 'Skills', requiredCapability: 'manage:skills' },
},
{
path: 'content-calendar',
name: 'ContentCalendar',
component: () => import('@/views/ContentCalendar.vue'),
meta: { title: 'Content Calendar', requiredCapability: 'manage:agents' },
},
// Tools 顶层入口已降级到 Settings ▸ Tools (Catalog) (RFC-090 Phase 1)
// 旧路径 /tools 由下方 redirect 兼容
{

View File

@ -23,9 +23,13 @@ declare module 'vue' {
ElIcon: typeof import('element-plus/es/components/icon/index')['ElIcon']
ElImageViewer: typeof import('element-plus/es/components/image-viewer/index')['ElImageViewer']
ElOption: typeof import('element-plus/es/components/select/index')['ElOption']
ElPagination: typeof import('element-plus/es/components/pagination/index')['ElPagination']
ElPopover: typeof import('element-plus/es/components/popover/index')['ElPopover']
ElSelect: typeof import('element-plus/es/components/select/index')['ElSelect']
ElSkeleton: typeof import('element-plus/es/components/skeleton/index')['ElSkeleton']
ElTable: typeof import('element-plus/es/components/table/index')['ElTable']
ElTableColumn: typeof import('element-plus/es/components/table/index')['ElTableColumn']
ElTag: typeof import('element-plus/es/components/tag/index')['ElTag']
ElTooltip: typeof import('element-plus/es/components/tooltip/index')['ElTooltip']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']

View File

@ -0,0 +1,191 @@
<template>
<div class="content-calendar">
<div class="page-header">
<h2>{{ t('contentCalendar.title') }}</h2>
<p class="subtitle">{{ t('contentCalendar.subtitle') }}</p>
</div>
<!-- Status summary cards -->
<div class="summary-cards">
<div
v-for="card in summaryCards"
:key="card.key"
class="summary-card"
:class="{ active: statusFilter === card.key }"
@click="toggleStatus(card.key)"
>
<div class="card-count">{{ card.count }}</div>
<div class="card-label">{{ card.label }}</div>
</div>
</div>
<!-- Filters -->
<div class="filters">
<el-select v-model="platformFilter" :placeholder="t('contentCalendar.allPlatforms')" clearable @change="reload">
<el-option label="公众号" value="gzh" />
<el-option label="小红书" value="xhs" />
</el-select>
<el-button :icon="RefreshIcon" @click="reload">{{ t('contentCalendar.refresh') }}</el-button>
</div>
<!-- Table -->
<el-table :data="rows" v-loading="loading" stripe class="calendar-table">
<el-table-column :label="t('contentCalendar.platform')" width="100">
<template #default="{ row }">
<el-tag size="small" :type="row.platform === 'gzh' ? 'success' : 'danger'">
{{ row.platform === 'gzh' ? '公众号' : '小红书' }}
</el-tag>
</template>
</el-table-column>
<el-table-column :label="t('contentCalendar.colTitle')" min-width="220">
<template #default="{ row }">
<a v-if="row.previewUrl" :href="row.previewUrl" target="_blank" rel="noopener" class="title-link">
{{ row.title || '(无标题)' }}
</a>
<span v-else>{{ row.title || '(无标题)' }}</span>
</template>
</el-table-column>
<el-table-column prop="topic" :label="t('contentCalendar.topic')" min-width="140" show-overflow-tooltip />
<el-table-column :label="t('contentCalendar.status')" width="120">
<template #default="{ row }">
<el-tag size="small" :type="statusTagType(row.status)">{{ statusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column :label="t('contentCalendar.createTime')" width="170">
<template #default="{ row }">{{ formatTime(row.createTime) }}</template>
</el-table-column>
<el-table-column :label="t('contentCalendar.publishTime')" width="170">
<template #default="{ row }">{{ formatTime(row.publishTime) || '—' }}</template>
</el-table-column>
</el-table>
<div class="pager">
<el-pagination
layout="prev, pager, next, total"
:total="total"
:page-size="pageSize"
:current-page="page"
@current-change="onPageChange"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, h } from 'vue'
import { useI18n } from 'vue-i18n'
import { contentItemApi } from '@/api'
const { t } = useI18n()
// Minimal inline refresh icon (avoids an extra import).
const RefreshIcon = () =>
h('svg', { width: 16, height: 16, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': 2 }, [
h('polyline', { points: '23 4 23 10 17 10' }),
h('path', { d: 'M20.49 15a9 9 0 1 1-2.12-9.36L23 10' }),
])
interface ContentItem {
id: string | number
platform: string
topic?: string
title?: string
status?: string
previewUrl?: string
createTime?: string
publishTime?: string
}
const rows = ref<ContentItem[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const loading = ref(false)
const platformFilter = ref('')
const statusFilter = ref('')
const summary = ref<Record<string, number>>({})
const summaryCards = computed(() => [
{ key: '', label: t('contentCalendar.total'), count: summary.value.total ?? 0 },
{ key: 'packaged', label: t('contentCalendar.packaged'), count: summary.value.packaged ?? 0 },
{ key: 'draft', label: t('contentCalendar.draft'), count: summary.value.draft ?? 0 },
{ key: 'published', label: t('contentCalendar.published'), count: summary.value.published ?? 0 },
])
function statusLabel(s?: string) {
return t(`contentCalendar.st_${s || 'unknown'}`)
}
function statusTagType(s?: string) {
return s === 'published' ? 'success' : s === 'draft' ? 'info' : s === 'failed' ? 'danger' : 'warning'
}
function formatTime(v?: string) {
if (!v) return ''
return v.replace('T', ' ').slice(0, 16)
}
async function loadSummary() {
try {
const res: any = await contentItemApi.summary()
summary.value = res.data || res || {}
} catch {
summary.value = {}
}
}
async function reload() {
loading.value = true
try {
const res: any = await contentItemApi.list({
page: page.value,
size: pageSize.value,
platform: platformFilter.value || undefined,
status: statusFilter.value || undefined,
})
const data = res.data || res
rows.value = data.records || data.list || []
total.value = Number(data.total ?? rows.value.length)
} catch {
// Degrade gracefully (e.g. backend not yet restarted with the new endpoint).
rows.value = []
total.value = 0
} finally {
loading.value = false
}
}
function toggleStatus(key: string) {
statusFilter.value = statusFilter.value === key ? '' : key
page.value = 1
reload()
}
function onPageChange(p: number) {
page.value = p
reload()
}
onMounted(() => {
loadSummary()
reload()
})
</script>
<style scoped>
.content-calendar { padding: 20px; max-width: 1200px; margin: 0 auto; }
.page-header h2 { margin: 0 0 4px; font-size: 20px; }
.subtitle { margin: 0 0 16px; color: var(--el-text-color-secondary); font-size: 13px; }
.summary-cards { display: flex; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
.summary-card {
flex: 1; min-width: 120px; padding: 14px 16px; border-radius: 10px;
background: var(--el-fill-color-light); cursor: pointer; transition: all .15s;
border: 1px solid transparent;
}
.summary-card:hover { background: var(--el-fill-color); }
.summary-card.active { border-color: var(--el-color-primary); }
.card-count { font-size: 24px; font-weight: 700; }
.card-label { font-size: 13px; color: var(--el-text-color-secondary); margin-top: 2px; }
.filters { display: flex; gap: 10px; margin-bottom: 12px; }
.calendar-table { width: 100%; }
.title-link { color: var(--el-color-primary); text-decoration: none; }
.title-link:hover { text-decoration: underline; }
.pager { margin-top: 14px; display: flex; justify-content: flex-end; }
</style>

View File

@ -510,6 +510,12 @@ const navGroups = computed(() => [
icon: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>`,
requiredCapability: 'manage:skills',
},
{
path: '/content-calendar',
label: t('nav.contentCalendar'),
icon: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>`,
requiredCapability: 'manage:agents',
},
{
path: '/plugins',
label: t('nav.plugins'),