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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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