添加公众号助手

This commit is contained in:
Jackzhou
2026-08-26 10:35:46 +08:00
parent 4f33b036d1
commit b00e55f0e5
16 changed files with 4905 additions and 49 deletions
@@ -0,0 +1,214 @@
package main
import (
"database/sql"
"encoding/json"
"flag"
"fmt"
"log"
"os"
"strings"
"time"
_ "modernc.org/sqlite"
)
type taskRow struct {
ID int64 `json:"id"`
Title string `json:"title"`
Status string `json:"status"`
UpdatedAt string `json:"updated_at"`
}
type articleRow struct {
TaskID int64 `json:"task_id"`
Keyword string `json:"keyword"`
SelectedTopic string `json:"selected_topic"`
TopicHeat string `json:"topic_heat"`
OutlineStyle string `json:"outline_style"`
OutlineWords int `json:"outline_words"`
ContentTargetWords int `json:"content_target_words"`
HotspotsJSON string `json:"hotspots_json"`
TopicCandidatesJSON string `json:"topic_candidates_json"`
}
type runRow struct {
ID int64 `json:"id"`
TaskID int64 `json:"task_id"`
ActionKey string `json:"action_key"`
ActionTitle string `json:"action_title"`
OutputJSON string `json:"output_json"`
LogsJSON string `json:"logs_json"`
StartedAt string `json:"started_at"`
}
func main() {
taskID := flag.Int64("task", 0, "official account task id")
resetStuck := flag.Bool("reset-stuck", false, "reset stuck running state for the given task")
flag.Parse()
dbPath := `d:\traecode\pj0235-eai_agentplatform\eai_ap_app\backend-go\data\eaisalestrain.db`
db, err := sql.Open("sqlite", dbPath)
if err != nil {
log.Fatal(err)
}
defer db.Close()
if *resetStuck {
if *taskID <= 0 {
log.Fatal("please pass -task when using -reset-stuck")
}
if err := resetOfficialAccountTaskState(db, *taskID); err != nil {
log.Fatal(err)
}
fmt.Printf("reset stuck running state for task %d\n", *taskID)
return
}
tasks := make([]taskRow, 0, 5)
taskRows, err := db.Query(`
select id, title, status, updated_at
from worker_task
where specialist_key = 'wechat-official-account'
order by id desc
limit 5
`)
if err != nil {
log.Fatal(err)
}
for taskRows.Next() {
var row taskRow
if err := taskRows.Scan(&row.ID, &row.Title, &row.Status, &row.UpdatedAt); err != nil {
log.Fatal(err)
}
tasks = append(tasks, row)
}
_ = taskRows.Close()
var article articleRow
_ = db.QueryRow(`
select task_id, keyword, selected_topic, topic_heat, outline_style, outline_words, content_target_words, hotspots_json, topic_candidates_json
from official_account_article
order by id desc
limit 1
`).Scan(
&article.TaskID,
&article.Keyword,
&article.SelectedTopic,
&article.TopicHeat,
&article.OutlineStyle,
&article.OutlineWords,
&article.ContentTargetWords,
&article.HotspotsJSON,
&article.TopicCandidatesJSON,
)
runs := make([]runRow, 0, 5)
runRows, err := db.Query(`
select id, task_id, action_key, action_title, output_json, logs_json, started_at
from worker_run
where task_id = ?
order by id desc
limit 5
`, article.TaskID)
if err != nil {
log.Fatal(err)
}
for runRows.Next() {
var row runRow
if err := runRows.Scan(&row.ID, &row.TaskID, &row.ActionKey, &row.ActionTitle, &row.OutputJSON, &row.LogsJSON, &row.StartedAt); err != nil {
log.Fatal(err)
}
runs = append(runs, row)
}
_ = runRows.Close()
var hotspotCount int
_ = db.QueryRow(`select count(1) from official_account_hotspot`).Scan(&hotspotCount)
result := map[string]any{
"tasks": tasks,
"latest_article": article,
"latest_runs": runs,
"hotspot_count": hotspotCount,
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
if err := enc.Encode(result); err != nil {
fmt.Println(err)
}
}
func resetOfficialAccountTaskState(db *sql.DB, taskID int64) error {
tx, err := db.Begin()
if err != nil {
return err
}
defer func() {
if err != nil {
_ = tx.Rollback()
}
}()
var contextJSON string
if err = tx.QueryRow(`select context_json from worker_task where id = ?`, taskID).Scan(&contextJSON); err != nil {
return err
}
type stepState struct {
Status string `json:"status"`
Progress int `json:"progress"`
ProgressText string `json:"progress_text"`
StartedAt string `json:"started_at"`
FinishedAt string `json:"finished_at"`
Logs []string `json:"logs"`
Output map[string]interface{} `json:"output"`
}
type workflowState struct {
CurrentStep string `json:"current_step"`
Steps map[string]*stepState `json:"steps"`
}
var workflow workflowState
if strings.TrimSpace(contextJSON) != "" {
if err = json.Unmarshal([]byte(contextJSON), &workflow); err != nil {
return err
}
}
if workflow.Steps == nil {
workflow.Steps = map[string]*stepState{}
}
for _, key := range []string{"topic_recommendation", "title_generation", "outline_generation", "content_creation"} {
step := workflow.Steps[key]
if step == nil || step.Status != "running" {
continue
}
step.Status = "error"
step.ProgressText = "上次执行已中断,请重新执行"
step.FinishedAt = time.Now().Format(time.RFC3339)
step.Logs = append(step.Logs, time.Now().Format("15:04:05")+" 执行已中断,已自动停止")
}
workflow.CurrentStep = "topic_recommendation"
newContext, err := json.Marshal(workflow)
if err != nil {
return err
}
if _, err = tx.Exec(`
update worker_task
set status = ?, current_result = ?, context_json = ?, updated_at = CURRENT_TIMESTAMP
where id = ?
`, "待处理", "", string(newContext), taskID); err != nil {
return err
}
if _, err = tx.Exec(`
update worker_run
set status = ?, output_json = ?, logs_json = ?, finished_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
where task_id = ? and status = ?
`, "error", `{"summary":"上次执行已中断,请重新执行","error":"interrupted"}`, `["执行已中断,已自动停止"]`, taskID, "running"); err != nil {
return err
}
return tx.Commit()
}
+1 -1
View File
@@ -1 +1 @@
{"agent_routes": {"embed_gen": "embed_route_openrouter_text_v3", "image_gen": "image_route_openrouter_gpt_image_2", "path_coach": "chat_route_openrouter_deepseek_v4_flash", "title_gen": "chat_route_openrouter_deepseek_v4_flash"}, "chat_routes": {"chat_route_ollama_qwen": {"description": "本地 Ollama Qwen2.5:7b", "endpoint": "/chat/completions", "max_tokens": 2048, "model": "qwen2.5:7b", "provider": "ollama", "temperature": 0.7}, "chat_route_ollama_qwen_fast": {"description": "本地快速对话 (Ollama Qwen2.5:7b 轻量)", "endpoint": "/chat/completions", "max_tokens": 512, "model": "qwen2.5:7b", "provider": "ollama", "temperature": 0.3}, "chat_route_openrouter_deepseek_v3": {"description": "DeepSeek V3.2 (OpenRouter)", "endpoint": "/chat/completions", "max_tokens": 4096, "model": "deepseek/deepseek-v3.2", "provider": "openrouter", "temperature": 0.6}, "chat_route_openrouter_deepseek_v4_flash": {"description": "DeepSeek V4 Flash (OpenRouter)", "endpoint": "/chat/completions", "max_tokens": 4096, "model": "deepseek/deepseek-v4-flash", "provider": "openrouter", "temperature": 0.7}, "chat_route_openrouter_gpt_5": {"description": "GPT 5.4 (OpenRouter)", "endpoint": "/chat/completions", "max_tokens": 8192, "model": "openai/gpt-5.4", "provider": "openrouter", "temperature": 0.7}, "chat_route_openrouter_grok_4": {"description": "Grok 4.20 (OpenRouter)", "endpoint": "/chat/completions", "max_tokens": 4096, "model": "x-ai/grok-4.20", "provider": "openrouter", "temperature": 0.6}}, "default_embed_route": "embed_route_openrouter_text_v3", "default_route": "chat_route_openrouter_deepseek_v4_flash", "description": "eaisalestrain AI 路由配置 — 按场景分类定义多条路由,支持 provider/模型切换", "embed_routes": {"embed_route_ollama_bge_m3": {"description": "本地 Ollama bge-m3", "endpoint": "/embeddings", "model": "bge-m3", "provider": "ollama"}, "embed_route_openrouter_text_v3": {"description": "OpenAI text-embedding-3-small (OpenRouter)", "endpoint": "/embeddings", "model": "openai/text-embedding-3-small", "provider": "openrouter"}}, "fallback_routes": {"chat_route_openrouter_deepseek_v3": ["chat_route_openrouter_grok_4", "chat_route_ollama_qwen"], "chat_route_openrouter_deepseek_v4_flash": ["chat_route_openrouter_deepseek_v3", "chat_route_openrouter_grok_4", "chat_route_ollama_qwen"], "chat_route_openrouter_grok_4": ["chat_route_ollama_qwen"], "embed_route_ollama_bge_m3": ["embed_route_openrouter_text_v3"], "embed_route_openrouter_text_v3": ["embed_route_ollama_bge_m3"]}, "image_routes": {"image_route_ollama_llava": {"description": "本地 Ollama LLaVA", "endpoint": "/chat/completions", "max_tokens": 2048, "model": "llava", "provider": "ollama", "temperature": 0.7}, "image_route_openrouter_flux": {"description": "FLUX 1.1 Pro (OpenRouter)", "endpoint": "/images/generations", "max_tokens": 1024, "model": "black-forest-labs/flux-1.1-pro", "provider": "openrouter", "temperature": 1}, "image_route_openrouter_gpt_image_2": {"description": "GPT 5.4 Image 2 (OpenRouter)", "endpoint": "/images/generations", "max_tokens": 4096, "model": "openai/gpt-5.4-image-2", "provider": "openrouter", "temperature": 1}}, "version": "2.1.0"}
{"agent_routes": {"embed_gen": "embed_route_openrouter_text_v3", "image_gen": "image_route_openrouter_gpt_image_2", "path_coach": "chat_route_openrouter_deepseek_v4_flash", "title_gen": "chat_route_openrouter_deepseek_v4_flash"}, "chat_routes": {"chat_route_ollama_qwen": {"description": "本地 Ollama Qwen2.5:7b", "endpoint": "/chat/completions", "max_tokens": 2048, "model": "qwen2.5:7b", "provider": "ollama", "temperature": 0.7}, "chat_route_ollama_qwen_fast": {"description": "本地快速对话 (Ollama Qwen2.5:7b 轻量)", "endpoint": "/chat/completions", "max_tokens": 512, "model": "qwen2.5:7b", "provider": "ollama", "temperature": 0.3}, "chat_route_openrouter_deepseek_v3": {"description": "DeepSeek V3.2 (OpenRouter)", "endpoint": "/chat/completions", "max_tokens": 4096, "model": "deepseek/deepseek-v3.2", "provider": "openrouter", "temperature": 0.6}, "chat_route_openrouter_deepseek_v4_flash": {"description": "DeepSeek V4 Flash (OpenRouter)", "endpoint": "/chat/completions", "max_tokens": 4096, "model": "deepseek/deepseek-v4-flash", "provider": "openrouter", "temperature": 0.7}, "chat_route_openrouter_gpt_5": {"description": "GPT 5.4 (OpenRouter)", "endpoint": "/chat/completions", "max_tokens": 8192, "model": "openai/gpt-5.4", "provider": "openrouter", "temperature": 0.7}, "chat_route_openrouter_grok_4": {"description": "Grok 4.20 (OpenRouter)", "endpoint": "/chat/completions", "max_tokens": 4096, "model": "x-ai/grok-4.20", "provider": "openrouter", "temperature": 0.6}}, "default_embed_route": "embed_route_openrouter_text_v3", "default_route": "chat_route_openrouter_deepseek_v4_flash", "description": "eaisalestrain AI 路由配置 — 按场景分类定义多条路由,支持 provider/模型切换", "embed_routes": {"embed_route_ollama_bge_m3": {"description": "本地 Ollama bge-m3", "endpoint": "/embeddings", "model": "bge-m3", "provider": "ollama"}, "embed_route_openrouter_text_v3": {"description": "OpenAI text-embedding-3-small (OpenRouter)", "endpoint": "/embeddings", "model": "openai/text-embedding-3-small", "provider": "openrouter"}}, "fallback_routes": {"chat_route_openrouter_deepseek_v3": ["chat_route_openrouter_grok_4", "chat_route_ollama_qwen"], "chat_route_openrouter_deepseek_v4_flash": ["chat_route_openrouter_deepseek_v3", "chat_route_openrouter_grok_4", "chat_route_ollama_qwen"], "chat_route_openrouter_grok_4": ["chat_route_ollama_qwen"], "embed_route_ollama_bge_m3": ["embed_route_openrouter_text_v3"], "embed_route_openrouter_text_v3": ["embed_route_ollama_bge_m3"]}, "image_routes": {"image_route_ollama_llava": {"description": "本地 Ollama LLaVA", "endpoint": "/chat/completions", "max_tokens": 2048, "model": "llava", "provider": "ollama", "temperature": 0.7}, "image_route_openrouter_flux": {"description": "FLUX 1.1 Pro (OpenRouter)", "endpoint": "/images/generations", "max_tokens": 1024, "model": "black-forest-labs/flux-1.1-pro", "provider": "openrouter", "temperature": 1}, "image_route_openrouter_gpt_image_2": {"description": "GPT 5.4 Image 2 (OpenRouter)", "endpoint": "/images/generations", "max_tokens": 4096, "model": "openai/gpt-5.4-image-2", "provider": "openrouter", "temperature": 1}}, "version": "2.1.0"}
@@ -0,0 +1,297 @@
package api
import (
"encoding/json"
"fmt"
"sort"
"strings"
"eaisalestrain/backend/internal/model"
"eaisalestrain/backend/internal/store"
)
func loadOfficialAccountArticle(taskID uint) (*model.OfficialAccountArticle, error) {
var article model.OfficialAccountArticle
if err := store.DB.Where("task_id = ?", taskID).First(&article).Error; err != nil {
return nil, err
}
return &article, nil
}
func ensureOfficialAccountArticle(task model.WorkerTask, workflow officialAccountWorkflowState) (*model.OfficialAccountArticle, error) {
article, err := loadOfficialAccountArticle(task.ID)
if err == nil {
return article, nil
}
row := &model.OfficialAccountArticle{
TaskID: task.ID,
SpecialistKey: task.SpecialistKey,
BusinessDomain: resolveOfficialAccountBusinessDomain(workflow.Form.BusinessDomain, workflow.Form.Keyword),
Keyword: firstNonEmpty(workflow.Form.Keyword, getOfficialAccountBusinessDomainConfig(workflow.Form.BusinessDomain).DefaultKeyword),
Audience: firstNonEmpty(workflow.Form.Audience, "公众号读者"),
Goal: firstNonEmpty(workflow.Form.Goal, "输出一篇可发布的公众号文章"),
Tone: firstNonEmpty(workflow.Form.Tone, "专业但好懂"),
Requirements: workflow.Form.Requirements,
OutlineStyle: normalizeOfficialAccountOutlineStyle(workflow.Form.OutlineStyle),
OutlineWords: normalizeOfficialAccountOutlineWords(workflow.Form.OutlineWords),
ContentTargetWords: normalizeOfficialAccountContentTargetWords(workflow.Form.ContentTargetWords),
Status: "draft",
}
if err := store.DB.Create(row).Error; err != nil {
return nil, err
}
return row, nil
}
func syncWorkflowFromOfficialAccountArticle(workflow *officialAccountWorkflowState, article *model.OfficialAccountArticle) {
if workflow == nil || article == nil {
return
}
workflow.Form.Keyword = firstNonEmpty(article.Keyword, workflow.Form.Keyword)
workflow.Form.BusinessDomain = resolveOfficialAccountBusinessDomain(article.BusinessDomain, workflow.Form.Keyword)
workflow.Form.Audience = firstNonEmpty(article.Audience, workflow.Form.Audience)
workflow.Form.Goal = firstNonEmpty(article.Goal, workflow.Form.Goal)
workflow.Form.Tone = firstNonEmpty(article.Tone, workflow.Form.Tone)
workflow.Form.Requirements = article.Requirements
workflow.Form.OutlineStyle = normalizeOfficialAccountOutlineStyle(article.OutlineStyle)
workflow.Form.OutlineWords = normalizeOfficialAccountOutlineWords(article.OutlineWords)
workflow.Form.ContentTargetWords = normalizeOfficialAccountContentTargetWords(article.ContentTargetWords)
if strings.TrimSpace(article.TopicCandidatesJSON) != "" {
var items []officialAccountTopicCandidate
if json.Unmarshal([]byte(article.TopicCandidatesJSON), &items) == nil {
workflow.Shared.TopicCandidates = items
}
}
if strings.TrimSpace(article.TitleCandidatesJSON) != "" {
var items []string
if json.Unmarshal([]byte(article.TitleCandidatesJSON), &items) == nil {
workflow.Shared.TitleCandidates = items
}
}
workflow.Shared.SelectedTopic = article.SelectedTopic
workflow.Shared.SelectedTitle = article.SelectedTitle
workflow.Shared.Outline = article.Outline
workflow.Shared.Content = article.Content
}
func syncOfficialAccountArticleFromWorkflow(article *model.OfficialAccountArticle, workflow officialAccountWorkflowState) {
if article == nil {
return
}
article.BusinessDomain = resolveOfficialAccountBusinessDomain(workflow.Form.BusinessDomain, workflow.Form.Keyword)
article.Keyword = firstNonEmpty(workflow.Form.Keyword, article.Keyword)
article.Audience = firstNonEmpty(workflow.Form.Audience, article.Audience)
article.Goal = firstNonEmpty(workflow.Form.Goal, article.Goal)
article.Tone = firstNonEmpty(workflow.Form.Tone, article.Tone)
article.Requirements = workflow.Form.Requirements
article.OutlineStyle = normalizeOfficialAccountOutlineStyle(workflow.Form.OutlineStyle)
article.OutlineWords = normalizeOfficialAccountOutlineWords(workflow.Form.OutlineWords)
article.ContentTargetWords = normalizeOfficialAccountContentTargetWords(workflow.Form.ContentTargetWords)
article.SelectedTopic = workflow.Shared.SelectedTopic
article.SelectedTitle = workflow.Shared.SelectedTitle
article.Outline = workflow.Shared.Outline
article.Content = workflow.Shared.Content
if data, err := json.Marshal(workflow.Shared.TopicCandidates); err == nil {
article.TopicCandidatesJSON = string(data)
}
if data, err := json.Marshal(workflow.Shared.TitleCandidates); err == nil {
article.TitleCandidatesJSON = string(data)
}
if workflow.Shared.Content != "" {
article.Status = "content_ready"
} else if workflow.Shared.Outline != "" {
article.Status = "outline_ready"
} else if workflow.Shared.SelectedTitle != "" {
article.Status = "title_ready"
} else if workflow.Shared.SelectedTopic != "" {
article.Status = "topic_ready"
} else {
article.Status = "draft"
}
}
func resetOfficialAccountArticle(article *model.OfficialAccountArticle, req officialAccountTaskUpdateReq) {
if article == nil {
return
}
article.BusinessDomain = resolveOfficialAccountBusinessDomain(req.BusinessDomain, req.Keyword)
article.Keyword = req.Keyword
article.Audience = firstNonEmpty(req.Audience, "公众号读者")
article.Goal = firstNonEmpty(req.Goal, "输出一篇可发布的公众号文章")
article.Tone = firstNonEmpty(req.Tone, "专业但好懂")
article.Requirements = req.Requirements
article.TopicCandidatesJSON = "[]"
article.HotspotsJSON = "[]"
article.SelectedTopic = ""
article.TopicHeat = ""
article.TitleCandidatesJSON = "[]"
article.SelectedTitle = ""
article.OutlineStyle = normalizeOfficialAccountOutlineStyle(req.OutlineStyle)
article.OutlineWords = normalizeOfficialAccountOutlineWords(req.OutlineWords)
article.Outline = ""
article.ContentTargetWords = normalizeOfficialAccountContentTargetWords(req.ContentTargetWords)
article.Content = ""
article.Status = "draft"
}
func buildOfficialAccountTopicCandidatesFromHotspots(form officialAccountForm, hotspots []model.OfficialAccountHotspot) []officialAccountTopicCandidate {
domainLabel := officialAccountBusinessDomainLabel(form.BusinessDomain)
candidates := make([]officialAccountTopicCandidate, 0, 4)
for _, item := range hotspots {
topic := deriveOfficialAccountTopic(form.Keyword, item.Title)
if topic == "" {
continue
}
candidates = append(candidates, officialAccountTopicCandidate{
Topic: topic,
Angle: deriveOfficialAccountAngle(item.Title),
Reason: firstNonEmpty(item.Summary, "来自公开"+domainLabel+"资讯源,已按业务域相关性筛选。"),
Heat: firstNonEmpty(item.HeatLabel, "中高"),
})
if len(candidates) >= 4 {
break
}
}
if len(candidates) == 0 {
return buildOfficialAccountTopicCandidates(form)
}
return dedupeOfficialAccountTopicCandidates(candidates)
}
func dedupeOfficialAccountTopicCandidates(items []officialAccountTopicCandidate) []officialAccountTopicCandidate {
seen := map[string]struct{}{}
result := make([]officialAccountTopicCandidate, 0, len(items))
for _, item := range items {
key := strings.TrimSpace(item.Topic)
if key == "" {
continue
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
result = append(result, item)
}
return result
}
func deriveOfficialAccountTopic(keyword, sourceTitle string) string {
keyword = strings.TrimSpace(keyword)
sourceTitle = strings.TrimSpace(sourceTitle)
switch {
case keyword == "":
return sourceTitle
case strings.Contains(strings.ToLower(sourceTitle), strings.ToLower(keyword)):
return sourceTitle
default:
return fmt.Sprintf("%s:%s", keyword, sourceTitle)
}
}
func deriveOfficialAccountAngle(sourceTitle string) string {
switch {
case strings.Contains(sourceTitle, "融资") || strings.Contains(sourceTitle, "估值"):
return "产业动向"
case strings.Contains(sourceTitle, "发布") || strings.Contains(sourceTitle, "上线"):
return "产品更新"
case strings.Contains(sourceTitle, "落地") || strings.Contains(sourceTitle, "案例"):
return "场景落地"
default:
return "热点追踪"
}
}
func marshalOfficialAccountHotspots(hotspots []model.OfficialAccountHotspot) string {
if len(hotspots) == 0 {
return "[]"
}
data, err := json.Marshal(hotspots)
if err != nil {
return "[]"
}
return string(data)
}
func buildOfficialAccountHotspotPrompt(form officialAccountForm, hotspots []model.OfficialAccountHotspot) string {
cfg := getOfficialAccountBusinessDomainConfig(form.BusinessDomain)
lines := []string{
fmt.Sprintf("你是公众号选题策划编辑,请基于下面的%s热点,为公众号生成 4 个值得写的热点选题。", cfg.PromptLabel),
"要求:",
fmt.Sprintf("1. 主题必须适合%s方向的公众号写作;", cfg.PromptLabel),
"2. 要能写成公众号文章,不是简单复述新闻;",
"3. 每个候选返回 topic、angle、reason、heat 四个字段;",
"4. 输出 JSON 数组,不要 markdown。",
fmt.Sprintf("业务域:%s", cfg.Label),
fmt.Sprintf("关键词:%s", firstNonEmpty(form.Keyword, cfg.DefaultKeyword)),
fmt.Sprintf("面向人群:%s", firstNonEmpty(form.Audience, "公众号读者")),
fmt.Sprintf("内容目标:%s", firstNonEmpty(form.Goal, "输出一篇可发布的公众号文章")),
fmt.Sprintf("语气:%s", firstNonEmpty(form.Tone, "专业但好懂")),
"热点列表:",
}
for idx, item := range hotspots {
lines = append(lines, fmt.Sprintf("%d. 标题:%s | 热度:%s | 摘要:%s", idx+1, item.Title, item.HeatLabel, firstNonEmpty(item.Summary, "无摘要")))
if idx >= 7 {
break
}
}
return strings.Join(lines, "\n")
}
func buildOfficialAccountTopicEvalPrompt(form officialAccountForm, topic string, hotspots []model.OfficialAccountHotspot) string {
cfg := getOfficialAccountBusinessDomainConfig(form.BusinessDomain)
lines := []string{
fmt.Sprintf("请判断这个公众号选题在当前%s热点里的热度和可写性,返回一个 JSON 对象,字段固定为 heat、reason。", cfg.PromptLabel),
fmt.Sprintf("选题:%s", topic),
fmt.Sprintf("业务域:%s", cfg.Label),
fmt.Sprintf("关键词:%s", firstNonEmpty(form.Keyword, cfg.DefaultKeyword)),
"参考热点:",
}
for idx, item := range hotspots {
lines = append(lines, fmt.Sprintf("%d. %s(%s)", idx+1, item.Title, item.HeatLabel))
if idx >= 5 {
break
}
}
return strings.Join(lines, "\n")
}
func buildOfficialAccountTopicBatchEvalPrompt(form officialAccountForm, candidates []officialAccountTopicCandidate, hotspots []model.OfficialAccountHotspot) string {
cfg := getOfficialAccountBusinessDomainConfig(form.BusinessDomain)
lines := []string{
fmt.Sprintf("你是公众号选题评审助手,请基于当前%s热点,对下面多个公众号选题候选做一次批量评判。", cfg.PromptLabel),
"要求:",
"1. 结合热点相关性、传播性、公众号可写性综合判断;",
"2. 每个候选都要返回 topic、heat、reason、score 四个字段;",
"3. heat 只能是 爆热 / 高 / 中高 / 中 其中之一;",
"4. score 为 0-100 的整数;",
"5. 只返回 JSON 数组,不要 markdown,不要额外解释。",
fmt.Sprintf("业务域:%s", cfg.Label),
fmt.Sprintf("关键词:%s", firstNonEmpty(form.Keyword, cfg.DefaultKeyword)),
fmt.Sprintf("面向人群:%s", firstNonEmpty(form.Audience, "公众号读者")),
fmt.Sprintf("内容目标:%s", firstNonEmpty(form.Goal, "输出一篇可发布的公众号文章")),
"待评估选题:",
}
for idx, item := range candidates {
lines = append(lines, fmt.Sprintf("%d. 选题:%s | 角度:%s | 当前理由:%s | 当前热度:%s", idx+1, item.Topic, firstNonEmpty(item.Angle, "热点追踪"), firstNonEmpty(item.Reason, "待评估"), firstNonEmpty(item.Heat, "中高")))
}
lines = append(lines, "参考热点:")
for idx, item := range hotspots {
lines = append(lines, fmt.Sprintf("%d. %s(%s)| 摘要:%s", idx+1, item.Title, firstNonEmpty(item.HeatLabel, "中高"), firstNonEmpty(item.Summary, "无摘要")))
if idx >= 5 {
break
}
}
return strings.Join(lines, "\n")
}
func sortOfficialAccountHotspots(items []model.OfficialAccountHotspot) []model.OfficialAccountHotspot {
cloned := append([]model.OfficialAccountHotspot(nil), items...)
sort.Slice(cloned, func(i, j int) bool {
if cloned[i].DomainScore == cloned[j].DomainScore {
return cloned[i].FetchedAt.After(cloned[j].FetchedAt)
}
return cloned[i].DomainScore > cloned[j].DomainScore
})
return cloned
}
@@ -0,0 +1,239 @@
package api
import "strings"
const (
officialAccountBusinessDomainAuto = "auto"
officialAccountBusinessDomainAI = "ai"
officialAccountBusinessDomainMedical = "medical"
officialAccountBusinessDomainInsurance = "insurance"
officialAccountBusinessDomainHR = "hr"
officialAccountBusinessDomainLegal = "legal"
officialAccountBusinessDomainGeneric = "generic"
)
type officialAccountHotspotSource struct {
Key string
Label string
Type string
URL string
}
type officialAccountBusinessDomainConfig struct {
Key string
Label string
PromptLabel string
DefaultKeyword string
DomainKeywords []string
BusinessKeywords []string
BoostKeywords []string
FallbackSearchHints []string
Sources []officialAccountHotspotSource
}
var officialAccountBusinessDomainConfigs = map[string]officialAccountBusinessDomainConfig{
officialAccountBusinessDomainAI: {
Key: officialAccountBusinessDomainAI,
Label: "AI / 智能体",
PromptLabel: "AI 业务域",
DefaultKeyword: "AI 落地",
DomainKeywords: []string{
"ai", "aigc", "大模型", "人工智能", "智能体", "agent", "生成式", "机器学习", "多模态", "工作流", "知识库",
"rag", "copilot", "自动化", "机器人", "推理", "模型", "算力", "数据智能", "数字员工", "workbuddy",
},
BusinessKeywords: []string{
"企业", "落地", "场景", "产品", "平台", "工作台", "办公", "培训", "实施", "知识库", "销售", "客户",
"提效", "运营", "私有化", "部署", "公司", "团队", "组织", "流程", "项目", "交付",
},
BoostKeywords: []string{"工作流", "智能体", "企业ai"},
FallbackSearchHints: []string{"AI", "大模型", "智能体", "企业AI"},
Sources: []officialAccountHotspotSource{
{Key: "36kr_feed", Label: "36氪订阅", Type: "rss", URL: "https://www.36kr.com/feed"},
{Key: "ithome_rss", Label: "IT之家订阅", Type: "rss", URL: "https://www.ithome.com/rss/"},
{Key: "36kr_ai_web", Label: "36氪AI专题", Type: "web", URL: "https://www.36kr.com/information/AI/"},
{Key: "36kr_aigc_web", Label: "36氪AIGC专题", Type: "web", URL: "https://www.36kr.com/information/AIGC/"},
},
},
officialAccountBusinessDomainMedical: {
Key: officialAccountBusinessDomainMedical,
Label: "医疗 / 健康",
PromptLabel: "医疗健康业务域",
DefaultKeyword: "医疗健康",
DomainKeywords: []string{
"医疗", "医保", "医院", "医生", "患者", "医药", "药企", "药品", "诊疗", "健康", "护理", "手术", "疾病", "控费",
"集采", "互联网医疗", "医疗器械", "生物医药", "健康险", "临床", "门诊", "住院", "卫健", "医保局", "药监", "药审",
"创新药", "医改", "药械", "医用耗材", "公共卫生", "分级诊疗",
},
BusinessKeywords: []string{
"政策", "支付", "落地", "案例", "服务", "行业", "机构", "改革", "商业模式", "运营", "平台", "场景", "解决方案",
"通知", "公告", "指南", "征求意见", "审评", "审批", "基金", "目录", "招采", "挂网", "集采",
},
BoostKeywords: []string{"医保", "医院", "医疗器械", "互联网医疗", "创新药", "药审", "医改", "分级诊疗"},
FallbackSearchHints: []string{"医疗", "医保", "医院", "健康险", "创新药", "医疗器械"},
Sources: []officialAccountHotspotSource{
{Key: "nhsa_policy_web", Label: "国家医保局政策栏目", Type: "web", URL: "https://www.nhsa.gov.cn/col/col14/index.html"},
{Key: "nhsa_news_web", Label: "国家医保局工作动态", Type: "web", URL: "https://www.nhsa.gov.cn/col/col104/index.html"},
{Key: "cde_home_web", Label: "药审中心", Type: "web", URL: "https://www.cde.org.cn/"},
},
},
officialAccountBusinessDomainInsurance: {
Key: officialAccountBusinessDomainInsurance,
Label: "保险 / 金融",
PromptLabel: "保险金融业务域",
DefaultKeyword: "保险行业",
DomainKeywords: []string{
"保险", "险资", "寿险", "财险", "健康险", "车险", "养老保险", "理赔", "赔付", "保单", "代理人", "年金", "再保险",
"偿付能力", "分红险", "重疾险", "医疗险", "保险公司", "保险经纪", "保险中介",
},
BusinessKeywords: []string{
"监管", "政策", "行业", "产品", "销售", "转型", "增长", "客户", "渠道", "经营", "服务", "投资", "市场", "赔付",
},
BoostKeywords: []string{"理赔", "偿付能力", "代理人", "保险公司"},
FallbackSearchHints: []string{"保险", "理赔", "代理人", "养老保险"},
Sources: []officialAccountHotspotSource{
{Key: "iachina_web", Label: "中国保险行业协会", Type: "web", URL: "https://www.iachina.cn/col/col3/index.html"},
{Key: "cnstock_insurance_web", Label: "上海证券报保险频道", Type: "web", URL: "http://www.cnstock.com/channel/10120"},
{Key: "china_finance_insurance_web", Label: "中国网财经保险", Type: "web", URL: "http://finance.china.com.cn/money/insurance/index.shtml"},
},
},
officialAccountBusinessDomainHR: {
Key: officialAccountBusinessDomainHR,
Label: "HR / 招聘",
PromptLabel: "人力资源业务域",
DefaultKeyword: "人力资源",
DomainKeywords: []string{
"hr", "人力", "招聘", "人才", "雇主", "员工", "绩效", "薪酬", "福利", "组织", "用工", "劳务", "社保",
"培训", "面试", "招聘流程", "入职", "离职", "考勤", "激励",
},
BusinessKeywords: []string{
"管理", "平台", "系统", "政策", "趋势", "案例", "企业", "数字化", "转型", "场景", "效率", "合规",
},
BoostKeywords: []string{"招聘", "社保", "薪酬", "劳动"},
FallbackSearchHints: []string{"招聘", "人力资源", "薪酬", "社保"},
Sources: []officialAccountHotspotSource{
{Key: "hrac_web", Label: "中国人力资源协会", Type: "web", URL: "http://www.hrac.net.cn/CN/HR/"},
{Key: "hrtechweekly_rss", Label: "HR Tech Weekly", Type: "rss", URL: "https://hrtechweekly.com/feed/"},
{Key: "techrseries_rss", Label: "HRTech Series", Type: "rss", URL: "https://techrseries.com/feed/"},
},
},
officialAccountBusinessDomainLegal: {
Key: officialAccountBusinessDomainLegal,
Label: "法务 / 合规",
PromptLabel: "法务合规业务域",
DefaultKeyword: "法务合规",
DomainKeywords: []string{
"法务", "法律", "合规", "合同", "诉讼", "仲裁", "监管", "公司法", "劳动法", "数据合规", "知产", "知识产权",
"审查", "风控", "条款", "争议", "律师", "执法", "规范",
},
BusinessKeywords: []string{
"企业", "实务", "案例", "解读", "风控", "业务", "制度", "管理", "要求", "落地", "操作", "要点",
},
BoostKeywords: []string{"合同", "合规", "数据", "劳动法"},
FallbackSearchHints: []string{"合同", "合规", "劳动法", "数据合规"},
Sources: []officialAccountHotspotSource{
{Key: "law_asia_cn_web", Label: "商法中国", Type: "web", URL: "https://law.asia/zh-hans/china/"},
{Key: "law_asia_en_web", Label: "China Business Law Journal", Type: "web", URL: "https://law.asia/china/"},
{Key: "jurist_rss", Label: "Jurist", Type: "rss", URL: "https://www.jurist.org/news/feed/"},
},
},
officialAccountBusinessDomainGeneric: {
Key: officialAccountBusinessDomainGeneric,
Label: "通用业务域",
PromptLabel: "通用业务域",
DefaultKeyword: "业务热点",
DomainKeywords: []string{
"行业", "企业", "客户", "增长", "经营", "产品", "服务", "转型", "市场", "政策", "平台", "解决方案",
},
BusinessKeywords: []string{
"热点", "趋势", "案例", "落地", "复盘", "方法", "实践", "问题", "经验", "机会", "风险",
},
BoostKeywords: []string{"行业", "企业", "趋势"},
FallbackSearchHints: []string{"行业", "企业", "政策", "趋势"},
Sources: []officialAccountHotspotSource{
{Key: "36kr_feed", Label: "36氪订阅", Type: "rss", URL: "https://www.36kr.com/feed"},
{Key: "cnstock_business_web", Label: "上海证券报", Type: "web", URL: "http://www.cnstock.com/"},
{Key: "ithome_rss", Label: "IT之家订阅", Type: "rss", URL: "https://www.ithome.com/rss/"},
},
},
}
var officialAccountBusinessDomainDetectionHints = map[string][]string{
officialAccountBusinessDomainAI: officialAccountBusinessDomainConfigs[officialAccountBusinessDomainAI].DomainKeywords,
officialAccountBusinessDomainMedical: officialAccountBusinessDomainConfigs[officialAccountBusinessDomainMedical].DomainKeywords,
officialAccountBusinessDomainInsurance: officialAccountBusinessDomainConfigs[officialAccountBusinessDomainInsurance].DomainKeywords,
officialAccountBusinessDomainHR: officialAccountBusinessDomainConfigs[officialAccountBusinessDomainHR].DomainKeywords,
officialAccountBusinessDomainLegal: officialAccountBusinessDomainConfigs[officialAccountBusinessDomainLegal].DomainKeywords,
}
func normalizeOfficialAccountBusinessDomain(value string) string {
value = strings.TrimSpace(strings.ToLower(value))
switch value {
case "", officialAccountBusinessDomainAuto, "自动识别", "自动":
return officialAccountBusinessDomainAuto
case "ai", "aigc", "智能体", "人工智能":
return officialAccountBusinessDomainAI
case "medical", "health", "医疗", "健康":
return officialAccountBusinessDomainMedical
case "insurance", "finance", "保险", "金融":
return officialAccountBusinessDomainInsurance
case "hr", "recruiting", "招聘", "人力", "人力资源":
return officialAccountBusinessDomainHR
case "legal", "compliance", "法务", "合规", "法律":
return officialAccountBusinessDomainLegal
case "generic", "other", "其他", "通用":
return officialAccountBusinessDomainGeneric
default:
if _, ok := officialAccountBusinessDomainConfigs[value]; ok {
return value
}
return officialAccountBusinessDomainGeneric
}
}
func detectOfficialAccountBusinessDomain(keyword string) string {
text := strings.ToLower(strings.TrimSpace(keyword))
if text == "" {
return officialAccountBusinessDomainAI
}
bestKey := officialAccountBusinessDomainAI
bestScore := 0
for key, hints := range officialAccountBusinessDomainDetectionHints {
score := 0
for _, hint := range hints {
if strings.Contains(text, strings.ToLower(hint)) {
score++
}
}
if score > bestScore {
bestScore = score
bestKey = key
}
}
if bestScore == 0 {
return officialAccountBusinessDomainGeneric
}
return bestKey
}
func resolveOfficialAccountBusinessDomain(domain, keyword string) string {
normalized := normalizeOfficialAccountBusinessDomain(domain)
if normalized == officialAccountBusinessDomainAuto {
return detectOfficialAccountBusinessDomain(keyword)
}
return normalized
}
func getOfficialAccountBusinessDomainConfig(domain string) officialAccountBusinessDomainConfig {
key := normalizeOfficialAccountBusinessDomain(domain)
if key == officialAccountBusinessDomainAuto {
key = officialAccountBusinessDomainAI
}
if cfg, ok := officialAccountBusinessDomainConfigs[key]; ok {
return cfg
}
return officialAccountBusinessDomainConfigs[officialAccountBusinessDomainGeneric]
}
func officialAccountBusinessDomainLabel(domain string) string {
return getOfficialAccountBusinessDomainConfig(domain).Label
}
@@ -0,0 +1,520 @@
package api
import (
"bytes"
"encoding/json"
"encoding/xml"
"html"
"io"
"net/http"
neturl "net/url"
"regexp"
"sort"
"strconv"
"strings"
"time"
"golang.org/x/net/html/charset"
"gorm.io/gorm/clause"
"eaisalestrain/backend/internal/model"
"eaisalestrain/backend/internal/store"
)
type officialAccountFetchedHotspot struct {
Title string
URL string
Summary string
PublishedAt *time.Time
SourceKey string
SourceLabel string
SourceType string
}
var (
officialAccountAnchorRE = regexp.MustCompile(`(?is)<a[^>]+href=["']([^"'#]+)["'][^>]*>(.*?)</a>`)
officialAccountStripTagRE = regexp.MustCompile(`(?is)<[^>]+>`)
officialAccountSpaceRE = regexp.MustCompile(`\s+`)
)
type officialAccountRSS struct {
Channel struct {
Items []struct {
Title string `xml:"title"`
Link string `xml:"link"`
Description string `xml:"description"`
PubDate string `xml:"pubDate"`
} `xml:"item"`
} `xml:"channel"`
Entries []struct {
Title string `xml:"title"`
Summary string `xml:"summary"`
Content string `xml:"content"`
Updated string `xml:"updated"`
Published string `xml:"published"`
ID string `xml:"id"`
Links []struct {
Href string `xml:"href,attr"`
} `xml:"link"`
} `xml:"entry"`
}
func ensureOfficialAccountHotspots(form officialAccountForm, force bool) ([]model.OfficialAccountHotspot, []string, error) {
domainKey := resolveOfficialAccountBusinessDomain(form.BusinessDomain, form.Keyword)
cfg := getOfficialAccountBusinessDomainConfig(domainKey)
logs := []string{}
if !force {
cached, err := loadFreshOfficialAccountHotspots(domainKey, form.Keyword, 90*time.Minute)
if err == nil && len(cached) >= 8 {
logs = append(logs, "命中热点缓存,直接复用近 90 分钟"+cfg.Label+"热点。")
return cached, logs, nil
}
}
client := &http.Client{Timeout: 12 * time.Second}
totalUpserts := 0
sourceKeywords := buildOfficialAccountSourceKeywords(domainKey, form.Keyword, cfg.FallbackSearchHints)
for _, source := range cfg.Sources {
items, err := fetchOfficialAccountHotspotsFromSource(client, source, sourceKeywords)
if err != nil {
logs = append(logs, source.Label+" 拉取失败:"+err.Error())
continue
}
upserts, sourceLogs := upsertOfficialAccountHotspots(domainKey, form.Keyword, items)
totalUpserts += upserts
logs = append(logs, sourceLogs...)
}
if totalUpserts == 0 {
logs = append(logs, "本轮未写入新热点,回退读取最近缓存。")
}
latest, err := loadFreshOfficialAccountHotspots(domainKey, form.Keyword, 48*time.Hour)
if err == nil && len(latest) == 0 && domainKey != officialAccountBusinessDomainGeneric {
logs = append(logs, "当前业务域命中热点较少,已自动降级到通用热点检索。")
genericCfg := getOfficialAccountBusinessDomainConfig(officialAccountBusinessDomainGeneric)
genericKeywords := buildOfficialAccountSourceKeywords(
officialAccountBusinessDomainGeneric,
form.Keyword,
append(cfg.FallbackSearchHints, genericCfg.FallbackSearchHints...),
)
for _, source := range genericCfg.Sources {
items, fetchErr := fetchOfficialAccountHotspotsFromSource(client, source, genericKeywords)
if fetchErr != nil {
logs = append(logs, source.Label+" 泛检索失败:"+fetchErr.Error())
continue
}
upserts, sourceLogs := upsertOfficialAccountHotspots(officialAccountBusinessDomainGeneric, form.Keyword, items)
totalUpserts += upserts
logs = append(logs, sourceLogs...)
}
latest, err = loadFreshOfficialAccountHotspots(officialAccountBusinessDomainGeneric, form.Keyword, 48*time.Hour)
}
if err != nil {
return nil, logs, err
}
return latest, logs, nil
}
func loadFreshOfficialAccountHotspots(domainKey, keyword string, maxAge time.Duration) ([]model.OfficialAccountHotspot, error) {
var items []model.OfficialAccountHotspot
cutoff := time.Now().Add(-maxAge)
err := store.DB.
Where("business_domain = ? AND fetched_at >= ? AND domain_score > 0", domainKey, cutoff).
Order("domain_score DESC, published_at DESC, id DESC").
Limit(40).
Find(&items).Error
if err != nil {
return nil, err
}
filtered := filterOfficialAccountHotspotsByKeyword(items, keyword)
if len(filtered) > 0 {
return filtered, nil
}
if len(items) > 20 {
return items[:20], nil
}
return items, nil
}
func fetchOfficialAccountHotspotsFromSource(client *http.Client, source officialAccountHotspotSource, keywords []string) ([]officialAccountFetchedHotspot, error) {
switch source.Type {
case "rss":
return fetchOfficialAccountRSSHotspots(client, source)
default:
return fetchOfficialAccountWebHotspots(client, source, keywords)
}
}
func fetchOfficialAccountRSSHotspots(client *http.Client, source officialAccountHotspotSource) ([]officialAccountFetchedHotspot, error) {
body, err := fetchOfficialAccountSourceBody(client, source.URL)
if err != nil {
return nil, err
}
var feed officialAccountRSS
if err := xml.Unmarshal(body, &feed); err != nil {
return nil, err
}
items := make([]officialAccountFetchedHotspot, 0, 24)
for _, item := range feed.Channel.Items {
title := normalizeOfficialAccountText(item.Title)
link := strings.TrimSpace(item.Link)
summary := normalizeOfficialAccountText(item.Description)
if title == "" || link == "" {
continue
}
items = append(items, officialAccountFetchedHotspot{
Title: title,
URL: link,
Summary: summary,
PublishedAt: parseOfficialAccountTime(item.PubDate),
SourceKey: source.Key,
SourceLabel: source.Label,
SourceType: source.Type,
})
}
for _, entry := range feed.Entries {
title := normalizeOfficialAccountText(entry.Title)
link := strings.TrimSpace(entry.ID)
if len(entry.Links) > 0 && strings.TrimSpace(entry.Links[0].Href) != "" {
link = strings.TrimSpace(entry.Links[0].Href)
}
summary := normalizeOfficialAccountText(firstNonEmpty(entry.Summary, entry.Content))
if title == "" || link == "" {
continue
}
items = append(items, officialAccountFetchedHotspot{
Title: title,
URL: link,
Summary: summary,
PublishedAt: parseOfficialAccountTime(firstNonEmpty(entry.Published, entry.Updated)),
SourceKey: source.Key,
SourceLabel: source.Label,
SourceType: source.Type,
})
}
return dedupeOfficialAccountFetched(items), nil
}
func fetchOfficialAccountWebHotspots(client *http.Client, source officialAccountHotspotSource, keywords []string) ([]officialAccountFetchedHotspot, error) {
body, err := fetchOfficialAccountSourceBody(client, source.URL)
if err != nil {
return nil, err
}
base, _ := neturl.Parse(source.URL)
matches := officialAccountAnchorRE.FindAllStringSubmatch(string(body), -1)
items := make([]officialAccountFetchedHotspot, 0, 24)
for _, match := range matches {
if len(match) < 3 {
continue
}
href := strings.TrimSpace(match[1])
text := normalizeOfficialAccountText(match[2])
if text == "" || len([]rune(text)) < 8 || len([]rune(text)) > 90 {
continue
}
if len(keywords) > 0 && !containsOfficialAccountKeywords(text, keywords) {
continue
}
if base != nil {
if ref, err := neturl.Parse(href); err == nil {
href = base.ResolveReference(ref).String()
}
}
items = append(items, officialAccountFetchedHotspot{
Title: text,
URL: href,
Summary: "",
PublishedAt: nil,
SourceKey: source.Key,
SourceLabel: source.Label,
SourceType: source.Type,
})
if len(items) >= 24 {
break
}
}
return dedupeOfficialAccountFetched(items), nil
}
func fetchOfficialAccountSourceBody(client *http.Client, rawURL string) ([]byte, error) {
req, err := http.NewRequest(http.MethodGet, rawURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "Mozilla/5.0 official-account-assistant/1.0")
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
rawBody, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
if err != nil {
return nil, err
}
decodedReader, err := charset.NewReader(bytes.NewReader(rawBody), resp.Header.Get("Content-Type"))
if err != nil {
return rawBody, nil
}
decodedBody, err := io.ReadAll(decodedReader)
if err != nil {
return rawBody, nil
}
return decodedBody, nil
}
func upsertOfficialAccountHotspots(domainKey, keyword string, items []officialAccountFetchedHotspot) (int, []string) {
logs := []string{}
if len(items) == 0 {
return 0, logs
}
cfg := getOfficialAccountBusinessDomainConfig(domainKey)
now := time.Now()
upserts := make([]model.OfficialAccountHotspot, 0, len(items))
for _, item := range items {
rawScore, domainScore, tags := scoreOfficialAccountHotspot(domainKey, keyword, item.Title, item.Summary)
if domainScore <= 0 {
continue
}
tagsJSON, _ := json.Marshal(tags)
upserts = append(upserts, model.OfficialAccountHotspot{
BusinessDomain: domainKey,
SourceKey: item.SourceKey,
SourceLabel: item.SourceLabel,
SourceType: item.SourceType,
Title: item.Title,
URL: item.URL,
Summary: item.Summary,
PublishedAt: item.PublishedAt,
RawScore: rawScore,
DomainScore: domainScore,
HeatLabel: officialAccountHeatLabel(domainScore),
TagsJSON: string(tagsJSON),
FetchedAt: now,
})
}
if len(upserts) == 0 {
return 0, logs
}
if err := store.DB.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "source_key"}, {Name: "url"}},
DoUpdates: clause.AssignmentColumns([]string{"title", "summary", "published_at", "raw_score", "domain_score", "heat_label", "tags_json", "fetched_at", "updated_at"}),
}).Create(&upserts).Error; err != nil {
logs = append(logs, "热点入库失败:"+err.Error())
return 0, logs
}
logs = append(logs, "已从公开 RSS / 网页源更新 "+itoa(len(upserts))+" 条"+cfg.Label+"热点。")
return len(upserts), logs
}
func scoreOfficialAccountHotspot(domainKey, keyword, title, summary string) (float64, float64, []string) {
cfg := getOfficialAccountBusinessDomainConfig(domainKey)
text := strings.ToLower(title + "\n" + summary)
rawScore := 0.0
domainScore := 0.0
tags := make([]string, 0, 8)
for _, item := range cfg.DomainKeywords {
if strings.Contains(text, strings.ToLower(item)) {
rawScore += 1
domainScore += 1.5
tags = append(tags, item)
}
}
for _, item := range cfg.BusinessKeywords {
if strings.Contains(text, strings.ToLower(item)) {
rawScore += 1
domainScore += 2
tags = append(tags, item)
}
}
for _, item := range cfg.BoostKeywords {
if strings.Contains(text, strings.ToLower(item)) {
domainScore += 2
tags = append(tags, item)
}
}
for _, item := range buildOfficialAccountKeywordTerms(keyword) {
if strings.Contains(text, strings.ToLower(item)) {
domainScore += 2.5
tags = append(tags, item)
}
}
if domainKey == officialAccountBusinessDomainGeneric {
domainScore += 2
}
return rawScore, domainScore, uniqueOfficialAccountStrings(tags)
}
func dedupeOfficialAccountFetched(items []officialAccountFetchedHotspot) []officialAccountFetchedHotspot {
seen := map[string]struct{}{}
result := make([]officialAccountFetchedHotspot, 0, len(items))
for _, item := range items {
key := strings.TrimSpace(item.URL)
if key == "" {
key = item.SourceKey + "::" + item.Title
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
result = append(result, item)
}
return result
}
func normalizeOfficialAccountText(value string) string {
value = html.UnescapeString(value)
value = officialAccountStripTagRE.ReplaceAllString(value, " ")
value = officialAccountSpaceRE.ReplaceAllString(value, " ")
return strings.TrimSpace(value)
}
func parseOfficialAccountTime(value string) *time.Time {
value = strings.TrimSpace(value)
if value == "" {
return nil
}
layouts := []string{
time.RFC1123Z,
time.RFC1123,
time.RFC3339,
"2006-01-02 15:04:05",
"2006-01-02",
}
for _, layout := range layouts {
if t, err := time.Parse(layout, value); err == nil {
return &t
}
}
return nil
}
func containsOfficialAccountKeywords(text string, keywords []string) bool {
text = strings.ToLower(text)
for _, keyword := range keywords {
if strings.Contains(text, strings.ToLower(keyword)) {
return true
}
}
return false
}
func filterOfficialAccountHotspotsByKeyword(items []model.OfficialAccountHotspot, keyword string) []model.OfficialAccountHotspot {
terms := buildOfficialAccountKeywordTerms(keyword)
if len(terms) == 0 {
return items
}
type scoredHotspot struct {
item model.OfficialAccountHotspot
score int
}
scored := make([]scoredHotspot, 0, len(items))
for _, item := range items {
text := strings.ToLower(item.Title + "\n" + item.Summary)
score := 0
for _, term := range terms {
if strings.Contains(text, strings.ToLower(term)) {
score++
}
}
if score > 0 {
scored = append(scored, scoredHotspot{item: item, score: score})
}
}
sort.SliceStable(scored, func(i, j int) bool {
if scored[i].score == scored[j].score {
if scored[i].item.DomainScore == scored[j].item.DomainScore {
return scored[i].item.ID > scored[j].item.ID
}
return scored[i].item.DomainScore > scored[j].item.DomainScore
}
return scored[i].score > scored[j].score
})
result := make([]model.OfficialAccountHotspot, 0, len(scored))
for _, item := range scored {
result = append(result, item.item)
if len(result) >= 20 {
break
}
}
return result
}
func buildOfficialAccountKeywordTerms(keyword string) []string {
raw := strings.TrimSpace(strings.ToLower(keyword))
if raw == "" {
return nil
}
splitter := func(r rune) bool {
switch r {
case ' ', '\t', '\n', '\r', ',', ',', '、', '/', '|', ';', ';', ':', ':', '(', ')', '(', ')', '+', '-', '_':
return true
default:
return false
}
}
parts := strings.FieldsFunc(raw, splitter)
terms := make([]string, 0, len(parts)+1)
if utf8RuneCount(raw) >= 2 {
terms = append(terms, raw)
}
for _, part := range parts {
part = strings.TrimSpace(part)
if utf8RuneCount(part) < 2 {
continue
}
terms = append(terms, part)
}
return uniqueOfficialAccountStrings(terms)
}
func buildOfficialAccountSourceKeywords(domainKey, keyword string, extras []string) []string {
cfg := getOfficialAccountBusinessDomainConfig(domainKey)
keywords := append([]string{}, cfg.DomainKeywords...)
keywords = append(keywords, buildOfficialAccountKeywordTerms(keyword)...)
keywords = append(keywords, extras...)
if domainKey == officialAccountBusinessDomainGeneric && len(buildOfficialAccountKeywordTerms(keyword)) == 0 {
keywords = append(keywords, cfg.FallbackSearchHints...)
}
return uniqueOfficialAccountStrings(keywords)
}
func utf8RuneCount(value string) int {
return len([]rune(value))
}
func officialAccountHeatLabel(score float64) string {
switch {
case score >= 12:
return "爆热"
case score >= 8:
return "高"
case score >= 5:
return "中高"
default:
return "中"
}
}
func uniqueOfficialAccountStrings(items []string) []string {
if len(items) == 0 {
return nil
}
seen := map[string]struct{}{}
result := make([]string, 0, len(items))
for _, item := range items {
item = strings.TrimSpace(item)
if item == "" {
continue
}
if _, ok := seen[item]; ok {
continue
}
seen[item] = struct{}{}
result = append(result, item)
}
sort.Strings(result)
return result
}
func itoa(v int) string {
return strconv.Itoa(v)
}
File diff suppressed because it is too large Load Diff
@@ -47,6 +47,10 @@ func RegisterRoutes(r *gin.Engine, cfg *config.Config) {
r.GET("/api/worker/tasks", middleware.Auth(cfg), ListWorkerTasks)
r.GET("/api/worker/tasks/:id", middleware.Auth(cfg), GetWorkerTaskDetail)
r.GET("/api/worker/artifacts/:id", middleware.Auth(cfg), GetWorkerArtifactDetail)
r.POST("/api/official-account/tasks", middleware.Auth(cfg), CreateOfficialAccountTask)
r.GET("/api/official-account/tasks/:id/workflow", middleware.Auth(cfg), GetOfficialAccountWorkflow)
r.PUT("/api/official-account/tasks/:id", middleware.Auth(cfg), UpdateOfficialAccountTask)
r.POST("/api/official-account/tasks/:id/steps/:stepKey", middleware.Auth(cfg), ExecuteOfficialAccountWorkflowStep)
r.GET("/api/connectors", middleware.Auth(cfg), ListConnectors)
r.GET("/api/connectors/:key", middleware.Auth(cfg), GetConnector)
r.POST("/api/connectors/:key/query", middleware.Auth(cfg), QueryConnector)
@@ -0,0 +1,32 @@
package model
import "time"
// OfficialAccountArticle 公众号文章状态表,对齐工作流中的主题/标题/提纲/正文显式传递。
type OfficialAccountArticle struct {
ID uint `gorm:"primaryKey" json:"id"`
TaskID uint `gorm:"not null;uniqueIndex" json:"task_id"`
SpecialistKey string `gorm:"size:64;not null;index" json:"specialist_key"`
BusinessDomain string `gorm:"size:32;not null;default:ai;index" json:"business_domain"`
Keyword string `gorm:"size:256;not null" json:"keyword"`
Audience string `gorm:"size:128;not null" json:"audience"`
Goal string `gorm:"size:128;not null" json:"goal"`
Tone string `gorm:"size:128;not null" json:"tone"`
Requirements string `gorm:"type:text" json:"requirements"`
TopicCandidatesJSON string `gorm:"type:text" json:"topic_candidates_json"`
HotspotsJSON string `gorm:"type:text" json:"hotspots_json"`
SelectedTopic string `gorm:"size:256" json:"selected_topic"`
TopicHeat string `gorm:"size:64" json:"topic_heat"`
TitleCandidatesJSON string `gorm:"type:text" json:"title_candidates_json"`
SelectedTitle string `gorm:"size:256" json:"selected_title"`
OutlineStyle string `gorm:"size:64;not null;default:问题拆解型" json:"outline_style"`
OutlineWords int `gorm:"not null;default:600" json:"outline_words"`
Outline string `gorm:"type:text" json:"outline"`
ContentTargetWords int `gorm:"not null;default:1400" json:"content_target_words"`
Content string `gorm:"type:text" json:"content"`
Status string `gorm:"size:32;not null;default:draft;index" json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (OfficialAccountArticle) TableName() string { return "official_account_article" }
@@ -0,0 +1,25 @@
package model
import "time"
// OfficialAccountHotspot 业务域热点缓存表,承接 RSS/网页抓取结果及领域评分。
type OfficialAccountHotspot struct {
ID uint `gorm:"primaryKey" json:"id"`
BusinessDomain string `gorm:"size:32;not null;default:ai;index" json:"business_domain"`
SourceKey string `gorm:"size:64;not null;index;uniqueIndex:idx_hotspot_source_url" json:"source_key"`
SourceLabel string `gorm:"size:128;not null" json:"source_label"`
SourceType string `gorm:"size:16;not null" json:"source_type"`
Title string `gorm:"size:512;not null" json:"title"`
URL string `gorm:"size:1024;not null;uniqueIndex:idx_hotspot_source_url" json:"url"`
Summary string `gorm:"type:text" json:"summary"`
PublishedAt *time.Time `json:"published_at"`
RawScore float64 `gorm:"not null;default:0" json:"raw_score"`
DomainScore float64 `gorm:"not null;default:0" json:"domain_score"`
HeatLabel string `gorm:"size:32;default:''" json:"heat_label"`
TagsJSON string `gorm:"type:text" json:"tags_json"`
FetchedAt time.Time `gorm:"index" json:"fetched_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (OfficialAccountHotspot) TableName() string { return "official_account_hotspot" }
@@ -58,6 +58,8 @@ func Init(dbPath string) (*gorm.DB, error) {
&model.WorkerTask{},
&model.WorkerArtifact{},
&model.WorkerRun{},
&model.OfficialAccountArticle{},
&model.OfficialAccountHotspot{},
); err != nil {
return nil, err
}
+57 -33
View File
@@ -1,13 +1,13 @@
package store
import (
"encoding/json"
"io"
"log"
"os"
"path/filepath"
"runtime"
"strings"
"encoding/json"
"io"
"log"
"os"
"path/filepath"
"runtime"
"strings"
"eaisalestrain/backend/internal/auth"
"eaisalestrain/backend/internal/config"
@@ -381,6 +381,30 @@ func seedSpecialists() error {
State: "active",
SortOrder: 40,
},
{
Key: "wechat-official-account",
Label: "公众号助手",
Tier: "generic",
WorkerType: "dw",
Route: "/apps/wechat-official-account",
Summary: "热点选题、标题、提纲、正文四步创作工作流",
WorkStatus: "4 个节点可执行",
RiskLabel: "0 个异常",
Color: "#2563eb",
Stage: "正文创作",
Progress: 0,
MarketTag: "已安装",
Version: "v1.0",
ConnectorScope: "连接知识库 / 公众号工作区上下文",
PermissionScope: "文章任务创建、步骤执行、草稿保存、人工确认",
ResourceBindings: "公众号工作流、知识库、事项运行记录、文章草稿",
InfoSources: "选题关键词、目标受众、文章目标、风格要求",
BaseSkills: "推荐热点选题、标题生成、提纲生成、正文创作",
AIAssistance: "按步骤生成中间结果,并展示进度与可追溯输出",
GeneratedSkills: "公众号选题建议、标题建议、文章提纲、正文草稿",
State: "active",
SortOrder: 45,
},
{
Key: "contract-review",
Label: "合同审查专员",
@@ -635,40 +659,40 @@ func ensureMediaLink(src, dst string) error {
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
if err := os.Symlink(absSrc, dst); err == nil {
return nil
} else if shouldCopySeedMedia(err) {
return copySeedMedia(absSrc, dst)
} else {
return err
}
if err := os.Symlink(absSrc, dst); err == nil {
return nil
} else if shouldCopySeedMedia(err) {
return copySeedMedia(absSrc, dst)
} else {
return err
}
}
func shouldCopySeedMedia(err error) bool {
if err == nil {
return false
}
// Windows 普通终端默认没有创建符号链接权限,开发环境回退为复制文件即可。
return runtime.GOOS == "windows" || strings.Contains(strings.ToLower(err.Error()), "privilege")
if err == nil {
return false
}
// Windows 普通终端默认没有创建符号链接权限,开发环境回退为复制文件即可。
return runtime.GOOS == "windows" || strings.Contains(strings.ToLower(err.Error()), "privilege")
}
func copySeedMedia(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return err
}
defer out.Close()
out, err := os.Create(dst)
if err != nil {
return err
}
defer out.Close()
if _, err := io.Copy(out, in); err != nil {
return err
}
return out.Close()
if _, err := io.Copy(out, in); err != nil {
return err
}
return out.Close()
}
func firstExistingPath(paths ...string) string {
@@ -0,0 +1,19 @@
import http from './http'
export function createOfficialAccountTask(data) {
return http.post('/official-account/tasks', data)
}
export function getOfficialAccountWorkflow(taskId) {
return http.get(`/official-account/tasks/${taskId}/workflow`)
}
export function updateOfficialAccountTask(taskId, data) {
return http.put(`/official-account/tasks/${taskId}`, data)
}
export function executeOfficialAccountWorkflowStep(taskId, stepKey, data) {
return http.post(`/official-account/tasks/${taskId}/steps/${stepKey}`, data, {
timeout: 0,
})
}
@@ -92,6 +92,20 @@ export const businessApps = [
stage: '报告汇总',
progress: 76,
},
{
key: 'wechat-official-account',
label: '公众号助手',
tier: 'generic',
workerType: 'dw',
marketTag: '已安装',
route: '/apps/wechat-official-account',
summary: '热点选题、标题、提纲、正文四步创作工作流',
status: '4 个节点可执行',
risk: '0 个异常',
color: '#2563eb',
stage: '正文创作',
progress: 0,
},
{
key: 'logistics-fulfillment',
label: '履约跟单专员',
+426 -15
View File
@@ -49,16 +49,54 @@
</header>
<div class="shell-body">
<nav class="nav-sidebar">
<nav class="nav-sidebar" :class="{ collapsed: topNavCollapsed }">
<button
v-for="item in visibleTopNavItems"
:key="item.key"
class="nav-sidebar-item"
:class="{ active: activeSurface === item.key }"
@click="router.push(item.route)"
class="sidebar-toggle drawer-handle"
:title="topNavCollapsed ? '展开模块' : '收起模块'"
:aria-label="topNavCollapsed ? '展开模块' : '收起模块'"
@click="toggleTopNavCollapsed"
>
{{ item.label }}
<span class="drawer-handle-grip" aria-hidden="true">
<span />
<span />
<span />
</span>
<el-icon class="drawer-handle-arrow" aria-hidden="true">
<ArrowRight v-if="topNavCollapsed" />
<ArrowLeft v-else />
</el-icon>
</button>
<template v-if="!topNavCollapsed">
<button
v-for="item in visibleTopNavItems"
:key="item.key"
class="nav-sidebar-item"
:class="{ active: activeSurface === item.key }"
@click="router.push(item.route)"
>
<span class="nav-sidebar-item-main">
<span class="nav-icon-badge nav-icon-badge-inline">
<el-icon><component :is="topNavIcon(item)" /></el-icon>
</span>
<span>{{ item.label }}</span>
</span>
</button>
</template>
<template v-else>
<button
v-for="item in visibleTopNavItems"
:key="item.key"
class="nav-sidebar-icon-item"
:class="{ active: activeSurface === item.key }"
:title="item.title || item.label"
:aria-label="item.title || item.label"
@click="router.push(item.route)"
>
<span class="nav-icon-badge">
<el-icon><component :is="topNavIcon(item)" /></el-icon>
</span>
</button>
</template>
</nav>
<div class="workspace-area">
@@ -82,7 +120,24 @@
</div>
<div class="workspace-body">
<aside v-if="activeSurface === 'apps'" class="left-rail">
<aside v-if="activeSurface === 'apps'" class="left-rail" :class="{ collapsed: appRailCollapsed }">
<button
class="drawer-handle"
:title="appRailCollapsed ? '展开专员工作区' : '收起专员工作区'"
:aria-label="appRailCollapsed ? '展开专员工作区' : '收起专员工作区'"
@click="toggleAppRailCollapsed"
>
<span class="drawer-handle-grip" aria-hidden="true">
<span />
<span />
<span />
</span>
<el-icon class="drawer-handle-arrow" aria-hidden="true">
<ArrowRight v-if="appRailCollapsed" />
<ArrowLeft v-else />
</el-icon>
</button>
<template v-if="!appRailCollapsed">
<section class="rail-section">
<div class="section-header">
<span>专员工作区</span>
@@ -97,7 +152,12 @@
@click="router.push(app.route)"
>
<div class="app-card-top">
<span class="app-name">{{ app.label }}</span>
<div class="app-card-title">
<span class="rail-icon-badge rail-icon-badge-inline">
<el-icon><component :is="railItemIcon(app, 'app')" /></el-icon>
</span>
<span class="app-name">{{ app.label }}</span>
</div>
<div class="app-card-tags">
<span class="app-worker-type">{{ app.workerType === 'adw' ? 'ADW' : 'DW' }}</span>
<span class="app-stage" :style="{ color: app.color }">{{ app.stage }}</span>
@@ -121,7 +181,12 @@
@click="router.push(app.route)"
>
<div class="app-card-top">
<span class="app-name">{{ app.label }}</span>
<div class="app-card-title">
<span class="rail-icon-badge rail-icon-badge-inline">
<el-icon><component :is="railItemIcon(app, 'app')" /></el-icon>
</span>
<span class="app-name">{{ app.label }}</span>
</div>
<div class="app-card-tags">
<span class="app-worker-type">{{ app.workerType === 'adw' ? 'ADW' : 'DW' }}</span>
<span class="app-stage" :style="{ color: app.color }">{{ app.stage }}</span>
@@ -150,8 +215,15 @@
:class="{ active: route.path === item.route }"
@click="router.push(item.route)"
>
<span>{{ item.label }}</span>
<small>{{ item.caption }}</small>
<span class="rail-link-main">
<span class="rail-icon-badge rail-icon-badge-inline">
<el-icon><component :is="railItemIcon(item, 'knowledge')" /></el-icon>
</span>
<span class="rail-link-text">
<span>{{ item.label }}</span>
<small>{{ item.caption }}</small>
</span>
</span>
</button>
</section>
@@ -168,7 +240,12 @@
@click="workbench.setSelectedCase(appKey, item.id)"
>
<div class="case-top">
<span class="case-title">{{ item.title }}</span>
<div class="case-title-wrap">
<span class="rail-icon-badge rail-icon-badge-inline rail-icon-badge-case">
<el-icon><component :is="railItemIcon(item, 'case')" /></el-icon>
</span>
<span class="case-title">{{ item.title }}</span>
</div>
<span class="case-priority">{{ item.priority }}</span>
</div>
<div class="case-summary">{{ item.summary }}</div>
@@ -191,9 +268,82 @@
:class="{ active: route.path === item.route }"
@click="router.push(item.route)"
>
<span>{{ item.label }}</span>
<span class="rail-link-main">
<span class="rail-icon-badge rail-icon-badge-inline">
<el-icon><component :is="railItemIcon(item, 'backend')" /></el-icon>
</span>
<span class="rail-link-text">
<span>{{ item.label }}</span>
</span>
</span>
</button>
</section>
</template>
<template v-else>
<div class="rail-icon-group">
<button
v-for="app in [...genericApps, ...industryApps]"
:key="app.key"
class="rail-icon-item"
:class="{ active: route.path === app.route }"
:title="app.label"
:aria-label="app.label"
@click="router.push(app.route)"
>
<span class="rail-icon-badge">
<el-icon><component :is="railItemIcon(app, 'app')" /></el-icon>
</span>
</button>
</div>
<div class="rail-icon-group" v-if="visibleKnowledgeLinks.length">
<button
v-for="item in visibleKnowledgeLinks"
:key="item.route"
class="rail-icon-item rail-icon-item-muted"
:class="{ active: route.path === item.route }"
:title="item.label"
:aria-label="item.label"
@click="router.push(item.route)"
>
<span class="rail-icon-badge">
<el-icon><component :is="railItemIcon(item, 'knowledge')" /></el-icon>
</span>
</button>
</div>
<div class="rail-icon-group" v-if="currentCases.length">
<button
v-for="item in currentCases"
:key="item.id"
class="rail-icon-item rail-icon-item-case"
:class="{ active: item.id === selectedCaseId }"
:title="item.title"
:aria-label="item.title"
@click="workbench.setSelectedCase(appKey, item.id)"
>
<span class="rail-icon-badge">
<el-icon><component :is="railItemIcon(item, 'case')" /></el-icon>
</span>
</button>
</div>
<div class="rail-icon-group" v-if="visibleBackendLinks.length">
<button
v-for="item in visibleBackendLinks"
:key="item.route"
class="rail-icon-item rail-icon-item-muted"
:class="{ active: route.path === item.route }"
:title="item.label"
:aria-label="item.label"
@click="router.push(item.route)"
>
<span class="rail-icon-badge">
<el-icon><component :is="railItemIcon(item, 'backend')" /></el-icon>
</span>
</button>
</div>
</template>
</aside>
<main class="workspace-column">
@@ -239,7 +389,7 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Bell, Close, Search } from '@element-plus/icons-vue'
import { ArrowLeft, ArrowRight, Bell, Close, Search, Menu, ShoppingBag, Tools, Reading, FolderOpened, Monitor, User, Setting, Files, EditPen } from '@element-plus/icons-vue'
import { useAuthStore } from '@/store/auth'
import { useNotificationStore } from '@/store/notification'
import { useWorkbenchStore } from '@/store/workbench'
@@ -322,6 +472,16 @@ const bottomOpen = computed({
get: () => workbench.bottomOpen,
set: (value) => workbench.setBottomOpen(value),
})
const topNavCollapsed = ref(localStorage.getItem('workbench-top-nav-collapsed') !== '0')
const appRailCollapsed = ref(localStorage.getItem('workbench-app-rail-collapsed') !== '0')
const topNavIconMap = {
apps: Menu,
market: ShoppingBag,
studio: Tools,
'knowledge-app': Reading,
'knowledge-admin': FolderOpened,
console: Monitor,
}
const currentTitle = computed(() => {
const currentNav = visibleTopNavItems.value.find((item) => item.key === activeSurface.value)
@@ -463,6 +623,43 @@ function formatDueAt(value) {
const minute = `${date.getMinutes()}`.padStart(2, '0')
return `${month}-${day} ${hour}:${minute}`
}
function topNavIcon(item) {
return topNavIconMap[item?.key] || Menu
}
function railItemIcon(target, kind = 'app') {
if (kind === 'knowledge') return Reading
if (kind === 'backend') return Setting
if (kind === 'case') return Files
const text = [
target?.key,
target?.route,
target?.label,
target?.title,
]
.filter(Boolean)
.join(' ')
.toLowerCase()
if (text.includes('official-account') || text.includes('公众号')) return EditPen
if (text.includes('knowledge') || text.includes('知识库')) return Reading
if (text.includes('console') || text.includes('admin') || text.includes('manage') || text.includes('配置')) return Setting
if (text.includes('market') || text.includes('市场')) return ShoppingBag
if (text.includes('studio') || text.includes('工坊')) return Tools
return User
}
function toggleTopNavCollapsed() {
topNavCollapsed.value = !topNavCollapsed.value
localStorage.setItem('workbench-top-nav-collapsed', topNavCollapsed.value ? '1' : '0')
}
function toggleAppRailCollapsed() {
appRailCollapsed.value = !appRailCollapsed.value
localStorage.setItem('workbench-app-rail-collapsed', appRailCollapsed.value ? '1' : '0')
}
</script>
<style scoped>
@@ -618,6 +815,74 @@ function formatDueAt(value) {
padding: 20px 12px;
background: #fff;
border-right: 1px solid var(--surface-border);
transition: width 0.22s ease, min-width 0.22s ease, padding 0.22s ease;
}
.nav-sidebar.collapsed {
width: 64px;
min-width: 64px;
padding: 16px 8px;
}
.sidebar-toggle {
width: 48px;
min-height: 60px;
border: none;
border-radius: 14px;
padding: 6px 4px;
background: linear-gradient(180deg, #eff6ff 0%, #e0ecff 100%);
color: #2563eb;
cursor: pointer;
box-shadow: inset 0 0 0 1px rgba(96, 165, 250, 0.22);
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 6px;
}
.drawer-handle {
width: 18px;
min-height: 76px;
padding: 8px 0;
margin: 0 2px 8px auto;
border-radius: 999px;
background: linear-gradient(180deg, #f8fbff 0%, #e8f0ff 100%);
box-shadow: inset 0 0 0 1px rgba(96, 165, 250, 0.2);
display: inline-flex;
flex-direction: column;
gap: 8px;
}
.drawer-handle:hover {
color: #1d4ed8;
transform: translateX(1px);
}
.nav-sidebar.collapsed .drawer-handle {
margin: 0 auto 8px;
}
.left-rail.collapsed .drawer-handle {
margin: 0 auto 8px;
}
.drawer-handle-grip {
display: inline-flex;
flex-direction: column;
gap: 4px;
align-items: center;
}
.drawer-handle-grip span {
width: 8px;
height: 2px;
border-radius: 999px;
background: currentColor;
opacity: 0.55;
}
.drawer-handle-arrow {
font-size: 10px;
}
.nav-sidebar-item {
@@ -633,12 +898,65 @@ function formatDueAt(value) {
text-align: left;
}
.nav-sidebar-item-main {
display: inline-flex;
align-items: center;
gap: 10px;
}
.nav-sidebar-item:hover,
.nav-sidebar-item.active {
background: rgba(64, 158, 255, 0.1);
color: var(--el-color-primary);
}
.nav-sidebar-icon-item {
width: 48px;
height: 48px;
margin: 0 auto;
border: none;
border-radius: 14px;
background: transparent;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
}
.nav-sidebar-icon-item:hover,
.nav-sidebar-icon-item.active {
background: rgba(64, 158, 255, 0.1);
}
.nav-icon-badge,
.rail-icon-badge {
width: 28px;
height: 28px;
border-radius: 10px;
background: rgba(37, 99, 235, 0.12);
color: #2563eb;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 700;
line-height: 1;
}
.nav-icon-badge :deep(.el-icon) {
font-size: 16px;
}
.rail-icon-badge :deep(.el-icon) {
font-size: 15px;
}
.nav-icon-badge-inline,
.rail-icon-badge-inline {
flex-shrink: 0;
}
.shell-body {
flex: 1;
min-height: 0;
@@ -672,6 +990,63 @@ function formatDueAt(value) {
background: #f6f9fc;
border-right: 1px solid var(--surface-border);
overflow-y: auto;
transition: width 0.22s ease, min-width 0.22s ease, padding 0.22s ease;
}
.left-rail.collapsed {
width: 76px;
min-width: 76px;
padding: 18px 10px;
overflow: hidden;
}
.rail-icon-group {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px 0;
border-top: 1px solid rgba(148, 163, 184, 0.16);
}
.rail-icon-group:first-of-type {
border-top: none;
padding-top: 0;
}
.rail-icon-item {
width: 44px;
height: 44px;
margin: 0 auto;
border: none;
border-radius: 14px;
background: rgba(255, 255, 255, 0.92);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.06);
transition: all 0.2s ease;
}
.rail-icon-item:hover,
.rail-icon-item.active {
transform: translateY(-1px);
background: rgba(239, 246, 255, 0.98);
box-shadow: 0 10px 20px rgba(37, 99, 235, 0.12);
}
.rail-icon-item.active .rail-icon-badge {
background: rgba(37, 99, 235, 0.18);
}
.rail-icon-item-muted .rail-icon-badge {
background: rgba(148, 163, 184, 0.14);
color: #475569;
}
.rail-icon-item-case .rail-icon-badge {
background: rgba(20, 184, 166, 0.14);
color: #0f766e;
}
.rail-section {
@@ -714,6 +1089,21 @@ function formatDueAt(value) {
transition: background 0.2s ease;
}
.rail-link-main {
display: inline-flex;
align-items: flex-start;
gap: 10px;
width: 100%;
}
.rail-link-text {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
text-align: left;
}
.rail-link small {
color: var(--surface-muted);
font-size: 12px;
@@ -757,12 +1147,24 @@ function formatDueAt(value) {
gap: 10px;
}
.case-title-wrap {
display: inline-flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.case-title {
font-size: 14px;
font-weight: 700;
color: var(--surface-text);
}
.rail-icon-badge-case {
background: rgba(20, 184, 166, 0.14);
color: #0f766e;
}
.case-priority {
font-size: 12px;
font-weight: 700;
@@ -815,6 +1217,13 @@ function formatDueAt(value) {
gap: 12px;
}
.app-card-title {
display: inline-flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.app-name {
font-size: 14px;
font-weight: 700;
@@ -910,6 +1319,8 @@ function formatDueAt(value) {
flex: 1;
min-height: 0;
overflow: auto;
padding: 12px;
padding: 20px;
padding: 20px;
}
+1
View File
@@ -23,6 +23,7 @@ const routes = [
{ path: 'apps/knowledge-operations', name: 'KnowledgeOperationsApp', meta: { workspaceKey: 'knowledge-operations' }, component: () => import('@/views/workbench/BusinessAppPage.vue') },
{ path: 'apps/process-coordination', name: 'ProcessCoordinationApp', meta: { workspaceKey: 'process-coordination' }, component: () => import('@/views/workbench/BusinessAppPage.vue') },
{ path: 'apps/report-generation', name: 'ReportGenerationApp', meta: { workspaceKey: 'report-generation' }, component: () => import('@/views/workbench/BusinessAppPage.vue') },
{ path: 'apps/wechat-official-account', name: 'WechatOfficialAccountApp', meta: { workspaceKey: 'wechat-official-account' }, component: () => import('@/views/workbench/OfficialAccountWorkflowPage.vue') },
{ path: 'apps/logistics-fulfillment', name: 'LogisticsFulfillmentApp', meta: { workspaceKey: 'logistics-fulfillment' }, component: () => import('@/views/workbench/BusinessAppPage.vue') },
{ path: 'apps/hr-email-sorter', name: 'HrEmailSorter', meta: { workspaceKey: 'hr-email-sorter' }, component: () => import('@/views/workbench/BusinessAppPage.vue') },
{ path: 'apps/resume-processor', name: 'ResumeProcessor', meta: { workspaceKey: 'resume-processor' }, component: () => import('@/views/workbench/BusinessAppPage.vue') },
File diff suppressed because it is too large Load Diff