diff --git a/mateclaw-server/src/main/java/vip/mate/content/controller/ContentItemController.java b/mateclaw-server/src/main/java/vip/mate/content/controller/ContentItemController.java new file mode 100644 index 00000000..ebbfcc60 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/content/controller/ContentItemController.java @@ -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> 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> summary() { + return R.ok(contentItemService.summary()); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/content/service/ContentItemService.java b/mateclaw-server/src/main/java/vip/mate/content/service/ContentItemService.java new file mode 100644 index 00000000..b15d2c14 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/content/service/ContentItemService.java @@ -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 COMMITTED_STATUSES = List.of("packaged", "published"); + /** Ignore same-topic rows created within this window, so a record→check 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 findRecent(String platform, String topic, int days) { + LocalDateTime now = LocalDateTime.now(); + return contentItemMapper.selectList(new LambdaQueryWrapper() + .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 page(int page, int size, String platform, String status) { + LambdaQueryWrapper 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 summary() { + Map m = new LinkedHashMap<>(); + for (String s : List.of("draft", "packaged", "published", "failed")) { + m.put(s, contentItemMapper.selectCount( + new LambdaQueryWrapper().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()); + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/ComplianceScanTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/ComplianceScanTool.java index 3c69677c..f3e95691 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/ComplianceScanTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/ComplianceScanTool.java @@ -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); } diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/ComplianceScanner.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/ComplianceScanner.java index 9d0bbc45..a5ef2644 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/ComplianceScanner.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/ComplianceScanner.java @@ -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 extraTerms) { + Result base = scan(text); + if (extraTerms == null || extraTerms.isEmpty() || text == null || text.isBlank()) { + return base; + } + List 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 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()) { diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/ContentItemTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/ContentItemTool.java index 37ce9725..a7bae7b3 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/ContentItemTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/ContentItemTool.java @@ -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. * *

Actions: *

    *
  • {@code check_recent} — has this topic been produced on this platform in * the last N days? Call BEFORE committing to a topic.
  • - *
  • {@code record} — log a produced piece (draft/packaged) with its title and - * preview link.
  • - *
  • {@code mark_published} — flip an item to published with its platform ref.
  • + *
  • {@code record} — log a produced piece (usually done automatically by the + * package tools; available for manual use).
  • + *
  • {@code mark_published} — flip an item to published.
  • *
*/ @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 recent = contentItemMapper.selectList( - new LambdaQueryWrapper() - .eq(ContentItemEntity::getPlatform, platform.trim().toLowerCase()) - .eq(ContentItemEntity::getTopicFingerprint, fp) - .ge(ContentItemEntity::getCreateTime, cutoff) - .orderByDesc(ContentItemEntity::getCreateTime)); + List 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) { diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/GzhPackageTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/GzhPackageTool.java index 22d07650..c59c8873 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/GzhPackageTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/GzhPackageTool.java @@ -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/"); } diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/XhsPackageTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/XhsPackageTool.java index 27c687fd..31bde690 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/XhsPackageTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/XhsPackageTool.java @@ -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]; diff --git a/mateclaw-server/src/main/resources/skills/gzh_article/SKILL.md b/mateclaw-server/src/main/resources/skills/gzh_article/SKILL.md index 4720d4e5..7526b14b 100644 --- a/mateclaw-server/src/main/resources/skills/gzh_article/SKILL.md +++ b/mateclaw-server/src/main/resources/skills/gzh_article/SKILL.md @@ -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="")`。 +- **发布**:`gzh_publish` 进草稿箱时对高危词(极限/诱导/承诺)硬拦截;真正发表后 `content_item(action="mark_published", id, externalRef)`。 ## 保存自定义模板 / 对话升级技能 diff --git a/mateclaw-server/src/main/resources/skills/xhs_note/SKILL.md b/mateclaw-server/src/main/resources/skills/xhs_note/SKILL.md index af43d3e1..3deb330f 100644 --- a/mateclaw-server/src/main/resources/skills/xhs_note/SKILL.md +++ b/mateclaw-server/src/main/resources/skills/xhs_note/SKILL.md @@ -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="")`。 +- **上传后**:用户手动发布后 `content_item(action="mark_published", id)`,就能知道哪些已发、哪些还在待办。 ## 保存自定义卡片模板 / 对话升级技能 diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/ComplianceScannerTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/ComplianceScannerTest.java index cd6f48a3..08fe3711 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/builtin/ComplianceScannerTest.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/ComplianceScannerTest.java @@ -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() { diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/ContentItemToolTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/ContentItemToolTest.java index bafdb255..44d557b1 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/builtin/ContentItemToolTest.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/ContentItemToolTest.java @@ -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:")); } } diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/GzhPackageCoverHealingTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/GzhPackageCoverHealingTest.java index 599df782..10e11418 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/builtin/GzhPackageCoverHealingTest.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/GzhPackageCoverHealingTest.java @@ -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"); diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/XhsPackageTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/XhsPackageTest.java index 283f27e6..f6e49857 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/builtin/XhsPackageTest.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/XhsPackageTest.java @@ -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"); diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index b0756329..62f6e7fa 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -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'), /** diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index 923e737d..8d88adcc 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -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', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index d7cff307..fa022d52 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -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: '工具', diff --git a/mateclaw-ui/src/router/index.ts b/mateclaw-ui/src/router/index.ts index 23541516..6aea06b4 100644 --- a/mateclaw-ui/src/router/index.ts +++ b/mateclaw-ui/src/router/index.ts @@ -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 兼容 { diff --git a/mateclaw-ui/src/types/components.d.ts b/mateclaw-ui/src/types/components.d.ts index e584d3d4..42324b25 100644 --- a/mateclaw-ui/src/types/components.d.ts +++ b/mateclaw-ui/src/types/components.d.ts @@ -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'] diff --git a/mateclaw-ui/src/views/ContentCalendar.vue b/mateclaw-ui/src/views/ContentCalendar.vue new file mode 100644 index 00000000..9f88bc35 --- /dev/null +++ b/mateclaw-ui/src/views/ContentCalendar.vue @@ -0,0 +1,191 @@ + + + + + diff --git a/mateclaw-ui/src/views/layout/MainLayout.vue b/mateclaw-ui/src/views/layout/MainLayout.vue index 64234ec0..62b89f43 100644 --- a/mateclaw-ui/src/views/layout/MainLayout.vue +++ b/mateclaw-ui/src/views/layout/MainLayout.vue @@ -510,6 +510,12 @@ const navGroups = computed(() => [ icon: ``, requiredCapability: 'manage:skills', }, + { + path: '/content-calendar', + label: t('nav.contentCalendar'), + icon: ``, + requiredCapability: 'manage:agents', + }, { path: '/plugins', label: t('nav.plugins'),