fix: 渠道账号群聊同步缓存 key 及成员名称兜底;统一 Docker/Maven 项目约定

This commit is contained in:
2026-07-10 15:55:30 +08:00
parent 560bd4599b
commit 758ed73f9d
56 changed files with 7689 additions and 1127 deletions

116
.stitch/DESIGN.md Normal file
View File

@@ -0,0 +1,116 @@
# Design System: Sino-MCI 消息中台
## 1. Visual Theme & Atmosphere
A modern, high-density administration dashboard with a calm, professional atmosphere. The interface feels like a precision cockpit: information-dense but breathable, predictable but polished. Visual hierarchy is established through restrained color, generous whitespace in headers, and tight data grids in tables. The personality is trustworthy and operational — no marketing fluff, no decorative gradients, no visual noise.
- **Density:** 7/10 — Cockpit-dense tables and forms, airy page headers.
- **Variance:** 3/10 — Predictable, symmetric layouts for operational efficiency.
- **Motion:** 4/10 — Fluid, purposeful micro-interactions; no cinematic delays.
## 2. Color Palette & Roles
- **Canvas White** (#F8FAFC) — Primary background surface behind all content.
- **Pure Surface** (#FFFFFF) — Cards, modals, drawers, table rows, input backgrounds.
- **Charcoal Ink** (#0F172A) — Primary text, headings, key labels.
- **Muted Steel** (#64748B) — Secondary text, placeholders, metadata, disabled states.
- **Whisper Border** (#E2E8F0) — 1px structural borders, table dividers, section separators.
- **Soft Tint** (#F1F5F9) — Hover backgrounds, zebra striping, subtle section fills.
- **Signal Blue** (#2563EB) — Single accent for primary buttons, active nav items, focus rings, links, status "online".
- **Signal Red** (#DC2626) — Destructive actions, errors, failed status.
- **Signal Green** (#16A34A) — Success states, sent/delivered status.
- **Signal Amber** (#D97706) — Warnings, pending status, cautions.
No neon glows, no purple accents, no gradient text, no pure black.
## 3. Typography Rules
- **Display / Headlines:** `Geist, "Noto Sans SC", system-ui, sans-serif` — tight tracking, weight-driven hierarchy. H1 24px/700, H2 20px/600, H3 16px/600.
- **Body:** `Geist, "Noto Sans SC", system-ui, sans-serif` — 14px/1.6 line-height for Chinese readability, max 65ch in prose.
- **Mono / Data:** `"Geist Mono", "JetBrains Mono", "SF Mono", monospace` — timestamps, IDs, counts, JSON preview. 13px.
- **Banned:** Inter as the primary font, generic serifs, decorative display fonts.
Numbers in tables and metrics use monospace for alignment.
## 4. Component Stylings
### Buttons
- **Primary:** Signal Blue fill (#2563EB), white text, 6px border-radius, 32px height, 12px horizontal padding. Active: -1px translateY, darker fill (#1D4ED8). No outer glow.
- **Secondary:** White fill, Whisper Border, Charcoal Ink text. Hover: Soft Tint background.
- **Danger:** Signal Red fill or ghost style depending on context.
- **Text/Link buttons:** Signal Blue text, no underline, hover underline.
### Cards / Sections
- White surface, 1px Whisper Border, 8px border-radius, subtle shadow `0 1px 3px rgba(15, 23, 42, 0.06)`.
- Used to group related controls (filter bar, stats row, form sections).
- In high-density tables, replace nested cards with border-top dividers.
### Inputs / Forms
- Label above input, 12px/500 Muted Steel.
- Input height 32px, 6px border-radius, Whisper Border, focus ring 2px Signal Blue at 20% opacity.
- Error text below input in Signal Red, 12px.
- No floating labels, no placeholder-only inputs.
### Tables
- Header: Soft Tint background, 13px/600 Muted Steel uppercase tracking.
- Rows: White, 44px min-height, hover Soft Tint.
- Cell padding: 12px 16px.
- Status tags: small rounded pills with tinted backgrounds.
- Row actions: icon buttons or link buttons grouped at right.
### Navigation
- Left sidebar: 220px width, White surface, Whisper Border right.
- Menu items: 40px height, 12px/14px text, Signal Blue left-border active indicator, Soft Tint active background.
- Submenu: indented 16px, lighter Muted Steel.
- Top header: 56px height, White surface, bottom Whisper Border, page title left, global search + user avatar right.
### Drawers / Modals
- Drawer: 560px width from right, White surface, Whisper Border left, 16px padding.
- Modal: 520px max-width, 8px border-radius, centered, backdrop rgba(15,23,42,0.4).
- Close icon top-right.
### Tags / Pills
- Small rounded pills (12px height, 4px radius) with tinted background.
- Examples: blue tint for active, gray tint for default, green tint for success, red tint for error.
### Empty States
- Centered composed illustration + headline + single CTA.
- No generic "No data" alone; explain what to do next.
## 5. Layout Principles
- **Sidebar + Main:** Fixed left sidebar, main content scrolls. Main has 24px padding.
- **Page Header:** H1 page title + short description + top-right primary action button.
- **Filter Bar:** Collapsible card directly below header; inline form items with 12px gaps.
- **Data Table:** Full-width card below filter bar.
- **Max Width:** None for tables (fluid); prose max-width 720px.
- **Grid:** Use CSS Grid for dashboard cards; tables are full-width.
- **Responsive:** Below 768px, sidebar collapses to hamburger, tables horizontal-scroll with sticky first column, drawers become full-screen.
- **Spacing Scale:** 4px base. Common: 8, 12, 16, 24, 32, 48.
## 6. Motion & Interaction
- **Spring default:** `cubic-bezier(0.25, 0.46, 0.45, 0.94)` for UI transitions; 150ms for hovers, 200ms for drawer/modal open.
- **Micro-interactions:**
- Buttons: -1px lift on hover, press-down on active.
- Table rows: background-color transition on hover.
- Status dots: subtle pulse when in "sending" / "online" state.
- Drawers: slide from right with backdrop fade.
- **Loading:** Skeleton shimmer matching exact row dimensions in tables; no generic spinners.
- **Performance:** Animate only `transform` and `opacity`.
## 7. Anti-Patterns (Banned)
- No emojis in UI copy or icons.
- No Inter as the primary typeface.
- No pure black (#000000).
- No neon/outer glow shadows.
- No gradient text or hero gradients.
- No 3-column equal feature cards.
- No centered hero sections.
- No AI copywriting clichés: "Seamless", "Elevate", "Unleash", "Next-Gen".
- No filler text like "Scroll to explore" or bouncing arrows.
- No fabricated metrics or fake statistics.
- No overlapping elements — every component has clear spatial separation.
- No custom mouse cursors.
- No broken Unsplash links — use abstract SVG patterns or neutral placeholders.

View File

@@ -0,0 +1,656 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sino-MCI 消息中台 - 完整交互原型</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Noto+Sans+SC:wght@400;500;600;700&display=swap" rel="stylesheet" />
<script>
tailwind.config = {
theme: {
extend: {
colors: {
primary: { 50: '#eff6ff', 100: '#dbeafe', 500: '#3b82f6', 600: '#2563eb', 700: '#1d4ed8' },
slate: { 50: '#f8fafc', 100: '#f1f5f9', 200: '#e2e8f0', 300: '#cbd5e1', 400: '#94a3b8', 500: '#64748b', 600: '#475569', 900: '#0f172a' },
},
fontFamily: {
sans: ['Inter', 'Noto Sans SC', 'system-ui', 'sans-serif'],
},
},
},
};
</script>
<style>
body { font-family: 'Inter', 'Noto Sans SC', system-ui, sans-serif; }
.nav-link.active { background: #eff6ff; color: #2563eb; border-right: 3px solid #2563eb; }
.drawer { transform: translateX(100%); transition: transform .25s ease; }
.drawer.open { transform: translateX(0); }
.scrollbar::-webkit-scrollbar { width: 6px; height: 6px; }
.scrollbar::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 3px; }
</style>
</head>
<body class="bg-slate-50 text-slate-900 h-screen overflow-hidden flex">
<!-- Sidebar -->
<aside class="w-64 bg-white border-r border-slate-200 flex flex-col flex-shrink-0">
<div class="h-16 flex items-center px-6 border-b border-slate-200">
<div class="w-8 h-8 rounded-lg bg-primary-600 flex items-center justify-center text-white font-bold mr-3">M</div>
<div>
<div class="font-bold text-slate-900 leading-tight">Sino-MCI</div>
<div class="text-xs text-slate-500">消息中台</div>
</div>
</div>
<nav class="flex-1 overflow-y-auto scrollbar py-3 text-sm" id="sidebar">
<a href="#" data-target="dashboard" class="nav-link active flex items-center px-6 py-2.5 text-slate-600 hover:bg-slate-50">
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/></svg>
概览
</a>
<div class="px-6 pt-4 pb-1 text-xs font-semibold text-slate-400 uppercase tracking-wider">渠道接入</div>
<a href="#" data-target="channels" class="nav-link flex items-center px-6 py-2.5 text-slate-600 hover:bg-slate-50"><svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/></svg>渠道主体</a>
<a href="#" data-target="channel-apps" class="nav-link flex items-center px-6 py-2.5 text-slate-600 hover:bg-slate-50"><svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"/></svg>渠道应用</a>
<a href="#" data-target="channel-accounts" class="nav-link flex items-center px-6 py-2.5 text-slate-600 hover:bg-slate-50"><svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg>渠道账号</a>
<div class="px-6 pt-4 pb-1 text-xs font-semibold text-slate-400 uppercase tracking-wider">客户关系</div>
<a href="#" data-target="contacts" class="nav-link flex items-center px-6 py-2.5 text-slate-600 hover:bg-slate-50"><svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/></svg>联系人</a>
<a href="#" data-target="groups" class="nav-link flex items-center px-6 py-2.5 text-slate-600 hover:bg-slate-50"><svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/></svg>群聊</a>
<a href="#" data-target="tags" class="nav-link flex items-center px-6 py-2.5 text-slate-600 hover:bg-slate-50"><svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"/></svg>标签体系</a>
<div class="px-6 pt-4 pb-1 text-xs font-semibold text-slate-400 uppercase tracking-wider">消息运营</div>
<a href="#" data-target="send-records" class="nav-link flex items-center px-6 py-2.5 text-slate-600 hover:bg-slate-50"><svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"/></svg>发送记录</a>
<a href="#" data-target="messages" class="nav-link flex items-center px-6 py-2.5 text-slate-600 hover:bg-slate-50"><svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"/></svg>消息查询</a>
<div class="px-6 pt-4 pb-1 text-xs font-semibold text-slate-400 uppercase tracking-wider">流程编排</div>
<a href="#" data-target="inbound-flow" class="nav-link flex items-center px-6 py-2.5 text-slate-600 hover:bg-slate-50"><svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z"/></svg>入站流程</a>
<div class="px-6 pt-4 pb-1 text-xs font-semibold text-slate-400 uppercase tracking-wider">系统管理</div>
<a href="#" data-target="tenants" class="nav-link flex items-center px-6 py-2.5 text-slate-600 hover:bg-slate-50"><svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/></svg>租户管理</a>
<a href="#" data-target="users" class="nav-link flex items-center px-6 py-2.5 text-slate-600 hover:bg-slate-50"><svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"/></svg>用户管理</a>
</nav>
</aside>
<!-- Main -->
<main class="flex-1 flex flex-col min-w-0">
<!-- Header -->
<header class="h-16 bg-white border-b border-slate-200 flex items-center justify-between px-6 flex-shrink-0">
<div class="text-lg font-semibold text-slate-900" id="page-title">概览</div>
<div class="flex items-center gap-4">
<button class="p-2 text-slate-500 hover:text-primary-600"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"/></svg></button>
<div class="flex items-center gap-2 pr-4 border-r border-slate-200">
<label class="text-sm text-slate-500">租户</label>
<select id="tenant-selector" class="border border-slate-200 rounded-lg px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500">
<option value="">全部租户</option>
<option value="default">默认租户</option>
<option value="sinocare">Sinocare</option>
</select>
</div>
<div class="flex items-center gap-2">
<div class="w-8 h-8 rounded-full bg-primary-100 text-primary-700 flex items-center justify-center font-semibold text-sm"></div>
<div class="text-sm">
<div class="font-medium text-slate-900">平台管理员</div>
<div class="text-xs text-slate-500">admin@sino-mci</div>
</div>
</div>
</div>
</header>
<!-- Content Views -->
<div class="flex-1 overflow-y-auto scrollbar p-6" id="content">
<!-- Dashboard -->
<section id="dashboard" class="view space-y-6">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div class="bg-white rounded-xl border border-slate-200 p-5"><div class="text-sm text-slate-500">今日发送消息</div><div class="text-3xl font-bold text-slate-900 mt-1">12,458</div><div class="text-xs text-emerald-600 mt-2">↑ 12% 较昨日</div></div>
<div class="bg-white rounded-xl border border-slate-200 p-5"><div class="text-sm text-slate-500">今日接收消息</div><div class="text-3xl font-bold text-slate-900 mt-1">8,932</div><div class="text-xs text-emerald-600 mt-2">↑ 8% 较昨日</div></div>
<div class="bg-white rounded-xl border border-slate-200 p-5"><div class="text-sm text-slate-500">渠道账号在线</div><div class="text-3xl font-bold text-slate-900 mt-1">24 / 26</div><div class="text-xs text-slate-400 mt-2">2 个离线</div></div>
<div class="bg-white rounded-xl border border-slate-200 p-5"><div class="text-sm text-slate-500">待处理入站会话</div><div class="text-3xl font-bold text-slate-900 mt-1">156</div><div class="text-xs text-amber-600 mt-2">需人工跟进</div></div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="lg:col-span-2 bg-white rounded-xl border border-slate-200 p-5">
<div class="flex items-center justify-between mb-4"><h3 class="font-semibold text-slate-900">最近消息</h3><button class="text-sm text-primary-600 hover:underline">查看全部</button></div>
<table class="w-full text-sm text-left"><thead class="text-xs text-slate-500 bg-slate-50"><tr><th class="px-3 py-2 font-medium">时间</th><th class="px-3 py-2 font-medium">渠道</th><th class="px-3 py-2 font-medium">发送方</th><th class="px-3 py-2 font-medium">内容摘要</th><th class="px-3 py-2 font-medium">状态</th></tr></thead>
<tbody class="divide-y divide-slate-100">
<tr class="hover:bg-slate-50"><td class="px-3 py-3 text-slate-500">10:23</td><td class="px-3 py-3">企微</td><td class="px-3 py-3">客户 A</td><td class="px-3 py-3 truncate max-w-xs">我要查询订单状态,订单号是 2025...</td><td class="px-3 py-3"><span class="px-2 py-0.5 rounded-full text-xs bg-amber-50 text-amber-700">待处理</span></td></tr>
<tr class="hover:bg-slate-50"><td class="px-3 py-3 text-slate-500">10:18</td><td class="px-3 py-3">个微</td><td class="px-3 py-3">客户 B</td><td class="px-3 py-3 truncate max-w-xs">[图片] 车牌号 京 A12345</td><td class="px-3 py-3"><span class="px-2 py-0.5 rounded-full text-xs bg-emerald-50 text-emerald-700">已归档</span></td></tr>
<tr class="hover:bg-slate-50"><td class="px-3 py-3 text-slate-500">10:05</td><td class="px-3 py-3">企微</td><td class="px-3 py-3">客户 C</td><td class="px-3 py-3 truncate max-w-xs">帮我发一份报价单到邮箱</td><td class="px-3 py-3"><span class="px-2 py-0.5 rounded-full text-xs bg-blue-50 text-blue-700">处理中</span></td></tr>
</tbody>
</table>
</div>
<div class="bg-white rounded-xl border border-slate-200 p-5">
<h3 class="font-semibold text-slate-900 mb-4">渠道状态</h3>
<div class="space-y-3">
<div class="flex items-center justify-between"><div class="flex items-center gap-2"><span class="w-2 h-2 rounded-full bg-emerald-500"></span><span class="text-sm">企业微信主体-01</span></div><span class="text-xs text-emerald-600">正常</span></div>
<div class="flex items-center justify-between"><div class="flex items-center gap-2"><span class="w-2 h-2 rounded-full bg-emerald-500"></span><span class="text-sm">个人微信机器人-A</span></div><span class="text-xs text-emerald-600">正常</span></div>
<div class="flex items-center justify-between"><div class="flex items-center gap-2"><span class="w-2 h-2 rounded-full bg-slate-300"></span><span class="text-sm">钉钉(预留)</span></div><span class="text-xs text-slate-400">未启用</span></div>
<div class="flex items-center justify-between"><div class="flex items-center gap-2"><span class="w-2 h-2 rounded-full bg-slate-300"></span><span class="text-sm">飞书(预留)</span></div><span class="text-xs text-slate-400">未启用</span></div>
</div>
</div>
</div>
</section>
<!-- Channel Subjects -->
<section id="channels" class="view hidden space-y-4">
<div class="flex items-center justify-between">
<div class="flex gap-2"><input type="text" placeholder="搜索主体名称 / corpId" class="border border-slate-200 rounded-lg px-3 py-2 text-sm w-64 focus:outline-none focus:ring-2 focus:ring-primary-500" /><button class="bg-white border border-slate-200 text-slate-600 px-3 py-2 rounded-lg text-sm hover:bg-slate-50">筛选</button></div>
<button class="bg-primary-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-primary-700" onclick="openModal('channel-subject-modal')">新增主体</button>
</div>
<div class="bg-white rounded-xl border border-slate-200 overflow-hidden">
<table class="w-full text-sm text-left">
<thead class="text-xs text-slate-500 bg-slate-50"><tr><th class="px-4 py-3 font-medium">主体名称</th><th class="px-4 py-3 font-medium">渠道类型</th><th class="px-4 py-3 font-medium">CorpID / 标识</th><th class="px-4 py-3 font-medium">应用数</th><th class="px-4 py-3 font-medium">账号数</th><th class="px-4 py-3 font-medium">状态</th></tr></thead>
<tbody class="divide-y divide-slate-100">
<tr class="hover:bg-slate-50 cursor-pointer" onclick="openDrawer('channel-detail')">
<td class="px-4 py-3 font-medium text-slate-900">Sinocare 企业微信</td>
<td class="px-4 py-3"><span class="px-2 py-0.5 rounded text-xs bg-blue-50 text-blue-700">企微</span></td>
<td class="px-4 py-3 text-slate-500">ww1234567890abcdef</td>
<td class="px-4 py-3">2</td>
<td class="px-4 py-3">18</td>
<td class="px-4 py-3"><span class="px-2 py-0.5 rounded-full text-xs bg-emerald-50 text-emerald-700">已连接</span></td>
</tr>
<tr class="hover:bg-slate-50 cursor-pointer" onclick="openDrawer('channel-detail')">
<td class="px-4 py-3 font-medium text-slate-900">Sinocare 个人微信</td>
<td class="px-4 py-3"><span class="px-2 py-0.5 rounded text-xs bg-green-50 text-green-700">个微</span></td>
<td class="px-4 py-3 text-slate-500">pywechat-001</td>
<td class="px-4 py-3">-</td>
<td class="px-4 py-3">3</td>
<td class="px-4 py-3"><span class="px-2 py-0.5 rounded-full text-xs bg-emerald-50 text-emerald-700">已连接</span></td>
</tr>
</tbody>
</table>
</div>
<div class="flex items-center justify-between bg-white rounded-xl border border-slate-200 px-4 py-3 text-sm">
<div class="text-slate-500">共 2 条</div>
<div class="flex items-center gap-2">
<button class="px-2 py-1 rounded border border-slate-200 text-slate-400" disabled>&lt;</button>
<button class="px-2.5 py-1 rounded bg-primary-600 text-white">1</button>
<button class="px-2 py-1 rounded border border-slate-200 text-slate-400" disabled>&gt;</button>
</div>
</div>
</section>
<!-- Channel Apps -->
<section id="channel-apps" class="view hidden space-y-4">
<div class="flex items-center justify-between"><h3 class="font-semibold text-slate-900">企微应用列表</h3><button class="bg-primary-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-primary-700">新增应用</button></div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div class="bg-white rounded-xl border border-slate-200 p-5 hover:shadow-sm transition">
<div class="flex items-center justify-between mb-3"><div class="font-semibold text-slate-900">会话存档应用</div><span class="px-2 py-0.5 rounded-full text-xs bg-emerald-50 text-emerald-700">启用</span></div>
<div class="text-sm text-slate-500 space-y-1"><div>AgentID: 1000002</div><div>Secret: ••••••••</div><div>会话存档: 已开启</div></div>
<div class="mt-4 flex gap-2"><button class="text-sm text-primary-600 hover:underline">编辑</button><button class="text-sm text-slate-500 hover:text-slate-700">初始化账号</button></div>
</div>
<div class="bg-white rounded-xl border border-slate-200 p-5 hover:shadow-sm transition">
<div class="flex items-center justify-between mb-3"><div class="font-semibold text-slate-900">消息推送应用</div><span class="px-2 py-0.5 rounded-full text-xs bg-emerald-50 text-emerald-700">启用</span></div>
<div class="text-sm text-slate-500 space-y-1"><div>AgentID: 1000005</div><div>Secret: ••••••••</div><div>主动推送: 已开启</div></div>
<div class="mt-4 flex gap-2"><button class="text-sm text-primary-600 hover:underline">编辑</button><button class="text-sm text-slate-500 hover:text-slate-700">初始化账号</button></div>
</div>
</div>
</section>
<!-- Channel Accounts -->
<section id="channel-accounts" class="view hidden space-y-4">
<div class="flex items-center justify-between">
<div class="flex gap-2"><input type="text" placeholder="搜索账号 / UserID" class="border border-slate-200 rounded-lg px-3 py-2 text-sm w-64 focus:outline-none focus:ring-2 focus:ring-primary-500" /><select class="border border-slate-200 rounded-lg px-3 py-2 text-sm"><option>全部主体</option><option>Sinocare 企业微信</option></select></div>
<button class="bg-primary-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-primary-700">新增账号</button>
</div>
<div class="bg-white rounded-xl border border-slate-200 overflow-hidden">
<table class="w-full text-sm text-left"><thead class="text-xs text-slate-500 bg-slate-50"><tr><th class="px-4 py-3 font-medium">账号昵称</th><th class="px-4 py-3 font-medium">UserID</th><th class="px-4 py-3 font-medium">所属主体</th><th class="px-4 py-3 font-medium">部门路径</th><th class="px-4 py-3 font-medium">会话存档</th><th class="px-4 py-3 font-medium">状态</th></tr></thead>
<tbody class="divide-y divide-slate-100">
<tr class="hover:bg-slate-50 cursor-pointer" onclick="openDrawer('channel-account-detail')"><td class="px-4 py-3 flex items-center gap-2"><div class="w-7 h-7 rounded-full bg-slate-200"></div><span class="font-medium text-slate-900">张三</span></td><td class="px-4 py-3 text-slate-500">zhangsan</td><td class="px-4 py-3">Sinocare 企业微信</td><td class="px-4 py-3 text-slate-500">/销售部/华东区</td><td class="px-4 py-3"><span class="px-2 py-0.5 rounded-full text-xs bg-emerald-50 text-emerald-700">已开启</span></td><td class="px-4 py-3"><span class="px-2 py-0.5 rounded-full text-xs bg-emerald-50 text-emerald-700">在线</span></td></tr>
<tr class="hover:bg-slate-50 cursor-pointer" onclick="openDrawer('channel-account-detail')"><td class="px-4 py-3 flex items-center gap-2"><div class="w-7 h-7 rounded-full bg-slate-200"></div><span class="font-medium text-slate-900">李四</span></td><td class="px-4 py-3 text-slate-500">lisi</td><td class="px-4 py-3">Sinocare 企业微信</td><td class="px-4 py-3 text-slate-500">/客服部</td><td class="px-4 py-3"><span class="px-2 py-0.5 rounded-full text-xs bg-slate-100 text-slate-600">未开启</span></td><td class="px-4 py-3"><span class="px-2 py-0.5 rounded-full text-xs bg-slate-100 text-slate-600">离线</span></td></tr>
</tbody>
</table>
</div>
<div class="flex items-center justify-between bg-white rounded-xl border border-slate-200 px-4 py-3 text-sm">
<div class="text-slate-500">共 2 条</div>
<div class="flex items-center gap-2"><button class="px-2 py-1 rounded border border-slate-200 text-slate-400" disabled>&lt;</button><button class="px-2.5 py-1 rounded bg-primary-600 text-white">1</button><button class="px-2 py-1 rounded border border-slate-200 text-slate-400" disabled>&gt;</button></div>
</div>
</section>
<!-- Contacts -->
<section id="contacts" class="view hidden space-y-4">
<div class="flex items-center justify-between">
<div class="flex gap-2"><input type="text" placeholder="搜索姓名 / 手机号 / UserID" class="border border-slate-200 rounded-lg px-3 py-2 text-sm w-64" /><button class="bg-white border border-slate-200 text-slate-600 px-3 py-2 rounded-lg text-sm hover:bg-slate-50">标签筛选</button></div>
<button class="bg-primary-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-primary-700">新增联系人</button>
</div>
<div class="bg-white rounded-xl border border-slate-200 overflow-hidden">
<table class="w-full text-sm text-left"><thead class="text-xs text-slate-500 bg-slate-50"><tr><th class="px-4 py-3 font-medium">联系人</th><th class="px-4 py-3 font-medium">渠道身份</th><th class="px-4 py-3 font-medium">所属主体</th><th class="px-4 py-3 font-medium">标签</th><th class="px-4 py-3 font-medium">最近会话</th></tr></thead>
<tbody class="divide-y divide-slate-100">
<tr class="hover:bg-slate-50 cursor-pointer" onclick="openDrawer('contact-detail')">
<td class="px-4 py-3"><div class="flex items-center gap-3"><div class="w-9 h-9 rounded-full bg-blue-100 text-blue-700 flex items-center justify-center font-semibold">A</div><div><div class="font-medium text-slate-900">艾先生</div><div class="text-xs text-slate-500">138****1234</div></div></div></td>
<td class="px-4 py-3 text-slate-500">wmABC123</td>
<td class="px-4 py-3">Sinocare 企业微信</td>
<td class="px-4 py-3"><span class="px-2 py-0.5 rounded text-xs bg-blue-50 text-blue-700">VIP客户</span> <span class="px-2 py-0.5 rounded text-xs bg-amber-50 text-amber-700">待回访</span></td>
<td class="px-4 py-3 text-slate-500">10 分钟前</td>
</tr>
<tr class="hover:bg-slate-50 cursor-pointer" onclick="openDrawer('contact-detail')">
<td class="px-4 py-3"><div class="flex items-center gap-3"><div class="w-9 h-9 rounded-full bg-emerald-100 text-emerald-700 flex items-center justify-center font-semibold">B</div><div><div class="font-medium text-slate-900">白女士</div><div class="text-xs text-slate-500">139****5678</div></div></div></td>
<td class="px-4 py-3 text-slate-500">wmDEF456</td>
<td class="px-4 py-3">Sinocare 企业微信</td>
<td class="px-4 py-3"><span class="px-2 py-0.5 rounded text-xs bg-slate-100 text-slate-600">普通客户</span></td>
<td class="px-4 py-3 text-slate-500">2 小时前</td>
</tr>
</tbody>
</table>
</div>
<div class="flex items-center justify-between bg-white rounded-xl border border-slate-200 px-4 py-3 text-sm">
<div class="text-slate-500">共 2 条</div>
<div class="flex items-center gap-2"><button class="px-2 py-1 rounded border border-slate-200 text-slate-400" disabled>&lt;</button><button class="px-2.5 py-1 rounded bg-primary-600 text-white">1</button><button class="px-2 py-1 rounded border border-slate-200 text-slate-400" disabled>&gt;</button></div>
</div>
</section>
<!-- Groups -->
<section id="groups" class="view hidden space-y-4">
<div class="flex items-center justify-between"><div class="flex gap-2"><input type="text" placeholder="搜索群名 / 群ID" class="border border-slate-200 rounded-lg px-3 py-2 text-sm w-64" /></div><button class="bg-primary-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-primary-700">抓取已有群聊</button></div>
<div class="bg-white rounded-xl border border-slate-200 overflow-hidden">
<table class="w-full text-sm text-left"><thead class="text-xs text-slate-500 bg-slate-50"><tr><th class="px-4 py-3 font-medium">群名称</th><th class="px-4 py-3 font-medium">群ID</th><th class="px-4 py-3 font-medium">所属主体</th><th class="px-4 py-3 font-medium">人数</th><th class="px-4 py-3 font-medium">标签</th></tr></thead>
<tbody class="divide-y divide-slate-100">
<tr class="hover:bg-slate-50 cursor-pointer" onclick="openDrawer('group-detail')"><td class="px-4 py-3 font-medium text-slate-900">华东销售群</td><td class="px-4 py-3 text-slate-500">wrABC123</td><td class="px-4 py-3">Sinocare 企业微信</td><td class="px-4 py-3">128</td><td class="px-4 py-3"><span class="px-2 py-0.5 rounded text-xs bg-blue-50 text-blue-700">销售群</span></td></tr>
</tbody>
</table>
</div>
<div class="flex items-center justify-between bg-white rounded-xl border border-slate-200 px-4 py-3 text-sm">
<div class="text-slate-500">共 1 条</div>
<div class="flex items-center gap-2"><button class="px-2 py-1 rounded border border-slate-200 text-slate-400" disabled>&lt;</button><button class="px-2.5 py-1 rounded bg-primary-600 text-white">1</button><button class="px-2 py-1 rounded border border-slate-200 text-slate-400" disabled>&gt;</button></div>
</div>
</section>
<!-- Send Records -->
<section id="send-records" class="view hidden space-y-4">
<div class="flex items-center justify-between">
<div class="flex gap-2"><input type="text" placeholder="任务ID / 回调地址" class="border border-slate-200 rounded-lg px-3 py-2 text-sm w-64" /><select class="border border-slate-200 rounded-lg px-3 py-2 text-sm"><option>全部状态</option><option>发送中</option><option>成功</option><option>失败</option></select></div>
<button class="bg-primary-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-primary-700">测试发送</button>
</div>
<div class="bg-white rounded-xl border border-slate-200 overflow-hidden">
<table class="w-full text-sm text-left"><thead class="text-xs text-slate-500 bg-slate-50"><tr><th class="px-4 py-3 font-medium">任务ID</th><th class="px-4 py-3 font-medium">业务系统</th><th class="px-4 py-3 font-medium">目标类型</th><th class="px-4 py-3 font-medium">目标数</th><th class="px-4 py-3 font-medium">发送时间</th><th class="px-4 py-3 font-medium">回调地址</th><th class="px-4 py-3 font-medium">状态</th><th class="px-4 py-3 font-medium text-right">日志</th></tr></thead>
<tbody class="divide-y divide-slate-100">
<tr class="hover:bg-slate-50 cursor-pointer"><td class="px-4 py-3 font-medium text-slate-900">SND-20250709-001</td><td class="px-4 py-3">CRM</td><td class="px-4 py-3">单聊</td><td class="px-4 py-3">1,200</td><td class="px-4 py-3 text-slate-500">2025-07-09 09:30</td><td class="px-4 py-3 text-slate-500 truncate max-w-xs">https://crm.example.com/callback</td><td class="px-4 py-3"><span class="px-2 py-0.5 rounded-full text-xs bg-emerald-50 text-emerald-700">成功</span></td><td class="px-4 py-3 text-right"><button class="text-slate-400 hover:text-primary-600">查看</button></td></tr>
<tr class="hover:bg-slate-50 cursor-pointer"><td class="px-4 py-3 font-medium text-slate-900">SND-20250709-002</td><td class="px-4 py-3">ERP</td><td class="px-4 py-3">群聊</td><td class="px-4 py-3">45</td><td class="px-4 py-3 text-slate-500">2025-07-09 09:45</td><td class="px-4 py-3 text-slate-500">-</td><td class="px-4 py-3"><span class="px-2 py-0.5 rounded-full text-xs bg-red-50 text-red-700">失败</span></td><td class="px-4 py-3 text-right"><button class="text-slate-400 hover:text-primary-600">查看</button></td></tr>
</tbody>
</table>
</div>
<div class="flex items-center justify-between bg-white rounded-xl border border-slate-200 px-4 py-3 text-sm">
<div class="text-slate-500">共 2 条</div>
<div class="flex items-center gap-2"><button class="px-2 py-1 rounded border border-slate-200 text-slate-400" disabled>&lt;</button><button class="px-2.5 py-1 rounded bg-primary-600 text-white">1</button><button class="px-2 py-1 rounded border border-slate-200 text-slate-400" disabled>&gt;</button></div>
</div>
</section>
<!-- Messages -->
<section id="messages" class="view hidden h-[calc(100vh-140px)]">
<div class="bg-white rounded-xl border border-slate-200 h-full flex overflow-hidden">
<!-- Conversation list -->
<div class="w-80 border-r border-slate-200 flex flex-col">
<div class="p-3 border-b border-slate-200"><input type="text" placeholder="搜索会话" class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm" /></div>
<div class="flex-1 overflow-y-auto scrollbar">
<div class="p-3 border-b border-slate-100 hover:bg-slate-50 cursor-pointer bg-slate-50">
<div class="flex justify-between items-center"><div class="font-medium text-slate-900">艾先生</div><div class="text-xs text-slate-400">10:23</div></div>
<div class="text-sm text-slate-500 truncate">我要查询订单状态</div>
</div>
<div class="p-3 border-b border-slate-100 hover:bg-slate-50 cursor-pointer">
<div class="flex justify-between items-center"><div class="font-medium text-slate-900">华东销售群</div><div class="text-xs text-slate-400">09:58</div></div>
<div class="text-sm text-slate-500 truncate">[图片] 车牌号 京A12345</div>
</div>
</div>
</div>
<!-- Chat area -->
<div class="flex-1 flex flex-col">
<div class="h-14 border-b border-slate-200 flex items-center justify-between px-4">
<div class="font-medium text-slate-900">艾先生</div>
<div class="flex gap-2"><button class="text-sm text-slate-500 hover:text-primary-600">打标签</button><button class="text-sm text-slate-500 hover:text-primary-600">会话详情</button></div>
</div>
<div class="flex-1 overflow-y-auto scrollbar p-4 space-y-4 bg-slate-50">
<div class="flex justify-start"><div class="max-w-md bg-white border border-slate-200 rounded-2xl rounded-tl-sm px-4 py-2 text-sm text-slate-800 shadow-sm">我要查询订单状态</div></div>
<div class="flex justify-start"><div class="max-w-md bg-white border border-slate-200 rounded-2xl rounded-tl-sm px-4 py-2 text-sm text-slate-800 shadow-sm">订单号是 20250709001</div></div>
<div class="flex justify-end"><div class="max-w-md bg-primary-600 text-white rounded-2xl rounded-tr-sm px-4 py-2 text-sm shadow-sm">好的,已为您查询到订单正在发货中。</div></div>
</div>
<div class="p-3 border-t border-slate-200 flex gap-2">
<input type="text" placeholder="输入消息..." class="flex-1 border border-slate-200 rounded-lg px-3 py-2 text-sm" />
<button class="bg-primary-600 text-white px-4 py-2 rounded-lg text-sm font-medium">发送</button>
</div>
</div>
</div>
</section>
<!-- Inbound Flow -->
<section id="inbound-flow" class="view hidden space-y-4">
<div class="flex items-center justify-between">
<div class="flex gap-2"><input type="text" placeholder="规则名称" class="border border-slate-200 rounded-lg px-3 py-2 text-sm w-64" /></div>
<button class="bg-primary-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-primary-700">新建流程</button>
</div>
<div class="bg-white rounded-xl border border-slate-200 h-[calc(100vh-220px)] flex">
<div class="w-72 border-r border-slate-200 p-4 overflow-y-auto">
<h4 class="font-semibold text-slate-900 mb-3">规则列表</h4>
<div class="space-y-2">
<div class="p-3 rounded-lg bg-primary-50 border border-primary-200 cursor-pointer"><div class="font-medium text-primary-700 text-sm">订单咨询自动分拣</div><div class="text-xs text-slate-500 mt-1">命中“订单”关键词</div></div>
<div class="p-3 rounded-lg border border-slate-200 hover:bg-slate-50 cursor-pointer"><div class="font-medium text-slate-900 text-sm">地址/车牌号收集</div><div class="text-xs text-slate-500 mt-1">多轮消息确认</div></div>
</div>
</div>
<div class="flex-1 bg-slate-50 relative p-8">
<div class="absolute top-6 left-1/2 -translate-x-1/2 px-4 py-2 bg-white border border-slate-200 rounded-lg shadow-sm text-sm font-medium">入站消息触发</div>
<div class="absolute top-24 left-1/2 -translate-x-1/2 w-0.5 h-12 bg-slate-300"></div>
<div class="absolute top-40 left-1/2 -translate-x-1/2 px-4 py-2 bg-white border border-slate-200 rounded-lg shadow-sm text-sm font-medium">LLM 意图识别</div>
<div class="absolute top-56 left-1/2 -translate-x-1/2 w-0.5 h-12 bg-slate-300"></div>
<div class="absolute top-72 left-1/2 -translate-x-1/2 flex gap-8">
<div class="px-4 py-2 bg-emerald-50 border border-emerald-200 rounded-lg text-sm text-emerald-700">自动回复</div>
<div class="px-4 py-2 bg-amber-50 border border-amber-200 rounded-lg text-sm text-amber-700">转人工</div>
<div class="px-4 py-2 bg-blue-50 border border-blue-200 rounded-lg text-sm text-blue-700">调用业务系统</div>
</div>
<div class="absolute bottom-6 right-6 text-xs text-slate-400">注:画布仅示意,真实实现使用 @vue-flow</div>
</div>
</div>
</section>
<!-- Tenants -->
<section id="tenants" class="view hidden space-y-4">
<div class="flex items-center justify-between"><button class="bg-primary-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-primary-700" onclick="openModal('tenant-modal')">新增租户</button></div>
<div class="bg-white rounded-xl border border-slate-200 overflow-hidden">
<table class="w-full text-sm text-left"><thead class="text-xs text-slate-500 bg-slate-50"><tr><th class="px-4 py-3 font-medium">租户编码</th><th class="px-4 py-3 font-medium">租户名称</th><th class="px-4 py-3 font-medium">负责人</th><th class="px-4 py-3 font-medium">业务系统数</th><th class="px-4 py-3 font-medium">状态</th></tr></thead>
<tbody class="divide-y divide-slate-100">
<tr class="hover:bg-slate-50 cursor-pointer" onclick="openDrawer('tenant-detail')"><td class="px-4 py-3 font-medium text-slate-900">TENANT_DEFAULT</td><td class="px-4 py-3">默认租户</td><td class="px-4 py-3">system</td><td class="px-4 py-3">3</td><td class="px-4 py-3"><span class="px-2 py-0.5 rounded-full text-xs bg-emerald-50 text-emerald-700">启用</span></td></tr>
<tr class="hover:bg-slate-50 cursor-pointer" onclick="openDrawer('tenant-detail')"><td class="px-4 py-3 font-medium text-slate-900">TENANT_001</td><td class="px-4 py-3">Sinocare</td><td class="px-4 py-3">张三</td><td class="px-4 py-3">2</td><td class="px-4 py-3"><span class="px-2 py-0.5 rounded-full text-xs bg-emerald-50 text-emerald-700">启用</span></td></tr>
</tbody>
</table>
</div>
<div class="flex items-center justify-between bg-white rounded-xl border border-slate-200 px-4 py-3 text-sm">
<div class="text-slate-500">共 2 条</div>
<div class="flex items-center gap-2"><button class="px-2 py-1 rounded border border-slate-200 text-slate-400" disabled>&lt;</button><button class="px-2.5 py-1 rounded bg-primary-600 text-white">1</button><button class="px-2 py-1 rounded border border-slate-200 text-slate-400" disabled>&gt;</button></div>
</div>
</section>
<!-- Users -->
<section id="users" class="view hidden space-y-4">
<div class="flex items-center justify-between"><div class="flex gap-2"><select class="border border-slate-200 rounded-lg px-3 py-2 text-sm"><option>全部租户</option><option>默认租户</option></select></div><button class="bg-primary-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-primary-700" onclick="openModal('user-modal')">新增用户</button></div>
<div class="bg-white rounded-xl border border-slate-200 overflow-hidden">
<table class="w-full text-sm text-left"><thead class="text-xs text-slate-500 bg-slate-50"><tr><th class="px-4 py-3 font-medium">用户名</th><th class="px-4 py-3 font-medium">角色</th><th class="px-4 py-3 font-medium">所属租户</th><th class="px-4 py-3 font-medium">手机号</th><th class="px-4 py-3 font-medium">状态</th></tr></thead>
<tbody class="divide-y divide-slate-100">
<tr class="hover:bg-slate-50 cursor-pointer" onclick="openDrawer('user-detail')"><td class="px-4 py-3 font-medium text-slate-900">admin</td><td class="px-4 py-3"><span class="px-2 py-0.5 rounded text-xs bg-purple-50 text-purple-700">平台管理员</span></td><td class="px-4 py-3">全部</td><td class="px-4 py-3 text-slate-500">138****0000</td><td class="px-4 py-3"><span class="px-2 py-0.5 rounded-full text-xs bg-emerald-50 text-emerald-700">启用</span></td></tr>
<tr class="hover:bg-slate-50 cursor-pointer" onclick="openDrawer('user-detail')"><td class="px-4 py-3 font-medium text-slate-900">ops_01</td><td class="px-4 py-3"><span class="px-2 py-0.5 rounded text-xs bg-blue-50 text-blue-700">租户管理员</span></td><td class="px-4 py-3">Sinocare</td><td class="px-4 py-3 text-slate-500">139****1111</td><td class="px-4 py-3"><span class="px-2 py-0.5 rounded-full text-xs bg-emerald-50 text-emerald-700">启用</span></td></tr>
</tbody>
</table>
</div>
<div class="flex items-center justify-between bg-white rounded-xl border border-slate-200 px-4 py-3 text-sm">
<div class="text-slate-500">共 2 条</div>
<div class="flex items-center gap-2"><button class="px-2 py-1 rounded border border-slate-200 text-slate-400" disabled>&lt;</button><button class="px-2.5 py-1 rounded bg-primary-600 text-white">1</button><button class="px-2 py-1 rounded border border-slate-200 text-slate-400" disabled>&gt;</button></div>
</div>
</section>
<!-- Tags -->
<section id="tags" class="view hidden space-y-4">
<div class="flex items-center justify-between"><button class="bg-primary-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-primary-700">新增标签</button></div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="lg:col-span-1 bg-white rounded-xl border border-slate-200 p-4">
<h4 class="font-semibold text-slate-900 mb-3">标签树</h4>
<div class="space-y-2 text-sm">
<div class="p-2 rounded bg-slate-50 font-medium text-slate-900">客户等级</div>
<div class="pl-4 space-y-1 text-slate-600">
<div class="p-2 rounded hover:bg-slate-50 cursor-pointer">VIP客户</div>
<div class="p-2 rounded hover:bg-slate-50 cursor-pointer">普通客户</div>
<div class="p-2 rounded hover:bg-slate-50 cursor-pointer">潜在客户</div>
</div>
<div class="p-2 rounded bg-slate-50 font-medium text-slate-900">跟进状态</div>
<div class="pl-4 space-y-1 text-slate-600">
<div class="p-2 rounded hover:bg-slate-50 cursor-pointer">待回访</div>
<div class="p-2 rounded hover:bg-slate-50 cursor-pointer">已成交</div>
</div>
</div>
</div>
<div class="lg:col-span-2 bg-white rounded-xl border border-slate-200 p-5">
<h4 class="font-semibold text-slate-900 mb-4">标签详情</h4>
<div class="grid grid-cols-2 gap-4 text-sm">
<div><label class="block text-slate-500 mb-1">标签名称</label><input type="text" value="VIP客户" class="w-full border border-slate-200 rounded-lg px-3 py-2" /></div>
<div><label class="block text-slate-500 mb-1">上级标签</label><select class="w-full border border-slate-200 rounded-lg px-3 py-2"><option>客户等级</option><option></option></select></div>
<div class="col-span-2"><label class="block text-slate-500 mb-1">备注</label><textarea class="w-full border border-slate-200 rounded-lg px-3 py-2" rows="3">高价值客户标签</textarea></div>
</div>
<div class="mt-4 flex gap-2"><button class="bg-primary-600 text-white px-4 py-2 rounded-lg text-sm">保存</button><button class="bg-white border border-slate-200 text-slate-600 px-4 py-2 rounded-lg text-sm">删除</button></div>
</div>
</div>
</section>
</div>
</main>
<!-- Drawer Overlay -->
<div id="drawer-overlay" class="fixed inset-0 bg-black/20 z-40 hidden" onclick="closeDrawer()"></div>
<!-- Channel Detail Drawer -->
<aside id="channel-detail" class="drawer fixed top-0 right-0 h-full w-[480px] bg-white shadow-2xl z-50 border-l border-slate-200 flex flex-col">
<div class="h-16 border-b border-slate-200 flex items-center justify-between px-6">
<div class="font-semibold text-slate-900">渠道主体详情</div>
<button class="text-slate-400 hover:text-slate-600" onclick="closeDrawer()"></button>
</div>
<div class="flex-1 overflow-y-auto scrollbar p-6 space-y-6">
<div class="space-y-3 text-sm">
<div class="flex justify-between"><span class="text-slate-500">主体名称</span><span class="font-medium text-slate-900">Sinocare 企业微信</span></div>
<div class="flex justify-between"><span class="text-slate-500">渠道类型</span><span class="px-2 py-0.5 rounded text-xs bg-blue-50 text-blue-700">企微</span></div>
<div class="flex justify-between"><span class="text-slate-500">CorpID</span><span class="text-slate-700">ww1234567890abcdef</span></div>
<div class="flex justify-between"><span class="text-slate-500">状态</span><span class="px-2 py-0.5 rounded-full text-xs bg-emerald-50 text-emerald-700">已连接</span></div>
</div>
<div>
<div class="flex items-center justify-between mb-2"><h4 class="font-semibold text-slate-900">应用</h4><button class="text-sm text-primary-600 hover:underline">新增应用</button></div>
<div class="bg-slate-50 rounded-lg border border-slate-200 p-3 text-sm space-y-2">
<div class="flex justify-between"><span class="font-medium">会话存档应用</span><span class="text-xs text-emerald-600">启用</span></div>
<div class="flex justify-between"><span class="font-medium">消息推送应用</span><span class="text-xs text-emerald-600">启用</span></div>
</div>
</div>
<div>
<div class="flex items-center justify-between mb-2"><h4 class="font-semibold text-slate-900">账号</h4><button class="text-sm text-primary-600 hover:underline">初始化账号</button></div>
<div class="bg-slate-50 rounded-lg border border-slate-200 p-3 text-sm space-y-2">
<div class="flex justify-between"><span>张三 (zhangsan)</span><span class="text-xs text-slate-500">会话存档 开</span></div>
<div class="flex justify-between"><span>李四 (lisi)</span><span class="text-xs text-slate-500">会话存档 关</span></div>
</div>
</div>
</div>
<div class="p-4 border-t border-slate-200 flex gap-2">
<button class="flex-1 bg-primary-600 text-white py-2 rounded-lg text-sm font-medium">保存</button>
<button class="flex-1 bg-white border border-slate-200 text-slate-600 py-2 rounded-lg text-sm">取消</button>
</div>
</aside>
<!-- Contact Detail Drawer -->
<aside id="contact-detail" class="drawer fixed top-0 right-0 h-full w-[420px] bg-white shadow-2xl z-50 border-l border-slate-200 flex flex-col">
<div class="h-16 border-b border-slate-200 flex items-center justify-between px-6">
<div class="font-semibold text-slate-900">联系人详情</div>
<button class="text-slate-400 hover:text-slate-600" onclick="closeDrawer()"></button>
</div>
<div class="flex-1 overflow-y-auto scrollbar p-6 space-y-6">
<div class="flex items-center gap-4">
<div class="w-16 h-16 rounded-full bg-blue-100 text-blue-700 flex items-center justify-center text-2xl font-bold">A</div>
<div>
<div class="text-lg font-bold text-slate-900">艾先生</div>
<div class="text-sm text-slate-500">138****1234</div>
</div>
</div>
<div class="space-y-3 text-sm">
<div class="flex justify-between"><span class="text-slate-500">渠道身份</span><span class="text-slate-700">wmABC123</span></div>
<div class="flex justify-between"><span class="text-slate-500">所属主体</span><span class="text-slate-700">Sinocare 企业微信</span></div>
<div class="flex justify-between"><span class="text-slate-500">来源</span><span class="text-slate-700">会话存档</span></div>
</div>
<div>
<div class="font-semibold text-slate-900 mb-2">标签</div>
<div class="flex flex-wrap gap-2">
<span class="px-2 py-1 rounded text-xs bg-blue-50 text-blue-700">VIP客户</span>
<span class="px-2 py-1 rounded text-xs bg-amber-50 text-amber-700">待回访</span>
<button class="px-2 py-1 rounded text-xs border border-dashed border-slate-300 text-slate-500 hover:border-primary-500 hover:text-primary-600">+ 编辑标签</button>
</div>
</div>
<div>
<div class="flex items-center justify-between mb-2"><div class="font-semibold text-slate-900">关联会话</div><div class="text-xs text-slate-500">12 条</div></div>
<div class="space-y-2">
<div class="p-3 rounded-lg border border-slate-200 text-sm hover:bg-slate-50 cursor-pointer"><div class="text-slate-500 text-xs mb-1">2025-07-09 10:23</div><div class="text-slate-800">我要查询订单状态</div></div>
<div class="p-3 rounded-lg border border-slate-200 text-sm hover:bg-slate-50 cursor-pointer"><div class="text-slate-500 text-xs mb-1">2025-07-08 16:45</div><div class="text-slate-800">帮我发一份报价单</div></div>
</div>
</div>
</div>
<div class="p-4 border-t border-slate-200 flex gap-2">
<button class="flex-1 bg-primary-600 text-white py-2 rounded-lg text-sm font-medium">编辑</button>
<button class="flex-1 bg-white border border-slate-200 text-slate-600 py-2 rounded-lg text-sm">发消息</button>
</div>
</aside>
<!-- Channel Account Detail Drawer -->
<aside id="channel-account-detail" class="drawer fixed top-0 right-0 h-full w-[420px] bg-white shadow-2xl z-50 border-l border-slate-200 flex flex-col">
<div class="h-16 border-b border-slate-200 flex items-center justify-between px-6"><div class="font-semibold text-slate-900">渠道账号详情</div><button class="text-slate-400 hover:text-slate-600" onclick="closeDrawer()"></button></div>
<div class="flex-1 overflow-y-auto scrollbar p-6 space-y-6">
<div class="flex items-center gap-4"><div class="w-16 h-16 rounded-full bg-slate-200 flex items-center justify-center text-2xl font-bold text-slate-500"></div><div><div class="text-lg font-bold text-slate-900">张三</div><div class="text-sm text-slate-500">zhangsan</div></div></div>
<div class="space-y-3 text-sm">
<div class="flex justify-between"><span class="text-slate-500">所属主体</span><span class="text-slate-700">Sinocare 企业微信</span></div>
<div class="flex justify-between"><span class="text-slate-500">部门路径</span><span class="text-slate-700">/销售部/华东区</span></div>
<div class="flex justify-between"><span class="text-slate-500">会话存档</span><span class="px-2 py-0.5 rounded-full text-xs bg-emerald-50 text-emerald-700">已开启</span></div>
<div class="flex justify-between"><span class="text-slate-500">在线状态</span><span class="px-2 py-0.5 rounded-full text-xs bg-emerald-50 text-emerald-700">在线</span></div>
</div>
</div>
<div class="p-4 border-t border-slate-200 flex gap-2"><button class="flex-1 bg-primary-600 text-white py-2 rounded-lg text-sm font-medium">编辑</button><button class="flex-1 bg-white border border-slate-200 text-slate-600 py-2 rounded-lg text-sm">同步企微信息</button></div>
</aside>
<!-- Group Detail Drawer -->
<aside id="group-detail" class="drawer fixed top-0 right-0 h-full w-[420px] bg-white shadow-2xl z-50 border-l border-slate-200 flex flex-col">
<div class="h-16 border-b border-slate-200 flex items-center justify-between px-6"><div class="font-semibold text-slate-900">群聊详情</div><button class="text-slate-400 hover:text-slate-600" onclick="closeDrawer()"></button></div>
<div class="flex-1 overflow-y-auto scrollbar p-6 space-y-6">
<div><div class="text-lg font-bold text-slate-900">华东销售群</div><div class="text-sm text-slate-500">wrABC123 · 128 人</div></div>
<div class="space-y-3 text-sm">
<div class="flex justify-between"><span class="text-slate-500">所属主体</span><span class="text-slate-700">Sinocare 企业微信</span></div>
<div class="flex justify-between"><span class="text-slate-500">群主</span><span class="text-slate-700">张三</span></div>
<div class="flex justify-between"><span class="text-slate-500">创建时间</span><span class="text-slate-700">2025-01-15</span></div>
</div>
<div>
<div class="font-semibold text-slate-900 mb-2">标签</div>
<div class="flex flex-wrap gap-2"><span class="px-2 py-1 rounded text-xs bg-blue-50 text-blue-700">销售群</span><button class="px-2 py-1 rounded text-xs border border-dashed border-slate-300 text-slate-500 hover:border-primary-500 hover:text-primary-600">+ 编辑标签</button></div>
</div>
</div>
<div class="p-4 border-t border-slate-200 flex gap-2"><button class="flex-1 bg-primary-600 text-white py-2 rounded-lg text-sm font-medium">编辑</button><button class="flex-1 bg-white border border-slate-200 text-slate-600 py-2 rounded-lg text-sm">查看会话</button></div>
</aside>
<!-- Tenant Detail Drawer -->
<aside id="tenant-detail" class="drawer fixed top-0 right-0 h-full w-[420px] bg-white shadow-2xl z-50 border-l border-slate-200 flex flex-col">
<div class="h-16 border-b border-slate-200 flex items-center justify-between px-6"><div class="font-semibold text-slate-900">租户详情</div><button class="text-slate-400 hover:text-slate-600" onclick="closeDrawer()"></button></div>
<div class="flex-1 overflow-y-auto scrollbar p-6 space-y-6">
<div><div class="text-lg font-bold text-slate-900">Sinocare</div><div class="text-sm text-slate-500">TENANT_001</div></div>
<div class="space-y-3 text-sm">
<div class="flex justify-between"><span class="text-slate-500">负责人</span><span class="text-slate-700">张三</span></div>
<div class="flex justify-between"><span class="text-slate-500">业务系统数</span><span class="text-slate-700">2</span></div>
<div class="flex justify-between"><span class="text-slate-500">状态</span><span class="px-2 py-0.5 rounded-full text-xs bg-emerald-50 text-emerald-700">启用</span></div>
</div>
<div>
<div class="font-semibold text-slate-900 mb-2">业务系统</div>
<div class="space-y-2"><div class="p-3 rounded-lg border border-slate-200 text-sm">CRM</div><div class="p-3 rounded-lg border border-slate-200 text-sm">ERP</div></div>
</div>
</div>
<div class="p-4 border-t border-slate-200 flex gap-2"><button class="flex-1 bg-primary-600 text-white py-2 rounded-lg text-sm font-medium">编辑</button><button class="flex-1 bg-white border border-red-200 text-red-600 py-2 rounded-lg text-sm">删除</button></div>
</aside>
<!-- User Detail Drawer -->
<aside id="user-detail" class="drawer fixed top-0 right-0 h-full w-[420px] bg-white shadow-2xl z-50 border-l border-slate-200 flex flex-col">
<div class="h-16 border-b border-slate-200 flex items-center justify-between px-6"><div class="font-semibold text-slate-900">用户详情</div><button class="text-slate-400 hover:text-slate-600" onclick="closeDrawer()"></button></div>
<div class="flex-1 overflow-y-auto scrollbar p-6 space-y-6">
<div class="flex items-center gap-4"><div class="w-16 h-16 rounded-full bg-primary-100 text-primary-700 flex items-center justify-center text-2xl font-bold"></div><div><div class="text-lg font-bold text-slate-900">admin</div><div class="text-sm text-slate-500">平台管理员</div></div></div>
<div class="space-y-3 text-sm">
<div class="flex justify-between"><span class="text-slate-500">所属租户</span><span class="text-slate-700">全部</span></div>
<div class="flex justify-between"><span class="text-slate-500">手机号</span><span class="text-slate-700">138****0000</span></div>
<div class="flex justify-between"><span class="text-slate-500">状态</span><span class="px-2 py-0.5 rounded-full text-xs bg-emerald-50 text-emerald-700">启用</span></div>
</div>
</div>
<div class="p-4 border-t border-slate-200 flex gap-2"><button class="flex-1 bg-primary-600 text-white py-2 rounded-lg text-sm font-medium">编辑</button><button class="flex-1 bg-white border border-red-200 text-red-600 py-2 rounded-lg text-sm">删除</button></div>
</aside>
<!-- Modal: Add Tenant -->
<div id="tenant-modal" class="fixed inset-0 bg-black/30 z-50 hidden flex items-center justify-center">
<div class="bg-white rounded-xl shadow-2xl w-[480px] max-w-[90%] max-h-[90vh] overflow-y-auto">
<div class="h-14 border-b border-slate-200 flex items-center justify-between px-6"><div class="font-semibold text-slate-900">新增租户</div><button class="text-slate-400 hover:text-slate-600" onclick="closeModal('tenant-modal')"></button></div>
<div class="p-6 space-y-4 text-sm">
<div><label class="block text-slate-500 mb-1">租户编码</label><input type="text" class="w-full border border-slate-200 rounded-lg px-3 py-2" /></div>
<div><label class="block text-slate-500 mb-1">租户名称</label><input type="text" class="w-full border border-slate-200 rounded-lg px-3 py-2" /></div>
<div><label class="block text-slate-500 mb-1">负责人</label><input type="text" class="w-full border border-slate-200 rounded-lg px-3 py-2" /></div>
<div><label class="block text-slate-500 mb-1">备注</label><textarea class="w-full border border-slate-200 rounded-lg px-3 py-2" rows="3"></textarea></div>
</div>
<div class="p-4 border-t border-slate-200 flex justify-end gap-2">
<button class="px-4 py-2 rounded-lg border border-slate-200 text-slate-600 text-sm" onclick="closeModal('tenant-modal')">取消</button>
<button class="px-4 py-2 rounded-lg bg-primary-600 text-white text-sm font-medium" onclick="closeModal('tenant-modal')">确认</button>
</div>
</div>
</div>
<!-- Modal: Add User -->
<div id="user-modal" class="fixed inset-0 bg-black/30 z-50 hidden flex items-center justify-center">
<div class="bg-white rounded-xl shadow-2xl w-[480px] max-w-[90%] max-h-[90vh] overflow-y-auto">
<div class="h-14 border-b border-slate-200 flex items-center justify-between px-6"><div class="font-semibold text-slate-900">新增用户</div><button class="text-slate-400 hover:text-slate-600" onclick="closeModal('user-modal')"></button></div>
<div class="p-6 space-y-4 text-sm">
<div><label class="block text-slate-500 mb-1">用户名</label><input type="text" class="w-full border border-slate-200 rounded-lg px-3 py-2" /></div>
<div><label class="block text-slate-500 mb-1">角色</label><select class="w-full border border-slate-200 rounded-lg px-3 py-2"><option>平台管理员</option><option>租户管理员</option><option>运营人员</option></select></div>
<div><label class="block text-slate-500 mb-1">所属租户</label><select class="w-full border border-slate-200 rounded-lg px-3 py-2"><option>全部租户</option><option>默认租户</option><option>Sinocare</option></select></div>
<div><label class="block text-slate-500 mb-1">手机号</label><input type="text" class="w-full border border-slate-200 rounded-lg px-3 py-2" /></div>
</div>
<div class="p-4 border-t border-slate-200 flex justify-end gap-2">
<button class="px-4 py-2 rounded-lg border border-slate-200 text-slate-600 text-sm" onclick="closeModal('user-modal')">取消</button>
<button class="px-4 py-2 rounded-lg bg-primary-600 text-white text-sm font-medium" onclick="closeModal('user-modal')">确认</button>
</div>
</div>
</div>
<!-- Modal: Add Channel Subject -->
<div id="channel-subject-modal" class="fixed inset-0 bg-black/30 z-50 hidden flex items-center justify-center">
<div class="bg-white rounded-xl shadow-2xl w-[480px] max-w-[90%] max-h-[90vh] overflow-y-auto">
<div class="h-14 border-b border-slate-200 flex items-center justify-between px-6"><div class="font-semibold text-slate-900">新增渠道主体</div><button class="text-slate-400 hover:text-slate-600" onclick="closeModal('channel-subject-modal')"></button></div>
<div class="p-6 space-y-4 text-sm">
<div><label class="block text-slate-500 mb-1">主体名称</label><input type="text" class="w-full border border-slate-200 rounded-lg px-3 py-2" /></div>
<div><label class="block text-slate-500 mb-1">渠道类型</label><select class="w-full border border-slate-200 rounded-lg px-3 py-2"><option>企业微信</option><option>个人微信</option><option>钉钉(预留)</option><option>飞书(预留)</option></select></div>
<div><label class="block text-slate-500 mb-1">CorpID / 标识</label><input type="text" class="w-full border border-slate-200 rounded-lg px-3 py-2" /></div>
<div><label class="block text-slate-500 mb-1">备注</label><textarea class="w-full border border-slate-200 rounded-lg px-3 py-2" rows="3"></textarea></div>
</div>
<div class="p-4 border-t border-slate-200 flex justify-end gap-2">
<button class="px-4 py-2 rounded-lg border border-slate-200 text-slate-600 text-sm" onclick="closeModal('channel-subject-modal')">取消</button>
<button class="px-4 py-2 rounded-lg bg-primary-600 text-white text-sm font-medium" onclick="closeModal('channel-subject-modal')">确认</button>
</div>
</div>
</div>
<script>
const links = document.querySelectorAll('.nav-link');
const views = document.querySelectorAll('.view');
const titleMap = {
dashboard: '概览',
channels: '渠道主体',
'channel-apps': '渠道应用',
'channel-accounts': '渠道账号',
contacts: '联系人',
groups: '群聊',
'send-records': '发送记录',
messages: '消息查询',
'inbound-flow': '入站流程',
tenants: '租户管理',
users: '用户管理',
tags: '标签体系',
};
links.forEach(link => {
link.addEventListener('click', e => {
e.preventDefault();
const target = link.dataset.target;
showView(target);
links.forEach(l => l.classList.remove('active'));
link.classList.add('active');
});
});
function showView(id) {
views.forEach(v => v.classList.add('hidden'));
const el = document.getElementById(id);
if (el) el.classList.remove('hidden');
document.getElementById('page-title').textContent = titleMap[id] || '';
}
function openDrawer(id) {
document.getElementById('drawer-overlay').classList.remove('hidden');
document.querySelectorAll('.drawer').forEach(d => d.classList.remove('open'));
document.getElementById(id).classList.add('open');
}
function closeDrawer() {
document.getElementById('drawer-overlay').classList.add('hidden');
document.querySelectorAll('.drawer').forEach(d => d.classList.remove('open'));
}
function openModal(id) {
document.getElementById(id).classList.remove('hidden');
}
function closeModal(id) {
document.getElementById(id).classList.add('hidden');
}
</script>
</body>
</html>

94
AGENTS.md Normal file
View File

@@ -0,0 +1,94 @@
# Sino-MCI 项目约定
> 本文件用于固定开发/部署时的关键操作,避免反复踩坑。
## 1. Java 与 Maven
- **必须使用 JDK 21**`/Library/Java/JavaVirtualMachines/zulu-21.jdk/Contents/Home`
- 项目已配置 Maven Toolchain**必须在 `backend/` 目录下执行完整 reactor 构建**
```bash
cd /Users/marsal/Projects/sino-project/sino-mci/backend
mvn clean install -DskipTests
```
- **不要**用 `-pl` 单模块编译,会跳过 `.mvn/maven.config` 中的 toolchains 配置,导致编译报 JDK 17 错误。
- 当前环境 `java -version` 可能显示 JDK 17但 Maven 会通过 toolchain 自动切换到 JDK 21无需手动修改 `JAVA_HOME`。
## 2. Docker Compose 项目名(重要)
Docker Compose 的 `-p` 参数决定容器前缀和命名卷前缀。反复换 `-p` 会产生多个 MySQL 数据卷,导致数据“丢失”的假象。
**统一使用项目名 `sino-mci`**
```bash
cd /Users/marsal/Projects/sino-project/sino-mci
# 启动全部基础设施 + 应用
# 使用 --profile 同时启用 infra 和 app否则带 profile 的服务不会启动
docker compose -p sino-mci --profile infra --profile app up -d
# 只启动基础设施MySQL/Redis/RabbitMQ/SeaweedFS
docker compose -p sino-mci --profile infra up -d
# 只启动应用mci-server / admin-web / channel-service
docker compose -p sino-mci --profile app up -d
# 停止整个项目(不删除数据卷)
docker compose -p sino-mci --profile infra --profile app down
```
- 不带 `-p` 时,项目名默认就是目录名 `sino-mci`,与上面等价。
- **不要**再使用 `-p infrastructure` 或其他名字启动,避免产生新的数据卷。
## 3. 数据卷
当前项目使用的命名卷:
- `sino-mci_mci-mysql-data`
- `sino-mci_mci-seaweedfs-data`
- `sino-mci_channel-service-data`
### 检查当前 MySQL 容器挂载的是哪个卷
```bash
docker inspect -f '{{ range .Mounts }}{{ if eq .Destination "/var/lib/mysql" }}{{ .Name }}{{ end }}{{ end }}' mci-mysql
```
### 查看所有 sino-mci 相关卷
```bash
docker volume ls | grep sino-mci
```
### 历史 `infrastructure_*` 卷
早期用 `-p infrastructure` 启动过,留下以下卷,**目前未被使用**
- `infrastructure_mci-mysql-data`
- `infrastructure_mci-seaweedfs-data`
- `infrastructure_channel-service-data`
如需迁移/核对其中的数据,请先停止当前 `mci-mysql`,再临时挂载该卷启动容器查询,查完立即恢复。
## 4. 常用命令
```bash
# 查看服务状态
docker compose -p sino-mci ps
# 查看 mci-server 日志
docker logs -f mci-server --tail 100
# 进入 MySQL
docker exec -it mci-mysql mysql -uroot -proot sino_mci
# 进入 Redis
docker exec -it mci-redis redis-cli
# 重新构建 mci-server 镜像(先执行 mvn clean install -DskipTests
docker compose -p sino-mci --profile app build mci-server
```
## 5. 环境变量
- 复制 `.env.example` 为 `.env` 后再修改本地配置。
- 默认 root 密码为 `root`,仅本地开发使用。

View File

@@ -0,0 +1,47 @@
# admin-web 视图重构指南
## 目标
把现有 Element Plus 页面统一为新的 Sino-MCI 设计语言:现代、统一、操作便捷、信息密度高。
## 设计参考
参考原型:`.stitch/designs/sino-mci-prototype.html`
## 通用规范
### 页面结构
1. 顶部工具栏:左侧放搜索/筛选,右侧放唯一主操作按钮(新增/创建/测试发送等)。
2. 内容区:白色卡片(`el-card` 或自定义 `.bg-white rounded-xl border`)包裹表格。
3. 表格:
- 删除无意义复选框;
- 删除每行多个操作按钮;
- 行可点击,点击后从右侧滑出抽屉(`el-drawer`)查看/编辑详情;
- 保留状态开关、标签等必要展示字段;
- 需要时显示分页(`el-pagination`)。
4. 新增用弹窗(`el-dialog`),编辑用抽屉,保持两者形式统一:标题栏 + 表单 + 底部确认/取消。
### 视觉
- 主色:`#2563EB`
- 背景:`#F8FAFC`
- 卡片/表面:`#FFFFFF`,圆角 12px边框 `#E2E8F0`
- 文字主色:`#0F172A`,次要:`#64748B`
- 字号:正文 14px标题 16-18px辅助 12-13px
- 间距:页面内边距 24px卡片内部 16-20px
### 交互
- 行 hover 变灰(`hover:bg-slate-50` 等效样式)。
- 按钮层级:主操作 `type="primary"`,次级 `link` 或默认。
- 删除操作放在抽屉底部或二次确认弹窗中,不要直接显示在列表行。
- 状态变更尽量使用行内 `el-switch`,切换后调用 API 并刷新列表。
### 代码约束
- 保留原有 API 调用和业务逻辑,只改模板和样式。
- 使用 `<script setup lang="ts">`
- 继续使用 Element Plus 组件,不引入新依赖。
- 每个视图文件底部使用 `<style scoped>` 自定义局部样式,避免污染全局。
- TypeScript 编译不能报错;重构后应能通过 `npm run build`
### 抽屉内容示例
- 对象名称/标题
- 基本信息(描述列表或表单)
- 关联信息(应用、账号、标签、会话等)
- 底部:保存 / 取消 / 删除

View File

@@ -8,6 +8,7 @@
"name": "admin-web",
"version": "0.0.0",
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"@vue-flow/background": "^1.3.2",
"@vue-flow/controls": "^1.1.2",
"@vue-flow/core": "^1.42.1",
@@ -395,6 +396,29 @@
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.11.1",
"resolved": "https://registry.npmmirror.com/@emnapi/core/-/core-1.11.1.tgz",
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0"
}
},
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.11.1",
"resolved": "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.11.1.tgz",
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.1.4",
"resolved": "https://registry.npmmirror.com/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.4.tgz",

View File

@@ -10,14 +10,15 @@
"test": "vue-tsc -b && npm run build"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"@vue-flow/background": "^1.3.2",
"@vue-flow/controls": "^1.1.2",
"@vue-flow/core": "^1.42.1",
"axios": "^1.18.1",
"element-plus": "^2.14.2",
"pinia": "^3.0.1",
"vue": "^3.5.39",
"vue-router": "^4.6.4",
"@vue-flow/core": "^1.42.1",
"@vue-flow/background": "^1.3.2",
"@vue-flow/controls": "^1.1.2"
"vue-router": "^4.6.4"
},
"devDependencies": {
"@types/node": "^24.13.2",

View File

@@ -28,6 +28,10 @@ export function initChannelAccount(data: any) {
return client.post('/api/v1/channel-account/init', data)
}
export function syncChannelAccountContacts(id: number) {
return client.post(`/api/v1/channel-account/${id}/sync-contacts`)
}
export function syncChannelAccountConversations(id: number) {
return client.post(`/api/v1/channel-account/${id}/sync-conversations`)
}

View File

@@ -0,0 +1,9 @@
import client from './client'
export function getSyncTask(id: number) {
return client.get(`/api/v1/sync-task/${id}`)
}
export function syncSubjectEnterpriseContacts(id: number) {
return client.post(`/api/v1/channel-subject/${id}/sync-contacts`)
}

View File

@@ -1,64 +1,119 @@
<template>
<el-container class="layout">
<el-aside width="200px" class="sidebar">
<div class="logo">Sino MCI</div>
<el-menu :default-active="$route.path" router>
<el-aside width="240px" class="sidebar">
<div class="logo">
<div class="logo-icon">M</div>
<div class="logo-text">
<div class="logo-title">Sino-MCI</div>
<div class="logo-sub">消息中台</div>
</div>
</div>
<el-menu
:default-active="$route.path"
router
class="menu"
:collapse-transition="false"
>
<el-menu-item index="/dashboard">
<el-icon><DataLine /></el-icon>
<span>概览</span>
</el-menu-item>
<el-menu-item index="/channel-subject">
<span>渠道主体</span>
</el-menu-item>
<el-menu-item index="/channel-account">
<span>渠道账号</span>
</el-menu-item>
<el-menu-item index="/conversation">
<span>会话/群聊</span>
</el-menu-item>
<el-menu-item index="/person">
<span>联系人</span>
</el-menu-item>
<el-menu-item index="/tag">
<span>标签</span>
</el-menu-item>
<el-menu-item index="/send-record">
<span>发送记录</span>
</el-menu-item>
<el-menu-item index="/inbound-flow">
<span>入站流程</span>
</el-menu-item>
<el-menu-item index="/ivr-flow">
<span>IVR 流程</span>
</el-menu-item>
<el-menu-item index="/flow-trace">
<span>流程追踪</span>
</el-menu-item>
<el-menu-item index="/message-test">
<span>消息测试</span>
</el-menu-item>
<el-menu-item index="/inbound-webhook">
<span>入站 Webhook</span>
</el-menu-item>
<el-menu-item index="/send-policy">
<span>发送策略</span>
</el-menu-item>
<el-menu-item v-if="authStore.roleCodes.includes('platform_admin')" index="/tenant-management">
<span>租户管理</span>
</el-menu-item>
<el-menu-item index="/user-management">
<span>用户管理</span>
</el-menu-item>
<el-sub-menu index="/channel">
<template #title>
<el-icon><Connection /></el-icon>
<span>渠道接入</span>
</template>
<el-menu-item index="/channel/subject">渠道主体</el-menu-item>
<el-menu-item index="/channel/app">渠道应用</el-menu-item>
<el-menu-item index="/channel/account">渠道账号</el-menu-item>
</el-sub-menu>
<el-sub-menu index="/customer">
<template #title>
<el-icon><User /></el-icon>
<span>客户关系</span>
</template>
<el-menu-item index="/customer/person">联系人</el-menu-item>
<el-menu-item index="/customer/group">群聊</el-menu-item>
<el-menu-item index="/customer/tag">标签体系</el-menu-item>
</el-sub-menu>
<el-sub-menu index="/message">
<template #title>
<el-icon><ChatDotRound /></el-icon>
<span>消息运营</span>
</template>
<el-menu-item index="/message/send-record">发送记录</el-menu-item>
<el-menu-item index="/message/query">消息查询</el-menu-item>
</el-sub-menu>
<el-sub-menu index="/flow">
<template #title>
<el-icon><Share /></el-icon>
<span>流程编排</span>
</template>
<el-menu-item index="/flow/inbound">入站流程</el-menu-item>
</el-sub-menu>
<el-sub-menu index="/system">
<template #title>
<el-icon><Setting /></el-icon>
<span>系统管理</span>
</template>
<el-menu-item v-if="isPlatformAdmin" index="/system/tenant">租户管理</el-menu-item>
<el-menu-item index="/system/user">用户管理</el-menu-item>
</el-sub-menu>
</el-menu>
</el-aside>
<el-container>
<el-header class="header">
<span>{{ $route.meta.title || '消息中台' }}</span>
<span class="page-title">{{ $route.meta.title || '消息中台' }}</span>
<div class="header-right">
<span class="user-info">{{ authStore.tenantCode }} / {{ authStore.username }}</span>
<el-button type="primary" text @click="handleLogout">退出登录</el-button>
<el-badge :value="0" class="notify-badge">
<el-icon class="notify-icon" :size="20"><Bell /></el-icon>
</el-badge>
<div v-if="isPlatformAdmin" class="tenant-wrap">
<span class="tenant-label">租户</span>
<el-select
v-model="selectedTenant"
placeholder="选择租户"
size="small"
class="tenant-select"
@change="handleTenantChange"
>
<el-option label="全部租户" value="" />
<el-option
v-for="t in tenantOptions"
:key="t.tenantCode"
:label="t.tenantName"
:value="t.tenantCode"
/>
</el-select>
</div>
<div v-else class="tenant-wrap">
<span class="tenant-name">{{ authStore.tenantCode || '-' }}</span>
</div>
<el-dropdown @command="handleUserCommand">
<div class="user-info">
<el-avatar :size="32" class="user-avatar">{{ avatarText }}</el-avatar>
<div class="user-meta">
<div class="user-role">{{ roleText }}</div>
<div class="user-name">{{ authStore.username }}</div>
</div>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="logout">退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</el-header>
<el-main>
<el-main class="main">
<router-view />
</el-main>
</el-container>
@@ -66,51 +121,182 @@
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import { listTenants } from '../api/account'
import {
DataLine,
Connection,
User,
ChatDotRound,
Share,
Setting,
Bell,
} from '@element-plus/icons-vue'
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
function handleLogout() {
authStore.clearAuth()
router.push('/login')
const isPlatformAdmin = computed(() => authStore.roleCodes.includes('platform_admin'))
const roleText = computed(() => {
if (authStore.roleCodes.includes('platform_admin')) return '平台管理员'
if (authStore.roleCodes.includes('tenant_admin')) return '租户管理员'
return '运营人员'
})
const avatarText = computed(() => {
const name = authStore.username || 'U'
return name.slice(0, 1).toUpperCase()
})
const selectedTenant = ref(authStore.tenantCode || '')
const tenantOptions = ref<any[]>([])
async function loadTenants() {
if (!isPlatformAdmin.value) return
try {
const res: any = await listTenants()
tenantOptions.value = res.data || []
} catch {
tenantOptions.value = []
}
}
function handleTenantChange(code: string) {
authStore.setTenant(code)
window.location.reload()
}
function handleUserCommand(command: string) {
if (command === 'logout') {
authStore.clearAuth()
router.push('/login')
}
}
onMounted(loadTenants)
</script>
<style scoped>
.layout {
height: 100vh;
background: #f8fafc;
}
.sidebar {
background: #fff;
border-right: 1px solid #e4e7ed;
border-right: 1px solid #e2e8f0;
display: flex;
flex-direction: column;
}
.logo {
height: 60px;
height: 64px;
display: flex;
align-items: center;
padding: 0 20px;
border-bottom: 1px solid #e2e8f0;
gap: 12px;
}
.logo-icon {
width: 36px;
height: 36px;
border-radius: 8px;
background: #2563eb;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 18px;
font-weight: bold;
border-bottom: 1px solid #e4e7ed;
}
.logo-title {
font-size: 16px;
font-weight: 700;
color: #0f172a;
line-height: 1.2;
}
.logo-sub {
font-size: 12px;
color: #64748b;
line-height: 1.2;
}
.menu {
border-right: none;
flex: 1;
overflow-y: auto;
}
.header {
background: #fff;
border-bottom: 1px solid #e4e7ed;
border-bottom: 1px solid #e2e8f0;
display: flex;
align-items: center;
justify-content: space-between;
font-weight: 500;
padding: 0 24px;
height: 64px;
}
.page-title {
font-size: 18px;
font-weight: 600;
color: #0f172a;
}
.header-right {
display: flex;
align-items: center;
gap: 16px;
gap: 20px;
}
.notify-badge {
cursor: pointer;
}
.notify-icon {
color: #64748b;
}
.tenant-wrap {
display: flex;
align-items: center;
gap: 8px;
padding-right: 20px;
border-right: 1px solid #e2e8f0;
}
.tenant-label {
font-size: 14px;
color: #64748b;
}
.tenant-select {
width: 140px;
}
.tenant-name {
font-size: 14px;
color: #334155;
font-weight: 500;
}
.user-info {
color: #606266;
font-size: 14px;
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
outline: none;
}
.user-avatar {
background: #eff6ff;
color: #2563eb;
font-weight: 600;
}
.user-meta {
line-height: 1.2;
}
.user-role {
font-size: 13px;
font-weight: 500;
color: #0f172a;
}
.user-name {
font-size: 12px;
color: #64748b;
}
.main {
padding: 24px;
overflow-y: auto;
}
</style>

View File

@@ -0,0 +1,61 @@
<template>
<div class="sino-page">
<div v-if="hasToolbar" class="sino-toolbar">
<div class="sino-toolbar-left">
<slot name="toolbar-left" />
</div>
<div class="sino-toolbar-right">
<slot name="toolbar-right" />
</div>
</div>
<div class="sino-card">
<slot />
</div>
<div v-if="$slots.pagination" class="sino-pagination">
<slot name="pagination" />
</div>
</div>
</template>
<script setup lang="ts">
import { computed, useSlots } from 'vue'
const slots = useSlots()
const hasToolbar = computed(() => !!slots['toolbar-left'] || !!slots['toolbar-right'])
</script>
<style scoped>
.sino-page {
display: flex;
flex-direction: column;
min-height: 100%;
}
.sino-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
.sino-toolbar-left,
.sino-toolbar-right {
display: flex;
align-items: center;
gap: 12px;
}
.sino-card {
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 12px;
overflow: hidden;
flex: 1;
}
.sino-pagination {
display: flex;
justify-content: flex-end;
align-items: center;
padding: 12px 20px;
background: #ffffff;
border-top: 1px solid #f1f5f9;
}
</style>

View File

@@ -2,19 +2,16 @@ import { createRouter, createWebHistory } from 'vue-router'
import Layout from '../components/Layout.vue'
import Dashboard from '../views/Dashboard.vue'
import ChannelSubject from '../views/ChannelSubject.vue'
import ChannelApp from '../views/ChannelApp.vue'
import ChannelAccount from '../views/ChannelAccount.vue'
import Conversation from '../views/Conversation.vue'
import Person from '../views/Person.vue'
import Conversation from '../views/Conversation.vue'
import Tag from '../views/Tag.vue'
import SendRecord from '../views/SendRecord.vue'
import Messages from '../views/Messages.vue'
import InboundFlow from '../views/InboundFlow.vue'
import IvrFlow from '../views/IvrFlow.vue'
import FlowTrace from '../views/FlowTrace.vue'
import MessageTest from '../views/MessageTest.vue'
import TenantManagement from '../views/TenantManagement.vue'
import UserManagement from '../views/UserManagement.vue'
import InboundWebhook from '../views/InboundWebhook.vue'
import SendPolicy from '../views/SendPolicy.vue'
import Login from '../views/Login.vue'
import { useAuthStore } from '../stores/auth'
@@ -30,20 +27,17 @@ const routes = [
redirect: '/dashboard',
children: [
{ path: 'dashboard', component: Dashboard, meta: { title: '概览' } },
{ path: 'channel-subject', component: ChannelSubject, meta: { title: '渠道主体' } },
{ path: 'channel-account', component: ChannelAccount, meta: { title: '渠道账号' } },
{ path: 'conversation', component: Conversation, meta: { title: '会话/群聊' } },
{ path: 'person', component: Person, meta: { title: '联系人' } },
{ path: 'tag', component: Tag, meta: { title: '标签' } },
{ path: 'send-record', component: SendRecord, meta: { title: '发送记录' } },
{ path: 'inbound-flow', component: InboundFlow, meta: { title: '入站流程' } },
{ path: 'ivr-flow', component: IvrFlow, meta: { title: 'IVR 流程' } },
{ path: 'flow-trace', component: FlowTrace, meta: { title: '流程追踪' } },
{ path: 'message-test', component: MessageTest, meta: { title: '消息测试' } },
{ path: 'inbound-webhook', component: InboundWebhook, meta: { title: '入站 Webhook' } },
{ path: 'send-policy', component: SendPolicy, meta: { title: '发送策略' } },
{ path: 'tenant-management', component: TenantManagement, meta: { title: '租户管理' } },
{ path: 'user-management', component: UserManagement, meta: { title: '用户管理' } },
{ path: 'channel/subject', component: ChannelSubject, meta: { title: '渠道主体' } },
{ path: 'channel/app', component: ChannelApp, meta: { title: '渠道应用' } },
{ path: 'channel/account', component: ChannelAccount, meta: { title: '渠道账号' } },
{ path: 'customer/person', component: Person, meta: { title: '联系人' } },
{ path: 'customer/group', component: Conversation, meta: { title: '群聊' } },
{ path: 'customer/tag', component: Tag, meta: { title: '标签体系' } },
{ path: 'message/send-record', component: SendRecord, meta: { title: '发送记录' } },
{ path: 'message/query', component: Messages, meta: { title: '消息查询' } },
{ path: 'flow/inbound', component: InboundFlow, meta: { title: '入站流程' } },
{ path: 'system/tenant', component: TenantManagement, meta: { title: '租户管理' } },
{ path: 'system/user', component: UserManagement, meta: { title: '用户管理' } },
],
},
]

View File

@@ -30,6 +30,11 @@ export const useAuthStore = defineStore('auth', () => {
localStorage.setItem('mci-role-codes', JSON.stringify(roles))
}
function setTenant(code: string) {
tenantCode.value = code
localStorage.setItem('mci-tenant-code', code)
}
function clearAuth() {
token.value = ''
tenantCode.value = ''
@@ -41,5 +46,5 @@ export const useAuthStore = defineStore('auth', () => {
localStorage.removeItem('mci-role-codes')
}
return { token, tenantCode, username, roleCodes, isLoggedIn, setAuth, clearAuth }
return { token, tenantCode, username, roleCodes, isLoggedIn, setAuth, setTenant, clearAuth }
})

View File

@@ -4,7 +4,7 @@
body {
margin: 0;
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-family: 'Inter', 'Noto Sans SC', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
#app {
@@ -12,6 +12,65 @@ body {
min-height: 100vh;
}
/* Sino-MCI shared page styles */
.sino-page {
min-height: 100%;
}
.sino-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
.sino-toolbar-left,
.sino-toolbar-right {
display: flex;
align-items: center;
gap: 12px;
}
.sino-card {
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 12px;
overflow: hidden;
}
.sino-pagination {
display: flex;
justify-content: flex-end;
align-items: center;
padding: 12px 20px;
background: #ffffff;
border-top: 1px solid #f1f5f9;
}
/* Table header unify */
.sino-table .el-table__header-wrapper th.el-table__cell {
background: #f8fafc !important;
color: #64748b !important;
font-weight: 500 !important;
}
.sino-table .el-table__row {
cursor: pointer;
}
/* Status cell */
.status-cell {
display: flex;
align-items: center;
gap: 8px;
}
.status-text {
font-size: 13px;
color: #475569;
}
.mt-4 {
margin-top: 20px;
}

View File

@@ -1,10 +1,22 @@
<template>
<div>
<div class="toolbar">
<el-button type="primary" @click="openCreateDialog">创建渠道账号</el-button>
</div>
<PageCard>
<template #toolbar-left>
<el-input
v-model="searchKeyword"
placeholder="搜索账号 / 名称"
clearable
style="width: 240px"
/>
<el-select v-model="filterStatus" placeholder="全部状态" clearable style="width: 140px">
<el-option label="启用" :value="1" />
<el-option label="禁用" :value="0" />
</el-select>
</template>
<template #toolbar-right>
<el-button type="primary" @click="openCreateDialog">新增账号</el-button>
</template>
<el-table :data="accounts" border>
<el-table :data="pagedAccounts" class="sino-table" @row-click="handleRowClick">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="accountType" label="类型" width="140">
<template #default="{ row }">
@@ -16,48 +28,50 @@
{{ channelLabel(row.channelType) }}
</template>
</el-table-column>
<el-table-column prop="accountId" label="账号ID" />
<el-table-column prop="accountName" label="名称" />
<el-table-column prop="accountId" label="账号ID" show-overflow-tooltip />
<el-table-column prop="accountName" label="名称" show-overflow-tooltip />
<el-table-column prop="loginStatus" label="登录状态" width="100">
<template #default="{ row }">
<el-tag :type="row.loginStatus === 1 ? 'success' : 'info'">
<el-tag :type="row.loginStatus === 1 ? 'success' : 'info'" size="small">
{{ row.loginStatus === 1 ? '已登录' : '未登录' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="在线状态" width="160">
<template #default="{ row }">
<span class="status-dot" :class="row.onlineStatus === 1 ? 'online' : 'offline'" />
{{ row.onlineStatus === 1 ? '在线' : '离线' }}
<div class="online-status">
<span class="status-dot" :class="row.onlineStatus === 1 ? 'online' : 'offline'" />
{{ row.onlineStatus === 1 ? '在线' : '离线' }}
</div>
<div v-if="row.lastHeartbeat" class="heartbeat">{{ row.lastHeartbeat }}</div>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="90">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'info'">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="420">
<template #default="{ row }">
<el-button size="small" @click="openEditDialog(row)">编辑</el-button>
<el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
<el-switch
v-model="row.status"
:active-value="1"
:inactive-value="0"
style="margin: 0 8px"
size="small"
@click.stop
@change="(val: any) => handleStatusChange(row, val)"
/>
<el-button size="small" type="primary" @click="handleInit(row)">初始化</el-button>
<el-button size="small" @click="handleSync(row)">同步会话</el-button>
<el-button size="small" @click="handleRefreshStatus(row)">刷新状态</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑渠道账号' : '创建渠道账号'" width="600px">
<template #pagination>
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:total="filteredAccounts.length"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
background
/>
</template>
<el-dialog v-model="dialogVisible" title="创建渠道账号" width="600px">
<el-form :model="dialogForm" label-width="120px">
<el-form-item label="账号类型">
<el-select v-model="dialogForm.accountType" placeholder="类型">
@@ -101,6 +115,63 @@
</template>
</el-dialog>
<el-drawer v-model="drawerVisible" title="渠道账号详情" size="420px">
<div class="drawer-body">
<div class="drawer-actions">
<el-button size="small" type="primary" @click="handleInit(currentAccount)">初始化</el-button>
<el-button size="small" @click="handleSyncContacts(currentAccount)">同步联系人</el-button>
<el-button size="small" @click="handleSync(currentAccount)">同步会话</el-button>
<el-button size="small" @click="handleRefreshStatus(currentAccount)">刷新状态</el-button>
</div>
<el-form :model="drawerForm" label-width="120px">
<el-form-item label="账号类型">
<el-select v-model="drawerForm.accountType" placeholder="类型">
<el-option label="企微内部账号" :value="1" />
<el-option label="企微应用" :value="2" />
<el-option label="pywechat 个人号" :value="3" />
</el-select>
</el-form-item>
<el-form-item label="渠道">
<el-input v-model="drawerForm.channelType" placeholder="wecom" />
</el-form-item>
<el-form-item label="所属主体">
<el-select v-model="drawerForm.subjectId" placeholder="选择主体" clearable>
<el-option
v-for="s in subjects"
:key="s.id"
:label="s.subjectName"
:value="s.id"
/>
</el-select>
</el-form-item>
<el-form-item label="账号ID">
<el-input v-model="drawerForm.accountId" placeholder="账号唯一标识" />
</el-form-item>
<el-form-item label="名称">
<el-input v-model="drawerForm.accountName" placeholder="名称" />
</el-form-item>
<el-form-item label="会话存档">
<el-switch v-model="drawerForm.sessionArchiveEnabled" :active-value="1" :inactive-value="0" />
</el-form-item>
<el-form-item label="Server Host">
<el-input v-model="drawerForm.serverHost" placeholder="服务地址" />
</el-form-item>
<el-form-item label="扩展信息">
<el-input v-model="drawerForm.extInfo" type="textarea" :rows="3" placeholder="JSON 扩展信息" />
</el-form-item>
</el-form>
</div>
<template #footer>
<div class="drawer-footer">
<el-button type="danger" plain @click="handleDelete(currentAccount)">删除</el-button>
<div class="drawer-footer-right">
<el-button @click="drawerVisible = false">取消</el-button>
<el-button type="primary" @click="handleDrawerSave">保存</el-button>
</div>
</div>
</template>
</el-drawer>
<el-dialog v-model="initDialogVisible" title="初始化结果" width="480px">
<div v-if="initResult">
<el-result
@@ -114,11 +185,11 @@
</div>
</div>
</el-dialog>
</div>
</PageCard>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { listChannelSubjects } from '@/api/channelSubject'
import {
@@ -129,14 +200,15 @@ import {
updateChannelAccountStatus,
getChannelAccountStatus,
initChannelAccount,
syncChannelAccountContacts,
syncChannelAccountConversations,
} from '@/api/channelAccount'
import { getSyncTask } from '@/api/syncTask'
import PageCard from '@/components/PageCard.vue'
const accounts = ref<any[]>([])
const subjects = ref<any[]>([])
const dialogVisible = ref(false)
const isEdit = ref(false)
const editingId = ref<number | null>(null)
const dialogForm = reactive({
accountType: 1,
@@ -149,6 +221,45 @@ const dialogForm = reactive({
extInfo: '',
})
const drawerVisible = ref(false)
const currentAccount = ref<any>({})
const drawerForm = reactive({
accountType: 1,
channelType: 'wecom',
subjectId: undefined as number | undefined,
accountId: '',
accountName: '',
sessionArchiveEnabled: 0,
serverHost: '',
extInfo: '',
})
const searchKeyword = ref('')
const filterStatus = ref<number | ''>('')
const page = ref(1)
const pageSize = ref(10)
const filteredAccounts = computed(() => {
let list = accounts.value
const kw = searchKeyword.value.trim().toLowerCase()
if (kw) {
list = list.filter(
(item) =>
(item.accountId || '').toLowerCase().includes(kw) ||
(item.accountName || '').toLowerCase().includes(kw)
)
}
if (filterStatus.value !== '') {
list = list.filter((item) => item.status === filterStatus.value)
}
return list
})
const pagedAccounts = computed(() => {
const start = (page.value - 1) * pageSize.value
return filteredAccounts.value.slice(start, start + pageSize.value)
})
const initDialogVisible = ref(false)
const initResult = ref<any>(null)
@@ -163,29 +274,37 @@ function resetDialogForm() {
dialogForm.extInfo = ''
}
function fillDialogForm(row: any) {
dialogForm.accountType = row.accountType ?? 1
dialogForm.channelType = row.channelType ?? 'wecom'
dialogForm.subjectId = row.subjectId
dialogForm.accountId = row.accountId ?? ''
dialogForm.accountName = row.accountName ?? ''
dialogForm.sessionArchiveEnabled = row.sessionArchiveEnabled ?? 0
dialogForm.serverHost = row.serverHost ?? ''
dialogForm.extInfo = row.extInfo ?? ''
function resetDrawerForm() {
drawerForm.accountType = 1
drawerForm.channelType = 'wecom'
drawerForm.subjectId = undefined
drawerForm.accountId = ''
drawerForm.accountName = ''
drawerForm.sessionArchiveEnabled = 0
drawerForm.serverHost = ''
drawerForm.extInfo = ''
}
function fillDrawerForm(row: any) {
drawerForm.accountType = row.accountType ?? 1
drawerForm.channelType = row.channelType ?? 'wecom'
drawerForm.subjectId = row.subjectId
drawerForm.accountId = row.accountId ?? ''
drawerForm.accountName = row.accountName ?? ''
drawerForm.sessionArchiveEnabled = row.sessionArchiveEnabled ?? 0
drawerForm.serverHost = row.serverHost ?? ''
drawerForm.extInfo = row.extInfo ?? ''
}
function openCreateDialog() {
isEdit.value = false
editingId.value = null
resetDialogForm()
dialogVisible.value = true
}
function openEditDialog(row: any) {
isEdit.value = true
editingId.value = row.id
fillDialogForm(row)
dialogVisible.value = true
function handleRowClick(row: any) {
currentAccount.value = row
fillDrawerForm(row)
drawerVisible.value = true
}
async function loadAccounts() {
@@ -208,13 +327,8 @@ async function loadSubjects() {
async function handleSave() {
try {
if (isEdit.value && editingId.value != null) {
await updateChannelAccount(editingId.value, dialogForm)
ElMessage.success('更新成功')
} else {
await createChannelAccount(dialogForm)
ElMessage.success('创建成功')
}
await createChannelAccount(dialogForm)
ElMessage.success('创建成功')
dialogVisible.value = false
await loadAccounts()
} catch (e: any) {
@@ -222,11 +336,25 @@ async function handleSave() {
}
}
async function handleDrawerSave() {
if (!currentAccount.value?.id) return
try {
await updateChannelAccount(currentAccount.value.id, drawerForm)
ElMessage.success('更新成功')
drawerVisible.value = false
await loadAccounts()
} catch (e: any) {
ElMessage.error(e.message)
}
}
async function handleDelete(row: any) {
if (!row?.id) return
try {
await ElMessageBox.confirm('确认删除该渠道账号?', '提示', { type: 'warning' })
await deleteChannelAccount(row.id)
ElMessage.success('删除成功')
drawerVisible.value = false
await loadAccounts()
} catch (e: any) {
if (e !== 'cancel') {
@@ -259,6 +387,44 @@ async function handleInit(row: any) {
initDialogVisible.value = true
if (initResult.value?.success) {
await loadAccounts()
await handleSyncContacts(row)
await handleSync(row)
}
} catch (e: any) {
ElMessage.error(e.message)
}
}
async function pollTask(taskId: number, label: string) {
const maxAttempts = 60
for (let i = 0; i < maxAttempts; i++) {
await new Promise((resolve) => setTimeout(resolve, 2000))
try {
const res: any = await getSyncTask(taskId)
const task = res.data
if (!task) continue
if (task.taskStatus === 2) {
ElMessage.success(`${label}同步完成,共 ${task.syncedCount ?? 0}`)
return
}
if (task.taskStatus === 3) {
ElMessage.error(`${label}同步失败:${task.errorMessage || '未知错误'}`)
return
}
} catch (e: any) {
// ignore polling error
}
}
ElMessage.warning(`${label}同步时间较长,请稍后到列表查看`)
}
async function handleSyncContacts(row: any) {
try {
const res: any = await syncChannelAccountContacts(row.id)
const taskId = res.data?.id
if (taskId) {
ElMessage.info('客户同步任务已提交')
await pollTask(taskId, '客户')
}
} catch (e: any) {
ElMessage.error(e.message)
@@ -268,8 +434,11 @@ async function handleInit(row: any) {
async function handleSync(row: any) {
try {
const res: any = await syncChannelAccountConversations(row.id)
ElMessage.success(`同步完成,共 ${res.data?.syncedCount ?? 0} 条会话`)
await loadAccounts()
const taskId = res.data?.id
if (taskId) {
ElMessage.info('群聊同步任务已提交')
await pollTask(taskId, '群聊')
}
} catch (e: any) {
ElMessage.error(e.message)
}
@@ -315,9 +484,6 @@ onMounted(() => {
</script>
<style scoped>
.toolbar {
margin-bottom: 16px;
}
.status-dot {
display: inline-block;
width: 8px;
@@ -336,6 +502,24 @@ onMounted(() => {
color: #909399;
margin-top: 4px;
}
.drawer-body {
padding: 20px;
}
.drawer-actions {
margin-bottom: 20px;
display: flex;
gap: 8px;
}
.drawer-footer {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.drawer-footer-right {
display: flex;
gap: 8px;
}
.qr-code {
text-align: center;
margin-top: 12px;

View File

@@ -0,0 +1,221 @@
<template>
<PageCard>
<template #toolbar-left>
<el-input
v-model="searchKeyword"
placeholder="搜索应用名称 / AgentID"
clearable
style="width: 260px"
/>
</template>
<template #toolbar-right>
<el-button type="primary" @click="openCreate">新增应用</el-button>
</template>
<el-table :data="pagedApps" class="sino-table" @row-click="handleRowClick">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="appName" label="应用名称" />
<el-table-column prop="agentId" label="AgentID" />
<el-table-column prop="secret" label="Secret">
<template #default="{ row }">
<span v-if="row.secret"></span>
<span v-else class="text-secondary">-</span>
</template>
</el-table-column>
<el-table-column prop="sessionArchiveEnabled" label="会话存档" width="120">
<template #default="{ row }">
<el-tag :type="row.sessionArchiveEnabled === 1 ? 'success' : 'info'" size="small">
{{ row.sessionArchiveEnabled === 1 ? '已开启' : '未开启' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
</el-table>
<template #pagination>
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:total="filteredApps.length"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
background
/>
</template>
<el-dialog v-model="dialogVisible" title="新增应用" width="520px">
<el-form :model="form" label-width="100px">
<el-form-item label="应用名称"><el-input v-model="form.appName" /></el-form-item>
<el-form-item label="AgentID"><el-input v-model="form.agentId" /></el-form-item>
<el-form-item label="Secret"><el-input v-model="form.secret" type="password" show-password /></el-form-item>
<el-form-item label="会话存档">
<el-switch v-model="form.sessionArchiveEnabled" :active-value="1" :inactive-value="0" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="save">保存</el-button>
</template>
</el-dialog>
<el-drawer v-model="drawerVisible" title="应用详情" size="420px">
<div class="drawer-body">
<div class="drawer-actions">
<el-button size="small" type="primary" @click="initAccounts(currentApp!)">初始化账号</el-button>
</div>
<el-form :model="drawerForm" label-width="100px">
<el-form-item label="应用名称"><el-input v-model="drawerForm.appName" /></el-form-item>
<el-form-item label="AgentID"><el-input v-model="drawerForm.agentId" /></el-form-item>
<el-form-item label="Secret"><el-input v-model="drawerForm.secret" type="password" show-password /></el-form-item>
<el-form-item label="会话存档">
<el-switch v-model="drawerForm.sessionArchiveEnabled" :active-value="1" :inactive-value="0" />
</el-form-item>
</el-form>
</div>
<template #footer>
<div class="drawer-footer">
<el-button @click="drawerVisible = false">取消</el-button>
<el-button type="primary" @click="saveDrawer">保存</el-button>
</div>
</template>
</el-drawer>
</PageCard>
</template>
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import PageCard from '@/components/PageCard.vue'
interface AppItem {
id: number
appName: string
agentId: string
secret: string
sessionArchiveEnabled: number
status: number
}
const apps = ref<AppItem[]>([
{ id: 1, appName: '会话存档应用', agentId: '1000002', secret: 'xxx', sessionArchiveEnabled: 1, status: 1 },
{ id: 2, appName: '消息推送应用', agentId: '1000005', secret: 'xxx', sessionArchiveEnabled: 0, status: 1 },
])
const searchKeyword = ref('')
const page = ref(1)
const pageSize = ref(10)
const filteredApps = computed(() => {
const kw = searchKeyword.value.trim().toLowerCase()
if (!kw) return apps.value
return apps.value.filter(
(item) =>
item.appName.toLowerCase().includes(kw) ||
item.agentId.toLowerCase().includes(kw)
)
})
const pagedApps = computed(() => {
const start = (page.value - 1) * pageSize.value
return filteredApps.value.slice(start, start + pageSize.value)
})
const dialogVisible = ref(false)
const form = reactive({
appName: '',
agentId: '',
secret: '',
sessionArchiveEnabled: 0,
})
const drawerVisible = ref(false)
const currentApp = ref<AppItem | null>(null)
const drawerForm = reactive({
appName: '',
agentId: '',
secret: '',
sessionArchiveEnabled: 0,
})
function resetForm() {
form.appName = ''
form.agentId = ''
form.secret = ''
form.sessionArchiveEnabled = 0
}
function fillDrawerForm(app: AppItem) {
drawerForm.appName = app.appName
drawerForm.agentId = app.agentId
drawerForm.secret = app.secret
drawerForm.sessionArchiveEnabled = app.sessionArchiveEnabled
}
function openCreate() {
resetForm()
dialogVisible.value = true
}
function handleRowClick(row: AppItem) {
currentApp.value = row
fillDrawerForm(row)
drawerVisible.value = true
}
function save() {
apps.value.push({
id: Date.now(),
appName: form.appName || '未命名应用',
agentId: form.agentId,
secret: form.secret,
sessionArchiveEnabled: form.sessionArchiveEnabled,
status: 1,
})
ElMessage.success('创建成功')
dialogVisible.value = false
}
function saveDrawer() {
if (!currentApp.value) return
const idx = apps.value.findIndex((a) => a.id === currentApp.value!.id)
if (idx >= 0) {
apps.value[idx] = {
...currentApp.value,
appName: drawerForm.appName,
agentId: drawerForm.agentId,
secret: drawerForm.secret,
sessionArchiveEnabled: drawerForm.sessionArchiveEnabled,
}
}
ElMessage.success('更新成功')
drawerVisible.value = false
}
function initAccounts(app: AppItem) {
ElMessage.info(`开始初始化账号:${app.appName}`)
}
</script>
<style scoped>
.text-secondary {
color: #94a3b8;
}
.drawer-body {
padding: 20px;
}
.drawer-actions {
margin-bottom: 20px;
}
.drawer-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
width: 100%;
}
</style>

View File

@@ -1,46 +1,64 @@
<template>
<div>
<div class="toolbar">
<el-button type="primary" @click="openCreateDialog">创建渠道主体</el-button>
</div>
<PageCard>
<template #toolbar-left>
<el-input
v-model="searchKeyword"
placeholder="搜索主体名称 / 编码 / CorpID"
clearable
class="search-input"
@keyup.enter="handleSearch"
/>
<el-button @click="handleSearch">查询</el-button>
</template>
<template #toolbar-right>
<el-button type="primary" @click="openCreateDialog">新增主体</el-button>
</template>
<el-table :data="subjects" border>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="channelType" label="渠道">
<el-table
:data="pagedSubjects"
class="sino-table"
@row-click="handleRowClick"
>
<el-table-column prop="subjectName" label="主体名称" min-width="160" show-overflow-tooltip />
<el-table-column prop="channelType" label="渠道类型" width="100">
<template #default="{ row }">
{{ channelLabel(row.channelType) }}
</template>
</el-table-column>
<el-table-column prop="subjectCode" label="编码" />
<el-table-column prop="subjectName" label="名称" />
<el-table-column prop="corpId" label="CorpID" />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'info'">
{{ row.status === 1 ? '启用' : '禁用' }}
<el-tag :type="row.channelType === 'wecom' ? 'primary' : 'info'" size="small">
{{ channelLabel(row.channelType) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="340">
<el-table-column prop="subjectCode" label="主体编码" min-width="140" show-overflow-tooltip />
<el-table-column prop="corpId" label="CorpID" min-width="220" show-overflow-tooltip />
<el-table-column label="状态" width="120">
<template #default="{ row }">
<el-button size="small" @click="openEditDialog(row)">编辑</el-button>
<el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
<el-switch
v-model="row.status"
:active-value="1"
:inactive-value="0"
style="margin: 0 12px"
@change="(val: any) => handleStatusChange(row, val)"
/>
<el-button size="small" @click="openDetail(row)">详情</el-button>
<div class="status-cell" @click.stop>
<el-switch
v-model="row.status"
:active-value="1"
:inactive-value="0"
@change="(val: any) => handleStatusChange(row, val)"
/>
<span class="status-text">{{ row.status === 1 ? '启用' : '禁用' }}</span>
</div>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑渠道主体' : '创建渠道主体'" width="600px">
<template #pagination>
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:total="filteredSubjects.length"
layout="total, prev, pager, next"
background
/>
</template>
<!-- 新增弹窗 -->
<el-dialog v-model="dialogVisible" title="新增渠道主体" width="600px" destroy-on-close>
<el-form :model="dialogForm" label-width="120px">
<el-form-item label="渠道">
<el-select v-model="dialogForm.channelType" placeholder="选择渠道">
<el-select v-model="dialogForm.channelType" placeholder="选择渠道" style="width: 100%">
<el-option label="企微" value="wecom" />
<el-option label="钉钉" value="dingtalk" />
<el-option label="飞书" value="feishu" />
@@ -55,9 +73,6 @@
<el-form-item label="CorpID">
<el-input v-model="dialogForm.corpId" placeholder="CorpID" />
</el-form-item>
<el-form-item label="CorpSecret">
<el-input v-model="dialogForm.corpSecret" placeholder="CorpSecret" />
</el-form-item>
<el-form-item label="AgentID">
<el-input v-model="dialogForm.agentId" placeholder="AgentID" />
</el-form-item>
@@ -77,26 +92,61 @@
</template>
</el-dialog>
<el-drawer v-model="drawerVisible" title="渠道主体详情" size="420px">
<el-descriptions :column="1" border>
<el-descriptions-item label="ID">{{ currentSubject.id }}</el-descriptions-item>
<el-descriptions-item label="渠道">{{ channelLabel(currentSubject.channelType) }}</el-descriptions-item>
<el-descriptions-item label="主体编码">{{ currentSubject.subjectCode }}</el-descriptions-item>
<el-descriptions-item label="主体名称">{{ currentSubject.subjectName }}</el-descriptions-item>
<el-descriptions-item label="CorpID">{{ mask(currentSubject.corpId) }}</el-descriptions-item>
<el-descriptions-item label="CorpSecret">{{ mask(currentSubject.corpSecret) }}</el-descriptions-item>
<el-descriptions-item label="AgentID">{{ mask(currentSubject.agentId) }}</el-descriptions-item>
<el-descriptions-item label="AgentSecret">{{ mask(currentSubject.agentSecret) }}</el-descriptions-item>
<el-descriptions-item label="会话存档">{{ currentSubject.sessionArchiveEnabled === 1 ? '开启' : '关闭' }}</el-descriptions-item>
<el-descriptions-item label="状态">{{ currentSubject.status === 1 ? '启用' : '禁用' }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ currentSubject.createTime }}</el-descriptions-item>
</el-descriptions>
<!-- 详情/编辑右侧抽屉 -->
<el-drawer
v-model="drawerVisible"
:title="`渠道主体详情${currentSubject.subjectName ? ' - ' + currentSubject.subjectName : ''}`"
size="480px"
destroy-on-close
>
<el-form :model="dialogForm" label-width="120px">
<el-form-item label="渠道">
<el-select v-model="dialogForm.channelType" placeholder="选择渠道" style="width: 100%">
<el-option label="企微" value="wecom" />
<el-option label="钉钉" value="dingtalk" />
<el-option label="飞书" value="feishu" />
</el-select>
</el-form-item>
<el-form-item label="主体编码">
<el-input v-model="dialogForm.subjectCode" placeholder="主体编码" />
</el-form-item>
<el-form-item label="主体名称">
<el-input v-model="dialogForm.subjectName" placeholder="主体名称" />
</el-form-item>
<el-form-item label="CorpID">
<el-input v-model="dialogForm.corpId" placeholder="CorpID" />
</el-form-item>
<el-form-item label="AgentID">
<el-input v-model="dialogForm.agentId" placeholder="AgentID" />
</el-form-item>
<el-form-item label="AgentSecret">
<el-input v-model="dialogForm.agentSecret" placeholder="AgentSecret" />
</el-form-item>
<el-form-item label="会话存档">
<el-switch v-model="dialogForm.sessionArchiveEnabled" :active-value="1" :inactive-value="0" />
</el-form-item>
<el-form-item label="会话存档私钥">
<el-input v-model="dialogForm.sessionArchivePrivateKey" type="textarea" :rows="3" placeholder="会话存档私钥" />
</el-form-item>
</el-form>
<template #footer>
<div class="drawer-footer">
<div class="drawer-actions-left">
<el-button v-if="currentSubject.channelType === 'wecom'" type="warning" plain @click="handleSyncEnterpriseContacts(currentSubject)">同步企业通讯录</el-button>
<el-button type="danger" plain @click="handleDrawerDelete">删除</el-button>
</div>
<div class="drawer-actions">
<el-button @click="drawerVisible = false">取消</el-button>
<el-button type="primary" @click="handleSave">保存</el-button>
</div>
</div>
</template>
</el-drawer>
</div>
</PageCard>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
createChannelSubject,
@@ -105,6 +155,9 @@ import {
deleteChannelSubject,
updateChannelSubjectStatus,
} from '@/api/channelSubject'
import { syncSubjectEnterpriseContacts } from '@/api/syncTask'
import { getSyncTask } from '@/api/syncTask'
import PageCard from '@/components/PageCard.vue'
const subjects = ref<any[]>([])
const dialogVisible = ref(false)
@@ -116,7 +169,6 @@ const dialogForm = reactive({
subjectCode: '',
subjectName: '',
corpId: '',
corpSecret: '',
agentId: '',
agentSecret: '',
sessionArchiveEnabled: 0,
@@ -126,12 +178,35 @@ const dialogForm = reactive({
const drawerVisible = ref(false)
const currentSubject = ref<any>({})
const searchKeyword = ref('')
const currentPage = ref(1)
const pageSize = ref(10)
const filteredSubjects = computed(() => {
const kw = searchKeyword.value.trim().toLowerCase()
if (!kw) return subjects.value
return subjects.value.filter(
(s) =>
(s.subjectName || '').toLowerCase().includes(kw) ||
(s.subjectCode || '').toLowerCase().includes(kw) ||
(s.corpId || '').toLowerCase().includes(kw)
)
})
const pagedSubjects = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
return filteredSubjects.value.slice(start, start + pageSize.value)
})
function handleSearch() {
currentPage.value = 1
}
function resetDialogForm() {
dialogForm.channelType = 'wecom'
dialogForm.subjectCode = ''
dialogForm.subjectName = ''
dialogForm.corpId = ''
dialogForm.corpSecret = ''
dialogForm.agentId = ''
dialogForm.agentSecret = ''
dialogForm.sessionArchiveEnabled = 0
@@ -143,7 +218,6 @@ function fillDialogForm(row: any) {
dialogForm.subjectCode = row.subjectCode ?? ''
dialogForm.subjectName = row.subjectName ?? ''
dialogForm.corpId = row.corpId ?? ''
dialogForm.corpSecret = row.corpSecret ?? ''
dialogForm.agentId = row.agentId ?? ''
dialogForm.agentSecret = row.agentSecret ?? ''
dialogForm.sessionArchiveEnabled = row.sessionArchiveEnabled ?? 0
@@ -157,15 +231,11 @@ function openCreateDialog() {
dialogVisible.value = true
}
function openEditDialog(row: any) {
function handleRowClick(row: any) {
isEdit.value = true
editingId.value = row.id
fillDialogForm(row)
dialogVisible.value = true
}
function openDetail(row: any) {
currentSubject.value = { ...row }
fillDialogForm(row)
drawerVisible.value = true
}
@@ -188,6 +258,7 @@ async function handleSave() {
ElMessage.success('创建成功')
}
dialogVisible.value = false
drawerVisible.value = false
await loadSubjects()
} catch (e: any) {
ElMessage.error(e.message)
@@ -207,6 +278,12 @@ async function handleDelete(row: any) {
}
}
async function handleDrawerDelete() {
if (!currentSubject.value?.id) return
await handleDelete(currentSubject.value)
drawerVisible.value = false
}
async function handleStatusChange(row: any, val: any) {
try {
await updateChannelSubjectStatus(row.id, { status: val })
@@ -218,6 +295,42 @@ async function handleStatusChange(row: any, val: any) {
}
}
async function pollTask(taskId: number, label: string) {
const maxAttempts = 60
for (let i = 0; i < maxAttempts; i++) {
await new Promise((resolve) => setTimeout(resolve, 2000))
try {
const res: any = await getSyncTask(taskId)
const task = res.data
if (!task) continue
if (task.taskStatus === 2) {
ElMessage.success(`${label}同步完成,共 ${task.syncedCount ?? 0}`)
return
}
if (task.taskStatus === 3) {
ElMessage.error(`${label}同步失败:${task.errorMessage || '未知错误'}`)
return
}
} catch (e: any) {
// ignore polling error
}
}
ElMessage.warning(`${label}同步时间较长,请稍后到列表查看`)
}
async function handleSyncEnterpriseContacts(row: any) {
try {
const res: any = await syncSubjectEnterpriseContacts(row.id)
const taskId = res.data?.id
if (taskId) {
ElMessage.info('企业通讯录同步任务已提交')
await pollTask(taskId, '企业通讯录')
}
} catch (e: any) {
ElMessage.error(e.message)
}
}
function channelLabel(type: string) {
const map: Record<string, string> = {
wecom: '企微',
@@ -237,7 +350,19 @@ onMounted(loadSubjects)
</script>
<style scoped>
.toolbar {
margin-bottom: 16px;
.search-input {
width: 280px;
}
.drawer-footer {
display: flex;
align-items: center;
justify-content: space-between;
}
.drawer-actions {
display: flex;
align-items: center;
gap: 12px;
}
</style>

View File

@@ -1,10 +1,26 @@
<template>
<div>
<div class="toolbar">
<el-button type="primary" @click="openCreateDialog">创建会话</el-button>
</div>
<PageCard>
<template #toolbar-left>
<el-input
v-model="searchQuery"
placeholder="搜索名称 / 渠道 / 会话ID"
clearable
style="width: 260px"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-select v-model="typeFilter" placeholder="全部类型" clearable style="width: 140px">
<el-option label="单聊" :value="1" />
<el-option label="群聊" :value="2" />
</el-select>
</template>
<template #toolbar-right>
<el-button type="primary" @click="openCreateDialog">新增会话</el-button>
</template>
<el-table :data="conversations" border>
<el-table :data="pagedConversations" class="sino-table" @row-click="openDrawer">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="conversationType" label="类型" width="100">
<template #default="{ row }">
@@ -19,35 +35,75 @@
{{ formatAccountName(row.ownerAccountId) }}
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<el-table-column prop="status" label="状态" width="120">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'info'">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="260">
<template #default="{ row }">
<div class="op-row">
<el-button size="small" @click="openEditDialog(row)">编辑</el-button>
<el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
<div class="status-cell" @click.stop>
<el-switch
v-model="row.status"
:active-value="1"
:inactive-value="0"
style="margin-left: 12px"
@change="(val: any) => handleStatusChange(row, val)"
/>
</div>
<div class="op-row" style="margin-top: 6px;">
<el-button link type="primary" size="small" @click="openBindAccount(row)">绑定账号</el-button>
<el-button link type="primary" size="small" @click="openBindTags(row)">绑定标签</el-button>
<span class="status-text">{{ row.status === 1 ? '启用' : '禁用' }}</span>
</div>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑会话' : '创建会话'" width="600px">
<template #pagination>
<el-pagination
v-model:current-page="currentPage"
:page-size="pageSize"
:total="filteredConversations.length"
layout="total, prev, pager, next"
background
/>
</template>
<el-dialog v-model="dialogVisible" title="新增会话" width="600px">
<el-form :model="dialogForm" label-width="120px">
<el-form-item label="会话类型">
<el-select v-model="dialogForm.conversationType" placeholder="类型" style="width: 100%">
<el-option label="单聊" :value="1" />
<el-option label="群聊" :value="2" />
</el-select>
</el-form-item>
<el-form-item label="渠道">
<el-input v-model="dialogForm.channelType" placeholder="wecom" />
</el-form-item>
<el-form-item label="渠道会话ID">
<el-input v-model="dialogForm.channelConversationId" placeholder="roomid_xxx" />
</el-form-item>
<el-form-item label="名称">
<el-input v-model="dialogForm.conversationName" placeholder="群名称" />
</el-form-item>
<el-form-item label="负责人">
<el-select v-model="dialogForm.ownerAccountId" placeholder="选择负责人账号" style="width: 100%">
<el-option
v-for="a in accounts"
:key="a.id"
:label="a.accountName"
:value="a.id"
/>
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-switch
v-model="dialogForm.status"
:active-value="1"
:inactive-value="0"
active-text="启用"
inactive-text="禁用"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSave">保存</el-button>
</template>
</el-dialog>
<el-drawer v-model="drawerVisible" title="会话详情" size="520px">
<el-form :model="dialogForm" label-width="120px">
<el-form-item label="会话类型">
<el-select v-model="dialogForm.conversationType" placeholder="类型" style="width: 100%">
@@ -84,11 +140,23 @@
/>
</el-form-item>
</el-form>
<el-divider />
<div class="relation-section">
<div class="section-title">关联信息</div>
<div class="relation-actions">
<el-button type="primary" link @click="openBindAccount(currentConversation)">绑定账号</el-button>
<el-button type="primary" link @click="openBindTags(currentConversation)">绑定标签</el-button>
</div>
</div>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button @click="drawerVisible = false">取消</el-button>
<el-button type="danger" @click="handleDelete(currentConversation)">删除</el-button>
<el-button type="primary" @click="handleSave">保存</el-button>
</template>
</el-dialog>
</el-drawer>
<el-dialog v-model="bindAccountVisible" title="绑定账号" width="500px">
<el-form :model="bindAccountForm" label-width="100px">
@@ -138,11 +206,12 @@
<el-button type="primary" @click="handleBindTags">确认</el-button>
</template>
</el-dialog>
</div>
</PageCard>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { Search } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
bindConversationAccount,
@@ -154,6 +223,7 @@ import {
updateConversation,
} from '@/api/channel'
import { listTags } from '@/api/crm'
import PageCard from '@/components/PageCard.vue'
const conversations = ref<any[]>([])
const accounts = ref<any[]>([])
@@ -172,6 +242,36 @@ const dialogForm = reactive({
status: 1,
})
const searchQuery = ref('')
const typeFilter = ref<number | ''>('')
const currentPage = ref(1)
const pageSize = ref(10)
const drawerVisible = ref(false)
const currentConversation = ref<any>({})
const filteredConversations = computed(() => {
const q = searchQuery.value.trim().toLowerCase()
return conversations.value.filter((c) => {
const matchesQuery =
!q ||
[c.conversationName, c.channelType, c.channelConversationId].some((v) =>
String(v || '').toLowerCase().includes(q)
)
const matchesType =
typeFilter.value === '' || c.conversationType === typeFilter.value
return matchesQuery && matchesType
})
})
const pagedConversations = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
return filteredConversations.value.slice(start, start + pageSize.value)
})
watch([searchQuery, typeFilter], () => {
currentPage.value = 1
})
function resetDialogForm() {
dialogForm.conversationType = 2
dialogForm.channelType = 'wecom'
@@ -197,11 +297,14 @@ function openCreateDialog() {
dialogVisible.value = true
}
function openEditDialog(row: any) {
function openDrawer(row: any) {
currentConversation.value = row
currentBindConversation.value = row
currentConversationId.value = row.id
isEdit.value = true
editingId.value = row.id
fillDialogForm(row)
dialogVisible.value = true
drawerVisible.value = true
}
const accountMap = computed(() => {
@@ -281,6 +384,7 @@ async function handleSave() {
ElMessage.success('创建成功')
}
dialogVisible.value = false
drawerVisible.value = false
await loadConversations()
} catch (e: any) {
ElMessage.error(e.message)
@@ -314,6 +418,7 @@ async function handleDelete(row: any) {
)
await deleteConversation(row.id)
ElMessage.success('删除成功')
drawerVisible.value = false
await loadConversations()
} catch (e: any) {
if (e !== 'cancel') {
@@ -353,7 +458,6 @@ async function handleBindAccount() {
}
}
const currentConversationId = ref<number | undefined>()
const bindTagsVisible = ref(false)
const bindTagsForm = reactive({
@@ -385,7 +489,19 @@ onMounted(() => {
</script>
<style scoped>
.toolbar {
margin-bottom: 16px;
.relation-section {
margin-top: 8px;
}
.section-title {
font-size: 14px;
font-weight: 600;
color: #0F172A;
margin-bottom: 12px;
}
.relation-actions {
display: flex;
gap: 16px;
}
</style>

View File

@@ -1,41 +1,50 @@
<template>
<div>
<el-row :gutter="20">
<el-col :span="6">
<el-statistic title="渠道主体" :value="stats.subjectCount" />
</el-col>
<el-col :span="6">
<el-statistic title="渠道账号" :value="stats.accountCount" />
</el-col>
<el-col :span="6">
<el-statistic title="会话/群聊" :value="stats.conversationCount" />
</el-col>
<el-col :span="6">
<el-statistic title="今日发送" :value="stats.sentToday" />
</el-col>
</el-row>
<el-card class="mt-4">
<template #header>使用说明</template>
<p>1. 先在渠道主体录入企微主体信息</p>
<p>2. 渠道账号添加发送/接收账号</p>
<p>3. 会话/群聊维护业务群并绑定发送账号</p>
<p>4. 发送记录查看业务系统调用发送 API 的结果</p>
</el-card>
<div class="sino-page">
<div class="stats-grid">
<div v-for="item in statItems" :key="item.key" class="stat-card">
<div class="stat-label">{{ item.label }}</div>
<div class="stat-value">{{ item.value }}</div>
</div>
</div>
<div class="sino-card">
<div class="card-header">使用说明</div>
<div class="card-body">
<p>1. 先在渠道主体录入企微主体信息</p>
<p>2. 渠道账号添加发送/接收账号</p>
<p>3. 会话/群聊维护业务群并绑定发送账号</p>
<p>4. 发送记录查看业务系统调用发送 API 的结果</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive } from 'vue'
import { computed, onMounted, reactive } from 'vue'
import { ElMessage } from 'element-plus'
import { getDashboardStats } from '@/api/dashboard'
const stats = reactive({
interface Stats {
subjectCount: number
accountCount: number
conversationCount: number
sentToday: number
}
const stats = reactive<Stats>({
subjectCount: 0,
accountCount: 0,
conversationCount: 0,
sentToday: 0,
})
const statItems = computed(() => [
{ key: 'subjectCount', label: '渠道主体', value: stats.subjectCount },
{ key: 'accountCount', label: '渠道账号', value: stats.accountCount },
{ key: 'conversationCount', label: '会话/群聊', value: stats.conversationCount },
{ key: 'sentToday', label: '今日发送', value: stats.sentToday },
])
async function loadStats() {
try {
const res: any = await getDashboardStats()
@@ -49,7 +58,64 @@ onMounted(loadStats)
</script>
<style scoped>
.mt-4 {
margin-top: 20px;
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 24px;
}
.stat-card {
background: #FFFFFF;
border: 1px solid #E2E8F0;
border-radius: 12px;
padding: 20px;
}
.stat-label {
font-size: 13px;
color: #64748B;
margin-bottom: 8px;
}
.stat-value {
font-size: 28px;
font-weight: 700;
color: #0F172A;
}
.card-header {
padding: 16px 20px;
border-bottom: 1px solid #E2E8F0;
font-size: 16px;
font-weight: 600;
color: #0F172A;
}
.card-body {
padding: 20px;
font-size: 14px;
color: #475569;
line-height: 1.8;
}
.card-body p {
margin: 0 0 8px;
}
.card-body p:last-child {
margin-bottom: 0;
}
@media (max-width: 1024px) {
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 640px) {
.stats-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -1,209 +1,281 @@
<template>
<div class="inbound-flow">
<div class="flow-layout">
<!-- Left sidebar: flow list + node palette -->
<div class="left-panel">
<div class="panel-section flow-list">
<div class="section-header">
<span class="section-title">流程列表</span>
<el-button type="primary" size="small" @click="createNewFlow">新建</el-button>
</div>
<el-table
:data="flows"
highlight-current-row
size="small"
@row-click="handleFlowRowClick"
>
<el-table-column prop="flowCode" label="编码" show-overflow-tooltip />
<el-table-column prop="flowName" label="名称" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="70">
<template #default="scope">
<el-tag :type="statusTagType(scope.row.status)" size="small">
{{ statusText(scope.row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="version" label="版本" width="55">
<template #default="scope">
<span>v{{ scope.row.version || 1 }}</span>
</template>
</el-table-column>
</el-table>
</div>
<div class="sino-page inbound-page">
<el-card class="toolbar-card" shadow="never">
<div class="toolbar">
<el-input
v-model="flowKeyword"
placeholder="搜索流程编码 / 名称"
clearable
style="width: 260px"
@keyup.enter="handleFlowSearch"
/>
<el-button type="primary" @click="openCreateDialog">新建流程</el-button>
</div>
</el-card>
<div class="panel-section node-palette">
<div class="section-header">
<span class="section-title">节点组件</span>
</div>
<div class="palette-grid">
<div
v-for="item in paletteNodes"
:key="item.type"
class="palette-item"
:class="`palette-${item.type}`"
draggable
@click="addNode(item.type)"
@dragstart="onDragStart($event, item.type)"
<el-card class="flow-card" shadow="never">
<div class="flow-layout">
<!-- Left sidebar: flow list + node palette -->
<div class="left-panel">
<div class="panel-section flow-list">
<div class="section-header">
<span class="section-title">流程列表</span>
</div>
<el-table
:data="pagedFlows"
highlight-current-row
size="small"
@row-click="handleFlowRowClick"
class="sino-table"
>
{{ item.label }}
<el-table-column prop="flowCode" label="编码" show-overflow-tooltip />
<el-table-column prop="flowName" label="名称" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="70">
<template #default="scope">
<el-tag :type="statusTagType(scope.row.status)" size="small">
{{ statusText(scope.row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="version" label="版本" width="55">
<template #default="scope">
<span>v{{ scope.row.version || 1 }}</span>
</template>
</el-table-column>
</el-table>
<div class="pagination-bar small">
<el-pagination
v-model:current-page="flowPage"
v-model:page-size="flowPageSize"
:total="totalFlowCount"
:page-sizes="[10, 20, 50]"
layout="total, prev, pager, next, sizes"
@size-change="handleFlowSizeChange"
@current-change="handleFlowPageChange"
/>
</div>
</div>
<div class="panel-section node-palette">
<div class="section-header">
<span class="section-title">节点组件</span>
</div>
<div class="palette-grid">
<div
v-for="item in paletteNodes"
:key="item.type"
class="palette-item"
:class="`palette-${item.type}`"
draggable
@click="addNode(item.type)"
@dragstart="onDragStart($event, item.type)"
>
{{ item.label }}
</div>
</div>
</div>
</div>
</div>
<!-- Center canvas -->
<div class="canvas-area" @drop="onDrop" @dragover="onDragOver">
<div class="flow-toolbar">
<el-input
v-model="form.flowCode"
placeholder="流程编码"
style="width: 160px"
:disabled="isEdit"
/>
<el-input
v-model="form.flowName"
placeholder="流程名称"
style="width: 180px"
/>
<el-button type="primary" @click="handleSave">保存</el-button>
<el-button type="success" @click="handlePublish">发布</el-button>
<el-button type="warning" @click="handleOffline">下线</el-button>
<el-button type="danger" @click="handleDelete">删除</el-button>
<el-button @click="resetCanvas">清空画布</el-button>
</div>
<div class="flow-canvas">
<VueFlow
v-model="elements"
fit-view-on-init
:default-zoom="1"
@node-click="onNodeClick"
@edge-click="onEdgeClick"
@pane-click="onPaneClick"
>
<Background pattern-color="#aaa" :gap="16" />
<Controls />
<template #node-flowNode="nodeProps">
<div
class="flow-node"
:class="[
`node-${nodeProps.data.nodeType}`,
{ 'node-selected': nodeProps.selected },
]"
>
<Handle type="target" :position="Position.Top" />
<div class="node-icon">{{ nodeTypeIcon(nodeProps.data.nodeType) }}</div>
<div class="node-label">{{ nodeProps.label }}</div>
<Handle type="source" :position="Position.Bottom" />
</div>
</template>
</VueFlow>
</div>
</div>
<!-- Right config panel -->
<div v-if="selectedNode || selectedEdge" class="right-panel">
<template v-if="selectedNode">
<div class="section-header">
<span class="section-title">节点配置</span>
<span class="node-type-tag">{{ nodeTypeLabel(selectedNode.data.nodeType) }}</span>
<!-- Center canvas -->
<div class="canvas-area" @drop="onDrop" @dragover="onDragOver">
<div class="flow-toolbar">
<el-input
v-model="form.flowCode"
placeholder="流程编码"
style="width: 160px"
:disabled="isEdit"
/>
<el-input
v-model="form.flowName"
placeholder="流程名称"
style="width: 180px"
/>
<el-button type="primary" @click="handleSave">保存</el-button>
<el-button type="success" @click="handlePublish">发布</el-button>
<el-button type="warning" @click="handleOffline">下线</el-button>
<el-button type="danger" @click="handleDelete">删除</el-button>
<el-button @click="resetCanvas">清空画布</el-button>
</div>
<el-form label-width="80px" class="config-form">
<!-- receive -->
<template v-if="selectedNode.data.nodeType === 'receive'">
<el-form-item label="渠道类型">
<el-select v-model="selectedNode.data.config.channel_type" style="width: 100%">
<el-option label="企业微信" value="wecom" />
<el-option label="个人微信" value="wechat_personal" />
</el-select>
</el-form-item>
</template>
<!-- intent -->
<template v-if="selectedNode.data.nodeType === 'intent'">
<el-form-item label="识别模式">
<el-select v-model="selectedNode.data.config.mode" style="width: 100%">
<el-option label="规则" value="rule" />
<el-option label="LLM" value="llm" />
<el-option label="混合" value="both" />
</el-select>
</el-form-item>
<el-form-item label="提示词" v-if="showIntentPrompt(selectedNode.data.config.mode)">
<el-input
v-model="selectedNode.data.config.prompt"
type="textarea"
:rows="6"
placeholder="请输入 LLM 识别提示词"
/>
</el-form-item>
</template>
<!-- condition -->
<template v-if="selectedNode.data.nodeType === 'condition'">
<el-form-item label="表达式">
<el-input
v-model="selectedNode.data.config.expression"
type="textarea"
:rows="4"
placeholder="例如intent.intent == 'create_order'"
/>
</el-form-item>
<div class="form-hint">提示出边标签可填写 true / false 用于分支判断</div>
</template>
<!-- webhook -->
<template v-if="selectedNode.data.nodeType === 'webhook'">
<el-form-item label="请求地址">
<el-input v-model="selectedNode.data.config.url" placeholder="https://" />
</el-form-item>
<el-form-item label="请求方法">
<el-select v-model="selectedNode.data.config.method" style="width: 100%">
<el-option label="POST" value="POST" />
<el-option label="GET" value="GET" />
<el-option label="PUT" value="PUT" />
</el-select>
</el-form-item>
</template>
<!-- reply -->
<template v-if="selectedNode.data.nodeType === 'reply'">
<el-form-item label="内容类型">
<el-select v-model="selectedNode.data.config.content.type" style="width: 100%">
<el-option label="文本" value="text" />
<el-option label="图片" value="image" />
<el-option label="图文卡片" value="news" />
</el-select>
</el-form-item>
<el-form-item label="内容">
<el-input
v-model="selectedNode.data.config.content.body"
type="textarea"
:rows="6"
placeholder="请输入回复内容"
/>
</el-form-item>
</template>
</el-form>
</template>
<template v-if="selectedEdge">
<div class="section-header">
<span class="section-title">边配置</span>
<div class="flow-canvas">
<VueFlow
v-model="elements"
fit-view-on-init
:default-zoom="1"
@node-click="onNodeClick"
@edge-click="onEdgeClick"
@pane-click="onPaneClick"
>
<Background pattern-color="#aaa" :gap="16" />
<Controls />
<template #node-flowNode="nodeProps">
<div
class="flow-node"
:class="[
`node-${nodeProps.data.nodeType}`,
{ 'node-selected': nodeProps.selected },
]"
>
<Handle type="target" :position="Position.Top" />
<div class="node-icon">{{ nodeTypeIcon(nodeProps.data.nodeType) }}</div>
<div class="node-label">{{ nodeProps.label }}</div>
<Handle type="source" :position="Position.Bottom" />
</div>
</template>
</VueFlow>
</div>
<el-form label-width="80px" class="config-form">
<el-form-item label="标签">
<el-input v-model="selectedEdge.label" placeholder="true / false" />
</el-form-item>
</el-form>
</template>
</div>
<!-- Right config panel -->
<div v-if="selectedNode || selectedEdge" class="right-panel">
<template v-if="selectedNode">
<div class="section-header">
<span class="section-title">节点配置</span>
<span class="node-type-tag">{{ nodeTypeLabel(selectedNode.data.nodeType) }}</span>
</div>
<el-form label-width="80px" class="config-form">
<!-- receive -->
<template v-if="selectedNode.data.nodeType === 'receive'">
<el-form-item label="渠道类型">
<el-select v-model="selectedNode.data.config.channel_type" style="width: 100%">
<el-option label="企业微信" value="wecom" />
<el-option label="个人微信" value="wechat_personal" />
</el-select>
</el-form-item>
</template>
<!-- intent -->
<template v-if="selectedNode.data.nodeType === 'intent'">
<el-form-item label="识别模式">
<el-select v-model="selectedNode.data.config.mode" style="width: 100%">
<el-option label="规则" value="rule" />
<el-option label="LLM" value="llm" />
<el-option label="混合" value="both" />
</el-select>
</el-form-item>
<el-form-item label="提示词" v-if="showIntentPrompt(selectedNode.data.config.mode)">
<el-input
v-model="selectedNode.data.config.prompt"
type="textarea"
:rows="6"
placeholder="请输入 LLM 识别提示词"
/>
</el-form-item>
</template>
<!-- condition -->
<template v-if="selectedNode.data.nodeType === 'condition'">
<el-form-item label="表达式">
<el-input
v-model="selectedNode.data.config.expression"
type="textarea"
:rows="4"
placeholder="例如intent.intent == 'create_order'"
/>
</el-form-item>
<div class="form-hint">提示出边标签可填写 true / false 用于分支判断</div>
</template>
<!-- webhook -->
<template v-if="selectedNode.data.nodeType === 'webhook'">
<el-form-item label="请求地址">
<el-input v-model="selectedNode.data.config.url" placeholder="https://" />
</el-form-item>
<el-form-item label="请求方法">
<el-select v-model="selectedNode.data.config.method" style="width: 100%">
<el-option label="POST" value="POST" />
<el-option label="GET" value="GET" />
<el-option label="PUT" value="PUT" />
</el-select>
</el-form-item>
</template>
<!-- reply -->
<template v-if="selectedNode.data.nodeType === 'reply'">
<el-form-item label="内容类型">
<el-select v-model="selectedNode.data.config.content.type" style="width: 100%">
<el-option label="文本" value="text" />
<el-option label="图片" value="image" />
<el-option label="图文卡片" value="news" />
</el-select>
</el-form-item>
<el-form-item label="内容">
<el-input
v-model="selectedNode.data.config.content.body"
type="textarea"
:rows="6"
placeholder="请输入回复内容"
/>
</el-form-item>
</template>
</el-form>
</template>
<template v-if="selectedEdge">
<div class="section-header">
<span class="section-title">边配置</span>
</div>
<el-form label-width="80px" class="config-form">
<el-form-item label="标签">
<el-input v-model="selectedEdge.label" placeholder="true / false" />
</el-form-item>
</el-form>
</template>
</div>
</div>
</div>
</el-card>
<!-- 新建流程弹窗 -->
<el-dialog v-model="createDialogVisible" title="新建流程" width="520px">
<el-form :model="createForm" label-width="100px">
<el-form-item label="流程编码">
<el-input v-model="createForm.flowCode" placeholder="flow_xxx" />
</el-form-item>
<el-form-item label="流程名称">
<el-input v-model="createForm.flowName" placeholder="新建入站流程" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="createDialogVisible = false">取消</el-button>
<el-button type="primary" @click="confirmCreateFlow">确认</el-button>
</template>
</el-dialog>
<!-- 流程详情抽屉 -->
<el-drawer v-model="drawerVisible" title="流程详情" size="460px">
<div v-if="selectedFlow" class="flow-detail">
<div class="detail-item">
<span class="detail-label">流程编码</span>
<span class="detail-value">{{ selectedFlow.flowCode }}</span>
</div>
<div class="detail-item">
<span class="detail-label">流程名称</span>
<span class="detail-value">{{ selectedFlow.flowName }}</span>
</div>
<div class="detail-item">
<span class="detail-label">状态</span>
<el-tag :type="statusTagType(selectedFlow.status)" size="small">
{{ statusText(selectedFlow.status) }}
</el-tag>
</div>
<div class="detail-item">
<span class="detail-label">版本</span>
<span class="detail-value">v{{ selectedFlow.version || 1 }}</span>
</div>
</div>
<template #footer>
<div class="drawer-footer">
<el-button @click="drawerVisible = false">关闭</el-button>
<el-button type="primary" @click="editSelectedFlow">编辑画布</el-button>
</div>
</template>
</el-drawer>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { VueFlow, Handle, Position, useVueFlow } from '@vue-flow/core'
import { Background } from '@vue-flow/background'
@@ -238,6 +310,18 @@ const selectedNode = ref<any>(null)
const selectedEdge = ref<any>(null)
const isEdit = ref(false)
const flowKeyword = ref('')
const flowPage = ref(1)
const flowPageSize = ref(10)
const createDialogVisible = ref(false)
const drawerVisible = ref(false)
const selectedFlow = ref<any>(null)
const createForm = reactive({
flowCode: '',
flowName: '',
})
const paletteNodes = [
{ type: 'receive', label: '接收消息' },
{ type: 'intent', label: '意图识别' },
@@ -270,6 +354,23 @@ const nodeTypeIcons: Record<string, string> = {
reply: '💬',
}
const filteredFlows = computed(() => {
if (!flowKeyword.value) return flows.value
const kw = flowKeyword.value.toLowerCase()
return flows.value.filter(
(f) =>
(f.flowCode || '').toLowerCase().includes(kw) ||
(f.flowName || '').toLowerCase().includes(kw)
)
})
const totalFlowCount = computed(() => filteredFlows.value.length)
const pagedFlows = computed(() => {
const start = (flowPage.value - 1) * flowPageSize.value
return filteredFlows.value.slice(start, start + flowPageSize.value)
})
function nodeTypeLabel(type: string) {
return nodeTypeLabels[type] || type
}
@@ -377,16 +478,51 @@ function resetCanvas() {
selectedEdge.value = null
}
function createNewFlow() {
function openCreateDialog() {
createForm.flowCode = `flow_${Date.now()}`
createForm.flowName = '新建入站流程'
createDialogVisible.value = true
}
function confirmCreateFlow() {
if (!createForm.flowCode.trim()) {
ElMessage.warning('请输入流程编码')
return
}
if (!createForm.flowName.trim()) {
ElMessage.warning('请输入流程名称')
return
}
resetCanvas()
form.flowCode = `flow_${Date.now()}`
form.flowName = '新建入站流程'
form.flowCode = createForm.flowCode
form.flowName = createForm.flowName
addNode('receive')
addNode('intent')
createDialogVisible.value = false
}
function handleFlowRowClick(row: any) {
loadFlow(row.flowCode)
selectedFlow.value = row
drawerVisible.value = true
}
function editSelectedFlow() {
if (!selectedFlow.value) return
loadFlow(selectedFlow.value.flowCode)
drawerVisible.value = false
}
function handleFlowSearch() {
flowPage.value = 1
}
function handleFlowPageChange(val: number) {
flowPage.value = val
}
function handleFlowSizeChange(val: number) {
flowPageSize.value = val
flowPage.value = 1
}
function parseDefinitionJson(row: any): { nodes: any[]; edges: any[] } {
@@ -561,135 +697,146 @@ onMounted(() => {
</script>
<style scoped>
.inbound-flow {
height: calc(100vh - 120px);
.sino-page {
--el-color-primary: #2563eb;
--el-color-primary-light-3: #1d4ed8;
display: flex;
flex-direction: column;
gap: 16px;
}
.inbound-page {
height: calc(100vh - 112px);
}
.toolbar-card {
border-radius: 12px;
border: 1px solid #e2e8f0;
}
.flow-card {
flex: 1;
min-height: 0;
border-radius: 12px;
border: 1px solid #e2e8f0;
}
.flow-card :deep(.el-card__body) {
height: 100%;
padding: 0;
}
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 16px;
}
.flow-layout {
display: flex;
flex: 1;
gap: 12px;
gap: 16px;
height: 100%;
min-height: 0;
}
.left-panel {
width: 260px;
width: 280px;
display: flex;
flex-direction: column;
gap: 12px;
min-height: 0;
padding: 16px;
}
.panel-section {
background: #fff;
border: 1px solid #e4e7ed;
border-radius: 4px;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 12px;
overflow: auto;
}
.flow-list {
flex: 1;
min-height: 0;
}
.node-palette {
flex-shrink: 0;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.section-title {
font-weight: 500;
font-weight: 600;
font-size: 14px;
color: #0f172a;
}
.palette-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.palette-item {
padding: 10px 8px;
border: 1px solid #dcdfe6;
border-radius: 4px;
border: 1px solid #e2e8f0;
border-radius: 8px;
text-align: center;
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
user-select: none;
color: #475569;
background: #fff;
}
.palette-item:hover {
border-color: #409eff;
color: #409eff;
background: #ecf5ff;
border-color: #2563eb;
color: #2563eb;
background: #eff6ff;
}
.canvas-area {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
border: 1px solid #e4e7ed;
border-radius: 4px;
border-left: 1px solid #e2e8f0;
border-right: 1px solid #e2e8f0;
background: #fff;
}
.flow-toolbar {
padding: 12px;
border-bottom: 1px solid #e4e7ed;
padding: 12px 16px;
border-bottom: 1px solid #e2e8f0;
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.flow-canvas {
flex: 1;
min-height: 0;
}
.right-panel {
width: 300px;
background: #fff;
border: 1px solid #e4e7ed;
border-radius: 4px;
padding: 12px;
padding: 16px;
overflow: auto;
}
.config-form {
margin-top: 12px;
}
.form-hint {
font-size: 12px;
color: #909399;
color: #64748b;
line-height: 1.5;
padding: 0 0 12px 80px;
}
.node-type-tag {
font-size: 12px;
color: #409eff;
background: #ecf5ff;
color: #2563eb;
background: #eff6ff;
padding: 2px 8px;
border-radius: 4px;
}
.flow-node {
padding: 10px 16px;
border-radius: 6px;
border: 1px solid #dcdfe6;
border-radius: 8px;
border: 1px solid #e2e8f0;
background: #fff;
display: flex;
align-items: center;
@@ -697,38 +844,59 @@ onMounted(() => {
min-width: 110px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.06);
}
.flow-node.node-selected {
border-color: #409eff;
box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.2);
border-color: #2563eb;
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.2);
}
.node-icon {
font-size: 16px;
}
.node-label {
font-size: 13px;
font-weight: 500;
}
.node-receive {
border-left: 4px solid #67c23a;
border-left: 4px solid #22c55e;
}
.node-intent {
border-left: 4px solid #e6a23c;
border-left: 4px solid #f59e0b;
}
.node-condition {
border-left: 4px solid #f56c6c;
border-left: 4px solid #ef4444;
}
.node-webhook {
border-left: 4px solid #409eff;
border-left: 4px solid #2563eb;
}
.node-reply {
border-left: 4px solid #909399;
border-left: 4px solid #94a3b8;
}
.pagination-bar {
margin-top: 12px;
display: flex;
justify-content: flex-end;
}
.pagination-bar.small :deep(.el-pagination) {
justify-content: flex-end;
}
.flow-detail {
font-size: 14px;
}
.detail-item {
margin-bottom: 16px;
}
.detail-label {
display: block;
color: #64748b;
font-size: 13px;
margin-bottom: 6px;
}
.detail-value {
color: #0f172a;
word-break: break-all;
}
.drawer-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
</style>

View File

@@ -0,0 +1,189 @@
<template>
<div class="messages-layout">
<div class="conversation-list">
<div class="conv-search">
<el-input v-model="search" placeholder="搜索会话" prefix-icon="Search" />
</div>
<div class="conv-items">
<div
v-for="conv in conversations"
:key="conv.id"
class="conv-item"
:class="{ active: current?.id === conv.id }"
@click="current = conv"
>
<div class="conv-title">{{ conv.name }}</div>
<div class="conv-preview">{{ conv.preview }}</div>
</div>
</div>
</div>
<div class="chat-area" v-if="current">
<div class="chat-header">
<span class="chat-name">{{ current.name }}</span>
<div class="chat-actions">
<el-button link type="primary">打标签</el-button>
<el-button link type="primary">会话详情</el-button>
</div>
</div>
<div class="chat-body">
<div
v-for="(msg, idx) in current.messages"
:key="idx"
class="chat-msg"
:class="msg.self ? 'self' : 'other'"
>
<div class="chat-bubble">{{ msg.content }}</div>
</div>
</div>
<div class="chat-footer">
<el-input v-model="reply" placeholder="输入消息..." @keyup.enter="send" />
<el-button type="primary" @click="send">发送</el-button>
</div>
</div>
<div class="chat-empty" v-else>
<el-empty description="请选择会话" />
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const search = ref('')
const reply = ref('')
const current = ref<any>(null)
const conversations = ref([
{
id: 1,
name: '艾先生',
preview: '我要查询订单状态',
messages: [
{ content: '我要查询订单状态', self: false },
{ content: '订单号是 20250709001', self: false },
{ content: '好的,已为您查询到订单正在发货中。', self: true },
],
},
{
id: 2,
name: '华东销售群',
preview: '[图片] 车牌号 京A12345',
messages: [
{ content: '[图片] 车牌号 京A12345', self: false },
],
},
])
function send() {
if (!reply.value.trim() || !current.value) return
current.value.messages.push({ content: reply.value, self: true })
current.value.preview = reply.value
reply.value = ''
}
</script>
<style scoped>
.messages-layout {
display: flex;
height: calc(100vh - 140px);
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
overflow: hidden;
}
.conversation-list {
width: 280px;
border-right: 1px solid #e2e8f0;
display: flex;
flex-direction: column;
}
.conv-search {
padding: 12px;
border-bottom: 1px solid #f1f5f9;
}
.conv-items {
flex: 1;
overflow-y: auto;
}
.conv-item {
padding: 12px 16px;
border-bottom: 1px solid #f8fafc;
cursor: pointer;
}
.conv-item:hover,
.conv-item.active {
background: #f8fafc;
}
.conv-title {
font-weight: 500;
font-size: 14px;
color: #0f172a;
}
.conv-preview {
font-size: 13px;
color: #64748b;
margin-top: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-area {
flex: 1;
display: flex;
flex-direction: column;
}
.chat-header {
height: 56px;
border-bottom: 1px solid #f1f5f9;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
}
.chat-name {
font-weight: 600;
}
.chat-body {
flex: 1;
padding: 20px;
overflow-y: auto;
background: #f8fafc;
}
.chat-msg {
display: flex;
margin-bottom: 12px;
}
.chat-msg.self {
justify-content: flex-end;
}
.chat-bubble {
max-width: 60%;
padding: 10px 14px;
border-radius: 12px;
font-size: 14px;
line-height: 1.5;
}
.chat-msg.other .chat-bubble {
background: #fff;
border: 1px solid #e2e8f0;
color: #0f172a;
border-top-left-radius: 4px;
}
.chat-msg.self .chat-bubble {
background: #2563eb;
color: #fff;
border-top-right-radius: 4px;
}
.chat-footer {
padding: 12px 20px;
border-top: 1px solid #f1f5f9;
display: flex;
gap: 12px;
}
.chat-empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
</style>

View File

@@ -1,48 +1,63 @@
<template>
<div>
<div class="toolbar">
<el-button type="primary" @click="openCreateDialog">创建联系人</el-button>
</div>
<PageCard>
<template #toolbar-left>
<el-input
v-model="searchQuery"
placeholder="搜索姓名 / 手机 / 编码"
clearable
style="width: 260px"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-select v-model="personTypeFilter" placeholder="全部类型" clearable style="width: 140px">
<el-option label="内部人员" :value="1" />
<el-option label="供应商人员" :value="2" />
<el-option label="客户" :value="3" />
<el-option label="司机" :value="4" />
</el-select>
</template>
<template #toolbar-right>
<el-button type="primary" @click="openCreateDialog">新增联系人</el-button>
</template>
<el-table :data="persons" border>
<el-table :data="pagedPersons" class="sino-table" @row-click="openDrawer">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="personCode" label="编码" />
<el-table-column prop="personName" label="姓名" />
<el-table-column prop="phone" label="手机" />
<el-table-column prop="personType" label="类型" width="100">
<el-table-column prop="personType" label="类型" width="120">
<template #default="{ row }">
{{ formatPersonType(row.personType) }}
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<el-table-column prop="status" label="状态" width="120">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'info'">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="260">
<template #default="{ row }">
<div class="op-row">
<el-button size="small" @click="openEditDialog(row)">编辑</el-button>
<el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
<div class="status-cell" @click.stop>
<el-switch
v-model="row.status"
:active-value="1"
:inactive-value="0"
style="margin-left: 12px"
@change="(val: any) => handleStatusChange(row, val)"
/>
</div>
<div class="op-row" style="margin-top: 6px;">
<el-button link type="primary" size="small" @click="openIdentities(row)">渠道身份</el-button>
<el-button link type="primary" size="small" @click="openBindTags(row)">绑定标签</el-button>
<span class="status-text">{{ row.status === 1 ? '启用' : '禁用' }}</span>
</div>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑联系人' : '创建联系人'" width="600px">
<template #pagination>
<el-pagination
v-model:current-page="currentPage"
:page-size="pageSize"
:total="filteredPersons.length"
layout="total, prev, pager, next"
background
/>
</template>
<el-dialog v-model="dialogVisible" title="新增联系人" width="600px">
<el-form :model="dialogForm" label-width="100px">
<el-form-item label="编码">
<el-input v-model="dialogForm.personCode" placeholder="人员编码" />
@@ -61,7 +76,7 @@
<el-option label="司机" :value="4" />
</el-select>
</el-form-item>
<el-form-item v-if="isEdit" label="状态">
<el-form-item label="状态">
<el-switch
v-model="dialogForm.status"
:active-value="1"
@@ -77,8 +92,55 @@
</template>
</el-dialog>
<el-drawer v-model="drawerVisible" title="联系人详情" size="520px">
<el-form :model="dialogForm" label-width="100px">
<el-form-item label="编码">
<el-input v-model="dialogForm.personCode" placeholder="人员编码" />
</el-form-item>
<el-form-item label="姓名">
<el-input v-model="dialogForm.personName" placeholder="姓名" />
</el-form-item>
<el-form-item label="手机">
<el-input v-model="dialogForm.phone" placeholder="手机号" />
</el-form-item>
<el-form-item label="类型">
<el-select v-model="dialogForm.personType" placeholder="类型" style="width: 100%">
<el-option label="内部人员" :value="1" />
<el-option label="供应商人员" :value="2" />
<el-option label="客户" :value="3" />
<el-option label="司机" :value="4" />
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-switch
v-model="dialogForm.status"
:active-value="1"
:inactive-value="0"
active-text="启用"
inactive-text="禁用"
/>
</el-form-item>
</el-form>
<el-divider />
<div class="relation-section">
<div class="section-title">关联信息</div>
<div class="relation-actions">
<el-button type="primary" link @click="openIdentities(currentPerson)">管理渠道身份</el-button>
<el-button type="primary" link @click="openBindTags(currentPerson)">绑定标签</el-button>
</div>
</div>
<template #footer>
<el-button @click="drawerVisible = false">取消</el-button>
<el-button type="danger" @click="handleDelete(currentPerson)">删除</el-button>
<el-button type="primary" @click="handleSave">保存</el-button>
</template>
</el-drawer>
<el-dialog v-model="identitiesVisible" title="渠道身份" width="700px">
<el-table :data="identities" border style="margin-bottom: 16px;">
<el-table :data="identities" class="sino-table" border style="margin-bottom: 16px;">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="channelType" label="渠道" />
<el-table-column prop="channelUserId" label="渠道用户ID" />
@@ -128,11 +190,12 @@
<el-button type="primary" @click="handleBindTags">确认</el-button>
</template>
</el-dialog>
</div>
</PageCard>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { Search } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
bindPersonTags,
@@ -145,6 +208,7 @@ import {
listTags,
updatePerson,
} from '@/api/crm'
import PageCard from '@/components/PageCard.vue'
const persons = ref<any[]>([])
const tags = ref<any[]>([])
@@ -161,6 +225,36 @@ const dialogForm = reactive({
status: 1,
})
const searchQuery = ref('')
const personTypeFilter = ref<number | ''>('')
const currentPage = ref(1)
const pageSize = ref(10)
const drawerVisible = ref(false)
const currentPerson = ref<any>({})
const filteredPersons = computed(() => {
const q = searchQuery.value.trim().toLowerCase()
return persons.value.filter((p) => {
const matchesQuery =
!q ||
[p.personName, p.phone, p.personCode].some((v) =>
String(v || '').toLowerCase().includes(q)
)
const matchesType =
personTypeFilter.value === '' || p.personType === personTypeFilter.value
return matchesQuery && matchesType
})
})
const pagedPersons = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
return filteredPersons.value.slice(start, start + pageSize.value)
})
watch([searchQuery, personTypeFilter], () => {
currentPage.value = 1
})
function resetDialogForm() {
dialogForm.personCode = ''
dialogForm.personName = ''
@@ -184,11 +278,13 @@ function openCreateDialog() {
dialogVisible.value = true
}
function openEditDialog(row: any) {
function openDrawer(row: any) {
currentPerson.value = row
currentPersonId.value = row.id
isEdit.value = true
editingId.value = row.id
fillDialogForm(row)
dialogVisible.value = true
drawerVisible.value = true
}
function formatPersonType(type: number | undefined) {
@@ -237,6 +333,7 @@ async function handleSave() {
ElMessage.success('创建成功')
}
dialogVisible.value = false
drawerVisible.value = false
await loadPersons()
} catch (e: any) {
ElMessage.error(e.message)
@@ -269,6 +366,7 @@ async function handleDelete(row: any) {
)
await deletePerson(row.id)
ElMessage.success('删除成功')
drawerVisible.value = false
await loadPersons()
} catch (e: any) {
if (e !== 'cancel') {
@@ -382,7 +480,19 @@ onMounted(() => {
</script>
<style scoped>
.toolbar {
margin-bottom: 16px;
.relation-section {
margin-top: 8px;
}
.section-title {
font-size: 14px;
font-weight: 600;
color: #0F172A;
margin-bottom: 12px;
}
.relation-actions {
display: flex;
gap: 16px;
}
</style>

View File

@@ -1,14 +1,14 @@
<template>
<div>
<div class="send-record-page">
<!-- 统计卡片 -->
<el-row :gutter="16">
<el-row :gutter="16" class="stat-row">
<el-col :span="6">
<el-card shadow="hover">
<el-card shadow="hover" class="stat-card">
<el-statistic title="今日发送总数" :value="statistics.total" />
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<el-card shadow="hover" class="stat-card">
<el-statistic title="成功" :value="statistics.successCount">
<template #suffix>
<el-tag type="success" size="small">成功</el-tag>
@@ -17,7 +17,7 @@
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<el-card shadow="hover" class="stat-card">
<el-statistic title="失败" :value="statistics.failedCount">
<template #suffix>
<el-tag type="danger" size="small">失败</el-tag>
@@ -26,7 +26,7 @@
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<el-card shadow="hover" class="stat-card">
<el-statistic title="待发送" :value="statistics.pendingCount">
<template #suffix>
<el-tag type="info" size="small">待发送</el-tag>
@@ -36,11 +36,84 @@
</el-col>
</el-row>
<!-- 测试发送 -->
<el-card class="mt-4" shadow="never">
<template #header>
<span>测试发送</span>
<PageCard>
<template #toolbar-left>
<el-form :model="query" inline class="search-form">
<el-form-item label="任务 ID">
<el-input v-model="query.taskId" placeholder="SND..." clearable style="width: 220px" />
</el-form-item>
<el-form-item label="发送时间">
<el-date-picker
v-model="dateRange"
type="datetimerange"
range-separator=""
start-placeholder="开始时间"
end-placeholder="结束时间"
value-format="YYYY-MM-DD[T]HH:mm:ss"
style="width: 360px"
/>
</el-form-item>
<el-form-item label="渠道">
<el-select v-model="query.channelType" placeholder="全部" clearable style="width: 140px">
<el-option label="企微" value="wecom" />
<el-option label="微信个人号" value="wechat_personal" />
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="query.status" placeholder="全部" clearable style="width: 120px">
<el-option label="待发送" :value="0" />
<el-option label="发送中" :value="1" />
<el-option label="成功" :value="2" />
<el-option label="失败" :value="3" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</template>
<template #toolbar-right>
<el-button type="primary" @click="openTestDialog">测试发送</el-button>
</template>
<el-table
:data="records"
v-loading="loading"
@row-click="openDetail"
class="sino-table"
>
<el-table-column prop="taskId" label="任务 ID" min-width="180" show-overflow-tooltip />
<el-table-column prop="targetType" label="目标类型" width="110" />
<el-table-column prop="channelType" label="渠道" width="120" />
<el-table-column prop="sendStatus" label="状态" width="100">
<template #default="{ row }">
<el-tag v-if="row.sendStatus === 0" type="info">待发送</el-tag>
<el-tag v-else-if="row.sendStatus === 1" type="warning">发送中</el-tag>
<el-tag v-else-if="row.sendStatus === 2" type="success">成功</el-tag>
<el-tag v-else-if="row.sendStatus === 3" type="danger">失败</el-tag>
<el-tag v-else type="info">未知</el-tag>
</template>
</el-table-column>
<el-table-column prop="sendTime" label="发送时间" min-width="160" />
<el-table-column prop="finishTime" label="完成时间" min-width="160" />
</el-table>
<template #pagination>
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.size"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next"
@size-change="handleSizeChange"
@current-change="loadRecords"
/>
</template>
</PageCard>
<!-- 测试发送弹窗 -->
<el-dialog v-model="testDialogVisible" title="测试发送" width="760px">
<el-form :model="testForm" label-width="80px" inline>
<el-form-item label="目标类型">
<el-select v-model="testForm.targetType" style="width: 140px">
@@ -97,94 +170,12 @@
<el-form-item label="内容">
<el-input v-model="textContent" placeholder="输入文本消息" style="width: 260px" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleTestSend">测试发送</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 筛选栏 -->
<el-card class="mt-4" shadow="never">
<el-form :model="query" inline>
<el-form-item label="任务 ID">
<el-input v-model="query.taskId" placeholder="SND..." clearable style="width: 220px" />
</el-form-item>
<el-form-item label="发送时间">
<el-date-picker
v-model="dateRange"
type="datetimerange"
range-separator=""
start-placeholder="开始时间"
end-placeholder="结束时间"
value-format="YYYY-MM-DD[T]HH:mm:ss"
style="width: 360px"
/>
</el-form-item>
<el-form-item label="渠道">
<el-select v-model="query.channelType" placeholder="全部" clearable style="width: 140px">
<el-option label="企微" value="wecom" />
<el-option label="微信个人号" value="wechat_personal" />
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="query.status" placeholder="全部" clearable style="width: 120px">
<el-option label="待发送" :value="0" />
<el-option label="发送中" :value="1" />
<el-option label="成功" :value="2" />
<el-option label="失败" :value="3" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 记录列表 -->
<el-card class="mt-4" shadow="never">
<el-table :data="records" border v-loading="loading">
<el-table-column prop="taskId" label="任务 ID" min-width="180" show-overflow-tooltip />
<el-table-column prop="targetType" label="目标类型" width="110" />
<el-table-column prop="channelType" label="渠道" width="120" />
<el-table-column prop="sendStatus" label="状态" width="100">
<template #default="{ row }">
<el-tag v-if="row.sendStatus === 0" type="info">待发送</el-tag>
<el-tag v-else-if="row.sendStatus === 1" type="warning">发送中</el-tag>
<el-tag v-else-if="row.sendStatus === 2" type="success">成功</el-tag>
<el-tag v-else-if="row.sendStatus === 3" type="danger">失败</el-tag>
<el-tag v-else type="info">未知</el-tag>
</template>
</el-table-column>
<el-table-column prop="sendTime" label="发送时间" min-width="160" />
<el-table-column prop="finishTime" label="完成时间" min-width="160" />
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openDetail(row)">详情</el-button>
<el-button
v-if="row.sendStatus === 0"
link
type="danger"
@click="handleCancel(row.taskId)"
>
取消
</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination-bar">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.size"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next"
@size-change="handleSizeChange"
@current-change="loadRecords"
/>
</div>
</el-card>
<template #footer>
<el-button @click="testDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleTestSend">确认发送</el-button>
</template>
</el-dialog>
<!-- 详情抽屉 -->
<el-drawer v-model="detailVisible" title="发送详情" size="520px">
@@ -226,6 +217,12 @@
<span class="detail-value">{{ currentRecord.finishTime || '-' }}</span>
</div>
</div>
<template #footer v-if="currentRecord && currentRecord.sendStatus === 0">
<div class="drawer-footer">
<el-button type="danger" @click="handleCancel(currentRecord.taskId)">取消任务</el-button>
<el-button @click="detailVisible = false">关闭</el-button>
</div>
</template>
</el-drawer>
</div>
</template>
@@ -242,6 +239,7 @@ import {
} from '@/api/sendRecord'
import { listConversations } from '@/api/channel'
import { listTags } from '@/api/crm'
import PageCard from '@/components/PageCard.vue'
interface RecordItem {
id: number
@@ -282,6 +280,7 @@ const records = ref<RecordItem[]>([])
const loading = ref(false)
const detailVisible = ref(false)
const currentRecord = ref<RecordItem | null>(null)
const testDialogVisible = ref(false)
const testForm = reactive({
targetType: 'conversation',
@@ -297,6 +296,10 @@ const textContent = ref('')
const conversations = ref<any[]>([])
const tags = ref<any[]>([])
function openTestDialog() {
testDialogVisible.value = true
}
function buildTargetValue() {
if (testForm.targetType === 'conversation' && selectedConversationId.value) {
return { conversation_id: selectedConversationId.value }
@@ -392,6 +395,7 @@ async function handleTestSend() {
})
ElMessage.success('测试发送已提交')
textContent.value = ''
testDialogVisible.value = false
await loadRecords()
await loadStatistics()
} catch (e: any) {
@@ -404,6 +408,7 @@ async function handleCancel(taskId: string) {
await ElMessageBox.confirm('确定取消该任务的待发送记录?', '提示', { type: 'warning' })
await cancelTask(taskId)
ElMessage.success('取消成功')
detailVisible.value = false
await loadRecords()
await loadStatistics()
} catch (e: any) {
@@ -459,13 +464,23 @@ onMounted(() => {
</script>
<style scoped>
.mt-4 {
margin-top: 16px;
}
.pagination-bar {
margin-top: 16px;
.send-record-page {
display: flex;
justify-content: flex-end;
flex-direction: column;
gap: 16px;
}
.stat-row {
margin: 0 !important;
}
.stat-card {
border-radius: 12px;
border: 1px solid #e2e8f0;
}
.search-form {
margin-bottom: 0;
}
.search-form :deep(.el-form-item) {
margin-bottom: 0;
}
.detail-content {
font-size: 14px;
@@ -475,20 +490,27 @@ onMounted(() => {
}
.detail-label {
display: block;
color: #606266;
color: #64748b;
font-size: 13px;
margin-bottom: 6px;
}
.detail-value {
color: #303133;
color: #0f172a;
word-break: break-all;
}
pre {
margin: 0;
padding: 12px;
background: #f5f7fa;
border-radius: 4px;
background: #f1f5f9;
border-radius: 8px;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-all;
font-size: 13px;
}
.drawer-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
</style>

View File

@@ -1,10 +1,22 @@
<template>
<div>
<div class="toolbar">
<el-button type="primary" @click="openCreateDialog">创建标签</el-button>
</div>
<PageCard>
<template #toolbar-left>
<el-input
v-model="searchQuery"
placeholder="搜索标签名称"
clearable
style="width: 260px"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
</template>
<template #toolbar-right>
<el-button type="primary" @click="openCreateDialog">新增标签</el-button>
</template>
<el-table :data="tags" border>
<el-table :data="pagedTags" class="sino-table" @row-click="openDrawer">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="tagName" label="名称" />
<el-table-column prop="tagPath" label="路径" show-overflow-tooltip />
@@ -13,31 +25,32 @@
{{ formatLevel(row.level) }}
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<el-table-column prop="status" label="状态" width="120">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'info'">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="260">
<template #default="{ row }">
<div class="op-row">
<el-button size="small" @click="openEditDialog(row)">编辑</el-button>
<el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
<div class="status-cell" @click.stop>
<el-switch
v-model="row.status"
:active-value="1"
:inactive-value="0"
style="margin-left: 12px"
@change="(val: any) => handleStatusChange(row, val)"
/>
<span class="status-text">{{ row.status === 1 ? '启用' : '禁用' }}</span>
</div>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑标签' : '创建标签'" width="600px">
<template #pagination>
<el-pagination
v-model:current-page="currentPage"
:page-size="pageSize"
:total="filteredTags.length"
layout="total, prev, pager, next"
background
/>
</template>
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑标签' : '新增标签'" width="600px">
<el-form :model="dialogForm" label-width="100px">
<el-form-item label="标签名">
<el-input v-model="dialogForm.tagName" placeholder="标签名" />
@@ -68,13 +81,48 @@
<el-button type="primary" @click="handleSave">保存</el-button>
</template>
</el-dialog>
</div>
<el-drawer v-model="drawerVisible" title="标签详情" size="480px">
<el-form :model="dialogForm" label-width="100px">
<el-form-item label="标签名">
<el-input v-model="dialogForm.tagName" placeholder="标签名" />
</el-form-item>
<el-form-item label="父标签">
<el-select v-model="dialogForm.parentId" placeholder="选择父标签" style="width: 100%">
<el-option label="无" :value="0" />
<el-option
v-for="t in tags"
:key="t.id"
:label="t.tagName"
:value="t.id"
/>
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-switch
v-model="dialogForm.status"
:active-value="1"
:inactive-value="0"
active-text="启用"
inactive-text="禁用"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="drawerVisible = false">取消</el-button>
<el-button type="danger" @click="handleDelete(currentRow)">删除</el-button>
<el-button type="primary" @click="handleSave">保存</el-button>
</template>
</el-drawer>
</PageCard>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { Search } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { createTag, deleteTag, listTags, updateTag } from '@/api/crm'
import PageCard from '@/components/PageCard.vue'
const tags = ref<any[]>([])
@@ -88,6 +136,31 @@ const dialogForm = reactive({
status: 1,
})
const searchQuery = ref('')
const currentPage = ref(1)
const pageSize = ref(10)
const drawerVisible = ref(false)
const currentRow = ref<any>({})
const filteredTags = computed(() => {
const q = searchQuery.value.trim().toLowerCase()
if (!q) return tags.value
return tags.value.filter(
(t) =>
String(t.tagName || '').toLowerCase().includes(q) ||
String(t.tagPath || '').toLowerCase().includes(q)
)
})
const pagedTags = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
return filteredTags.value.slice(start, start + pageSize.value)
})
watch(searchQuery, () => {
currentPage.value = 1
})
function resetDialogForm() {
dialogForm.tagName = ''
dialogForm.parentId = 0
@@ -107,11 +180,12 @@ function openCreateDialog() {
dialogVisible.value = true
}
function openEditDialog(row: any) {
function openDrawer(row: any) {
currentRow.value = row
isEdit.value = true
editingId.value = row.id
fillDialogForm(row)
dialogVisible.value = true
drawerVisible.value = true
}
function formatLevel(level: number | undefined) {
@@ -148,6 +222,7 @@ async function handleSave() {
ElMessage.success('创建成功')
}
dialogVisible.value = false
drawerVisible.value = false
await loadTags()
} catch (e: any) {
ElMessage.error(e.message)
@@ -178,6 +253,7 @@ async function handleDelete(row: any) {
)
await deleteTag(row.id)
ElMessage.success('删除成功')
drawerVisible.value = false
await loadTags()
} catch (e: any) {
if (e !== 'cancel') {
@@ -188,9 +264,3 @@ async function handleDelete(row: any) {
onMounted(loadTags)
</script>
<style scoped>
.toolbar {
margin-bottom: 16px;
}
</style>

View File

@@ -1,40 +1,57 @@
<template>
<div>
<div class="toolbar">
<el-button type="primary" @click="openCreateDialog">创建租户</el-button>
</div>
<PageCard>
<template #toolbar-left>
<el-input
v-model="searchText"
placeholder="搜索租户编码 / 租户名称"
clearable
style="width: 260px"
@keyup.enter="handleSearch"
/>
</template>
<template #toolbar-right>
<el-button type="primary" @click="openCreateDialog">新增租户</el-button>
</template>
<el-table :data="tenants" border>
<el-table :data="pagedTenants" @row-click="openEditDrawer" class="sino-table">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="tenantCode" label="租户编码" />
<el-table-column prop="tenantName" label="租户名称" />
<el-table-column prop="inboundWebhookUrl" label="入站 Webhook" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="100">
<el-table-column prop="tenantCode" label="租户编码" min-width="160" />
<el-table-column prop="tenantName" label="租户名称" min-width="160" />
<el-table-column prop="inboundWebhookUrl" label="入站 Webhook" show-overflow-tooltip min-width="220" />
<el-table-column prop="status" label="状态" width="120">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'info'">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="260">
<template #default="{ row }">
<el-button size="small" @click="openEditDialog(row)">编辑</el-button>
<el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
<el-switch
v-model="row.status"
:active-value="1"
:inactive-value="0"
style="margin: 0 12px"
@change="(val: any) => handleStatusChange(row, val)"
/>
<div class="status-cell" @click.stop>
<el-switch
v-model="row.status"
:active-value="1"
:inactive-value="0"
inline-prompt
active-text="启用"
inactive-text="禁用"
@change="(val: any) => handleStatusChange(row, val)"
/>
</div>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑租户' : '创建租户'" width="600px">
<template #pagination>
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:total="totalCount"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next"
@size-change="handleSizeChange"
@current-change="handlePageChange"
/>
</template>
<!-- 新增租户弹窗 -->
<el-dialog v-model="dialogVisible" title="新增租户" width="600px">
<el-form :model="dialogForm" label-width="140px">
<el-form-item label="租户编码">
<el-input v-model="dialogForm.tenantCode" :disabled="isEdit" placeholder="租户编码" />
<el-input v-model="dialogForm.tenantCode" placeholder="租户编码" />
</el-form-item>
<el-form-item label="租户名称">
<el-input v-model="dialogForm.tenantName" placeholder="租户名称" />
@@ -51,30 +68,62 @@
inactive-text="禁用"
/>
</el-form-item>
<template v-if="!isEdit">
<el-form-item label="初始化管理员">
<el-checkbox v-model="dialogForm.createAdmin">同时创建租户管理员</el-checkbox>
</el-form-item>
<el-form-item v-if="dialogForm.createAdmin" label="管理员账号">
<el-input v-model="dialogForm.initAdminUsername" placeholder="admin" />
</el-form-item>
<el-form-item v-if="dialogForm.createAdmin" label="初始密码">
<el-input v-model="dialogForm.initAdminPassword" type="password" placeholder="初始密码" />
</el-form-item>
</template>
<el-form-item label="初始化管理员">
<el-checkbox v-model="dialogForm.createAdmin">同时创建租户管理员</el-checkbox>
</el-form-item>
<el-form-item v-if="dialogForm.createAdmin" label="管理员账号">
<el-input v-model="dialogForm.initAdminUsername" placeholder="admin" />
</el-form-item>
<el-form-item v-if="dialogForm.createAdmin" label="初始密码">
<el-input v-model="dialogForm.initAdminPassword" type="password" placeholder="初始密码" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSave">保存</el-button>
</template>
</el-dialog>
</div>
<!-- 租户详情 / 编辑抽屉 -->
<el-drawer v-model="drawerVisible" :title="drawerTitle" size="480px">
<el-form :model="drawerForm" label-width="120px" v-if="currentRow">
<el-form-item label="租户编码">
<el-input v-model="drawerForm.tenantCode" disabled />
</el-form-item>
<el-form-item label="租户名称">
<el-input v-model="drawerForm.tenantName" placeholder="租户名称" />
</el-form-item>
<el-form-item label="入站 Webhook">
<el-input v-model="drawerForm.inboundWebhookUrl" placeholder="https://example.com/webhook" />
</el-form-item>
<el-form-item label="状态">
<el-switch
v-model="drawerForm.status"
:active-value="1"
:inactive-value="0"
active-text="启用"
inactive-text="禁用"
/>
</el-form-item>
</el-form>
<template #footer>
<div class="drawer-footer">
<el-button type="danger" plain @click="handleDrawerDelete">删除</el-button>
<div class="drawer-actions">
<el-button @click="drawerVisible = false">取消</el-button>
<el-button type="primary" @click="handleDrawerSave">保存</el-button>
</div>
</div>
</template>
</el-drawer>
</PageCard>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { createTenant, deleteTenant, listTenants, updateTenant } from '@/api/account'
import PageCard from '@/components/PageCard.vue'
const tenants = ref<any[]>([])
const dialogVisible = ref(false)
@@ -91,6 +140,53 @@ const dialogForm = reactive({
initAdminPassword: '',
})
const searchText = ref('')
const drawerVisible = ref(false)
const currentRow = ref<any>(null)
const page = ref(1)
const pageSize = ref(10)
const drawerForm = reactive({
tenantCode: '',
tenantName: '',
inboundWebhookUrl: '',
status: 1,
})
const filteredTenants = computed(() => {
if (!searchText.value) return tenants.value
const kw = searchText.value.toLowerCase()
return tenants.value.filter(
(t) =>
(t.tenantCode || '').toLowerCase().includes(kw) ||
(t.tenantName || '').toLowerCase().includes(kw)
)
})
const totalCount = computed(() => filteredTenants.value.length)
const pagedTenants = computed(() => {
const start = (page.value - 1) * pageSize.value
return filteredTenants.value.slice(start, start + pageSize.value)
})
const drawerTitle = computed(() =>
currentRow.value ? `编辑租户 - ${currentRow.value.tenantName}` : '租户详情'
)
function handleSearch() {
page.value = 1
}
function handlePageChange(val: number) {
page.value = val
}
function handleSizeChange(val: number) {
pageSize.value = val
page.value = 1
}
function resetDialogForm() {
dialogForm.tenantCode = ''
dialogForm.tenantName = ''
@@ -108,6 +204,20 @@ function fillDialogForm(row: any) {
dialogForm.status = row.status ?? 1
}
function resetDrawerForm() {
drawerForm.tenantCode = ''
drawerForm.tenantName = ''
drawerForm.inboundWebhookUrl = ''
drawerForm.status = 1
}
function fillDrawerForm(row: any) {
drawerForm.tenantCode = row.tenantCode ?? ''
drawerForm.tenantName = row.tenantName ?? ''
drawerForm.inboundWebhookUrl = row.inboundWebhookUrl ?? ''
drawerForm.status = row.status ?? 1
}
function openCreateDialog() {
isEdit.value = false
editingId.value = null
@@ -122,6 +232,12 @@ function openEditDialog(row: any) {
dialogVisible.value = true
}
function openEditDrawer(row: any) {
currentRow.value = row
fillDrawerForm(row)
drawerVisible.value = true
}
async function loadTenants() {
try {
const res: any = await listTenants()
@@ -162,6 +278,7 @@ async function handleSave() {
ElMessage.success('创建成功')
}
dialogVisible.value = false
page.value = 1
await loadTenants()
} catch (e: any) {
ElMessage.error(e.message)
@@ -200,11 +317,40 @@ async function handleDelete(row: any) {
}
}
async function handleDrawerSave() {
if (!currentRow.value) return
try {
await updateTenant(currentRow.value.id, {
tenantName: drawerForm.tenantName,
inboundWebhookUrl: drawerForm.inboundWebhookUrl,
status: drawerForm.status,
})
ElMessage.success('保存成功')
drawerVisible.value = false
await loadTenants()
} catch (e: any) {
ElMessage.error(e.message)
}
}
async function handleDrawerDelete() {
if (!currentRow.value) return
await handleDelete(currentRow.value)
drawerVisible.value = false
}
onMounted(loadTenants)
</script>
<style scoped>
.toolbar {
margin-bottom: 16px;
.drawer-footer {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
.drawer-actions {
display: flex;
gap: 12px;
}
</style>

View File

@@ -1,11 +1,11 @@
<template>
<div>
<div class="toolbar">
<PageCard>
<template #toolbar-left>
<el-select
v-model="selectedTenant"
placeholder="选择租户"
clearable
style="width: 200px; margin-right: 12px"
style="width: 200px"
:disabled="!isPlatformAdmin"
@change="loadUsers"
>
@@ -16,46 +16,63 @@
:value="t.tenantCode"
/>
</el-select>
<el-button type="primary" @click="openCreateDialog">创建用户</el-button>
</div>
<el-input
v-model="searchText"
placeholder="搜索用户名 / 姓名 / 手机号"
clearable
style="width: 240px"
@keyup.enter="handleSearch"
/>
</template>
<template #toolbar-right>
<el-button type="primary" @click="openCreateDialog">新增用户</el-button>
</template>
<el-table :data="users" border>
<el-table :data="pagedUsers" @row-click="openEditDrawer" class="sino-table">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="tenantCode" label="租户编码" />
<el-table-column prop="username" label="用户名" />
<el-table-column prop="realName" label="真实姓名" />
<el-table-column prop="phone" label="手机号" />
<el-table-column prop="roleCodes" label="角色">
<el-table-column prop="tenantCode" label="租户编码" min-width="140" />
<el-table-column prop="username" label="用户名" min-width="140" />
<el-table-column prop="realName" label="真实姓名" min-width="120" />
<el-table-column prop="phone" label="手机号" min-width="140" />
<el-table-column prop="roleCodes" label="角色" min-width="180">
<template #default="{ row }">
{{ formatRoles(row.roleCodes) }}
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<el-table-column prop="status" label="状态" width="120">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'info'">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="260">
<template #default="{ row }">
<el-button size="small" @click="openEditDialog(row)">编辑</el-button>
<el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
<el-switch
v-model="row.status"
:active-value="1"
:inactive-value="0"
style="margin: 0 12px"
@change="(val: any) => handleStatusChange(row, val)"
/>
<div class="status-cell" @click.stop>
<el-switch
v-model="row.status"
:active-value="1"
:inactive-value="0"
inline-prompt
active-text="启用"
inactive-text="禁用"
@change="(val: any) => handleStatusChange(row, val)"
/>
</div>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑用户' : '创建用户'" width="600px">
<template #pagination>
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:total="totalCount"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next"
@size-change="handleSizeChange"
@current-change="handlePageChange"
/>
</template>
<!-- 新增用户弹窗 -->
<el-dialog v-model="dialogVisible" title="新增用户" width="600px">
<el-form :model="dialogForm" label-width="100px">
<el-form-item label="租户">
<el-select v-model="dialogForm.tenantCode" :disabled="isEdit" style="width: 100%">
<el-select v-model="dialogForm.tenantCode" style="width: 100%">
<el-option
v-for="t in tenants"
:key="t.id"
@@ -65,9 +82,9 @@
</el-select>
</el-form-item>
<el-form-item label="用户名">
<el-input v-model="dialogForm.username" :disabled="isEdit" placeholder="用户名" />
<el-input v-model="dialogForm.username" placeholder="用户名" />
</el-form-item>
<el-form-item v-if="!isEdit" label="密码">
<el-form-item label="密码">
<el-input v-model="dialogForm.password" type="password" placeholder="密码" />
</el-form-item>
<el-form-item label="真实姓名">
@@ -101,7 +118,60 @@
<el-button type="primary" @click="handleSave">保存</el-button>
</template>
</el-dialog>
</div>
<!-- 用户详情 / 编辑抽屉 -->
<el-drawer v-model="drawerVisible" :title="drawerTitle" size="480px">
<el-form :model="drawerForm" label-width="100px" v-if="currentRow">
<el-form-item label="租户">
<el-select v-model="drawerForm.tenantCode" disabled style="width: 100%">
<el-option
v-for="t in tenants"
:key="t.id"
:label="`${t.tenantName}${t.tenantCode}`"
:value="t.tenantCode"
/>
</el-select>
</el-form-item>
<el-form-item label="用户名">
<el-input v-model="drawerForm.username" disabled />
</el-form-item>
<el-form-item label="真实姓名">
<el-input v-model="drawerForm.realName" placeholder="真实姓名" />
</el-form-item>
<el-form-item label="手机号">
<el-input v-model="drawerForm.phone" placeholder="手机号" />
</el-form-item>
<el-form-item label="角色">
<el-select v-model="drawerForm.roleCodes" multiple placeholder="选择角色" style="width: 100%">
<el-option
v-for="r in roles"
:key="r.id"
:label="r.roleName"
:value="r.roleCode"
/>
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-switch
v-model="drawerForm.status"
:active-value="1"
:inactive-value="0"
active-text="启用"
inactive-text="禁用"
/>
</el-form-item>
</el-form>
<template #footer>
<div class="drawer-footer">
<el-button type="danger" plain @click="handleDrawerDelete">删除</el-button>
<div class="drawer-actions">
<el-button @click="drawerVisible = false">取消</el-button>
<el-button type="primary" @click="handleDrawerSave">保存</el-button>
</div>
</div>
</template>
</el-drawer>
</PageCard>
</template>
<script setup lang="ts">
@@ -116,6 +186,7 @@ import {
listUsers,
updateUser,
} from '@/api/account'
import PageCard from '@/components/PageCard.vue'
const authStore = useAuthStore()
const isPlatformAdmin = computed(() => authStore.roleCodes.includes('platform_admin'))
@@ -139,6 +210,59 @@ const dialogForm = reactive({
status: 1,
})
const searchText = ref('')
const drawerVisible = ref(false)
const currentRow = ref<any>(null)
const page = ref(1)
const pageSize = ref(10)
const drawerForm = reactive({
tenantCode: '',
username: '',
realName: '',
phone: '',
roleCodes: [] as string[],
status: 1,
})
const filteredUsers = computed(() => {
let list = users.value
if (searchText.value) {
const kw = searchText.value.toLowerCase()
list = list.filter(
(u) =>
(u.username || '').toLowerCase().includes(kw) ||
(u.realName || '').toLowerCase().includes(kw) ||
(u.phone || '').toLowerCase().includes(kw)
)
}
return list
})
const totalCount = computed(() => filteredUsers.value.length)
const pagedUsers = computed(() => {
const start = (page.value - 1) * pageSize.value
return filteredUsers.value.slice(start, start + pageSize.value)
})
const drawerTitle = computed(() =>
currentRow.value ? `编辑用户 - ${currentRow.value.username}` : '用户详情'
)
function handleSearch() {
page.value = 1
}
function handlePageChange(val: number) {
page.value = val
}
function handleSizeChange(val: number) {
pageSize.value = val
page.value = 1
}
function resetDialogForm() {
dialogForm.tenantCode = isPlatformAdmin.value ? '' : (authStore.tenantCode || '')
dialogForm.username = ''
@@ -159,6 +283,24 @@ function fillDialogForm(row: any) {
dialogForm.status = row.status ?? 1
}
function resetDrawerForm() {
drawerForm.tenantCode = ''
drawerForm.username = ''
drawerForm.realName = ''
drawerForm.phone = ''
drawerForm.roleCodes = []
drawerForm.status = 1
}
function fillDrawerForm(row: any) {
drawerForm.tenantCode = row.tenantCode ?? ''
drawerForm.username = row.username ?? ''
drawerForm.realName = row.realName ?? ''
drawerForm.phone = row.phone ?? ''
drawerForm.roleCodes = row.roleCodes || []
drawerForm.status = row.status ?? 1
}
function openCreateDialog() {
isEdit.value = false
editingId.value = null
@@ -173,6 +315,12 @@ function openEditDialog(row: any) {
dialogVisible.value = true
}
function openEditDrawer(row: any) {
currentRow.value = row
fillDrawerForm(row)
drawerVisible.value = true
}
function formatRoles(codes: string[] | undefined) {
if (!codes || codes.length === 0) return '-'
return codes
@@ -205,6 +353,7 @@ async function loadUsers() {
try {
const res: any = await listUsers(selectedTenant.value || undefined)
users.value = res.data || []
page.value = 1
} catch (e: any) {
ElMessage.error(e.message)
}
@@ -282,6 +431,29 @@ async function handleDelete(row: any) {
}
}
async function handleDrawerSave() {
if (!currentRow.value) return
try {
await updateUser(currentRow.value.id, {
realName: drawerForm.realName,
phone: drawerForm.phone,
status: drawerForm.status,
roleCodes: drawerForm.roleCodes,
})
ElMessage.success('保存成功')
drawerVisible.value = false
await loadUsers()
} catch (e: any) {
ElMessage.error(e.message)
}
}
async function handleDrawerDelete() {
if (!currentRow.value) return
await handleDelete(currentRow.value)
drawerVisible.value = false
}
onMounted(() => {
loadTenants()
loadRoles()
@@ -290,7 +462,14 @@ onMounted(() => {
</script>
<style scoped>
.toolbar {
margin-bottom: 16px;
.drawer-footer {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
.drawer-actions {
display: flex;
gap: 12px;
}
</style>

View File

@@ -1 +1 @@
{"root":["./src/main.ts","./src/vite-env.d.ts","./src/api/account.ts","./src/api/channel.ts","./src/api/channelaccount.ts","./src/api/channelsubject.ts","./src/api/client.ts","./src/api/crm.ts","./src/api/dashboard.ts","./src/api/flow.ts","./src/api/inbound.ts","./src/api/inboundflow.ts","./src/api/inboundwebhook.ts","./src/api/ivrflow.ts","./src/api/send.ts","./src/api/sendpolicy.ts","./src/api/sendrecord.ts","./src/router/index.ts","./src/stores/auth.ts","./src/app.vue","./src/components/layout.vue","./src/views/channelaccount.vue","./src/views/channelsubject.vue","./src/views/conversation.vue","./src/views/dashboard.vue","./src/views/flowtrace.vue","./src/views/inboundflow.vue","./src/views/inboundwebhook.vue","./src/views/ivrflow.vue","./src/views/login.vue","./src/views/messagetest.vue","./src/views/person.vue","./src/views/sendpolicy.vue","./src/views/sendrecord.vue","./src/views/tag.vue","./src/views/tenantmanagement.vue","./src/views/usermanagement.vue"],"version":"6.0.3"}
{"root":["./src/main.ts","./src/vite-env.d.ts","./src/api/account.ts","./src/api/channel.ts","./src/api/channelaccount.ts","./src/api/channelsubject.ts","./src/api/client.ts","./src/api/crm.ts","./src/api/dashboard.ts","./src/api/flow.ts","./src/api/inbound.ts","./src/api/inboundflow.ts","./src/api/inboundwebhook.ts","./src/api/ivrflow.ts","./src/api/send.ts","./src/api/sendpolicy.ts","./src/api/sendrecord.ts","./src/api/synctask.ts","./src/router/index.ts","./src/stores/auth.ts","./src/app.vue","./src/components/layout.vue","./src/components/pagecard.vue","./src/views/channelaccount.vue","./src/views/channelapp.vue","./src/views/channelsubject.vue","./src/views/conversation.vue","./src/views/dashboard.vue","./src/views/flowtrace.vue","./src/views/inboundflow.vue","./src/views/inboundwebhook.vue","./src/views/ivrflow.vue","./src/views/login.vue","./src/views/messagetest.vue","./src/views/messages.vue","./src/views/person.vue","./src/views/sendpolicy.vue","./src/views/sendrecord.vue","./src/views/tag.vue","./src/views/tenantmanagement.vue","./src/views/usermanagement.vue"],"version":"6.0.3"}

View File

@@ -0,0 +1,2 @@
--toolchains
/Users/marsal/Projects/sino-project/sino-mci/backend/.mvn/toolchains.xml

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<toolchains>
<toolchain>
<type>jdk</type>
<provides>
<version>21</version>
<vendor>zulu</vendor>
</provides>
<configuration>
<jdkHome>/Library/Java/JavaVirtualMachines/zulu-21.jdk/Contents/Home</jdkHome>
</configuration>
</toolchain>
</toolchains>

View File

@@ -1,5 +1,6 @@
package com.sino.mci.channel.wecom.sync;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import java.util.List;
@@ -10,10 +11,11 @@ import java.util.List;
* <p>不发起真实企微 API 调用,仅返回固定样例数据,供开发与测试使用。</p>
*/
@Component
@ConditionalOnProperty(name = "mci.wecom.sync.mock", havingValue = "true")
public class MockWeComSyncClient implements WeComSyncClient {
@Override
public List<WeComContact> syncContacts(WeComSyncConfig config) {
public List<WeComContact> syncEnterpriseContacts(WeComSyncConfig config) {
return List.of(
WeComContact.builder()
.userId("user001")
@@ -21,7 +23,13 @@ public class MockWeComSyncClient implements WeComSyncClient {
.mobile("13800138001")
.type(WeComContactType.INTERNAL)
.departmentIds(List.of(1L, 2L))
.build(),
.build()
);
}
@Override
public List<WeComContact> syncAccountContacts(WeComSyncConfig config, String userId) {
return List.of(
WeComContact.builder()
.userId("wmexternal001")
.name("客户李四")
@@ -32,15 +40,15 @@ public class MockWeComSyncClient implements WeComSyncClient {
}
@Override
public List<WeComConversation> syncConversations(WeComSyncConfig config) {
public List<WeComConversation> syncAccountConversations(WeComSyncConfig config, String userId) {
return List.of(
WeComConversation.builder()
.chatId("chat001")
.chatName("测试客户群")
.ownerUserId("user001")
.ownerUserId(userId)
.memberList(List.of(
WeComConversationMember.builder()
.userId("user001")
.userId(userId)
.name("张三")
.build(),
WeComConversationMember.builder()

View File

@@ -0,0 +1,258 @@
package com.sino.mci.channel.wecom.sync;
import com.sino.mci.channel.wecom.support.WeComServiceFactory;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import me.chanjar.weixin.common.error.WxErrorException;
import me.chanjar.weixin.cp.api.WxCpService;
import me.chanjar.weixin.cp.bean.WxCpDepart;
import me.chanjar.weixin.cp.bean.WxCpUser;
import me.chanjar.weixin.cp.bean.external.WxCpUserExternalGroupChatInfo;
import me.chanjar.weixin.cp.bean.external.WxCpUserExternalGroupChatList;
import me.chanjar.weixin.cp.bean.external.contact.WxCpExternalContactInfo;
import org.apache.commons.lang3.StringUtils;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
/**
* 企业微信通讯录与群聊真实同步客户端。
*
* <p>基于 {@link WxCpService} 调用企微开放接口,拉取企业内部成员、外部联系人及客户群。</p>
*/
@Slf4j
@Component
@RequiredArgsConstructor
@ConditionalOnProperty(name = "mci.wecom.sync.mock", havingValue = "false", matchIfMissing = true)
public class RealWeComSyncClient implements WeComSyncClient {
private final WeComServiceFactory serviceFactory;
@Override
public List<WeComContact> syncEnterpriseContacts(WeComSyncConfig config) {
WxCpService service = resolveService(config);
if (service == null) {
log.warn("[WeComSync] 无法创建 WxCpService跳过企业通讯录同步");
return Collections.emptyList();
}
List<WeComContact> contacts = new ArrayList<>();
contacts.addAll(syncInternalUsers(service, config));
contacts.addAll(syncExternalContacts(service, config));
log.info("[WeComSync] 同步企业通讯录完成,共 {} 条", contacts.size());
return contacts;
}
@Override
public List<WeComContact> syncAccountContacts(WeComSyncConfig config, String userId) {
WxCpService service = resolveService(config);
if (service == null) {
log.warn("[WeComSync] 无法创建 WxCpService跳过账号客户同步");
return Collections.emptyList();
}
List<WeComContact> contacts = syncExternalContactsForUser(service, config, userId);
log.info("[WeComSync] 同步账号 {} 的客户完成,共 {} 条", userId, contacts.size());
return contacts;
}
@Override
public List<WeComConversation> syncAccountConversations(WeComSyncConfig config, String userId) {
WxCpService service = resolveService(config);
if (service == null) {
log.warn("[WeComSync] 无法创建 WxCpService跳过账号群聊同步");
return Collections.emptyList();
}
List<WeComConversation> conversations = new ArrayList<>();
try {
String cursor = "";
do {
WxCpUserExternalGroupChatList result = service.getExternalContactService()
.listGroupChat(1000, cursor, 0, new String[]{userId});
if (result == null || result.getGroupChatList() == null) {
break;
}
for (WxCpUserExternalGroupChatList.ChatStatus chat : result.getGroupChatList()) {
WeComConversation conversation = fetchGroupChatDetail(service, chat.getChatId());
if (conversation != null) {
conversations.add(conversation);
}
}
cursor = result.getNextCursor();
} while (cursor != null && !cursor.isEmpty());
} catch (WxErrorException e) {
log.warn("[WeComSync] 拉取账号 {} 的客户群列表失败,可能应用缺少客户联系权限: {}", userId, e.getMessage());
} catch (Exception e) {
log.warn("[WeComSync] 拉取账号 {} 的客户群列表异常", userId, e);
}
log.info("[WeComSync] 同步账号 {} 的群聊完成,共 {} 条", userId, conversations.size());
return conversations;
}
private WxCpService resolveService(WeComSyncConfig config) {
if (config == null || config.getCorpId() == null || config.getAgentId() == null || config.getSecret() == null) {
return null;
}
try {
return serviceFactory.getOrCreateService(
config.getCorpId(), Integer.valueOf(config.getAgentId()), config.getSecret());
} catch (Exception e) {
log.warn("[WeComSync] 创建 WxCpService 失败", e);
return null;
}
}
private List<WeComContact> syncInternalUsers(WxCpService service, WeComSyncConfig config) {
List<WeComContact> contacts = new ArrayList<>();
try {
List<WxCpDepart> departments = service.getDepartmentService().list(null);
if (departments == null || departments.isEmpty()) {
return contacts;
}
for (WxCpDepart dept : departments) {
try {
List<WxCpUser> users = service.getUserService().listByDepartment(dept.getId(), true, 0);
if (users == null) {
continue;
}
for (WxCpUser user : users) {
contacts.add(WeComContact.builder()
.userId(user.getUserId())
.name(user.getName())
.mobile(user.getMobile())
.type(WeComContactType.INTERNAL)
.departmentIds(user.getDepartIds() == null
? Collections.emptyList()
: Arrays.stream(user.getDepartIds()).collect(Collectors.toList()))
.build());
}
} catch (WxErrorException e) {
log.warn("[WeComSync] 获取部门 {} 成员失败: {}", dept.getId(), e.getMessage());
}
}
} catch (WxErrorException e) {
log.warn("[WeComSync] 拉取部门列表失败,可能应用缺少通讯录权限: {}", e.getMessage());
} catch (Exception e) {
log.warn("[WeComSync] 拉取内部成员异常", e);
}
return contacts;
}
private List<WeComContact> syncExternalContacts(WxCpService service, WeComSyncConfig config) {
List<WeComContact> contacts = new ArrayList<>();
try {
List<String> followUsers = service.getExternalContactService().listFollowers();
if (followUsers == null || followUsers.isEmpty()) {
return contacts;
}
for (String userId : followUsers) {
contacts.addAll(syncExternalContactsForUser(service, config, userId));
}
} catch (WxErrorException e) {
log.warn("[WeComSync] 拉取客户联系成员失败,可能应用缺少客户联系权限: {}", e.getMessage());
} catch (Exception e) {
log.warn("[WeComSync] 拉取外部联系人异常", e);
}
return contacts;
}
private List<WeComContact> syncExternalContactsForUser(WxCpService service, WeComSyncConfig config, String userId) {
List<WeComContact> contacts = new ArrayList<>();
try {
List<String> externalUserIds = service.getExternalContactService().listExternalContacts(userId);
if (externalUserIds == null) {
return contacts;
}
for (String externalUserId : externalUserIds) {
try {
WxCpExternalContactInfo info = service.getExternalContactService()
.getContactDetail(externalUserId, null);
if (info == null || info.getExternalContact() == null) {
continue;
}
contacts.add(WeComContact.builder()
.userId(externalUserId)
.name(info.getExternalContact().getName())
.mobile(null)
.type(WeComContactType.EXTERNAL)
.departmentIds(Collections.emptyList())
.build());
} catch (WxErrorException e) {
log.warn("[WeComSync] 获取外部联系人 {} 详情失败: {}", externalUserId, e.getMessage());
}
}
} catch (WxErrorException e) {
log.warn("[WeComSync] 获取成员 {} 的客户列表失败: {}", userId, e.getMessage());
}
return contacts;
}
private WeComConversation fetchGroupChatDetail(WxCpService service, String chatId) {
try {
WxCpUserExternalGroupChatInfo detail = service.getExternalContactService().getGroupChat(chatId, 0);
if (detail == null || detail.getGroupChat() == null) {
return null;
}
WxCpUserExternalGroupChatInfo.GroupChat groupChat = detail.getGroupChat();
List<WeComConversationMember> members = new ArrayList<>();
if (groupChat.getMemberList() != null) {
for (WxCpUserExternalGroupChatInfo.GroupMember member : groupChat.getMemberList()) {
members.add(WeComConversationMember.builder()
.userId(member.getUserId())
.name(resolveMemberName(service, member))
.build());
}
}
return WeComConversation.builder()
.chatId(groupChat.getChatId())
.chatName(groupChat.getName())
.ownerUserId(groupChat.getOwner())
.memberList(members)
.build();
} catch (WxErrorException e) {
log.warn("[WeComSync] 获取群聊 {} 详情失败: {}", chatId, e.getMessage());
return null;
} catch (Exception e) {
log.warn("[WeComSync] 获取群聊 {} 详情异常", chatId, e);
return null;
}
}
private String resolveMemberName(WxCpService service, WxCpUserExternalGroupChatInfo.GroupMember member) {
if (StringUtils.isNotBlank(member.getName())) {
return member.getName();
}
String userId = member.getUserId();
if (StringUtils.isBlank(userId)) {
return null;
}
// 外部联系人:通过客户详情接口补全名称
if (userId.startsWith("wm")) {
try {
WxCpExternalContactInfo info = service.getExternalContactService().getContactDetail(userId, null);
if (info != null && info.getExternalContact() != null
&& StringUtils.isNotBlank(info.getExternalContact().getName())) {
return info.getExternalContact().getName();
}
} catch (WxErrorException e) {
log.debug("[WeComSync] 获取外部联系人 {} 名称失败: {}", userId, e.getMessage());
}
}
// 内部成员:通过通讯录接口补全名称
try {
WxCpUser user = service.getUserService().getById(userId);
if (user != null && StringUtils.isNotBlank(user.getName())) {
return user.getName();
}
} catch (WxErrorException e) {
log.debug("[WeComSync] 获取内部成员 {} 名称失败: {}", userId, e.getMessage());
}
return userId;
}
}

View File

@@ -12,18 +12,28 @@ import java.util.List;
public interface WeComSyncClient {
/**
* 同步联系人(企业内部成员 + 外部联系人)。
* 同步企业通讯录(部门 + 内部成员)。
*
* @param config 企微配置
* @return 联系人列表
*/
List<WeComContact> syncContacts(WeComSyncConfig config);
List<WeComContact> syncEnterpriseContacts(WeComSyncConfig config);
/**
* 同步群聊(客户群)。
* 同步某个成员的客户(外部联系人)。
*
* @param config 企微配置
* @param userId 成员 userId
* @return 联系人列表
*/
List<WeComContact> syncAccountContacts(WeComSyncConfig config, String userId);
/**
* 同步某个成员的客户群。
*
* @param config 企微配置
* @param userId 成员 userId
* @return 群聊列表
*/
List<WeComConversation> syncConversations(WeComSyncConfig config);
List<WeComConversation> syncAccountConversations(WeComSyncConfig config, String userId);
}

View File

@@ -70,6 +70,11 @@
<artifactId>sa-token-spring-boot3-starter</artifactId>
<version>1.39.0</version>
</dependency>
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-redis-jackson</artifactId>
<version>1.39.0</version>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai-spring-boot-starter</artifactId>

View File

@@ -4,9 +4,9 @@ import com.sino.mci.admin.security.AuthContext;
import com.sino.mci.channel.dto.ChannelAccountInitRequest;
import com.sino.mci.channel.dto.CreateChannelAccountRequest;
import com.sino.mci.channel.dto.HeartbeatRequest;
import com.sino.mci.channel.dto.SyncResult;
import com.sino.mci.channel.dto.UpdateStatusRequest;
import com.sino.mci.channel.entity.ChannelAccount;
import com.sino.mci.channel.entity.SyncTask;
import com.sino.mci.channel.service.ChannelAccountService;
import com.sino.mci.shared.web.ApiResponse;
import jakarta.validation.Valid;
@@ -78,16 +78,16 @@ public class ChannelAccountController {
}
@PostMapping("/{account_id}/sync-contacts")
public ApiResponse<SyncResult> syncContacts(
public ApiResponse<SyncTask> syncContacts(
@RequestHeader("X-Tenant-Code") String tenantCode,
@PathVariable("account_id") Long accountId) {
return ApiResponse.ok(accountService.syncContacts(accountId));
return ApiResponse.ok(accountService.submitContactsSyncTask(accountId));
}
@PostMapping("/{account_id}/sync-conversations")
public ApiResponse<SyncResult> syncConversations(
public ApiResponse<SyncTask> syncConversations(
@RequestHeader("X-Tenant-Code") String tenantCode,
@PathVariable("account_id") Long accountId) {
return ApiResponse.ok(accountService.syncConversations(accountId));
return ApiResponse.ok(accountService.submitConversationsSyncTask(accountId));
}
}

View File

@@ -54,4 +54,11 @@ public class ChannelSubjectController {
public ApiResponse<List<ChannelSubject>> listSubjects() {
return ApiResponse.ok(subjectService.listSubjects(AuthContext.currentTenantCode()));
}
@PostMapping("/{id}/sync-contacts")
public ApiResponse<com.sino.mci.channel.entity.SyncTask> syncEnterpriseContacts(
@RequestHeader("X-Tenant-Code") String tenantCode,
@PathVariable("id") Long id) {
return ApiResponse.ok(subjectService.submitEnterpriseContactsSyncTask(id, tenantCode));
}
}

View File

@@ -0,0 +1,22 @@
package com.sino.mci.channel.controller;
import com.sino.mci.channel.entity.SyncTask;
import com.sino.mci.channel.service.SyncTaskService;
import com.sino.mci.shared.web.ApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/v1/sync-task")
@RequiredArgsConstructor
public class SyncTaskController {
private final SyncTaskService syncTaskService;
@GetMapping("/{id}")
public ApiResponse<SyncTask> getTask(
@RequestHeader("X-Tenant-Code") String tenantCode,
@PathVariable("id") Long id) {
return ApiResponse.ok(syncTaskService.getTask(id, tenantCode));
}
}

View File

@@ -85,7 +85,7 @@ public class WeComCallbackController {
command.setChannelSubjectId(subject.getId());
command.setTenantCode(subject.getTenantCode());
command.setCorpId(subject.getCorpId());
command.setSecret(subject.getCorpSecret());
command.setSecret(subject.getAgentSecret());
command.setPrivateKey(subject.getSessionArchivePrivateKey());
rabbitTemplate.convertAndSend(RabbitConfig.SESSION_ARCHIVE_EXCHANGE,

View File

@@ -16,7 +16,6 @@ public class CreateChannelSubjectRequest {
private String subjectName;
private String corpId;
private String corpSecret;
private String agentId;
private String agentSecret;
private Integer sessionArchiveEnabled;

View File

@@ -15,7 +15,6 @@ public class ChannelSubject extends BaseEntity {
private String subjectCode;
private String subjectName;
private String corpId;
private String corpSecret;
private String agentId;
private String agentSecret;
private Integer sessionArchiveEnabled;

View File

@@ -0,0 +1,30 @@
package com.sino.mci.channel.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.sino.mci.shared.entity.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("mci_sync_task")
public class SyncTask extends BaseEntity {
public static final int STATUS_PENDING = 0;
public static final int STATUS_RUNNING = 1;
public static final int STATUS_SUCCESS = 2;
public static final int STATUS_FAILED = 3;
private String tenantCode;
private String taskType;
private String channelType;
private Long targetId;
private String targetCode;
private Integer taskStatus;
private Integer syncedCount;
private Integer failedCount;
private String errorMessage;
private Integer retryCount;
private java.time.LocalDateTime finishTime;
private Integer deleted;
}

View File

@@ -0,0 +1,9 @@
package com.sino.mci.channel.repository;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.sino.mci.channel.entity.SyncTask;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface SyncTaskMapper extends BaseMapper<SyncTask> {
}

View File

@@ -13,27 +13,12 @@ import com.sino.mci.channel.dto.HeartbeatRequest;
import com.sino.mci.channel.dto.SyncResult;
import com.sino.mci.channel.dto.UpdateStatusRequest;
import com.sino.mci.channel.entity.ChannelAccount;
import com.sino.mci.channel.entity.ChannelAccountConversation;
import com.sino.mci.channel.entity.ChannelSubject;
import com.sino.mci.channel.entity.Conversation;
import com.sino.mci.channel.entity.ConversationParticipant;
import com.sino.mci.channel.repository.ChannelAccountConversationMapper;
import com.sino.mci.channel.entity.SyncTask;
import com.sino.mci.channel.repository.ChannelAccountMapper;
import com.sino.mci.channel.repository.ChannelSubjectMapper;
import com.sino.mci.channel.repository.ConversationMapper;
import com.sino.mci.channel.repository.ConversationParticipantMapper;
import com.sino.mci.channel.wecom.sync.WeComContact;
import com.sino.mci.channel.wecom.sync.WeComContactType;
import com.sino.mci.channel.wecom.sync.WeComConversation;
import com.sino.mci.channel.wecom.sync.WeComConversationMember;
import com.sino.mci.channel.wecom.sync.WeComSyncClient;
import com.sino.mci.channel.wecom.sync.WeComSyncConfig;
import com.sino.mci.crm.entity.ChannelIdentity;
import com.sino.mci.crm.entity.Person;
import com.sino.mci.crm.repository.ChannelIdentityMapper;
import com.sino.mci.crm.repository.PersonMapper;
import com.sino.mci.exception.BusinessException;
import com.sino.mci.shared.util.JsonUtils;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@@ -51,16 +36,6 @@ public class ChannelAccountService {
private static final String CHANNEL_WECOM = ChannelType.WECOM.name().toLowerCase();
private static final int BINDING_SEND_MASTER = 1;
private static final int BINDING_RECEIVE_MASTER = 3;
private static final int PERSON_TYPE_INTERNAL = 1;
private static final int PERSON_TYPE_CUSTOMER = 3;
private static final int CONVERSATION_TYPE_GROUP = 2;
private static final int PARTICIPANT_TYPE_MEMBER = 1;
private static final int PARTICIPANT_TYPE_OWNER = 2;
private static final int ONLINE_STATUS_OFFLINE = 0;
private static final int ONLINE_STATUS_ONLINE = 1;
private static final int ACCOUNT_STATUS_ENABLED = 1;
@@ -68,13 +43,10 @@ public class ChannelAccountService {
private final ChannelAccountMapper accountMapper;
private final ChannelSubjectMapper subjectMapper;
private final PersonMapper personMapper;
private final ChannelIdentityMapper channelIdentityMapper;
private final ConversationMapper conversationMapper;
private final ConversationParticipantMapper conversationParticipantMapper;
private final ChannelAccountConversationMapper accountConversationMapper;
private final WeComSyncClient weComSyncClient;
private final ChannelAdapterRegistry adapterRegistry;
private final SyncTaskService syncTaskService;
private final SyncTaskExecutor syncTaskExecutor;
public List<ChannelAccount> listAccounts(String tenantCode) {
return accountMapper.selectList(
@@ -119,7 +91,7 @@ public class ChannelAccountService {
account.setAccountName(request.getAccountName());
account.setSessionArchiveEnabled(request.getSessionArchiveEnabled() != null ? request.getSessionArchiveEnabled() : 0);
account.setServerHost(request.getServerHost());
account.setExtInfo(request.getExtInfo());
account.setExtInfo(normalizeExtInfo(request.getExtInfo()));
account.setLoginStatus(0);
account.setOnlineStatus(0);
account.setRiskLevel(0);
@@ -157,11 +129,15 @@ public class ChannelAccountService {
account.setAccountName(request.getAccountName());
account.setSessionArchiveEnabled(request.getSessionArchiveEnabled() != null ? request.getSessionArchiveEnabled() : 0);
account.setServerHost(request.getServerHost());
account.setExtInfo(request.getExtInfo());
account.setExtInfo(normalizeExtInfo(request.getExtInfo()));
accountMapper.updateById(account);
return account;
}
private String normalizeExtInfo(String extInfo) {
return (extInfo == null || extInfo.isBlank()) ? null : extInfo;
}
@Transactional
public void deleteAccount(Long id, String tenantCode) {
ChannelAccount account = getAccount(id, tenantCode);
@@ -268,8 +244,8 @@ public class ChannelAccountService {
if (subject.getCorpId() != null) {
config.putIfAbsent("corp_id", subject.getCorpId());
}
if (subject.getCorpSecret() != null) {
config.putIfAbsent("secret", subject.getCorpSecret());
if (subject.getAgentSecret() != null) {
config.putIfAbsent("secret", subject.getAgentSecret());
}
if (subject.getAgentId() != null) {
config.putIfAbsent("agent_id", subject.getAgentId());
@@ -322,32 +298,20 @@ public class ChannelAccountService {
return result;
}
@Transactional
public SyncResult syncContacts(Long accountId) {
public SyncTask submitContactsSyncTask(Long accountId) {
ChannelAccount account = getWeComAccount(accountId);
ChannelSubject subject = getSubject(account);
WeComSyncConfig config = buildSyncConfig(subject);
List<WeComContact> contacts = weComSyncClient.syncContacts(config);
for (WeComContact contact : contacts) {
upsertContact(account.getTenantCode(), account.getSubjectId(), contact);
}
return SyncResult.builder().syncedCount(contacts.size()).build();
SyncTask task = syncTaskService.createTask(
account.getTenantCode(), "account_contacts", account.getChannelType(), accountId, account.getAccountId());
syncTaskExecutor.executeAccountContactsSync(task.getId());
return task;
}
@Transactional
public SyncResult syncConversations(Long accountId) {
public SyncTask submitConversationsSyncTask(Long accountId) {
ChannelAccount account = getWeComAccount(accountId);
ChannelSubject subject = getSubject(account);
WeComSyncConfig config = buildSyncConfig(subject);
List<WeComConversation> conversations = weComSyncClient.syncConversations(config);
for (WeComConversation conversation : conversations) {
Conversation saved = upsertConversation(account, subject.getId(), conversation);
refreshParticipants(account.getTenantCode(), account.getSubjectId(), saved, conversation);
bindAccountAsDefault(accountId, saved.getId());
}
return SyncResult.builder().syncedCount(conversations.size()).build();
SyncTask task = syncTaskService.createTask(
account.getTenantCode(), "account_conversations", account.getChannelType(), accountId, account.getAccountId());
syncTaskExecutor.executeAccountConversationsSync(task.getId());
return task;
}
private ChannelAccount getWeComAccount(Long accountId) {
@@ -361,193 +325,4 @@ public class ChannelAccountService {
return account;
}
private ChannelSubject getSubject(ChannelAccount account) {
if (account.getSubjectId() == null) {
throw new BusinessException("企微账号未绑定渠道主体");
}
ChannelSubject subject = subjectMapper.selectById(account.getSubjectId());
if (subject == null) {
throw new BusinessException("渠道主体不存在: " + account.getSubjectId());
}
if (subject.getCorpId() == null || subject.getCorpSecret() == null) {
throw new BusinessException("渠道主体缺少 corp_id / corp_secret");
}
return subject;
}
private WeComSyncConfig buildSyncConfig(ChannelSubject subject) {
return WeComSyncConfig.builder()
.corpId(subject.getCorpId())
.agentId(subject.getAgentId())
.secret(subject.getCorpSecret())
.build();
}
private void upsertContact(String tenantCode, Long subjectId, WeComContact contact) {
ChannelIdentity identity = channelIdentityMapper.selectOne(
new LambdaQueryWrapper<ChannelIdentity>()
.eq(ChannelIdentity::getTenantCode, tenantCode)
.eq(ChannelIdentity::getChannelType, CHANNEL_WECOM)
.eq(ChannelIdentity::getChannelUserId, contact.getUserId()));
Person person;
if (identity != null) {
person = personMapper.selectById(identity.getPersonId());
if (person == null) {
log.warn("ChannelIdentity {} references missing person {}", identity.getId(), identity.getPersonId());
return;
}
Long originalInstitutionId = person.getInstitutionId();
Long originalSupplierId = person.getSupplierId();
Integer originalPersonType = person.getPersonType();
person.setPersonName(contact.getName());
person.setPhone(contact.getMobile());
person.setInstitutionId(originalInstitutionId);
person.setSupplierId(originalSupplierId);
person.setPersonType(originalPersonType);
personMapper.updateById(person);
identity.setChannelUserName(contact.getName());
identity.setExtInfo(JsonUtils.toJson(contact));
channelIdentityMapper.updateById(identity);
} else {
person = new Person();
person.setTenantCode(tenantCode);
person.setPersonCode(contact.getUserId());
person.setPersonName(contact.getName());
person.setPhone(contact.getMobile());
person.setPersonType(resolvePersonType(contact));
person.setStatus(1);
personMapper.insert(person);
ChannelIdentity newIdentity = new ChannelIdentity();
newIdentity.setTenantCode(tenantCode);
newIdentity.setPersonId(person.getId());
newIdentity.setChannelType(CHANNEL_WECOM);
newIdentity.setChannelUserId(contact.getUserId());
newIdentity.setChannelUserName(contact.getName());
newIdentity.setSubjectId(subjectId);
newIdentity.setStatus(1);
newIdentity.setExtInfo(JsonUtils.toJson(contact));
channelIdentityMapper.insert(newIdentity);
}
}
private Conversation upsertConversation(ChannelAccount account, Long subjectId, WeComConversation conv) {
Conversation conversation = conversationMapper.selectOne(
new LambdaQueryWrapper<Conversation>()
.eq(Conversation::getTenantCode, account.getTenantCode())
.eq(Conversation::getChannelType, CHANNEL_WECOM)
.eq(Conversation::getChannelConversationId, conv.getChatId()));
if (conversation != null) {
Long originalInstitutionId = conversation.getInstitutionId();
Long originalSupplierId = conversation.getSupplierId();
Long originalContractId = conversation.getContractId();
String originalChannelCode = conversation.getChannelCode();
conversation.setConversationName(conv.getChatName());
conversation.setOwnerAccountId(account.getId());
conversation.setSubjectId(subjectId);
conversation.setInstitutionId(originalInstitutionId);
conversation.setSupplierId(originalSupplierId);
conversation.setContractId(originalContractId);
conversation.setChannelCode(originalChannelCode);
conversationMapper.updateById(conversation);
return conversation;
}
Conversation newConversation = new Conversation();
newConversation.setTenantCode(account.getTenantCode());
newConversation.setConversationType(CONVERSATION_TYPE_GROUP);
newConversation.setChannelType(CHANNEL_WECOM);
newConversation.setChannelConversationId(conv.getChatId());
newConversation.setOwnerAccountId(account.getId());
newConversation.setSubjectId(subjectId);
newConversation.setConversationName(conv.getChatName());
newConversation.setCreateSource(1);
newConversation.setStatus(1);
conversationMapper.insert(newConversation);
return newConversation;
}
private void refreshParticipants(String tenantCode, Long subjectId, Conversation conversation, WeComConversation conv) {
conversationParticipantMapper.delete(
new LambdaQueryWrapper<ConversationParticipant>()
.eq(ConversationParticipant::getConversationId, conversation.getId()));
for (WeComConversationMember member : conv.getMemberList()) {
ChannelIdentity identity = findOrCreateIdentity(tenantCode, subjectId, member);
ConversationParticipant participant = new ConversationParticipant();
participant.setConversationId(conversation.getId());
participant.setPersonId(identity.getPersonId());
participant.setChannelIdentityId(identity.getId());
participant.setParticipantType(resolveParticipantType(member, conv.getOwnerUserId()));
participant.setJoinTime(LocalDateTime.now());
conversationParticipantMapper.insert(participant);
}
}
private ChannelIdentity findOrCreateIdentity(String tenantCode, Long subjectId, WeComConversationMember member) {
ChannelIdentity identity = channelIdentityMapper.selectOne(
new LambdaQueryWrapper<ChannelIdentity>()
.eq(ChannelIdentity::getTenantCode, tenantCode)
.eq(ChannelIdentity::getChannelType, CHANNEL_WECOM)
.eq(ChannelIdentity::getChannelUserId, member.getUserId()));
if (identity != null) {
return identity;
}
Person person = new Person();
person.setTenantCode(tenantCode);
person.setPersonCode(member.getUserId());
person.setPersonName(member.getName());
person.setPersonType(PERSON_TYPE_CUSTOMER);
person.setStatus(1);
personMapper.insert(person);
ChannelIdentity newIdentity = new ChannelIdentity();
newIdentity.setTenantCode(tenantCode);
newIdentity.setPersonId(person.getId());
newIdentity.setChannelType(CHANNEL_WECOM);
newIdentity.setChannelUserId(member.getUserId());
newIdentity.setChannelUserName(member.getName());
newIdentity.setSubjectId(subjectId);
newIdentity.setStatus(1);
channelIdentityMapper.insert(newIdentity);
return newIdentity;
}
private void bindAccountAsDefault(Long accountId, Long conversationId) {
ensureBinding(accountId, conversationId, BINDING_SEND_MASTER);
ensureBinding(accountId, conversationId, BINDING_RECEIVE_MASTER);
}
private void ensureBinding(Long accountId, Long conversationId, int bindingType) {
ChannelAccountConversation existing = accountConversationMapper.selectOne(
new LambdaQueryWrapper<ChannelAccountConversation>()
.eq(ChannelAccountConversation::getAccountId, accountId)
.eq(ChannelAccountConversation::getConversationId, conversationId)
.eq(ChannelAccountConversation::getBindingType, bindingType));
if (existing != null) {
return;
}
ChannelAccountConversation binding = new ChannelAccountConversation();
binding.setAccountId(accountId);
binding.setConversationId(conversationId);
binding.setBindingType(bindingType);
binding.setSortOrder(0);
binding.setStatus(1);
accountConversationMapper.insert(binding);
}
private int resolvePersonType(WeComContact contact) {
return contact.getType() == WeComContactType.INTERNAL ? PERSON_TYPE_INTERNAL : PERSON_TYPE_CUSTOMER;
}
private int resolveParticipantType(WeComConversationMember member, String ownerUserId) {
return ownerUserId != null && ownerUserId.equals(member.getUserId()) ? PARTICIPANT_TYPE_OWNER : PARTICIPANT_TYPE_MEMBER;
}
}

View File

@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.sino.mci.channel.dto.CreateChannelSubjectRequest;
import com.sino.mci.channel.dto.UpdateStatusRequest;
import com.sino.mci.channel.entity.ChannelSubject;
import com.sino.mci.channel.entity.SyncTask;
import com.sino.mci.channel.repository.ChannelSubjectMapper;
import com.sino.mci.exception.BusinessException;
import lombok.RequiredArgsConstructor;
@@ -17,6 +18,8 @@ import java.util.List;
public class ChannelSubjectService {
private final ChannelSubjectMapper subjectMapper;
private final SyncTaskService syncTaskService;
private final SyncTaskExecutor syncTaskExecutor;
public List<ChannelSubject> listSubjects(String tenantCode) {
return subjectMapper.selectList(
@@ -51,7 +54,6 @@ public class ChannelSubjectService {
subject.setSubjectCode(request.getSubjectCode());
subject.setSubjectName(request.getSubjectName());
subject.setCorpId(request.getCorpId());
subject.setCorpSecret(request.getCorpSecret());
subject.setAgentId(request.getAgentId());
subject.setAgentSecret(request.getAgentSecret());
subject.setSessionArchiveEnabled(request.getSessionArchiveEnabled() != null ? request.getSessionArchiveEnabled() : 0);
@@ -80,7 +82,6 @@ public class ChannelSubjectService {
subject.setSubjectCode(request.getSubjectCode());
subject.setSubjectName(request.getSubjectName());
subject.setCorpId(request.getCorpId());
subject.setCorpSecret(request.getCorpSecret());
subject.setAgentId(request.getAgentId());
subject.setAgentSecret(request.getAgentSecret());
subject.setSessionArchiveEnabled(request.getSessionArchiveEnabled() != null ? request.getSessionArchiveEnabled() : 0);
@@ -103,4 +104,12 @@ public class ChannelSubjectService {
subjectMapper.updateById(subject);
return subject;
}
public SyncTask submitEnterpriseContactsSyncTask(Long id, String tenantCode) {
ChannelSubject subject = getSubject(id, tenantCode);
SyncTask task = syncTaskService.createTask(
tenantCode, "enterprise_contacts", subject.getChannelType(), id, subject.getSubjectCode());
syncTaskExecutor.executeEnterpriseContactsSync(task.getId());
return task;
}
}

View File

@@ -0,0 +1,226 @@
package com.sino.mci.channel.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.sino.mci.channel.entity.ChannelAccount;
import com.sino.mci.channel.entity.ChannelAccountConversation;
import com.sino.mci.channel.entity.Conversation;
import com.sino.mci.channel.entity.ConversationParticipant;
import com.sino.mci.channel.repository.ChannelAccountConversationMapper;
import com.sino.mci.channel.repository.ConversationMapper;
import com.sino.mci.channel.repository.ConversationParticipantMapper;
import com.sino.mci.channel.wecom.sync.WeComContact;
import com.sino.mci.channel.wecom.sync.WeComContactType;
import com.sino.mci.channel.wecom.sync.WeComConversation;
import com.sino.mci.channel.wecom.sync.WeComConversationMember;
import com.sino.mci.crm.entity.ChannelIdentity;
import com.sino.mci.crm.entity.Person;
import com.sino.mci.crm.repository.ChannelIdentityMapper;
import com.sino.mci.crm.repository.PersonMapper;
import com.sino.mci.shared.util.JsonUtils;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
@Slf4j
@Component
@RequiredArgsConstructor
public class SyncDataPersister {
private static final String CHANNEL_WECOM = "wecom";
private static final int BINDING_SEND_MASTER = 1;
private static final int BINDING_RECEIVE_MASTER = 3;
private static final int PERSON_TYPE_INTERNAL = 1;
private static final int PERSON_TYPE_CUSTOMER = 3;
private static final int CONVERSATION_TYPE_GROUP = 2;
private static final int PARTICIPANT_TYPE_MEMBER = 1;
private static final int PARTICIPANT_TYPE_OWNER = 2;
private final PersonMapper personMapper;
private final ChannelIdentityMapper channelIdentityMapper;
private final ConversationMapper conversationMapper;
private final ConversationParticipantMapper conversationParticipantMapper;
private final ChannelAccountConversationMapper accountConversationMapper;
@Transactional
public void upsertContact(String tenantCode, Long subjectId, WeComContact contact) {
ChannelIdentity identity = channelIdentityMapper.selectOne(
new LambdaQueryWrapper<ChannelIdentity>()
.eq(ChannelIdentity::getTenantCode, tenantCode)
.eq(ChannelIdentity::getChannelType, CHANNEL_WECOM)
.eq(ChannelIdentity::getChannelUserId, contact.getUserId()));
Person person;
if (identity != null) {
person = personMapper.selectById(identity.getPersonId());
if (person == null) {
log.warn("ChannelIdentity {} references missing person {}", identity.getId(), identity.getPersonId());
return;
}
Long originalInstitutionId = person.getInstitutionId();
Long originalSupplierId = person.getSupplierId();
Integer originalPersonType = person.getPersonType();
person.setPersonName(contact.getName());
person.setPhone(contact.getMobile());
person.setInstitutionId(originalInstitutionId);
person.setSupplierId(originalSupplierId);
person.setPersonType(originalPersonType);
personMapper.updateById(person);
identity.setChannelUserName(contact.getName());
identity.setExtInfo(JsonUtils.toJson(contact));
channelIdentityMapper.updateById(identity);
} else {
person = new Person();
person.setTenantCode(tenantCode);
person.setPersonCode(contact.getUserId());
person.setPersonName(contact.getName());
person.setPhone(contact.getMobile());
person.setPersonType(resolvePersonType(contact));
person.setStatus(1);
personMapper.insert(person);
ChannelIdentity newIdentity = new ChannelIdentity();
newIdentity.setTenantCode(tenantCode);
newIdentity.setPersonId(person.getId());
newIdentity.setChannelType(CHANNEL_WECOM);
newIdentity.setChannelUserId(contact.getUserId());
newIdentity.setChannelUserName(contact.getName());
newIdentity.setSubjectId(subjectId);
newIdentity.setStatus(1);
newIdentity.setExtInfo(JsonUtils.toJson(contact));
channelIdentityMapper.insert(newIdentity);
}
}
@Transactional
public Conversation upsertConversation(ChannelAccount account, Long subjectId, WeComConversation conv) {
Conversation conversation = conversationMapper.selectOne(
new LambdaQueryWrapper<Conversation>()
.eq(Conversation::getTenantCode, account.getTenantCode())
.eq(Conversation::getChannelType, CHANNEL_WECOM)
.eq(Conversation::getChannelConversationId, conv.getChatId()));
if (conversation != null) {
Long originalInstitutionId = conversation.getInstitutionId();
Long originalSupplierId = conversation.getSupplierId();
Long originalContractId = conversation.getContractId();
String originalChannelCode = conversation.getChannelCode();
conversation.setConversationName(conv.getChatName());
conversation.setOwnerAccountId(account.getId());
conversation.setSubjectId(subjectId);
conversation.setInstitutionId(originalInstitutionId);
conversation.setSupplierId(originalSupplierId);
conversation.setContractId(originalContractId);
conversation.setChannelCode(originalChannelCode);
conversationMapper.updateById(conversation);
return conversation;
}
Conversation newConversation = new Conversation();
newConversation.setTenantCode(account.getTenantCode());
newConversation.setConversationType(CONVERSATION_TYPE_GROUP);
newConversation.setChannelType(CHANNEL_WECOM);
newConversation.setChannelConversationId(conv.getChatId());
newConversation.setOwnerAccountId(account.getId());
newConversation.setSubjectId(subjectId);
newConversation.setConversationName(conv.getChatName());
newConversation.setCreateSource(1);
newConversation.setStatus(1);
conversationMapper.insert(newConversation);
return newConversation;
}
@Transactional
public void refreshParticipants(String tenantCode, Long subjectId, Conversation conversation, WeComConversation conv) {
conversationParticipantMapper.delete(
new LambdaQueryWrapper<ConversationParticipant>()
.eq(ConversationParticipant::getConversationId, conversation.getId()));
for (WeComConversationMember member : conv.getMemberList()) {
ChannelIdentity identity = findOrCreateIdentity(tenantCode, subjectId, member);
ConversationParticipant participant = new ConversationParticipant();
participant.setConversationId(conversation.getId());
participant.setPersonId(identity.getPersonId());
participant.setChannelIdentityId(identity.getId());
participant.setParticipantType(resolveParticipantType(member, conv.getOwnerUserId()));
participant.setJoinTime(LocalDateTime.now());
conversationParticipantMapper.insert(participant);
}
}
@Transactional
public void bindAccountAsDefault(Long accountId, Long conversationId) {
ensureBinding(accountId, conversationId, BINDING_SEND_MASTER);
ensureBinding(accountId, conversationId, BINDING_RECEIVE_MASTER);
}
private ChannelIdentity findOrCreateIdentity(String tenantCode, Long subjectId, WeComConversationMember member) {
ChannelIdentity identity = channelIdentityMapper.selectOne(
new LambdaQueryWrapper<ChannelIdentity>()
.eq(ChannelIdentity::getTenantCode, tenantCode)
.eq(ChannelIdentity::getChannelType, CHANNEL_WECOM)
.eq(ChannelIdentity::getChannelUserId, member.getUserId()));
if (identity != null) {
return identity;
}
String displayName = resolveDisplayName(member.getName(), member.getUserId());
Person person = new Person();
person.setTenantCode(tenantCode);
person.setPersonCode(member.getUserId());
person.setPersonName(displayName);
person.setPersonType(PERSON_TYPE_CUSTOMER);
person.setStatus(1);
personMapper.insert(person);
ChannelIdentity newIdentity = new ChannelIdentity();
newIdentity.setTenantCode(tenantCode);
newIdentity.setPersonId(person.getId());
newIdentity.setChannelType(CHANNEL_WECOM);
newIdentity.setChannelUserId(member.getUserId());
newIdentity.setChannelUserName(displayName);
newIdentity.setSubjectId(subjectId);
newIdentity.setStatus(1);
channelIdentityMapper.insert(newIdentity);
return newIdentity;
}
private String resolveDisplayName(String name, String userId) {
if (name != null && !name.isBlank()) {
return name;
}
return userId != null && !userId.isBlank() ? userId : "未知";
}
private void ensureBinding(Long accountId, Long conversationId, int bindingType) {
ChannelAccountConversation existing = accountConversationMapper.selectOne(
new LambdaQueryWrapper<ChannelAccountConversation>()
.eq(ChannelAccountConversation::getAccountId, accountId)
.eq(ChannelAccountConversation::getConversationId, conversationId)
.eq(ChannelAccountConversation::getBindingType, bindingType));
if (existing != null) {
return;
}
ChannelAccountConversation binding = new ChannelAccountConversation();
binding.setAccountId(accountId);
binding.setConversationId(conversationId);
binding.setBindingType(bindingType);
binding.setSortOrder(0);
binding.setStatus(1);
accountConversationMapper.insert(binding);
}
private int resolvePersonType(WeComContact contact) {
return contact.getType() == WeComContactType.INTERNAL ? PERSON_TYPE_INTERNAL : PERSON_TYPE_CUSTOMER;
}
private int resolveParticipantType(WeComConversationMember member, String ownerUserId) {
return ownerUserId != null && ownerUserId.equals(member.getUserId()) ? PARTICIPANT_TYPE_OWNER : PARTICIPANT_TYPE_MEMBER;
}
}

View File

@@ -0,0 +1,124 @@
package com.sino.mci.channel.service;
import com.sino.mci.channel.entity.ChannelAccount;
import com.sino.mci.channel.entity.ChannelSubject;
import com.sino.mci.channel.entity.Conversation;
import com.sino.mci.channel.entity.SyncTask;
import com.sino.mci.channel.repository.ChannelAccountMapper;
import com.sino.mci.channel.repository.ChannelSubjectMapper;
import com.sino.mci.channel.wecom.sync.WeComContact;
import com.sino.mci.channel.wecom.sync.WeComConversation;
import com.sino.mci.channel.wecom.sync.WeComSyncClient;
import com.sino.mci.channel.wecom.sync.WeComSyncConfig;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import java.util.List;
@Slf4j
@Component
@RequiredArgsConstructor
public class SyncTaskExecutor {
private final SyncTaskService syncTaskService;
private final ChannelAccountMapper accountMapper;
private final ChannelSubjectMapper subjectMapper;
private final SyncDataPersister syncDataPersister;
private final WeComSyncClient weComSyncClient;
@Async("syncTaskPool")
public void executeEnterpriseContactsSync(Long taskId) {
SyncTask task = syncTaskService.getTask(taskId, null);
if (task == null) {
return;
}
syncTaskService.markRunning(taskId);
try {
ChannelSubject subject = subjectMapper.selectById(task.getTargetId());
if (subject == null) {
throw new RuntimeException("渠道主体不存在");
}
WeComSyncConfig config = WeComSyncConfig.builder()
.corpId(subject.getCorpId())
.agentId(subject.getAgentId())
.secret(subject.getAgentSecret())
.build();
List<WeComContact> contacts = weComSyncClient.syncEnterpriseContacts(config);
for (WeComContact contact : contacts) {
syncDataPersister.upsertContact(subject.getTenantCode(), subject.getId(), contact);
}
syncTaskService.markSuccess(taskId, contacts.size());
} catch (Exception e) {
log.error("[SyncTask] 企业通讯录同步失败 taskId={}", taskId, e);
syncTaskService.markFailed(taskId, e.getMessage());
}
}
@Async("syncTaskPool")
public void executeAccountContactsSync(Long taskId) {
SyncTask task = syncTaskService.getTask(taskId, null);
if (task == null) {
return;
}
syncTaskService.markRunning(taskId);
try {
ChannelAccount account = accountMapper.selectById(task.getTargetId());
if (account == null) {
throw new RuntimeException("渠道账号不存在");
}
ChannelSubject subject = subjectMapper.selectById(account.getSubjectId());
if (subject == null) {
throw new RuntimeException("渠道主体不存在");
}
WeComSyncConfig config = WeComSyncConfig.builder()
.corpId(subject.getCorpId())
.agentId(subject.getAgentId())
.secret(subject.getAgentSecret())
.build();
List<WeComContact> contacts = weComSyncClient.syncAccountContacts(config, account.getAccountId());
for (WeComContact contact : contacts) {
syncDataPersister.upsertContact(account.getTenantCode(), account.getSubjectId(), contact);
}
syncTaskService.markSuccess(taskId, contacts.size());
} catch (Exception e) {
log.error("[SyncTask] 账号客户同步失败 taskId={}", taskId, e);
syncTaskService.markFailed(taskId, e.getMessage());
}
}
@Async("syncTaskPool")
public void executeAccountConversationsSync(Long taskId) {
SyncTask task = syncTaskService.getTask(taskId, null);
if (task == null) {
return;
}
syncTaskService.markRunning(taskId);
try {
ChannelAccount account = accountMapper.selectById(task.getTargetId());
if (account == null) {
throw new RuntimeException("渠道账号不存在");
}
ChannelSubject subject = subjectMapper.selectById(account.getSubjectId());
if (subject == null) {
throw new RuntimeException("渠道主体不存在");
}
WeComSyncConfig config = WeComSyncConfig.builder()
.corpId(subject.getCorpId())
.agentId(subject.getAgentId())
.secret(subject.getAgentSecret())
.build();
List<WeComConversation> conversations = weComSyncClient.syncAccountConversations(config, account.getAccountId());
for (WeComConversation conversation : conversations) {
Conversation saved = syncDataPersister.upsertConversation(account, subject.getId(), conversation);
syncDataPersister.refreshParticipants(account.getTenantCode(), account.getSubjectId(), saved, conversation);
syncDataPersister.bindAccountAsDefault(account.getId(), saved.getId());
}
syncTaskService.markSuccess(taskId, conversations.size());
} catch (Exception e) {
log.error("[SyncTask] 账号群聊同步失败 taskId={}", taskId, e);
syncTaskService.markFailed(taskId, e.getMessage());
}
}
}

View File

@@ -0,0 +1,97 @@
package com.sino.mci.channel.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.sino.mci.channel.entity.SyncTask;
import com.sino.mci.channel.repository.SyncTaskMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
@Service
@RequiredArgsConstructor
public class SyncTaskService {
private final SyncTaskMapper syncTaskMapper;
@Transactional
public SyncTask createTask(String tenantCode, String taskType, String channelType, Long targetId, String targetCode) {
SyncTask task = new SyncTask();
task.setTenantCode(tenantCode);
task.setTaskType(taskType);
task.setChannelType(channelType);
task.setTargetId(targetId);
task.setTargetCode(targetCode);
task.setTaskStatus(SyncTask.STATUS_PENDING);
task.setSyncedCount(0);
task.setFailedCount(0);
task.setRetryCount(0);
task.setDeleted(0);
syncTaskMapper.insert(task);
return task;
}
@Transactional
public void markRunning(Long taskId) {
SyncTask task = syncTaskMapper.selectById(taskId);
if (task == null) {
return;
}
task.setTaskStatus(SyncTask.STATUS_RUNNING);
task.setUpdateTime(LocalDateTime.now());
syncTaskMapper.updateById(task);
}
@Transactional
public void markSuccess(Long taskId, int syncedCount) {
SyncTask task = syncTaskMapper.selectById(taskId);
if (task == null) {
return;
}
task.setTaskStatus(SyncTask.STATUS_SUCCESS);
task.setSyncedCount(syncedCount);
task.setFinishTime(LocalDateTime.now());
task.setUpdateTime(LocalDateTime.now());
syncTaskMapper.updateById(task);
}
@Transactional
public void markFailed(Long taskId, String errorMessage) {
SyncTask task = syncTaskMapper.selectById(taskId);
if (task == null) {
return;
}
task.setTaskStatus(SyncTask.STATUS_FAILED);
task.setErrorMessage(errorMessage);
task.setFinishTime(LocalDateTime.now());
task.setUpdateTime(LocalDateTime.now());
syncTaskMapper.updateById(task);
}
public SyncTask getTask(Long taskId, String tenantCode) {
SyncTask task = syncTaskMapper.selectById(taskId);
if (task == null) {
return null;
}
if (tenantCode != null && !tenantCode.equals(task.getTenantCode())) {
return null;
}
return task;
}
public SyncTask getTaskById(Long taskId) {
return syncTaskMapper.selectById(taskId);
}
public List<SyncTask> listRecentTasks(String tenantCode, String taskType, Long targetId, int limit) {
return syncTaskMapper.selectList(
new LambdaQueryWrapper<SyncTask>()
.eq(SyncTask::getTenantCode, tenantCode)
.eq(SyncTask::getTaskType, taskType)
.eq(targetId != null, SyncTask::getTargetId, targetId)
.orderByDesc(SyncTask::getId)
.last("LIMIT " + limit));
}
}

View File

@@ -0,0 +1,26 @@
package com.sino.mci.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;
import java.util.concurrent.ThreadPoolExecutor;
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean("syncTaskPool")
public Executor syncTaskPool() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(4);
executor.setMaxPoolSize(16);
executor.setQueueCapacity(200);
executor.setThreadNamePrefix("sync-task-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
}

View File

@@ -0,0 +1 @@
ALTER TABLE mci_channel_subject DROP COLUMN corp_secret;

View File

@@ -0,0 +1,20 @@
CREATE TABLE IF NOT EXISTS mci_sync_task (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
tenant_code VARCHAR(64) NOT NULL COMMENT '所属租户',
task_type VARCHAR(32) NOT NULL COMMENT '任务类型enterprise_contacts / account_contacts / account_conversations',
channel_type VARCHAR(32) NOT NULL COMMENT '渠道wecom / wechat_personal / dingtalk / feishu',
target_id BIGINT COMMENT '关联目标 ID主体 ID 或账号 ID',
target_code VARCHAR(128) COMMENT '关联目标编码:如 userId',
task_status TINYINT NOT NULL DEFAULT 0 COMMENT '任务状态0待执行 1执行中 2成功 3失败',
synced_count INT NOT NULL DEFAULT 0 COMMENT '已同步数量',
failed_count INT NOT NULL DEFAULT 0 COMMENT '失败数量',
error_message VARCHAR(1024) COMMENT '错误信息',
retry_count INT NOT NULL DEFAULT 0 COMMENT '重试次数',
finish_time DATETIME COMMENT '完成时间',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted TINYINT NOT NULL DEFAULT 0,
KEY idx_tenant_status (tenant_code, task_status),
KEY idx_target (task_type, target_id),
KEY idx_create_time (create_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='同步任务表';

View File

@@ -48,7 +48,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
@TestPropertySource(properties = {
"spring.rabbitmq.listener.simple.auto-startup=false",
"spring.main.allow-bean-definition-overriding=true",
"sino.channel.wecom.session-archive.listener-enabled=false"
"sino.channel.wecom.session-archive.listener-enabled=false",
"mci.wecom.sync.mock=true"
})
@Transactional
class WeComSessionArchiveTest {
@@ -133,7 +134,7 @@ class WeComSessionArchiveTest {
command.setChannelSubjectId(subject.getId());
command.setTenantCode(subject.getTenantCode());
command.setCorpId(subject.getCorpId());
command.setSecret(subject.getCorpSecret());
command.setSecret(subject.getAgentSecret());
command.setPrivateKey(subject.getSessionArchivePrivateKey());
sessionArchivePullService.onPullCommand(command);
@@ -177,7 +178,7 @@ class WeComSessionArchiveTest {
command.setChannelSubjectId(subject.getId());
command.setTenantCode(subject.getTenantCode());
command.setCorpId(subject.getCorpId());
command.setSecret(subject.getCorpSecret());
command.setSecret(subject.getAgentSecret());
command.setPrivateKey(subject.getSessionArchivePrivateKey());
sessionArchivePullService.onPullCommand(command);
@@ -202,7 +203,7 @@ class WeComSessionArchiveTest {
subject.setSubjectCode("archive");
subject.setSubjectName("会话存档主体");
subject.setCorpId(CORP_ID);
subject.setCorpSecret("secret-" + UUID.randomUUID());
subject.setAgentSecret("secret-" + UUID.randomUUID());
subject.setSessionArchiveEnabled(1);
subject.setSessionArchivePrivateKey("private-key");
subject.setStatus(1);

View File

@@ -1,16 +1,19 @@
package com.sino.mci.channel.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sino.mci.channel.common.model.ChannelType;
import com.sino.mci.channel.entity.ChannelAccount;
import com.sino.mci.channel.entity.ChannelAccountConversation;
import com.sino.mci.channel.entity.ChannelSubject;
import com.sino.mci.channel.entity.Conversation;
import com.sino.mci.channel.entity.ConversationParticipant;
import com.sino.mci.channel.entity.SyncTask;
import com.sino.mci.channel.repository.ChannelAccountConversationMapper;
import com.sino.mci.channel.repository.ChannelAccountMapper;
import com.sino.mci.channel.repository.ChannelSubjectMapper;
import com.sino.mci.channel.repository.ConversationMapper;
import com.sino.mci.channel.repository.ConversationParticipantMapper;
import com.sino.mci.channel.service.SyncTaskService;
import com.sino.mci.channel.wecom.sync.WeComContact;
import com.sino.mci.channel.wecom.sync.WeComContactType;
import com.sino.mci.channel.wecom.sync.WeComConversation;
@@ -29,7 +32,7 @@ import org.springframework.context.annotation.Bean;
import org.springframework.http.MediaType;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.test.web.servlet.MvcResult;
import java.util.List;
import java.util.UUID;
@@ -47,14 +50,16 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
@AutoConfigureMockMvc
@TestPropertySource(properties = {
"spring.rabbitmq.listener.simple.auto-startup=false",
"spring.main.allow-bean-definition-overriding=true"
"spring.main.allow-bean-definition-overriding=true",
"mci.wecom.sync.mock=true"
})
@Transactional
class WeComSyncTest {
private static final String TENANT_CODE = "wecom-sync-" + UUID.randomUUID().toString().substring(0, 8);
private final String tenantCode = "wecom-sync-" + UUID.randomUUID().toString().substring(0, 8);
private static final String CHANNEL_TYPE = ChannelType.WECOM.name().toLowerCase();
private final ObjectMapper objectMapper = new ObjectMapper();
@Autowired
private MockMvc mockMvc;
@@ -79,23 +84,31 @@ class WeComSyncTest {
@Autowired
private ChannelAccountConversationMapper accountConversationMapper;
@Test
void shouldSyncContactsAndPersistPersonAndIdentity() throws Exception {
String authToken = createTenantAndLogin(mockMvc, TENANT_CODE, "企微同步租户");
ChannelAccount account = createWeComAccount();
@Autowired
private SyncTaskService syncTaskService;
mockMvc.perform(post("/msg-platform/api/v1/channel-account/{account_id}/sync-contacts", account.getId())
@Test
void shouldSyncEnterpriseContactsViaSubjectAndPersistInternalPerson() throws Exception {
String authToken = createTenantAndLogin(mockMvc, tenantCode, "企微同步租户");
ChannelSubject subject = createWeComSubject();
MvcResult result = mockMvc.perform(post("/msg-platform/api/v1/channel-subject/{id}/sync-contacts", subject.getId())
.contextPath("/msg-platform")
.header("Authorization", authToken)
.header("X-Tenant-Code", TENANT_CODE)
.header("X-Tenant-Code", tenantCode)
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.syncedCount").value(2));
.andReturn();
Long taskId = extractTaskId(result);
SyncTask task = waitForTask(taskId);
assertThat(task.getTaskStatus()).isEqualTo(SyncTask.STATUS_SUCCESS);
assertThat(task.getSyncedCount()).isEqualTo(2);
Person internalPerson = personMapper.selectOne(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<Person>()
.eq(Person::getTenantCode, TENANT_CODE)
.eq(Person::getTenantCode, tenantCode)
.eq(Person::getPersonCode, "user001"));
assertThat(internalPerson).isNotNull();
assertThat(internalPerson.getPersonName()).isEqualTo("张三");
@@ -104,23 +117,42 @@ class WeComSyncTest {
ChannelIdentity internalIdentity = channelIdentityMapper.selectOne(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<ChannelIdentity>()
.eq(ChannelIdentity::getTenantCode, TENANT_CODE)
.eq(ChannelIdentity::getTenantCode, tenantCode)
.eq(ChannelIdentity::getChannelType, CHANNEL_TYPE)
.eq(ChannelIdentity::getChannelUserId, "user001"));
assertThat(internalIdentity).isNotNull();
assertThat(internalIdentity.getPersonId()).isEqualTo(internalPerson.getId());
assertThat(internalIdentity.getChannelUserName()).isEqualTo("张三");
}
@Test
void shouldSyncAccountContactsAndPersistExternalPerson() throws Exception {
String authToken = createTenantAndLogin(mockMvc, tenantCode, "企微同步租户");
ChannelAccount account = createWeComAccount();
MvcResult result = mockMvc.perform(post("/msg-platform/api/v1/channel-account/{account_id}/sync-contacts", account.getId())
.contextPath("/msg-platform")
.header("Authorization", authToken)
.header("X-Tenant-Code", tenantCode)
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andReturn();
Long taskId = extractTaskId(result);
SyncTask task = waitForTask(taskId);
assertThat(task.getTaskStatus()).isEqualTo(SyncTask.STATUS_SUCCESS);
Person externalPerson = personMapper.selectOne(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<Person>()
.eq(Person::getTenantCode, TENANT_CODE)
.eq(Person::getTenantCode, tenantCode)
.eq(Person::getPersonCode, "wmexternal001"));
assertThat(externalPerson).isNotNull();
assertThat(externalPerson.getPersonType()).isEqualTo(3);
ChannelIdentity externalIdentity = channelIdentityMapper.selectOne(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<ChannelIdentity>()
.eq(ChannelIdentity::getTenantCode, TENANT_CODE)
.eq(ChannelIdentity::getTenantCode, tenantCode)
.eq(ChannelIdentity::getChannelType, CHANNEL_TYPE)
.eq(ChannelIdentity::getChannelUserId, "wmexternal001"));
assertThat(externalIdentity).isNotNull();
@@ -129,21 +161,25 @@ class WeComSyncTest {
@Test
void shouldSyncConversationsAndPersistParticipantsAndBindings() throws Exception {
String authToken = createTenantAndLogin(mockMvc, TENANT_CODE, "企微同步租户");
String authToken = createTenantAndLogin(mockMvc, tenantCode, "企微同步租户");
ChannelAccount account = createWeComAccount();
mockMvc.perform(post("/msg-platform/api/v1/channel-account/{account_id}/sync-conversations", account.getId())
MvcResult result = mockMvc.perform(post("/msg-platform/api/v1/channel-account/{account_id}/sync-conversations", account.getId())
.contextPath("/msg-platform")
.header("Authorization", authToken)
.header("X-Tenant-Code", TENANT_CODE)
.header("X-Tenant-Code", tenantCode)
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.syncedCount").value(1));
.andReturn();
Long taskId = extractTaskId(result);
SyncTask task = waitForTask(taskId);
assertThat(task.getTaskStatus()).isEqualTo(SyncTask.STATUS_SUCCESS);
Conversation conversation = conversationMapper.selectOne(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<Conversation>()
.eq(Conversation::getTenantCode, TENANT_CODE)
.eq(Conversation::getTenantCode, tenantCode)
.eq(Conversation::getChannelType, CHANNEL_TYPE)
.eq(Conversation::getChannelConversationId, "chat001"));
assertThat(conversation).isNotNull();
@@ -181,53 +217,57 @@ class WeComSyncTest {
@Test
void shouldPreservePersonBusinessFieldsDuringContactSync() throws Exception {
String authToken = createTenantAndLogin(mockMvc, TENANT_CODE, "企微同步租户");
String authToken = createTenantAndLogin(mockMvc, tenantCode, "企微同步租户");
ChannelAccount account = createWeComAccount();
Person person = new Person();
person.setTenantCode(TENANT_CODE);
person.setPersonCode("user001");
person.setTenantCode(tenantCode);
person.setPersonCode("wmexternal001");
person.setPersonName("原始姓名");
person.setPhone("13800138001");
// Seed with a personType that differs from what resolvePersonType(user001/INTERNAL) would assign (1).
person.setPersonType(3);
person.setPhone("13900139001");
person.setPersonType(1);
person.setInstitutionId(100L);
person.setSupplierId(200L);
person.setStatus(1);
personMapper.insert(person);
ChannelIdentity identity = new ChannelIdentity();
identity.setTenantCode(TENANT_CODE);
identity.setTenantCode(tenantCode);
identity.setPersonId(person.getId());
identity.setChannelType(CHANNEL_TYPE);
identity.setChannelUserId("user001");
identity.setChannelUserId("wmexternal001");
identity.setChannelUserName("原始姓名");
identity.setSubjectId(account.getSubjectId());
identity.setStatus(1);
channelIdentityMapper.insert(identity);
mockMvc.perform(post("/msg-platform/api/v1/channel-account/{account_id}/sync-contacts", account.getId())
MvcResult result = mockMvc.perform(post("/msg-platform/api/v1/channel-account/{account_id}/sync-contacts", account.getId())
.contextPath("/msg-platform")
.header("Authorization", authToken)
.header("X-Tenant-Code", TENANT_CODE)
.header("X-Tenant-Code", tenantCode)
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
.andExpect(jsonPath("$.code").value(0))
.andReturn();
Long taskId = extractTaskId(result);
SyncTask task = waitForTask(taskId);
assertThat(task.getTaskStatus()).isEqualTo(SyncTask.STATUS_SUCCESS);
Person updated = personMapper.selectById(person.getId());
assertThat(updated.getPersonName()).isEqualTo("张三");
assertThat(updated.getPersonName()).isEqualTo("客户李四");
assertThat(updated.getInstitutionId()).isEqualTo(100L);
assertThat(updated.getSupplierId()).isEqualTo(200L);
assertThat(updated.getPersonType()).isEqualTo(3);
assertThat(updated.getPersonType()).isEqualTo(1);
}
@Test
void shouldPreserveConversationBusinessFieldsDuringConversationSync() throws Exception {
String authToken = createTenantAndLogin(mockMvc, TENANT_CODE, "企微同步租户");
String authToken = createTenantAndLogin(mockMvc, tenantCode, "企微同步租户");
ChannelAccount account = createWeComAccount();
Conversation conversation = new Conversation();
conversation.setTenantCode(TENANT_CODE);
conversation.setTenantCode(tenantCode);
conversation.setConversationType(2);
conversation.setChannelType(CHANNEL_TYPE);
conversation.setChannelConversationId("chat001");
@@ -242,13 +282,18 @@ class WeComSyncTest {
conversation.setStatus(1);
conversationMapper.insert(conversation);
mockMvc.perform(post("/msg-platform/api/v1/channel-account/{account_id}/sync-conversations", account.getId())
MvcResult result = mockMvc.perform(post("/msg-platform/api/v1/channel-account/{account_id}/sync-conversations", account.getId())
.contextPath("/msg-platform")
.header("Authorization", authToken)
.header("X-Tenant-Code", TENANT_CODE)
.header("X-Tenant-Code", tenantCode)
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
.andExpect(jsonPath("$.code").value(0))
.andReturn();
Long taskId = extractTaskId(result);
SyncTask task = waitForTask(taskId);
assertThat(task.getTaskStatus()).isEqualTo(SyncTask.STATUS_SUCCESS);
Conversation updated = conversationMapper.selectById(conversation.getId());
assertThat(updated.getConversationName()).isEqualTo("测试客户群");
@@ -258,20 +303,40 @@ class WeComSyncTest {
assertThat(updated.getChannelCode()).isEqualTo("ORIGINAL");
}
private ChannelAccount createWeComAccount() {
private Long extractTaskId(MvcResult result) throws Exception {
return objectMapper.readTree(result.getResponse().getContentAsString()).path("data").path("id").asLong();
}
private SyncTask waitForTask(Long taskId) throws InterruptedException {
for (int i = 0; i < 50; i++) {
SyncTask task = syncTaskService.getTaskById(taskId);
if (task != null && (task.getTaskStatus() == SyncTask.STATUS_SUCCESS || task.getTaskStatus() == SyncTask.STATUS_FAILED)) {
return task;
}
Thread.sleep(200);
}
throw new RuntimeException("同步任务未在预期时间内完成: taskId=" + taskId);
}
private ChannelSubject createWeComSubject() {
ChannelSubject subject = new ChannelSubject();
subject.setTenantCode(TENANT_CODE);
subject.setTenantCode(tenantCode);
subject.setChannelType(CHANNEL_TYPE);
subject.setSubjectCode("sino");
subject.setSubjectName("Sino");
subject.setCorpId("ww-sync-" + UUID.randomUUID().toString().substring(0, 8));
subject.setCorpSecret("secret-" + UUID.randomUUID());
subject.setAgentId("1000002");
subject.setAgentSecret("secret-" + UUID.randomUUID());
subject.setStatus(1);
subjectMapper.insert(subject);
return subject;
}
private ChannelAccount createWeComAccount() {
ChannelSubject subject = createWeComSubject();
ChannelAccount account = new ChannelAccount();
account.setTenantCode(TENANT_CODE);
account.setTenantCode(tenantCode);
account.setAccountType(1);
account.setChannelType(CHANNEL_TYPE);
account.setSubjectId(subject.getId());
@@ -288,7 +353,7 @@ class WeComSyncTest {
@Bean
public WeComSyncClient weComSyncClient() {
WeComSyncClient client = mock(WeComSyncClient.class);
when(client.syncContacts(any())).thenReturn(List.of(
when(client.syncEnterpriseContacts(any())).thenReturn(List.of(
WeComContact.builder()
.userId("user001")
.name("张三")
@@ -302,7 +367,15 @@ class WeComSyncTest {
.type(WeComContactType.EXTERNAL)
.build()
));
when(client.syncConversations(any())).thenReturn(List.of(
when(client.syncAccountContacts(any(), any())).thenReturn(List.of(
WeComContact.builder()
.userId("wmexternal001")
.name("客户李四")
.mobile("13900139001")
.type(WeComContactType.EXTERNAL)
.build()
));
when(client.syncAccountConversations(any(), any())).thenReturn(List.of(
WeComConversation.builder()
.chatId("chat001")
.chatName("测试客户群")

View File

@@ -84,6 +84,28 @@
</plugin>
</plugins>
</pluginManagement>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-toolchains-plugin</artifactId>
<version>3.2.0</version>
<configuration>
<toolchains>
<jdk>
<version>21</version>
<vendor>zulu</vendor>
</jdk>
</toolchains>
</configuration>
<executions>
<execution>
<goals>
<goal>toolchain</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
<repositories>

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,8 @@
# 消息中台管理后台菜单重构设计
# 消息中台管理后台菜单重构与界面设计
> **日期**2026-07-09
> **状态**:已确认,待实现
> **关联文档**[消息与客户互动中台架构设计](../architecture/2026-07-06-message-customer-interaction-design.md)
> **关联文档**[消息与客户互动中台架构设计](../architecture/2026-07-06-message-customer-interaction-design.md)
---
@@ -20,16 +20,20 @@
## 2. 设计目标
1. **按工作流组织顶层菜单**:让用户按“接入 → 维护资产 → 运营发送 → 编排流程 → 系统管理”的路径找到功能。
1. **按工作流组织顶层菜单**:让用户按“接入 → 客户关系 → 运营发送 → 编排流程 → 系统管理”的路径找到功能。
2. **按业务对象聚合二级功能**:同一对象(渠道账号、会话、联系人)的关联操作放在同一模块下。
3. **明确功能边界**:每个菜单项都有清晰的功能定义、面向角色和与现有页面的映射关系。
4. **预留扩展位**IVR 流程等 2092 阶段功能先不实现,但保留模块位置说明。
4. **统一设计语言**
- 列表页 = 筛选 + 表格 + 顶部「新增」按钮 + 分页
- 新增 = 弹窗/小抽屉,只填核心字段
- 编辑/详情 = 大抽屉,基础信息区 + 标签字段 + 关联内容区
5. **预留扩展位**:钉钉/飞书/IVR 本期仅保留入口,不实现具体能力。
---
## 3. 设计决策
采用 **混合式组织(候选 C**
采用 **混合式组织**
- 顶层按 **工作流/职责域** 划分,降低日常操作路径长度。
- 模块内按 **业务对象** 分组,保持数据模型与 UI 结构一致。
@@ -57,8 +61,8 @@
|---------|------|---------|
| 概览 | 运营总览、快捷入口 | 全部 |
| 渠道接入 | 渠道资源与账号接入 | 租户管理员、运营人员 |
| 客户与群 | 联系人、会话、标签资产 | 租户管理员、运营人员 |
| 消息运营 | 发送、记录、策略、消息查询 | 租户管理员、运营人员 |
| 客户关系 | 联系人、会话、标签资产 | 租户管理员、运营人员 |
| 消息运营 | 发送、记录、消息查询 | 租户管理员、运营人员 |
| 流程编排 | 入站消息处理配置 | 租户管理员 |
| 系统管理 | 租户、用户、角色 | 平台管理员、租户管理员 |
@@ -73,46 +77,42 @@
| 二级菜单 | 功能定义 | 页面/备注 |
|---------|---------|----------|
| 渠道主体 | 维护企业在各渠道的主体信息企微 corp_id/secret、钉钉/飞书主体支持会话存档开关 | 现有 ChannelSubject 页面迁移 |
| 渠道账号 | 维护用于收发消息的载体账号:新增、编辑、启用/停用、查看在线/登录/风控状态、强制下线 | 现有 ChannelAccount 页面迁移 |
| 账号初始化 | 统一入口引导初始化:企微员工/应用、个微扫码登录、机器人 webhook | 从 ChannelAccount 页面拆分出的引导流程 |
| 应用管理 | 企微应用等渠道应用的配置agent_id、agent_secret、应用消息推送能力 | 新增(原散落在渠道账号中) |
| 渠道主体 | 维护企业在各渠道的主体信息企微主体支持会话存档、员工/客户/群聊/会话存档同步 | 现有 ChannelSubject 页面增强 |
| 渠道账号 | 维护用于收发消息的载体账号;支持按 ID 拉取企微信息、pywechat 扫码登录、同步会话 | 现有 ChannelAccount 页面迁移 |
| 应用管理 | 企微应用的配置agent_id、agent_secret、能力验证本期只实现企微其他占位 | 新增 |
### 5.4 客户与群
### 5.4 客户关系
| 二级菜单 | 功能定义 | 页面/备注 |
|---------|---------|----------|
| 联系人 | 维护 Person(业务身份),含 person_code、person_type、phone、institution_id、supplier_id、contract_id支持新增/编辑/启用停用 | 现有 Person 页面增强 |
| 渠道身份 | 管理 Person 在各渠道的身份映射:企微 external_userid、个微 wxid、钉钉 userid、飞书 open_id | 新增独立入口 |
| 标签体系 | 树形标签管理:新增/编辑/删除、查看人员与会话标签分布;标签表达角色/合同/机构/供应商等业务属性 | 现有 Tag 页面升级为体系化管理 |
| 会话/群聊 | 维护 Conversation新增、编辑、同步群成员、查看历史消息、绑定业务字段contract_id 等) | 现有 Conversation 页面迁移 |
| 账号-会话绑定 | 为会话配置发送主备号、接收主备号及优先级;单聊约束需校验 channel_type 一致性 | 从 Conversation 页面拆分出的独立管理 |
| 联系人 | 维护 Person;详情抽屉涵盖基本信息、渠道身份、标签、所属会话 | 现有 Person 页面增强 |
| 会话/群聊 | 维护 Conversation详情抽屉涵盖基本信息、成员、收发账号绑定、标签、历史消息 | 现有 Conversation 页面迁移 |
| 标签体系 | 树形标签管理;标签用于表达角色/合同/机构/供应商等业务属性 | 现有 Tag 页面升级为体系化管理 |
### 5.5 消息运营
| 二级菜单 | 功能定义 | 页面/备注 |
|---------|---------|----------|
| 发送记录 | 按任务查询发送明细、状态、结果、重试记录;支持取消待发送任务 | 现有 SendRecord 页面迁移 |
| 测试发送 | 模拟发送,不实际调用渠道,用于业务系统联调和发送逻辑验证 | 现有 MessageTest 页面迁移并改名 |
| 发送策略 | 策略组配置:频率控制、时间窗口、黑名单、重试、主备切换 | 现有 SendPolicy 页面迁移 |
| 消息查询 | 统一查询入站/出站消息事件MessageEvent支持按会话、人员、账号、来源realtime / session_archive筛选 | 新增 |
| 发送记录 | 按任务查询发送明细、状态、结果;支持取消待发送任务;失败固定重试 2 次 | 现有 SendRecord 页面迁移 |
| 测试发送 | 模拟出站/入站消息,用于业务系统联调和发送逻辑验证 | 现有 MessageTest 页面迁移并改名 |
| 消息查询 | 聊天界面查看入站/出站消息事件,支持按会话、人员、来源筛选 | 新增 |
### 5.6 流程编排
| 二级菜单 | 功能定义 | 页面/备注 |
|---------|---------|----------|
| 入站 Webhook | 配置业务系统接收入站消息的 webhook URL、签名方式、测试连通性 | 现有 InboundWebhook 页面迁移 |
| 入站流程 | 轻量画布配置入站消息处理流程receive → intent → condition → webhook/reply | 现有 InboundFlow 页面迁移 |
| 入站流程 | 画布配置入站消息处理流程,支持多轮上下文与槽位收集 | 现有 InboundFlow 页面增强 |
> **本期不做**IVR 流程2092 阶段接入)
> **本期不做**IVR 流程、发送策略配置
### 5.7 系统管理
| 二级菜单 | 功能定义 | 页面/备注 |
|---------|---------|----------|
| 租户管理 | 平台管理员创建/编辑租户;租户管理员维护本租户基础信息、认证配置、入站 webhook | 现有 TenantManagement 页面增强 |
| 租户管理 | 平台管理员创建/编辑租户;租户管理员维护本租户基础信息 | 现有 TenantManagement 页面增强 |
| 用户管理 | 中台用户增删改查、重置密码、启用/停用、绑定角色 | 现有 UserManagement 页面迁移 |
| 角色权限 | 平台角色 admin/operator/viewer 的权限配置与分配 | 新增(首期角色固定,可配置权限列表) |
| 角色权限 | 平台角色 admin/operator/viewer 的权限配置与分配 | 新增 |
---
@@ -121,62 +121,678 @@
| 当前菜单 | 调整方式 | 新位置 |
|---------|---------|--------|
| 概览 | 保留并增强 | 概览 → 运营总览 |
| 渠道主体 | 保留 | 渠道接入 → 渠道主体 |
| 渠道账号 | 拆分 | 渠道接入 → 渠道账号 / 账号初始化 / 应用管理 |
| 会话/群聊 | 拆分 | 客户与群 → 会话/群聊 / 账号-会话绑定 |
| 联系人 | 保留,增强 | 客户与群 → 联系人 |
| 标签 | 升级为体系 | 客户与群 → 标签体系 |
| 渠道主体 | 保留并增强 | 渠道接入 → 渠道主体 |
| 渠道账号 | 拆分 | 渠道接入 → 渠道账号 / 应用管理 |
| 会话/群聊 | 保留,绑定功能移入详情 | 客户关系 → 会话/群聊 |
| 联系人 | 保留,增强 | 客户关系 → 联系人 |
| 标签 | 升级为体系 | 客户关系 → 标签体系 |
| 发送记录 | 保留 | 消息运营 → 发送记录 |
| 消息测试 | 保留,改名 | 消息运营 → 测试发送 |
| 发送策略 | 保留 | 消息运营 → 发送策略 |
| 入站流程 | 保留 | 流程编排 → 入站流程 |
| 发送策略 | 本期移除 | — |
| 入站流程 | 保留并增强 | 流程编排 → 入站流程 |
| IVR 流程 | 本期移除 | — |
| 入站 Webhook | 保留 | 流程编排 → 入站 Webhook |
| 流程追踪 | 合并 | 消息运营 → 发送记录(详情)/ 消息查询 |
| 用户管理 | 保留 | 系统管理 → 用户管理 |
| 租户管理 | 保留 | 系统管理 → 租户管理 |
| 账号初始化 | 删除独立菜单 | 能力分散到渠道主体/账号/联系人/会话 |
| 渠道身份 | 删除独立菜单 | 并入联系人详情 |
| 账号-会话绑定 | 删除独立菜单 | 并入会话详情 |
---
## 7. 新增页面清单
## 7. 统一页面设计语言
| 页面 | 所属模块 | 功能说明 |
|------|---------|---------|
| 账号初始化 | 渠道接入 | 引导式初始化各渠道账号 |
| 应用管理 | 渠道接入 | 企微应用等渠道应用配置 |
| 渠道身份 | 客户与群 | 管理 Person 在各渠道的身份映射 |
| 账号-会话绑定 | 客户与群 | 会话与收发账号的绑定管理 |
| 消息查询 | 消息运营 | 统一查询入站/出站消息事件 |
| 角色权限 | 系统管理 | 中台角色与权限配置 |
### 7.1 列表页
- 顶部筛选区(可折叠)
- 右上角「新增」按钮
- 表格展示核心字段 + 标签 + 关联数量
- 行内操作:编辑 / 详情 / 删除 / 启用禁用
- 底部分页
### 7.2 新增
- 弹窗或小抽屉
- 只收集创建必需的字段
- 创建成功后刷新列表
### 7.3 编辑/详情
- 大抽屉
- 基础信息区(可编辑)
- 标签字段(树形多选,随基础信息一起保存)
- 关联内容区(只读列表 + 快捷操作)
- 不同对象按自身关联内容展示,不拆成多个 Tab
---
## 8. 不在本期范围内的功能
## 8. 渠道接入模块详细设计
| 功能 | 原因 | 后续阶段 |
|------|------|---------|
| IVR 流程 | 属于 2092 IVR 语音配置需求,本期聚焦 1851 企微场景 | 2092 阶段 |
| 模板管理 | 中台不保存模板,由业务系统自行维护 | 不涉及 |
| 业务触发规则 | 中台不承载业务系统触发规则 | 不涉及 |
### 8.1 渠道主体
#### 列表字段
| 字段 | 说明 |
|------|------|
| 渠道 | wecom / dingtalk / feishu本期仅 wecom 可用) |
| 主体编码 | `subject_code` |
| 主体名称 | `subject_name` |
| CorpID | `corp_id` |
| 会话存档 | 开启 / 关闭 |
| 状态 | 启用 / 禁用 |
| 操作 | 编辑 / 详情 / 删除 / 启用禁用 |
#### 新增/编辑
- 渠道类型
- 主体编码
- 主体名称
- CorpID
- CorpSecret通讯录同步 secret
- AgentID
- AgentSecret
- 会话存档开关
- 会话存档 RSA 私钥
#### 详情抽屉(企微能力区)
| 能力 | 所需凭证 | 操作 |
|------|---------|------|
| 验证主体连通 | `corp_id + corp_secret` | 点击验证 |
| 同步企业员工 | `corp_id + corp_secret` | 拉取通讯录,写入 `mci_person` + `mci_channel_identity` + `department_path` |
| 同步客户/客户群 | 主体下配置的企微应用 `agent_secret` | 拉取外部联系人和客户群 |
| 同步会话存档 | 应用管理中配置的会话存档应用 secret | 用 Finance SDK 拉取最近消息 |
每个能力执行后展示状态、上次执行时间、成功/失败数量、错误信息,并写入 `mci_channel_subject_sync_task` 日志表。
### 8.2 渠道账号
#### 列表字段
| 字段 | 说明 |
|------|------|
| 账号类型 | 企微内部账号 / 企微应用 / pywechat 个人号 |
| 渠道 | wecom / wechat_personal |
| 账号 ID | `account_id` |
| 名称 | `account_name` |
| 所属主体 | `subject_name` |
| 登录状态 | 已登录 / 未登录 |
| 在线状态 | 在线 / 离线 |
| 状态 | 启用 / 禁用 |
| 操作 | 编辑 / 删除 / 启用禁用 / 初始化 / 同步会话 |
#### 企微内部账号
- 创建/编辑时输入 `userid` 后,可点击「从企微拉取」回填姓名、部门信息
- 会话存档开关(标记该员工是否开启存档)
#### 企微应用
- 应用凭证在「应用管理」维护
- 渠道账号中创建时选择应用,自动带出 `agent_id`
- 提供「验证应用」按钮
#### pywechat 个人号
- 创建时填写 `wxid / bot_code``server_host`
- 行操作「扫码登录」:返回二维码,轮询登录状态
- 行操作「同步会话」:拉取该个人号所在群聊
### 8.3 应用管理
#### 列表字段
| 字段 | 说明 |
|------|------|
| 应用名称 | `app_name` |
| 应用类型 | wecom / wecom_session_archive / dingtalk / feishu |
| agent_id | 企微应用 ID |
| 所属主体 | `subject_name` |
| 状态 | 启用 / 禁用 |
| 操作 | 编辑 / 验证 / 删除 / 启用禁用 |
#### 新增/编辑
- 应用名称
- 所属主体
- 应用类型
- agent_id
- agent_secret
- 状态
#### 操作
- **验证并获取能力**:用 `corp_id + agent_id + agent_secret` 获取 access_token验证通过更新状态
- 钉钉/飞书应用仅保留表单入口,验证按钮禁用
---
## 9. 实现要点
## 9. 客户关系模块详细设计
1. **前端路由**:按新菜单结构重排 `admin-web/src/router/index.ts`,路径建议保持语义化,如 `/channel-subject``/channel-account/init``/customer/person``/message/record`
2. **布局组件**`Layout.vue` 从平铺 `el-menu-item` 改为分组 `el-sub-menu`,支持一级展开/收起。
3. **权限控制**:菜单可见性通过 `authStore.roleCodes` 或后端返回的权限列表控制URL 级权限同步调整。
4. **页面拆分**
- `Conversation.vue` 中的“绑定账号”功能拆出为 `ConversationAccountBinding.vue`
- `ChannelAccount.vue` 中的初始化流程拆出为 `ChannelAccountInit.vue`
5. **空状态处理**:新增页面先实现最小可用版本,避免阻塞菜单上线。
### 9.1 联系人
#### 列表字段
| 字段 | 说明 |
|------|------|
| 人员编码 | `person_code` |
| 姓名 | `person_name` |
| 手机号 | `phone` |
| 类型 | 内部人员 / 供应商人员 / 客户 / 司机 |
| 所属机构 | `institution_id` 名称 |
| 部门路径 | 多个标签式展示 |
| 标签 | 直接展示绑定的标签 |
| 渠道身份数 | 几个渠道身份 |
| 参与会话数 | 该人员在多少个会话中 |
| 状态 | 启用 / 禁用 |
| 操作 | 编辑 / 详情 / 删除 / 启用禁用 |
#### 新增
弹窗字段:
- 人员编码
- 姓名
- 手机号
- 类型
- 所属机构
- 状态
#### 编辑/详情抽屉
**基础信息区**
- 人员编码、姓名、手机号
- 类型
- 所属机构、供应商、合同
- 部门路径(支持多条)
- 状态
**标签字段**
- 树形多选,已选标签平铺展示
**渠道身份区**
- 列表展示所有渠道身份
- 行内新增 / 编辑 / 删除
- 新增时:选择渠道 → 选择主体 → 输入渠道用户 ID → 「拉取企微信息」回填昵称
**所属会话区**
- 展示该人员参与的会话列表
- 点击会话名称打开会话详情抽屉
### 9.2 会话/群聊
#### 列表字段
| 字段 | 说明 |
|------|------|
| 会话类型 | 单聊 / 群聊 |
| 渠道 | wecom / wechat_personal |
| 渠道会话 ID | `channel_conversation_id` |
| 会话名称 | `conversation_name` |
| 群主/负责人 | `owner_account_id` 名称 |
| 所属主体 | `subject_id` 名称 |
| 标签 | 直接展示 |
| 成员数 | 群聊显示成员数量 |
| 业务绑定 | 机构 / 供应商 / 合同 / 渠道编码 |
| 状态 | 启用 / 禁用 |
| 操作 | 编辑 / 详情 / 删除 / 同步成员 |
#### 新增
弹窗字段:
- 会话类型
- 渠道
- 所属主体
- 渠道会话 ID
- 会话名称
- 负责人账号
- 状态
#### 编辑/详情抽屉
**基础信息区**
- 会话类型、渠道、所属主体
- 渠道会话 ID、会话名称
- 负责人账号
- 关联机构 / 供应商 / 合同 / 渠道编码
- 状态
**标签字段**
- 树形多选
**成员区**
- 列表展示 `mci_conversation_participant`
- 字段:人员姓名、渠道身份、角色
- 操作:同步成员、移除
- 人员名称可点击进入联系人详情
**收发账号绑定区**
- 展示发送主号 / 发送备用号 / 接收主号 / 接收备用号
- 行内新增、编辑优先级、删除
- 单聊限制:只能绑定同 `channel_type` 的账号
- 群聊允许跨渠道绑定,但给出提示
**历史消息区**
- 展示该会话最近消息列表
- 可跳转消息查询页面
### 9.3 标签体系
#### 列表字段
| 字段 | 说明 |
|------|------|
| 标签名 | `tag_name` |
| 路径 | `tag_path` |
| 层级 | `level` |
| 来源 | system / business_sync / manual |
| 状态 | 启用 / 禁用 |
| 关联人员数 | 使用数量 |
| 关联会话数 | 使用数量 |
| 操作 | 新增子标签 / 编辑 / 删除 / 启用禁用 |
#### 新增/编辑
弹窗字段:
- 标签名
- 父标签(树形选择,顶级为根)
- 来源
- 状态
#### 详情抽屉
- 标签基本信息
- 关联人员列表(分页)
- 关联会话列表(分页)
---
## 10. 验收标准
## 10. 消息运营模块详细设计
- [ ] 左侧导航按新 6 大模块分组展示,无 IVR 流程入口。
### 10.1 发送记录
#### 页面结构
- 顶部统计卡片:今日发送总数 / 成功 / 失败 / 待发送
- 筛选区:任务 ID、发送时间、渠道、状态、目标类型
- 记录列表
- 详情抽屉
#### 列表字段
| 字段 | 说明 |
|------|------|
| 任务 ID | `task_id` |
| 目标类型 | 单人会话 / 群聊 / 人员 / 标签 / 规则 |
| 目标摘要 | 会话名称 / 人员姓名 / 标签名 / 规则描述 |
| 渠道 | wecom / wechat_personal |
| 发送账号 | 实际使用的账号名称 |
| 状态 | 待发送 / 发送中 / 成功 / 失败 |
| 发送时间 | `send_time` |
| 完成时间 | `finish_time` |
| 操作 | 详情 / 取消(仅待发送) |
#### 详情抽屉
- 基础信息:任务 ID、目标、渠道、发送账号、内容摘要
- 内容区:完整 `content_body`
- 策略与回调:`channel_strategy``callback_url`
- 渠道结果:`channel_result` 格式化展示
- 关联跳转:会话 / 人员 / 账号 / 消息事件
- 时间线:创建 → 发送中 → 完成 / 失败
> 失败固定重试 2 次,由后端策略处理,前台不配置。
### 10.2 测试发送
工具型页面,两个 Tab
#### Tab 1出站测试
| 字段 | 说明 |
|------|------|
| 目标类型 | 会话 / 人员 / 标签 / 规则 |
| 目标 | 根据类型选择或输入 |
| 内容类型 | text / markdown / image / file / news 等 |
| 内容 | 对应内容编辑器 |
| 渠道策略 | 优先渠道、备用渠道 |
| 回调地址 | 可选 |
点击「发送测试」后展示返回的 `task_id`,并刷新发送记录。
#### Tab 2入站模拟
| 字段 | 说明 |
|------|------|
| 事件 ID | 自定义事件 ID |
| 渠道类型 | wecom / wechat_personal |
| 消息来源 | realtime / session_archive |
| 会话 ID | 选择已有会话 |
| 发送人 | 选择人员或输入 channel_user_id |
| 接收账号 | 选择接收账号 |
| 内容类型 | text / image / voice 等 |
| 内容 | 消息内容 |
点击「模拟入站」后触发入站流程编排,展示处理结果。
### 10.3 消息查询
#### 页面布局
三栏式聊天界面:
```
┌─────────────┬─────────────────────┬─────────────┐
│ 会话列表 │ 聊天内容 │ 会话信息 │
│ 搜索/筛选 │ │ 成员/标签 │
└─────────────┴─────────────────────┴─────────────┘
```
#### 左侧:会话列表
- 按时间倒序展示会话
- 每个会话卡片:会话名称、最后一条消息预览、最后消息时间、标签小圆点
- 搜索:会话名称、成员姓名、消息内容
- 筛选:渠道、消息来源(实时/会话存档)、会话类型、标签
#### 中间:聊天内容区
- 按时间顺序展示消息气泡
- 区分入站消息(左侧)、出站消息(右侧)、系统事件(中间)
- 根据 `msgtype` 渲染:文本、图片、语音、视频、文件、撤回提示等
- 支持按时间范围筛选,滚动加载历史消息
#### 右侧:会话信息
- 基础信息:会话名称、渠道、来源、群主
- 业务绑定:机构 / 供应商 / 合同 / 渠道编码
- 标签:直接展示,可编辑
- 成员列表
- 快捷操作:查看发送记录、跳转联系人详情、同步成员
---
## 11. 流程编排模块详细设计
### 11.1 入站 Webhook
每个租户独立配置一个入站推送地址。
#### 页面内容
| 字段 | 说明 |
|------|------|
| Webhook URL | 业务系统接收中台入站消息的地址 |
| 签名算法 | HMAC-SHA256 / HMAC-SHA512 / 无 |
| Webhook Secret | 签名密钥 |
| 超时时间 | 推送超时秒数,默认 10s |
| 失败重试 | 固定重试 2 次 |
#### 操作
- 保存
- 测试推送
- 查看最近推送日志(状态、响应码、响应摘要)
> 这是流程中 Webhook 节点的默认推送地址,只有流程走到该节点才会触发。
### 11.2 入站流程
#### 页面布局
保留三栏画布:
```
┌────────────┬─────────────────────────┬────────────┐
│ 流程列表 │ 流程画布 │ 节点配置 │
│ 组件面板 │ │ │
└────────────┴─────────────────────────┴────────────┘
```
#### 流程节点
| 节点 | 作用 |
|------|------|
| 接收消息 | 流程入口,指定渠道 |
| 加载上下文 | 读取当前会话已有的 intent / slots / 轮次 |
| 意图识别 | LLM / 规则识别本轮意图和槽位 |
| 合并槽位 | 把本轮新槽位合并到会话上下文 |
| 槽位校验 | 检查必需槽位是否齐全 |
| 自动追问 | 缺槽位时自动生成追问文案并回复 |
| Webhook | 槽位齐全后推送给业务系统 |
| 回复消息 | 可配置固定回复 |
| 保存上下文 | 把更新后的 intent / slots 写回 |
| 清空上下文 | Webhook 成功后结束当前会话 |
#### 多轮流程示例
```
接收消息
加载上下文
意图识别(结合上下文)
合并槽位
槽位校验
├─ 缺槽位 ──▶ 自动追问 ──▶ 保存上下文 ──▶ 结束
└─ 槽位齐全 ──▶ Webhook ──▶ 回复客户 ──▶ 清空上下文 ──▶ 结束
```
#### 上下文存储
新增 `mci_conversation_context` 表:
| 字段 | 说明 |
|------|------|
| conversation_id | 会话 ID |
| intent | 当前意图 |
| slots | JSON已收集槽位 |
| missing_slots | JSON还缺哪些槽位 |
| turn_count | 当前轮次 |
| status | collecting / complete / expired |
| expire_time | 上下文过期时间 |
| update_time | 最后更新时间 |
超过一定时间(如 10 分钟)无新消息,上下文自动过期。
---
## 12. 系统管理模块详细设计
### 12.1 租户管理
#### 列表字段
| 字段 | 说明 |
|------|------|
| 租户编码 | `tenant_code` |
| 租户名称 | `tenant_name` |
| 状态 | 启用 / 禁用 |
| 用户数量 | 该租户下用户总数 |
| 创建时间 | `create_time` |
| 操作 | 编辑 / 删除 / 启用禁用 |
**平台管理员**:可操作全部租户。
**租户管理员**:只能查看/编辑本租户,不可删除。
#### 新增(仅平台管理员)
- 租户编码
- 租户名称
- 入站 Webhook URL
- 初始化管理员账号(默认 `admin`
- 初始化管理员密码
- 状态
创建成功后自动创建该租户的第一个管理员用户。
#### 编辑
- 租户名称
- 入站 Webhook URL
- 状态
默认租户(如 `platform`)不可删除。
### 12.2 用户管理
#### 列表字段
| 字段 | 说明 |
|------|------|
| 用户名 | `username` |
| 真实姓名 | `real_name` |
| 手机号 | `phone` |
| 所属租户 | `tenant_name`(平台管理员可见) |
| 角色 | 角色名称,多个用逗号 |
| 状态 | 启用 / 禁用 |
| 创建时间 | `create_time` |
| 操作 | 编辑 / 重置密码 / 删除 / 启用禁用 |
#### 新增
- 所属租户(平台管理员选择,租户管理员固定为本租户)
- 用户名
- 密码
- 真实姓名
- 手机号
- 角色(多选)
- 状态
#### 编辑
可编辑:真实姓名、手机号、角色、状态。
不可编辑:用户名、所属租户。
### 12.3 角色权限
#### 列表字段
| 字段 | 说明 |
|------|------|
| 角色编码 | `role_code` |
| 角色名称 | `role_name` |
| 权限数量 | 已配置的权限项数 |
| 状态 | 启用 / 禁用 |
| 操作 | 配置权限 / 启用禁用 |
**首期固定角色**
| 角色编码 | 角色名称 | 默认权限 |
|---------|---------|---------|
| `platform_admin` | 平台管理员 | 全部权限 |
| `admin` | 租户管理员 | 除租户创建外的全部权限 |
| `operator` | 运营人员 | 发送、查看记录、维护联系人/会话、配置渠道账号 |
| `viewer` | 只读用户 | 仅查看发送记录、统计、消息查询 |
#### 权限配置
抽屉中展示权限树:
```
□ 全部
□ 概览
□ 查看
□ 渠道接入
□ 渠道主体(查看 / 新增 / 编辑 / 删除)
□ 渠道账号(查看 / 新增 / 编辑 / 删除)
□ 应用管理(查看 / 新增 / 编辑 / 删除)
□ 客户关系
□ 联系人(查看 / 新增 / 编辑 / 删除)
□ 会话/群聊(查看 / 新增 / 编辑 / 删除)
□ 标签体系(查看 / 新增 / 编辑 / 删除)
□ 消息运营
□ 发送记录(查看 / 取消)
□ 测试发送
□ 消息查询
□ 流程编排
□ 入站 Webhook查看 / 编辑)
□ 入站流程(查看 / 新增 / 编辑 / 删除 / 发布)
□ 系统管理
□ 租户管理(查看 / 新增 / 编辑 / 删除)
□ 用户管理(查看 / 新增 / 编辑 / 删除 / 重置密码)
□ 角色权限(查看 / 配置)
```
保存后同步到 `mci_platform_role` 表的 `permissions` JSON。
### 12.4 全局管理员登录
- 默认创建 `platform` 租户
- 通过 Flyway 初始化 `admin` 用户,角色 `platform_admin`
- 初始密码在配置文件中设置,首次登录强制修改
---
## 13. 数据模型补充
### 13.1 新增/调整字段
| 表 | 字段 | 类型 | 说明 |
|---|------|------|------|
| `mci_person` | `department_path` | JSON | 多个部门路径,如 `["销售中心/销售一部", "销售中心/华北组"]` |
| `mci_channel_subject_sync_task` | — | 新表 | 记录各渠道主体同步任务 |
| `mci_conversation_context` | — | 新表 | 入站流程多轮会话上下文 |
### 13.2 同步任务日志表
`mci_channel_subject_sync_task`
| 字段 | 说明 |
|------|------|
| subject_id | 渠道主体 ID |
| sync_type | employee / external_contact / group / session_archive |
| status | 0待执行 1执行中 2成功 3失败 |
| total_count | 总数 |
| success_count | 成功数 |
| fail_count | 失败数 |
| error_msg | 错误信息 |
| create_time / finish_time | 时间 |
---
## 14. 不在本期范围内的功能
| 功能 | 原因 |
|------|------|
| IVR 流程 | 属于 2092 IVR 语音配置需求 |
| 发送策略配置 | 本期失败固定重试 2 次,不开放前台配置 |
| 模板管理 | 中台不保存模板 |
| 业务触发规则 | 中台不承载业务系统触发规则 |
| 钉钉/飞书具体能力 | 仅保留入口 |
---
## 15. 实现要点
1. **前端路由**:按新菜单结构重排 `admin-web/src/router/index.ts`
2. **布局组件**`Layout.vue` 从平铺 `el-menu-item` 改为分组 `el-sub-menu`
3. **权限控制**:菜单可见性通过 `authStore.roleCodes` 或后端返回的权限列表控制。
4. **页面拆分与合并**
- 删除 `ChannelAccountInit.vue` 独立页面
- 删除独立的渠道身份、账号-会话绑定页面
- `Conversation.vue` 增强详情抽屉,包含绑定功能
- `Person.vue` 增强详情抽屉,包含渠道身份
5. **消息查询**:新增聊天界面组件。
6. **入站流程**:增强节点配置,新增上下文存储能力。
7. **空状态处理**:新增页面先实现最小可用版本。
---
## 16. 验收标准
- [ ] 左侧导航按新 6 大模块分组展示,无 IVR 流程、发送策略、账号初始化入口。
- [ ] 每个二级菜单有明确页面,无 404。
- [ ] 租户管理员、运营人员、平台管理员看到的菜单符合角色定义。
- [ ] 联系人、会话详情抽屉涵盖所有关联内容。
- [ ] 标签在列表和编辑表单中直接展示/编辑。
- [ ] 消息查询实现聊天界面。
- [ ] 入站流程支持多轮上下文与槽位收集。
- [ ] 现有功能在迁移后行为保持一致。
- [ ] 新增页面至少具备列表/查询能力,复杂编辑可二期补齐。

View File

@@ -0,0 +1,427 @@
# channel-service 与 mci-server 双向集成设计
> **需求来源**:继续完成 pywechat 客户端功能,打通 Python 渠道服务与 Java 消息中台的双向通信。
> **日期**2026-07-10
> **状态**:待实现
> **关联文档**`docs/architecture/2026-07-06-message-customer-interaction-design.md`
---
## 1. 目标与范围
### 1.1 目标
`channel-service`Python FastAPI`mci-server`Java Spring Boot完成双向 HTTP 集成:
- **下行mci-server → channel-service**mci-server 继续通过现有 REST API 调用 channel-service 的发送、登录控制等指令。
- **上行channel-service → mci-server**channel-service 把心跳、登录/在线状态变更、发送结果、异常事件持久化到本地 SQLite并由后台 worker 可靠地推送到 mci-server。
- **可靠性**:服务重启不丢事件;网络异常按指数退避重试;超次进死信;本地事件保留 10 天后自动清理。
### 1.2 范围
- **channel-service**新增事件模型、SQLite 持久化、后台事件发布 worker、mci-server HTTP 客户端、配置项扩展、API 行为调整。
- **mci-server**:新增 `/api/v1/channel-account/event` 接收端点,处理心跳/状态/发送结果事件并更新 `mci_channel_account``mci_send_record`
- **不在本期范围**
- 真实 pywechat/pywinauto 接入(仍为 mock
- RabbitMQ/MQ 等外部消息总线。
- 入站消息从 channel-service 回传(本期只传运行态事件和发送结果)。
---
## 2. 整体架构
```
┌─────────────────────────────────────────────────────────────┐
│ mci-server │
│ Java 21 + Spring Boot 4.1.x │
│ POST /api/v1/channel-account/event │
└───────────────────────┬─────────────────────────────────────┘
│ HTTP 上行事件
┌─────────────────────────────────────────────────────────────┐
│ channel-service │
│ Python 3.11 + FastAPI │
│ │
│ ┌─────────────┐ ┌──────────────────┐ ┌────────────┐ │
│ │ HTTP API │───►│ 业务操作 + 事件 │───►│ outbound_ │ │
│ │ │ │ 写入 SQLite │ │ events │ │
│ └─────────────┘ └──────────────────┘ └─────┬──────┘ │
│ │ │
│ ┌────────────────────┘ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ EventPublisher │ │
│ │ 单线程 worker │ │
│ └────────┬────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ mci_server_client│ │
│ │ HTTP POST │ │
│ └─────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
核心原则:**状态变更/发送操作与事件写入在同一个 SQLite 事务内完成**,保证原子性。
---
## 3. channel-service 设计
### 3.1 新增模块
| 模块 | 路径 | 职责 |
|------|------|------|
| 事件模型 | `app/models/event.py` | `OutboundEvent` 数据类、事件类型枚举、payload schema |
| 事件存储 | `app/repositories/event_store.py` | SQLite 建表、CRUD、事务、清理 |
| 事件发布 | `app/services/event_publisher.py` | 后台单线程 worker、重试、死信 |
| mci-server 客户端 | `app/clients/mci_server_client.py` | HTTP POST 到 mci-server |
| 配置扩展 | `app/config.py` | 新增事件存储、重试、清理相关配置 |
### 3.2 SQLite 事件表 `outbound_events`
```sql
CREATE TABLE IF NOT EXISTS outbound_events (
id TEXT PRIMARY KEY,
event_type TEXT NOT NULL,
payload TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
attempt_count INTEGER NOT NULL DEFAULT 0,
next_retry_at TIMESTAMP NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_error TEXT
);
CREATE INDEX IF NOT EXISTS idx_status_next_retry
ON outbound_events(status, next_retry_at);
CREATE INDEX IF NOT EXISTS idx_created_at
ON outbound_events(created_at);
```
字段说明:
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | TEXT PK | UUID v4`event_id` |
| `event_type` | TEXT | `heartbeat` / `login_status_changed` / `online_status_changed` / `send_result` / `error` |
| `payload` | TEXT | JSON 字符串,见第 3.3 节 |
| `status` | TEXT | `pending` / `retrying` / `delivered` / `dead_letter` |
| `attempt_count` | INTEGER | 已尝试次数,上限 `max_retry` |
| `next_retry_at` | TIMESTAMP | 下次重试时间;`pending` 事件为创建时间 |
| `created_at` | TIMESTAMP | 创建时间,用于 10 天清理 |
| `updated_at` | TIMESTAMP | 更新时间 |
| `last_error` | TEXT | 最近一次失败摘要 |
### 3.3 事件 payload 统一结构
所有事件统一外层:
```json
{
"event_id": "evt_20260710_xxx",
"event_type": "heartbeat",
"account_id": "wx-bot-01",
"tenant_code": "rescue",
"timestamp": "2026-07-10T10:00:00Z",
"data": { }
}
```
`data` 按事件类型定义:
#### heartbeat
```json
{
"login_status": "success",
"online_status": "online",
"risk_level": 10,
"sent_count_today": 5,
"server_host": "win-svr-01"
}
```
#### login_status_changed
```json
{
"previous": "pending",
"current": "success",
"qr_code_url": "https://mock.pywechat.qrcode/...",
"error_message": null
}
```
#### online_status_changed
```json
{
"previous": "offline",
"current": "online"
}
```
#### send_result
```json
{
"task_id": "task_001",
"record_id": 1001,
"conversation_id": "room-001",
"success": true,
"message_id": "msg-123",
"channel_result": {},
"error": null
}
```
> 说明:`record_id` 由 mci-server 在调用 send API 时传入channel-service 原样回传,用于 mci-server 更新 `mci_send_record`。
#### error
```json
{
"operation": "send_text",
"error_code": "ACCOUNT_OFFLINE",
"error_message": "account wx-bot-01 not online"
}
```
### 3.4 API 行为变更
| API | 变更 |
|------|------|
| `POST /v1/wechat-personal/login/start` | 创建/更新账号、生成二维码,并在同一事务写入 `login_status_changed` 事件 |
| `POST /v1/wechat-personal/login/{id}/confirm` | 确认登录后,在同一事务写入 `login_status_changed` + `online_status_changed` |
| `POST /v1/wechat-personal/login/{id}/expire` | 二维码过期后,写入 `login_status_changed` 事件 |
| `POST /v1/wechat-personal/send/text` | 改为**异步受理**:立即返回 `{"accepted": true, "message_id": "..."}`;真实发送完成后写入 `send_result` 事件 |
| `POST /v1/wechat-personal/send/image` | 同上 |
| `POST /v1/heartbeat` | 更新本地状态,并在同一事务写入 `heartbeat` 事件 |
| `POST /v1/admin/retry-dead-letter`(新增) | 手动把 `dead_letter` 状态重置为 `retrying`,用于人工恢复 |
### 3.5 发送异步化与风控
当前仍为 mock 客户端,但接口行为需与真实 pywechat 一致:
1. `send_text` / `send_image` 收到请求后,先校验账号在线、风控等级。
2. 校验通过后返回 `accepted=true`,并立即写入 `send_result` 事件。
3. mock 场景下该事件直接表示成功;真实 pywechat 场景下,发送完成后用同一 `event_id` 更新 `send_result` 事件内容,或删除旧事件后写入最终结果事件。
**风控处理**
- 风控等级 `risk_level >= 80` 的账号,发送请求直接返回失败,不进入重试。
- 因微信风控导致的发送失败(如 `error_code``RISK_CONTROL_BLOCKED``ACCOUNT_BANNED`),事件直接标记为 `dead_letter`,不再重试。
- 因网络超时、mci-server 不可达等**临时错误**,才进入指数退避重试。
- `sent_count_today` 每日重置channel-service 在心跳中上报mci-server 可据此做策略判断。
### 3.6 EventPublisher 后台 worker
- **单线程执行**:使用一个 `asyncio` 任务循环消费,避免并发导致 SQLite 锁竞争或事件乱序。
- **扫描频率**:每 5 秒一次。
- **每次拉取**:最多 100 条 `status IN ('pending','retrying') AND next_retry_at <= now()`,按 `next_retry_at` 升序。
- **推送目标**`{mci_server_url}/api/v1/channel-account/event`
- **成功**`status='delivered'``attempt_count` 不变。
- **失败**
- `attempt_count += 1`
- `next_retry_at = now + base_delay * 2^(attempt_count - 1)`(首次重试为 `base_delay`,第二次为 `2*base_delay`,依此类推)
- `status='retrying'`
- `last_error` 记录 HTTP 状态码或异常摘要
-`attempt_count >= max_retry`,则 `status='dead_letter'`
- **最大重试次数**:由配置 `max_retry` 控制,默认 `5`,上限 `10`(代码中硬上限,防止配置错误)。
- **服务启动**:从 `main.py` 启动时创建 worker启动后自动恢复 `pending` / `retrying` 事件。
- **服务关闭**:优雅关闭时等待当前批次处理完成(超时 5 秒)。
### 3.7 数据清理
- 每天凌晨 3:00 启动清理任务(或每次 worker 循环顺带清理)。
- 删除 `status IN ('delivered','dead_letter')``created_at < CURRENT_TIMESTAMP - INTERVAL '10 days'` 的事件。
- 保留 `pending` / `retrying` 事件,无论创建时间。
### 3.8 配置项
```env
# mci-server 地址
MCI_SERVER_URL=http://localhost:8080/msg-platform
# SQLite 事件库路径
EVENT_STORE_PATH=./data/events.db
# 心跳间隔(秒)
HEARTBEAT_INTERVAL_SECONDS=30
# 最大重试次数(代码硬上限 10
MAX_RETRY=5
# 基础重试延迟(秒)
BASE_RETRY_DELAY_SECONDS=5
# 事件保留天数
EVENT_RETENTION_DAYS=10
# 后台 worker 扫描间隔(秒)
EVENT_PUBLISH_INTERVAL_SECONDS=5
```
---
## 4. mci-server 设计
### 4.1 新增端点
#### POST `/api/v1/channel-account/event`
接收 channel-service 推送的统一事件。
**请求头**
```http
Content-Type: application/json
```
**请求体**:见第 3.3 节统一 payload。
**处理逻辑**
| event_type | 处理 |
|------------|------|
| `heartbeat` | 根据 `account_id` 查询 `mci_channel_account`;更新 `login_status``online_status``last_heartbeat``risk_level``sent_count_today` |
| `login_status_changed` | 更新 `mci_channel_account.login_status`;如果 `current=success` 则同时更新 `online_status=1` |
| `online_status_changed` | 更新 `mci_channel_account.online_status` |
| `send_result` | 根据 `record_id` 查询 `mci_send_record`;更新 `send_status``success=true` 则为 2否则 3`finish_time``channel_result`;成功后调用 `SendCallbackService.sendCallback()` 向业务系统回调 |
| `error` | 记录告警日志,可扩展发送告警通知 |
**状态字段映射**channel-service 字符串 → mci-server 整数):
| channel-service 字段 | mci-server 字段 | 映射关系 |
|----------------------|-----------------|----------|
| `login_status` | `mci_channel_account.login_status` | `pending=0`, `scanning=0`, `success=1`, `expired=2`, `failed=3` |
| `online_status` | `mci_channel_account.online_status` | `offline=0`, `online=1`, `error=2` |
账号定位:使用 `tenant_code` + `account_id` 联合查询 `mci_channel_account`,确保租户隔离。
**幂等**
- 使用 `event_id` 作为幂等键。
- 第一次收到时写入 `mci_channel_event` 去重表;重复事件直接返回 200。
**响应**
```json
{
"code": 0,
"message": "ok",
"data": null,
"request_id": "..."
}
```
### 4.2 保持现有端点
- `POST /api/v1/channel-account/heartbeat`:保持兼容,但建议新接入走 `/event`
- `POST /api/v1/channel-account/init`:继续调用 `WechatPersonalChannelAdapter`,由 adapter 调用 channel-service `/v1/wechat-personal/login/start`
### 4.3 发送流程补齐
为了让 `send_result` 能回写到正确的 `mci_send_record`,需要在 mci-server 调用 channel-service 发送时传入 `record_id`
1. `SendService.dispatchRecord(record)` 调用 `ChannelAdapter.sendMessage(request)`
2. `SendMessageRequest.extra` 中带上 `record_id`
3. `WechatPersonalChannelAdapter.sendMessage()``record_id` 放入 body调用 channel-service `/v1/wechat-personal/send/text``/send/image`
4. channel-service 异步完成后,通过 `/event` 回传 `send_result` 并携带 `record_id`
### 4.4 数据库表扩展
#### 4.4.1 新增幂等表 `mci_channel_event`
用于防止 channel-service 重试导致重复处理。
| 字段 | 类型 | 说明 |
|------|------|------|
| `event_id` | VARCHAR PK | channel-service 事件 ID |
| `account_id` | VARCHAR | 账号 ID |
| `event_type` | VARCHAR | 事件类型 |
| `create_time` | DATETIME | 接收时间 |
索引:`CREATE INDEX idx_account_time ON mci_channel_event(account_id, create_time);`
清理策略:保留 30 天后删除(可配置)。
---
## 5. 错误处理与边界
| 场景 | 处理 |
|------|------|
| mci-server 不可达 | 事件留在 SQLiteworker 按指数退避重试 |
| 服务重启 | 启动时恢复 worker继续推送 `pending` / `retrying` 事件 |
| payload 序列化失败 | 直接标记 `dead_letter`,不阻塞其他事件 |
| mci-server 返回业务错误(如账号不存在) | 视为失败重试;若连续失败达到上限,进 `dead_letter` |
| 微信风控/账号被封 | 对应 `error_code` 直接标记 `dead_letter`,不重试 |
| SQLite 磁盘满/损坏 | worker 记录错误并停止推送;监控告警 |
| 单条事件推送超时 | 使用 `httpx` timeout默认 10s超时即失败进入重试 |
| 重试次数达到上限 | `status='dead_letter'`,记录 `last_error`,等待人工处理或手动重试 |
| 事件堆积 | worker 每批最多 100 条;如果持续堆积,考虑后续改为批量 POST 或引入 MQ |
---
## 6. 测试策略
### 6.1 channel-service 单元测试
- `test_event_store.py`:验证 SQLite CRUD、事务、状态流转、清理。
- `test_event_publisher.py`mock mci-server HTTP验证重试、死信、幂等。
- `test_send_async.py`:验证 send API 改为异步受理后,事件正确写入 `outbound_events`
- `test_heartbeat_event.py`:验证心跳接口产生事件。
### 6.2 mci-server 单元测试
- `ChannelAccountEventControllerTest`:使用 MockWebServer 或 `MockMvc` 接收 channel-service 事件,验证:
- heartbeat 更新 `mci_channel_account`
- send_result 更新 `mci_send_record`
- event_id 幂等
### 6.3 联调测试
- docker-compose 启动 mci-server + channel-service。
- 调用 mci-server 发送 API。
- 验证 channel-service 收到指令、产生 send_result 事件、推回 mci-server。
- 验证 mci-server 更新 SendRecord 并向业务系统 callback_url 回调。
---
## 7. 部署与配置
### 7.1 channel-service 新增环境变量
```yaml
environment:
MCI_SERVER_URL: http://mci-server:8080/msg-platform
EVENT_STORE_PATH: /app/data/events.db
HEARTBEAT_INTERVAL_SECONDS: "30"
MAX_RETRY: "5"
BASE_RETRY_DELAY_SECONDS: "5"
EVENT_RETENTION_DAYS: "10"
EVENT_PUBLISH_INTERVAL_SECONDS: "5"
```
### 7.2 数据卷
```yaml
volumes:
- channel-service-data:/app/data
```
---
## 8. 后续扩展
- 入站消息从 channel-service 回传 mci-server复用 `/event` 新增 `message_received` 类型)。
- 批量事件上报:把多条事件打包为一个 HTTP 请求,降低 mci-server 压力。
- 当事件量增大时,可考虑用 RabbitMQ 替代 SQLite + HTTP worker。
- 真实 pywechat 接入时,把 mock 客户端替换为 `pywechat`/`pywinauto` 驱动,事件模型保持不变。
---
*设计完成,待评审。*

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 KiB