Compare commits
35 Commits
1d2948677e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e09024b000 | |||
| f108465d4f | |||
| 1378649fc7 | |||
| d4b48fb611 | |||
| 0ad12c6df6 | |||
| 168cd2de11 | |||
| 0a326e7ff9 | |||
| c24891a82e | |||
| 11ee7384b3 | |||
| e1c7c582ee | |||
| 128df39113 | |||
| 687269cce6 | |||
| c4caf48bad | |||
| 3a8d51b0df | |||
| aeb14cfa30 | |||
| ed76915f56 | |||
| 0bef33bbb8 | |||
| 4bc643024d | |||
| 895c78d60e | |||
| 93df416954 | |||
| 0e8d3f61d4 | |||
| 0bf48fd449 | |||
| 8c4deb5be4 | |||
| 31d4a34ebb | |||
| 749856c455 | |||
| 758ed73f9d | |||
| 560bd4599b | |||
| 3b8cde2995 | |||
| 82d1450182 | |||
| 302e0ab442 | |||
| 1fabc04d70 | |||
| e3bae8aa1f | |||
| 4b0780c181 | |||
| c3aefc97de | |||
| 1641553d31 |
51
.env.example
51
.env.example
@@ -1,32 +1,59 @@
|
||||
# MySQL
|
||||
# ============================================================
|
||||
# Sino-MCI 通用环境配置模板
|
||||
#
|
||||
# 推荐用法:
|
||||
# cp .env.local .env # 本机开发,连接 localhost 基础设施
|
||||
# cp .env.remote .env # 本机开发,连接远程基础设施(如 192.168.1.173)
|
||||
#
|
||||
# 注意:
|
||||
# 1. .env 文件不会被提交到仓库(已在 .gitignore 中忽略)。
|
||||
# 2. scripts/local-up.sh 启动 docker-compose 时,会自动把 host 覆盖为容器名,
|
||||
# 因此无论 .env 中是 localhost 还是远程 IP,docker-compose 内部都能正确互联。
|
||||
# 3. 若手动执行 docker compose,请确保当前 .env 中的 MYSQL_HOST/REDIS_HOST/
|
||||
# RABBITMQ_HOST 指向可访问地址,或使用 local-up.sh 启动。
|
||||
# ============================================================
|
||||
|
||||
# --------------------------------------------------
|
||||
# 基础设施端口与账号
|
||||
# --------------------------------------------------
|
||||
MYSQL_PORT=3306
|
||||
MYSQL_ROOT_PASSWORD=root
|
||||
MYSQL_DATABASE=sino_mci
|
||||
MYSQL_USER=root
|
||||
MYSQL_PORT=3306
|
||||
|
||||
# Redis
|
||||
REDIS_PORT=6379
|
||||
|
||||
# RabbitMQ
|
||||
RABBITMQ_DEFAULT_USER=guest
|
||||
RABBITMQ_DEFAULT_PASS=guest
|
||||
RABBITMQ_AMQP_PORT=5672
|
||||
RABBITMQ_MGMT_PORT=15672
|
||||
|
||||
# SeaweedFS
|
||||
SEAWEEDFS_MASTER_PORT=9333
|
||||
SEAWEEDFS_S3_PORT=8888
|
||||
|
||||
# Application ports
|
||||
# --------------------------------------------------
|
||||
# 应用服务端口
|
||||
# --------------------------------------------------
|
||||
MCI_SERVER_PORT=8080
|
||||
ADMIN_WEB_PORT=80
|
||||
CHANNEL_SERVICE_PORT=8000
|
||||
|
||||
# JVM options for mci-server
|
||||
JAVA_OPTS=-XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0
|
||||
|
||||
# Spring profile
|
||||
# --------------------------------------------------
|
||||
# Spring Boot / JVM
|
||||
# --------------------------------------------------
|
||||
SPRING_PROFILES_ACTIVE=dev
|
||||
JAVA_OPTS="-XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0"
|
||||
|
||||
# OpenAI API key (optional)
|
||||
OPENAI_API_KEY=your-openai-api-key-here
|
||||
# --------------------------------------------------
|
||||
# Channel Service
|
||||
# --------------------------------------------------
|
||||
MAX_RETRY=5
|
||||
BASE_RETRY_DELAY_SECONDS=5
|
||||
EVENT_RETENTION_DAYS=10
|
||||
EVENT_PUBLISH_INTERVAL_SECONDS=5
|
||||
|
||||
# --------------------------------------------------
|
||||
# OpenAI(可选)
|
||||
# --------------------------------------------------
|
||||
OPENAI_API_KEY=
|
||||
OPENAI_BASE_URL=https://api.openai.com
|
||||
|
||||
50
.env.local
Normal file
50
.env.local
Normal file
@@ -0,0 +1,50 @@
|
||||
# ============================================================
|
||||
# Sino-MCI 本地开发环境(本机启动后端/前端时加载)
|
||||
# 使用方式:cp .env.local .env
|
||||
# 说明:应用服务连接本机 localhost 上的基础设施
|
||||
# ============================================================
|
||||
|
||||
# 基础设施连接地址
|
||||
MYSQL_HOST=localhost
|
||||
REDIS_HOST=localhost
|
||||
RABBITMQ_HOST=localhost
|
||||
SEAWEEDFS_HOST=localhost
|
||||
|
||||
# 基础设施端口与账号
|
||||
MYSQL_PORT=3306
|
||||
MYSQL_ROOT_PASSWORD=root
|
||||
MYSQL_DATABASE=sino_mci
|
||||
MYSQL_USER=root
|
||||
|
||||
REDIS_PORT=6379
|
||||
|
||||
RABBITMQ_DEFAULT_USER=guest
|
||||
RABBITMQ_DEFAULT_PASS=guest
|
||||
RABBITMQ_AMQP_PORT=5672
|
||||
RABBITMQ_MGMT_PORT=15672
|
||||
|
||||
SEAWEEDFS_MASTER_PORT=9333
|
||||
SEAWEEDFS_S3_PORT=8888
|
||||
|
||||
# 应用服务端口
|
||||
MCI_SERVER_PORT=8080
|
||||
ADMIN_WEB_PORT=80
|
||||
CHANNEL_SERVICE_PORT=8000
|
||||
|
||||
# 应用服务地址
|
||||
MCI_SERVER_URL=http://localhost:8080/msg-platform
|
||||
VITE_API_BASE_URL=http://localhost:8080
|
||||
|
||||
# Spring Boot / JVM
|
||||
SPRING_PROFILES_ACTIVE=dev
|
||||
JAVA_OPTS="-XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0"
|
||||
|
||||
# Channel Service
|
||||
MAX_RETRY=5
|
||||
BASE_RETRY_DELAY_SECONDS=5
|
||||
EVENT_RETENTION_DAYS=10
|
||||
EVENT_PUBLISH_INTERVAL_SECONDS=5
|
||||
|
||||
# OpenAI(可选)
|
||||
OPENAI_API_KEY=
|
||||
OPENAI_BASE_URL=https://api.openai.com
|
||||
50
.env.remote
Normal file
50
.env.remote
Normal file
@@ -0,0 +1,50 @@
|
||||
# ============================================================
|
||||
# Sino-MCI 远程基础设施环境(本机启动后端/前端时加载)
|
||||
# 使用方式:cp .env.remote .env
|
||||
# 说明:只启动本机应用服务,连接远程 MySQL/Redis/RabbitMQ/SeaweedFS
|
||||
# ============================================================
|
||||
|
||||
# 基础设施连接地址
|
||||
MYSQL_HOST=192.168.1.173
|
||||
REDIS_HOST=192.168.1.173
|
||||
RABBITMQ_HOST=192.168.1.173
|
||||
SEAWEEDFS_HOST=192.168.1.173
|
||||
|
||||
# 基础设施端口与账号
|
||||
MYSQL_PORT=3306
|
||||
MYSQL_ROOT_PASSWORD=root
|
||||
MYSQL_DATABASE=sino_mci
|
||||
MYSQL_USER=root
|
||||
|
||||
REDIS_PORT=6379
|
||||
|
||||
RABBITMQ_DEFAULT_USER=guest
|
||||
RABBITMQ_DEFAULT_PASS=guest
|
||||
RABBITMQ_AMQP_PORT=5672
|
||||
RABBITMQ_MGMT_PORT=15672
|
||||
|
||||
SEAWEEDFS_MASTER_PORT=9333
|
||||
SEAWEEDFS_S3_PORT=8888
|
||||
|
||||
# 应用服务端口
|
||||
MCI_SERVER_PORT=8080
|
||||
ADMIN_WEB_PORT=80
|
||||
CHANNEL_SERVICE_PORT=8000
|
||||
|
||||
# 应用服务地址
|
||||
MCI_SERVER_URL=http://192.168.1.173:8080/msg-platform
|
||||
VITE_API_BASE_URL=http://192.168.1.173:8080
|
||||
|
||||
# Spring Boot / JVM
|
||||
SPRING_PROFILES_ACTIVE=dev
|
||||
JAVA_OPTS="-XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0"
|
||||
|
||||
# Channel Service
|
||||
MAX_RETRY=5
|
||||
BASE_RETRY_DELAY_SECONDS=5
|
||||
EVENT_RETENTION_DAYS=10
|
||||
EVENT_PUBLISH_INTERVAL_SECONDS=5
|
||||
|
||||
# OpenAI(可选)
|
||||
OPENAI_API_KEY=
|
||||
OPENAI_BASE_URL=https://api.openai.com
|
||||
116
.stitch/DESIGN.md
Normal file
116
.stitch/DESIGN.md
Normal 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.
|
||||
656
.stitch/designs/sino-mci-prototype.html
Normal file
656
.stitch/designs/sino-mci-prototype.html
Normal 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><</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>></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><</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>></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><</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>></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><</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>></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><</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>></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><</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>></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><</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>></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>
|
||||
196
AGENTS.md
Normal file
196
AGENTS.md
Normal file
@@ -0,0 +1,196 @@
|
||||
# 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` 统一管理 MySQL、Redis、RabbitMQ、SeaweedFS 以及应用服务的连接地址。
|
||||
|
||||
### 5.1 快速切换本地/远程基础设施
|
||||
|
||||
```bash
|
||||
cd /Users/marsal/Projects/sino-project/sino-mci
|
||||
|
||||
# 方案 A:连接本机 localhost 上的基础设施(默认)
|
||||
cp .env.local .env
|
||||
|
||||
# 方案 B:连接远程基础设施(如 192.168.1.173)
|
||||
cp .env.remote .env
|
||||
```
|
||||
|
||||
`.env` 文件不会被提交(已加入 `.gitignore`),可以按需要随时切换。
|
||||
|
||||
### 5.2 本地启动后端/前端/Channel Service
|
||||
|
||||
```bash
|
||||
# 后端
|
||||
./scripts/run-local.sh backend
|
||||
|
||||
# 前端
|
||||
./scripts/run-local.sh frontend
|
||||
|
||||
# Channel Service
|
||||
./scripts/run-local.sh channel
|
||||
```
|
||||
|
||||
`run-local.sh` 会自动加载项目根目录的 `.env`,并把 `MYSQL_HOST`、`REDIS_HOST`、`RABBITMQ_HOST`、`MCI_SERVER_URL`、`VITE_API_BASE_URL` 等变量注入到对应进程中。
|
||||
|
||||
### 5.3 Docker Compose 启动(不受 .env 中 host 影响)
|
||||
|
||||
```bash
|
||||
# 只启动基础设施
|
||||
./scripts/local-up.sh
|
||||
|
||||
# 启动基础设施 + 应用服务
|
||||
./scripts/local-up.sh --with-apps
|
||||
```
|
||||
|
||||
`local-up.sh` 会自动把 host 覆盖为 Docker 容器名(`mci-mysql`、`mci-redis`、`mci-rabbitmq`、`mci-server`),因此无论 `.env` 中是 `localhost` 还是远程 IP,容器内部都能正确互联。
|
||||
|
||||
### 5.4 手动执行 docker compose 的注意事项
|
||||
|
||||
若直接运行 `docker compose` 命令,容器会使用当前 `.env` 中的 `MYSQL_HOST` 等变量。请确保 `.env` 中的 host 对容器可访问,或改用 `local-up.sh` 启动。
|
||||
|
||||
### 5.5 默认账号
|
||||
|
||||
- MySQL root 密码:`root`(仅本地/内网开发使用)
|
||||
- 平台管理员:`superadmin` / `admin123`
|
||||
|
||||
### 5.6 新增环境/自定义地址
|
||||
|
||||
复制任意一份模板后修改:
|
||||
|
||||
```bash
|
||||
cp .env.local .env
|
||||
# 编辑 .env 中的 MYSQL_HOST、REDIS_HOST、RABBITMQ_HOST、MCI_SERVER_URL、VITE_API_BASE_URL
|
||||
```
|
||||
|
||||
## 6. 本地调试(IDE)
|
||||
|
||||
### 6.1 后端 Spring Boot 调试
|
||||
|
||||
1. 切换环境:
|
||||
```bash
|
||||
cd /Users/marsal/Projects/sino-project/sino-mci
|
||||
cp .env.local .env # 连接本地 docker-compose 基础设施
|
||||
# 或 cp .env.remote .env # 连接远程基础设施(如 192.168.1.173)
|
||||
```
|
||||
|
||||
2. 在 IntelliJ IDEA 中创建 Run Configuration:
|
||||
- **Main class**: `com.sino.mci.server.MciServerApplication`
|
||||
- **Active profiles**: `dev`
|
||||
- **Environment variables**: 填入 `.env` 中的关键变量,例如:
|
||||
```
|
||||
MYSQL_HOST=localhost;REDIS_HOST=localhost;RABBITMQ_HOST=localhost;MYSQL_PORT=3306;MYSQL_ROOT_PASSWORD=root;MYSQL_DATABASE=sino_mci;MYSQL_USER=root;REDIS_PORT=6379;RABBITMQ_DEFAULT_USER=guest;RABBITMQ_DEFAULT_PASS=guest;RABBITMQ_AMQP_PORT=5672
|
||||
```
|
||||
|
||||
> 也可安装 EnvFile / .env 插件让 IDE 自动加载 `.env`。
|
||||
|
||||
3. 启动 Debug,访问 `http://localhost:8080/msg-platform`。
|
||||
|
||||
### 6.2 前端 Vite 调试
|
||||
|
||||
```bash
|
||||
cd admin-web
|
||||
npm run dev
|
||||
```
|
||||
|
||||
代理目标由 `VITE_API_BASE_URL` 决定(定义在 `.env` 中):
|
||||
- `.env.local`:指向 `http://localhost:8080`
|
||||
- `.env.remote`:指向 `http://192.168.1.173:8080`
|
||||
|
||||
### 6.3 Channel Service 调试
|
||||
|
||||
```bash
|
||||
cd channel-service
|
||||
source .venv/bin/activate
|
||||
python -m app.main
|
||||
```
|
||||
|
||||
`MCI_SERVER_URL` 从 `.env` 加载,指向后端地址。
|
||||
47
admin-web/REFACTOR_GUIDE.md
Normal file
47
admin-web/REFACTOR_GUIDE.md
Normal 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`。
|
||||
|
||||
### 抽屉内容示例
|
||||
- 对象名称/标题
|
||||
- 基本信息(描述列表或表单)
|
||||
- 关联信息(应用、账号、标签、会话等)
|
||||
- 底部:保存 / 取消 / 删除
|
||||
24
admin-web/package-lock.json
generated
24
admin-web/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
BIN
admin-web/screenshots/02-bot-created.png
Normal file
BIN
admin-web/screenshots/02-bot-created.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 86 KiB |
BIN
admin-web/screenshots/04-internal-group-created.png
Normal file
BIN
admin-web/screenshots/04-internal-group-created.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 88 KiB |
BIN
admin-web/screenshots/05-bot-bound.png
Normal file
BIN
admin-web/screenshots/05-bot-bound.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 106 KiB |
BIN
admin-web/screenshots/06-message-test.jpeg
Normal file
BIN
admin-web/screenshots/06-message-test.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
BIN
admin-web/screenshots/07-message-sent.png
Normal file
BIN
admin-web/screenshots/07-message-sent.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 71 KiB |
BIN
admin-web/screenshots/08-task-status.png
Normal file
BIN
admin-web/screenshots/08-task-status.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 95 KiB |
@@ -2,6 +2,10 @@ import client from './client'
|
||||
export * from './channelSubject'
|
||||
export * from './channelAccount'
|
||||
|
||||
export interface ChannelIdentityQuery {
|
||||
keyword?: string
|
||||
}
|
||||
|
||||
export function createConversation(data: any) {
|
||||
return client.post('/api/v1/conversation', data)
|
||||
}
|
||||
@@ -25,3 +29,15 @@ export function bindConversationAccount(conversationId: number, data: any) {
|
||||
export function bindConversationTags(conversationId: number, data: any) {
|
||||
return client.post(`/api/v1/conversation/${conversationId}/bind-tags`, data)
|
||||
}
|
||||
|
||||
export function listChannelIdentities(params?: ChannelIdentityQuery) {
|
||||
return client.get('/api/v1/channel-identity/list', { params })
|
||||
}
|
||||
|
||||
export function listConversationParticipants(conversationId: number) {
|
||||
return client.get(`/api/v1/conversation/${conversationId}/participants`)
|
||||
}
|
||||
|
||||
export function listConversationTags(conversationId: number) {
|
||||
return client.get(`/api/v1/conversation/${conversationId}/tags`)
|
||||
}
|
||||
|
||||
@@ -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`)
|
||||
}
|
||||
|
||||
9
admin-web/src/api/syncTask.ts
Normal file
9
admin-web/src/api/syncTask.ts
Normal 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`)
|
||||
}
|
||||
@@ -1,64 +1,120 @@
|
||||
<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-menu-item index="/channel/identity">渠道身份</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 +122,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() {
|
||||
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>
|
||||
|
||||
61
admin-web/src/components/PageCard.vue
Normal file
61
admin-web/src/components/PageCard.vue
Normal 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>
|
||||
@@ -2,19 +2,17 @@ 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 ChannelIdentity from '../views/ChannelIdentity.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 +28,18 @@ 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: 'channel/identity', component: ChannelIdentity, 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: '用户管理' } },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,54 +28,57 @@
|
||||
{{ 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 }">
|
||||
<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="类型">
|
||||
<el-option label="企微内部账号" :value="1" />
|
||||
<el-option label="企微应用" :value="2" />
|
||||
<el-option label="pywechat 个人号" :value="3" />
|
||||
<el-option label="企微群机器人" :value="6" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="渠道">
|
||||
@@ -79,7 +94,7 @@
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="账号ID">
|
||||
<el-form-item :label="dialogForm.accountType === 6 ? 'Webhook Key / Robot ID' : '账号ID'">
|
||||
<el-input v-model="dialogForm.accountId" placeholder="账号唯一标识" />
|
||||
</el-form-item>
|
||||
<el-form-item label="名称">
|
||||
@@ -91,8 +106,8 @@
|
||||
<el-form-item label="Server Host">
|
||||
<el-input v-model="dialogForm.serverHost" placeholder="服务地址" />
|
||||
</el-form-item>
|
||||
<el-form-item label="扩展信息">
|
||||
<el-input v-model="dialogForm.extInfo" type="textarea" :rows="3" placeholder="JSON 扩展信息" />
|
||||
<el-form-item :label="dialogForm.accountType === 6 ? 'Webhook URL(可选,默认使用官方 URL)' : '扩展信息'">
|
||||
<el-input v-model="dialogForm.extInfo" type="textarea" :rows="3" :placeholder="dialogForm.accountType === 6 ? 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=...' : 'JSON 扩展信息'" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
@@ -101,6 +116,64 @@
|
||||
</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-option label="企微群机器人" :value="6" />
|
||||
</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="drawerForm.accountType === 6 ? 'Webhook Key / Robot ID' : '账号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="drawerForm.accountType === 6 ? 'Webhook URL(可选,默认使用官方 URL)' : '扩展信息'">
|
||||
<el-input v-model="drawerForm.extInfo" type="textarea" :rows="3" :placeholder="drawerForm.accountType === 6 ? 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=...' : '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 +187,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 +202,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 +223,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 +276,51 @@ 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 parseBotExtInfo(extInfo: string | undefined): string {
|
||||
if (!extInfo) return ''
|
||||
if (!extInfo.trim().startsWith('{')) return extInfo
|
||||
try {
|
||||
const parsed = JSON.parse(extInfo)
|
||||
if (parsed && typeof parsed === 'object' && parsed.webhook_url) {
|
||||
return parsed.webhook_url
|
||||
}
|
||||
} catch {
|
||||
// fallback to legacy plain URL
|
||||
}
|
||||
return 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.accountType === 6 ? parseBotExtInfo(row.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() {
|
||||
@@ -206,15 +341,18 @@ async function loadSubjects() {
|
||||
}
|
||||
}
|
||||
|
||||
function buildAccountPayload(form: typeof dialogForm) {
|
||||
const payload: any = { ...form }
|
||||
if (form.accountType === 6 && form.extInfo && form.extInfo.trim()) {
|
||||
payload.extInfo = JSON.stringify({ webhook_url: form.extInfo.trim() })
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
try {
|
||||
if (isEdit.value && editingId.value != null) {
|
||||
await updateChannelAccount(editingId.value, dialogForm)
|
||||
ElMessage.success('更新成功')
|
||||
} else {
|
||||
await createChannelAccount(dialogForm)
|
||||
await createChannelAccount(buildAccountPayload(dialogForm))
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
await loadAccounts()
|
||||
} catch (e: any) {
|
||||
@@ -222,11 +360,25 @@ async function handleSave() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDrawerSave() {
|
||||
if (!currentAccount.value?.id) return
|
||||
try {
|
||||
await updateChannelAccount(currentAccount.value.id, buildAccountPayload(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 +411,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 +458,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)
|
||||
}
|
||||
@@ -293,6 +486,7 @@ function accountTypeLabel(type: number) {
|
||||
1: '企微内部账号',
|
||||
2: '企微应用',
|
||||
3: 'pywechat 个人号',
|
||||
6: '企微群机器人',
|
||||
}
|
||||
return map[type] || type
|
||||
}
|
||||
@@ -315,9 +509,6 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.toolbar {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.status-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
@@ -336,6 +527,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;
|
||||
|
||||
221
admin-web/src/views/ChannelApp.vue
Normal file
221
admin-web/src/views/ChannelApp.vue
Normal 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>
|
||||
112
admin-web/src/views/ChannelIdentity.vue
Normal file
112
admin-web/src/views/ChannelIdentity.vue
Normal file
@@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<PageCard>
|
||||
<template #toolbar-left>
|
||||
<el-input
|
||||
v-model="searchKeyword"
|
||||
placeholder="搜索渠道用户ID / 渠道用户名 / 联系人姓名"
|
||||
clearable
|
||||
style="width: 320px"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</template>
|
||||
|
||||
<el-table :data="pagedIdentities" class="sino-table">
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="channelType" label="渠道类型" width="110">
|
||||
<template #default="{ row }">
|
||||
{{ channelLabel(row.channelType) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="channelUserId" label="渠道用户ID" show-overflow-tooltip />
|
||||
<el-table-column prop="channelUserName" label="渠道用户名" show-overflow-tooltip />
|
||||
<el-table-column prop="subjectName" label="所属主体" show-overflow-tooltip />
|
||||
<el-table-column prop="personName" label="关联联系人" show-overflow-tooltip />
|
||||
<el-table-column prop="personType" label="人员类型" width="110">
|
||||
<template #default="{ row }">
|
||||
{{ formatPersonType(row.personType) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="120">
|
||||
<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="currentPage"
|
||||
v-model:page-size="pageSize"
|
||||
:total="filteredIdentities.length"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
background
|
||||
/>
|
||||
</template>
|
||||
</PageCard>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { Search } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { listChannelIdentities } from '@/api/channel'
|
||||
import PageCard from '@/components/PageCard.vue'
|
||||
|
||||
const identities = ref<any[]>([])
|
||||
const searchKeyword = ref('')
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(10)
|
||||
|
||||
const filteredIdentities = computed(() => {
|
||||
const kw = searchKeyword.value.trim().toLowerCase()
|
||||
if (!kw) return identities.value
|
||||
return identities.value.filter((item) =>
|
||||
[item.channelUserId, item.channelUserName, item.personName].some((v) =>
|
||||
String(v || '').toLowerCase().includes(kw)
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
const pagedIdentities = computed(() => {
|
||||
const start = (currentPage.value - 1) * pageSize.value
|
||||
return filteredIdentities.value.slice(start, start + pageSize.value)
|
||||
})
|
||||
|
||||
watch([searchKeyword], () => {
|
||||
currentPage.value = 1
|
||||
})
|
||||
|
||||
function channelLabel(type: string | undefined) {
|
||||
const map: Record<string, string> = {
|
||||
wecom: '企微',
|
||||
wechat_personal: '个微',
|
||||
dingtalk: '钉钉',
|
||||
feishu: '飞书',
|
||||
ivr: 'IVR',
|
||||
}
|
||||
return map[type || ''] || type || '-'
|
||||
}
|
||||
|
||||
function formatPersonType(type: number | undefined) {
|
||||
if (type === 3) return '客户'
|
||||
if (type == null) return '-'
|
||||
return '内部'
|
||||
}
|
||||
|
||||
async function loadIdentities() {
|
||||
try {
|
||||
const res: any = await listChannelIdentities({ keyword: searchKeyword.value.trim() })
|
||||
identities.value = res.data || []
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadIdentities)
|
||||
</script>
|
||||
@@ -1,46 +1,64 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="openCreateDialog">创建渠道主体</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="subjects" border>
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="channelType" label="渠道">
|
||||
<template #default="{ row }">
|
||||
{{ channelLabel(row.channelType) }}
|
||||
<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>
|
||||
</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 #toolbar-right>
|
||||
<el-button type="primary" @click="openCreateDialog">新增主体</el-button>
|
||||
</template>
|
||||
|
||||
<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 }">
|
||||
<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>
|
||||
<div class="status-cell" @click.stop>
|
||||
<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>
|
||||
<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>
|
||||
<!-- 详情/编辑:右侧抽屉 -->
|
||||
<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>
|
||||
</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>
|
||||
|
||||
@@ -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 }">
|
||||
@@ -12,6 +28,11 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="channelType" label="渠道" />
|
||||
<el-table-column prop="conversationScene" label="场景" width="100">
|
||||
<template #default="{ row }">
|
||||
{{ formatConversationScene(row.conversationScene, row.conversationType) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="channelConversationId" label="渠道会话ID" show-overflow-tooltip />
|
||||
<el-table-column prop="conversationName" label="名称" />
|
||||
<el-table-column prop="ownerAccountId" label="负责人">
|
||||
@@ -19,35 +40,32 @@
|
||||
{{ 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%">
|
||||
@@ -58,8 +76,14 @@
|
||||
<el-form-item label="渠道">
|
||||
<el-input v-model="dialogForm.channelType" placeholder="wecom" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="dialogForm.conversationType === 2" label="场景">
|
||||
<el-select v-model="dialogForm.conversationScene" placeholder="选择场景" style="width: 100%">
|
||||
<el-option label="内部群" :value="1" />
|
||||
<el-option label="外部群" :value="2" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="渠道会话ID">
|
||||
<el-input v-model="dialogForm.channelConversationId" :disabled="isEdit" placeholder="roomid_xxx" />
|
||||
<el-input v-model="dialogForm.channelConversationId" placeholder="roomid_xxx" />
|
||||
</el-form-item>
|
||||
<el-form-item label="名称">
|
||||
<el-input v-model="dialogForm.conversationName" placeholder="群名称" />
|
||||
@@ -90,6 +114,99 @@
|
||||
</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%">
|
||||
<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 v-if="dialogForm.conversationType === 2" label="场景">
|
||||
<el-select v-model="dialogForm.conversationScene" placeholder="选择场景" style="width: 100%">
|
||||
<el-option label="内部群" :value="1" />
|
||||
<el-option label="外部群" :value="2" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="渠道会话ID">
|
||||
<el-input v-model="dialogForm.channelConversationId" :disabled="isEdit" 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>
|
||||
|
||||
<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>
|
||||
|
||||
<el-divider />
|
||||
|
||||
<div class="detail-section">
|
||||
<div class="section-title">群成员</div>
|
||||
<el-table :data="participants" class="sino-table" size="small" max-height="320">
|
||||
<el-table-column prop="participantType" label="类型" width="90">
|
||||
<template #default="{ row }">
|
||||
{{ formatParticipantType(row.participantType) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="channelUserId" label="渠道用户ID" show-overflow-tooltip />
|
||||
<el-table-column prop="personName" label="姓名" show-overflow-tooltip />
|
||||
<el-table-column prop="personType" label="人员类型" width="100">
|
||||
<template #default="{ row }">
|
||||
{{ formatParticipantPersonType(row.personType) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<el-divider />
|
||||
|
||||
<div class="detail-section">
|
||||
<div class="section-title">已绑定标签</div>
|
||||
<div v-if="boundTags.length" class="tag-list">
|
||||
<el-tag v-for="t in boundTags" :key="t.id" class="tag-item" size="small">
|
||||
{{ t.tagName }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<el-empty v-else description="暂无标签" :image-size="60" />
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<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-drawer>
|
||||
|
||||
<el-dialog v-model="bindAccountVisible" title="绑定账号" width="500px">
|
||||
<el-form :model="bindAccountForm" label-width="100px">
|
||||
<el-form-item label="账号">
|
||||
@@ -138,11 +255,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,
|
||||
@@ -150,10 +268,13 @@ import {
|
||||
createConversation,
|
||||
deleteConversation,
|
||||
listChannelAccounts,
|
||||
listConversationParticipants,
|
||||
listConversationTags,
|
||||
listConversations,
|
||||
updateConversation,
|
||||
} from '@/api/channel'
|
||||
import { listTags } from '@/api/crm'
|
||||
import PageCard from '@/components/PageCard.vue'
|
||||
|
||||
const conversations = ref<any[]>([])
|
||||
const accounts = ref<any[]>([])
|
||||
@@ -166,15 +287,55 @@ const editingId = ref<number | null>(null)
|
||||
const dialogForm = reactive({
|
||||
conversationType: 2,
|
||||
channelType: 'wecom',
|
||||
conversationScene: undefined as number | undefined,
|
||||
channelConversationId: '',
|
||||
conversationName: '',
|
||||
ownerAccountId: undefined as number | undefined,
|
||||
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 participants = ref<any[]>([])
|
||||
const boundTags = 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
|
||||
})
|
||||
|
||||
watch(() => dialogForm.conversationType, (val) => {
|
||||
if (val === 1) {
|
||||
dialogForm.conversationScene = undefined
|
||||
}
|
||||
})
|
||||
|
||||
function resetDialogForm() {
|
||||
dialogForm.conversationType = 2
|
||||
dialogForm.channelType = 'wecom'
|
||||
dialogForm.conversationScene = undefined
|
||||
dialogForm.channelConversationId = ''
|
||||
dialogForm.conversationName = ''
|
||||
dialogForm.ownerAccountId = undefined
|
||||
@@ -184,6 +345,7 @@ function resetDialogForm() {
|
||||
function fillDialogForm(row: any) {
|
||||
dialogForm.conversationType = row.conversationType ?? 2
|
||||
dialogForm.channelType = row.channelType ?? 'wecom'
|
||||
dialogForm.conversationScene = row.conversationScene ?? undefined
|
||||
dialogForm.channelConversationId = row.channelConversationId ?? ''
|
||||
dialogForm.conversationName = row.conversationName ?? ''
|
||||
dialogForm.ownerAccountId = row.ownerAccountId
|
||||
@@ -197,11 +359,16 @@ 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
|
||||
loadParticipants(row.id)
|
||||
loadBoundTags(row.id)
|
||||
}
|
||||
|
||||
const accountMap = computed(() => {
|
||||
@@ -218,11 +385,20 @@ function formatAccountName(id: number | undefined) {
|
||||
}
|
||||
|
||||
const filteredBindAccounts = computed(() => {
|
||||
const scene = currentBindConversation.value?.conversationScene
|
||||
const channelType = currentBindConversation.value?.channelType
|
||||
if (!channelType) return accounts.value
|
||||
if (scene === 1) {
|
||||
return accounts.value.filter((a: any) => a.accountType === 6)
|
||||
}
|
||||
if (scene === 2) {
|
||||
return accounts.value.filter((a: any) => a.accountType === 3)
|
||||
}
|
||||
if (channelType) {
|
||||
return accounts.value.filter((a: any) =>
|
||||
a.channelType?.toLowerCase() === channelType?.toLowerCase()
|
||||
)
|
||||
}
|
||||
return accounts.value
|
||||
})
|
||||
|
||||
function formatConversationType(type: number | undefined) {
|
||||
@@ -231,6 +407,52 @@ function formatConversationType(type: number | undefined) {
|
||||
return type ?? '-'
|
||||
}
|
||||
|
||||
function formatConversationScene(scene: number | undefined, type?: number) {
|
||||
if (type === 1) return '-'
|
||||
if (scene === 1) return '内部群'
|
||||
if (scene === 2) return '外部群'
|
||||
return scene ?? '-'
|
||||
}
|
||||
|
||||
function formatParticipantType(type: number | undefined) {
|
||||
if (type === 1) return '群主'
|
||||
return '成员'
|
||||
}
|
||||
|
||||
function formatParticipantPersonType(type: number | undefined) {
|
||||
if (type === 3) return '客户'
|
||||
if (type == null) return '-'
|
||||
return '内部'
|
||||
}
|
||||
|
||||
async function loadParticipants(conversationId: number | undefined) {
|
||||
if (!conversationId) {
|
||||
participants.value = []
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res: any = await listConversationParticipants(conversationId)
|
||||
participants.value = res.data || []
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e.message)
|
||||
participants.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBoundTags(conversationId: number | undefined) {
|
||||
if (!conversationId) {
|
||||
boundTags.value = []
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res: any = await listConversationTags(conversationId)
|
||||
boundTags.value = res.data || []
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e.message)
|
||||
boundTags.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function loadConversations() {
|
||||
try {
|
||||
const res: any = await listConversations()
|
||||
@@ -267,6 +489,7 @@ async function handleSave() {
|
||||
const payload = {
|
||||
conversationType: dialogForm.conversationType,
|
||||
channelType: dialogForm.channelType,
|
||||
conversationScene: dialogForm.conversationScene,
|
||||
channelConversationId: dialogForm.channelConversationId,
|
||||
conversationName: dialogForm.conversationName,
|
||||
ownerAccountId: dialogForm.ownerAccountId,
|
||||
@@ -281,6 +504,7 @@ async function handleSave() {
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
drawerVisible.value = false
|
||||
await loadConversations()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e.message)
|
||||
@@ -292,6 +516,7 @@ async function handleStatusChange(row: any, val: any) {
|
||||
await updateConversation(row.id, {
|
||||
conversationType: row.conversationType,
|
||||
channelType: row.channelType,
|
||||
conversationScene: row.conversationScene,
|
||||
channelConversationId: row.channelConversationId,
|
||||
conversationName: row.conversationName,
|
||||
ownerAccountId: row.ownerAccountId,
|
||||
@@ -314,6 +539,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 +579,6 @@ async function handleBindAccount() {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const currentConversationId = ref<number | undefined>()
|
||||
const bindTagsVisible = ref(false)
|
||||
const bindTagsForm = reactive({
|
||||
@@ -385,7 +610,33 @@ 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;
|
||||
}
|
||||
|
||||
.detail-section {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.tag-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tag-item {
|
||||
margin-right: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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>
|
||||
<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>
|
||||
</el-card>
|
||||
</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>
|
||||
|
||||
@@ -1,18 +1,32 @@
|
||||
<template>
|
||||
<div class="inbound-flow">
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<el-button type="primary" size="small" @click="createNewFlow">新建</el-button>
|
||||
</div>
|
||||
<el-table
|
||||
:data="flows"
|
||||
:data="pagedFlows"
|
||||
highlight-current-row
|
||||
size="small"
|
||||
@row-click="handleFlowRowClick"
|
||||
class="sino-table"
|
||||
>
|
||||
<el-table-column prop="flowCode" label="编码" show-overflow-tooltip />
|
||||
<el-table-column prop="flowName" label="名称" show-overflow-tooltip />
|
||||
@@ -29,6 +43,17 @@
|
||||
</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">
|
||||
@@ -199,11 +224,58 @@
|
||||
</template>
|
||||
</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>
|
||||
|
||||
189
admin-web/src/views/Messages.vue
Normal file
189
admin-web/src/views/Messages.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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 #footer>
|
||||
<el-button @click="testDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleTestSend">确认发送</el-button>
|
||||
</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>
|
||||
</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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,40 +1,57 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="openCreateDialog">创建租户</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="tenants" border>
|
||||
<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">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 1 ? 'success' : 'info'">
|
||||
{{ row.status === 1 ? '启用' : '禁用' }}
|
||||
</el-tag>
|
||||
<PageCard>
|
||||
<template #toolbar-left>
|
||||
<el-input
|
||||
v-model="searchText"
|
||||
placeholder="搜索租户编码 / 租户名称"
|
||||
clearable
|
||||
style="width: 260px"
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="260">
|
||||
<template #toolbar-right>
|
||||
<el-button type="primary" @click="openCreateDialog">新增租户</el-button>
|
||||
</template>
|
||||
|
||||
<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="租户编码" 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-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: 0 12px"
|
||||
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,7 +68,6 @@
|
||||
inactive-text="禁用"
|
||||
/>
|
||||
</el-form-item>
|
||||
<template v-if="!isEdit">
|
||||
<el-form-item label="初始化管理员">
|
||||
<el-checkbox v-model="dialogForm.createAdmin">同时创建租户管理员</el-checkbox>
|
||||
</el-form-item>
|
||||
@@ -61,20 +77,53 @@
|
||||
<el-form-item v-if="dialogForm.createAdmin" label="初始密码">
|
||||
<el-input v-model="dialogForm.initAdminPassword" type="password" placeholder="初始密码" />
|
||||
</el-form-item>
|
||||
</template>
|
||||
</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="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>
|
||||
|
||||
@@ -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>
|
||||
<div class="status-cell" @click.stop>
|
||||
<el-switch
|
||||
v-model="row.status"
|
||||
:active-value="1"
|
||||
:inactive-value="0"
|
||||
style="margin: 0 12px"
|
||||
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>
|
||||
|
||||
<!-- 用户详情 / 编辑抽屉 -->
|
||||
<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>
|
||||
|
||||
@@ -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/channelidentity.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"}
|
||||
@@ -1,9 +1,13 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { resolve } from 'path'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), '')
|
||||
const apiBaseUrl = env.VITE_API_BASE_URL || 'http://localhost:8080'
|
||||
|
||||
return {
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
@@ -11,10 +15,10 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
port: parseInt(env.VITE_ADMIN_WEB_PORT || '5173', 10),
|
||||
proxy: {
|
||||
'/msg-platform': {
|
||||
target: 'http://localhost:8080',
|
||||
target: apiBaseUrl,
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
@@ -22,4 +26,5 @@ export default defineConfig({
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
2
backend/.mvn/maven.config
Normal file
2
backend/.mvn/maven.config
Normal file
@@ -0,0 +1,2 @@
|
||||
--toolchains
|
||||
/Users/marsal/Projects/sino-project/sino-mci/backend/.mvn/toolchains.xml
|
||||
13
backend/.mvn/toolchains.xml
Normal file
13
backend/.mvn/toolchains.xml
Normal 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>
|
||||
@@ -5,7 +5,8 @@ public enum AccountType {
|
||||
WECOM_AGENT(2, "企微应用"),
|
||||
WECHAT_PERSONAL(3, "pywechat 个人号"),
|
||||
DINGTALK_BOT(4, "钉钉机器人"),
|
||||
FEISHU_BOT(5, "飞书机器人");
|
||||
FEISHU_BOT(5, "飞书机器人"),
|
||||
WECOM_BOT(6, "企微群机器人");
|
||||
|
||||
private final int code;
|
||||
private final String label;
|
||||
|
||||
@@ -118,12 +118,14 @@ public class WechatPersonalChannelAdapter implements ChannelAdapter {
|
||||
}
|
||||
|
||||
String contentType = defaultString(request.getContentType()).toLowerCase();
|
||||
String recordId = getString(request.getExtra(), "record_id");
|
||||
String tenantCode = defaultString(getString(request.getExtra(), "tenant_code"));
|
||||
try {
|
||||
Map<String, Object> response;
|
||||
if ("image".equals(contentType)) {
|
||||
response = sendImage(accountId, conversationId, request.getContent());
|
||||
response = sendImage(accountId, conversationId, recordId, tenantCode, request.getContent());
|
||||
} else {
|
||||
response = sendText(accountId, conversationId, request.getContent());
|
||||
response = sendText(accountId, conversationId, recordId, tenantCode, request.getContent());
|
||||
}
|
||||
|
||||
if (response == null) {
|
||||
@@ -133,7 +135,8 @@ public class WechatPersonalChannelAdapter implements ChannelAdapter {
|
||||
}
|
||||
|
||||
Boolean success = getBoolean(response, "success");
|
||||
if (!Boolean.TRUE.equals(success)) {
|
||||
Boolean accepted = getBoolean(response, "accepted");
|
||||
if (!Boolean.TRUE.equals(success) && !Boolean.TRUE.equals(accepted)) {
|
||||
result.setSuccess(false);
|
||||
result.setMessage("个人微信发送失败:" + getString(response, "error"));
|
||||
return result;
|
||||
@@ -157,7 +160,7 @@ public class WechatPersonalChannelAdapter implements ChannelAdapter {
|
||||
return payload;
|
||||
}
|
||||
|
||||
private Map<String, Object> sendText(String accountId, String conversationId, Map<String, Object> content) {
|
||||
private Map<String, Object> sendText(String accountId, String conversationId, String recordId, String tenantCode, Map<String, Object> content) {
|
||||
String text = getString(content, "text");
|
||||
if (isBlank(text)) {
|
||||
throw new IllegalArgumentException("文本内容为空");
|
||||
@@ -166,10 +169,12 @@ public class WechatPersonalChannelAdapter implements ChannelAdapter {
|
||||
body.put("account_id", accountId);
|
||||
body.put("conversation_id", conversationId);
|
||||
body.put("text", text);
|
||||
body.put("record_id", recordId);
|
||||
body.put("tenant_code", tenantCode);
|
||||
return post("/v1/wechat-personal/send/text", body);
|
||||
}
|
||||
|
||||
private Map<String, Object> sendImage(String accountId, String conversationId, Map<String, Object> content) {
|
||||
private Map<String, Object> sendImage(String accountId, String conversationId, String recordId, String tenantCode, Map<String, Object> content) {
|
||||
String imageUrl = getString(content, "image_url");
|
||||
if (isBlank(imageUrl)) {
|
||||
throw new IllegalArgumentException("图片地址为空");
|
||||
@@ -178,6 +183,8 @@ public class WechatPersonalChannelAdapter implements ChannelAdapter {
|
||||
body.put("account_id", accountId);
|
||||
body.put("conversation_id", conversationId);
|
||||
body.put("image_url", imageUrl);
|
||||
body.put("record_id", recordId);
|
||||
body.put("tenant_code", tenantCode);
|
||||
return post("/v1/wechat-personal/send/image", body);
|
||||
}
|
||||
|
||||
|
||||
@@ -99,7 +99,7 @@ class WechatPersonalChannelAdapterTest {
|
||||
.setResponseCode(200)
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody(toJson(Map.of(
|
||||
"success", true,
|
||||
"accepted", true,
|
||||
"message_id", "msg-123"))));
|
||||
|
||||
SendMessageRequest request = new SendMessageRequest();
|
||||
@@ -110,6 +110,8 @@ class WechatPersonalChannelAdapterTest {
|
||||
request.setContent(content);
|
||||
Map<String, Object> extra = new HashMap<>();
|
||||
extra.put("account_id", "wx-123");
|
||||
extra.put("record_id", "1001");
|
||||
extra.put("tenant_code", "rescue");
|
||||
request.setExtra(extra);
|
||||
|
||||
SendMessageResult result = adapter.sendMessage(request);
|
||||
@@ -121,7 +123,7 @@ class WechatPersonalChannelAdapterTest {
|
||||
assertThat(recorded.getMethod()).isEqualTo("POST");
|
||||
assertThat(recorded.getPath()).isEqualTo("/v1/wechat-personal/send/text");
|
||||
String body = recorded.getBody().readUtf8();
|
||||
assertThat(body).contains("wx-123", "chat-123", "hello");
|
||||
assertThat(body).contains("wx-123", "chat-123", "hello", "1001", "rescue");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -130,7 +132,7 @@ class WechatPersonalChannelAdapterTest {
|
||||
.setResponseCode(200)
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody(toJson(Map.of(
|
||||
"success", true,
|
||||
"accepted", true,
|
||||
"message_id", "img-123"))));
|
||||
|
||||
SendMessageRequest request = new SendMessageRequest();
|
||||
@@ -141,6 +143,8 @@ class WechatPersonalChannelAdapterTest {
|
||||
request.setContent(content);
|
||||
Map<String, Object> extra = new HashMap<>();
|
||||
extra.put("account_id", "wx-123");
|
||||
extra.put("record_id", "1002");
|
||||
extra.put("tenant_code", "rescue");
|
||||
request.setExtra(extra);
|
||||
|
||||
SendMessageResult result = adapter.sendMessage(request);
|
||||
@@ -150,6 +154,8 @@ class WechatPersonalChannelAdapterTest {
|
||||
|
||||
RecordedRequest recorded = mockWebServer.takeRequest();
|
||||
assertThat(recorded.getPath()).isEqualTo("/v1/wechat-personal/send/image");
|
||||
String body = recorded.getBody().readUtf8();
|
||||
assertThat(body).contains("wx-123", "1002", "rescue", "https://example.com/img.png");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -23,6 +23,10 @@
|
||||
<groupId>org.springframework</groupId>
|
||||
<artifactId>spring-context</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework</groupId>
|
||||
<artifactId>spring-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-autoconfigure</artifactId>
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
package com.sino.mci.channel.wecom;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.sino.mci.channel.common.dto.SendMessageRequest;
|
||||
import com.sino.mci.channel.common.dto.SendMessageResult;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.http.client.SimpleClientHttpRequestFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.HttpStatusCodeException;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
public class WeComBotSender {
|
||||
|
||||
private static final String DEFAULT_WEBHOOK_URL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=%s";
|
||||
private static final int CONNECT_TIMEOUT_MS = 5000;
|
||||
private static final int READ_TIMEOUT_MS = 10000;
|
||||
|
||||
private final RestTemplate restTemplate;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public WeComBotSender() {
|
||||
this.restTemplate = createRestTemplate();
|
||||
this.objectMapper = new ObjectMapper();
|
||||
}
|
||||
|
||||
public WeComBotSender(RestTemplate restTemplate, ObjectMapper objectMapper) {
|
||||
this.restTemplate = restTemplate;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
public SendMessageResult send(SendMessageRequest request) {
|
||||
SendMessageResult result = new SendMessageResult();
|
||||
String key = getString(request.getExtra(), "account_id");
|
||||
if (key == null || key.isBlank()) {
|
||||
result.setSuccess(false);
|
||||
result.setMessage("企微群机器人缺少 webhook key");
|
||||
return result;
|
||||
}
|
||||
|
||||
String contentType = request.getContentType() == null ? "text" : request.getContentType().toLowerCase();
|
||||
Map<String, Object> body = buildBody(contentType, request.getContent());
|
||||
if (body == null) {
|
||||
result.setSuccess(false);
|
||||
result.setMessage("不支持的消息类型: " + contentType);
|
||||
return result;
|
||||
}
|
||||
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(body, headers);
|
||||
|
||||
try {
|
||||
String url = resolveWebhookUrl(request.getExtra(), key);
|
||||
ResponseEntity<String> response = restTemplate.postForEntity(url, entity, String.class);
|
||||
Map<String, Object> responseBody = parseResponseBody(response.getBody());
|
||||
if (responseBody == null) {
|
||||
result.setSuccess(false);
|
||||
result.setMessage("企微群机器人返回无法解析的响应");
|
||||
return result;
|
||||
}
|
||||
int errcode = parseInt(responseBody.get("errcode"));
|
||||
if (errcode != 0) {
|
||||
result.setSuccess(false);
|
||||
String errmsg = getString(responseBody, "errmsg");
|
||||
result.setMessage("企微群机器人发送失败: [" + errcode + "] " + errmsg);
|
||||
return result;
|
||||
}
|
||||
result.setSuccess(true);
|
||||
} catch (HttpStatusCodeException e) {
|
||||
log.warn("[WeComBot] 发送失败,HTTP 状态: {}", e.getStatusCode(), e);
|
||||
result.setSuccess(false);
|
||||
result.setMessage("企微群机器人发送失败,HTTP 状态: " + e.getStatusCode());
|
||||
} catch (Exception e) {
|
||||
log.warn("[WeComBot] 发送失败", e);
|
||||
result.setSuccess(false);
|
||||
result.setMessage("企微群机器人发送失败: " + e.getMessage());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private String resolveWebhookUrl(Map<String, Object> extra, String key) {
|
||||
String customUrl = getString(extra, "webhook_url");
|
||||
if (customUrl != null && !customUrl.isBlank()) {
|
||||
if (customUrl.contains("%s")) {
|
||||
return customUrl.replaceFirst("%s", key);
|
||||
}
|
||||
return customUrl;
|
||||
}
|
||||
return String.format(DEFAULT_WEBHOOK_URL, key);
|
||||
}
|
||||
|
||||
private Map<String, Object> parseResponseBody(String body) {
|
||||
if (body == null || body.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return objectMapper.readValue(body, Map.class);
|
||||
} catch (Exception e) {
|
||||
log.warn("[WeComBot] 解析响应失败: {}", body, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private int parseInt(Object value) {
|
||||
if (value == null) {
|
||||
return 0;
|
||||
}
|
||||
if (value instanceof Number number) {
|
||||
return number.intValue();
|
||||
}
|
||||
try {
|
||||
return Integer.parseInt(value.toString());
|
||||
} catch (NumberFormatException e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private RestTemplate createRestTemplate() {
|
||||
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
|
||||
factory.setConnectTimeout(CONNECT_TIMEOUT_MS);
|
||||
factory.setReadTimeout(READ_TIMEOUT_MS);
|
||||
return new RestTemplate(factory);
|
||||
}
|
||||
|
||||
private Map<String, Object> buildBody(String contentType, Map<String, Object> content) {
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
switch (contentType) {
|
||||
case "text" -> {
|
||||
body.put("msgtype", "text");
|
||||
Map<String, Object> text = new HashMap<>();
|
||||
text.put("content", getString(content, "text"));
|
||||
body.put("text", text);
|
||||
}
|
||||
case "markdown" -> {
|
||||
body.put("msgtype", "markdown");
|
||||
Map<String, Object> markdown = new HashMap<>();
|
||||
markdown.put("content", getString(content, "text"));
|
||||
body.put("markdown", markdown);
|
||||
}
|
||||
default -> {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
private String getString(Map<String, Object> map, String key) {
|
||||
if (map == null) {
|
||||
return null;
|
||||
}
|
||||
Object value = map.get(key);
|
||||
return value == null ? null : value.toString();
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import com.sino.mci.channel.common.dto.ChannelInitRequest;
|
||||
import com.sino.mci.channel.common.dto.ChannelInitResult;
|
||||
import com.sino.mci.channel.common.dto.SendMessageRequest;
|
||||
import com.sino.mci.channel.common.dto.SendMessageResult;
|
||||
import com.sino.mci.channel.common.model.AccountType;
|
||||
import com.sino.mci.channel.common.model.ChannelType;
|
||||
import com.sino.mci.channel.common.spi.ChannelAdapter;
|
||||
import com.sino.mci.channel.wecom.support.WeComServiceFactory;
|
||||
@@ -35,6 +36,7 @@ public class WeComChannelAdapter implements ChannelAdapter {
|
||||
private static final Logger log = LoggerFactory.getLogger(WeComChannelAdapter.class);
|
||||
|
||||
private final WeComServiceFactory serviceFactory;
|
||||
private final WeComBotSender weComBotSender;
|
||||
|
||||
@Override
|
||||
public ChannelType getChannelType() {
|
||||
@@ -84,6 +86,11 @@ public class WeComChannelAdapter implements ChannelAdapter {
|
||||
return result;
|
||||
}
|
||||
|
||||
Integer accountType = getAccountType(request);
|
||||
if (accountType != null && accountType == AccountType.WECOM_BOT.getCode()) {
|
||||
return weComBotSender.send(request);
|
||||
}
|
||||
|
||||
WxCpService service = resolveService(request);
|
||||
if (service == null) {
|
||||
result.setSuccess(false);
|
||||
@@ -277,6 +284,13 @@ public class WeComChannelAdapter implements ChannelAdapter {
|
||||
return receiverId.replace(',', '|').replaceAll("\\s+", "");
|
||||
}
|
||||
|
||||
private Integer getAccountType(SendMessageRequest request) {
|
||||
if (request.getExtra() != null && request.getExtra().get("account_type") instanceof Number n) {
|
||||
return n.intValue();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String getString(Map<?, ?> map, String key) {
|
||||
if (map == null) {
|
||||
return null;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
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))
|
||||
.type(member.getType())
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@@ -13,4 +13,5 @@ public class WeComConversationMember {
|
||||
|
||||
private String userId;
|
||||
private String name;
|
||||
private Integer type;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
package com.sino.mci.channel.wecom;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.sino.mci.channel.common.dto.SendMessageRequest;
|
||||
import com.sino.mci.channel.common.dto.SendMessageResult;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.client.MockRestServiceServer;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.springframework.test.web.client.match.MockRestRequestMatchers.content;
|
||||
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
|
||||
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
|
||||
import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus;
|
||||
|
||||
class WeComBotSenderTest {
|
||||
|
||||
private RestTemplate restTemplate;
|
||||
private MockRestServiceServer server;
|
||||
private WeComBotSender sender;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
restTemplate = new RestTemplate();
|
||||
server = MockRestServiceServer.createServer(restTemplate);
|
||||
sender = new WeComBotSender(restTemplate, new ObjectMapper());
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendRejectsMissingKey() {
|
||||
SendMessageRequest request = new SendMessageRequest();
|
||||
request.setExtra(null);
|
||||
SendMessageResult result = sender.send(request);
|
||||
assertFalse(result.isSuccess());
|
||||
assertTrue(result.getMessage().contains("webhook key"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendBuildsTextBody() throws Exception {
|
||||
server.expect(requestTo("https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=dummy"))
|
||||
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
|
||||
.andExpect(content().json("{\"msgtype\":\"text\",\"text\":{\"content\":\"hello\"}}"))
|
||||
.andRespond(withSuccess("{\"errcode\":0,\"errmsg\":\"ok\"}", MediaType.APPLICATION_JSON));
|
||||
|
||||
SendMessageRequest request = new SendMessageRequest();
|
||||
request.setExtra(Map.of("account_id", "dummy"));
|
||||
request.setContentType("text");
|
||||
request.setContent(Map.of("text", "hello"));
|
||||
SendMessageResult result = sender.send(request);
|
||||
assertTrue(result.isSuccess(), result.getMessage());
|
||||
server.verify();
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendRejectsUnsupportedContentType() {
|
||||
SendMessageRequest request = new SendMessageRequest();
|
||||
request.setExtra(Map.of("account_id", "dummy"));
|
||||
request.setContentType("image");
|
||||
request.setContent(Map.of("media_id", "xxx"));
|
||||
SendMessageResult result = sender.send(request);
|
||||
assertFalse(result.isSuccess());
|
||||
assertTrue(result.getMessage().contains("不支持的消息类型"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendReportsWeComErrorResponse() {
|
||||
server.expect(requestTo("https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=dummy"))
|
||||
.andRespond(withSuccess("{\"errcode\":40008,\"errmsg\":\"invalid message type\"}", MediaType.APPLICATION_JSON));
|
||||
|
||||
SendMessageRequest request = new SendMessageRequest();
|
||||
request.setExtra(Map.of("account_id", "dummy"));
|
||||
request.setContentType("text");
|
||||
request.setContent(Map.of("text", "hello"));
|
||||
SendMessageResult result = sender.send(request);
|
||||
assertFalse(result.isSuccess());
|
||||
assertTrue(result.getMessage().contains("40008"));
|
||||
assertTrue(result.getMessage().contains("invalid message type"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendReportsHttpErrorStatus() {
|
||||
server.expect(requestTo("https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=dummy"))
|
||||
.andRespond(withStatus(org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR));
|
||||
|
||||
SendMessageRequest request = new SendMessageRequest();
|
||||
request.setExtra(Map.of("account_id", "dummy"));
|
||||
request.setContentType("text");
|
||||
request.setContent(Map.of("text", "hello"));
|
||||
SendMessageResult result = sender.send(request);
|
||||
assertFalse(result.isSuccess());
|
||||
assertTrue(result.getMessage().contains("HTTP 状态"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendUsesCustomWebhookUrl() throws Exception {
|
||||
server.expect(requestTo("https://custom.example.com/webhook/send?key=dummy"))
|
||||
.andExpect(content().json("{\"msgtype\":\"text\",\"text\":{\"content\":\"hello\"}}"))
|
||||
.andRespond(withSuccess("{\"errcode\":0,\"errmsg\":\"ok\"}", MediaType.APPLICATION_JSON));
|
||||
|
||||
SendMessageRequest request = new SendMessageRequest();
|
||||
request.setExtra(Map.of("account_id", "dummy", "webhook_url", "https://custom.example.com/webhook/send?key=%s"));
|
||||
request.setContentType("text");
|
||||
request.setContent(Map.of("text", "hello"));
|
||||
SendMessageResult result = sender.send(request);
|
||||
assertTrue(result.isSuccess(), result.getMessage());
|
||||
server.verify();
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendUsesCustomWebhookUrlWithoutPlaceholder() throws Exception {
|
||||
server.expect(requestTo("https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=REALKEY"))
|
||||
.andExpect(content().json("{\"msgtype\":\"text\",\"text\":{\"content\":\"hello\"}}"))
|
||||
.andRespond(withSuccess("{\"errcode\":0,\"errmsg\":\"ok\"}", MediaType.APPLICATION_JSON));
|
||||
|
||||
SendMessageRequest request = new SendMessageRequest();
|
||||
request.setExtra(Map.of("account_id", "dummy", "webhook_url", "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=REALKEY"));
|
||||
request.setContentType("text");
|
||||
request.setContent(Map.of("text", "hello"));
|
||||
SendMessageResult result = sender.send(request);
|
||||
assertTrue(result.isSuccess(), result.getMessage());
|
||||
server.verify();
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendFailsOnUnparseableResponseBody() {
|
||||
server.expect(requestTo("https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=dummy"))
|
||||
.andRespond(withSuccess("not-json", MediaType.TEXT_PLAIN));
|
||||
|
||||
SendMessageRequest request = new SendMessageRequest();
|
||||
request.setExtra(Map.of("account_id", "dummy"));
|
||||
request.setContentType("text");
|
||||
request.setContent(Map.of("text", "hello"));
|
||||
SendMessageResult result = sender.send(request);
|
||||
assertFalse(result.isSuccess());
|
||||
assertTrue(result.getMessage().contains("无法解析"));
|
||||
}
|
||||
}
|
||||
@@ -59,7 +59,7 @@ class WeComChannelAdapterTest {
|
||||
WeComServiceFactory factory = mock(WeComServiceFactory.class);
|
||||
when(factory.getOrCreateService(any(Map.class))).thenReturn(service);
|
||||
|
||||
WeComChannelAdapter adapter = new WeComChannelAdapter(factory);
|
||||
WeComChannelAdapter adapter = new WeComChannelAdapter(factory, new WeComBotSender());
|
||||
SendMessageResult result = adapter.sendMessage(textMessageRequest());
|
||||
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
@@ -82,7 +82,7 @@ class WeComChannelAdapterTest {
|
||||
WeComServiceFactory factory = mock(WeComServiceFactory.class);
|
||||
when(factory.getOrCreateService(any(Map.class))).thenReturn(service);
|
||||
|
||||
WeComChannelAdapter adapter = new WeComChannelAdapter(factory);
|
||||
WeComChannelAdapter adapter = new WeComChannelAdapter(factory, new WeComBotSender());
|
||||
|
||||
ChannelInitRequest request = new ChannelInitRequest();
|
||||
request.setConfig(validConfig());
|
||||
@@ -98,7 +98,7 @@ class WeComChannelAdapterTest {
|
||||
WeComServiceFactory factory = mock(WeComServiceFactory.class);
|
||||
when(factory.getOrCreateService(any(Map.class))).thenReturn(null);
|
||||
|
||||
WeComChannelAdapter adapter = new WeComChannelAdapter(factory);
|
||||
WeComChannelAdapter adapter = new WeComChannelAdapter(factory, new WeComBotSender());
|
||||
|
||||
ChannelInitRequest request = new ChannelInitRequest();
|
||||
request.setConfig(new HashMap<>());
|
||||
@@ -120,7 +120,7 @@ class WeComChannelAdapterTest {
|
||||
WeComServiceFactory factory = mock(WeComServiceFactory.class);
|
||||
when(factory.getOrCreateService(any(Map.class))).thenReturn(service);
|
||||
|
||||
WeComChannelAdapter adapter = new WeComChannelAdapter(factory);
|
||||
WeComChannelAdapter adapter = new WeComChannelAdapter(factory, new WeComBotSender());
|
||||
|
||||
SendMessageRequest request = textMessageRequest();
|
||||
request.setConversationId("chat123");
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -18,7 +18,8 @@ public class AuthInterceptor implements HandlerInterceptor {
|
||||
|| ("POST".equals(method) && isExactPath(path, "/api/v1/account/tenant"))
|
||||
|| ("POST".equals(method) && isExactPath(path, "/api/v1/account/user"))
|
||||
|| isExactPath(path, "/api/v1/health")
|
||||
|| ("POST".equals(method) && isExactPath(path, "/api/v1/channel-account/heartbeat"))) {
|
||||
|| ("POST".equals(method) && isExactPath(path, "/api/v1/channel-account/heartbeat"))
|
||||
|| ("POST".equals(method) && isExactPath(path, "/api/v1/channel-account/event"))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.sino.mci.channel.controller;
|
||||
|
||||
import com.sino.mci.channel.dto.ChannelAccountEventRequest;
|
||||
import com.sino.mci.channel.service.ChannelAccountEventService;
|
||||
import com.sino.mci.shared.web.ApiResponse;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/channel-account")
|
||||
@RequiredArgsConstructor
|
||||
public class ChannelAccountEventController {
|
||||
|
||||
private final ChannelAccountEventService eventService;
|
||||
|
||||
@PostMapping("/event")
|
||||
public ApiResponse<?> receiveEvent(@Valid @RequestBody ChannelAccountEventRequest request) {
|
||||
eventService.handleEvent(request);
|
||||
return ApiResponse.ok(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package com.sino.mci.channel.controller;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.sino.mci.admin.security.AuthContext;
|
||||
import com.sino.mci.channel.dto.ChannelIdentityResponse;
|
||||
import com.sino.mci.channel.entity.ChannelSubject;
|
||||
import com.sino.mci.channel.repository.ChannelSubjectMapper;
|
||||
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.web.ApiResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/channel-identity")
|
||||
@RequiredArgsConstructor
|
||||
public class ChannelIdentityController {
|
||||
|
||||
private final ChannelIdentityMapper channelIdentityMapper;
|
||||
private final PersonMapper personMapper;
|
||||
private final ChannelSubjectMapper channelSubjectMapper;
|
||||
|
||||
@GetMapping("/list")
|
||||
public ApiResponse<List<ChannelIdentityResponse>> listChannelIdentities() {
|
||||
String tenantCode = AuthContext.currentTenantCode();
|
||||
List<ChannelIdentity> identities = channelIdentityMapper.selectList(
|
||||
new LambdaQueryWrapper<ChannelIdentity>()
|
||||
.eq(ChannelIdentity::getTenantCode, tenantCode)
|
||||
.orderByDesc(ChannelIdentity::getId));
|
||||
|
||||
if (identities.isEmpty()) {
|
||||
return ApiResponse.ok(Collections.emptyList());
|
||||
}
|
||||
|
||||
Set<Long> personIds = identities.stream()
|
||||
.map(ChannelIdentity::getPersonId)
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toSet());
|
||||
Set<Long> subjectIds = identities.stream()
|
||||
.map(ChannelIdentity::getSubjectId)
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
Map<Long, Person> personMap = personIds.isEmpty() ? Collections.emptyMap()
|
||||
: personMapper.selectBatchIds(personIds).stream()
|
||||
.collect(Collectors.toMap(Person::getId, p -> p));
|
||||
Map<Long, ChannelSubject> subjectMap = subjectIds.isEmpty() ? Collections.emptyMap()
|
||||
: channelSubjectMapper.selectBatchIds(subjectIds).stream()
|
||||
.collect(Collectors.toMap(ChannelSubject::getId, s -> s));
|
||||
|
||||
List<ChannelIdentityResponse> responses = identities.stream().map(identity -> {
|
||||
ChannelIdentityResponse response = new ChannelIdentityResponse();
|
||||
response.setId(identity.getId());
|
||||
response.setTenantCode(identity.getTenantCode());
|
||||
response.setChannelType(identity.getChannelType());
|
||||
response.setChannelUserId(identity.getChannelUserId());
|
||||
response.setChannelUserName(identity.getChannelUserName());
|
||||
response.setSubjectId(identity.getSubjectId());
|
||||
ChannelSubject subject = identity.getSubjectId() == null ? null : subjectMap.get(identity.getSubjectId());
|
||||
response.setSubjectName(subject == null ? null : subject.getSubjectName());
|
||||
response.setPersonId(identity.getPersonId());
|
||||
Person person = identity.getPersonId() == null ? null : personMap.get(identity.getPersonId());
|
||||
response.setPersonName(person == null ? null : person.getPersonName());
|
||||
response.setPersonType(person == null ? null : person.getPersonType());
|
||||
response.setStatus(identity.getStatus());
|
||||
return response;
|
||||
}).collect(Collectors.toList());
|
||||
|
||||
return ApiResponse.ok(responses);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@ package com.sino.mci.channel.controller;
|
||||
import com.sino.mci.admin.security.AuthContext;
|
||||
import com.sino.mci.channel.dto.BindConversationAccountRequest;
|
||||
import com.sino.mci.channel.dto.BindConversationTagsRequest;
|
||||
import com.sino.mci.channel.dto.ConversationParticipantResponse;
|
||||
import com.sino.mci.channel.dto.CreateConversationRequest;
|
||||
import com.sino.mci.channel.dto.TagResponse;
|
||||
import com.sino.mci.channel.dto.UpdateConversationRequest;
|
||||
import com.sino.mci.channel.entity.Conversation;
|
||||
import com.sino.mci.channel.service.ConversationService;
|
||||
@@ -73,4 +75,18 @@ public class ConversationController {
|
||||
conversationService.bindTags(tenantCode, conversationId, request);
|
||||
return ApiResponse.ok();
|
||||
}
|
||||
|
||||
@GetMapping("/{conversation_id}/participants")
|
||||
public ApiResponse<List<ConversationParticipantResponse>> listParticipants(
|
||||
@RequestHeader("X-Tenant-Code") String tenantCode,
|
||||
@PathVariable("conversation_id") Long conversationId) {
|
||||
return ApiResponse.ok(conversationService.listParticipants(tenantCode, conversationId));
|
||||
}
|
||||
|
||||
@GetMapping("/{conversation_id}/tags")
|
||||
public ApiResponse<List<TagResponse>> listTags(
|
||||
@RequestHeader("X-Tenant-Code") String tenantCode,
|
||||
@PathVariable("conversation_id") Long conversationId) {
|
||||
return ApiResponse.ok(conversationService.listTags(tenantCode, conversationId));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.sino.mci.channel.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@Data
|
||||
public class ChannelAccountEventRequest {
|
||||
|
||||
@NotBlank
|
||||
@JsonProperty("event_id")
|
||||
private String eventId;
|
||||
|
||||
@NotBlank
|
||||
@JsonProperty("event_type")
|
||||
private String eventType;
|
||||
|
||||
@NotBlank
|
||||
@JsonProperty("account_id")
|
||||
private String accountId;
|
||||
|
||||
@NotBlank
|
||||
@JsonProperty("tenant_code")
|
||||
private String tenantCode;
|
||||
|
||||
@JsonProperty("timestamp")
|
||||
private String timestamp;
|
||||
|
||||
@NotNull
|
||||
private Map<String, Object> data;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.sino.mci.channel.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class ChannelIdentityResponse {
|
||||
|
||||
private Long id;
|
||||
private String tenantCode;
|
||||
private String channelType;
|
||||
private String channelUserId;
|
||||
private String channelUserName;
|
||||
private Long subjectId;
|
||||
private String subjectName;
|
||||
private Long personId;
|
||||
private String personName;
|
||||
private Integer personType;
|
||||
private Integer status;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.sino.mci.channel.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
public class ConversationParticipantResponse {
|
||||
|
||||
private Long id;
|
||||
private Integer participantType;
|
||||
private LocalDateTime joinTime;
|
||||
private Long channelIdentityId;
|
||||
private String channelUserId;
|
||||
private String channelUserName;
|
||||
private Long personId;
|
||||
private String personName;
|
||||
private Integer personType;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -21,6 +21,7 @@ public class CreateConversationRequest {
|
||||
private Long ownerAccountId;
|
||||
private Long subjectId;
|
||||
private String conversationName;
|
||||
private Integer conversationScene;
|
||||
private Integer createSource;
|
||||
private Map<String, Object> receiveConfig;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.sino.mci.channel.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class TagResponse {
|
||||
|
||||
private Long id;
|
||||
private String tenantCode;
|
||||
private Long parentId;
|
||||
private String tagPath;
|
||||
private Integer level;
|
||||
private String tagName;
|
||||
private String tagSource;
|
||||
private Integer status;
|
||||
}
|
||||
@@ -13,6 +13,7 @@ public class UpdateConversationRequest {
|
||||
|
||||
private Long ownerAccountId;
|
||||
private Long subjectId;
|
||||
private Integer conversationScene;
|
||||
private Integer status;
|
||||
private Map<String, Object> receiveConfig;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.sino.mci.channel.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("mci_channel_event_record")
|
||||
public class ChannelEventRecord {
|
||||
|
||||
@TableId(type = IdType.INPUT)
|
||||
private String eventId;
|
||||
|
||||
private String accountId;
|
||||
|
||||
private String eventType;
|
||||
|
||||
private LocalDateTime createTime;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -30,5 +30,6 @@ public class Conversation extends BaseEntity {
|
||||
private Long contractId;
|
||||
private String channelCode;
|
||||
private String receiveConfig;
|
||||
private Integer conversationScene;
|
||||
private Integer status;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.sino.mci.channel.repository;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.sino.mci.channel.entity.ChannelEventRecord;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface ChannelEventRecordMapper extends BaseMapper<ChannelEventRecord> {
|
||||
}
|
||||
@@ -2,8 +2,12 @@ package com.sino.mci.channel.repository;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.sino.mci.channel.entity.ConversationParticipant;
|
||||
import org.apache.ibatis.annotations.Delete;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface ConversationParticipantMapper extends BaseMapper<ConversationParticipant> {
|
||||
|
||||
@Delete("DELETE FROM mci_conversation_participant WHERE conversation_id = #{conversationId}")
|
||||
void physicalDeleteByConversationId(Long conversationId);
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@@ -39,7 +40,14 @@ public class AccountSelector {
|
||||
return CollectionUtils.isEmpty(accounts) ? null : accounts.get(0);
|
||||
}
|
||||
|
||||
public List<Long> selectSendAccounts(String tenantCode, Long conversationId, ChannelType channelType, String preferredStrategy) {
|
||||
public List<Long> selectSendAccounts(String tenantCode, Long conversationId,
|
||||
ChannelType channelType, String preferredStrategy) {
|
||||
return selectSendAccounts(tenantCode, conversationId, channelType, null, preferredStrategy);
|
||||
}
|
||||
|
||||
public List<Long> selectSendAccounts(String tenantCode, Long conversationId,
|
||||
ChannelType channelType, Set<Integer> accountTypes,
|
||||
String preferredStrategy) {
|
||||
if (conversationId == null || channelType == null) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
@@ -64,9 +72,13 @@ public class AccountSelector {
|
||||
List<Long> healthyAccountIds = new ArrayList<>();
|
||||
for (ChannelAccountConversation binding : bindings) {
|
||||
ChannelAccount account = accountMapper.selectById(binding.getAccountId());
|
||||
if (isHealthy(account, tenantCode, channelType)) {
|
||||
healthyAccountIds.add(account.getId());
|
||||
if (!isHealthy(account, tenantCode, channelType)) {
|
||||
continue;
|
||||
}
|
||||
if (accountTypes != null && !accountTypes.contains(account.getAccountType())) {
|
||||
continue;
|
||||
}
|
||||
healthyAccountIds.add(account.getId());
|
||||
}
|
||||
return healthyAccountIds;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
package com.sino.mci.channel.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.sino.mci.channel.dto.ChannelAccountEventRequest;
|
||||
import com.sino.mci.channel.entity.ChannelAccount;
|
||||
import com.sino.mci.channel.entity.ChannelEventRecord;
|
||||
import com.sino.mci.channel.repository.ChannelAccountMapper;
|
||||
import com.sino.mci.channel.repository.ChannelEventRecordMapper;
|
||||
import com.sino.mci.exception.BusinessException;
|
||||
import com.sino.mci.send.entity.SendRecord;
|
||||
import com.sino.mci.send.repository.SendRecordMapper;
|
||||
import com.sino.mci.send.service.SendCallbackService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Map;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ChannelAccountEventService {
|
||||
|
||||
private static final int LOGIN_STATUS_PENDING = 0;
|
||||
private static final int LOGIN_STATUS_SUCCESS = 1;
|
||||
private static final int LOGIN_STATUS_EXPIRED = 2;
|
||||
private static final int LOGIN_STATUS_FAILED = 3;
|
||||
|
||||
private static final int ONLINE_STATUS_OFFLINE = 0;
|
||||
private static final int ONLINE_STATUS_ONLINE = 1;
|
||||
private static final int ONLINE_STATUS_ERROR = 2;
|
||||
|
||||
private static final int SEND_STATUS_SUCCESS = 2;
|
||||
private static final int SEND_STATUS_FAILED = 3;
|
||||
|
||||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||
|
||||
private final ChannelAccountMapper accountMapper;
|
||||
private final ChannelEventRecordMapper eventRecordMapper;
|
||||
private final SendRecordMapper sendRecordMapper;
|
||||
private final SendCallbackService sendCallbackService;
|
||||
|
||||
@Transactional
|
||||
public void handleEvent(ChannelAccountEventRequest request) {
|
||||
if (isDuplicate(request.getEventId())) {
|
||||
log.debug("重复事件已忽略: eventId={}", request.getEventId());
|
||||
return;
|
||||
}
|
||||
|
||||
ChannelAccount account = findAccount(request.getTenantCode(), request.getAccountId());
|
||||
|
||||
switch (request.getEventType()) {
|
||||
case "heartbeat":
|
||||
handleHeartbeat(account, request.getData());
|
||||
break;
|
||||
case "login_status_changed":
|
||||
handleLoginStatusChanged(account, request.getData());
|
||||
break;
|
||||
case "online_status_changed":
|
||||
handleOnlineStatusChanged(account, request.getData());
|
||||
break;
|
||||
case "send_result":
|
||||
handleSendResult(request.getData());
|
||||
break;
|
||||
case "error":
|
||||
log.warn("channel-service 上报错误: accountId={}, data={}", request.getAccountId(), request.getData());
|
||||
break;
|
||||
default:
|
||||
log.warn("未知事件类型: {}", request.getEventType());
|
||||
}
|
||||
|
||||
saveEventRecord(request);
|
||||
}
|
||||
|
||||
private boolean isDuplicate(String eventId) {
|
||||
return eventRecordMapper.selectById(eventId) != null;
|
||||
}
|
||||
|
||||
private void saveEventRecord(ChannelAccountEventRequest request) {
|
||||
ChannelEventRecord record = new ChannelEventRecord();
|
||||
record.setEventId(request.getEventId());
|
||||
record.setAccountId(request.getAccountId());
|
||||
record.setEventType(request.getEventType());
|
||||
record.setCreateTime(LocalDateTime.now());
|
||||
eventRecordMapper.insert(record);
|
||||
}
|
||||
|
||||
private ChannelAccount findAccount(String tenantCode, String accountId) {
|
||||
ChannelAccount account = accountMapper.selectOne(
|
||||
new LambdaQueryWrapper<ChannelAccount>()
|
||||
.eq(ChannelAccount::getTenantCode, tenantCode)
|
||||
.eq(ChannelAccount::getAccountId, accountId));
|
||||
if (account == null) {
|
||||
throw new BusinessException("渠道账号不存在: " + accountId);
|
||||
}
|
||||
return account;
|
||||
}
|
||||
|
||||
private void handleHeartbeat(ChannelAccount account, Map<String, Object> data) {
|
||||
account.setLoginStatus(parseLoginStatus(getString(data, "login_status")));
|
||||
account.setOnlineStatus(parseOnlineStatus(getString(data, "online_status")));
|
||||
account.setLastHeartbeat(LocalDateTime.now());
|
||||
Integer riskLevel = getInteger(data, "risk_level");
|
||||
if (riskLevel != null) {
|
||||
account.setRiskLevel(riskLevel);
|
||||
}
|
||||
accountMapper.updateById(account);
|
||||
}
|
||||
|
||||
private void handleLoginStatusChanged(ChannelAccount account, Map<String, Object> data) {
|
||||
String current = getString(data, "current");
|
||||
account.setLoginStatus(parseLoginStatus(current));
|
||||
if ("success".equalsIgnoreCase(current)) {
|
||||
account.setOnlineStatus(ONLINE_STATUS_ONLINE);
|
||||
}
|
||||
accountMapper.updateById(account);
|
||||
}
|
||||
|
||||
private void handleOnlineStatusChanged(ChannelAccount account, Map<String, Object> data) {
|
||||
account.setOnlineStatus(parseOnlineStatus(getString(data, "current")));
|
||||
accountMapper.updateById(account);
|
||||
}
|
||||
|
||||
private void handleSendResult(Map<String, Object> data) {
|
||||
Long recordId = getLong(data, "record_id");
|
||||
if (recordId == null) {
|
||||
log.warn("send_result 事件缺少 record_id");
|
||||
return;
|
||||
}
|
||||
SendRecord record = sendRecordMapper.selectById(recordId);
|
||||
if (record == null) {
|
||||
log.warn("发送记录不存在: recordId={}", recordId);
|
||||
return;
|
||||
}
|
||||
|
||||
Boolean success = getBoolean(data, "success");
|
||||
record.setSendStatus(Boolean.TRUE.equals(success) ? SEND_STATUS_SUCCESS : SEND_STATUS_FAILED);
|
||||
record.setFinishTime(LocalDateTime.now());
|
||||
record.setChannelResult(toJson(data.get("channel_result")));
|
||||
sendRecordMapper.updateById(record);
|
||||
|
||||
sendCallbackService.sendCallback(record);
|
||||
}
|
||||
|
||||
private int parseLoginStatus(String status) {
|
||||
if ("success".equalsIgnoreCase(status)) {
|
||||
return LOGIN_STATUS_SUCCESS;
|
||||
}
|
||||
if ("expired".equalsIgnoreCase(status)) {
|
||||
return LOGIN_STATUS_EXPIRED;
|
||||
}
|
||||
if ("failed".equalsIgnoreCase(status)) {
|
||||
return LOGIN_STATUS_FAILED;
|
||||
}
|
||||
return LOGIN_STATUS_PENDING;
|
||||
}
|
||||
|
||||
private int parseOnlineStatus(String status) {
|
||||
if ("online".equalsIgnoreCase(status)) {
|
||||
return ONLINE_STATUS_ONLINE;
|
||||
}
|
||||
if ("error".equalsIgnoreCase(status)) {
|
||||
return ONLINE_STATUS_ERROR;
|
||||
}
|
||||
return ONLINE_STATUS_OFFLINE;
|
||||
}
|
||||
|
||||
private String getString(Map<String, Object> map, String key) {
|
||||
Object value = map.get(key);
|
||||
return value == null ? null : value.toString();
|
||||
}
|
||||
|
||||
private Integer getInteger(Map<String, Object> map, String key) {
|
||||
Object value = map.get(key);
|
||||
if (value instanceof Number n) {
|
||||
return n.intValue();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private Long getLong(Map<String, Object> map, String key) {
|
||||
Object value = map.get(key);
|
||||
if (value instanceof Number n) {
|
||||
return n.longValue();
|
||||
}
|
||||
if (value instanceof String s) {
|
||||
try {
|
||||
return Long.parseLong(s);
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private Boolean getBoolean(Map<String, Object> map, String key) {
|
||||
Object value = map.get(key);
|
||||
if (value instanceof Boolean b) {
|
||||
return b;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String toJson(Object value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return OBJECT_MAPPER.writeValueAsString(value);
|
||||
} catch (Exception e) {
|
||||
return value.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,17 +3,26 @@ package com.sino.mci.channel.service;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.sino.mci.channel.dto.BindConversationAccountRequest;
|
||||
import com.sino.mci.channel.dto.BindConversationTagsRequest;
|
||||
import com.sino.mci.channel.dto.ConversationParticipantResponse;
|
||||
import com.sino.mci.channel.dto.CreateConversationRequest;
|
||||
import com.sino.mci.channel.dto.TagResponse;
|
||||
import com.sino.mci.channel.dto.UpdateConversationRequest;
|
||||
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.common.model.AccountType;
|
||||
import com.sino.mci.channel.entity.ConversationParticipant;
|
||||
import com.sino.mci.channel.entity.ConversationTagBinding;
|
||||
import com.sino.mci.channel.repository.ChannelAccountConversationMapper;
|
||||
import com.sino.mci.channel.repository.ChannelAccountMapper;
|
||||
import com.sino.mci.channel.repository.ConversationMapper;
|
||||
import com.sino.mci.channel.repository.ConversationParticipantMapper;
|
||||
import com.sino.mci.channel.repository.ConversationTagBindingMapper;
|
||||
import com.sino.mci.crm.entity.ChannelIdentity;
|
||||
import com.sino.mci.crm.entity.Person;
|
||||
import com.sino.mci.crm.entity.Tag;
|
||||
import com.sino.mci.crm.repository.ChannelIdentityMapper;
|
||||
import com.sino.mci.crm.repository.PersonMapper;
|
||||
import com.sino.mci.crm.repository.TagMapper;
|
||||
import com.sino.mci.exception.BusinessException;
|
||||
import com.sino.mci.shared.util.JsonUtils;
|
||||
@@ -22,7 +31,12 @@ import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.CollectionUtils;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
@@ -34,6 +48,9 @@ public class ConversationService {
|
||||
private final ConversationTagBindingMapper conversationTagBindingMapper;
|
||||
private final TagMapper tagMapper;
|
||||
private final ChannelAccountMapper accountMapper;
|
||||
private final ConversationParticipantMapper participantMapper;
|
||||
private final ChannelIdentityMapper channelIdentityMapper;
|
||||
private final PersonMapper personMapper;
|
||||
|
||||
public List<Conversation> listConversations(String tenantCode) {
|
||||
return conversationMapper.selectList(
|
||||
@@ -62,6 +79,7 @@ public class ConversationService {
|
||||
conversation.setOwnerAccountId(request.getOwnerAccountId());
|
||||
conversation.setSubjectId(request.getSubjectId());
|
||||
conversation.setConversationName(request.getConversationName());
|
||||
conversation.setConversationScene(request.getConversationScene());
|
||||
conversation.setCreateSource(request.getCreateSource() != null ? request.getCreateSource() : 2);
|
||||
conversation.setReceiveConfig(JsonUtils.toJson(request.getReceiveConfig()));
|
||||
conversation.setStatus(1);
|
||||
@@ -93,6 +111,9 @@ public class ConversationService {
|
||||
if (request.getReceiveConfig() != null) {
|
||||
conversation.setReceiveConfig(JsonUtils.toJson(request.getReceiveConfig()));
|
||||
}
|
||||
if (request.getConversationScene() != null) {
|
||||
conversation.setConversationScene(request.getConversationScene());
|
||||
}
|
||||
conversationMapper.updateById(conversation);
|
||||
return conversation;
|
||||
}
|
||||
@@ -111,7 +132,7 @@ public class ConversationService {
|
||||
if (account == null || !tenantCode.equals(account.getTenantCode())) {
|
||||
throw new BusinessException("账号不存在或不在当前租户");
|
||||
}
|
||||
if (!conversation.getChannelType().equalsIgnoreCase(account.getChannelType())) {
|
||||
if (!isAccountCompatibleWithConversation(conversation, account)) {
|
||||
throw new BusinessException("账号渠道类型与会话不一致");
|
||||
}
|
||||
|
||||
@@ -128,6 +149,28 @@ public class ConversationService {
|
||||
binding.setSortOrder(request.getSortOrder() != null ? request.getSortOrder() : 0);
|
||||
binding.setStatus(1);
|
||||
bindingMapper.insert(binding);
|
||||
|
||||
// 主发送账号作为会话负责人
|
||||
if (request.getBindingType() != null && request.getBindingType() == 1) {
|
||||
conversation.setOwnerAccountId(request.getAccountId());
|
||||
conversationMapper.updateById(conversation);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isAccountCompatibleWithConversation(Conversation conversation, ChannelAccount account) {
|
||||
// 单聊不强制按场景绑定,保持与 SendService 路由逻辑一致
|
||||
if (conversation.getConversationType() != null && conversation.getConversationType() == 1) {
|
||||
return conversation.getChannelType().equalsIgnoreCase(account.getChannelType());
|
||||
}
|
||||
Integer scene = conversation.getConversationScene();
|
||||
if (scene != null) {
|
||||
return switch (scene) {
|
||||
case 1 -> AccountType.WECOM_BOT.getCode() == account.getAccountType();
|
||||
case 2 -> AccountType.WECHAT_PERSONAL.getCode() == account.getAccountType();
|
||||
default -> conversation.getChannelType().equalsIgnoreCase(account.getChannelType());
|
||||
};
|
||||
}
|
||||
return conversation.getChannelType().equalsIgnoreCase(account.getChannelType());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -160,4 +203,95 @@ public class ConversationService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public List<ConversationParticipantResponse> listParticipants(String tenantCode, Long conversationId) {
|
||||
getConversation(tenantCode, conversationId);
|
||||
|
||||
List<ConversationParticipant> participants = participantMapper.selectList(
|
||||
new LambdaQueryWrapper<ConversationParticipant>()
|
||||
.eq(ConversationParticipant::getConversationId, conversationId)
|
||||
.orderByAsc(ConversationParticipant::getId));
|
||||
|
||||
if (participants.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
Set<Long> channelIdentityIds = participants.stream()
|
||||
.map(ConversationParticipant::getChannelIdentityId)
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toSet());
|
||||
Set<Long> personIds = participants.stream()
|
||||
.map(ConversationParticipant::getPersonId)
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
Map<Long, ChannelIdentity> identityMap = channelIdentityIds.isEmpty() ? Collections.emptyMap()
|
||||
: channelIdentityMapper.selectBatchIds(channelIdentityIds).stream()
|
||||
.collect(Collectors.toMap(ChannelIdentity::getId, Function.identity()));
|
||||
Map<Long, Person> personMap = personIds.isEmpty() ? Collections.emptyMap()
|
||||
: personMapper.selectBatchIds(personIds).stream()
|
||||
.collect(Collectors.toMap(Person::getId, Function.identity()));
|
||||
|
||||
return participants.stream().map(participant -> {
|
||||
ConversationParticipantResponse response = new ConversationParticipantResponse();
|
||||
response.setId(participant.getId());
|
||||
response.setParticipantType(participant.getParticipantType());
|
||||
response.setJoinTime(participant.getJoinTime());
|
||||
response.setChannelIdentityId(participant.getChannelIdentityId());
|
||||
|
||||
ChannelIdentity identity = participant.getChannelIdentityId() == null
|
||||
? null : identityMap.get(participant.getChannelIdentityId());
|
||||
response.setChannelUserId(identity == null ? null : identity.getChannelUserId());
|
||||
response.setChannelUserName(identity == null ? null : identity.getChannelUserName());
|
||||
|
||||
Person person = participant.getPersonId() == null
|
||||
? null : personMap.get(participant.getPersonId());
|
||||
response.setPersonId(participant.getPersonId());
|
||||
response.setPersonName(person == null ? null : person.getPersonName());
|
||||
response.setPersonType(person == null ? null : person.getPersonType());
|
||||
|
||||
return response;
|
||||
}).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
public List<TagResponse> listTags(String tenantCode, Long conversationId) {
|
||||
getConversation(tenantCode, conversationId);
|
||||
|
||||
List<ConversationTagBinding> bindings = conversationTagBindingMapper.selectList(
|
||||
new LambdaQueryWrapper<ConversationTagBinding>()
|
||||
.eq(ConversationTagBinding::getConversationId, conversationId)
|
||||
.orderByAsc(ConversationTagBinding::getId));
|
||||
|
||||
if (bindings.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
Set<Long> tagIds = bindings.stream()
|
||||
.map(ConversationTagBinding::getTagId)
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
Map<Long, Tag> tagMap = tagMapper.selectBatchIds(tagIds).stream()
|
||||
.collect(Collectors.toMap(Tag::getId, Function.identity()));
|
||||
|
||||
return bindings.stream()
|
||||
.map(binding -> {
|
||||
Tag tag = tagMap.get(binding.getTagId());
|
||||
if (tag == null) {
|
||||
return null;
|
||||
}
|
||||
TagResponse response = new TagResponse();
|
||||
response.setId(tag.getId());
|
||||
response.setTenantCode(tag.getTenantCode());
|
||||
response.setParentId(tag.getParentId());
|
||||
response.setTagPath(tag.getTagPath());
|
||||
response.setLevel(tag.getLevel());
|
||||
response.setTagName(tag.getTagName());
|
||||
response.setTagSource(tag.getTagSource());
|
||||
response.setStatus(tag.getStatus());
|
||||
return response;
|
||||
})
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
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 CONVERSATION_SCENE_EXTERNAL = 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();
|
||||
Integer originalConversationScene = conversation.getConversationScene();
|
||||
conversation.setConversationName(conv.getChatName());
|
||||
conversation.setOwnerAccountId(account.getId());
|
||||
conversation.setSubjectId(subjectId);
|
||||
conversation.setInstitutionId(originalInstitutionId);
|
||||
conversation.setSupplierId(originalSupplierId);
|
||||
conversation.setContractId(originalContractId);
|
||||
conversation.setChannelCode(originalChannelCode);
|
||||
conversation.setConversationScene(originalConversationScene);
|
||||
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.setConversationScene(CONVERSATION_SCENE_EXTERNAL);
|
||||
newConversation.setCreateSource(1);
|
||||
newConversation.setStatus(1);
|
||||
conversationMapper.insert(newConversation);
|
||||
return newConversation;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void refreshParticipants(String tenantCode, Long subjectId, Conversation conversation, WeComConversation conv) {
|
||||
conversationParticipantMapper.physicalDeleteByConversationId(conversation.getId());
|
||||
|
||||
for (WeComConversationMember member : conv.getMemberList()) {
|
||||
log.debug("[SyncDataPersister] 群聊成员 userId={}, type={}", member.getUserId(), member.getType());
|
||||
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()));
|
||||
|
||||
int personType = resolveMemberPersonType(member.getType());
|
||||
if (identity != null) {
|
||||
Person existPerson = personMapper.selectById(identity.getPersonId());
|
||||
if (existPerson != null && !existPerson.getPersonType().equals(personType)) {
|
||||
existPerson.setPersonType(personType);
|
||||
personMapper.updateById(existPerson);
|
||||
}
|
||||
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(personType);
|
||||
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 int resolveMemberPersonType(Integer memberType) {
|
||||
// 企微群聊成员类型:1=企业成员,2=外部联系人
|
||||
return memberType != null && memberType == 1 ? PERSON_TYPE_INTERNAL : PERSON_TYPE_CUSTOMER;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
package com.sino.mci.config;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.DbType;
|
||||
import com.baomidou.mybatisplus.core.config.GlobalConfig;
|
||||
import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
|
||||
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
|
||||
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
|
||||
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
|
||||
import org.apache.ibatis.session.SqlSessionFactory;
|
||||
import org.mybatis.spring.annotation.MapperScan;
|
||||
@@ -14,10 +17,18 @@ import javax.sql.DataSource;
|
||||
@MapperScan({"com.sino.mci.admin.repository", "com.sino.mci.channel.repository", "com.sino.mci.crm.repository", "com.sino.mci.flow.repository", "com.sino.mci.inbound.repository", "com.sino.mci.message.repository", "com.sino.mci.policy.repository", "com.sino.mci.send.repository"})
|
||||
public class MybatisPlusConfig {
|
||||
|
||||
@Bean
|
||||
public MybatisPlusInterceptor mybatisPlusInterceptor() {
|
||||
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
|
||||
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
|
||||
return interceptor;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public SqlSessionFactory sqlSessionFactory(DataSource dataSource, MetaObjectHandler metaObjectHandler) throws Exception {
|
||||
MybatisSqlSessionFactoryBean factoryBean = new MybatisSqlSessionFactoryBean();
|
||||
factoryBean.setDataSource(dataSource);
|
||||
factoryBean.setPlugins(mybatisPlusInterceptor());
|
||||
|
||||
GlobalConfig globalConfig = new GlobalConfig();
|
||||
globalConfig.setMetaObjectHandler(metaObjectHandler);
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.sino.mci.channel.adapter.registry.ChannelAdapterRegistry;
|
||||
import com.sino.mci.channel.common.dto.SendMessageRequest;
|
||||
import com.sino.mci.channel.common.dto.SendMessageResult;
|
||||
import com.sino.mci.channel.common.model.AccountType;
|
||||
import com.sino.mci.channel.common.model.ChannelType;
|
||||
import com.sino.mci.channel.common.spi.ChannelAdapter;
|
||||
import com.sino.mci.channel.entity.ChannelAccount;
|
||||
@@ -79,6 +80,14 @@ public class SendService {
|
||||
private static final int SEND_STATUS_SUCCESS = 2;
|
||||
private static final int SEND_STATUS_FAILED = 3;
|
||||
|
||||
private static final int CONVERSATION_TYPE_SINGLE = 1;
|
||||
private static final int CONVERSATION_TYPE_GROUP = 2;
|
||||
private static final int CONVERSATION_SCENE_INTERNAL = 1;
|
||||
private static final int CONVERSATION_SCENE_EXTERNAL = 2;
|
||||
private static final Set<Integer> ACCOUNT_TYPES_BOT = Set.of(AccountType.WECOM_BOT.getCode());
|
||||
private static final Set<Integer> ACCOUNT_TYPES_PYWECHAT = Set.of(AccountType.WECHAT_PERSONAL.getCode());
|
||||
private static final Set<Integer> ACCOUNT_TYPES_WECOM_AGENT = Set.of(AccountType.WECOM_AGENT.getCode());
|
||||
|
||||
private final SendRecordMapper sendRecordMapper;
|
||||
private final ConversationMapper conversationMapper;
|
||||
private final ChannelAccountMapper accountMapper;
|
||||
@@ -322,15 +331,46 @@ public class SendService {
|
||||
}
|
||||
}
|
||||
|
||||
private ChannelType resolveChannelTypeForScene(Integer scene) {
|
||||
if (scene == null) {
|
||||
throw new BusinessException("会话未设置场景,无法自动路由");
|
||||
}
|
||||
return scene == CONVERSATION_SCENE_INTERNAL ? ChannelType.WECOM : ChannelType.WECHAT_PERSONAL;
|
||||
}
|
||||
|
||||
private Set<Integer> resolveAccountTypesForScene(Integer scene) {
|
||||
if (scene == null) {
|
||||
throw new BusinessException("会话未设置场景,无法自动路由");
|
||||
}
|
||||
return scene == CONVERSATION_SCENE_INTERNAL ? ACCOUNT_TYPES_BOT : ACCOUNT_TYPES_PYWECHAT;
|
||||
}
|
||||
|
||||
private List<SendTarget> resolveConversationTarget(String tenantCode, Long conversationId,
|
||||
ChannelType channelType, Map<String, Object> channelStrategy) {
|
||||
ChannelType ignoredChannelType,
|
||||
Map<String, Object> channelStrategy) {
|
||||
Conversation conversation = conversationMapper.selectById(conversationId);
|
||||
if (conversation == null || !tenantCode.equals(conversation.getTenantCode())) {
|
||||
throw new BusinessException("会话不存在: " + conversationId);
|
||||
}
|
||||
Long accountId = selectSendAccount(tenantCode, conversationId, channelType, channelStrategy);
|
||||
|
||||
ChannelType channelType;
|
||||
Set<Integer> accountTypes;
|
||||
// 单聊未回填场景时保持旧行为,按 channelStrategy 默认 WECOM 路由;群聊必须按场景路由
|
||||
if (conversation.getConversationType() != null && conversation.getConversationType() == CONVERSATION_TYPE_SINGLE) {
|
||||
channelType = parseChannelType(extractChannelType(channelStrategy));
|
||||
accountTypes = null;
|
||||
} else {
|
||||
channelType = resolveChannelTypeForScene(conversation.getConversationScene());
|
||||
accountTypes = resolveAccountTypesForScene(conversation.getConversationScene());
|
||||
}
|
||||
|
||||
Long accountId = selectSendAccount(tenantCode, conversationId, channelType, accountTypes, channelStrategy);
|
||||
if (accountId == null) {
|
||||
accountId = conversation.getOwnerAccountId();
|
||||
ChannelAccount ownerAccount = accountMapper.selectById(accountId);
|
||||
if (ownerAccount == null || (accountTypes != null && !accountTypes.contains(ownerAccount.getAccountType()))) {
|
||||
accountId = null;
|
||||
}
|
||||
}
|
||||
if (accountId == null) {
|
||||
throw new BusinessException("会话未配置发送账号: " + conversationId);
|
||||
@@ -349,17 +389,22 @@ public class SendService {
|
||||
return Collections.singletonList(target);
|
||||
}
|
||||
|
||||
private List<SendTarget> resolvePersonTarget(String tenantCode, Long personId, ChannelType channelType) {
|
||||
private List<SendTarget> resolvePersonTarget(String tenantCode, Long personId, ChannelType ignoredChannelType) {
|
||||
Person person = personMapper.selectById(personId);
|
||||
if (person == null || !tenantCode.equals(person.getTenantCode())) {
|
||||
throw new BusinessException("人员不存在: " + personId);
|
||||
}
|
||||
ChannelIdentity identity = channelIdentityMapper.selectOne(
|
||||
new LambdaQueryWrapper<ChannelIdentity>()
|
||||
.eq(ChannelIdentity::getTenantCode, tenantCode)
|
||||
.eq(ChannelIdentity::getPersonId, personId)
|
||||
.eq(ChannelIdentity::getChannelType, channelType.name().toLowerCase())
|
||||
.eq(ChannelIdentity::getChannelType, ChannelType.WECOM.name().toLowerCase())
|
||||
.eq(ChannelIdentity::getStatus, STATUS_ENABLED));
|
||||
if (identity == null) {
|
||||
throw new BusinessException("人员未配置渠道身份: person_id=" + personId + ", channel=" + channelType.name().toLowerCase());
|
||||
throw new BusinessException("人员未配置渠道身份: person_id=" + personId);
|
||||
}
|
||||
Long accountId = selectDefaultAccount(tenantCode, channelType);
|
||||
ChannelType channelType = ChannelType.WECOM;
|
||||
Long accountId = selectDefaultAccount(tenantCode, channelType, ACCOUNT_TYPES_WECOM_AGENT);
|
||||
if (accountId == null) {
|
||||
throw new BusinessException("租户未配置可用发送账号: channel=" + channelType.name().toLowerCase());
|
||||
}
|
||||
@@ -687,18 +732,33 @@ public class SendService {
|
||||
|
||||
private Long selectSendAccount(String tenantCode, Long conversationId, ChannelType channelType,
|
||||
Map<String, Object> channelStrategy) {
|
||||
return selectSendAccount(tenantCode, conversationId, channelType, null, channelStrategy);
|
||||
}
|
||||
|
||||
private Long selectSendAccount(String tenantCode, Long conversationId, ChannelType channelType,
|
||||
Set<Integer> accountTypes, Map<String, Object> channelStrategy) {
|
||||
List<Long> accounts = accountSelector.selectSendAccounts(tenantCode, conversationId, channelType,
|
||||
extractStrategy(channelStrategy));
|
||||
accountTypes, extractStrategy(channelStrategy));
|
||||
return CollectionUtils.isEmpty(accounts) ? null : accounts.get(0);
|
||||
}
|
||||
|
||||
private Long selectDefaultAccount(String tenantCode, ChannelType channelType) {
|
||||
private Long selectDefaultAccount(String tenantCode, ChannelType channelType, Set<Integer> accountTypes) {
|
||||
List<ChannelAccount> accounts = accountMapper.selectList(
|
||||
new LambdaQueryWrapper<ChannelAccount>()
|
||||
.eq(ChannelAccount::getTenantCode, tenantCode)
|
||||
.eq(ChannelAccount::getChannelType, channelType.name().toLowerCase())
|
||||
.eq(ChannelAccount::getStatus, STATUS_ENABLED));
|
||||
return CollectionUtils.isEmpty(accounts) ? null : accounts.get(0).getId();
|
||||
if (CollectionUtils.isEmpty(accounts)) {
|
||||
return null;
|
||||
}
|
||||
if (accountTypes != null) {
|
||||
return accounts.stream()
|
||||
.filter(a -> accountTypes.contains(a.getAccountType()))
|
||||
.findFirst()
|
||||
.map(ChannelAccount::getId)
|
||||
.orElse(null);
|
||||
}
|
||||
return accounts.get(0).getId();
|
||||
}
|
||||
|
||||
private String extractChannelType(Map<String, Object> channelStrategy) {
|
||||
@@ -731,6 +791,24 @@ public class SendService {
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private String extractWeComBotWebhookUrl(String extInfo) {
|
||||
if (!StringUtils.hasText(extInfo)) {
|
||||
return null;
|
||||
}
|
||||
if (extInfo.trim().startsWith("{")) {
|
||||
try {
|
||||
Map<String, Object> parsed = JsonUtils.fromJson(extInfo, Map.class);
|
||||
if (parsed != null && parsed.get("webhook_url") != null) {
|
||||
return String.valueOf(parsed.get("webhook_url"));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// fallback: treat invalid JSON ext_info as plain URL string
|
||||
}
|
||||
}
|
||||
return extInfo;
|
||||
}
|
||||
|
||||
private RetryConfig loadRetryConfig(String tenantCode, SendRequest request) {
|
||||
String channelTypeStr = extractChannelType(request.getChannelStrategy());
|
||||
PolicyGroup policyGroup = loadPolicyGroup(tenantCode, channelTypeStr);
|
||||
@@ -800,6 +878,22 @@ public class SendService {
|
||||
public void dispatchRecord(SendRecord record) {
|
||||
|
||||
ChannelType channelType = parseChannelType(record.getChannelType());
|
||||
Set<Integer> accountTypes;
|
||||
if (record.getConversationId() != null) {
|
||||
Conversation conversation = conversationMapper.selectById(record.getConversationId());
|
||||
Integer scene = conversation != null ? conversation.getConversationScene() : null;
|
||||
if (scene != null) {
|
||||
channelType = resolveChannelTypeForScene(scene);
|
||||
accountTypes = resolveAccountTypesForScene(scene);
|
||||
} else {
|
||||
accountTypes = null;
|
||||
}
|
||||
} else if (record.getPersonId() != null) {
|
||||
accountTypes = ACCOUNT_TYPES_WECOM_AGENT;
|
||||
} else {
|
||||
accountTypes = null;
|
||||
}
|
||||
|
||||
ChannelAdapter adapter = adapterRegistry.getAdapter(channelType);
|
||||
if (adapter == null) {
|
||||
record.setSendStatus(SEND_STATUS_FAILED);
|
||||
@@ -822,7 +916,7 @@ public class SendService {
|
||||
return;
|
||||
}
|
||||
|
||||
List<Long> candidateAccountIds = selectSendAccountCandidates(record, channelType);
|
||||
List<Long> candidateAccountIds = selectSendAccountCandidates(record, channelType, accountTypes);
|
||||
if (candidateAccountIds.isEmpty()) {
|
||||
record.setSendStatus(SEND_STATUS_FAILED);
|
||||
record.setChannelResult(JsonUtils.toJson(Collections.singletonMap("error", "no_healthy_account")));
|
||||
@@ -865,12 +959,22 @@ public class SendService {
|
||||
sendRecordMapper.updateById(record);
|
||||
}
|
||||
|
||||
private List<Long> selectSendAccountCandidates(SendRecord record, ChannelType channelType) {
|
||||
private List<Long> selectSendAccountCandidates(SendRecord record, ChannelType channelType, Set<Integer> accountTypes) {
|
||||
if (record.getConversationId() == null) {
|
||||
return record.getAccountId() != null ? Collections.singletonList(record.getAccountId()) : Collections.emptyList();
|
||||
if (record.getAccountId() == null) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
ChannelAccount account = accountMapper.selectById(record.getAccountId());
|
||||
if (account == null || !AccountSelector.isHealthy(account, record.getTenantCode(), channelType)) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
if (accountTypes != null && !accountTypes.contains(account.getAccountType())) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
return Collections.singletonList(record.getAccountId());
|
||||
}
|
||||
String preferredStrategy = extractStrategy(parseChannelStrategy(record.getChannelStrategy()));
|
||||
return accountSelector.selectSendAccounts(record.getTenantCode(), record.getConversationId(), channelType, preferredStrategy);
|
||||
return accountSelector.selectSendAccounts(record.getTenantCode(), record.getConversationId(), channelType, accountTypes, preferredStrategy);
|
||||
}
|
||||
|
||||
private Map<String, Object> parseChannelStrategy(String strategyJson) {
|
||||
@@ -896,9 +1000,18 @@ public class SendService {
|
||||
messageRequest.setContent(JsonUtils.fromJson(record.getContentBody(), Map.class));
|
||||
|
||||
Map<String, Object> extra = new HashMap<>();
|
||||
if (account != null && StringUtils.hasText(account.getAccountId())) {
|
||||
if (account != null) {
|
||||
extra.put("account_type", account.getAccountType());
|
||||
if (StringUtils.hasText(account.getAccountId())) {
|
||||
extra.put("account_id", account.getAccountId());
|
||||
}
|
||||
if (account.getAccountType() != null && account.getAccountType() == AccountType.WECOM_BOT.getCode()
|
||||
&& StringUtils.hasText(account.getExtInfo())) {
|
||||
extra.put("webhook_url", extractWeComBotWebhookUrl(account.getExtInfo()));
|
||||
}
|
||||
}
|
||||
extra.put("record_id", record.getId());
|
||||
extra.put("tenant_code", record.getTenantCode());
|
||||
messageRequest.setExtra(extra);
|
||||
|
||||
try {
|
||||
@@ -914,7 +1027,8 @@ public class SendService {
|
||||
}
|
||||
|
||||
private void markAccountUnhealthy(ChannelAccount account) {
|
||||
if (account == null || account.getAccountType() == null || account.getAccountType() != 3) {
|
||||
if (account == null || account.getAccountType() == null
|
||||
|| account.getAccountType() != AccountType.WECHAT_PERSONAL.getCode()) {
|
||||
return;
|
||||
}
|
||||
account.setRiskLevel(2);
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
spring:
|
||||
datasource:
|
||||
url: jdbc:mysql://localhost:3306/sino_mci?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true
|
||||
username: root
|
||||
password: root
|
||||
url: jdbc:mysql://${MYSQL_HOST:localhost}:${MYSQL_PORT:3306}/${MYSQL_DATABASE:sino_mci}?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true
|
||||
username: ${MYSQL_USER:root}
|
||||
password: ${MYSQL_ROOT_PASSWORD:root}
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
data:
|
||||
redis:
|
||||
host: localhost
|
||||
port: 6379
|
||||
host: ${REDIS_HOST:localhost}
|
||||
port: ${REDIS_PORT:6379}
|
||||
database: 0
|
||||
rabbitmq:
|
||||
host: localhost
|
||||
port: 5672
|
||||
username: guest
|
||||
password: guest
|
||||
host: ${RABBITMQ_HOST:localhost}
|
||||
port: ${RABBITMQ_AMQP_PORT:5672}
|
||||
username: ${RABBITMQ_DEFAULT_USER:guest}
|
||||
password: ${RABBITMQ_DEFAULT_PASS:guest}
|
||||
ai:
|
||||
openai:
|
||||
api-key: ${OPENAI_API_KEY:}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE mci_channel_subject DROP COLUMN corp_secret;
|
||||
@@ -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='同步任务表';
|
||||
@@ -0,0 +1,8 @@
|
||||
CREATE TABLE IF NOT EXISTS mci_channel_event_record (
|
||||
event_id VARCHAR(64) PRIMARY KEY,
|
||||
account_id VARCHAR(64) NOT NULL,
|
||||
event_type VARCHAR(32) NOT NULL,
|
||||
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_channel_event_account_time ON mci_channel_event_record(account_id, create_time);
|
||||
@@ -0,0 +1,7 @@
|
||||
ALTER TABLE mci_conversation
|
||||
ADD COLUMN conversation_scene TINYINT NULL COMMENT '会话场景:1=内部群 2=外部群';
|
||||
|
||||
-- 现有数据:群聊默认外部群(客户群)
|
||||
UPDATE mci_conversation
|
||||
SET conversation_scene = 2
|
||||
WHERE conversation_type = 2 AND conversation_scene IS NULL;
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.sino.mci.channel.controller;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.sino.mci.channel.dto.ChannelAccountEventRequest;
|
||||
import com.sino.mci.channel.entity.ChannelAccount;
|
||||
import com.sino.mci.channel.repository.ChannelAccountMapper;
|
||||
import com.sino.mci.channel.repository.ChannelEventRecordMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.context.jdbc.Sql;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@Transactional
|
||||
@Sql(scripts = "/sql/test-channel-account.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
|
||||
class ChannelAccountEventControllerTest {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@Autowired
|
||||
private ChannelAccountMapper accountMapper;
|
||||
|
||||
@Autowired
|
||||
private ChannelEventRecordMapper eventRecordMapper;
|
||||
|
||||
@Test
|
||||
void shouldUpdateAccountOnHeartbeat() throws Exception {
|
||||
ChannelAccountEventRequest request = new ChannelAccountEventRequest();
|
||||
request.setEventId("evt-001");
|
||||
request.setEventType("heartbeat");
|
||||
request.setAccountId("wx-bot-01");
|
||||
request.setTenantCode("rescue");
|
||||
request.setData(Map.of(
|
||||
"login_status", "success",
|
||||
"online_status", "online",
|
||||
"risk_level", 10));
|
||||
|
||||
mockMvc.perform(post("/msg-platform/api/v1/channel-account/event")
|
||||
.contextPath("/msg-platform")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0));
|
||||
|
||||
ChannelAccount account = accountMapper.selectById(1L);
|
||||
assertThat(account.getOnlineStatus()).isEqualTo(1);
|
||||
assertThat(account.getRiskLevel()).isEqualTo(10);
|
||||
assertThat(eventRecordMapper.selectById("evt-001")).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldIgnoreDuplicateEvent() throws Exception {
|
||||
ChannelAccountEventRequest request = new ChannelAccountEventRequest();
|
||||
request.setEventId("evt-dup-001");
|
||||
request.setEventType("heartbeat");
|
||||
request.setAccountId("wx-bot-01");
|
||||
request.setTenantCode("rescue");
|
||||
request.setData(Map.of("online_status", "online"));
|
||||
|
||||
mockMvc.perform(post("/msg-platform/api/v1/channel-account/event")
|
||||
.contextPath("/msg-platform")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
mockMvc.perform(post("/msg-platform/api/v1/channel-account/event")
|
||||
.contextPath("/msg-platform")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
assertThat(eventRecordMapper.selectCount(null)).isEqualTo(1);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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("测试客户群")
|
||||
|
||||
@@ -14,6 +14,8 @@ import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
@@ -86,6 +88,20 @@ class AccountSelectorTest {
|
||||
assertEquals(2L, accountId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void selectSendAccountsFiltersByAccountType() {
|
||||
ChannelAccountConversation primary = createBinding(1L, 1, 1);
|
||||
ChannelAccountConversation backup = createBinding(2L, 2, 1);
|
||||
when(accountConversationMapper.selectList(any())).thenReturn(Arrays.asList(primary, backup));
|
||||
when(accountMapper.selectById(1L)).thenReturn(createHealthyAccount(1L, 1));
|
||||
when(accountMapper.selectById(2L)).thenReturn(createHealthyAccount(2L, 3));
|
||||
|
||||
List<Long> accountIds = accountSelector.selectSendAccounts(
|
||||
"tenant", 100L, ChannelType.WECHAT_PERSONAL, Set.of(1), "primary");
|
||||
|
||||
assertEquals(Collections.singletonList(1L), accountIds);
|
||||
}
|
||||
|
||||
private ChannelAccountConversation createBinding(Long accountId, int bindingType, int sortOrder) {
|
||||
ChannelAccountConversation binding = new ChannelAccountConversation();
|
||||
binding.setAccountId(accountId);
|
||||
@@ -96,10 +112,14 @@ class AccountSelectorTest {
|
||||
}
|
||||
|
||||
private ChannelAccount createHealthyAccount(Long id) {
|
||||
return createHealthyAccount(id, 3);
|
||||
}
|
||||
|
||||
private ChannelAccount createHealthyAccount(Long id, int accountType) {
|
||||
ChannelAccount account = new ChannelAccount();
|
||||
account.setId(id);
|
||||
account.setTenantCode("tenant");
|
||||
account.setAccountType(3);
|
||||
account.setAccountType(accountType);
|
||||
account.setChannelType("wechat_personal");
|
||||
account.setAccountId("wxid-" + id);
|
||||
account.setStatus(1);
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.sino.mci.config;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
|
||||
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class MybatisPlusConfigTest {
|
||||
|
||||
@Test
|
||||
void mybatisPlusInterceptorContainsPaginationInnerInterceptor() {
|
||||
MybatisPlusConfig config = new MybatisPlusConfig();
|
||||
MybatisPlusInterceptor interceptor = config.mybatisPlusInterceptor();
|
||||
|
||||
assertNotNull(interceptor);
|
||||
assertTrue(
|
||||
interceptor.getInterceptors().stream().anyMatch(i -> i instanceof PaginationInnerInterceptor),
|
||||
"MybatisPlusInterceptor should contain PaginationInnerInterceptor"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -52,10 +52,11 @@ class SendEndToEndTest {
|
||||
.andReturn().getResponse().getContentAsString();
|
||||
Long subjectId = ((Number) com.jayway.jsonpath.JsonPath.read(subjectResponse, "$.data.id")).longValue();
|
||||
|
||||
// 2. 创建渠道账号
|
||||
// 2. 创建外部群发送账号(pywechat 个人号)
|
||||
String accountCode = "wxid-e2e-" + UUID.randomUUID().toString().substring(0, 8);
|
||||
String accountPayload = String.format(
|
||||
"{\"accountType\":1,\"channelType\":\"wecom\",\"subjectId\":%d,\"accountId\":\"ZhangSan\",\"accountName\":\"张三\"}",
|
||||
subjectId);
|
||||
"{\"accountType\":3,\"channelType\":\"wechat_personal\",\"subjectId\":%d,\"accountId\":\"%s\",\"accountName\":\"张三\"}",
|
||||
subjectId, accountCode);
|
||||
String accountResponse = mockMvc.perform(post("/msg-platform/api/v1/channel-account")
|
||||
.contextPath("/msg-platform")
|
||||
.header("Authorization", authToken)
|
||||
@@ -67,10 +68,20 @@ class SendEndToEndTest {
|
||||
.andReturn().getResponse().getContentAsString();
|
||||
Long accountId = ((Number) com.jayway.jsonpath.JsonPath.read(accountResponse, "$.data.id")).longValue();
|
||||
|
||||
// 3. 创建会话
|
||||
// 个人号需要心跳才能被路由视为可用
|
||||
mockMvc.perform(post("/msg-platform/api/v1/channel-account/heartbeat")
|
||||
.contextPath("/msg-platform")
|
||||
.header("Authorization", authToken)
|
||||
.header("X-Tenant-Code", tenantCode)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(String.format("{\"accountId\":\"%s\",\"riskLevel\":1}", accountCode)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0));
|
||||
|
||||
// 3. 创建外部群会话
|
||||
String channelConversationId = "room-" + UUID.randomUUID().toString().substring(0, 8);
|
||||
String conversationPayload = String.format(
|
||||
"{\"conversationType\":2,\"channelType\":\"wecom\",\"channelConversationId\":\"%s\",\"conversationName\":\"E2E群\",\"subjectId\":%d}",
|
||||
"{\"conversationType\":2,\"channelType\":\"wechat_personal\",\"conversationScene\":2,\"channelConversationId\":\"%s\",\"conversationName\":\"E2E群\",\"subjectId\":%d}",
|
||||
channelConversationId, subjectId);
|
||||
String conversationResponse = mockMvc.perform(post("/msg-platform/api/v1/conversation")
|
||||
.contextPath("/msg-platform")
|
||||
@@ -121,7 +132,7 @@ class SendEndToEndTest {
|
||||
.andExpect(jsonPath("$.data.length()").value(1))
|
||||
.andExpect(jsonPath("$.data[0].taskId").value(taskId))
|
||||
.andExpect(jsonPath("$.data[0].sendStatus").value(2))
|
||||
.andExpect(jsonPath("$.data[0].channelType").value("wecom"))
|
||||
.andExpect(jsonPath("$.data[0].channelType").value("wechat_personal"))
|
||||
.andExpect(jsonPath("$.data[0].conversationId").value(conversationId))
|
||||
.andExpect(jsonPath("$.data[0].accountId").value(accountId))
|
||||
.andExpect(jsonPath("$.data[0].contentType").value("text"))
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
package com.sino.mci.send.service;
|
||||
|
||||
import com.sino.mci.channel.common.model.AccountType;
|
||||
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.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.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.send.entity.SendRecord;
|
||||
import com.sino.mci.send.repository.SendRecordMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
||||
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 java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static com.sino.mci.test.AuthTestHelper.createTenantAndLogin;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
/**
|
||||
* 基于会话场景与目标类型的发送路由集成测试。
|
||||
*
|
||||
* <p>覆盖自动路由策略:
|
||||
* <ul>
|
||||
* <li>内部群(conversation_scene = 1)→ WECOM / WECOM_BOT</li>
|
||||
* <li>外部群(conversation_scene = 2)→ WECHAT_PERSONAL</li>
|
||||
* <li>内部人员 → WECOM_AGENT</li>
|
||||
* <li>外部人员 → WECOM_AGENT</li>
|
||||
* </ul>
|
||||
*/
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@Transactional
|
||||
@TestPropertySource(properties = {
|
||||
"spring.rabbitmq.listener.simple.auto-startup=false"
|
||||
})
|
||||
class SendRoutingIntegrationTest {
|
||||
|
||||
private static final String CHANNEL_TYPE_WECOM = ChannelType.WECOM.name().toLowerCase();
|
||||
private static final String CHANNEL_TYPE_WECHAT_PERSONAL = ChannelType.WECHAT_PERSONAL.name().toLowerCase();
|
||||
|
||||
private static final int CONVERSATION_SCENE_INTERNAL = 1;
|
||||
private static final int CONVERSATION_SCENE_EXTERNAL = 2;
|
||||
|
||||
private static final int PERSON_TYPE_INTERNAL = 1;
|
||||
private static final int PERSON_TYPE_EXTERNAL = 3;
|
||||
|
||||
private static final int STATUS_ENABLED = 1;
|
||||
private static final int BINDING_TYPE_SEND_PRIMARY = 1;
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Autowired
|
||||
private ChannelSubjectMapper subjectMapper;
|
||||
|
||||
@Autowired
|
||||
private ChannelAccountMapper accountMapper;
|
||||
|
||||
@Autowired
|
||||
private ConversationMapper conversationMapper;
|
||||
|
||||
@Autowired
|
||||
private ChannelAccountConversationMapper accountConversationMapper;
|
||||
|
||||
@Autowired
|
||||
private PersonMapper personMapper;
|
||||
|
||||
@Autowired
|
||||
private ChannelIdentityMapper channelIdentityMapper;
|
||||
|
||||
@Autowired
|
||||
private SendRecordMapper sendRecordMapper;
|
||||
|
||||
@Test
|
||||
void shouldRouteInternalGroupToWeComBot() throws Exception {
|
||||
String tenantCode = "routing-int-group-" + UUID.randomUUID().toString().substring(0, 8);
|
||||
String authToken = createTenantAndLogin(mockMvc, tenantCode, "内部群路由测试");
|
||||
|
||||
ChannelSubject subject = createWeComSubject(tenantCode);
|
||||
ChannelAccount botAccount = createWeComBotAccount(tenantCode, subject.getId());
|
||||
Conversation conversation = createConversation(tenantCode, subject.getId(),
|
||||
CONVERSATION_SCENE_INTERNAL, "内部群");
|
||||
bindAccountToConversation(botAccount.getId(), conversation.getId());
|
||||
|
||||
String taskId = sendTestMessage(authToken, tenantCode,
|
||||
buildConversationPayload(conversation.getId()));
|
||||
|
||||
List<SendRecord> records = sendRecordMapper.selectList(
|
||||
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<SendRecord>()
|
||||
.eq(SendRecord::getTenantCode, tenantCode)
|
||||
.eq(SendRecord::getTaskId, taskId));
|
||||
|
||||
assertThat(records).hasSize(1);
|
||||
SendRecord record = records.get(0);
|
||||
assertThat(record.getChannelType()).isEqualTo(CHANNEL_TYPE_WECOM);
|
||||
assertThat(record.getAccountId()).isEqualTo(botAccount.getId());
|
||||
assertThat(record.getConversationId()).isEqualTo(conversation.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRouteExternalGroupToWeChatPersonal() throws Exception {
|
||||
String tenantCode = "routing-ext-group-" + UUID.randomUUID().toString().substring(0, 8);
|
||||
String authToken = createTenantAndLogin(mockMvc, tenantCode, "外部群路由测试");
|
||||
|
||||
ChannelSubject subject = createWeComSubject(tenantCode);
|
||||
ChannelAccount pywechatAccount = createWeChatPersonalAccount(tenantCode, subject.getId());
|
||||
Conversation conversation = createConversation(tenantCode, subject.getId(),
|
||||
CONVERSATION_SCENE_EXTERNAL, "外部群");
|
||||
bindAccountToConversation(pywechatAccount.getId(), conversation.getId());
|
||||
|
||||
String taskId = sendTestMessage(authToken, tenantCode,
|
||||
buildConversationPayload(conversation.getId()));
|
||||
|
||||
List<SendRecord> records = sendRecordMapper.selectList(
|
||||
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<SendRecord>()
|
||||
.eq(SendRecord::getTenantCode, tenantCode)
|
||||
.eq(SendRecord::getTaskId, taskId));
|
||||
|
||||
assertThat(records).hasSize(1);
|
||||
SendRecord record = records.get(0);
|
||||
assertThat(record.getChannelType()).isEqualTo(CHANNEL_TYPE_WECHAT_PERSONAL);
|
||||
assertThat(record.getAccountId()).isEqualTo(pywechatAccount.getId());
|
||||
assertThat(record.getConversationId()).isEqualTo(conversation.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRouteInternalPersonToWeComAgent() throws Exception {
|
||||
String tenantCode = "routing-int-person-" + UUID.randomUUID().toString().substring(0, 8);
|
||||
String authToken = createTenantAndLogin(mockMvc, tenantCode, "内部人员路由测试");
|
||||
|
||||
ChannelSubject subject = createWeComSubject(tenantCode);
|
||||
ChannelAccount agentAccount = createWeComAgentAccount(tenantCode, subject.getId());
|
||||
Person person = createPerson(tenantCode, "int-user", "内部人员", PERSON_TYPE_INTERNAL);
|
||||
createChannelIdentity(tenantCode, person.getId(), subject.getId(), "int-user");
|
||||
|
||||
String taskId = sendTestMessage(authToken, tenantCode,
|
||||
buildPersonPayload(person.getId()));
|
||||
|
||||
List<SendRecord> records = sendRecordMapper.selectList(
|
||||
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<SendRecord>()
|
||||
.eq(SendRecord::getTenantCode, tenantCode)
|
||||
.eq(SendRecord::getTaskId, taskId));
|
||||
|
||||
assertThat(records).hasSize(1);
|
||||
SendRecord record = records.get(0);
|
||||
assertThat(record.getChannelType()).isEqualTo(CHANNEL_TYPE_WECOM);
|
||||
assertThat(record.getAccountId()).isEqualTo(agentAccount.getId());
|
||||
assertThat(record.getPersonId()).isEqualTo(person.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRouteExternalPersonToWeComAgent() throws Exception {
|
||||
String tenantCode = "routing-ext-person-" + UUID.randomUUID().toString().substring(0, 8);
|
||||
String authToken = createTenantAndLogin(mockMvc, tenantCode, "外部人员路由测试");
|
||||
|
||||
ChannelSubject subject = createWeComSubject(tenantCode);
|
||||
ChannelAccount agentAccount = createWeComAgentAccount(tenantCode, subject.getId());
|
||||
Person person = createPerson(tenantCode, "ext-user", "外部人员", PERSON_TYPE_EXTERNAL);
|
||||
createChannelIdentity(tenantCode, person.getId(), subject.getId(), "ext-user");
|
||||
|
||||
String taskId = sendTestMessage(authToken, tenantCode,
|
||||
buildPersonPayload(person.getId()));
|
||||
|
||||
List<SendRecord> records = sendRecordMapper.selectList(
|
||||
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<SendRecord>()
|
||||
.eq(SendRecord::getTenantCode, tenantCode)
|
||||
.eq(SendRecord::getTaskId, taskId));
|
||||
|
||||
assertThat(records).hasSize(1);
|
||||
SendRecord record = records.get(0);
|
||||
assertThat(record.getChannelType()).isEqualTo(CHANNEL_TYPE_WECOM);
|
||||
assertThat(record.getAccountId()).isEqualTo(agentAccount.getId());
|
||||
assertThat(record.getPersonId()).isEqualTo(person.getId());
|
||||
}
|
||||
|
||||
private String sendTestMessage(String authToken, String tenantCode, String payload) throws Exception {
|
||||
String response = mockMvc.perform(post("/msg-platform/api/v1/send/test")
|
||||
.contextPath("/msg-platform")
|
||||
.header("Authorization", authToken)
|
||||
.header("X-Tenant-Code", tenantCode)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.totalCount").value(1))
|
||||
.andExpect(jsonPath("$.data.status").value("simulated"))
|
||||
.andReturn().getResponse().getContentAsString();
|
||||
return com.jayway.jsonpath.JsonPath.read(response, "$.data.taskId");
|
||||
}
|
||||
|
||||
private String buildConversationPayload(Long conversationId) {
|
||||
return String.format(
|
||||
"{\"targetType\":\"conversation\",\"targetValue\":{\"conversation_id\":%d},\"contentType\":\"text\",\"content\":{\"text\":\"hello\"},\"channelStrategy\":{\"channel_type\":\"wecom\",\"strategy\":\"primary\"}}",
|
||||
conversationId);
|
||||
}
|
||||
|
||||
private String buildPersonPayload(Long personId) {
|
||||
return String.format(
|
||||
"{\"targetType\":\"person\",\"targetValue\":{\"person_id\":%d},\"contentType\":\"text\",\"content\":{\"text\":\"hello\"},\"channelStrategy\":{\"channel_type\":\"wecom\"}}",
|
||||
personId);
|
||||
}
|
||||
|
||||
private ChannelSubject createWeComSubject(String tenantCode) {
|
||||
ChannelSubject subject = new ChannelSubject();
|
||||
subject.setTenantCode(tenantCode);
|
||||
subject.setChannelType(CHANNEL_TYPE_WECOM);
|
||||
subject.setSubjectCode("sub-" + UUID.randomUUID().toString().substring(0, 8));
|
||||
subject.setSubjectName("测试主体");
|
||||
subject.setCorpId("corp-" + UUID.randomUUID().toString().substring(0, 8));
|
||||
subject.setAgentId("1000002");
|
||||
subject.setAgentSecret("secret-" + UUID.randomUUID());
|
||||
subject.setStatus(STATUS_ENABLED);
|
||||
subjectMapper.insert(subject);
|
||||
return subject;
|
||||
}
|
||||
|
||||
private ChannelAccount createWeComBotAccount(String tenantCode, Long subjectId) {
|
||||
ChannelAccount account = new ChannelAccount();
|
||||
account.setTenantCode(tenantCode);
|
||||
account.setAccountType(AccountType.WECOM_BOT.getCode());
|
||||
account.setChannelType(CHANNEL_TYPE_WECOM);
|
||||
account.setSubjectId(subjectId);
|
||||
account.setAccountId("bot-" + UUID.randomUUID().toString().substring(0, 8));
|
||||
account.setAccountName("企微群机器人");
|
||||
account.setStatus(STATUS_ENABLED);
|
||||
accountMapper.insert(account);
|
||||
return account;
|
||||
}
|
||||
|
||||
private ChannelAccount createWeComAgentAccount(String tenantCode, Long subjectId) {
|
||||
ChannelAccount account = new ChannelAccount();
|
||||
account.setTenantCode(tenantCode);
|
||||
account.setAccountType(AccountType.WECOM_AGENT.getCode());
|
||||
account.setChannelType(CHANNEL_TYPE_WECOM);
|
||||
account.setSubjectId(subjectId);
|
||||
account.setAccountId("agent-" + UUID.randomUUID().toString().substring(0, 8));
|
||||
account.setAccountName("企微应用");
|
||||
account.setStatus(STATUS_ENABLED);
|
||||
accountMapper.insert(account);
|
||||
return account;
|
||||
}
|
||||
|
||||
private ChannelAccount createWeChatPersonalAccount(String tenantCode, Long subjectId) {
|
||||
ChannelAccount account = new ChannelAccount();
|
||||
account.setTenantCode(tenantCode);
|
||||
account.setAccountType(AccountType.WECHAT_PERSONAL.getCode());
|
||||
account.setChannelType(CHANNEL_TYPE_WECHAT_PERSONAL);
|
||||
account.setSubjectId(subjectId);
|
||||
account.setAccountId("wxid-" + UUID.randomUUID().toString().substring(0, 8));
|
||||
account.setAccountName("pywechat 个人号");
|
||||
account.setStatus(STATUS_ENABLED);
|
||||
account.setOnlineStatus(STATUS_ENABLED);
|
||||
account.setRiskLevel(1);
|
||||
account.setLastHeartbeat(LocalDateTime.now());
|
||||
accountMapper.insert(account);
|
||||
return account;
|
||||
}
|
||||
|
||||
private Conversation createConversation(String tenantCode, Long subjectId,
|
||||
Integer conversationScene, String conversationName) {
|
||||
Conversation conversation = new Conversation();
|
||||
conversation.setTenantCode(tenantCode);
|
||||
conversation.setConversationType(2);
|
||||
conversation.setChannelType(CHANNEL_TYPE_WECOM);
|
||||
conversation.setChannelConversationId("chat-" + UUID.randomUUID().toString().substring(0, 8));
|
||||
conversation.setSubjectId(subjectId);
|
||||
conversation.setConversationName(conversationName);
|
||||
conversation.setConversationScene(conversationScene);
|
||||
conversation.setCreateSource(1);
|
||||
conversation.setStatus(STATUS_ENABLED);
|
||||
conversationMapper.insert(conversation);
|
||||
return conversation;
|
||||
}
|
||||
|
||||
private void bindAccountToConversation(Long accountId, Long conversationId) {
|
||||
ChannelAccountConversation binding = new ChannelAccountConversation();
|
||||
binding.setAccountId(accountId);
|
||||
binding.setConversationId(conversationId);
|
||||
binding.setBindingType(BINDING_TYPE_SEND_PRIMARY);
|
||||
binding.setSortOrder(1);
|
||||
binding.setStatus(STATUS_ENABLED);
|
||||
accountConversationMapper.insert(binding);
|
||||
}
|
||||
|
||||
private Person createPerson(String tenantCode, String personCode, String personName, Integer personType) {
|
||||
Person person = new Person();
|
||||
person.setTenantCode(tenantCode);
|
||||
person.setPersonCode(personCode + "-" + UUID.randomUUID().toString().substring(0, 8));
|
||||
person.setPersonName(personName);
|
||||
person.setPhone("13800138000");
|
||||
person.setPersonType(personType);
|
||||
person.setStatus(STATUS_ENABLED);
|
||||
personMapper.insert(person);
|
||||
return person;
|
||||
}
|
||||
|
||||
private void createChannelIdentity(String tenantCode, Long personId, Long subjectId, String channelUserId) {
|
||||
ChannelIdentity identity = new ChannelIdentity();
|
||||
identity.setTenantCode(tenantCode);
|
||||
identity.setPersonId(personId);
|
||||
identity.setChannelType(CHANNEL_TYPE_WECOM);
|
||||
identity.setChannelUserId(channelUserId + "-" + UUID.randomUUID().toString().substring(0, 8));
|
||||
identity.setChannelUserName("测试用户");
|
||||
identity.setSubjectId(subjectId);
|
||||
identity.setStatus(STATUS_ENABLED);
|
||||
channelIdentityMapper.insert(identity);
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,11 @@ package com.sino.mci.send.service;
|
||||
import com.sino.mci.channel.adapter.registry.ChannelAdapterRegistry;
|
||||
import com.sino.mci.channel.common.dto.SendMessageRequest;
|
||||
import com.sino.mci.channel.common.dto.SendMessageResult;
|
||||
import com.sino.mci.channel.common.model.AccountType;
|
||||
import com.sino.mci.channel.common.model.ChannelType;
|
||||
import com.sino.mci.channel.common.spi.ChannelAdapter;
|
||||
import com.sino.mci.channel.entity.ChannelAccount;
|
||||
import com.sino.mci.channel.entity.Conversation;
|
||||
import com.sino.mci.channel.repository.ChannelAccountMapper;
|
||||
import com.sino.mci.channel.repository.ConversationMapper;
|
||||
import com.sino.mci.channel.repository.ConversationTagBindingMapper;
|
||||
@@ -31,12 +33,14 @@ import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
@@ -77,7 +81,7 @@ class SendServiceFailoverTest {
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
when(adapterRegistry.getAdapter(ChannelType.WECHAT_PERSONAL)).thenReturn(adapter);
|
||||
lenient().when(adapterRegistry.getAdapter(ChannelType.WECHAT_PERSONAL)).thenReturn(adapter);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -86,7 +90,8 @@ class SendServiceFailoverTest {
|
||||
Long primaryAccountId = 100L;
|
||||
Long backupAccountId = 200L;
|
||||
|
||||
when(accountSelector.selectSendAccounts("tenant", 1L, ChannelType.WECHAT_PERSONAL, "primary"))
|
||||
when(conversationMapper.selectById(1L)).thenReturn(createExternalConversation());
|
||||
when(accountSelector.selectSendAccounts("tenant", 1L, ChannelType.WECHAT_PERSONAL, Set.of(AccountType.WECHAT_PERSONAL.getCode()), "primary"))
|
||||
.thenReturn(Arrays.asList(primaryAccountId, backupAccountId));
|
||||
when(accountMapper.selectById(primaryAccountId)).thenReturn(createPywechatAccount(primaryAccountId, "wxid-primary"));
|
||||
when(accountMapper.selectById(backupAccountId)).thenReturn(createPywechatAccount(backupAccountId, "wxid-backup"));
|
||||
@@ -110,7 +115,9 @@ class SendServiceFailoverTest {
|
||||
verify(adapter, times(2)).sendMessage(requestCaptor.capture());
|
||||
List<SendMessageRequest> requests = requestCaptor.getAllValues();
|
||||
assertEquals("wxid-primary", requests.get(0).getExtra().get("account_id"));
|
||||
assertEquals(Integer.valueOf(AccountType.WECHAT_PERSONAL.getCode()), requests.get(0).getExtra().get("account_type"));
|
||||
assertEquals("wxid-backup", requests.get(1).getExtra().get("account_id"));
|
||||
assertEquals(Integer.valueOf(AccountType.WECHAT_PERSONAL.getCode()), requests.get(1).getExtra().get("account_type"));
|
||||
|
||||
ArgumentCaptor<ChannelAccount> accountCaptor = ArgumentCaptor.forClass(ChannelAccount.class);
|
||||
verify(accountMapper, times(1)).updateById(accountCaptor.capture());
|
||||
@@ -125,7 +132,8 @@ class SendServiceFailoverTest {
|
||||
SendRecord record = createRecord();
|
||||
Long accountId = 100L;
|
||||
|
||||
when(accountSelector.selectSendAccounts("tenant", 1L, ChannelType.WECHAT_PERSONAL, "primary"))
|
||||
when(conversationMapper.selectById(1L)).thenReturn(createExternalConversation());
|
||||
when(accountSelector.selectSendAccounts("tenant", 1L, ChannelType.WECHAT_PERSONAL, Set.of(AccountType.WECHAT_PERSONAL.getCode()), "primary"))
|
||||
.thenReturn(Collections.singletonList(accountId));
|
||||
when(accountMapper.selectById(accountId)).thenReturn(createPywechatAccount(accountId, "wxid-primary"));
|
||||
|
||||
@@ -159,7 +167,8 @@ class SendServiceFailoverTest {
|
||||
void dispatchRecordFailsWithNoHealthyAccount() {
|
||||
SendRecord record = createRecord();
|
||||
|
||||
when(accountSelector.selectSendAccounts("tenant", 1L, ChannelType.WECHAT_PERSONAL, "primary"))
|
||||
when(conversationMapper.selectById(1L)).thenReturn(createExternalConversation());
|
||||
when(accountSelector.selectSendAccounts("tenant", 1L, ChannelType.WECHAT_PERSONAL, Set.of(AccountType.WECHAT_PERSONAL.getCode()), "primary"))
|
||||
.thenReturn(Collections.emptyList());
|
||||
|
||||
sendService.dispatchRecord(record);
|
||||
@@ -172,6 +181,33 @@ class SendServiceFailoverTest {
|
||||
assertEquals("no_healthy_account", result.get("error"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void dispatchRecordPropagatesWebhookUrlForWeComBot() {
|
||||
SendRecord record = createInternalGroupRecord();
|
||||
Long botAccountId = 100L;
|
||||
|
||||
when(adapterRegistry.getAdapter(ChannelType.WECOM)).thenReturn(adapter);
|
||||
when(conversationMapper.selectById(1L)).thenReturn(createInternalConversation());
|
||||
when(accountSelector.selectSendAccounts("tenant", 1L, ChannelType.WECOM, Set.of(AccountType.WECOM_BOT.getCode()), "primary"))
|
||||
.thenReturn(Collections.singletonList(botAccountId));
|
||||
when(accountMapper.selectById(botAccountId)).thenReturn(createWeComBotAccount(botAccountId, "bot-1", "https://custom.example.com/webhook?key=secret"));
|
||||
|
||||
SendMessageResult successResult = new SendMessageResult();
|
||||
successResult.setSuccess(true);
|
||||
when(adapter.sendMessage(any(SendMessageRequest.class))).thenReturn(successResult);
|
||||
|
||||
sendService.dispatchRecord(record);
|
||||
|
||||
assertEquals(2, record.getSendStatus().intValue());
|
||||
assertEquals(botAccountId, record.getAccountId());
|
||||
|
||||
ArgumentCaptor<SendMessageRequest> requestCaptor = ArgumentCaptor.forClass(SendMessageRequest.class);
|
||||
verify(adapter).sendMessage(requestCaptor.capture());
|
||||
SendMessageRequest request = requestCaptor.getValue();
|
||||
assertEquals("https://custom.example.com/webhook?key=secret", request.getExtra().get("webhook_url"));
|
||||
assertEquals(Integer.valueOf(AccountType.WECOM_BOT.getCode()), request.getExtra().get("account_type"));
|
||||
}
|
||||
|
||||
private SendRecord createRecord() {
|
||||
SendRecord record = new SendRecord();
|
||||
record.setId(1L);
|
||||
@@ -187,16 +223,63 @@ class SendServiceFailoverTest {
|
||||
return record;
|
||||
}
|
||||
|
||||
private SendRecord createInternalGroupRecord() {
|
||||
SendRecord record = new SendRecord();
|
||||
record.setId(1L);
|
||||
record.setTaskId("SND001");
|
||||
record.setTenantCode("tenant");
|
||||
record.setConversationId(1L);
|
||||
record.setAccountId(100L);
|
||||
record.setChannelType("wecom");
|
||||
record.setChannelStrategy("{\"channel_type\":\"wecom\",\"strategy\":\"primary\"}");
|
||||
record.setContentType("text");
|
||||
record.setContentBody("{\"text\":\"hello\"}");
|
||||
record.setSendStatus(0);
|
||||
return record;
|
||||
}
|
||||
|
||||
private Conversation createExternalConversation() {
|
||||
Conversation conversation = new Conversation();
|
||||
conversation.setId(1L);
|
||||
conversation.setTenantCode("tenant");
|
||||
conversation.setConversationScene(2);
|
||||
conversation.setChannelConversationId("conv-1");
|
||||
return conversation;
|
||||
}
|
||||
|
||||
private Conversation createInternalConversation() {
|
||||
Conversation conversation = new Conversation();
|
||||
conversation.setId(1L);
|
||||
conversation.setTenantCode("tenant");
|
||||
conversation.setConversationType(2);
|
||||
conversation.setConversationScene(1);
|
||||
conversation.setChannelConversationId("conv-1");
|
||||
return conversation;
|
||||
}
|
||||
|
||||
private ChannelAccount createPywechatAccount(Long id, String accountId) {
|
||||
ChannelAccount account = new ChannelAccount();
|
||||
account.setId(id);
|
||||
account.setTenantCode("tenant");
|
||||
account.setAccountType(3);
|
||||
account.setAccountType(AccountType.WECHAT_PERSONAL.getCode());
|
||||
account.setChannelType("wechat_personal");
|
||||
account.setAccountId(accountId);
|
||||
account.setStatus(1);
|
||||
account.setOnlineStatus(1);
|
||||
account.setRiskLevel(1);
|
||||
account.setLastHeartbeat(java.time.LocalDateTime.now());
|
||||
return account;
|
||||
}
|
||||
|
||||
private ChannelAccount createWeComBotAccount(Long id, String accountId, String webhookUrl) {
|
||||
ChannelAccount account = new ChannelAccount();
|
||||
account.setId(id);
|
||||
account.setTenantCode("tenant");
|
||||
account.setAccountType(AccountType.WECOM_BOT.getCode());
|
||||
account.setChannelType("wecom");
|
||||
account.setAccountId(accountId);
|
||||
account.setStatus(1);
|
||||
account.setExtInfo(webhookUrl);
|
||||
return account;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.sino.mci.send.service;
|
||||
|
||||
import com.sino.mci.channel.adapter.registry.ChannelAdapterRegistry;
|
||||
import com.sino.mci.channel.common.model.AccountType;
|
||||
import com.sino.mci.channel.common.model.ChannelType;
|
||||
import com.sino.mci.channel.entity.ChannelAccount;
|
||||
import com.sino.mci.channel.entity.Conversation;
|
||||
@@ -11,6 +12,7 @@ import com.sino.mci.channel.repository.ConversationTagBindingMapper;
|
||||
import com.sino.mci.channel.service.AccountSelector;
|
||||
import com.sino.mci.crm.repository.ChannelIdentityMapper;
|
||||
import com.sino.mci.crm.entity.ChannelIdentity;
|
||||
import com.sino.mci.crm.entity.Person;
|
||||
import com.sino.mci.crm.repository.PersonMapper;
|
||||
import com.sino.mci.crm.repository.PersonTagBindingMapper;
|
||||
import com.sino.mci.crm.repository.TagMapper;
|
||||
@@ -31,6 +33,7 @@ import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
@@ -76,13 +79,13 @@ class SendServiceResolveTargetTest {
|
||||
|
||||
@Test
|
||||
void resolveConversationTarget_selectsHealthyPrimaryAccount() {
|
||||
Conversation conversation = createConversation();
|
||||
Conversation conversation = createConversation(1);
|
||||
Long primaryAccountId = 10L;
|
||||
|
||||
when(conversationMapper.selectById(1L)).thenReturn(conversation);
|
||||
when(accountSelector.selectSendAccounts("tenant", 1L, ChannelType.WECOM, "primary"))
|
||||
when(accountSelector.selectSendAccounts("tenant", 1L, ChannelType.WECOM, Set.of(AccountType.WECOM_BOT.getCode()), "primary"))
|
||||
.thenReturn(Collections.singletonList(primaryAccountId));
|
||||
when(accountMapper.selectById(primaryAccountId)).thenReturn(createAccount(primaryAccountId, "wecom", 1));
|
||||
when(accountMapper.selectById(primaryAccountId)).thenReturn(createWecomBotAccount(primaryAccountId, 1));
|
||||
|
||||
List<SendTarget> targets = sendService.resolveTestTargets("tenant", createConversationRequest("primary"));
|
||||
|
||||
@@ -93,13 +96,13 @@ class SendServiceResolveTargetTest {
|
||||
|
||||
@Test
|
||||
void resolveConversationTarget_rejectsUnhealthyOwnerAccount() {
|
||||
Conversation conversation = createConversation();
|
||||
Conversation conversation = createConversation(1);
|
||||
conversation.setOwnerAccountId(20L);
|
||||
|
||||
when(conversationMapper.selectById(1L)).thenReturn(conversation);
|
||||
when(accountSelector.selectSendAccounts("tenant", 1L, ChannelType.WECOM, "primary"))
|
||||
when(accountSelector.selectSendAccounts("tenant", 1L, ChannelType.WECOM, Set.of(AccountType.WECOM_BOT.getCode()), "primary"))
|
||||
.thenReturn(Collections.emptyList());
|
||||
ChannelAccount disabledAccount = createAccount(20L, "wecom", 0);
|
||||
ChannelAccount disabledAccount = createWecomBotAccount(20L, 0);
|
||||
when(accountMapper.selectById(20L)).thenReturn(disabledAccount);
|
||||
|
||||
BusinessException ex = assertThrows(BusinessException.class,
|
||||
@@ -109,13 +112,13 @@ class SendServiceResolveTargetTest {
|
||||
|
||||
@Test
|
||||
void resolveConversationTarget_usesAccountReturnedBySelector() {
|
||||
Conversation conversation = createConversation();
|
||||
Conversation conversation = createConversation(1);
|
||||
Long backupAccountId = 20L;
|
||||
|
||||
when(conversationMapper.selectById(1L)).thenReturn(conversation);
|
||||
when(accountSelector.selectSendAccounts("tenant", 1L, ChannelType.WECOM, "primary"))
|
||||
when(accountSelector.selectSendAccounts("tenant", 1L, ChannelType.WECOM, Set.of(AccountType.WECOM_BOT.getCode()), "primary"))
|
||||
.thenReturn(Collections.singletonList(backupAccountId));
|
||||
when(accountMapper.selectById(backupAccountId)).thenReturn(createAccount(backupAccountId, "wecom", 1));
|
||||
when(accountMapper.selectById(backupAccountId)).thenReturn(createWecomBotAccount(backupAccountId, 1));
|
||||
|
||||
List<SendTarget> targets = sendService.resolveTestTargets("tenant", createConversationRequest("primary"));
|
||||
|
||||
@@ -125,13 +128,13 @@ class SendServiceResolveTargetTest {
|
||||
|
||||
@Test
|
||||
void resolveConversationTarget_respectsBackupStrategy() {
|
||||
Conversation conversation = createConversation();
|
||||
Conversation conversation = createConversation(1);
|
||||
Long backupAccountId = 20L;
|
||||
|
||||
when(conversationMapper.selectById(1L)).thenReturn(conversation);
|
||||
when(accountSelector.selectSendAccounts("tenant", 1L, ChannelType.WECOM, "backup"))
|
||||
when(accountSelector.selectSendAccounts("tenant", 1L, ChannelType.WECOM, Set.of(AccountType.WECOM_BOT.getCode()), "backup"))
|
||||
.thenReturn(Collections.singletonList(backupAccountId));
|
||||
when(accountMapper.selectById(backupAccountId)).thenReturn(createAccount(backupAccountId, "wecom", 1));
|
||||
when(accountMapper.selectById(backupAccountId)).thenReturn(createWecomBotAccount(backupAccountId, 1));
|
||||
|
||||
List<SendTarget> targets = sendService.resolveTestTargets("tenant", createConversationRequest("backup"));
|
||||
|
||||
@@ -139,15 +142,63 @@ class SendServiceResolveTargetTest {
|
||||
assertEquals(backupAccountId, targets.get(0).getAccountId());
|
||||
|
||||
ArgumentCaptor<String> strategyCaptor = ArgumentCaptor.forClass(String.class);
|
||||
verify(accountSelector).selectSendAccounts(eq("tenant"), eq(1L), eq(ChannelType.WECOM), strategyCaptor.capture());
|
||||
verify(accountSelector).selectSendAccounts(eq("tenant"), eq(1L), eq(ChannelType.WECOM), eq(Set.of(AccountType.WECOM_BOT.getCode())), strategyCaptor.capture());
|
||||
assertEquals("backup", strategyCaptor.getValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolvePersonTarget_rejectsDisabledAccount() {
|
||||
ChannelIdentity identity = createIdentity();
|
||||
ChannelAccount account = createAccount(30L, "wecom", 0);
|
||||
void resolveConversationTarget_routesExternalSceneToPywechat() {
|
||||
Conversation conversation = createConversation(2);
|
||||
Long pywechatAccountId = 30L;
|
||||
|
||||
when(conversationMapper.selectById(1L)).thenReturn(conversation);
|
||||
when(accountSelector.selectSendAccounts("tenant", 1L, ChannelType.WECHAT_PERSONAL, Set.of(AccountType.WECHAT_PERSONAL.getCode()), "primary"))
|
||||
.thenReturn(Collections.singletonList(pywechatAccountId));
|
||||
when(accountMapper.selectById(pywechatAccountId)).thenReturn(createPywechatAccount(pywechatAccountId, 1));
|
||||
|
||||
List<SendTarget> targets = sendService.resolveTestTargets("tenant", createConversationRequest("primary"));
|
||||
|
||||
assertEquals(1, targets.size());
|
||||
assertEquals(pywechatAccountId, targets.get(0).getAccountId());
|
||||
assertEquals(ChannelType.WECHAT_PERSONAL, targets.get(0).getChannelType());
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveConversationTarget_rejectsNullScene() {
|
||||
Conversation conversation = createConversation(null);
|
||||
|
||||
when(conversationMapper.selectById(1L)).thenReturn(conversation);
|
||||
|
||||
BusinessException ex = assertThrows(BusinessException.class,
|
||||
() -> sendService.resolveTestTargets("tenant", createConversationRequest("primary")));
|
||||
assertEquals("会话未设置场景,无法自动路由", ex.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveConversationTarget_singleChatWithNullSceneUsesChannelStrategy() {
|
||||
Conversation conversation = createConversation(null);
|
||||
conversation.setConversationType(1);
|
||||
Long agentAccountId = 40L;
|
||||
|
||||
when(conversationMapper.selectById(1L)).thenReturn(conversation);
|
||||
when(accountSelector.selectSendAccounts("tenant", 1L, ChannelType.WECOM, null, "primary"))
|
||||
.thenReturn(Collections.singletonList(agentAccountId));
|
||||
when(accountMapper.selectById(agentAccountId)).thenReturn(createWecomAgentAccount(agentAccountId, 1));
|
||||
|
||||
List<SendTarget> targets = sendService.resolveTestTargets("tenant", createConversationRequest("primary"));
|
||||
|
||||
assertEquals(1, targets.size());
|
||||
assertEquals(agentAccountId, targets.get(0).getAccountId());
|
||||
assertEquals(ChannelType.WECOM, targets.get(0).getChannelType());
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolvePersonTarget_rejectsDisabledAccount() {
|
||||
Person person = createPerson();
|
||||
ChannelIdentity identity = createIdentity();
|
||||
ChannelAccount account = createWecomAgentAccount(30L, 0);
|
||||
|
||||
when(personMapper.selectById(1L)).thenReturn(person);
|
||||
when(channelIdentityMapper.selectOne(any())).thenReturn(identity);
|
||||
when(accountMapper.selectList(any())).thenReturn(Collections.singletonList(account));
|
||||
when(accountMapper.selectById(30L)).thenReturn(account);
|
||||
@@ -159,10 +210,12 @@ class SendServiceResolveTargetTest {
|
||||
|
||||
@Test
|
||||
void resolvePersonTarget_rejectsCrossTenantAccount() {
|
||||
Person person = createPerson();
|
||||
ChannelIdentity identity = createIdentity();
|
||||
ChannelAccount account = createAccount(30L, "wecom", 1);
|
||||
ChannelAccount account = createWecomAgentAccount(30L, 1);
|
||||
account.setTenantCode("other");
|
||||
|
||||
when(personMapper.selectById(1L)).thenReturn(person);
|
||||
when(channelIdentityMapper.selectOne(any())).thenReturn(identity);
|
||||
when(accountMapper.selectList(any())).thenReturn(Collections.singletonList(account));
|
||||
when(accountMapper.selectById(30L)).thenReturn(account);
|
||||
@@ -174,16 +227,36 @@ class SendServiceResolveTargetTest {
|
||||
|
||||
@Test
|
||||
void resolvePersonTarget_rejectsChannelTypeMismatch() {
|
||||
Person person = createPerson();
|
||||
ChannelIdentity identity = createIdentity();
|
||||
ChannelAccount account = createAccount(30L, "mobile", 1);
|
||||
ChannelAccount account = createAccount(30L, "wecom", 1);
|
||||
account.setAccountType(AccountType.WECHAT_PERSONAL.getCode());
|
||||
|
||||
when(personMapper.selectById(1L)).thenReturn(person);
|
||||
when(channelIdentityMapper.selectOne(any())).thenReturn(identity);
|
||||
when(accountMapper.selectList(any())).thenReturn(Collections.singletonList(account));
|
||||
|
||||
BusinessException ex = assertThrows(BusinessException.class,
|
||||
() -> sendService.resolveTestTargets("tenant", createPersonRequest()));
|
||||
assertEquals("租户未配置可用发送账号: channel=wecom", ex.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolvePersonTarget_selectsWecomAgentAccount() {
|
||||
Person person = createPerson();
|
||||
ChannelIdentity identity = createIdentity();
|
||||
ChannelAccount account = createWecomAgentAccount(30L, 1);
|
||||
|
||||
when(personMapper.selectById(1L)).thenReturn(person);
|
||||
when(channelIdentityMapper.selectOne(any())).thenReturn(identity);
|
||||
when(accountMapper.selectList(any())).thenReturn(Collections.singletonList(account));
|
||||
when(accountMapper.selectById(30L)).thenReturn(account);
|
||||
|
||||
BusinessException ex = assertThrows(BusinessException.class,
|
||||
() -> sendService.resolveTestTargets("tenant", createPersonRequest()));
|
||||
assertEquals("租户没有可用的发送账号: channel=wecom", ex.getMessage());
|
||||
List<SendTarget> targets = sendService.resolveTestTargets("tenant", createPersonRequest());
|
||||
|
||||
assertEquals(1, targets.size());
|
||||
assertEquals(Long.valueOf(30L), targets.get(0).getAccountId());
|
||||
assertEquals(ChannelType.WECOM, targets.get(0).getChannelType());
|
||||
}
|
||||
|
||||
private SendTestRequest createConversationRequest(String strategy) {
|
||||
@@ -217,16 +290,24 @@ class SendServiceResolveTargetTest {
|
||||
return request;
|
||||
}
|
||||
|
||||
private Conversation createConversation() {
|
||||
private Conversation createConversation(Integer scene) {
|
||||
Conversation conversation = new Conversation();
|
||||
conversation.setId(1L);
|
||||
conversation.setTenantCode("tenant");
|
||||
conversation.setChannelType("wecom");
|
||||
conversation.setConversationScene(scene);
|
||||
conversation.setChannelConversationId("conv-1");
|
||||
conversation.setStatus(1);
|
||||
return conversation;
|
||||
}
|
||||
|
||||
private Person createPerson() {
|
||||
Person person = new Person();
|
||||
person.setId(1L);
|
||||
person.setTenantCode("tenant");
|
||||
return person;
|
||||
}
|
||||
|
||||
private ChannelAccount createAccount(Long id, String channelType, int status) {
|
||||
ChannelAccount account = new ChannelAccount();
|
||||
account.setId(id);
|
||||
@@ -236,6 +317,27 @@ class SendServiceResolveTargetTest {
|
||||
return account;
|
||||
}
|
||||
|
||||
private ChannelAccount createWecomBotAccount(Long id, int status) {
|
||||
ChannelAccount account = createAccount(id, "wecom", status);
|
||||
account.setAccountType(AccountType.WECOM_BOT.getCode());
|
||||
return account;
|
||||
}
|
||||
|
||||
private ChannelAccount createWecomAgentAccount(Long id, int status) {
|
||||
ChannelAccount account = createAccount(id, "wecom", status);
|
||||
account.setAccountType(AccountType.WECOM_AGENT.getCode());
|
||||
return account;
|
||||
}
|
||||
|
||||
private ChannelAccount createPywechatAccount(Long id, int status) {
|
||||
ChannelAccount account = createAccount(id, "wechat_personal", status);
|
||||
account.setAccountType(AccountType.WECHAT_PERSONAL.getCode());
|
||||
account.setOnlineStatus(1);
|
||||
account.setRiskLevel(1);
|
||||
account.setLastHeartbeat(java.time.LocalDateTime.now());
|
||||
return account;
|
||||
}
|
||||
|
||||
private ChannelIdentity createIdentity() {
|
||||
ChannelIdentity identity = new ChannelIdentity();
|
||||
identity.setTenantCode("tenant");
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user