mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(content-studio): 交付即扫即记 + 内容日历只读页(自动化与打磨)
This commit is contained in:
parent
f184b94bcd
commit
ac4b277492
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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 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<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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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()) {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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/");
|
||||
}
|
||||
|
||||
@ -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];
|
||||
|
||||
@ -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)`。
|
||||
|
||||
## 保存自定义模板 / 对话升级技能
|
||||
|
||||
|
||||
@ -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)`,就能知道哪些已发、哪些还在待办。
|
||||
|
||||
## 保存自定义卡片模板 / 对话升级技能
|
||||
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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:"));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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'),
|
||||
/**
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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: '工具',
|
||||
|
||||
@ -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 兼容
|
||||
{
|
||||
|
||||
4
mateclaw-ui/src/types/components.d.ts
vendored
4
mateclaw-ui/src/types/components.d.ts
vendored
@ -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']
|
||||
|
||||
191
mateclaw-ui/src/views/ContentCalendar.vue
Normal file
191
mateclaw-ui/src/views/ContentCalendar.vue
Normal 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>
|
||||
@ -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'),
|
||||
|
||||
Loading…
Reference in New Issue
Block a user